Wire eth_subscribe(newHeads) for Autobahn (CON-257)#3419
Conversation
|
The latest Buf updates on your PR. Results from workflow Buf / buf (pull_request).
|
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #3419 +/- ##
==========================================
+ Coverage 59.31% 59.34% +0.03%
==========================================
Files 2120 2121 +1
Lines 175523 175709 +186
==========================================
+ Hits 104106 104281 +175
+ Misses 62338 62334 -4
- Partials 9079 9094 +15
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
encodeCommittedBlock was mirroring the legacy encodeTmHeader, which emits both the typo'd "withdrawlsRoot" and the correctly-spelled "withdrawalsRoot". Since the Autobahn encoder is new code there is no back-compat reason to carry the typo; legacy encodeTmHeader is left untouched. Reported by Cursor Bugbot on #3419. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ResponseFinalizeBlock.ConsensusParamUpdates is only populated when the app proposes a consensus-param update — nil on the vast majority of blocks. Reading it for newHeads gasLimit means the reported gasLimit would be 0 for nearly every notification under Autobahn. Match the pattern already used in evmrpc/block.go's GetBlockByNumber: pull gasLimit from ctx.ConsensusParams() in the consumer goroutine and pass it as a parameter into encodeCommittedBlock. Reported by Cursor Bugbot on #3419 (high severity). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 84cd2e6. Configure here.
encodeCommittedBlock was mirroring the legacy encodeTmHeader, which emits both the typo'd "withdrawlsRoot" and the correctly-spelled "withdrawalsRoot". Since the Autobahn encoder is new code there is no back-compat reason to carry the typo; legacy encodeTmHeader is left untouched. Reported by Cursor Bugbot on #3419. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ResponseFinalizeBlock.ConsensusParamUpdates is only populated when the app proposes a consensus-param update — nil on the vast majority of blocks. Reading it for newHeads gasLimit means the reported gasLimit would be 0 for nearly every notification under Autobahn. Match the pattern already used in evmrpc/block.go's GetBlockByNumber: pull gasLimit from ctx.ConsensusParams() in the consumer goroutine and pass it as a parameter into encodeCommittedBlock. Reported by Cursor Bugbot on #3419 (high severity). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ExecTxResult has both a GasWanted and a GasUsed field with different semantics. The variable was accumulating GasUsed but named "gasWanted", inviting a future field-swap regression. Rename for clarity. Legacy encodeTmHeader carries the same anti-pattern but is left alone. Reported by Cursor Bugbot on #3419 (low severity). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
073496b to
c8c0d46
Compare
The five `if err := ...` blocks in TestEthSubscribeNewHeads each shadowed the outer err from the initial Dial call. Reuse with `err =` for clarity (the outer err is already in scope and gets overwritten on each call anyway). Reported by @amir-deris on #3419. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
| ) (service.Service, error) { | ||
| // Capture any optional BlockHeaderListener implementation before | ||
| // wrapping the app with the ABCI proxy, which only forwards ABCI | ||
| // methods. |
There was a problem hiding this comment.
this looks bad - either add a method returning BlockHeaderListener to Application interface, or add a separate argument to New
There was a problem hiding this comment.
I feel like adding OnBlockCommitted to Application should be good enough
There was a problem hiding this comment.
Adding OnBlockCommitted would require us to touch every App impl.
But I got your point. Thought about it more and decided to override Commit in App to push, see if this looks better? We don't need the duct tape any more.
Under Autobahn, app.FinalizeBlock is driven by giga_router rather than
CometBFT consensus, so the legacy Tendermint event-bus subscription
that backs eth_subscribe("newHeads") never fires. This adds a direct
in-process notifier from giga_router's commit path into evmrpc's
SubscriptionAPI fan-out (overwrite-on-full, capacity 1, so the latest
head always wins if a consumer lags). The legacy path is untouched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
encodeCommittedBlock was mirroring the legacy encodeTmHeader, which emits both the typo'd "withdrawlsRoot" and the correctly-spelled "withdrawalsRoot". Since the Autobahn encoder is new code there is no back-compat reason to carry the typo; legacy encodeTmHeader is left untouched. Reported by Cursor Bugbot on #3419. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Tighten hash field doc in blockHeaderEvent: receipt store records zero blockHash on disk and evmrpc overlays the autobahn hash at read time. The prior wording implied the receipt store stored it directly. - Clarify single-producer assumption in OnBlockCommitted and document multi-producer behaviour (still safe, latest-wins is acceptable). - Move SubscriptionManager construction inside the legacy event-bus branch in NewSubscriptionAPI so the field is nil under Autobahn instead of dead state. - Document the non-nil header/response contract on BlockHeaderListener, and add a defensive guard in runNewHeadsFromNotifier so a single malformed event does not kill the fan-out goroutine for all subscribers. - Annotate the listener call site in giga_router.executeBlock so the fire-after-Commit-before-mempool-update ordering is explicit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ResponseFinalizeBlock.ConsensusParamUpdates is only populated when the app proposes a consensus-param update — nil on the vast majority of blocks. Reading it for newHeads gasLimit means the reported gasLimit would be 0 for nearly every notification under Autobahn. Match the pattern already used in evmrpc/block.go's GetBlockByNumber: pull gasLimit from ctx.ConsensusParams() in the consumer goroutine and pass it as a parameter into encodeCommittedBlock. Reported by Cursor Bugbot on #3419 (high severity). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GetNextBaseFeePerGas(ctx_at_N) returns the base fee for block N+1, not block N. The previous code passed the current block's ctx, so the newHeads notification for block N reported what should have been attached to N+1. Match the pattern from block.go's GetBlockByNumber: derive baseFee from the parent block's ctx, with a genesis (height==1) fallback to DefaultMinFeePerGas. The legacy CometBFT path has the same bug but is left alone — the more important consistency to restore is between eth_subscribe(newHeads) and eth_getBlockByNumber on the same Autobahn cluster. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ExecTxResult has both a GasWanted and a GasUsed field with different semantics. The variable was accumulating GasUsed but named "gasWanted", inviting a future field-swap regression. Rename for clarity. Legacy encodeTmHeader carries the same anti-pattern but is left alone. Reported by Cursor Bugbot on #3419 (low severity). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The five `if err := ...` blocks in TestEthSubscribeNewHeads each shadowed the outer err from the initial Dial call. Reuse with `err =` for clarity (the outer err is already in scope and gets overwritten on each call anyway). Reported by @amir-deris on #3419. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The base-fee selection in runNewHeadsFromNotifier was the actual location of the off-by-one fix (parent ctx vs current ctx) but had no direct test coverage — encodeCommittedBlock takes baseFee as an arg, so testing it only proved the encoder forwards the value it's given, not that the caller picks the right ctx. Extract pickHeadBaseFee as a free function that takes getNextBaseFee as a function pointer (rather than reaching into *keeper.Keeper), then spy on the ctxProvider in tests to verify: - TestPickHeadBaseFee_UsesParentCtx: ctxProvider called with height-1 (NOT height), and result is forwarded. - TestPickHeadBaseFee_GenesisFallback: at height 1 the keeper call is skipped entirely and DefaultMinFeePerGas is returned. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…er in Option Header.AppHash by Tendermint convention holds the previous block's app hash. The OnBlockCommitted path was setting it to the current block's resp.AppHash, which is wrong. Construct the header once, leave AppHash unset, and have evmrpc's encodeCommittedBlock read stateRoot from response.AppHash (the current block's hash, which is what subscribers actually want). Also wrap GigaRouterConfig.BlockHeaderListener and the createRouter / makeNode parameters in utils.Option to make the optionality explicit at every call site (matches existing Option usage for validatorKey, txMempool, etc.).
aa7e676 to
e9eb7d3
Compare
The previous design routed eth_newHeads notifications through a BlockHeaderListener interface that giga_router invoked after Commit. That required a hidden type-assertion in node.New (to discover whether the app implemented the listener) and threaded the listener through New/makeNode/createRouter/gigaCfg. ABCI already provides exactly this signal: FinalizeBlock gives the app the hash, header, and the response it produces; Commit tells the app the block is durable. So the app can stash FinalizeBlock outputs and publish to the notifier from its own Commit override — no new interface, no parameter plumbing, no duck typing. - Delete sei-tendermint/types/block_listener.go - Delete BlockHeaderListener field from GigaRouterConfig - Delete listener invocation from GigaRouter.executeBlock (header dedup retained; Header.AppHash still unset per Tendermint convention) - Delete listener parameter from node.New / makeNode / createRouter - Delete the type-assertion capture from node/public.go - App.stashPendingHead at FinalizeBlocker success returns; publish inside existing App.Commit override after BaseApp.Commit succeeds
App.Commit publishes whatever sits in pendingHeadEvent. Today the field is only set by stashPendingHead at successful FinalizeBlocker returns and only cleared by Commit after publishing, so the lifecycle relies on every FinalizeBlocker return path either stashing or being preceded by a clearing Commit. That holds in production but is thin: an EthReplay or EthBlockTest early-return doesn't stash, and a failed Commit doesn't clear, so a future code path could republish a stale tuple on a later block. Clear the field at FinalizeBlocker entry so the stash always reflects only the current block's outputs (or nothing). Document the serialization invariant on the field. Add a test that pins the behavior via the optimistic + EthReplay early-return path.
| "extraData": hexutil.Bytes{}, // inapplicable to Sei | ||
| "gasLimit": hexutil.Uint64(gasLimit), //nolint:gosec | ||
| "gasUsed": hexutil.Uint64(totalGasUsed), //nolint:gosec | ||
| "logsBloom": ethtypes.Bloom{}, // inapplicable to Sei |
There was a problem hiding this comment.
We are planning to fix this in the future right?
| appHash := common.BytesToHash(evt.response.AppHash) | ||
| var totalGasUsed int64 | ||
| for _, txRes := range evt.response.TxResults { | ||
| totalGasUsed += txRes.GasUsed |
There was a problem hiding this comment.
Is gas used from tx result always right? Thinking of specific edgecases here
There was a problem hiding this comment.
hmm, good catch, but this is too large to fix in this PR, added a TODO as well
Two known gaps in the Autobahn newHeads payload: - logsBloom is returned as zero, but the field IS applicable to Sei (block.go ORs receipt blooms for full block reads). Replace the misleading "inapplicable to Sei" comment with a TODO. - gasUsed sums TxResult.GasUsed, which is inaccurate for ante-failing EVM txs; block.go reads from the receipt store instead. Document the approximation; subscribers needing exact numbers can call eth_getBlockByNumber.
PR SummaryMedium Risk Overview
Reviewed by Cursor Bugbot for commit 062f0c3. Bugbot is set up for automated code reviews on this repo. Configure here. |
| if err != nil { | ||
| panic(fmt.Sprintf("error reading EVM config due to %s", err)) | ||
| } | ||
| if tmConfig != nil && tmConfig.AutobahnConfigFile != "" { |
There was a problem hiding this comment.
this is rather ugly that App needs to understand that it will interact with autobahn vs tendermint. Can we just make it always work instead?
There was a problem hiding this comment.
You mean have non-Autobahn also ditch event bus and move here? It's possible but it might bring in semantics change, which is why I'm a bit hesitant. In this current code, there are a few header fields we haven't figured out how to properly populate yet.
Can I add a TODO for now, if we figure out it's semantically equivalent, then switch non-Autobahn?
…rified Pompon0 flagged that the app.go mode-gate is a smell — the App shouldn't need to know whether it's interacting with Autobahn or CometBFT. Agreed, but unifying requires reconciling the two encoders first: legacy encodeTmHeader populates parentHash/receiptsRoot/transactionsRoot from the real Tendermint Header and uses pre-execution stateRoot, while encodeCommittedBlock zeroes those fields and uses post-execution stateRoot. Switching legacy to the notifier path without that reconciliation would silently change non-Autobahn newHeads semantics. Add a TODO at the gate site describing what needs to be verified before removing it. Defer the actual refactor to a follow-up PR. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Summary
eth_subscribe("newHeads")is currently broken: the legacy fan-out subscribes to the Tendermint event bus ontm.event = 'NewBlockHeader', but Autobahn drivesapp.FinalizeBlockfromgiga_routerrather than CometBFT consensus, soPublishEventNewBlockHeadernever fires.BlockHeaderNotifier) consumed byevmrpc.SubscriptionAPI's fan-out goroutine. The notifier is fed fromApp.Commititself —FinalizeBlockerstashes(hash, header, response)andApp.Commitpublishes after the underlyingBaseApp.Commitsucceeds. The legacy CometBFT path is untouched (notifier is only constructed whentmConfig.AutobahnConfigFile != "").newHeadssubscribers care about the latest head, not a backlog, so stale heads are dropped in favor of new ones.FinalizeBlock+Commit);giga_routerstays a pure ABCI consumer.Test plan
gofmt -s -lclean on all modified files;go build ./...clean.go test ./evmrpc/ ./sei-tendermint/internal/p2p/ ./sei-tendermint/internal/proxy/ ./sei-cosmos/baseapp/ ./app/— all PASS.TestSubscribeNewHeads(legacy event-bus path) — unchanged behaviour.TestSubscribeNewHeadsAutobahn(notifier path, in-process WS server end-to-end).BlockHeaderNotifier(deliver, overwrite-on-full, nil-safe) andencodeCommittedBlock(happy path + zero gasLimit).pickHeadBaseFeeoff-by-one coverage.evm_rpc_tests.sh(HTTP-method fixtures) against a real non-Autobahn cluster — 160/160 pass, no regressions on the request/response RPC surface.integration_test/evm_module/ws_test/— consensus-mode-agnostic, gated onSEI_EVM_WS_RUN_INTEGRATION=1, wired intoevm_rpc_tests.sh. End-to-end against both cluster modes:number=0x2b, hash0xe0dd089b...)number=0xf5, hash0xc4d1bc59...,GigaRouter initializedconfirmed in all 4 node logs)non-app-hash-breakinglabel is correct — this PR only adds a notification side-effect after commit; no state machine or app-hash-relevant logic changes.🤖 Generated with Claude Code
Note
Medium Risk
Touches the block execution lifecycle by stashing FinalizeBlock data and publishing from
App.Commit, which could affect head notification correctness/timing if mis-ordered or if Commit error paths differ. WS subscription behavior now has two code paths (event bus vs notifier) and relies on correct base-fee/gas-limit derivation for the new notifier stream.Overview
Fixes
eth_subscribe("newHeads")under Autobahn by adding an in-processBlockHeaderNotifierthat bypasses the Tendermint event bus.FinalizeBlockernow stashes the (hash, header, finalize response) tuple andApp.Commitpublishes it only after a successful commit; stale stashes are defensively cleared to prevent announcing the wrong block. The WS server/SubscriptionAPIis extended to consume either the legacy event-bus stream or the new notifier stream (Autobahn), including a dedicatedencodeCommittedBlockpayload builder and a base-fee selection fix using the parent height.Adds unit + integration coverage for notifier overwrite semantics, committed-head encoding, stale-stash clearing, and an end-to-end WS
newHeadstest for the notifier-backed path; integration scripts now run WS subscription tests.Reviewed by Cursor Bugbot for commit fab0c5b. Bugbot is set up for automated code reviews on this repo. Configure here.