From 43cae8b9e289e6d21aed4f762adf3553ddaa174a Mon Sep 17 00:00:00 2001 From: lupin012 <58134934+lupin012@users.noreply.github.com.> Date: Sat, 2 May 2026 10:07:17 +0200 Subject: [PATCH 01/16] rpc: implement eth_capabilities method Implements eth_capabilities per https://github.com/ethereum/execution-apis/pull/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 --- rpc/jsonrpc/eth_api.go | 27 +++- rpc/jsonrpc/eth_receipts.go | 3 +- rpc/jsonrpc/eth_simulation.go | 3 +- rpc/jsonrpc/eth_system.go | 71 ++++++++++ rpc/jsonrpc/eth_system_test.go | 146 +++++++++++++++++++++ rpc/jsonrpc/receipts/receipts_generator.go | 19 ++- 6 files changed, 260 insertions(+), 9 deletions(-) diff --git a/rpc/jsonrpc/eth_api.go b/rpc/jsonrpc/eth_api.go index df74cdc57ef..50adb4d5707 100644 --- a/rpc/jsonrpc/eth_api.go +++ b/rpc/jsonrpc/eth_api.go @@ -110,6 +110,7 @@ type EthAPI interface { ProtocolVersion(_ context.Context) (hexutil.Uint, error) GasPrice(_ context.Context) (*hexutil.Big, error) Config(ctx context.Context, timeArg *hexutil.Uint64) (*EthConfigResp, error) + Capabilities(ctx context.Context) (*CapabilitiesResult, error) // Sending related (see ./eth_call.go) Call(ctx context.Context, args ethapi.CallArgs, blockNrOrHash *rpc.BlockNumberOrHash, overrides *ethapi.StateOverrides, blockOverrides *ethapi.BlockOverrides) (hexutil.Bytes, error) @@ -139,10 +140,11 @@ type BaseAPI struct { stateCache kvcache.Cache blocksLRU *lru.Cache[common.Hash, *types.Block] - filters *rpchelper.Filters - _chainConfig atomic.Pointer[chain.Config] - _genesis atomic.Pointer[types.Block] - _pruneMode atomic.Pointer[prune.Mode] + filters *rpchelper.Filters + _chainConfig atomic.Pointer[chain.Config] + _genesis atomic.Pointer[types.Block] + _pruneMode atomic.Pointer[prune.Mode] + _commitmentHistoryEnabled atomic.Pointer[bool] _blockReader services.FullBlockReader _txNumReader rawdbv3.TxNumsReader @@ -424,6 +426,23 @@ func (api *BaseAPI) pruneMode(tx kv.Tx) (*prune.Mode, error) { return &mode, nil } +// commitmentHistoryEnabled returns whether --prune.include-commitment-history was set at node +// startup. The flag is written once and never changed, so the result is cached after first read. +// If the DB key is absent (node not yet initialised) the value is not cached and false is returned. +func (api *BaseAPI) commitmentHistoryEnabled(tx kv.Tx) (bool, error) { + if p := api._commitmentHistoryEnabled.Load(); p != nil { + return *p, nil + } + enabled, ok, err := rawdb.ReadDBCommitmentHistoryEnabled(tx) + if err != nil { + return false, err + } + if ok { + api._commitmentHistoryEnabled.Store(&enabled) + } + return enabled, nil +} + type bridgeReader interface { Events(ctx context.Context, blockHash common.Hash, blockNum uint64) ([]*types.Message, error) EventTxnLookup(ctx context.Context, borTxHash common.Hash) (uint64, bool, error) diff --git a/rpc/jsonrpc/eth_receipts.go b/rpc/jsonrpc/eth_receipts.go index 3544389a10a..d8ae2eb12f2 100644 --- a/rpc/jsonrpc/eth_receipts.go +++ b/rpc/jsonrpc/eth_receipts.go @@ -32,7 +32,6 @@ import ( "github.com/erigontech/erigon/db/kv/order" "github.com/erigontech/erigon/db/kv/rawdbv3" "github.com/erigontech/erigon/db/kv/stream" - "github.com/erigontech/erigon/db/rawdb" "github.com/erigontech/erigon/execution/chain" "github.com/erigontech/erigon/execution/types" "github.com/erigontech/erigon/execution/types/ethutils" @@ -565,7 +564,7 @@ func (api *APIImpl) GetTransactionReceipt(ctx context.Context, txnHash common.Ha } // Check if we have commitment history: this is required to know if state root will be computed for historical state. - commitmentHistory, _, err := rawdb.ReadDBCommitmentHistoryEnabled(tx) + commitmentHistory, err := api.commitmentHistoryEnabled(tx) if err != nil { return nil, err } diff --git a/rpc/jsonrpc/eth_simulation.go b/rpc/jsonrpc/eth_simulation.go index 27feb7122c7..dedc3dfab5a 100644 --- a/rpc/jsonrpc/eth_simulation.go +++ b/rpc/jsonrpc/eth_simulation.go @@ -36,7 +36,6 @@ import ( "github.com/erigontech/erigon/db/kv" "github.com/erigontech/erigon/db/kv/order" "github.com/erigontech/erigon/db/kv/rawdbv3" - "github.com/erigontech/erigon/db/rawdb" "github.com/erigontech/erigon/db/services" "github.com/erigontech/erigon/db/state/execctx" "github.com/erigontech/erigon/execution/chain" @@ -148,7 +147,7 @@ func (api *APIImpl) SimulateV1(ctx context.Context, req SimulationRequest, block simulatedBlockResults := make(SimulationResult, 0, len(req.BlockStateCalls)) // Check if we have commitment history: this is required to know if state root will be computed or left zero for historical state. - commitmentHistory, _, err := rawdb.ReadDBCommitmentHistoryEnabled(tx) + commitmentHistory, err := api.commitmentHistoryEnabled(tx) if err != nil { return nil, err } diff --git a/rpc/jsonrpc/eth_system.go b/rpc/jsonrpc/eth_system.go index cc8cdd15b3a..530599f0a92 100644 --- a/rpc/jsonrpc/eth_system.go +++ b/rpc/jsonrpc/eth_system.go @@ -40,6 +40,77 @@ import ( "github.com/erigontech/erigon/rpc/rpchelper" ) +// CapabilityField describes availability of a data category: when Disabled is true the node +// does not hold that data at all; otherwise OldestBlock is the lowest block number available. +type CapabilityField struct { + Disabled bool `json:"disabled"` + OldestBlock *hexutil.Uint64 `json:"oldestBlock,omitempty"` +} + +// CapabilitiesResult is the response type of eth_capabilities. +type CapabilitiesResult struct { + State CapabilityField `json:"state"` + Tx CapabilityField `json:"tx"` + Logs CapabilityField `json:"logs"` + Receipts CapabilityField `json:"receipts"` + Blocks CapabilityField `json:"blocks"` + StateProofs CapabilityField `json:"stateproofs"` +} + +// Capabilities implements eth_capabilities. +// stateproofs is only available when --prune.include-commitment-history was set at node startup; +// otherwise it is disabled regardless of prune mode. +func (api *APIImpl) Capabilities(ctx context.Context) (*CapabilitiesResult, error) { + tx, err := api.db.BeginTemporalRo(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback() + + pruneMode, err := api.pruneMode(tx) + if err != nil { + return nil, err + } + + keepExecutionProofs, err := api.commitmentHistoryEnabled(tx) + if err != nil { + return nil, err + } + + headBlock, err := rpchelper.GetLatestBlockNumber(api.filters.WithOverlay(tx)) + if err != nil { + return nil, err + } + + avail := func(oldest uint64) CapabilityField { + o := hexutil.Uint64(oldest) + return CapabilityField{OldestBlock: &o} + } + + // PruneTo returns 0 when the distance is MaxUint64 (archive/full-blocks), so these two + // lines handle all prune modes without branching. + stateOldest := pruneMode.History.PruneTo(headBlock) + blocksOldest := pruneMode.Blocks.PruneTo(headBlock) + + var stateproofs CapabilityField + if keepExecutionProofs { + stateproofs = avail(stateOldest) + } else { + stateproofs = CapabilityField{Disabled: true} + } + + return &CapabilitiesResult{ + State: avail(stateOldest), + Tx: avail(blocksOldest), + Logs: avail(stateOldest), + // receipts are read from DB (RCacheDomain) if --persist.receipts is enabled, otherwise + // recalculated via re-execution; in both cases the available range matches state history + Receipts: avail(stateOldest), + Blocks: avail(blocksOldest), + StateProofs: stateproofs, + }, nil +} + // BlockNumber implements eth_blockNumber. Returns the block number of most recent block. func (api *APIImpl) BlockNumber(ctx context.Context) (hexutil.Uint64, error) { tx, err := api.db.BeginTemporalRo(ctx) diff --git a/rpc/jsonrpc/eth_system_test.go b/rpc/jsonrpc/eth_system_test.go index 4b3db531afb..beb8249eb42 100644 --- a/rpc/jsonrpc/eth_system_test.go +++ b/rpc/jsonrpc/eth_system_test.go @@ -32,13 +32,159 @@ import ( "github.com/erigontech/erigon/common" "github.com/erigontech/erigon/common/crypto" "github.com/erigontech/erigon/common/hexutil" + "github.com/erigontech/erigon/db/kv/prune" "github.com/erigontech/erigon/db/rawdb" "github.com/erigontech/erigon/execution/chain" "github.com/erigontech/erigon/execution/execmodule/execmoduletester" + "github.com/erigontech/erigon/execution/stagedsync/stages" "github.com/erigontech/erigon/execution/tests/blockgen" "github.com/erigontech/erigon/execution/types" ) +func TestCapabilities(t *testing.T) { + if testing.Short() { + t.Skip("slow test") + } + t.Parallel() + + // Use a small prune distance so tests don't need to generate 100k blocks. + const chainSize = 20 + const testPruneDistance = uint64(10) + + testFullMode := prune.Mode{ + Initialised: true, + History: prune.Distance(testPruneDistance), + Blocks: prune.DefaultBlocksPruneMode, // MaxUint64 = keeps all block snapshots + } + testMinimalMode := prune.Mode{ + Initialised: true, + History: prune.Distance(testPruneDistance), + Blocks: prune.Distance(testPruneDistance), + } + + setupAPI := func(t *testing.T, pruneMode prune.Mode, commitmentHistory bool) (*APIImpl, uint64) { + t.Helper() + key, _ := crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291") + addr := crypto.PubkeyToAddress(key.PublicKey) + gspec := &types.Genesis{ + Config: chain.TestChainBerlinConfig, + Alloc: types.GenesisAlloc{addr: {Balance: big.NewInt(math.MaxInt64)}}, + } + m := execmoduletester.New(t, execmoduletester.WithGenesisSpec(gspec), execmoduletester.WithKey(key)) + + // Generate and insert blocks so Execution stage progress is set. + signer := types.LatestSigner(gspec.Config) + c, err := blockgen.GenerateChain(m.ChainConfig, m.Genesis, m.Engine, m.DB, chainSize, func(i int, b *blockgen.BlockGen) { + b.SetCoinbase(common.Address{1}) + tx, txErr := types.SignTx(types.NewTransaction(b.TxNonce(addr), common.HexToAddress("deadbeef"), uint256.NewInt(1), 21000, uint256.NewInt(uint64(i+1)*common.GWei), nil), *signer, key) + if txErr != nil { + t.Fatal(txErr) + } + b.AddTx(tx) + }) + require.NoError(t, err) + require.NoError(t, m.InsertChain(c)) + + // Write prune mode and commitment history flag. + // prune.EnsureNotChanged writes on empty keys; execmoduletester never pre-populates them. + ctx := t.Context() + tx, err := m.DB.BeginTemporalRw(ctx) + require.NoError(t, err) + defer tx.Rollback() + _, err = prune.EnsureNotChanged(tx, pruneMode) + require.NoError(t, err) + require.NoError(t, rawdb.WriteDBCommitmentHistoryEnabled(tx, commitmentHistory)) + require.NoError(t, tx.Commit()) + + roTx, err := m.DB.BeginTemporalRo(ctx) + require.NoError(t, err) + defer roTx.Rollback() + head, err := stages.GetStageProgress(roTx, stages.Execution) + require.NoError(t, err) + + return newEthApiForTest(newBaseApiForTest(m), m.DB, nil, nil), head + } + + oldest := func(t *testing.T, f CapabilityField) uint64 { + t.Helper() + require.NotNil(t, f.OldestBlock) + return uint64(*f.OldestBlock) + } + + t.Run("archive_no_commitment", func(t *testing.T) { + t.Parallel() + api, _ := setupAPI(t, prune.ArchiveMode, false) + result, err := api.Capabilities(t.Context()) + require.NoError(t, err) + require.Equal(t, uint64(0), oldest(t, result.State)) + require.Equal(t, uint64(0), oldest(t, result.Tx)) + require.Equal(t, uint64(0), oldest(t, result.Logs)) + require.Equal(t, uint64(0), oldest(t, result.Receipts)) + require.Equal(t, uint64(0), oldest(t, result.Blocks)) + require.True(t, result.StateProofs.Disabled) + require.Nil(t, result.StateProofs.OldestBlock) + }) + + t.Run("archive_with_commitment", func(t *testing.T) { + t.Parallel() + api, _ := setupAPI(t, prune.ArchiveMode, true) + result, err := api.Capabilities(t.Context()) + require.NoError(t, err) + require.Equal(t, uint64(0), oldest(t, result.State)) + require.False(t, result.StateProofs.Disabled) + require.Equal(t, uint64(0), oldest(t, result.StateProofs)) + }) + + t.Run("full_no_commitment", func(t *testing.T) { + t.Parallel() + api, head := setupAPI(t, testFullMode, false) + result, err := api.Capabilities(t.Context()) + require.NoError(t, err) + pruned := head - testPruneDistance + // state/logs/receipts limited to history prune distance + require.Equal(t, pruned, oldest(t, result.State)) + require.Equal(t, pruned, oldest(t, result.Logs)) + require.Equal(t, pruned, oldest(t, result.Receipts)) + // full keeps all block snapshots: tx and blocks start from 0 + require.Equal(t, uint64(0), oldest(t, result.Tx)) + require.Equal(t, uint64(0), oldest(t, result.Blocks)) + require.True(t, result.StateProofs.Disabled) + }) + + t.Run("full_with_commitment", func(t *testing.T) { + t.Parallel() + api, head := setupAPI(t, testFullMode, true) + result, err := api.Capabilities(t.Context()) + require.NoError(t, err) + require.Equal(t, head-testPruneDistance, oldest(t, result.StateProofs)) + require.False(t, result.StateProofs.Disabled) + }) + + t.Run("minimal_no_commitment", func(t *testing.T) { + t.Parallel() + api, head := setupAPI(t, testMinimalMode, false) + result, err := api.Capabilities(t.Context()) + require.NoError(t, err) + pruned := head - testPruneDistance + // minimal prunes everything including blocks and tx + require.Equal(t, pruned, oldest(t, result.State)) + require.Equal(t, pruned, oldest(t, result.Tx)) + require.Equal(t, pruned, oldest(t, result.Logs)) + require.Equal(t, pruned, oldest(t, result.Receipts)) + require.Equal(t, pruned, oldest(t, result.Blocks)) + require.True(t, result.StateProofs.Disabled) + }) + + t.Run("minimal_with_commitment", func(t *testing.T) { + t.Parallel() + api, head := setupAPI(t, testMinimalMode, true) + result, err := api.Capabilities(t.Context()) + require.NoError(t, err) + require.Equal(t, head-testPruneDistance, oldest(t, result.StateProofs)) + require.False(t, result.StateProofs.Disabled) + }) +} + func TestGasPrice(t *testing.T) { if testing.Short() { t.Skip("slow test") diff --git a/rpc/jsonrpc/receipts/receipts_generator.go b/rpc/jsonrpc/receipts/receipts_generator.go index b1dd369b713..d0e04ef570f 100644 --- a/rpc/jsonrpc/receipts/receipts_generator.go +++ b/rpc/jsonrpc/receipts/receipts_generator.go @@ -6,6 +6,7 @@ import ( "fmt" "runtime" "sync" + "sync/atomic" "time" "github.com/google/go-cmp/cmp" @@ -62,6 +63,8 @@ type Generator struct { commitmentReplay *rpchelper.CommitmentReplay filters *rpchelper.Filters + + _commitmentHistoryEnabled atomic.Pointer[bool] } type ReceiptEnv struct { @@ -117,6 +120,20 @@ func NewGenerator(dirs datadir.Dirs, blockReader services.FullBlockReader, engin } } +func (g *Generator) commitmentHistoryEnabled(tx kv.Tx) (bool, error) { + if p := g._commitmentHistoryEnabled.Load(); p != nil { + return *p, nil + } + enabled, ok, err := rawdb.ReadDBCommitmentHistoryEnabled(tx) + if err != nil { + return false, err + } + if ok { + g._commitmentHistoryEnabled.Store(&enabled) + } + return enabled, nil +} + func (g *Generator) LogStats() { if g == nil || !g.receiptsCacheTrace { return @@ -484,7 +501,7 @@ func (g *Generator) GetReceipts(ctx context.Context, cfg *chain.Config, tx kv.Te // Check if we have commitment history: this is required to know if state root will be computed or left zero for historical state. var commitmentHistory bool - commitmentHistory, _, err = rawdb.ReadDBCommitmentHistoryEnabled(tx) + commitmentHistory, err = g.commitmentHistoryEnabled(tx) if err != nil { return nil, err } From 7a4362643ced72a1674e6a254e320f2e573ffe84 Mon Sep 17 00:00:00 2001 From: lupin012 <58134934+lupin012@users.noreply.github.com.> Date: Wed, 6 May 2026 22:16:23 +0200 Subject: [PATCH 02/16] rpc, p2p: pass commitmentHistoryEnabled as parameter to GetReceipts 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 --- execution/abi/bind/backends/simulated.go | 6 +++- execution/tests/blockchain_test.go | 6 +++- p2p/protocols/eth/handlers.go | 9 ++++-- p2p/protocols/eth/handlers_test.go | 2 +- rpc/jsonrpc/eth_receipts.go | 6 +++- rpc/jsonrpc/receipts/handler_test.go | 2 +- rpc/jsonrpc/receipts/receipts_generator.go | 33 ++++------------------ 7 files changed, 29 insertions(+), 35 deletions(-) diff --git a/execution/abi/bind/backends/simulated.go b/execution/abi/bind/backends/simulated.go index 29eab9e28df..330b9df9c48 100644 --- a/execution/abi/bind/backends/simulated.go +++ b/execution/abi/bind/backends/simulated.go @@ -295,8 +295,12 @@ func (b *SimulatedBackend) TransactionReceipt(ctx context.Context, txHash common return nil, err } + commitmentHistoryEnabled, _, err := rawdb.ReadDBCommitmentHistoryEnabled(tx) + if err != nil { + return nil, err + } // Read all the receipts from the block and return the one with the matching hash - receipts, err := b.m.ReceiptsReader.GetReceipts(ctx, b.m.ChainConfig, tx, block) + receipts, err := b.m.ReceiptsReader.GetReceipts(ctx, b.m.ChainConfig, tx, block, commitmentHistoryEnabled) if err != nil { panic(err) } diff --git a/execution/tests/blockchain_test.go b/execution/tests/blockchain_test.go index e29035968cc..8e60e79a658 100644 --- a/execution/tests/blockchain_test.go +++ b/execution/tests/blockchain_test.go @@ -605,8 +605,12 @@ func readReceipt(db kv.TemporalTx, txHash common.Hash, m *execmoduletester.ExecM return nil, common.Hash{}, 0, 0, err } + commitmentHistoryEnabled, _, err := rawdb.ReadDBCommitmentHistoryEnabled(db) + if err != nil { + return nil, common.Hash{}, 0, 0, err + } // Read all the receipts from the block and return the one with the matching hash - receipts, err := m.ReceiptsReader.GetReceipts(context.Background(), m.ChainConfig, db, b) + receipts, err := m.ReceiptsReader.GetReceipts(context.Background(), m.ChainConfig, db, b, commitmentHistoryEnabled) if err != nil { return nil, common.Hash{}, 0, 0, err } diff --git a/p2p/protocols/eth/handlers.go b/p2p/protocols/eth/handlers.go index bd767fb6fdb..93927379d95 100644 --- a/p2p/protocols/eth/handlers.go +++ b/p2p/protocols/eth/handlers.go @@ -223,7 +223,7 @@ func AnswerGetBlockAccessListsQuery(db kv.Tx, query GetBlockAccessListsPacket, b } type ReceiptsGetter interface { - GetReceipts(ctx context.Context, cfg *chain.Config, tx kv.TemporalTx, block *types.Block) (types.Receipts, error) + GetReceipts(ctx context.Context, cfg *chain.Config, tx kv.TemporalTx, block *types.Block, commitmentHistoryEnabled bool) (types.Receipts, error) GetCachedReceipts(ctx context.Context, blockHash common.Hash) (types.Receipts, bool) } @@ -372,6 +372,11 @@ func AnswerGetReceiptsQuery(ctx context.Context, cfg *chain.Config, receiptsGett pendingIndex = cached.PendingIndex } + commitmentHistoryEnabled, _, err := rawdb.ReadDBCommitmentHistoryEnabled(db) + if err != nil { + return nil, false, err + } + for lookups := pendingIndex; lookups < len(query); lookups++ { hash := query[lookups] if numBytes >= softResponseLimit || len(receipts) >= maxReceiptsServe || @@ -390,7 +395,7 @@ func AnswerGetReceiptsQuery(ctx context.Context, cfg *chain.Config, receiptsGett return nil, false, nil } - results, err := receiptsGetter.GetReceipts(ctx, cfg, db, b) + results, err := receiptsGetter.GetReceipts(ctx, cfg, db, b, commitmentHistoryEnabled) if err != nil { return nil, false, err } diff --git a/p2p/protocols/eth/handlers_test.go b/p2p/protocols/eth/handlers_test.go index 010829be119..9fb2e6a5fe8 100644 --- a/p2p/protocols/eth/handlers_test.go +++ b/p2p/protocols/eth/handlers_test.go @@ -25,7 +25,7 @@ func (m *mockReceiptsGetter) GetCachedReceipts(_ context.Context, hash common.Ha return r, ok } -func (m *mockReceiptsGetter) GetReceipts(_ context.Context, _ *chain.Config, _ kv.TemporalTx, _ *types.Block) (types.Receipts, error) { +func (m *mockReceiptsGetter) GetReceipts(_ context.Context, _ *chain.Config, _ kv.TemporalTx, _ *types.Block, _ bool) (types.Receipts, error) { panic("not expected in cache-only tests") } diff --git a/rpc/jsonrpc/eth_receipts.go b/rpc/jsonrpc/eth_receipts.go index d8ae2eb12f2..3a746b51d19 100644 --- a/rpc/jsonrpc/eth_receipts.go +++ b/rpc/jsonrpc/eth_receipts.go @@ -66,7 +66,11 @@ func (api *BaseAPI) getReceipts(ctx context.Context, tx kv.TemporalTx, block *ty return nil, err } - return api.receiptsGenerator.GetReceipts(ctx, chainConfig, tx, block) + commitmentHistoryEnabled, err := api.commitmentHistoryEnabled(tx) + if err != nil { + return nil, err + } + return api.receiptsGenerator.GetReceipts(ctx, chainConfig, tx, block, commitmentHistoryEnabled) } func (api *BaseAPI) getReceipt(ctx context.Context, cc *chain.Config, tx kv.TemporalTx, header *types.Header, txn types.Transaction, index int, txNum uint64, postState *receipts.PostStateInfo) (*types.Receipt, error) { diff --git a/rpc/jsonrpc/receipts/handler_test.go b/rpc/jsonrpc/receipts/handler_test.go index ba4ddd3139f..e110ba16399 100644 --- a/rpc/jsonrpc/receipts/handler_test.go +++ b/rpc/jsonrpc/receipts/handler_test.go @@ -316,7 +316,7 @@ func TestGetBlockReceipts(t *testing.T) { hashes = append(hashes, block.Hash()) // If known, encode and queue for response packet - r, err := receiptsGetter.GetReceipts(m.Ctx, m.ChainConfig, tx, block) + r, err := receiptsGetter.GetReceipts(m.Ctx, m.ChainConfig, tx, block, false) require.NoError(t, err) encoded, err := rlp.EncodeToBytes(r) require.NoError(t, err) diff --git a/rpc/jsonrpc/receipts/receipts_generator.go b/rpc/jsonrpc/receipts/receipts_generator.go index d0e04ef570f..1506b2cd2d0 100644 --- a/rpc/jsonrpc/receipts/receipts_generator.go +++ b/rpc/jsonrpc/receipts/receipts_generator.go @@ -6,7 +6,6 @@ import ( "fmt" "runtime" "sync" - "sync/atomic" "time" "github.com/google/go-cmp/cmp" @@ -63,8 +62,6 @@ type Generator struct { commitmentReplay *rpchelper.CommitmentReplay filters *rpchelper.Filters - - _commitmentHistoryEnabled atomic.Pointer[bool] } type ReceiptEnv struct { @@ -120,20 +117,6 @@ func NewGenerator(dirs datadir.Dirs, blockReader services.FullBlockReader, engin } } -func (g *Generator) commitmentHistoryEnabled(tx kv.Tx) (bool, error) { - if p := g._commitmentHistoryEnabled.Load(); p != nil { - return *p, nil - } - enabled, ok, err := rawdb.ReadDBCommitmentHistoryEnabled(tx) - if err != nil { - return false, err - } - if ok { - g._commitmentHistoryEnabled.Store(&enabled) - } - return enabled, nil -} - func (g *Generator) LogStats() { if g == nil || !g.receiptsCacheTrace { return @@ -466,7 +449,7 @@ func (g *Generator) GetReceipt(ctx context.Context, cfg *chain.Config, tx kv.Tem return receipt, nil } -func (g *Generator) GetReceipts(ctx context.Context, cfg *chain.Config, tx kv.TemporalTx, block *types.Block) (_ types.Receipts, err error) { +func (g *Generator) GetReceipts(ctx context.Context, cfg *chain.Config, tx kv.TemporalTx, block *types.Block, commitmentHistoryEnabled bool) (_ types.Receipts, err error) { blockHash := block.Hash() blockNum := block.NumberU64() @@ -499,13 +482,7 @@ func (g *Generator) GetReceipts(ctx context.Context, cfg *chain.Config, tx kv.Te return nil, err } - // Check if we have commitment history: this is required to know if state root will be computed or left zero for historical state. - var commitmentHistory bool - commitmentHistory, err = g.commitmentHistoryEnabled(tx) - if err != nil { - return nil, err - } - calculatePostState := (commitmentHistory || g.blockReader.FrozenBlocks() == 0) && !cfg.IsByzantium(blockNum) + calculatePostState := (commitmentHistoryEnabled || g.blockReader.FrozenBlocks() == 0) && !cfg.IsByzantium(blockNum) // Now the snapshot have not the `postState` field. Therefore, for pre-Byzantium blocks, // we must skip persistent receipts and re-calculate @@ -564,7 +541,7 @@ func (g *Generator) GetReceipts(ctx context.Context, cfg *chain.Config, tx kv.Te } var stateWriter state.StateWriter - if commitmentHistory { + if commitmentHistoryEnabled { sharedDomains, err = execctx.NewSharedDomains(ctx, tx, log.Root()) if err != nil { return nil, err @@ -623,7 +600,7 @@ func (g *Generator) GetReceipts(ctx context.Context, cfg *chain.Config, tx kv.Te } var stateRoot []byte - if commitmentHistory { + if commitmentHistoryEnabled { sharedDomains.GetCommitmentContext().SetHistoryStateReader(tx, txNum+1) latestTxNum, _, err := sharedDomains.SeekCommitment(ctx, tx) if err != nil { @@ -654,7 +631,7 @@ func (g *Generator) GetReceipts(ctx context.Context, cfg *chain.Config, tx kv.Te // When assertions are enabled, receipts are *always* computed (i.e. receipt cache V2 is skipped) // Hence, we need commitment history to correctly compute the `root` field for pre-Byzantium receipts - if dbg.AssertEnabled && (commitmentHistory || cfg.IsByzantium(blockNum)) { + if dbg.AssertEnabled && (commitmentHistoryEnabled || cfg.IsByzantium(blockNum)) { computedReceiptsRoot := types.DeriveSha(receipts) blockReceiptsRoot := block.Header().ReceiptHash if computedReceiptsRoot != blockReceiptsRoot { From 47e5d16ca161e700a3b8cd64f31be09893387894 Mon Sep 17 00:00:00 2001 From: lupin012 <58134934+lupin012@users.noreply.github.com.> Date: Mon, 11 May 2026 20:50:16 +0200 Subject: [PATCH 03/16] update rpc version --- .github/workflows/scripts/rpc_version.env | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/scripts/rpc_version.env b/.github/workflows/scripts/rpc_version.env index 5e23f1e0a54..4c33706782f 100644 --- a/.github/workflows/scripts/rpc_version.env +++ b/.github/workflows/scripts/rpc_version.env @@ -1 +1 @@ -RPC_VERSION=v2.9.0 +RPC_VERSION=v2.10.0 From 2a5eeffc4b4fc893cd565d97e61f2d016895277a Mon Sep 17 00:00:00 2001 From: lupin012 <58134934+lupin012@users.noreply.github.com.> Date: Wed, 13 May 2026 20:11:45 +0200 Subject: [PATCH 04/16] rpc: add required head field to eth_capabilities response 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 --- rpc/jsonrpc/eth_system.go | 12 ++++++++++++ rpc/jsonrpc/eth_system_test.go | 4 +++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/rpc/jsonrpc/eth_system.go b/rpc/jsonrpc/eth_system.go index 530599f0a92..394eaf0bba1 100644 --- a/rpc/jsonrpc/eth_system.go +++ b/rpc/jsonrpc/eth_system.go @@ -47,8 +47,15 @@ type CapabilityField struct { OldestBlock *hexutil.Uint64 `json:"oldestBlock,omitempty"` } +// CapabilityHead identifies the canonical chain tip at the moment eth_capabilities was called. +type CapabilityHead struct { + Number hexutil.Uint64 `json:"number"` + Hash common.Hash `json:"hash"` +} + // CapabilitiesResult is the response type of eth_capabilities. type CapabilitiesResult struct { + Head CapabilityHead `json:"head"` State CapabilityField `json:"state"` Tx CapabilityField `json:"tx"` Logs CapabilityField `json:"logs"` @@ -81,6 +88,10 @@ func (api *APIImpl) Capabilities(ctx context.Context) (*CapabilitiesResult, erro if err != nil { return nil, err } + headHash, err := rawdb.ReadCanonicalHash(tx, headBlock) + if err != nil { + return nil, err + } avail := func(oldest uint64) CapabilityField { o := hexutil.Uint64(oldest) @@ -100,6 +111,7 @@ func (api *APIImpl) Capabilities(ctx context.Context) (*CapabilitiesResult, erro } return &CapabilitiesResult{ + Head: CapabilityHead{Number: hexutil.Uint64(headBlock), Hash: headHash}, State: avail(stateOldest), Tx: avail(blocksOldest), Logs: avail(stateOldest), diff --git a/rpc/jsonrpc/eth_system_test.go b/rpc/jsonrpc/eth_system_test.go index beb8249eb42..cf48deb36d9 100644 --- a/rpc/jsonrpc/eth_system_test.go +++ b/rpc/jsonrpc/eth_system_test.go @@ -113,9 +113,11 @@ func TestCapabilities(t *testing.T) { t.Run("archive_no_commitment", func(t *testing.T) { t.Parallel() - api, _ := setupAPI(t, prune.ArchiveMode, false) + api, head := setupAPI(t, prune.ArchiveMode, false) result, err := api.Capabilities(t.Context()) require.NoError(t, err) + require.Equal(t, head, uint64(result.Head.Number)) + require.NotEqual(t, common.Hash{}, result.Head.Hash) require.Equal(t, uint64(0), oldest(t, result.State)) require.Equal(t, uint64(0), oldest(t, result.Tx)) require.Equal(t, uint64(0), oldest(t, result.Logs)) From 02b7143acb7d7173cfa7e21bd4db8309114bebda Mon Sep 17 00:00:00 2001 From: lupin012 <58134934+lupin012@users.noreply.github.com.> Date: Wed, 13 May 2026 20:37:16 +0200 Subject: [PATCH 05/16] rpc: fix eth_capabilities tx/blocks oldestBlock for chains with MergeHeight 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 --- rpc/jsonrpc/eth_system.go | 16 +++++++++-- rpc/jsonrpc/eth_system_test.go | 50 ++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/rpc/jsonrpc/eth_system.go b/rpc/jsonrpc/eth_system.go index 394eaf0bba1..a97821f281f 100644 --- a/rpc/jsonrpc/eth_system.go +++ b/rpc/jsonrpc/eth_system.go @@ -26,6 +26,7 @@ import ( "github.com/erigontech/erigon/common" "github.com/erigontech/erigon/common/hexutil" "github.com/erigontech/erigon/db/kv" + "github.com/erigontech/erigon/db/kv/prune" "github.com/erigontech/erigon/db/rawdb" "github.com/erigontech/erigon/execution/chain" "github.com/erigontech/erigon/execution/protocol/misc" @@ -84,6 +85,11 @@ func (api *APIImpl) Capabilities(ctx context.Context) (*CapabilitiesResult, erro return nil, err } + chainConfig, err := api.chainConfig(ctx, tx) + if err != nil { + return nil, err + } + headBlock, err := rpchelper.GetLatestBlockNumber(api.filters.WithOverlay(tx)) if err != nil { return nil, err @@ -98,10 +104,16 @@ func (api *APIImpl) Capabilities(ctx context.Context) (*CapabilitiesResult, erro return CapabilityField{OldestBlock: &o} } - // PruneTo returns 0 when the distance is MaxUint64 (archive/full-blocks), so these two - // lines handle all prune modes without branching. + // For KeepAllBlocksPruneMode (MaxUint64-1) and DefaultBlocksPruneMode (MaxUint64), + // PruneTo returns 0 because distance > headBlock. stateOldest := pruneMode.History.PruneTo(headBlock) blocksOldest := pruneMode.Blocks.PruneTo(headBlock) + // DefaultBlocksPruneMode uses chain-specific history expiry: on chains that have + // MergeHeight set (mainnet, sepolia, gnosis…), pre-merge blocks/tx segments are + // never downloaded, so the oldest available block is the merge point, not 0. + if pruneMode.Blocks == prune.DefaultBlocksPruneMode && chainConfig.MergeHeight != nil { + blocksOldest = *chainConfig.MergeHeight + } var stateproofs CapabilityField if keepExecutionProofs { diff --git a/rpc/jsonrpc/eth_system_test.go b/rpc/jsonrpc/eth_system_test.go index cf48deb36d9..76f8d251c57 100644 --- a/rpc/jsonrpc/eth_system_test.go +++ b/rpc/jsonrpc/eth_system_test.go @@ -27,6 +27,7 @@ import ( "testing" "github.com/holiman/uint256" + "github.com/jinzhu/copier" "github.com/stretchr/testify/require" "github.com/erigontech/erigon/common" @@ -185,6 +186,55 @@ func TestCapabilities(t *testing.T) { require.Equal(t, head-testPruneDistance, oldest(t, result.StateProofs)) require.False(t, result.StateProofs.Disabled) }) + + // full mode on a chain with MergeHeight: pre-merge tx/blocks are not kept, + // so tx.oldestBlock and blocks.oldestBlock must reflect the merge point, not 0. + t.Run("full_merge_height", func(t *testing.T) { + t.Parallel() + mergeAt := uint64(chainSize / 2) // = 10, well within the 20-block chain + + key, _ := crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291") + addr := crypto.PubkeyToAddress(key.PublicKey) + + var cfgWithMerge chain.Config + require.NoError(t, copier.CopyWithOption(&cfgWithMerge, chain.TestChainBerlinConfig, copier.Option{DeepCopy: true})) + cfgWithMerge.MergeHeight = &mergeAt + gspec := &types.Genesis{ + Config: &cfgWithMerge, + Alloc: types.GenesisAlloc{addr: {Balance: big.NewInt(math.MaxInt64)}}, + } + m := execmoduletester.New(t, execmoduletester.WithGenesisSpec(gspec), execmoduletester.WithKey(key)) + + signer := types.LatestSigner(gspec.Config) + c, err := blockgen.GenerateChain(m.ChainConfig, m.Genesis, m.Engine, m.DB, chainSize, func(i int, b *blockgen.BlockGen) { + b.SetCoinbase(common.Address{1}) + tx, txErr := types.SignTx(types.NewTransaction(b.TxNonce(addr), common.HexToAddress("deadbeef"), uint256.NewInt(1), 21000, uint256.NewInt(uint64(i+1)*common.GWei), nil), *signer, key) + if txErr != nil { + t.Fatal(txErr) + } + b.AddTx(tx) + }) + require.NoError(t, err) + require.NoError(t, m.InsertChain(c)) + + ctx := t.Context() + dbTx, err := m.DB.BeginTemporalRw(ctx) + require.NoError(t, err) + defer dbTx.Rollback() + _, err = prune.EnsureNotChanged(dbTx, testFullMode) + require.NoError(t, err) + require.NoError(t, rawdb.WriteDBCommitmentHistoryEnabled(dbTx, false)) + require.NoError(t, dbTx.Commit()) + + api := newEthApiForTest(newBaseApiForTest(m), m.DB, nil, nil) + result, err := api.Capabilities(ctx) + require.NoError(t, err) + // tx and blocks must start at the merge point, not 0 + require.Equal(t, mergeAt, oldest(t, result.Tx)) + require.Equal(t, mergeAt, oldest(t, result.Blocks)) + // state is still limited by history prune distance + require.Equal(t, uint64(chainSize)-testPruneDistance, oldest(t, result.State)) + }) } func TestGasPrice(t *testing.T) { From 46ab45dbe3516865a951f7ef728007a4d7710d0b Mon Sep 17 00:00:00 2001 From: lupin012 <58134934+lupin012@users.noreply.github.com.> Date: Wed, 13 May 2026 20:39:54 +0200 Subject: [PATCH 06/16] rpc: fix misleading DefaultBlocksPruneMode comment in capabilities test 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 --- rpc/jsonrpc/eth_system_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rpc/jsonrpc/eth_system_test.go b/rpc/jsonrpc/eth_system_test.go index 76f8d251c57..286e14dc599 100644 --- a/rpc/jsonrpc/eth_system_test.go +++ b/rpc/jsonrpc/eth_system_test.go @@ -55,7 +55,7 @@ func TestCapabilities(t *testing.T) { testFullMode := prune.Mode{ Initialised: true, History: prune.Distance(testPruneDistance), - Blocks: prune.DefaultBlocksPruneMode, // MaxUint64 = keeps all block snapshots + Blocks: prune.DefaultBlocksPruneMode, // chain-specific history expiry (pre-merge blocks not kept on merge chains) } testMinimalMode := prune.Mode{ Initialised: true, From f6e1ab47468581d4a4b009a576c3d2a390830efc Mon Sep 17 00:00:00 2001 From: lupin012 <58134934+lupin012@users.noreply.github.com.> Date: Wed, 13 May 2026 20:41:22 +0200 Subject: [PATCH 07/16] p2p/eth: skip commitmentHistoryEnabled DB read on full cache hit 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 --- p2p/protocols/eth/handlers.go | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/p2p/protocols/eth/handlers.go b/p2p/protocols/eth/handlers.go index 93927379d95..6928282bf1e 100644 --- a/p2p/protocols/eth/handlers.go +++ b/p2p/protocols/eth/handlers.go @@ -372,9 +372,14 @@ func AnswerGetReceiptsQuery(ctx context.Context, cfg *chain.Config, receiptsGett pendingIndex = cached.PendingIndex } - commitmentHistoryEnabled, _, err := rawdb.ReadDBCommitmentHistoryEnabled(db) - if err != nil { - return nil, false, err + // Only read the flag when there is work to do; full cache hits skip this DB lookup. + var commitmentHistoryEnabled bool + if pendingIndex < len(query) { + var err error + commitmentHistoryEnabled, _, err = rawdb.ReadDBCommitmentHistoryEnabled(db) + if err != nil { + return nil, false, err + } } for lookups := pendingIndex; lookups < len(query); lookups++ { From 1efa4f3a19fb125d26fc48282c712b5219b6a506 Mon Sep 17 00:00:00 2001 From: lupin012 <58134934+lupin012@users.noreply.github.com.> Date: Wed, 13 May 2026 20:42:19 +0200 Subject: [PATCH 08/16] rpc: document why commitmentHistoryEnabled does not cache false 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 --- rpc/jsonrpc/eth_api.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/rpc/jsonrpc/eth_api.go b/rpc/jsonrpc/eth_api.go index 50adb4d5707..8d256020f9b 100644 --- a/rpc/jsonrpc/eth_api.go +++ b/rpc/jsonrpc/eth_api.go @@ -427,8 +427,11 @@ func (api *BaseAPI) pruneMode(tx kv.Tx) (*prune.Mode, error) { } // commitmentHistoryEnabled returns whether --prune.include-commitment-history was set at node -// startup. The flag is written once and never changed, so the result is cached after first read. -// If the DB key is absent (node not yet initialised) the value is not cached and false is returned. +// startup. The flag is written once by checkAndSetCommitmentHistoryFlag and never changed, so +// the result is cached after the first successful read. +// Unlike pruneMode, false is not cached when the DB key is absent: during the brief boot window +// before checkAndSetCommitmentHistoryFlag runs the key may not exist yet, and caching false +// would shadow a subsequent true write. Each request during that window pays one DB lookup. func (api *BaseAPI) commitmentHistoryEnabled(tx kv.Tx) (bool, error) { if p := api._commitmentHistoryEnabled.Load(); p != nil { return *p, nil From de18640482061c5d5c8ce3c75296e36452086f83 Mon Sep 17 00:00:00 2001 From: lupin012 <58134934+lupin012@users.noreply.github.com.> Date: Wed, 13 May 2026 20:45:27 +0200 Subject: [PATCH 09/16] rpc: add deleteStrategy field to eth_capabilities response 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 --- rpc/jsonrpc/eth_system.go | 37 +++++++++++++++++++++++++--------- rpc/jsonrpc/eth_system_test.go | 25 +++++++++++++++++++++-- 2 files changed, 50 insertions(+), 12 deletions(-) diff --git a/rpc/jsonrpc/eth_system.go b/rpc/jsonrpc/eth_system.go index a97821f281f..7dfd81539f1 100644 --- a/rpc/jsonrpc/eth_system.go +++ b/rpc/jsonrpc/eth_system.go @@ -41,11 +41,23 @@ import ( "github.com/erigontech/erigon/rpc/rpchelper" ) +// DeleteStrategy describes how a node removes old data for a category. +// Currently only the "window" type is defined: the node keeps a sliding +// window of RetentionBlocks blocks and discards everything older. +// The field is omitted when data is kept indefinitely (archive nodes, or +// DefaultBlocksPruneMode which uses chain-specific history expiry). +type DeleteStrategy struct { + Type string `json:"type"` + RetentionBlocks hexutil.Uint64 `json:"retentionBlocks"` +} + // CapabilityField describes availability of a data category: when Disabled is true the node // does not hold that data at all; otherwise OldestBlock is the lowest block number available. +// DeleteStrategy is set when the node uses a finite retention window. type CapabilityField struct { - Disabled bool `json:"disabled"` - OldestBlock *hexutil.Uint64 `json:"oldestBlock,omitempty"` + Disabled bool `json:"disabled"` + OldestBlock *hexutil.Uint64 `json:"oldestBlock,omitempty"` + DeleteStrategy *DeleteStrategy `json:"deleteStrategy,omitempty"` } // CapabilityHead identifies the canonical chain tip at the moment eth_capabilities was called. @@ -99,9 +111,14 @@ func (api *APIImpl) Capabilities(ctx context.Context) (*CapabilitiesResult, erro return nil, err } - avail := func(oldest uint64) CapabilityField { + avail := func(oldest uint64, dist prune.BlockAmount) CapabilityField { o := hexutil.Uint64(oldest) - return CapabilityField{OldestBlock: &o} + f := CapabilityField{OldestBlock: &o} + if d, ok := dist.(prune.Distance); ok && d != prune.DefaultBlocksPruneMode && d != prune.KeepAllBlocksPruneMode { + rb := hexutil.Uint64(d) + f.DeleteStrategy = &DeleteStrategy{Type: "window", RetentionBlocks: rb} + } + return f } // For KeepAllBlocksPruneMode (MaxUint64-1) and DefaultBlocksPruneMode (MaxUint64), @@ -117,20 +134,20 @@ func (api *APIImpl) Capabilities(ctx context.Context) (*CapabilitiesResult, erro var stateproofs CapabilityField if keepExecutionProofs { - stateproofs = avail(stateOldest) + stateproofs = avail(stateOldest, pruneMode.History) } else { stateproofs = CapabilityField{Disabled: true} } return &CapabilitiesResult{ Head: CapabilityHead{Number: hexutil.Uint64(headBlock), Hash: headHash}, - State: avail(stateOldest), - Tx: avail(blocksOldest), - Logs: avail(stateOldest), + State: avail(stateOldest, pruneMode.History), + Tx: avail(blocksOldest, pruneMode.Blocks), + Logs: avail(stateOldest, pruneMode.History), // receipts are read from DB (RCacheDomain) if --persist.receipts is enabled, otherwise // recalculated via re-execution; in both cases the available range matches state history - Receipts: avail(stateOldest), - Blocks: avail(blocksOldest), + Receipts: avail(stateOldest, pruneMode.History), + Blocks: avail(blocksOldest, pruneMode.Blocks), StateProofs: stateproofs, }, nil } diff --git a/rpc/jsonrpc/eth_system_test.go b/rpc/jsonrpc/eth_system_test.go index 286e14dc599..236f168a639 100644 --- a/rpc/jsonrpc/eth_system_test.go +++ b/rpc/jsonrpc/eth_system_test.go @@ -111,6 +111,12 @@ func TestCapabilities(t *testing.T) { require.NotNil(t, f.OldestBlock) return uint64(*f.OldestBlock) } + window := func(t *testing.T, f CapabilityField) uint64 { + t.Helper() + require.NotNil(t, f.DeleteStrategy) + require.Equal(t, "window", f.DeleteStrategy.Type) + return uint64(f.DeleteStrategy.RetentionBlocks) + } t.Run("archive_no_commitment", func(t *testing.T) { t.Parallel() @@ -124,6 +130,10 @@ func TestCapabilities(t *testing.T) { require.Equal(t, uint64(0), oldest(t, result.Logs)) require.Equal(t, uint64(0), oldest(t, result.Receipts)) require.Equal(t, uint64(0), oldest(t, result.Blocks)) + // archive keeps everything: no delete strategy on any field + require.Nil(t, result.State.DeleteStrategy) + require.Nil(t, result.Tx.DeleteStrategy) + require.Nil(t, result.Blocks.DeleteStrategy) require.True(t, result.StateProofs.Disabled) require.Nil(t, result.StateProofs.OldestBlock) }) @@ -136,6 +146,7 @@ func TestCapabilities(t *testing.T) { require.Equal(t, uint64(0), oldest(t, result.State)) require.False(t, result.StateProofs.Disabled) require.Equal(t, uint64(0), oldest(t, result.StateProofs)) + require.Nil(t, result.StateProofs.DeleteStrategy) }) t.Run("full_no_commitment", func(t *testing.T) { @@ -144,13 +155,18 @@ func TestCapabilities(t *testing.T) { result, err := api.Capabilities(t.Context()) require.NoError(t, err) pruned := head - testPruneDistance - // state/logs/receipts limited to history prune distance + // state/logs/receipts: finite history window require.Equal(t, pruned, oldest(t, result.State)) require.Equal(t, pruned, oldest(t, result.Logs)) require.Equal(t, pruned, oldest(t, result.Receipts)) - // full keeps all block snapshots: tx and blocks start from 0 + require.Equal(t, testPruneDistance, window(t, result.State)) + require.Equal(t, testPruneDistance, window(t, result.Logs)) + require.Equal(t, testPruneDistance, window(t, result.Receipts)) + // full keeps all block snapshots via DefaultBlocksPruneMode: no delete strategy require.Equal(t, uint64(0), oldest(t, result.Tx)) require.Equal(t, uint64(0), oldest(t, result.Blocks)) + require.Nil(t, result.Tx.DeleteStrategy) + require.Nil(t, result.Blocks.DeleteStrategy) require.True(t, result.StateProofs.Disabled) }) @@ -161,6 +177,7 @@ func TestCapabilities(t *testing.T) { require.NoError(t, err) require.Equal(t, head-testPruneDistance, oldest(t, result.StateProofs)) require.False(t, result.StateProofs.Disabled) + require.Equal(t, testPruneDistance, window(t, result.StateProofs)) }) t.Run("minimal_no_commitment", func(t *testing.T) { @@ -175,6 +192,9 @@ func TestCapabilities(t *testing.T) { require.Equal(t, pruned, oldest(t, result.Logs)) require.Equal(t, pruned, oldest(t, result.Receipts)) require.Equal(t, pruned, oldest(t, result.Blocks)) + require.Equal(t, testPruneDistance, window(t, result.State)) + require.Equal(t, testPruneDistance, window(t, result.Tx)) + require.Equal(t, testPruneDistance, window(t, result.Blocks)) require.True(t, result.StateProofs.Disabled) }) @@ -185,6 +205,7 @@ func TestCapabilities(t *testing.T) { require.NoError(t, err) require.Equal(t, head-testPruneDistance, oldest(t, result.StateProofs)) require.False(t, result.StateProofs.Disabled) + require.Equal(t, testPruneDistance, window(t, result.StateProofs)) }) // full mode on a chain with MergeHeight: pre-merge tx/blocks are not kept, From 55a7b08ed813009f0c5faa44598e93f7ac81adfa Mon Sep 17 00:00:00 2001 From: lupin012 <58134934+lupin012@users.noreply.github.com.> Date: Wed, 13 May 2026 20:50:32 +0200 Subject: [PATCH 10/16] rpc: extract deleteStrategyWindow constant, deduplicate avail calls 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 --- rpc/jsonrpc/eth_system.go | 19 +++++++++++++------ rpc/jsonrpc/eth_system_test.go | 2 +- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/rpc/jsonrpc/eth_system.go b/rpc/jsonrpc/eth_system.go index 7dfd81539f1..a4ebde7c420 100644 --- a/rpc/jsonrpc/eth_system.go +++ b/rpc/jsonrpc/eth_system.go @@ -41,6 +41,10 @@ import ( "github.com/erigontech/erigon/rpc/rpchelper" ) +// deleteStrategyWindow is the only currently defined deleteStrategy type in the +// execution-apis spec: a sliding window of RetentionBlocks blocks. +const deleteStrategyWindow = "window" + // DeleteStrategy describes how a node removes old data for a category. // Currently only the "window" type is defined: the node keeps a sliding // window of RetentionBlocks blocks and discards everything older. @@ -116,7 +120,7 @@ func (api *APIImpl) Capabilities(ctx context.Context) (*CapabilitiesResult, erro f := CapabilityField{OldestBlock: &o} if d, ok := dist.(prune.Distance); ok && d != prune.DefaultBlocksPruneMode && d != prune.KeepAllBlocksPruneMode { rb := hexutil.Uint64(d) - f.DeleteStrategy = &DeleteStrategy{Type: "window", RetentionBlocks: rb} + f.DeleteStrategy = &DeleteStrategy{Type: deleteStrategyWindow, RetentionBlocks: rb} } return f } @@ -139,15 +143,18 @@ func (api *APIImpl) Capabilities(ctx context.Context) (*CapabilitiesResult, erro stateproofs = CapabilityField{Disabled: true} } + // state, logs and receipts share the same history prune distance. + stateField := avail(stateOldest, pruneMode.History) + blocksField := avail(blocksOldest, pruneMode.Blocks) return &CapabilitiesResult{ Head: CapabilityHead{Number: hexutil.Uint64(headBlock), Hash: headHash}, - State: avail(stateOldest, pruneMode.History), - Tx: avail(blocksOldest, pruneMode.Blocks), - Logs: avail(stateOldest, pruneMode.History), + State: stateField, + Tx: blocksField, + Logs: stateField, // receipts are read from DB (RCacheDomain) if --persist.receipts is enabled, otherwise // recalculated via re-execution; in both cases the available range matches state history - Receipts: avail(stateOldest, pruneMode.History), - Blocks: avail(blocksOldest, pruneMode.Blocks), + Receipts: stateField, + Blocks: blocksField, StateProofs: stateproofs, }, nil } diff --git a/rpc/jsonrpc/eth_system_test.go b/rpc/jsonrpc/eth_system_test.go index 236f168a639..ab5137fbdca 100644 --- a/rpc/jsonrpc/eth_system_test.go +++ b/rpc/jsonrpc/eth_system_test.go @@ -114,7 +114,7 @@ func TestCapabilities(t *testing.T) { window := func(t *testing.T, f CapabilityField) uint64 { t.Helper() require.NotNil(t, f.DeleteStrategy) - require.Equal(t, "window", f.DeleteStrategy.Type) + require.Equal(t, deleteStrategyWindow, f.DeleteStrategy.Type) return uint64(f.DeleteStrategy.RetentionBlocks) } From ca4fafe298040d447d74a3c1704a12196838ed66 Mon Sep 17 00:00:00 2001 From: lupin012 <58134934+lupin012@users.noreply.github.com.> Date: Thu, 14 May 2026 19:56:19 +0200 Subject: [PATCH 11/16] rpc: fix eth_capabilities receipts range for --persist.receipts, clarify 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 --- rpc/jsonrpc/eth_system.go | 37 +++++++++++++++++++++++++--------- rpc/jsonrpc/eth_system_test.go | 23 +++++++++++++++++++-- 2 files changed, 48 insertions(+), 12 deletions(-) diff --git a/rpc/jsonrpc/eth_system.go b/rpc/jsonrpc/eth_system.go index a4ebde7c420..5e7ae79ba1b 100644 --- a/rpc/jsonrpc/eth_system.go +++ b/rpc/jsonrpc/eth_system.go @@ -26,6 +26,7 @@ import ( "github.com/erigontech/erigon/common" "github.com/erigontech/erigon/common/hexutil" "github.com/erigontech/erigon/db/kv" + "github.com/erigontech/erigon/db/kv/kvcfg" "github.com/erigontech/erigon/db/kv/prune" "github.com/erigontech/erigon/db/rawdb" "github.com/erigontech/erigon/execution/chain" @@ -125,8 +126,10 @@ func (api *APIImpl) Capabilities(ctx context.Context) (*CapabilitiesResult, erro return f } - // For KeepAllBlocksPruneMode (MaxUint64-1) and DefaultBlocksPruneMode (MaxUint64), - // PruneTo returns 0 because distance > headBlock. + // PruneTo returns 0 for both KeepAllBlocksPruneMode (MaxUint64-1, keep all) and + // DefaultBlocksPruneMode (MaxUint64, chain-specific history expiry) because their + // distances exceed headBlock. For DefaultBlocksPruneMode the true oldest is then + // adjusted below using MergeHeight where applicable. stateOldest := pruneMode.History.PruneTo(headBlock) blocksOldest := pruneMode.Blocks.PruneTo(headBlock) // DefaultBlocksPruneMode uses chain-specific history expiry: on chains that have @@ -143,17 +146,31 @@ func (api *APIImpl) Capabilities(ctx context.Context) (*CapabilitiesResult, erro stateproofs = CapabilityField{Disabled: true} } - // state, logs and receipts share the same history prune distance. + persistReceipts, err := kvcfg.PersistReceipts.Enabled(tx) + if err != nil { + return nil, err + } + stateField := avail(stateOldest, pruneMode.History) blocksField := avail(blocksOldest, pruneMode.Blocks) + + var receiptsField CapabilityField + if persistReceipts { + // --persist.receipts stores receipts for all blocks from genesis. + zero := hexutil.Uint64(0) + receiptsField = CapabilityField{OldestBlock: &zero} + } else { + // Without --persist.receipts, receipts are re-executed on demand; the + // available range is bounded by the state history prune window. + receiptsField = stateField + } + return &CapabilitiesResult{ - Head: CapabilityHead{Number: hexutil.Uint64(headBlock), Hash: headHash}, - State: stateField, - Tx: blocksField, - Logs: stateField, - // receipts are read from DB (RCacheDomain) if --persist.receipts is enabled, otherwise - // recalculated via re-execution; in both cases the available range matches state history - Receipts: stateField, + Head: CapabilityHead{Number: hexutil.Uint64(headBlock), Hash: headHash}, + State: stateField, + Tx: blocksField, + Logs: stateField, + Receipts: receiptsField, Blocks: blocksField, StateProofs: stateproofs, }, nil diff --git a/rpc/jsonrpc/eth_system_test.go b/rpc/jsonrpc/eth_system_test.go index ab5137fbdca..7590c749d4d 100644 --- a/rpc/jsonrpc/eth_system_test.go +++ b/rpc/jsonrpc/eth_system_test.go @@ -33,6 +33,7 @@ import ( "github.com/erigontech/erigon/common" "github.com/erigontech/erigon/common/crypto" "github.com/erigontech/erigon/common/hexutil" + "github.com/erigontech/erigon/db/kv/kvcfg" "github.com/erigontech/erigon/db/kv/prune" "github.com/erigontech/erigon/db/rawdb" "github.com/erigontech/erigon/execution/chain" @@ -63,7 +64,7 @@ func TestCapabilities(t *testing.T) { Blocks: prune.Distance(testPruneDistance), } - setupAPI := func(t *testing.T, pruneMode prune.Mode, commitmentHistory bool) (*APIImpl, uint64) { + setupAPI := func(t *testing.T, pruneMode prune.Mode, commitmentHistory bool, persistReceiptsOpts ...bool) (*APIImpl, uint64) { t.Helper() key, _ := crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291") addr := crypto.PubkeyToAddress(key.PublicKey) @@ -95,6 +96,9 @@ func TestCapabilities(t *testing.T) { _, err = prune.EnsureNotChanged(tx, pruneMode) require.NoError(t, err) require.NoError(t, rawdb.WriteDBCommitmentHistoryEnabled(tx, commitmentHistory)) + if len(persistReceiptsOpts) > 0 && persistReceiptsOpts[0] { + require.NoError(t, kvcfg.PersistReceipts.ForceWrite(tx, true)) + } require.NoError(t, tx.Commit()) roTx, err := m.DB.BeginTemporalRo(ctx) @@ -162,7 +166,8 @@ func TestCapabilities(t *testing.T) { require.Equal(t, testPruneDistance, window(t, result.State)) require.Equal(t, testPruneDistance, window(t, result.Logs)) require.Equal(t, testPruneDistance, window(t, result.Receipts)) - // full keeps all block snapshots via DefaultBlocksPruneMode: no delete strategy + // DefaultBlocksPruneMode: no explicit window; oldest depends on chain history expiry + // (here 0 because the test chain has no MergeHeight) require.Equal(t, uint64(0), oldest(t, result.Tx)) require.Equal(t, uint64(0), oldest(t, result.Blocks)) require.Nil(t, result.Tx.DeleteStrategy) @@ -170,6 +175,20 @@ func TestCapabilities(t *testing.T) { require.True(t, result.StateProofs.Disabled) }) + t.Run("full_persist_receipts", func(t *testing.T) { + t.Parallel() + api, head := setupAPI(t, testFullMode, false, true) + result, err := api.Capabilities(t.Context()) + require.NoError(t, err) + pruned := head - testPruneDistance + // --persist.receipts: receipts available from genesis, not limited by state prune window. + require.Equal(t, uint64(0), oldest(t, result.Receipts)) + require.Nil(t, result.Receipts.DeleteStrategy) + // state and logs still respect history prune distance + require.Equal(t, pruned, oldest(t, result.State)) + require.Equal(t, pruned, oldest(t, result.Logs)) + }) + t.Run("full_with_commitment", func(t *testing.T) { t.Parallel() api, head := setupAPI(t, testFullMode, true) From a033482f68ef091151a9dc8eee54810ccb565a16 Mon Sep 17 00:00:00 2001 From: lupin012 <58134934+lupin012@users.noreply.github.com.> Date: Thu, 14 May 2026 20:07:06 +0200 Subject: [PATCH 12/16] update rpc test version to 2.10.1 --- .github/workflows/scripts/rpc_version.env | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/scripts/rpc_version.env b/.github/workflows/scripts/rpc_version.env index 4c33706782f..c6803eb2039 100644 --- a/.github/workflows/scripts/rpc_version.env +++ b/.github/workflows/scripts/rpc_version.env @@ -1 +1 @@ -RPC_VERSION=v2.10.0 +RPC_VERSION=v2.10.1 From 1445da50b69eb427c6e42a5795a0019bbeb60f36 Mon Sep 17 00:00:00 2001 From: lupin012 <58134934+lupin012@users.noreply.github.com.> Date: Tue, 19 May 2026 21:49:23 +0200 Subject: [PATCH 13/16] fix after review Andrew --- cmd/rpcdaemon/README.md | 1 + rpc/jsonrpc/eth_system.go | 20 ++++--- rpc/jsonrpc/eth_system_test.go | 105 ++++++++++++++++++++++++++++++++- 3 files changed, 116 insertions(+), 10 deletions(-) diff --git a/cmd/rpcdaemon/README.md b/cmd/rpcdaemon/README.md index 4a267a74945..e5ec220ff20 100644 --- a/cmd/rpcdaemon/README.md +++ b/cmd/rpcdaemon/README.md @@ -260,6 +260,7 @@ The following table shows the current implementation status of Erigon's RPC daem | eth_feeHistory | Yes | | | eth_blobBaseFee | Yes | | | eth_config | Yes | EIP-7910 | +| eth_capabilities | Yes | execution-apis#755 | | | | | | eth_getBlockByHash | Yes | | | eth_getBlockByNumber | Yes | | diff --git a/rpc/jsonrpc/eth_system.go b/rpc/jsonrpc/eth_system.go index 5e7ae79ba1b..10aef19a03f 100644 --- a/rpc/jsonrpc/eth_system.go +++ b/rpc/jsonrpc/eth_system.go @@ -60,7 +60,7 @@ type DeleteStrategy struct { // does not hold that data at all; otherwise OldestBlock is the lowest block number available. // DeleteStrategy is set when the node uses a finite retention window. type CapabilityField struct { - Disabled bool `json:"disabled"` + Disabled bool `json:"disabled,omitempty"` OldestBlock *hexutil.Uint64 `json:"oldestBlock,omitempty"` DeleteStrategy *DeleteStrategy `json:"deleteStrategy,omitempty"` } @@ -154,22 +154,28 @@ func (api *APIImpl) Capabilities(ctx context.Context) (*CapabilitiesResult, erro stateField := avail(stateOldest, pruneMode.History) blocksField := avail(blocksOldest, pruneMode.Blocks) - var receiptsField CapabilityField + var receiptsField, logsField CapabilityField if persistReceipts { - // --persist.receipts stores receipts for all blocks from genesis. + // --persist.receipts stores receipts (which include logs) for all blocks from genesis. zero := hexutil.Uint64(0) receiptsField = CapabilityField{OldestBlock: &zero} + logsField = receiptsField } else { - // Without --persist.receipts, receipts are re-executed on demand; the - // available range is bounded by the state history prune window. - receiptsField = stateField + // Without --persist.receipts, receipts are re-executed on demand, requiring both state + // history and the block body. Use the more restrictive of the two oldest-block bounds. + if blocksOldest > stateOldest { + receiptsField = avail(blocksOldest, pruneMode.Blocks) + } else { + receiptsField = stateField + } + logsField = stateField } return &CapabilitiesResult{ Head: CapabilityHead{Number: hexutil.Uint64(headBlock), Hash: headHash}, State: stateField, Tx: blocksField, - Logs: stateField, + Logs: logsField, Receipts: receiptsField, Blocks: blocksField, StateProofs: stateproofs, diff --git a/rpc/jsonrpc/eth_system_test.go b/rpc/jsonrpc/eth_system_test.go index 7590c749d4d..bc65f5a7c52 100644 --- a/rpc/jsonrpc/eth_system_test.go +++ b/rpc/jsonrpc/eth_system_test.go @@ -181,12 +181,13 @@ func TestCapabilities(t *testing.T) { result, err := api.Capabilities(t.Context()) require.NoError(t, err) pruned := head - testPruneDistance - // --persist.receipts: receipts available from genesis, not limited by state prune window. + // --persist.receipts: receipts and logs available from genesis, not limited by state prune window. require.Equal(t, uint64(0), oldest(t, result.Receipts)) require.Nil(t, result.Receipts.DeleteStrategy) - // state and logs still respect history prune distance + require.Equal(t, uint64(0), oldest(t, result.Logs)) + require.Nil(t, result.Logs.DeleteStrategy) + // state still respects history prune distance require.Equal(t, pruned, oldest(t, result.State)) - require.Equal(t, pruned, oldest(t, result.Logs)) }) t.Run("full_with_commitment", func(t *testing.T) { @@ -227,6 +228,26 @@ func TestCapabilities(t *testing.T) { require.Equal(t, testPruneDistance, window(t, result.StateProofs)) }) + t.Run("minimal_persist_receipts", func(t *testing.T) { + t.Parallel() + api, head := setupAPI(t, testMinimalMode, false, true) + result, err := api.Capabilities(t.Context()) + require.NoError(t, err) + pruned := head - testPruneDistance + // --persist.receipts overrides the prune window: receipts and logs available from genesis. + require.Equal(t, uint64(0), oldest(t, result.Receipts)) + require.Nil(t, result.Receipts.DeleteStrategy) + require.Equal(t, uint64(0), oldest(t, result.Logs)) + require.Nil(t, result.Logs.DeleteStrategy) + // state, tx, and blocks still respect the minimal prune window. + require.Equal(t, pruned, oldest(t, result.State)) + require.Equal(t, pruned, oldest(t, result.Tx)) + require.Equal(t, pruned, oldest(t, result.Blocks)) + require.Equal(t, testPruneDistance, window(t, result.State)) + require.Equal(t, testPruneDistance, window(t, result.Tx)) + require.Equal(t, testPruneDistance, window(t, result.Blocks)) + }) + // full mode on a chain with MergeHeight: pre-merge tx/blocks are not kept, // so tx.oldestBlock and blocks.oldestBlock must reflect the merge point, not 0. t.Run("full_merge_height", func(t *testing.T) { @@ -275,6 +296,84 @@ func TestCapabilities(t *testing.T) { // state is still limited by history prune distance require.Equal(t, uint64(chainSize)-testPruneDistance, oldest(t, result.State)) }) + + // When MergeHeight > head-pruneDistance, pre-merge blocks are absent (DefaultBlocksPruneMode) + // so receipts.oldestBlock must be clamped to the merge point, not stateOldest. + t.Run("full_merge_height_receipts_seam", func(t *testing.T) { + t.Parallel() + mergeAt := uint64(chainSize - 2) // 18 > head-testPruneDistance=10 + + key, _ := crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291") + addr := crypto.PubkeyToAddress(key.PublicKey) + + var cfgWithMerge chain.Config + require.NoError(t, copier.CopyWithOption(&cfgWithMerge, chain.TestChainBerlinConfig, copier.Option{DeepCopy: true})) + cfgWithMerge.MergeHeight = &mergeAt + gspec := &types.Genesis{ + Config: &cfgWithMerge, + Alloc: types.GenesisAlloc{addr: {Balance: big.NewInt(math.MaxInt64)}}, + } + m := execmoduletester.New(t, execmoduletester.WithGenesisSpec(gspec), execmoduletester.WithKey(key)) + + signer := types.LatestSigner(gspec.Config) + c, err := blockgen.GenerateChain(m.ChainConfig, m.Genesis, m.Engine, m.DB, chainSize, func(i int, b *blockgen.BlockGen) { + b.SetCoinbase(common.Address{1}) + tx, txErr := types.SignTx(types.NewTransaction(b.TxNonce(addr), common.HexToAddress("deadbeef"), uint256.NewInt(1), 21000, uint256.NewInt(uint64(i+1)*common.GWei), nil), *signer, key) + if txErr != nil { + t.Fatal(txErr) + } + b.AddTx(tx) + }) + require.NoError(t, err) + require.NoError(t, m.InsertChain(c)) + + ctx := t.Context() + dbTx, err := m.DB.BeginTemporalRw(ctx) + require.NoError(t, err) + defer dbTx.Rollback() + _, err = prune.EnsureNotChanged(dbTx, testFullMode) + require.NoError(t, err) + require.NoError(t, rawdb.WriteDBCommitmentHistoryEnabled(dbTx, false)) + require.NoError(t, dbTx.Commit()) + + api := newEthApiForTest(newBaseApiForTest(m), m.DB, nil, nil) + result, err := api.Capabilities(ctx) + require.NoError(t, err) + stateOldest := uint64(chainSize) - testPruneDistance // = 10 + // blocks constraint (mergeAt=18) is tighter than state (10): receipts must reflect it + require.Equal(t, mergeAt, oldest(t, result.Receipts)) + require.Nil(t, result.Receipts.DeleteStrategy) + // logs only require state history, not block bodies + require.Equal(t, stateOldest, oldest(t, result.Logs)) + require.Equal(t, testPruneDistance, window(t, result.Logs)) + }) + + // head_zero pins that ReadCanonicalHash(tx, 0) returns the genesis hash, not the zero hash. + t.Run("head_zero", func(t *testing.T) { + t.Parallel() + key, _ := crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291") + addr := crypto.PubkeyToAddress(key.PublicKey) + gspec := &types.Genesis{ + Config: chain.TestChainBerlinConfig, + Alloc: types.GenesisAlloc{addr: {Balance: big.NewInt(math.MaxInt64)}}, + } + m := execmoduletester.New(t, execmoduletester.WithGenesisSpec(gspec), execmoduletester.WithKey(key)) + + ctx := t.Context() + dbTx, err := m.DB.BeginTemporalRw(ctx) + require.NoError(t, err) + defer dbTx.Rollback() + _, err = prune.EnsureNotChanged(dbTx, prune.ArchiveMode) + require.NoError(t, err) + require.NoError(t, rawdb.WriteDBCommitmentHistoryEnabled(dbTx, false)) + require.NoError(t, dbTx.Commit()) + + api := newEthApiForTest(newBaseApiForTest(m), m.DB, nil, nil) + result, err := api.Capabilities(ctx) + require.NoError(t, err) + require.Equal(t, uint64(0), uint64(result.Head.Number)) + require.NotEqual(t, common.Hash{}, result.Head.Hash) + }) } func TestGasPrice(t *testing.T) { From 810a9424e35158ff4c8366083a5c9b2596562c7d Mon Sep 17 00:00:00 2001 From: lupin012 <58134934+lupin012@users.noreply.github.com.> Date: Wed, 20 May 2026 10:34:44 +0200 Subject: [PATCH 14/16] rpc, p2p/eth: address review feedback on eth_capabilities - 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 --- execution/abi/bind/backends/simulated.go | 3 +- execution/tests/blockchain_test.go | 2 +- p2p/protocols/eth/handlers.go | 14 +- p2p/protocols/eth/handlers_test.go | 2 +- rpc/jsonrpc/eth_receipts.go | 3 +- rpc/jsonrpc/eth_system.go | 24 +++- rpc/jsonrpc/eth_system_test.go | 149 +++++++++------------ rpc/jsonrpc/receipts/handler_test.go | 2 +- rpc/jsonrpc/receipts/receipts_generator.go | 11 +- 9 files changed, 105 insertions(+), 105 deletions(-) diff --git a/execution/abi/bind/backends/simulated.go b/execution/abi/bind/backends/simulated.go index 07782ca0cf4..563c9712224 100644 --- a/execution/abi/bind/backends/simulated.go +++ b/execution/abi/bind/backends/simulated.go @@ -57,6 +57,7 @@ import ( "github.com/erigontech/erigon/execution/vm" "github.com/erigontech/erigon/execution/vm/evmtypes" "github.com/erigontech/erigon/p2p/event" + eth "github.com/erigontech/erigon/p2p/protocols/eth" "github.com/erigontech/erigon/polygon/bor" ) @@ -290,7 +291,7 @@ func (b *SimulatedBackend) TransactionReceipt(ctx context.Context, txHash common return nil, err } // Read all the receipts from the block and return the one with the matching hash - receipts, err := b.m.ReceiptsReader.GetReceipts(ctx, b.m.ChainConfig, tx, block, commitmentHistoryEnabled) + receipts, err := b.m.ReceiptsReader.GetReceipts(ctx, b.m.ChainConfig, tx, block, eth.ReceiptsOpts{CommitmentHistoryEnabled: commitmentHistoryEnabled}) if err != nil { panic(err) } diff --git a/execution/tests/blockchain_test.go b/execution/tests/blockchain_test.go index a127249b6fb..65630fa4524 100644 --- a/execution/tests/blockchain_test.go +++ b/execution/tests/blockchain_test.go @@ -610,7 +610,7 @@ func readReceipt(db kv.TemporalTx, txHash common.Hash, m *execmoduletester.ExecM return nil, common.Hash{}, 0, 0, err } // Read all the receipts from the block and return the one with the matching hash - receipts, err := m.ReceiptsReader.GetReceipts(context.Background(), m.ChainConfig, db, b, commitmentHistoryEnabled) + receipts, err := m.ReceiptsReader.GetReceipts(context.Background(), m.ChainConfig, db, b, eth.ReceiptsOpts{CommitmentHistoryEnabled: commitmentHistoryEnabled}) if err != nil { return nil, common.Hash{}, 0, 0, err } diff --git a/p2p/protocols/eth/handlers.go b/p2p/protocols/eth/handlers.go index 6928282bf1e..0048a3e168b 100644 --- a/p2p/protocols/eth/handlers.go +++ b/p2p/protocols/eth/handlers.go @@ -222,8 +222,14 @@ func AnswerGetBlockAccessListsQuery(db kv.Tx, query GetBlockAccessListsPacket, b return bals } +// ReceiptsOpts carries per-call options for GetReceipts. +// Using a struct keeps the ReceiptsGetter interface stable when new options are added. +type ReceiptsOpts struct { + CommitmentHistoryEnabled bool +} + type ReceiptsGetter interface { - GetReceipts(ctx context.Context, cfg *chain.Config, tx kv.TemporalTx, block *types.Block, commitmentHistoryEnabled bool) (types.Receipts, error) + GetReceipts(ctx context.Context, cfg *chain.Config, tx kv.TemporalTx, block *types.Block, opts ReceiptsOpts) (types.Receipts, error) GetCachedReceipts(ctx context.Context, blockHash common.Hash) (types.Receipts, bool) } @@ -373,10 +379,10 @@ func AnswerGetReceiptsQuery(ctx context.Context, cfg *chain.Config, receiptsGett } // Only read the flag when there is work to do; full cache hits skip this DB lookup. - var commitmentHistoryEnabled bool + var receiptsOpts ReceiptsOpts if pendingIndex < len(query) { var err error - commitmentHistoryEnabled, _, err = rawdb.ReadDBCommitmentHistoryEnabled(db) + receiptsOpts.CommitmentHistoryEnabled, _, err = rawdb.ReadDBCommitmentHistoryEnabled(db) if err != nil { return nil, false, err } @@ -400,7 +406,7 @@ func AnswerGetReceiptsQuery(ctx context.Context, cfg *chain.Config, receiptsGett return nil, false, nil } - results, err := receiptsGetter.GetReceipts(ctx, cfg, db, b, commitmentHistoryEnabled) + results, err := receiptsGetter.GetReceipts(ctx, cfg, db, b, receiptsOpts) if err != nil { return nil, false, err } diff --git a/p2p/protocols/eth/handlers_test.go b/p2p/protocols/eth/handlers_test.go index 9fb2e6a5fe8..218704dd91f 100644 --- a/p2p/protocols/eth/handlers_test.go +++ b/p2p/protocols/eth/handlers_test.go @@ -25,7 +25,7 @@ func (m *mockReceiptsGetter) GetCachedReceipts(_ context.Context, hash common.Ha return r, ok } -func (m *mockReceiptsGetter) GetReceipts(_ context.Context, _ *chain.Config, _ kv.TemporalTx, _ *types.Block, _ bool) (types.Receipts, error) { +func (m *mockReceiptsGetter) GetReceipts(_ context.Context, _ *chain.Config, _ kv.TemporalTx, _ *types.Block, _ ReceiptsOpts) (types.Receipts, error) { panic("not expected in cache-only tests") } diff --git a/rpc/jsonrpc/eth_receipts.go b/rpc/jsonrpc/eth_receipts.go index 3a746b51d19..0e5e4eb492f 100644 --- a/rpc/jsonrpc/eth_receipts.go +++ b/rpc/jsonrpc/eth_receipts.go @@ -23,6 +23,7 @@ import ( "github.com/RoaringBitmap/roaring/v2" + eth "github.com/erigontech/erigon/p2p/protocols/eth" "github.com/erigontech/erigon/rpc/jsonrpc/receipts" "github.com/erigontech/erigon/common" @@ -70,7 +71,7 @@ func (api *BaseAPI) getReceipts(ctx context.Context, tx kv.TemporalTx, block *ty if err != nil { return nil, err } - return api.receiptsGenerator.GetReceipts(ctx, chainConfig, tx, block, commitmentHistoryEnabled) + return api.receiptsGenerator.GetReceipts(ctx, chainConfig, tx, block, eth.ReceiptsOpts{CommitmentHistoryEnabled: commitmentHistoryEnabled}) } func (api *BaseAPI) getReceipt(ctx context.Context, cc *chain.Config, tx kv.TemporalTx, header *types.Header, txn types.Transaction, index int, txNum uint64, postState *receipts.PostStateInfo) (*types.Receipt, error) { diff --git a/rpc/jsonrpc/eth_system.go b/rpc/jsonrpc/eth_system.go index 10aef19a03f..28b3901f673 100644 --- a/rpc/jsonrpc/eth_system.go +++ b/rpc/jsonrpc/eth_system.go @@ -60,7 +60,7 @@ type DeleteStrategy struct { // does not hold that data at all; otherwise OldestBlock is the lowest block number available. // DeleteStrategy is set when the node uses a finite retention window. type CapabilityField struct { - Disabled bool `json:"disabled,omitempty"` + Disabled bool `json:"disabled"` OldestBlock *hexutil.Uint64 `json:"oldestBlock,omitempty"` DeleteStrategy *DeleteStrategy `json:"deleteStrategy,omitempty"` } @@ -107,11 +107,12 @@ func (api *APIImpl) Capabilities(ctx context.Context) (*CapabilitiesResult, erro return nil, err } - headBlock, err := rpchelper.GetLatestBlockNumber(api.filters.WithOverlay(tx)) + overlayTx := api.filters.WithOverlay(tx) + headBlock, err := rpchelper.GetLatestBlockNumber(overlayTx) if err != nil { return nil, err } - headHash, err := rawdb.ReadCanonicalHash(tx, headBlock) + headHash, err := rawdb.ReadCanonicalHash(overlayTx, headBlock) if err != nil { return nil, err } @@ -156,9 +157,15 @@ func (api *APIImpl) Capabilities(ctx context.Context) (*CapabilitiesResult, erro var receiptsField, logsField CapabilityField if persistReceipts { - // --persist.receipts stores receipts (which include logs) for all blocks from genesis. - zero := hexutil.Uint64(0) - receiptsField = CapabilityField{OldestBlock: &zero} + // DefaultBlocksPruneMode means pre-merge blocks were never downloaded on merge chains, + // so their receipts were never persisted. All other modes downloaded every block first; + // receipts survive block-body pruning in a separate table and are available from genesis. + var oldest uint64 + if pruneMode.Blocks == prune.DefaultBlocksPruneMode { + oldest = blocksOldest // = MergeHeight on merge chains, 0 otherwise + } + o := hexutil.Uint64(oldest) + receiptsField = CapabilityField{OldestBlock: &o} logsField = receiptsField } else { // Without --persist.receipts, receipts are re-executed on demand, requiring both state @@ -168,7 +175,10 @@ func (api *APIImpl) Capabilities(ctx context.Context) (*CapabilitiesResult, erro } else { receiptsField = stateField } - logsField = stateField + // getLogsV3 uses TxnByIdxInBlock to reconstruct receipts for log filtering; that + // call returns nil when block bodies are absent. Matches in [stateOldest, blocksOldest) + // are silently dropped, so the effective oldest for logs equals receipts. + logsField = receiptsField } return &CapabilitiesResult{ diff --git a/rpc/jsonrpc/eth_system_test.go b/rpc/jsonrpc/eth_system_test.go index bc65f5a7c52..d9524d62b11 100644 --- a/rpc/jsonrpc/eth_system_test.go +++ b/rpc/jsonrpc/eth_system_test.go @@ -64,7 +64,7 @@ func TestCapabilities(t *testing.T) { Blocks: prune.Distance(testPruneDistance), } - setupAPI := func(t *testing.T, pruneMode prune.Mode, commitmentHistory bool, persistReceiptsOpts ...bool) (*APIImpl, uint64) { + setupAPI := func(t *testing.T, pruneMode prune.Mode, commitmentHistory bool, persistReceipts bool) (*APIImpl, uint64) { t.Helper() key, _ := crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291") addr := crypto.PubkeyToAddress(key.PublicKey) @@ -96,7 +96,7 @@ func TestCapabilities(t *testing.T) { _, err = prune.EnsureNotChanged(tx, pruneMode) require.NoError(t, err) require.NoError(t, rawdb.WriteDBCommitmentHistoryEnabled(tx, commitmentHistory)) - if len(persistReceiptsOpts) > 0 && persistReceiptsOpts[0] { + if persistReceipts { require.NoError(t, kvcfg.PersistReceipts.ForceWrite(tx, true)) } require.NoError(t, tx.Commit()) @@ -110,6 +110,43 @@ func TestCapabilities(t *testing.T) { return newEthApiForTest(newBaseApiForTest(m), m.DB, nil, nil), head } + setupAPIWithMerge := func(t *testing.T, mergeAt uint64, persistReceipts bool) *APIImpl { + t.Helper() + key, _ := crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291") + addr := crypto.PubkeyToAddress(key.PublicKey) + var cfgWithMerge chain.Config + require.NoError(t, copier.CopyWithOption(&cfgWithMerge, chain.TestChainBerlinConfig, copier.Option{DeepCopy: true})) + cfgWithMerge.MergeHeight = &mergeAt + gspec := &types.Genesis{ + Config: &cfgWithMerge, + Alloc: types.GenesisAlloc{addr: {Balance: big.NewInt(math.MaxInt64)}}, + } + m := execmoduletester.New(t, execmoduletester.WithGenesisSpec(gspec), execmoduletester.WithKey(key)) + signer := types.LatestSigner(gspec.Config) + c, err := blockgen.GenerateChain(m.ChainConfig, m.Genesis, m.Engine, m.DB, chainSize, func(i int, b *blockgen.BlockGen) { + b.SetCoinbase(common.Address{1}) + tx, txErr := types.SignTx(types.NewTransaction(b.TxNonce(addr), common.HexToAddress("deadbeef"), uint256.NewInt(1), 21000, uint256.NewInt(uint64(i+1)*common.GWei), nil), *signer, key) + if txErr != nil { + t.Fatal(txErr) + } + b.AddTx(tx) + }) + require.NoError(t, err) + require.NoError(t, m.InsertChain(c)) + ctx := t.Context() + dbTx, err := m.DB.BeginTemporalRw(ctx) + require.NoError(t, err) + defer dbTx.Rollback() + _, err = prune.EnsureNotChanged(dbTx, testFullMode) + require.NoError(t, err) + require.NoError(t, rawdb.WriteDBCommitmentHistoryEnabled(dbTx, false)) + if persistReceipts { + require.NoError(t, kvcfg.PersistReceipts.ForceWrite(dbTx, true)) + } + require.NoError(t, dbTx.Commit()) + return newEthApiForTest(newBaseApiForTest(m), m.DB, nil, nil) + } + oldest := func(t *testing.T, f CapabilityField) uint64 { t.Helper() require.NotNil(t, f.OldestBlock) @@ -124,7 +161,7 @@ func TestCapabilities(t *testing.T) { t.Run("archive_no_commitment", func(t *testing.T) { t.Parallel() - api, head := setupAPI(t, prune.ArchiveMode, false) + api, head := setupAPI(t, prune.ArchiveMode, false, false) result, err := api.Capabilities(t.Context()) require.NoError(t, err) require.Equal(t, head, uint64(result.Head.Number)) @@ -144,7 +181,7 @@ func TestCapabilities(t *testing.T) { t.Run("archive_with_commitment", func(t *testing.T) { t.Parallel() - api, _ := setupAPI(t, prune.ArchiveMode, true) + api, _ := setupAPI(t, prune.ArchiveMode, true, false) result, err := api.Capabilities(t.Context()) require.NoError(t, err) require.Equal(t, uint64(0), oldest(t, result.State)) @@ -155,7 +192,7 @@ func TestCapabilities(t *testing.T) { t.Run("full_no_commitment", func(t *testing.T) { t.Parallel() - api, head := setupAPI(t, testFullMode, false) + api, head := setupAPI(t, testFullMode, false, false) result, err := api.Capabilities(t.Context()) require.NoError(t, err) pruned := head - testPruneDistance @@ -192,7 +229,7 @@ func TestCapabilities(t *testing.T) { t.Run("full_with_commitment", func(t *testing.T) { t.Parallel() - api, head := setupAPI(t, testFullMode, true) + api, head := setupAPI(t, testFullMode, true, false) result, err := api.Capabilities(t.Context()) require.NoError(t, err) require.Equal(t, head-testPruneDistance, oldest(t, result.StateProofs)) @@ -202,7 +239,7 @@ func TestCapabilities(t *testing.T) { t.Run("minimal_no_commitment", func(t *testing.T) { t.Parallel() - api, head := setupAPI(t, testMinimalMode, false) + api, head := setupAPI(t, testMinimalMode, false, false) result, err := api.Capabilities(t.Context()) require.NoError(t, err) pruned := head - testPruneDistance @@ -220,7 +257,7 @@ func TestCapabilities(t *testing.T) { t.Run("minimal_with_commitment", func(t *testing.T) { t.Parallel() - api, head := setupAPI(t, testMinimalMode, true) + api, head := setupAPI(t, testMinimalMode, true, false) result, err := api.Capabilities(t.Context()) require.NoError(t, err) require.Equal(t, head-testPruneDistance, oldest(t, result.StateProofs)) @@ -253,42 +290,8 @@ func TestCapabilities(t *testing.T) { t.Run("full_merge_height", func(t *testing.T) { t.Parallel() mergeAt := uint64(chainSize / 2) // = 10, well within the 20-block chain - - key, _ := crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291") - addr := crypto.PubkeyToAddress(key.PublicKey) - - var cfgWithMerge chain.Config - require.NoError(t, copier.CopyWithOption(&cfgWithMerge, chain.TestChainBerlinConfig, copier.Option{DeepCopy: true})) - cfgWithMerge.MergeHeight = &mergeAt - gspec := &types.Genesis{ - Config: &cfgWithMerge, - Alloc: types.GenesisAlloc{addr: {Balance: big.NewInt(math.MaxInt64)}}, - } - m := execmoduletester.New(t, execmoduletester.WithGenesisSpec(gspec), execmoduletester.WithKey(key)) - - signer := types.LatestSigner(gspec.Config) - c, err := blockgen.GenerateChain(m.ChainConfig, m.Genesis, m.Engine, m.DB, chainSize, func(i int, b *blockgen.BlockGen) { - b.SetCoinbase(common.Address{1}) - tx, txErr := types.SignTx(types.NewTransaction(b.TxNonce(addr), common.HexToAddress("deadbeef"), uint256.NewInt(1), 21000, uint256.NewInt(uint64(i+1)*common.GWei), nil), *signer, key) - if txErr != nil { - t.Fatal(txErr) - } - b.AddTx(tx) - }) - require.NoError(t, err) - require.NoError(t, m.InsertChain(c)) - - ctx := t.Context() - dbTx, err := m.DB.BeginTemporalRw(ctx) - require.NoError(t, err) - defer dbTx.Rollback() - _, err = prune.EnsureNotChanged(dbTx, testFullMode) - require.NoError(t, err) - require.NoError(t, rawdb.WriteDBCommitmentHistoryEnabled(dbTx, false)) - require.NoError(t, dbTx.Commit()) - - api := newEthApiForTest(newBaseApiForTest(m), m.DB, nil, nil) - result, err := api.Capabilities(ctx) + api := setupAPIWithMerge(t, mergeAt, false) + result, err := api.Capabilities(t.Context()) require.NoError(t, err) // tx and blocks must start at the merge point, not 0 require.Equal(t, mergeAt, oldest(t, result.Tx)) @@ -298,54 +301,32 @@ func TestCapabilities(t *testing.T) { }) // When MergeHeight > head-pruneDistance, pre-merge blocks are absent (DefaultBlocksPruneMode) - // so receipts.oldestBlock must be clamped to the merge point, not stateOldest. + // so receipts.oldestBlock and logs.oldestBlock must be clamped to the merge point. t.Run("full_merge_height_receipts_seam", func(t *testing.T) { t.Parallel() mergeAt := uint64(chainSize - 2) // 18 > head-testPruneDistance=10 - - key, _ := crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291") - addr := crypto.PubkeyToAddress(key.PublicKey) - - var cfgWithMerge chain.Config - require.NoError(t, copier.CopyWithOption(&cfgWithMerge, chain.TestChainBerlinConfig, copier.Option{DeepCopy: true})) - cfgWithMerge.MergeHeight = &mergeAt - gspec := &types.Genesis{ - Config: &cfgWithMerge, - Alloc: types.GenesisAlloc{addr: {Balance: big.NewInt(math.MaxInt64)}}, - } - m := execmoduletester.New(t, execmoduletester.WithGenesisSpec(gspec), execmoduletester.WithKey(key)) - - signer := types.LatestSigner(gspec.Config) - c, err := blockgen.GenerateChain(m.ChainConfig, m.Genesis, m.Engine, m.DB, chainSize, func(i int, b *blockgen.BlockGen) { - b.SetCoinbase(common.Address{1}) - tx, txErr := types.SignTx(types.NewTransaction(b.TxNonce(addr), common.HexToAddress("deadbeef"), uint256.NewInt(1), 21000, uint256.NewInt(uint64(i+1)*common.GWei), nil), *signer, key) - if txErr != nil { - t.Fatal(txErr) - } - b.AddTx(tx) - }) - require.NoError(t, err) - require.NoError(t, m.InsertChain(c)) - - ctx := t.Context() - dbTx, err := m.DB.BeginTemporalRw(ctx) - require.NoError(t, err) - defer dbTx.Rollback() - _, err = prune.EnsureNotChanged(dbTx, testFullMode) + api := setupAPIWithMerge(t, mergeAt, false) + result, err := api.Capabilities(t.Context()) require.NoError(t, err) - require.NoError(t, rawdb.WriteDBCommitmentHistoryEnabled(dbTx, false)) - require.NoError(t, dbTx.Commit()) + // blocks constraint (mergeAt=18) is tighter than state (10): both receipts and logs must reflect it + require.Equal(t, mergeAt, oldest(t, result.Receipts)) + require.Nil(t, result.Receipts.DeleteStrategy) + require.Equal(t, mergeAt, oldest(t, result.Logs)) + require.Nil(t, result.Logs.DeleteStrategy) + }) - api := newEthApiForTest(newBaseApiForTest(m), m.DB, nil, nil) - result, err := api.Capabilities(ctx) + // full mode + --persist.receipts on a merge chain: pre-merge blocks were never downloaded, + // so their receipts were never persisted. receipts/logs.oldestBlock must reflect the merge point. + t.Run("full_persist_receipts_merge_height", func(t *testing.T) { + t.Parallel() + mergeAt := uint64(chainSize / 2) // = 10 + api := setupAPIWithMerge(t, mergeAt, true) + result, err := api.Capabilities(t.Context()) require.NoError(t, err) - stateOldest := uint64(chainSize) - testPruneDistance // = 10 - // blocks constraint (mergeAt=18) is tighter than state (10): receipts must reflect it require.Equal(t, mergeAt, oldest(t, result.Receipts)) require.Nil(t, result.Receipts.DeleteStrategy) - // logs only require state history, not block bodies - require.Equal(t, stateOldest, oldest(t, result.Logs)) - require.Equal(t, testPruneDistance, window(t, result.Logs)) + require.Equal(t, mergeAt, oldest(t, result.Logs)) + require.Nil(t, result.Logs.DeleteStrategy) }) // head_zero pins that ReadCanonicalHash(tx, 0) returns the genesis hash, not the zero hash. diff --git a/rpc/jsonrpc/receipts/handler_test.go b/rpc/jsonrpc/receipts/handler_test.go index e110ba16399..ae56fd81803 100644 --- a/rpc/jsonrpc/receipts/handler_test.go +++ b/rpc/jsonrpc/receipts/handler_test.go @@ -316,7 +316,7 @@ func TestGetBlockReceipts(t *testing.T) { hashes = append(hashes, block.Hash()) // If known, encode and queue for response packet - r, err := receiptsGetter.GetReceipts(m.Ctx, m.ChainConfig, tx, block, false) + r, err := receiptsGetter.GetReceipts(m.Ctx, m.ChainConfig, tx, block, eth.ReceiptsOpts{}) require.NoError(t, err) encoded, err := rlp.EncodeToBytes(r) require.NoError(t, err) diff --git a/rpc/jsonrpc/receipts/receipts_generator.go b/rpc/jsonrpc/receipts/receipts_generator.go index 1506b2cd2d0..06b1a29ed63 100644 --- a/rpc/jsonrpc/receipts/receipts_generator.go +++ b/rpc/jsonrpc/receipts/receipts_generator.go @@ -36,6 +36,7 @@ import ( "github.com/erigontech/erigon/execution/types/accounts" "github.com/erigontech/erigon/execution/vm" "github.com/erigontech/erigon/execution/vm/evmtypes" + eth "github.com/erigontech/erigon/p2p/protocols/eth" "github.com/erigontech/erigon/rpc/transactions" ) @@ -449,7 +450,7 @@ func (g *Generator) GetReceipt(ctx context.Context, cfg *chain.Config, tx kv.Tem return receipt, nil } -func (g *Generator) GetReceipts(ctx context.Context, cfg *chain.Config, tx kv.TemporalTx, block *types.Block, commitmentHistoryEnabled bool) (_ types.Receipts, err error) { +func (g *Generator) GetReceipts(ctx context.Context, cfg *chain.Config, tx kv.TemporalTx, block *types.Block, opts eth.ReceiptsOpts) (_ types.Receipts, err error) { blockHash := block.Hash() blockNum := block.NumberU64() @@ -482,7 +483,7 @@ func (g *Generator) GetReceipts(ctx context.Context, cfg *chain.Config, tx kv.Te return nil, err } - calculatePostState := (commitmentHistoryEnabled || g.blockReader.FrozenBlocks() == 0) && !cfg.IsByzantium(blockNum) + calculatePostState := (opts.CommitmentHistoryEnabled || g.blockReader.FrozenBlocks() == 0) && !cfg.IsByzantium(blockNum) // Now the snapshot have not the `postState` field. Therefore, for pre-Byzantium blocks, // we must skip persistent receipts and re-calculate @@ -541,7 +542,7 @@ func (g *Generator) GetReceipts(ctx context.Context, cfg *chain.Config, tx kv.Te } var stateWriter state.StateWriter - if commitmentHistoryEnabled { + if opts.CommitmentHistoryEnabled { sharedDomains, err = execctx.NewSharedDomains(ctx, tx, log.Root()) if err != nil { return nil, err @@ -600,7 +601,7 @@ func (g *Generator) GetReceipts(ctx context.Context, cfg *chain.Config, tx kv.Te } var stateRoot []byte - if commitmentHistoryEnabled { + if opts.CommitmentHistoryEnabled { sharedDomains.GetCommitmentContext().SetHistoryStateReader(tx, txNum+1) latestTxNum, _, err := sharedDomains.SeekCommitment(ctx, tx) if err != nil { @@ -631,7 +632,7 @@ func (g *Generator) GetReceipts(ctx context.Context, cfg *chain.Config, tx kv.Te // When assertions are enabled, receipts are *always* computed (i.e. receipt cache V2 is skipped) // Hence, we need commitment history to correctly compute the `root` field for pre-Byzantium receipts - if dbg.AssertEnabled && (commitmentHistoryEnabled || cfg.IsByzantium(blockNum)) { + if dbg.AssertEnabled && (opts.CommitmentHistoryEnabled || cfg.IsByzantium(blockNum)) { computedReceiptsRoot := types.DeriveSha(receipts) blockReceiptsRoot := block.Header().ReceiptHash if computedReceiptsRoot != blockReceiptsRoot { From 75ad5e220e33494fb7d2ed54305edee9b7b04b58 Mon Sep 17 00:00:00 2001 From: lupin012 <58134934+lupin012@users.noreply.github.com.> Date: Wed, 20 May 2026 10:44:40 +0200 Subject: [PATCH 15/16] p2p/eth: drop redundant what-comment on ReceiptsOpts Co-Authored-By: Claude Sonnet 4.6 --- p2p/protocols/eth/handlers.go | 1 - 1 file changed, 1 deletion(-) diff --git a/p2p/protocols/eth/handlers.go b/p2p/protocols/eth/handlers.go index 0048a3e168b..068ad0224cc 100644 --- a/p2p/protocols/eth/handlers.go +++ b/p2p/protocols/eth/handlers.go @@ -222,7 +222,6 @@ func AnswerGetBlockAccessListsQuery(db kv.Tx, query GetBlockAccessListsPacket, b return bals } -// ReceiptsOpts carries per-call options for GetReceipts. // Using a struct keeps the ReceiptsGetter interface stable when new options are added. type ReceiptsOpts struct { CommitmentHistoryEnabled bool From 180364e5f5c209caba28dd1dc1c8ed439de77284 Mon Sep 17 00:00:00 2001 From: lupin012 <58134934+lupin012@users.noreply.github.com.> Date: Thu, 21 May 2026 23:07:55 +0200 Subject: [PATCH 16/16] rpc, p2p/eth: address review feedback on eth_capabilities Co-Authored-By: Claude Sonnet 4.6 --- execution/abi/bind/backends/simulated.go | 2 +- rpc/jsonrpc/eth_api.go | 34 ++++++----- rpc/jsonrpc/eth_block.go | 8 +-- rpc/jsonrpc/eth_block_test.go | 69 ++++++++++++++++++++++ rpc/jsonrpc/eth_receipts.go | 2 +- rpc/jsonrpc/eth_system.go | 14 +++-- rpc/jsonrpc/eth_system_test.go | 16 +++++ rpc/jsonrpc/eth_txs.go | 12 ++-- rpc/jsonrpc/receipts/receipts_generator.go | 2 +- 9 files changed, 127 insertions(+), 32 deletions(-) diff --git a/execution/abi/bind/backends/simulated.go b/execution/abi/bind/backends/simulated.go index 563c9712224..9e3e22b61a0 100644 --- a/execution/abi/bind/backends/simulated.go +++ b/execution/abi/bind/backends/simulated.go @@ -57,7 +57,7 @@ import ( "github.com/erigontech/erigon/execution/vm" "github.com/erigontech/erigon/execution/vm/evmtypes" "github.com/erigontech/erigon/p2p/event" - eth "github.com/erigontech/erigon/p2p/protocols/eth" + "github.com/erigontech/erigon/p2p/protocols/eth" "github.com/erigontech/erigon/polygon/bor" ) diff --git a/rpc/jsonrpc/eth_api.go b/rpc/jsonrpc/eth_api.go index 8d256020f9b..679724f6e52 100644 --- a/rpc/jsonrpc/eth_api.go +++ b/rpc/jsonrpc/eth_api.go @@ -372,28 +372,34 @@ func (api *BaseAPI) headerByHash(ctx context.Context, hash common.Hash, tx kv.Tx // history for blocks that have been pruned away giving nonce too low errors // etc. as red herrings func (api *BaseAPI) checkPruneHistory(ctx context.Context, tx kv.Tx, block uint64) error { + return api.checkPruneField(tx, block, func(p *prune.Mode) prune.BlockAmount { return p.History }, "history is available") +} + +// checkPruneBlocks gates on block-body availability rather than state history — use for RPCs +// that read block headers/bodies but do not require state (e.g. GetBlockByNumber, GetTransactionByHash). +func (api *BaseAPI) checkPruneBlocks(ctx context.Context, tx kv.Tx, block uint64) error { + return api.checkPruneField(tx, block, func(p *prune.Mode) prune.BlockAmount { return p.Blocks }, "blocks are available") +} + +func (api *BaseAPI) checkPruneField(tx kv.Tx, block uint64, field func(*prune.Mode) prune.BlockAmount, available string) error { p, err := api.pruneMode(tx) if err != nil { return err } if p == nil { - // no prune info found return nil } - if p.History.Enabled() { - latest, _, _, err := rpchelper.GetBlockNumber(ctx, rpc.BlockNumberOrHashWithNumber(rpc.LatestBlockNumber), tx, api._blockReader, api.filters) - if err != nil { - return err - } - if latest <= 1 { - return nil - } - prunedTo := p.History.PruneTo(latest) - if block < prunedTo { - return fmt.Errorf("%w: requested block %d, history is available from block %d", state.PrunedError, block, prunedTo) - } + amount := field(p) + if !amount.Enabled() { + return nil + } + latest, err := rpchelper.GetLatestBlockNumber(tx) + if err != nil { + return err + } + if block < amount.PruneTo(latest) { + return fmt.Errorf("%w: requested block %d, %s from block %d", state.PrunedError, block, available, amount.PruneTo(latest)) } - return nil } diff --git a/rpc/jsonrpc/eth_block.go b/rpc/jsonrpc/eth_block.go index 4796923d417..65db83c6ba0 100644 --- a/rpc/jsonrpc/eth_block.go +++ b/rpc/jsonrpc/eth_block.go @@ -245,7 +245,7 @@ func (api *APIImpl) GetBlockByNumber(ctx context.Context, number rpc.BlockNumber } return nil, err } - if err = api.BaseAPI.checkPruneHistory(ctx, tx, blockNum); err != nil { + if err = api.BaseAPI.checkPruneBlocks(ctx, tx, blockNum); err != nil { return nil, err } b, err = api.blockByNumber(ctx, rpc.BlockNumber(blockNum), tx) @@ -307,7 +307,7 @@ func (api *APIImpl) GetBlockByHash(ctx context.Context, numberOrHash rpc.BlockNu return nil, nil } - err = api.BaseAPI.checkPruneHistory(ctx, tx, blockNumber) + err = api.BaseAPI.checkPruneBlocks(ctx, tx, blockNumber) if err != nil { return nil, err } @@ -371,7 +371,7 @@ func (api *APIImpl) GetBlockTransactionCountByNumber(ctx context.Context, blockN return nil, err } - err = api.BaseAPI.checkPruneHistory(ctx, tx, blockNum) + err = api.BaseAPI.checkPruneBlocks(ctx, tx, blockNum) if err != nil { return nil, err } @@ -426,7 +426,7 @@ func (api *APIImpl) GetBlockTransactionCountByHash(ctx context.Context, blockHas return nil, nil } - err = api.BaseAPI.checkPruneHistory(ctx, tx, blockNum) + err = api.BaseAPI.checkPruneBlocks(ctx, tx, blockNum) if err != nil { return nil, err } diff --git a/rpc/jsonrpc/eth_block_test.go b/rpc/jsonrpc/eth_block_test.go index c1547a3a06d..df648d5148c 100644 --- a/rpc/jsonrpc/eth_block_test.go +++ b/rpc/jsonrpc/eth_block_test.go @@ -28,9 +28,12 @@ import ( "github.com/erigontech/erigon/common" "github.com/erigontech/erigon/common/hexutil" "github.com/erigontech/erigon/db/kv/kvcache" + "github.com/erigontech/erigon/db/kv/prune" "github.com/erigontech/erigon/db/rawdb" "github.com/erigontech/erigon/execution/execmodule/execmoduletester" "github.com/erigontech/erigon/execution/rlp" + "github.com/erigontech/erigon/execution/state" + "github.com/erigontech/erigon/execution/tests/blockgen" "github.com/erigontech/erigon/execution/types" "github.com/erigontech/erigon/node/gointerfaces/txpoolproto" "github.com/erigontech/erigon/rpc" @@ -326,3 +329,69 @@ func TestGetBlockTransactionCountByNumber_ZeroTx(t *testing.T) { assert.Equal(t, expectedAmount, *txCount) } + +func TestGetBlockByNumber_BlockPruneGating(t *testing.T) { + if testing.Short() { + t.Skip("slow test") + } + t.Parallel() + + const chainSize = 20 + const pruneDistance = uint64(10) + + setup := func(t *testing.T, pm prune.Mode) *APIImpl { + t.Helper() + m := execmoduletester.New(t, execmoduletester.WithPruneMode(pm)) + c, err := blockgen.GenerateChain(m.ChainConfig, m.Genesis, m.Engine, m.DB, chainSize, func(_ int, _ *blockgen.BlockGen) {}) + require.NoError(t, err) + require.NoError(t, m.InsertChain(c)) + + ctx := t.Context() + tx, err := m.DB.BeginTemporalRw(ctx) + require.NoError(t, err) + defer tx.Rollback() + _, err = prune.EnsureNotChanged(tx, pm) + require.NoError(t, err) + require.NoError(t, tx.Commit()) + + return newEthApiForTest(newBaseApiForTest(m), m.DB, nil, nil) + } + + fullMode := prune.Mode{ + Initialised: true, + History: prune.Distance(pruneDistance), + Blocks: prune.DefaultBlocksPruneMode, + } + minimalMode := prune.Mode{ + Initialised: true, + History: prune.Distance(pruneDistance), + Blocks: prune.Distance(pruneDistance), + } + + // In full mode, block bodies are in snapshots and DefaultBlocksPruneMode means no block-body + // gate — GetBlockByNumber must succeed even for blocks older than the state-history window. + t.Run("full_mode_old_block_accessible", func(t *testing.T) { + t.Parallel() + api := setup(t, fullMode) + b, err := api.GetBlockByNumber(t.Context(), rpc.BlockNumber(0), false) + require.NoError(t, err) + require.NotNil(t, b) + }) + + // In minimal mode, Blocks=Distance(pruneDistance) gates access: block 0 < head-pruneDistance. + t.Run("minimal_mode_old_block_pruned", func(t *testing.T) { + t.Parallel() + api := setup(t, minimalMode) + _, err := api.GetBlockByNumber(t.Context(), rpc.BlockNumber(0), false) + require.ErrorIs(t, err, state.PrunedError) + }) + + // Recent blocks (within the prune window) must always be accessible. + t.Run("minimal_mode_recent_block_accessible", func(t *testing.T) { + t.Parallel() + api := setup(t, minimalMode) + b, err := api.GetBlockByNumber(t.Context(), rpc.BlockNumber(chainSize), false) + require.NoError(t, err) + require.NotNil(t, b) + }) +} diff --git a/rpc/jsonrpc/eth_receipts.go b/rpc/jsonrpc/eth_receipts.go index 0e5e4eb492f..c39e8acdd88 100644 --- a/rpc/jsonrpc/eth_receipts.go +++ b/rpc/jsonrpc/eth_receipts.go @@ -23,7 +23,7 @@ import ( "github.com/RoaringBitmap/roaring/v2" - eth "github.com/erigontech/erigon/p2p/protocols/eth" + "github.com/erigontech/erigon/p2p/protocols/eth" "github.com/erigontech/erigon/rpc/jsonrpc/receipts" "github.com/erigontech/erigon/common" diff --git a/rpc/jsonrpc/eth_system.go b/rpc/jsonrpc/eth_system.go index 28b3901f673..fa05a8d3bae 100644 --- a/rpc/jsonrpc/eth_system.go +++ b/rpc/jsonrpc/eth_system.go @@ -19,6 +19,7 @@ package jsonrpc import ( "context" "errors" + "fmt" "math" "github.com/holiman/uint256" @@ -52,8 +53,8 @@ const deleteStrategyWindow = "window" // The field is omitted when data is kept indefinitely (archive nodes, or // DefaultBlocksPruneMode which uses chain-specific history expiry). type DeleteStrategy struct { - Type string `json:"type"` - RetentionBlocks hexutil.Uint64 `json:"retentionBlocks"` + Type string `json:"type"` + RetentionBlocks uint64 `json:"retentionBlocks"` } // CapabilityField describes availability of a data category: when Disabled is true the node @@ -112,16 +113,19 @@ func (api *APIImpl) Capabilities(ctx context.Context) (*CapabilitiesResult, erro if err != nil { return nil, err } - headHash, err := rawdb.ReadCanonicalHash(overlayTx, headBlock) + headHash, ok, err := api._blockReader.CanonicalHash(ctx, overlayTx, headBlock) if err != nil { return nil, err } + if !ok { + return nil, fmt.Errorf("canonical hash not found %d", headBlock) + } avail := func(oldest uint64, dist prune.BlockAmount) CapabilityField { o := hexutil.Uint64(oldest) f := CapabilityField{OldestBlock: &o} if d, ok := dist.(prune.Distance); ok && d != prune.DefaultBlocksPruneMode && d != prune.KeepAllBlocksPruneMode { - rb := hexutil.Uint64(d) + rb := uint64(d) f.DeleteStrategy = &DeleteStrategy{Type: deleteStrategyWindow, RetentionBlocks: rb} } return f @@ -184,7 +188,7 @@ func (api *APIImpl) Capabilities(ctx context.Context) (*CapabilitiesResult, erro return &CapabilitiesResult{ Head: CapabilityHead{Number: hexutil.Uint64(headBlock), Hash: headHash}, State: stateField, - Tx: blocksField, + Tx: blocksField, // tx-by-hash goes through block bodies; no independent tx-index pruning Logs: logsField, Receipts: receiptsField, Blocks: blocksField, diff --git a/rpc/jsonrpc/eth_system_test.go b/rpc/jsonrpc/eth_system_test.go index d9524d62b11..29b4c166c29 100644 --- a/rpc/jsonrpc/eth_system_test.go +++ b/rpc/jsonrpc/eth_system_test.go @@ -19,6 +19,7 @@ package jsonrpc import ( "context" "encoding/json" + "fmt" "math" "math/big" "os" @@ -329,6 +330,21 @@ func TestCapabilities(t *testing.T) { require.Nil(t, result.Logs.DeleteStrategy) }) + t.Run("wire_format", func(t *testing.T) { + t.Parallel() + api, _ := setupAPI(t, testMinimalMode, false, false) + result, err := api.Capabilities(t.Context()) + require.NoError(t, err) + raw, err := json.Marshal(result) + require.NoError(t, err) + s := string(raw) + require.Contains(t, s, fmt.Sprintf(`"retentionBlocks":%d`, testPruneDistance), "retentionBlocks must be decimal, not hex") + require.NotContains(t, s, `"retentionBlocks":"0x`, "retentionBlocks must not be hex-encoded") + require.Contains(t, s, `"oldestBlock":"0x`, "oldestBlock must be hex-encoded") + require.Contains(t, s, `"disabled":false`, "disabled:false must be present, not omitted") + require.Contains(t, s, `"stateproofs":{"disabled":true}`, "disabled category must serialize as {disabled:true} only") + }) + // head_zero pins that ReadCanonicalHash(tx, 0) returns the genesis hash, not the zero hash. t.Run("head_zero", func(t *testing.T) { t.Parallel() diff --git a/rpc/jsonrpc/eth_txs.go b/rpc/jsonrpc/eth_txs.go index b21f091198d..61451cc5172 100644 --- a/rpc/jsonrpc/eth_txs.go +++ b/rpc/jsonrpc/eth_txs.go @@ -64,7 +64,7 @@ func (api *APIImpl) GetTransactionByHash(ctx context.Context, txnHash common.Has } } if ok { - err = api.BaseAPI.checkPruneHistory(ctx, tx, blockNum) + err = api.BaseAPI.checkPruneBlocks(ctx, tx, blockNum) if err != nil { return nil, err } @@ -161,7 +161,7 @@ func (api *APIImpl) GetRawTransactionByHash(ctx context.Context, hash common.Has return nil, nil } - err = api.BaseAPI.checkPruneHistory(ctx, tx, blockNum) + err = api.BaseAPI.checkPruneBlocks(ctx, tx, blockNum) if err != nil { return nil, err } @@ -213,7 +213,7 @@ func (api *APIImpl) GetTransactionByBlockHashAndIndex(ctx context.Context, block return nil, nil } - err = api.BaseAPI.checkPruneHistory(ctx, tx, blockNum) + err = api.BaseAPI.checkPruneBlocks(ctx, tx, blockNum) if err != nil { return nil, err } @@ -259,7 +259,7 @@ func (api *APIImpl) GetRawTransactionByBlockHashAndIndex(ctx context.Context, bl return nil, nil } - err = api.BaseAPI.checkPruneHistory(ctx, tx, blockNum) + err = api.BaseAPI.checkPruneBlocks(ctx, tx, blockNum) if err != nil { return nil, err } @@ -311,7 +311,7 @@ func (api *APIImpl) GetTransactionByBlockNumberAndIndex(ctx context.Context, blo return nil, err } - err = api.BaseAPI.checkPruneHistory(ctx, tx, blockNum) + err = api.BaseAPI.checkPruneBlocks(ctx, tx, blockNum) if err != nil { return nil, err } @@ -370,7 +370,7 @@ func (api *APIImpl) GetRawTransactionByBlockNumberAndIndex(ctx context.Context, return nil, err } - err = api.BaseAPI.checkPruneHistory(ctx, tx, blockNum) + err = api.BaseAPI.checkPruneBlocks(ctx, tx, blockNum) if err != nil { return nil, err } diff --git a/rpc/jsonrpc/receipts/receipts_generator.go b/rpc/jsonrpc/receipts/receipts_generator.go index 06b1a29ed63..167b1aba462 100644 --- a/rpc/jsonrpc/receipts/receipts_generator.go +++ b/rpc/jsonrpc/receipts/receipts_generator.go @@ -36,7 +36,7 @@ import ( "github.com/erigontech/erigon/execution/types/accounts" "github.com/erigontech/erigon/execution/vm" "github.com/erigontech/erigon/execution/vm/evmtypes" - eth "github.com/erigontech/erigon/p2p/protocols/eth" + "github.com/erigontech/erigon/p2p/protocols/eth" "github.com/erigontech/erigon/rpc/transactions" )