diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 11b776014f..69e987db6d 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -452,10 +452,66 @@ jobs: path: artifacts/sei-${{ steps.log_artifact_meta.outputs.artifact_name }} if-no-files-found: warn + # Autobahn integration suite from PR #3234 + the rpc-only forwarding sub-test. + # The test owns its own cluster lifecycle via TestMain (docker-cluster-start / + # -stop), so it can't share the matrix's cluster — it runs as a separate job. + autobahn-integration-tests: + name: Integration Test (Autobahn Basic) + runs-on: ubuntu-large + timeout-minutes: 45 + needs: prepare-cluster + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-go@v6 + with: + go-version: '1.25.6' + - name: Install jq + run: sudo apt-get install -y jq + - name: Login to Docker Hub + uses: docker/login-action@v3 + if: env.DOCKERHUB_USERNAME != '' + env: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Download integration CI artifacts + uses: actions/download-artifact@v4 + with: + name: integration-ci-artifacts + - name: Load prebuilt seid and Docker images + run: | + tar -xzf integration-build.tar.gz + zstd -d -c integration-docker-images.tar.zst | docker load + - name: Run autobahn integration tests + run: make autobahn-integration-test + - name: Print node logs on failure + if: ${{ failure() }} + run: | + set -euo pipefail + for c in sei-node-0 sei-node-1 sei-node-2 sei-node-3 sei-rpc-node; do + echo "==================== ${c} (docker logs tail) ====================" + docker logs --tail 200 "${c}" || true + done + - name: Collect logs directory + if: ${{ always() }} + run: | + mkdir -p artifacts/sei-autobahn-integration + if [ -d build/generated/logs ]; then + cp -r build/generated/logs artifacts/sei-autobahn-integration/ + fi + - name: Upload logs directory + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: integration-logs-autobahn-integration + path: artifacts/sei-autobahn-integration + if-no-files-found: warn + integration-test-check: name: Integration Test Check runs-on: ubuntu-latest - needs: [prepare-cluster, integration-tests] + needs: [prepare-cluster, integration-tests, autobahn-integration-tests] if: always() steps: - name: Verify prepare and test jobs succeeded @@ -468,4 +524,8 @@ jobs: echo "integration-tests job did not succeed (${{ needs.integration-tests.result }})" exit 1 fi + if [[ "${{ needs.autobahn-integration-tests.result }}" != "success" ]]; then + echo "autobahn-integration-tests job did not succeed (${{ needs.autobahn-integration-tests.result }})" + exit 1 + fi echo "All integration test jobs passed." diff --git a/Makefile b/Makefile index 36fcf29a09..85be123008 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ # - Prefer tag if bases are equal; otherwise use whichever base is newer. BRANCH_NAME := $(shell git rev-parse --abbrev-ref HEAD) BRANCH_VERSION := $(shell echo "$(BRANCH_NAME)" | sed -E -n 's|.*(v[0-9]+\.[0-9]+\.[0-9]+[-A-Za-z0-9._]*).*|\1|p') -TAG_VERSION := $(shell echo $(shell git describe --tags)) +TAG_VERSION := $(shell echo $(shell git describe --tags 2>/dev/null)) VERSION := $(shell \ bv="$(BRANCH_VERSION)"; tv="$(TAG_VERSION)"; \ bb=$$(echo "$$bv" | sed 's/^\(v[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\).*/\1/'); \ @@ -225,7 +225,7 @@ build-docker-node: .PHONY: build-docker-node build-rpc-node: - @cd docker && docker build --tag sei-chain/rpcnode rpcnode --platform linux/x86_64 + @cd docker && docker build --tag sei-chain/rpcnode rpcnode --platform $(DOCKER_PLATFORM) .PHONY: build-rpc-node # Integration-test CI: verify images loaded from prepare-cluster artifacts. @@ -264,7 +264,7 @@ run-local-node: kill-sei-node build-docker-node -v $(PROJECT_HOME):/sei-protocol/sei-chain:Z \ -v $(GO_PKG_PATH)/mod:/root/go/pkg/mod:Z \ -v $(shell go env GOCACHE):/root/.cache/go-build:Z \ - --platform linux/x86_64 \ + --platform $(DOCKER_PLATFORM) \ sei-chain/localnode .PHONY: run-local-node @@ -281,8 +281,10 @@ run-rpc-node: build-rpc-node -v $(GO_PKG_PATH)/mod:/root/go/pkg/mod:Z \ -v $(shell go env GOCACHE):/root/.cache/go-build:Z \ -p 26668-26670:26656-26658 \ - --platform linux/x86_64 \ + --platform $(DOCKER_PLATFORM) \ --env GIGA_STORAGE=${GIGA_STORAGE} \ + --env AUTOBAHN=${AUTOBAHN} \ + --env CLUSTER_SIZE=${CLUSTER_SIZE} \ --env RECEIPT_BACKEND=${RECEIPT_BACKEND} \ sei-chain/rpcnode .PHONY: run-rpc-node @@ -299,9 +301,11 @@ run-rpc-node-skipbuild: build-rpc-node -v $(GO_PKG_PATH)/mod:/root/go/pkg/mod:Z \ -v $(shell go env GOCACHE):/root/.cache/go-build:Z \ -p 26668-26670:26656-26658 \ - --platform linux/x86_64 \ + --platform $(DOCKER_PLATFORM) \ --env SKIP_BUILD=true \ --env GIGA_STORAGE=${GIGA_STORAGE} \ + --env AUTOBAHN=${AUTOBAHN} \ + --env CLUSTER_SIZE=${CLUSTER_SIZE} \ --env RECEIPT_BACKEND=${RECEIPT_BACKEND} \ sei-chain/rpcnode .PHONY: run-rpc-node @@ -328,7 +332,7 @@ run-rpc-node-integration-ci: kill-rpc-node ensure-integration-ci-images -v $(GO_PKG_PATH)/mod:/root/go/pkg/mod:Z \ -v $(shell go env GOCACHE):/root/.cache/go-build:Z \ -p 26668-26670:26656-26658 \ - --platform linux/x86_64 \ + --platform $(DOCKER_PLATFORM) \ --env SKIP_BUILD=true \ --env GIGA_STORAGE=${GIGA_STORAGE} \ --env RECEIPT_BACKEND=${RECEIPT_BACKEND} \ diff --git a/app/app.go b/app/app.go index d8a0105068..d819ba899a 100644 --- a/app/app.go +++ b/app/app.go @@ -480,10 +480,21 @@ type App struct { forkInitializer func(sdk.Context) - httpServerStartSignal chan struct{} - wsServerStartSignal chan struct{} - httpServerStartSignalSent bool - wsServerStartSignalSent bool + httpServerStartSignal chan struct{} + wsServerStartSignal chan struct{} + // evmRPCReadyOnce ensures the gate signals fire exactly once even when + // multiple call sites (InitChainer, ProcessBlock, Info) race. The + // alternative — a pair of bool flags — was racy because the read + + // write + channel-send sequence isn't atomic, and a losing second send + // would block forever on the cap=1 buffer once the consumer drained + // it. The Info-side fire site makes this race reachable from any + // /abci_info RPC call. + evmRPCReadyOnce sync.Once + + // autobahnRPCOnly scopes the Info-side EVM RPC gate-fire to autobahn + // rpc-only nodes. Set from tmConfig at New(). See the comment on Info + // for why we don't fire that path unconditionally. + autobahnRPCOnly bool txPrioritizer sdk.TxPrioritizer @@ -551,6 +562,7 @@ func New( stateStore: stateStore, httpServerStartSignal: make(chan struct{}, 1), wsServerStartSignal: make(chan struct{}, 1), + autobahnRPCOnly: tmConfig != nil && tmConfig.AutobahnConfigFile != "" && tmConfig.IsAutobahnRPCOnly(), } for _, option := range appOptions { @@ -1252,8 +1264,59 @@ func (app *App) MidBlocker(ctx sdk.Context, height int64) []abci.Event { return app.mm.MidBlock(ctx, height) } -// InitChainer application update at chain initialization +// signalEVMRPCReady fires the EVM HTTP/WS start-gate signals so the +// goroutines in RegisterLocalServices stop waiting and bind their listeners. +// sync.Once makes duplicate sends a no-op. The full set of fire sites +// covers every startup shape: +// - InitChainer: fresh start (validators via the handshaker/runExecute, +// rpc-only via GigaRouter.InitRPCOnly). +// - Info (below): restart with existing app state, where InitChainer is +// not called. Covers validators restarting and the future rpc-only +// restart with read-side state. +// - ProcessBlock: steady-state trigger; redundant after the above land +// but kept for safety on any path that reaches a block without firing +// either earlier. +func (app *App) signalEVMRPCReady() { + app.evmRPCReadyOnce.Do(func() { + app.httpServerStartSignal <- struct{}{} + app.wsServerStartSignal <- struct{}{} + }) +} + +// Info overrides BaseApp.Info to fire the EVM RPC gate on restart-with- +// state for Autobahn rpc-only nodes. Those nodes never call ProcessBlock +// (where the gate normally fires), so the gate has to be triggered from +// a startup event the app is guaranteed to receive. Info is the natural +// fit — the consensus engine queries it first thing on startup, and +// LastBlockHeight > 0 reliably indicates the app's state is already +// loaded from disk. +// +// We gate on app.autobahnRPCOnly rather than firing unconditionally +// because the CometBFT Handshaker also calls app.Info before +// ReplayBlocks. Firing the gate there would bind the EVM HTTP/WS +// listeners while replay is still in progress, serving stale (pre- +// restart) state until replay catches up — a regression vs. the +// original ProcessBlock-defer trigger, which fires after the first +// replayed block commits. Autobahn nodes skip the Handshaker entirely +// (see node.go's shouldHandshake), so this gating is also what makes +// the override safe for them. Fresh-start nodes (LastBlockHeight == 0) +// fall through to InitChainer's defer. +// +// TODO(autobahn-read-path): delete this override (and the +// autobahnRPCOnly field) once Autobahn rpc-only nodes subscribe to +// finalized blocks and run ProcessBlock like validators. Both this Info +// wrap and the InitChainer defer become redundant at that point. +func (app *App) Info(ctx context.Context, req *abci.RequestInfo) (*abci.ResponseInfo, error) { + resp, err := app.BaseApp.Info(ctx, req) + if app.autobahnRPCOnly && err == nil && resp != nil && resp.LastBlockHeight > 0 { + app.signalEVMRPCReady() + } + return resp, err +} + +// InitChainer application update at chain initialization. func (app *App) InitChainer(ctx sdk.Context, req abci.RequestInitChain) abci.ResponseInitChain { + defer app.signalEVMRPCReady() var genesisState GenesisState if !app.genesisImportConfig.StreamGenesisImport { if err := json.Unmarshal(req.AppStateBytes, &genesisState); err != nil { @@ -1910,16 +1973,7 @@ func (app *App) ProcessBlock(ctx sdk.Context, txs [][]byte, req *BlockProcessReq } }() - defer func() { - if !app.httpServerStartSignalSent { - app.httpServerStartSignalSent = true - app.httpServerStartSignal <- struct{}{} - } - if !app.wsServerStartSignalSent { - app.wsServerStartSignalSent = true - app.wsServerStartSignal <- struct{}{} - } - }() + defer app.signalEVMRPCReady() ctx = ctx.WithIsOCCEnabled(app.OccEnabled()) diff --git a/docker/rpcnode/scripts/step1_configure_init.sh b/docker/rpcnode/scripts/step1_configure_init.sh index d45e8380c9..f1b5c07db4 100755 --- a/docker/rpcnode/scripts/step1_configure_init.sh +++ b/docker/rpcnode/scripts/step1_configure_init.sh @@ -48,6 +48,44 @@ if [ -n "$RECEIPT_BACKEND" ]; then fi fi +# Generate Autobahn (GigaRouter) config when the validators are running +# Autobahn consensus. The RPC node joins the cluster as +# autobahn-role="rpc-only" so it can forward eth_sendRawTransaction to the +# shard owner. Reuse the validator node directories under build/generated/ +# (mounted into the container) so the committee description matches the +# cluster. +AUTOBAHN=${AUTOBAHN:-false} +if [ "$AUTOBAHN" = "true" ]; then + echo "Generating Autobahn config for RPC node (rpc-only)..." + AUTOBAHN_CONFIG="$HOME/.sei/config/autobahn.json" + + # Default to 4 (the docker-compose cluster size) when CLUSTER_SIZE is unset. + CLUSTER_SIZE=${CLUSTER_SIZE:-4} + NODE_DIRS="" + i=0 + while [ "$i" -lt "$CLUSTER_SIZE" ]; do + NODE_DIRS="$NODE_DIRS build/generated/node_${i}" + i=$((i + 1)) + done + + seid tendermint gen-autobahn-config $NODE_DIRS --output "$AUTOBAHN_CONFIG" + + # Inject autobahn-config-file + autobahn-role as top-level keys in + # config.toml. They must precede any [section] header so the TOML parser + # sees them at root scope. + if grep -q "autobahn-config-file" ~/.sei/config/config.toml; then + sed -i 's|autobahn-config-file = .*|autobahn-config-file = "'"$AUTOBAHN_CONFIG"'"|' ~/.sei/config/config.toml + else + sed -i '1s|^|autobahn-config-file = "'"$AUTOBAHN_CONFIG"'"\n|' ~/.sei/config/config.toml + fi + if grep -q "autobahn-role" ~/.sei/config/config.toml; then + sed -i 's|autobahn-role = .*|autobahn-role = "rpc-only"|' ~/.sei/config/config.toml + else + sed -i '1s|^|autobahn-role = "rpc-only"\n|' ~/.sei/config/config.toml + fi + echo "Autobahn config written to $AUTOBAHN_CONFIG (rpc-only)" +fi + # Override state sync configs STATE_SYNC_RPC="192.168.10.10:26657" STATE_SYNC_PEER="2f9846450b7a3dcf4af1ac0082e3279c16744df8@172.31.9.18:26656,ec98c4a28a2023f4f976828c8a8e7127bfef4e1b@172.31.4.96:26656,b03014d67384fb0ef6ad992c77cefe4f9d2c1640@172.31.4.219:26656" diff --git a/integration_test/autobahn/autobahn_test.go b/integration_test/autobahn/autobahn_test.go index a91592d42d..d229c06811 100644 --- a/integration_test/autobahn/autobahn_test.go +++ b/integration_test/autobahn/autobahn_test.go @@ -169,6 +169,11 @@ func TestMain(m *testing.M) { teardownCluster() // best-effort os.Exit(1) } + if err := setupRPCOnlyNode(); err != nil { + fmt.Fprintf(os.Stderr, "rpc-only sidecar setup failed: %v\n", err) + teardownCluster() + os.Exit(1) + } code := m.Run() teardownCluster() os.Exit(code) @@ -250,8 +255,14 @@ func countSeiContainers() (int, error) { return len(strings.Fields(strings.TrimSpace(string(out)))), nil } -// teardownCluster runs `make docker-cluster-stop`, ignoring errors. +// teardownCluster tears down every container TestMain brought up: first +// the rpc-only sidecar (so its run-rpc-node `docker run --rm` process +// exits cleanly), then the validator cluster. Best-effort — errors are +// ignored so a partially-failed setupCluster can still clean up. Adding +// new sidecars later goes here too. func teardownCluster() { + fmt.Println("=== Stopping rpc-only sidecar ===") + _ = runMake(nil, "kill-rpc-node") fmt.Println("=== Stopping cluster ===") _ = runMake(nil, "docker-cluster-stop") } @@ -290,6 +301,7 @@ func TestAutobahn(t *testing.T) { t.Run("BlockProduction", testBlockProduction) t.Run("BankTransfer", testBankTransfer) + t.Run("RPCOnlyForwarding", testRPCOnlyForwarding) t.Run("LivenessUnderMaxFaults", testLivenessUnderMaxFaults) t.Run("HaltsBeyondMaxFaults", testHaltsBeyondMaxFaults) t.Run("Recovery", testRecovery) diff --git a/integration_test/autobahn/rpc_only_test.go b/integration_test/autobahn/rpc_only_test.go new file mode 100644 index 0000000000..3543a5b121 --- /dev/null +++ b/integration_test/autobahn/rpc_only_test.go @@ -0,0 +1,362 @@ +//go:build autobahn_integration + +package autobahn + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "math/big" + "net/http" + "os" + "os/exec" + "strconv" + "strings" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + ethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" +) + +const ( + rpcOnlyContainer = "sei-rpc-node" + rpcOnlyBootTimeout = 5 * time.Minute + rpcOnlyBootPoll = 5 * time.Second + rpcOnlyReceiptPoll = 500 * time.Millisecond + rpcOnlyReceiptLimit = 60 * time.Second + + // evmRPCURLOnContainerLocalhost is the EVM RPC address inside a + // docker container — used with `docker exec ... curl` to reach the + // rpc-only sidecar's own EVM RPC (it isn't host-published). + evmRPCURLOnContainerLocalhost = "http://localhost:8545" + // validatorEVMRPCURLOnHost is sei-node-0's EVM RPC, host-published + // at 8545 via docker-compose. Used from the test host directly. + validatorEVMRPCURLOnHost = "http://localhost:8545" +) + +// setupRPCOnlyNode boots an autobahn rpc-only sidecar alongside the validator +// cluster. Backgrounded via cmd.Start() because `make run-rpc-node` uses +// `docker run --rm` (foreground until the container exits); the actual +// docker container detaches from this process once it starts. +// +// AUTOBAHN=true triggers step1_configure_init.sh's autobahn.json generation +// and writes autobahn-role="rpc-only" into the rpc node's config.toml. +func setupRPCOnlyNode() error { + fmt.Println("=== Starting rpc-only sidecar ===") + _ = runMake(nil, "kill-rpc-node") // best-effort cleanup + + cmd := exec.Command("make", "run-rpc-node") + cmd.Env = append(os.Environ(), "AUTOBAHN=true", "CLUSTER_SIZE=4") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Start(); err != nil { + return fmt.Errorf("start make run-rpc-node: %w", err) + } + // Reap the process when it eventually exits (e.g. on container kill); not + // blocking on Wait here since the container runs for the duration of the + // test suite. + go func() { _ = cmd.Wait() }() + + deadline := time.Now().Add(rpcOnlyBootTimeout) + for time.Now().Before(deadline) { + if rpcOnlyRunning() && rpcOnlyEVMReady() { + fmt.Println("rpc-only sidecar is ready") + return nil + } + time.Sleep(rpcOnlyBootPoll) + } + return fmt.Errorf("rpc-only sidecar didn't come up within %s", rpcOnlyBootTimeout) +} + +func rpcOnlyRunning() bool { + out, err := exec.Command("docker", "ps", + "--filter", "name="+rpcOnlyContainer, + "--filter", "status=running", + "--format", "{{.Names}}").Output() + if err != nil { + return false + } + return strings.TrimSpace(string(out)) == rpcOnlyContainer +} + +func rpcOnlyEVMReady() bool { + r, err := evmRPCInContainer(rpcOnlyContainer, "eth_chainId", []any{}) + return err == nil && r.Error == nil && len(r.Result) > 0 +} + +type evmRPCResponse struct { + Result json.RawMessage `json:"result"` + Error *evmRPCError `json:"error,omitempty"` +} + +type evmRPCError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +// evmRPCInContainer POSTs a JSON-RPC call to the given container's +// localhost:8545. The rpc-only container's 8545 isn't host-published; this +// is the only way to talk to it without changing the run target. +func evmRPCInContainer(container, method string, params any) (*evmRPCResponse, error) { + body, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", "id": 1, "method": method, "params": params, + }) + if err != nil { + return nil, err + } + out, err := exec.Command("docker", "exec", container, + "curl", "-sf", "-X", "POST", + "-H", "content-type: application/json", + "--data", string(body), + evmRPCURLOnContainerLocalhost).Output() + if err != nil { + return nil, fmt.Errorf("docker exec curl: %v", err) + } + var r evmRPCResponse + if err := json.Unmarshal(out, &r); err != nil { + return nil, fmt.Errorf("decode (body=%s): %w", out, err) + } + return &r, nil +} + +// evmRPCOnHost POSTs a JSON-RPC call to a validator's host-published 8545. +func evmRPCOnHost(method string, params any) (*evmRPCResponse, error) { + body, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", "id": 1, "method": method, "params": params, + }) + if err != nil { + return nil, err + } + resp, err := http.Post(validatorEVMRPCURLOnHost, "application/json", bytes.NewReader(body)) + if err != nil { + return nil, err + } + defer resp.Body.Close() + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + var r evmRPCResponse + if err := json.Unmarshal(raw, &r); err != nil { + return nil, fmt.Errorf("decode (body=%s): %w", raw, err) + } + return &r, nil +} + +// extractAdminPrivKey reads admin's secp256k1 privkey from sei-node-0's +// local keyring. This is the only cosmos-side touch in the test — purely a +// file decryption, no chain interaction. +func extractAdminPrivKey(t *testing.T) string { + t.Helper() + out := dockerExec(t, "sei-node-0", + `(echo y; echo 12345678) | seid keys export admin --unsafe --unarmored-hex 2>/dev/null`) + return strings.TrimSpace(out) +} + +// associateAdmin calls sei_associate via curl on a validator. Same JSON-RPC +// method that `seid tx evm associate-address` wraps under the hood; the +// chain records the cosmos↔EVM mapping so admin's cosmos balance becomes +// visible at its derived EVM address. Idempotent: returns nil if admin is +// already associated. +func associateAdmin(t *testing.T, privHex string) { + t.Helper() + priv, err := crypto.HexToECDSA(privHex) + if err != nil { + t.Fatalf("HexToECDSA: %v", err) + } + emptyHash := crypto.Keccak256Hash([]byte{}) + sig, err := crypto.Sign(emptyHash[:], priv) + if err != nil { + t.Fatalf("crypto.Sign: %v", err) + } + // sig layout: [R(32) | S(32) | V(1)]. V is the recovery byte (0 or 1). + // Encode R/S/V via big.Int.Bytes() so leading zeros are trimmed — + // matches the wire format `seid tx evm associate-address` uses (see + // x/evm/client/cli/tx.go). The chain verifies the signature against + // the exact byte representation it received, so encoding mismatch + // (e.g. V=0 → "" vs "00") makes CheckTx reject the tx. + r := hex.EncodeToString(new(big.Int).SetBytes(sig[:32]).Bytes()) + s := hex.EncodeToString(new(big.Int).SetBytes(sig[32:64]).Bytes()) + v := hex.EncodeToString(big.NewInt(int64(sig[64])).Bytes()) + resp, err := evmRPCOnHost("sei_associate", []any{map[string]string{"v": v, "r": r, "s": s}}) + if err != nil { + t.Fatalf("sei_associate: %v", err) + } + if resp.Error != nil { + msg := strings.ToLower(resp.Error.Message) + if !strings.Contains(msg, "already associated") && !strings.Contains(msg, "tx already exists") { + t.Fatalf("sei_associate error: %s", resp.Error.Message) + } + } +} + +// testRPCOnlyForwarding verifies that an Autobahn rpc-only sidecar accepts a +// signed EVM transaction, forwards it to the shard owner over HTTP, and the +// tx lands in a block on the cluster. +// +// Why this proves the rpc-only milestone: +// - The rpc-only container has no consensus state, no producer, no block +// execution loop. EvmProxy is its only meaningful surface. +// - Submitting via the rpc-only's 8545 exercises send.go's proxy branch: +// parse tx → recover sender → committee.EvmShard → dial shard-owner → +// return the validator's hash response. +// - Polling the validator's eth_getTransactionReceipt confirms the tx +// actually landed (not just that the proxy hop happened with an error). +func testRPCOnlyForwarding(t *testing.T) { + assertAutobahnEnabled(t) + + // 1. Extract admin's privkey from the validator container's keyring. + adminPrivHex := extractAdminPrivKey(t) + priv, err := crypto.HexToECDSA(adminPrivHex) + if err != nil { + t.Fatalf("HexToECDSA: %v", err) + } + addr := crypto.PubkeyToAddress(priv.PublicKey) + t.Logf("admin EVM address: %s", addr.Hex()) + + // 2. Associate admin if its EVM-side balance is still 0. Skipping when + // balance is already > 0 keeps the test idempotent across test re-runs. + if balanceAt(t, addr).Sign() == 0 { + associateAdmin(t, adminPrivHex) + } + + // 3. Wait for admin's EVM balance to materialize. + deadline := time.Now().Add(30 * time.Second) + var bal *big.Int + for time.Now().Before(deadline) { + bal = balanceAt(t, addr) + if bal.Sign() > 0 { + break + } + time.Sleep(time.Second) + } + if bal.Sign() == 0 { + t.Fatalf("admin balance never appeared at %s", addr.Hex()) + } + t.Logf("admin balance: %s wei", bal) + + // 4. Build, sign, and submit a 0-value self-transfer via the rpc-only. + chainID := chainIDFromRPC(t) + nonce := nonceAt(t, addr) + gasPrice := new(big.Int).Mul(big.NewInt(100), big.NewInt(1_000_000_000)) // 100 gwei + tx := ethtypes.NewTx(ðtypes.LegacyTx{ + Nonce: nonce, + GasPrice: gasPrice, + Gas: 100_000, + To: &addr, + Value: big.NewInt(0), + Data: nil, + }) + signedTx, err := ethtypes.SignTx(tx, ethtypes.NewEIP155Signer(chainID), priv) + if err != nil { + t.Fatalf("SignTx: %v", err) + } + raw, err := signedTx.MarshalBinary() + if err != nil { + t.Fatalf("MarshalBinary: %v", err) + } + expected := signedTx.Hash() + rawHex := "0x" + hex.EncodeToString(raw) + + submit, err := evmRPCInContainer(rpcOnlyContainer, "eth_sendRawTransaction", []any{rawHex}) + if err != nil { + t.Fatalf("rpc-only eth_sendRawTransaction: %v", err) + } + if submit.Error != nil { + t.Fatalf("rpc-only rejected the tx: %s", submit.Error.Message) + } + var returnedHash string + if err := json.Unmarshal(submit.Result, &returnedHash); err != nil { + t.Fatalf("decode hash: %v (raw=%s)", err, submit.Result) + } + if !strings.EqualFold(returnedHash, expected.Hex()) { + t.Fatalf("rpc-only returned hash %s, expected %s", returnedHash, expected.Hex()) + } + + // 5. Poll the validator for the receipt — proves the tx actually landed + // in a block (not just that the proxy hop returned a hash). + deadline = time.Now().Add(rpcOnlyReceiptLimit) + for time.Now().Before(deadline) { + r, err := evmRPCOnHost("eth_getTransactionReceipt", []any{expected.Hex()}) + if err == nil && r.Error == nil && len(r.Result) > 0 && string(r.Result) != "null" { + var receipt struct { + Status string `json:"status"` + BlockNumber string `json:"blockNumber"` + } + if err := json.Unmarshal(r.Result, &receipt); err != nil { + t.Fatalf("decode receipt: %v (raw=%s)", err, r.Result) + } + t.Logf("tx %s landed in block %s (status=%s)", + expected.Hex(), receipt.BlockNumber, receipt.Status) + if receipt.Status != "0x1" { + t.Fatalf("tx reverted (status=%s)", receipt.Status) + } + return + } + time.Sleep(rpcOnlyReceiptPoll) + } + t.Fatalf("receipt never landed on validator for tx %s", expected.Hex()) +} + +// balanceAt fetches the EVM balance at addr via the validator RPC. +func balanceAt(t *testing.T, addr common.Address) *big.Int { + t.Helper() + resp, err := evmRPCOnHost("eth_getBalance", []any{addr.Hex(), "latest"}) + if err != nil { + t.Fatalf("eth_getBalance: %v", err) + } + if resp.Error != nil { + t.Fatalf("eth_getBalance: %s", resp.Error.Message) + } + var s string + if err := json.Unmarshal(resp.Result, &s); err != nil { + t.Fatalf("decode balance: %v", err) + } + b, ok := new(big.Int).SetString(strings.TrimPrefix(s, "0x"), 16) + if !ok { + t.Fatalf("parse balance hex %q", s) + } + return b +} + +func chainIDFromRPC(t *testing.T) *big.Int { + t.Helper() + resp, err := evmRPCOnHost("eth_chainId", []any{}) + if err != nil { + t.Fatalf("eth_chainId: %v", err) + } + var s string + if err := json.Unmarshal(resp.Result, &s); err != nil { + t.Fatalf("decode chainId: %v", err) + } + c, ok := new(big.Int).SetString(strings.TrimPrefix(s, "0x"), 16) + if !ok { + t.Fatalf("parse chainId hex %q", s) + } + return c +} + +func nonceAt(t *testing.T, addr common.Address) uint64 { + t.Helper() + resp, err := evmRPCOnHost("eth_getTransactionCount", []any{addr.Hex(), "pending"}) + if err != nil { + t.Fatalf("eth_getTransactionCount: %v", err) + } + var s string + if err := json.Unmarshal(resp.Result, &s); err != nil { + t.Fatalf("decode nonce: %v", err) + } + hexStr := strings.TrimPrefix(s, "0x") + if hexStr == "" { + return 0 + } + n, err := strconv.ParseUint(hexStr, 16, 64) + if err != nil { + t.Fatalf("parse nonce hex %q: %v", s, err) + } + return n +} diff --git a/sei-tendermint/config/config.go b/sei-tendermint/config/config.go index 49b3dfcd85..2f42eb409c 100644 --- a/sei-tendermint/config/config.go +++ b/sei-tendermint/config/config.go @@ -78,6 +78,27 @@ type Config struct { // AutobahnConfigFile is the path to a JSON file containing the Autobahn (GigaRouter) // configuration. Leave empty to disable Autobahn. AutobahnConfigFile string `mapstructure:"autobahn-config-file"` + + // AutobahnRole selects how the node participates in Autobahn. Valid values: + // "" (default) / "validator" — full participant; the node's validator key + // must be present in the committee. + // "rpc-only" — non-validator RPC node; loads the + // committee and forwards + // eth_sendRawTransaction to the shard + // owner. No consensus participation. + // Ignored when AutobahnConfigFile is empty. + AutobahnRole string `mapstructure:"autobahn-role"` +} + +const ( + AutobahnRoleValidator = "validator" + AutobahnRoleRPCOnly = "rpc-only" +) + +// IsAutobahnRPCOnly reports whether the node is configured as an Autobahn +// RPC-only (non-validator) node. +func (cfg *Config) IsAutobahnRPCOnly() bool { + return cfg.AutobahnRole == AutobahnRoleRPCOnly } // DefaultConfig returns a default configuration for a Tendermint node @@ -155,6 +176,11 @@ func (cfg *Config) ValidateBasic() error { if err := cfg.SelfRemediation.ValidateBasic(); err != nil { return fmt.Errorf("error in [self-remediation] section: %w", err) } + switch cfg.AutobahnRole { + case "", AutobahnRoleValidator, AutobahnRoleRPCOnly: + default: + return fmt.Errorf("autobahn-role %q: must be %q or %q", cfg.AutobahnRole, AutobahnRoleValidator, AutobahnRoleRPCOnly) + } return nil } diff --git a/sei-tendermint/config/toml.go b/sei-tendermint/config/toml.go index a445942e0f..9181cb73ac 100644 --- a/sei-tendermint/config/toml.go +++ b/sei-tendermint/config/toml.go @@ -632,6 +632,13 @@ restart-cooldown-seconds = {{ .SelfRemediation.RestartCooldownSeconds }} # Leave empty to disable Autobahn. autobahn-config-file = "{{ .AutobahnConfigFile }}" +# How the node participates in Autobahn. One of: +# "" — same as "validator" (default). +# "validator" — full participant; the node's validator key must be in the committee. +# "rpc-only" — non-validator RPC node; routes eth_sendRawTransaction to the +# shard owner over EVM JSON-RPC, does not produce or vote on blocks. +autobahn-role = "{{ .AutobahnRole }}" + ` // defaultConfigTemplate combines manual and auto-managed templates for backward compatibility diff --git a/sei-tendermint/internal/p2p/giga_router.go b/sei-tendermint/internal/p2p/giga_router.go index d04b4e0f01..cde5df226d 100644 --- a/sei-tendermint/internal/p2p/giga_router.go +++ b/sei-tendermint/internal/p2p/giga_router.go @@ -45,11 +45,20 @@ type GigaRouterConfig struct { Producer *producer.Config TxMempool *mempool.TxMempool GenDoc *types.GenesisDoc + // RPCOnly selects the rpc-only construction path in NewGigaRouter, which + // today builds only the committee + outbound proxy pools needed for + // EvmProxy forwarding. EvmProxy never short-circuits to "local" when set. + RPCOnly bool } +// GigaRouter is the per-node entry point into the Autobahn stack. In rpc-only +// mode (cfg.RPCOnly) data, producer, consensus, and service are nil — see the +// TODO(autobahn-read-path) in NewGigaRouter for what the read-side milestone +// will bring back. type GigaRouter struct { cfg *GigaRouterConfig key NodeSecretKey + committee *atypes.Committee data *data.State producer *producer.State consensus *consensus.State @@ -82,6 +91,24 @@ func NewGigaRouter(cfg *GigaRouterConfig, key NodeSecretKey) (*GigaRouter, error if err != nil { return nil, fmt.Errorf("atypes.NewRoundRobinElection(): %w", err) } + if cfg.RPCOnly { + // TODO(autobahn-read-path): rpc-only currently constructs only the + // committee + outbound proxy state needed by EvmProxy. When the read + // side lands (eth_getTransactionReceipt / eth_call / log queries on + // the rpc node) we'll likely need to bring back the data layer here + // so the rpc node can subscribe to finalized blocks and serve the + // CometBFT block/state read APIs — but still without consensus.Key, + // producer.State, or the giga.Service inbound peer-side. Revisit + // this branch then and pull in only the pieces that are actually + // needed; leaving them all out for now keeps the write-only milestone + // minimal and the read-path requirements explicit. + logger.Info("GigaRouter initialized (rpc-only)", "validators", len(cfg.ValidatorAddrs)) + return &GigaRouter{ + cfg: cfg, + key: key, + committee: committee, + }, nil + } // Automated pruning is disabled, because it is controlled by the application. // The data WAL piggybacks on Consensus.PersistentStateDir: the two layers // share the same on-disk root and write to distinct subdirectories under @@ -110,6 +137,7 @@ func NewGigaRouter(cfg *GigaRouterConfig, key NodeSecretKey) (*GigaRouter, error return &GigaRouter{ cfg: cfg, key: key, + committee: committee, data: dataState, consensus: consensusState, producer: producerState, @@ -123,16 +151,64 @@ func NewGigaRouter(cfg *GigaRouterConfig, key NodeSecretKey) (*GigaRouter, error }, nil } +// IsRPCOnly reports whether this router was constructed in rpc-only +// (non-validator, write-forwarding-only) mode. +func (r *GigaRouter) IsRPCOnly() bool { return r.cfg.RPCOnly } + +// InitRPCOnly performs the one-time application initialization an rpc-only +// node needs before serving requests: app.InitChain on a fresh app, so x/evm +// params (chain ID, signer config) are populated for eth_sendRawTransaction's +// sender recovery. Mirrors the InitChain branch of runExecute — kept here +// alongside it rather than in the Cosmos node startup so the giga layer +// owns its own app bootstrapping. Idempotent across restarts: when the app +// already has committed state (LastBlockHeight > 0), this is a no-op. +func (r *GigaRouter) InitRPCOnly(ctx context.Context) error { + if !r.cfg.RPCOnly { + return errors.New("InitRPCOnly called on non-rpc-only router") + } + app := r.cfg.TxMempool.App() + info, err := app.Info(ctx, &version.RequestInfo) + if err != nil { + return fmt.Errorf("app.Info(): %w", err) + } + if info.LastBlockHeight > 0 { + logger.Info("rpc-only: app already has committed state, skipping InitChain", + "appHeight", info.LastBlockHeight) + return nil + } + if _, err := app.InitChain(ctx, r.cfg.GenDoc.ToRequestInitChain()); err != nil { + return fmt.Errorf("app.InitChain(): %w", err) + } + logger.Info("rpc-only: app initialized from genesis", "chain_id", r.cfg.GenDoc.ChainID) + return nil +} + +// errRPCOnlyReadPath is returned by methods that read finalized block / +// consensus state from a GigaRouter running in rpc-only mode. In the current +// write-only milestone rpc-only nodes don't sync the autobahn block stream, +// so the data simply isn't present (see the TODO(autobahn-read-path) in +// NewGigaRouter). +var errRPCOnlyReadPath = errors.New("autobahn rpc-only: block/consensus read path not available") + // LastCommittedBlockNumber returns the highest global block number finalized // by consensus (derived from the latest CommitQC). When no CommitQC has been // recorded yet, atypes.GlobalRangeOpt returns the committee's empty default // range {First: FirstBlock, Next: FirstBlock}, so this returns FirstBlock-1. // Safe for high-frequency callers — uses a cached lock-free receiver; no // locks taken on this path. +// +// Returns 0 in rpc-only mode (no consensus state — rpc-only nodes don't +// produce or commit blocks in the current write-only milestone). 0 is +// chosen over -1 so /status's JSON response carries a non-negative +// integer; the "LastCommittedBlockHeight < LatestBlockHeight" warning at +// status.go is gated on `committed > 0`, so 0 is silently skipped there. func (r *GigaRouter) LastCommittedBlockNumber() int64 { + if r.cfg.RPCOnly { + return 0 + } // GlobalRange is a half-open [First, Next) interval; the highest // committed block number is Next-1. - gr := atypes.GlobalRangeOpt(r.lastCommitQCRecv.Load(), r.data.Committee()) + gr := atypes.GlobalRangeOpt(r.lastCommitQCRecv.Load(), r.committee) return int64(gr.Next) - 1 // nolint:gosec // gr.Next is uint64 but bounded by actual chain height. } @@ -143,6 +219,12 @@ func (r *GigaRouter) LastCommittedBlockNumber() int64 { // FinalizeBlock responses are not stored on disk) without reaching into // the unexported router.cfg. func (r *GigaRouter) MaxGasPerBlock() int64 { + if r.cfg.RPCOnly { + // Rpc-only has no Producer; reachable only via BlockResults, which + // short-circuits earlier on the autobahnCheckAndGetHeight call when + // the node has no committed blocks. Return 0 defensively. + return 0 + } return r.cfg.Producer.MaxGasPerBlockI64() } @@ -168,6 +250,9 @@ func (r *GigaRouter) MaxGasPerBlock() int64 { // BlockDB has the right shape (height + hash indexes, async pruning) and // is the long-term home for this read path. func (r *GigaRouter) BlockByNumber(ctx context.Context, n atypes.GlobalBlockNumber) (*coretypes.ResultBlock, error) { + if r.cfg.RPCOnly { + return nil, errRPCOnlyReadPath + } gb, err := r.data.GlobalBlock(ctx, n) if err != nil { // Map Autobahn's pruning sentinel to CometBFT's, so callers @@ -198,6 +283,9 @@ func (r *GigaRouter) BlockByNumber(ctx context.Context, n atypes.GlobalBlockNumb // sei-db/ledger_db/block.BlockDB.GetBlockByHash once a writer is wired into // block execution. The data.State-side index can also go away at that point. func (r *GigaRouter) BlockByHash(ctx context.Context, hash atypes.BlockHeaderHash) (*coretypes.ResultBlock, error) { + if r.cfg.RPCOnly { + return nil, errRPCOnlyReadPath + } opt, err := r.data.GlobalBlockByHash(hash) if err != nil { return nil, fmt.Errorf("data.GlobalBlockByHash: %w", err) @@ -387,6 +475,16 @@ func (r *GigaRouter) runExecute(ctx context.Context) error { } func (r *GigaRouter) Run(ctx context.Context) error { + if r.cfg.RPCOnly { + // Write forwarding happens over fresh HTTP from evmrpc per request + // (see evmrpc.SendRawTransaction → env.EvmProxy), so no spawn loops + // are needed yet — block until shutdown. App init (InitChain) is + // handled by InitRPCOnly, called synchronously from node startup + // before RPC begins serving. See the TODO(autobahn-read-path) in + // NewGigaRouter for the loops the read side will pull back in. + <-ctx.Done() + return ctx.Err() + } return scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { // Spawn outbound connections dialing. for _, addr := range r.cfg.ValidatorAddrs { @@ -448,6 +546,11 @@ func (r *GigaRouter) RunInboundConn(ctx context.Context, hConn *handshakedConn) if !hConn.msg.SeiGigaConnection { return fmt.Errorf("not a SeiGiga connection") } + if r.cfg.RPCOnly { + // Rpc-only doesn't run the giga service, so accepting inbound peers + // would NPE on poolIn / service. Reject at the door. + return fmt.Errorf("rpc-only node does not accept inbound giga connections") + } // Filter unwanded connections. key := hConn.msg.NodeAuth.Key() ok := false @@ -470,8 +573,11 @@ func (r *GigaRouter) RunInboundConn(ctx context.Context, hConn *handshakedConn) } func (r *GigaRouter) EvmProxy(sender common.Address) (*url.URL, bool) { - shardValidator := r.data.Committee().EvmShard(sender) - if r.cfg.Consensus.Key.Public() == shardValidator { + shardValidator := r.committee.EvmShard(sender) + // Rpc-only nodes have no validator key, so the shard owner is never + // "us" — always forward. Validators short-circuit when they own the + // shard so the tx is checked into the local mempool instead. + if !r.cfg.RPCOnly && r.cfg.Consensus.Key.Public() == shardValidator { return nil, false } return r.cfg.ValidatorAddrs[shardValidator].EVMRPC.Get() diff --git a/sei-tendermint/internal/p2p/giga_router_test.go b/sei-tendermint/internal/p2p/giga_router_test.go index 2c68c7f5b3..2e5a390416 100644 --- a/sei-tendermint/internal/p2p/giga_router_test.go +++ b/sei-tendermint/internal/p2p/giga_router_test.go @@ -506,3 +506,122 @@ func TestGigaRouter_EvmProxy(t *testing.T) { require.Equal(t, expectedRemoteURLs, returnedRemoteURLs) } + +// TestGigaRouter_RPCOnly covers the non-validator (rpc-only) GigaRouter +// path end-to-end in this milestone: routing always picks a remote shard +// owner (no local short-circuit because there is no validator key), Run is +// a no-op that returns on context cancel, InitRPCOnly seeds the app with +// InitChain from genesis, and the block/consensus read methods return the +// sentinel error so /block and /block_results don't NPE on rpc-only nodes +// reaching the RPC layer. The shape assertions (data/consensus/producer/ +// service all nil) track the current write-only build and will need to +// loosen when the read side lands — see TODO(autobahn-read-path) in +// NewGigaRouter. +func TestGigaRouter_RPCOnly(t *testing.T) { + rng := utils.TestRng() + _, validatorKeys := atypes.GenCommittee(rng, 5) + addrs := map[atypes.PublicKey]GigaNodeAddr{} + urlByValidator := map[atypes.PublicKey]*url.URL{} + for i, validatorKey := range validatorKeys { + nodeKey := makeKey(rng) + addr := GigaNodeAddr{ + Key: nodeKey.Public(), + HostPort: tcp.HostPort{Hostname: "127.0.0.1", Port: 26657}, + } + // Leave one validator without an EVMRPC URL so we exercise the + // "shard owner has no proxy" path too. + if i < 4 { + rpcURL, err := url.Parse(fmt.Sprintf("http://validator-%d.example.com:8545", i)) + require.NoError(t, err) + addr.EVMRPC = utils.Some(rpcURL) + urlByValidator[validatorKey.Public()] = rpcURL + } + addrs[validatorKey.Public()] = addr + } + genDoc := &types.GenesisDoc{ + ChainID: "giga-router-rpc-only-test", + InitialHeight: 1, + AppState: testAppStateJSON(rng), + } + require.NoError(t, genDoc.ValidateAndComplete()) + + app := newTestApp() + txMempool := mempool.NewTxMempool(mempool.TestConfig(), proxy.New(app, proxy.NopMetrics()), mempool.NopMetrics(), mempool.NopTxConstraintsFetcher) + + // Construct with no validator key (rpc-only nodes don't have one) and no + // Consensus / Producer configs. + router, err := NewGigaRouter(&GigaRouterConfig{ + DialInterval: time.Second, + ValidatorAddrs: addrs, + TxMempool: txMempool, + GenDoc: genDoc, + RPCOnly: true, + }, makeKey(rng)) + require.NoError(t, err) + + // Shape: rpc-only routers carry only the committee for EvmProxy routing; + // the data/consensus/producer/service stack and the peer-connection pools + // stay nil because no spawn loops run. + require.True(t, router.IsRPCOnly()) + require.NotNil(t, router.committee) + require.Nil(t, router.data) + require.Nil(t, router.consensus) + require.Nil(t, router.producer) + require.Nil(t, router.service) + require.Nil(t, router.poolIn) + require.Nil(t, router.poolOut) + + // EvmProxy: for every sender, the rpc-only router resolves to the shard + // owner's URL when the owner has one configured, and (nil,false) when it + // doesn't. Crucially, no sender is ever proxied "to ourselves" — that + // short-circuit doesn't exist in rpc-only mode. + expectedRemoteURLs := map[string]struct{}{} + for _, rpcURL := range urlByValidator { + expectedRemoteURLs[rpcURL.String()] = struct{}{} + } + returnedRemoteURLs := map[string]struct{}{} + for range 200 { + sender := common.BytesToAddress(utils.GenBytes(rng, common.AddressLength)) + shardValidator := router.committee.EvmShard(sender) + expectedURL, hasURL := urlByValidator[shardValidator] + proxyURL, ok := router.EvmProxy(sender) + if hasURL { + require.True(t, ok) + require.Equal(t, expectedURL.String(), proxyURL.String()) + returnedRemoteURLs[proxyURL.String()] = struct{}{} + } else { + require.False(t, ok) + require.Nil(t, proxyURL) + } + } + // Sanity: with 200 random senders mapped uniformly over 5 shards we + // expect to have hit every shard owner with an EVMRPC URL at least once. + require.Equal(t, expectedRemoteURLs, returnedRemoteURLs) + + // Read-path methods: must fail cleanly rather than nil-deref on r.data. + require.Equal(t, int64(0), router.LastCommittedBlockNumber()) + require.Equal(t, int64(0), router.MaxGasPerBlock()) + _, err = router.BlockByNumber(t.Context(), 1) + require.ErrorIs(t, err, errRPCOnlyReadPath) + _, err = router.BlockByHash(t.Context(), atypes.BlockHeaderHash{}) + require.ErrorIs(t, err, errRPCOnlyReadPath) + + // InitRPCOnly: on a fresh app, must call InitChain. The chain ID and + // validator update in the app's snapshot prove the genesis flowed + // through. + require.False(t, app.Snapshot().Init.IsPresent()) + require.NoError(t, router.InitRPCOnly(t.Context())) + snap := app.Snapshot() + init, ok := snap.Init.Get() + require.True(t, ok) + require.Equal(t, genDoc.ChainID, init.ChainId) + require.Equal(t, int64(1), init.InitialHeight) + require.Len(t, snap.Validators, 1) + + // Run: rpc-only Run blocks on ctx and returns ctx.Err() when cancelled. + // A pre-cancelled context proves the unblock path without time-based + // synchronization between goroutines. + ctx, cancel := context.WithCancel(t.Context()) + cancel() + require.ErrorIs(t, router.Run(ctx), context.Canceled) +} diff --git a/sei-tendermint/node/node.go b/sei-tendermint/node/node.go index 761cccd68d..a0a34c0131 100644 --- a/sei-tendermint/node/node.go +++ b/sei-tendermint/node/node.go @@ -188,17 +188,26 @@ func makeNode( }, } - // Autobahn requires a local validator key; remote signers are not supported. - if cfg.AutobahnConfigFile != "" && cfg.PrivValidator.ListenAddr != "" { + gigaEnabled := cfg.AutobahnConfigFile != "" + gigaRPCOnly := gigaEnabled && cfg.IsAutobahnRPCOnly() + // Validator-mode Autobahn requires a local validator key; remote signers + // are not supported. Rpc-only mode doesn't sign anything, so this + // constraint is irrelevant there. + if gigaEnabled && !gigaRPCOnly && cfg.PrivValidator.ListenAddr != "" { return nil, fmt.Errorf("autobahn does not support remote validator signers (priv-validator.laddr is set)") } - gigaEnabled := cfg.AutobahnConfigFile != "" + // Rpc-only mode never short-circuits to a local mempool path, so the + // giga router doesn't need (and must not see) a validator signing key. + gigaValidatorKey := utils.None[atypes.SecretKey]() + if !gigaRPCOnly { + gigaValidatorKey = utils.Some(atypes.SecretKeyFromED25519(filePrivval.Key.PrivKey)) + } mp := mempool.NewTxMempool(cfg.Mempool.ToMempoolConfig(), proxyApp, nodeMetrics.mempool, sm.TxConstraintsFetcherFromStore(stateStore)) router, peerCloser, err := createRouter( nodeMetrics.p2p, node.NodeInfo, nodeKey, - utils.Some(atypes.SecretKeyFromED25519(filePrivval.Key.PrivKey)), + gigaValidatorKey, cfg, utils.Some(mp), genDoc, @@ -439,6 +448,12 @@ func (n *nodeImpl) OnStart(ctx context.Context) error { } } + if giga, ok := n.router.Giga().Get(); ok && giga.IsRPCOnly() { + if err := giga.InitRPCOnly(ctx); err != nil { + return fmt.Errorf("giga.InitRPCOnly: %w", err) + } + } + // Reload the state. It will have the Version.Consensus.App set by the // Handshake, and may have other modifications as well (ie. depending on // what happened during block replay). diff --git a/sei-tendermint/node/setup.go b/sei-tendermint/node/setup.go index a13d23df75..ddd27fb8ba 100644 --- a/sei-tendermint/node/setup.go +++ b/sei-tendermint/node/setup.go @@ -191,30 +191,27 @@ func loadAutobahnFileConfig(path string) (*config.AutobahnFileConfig, error) { } // buildGigaConfig constructs a GigaRouterConfig from the autobahn config file, node key, and genesis doc. -func buildGigaConfig( - autobahnConfigFile string, - nodeKey types.NodeKey, - validatorKey atypes.SecretKey, - txMempool *mempool.TxMempool, - genDoc *types.GenesisDoc, -) (*p2p.GigaRouterConfig, error) { +// loadAutobahnCommittee reads and validates the autobahn config file, then +// builds the committee map (validator pubkey → GigaNodeAddr) used by both +// validator and rpc-only routers. Returns the parsed file config (for +// callers that need other fields like DialInterval / ViewTimeout) and the +// committee map. Rejects duplicate validator keys or duplicate node keys. +func loadAutobahnCommittee(autobahnConfigFile string) (*config.AutobahnFileConfig, map[atypes.PublicKey]p2p.GigaNodeAddr, error) { if autobahnConfigFile == "" { - return nil, errors.New("autobahn config file path must not be empty") + return nil, nil, errors.New("autobahn config file path must not be empty") } fc, err := loadAutobahnFileConfig(autobahnConfigFile) if err != nil { - return nil, fmt.Errorf("loading autobahn config from %q: %w", autobahnConfigFile, err) + return nil, nil, fmt.Errorf("loading autobahn config from %q: %w", autobahnConfigFile, err) } - validatorAddrs := map[atypes.PublicKey]p2p.GigaNodeAddr{} seenNodeKeys := map[p2p.NodePublicKey]bool{} - for _, entry := range fc.Validators { if _, exists := validatorAddrs[entry.ValidatorKey]; exists { - return nil, fmt.Errorf("duplicate validator key in autobahn validators: %s", entry.ValidatorKey) + return nil, nil, fmt.Errorf("duplicate validator key in autobahn validators: %s", entry.ValidatorKey) } if seenNodeKeys[entry.NodeKey] { - return nil, fmt.Errorf("duplicate node key in autobahn validators: %s", entry.NodeKey) + return nil, nil, fmt.Errorf("duplicate node key in autobahn validators: %s", entry.NodeKey) } seenNodeKeys[entry.NodeKey] = true validatorAddrs[entry.ValidatorKey] = p2p.GigaNodeAddr{ @@ -223,6 +220,20 @@ func buildGigaConfig( EVMRPC: entry.GetEVMRPC(), } } + return fc, validatorAddrs, nil +} + +func buildGigaConfig( + autobahnConfigFile string, + nodeKey types.NodeKey, + validatorKey atypes.SecretKey, + txMempool *mempool.TxMempool, + genDoc *types.GenesisDoc, +) (*p2p.GigaRouterConfig, error) { + fc, validatorAddrs, err := loadAutobahnCommittee(autobahnConfigFile) + if err != nil { + return nil, err + } // Verify self is in the validator set. selfAddr, ok := validatorAddrs[validatorKey.Public()] @@ -265,6 +276,28 @@ func buildGigaConfig( }, nil } +// buildRPCOnlyGigaConfig builds a GigaRouterConfig for a non-validator RPC +// node: loads the committee but skips the membership check and the +// Consensus/Producer configs. See the TODO(autobahn-read-path) in +// NewGigaRouter for the read-side scope. +func buildRPCOnlyGigaConfig( + autobahnConfigFile string, + txMempool *mempool.TxMempool, + genDoc *types.GenesisDoc, +) (*p2p.GigaRouterConfig, error) { + fc, validatorAddrs, err := loadAutobahnCommittee(autobahnConfigFile) + if err != nil { + return nil, err + } + return &p2p.GigaRouterConfig{ + DialInterval: time.Duration(fc.DialInterval), + ValidatorAddrs: validatorAddrs, + TxMempool: txMempool, + GenDoc: genDoc, + RPCOnly: true, + }, nil +} + func createRouter( p2pMetrics *p2p.Metrics, nodeInfoProducer func() *types.NodeInfo, @@ -357,17 +390,22 @@ func createRouter( } // Wire up Autobahn (GigaRouter) if enabled. if cfg.AutobahnConfigFile != "" { - logger.Info("Autobahn config enabled", "config_file", cfg.AutobahnConfigFile) - // TODO: add support for autobahn non-validator (observer) nodes that don't need a signing key. - valKey, ok := validatorKey.Get() - if !ok { - return nil, closer, fmt.Errorf("autobahn non-validator nodes are not supported yet; a local validator key is required") - } + logger.Info("Autobahn config enabled", "config_file", cfg.AutobahnConfigFile, "role", cfg.AutobahnRole) mp, ok := txMempool.Get() if !ok { return nil, closer, errors.New("autobahn requires a tx mempool") } - gigaCfg, err := buildGigaConfig(cfg.AutobahnConfigFile, nodeKey, valKey, mp, genDoc) + var gigaCfg *p2p.GigaRouterConfig + var err error + if cfg.IsAutobahnRPCOnly() { + gigaCfg, err = buildRPCOnlyGigaConfig(cfg.AutobahnConfigFile, mp, genDoc) + } else { + valKey, keyOk := validatorKey.Get() + if !keyOk { + return nil, closer, fmt.Errorf("autobahn validator mode requires a local validator key; set autobahn-role=%q for non-validator nodes", config.AutobahnRoleRPCOnly) + } + gigaCfg, err = buildGigaConfig(cfg.AutobahnConfigFile, nodeKey, valKey, mp, genDoc) + } if err != nil { return nil, closer, fmt.Errorf("buildGigaConfig: %w", err) } @@ -375,10 +413,13 @@ func createRouter( // matching how other paths in the tendermint config are handled // (config.go's rootify). Absolute paths pass through unchanged. None // means the operator opted into in-memory-only mode and stays None. - if dir, ok := gigaCfg.Consensus.PersistentStateDir.Get(); ok && !filepath.IsAbs(dir) { - gigaCfg.Consensus.PersistentStateDir = utils.Some(filepath.Join(cfg.RootDir, dir)) + // Rpc-only mode has no Consensus config, so this is a no-op there. + if gigaCfg.Consensus != nil { + if dir, ok := gigaCfg.Consensus.PersistentStateDir.Get(); ok && !filepath.IsAbs(dir) { + gigaCfg.Consensus.PersistentStateDir = utils.Some(filepath.Join(cfg.RootDir, dir)) + } } - logger.Info("Autobahn config loaded", "validators", len(gigaCfg.ValidatorAddrs)) + logger.Info("Autobahn config loaded", "validators", len(gigaCfg.ValidatorAddrs), "rpc_only", gigaCfg.RPCOnly) options.Giga = utils.Some(gigaCfg) }