From 138e561acb3df300c6de3dfcb0a97ea6857d8adb Mon Sep 17 00:00:00 2001 From: Wen Date: Fri, 29 May 2026 18:03:44 -0700 Subject: [PATCH 01/12] feat(autobahn): rpc-only mode forwards eth_sendRawTransaction (CON-309) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an autobahn-role config (validator|rpc-only). With autobahn-role= "rpc-only", a non-validator RPC node loads the committee from autobahn.json as a routing table only — no consensus participation, no block execution, no validator key required. eth_sendRawTransaction submitted to such a node is recovered, sender-shard- mapped via Committee.EvmShard, and forwarded over HTTP to the shard owner's EVM RPC. The rest of the giga stack (consensus, producer, data, service) stays nil; Run is a no-op; block-read methods return a sentinel error. InitRPCOnly bootstraps the app once at startup so x/evm params (chain ID, signer config) are populated. app.go pre-fires the EVM HTTP/WS start gate since rpc-only nodes don't call ProcessBlock in the current milestone — see TODO(autobahn-read-path) in NewGigaRouter for the read-side scope. CI: wires PR #3234's make autobahn-integration-test into the workflow as a new top-level job (it owns its own cluster via TestMain, so can't share the matrix's cluster), and adds a TestAutobahn/RPCOnlyForwarding sub-test that verifies an actual signed tx round-trips through the proxy: rpc-only sidecar → shard owner → block inclusion → receipt on validator. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/integration-test.yml | 62 ++- Makefile | 14 +- app/app.go | 29 ++ .../rpcnode/scripts/step1_configure_init.sh | 38 ++ integration_test/autobahn/autobahn_test.go | 8 + integration_test/autobahn/rpc_only_test.go | 357 ++++++++++++++++++ sei-tendermint/config/config.go | 26 ++ sei-tendermint/config/toml.go | 7 + sei-tendermint/internal/p2p/giga_router.go | 111 +++++- .../internal/p2p/giga_router_test.go | 128 +++++++ sei-tendermint/node/node.go | 23 +- sei-tendermint/node/setup.go | 70 +++- 12 files changed, 850 insertions(+), 23 deletions(-) create mode 100644 integration_test/autobahn/rpc_only_test.go diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 11b776014f..8b94592643 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: Autobahn Integration Tests + 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..3b512595fc 100644 --- a/Makefile +++ b/Makefile @@ -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..3317dfde25 100644 --- a/app/app.go +++ b/app/app.go @@ -485,6 +485,14 @@ type App struct { httpServerStartSignalSent bool wsServerStartSignalSent bool + // autobahnRPCOnly is true when the node runs as an Autobahn rpc-only + // (non-validator) node. In the current write-only milestone these nodes + // don't execute blocks, so the EVM HTTP/WS servers — normally gated on + // the first ProcessBlock — instead start as soon as RegisterLocalServices + // wires them up. See the TODO(autobahn-read-path) at the pre-fire site + // in RegisterLocalServices for when this field can go away. + autobahnRPCOnly bool + txPrioritizer sdk.TxPrioritizer benchmarkManager *benchmark.Manager @@ -551,6 +559,7 @@ func New( stateStore: stateStore, httpServerStartSignal: make(chan struct{}, 1), wsServerStartSignal: make(chan struct{}, 1), + autobahnRPCOnly: tmConfig != nil && tmConfig.IsAutobahnRPCOnly(), } for _, option := range appOptions { @@ -2654,6 +2663,26 @@ func (app *App) RegisterLocalServices(node client.LocalClient, txConfig client.T rpcCtxProvider := app.RPCContextProvider traceCtxProvider := app.SnapshotAwareRPCContextProvider() + // In the current write-only milestone, rpc-only nodes don't call + // ProcessBlock, so they have to start their EVM RPC servers without + // waiting on the ProcessBlock gate. Pre-fire both signals; ProcessBlock's + // send is guarded by *Sent flags so this won't deadlock validator-mode + // startup if both paths run. + // + // TODO(autobahn-read-path): once rpc-only nodes subscribe to finalized + // blocks and execute them locally, they will run ProcessBlock just like + // validators and the gate signal will fire naturally. Delete this + // pre-fire branch and the autobahnRPCOnly field at that point. + if app.autobahnRPCOnly { + if !app.httpServerStartSignalSent { + app.httpServerStartSignalSent = true + app.httpServerStartSignal <- struct{}{} + } + if !app.wsServerStartSignalSent { + app.wsServerStartSignalSent = true + app.wsServerStartSignal <- struct{}{} + } + } if app.evmRPCConfig.HTTPEnabled { evmHTTPServer, err := evmrpc.NewEVMHTTPServer(app.evmRPCConfig, node, &app.EvmKeeper, app.BeginBlockKeepers, app.BaseApp, app.TracerAnteHandler, app.RPCContextProvider, txConfigProvider, DefaultNodeHome, app.GetStateStore(), traceCtxProvider) if err != nil { 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..ef38a8d838 100644 --- a/integration_test/autobahn/autobahn_test.go +++ b/integration_test/autobahn/autobahn_test.go @@ -169,7 +169,14 @@ 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) + teardownRPCOnlyNode() // best-effort + teardownCluster() + os.Exit(1) + } code := m.Run() + teardownRPCOnlyNode() teardownCluster() os.Exit(code) } @@ -290,6 +297,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..b3b09c17e3 --- /dev/null +++ b/integration_test/autobahn/rpc_only_test.go @@ -0,0 +1,357 @@ +//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 + validatorEVMRPCURL = "http://localhost:8545" // sei-node-0 (host-published) + rpcOnlyInternalURL = "http://localhost:8545" // inside sei-rpc-node + rpcOnlyReceiptPoll = 500 * time.Millisecond + rpcOnlyReceiptLimit = 60 * time.Second +) + +// 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) +} + +// teardownRPCOnlyNode tears down the rpc-only sidecar. +func teardownRPCOnlyNode() { + fmt.Println("=== Stopping rpc-only sidecar ===") + _ = runMake(nil, "kill-rpc-node") +} + +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), + rpcOnlyInternalURL).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(validatorEVMRPCURL, "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.Keccak256(nil) + 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). + r := hex.EncodeToString(sig[:32]) + s := hex.EncodeToString(sig[32:64]) + v := hex.EncodeToString([]byte{sig[64]}) + 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..9aeb9054e6 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,63 @@ 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 -1 in rpc-only mode (no consensus state) so callers treat the +// node as having no committed blocks; /status's invariant check +// (LastCommitted >= Latest) still holds because the rpc-only node's +// LatestBlockHeight is also 0. func (r *GigaRouter) LastCommittedBlockNumber() int64 { + if r.cfg.RPCOnly { + return -1 + } // 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 +218,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 +249,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 +282,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 +474,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 +545,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 +572,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..f45603ac3e 100644 --- a/sei-tendermint/internal/p2p/giga_router_test.go +++ b/sei-tendermint/internal/p2p/giga_router_test.go @@ -506,3 +506,131 @@ 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(-1), 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 is a passive wait; cancelling the context unblocks + // it with context.Canceled. + ctx, cancel := context.WithCancel(t.Context()) + runErr := make(chan error, 1) + go func() { runErr <- router.Run(ctx) }() + // Give Run a tick to actually park on ctx.Done() so we don't race with + // the cancel happening before Run starts waiting. + time.Sleep(10 * time.Millisecond) + cancel() + select { + case err := <-runErr: + require.ErrorIs(t, err, context.Canceled) + case <-time.After(time.Second): + t.Fatal("rpc-only Run did not return after ctx cancel") + } +} 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..9904c230fd 100644 --- a/sei-tendermint/node/setup.go +++ b/sei-tendermint/node/setup.go @@ -265,6 +265,48 @@ 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, + nodeKey types.NodeKey, + txMempool *mempool.TxMempool, + genDoc *types.GenesisDoc, +) (*p2p.GigaRouterConfig, error) { + if autobahnConfigFile == "" { + return 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) + } + 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) + } + if seenNodeKeys[entry.NodeKey] { + return nil, fmt.Errorf("duplicate node key in autobahn validators: %s", entry.NodeKey) + } + seenNodeKeys[entry.NodeKey] = true + validatorAddrs[entry.ValidatorKey] = p2p.GigaNodeAddr{ + Key: entry.NodeKey, + HostPort: entry.Address, + EVMRPC: entry.GetEVMRPC(), + } + } + 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 +399,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, nodeKey, 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 +422,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) } From bf586c09500316c823b5a989fa8a88464a098a6a Mon Sep 17 00:00:00 2001 From: Wen Date: Fri, 29 May 2026 18:14:13 -0700 Subject: [PATCH 02/12] ci(autobahn): rename job to "Integration Test (Autobahn Basic)" Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/integration-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 8b94592643..69e987db6d 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -456,7 +456,7 @@ jobs: # 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: Autobahn Integration Tests + name: Integration Test (Autobahn Basic) runs-on: ubuntu-large timeout-minutes: 45 needs: prepare-cluster From 7afda0cf466757cfca98ad70474d8bb30074e312 Mon Sep 17 00:00:00 2001 From: Wen Date: Fri, 29 May 2026 18:17:29 -0700 Subject: [PATCH 03/12] fix(autobahn): bugbot review fixes - Guard autobahnRPCOnly on AutobahnConfigFile != "" so a stray autobahn-role without a config file doesn't pre-fire the EVM gate (matches node.go's gigaRPCOnly construction and the AutobahnRole godoc, which already says the role is ignored when the config file is empty). - Drop time.Sleep + time.After from the rpc-only Run-cancel test; a pre-cancelled context proves the unblock path without any goroutine- timing synchronization. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/app.go | 2 +- sei-tendermint/internal/p2p/giga_router_test.go | 17 ++++------------- 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/app/app.go b/app/app.go index 3317dfde25..a983dd31fa 100644 --- a/app/app.go +++ b/app/app.go @@ -559,7 +559,7 @@ func New( stateStore: stateStore, httpServerStartSignal: make(chan struct{}, 1), wsServerStartSignal: make(chan struct{}, 1), - autobahnRPCOnly: tmConfig != nil && tmConfig.IsAutobahnRPCOnly(), + autobahnRPCOnly: tmConfig != nil && tmConfig.AutobahnConfigFile != "" && tmConfig.IsAutobahnRPCOnly(), } for _, option := range appOptions { diff --git a/sei-tendermint/internal/p2p/giga_router_test.go b/sei-tendermint/internal/p2p/giga_router_test.go index f45603ac3e..a238e6a4d8 100644 --- a/sei-tendermint/internal/p2p/giga_router_test.go +++ b/sei-tendermint/internal/p2p/giga_router_test.go @@ -618,19 +618,10 @@ func TestGigaRouter_RPCOnly(t *testing.T) { require.Equal(t, int64(1), init.InitialHeight) require.Len(t, snap.Validators, 1) - // Run: rpc-only Run is a passive wait; cancelling the context unblocks - // it with context.Canceled. + // 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()) - runErr := make(chan error, 1) - go func() { runErr <- router.Run(ctx) }() - // Give Run a tick to actually park on ctx.Done() so we don't race with - // the cancel happening before Run starts waiting. - time.Sleep(10 * time.Millisecond) cancel() - select { - case err := <-runErr: - require.ErrorIs(t, err, context.Canceled) - case <-time.After(time.Second): - t.Fatal("rpc-only Run did not return after ctx cancel") - } + require.ErrorIs(t, router.Run(ctx), context.Canceled) } From b1b1de06ced1f764ca9631d7b393078bcd192a46 Mon Sep 17 00:00:00 2001 From: Wen Date: Fri, 29 May 2026 19:57:36 -0700 Subject: [PATCH 04/12] fix(app): fire EVM RPC gate from InitChainer too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProcessBlock's deferred gate-fire didn't cover rpc-only nodes because they never execute a block. Factor the gate-fire into a helper and call it from InitChainer as well — fresh-start validators reach it via the handshaker / runExecute InitChain call, rpc-only nodes via GigaRouter.InitRPCOnly. Both paths converge on the same chain event. The *Sent flags keep the second fire a no-op. Drops the autobahnRPCOnly field on App and the consensus-mode branch in RegisterLocalServices that bugbot flagged. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/app.go | 59 ++++++++++++++++++------------------------------------ 1 file changed, 20 insertions(+), 39 deletions(-) diff --git a/app/app.go b/app/app.go index a983dd31fa..a1dba16069 100644 --- a/app/app.go +++ b/app/app.go @@ -485,14 +485,6 @@ type App struct { httpServerStartSignalSent bool wsServerStartSignalSent bool - // autobahnRPCOnly is true when the node runs as an Autobahn rpc-only - // (non-validator) node. In the current write-only milestone these nodes - // don't execute blocks, so the EVM HTTP/WS servers — normally gated on - // the first ProcessBlock — instead start as soon as RegisterLocalServices - // wires them up. See the TODO(autobahn-read-path) at the pre-fire site - // in RegisterLocalServices for when this field can go away. - autobahnRPCOnly bool - txPrioritizer sdk.TxPrioritizer benchmarkManager *benchmark.Manager @@ -559,7 +551,6 @@ 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 { @@ -1262,7 +1253,26 @@ func (app *App) MidBlocker(ctx sdk.Context, height int64) []abci.Event { } // 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. +// Idempotent: the *Sent flags skip duplicate sends. Fired from both +// ProcessBlock (the steady-state trigger) and InitChainer (which covers +// rpc-only nodes — they call InitChain via GigaRouter.InitRPCOnly but never +// reach ProcessBlock). On a fresh-start validator both fire; the second is +// a no-op. +func (app *App) signalEVMRPCReady() { + if !app.httpServerStartSignalSent { + app.httpServerStartSignalSent = true + app.httpServerStartSignal <- struct{}{} + } + if !app.wsServerStartSignalSent { + app.wsServerStartSignalSent = true + app.wsServerStartSignal <- struct{}{} + } +} + 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 { @@ -1919,16 +1929,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()) @@ -2663,26 +2664,6 @@ func (app *App) RegisterLocalServices(node client.LocalClient, txConfig client.T rpcCtxProvider := app.RPCContextProvider traceCtxProvider := app.SnapshotAwareRPCContextProvider() - // In the current write-only milestone, rpc-only nodes don't call - // ProcessBlock, so they have to start their EVM RPC servers without - // waiting on the ProcessBlock gate. Pre-fire both signals; ProcessBlock's - // send is guarded by *Sent flags so this won't deadlock validator-mode - // startup if both paths run. - // - // TODO(autobahn-read-path): once rpc-only nodes subscribe to finalized - // blocks and execute them locally, they will run ProcessBlock just like - // validators and the gate signal will fire naturally. Delete this - // pre-fire branch and the autobahnRPCOnly field at that point. - if app.autobahnRPCOnly { - if !app.httpServerStartSignalSent { - app.httpServerStartSignalSent = true - app.httpServerStartSignal <- struct{}{} - } - if !app.wsServerStartSignalSent { - app.wsServerStartSignalSent = true - app.wsServerStartSignal <- struct{}{} - } - } if app.evmRPCConfig.HTTPEnabled { evmHTTPServer, err := evmrpc.NewEVMHTTPServer(app.evmRPCConfig, node, &app.EvmKeeper, app.BeginBlockKeepers, app.BaseApp, app.TracerAnteHandler, app.RPCContextProvider, txConfigProvider, DefaultNodeHome, app.GetStateStore(), traceCtxProvider) if err != nil { From f29e6478aaee030624e848e7a1502d8a90418449 Mon Sep 17 00:00:00 2001 From: Wen Date: Fri, 29 May 2026 19:58:49 -0700 Subject: [PATCH 05/12] fix(setup): drop unused nodeKey param in buildRPCOnlyGigaConfig MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bugbot caught the leftover copy-paste from buildGigaConfig — the rpc-only variant intentionally skips the membership check, so nodeKey was never read. Co-Authored-By: Claude Opus 4.7 (1M context) --- sei-tendermint/node/setup.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/sei-tendermint/node/setup.go b/sei-tendermint/node/setup.go index 9904c230fd..4a3a1add5f 100644 --- a/sei-tendermint/node/setup.go +++ b/sei-tendermint/node/setup.go @@ -271,7 +271,6 @@ func buildGigaConfig( // NewGigaRouter for the read-side scope. func buildRPCOnlyGigaConfig( autobahnConfigFile string, - nodeKey types.NodeKey, txMempool *mempool.TxMempool, genDoc *types.GenesisDoc, ) (*p2p.GigaRouterConfig, error) { @@ -407,7 +406,7 @@ func createRouter( var gigaCfg *p2p.GigaRouterConfig var err error if cfg.IsAutobahnRPCOnly() { - gigaCfg, err = buildRPCOnlyGigaConfig(cfg.AutobahnConfigFile, nodeKey, mp, genDoc) + gigaCfg, err = buildRPCOnlyGigaConfig(cfg.AutobahnConfigFile, mp, genDoc) } else { valKey, keyOk := validatorKey.Get() if !keyOk { From 9ff1a12850cda8cabc5849aea60a4223f0f93b6a Mon Sep 17 00:00:00 2001 From: Wen Date: Fri, 29 May 2026 20:42:18 -0700 Subject: [PATCH 06/12] fix(app): also fire EVM RPC gate from Info on restart-with-state Bot caught a latent issue: InitRPCOnly's early-return (when the app already has committed state) skipped the InitChain call, so the InitChainer defer never fired and the EVM RPC goroutines would block forever. Today the path is unreachable (rpc-only never commits) but read-side scope changes that. Wrap BaseApp.Info to also fire the gate when LastBlockHeight > 0. The trigger is the app's own committed state, not a consensus-engine flag, so no cross-layer branching. Pairs naturally with InitChainer's defer: fresh start fires via InitChain, restart-with-state via Info, steady- state via ProcessBlock. Verified: make autobahn-integration-test passes all 6 sub-tests including RPCOnlyForwarding. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/app.go | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/app/app.go b/app/app.go index a1dba16069..40ed515ce2 100644 --- a/app/app.go +++ b/app/app.go @@ -1255,11 +1255,16 @@ func (app *App) MidBlocker(ctx sdk.Context, height int64) []abci.Event { // 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. -// Idempotent: the *Sent flags skip duplicate sends. Fired from both -// ProcessBlock (the steady-state trigger) and InitChainer (which covers -// rpc-only nodes — they call InitChain via GigaRouter.InitRPCOnly but never -// reach ProcessBlock). On a fresh-start validator both fire; the second is -// a no-op. +// Idempotent: the *Sent flags skip duplicate sends. 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() { if !app.httpServerStartSignalSent { app.httpServerStartSignalSent = true @@ -1271,6 +1276,19 @@ func (app *App) signalEVMRPCReady() { } } +// Info wraps BaseApp.Info so the EVM RPC gate also fires on restart-with- +// state. InitChainer's defer covers fresh start; on a restart where +// InitChain isn't called, the consensus engine still queries Info first +// and that response's LastBlockHeight > 0 is the signal that the app is +// initialized and the EVM RPC can serve queries. +func (app *App) Info(ctx context.Context, req *abci.RequestInfo) (*abci.ResponseInfo, error) { + resp, err := app.BaseApp.Info(ctx, req) + if err == nil && resp != nil && resp.LastBlockHeight > 0 { + app.signalEVMRPCReady() + } + return resp, err +} + func (app *App) InitChainer(ctx sdk.Context, req abci.RequestInitChain) abci.ResponseInitChain { defer app.signalEVMRPCReady() var genesisState GenesisState From 0cb13f52a7f49d939cb7e7dc0335ffc8a6a20a8f Mon Sep 17 00:00:00 2001 From: Wen Date: Fri, 29 May 2026 20:50:29 -0700 Subject: [PATCH 07/12] docs(app): TODO to delete Info wrapper when read-side milestone lands Co-Authored-By: Claude Opus 4.7 (1M context) --- app/app.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/app.go b/app/app.go index 40ed515ce2..c1c126d8a2 100644 --- a/app/app.go +++ b/app/app.go @@ -1281,6 +1281,12 @@ func (app *App) signalEVMRPCReady() { // InitChain isn't called, the consensus engine still queries Info first // and that response's LastBlockHeight > 0 is the signal that the app is // initialized and the EVM RPC can serve queries. +// +// TODO(autobahn-read-path): delete this wrapper once Autobahn rpc-only +// nodes subscribe to finalized blocks and run ProcessBlock like +// validators. The gate will fire naturally from ProcessBlock's defer +// and this Info path becomes redundant. The InitChainer defer can also +// be reconsidered at that point. func (app *App) Info(ctx context.Context, req *abci.RequestInfo) (*abci.ResponseInfo, error) { resp, err := app.BaseApp.Info(ctx, req) if err == nil && resp != nil && resp.LastBlockHeight > 0 { From c06c5d4e6f124cf9e17600c6bb4667e4ec28f79f Mon Sep 17 00:00:00 2001 From: Wen Date: Fri, 29 May 2026 21:04:45 -0700 Subject: [PATCH 08/12] refactor(setup): extract loadAutobahnCommittee to dedupe map build Bugbot caught the validator-address-map construction was copy-pasted between buildGigaConfig and buildRPCOnlyGigaConfig. Pull it into a single loadAutobahnCommittee helper that returns the parsed file config + the committee map; both callers compose the rest of their config from there. Co-Authored-By: Claude Opus 4.7 (1M context) --- sei-tendermint/node/setup.go | 60 ++++++++++++++++-------------------- 1 file changed, 26 insertions(+), 34 deletions(-) diff --git a/sei-tendermint/node/setup.go b/sei-tendermint/node/setup.go index 4a3a1add5f..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()] @@ -274,28 +285,9 @@ func buildRPCOnlyGigaConfig( txMempool *mempool.TxMempool, genDoc *types.GenesisDoc, ) (*p2p.GigaRouterConfig, error) { - if autobahnConfigFile == "" { - return nil, errors.New("autobahn config file path must not be empty") - } - fc, err := loadAutobahnFileConfig(autobahnConfigFile) + fc, validatorAddrs, err := loadAutobahnCommittee(autobahnConfigFile) if err != nil { - return 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) - } - if seenNodeKeys[entry.NodeKey] { - return nil, fmt.Errorf("duplicate node key in autobahn validators: %s", entry.NodeKey) - } - seenNodeKeys[entry.NodeKey] = true - validatorAddrs[entry.ValidatorKey] = p2p.GigaNodeAddr{ - Key: entry.NodeKey, - HostPort: entry.Address, - EVMRPC: entry.GetEVMRPC(), - } + return nil, err } return &p2p.GigaRouterConfig{ DialInterval: time.Duration(fc.DialInterval), From 8fd8bd1830a5409ef6f827d0be544004767d3733 Mon Sep 17 00:00:00 2001 From: Wen Date: Fri, 29 May 2026 21:08:04 -0700 Subject: [PATCH 09/12] docs(app): clarify Info override exists for Autobahn rpc-only The override is a sei-tendermint-specific concession (rpc-only nodes have no ProcessBlock to fire the gate from), not a general improvement to Info. Calling that out in the header so a future reader doesn't wonder why we touched an ABCI method that looks innocent. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/app.go | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/app/app.go b/app/app.go index c1c126d8a2..b3f2c57973 100644 --- a/app/app.go +++ b/app/app.go @@ -1276,17 +1276,23 @@ func (app *App) signalEVMRPCReady() { } } -// Info wraps BaseApp.Info so the EVM RPC gate also fires on restart-with- -// state. InitChainer's defer covers fresh start; on a restart where -// InitChain isn't called, the consensus engine still queries Info first -// and that response's LastBlockHeight > 0 is the signal that the app is -// initialized and the EVM RPC can serve queries. +// Info overrides BaseApp.Info specifically to fire the EVM RPC gate on +// restart-with-state. The override exists for the Autobahn rpc-only +// milestone: 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. // -// TODO(autobahn-read-path): delete this wrapper once Autobahn rpc-only +// Validators and state-sync nodes also see this fire (slightly earlier +// than ProcessBlock would have); harmless because their state is already +// loaded by the time Info returns. Fresh-start nodes (LastBlockHeight == +// 0) fall through to InitChainer's defer instead. +// +// TODO(autobahn-read-path): delete this override once Autobahn rpc-only // nodes subscribe to finalized blocks and run ProcessBlock like -// validators. The gate will fire naturally from ProcessBlock's defer -// and this Info path becomes redundant. The InitChainer defer can also -// be reconsidered at that point. +// validators. Both the InitChainer defer and this Info wrap 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 err == nil && resp != nil && resp.LastBlockHeight > 0 { From 01a636e8d9a9fa6fd4a79ed7a6237c7b694f7d2c Mon Sep 17 00:00:00 2001 From: Wen Date: Sat, 30 May 2026 13:57:14 -0700 Subject: [PATCH 10/12] fix(app): scope Info gate-fire to rpc-only; polish review nits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review caught that the unconditional Info wrapper fires the gate before CometBFT Handshaker's ReplayBlocks runs, binding EVM HTTP/WS while replay is mid-flight — strictly worse staleness window than the original ProcessBlock-defer trigger (which fires after the first replayed block commits). Re-introduce autobahnRPCOnly as a single bool on App, set from tmConfig (guarded on AutobahnConfigFile != ""), and gate the Info-side fire on it. Autobahn nodes skip the Handshaker entirely, so the gating is also what makes the override safe for the mode it exists for. Also addresses smaller review feedback: - LastCommittedBlockNumber: reword to match /status's actual committed > 0 guard; the "LastCommitted >= Latest" framing was overstated and fragile. - rpc_only_test.go: rename identical-string constants to expose the routing intent at call sites (validatorEVMRPCURLOnHost vs evmRPCURLOnContainerLocalhost — one is host-curled, the other goes through docker exec into the rpc-only sidecar). Verified: make autobahn-integration-test passes all 6 sub-tests including RPCOnlyForwarding. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/app.go | 44 ++++++++++++++-------- integration_test/autobahn/rpc_only_test.go | 14 +++++-- sei-tendermint/internal/p2p/giga_router.go | 7 ++-- 3 files changed, 41 insertions(+), 24 deletions(-) diff --git a/app/app.go b/app/app.go index b3f2c57973..e9b3134f62 100644 --- a/app/app.go +++ b/app/app.go @@ -485,6 +485,11 @@ type App struct { httpServerStartSignalSent bool wsServerStartSignalSent bool + // 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 benchmarkManager *benchmark.Manager @@ -551,6 +556,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 { @@ -1276,26 +1282,32 @@ func (app *App) signalEVMRPCReady() { } } -// Info overrides BaseApp.Info specifically to fire the EVM RPC gate on -// restart-with-state. The override exists for the Autobahn rpc-only -// milestone: 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. +// 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. // -// Validators and state-sync nodes also see this fire (slightly earlier -// than ProcessBlock would have); harmless because their state is already -// loaded by the time Info returns. Fresh-start nodes (LastBlockHeight == -// 0) fall through to InitChainer's defer instead. +// 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 once Autobahn rpc-only -// nodes subscribe to finalized blocks and run ProcessBlock like -// validators. Both the InitChainer defer and this Info wrap become -// redundant at that point. +// 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 err == nil && resp != nil && resp.LastBlockHeight > 0 { + if app.autobahnRPCOnly && err == nil && resp != nil && resp.LastBlockHeight > 0 { app.signalEVMRPCReady() } return resp, err diff --git a/integration_test/autobahn/rpc_only_test.go b/integration_test/autobahn/rpc_only_test.go index b3b09c17e3..5f8e0c9b35 100644 --- a/integration_test/autobahn/rpc_only_test.go +++ b/integration_test/autobahn/rpc_only_test.go @@ -26,10 +26,16 @@ const ( rpcOnlyContainer = "sei-rpc-node" rpcOnlyBootTimeout = 5 * time.Minute rpcOnlyBootPoll = 5 * time.Second - validatorEVMRPCURL = "http://localhost:8545" // sei-node-0 (host-published) - rpcOnlyInternalURL = "http://localhost:8545" // inside sei-rpc-node 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 @@ -112,7 +118,7 @@ func evmRPCInContainer(container, method string, params any) (*evmRPCResponse, e "curl", "-sf", "-X", "POST", "-H", "content-type: application/json", "--data", string(body), - rpcOnlyInternalURL).Output() + evmRPCURLOnContainerLocalhost).Output() if err != nil { return nil, fmt.Errorf("docker exec curl: %v", err) } @@ -131,7 +137,7 @@ func evmRPCOnHost(method string, params any) (*evmRPCResponse, error) { if err != nil { return nil, err } - resp, err := http.Post(validatorEVMRPCURL, "application/json", bytes.NewReader(body)) + resp, err := http.Post(validatorEVMRPCURLOnHost, "application/json", bytes.NewReader(body)) if err != nil { return nil, err } diff --git a/sei-tendermint/internal/p2p/giga_router.go b/sei-tendermint/internal/p2p/giga_router.go index 9aeb9054e6..0c38601bc1 100644 --- a/sei-tendermint/internal/p2p/giga_router.go +++ b/sei-tendermint/internal/p2p/giga_router.go @@ -197,10 +197,9 @@ var errRPCOnlyReadPath = errors.New("autobahn rpc-only: block/consensus read pat // Safe for high-frequency callers — uses a cached lock-free receiver; no // locks taken on this path. // -// Returns -1 in rpc-only mode (no consensus state) so callers treat the -// node as having no committed blocks; /status's invariant check -// (LastCommitted >= Latest) still holds because the rpc-only node's -// LatestBlockHeight is also 0. +// Returns -1 in rpc-only mode (no consensus state). /status's +// "LastCommittedBlockHeight < LatestBlockHeight" warning is gated on +// `committed > 0`, so -1 is silently skipped there. func (r *GigaRouter) LastCommittedBlockNumber() int64 { if r.cfg.RPCOnly { return -1 From 4e16b89ccfda170f3050c6454ceb9547568f6d95 Mon Sep 17 00:00:00 2001 From: Wen Date: Sat, 30 May 2026 14:59:37 -0700 Subject: [PATCH 11/12] fix(autobahn-integ): match seid CLI's R/S/V encoding for sei_associate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI fresh-cluster runs failed at TestAutobahn/RPCOnlyForwarding with "sei_associate error: : unknown" because the V/R/S hex encoding differed from what `seid tx evm associate-address` produces: crypto.Sign returns sig[64] as a raw byte and hex.EncodeToString of []byte{0x00} produces "00", but the CLI uses big.Int.Bytes() which strips leading zeros to "" for V=0. The chain's signature verification rejects the encoding mismatch (CheckTx code != 0). Match the CLI exactly: round-trip through big.Int.Bytes() for R, S, and V. Local runs previously passed because they hit the "balance > 0 → skip associate" branch against a long-lived cluster where admin was already associated from prior runs. Also silence `git describe --tags 2>/dev/null` in Makefile — the "fatal: No names found, cannot describe anything" line was cluttering every CI log and surfaced from a shallow clone with no tags. Co-Authored-By: Claude Opus 4.7 (1M context) --- Makefile | 2 +- integration_test/autobahn/rpc_only_test.go | 15 ++++++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/Makefile b/Makefile index 3b512595fc..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/'); \ diff --git a/integration_test/autobahn/rpc_only_test.go b/integration_test/autobahn/rpc_only_test.go index 5f8e0c9b35..b747b2425f 100644 --- a/integration_test/autobahn/rpc_only_test.go +++ b/integration_test/autobahn/rpc_only_test.go @@ -174,15 +174,20 @@ func associateAdmin(t *testing.T, privHex string) { if err != nil { t.Fatalf("HexToECDSA: %v", err) } - emptyHash := crypto.Keccak256(nil) - sig, err := crypto.Sign(emptyHash, priv) + 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). - r := hex.EncodeToString(sig[:32]) - s := hex.EncodeToString(sig[32:64]) - v := hex.EncodeToString([]byte{sig[64]}) + // 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) From e25d6b145f494e201a9384289520f3e73f725039 Mon Sep 17 00:00:00 2001 From: Wen Date: Sat, 30 May 2026 17:52:26 -0700 Subject: [PATCH 12/12] fix: address fresh-eyes review (sync.Once, sentinel, fold teardown) - app/app.go: replace racy *Sent flag pair on signalEVMRPCReady with sync.Once. The Info-side fire site makes the race reachable from any concurrent /abci_info RPC call once read-path lands; cheap to fix now. Also restore the InitChainer doc comment that an earlier edit orphaned onto the helper above it. - sei-tendermint/internal/p2p/giga_router.go: change LastCommittedBlockNumber's rpc-only sentinel from -1 to 0 so /status's JSON response carries a non-negative integer for downstream clients. status.go's "committed > 0" guard still silently skips it, so the invariant warning stays quiet. Update unit test. - integration_test/autobahn: fold teardownRPCOnlyNode into teardownCluster. TestMain no longer repeats the kill in the two error paths + the success path; adding future sidecars goes in the same centralized teardown. Verified: make autobahn-integration-test passes all 6 sub-tests. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/app.go | 28 ++++++++++--------- integration_test/autobahn/autobahn_test.go | 10 +++++-- integration_test/autobahn/rpc_only_test.go | 6 ---- sei-tendermint/internal/p2p/giga_router.go | 10 ++++--- .../internal/p2p/giga_router_test.go | 2 +- 5 files changed, 29 insertions(+), 27 deletions(-) diff --git a/app/app.go b/app/app.go index e9b3134f62..d819ba899a 100644 --- a/app/app.go +++ b/app/app.go @@ -480,10 +480,16 @@ 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 @@ -1258,11 +1264,10 @@ 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. -// Idempotent: the *Sent flags skip duplicate sends. The full set of fire -// sites covers every startup shape: +// 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 @@ -1272,14 +1277,10 @@ func (app *App) MidBlocker(ctx sdk.Context, height int64) []abci.Event { // but kept for safety on any path that reaches a block without firing // either earlier. func (app *App) signalEVMRPCReady() { - if !app.httpServerStartSignalSent { - app.httpServerStartSignalSent = true + app.evmRPCReadyOnce.Do(func() { app.httpServerStartSignal <- struct{}{} - } - if !app.wsServerStartSignalSent { - app.wsServerStartSignalSent = true app.wsServerStartSignal <- struct{}{} - } + }) } // Info overrides BaseApp.Info to fire the EVM RPC gate on restart-with- @@ -1313,6 +1314,7 @@ func (app *App) Info(ctx context.Context, req *abci.RequestInfo) (*abci.Response 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 diff --git a/integration_test/autobahn/autobahn_test.go b/integration_test/autobahn/autobahn_test.go index ef38a8d838..d229c06811 100644 --- a/integration_test/autobahn/autobahn_test.go +++ b/integration_test/autobahn/autobahn_test.go @@ -171,12 +171,10 @@ func TestMain(m *testing.M) { } if err := setupRPCOnlyNode(); err != nil { fmt.Fprintf(os.Stderr, "rpc-only sidecar setup failed: %v\n", err) - teardownRPCOnlyNode() // best-effort teardownCluster() os.Exit(1) } code := m.Run() - teardownRPCOnlyNode() teardownCluster() os.Exit(code) } @@ -257,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") } diff --git a/integration_test/autobahn/rpc_only_test.go b/integration_test/autobahn/rpc_only_test.go index b747b2425f..3543a5b121 100644 --- a/integration_test/autobahn/rpc_only_test.go +++ b/integration_test/autobahn/rpc_only_test.go @@ -72,12 +72,6 @@ func setupRPCOnlyNode() error { return fmt.Errorf("rpc-only sidecar didn't come up within %s", rpcOnlyBootTimeout) } -// teardownRPCOnlyNode tears down the rpc-only sidecar. -func teardownRPCOnlyNode() { - fmt.Println("=== Stopping rpc-only sidecar ===") - _ = runMake(nil, "kill-rpc-node") -} - func rpcOnlyRunning() bool { out, err := exec.Command("docker", "ps", "--filter", "name="+rpcOnlyContainer, diff --git a/sei-tendermint/internal/p2p/giga_router.go b/sei-tendermint/internal/p2p/giga_router.go index 0c38601bc1..cde5df226d 100644 --- a/sei-tendermint/internal/p2p/giga_router.go +++ b/sei-tendermint/internal/p2p/giga_router.go @@ -197,12 +197,14 @@ var errRPCOnlyReadPath = errors.New("autobahn rpc-only: block/consensus read pat // Safe for high-frequency callers — uses a cached lock-free receiver; no // locks taken on this path. // -// Returns -1 in rpc-only mode (no consensus state). /status's -// "LastCommittedBlockHeight < LatestBlockHeight" warning is gated on -// `committed > 0`, so -1 is silently skipped there. +// 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 -1 + return 0 } // GlobalRange is a half-open [First, Next) interval; the highest // committed block number is Next-1. diff --git a/sei-tendermint/internal/p2p/giga_router_test.go b/sei-tendermint/internal/p2p/giga_router_test.go index a238e6a4d8..2e5a390416 100644 --- a/sei-tendermint/internal/p2p/giga_router_test.go +++ b/sei-tendermint/internal/p2p/giga_router_test.go @@ -599,7 +599,7 @@ func TestGigaRouter_RPCOnly(t *testing.T) { require.Equal(t, expectedRemoteURLs, returnedRemoteURLs) // Read-path methods: must fail cleanly rather than nil-deref on r.data. - require.Equal(t, int64(-1), router.LastCommittedBlockNumber()) + 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)