evmrpc: return null on above-watermark for spec-compliant endpoints#3530
Conversation
Endpoints that take a block identifier and return JSON null for missing
blocks per Ethereum JSON-RPC spec currently surface the watermark race
("requested height N is not yet available; safe latest is N-1") as an
error to clients. Wallets, indexers, and similar tools see a transient
failure where they expected null, exactly when a tx has just been mined
and the safe-latest watermark hasn't caught up yet.
#3119 fixed eth_getBlockByNumber and #3501 fixed eth_getTransactionReceipt
the same way. This generalizes the pattern via two helpers
(blockByNumberOrNullForJSONRPC, blockByHashOrNullForJSONRPC) and applies
the spec-aligned conversion to the remaining user-facing endpoints:
eth_getBlockReceipts
eth_getBlockTransactionCountByNumber
eth_getBlockTransactionCountByHash
eth_getBlockByHash (and sei_getBlockByHashExcludeTraceFail)
eth_getTransactionByBlockNumberAndIndex
eth_getTransactionByBlockHashAndIndex
eth_getTransactionByHash
State queries (GetBalance/Code/StorageAt) and simulation/filter/internal
paths keep using blockByNumberRespectingWatermarks directly — they have
different semantics (reject invalid heights, internal validation).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR SummaryMedium Risk Overview It adds State/simulation paths still use Reviewed by Cursor Bugbot for commit 5d0bccb. Bugbot is set up for automated code reviews on this repo. Configure here. |
|
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 #3530 +/- ##
==========================================
- Coverage 59.12% 58.28% -0.84%
==========================================
Files 2213 2140 -73
Lines 182774 174431 -8343
==========================================
- Hits 108072 101675 -6397
+ Misses 64985 63717 -1268
+ Partials 9717 9039 -678
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
Per cursor bugbot: GetBlockReceipts(blockHash) resolves the hash via GetBlockNumberByNrOrHash, which internally calls the original blockByHashRespectingWatermarks (not the new null-converting helper). An above-watermark hash returns ErrBlockHeightNotYetAvailable from that lookup, which propagates as an RPC error before the second blockByNumberOrNullForJSONRPC call is reached. Treat ErrBlockHeightNotYetAvailable from GetBlockNumberByNrOrHash the same as ErrBlockNotFoundByHash (return null per Ethereum JSON-RPC spec). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per cursor bugbot: the by-hash helper only converted ErrBlockHeightNotYetAvailable; ErrBlockNotFoundByHash (genuinely unknown hash) still propagated as an RPC error from GetBlockTransactionCountByHash and GetTransactionByBlockHashAndIndex. Both forms are "block doesn't exist from the caller's perspective" and the Ethereum JSON-RPC spec maps both to null. Extend the helper to convert both, then drop the now-redundant explicit ErrBlockNotFoundByHash check from getBlockByHash so all by-hash endpoints share uniform spec-aligned semantics through the helper. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per review: GetBlockReceipts had duplicated watermark handling — once via GetBlockNumberByNrOrHash and once via blockByNumberOrNullForJSONRPC. Push the "block doesn't exist" mapping (both unknown-hash and above- watermark) into GetBlockNumberByNrOrHash so callers serving Ethereum JSON-RPC endpoints can detect "null result" via a single heightPtr==nil check instead of two errors.Is branches. Single source of truth for the conversion; behavior is equivalent across all scenarios (verified by the existing height-availability + receipts tests). 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 using default effort 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 06389ea. Configure here.
…tests Per review: - getBlockByNumber (was #3119) and getTransactionReceipt (was #3501) had inline `if errors.Is(err, ErrBlockHeightNotYetAvailable) { return nil, nil }` predating this PR's helpers. Route both through blockByNumberOrNullForJSONRPC so all spec-compliant conversion lives in one place. Behavior verified equivalent by the existing endpoint tests (TestGetBlockByNumber*, TestGetTransactionReceiptReturnsNullAboveWatermark). - Add direct unit tests covering each helper's branches (above-watermark, in-range, sentinel-not-found-by-hash, non-watermark error propagation). The Block:nil → ErrBlockNotFoundByHash path was previously not exercised by any test (MockClient returns a plain string error instead of the sentinel), so this closes that gap. `errors` import removed from block.go (no remaining callers after this consolidation; getBlockByHash still uses errors elsewhere... actually no it doesn't either after the prior commit, hence the removal). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The centralization in 06389ea made GetBlockNumberByNrOrHash return (nil, nil) for unknown hashes, but the function already returned (nil, nil) for the "latest"/"safe"/"finalized"/"pending" named tags (via getBlockNumber) — meaning "caller should resolve to latest height". Overloading nil broke eth_getBlockReceipts("latest"): the new "if heightPtr == nil { return nil, nil }" guard intercepted the tag-means-latest case and returned JSON null instead of fetching the latest block's receipts. Disentangle: - Revert GetBlockNumberByNrOrHash to its pre-PR form (errors for unknown-hash / above-watermark). - GetBlockReceipts inlines hash-vs-number dispatch via the helpers directly; numberPtr=nil flows into blockByNumberOrNullForJSONRPC which routes it through wm.LatestHeight as intended. Regression test TestBlockAPILatestTagResolves exercises all four named tags against GetBlockReceipts and GetBlockTransactionCountByNumber. Other tag-accepting endpoints (getBlockByNumber, getTransactionByBlockNumberAndIndex) use the identical (getBlockNumber → helper → if block == nil) pattern and are safe by inspection — they don't short-circuit on numberPtr==nil between the two calls. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three review-cleanup items in this commit:
1. MockClient.BlockByHash returned (nil, errors.New("not found")) for
the test sentinel hash 0xbbbb..., whereas real Tendermint returns
(ResultBlock{Block: nil}, nil) which blockByHashWithRetry wraps as
ErrBlockNotFoundByHash. Tests against this mock exercised a different
code path from production. Mock now matches production, and
TestGetTransactionByBlockHashAndIndexErrors flips the unknown-hash
assertion from "expect error" to "expect null result" — same Ethereum
JSON-RPC spec contract as the surrounding tests.
2. GetBlockReceipts dispatch tightened: single err declaration, no
shadow-and-assign through a temporary b variable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Summary
Several Ethereum JSON-RPC endpoints that take a block identifier currently surface the watermark race (
"requested height N is not yet available; safe latest is N-1") as an error, where the Ethereum JSON-RPC spec says they should return JSONnull(block doesn't exist yet from the caller's perspective). Wallets, indexers, and similar tools see this as a transient failure exactly when a tx has just been mined and the safe-latest watermark hasn't caught up.#3119 fixed
eth_getBlockByNumberand #3501 fixedeth_getTransactionReceiptwith the sameif errors.Is(err, ErrBlockHeightNotYetAvailable) { return nil, nil }pattern. This PR generalizes the pattern into two helpers and applies it to the remaining user‑facing endpoints.Endpoints converted
eth_getBlockReceiptsblock.goeth_getBlockTransactionCountByNumberblock.goeth_getBlockTransactionCountByHashblock.goeth_getBlockByHash+sei_getBlockByHashExcludeTraceFail(sharedgetBlockByHash)block.goeth_getTransactionByBlockNumberAndIndextx.goeth_getTransactionByBlockHashAndIndextx.goeth_getTransactionByHashtx.goHistoric context (why propagation was the previous default)
The watermark integration (#2465) deliberately propagated the error so clients would know "data not yet ready — retry later" rather than getting a misleading null. That was the original conservative default across all evmrpc endpoints that touched a height.
Since then, subsequent PRs have progressively chosen JSON
nullper Ethereum spec on a per‑endpoint basis:eth_getBlockByHash(unknown hash)eth_getBlockByNumber(above watermark)eth_getTransactionByBlock*AndIndex(out‑of‑range index)eth_getTransactionReceipt(above watermark)So the propagation on these 7 endpoints wasn't a deliberate "this endpoint should error" choice — it's historical inertia from #2465 that subsequent PRs have been chipping away one endpoint at a time. #3530 finishes the same job; nothing about the original watermark intent is being reversed where it still serves users (see out of scope below).
Out of scope
State queries (
eth_getBalance/Code/StorageAt/Proof), simulation paths, filter internals, andinfo.go/association.gohelpers keep usingblockByNumberRespectingWatermarksdirectly. These are where the original #2465 design still applies:balance: 0for a not‑yet‑ready block would mislead users.Tests covering those error paths (
TestStateAPIGetProofUnavailableHeight, the filter‑cache test) are intentionally unchanged.Pruned-block path also unchanged. Explicit numeric requests for heights below the earliest watermark (
height < earliest) still return"requested height N has been pruned". The helpers only convertErrBlockHeightNotYetAvailable(andErrBlockNotFoundByHashfor the by-hash variant). Pre-PR behavior is preserved here — pruned is a different (permanent, not transient) failure class from the watermark race this PR addresses, and changing it would touch a different set of test assertions (watermark_manager_test.go:106,135). Hash-based requests for pruned blocks already return null in production because Tendermint returnsBlock: nilfor unknown hashes, whichblockByHashWithRetrywraps asErrBlockNotFoundByHash.Helpers
Internal call sites that want the error keep using the originals.
Operational impact (broader than tests)
This is a production-correctness fix. Currently a wallet polling for a freshly-mined tx, or an indexer doing
eth_getBlockReceiptsnear the chain head, can intermittently get a"block height not yet available"error response instead ofnull— they then surface that as a transient failure to users. With this change, those endpoints behave per Ethereum JSON-RPC spec.Test plan
go test ./evmrpc/ -count=1passes (21s, includes the updated above-watermark tests).golangci-lint run ./evmrpc/: 0 issues.gofmt -s -lclean.🤖 Generated with Claude Code