rpc: implement eth_capabilities method#20951
Conversation
Implements eth_capabilities per ethereum/execution-apis#755. Returns per-category data availability (state, tx, logs, receipts, blocks, stateproofs) with oldestBlock computed from the node's prune mode: - archive: all fields from block 0 - full: state/logs/receipts from head-pruneDistance, tx/blocks from 0 - minimal: all fields from head-pruneDistance - stateproofs: disabled unless --prune.include-commitment-history is set Also caches the commitment-history-enabled flag (written once at startup) in BaseAPI and Generator to avoid a DB read per call, and migrates eth_receipts.go and eth_simulation.go to use the new cached helper. Closes #19762 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Remove the cached atomic.Pointer[bool] field from Generator and the internal rawdb.ReadDBCommitmentHistoryEnabled call inside GetReceipts. Callers with BaseAPI access use the existing atomic cache via api.commitmentHistoryEnabled(tx); other callers read from the DB directly. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
yperbasis
left a comment
There was a problem hiding this comment.
Overview
Implements eth_capabilities per execution-apis #755. Also refactors commitmentHistoryEnabled reads: the flag is lifted into a single param threaded through Generator.GetReceipts, with a cached accessor on BaseAPI. Signature change is propagated through all call sites.
Verified locally: builds cleanly, TestCapabilities passes.
Major — spec compliance
The spec at #755 has evolved since the linked URL; I pulled the current schema and the canonical fixture (commit 9cb5230). The shape in the spec example:
{
"head": {"number": "0x...", "hash": "0x..."},
"state": {"disabled": false, "oldestBlock": "0x...", "deleteStrategy": {"type": "window", "retentionBlocks": 90000}},
...
"blocks": {"disabled": false, "oldestBlock": "0x0"}, // archive — no deleteStrategy
"stateproofs": {"disabled": true} // disabled — nothing else
}
headis missing. The schema (src/schemas/capabilities.yaml) listsheadas required alongsidestate, tx, logs, receipts, blocks, stateproofs. Needs to be added —{number: hexutil.Uint64, hash: common.Hash}populated from the canonical head.deleteStrategyis missing. Optional per schema, but it's the field that lets routers distinguish "kept forever" (omitted) from "sliding window of N blocks" ({type: \"window\", retentionBlocks: N}). Without it a router can't tell archive from full from minimal. Mapprune.Distancedistances toretentionBlocks; omitdeleteStrategyforDefaultBlocksPruneMode/KeepAllBlocksPruneMode.
Major — incorrect tx/blocks for chains with history expiry
rpc/jsonrpc/eth_system.go:93:
blocksOldest := pruneMode.Blocks.PruneTo(headBlock)For prune.FullMode, Blocks == DefaultBlocksPruneMode (= math.MaxUint64), so PruneTo returns 0 — and the PR advertises tx.oldestBlock = 0 and blocks.oldestBlock = 0. But DefaultBlocksPruneMode actually means "use chain-specific history expiry," and the existing consumers handle the merge cutoff explicitly:
db/snapshotsync/snapshotsync.go:264for receipts:if pruneMode.Blocks == prune.DefaultBlocksPruneMode && cc.MergeHeight != nil { pruneHeight = *cc.MergeHeight }db/snapshotsync/snapshotsync.go:244isTransactionsSegmentExpired: pre-merge transaction segments are never downloaded underDefaultBlocksPruneMode.
So on mainnet / sepolia / gnosis / bloatnet (all have mergeBlock in their chainspecs) running --prune.mode=full, eth_getTransactionByHash for a pre-merge tx will fail, but eth_capabilities will advertise tx.oldestBlock = 0x0. The advertised range and the actually-served range disagree — exactly what this API is designed to prevent.
Suggested fix (and add a test that uses a chainspec with MergeHeight set):
blocksOldest := pruneMode.Blocks.PruneTo(headBlock)
if pruneMode.Blocks == prune.DefaultBlocksPruneMode && chainConfig.MergeHeight != nil {
blocksOldest = *chainConfig.MergeHeight
}You'll need chainConfig in Capabilities; api.chainConfig(ctx, tx) returns it.
Minor
AnswerGetReceiptsQueryreads the flag even on full cache hits (p2p/protocols/eth/handlers.go:375). Previously the read was insideGenerator.GetReceiptsafter thereceiptsCache.Getshort-circuit, so cache hits avoided the DB. NowReadDBCommitmentHistoryEnabled(db)runs unconditionally before the loop. Single key lookup per query (not per block), so impact is small.BaseAPI.commitmentHistoryEnableddoesn't cachefalsefrom an absent key. Intentional per the comment, butpruneMode(mirrored pattern) caches whateverprune.Getreturns even when keys are empty. Fine in production (key is always written at startup bycheckAndSetCommitmentHistoryFlag), but worth a one-line acknowledgment that this can briefly hit the DB per request during the boot window.- Misleading comment at
eth_system.go:90: "PruneTo returns 0 when the distance is MaxUint64 (archive/full-blocks)".PruneToreturns 0 wheneverdistance > stageHead, not specifically forMaxUint64. Alsoarchiveandfull-blocksuse different sentinels (KeepAllBlocksPruneMode=MaxUint64-1vsDefaultBlocksPruneMode=MaxUint64) — which is exactly the distinction the history-expiry issue turns on. - Test coverage gap:
TestCapabilitiesuseschain.TestChainBerlinConfig(noMergeHeight), so the history-expiry branch isn't exercised. Add a sub-test with a chainspec that setsMergeHeightonce the fix above lands.
Other notes
- Spec is still OPEN (not merged). Two member approvals so far, but the schema can still move — the recent comment thread already added
head, renamedtrienodes→stateproofs, madeoldestBlockoptional, and removeddeleteStrategy: none. Worth keeping a close eye on it before merging the Erigon side. RPC_VERSIONbump fromv2.9.0→v2.10.0: presumes a matching tag exists inerigontech/rpc-testscontaining fixtures foreth_capabilities. Worth confirming that tag is published, otherwise QA RPC tests will fail to fetch.
Risk summary
| Risk | |
|---|---|
| Correctness | Med — tx/blocks wrong for full mode on chains with MergeHeight |
| Spec compliance | Med — missing required head; missing deleteStrategy |
| Performance | Low — one extra DB read per p2p receipts query |
| API stability | Low — spec PR may still change before merge |
Recommendation
Add head (required), add deleteStrategy, and fix the history-expiry case for tx/blocks (with a chainspec-with-MergeHeight test). The rest is solid — interface threading is clean, tests cover the prune-mode matrix, caching pattern mirrors existing code well.
There was a problem hiding this comment.
Pull request overview
Adds support for the eth_capabilities RPC method and refactors receipt-generation call paths to avoid repeated DB reads for the commitment-history flag.
Changes:
- Implement
eth_capabilitiesand add JSON response types for per-category availability ranges. - Cache the commitment-history-enabled flag in
BaseAPIand thread it into receipt generation. - Update receipt getter interfaces/callers (JSON-RPC, P2P, tests) for the new
GetReceiptssignature.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| rpc/jsonrpc/receipts/receipts_generator.go | Passes commitment-history flag into receipt generation instead of reading it internally. |
| rpc/jsonrpc/receipts/handler_test.go | Updates receipt generator usage to new GetReceipts signature. |
| rpc/jsonrpc/eth_system.go | Implements eth_capabilities and defines response structs. |
| rpc/jsonrpc/eth_system_test.go | Adds test coverage for eth_capabilities across prune modes and commitment-history flag. |
| rpc/jsonrpc/eth_simulation.go | Uses cached commitmentHistoryEnabled accessor instead of direct DB read. |
| rpc/jsonrpc/eth_receipts.go | Uses cached commitmentHistoryEnabled accessor and passes it into receipt generation. |
| rpc/jsonrpc/eth_api.go | Extends EthAPI interface; adds cached commitmentHistoryEnabled to BaseAPI. |
| p2p/protocols/eth/handlers.go | Updates ReceiptsGetter interface and threads commitment-history flag once per request. |
| p2p/protocols/eth/handlers_test.go | Updates mock ReceiptsGetter to match new interface. |
| execution/tests/blockchain_test.go | Updates receipt reading helper to pass commitment-history flag. |
| execution/abi/bind/backends/simulated.go | Updates simulated backend receipt path to pass commitment-history flag. |
| .github/workflows/scripts/rpc_version.env | Bumps RPC API version to v2.10.0. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
The execution-apis spec (PR #755) marks head as required alongside the data-category fields. Populate it with the canonical chain tip (number + hash) at call time. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…Height On chains with MergeHeight set (mainnet, sepolia, gnosis) running full prune mode, DefaultBlocksPruneMode causes PruneTo to return 0, but pre-merge block/tx segments are never downloaded. Advertise MergeHeight as the oldest available block instead of 0. Adds a full_merge_height sub-test to TestCapabilities to cover this path. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The old comment "MaxUint64 = keeps all block snapshots" was wrong: DefaultBlocksPruneMode uses chain-specific history expiry and does not preserve pre-merge blocks on merge chains. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ReadDBCommitmentHistoryEnabled was called unconditionally before the fetch loop, paying a DB round-trip even when the entire query was already cached (pendingIndex == len(query)). Guard it behind a pendingIndex < len(query) check so cache hits avoid the read. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Unlike pruneMode, false is intentionally not cached when the DB key is absent: during the boot window before checkAndSetCommitmentHistoryFlag runs, caching false would shadow a subsequent true write. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add optional deleteStrategy to CapabilityField: when a category uses
a finite prune window (Distance), set {type:"window", retentionBlocks:N}
so routers can distinguish archive/full/minimal modes. Omit the field
for KeepAllBlocksPruneMode and DefaultBlocksPruneMode which do not use
a fixed retention window.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace raw "window" string with a named constant and compute avail(stateOldest, history) once for state/logs/receipts instead of three identical calls. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ify comments When --persist.receipts is enabled, receipts are stored from genesis so receipts.oldestBlock should be 0, not the state history prune window. Check kvcfg.PersistReceipts.Enabled and report accordingly. Also clarify two misleading comments: - PruneTo comment now distinguishes KeepAllBlocksPruneMode (keep all) from DefaultBlocksPruneMode (chain-specific history expiry). - Test comment no longer implies DefaultBlocksPruneMode keeps all block snapshots unconditionally. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
@yperbasis All the points from your review have been addressed. |
yperbasis
left a comment
There was a problem hiding this comment.
A few items I'd like to see addressed:
Correctness / spec conformance
1. logs ignores --persist.receipts. Logs is always pinned to stateField, but when persistReceipts is on, logs (which are stored as part of each receipt) are effectively available from genesis too. Clients reading eth_capabilities will skip queries the node can actually answer. At minimum Logs should match Receipts when persistReceipts is on.
2. CapabilityField.Disabled always serializes as \"disabled\": false. The spec marks disabled as optional (present only when true). Suggest:
Disabled bool `json:\"disabled,omitempty\"`Otherwise the v2.10.1 fixtures may well fail on the disabled-absence assertions; even if they don't today, it's safer aligned with the spec.
3. JSON field name stateproofs (lowercase). Worth double-checking against the latest revision of execution-apis#755 — earlier drafts used stateProofs (camelCase). If the spec lands on camelCase this needs to change before clients integrate.
4. receipts.oldestBlock seam on merge chains in full mode (without --persist.receipts). Receipts re-execution requires both state history and the block to be present. The code returns stateField, which on a merge chain in full mode is head − pruneDistance. In steady state on mainnet this is fine (head − pruneDistance ≫ MergeHeight), but right at/after the merge it would over-promise. Either compute max(stateOldest, blocksOldest) or document the assumption.
Documentation gap
5. Missing entry in cmd/rpcdaemon/README.md. Erigon's canonical RPC method matrix lives there (the eth_* block around lines 254–326). New methods are conventionally added with an EIP/spec annotation in the notes column (precedent: eth_config → EIP-7910 on line 262, eth_getBlockAccessList → Added in Amsterdam (EIP-7928) on line 272). Please slot eth_capabilities in alongside eth_config with a link to execution-apis#755.
Tests
6. Coverage gaps worth filling. The mode × commitment matrix is good, but I'd like:
minimal_persist_receipts— provesReceipts.oldestBlock = 0overrides the prune window across all modes (not justfull).full_merge_heightwithpruneDistance > head − MergeHeight— exercises the receipts/logs seam from item 4 above.head = 0boundary — pins thatReadCanonicalHash(tx, 0)returns the genesis hash, not zero.
Plumbing nit (non-blocking)
7. Boolean threaded through ReceiptsGetter.GetReceipts. Every caller now has to remember the flag. Caching inside Generator itself (atomic bool, set on first successful read) would give the same per-process savings without widening the interface or touching simulated.go/blockchain_test.go/p2p. Not a blocker — but it would have been a smaller diff. If you keep the parameter, please at least consider an opts struct so future flags don't keep widening the signature.
Minor
- The asymmetry between
commitmentHistoryEnabled()(doesn't cache on `!ok`) andpruneMode()(caches default) is sensible but slightly surprising; a one-line comment inpruneMode()pointing at the boot-window rationale would help the next reader. - The
persistReceiptsOpts ...boolvariadic insetupAPIfor a single optional flag reads awkwardly — a named bool would be clearer.
Overall the implementation is solid and the cache plumbing is a nice incidental win; items 1 and 2 are the ones I'd consider blocking, the rest are smaller asks.
yperbasis
left a comment
There was a problem hiding this comment.
Thanks for the round-trip — the receipts seam, README entry, full_merge_height/head_zero tests, and persist-receipts handling are all in good shape. One blocker I created, plus a couple of follow-ups.
Blocker — omitempty on Disabled (my prior advice was wrong, mea culpa)
I asked for Disabled bool + "" + json:"disabled,omitempty" + "" + in the last round, claiming the spec marked it optional. That was incorrect — the spec schema (src/schemas/capabilities.yaml, commit 9cb5230d) lists disabledunderrequired`:
EthCapabilitiesEffectiveResource:
required:
- disabled
properties:
disabled:
type: booleanAnd the canonical fixture in execution-apis#755 has "disabled": false present in every non-disabled category. The Erigon QA fixture in erigontech/rpc-tests v2.10.1 (integration/mainnet/eth_capabilities/test_01.json) mirrors that:
"state": { "disabled": false, "oldestBlock": "0x0" },
"tx": { "disabled": false, "oldestBlock": "0x0" },
...
"stateproofs": { "disabled": true }The May 19 mainnet-rpc-integ-tests run on this branch (26121305203) failed on this exact test across http/http_comp/websocket — eth_capabilities/test_01.json failed: diff mismatch. Reproduce: the commit 1445da50 flipped json:"disabled" → json:"disabled,omitempty", and Go's encoder drops false fields with omitempty, so the daemon now emits "state":{"oldestBlock":"0x0"} while the fixture expects "state":{"disabled":false,"oldestBlock":"0x0"}.
Fix is one character — revert Disabled to json:"disabled", keep the ,omitempty on OldestBlock/DeleteStrategy so disabled-only categories still serialize as {"disabled":true}.
Aside: the fixture's metadata.ignoreFields: ["result.stateproofs", "result.head"] is documentation only — the v2.10.1 runner (integration/run_tests.py + src/rpctests/integration/compare.py) doesn't honor it. So result.head will also mismatch the static 0xd4e56740... once disabled is fixed. Either set the expected result to null (which should_ignore_diff does honor as a wildcard via expected_from_file), or have the fixture omit the dynamic fields. Worth a follow-up on the rpc-tests side; otherwise this test will start failing the moment the disabled fix lands.
Medium — logsField = stateField under-restricts when blocks are pruned tighter than state
The full_merge_height_receipts_seam test pins this behavior:
require.Equal(t, mergeAt, oldest(t, result.Receipts)) // 18 (correct — needs block body)
require.Equal(t, stateOldest, oldest(t, result.Logs)) // 10 (under-restricts)In Erigon (rpc/jsonrpc/eth_receipts.go:399-407), getLogsV3 looks up matches via LogTopicIdx/LogAddrIdx (state-history-bound), then calls _txnReader.TxnByIdxInBlock to fetch the txn for receipt re-generation. When block bodies are pruned in [stateOldest, blocksOldest), TxnByIdxInBlock returns nil and the loop continues — the index matches are silently dropped. So advertising logs.oldestBlock = stateOldest means clients querying that window get partial / incomplete logs without any error.
In mainnet steady-state this doesn't matter (head − pruneDistance ≫ MergeHeight, so the binding constraint is state and the two fields converge). It only bites right after the merge or in the test config. Still, this is exactly the routing-correctness contract eth_capabilities exists to enforce — recommend logsField = receiptsField in the non-persist branch, and update the test comment ("logs only require state history") which is what made me sign off last round.
Minor
persistReceiptsOpts ...boolinsetupAPI. I flagged this last round; still reads awkwardly for a single optional flag. A namedpersistReceipts boolparameter would be clearer (and removes thelen(persistReceiptsOpts) > 0 && persistReceiptsOpts[0]guard).- Duplication in
full_merge_height/full_merge_height_receipts_seam. ~30 lines of identical chain-with-merge-height setup. A helpersetupAPIWithMergeHeight(t, mergeAt, pruneMode, commitmentHistory)would compress both. copier.CopyWithOption(..., DeepCopy: true)onchain.Config. Fine — the codebase explicitly usesjinzhu/copierforchain.Config(see the comment atexecution/chain/chain_config.go:42: "Config must be copied only with jinzhu/copier since it contains a sync.Once.").
Risk summary
| Risk | |
|---|---|
| Correctness | Low — logs seam edge case only relevant post-merge / in tests |
| Spec compliance | High — current code emits invalid JSON per the schema, blocking the QA RPC test |
| API stability | Low — execution-apis#755 still open but the disabled requirement has been stable across all revisions I checked back to March |
Recommendation
Remove ,omitempty from Disabled, align logsField with receiptsField (with a comment about Erigon's getLogsV3 pruning behavior), update the seam-test assertion accordingly. Once disabled is fixed, separately address the head mismatch in the rpc-tests fixture (set the expected result to null, or omit the head field from the response object), otherwise the test will fail for a different reason. Apologies again for the round-trip — the v2.10.1 fixture failure is on me.
- Fix Disabled field: remove omitempty so disabled:false is always present (spec marks it required; its absence caused QA fixture failures) - Fix logs oldest-block: use receiptsField instead of stateField in the non-persist branch (getLogsV3 uses block bodies, not state history) - Fix persist-receipts+MergeHeight over-reporting: when DefaultBlocksPruneMode applies on a merge chain, oldest = mergeHeight rather than 0 - Fix overlay inconsistency: use overlayTx for both GetLatestBlockNumber and ReadCanonicalHash so headBlock and headHash come from the same view - Replace bare bool in ReceiptsGetter.GetReceipts with ReceiptsOpts struct to keep the interface stable as new options are added - Reduce test duplication with setupAPIWithMerge helper; add full_persist_receipts_merge_height sub-test Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
yperbasis
left a comment
There was a problem hiding this comment.
Blocker — retentionBlocks serialized as hex string, spec wants plain integer
rpc/jsonrpc/eth_system.go:55-57:
type DeleteStrategy struct {
Type string `json:"type"`
RetentionBlocks hexutil.Uint64 `json:"retentionBlocks"`
}hexutil.Uint64.MarshalText emits "0x..." (common/hexutil/json.go:118-124), so the daemon will return e.g. "retentionBlocks":"0x15f90". The spec schema (src/schemas/capabilities.yaml at commit 9cb5230d) declares:
retentionBlocks:
type: integer
minimum: 0…and both canonical fixtures (get-capabilities.io, get-capabilities-disabled-stateproofs.io) show plain numbers like "retentionBlocks":90000, "retentionBlocks":2350000. Routers validating against the schema will reject the Erigon response, and oldestBlock right next to it — correctly hex-encoded because the schema $refs uint — makes the inconsistency more glaring.
Note that the schema convention here is deliberate: oldestBlock is a chain quantity (hex) but retentionBlocks is a configuration scalar (decimal integer). Mixing them under hexutil.Uint64 collapses that distinction.
Fix:
type DeleteStrategy struct {
Type string `json:"type"`
RetentionBlocks uint64 `json:"retentionBlocks"`
}…and drop the cast at the single call site in Capabilities (rb := hexutil.Uint64(d) → rb := uint64(d)).
The reason this slipped through is that TestCapabilities only asserts on Go struct fields (f.DeleteStrategy.RetentionBlocks) and never round-trips through json.Marshal. Please add one wire-format assertion per major shape variant (window strategy, no strategy, disabled), e.g.:
raw, err := json.Marshal(result)
require.NoError(t, err)
require.Contains(t, string(raw), `"retentionBlocks":10`) // not "0xa"
require.Contains(t, string(raw), `"oldestBlock":"0x`) // hex
require.Contains(t, string(raw), `"disabled":false`) // always present
require.Contains(t, string(raw), `"stateproofs":{"disabled":true}`) // disabled shapeThis pins the wire format independent of struct definitions — would have caught both this issue and the earlier omitempty regression in 1445da50, and protects future refactors of CapabilityField.
Minor — Tx and Blocks reported identically without explanation
Tx: blocksField,
Blocks: blocksField,The spec schema models these as independent axes (the canonical fixture has tx with a window retention while blocks is full archive — clients that maintain a separate tx-lookup index can populate both fields meaningfully). For Erigon the conflation is correct because tx-by-hash availability is tied to block body availability (no independent tx-lookup-index pruning), but a one-line comment above the return makes this a deliberate Erigon-specific choice rather than an apparent oversight:
// In Erigon, tx-by-hash lookups go through block bodies — no independent tx index
// pruning — so tx availability == blocks availability.
Tx: blocksField,Nit — redundant eth package alias
rpc/jsonrpc/eth_receipts.go:26, rpc/jsonrpc/receipts/receipts_generator.go:39, execution/abi/bind/backends/simulated.go:60, execution/tests/blockchain_test.go:
eth "github.com/erigontech/erigon/p2p/protocols/eth"The package is already named eth (p2p/protocols/eth/*.go all declare package eth), so the leading eth is redundant — drop it.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Implements eth_capabilities per ethereum/execution-apis#755.
Returns per-category data availability (state, tx, logs, receipts, blocks, stateproofs) with oldestBlock computed from the node's prune mode:
Also caches the commitment-history-enabled flag (written once at startup) in BaseAPI to avoid a DB read per call
Closes #19762