From f030cdf3404a1f69e6f14b63b955e493f55d6b67 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Thu, 16 Jul 2026 16:34:25 +0200 Subject: [PATCH 01/11] rpc: resolve head-sensitive reads on a well-defined view Split out of #21293. Overlay-view methods pin one BlockOverlay read view per request; committed-view methods resolve tags with nil filters so the bounds agree with the temporal data they scan. eth_getProof keeps all reads on the caller's single RO snapshot. --- rpc/jsonrpc/bor_api_impl.go | 78 +++++++-------------- rpc/jsonrpc/debug_api.go | 38 ++++++---- rpc/jsonrpc/erigon_block.go | 2 + rpc/jsonrpc/eth_api.go | 5 +- rpc/jsonrpc/eth_block.go | 12 ++-- rpc/jsonrpc/eth_call.go | 57 +++++++-------- rpc/jsonrpc/eth_call_test.go | 76 ++++++++++++++++++++ rpc/jsonrpc/eth_receipts.go | 16 ++--- rpc/jsonrpc/eth_simulation.go | 4 +- rpc/jsonrpc/graphql_api.go | 2 +- rpc/jsonrpc/overlay_api.go | 1 + rpc/jsonrpc/overlay_race_test.go | 116 ++++++++++++++++++++++++++++++- rpc/jsonrpc/parity_api.go | 5 ++ rpc/jsonrpc/trace_filtering.go | 9 ++- rpc/jsonrpc/tracing.go | 4 +- rpc/rpchelper/helper.go | 14 +++- 16 files changed, 322 insertions(+), 117 deletions(-) diff --git a/rpc/jsonrpc/bor_api_impl.go b/rpc/jsonrpc/bor_api_impl.go index f748b0733cf..d47f0ea997b 100644 --- a/rpc/jsonrpc/bor_api_impl.go +++ b/rpc/jsonrpc/bor_api_impl.go @@ -21,7 +21,6 @@ import ( "errors" "github.com/erigontech/erigon/common" - "github.com/erigontech/erigon/db/rawdb" "github.com/erigontech/erigon/execution/types" "github.com/erigontech/erigon/execution/types/accounts" "github.com/erigontech/erigon/polygon/heimdall" @@ -58,12 +57,11 @@ func (api *BorImpl) GetSnapshot(number *rpc.BlockNumber) (*Snapshot, error) { defer tx.Rollback() // Retrieve the requested block number (or current if none requested) - var header *types.Header - if number == nil || *number == rpc.LatestBlockNumber { - header = rawdb.ReadCurrentHeader(tx) - } else { - header, _ = api.headerByNumber(ctx, *number, tx) + blockNr := rpc.LatestBlockNumber + if number != nil { + blockNr = *number } + header, _ := api.headerByNumber(ctx, blockNr, tx) // Ensure we have an actually valid block if header == nil { return nil, errUnknownBlock @@ -99,22 +97,12 @@ func (api *BorImpl) GetAuthor(blockNrOrHash *rpc.BlockNumberOrHash) (accounts.Ad // Retrieve the requested block number (or current if none requested) var header *types.Header - - //nolint:nestif if blockNrOrHash == nil { - latestBlockNum, err2 := rpchelper.GetLatestBlockNumber(tx) - if err2 != nil { - return accounts.NilAddress, err2 - } - header, err = api._blockReader.HeaderByNumber(ctx, tx, latestBlockNum) - } else { - if blockNr, ok := blockNrOrHash.Number(); ok { - header, err = api._blockReader.HeaderByNumber(ctx, tx, uint64(blockNr)) - } else { - if blockHash, ok := blockNrOrHash.Hash(); ok { - header, err = api._blockReader.HeaderByHash(ctx, tx, blockHash) - } - } + header, err = api.headerByNumber(ctx, rpc.LatestBlockNumber, tx) + } else if blockNr, ok := blockNrOrHash.Number(); ok { + header, err = api.headerByNumber(ctx, blockNr, tx) + } else if blockHash, ok := blockNrOrHash.Hash(); ok { + header, err = api.headerByHash(ctx, blockHash, tx) } // Ensure we have an actually valid block and return its snapshot @@ -172,12 +160,11 @@ func (api *BorImpl) GetSigners(number *rpc.BlockNumber) ([]common.Address, error defer tx.Rollback() // Retrieve the requested block number (or current if none requested) - var header *types.Header - if number == nil || *number == rpc.LatestBlockNumber { - header = rawdb.ReadCurrentHeader(tx) - } else { - header, _ = api.headerByNumber(ctx, *number, tx) + blockNr := rpc.LatestBlockNumber + if number != nil { + blockNr = *number } + header, _ := api.headerByNumber(ctx, blockNr, tx) // Ensure we have an actually valid block if header == nil { return nil, errUnknownBlock @@ -298,7 +285,7 @@ func (api *BorImpl) getLatestBlockNum(ctx context.Context) (uint64, error) { } defer tx.Rollback() - return rpchelper.GetLatestBlockNumber(tx) + return rpchelper.GetLatestBlockNumber(api.filters.WithOverlay(tx)) } // GetSnapshotProposer retrieves the in-turn signer at a given block. @@ -312,21 +299,12 @@ func (api *BorImpl) GetSnapshotProposer(blockNrOrHash *rpc.BlockNumberOrHash) (c defer tx.Rollback() var header *types.Header - //nolint:nestif if blockNrOrHash == nil { - header = rawdb.ReadCurrentHeader(tx) - } else { - if blockNr, ok := blockNrOrHash.Number(); ok { - if blockNr == rpc.LatestBlockNumber { - header = rawdb.ReadCurrentHeader(tx) - } else { - header, err = api.headerByNumber(ctx, blockNr, tx) - } - } else { - if blockHash, ok := blockNrOrHash.Hash(); ok { - header, err = api.headerByHash(ctx, blockHash, tx) - } - } + header, err = api.headerByNumber(ctx, rpc.LatestBlockNumber, tx) + } else if blockNr, ok := blockNrOrHash.Number(); ok { + header, err = api.headerByNumber(ctx, blockNr, tx) + } else if blockHash, ok := blockNrOrHash.Hash(); ok { + header, err = api.headerByHash(ctx, blockHash, tx) } if header == nil || err != nil { @@ -352,19 +330,11 @@ func (api *BorImpl) GetSnapshotProposerSequence(blockNrOrHash *rpc.BlockNumberOr // Retrieve the requested block number (or current if none requested) var header *types.Header if blockNrOrHash == nil { - header = rawdb.ReadCurrentHeader(tx) - } else { - if blockNr, ok := blockNrOrHash.Number(); ok { - if blockNr == rpc.LatestBlockNumber { - header = rawdb.ReadCurrentHeader(tx) - } else { - header, err = api.headerByNumber(ctx, blockNr, tx) - } - } else { - if blockHash, ok := blockNrOrHash.Hash(); ok { - header, err = api.headerByHash(ctx, blockHash, tx) - } - } + header, err = api.headerByNumber(ctx, rpc.LatestBlockNumber, tx) + } else if blockNr, ok := blockNrOrHash.Number(); ok { + header, err = api.headerByNumber(ctx, blockNr, tx) + } else if blockHash, ok := blockNrOrHash.Hash(); ok { + header, err = api.headerByHash(ctx, blockHash, tx) } // Ensure we have an actually valid block diff --git a/rpc/jsonrpc/debug_api.go b/rpc/jsonrpc/debug_api.go index f8515afde2d..050aecb8d99 100644 --- a/rpc/jsonrpc/debug_api.go +++ b/rpc/jsonrpc/debug_api.go @@ -106,7 +106,9 @@ func (api *DebugAPIImpl) SetHead(ctx context.Context, number hexutil.Uint64) err } defer tx.Rollback() - currentHead, err := rpchelper.GetLatestBlockNumber(tx) + // Overlay-aware head, so setHead(N) isn't rejected as future while N's + // commit is still in flight. + currentHead, err := rpchelper.GetLatestBlockNumber(api.filters.WithOverlay(tx)) if err != nil { return err } @@ -137,7 +139,9 @@ func (api *DebugAPIImpl) StorageRangeAt(ctx context.Context, blockHash common.Ha } blockNrOrHash := rpc.BlockNumberOrHashWithHash(blockHash, true) - blockNumber, _, _, err := rpchelper.GetCanonicalBlockNumber(ctx, blockNrOrHash, tx, api._blockReader, api.filters) + // nil filters: resolve on the committed view — the storage-range scan reads + // temporal data through the same plain tx (see rpchelper.GetBlockNumber). + blockNumber, _, _, err := rpchelper.GetCanonicalBlockNumber(ctx, blockNrOrHash, tx, api._blockReader, nil) if err != nil { if errors.As(err, &rpc.BlockNotFoundErr{}) { return StorageRangeResult{}, nil @@ -232,7 +236,9 @@ func (api *DebugAPIImpl) AccountRange(ctx context.Context, blockNrOrHash rpc.Blo } } else if _, ok := blockNrOrHash.Hash(); ok { - bn, _, _, err2 := rpchelper.GetCanonicalBlockNumber(ctx, blockNrOrHash, tx, api._blockReader, api.filters) + // nil filters: resolve on the committed view — the dumper reads temporal + // data through the same plain tx (see rpchelper.GetBlockNumber). + bn, _, _, err2 := rpchelper.GetCanonicalBlockNumber(ctx, blockNrOrHash, tx, api._blockReader, nil) if err2 != nil { return state.IteratorDump{}, err2 } @@ -556,31 +562,33 @@ func (api *DebugAPIImpl) AccountAt(ctx context.Context, blockHash common.Hash, t } defer tx.Rollback() - header, err := api.headerByHash(ctx, blockHash, tx) + // Committed view: the canonical-hash check and GetAsOf reads below use the + // same plain tx (an overlay-resolved head would have no committed history). + blockNumber, err := api._blockReader.HeaderNumber(ctx, tx, blockHash) if err != nil { - return &AccountResult{}, err + return nil, err } - if header == nil { + if blockNumber == nil { return nil, nil // not error, see https://github.com/erigontech/erigon/issues/1645 } - canonicalHash, ok, err := api._blockReader.CanonicalHash(ctx, tx, header.Number.Uint64()) + canonicalHash, ok, err := api._blockReader.CanonicalHash(ctx, tx, *blockNumber) if err != nil { return nil, err } if !ok { - return nil, fmt.Errorf("canonical hash not found %d", header.Number.Uint64()) + return nil, fmt.Errorf("canonical hash not found %d", *blockNumber) } isCanonical := canonicalHash == blockHash if !isCanonical { return nil, errors.New("block hash is not canonical") } - err = api.BaseAPI.checkPruneHistory(ctx, tx, header.Number.Uint64()) + err = api.BaseAPI.checkPruneHistory(ctx, tx, *blockNumber) if err != nil { return nil, err } - minTxNum, err := api._txNumReader.Min(ctx, tx, header.Number.Uint64()) + minTxNum, err := api._txNumReader.Min(ctx, tx, *blockNumber) if err != nil { return nil, err } @@ -619,19 +627,25 @@ type AccountResult struct { // GetRawHeader implements debug_getRawHeader - returns a an RLP-encoded header, given a block number or hash func (api *DebugAPIImpl) GetRawHeader(ctx context.Context, blockNrOrHash rpc.BlockNumberOrHash) (hexutil.Bytes, error) { + if number, ok := blockNrOrHash.Number(); ok && number == rpc.PendingBlockNumber { + if block := api.pendingBlock(); block != nil { + return rlp.EncodeToBytes(block.Header()) + } + } tx, err := api.db.BeginTemporalRo(ctx) if err != nil { return nil, err } defer tx.Rollback() - n, h, _, err := rpchelper.GetBlockNumber(ctx, blockNrOrHash, tx, api._blockReader, api.filters) + overlayTx := api.filters.WithOverlay(tx) + n, h, _, err := rpchelper.GetBlockNumber(ctx, blockNrOrHash, overlayTx, api._blockReader, nil) if err != nil { if errors.As(err, &rpc.BlockNotFoundErr{}) { return nil, nil // waiting for spec: not error, see Geth and https://github.com/erigontech/erigon/issues/1645 } return nil, err } - header, err := api._blockReader.Header(ctx, tx, h, n) + header, err := api._blockReader.Header(ctx, overlayTx, h, n) if err != nil { return nil, err } diff --git a/rpc/jsonrpc/erigon_block.go b/rpc/jsonrpc/erigon_block.go index 7b75cd7cea1..65ff099bbed 100644 --- a/rpc/jsonrpc/erigon_block.go +++ b/rpc/jsonrpc/erigon_block.go @@ -91,6 +91,8 @@ func (api *ErigonImpl) GetBlockByTimestamp(ctx context.Context, timeStamp rpc.Ti return nil, err } defer tx.Rollback() + // Everything here is a block-table read, so one overlay view keeps the + // head, the search bounds, and the lookups consistent. overlayTx := api.filters.WithOverlay(tx) uintTimestamp := timeStamp.TurnIntoUint64() diff --git a/rpc/jsonrpc/eth_api.go b/rpc/jsonrpc/eth_api.go index b72af9adaa5..116c4bcd48b 100644 --- a/rpc/jsonrpc/eth_api.go +++ b/rpc/jsonrpc/eth_api.go @@ -402,7 +402,8 @@ func (api *BaseAPI) headerByHash(ctx context.Context, hash common.Hash, tx kv.Tx } } - number, err := api._blockReader.HeaderNumber(ctx, tx, hash) + overlayTx := api.filters.WithOverlay(tx) + number, err := api._blockReader.HeaderNumber(ctx, overlayTx, hash) if err != nil { return nil, err } @@ -410,7 +411,7 @@ func (api *BaseAPI) headerByHash(ctx context.Context, hash common.Hash, tx kv.Tx if number == nil { return nil, nil } - return api._blockReader.Header(ctx, tx, hash, *number) + return api._blockReader.Header(ctx, overlayTx, hash, *number) } // checks the pruning state to see if we would hold information about this diff --git a/rpc/jsonrpc/eth_block.go b/rpc/jsonrpc/eth_block.go index 76a0255ce0d..743e4e11087 100644 --- a/rpc/jsonrpc/eth_block.go +++ b/rpc/jsonrpc/eth_block.go @@ -343,7 +343,8 @@ func (api *APIImpl) GetBlockTransactionCountByNumber(ctx context.Context, blockN return &n, nil } - blockNum, blockHash, _, err := rpchelper.GetBlockNumber(ctx, rpc.BlockNumberOrHashWithNumber(blockNr), tx, api._blockReader, api.filters) + overlayTx := api.filters.WithOverlay(tx) + blockNum, blockHash, _, err := rpchelper.GetBlockNumber(ctx, rpc.BlockNumberOrHashWithNumber(blockNr), overlayTx, api._blockReader, nil) if err != nil { if errors.As(err, &rpc.BlockNotFoundErr{}) { return nil, nil // not error, see https://github.com/erigontech/erigon/issues/1645 @@ -356,7 +357,7 @@ func (api *APIImpl) GetBlockTransactionCountByNumber(ctx context.Context, blockN return nil, err } - latestBlockNumber, err := rpchelper.GetLatestBlockNumber(tx) + latestBlockNumber, err := rpchelper.GetLatestBlockNumber(overlayTx) if err != nil { return nil, err } @@ -365,7 +366,7 @@ func (api *APIImpl) GetBlockTransactionCountByNumber(ctx context.Context, blockN return nil, nil } - body, txCount, err := api._blockReader.Body(ctx, tx, blockHash, blockNum) + body, txCount, err := api._blockReader.Body(ctx, overlayTx, blockHash, blockNum) if err != nil { return nil, err } @@ -399,7 +400,8 @@ func (api *APIImpl) GetBlockTransactionCountByHash(ctx context.Context, blockHas } defer tx.Rollback() - blockNum, _, _, err := rpchelper.GetBlockNumber(ctx, rpc.BlockNumberOrHash{BlockHash: &blockHash}, tx, api._blockReader, nil) + overlayTx := api.filters.WithOverlay(tx) + blockNum, _, _, err := rpchelper.GetBlockNumber(ctx, rpc.BlockNumberOrHash{BlockHash: &blockHash}, overlayTx, api._blockReader, nil) if err != nil { // (Compatibility) Every other node just return `null` for when the block does not exist. log.Debug("eth_getBlockTransactionCountByHash GetBlockNumber failed", "err", err) @@ -411,7 +413,7 @@ func (api *APIImpl) GetBlockTransactionCountByHash(ctx context.Context, blockHas return nil, err } - _, txCount, err := api._blockReader.Body(ctx, tx, blockHash, blockNum) + _, txCount, err := api._blockReader.Body(ctx, overlayTx, blockHash, blockNum) if err != nil { return nil, err } diff --git a/rpc/jsonrpc/eth_call.go b/rpc/jsonrpc/eth_call.go index 9be27296e01..5a1d25870b7 100644 --- a/rpc/jsonrpc/eth_call.go +++ b/rpc/jsonrpc/eth_call.go @@ -82,8 +82,9 @@ func (api *APIImpl) Call(ctx context.Context, args ethapi2.CallArgs, requestedBl } defer roTx.Rollback() - // Use the block overlay if available — reads uncommitted data from the - // pre-commit overlay so consumers don't need to wait for DB commit. + // The overlay exposes block tables only: "latest" resolves to the + // pre-commit head while temporal state reads still see the last committed + // block (see ethconfig.Defaults.FcuBackgroundCommit). var tx kv.TemporalTx = roTx if api.filters != nil { if sd := api.filters.LatestSD(); sd != nil { @@ -427,14 +428,16 @@ func (api *APIImpl) GetProof(ctx context.Context, address common.Address, storag } defer roTx.Rollback() - requestedBlockNr, _, _, err := rpchelper.GetCanonicalBlockNumber(ctx, blockNrOrHash, roTx, api._blockReader, api.filters) + // nil filters: resolve on the committed view — getProof gates on and reads + // the same plain roTx (see rpchelper.GetBlockNumber). + blockNumber, _, isLatest, err := rpchelper.GetCanonicalBlockNumber(ctx, blockNrOrHash, roTx, api._blockReader, nil) if err != nil { return nil, err - } else if requestedBlockNr == 0 { + } else if blockNumber == 0 { return nil, errors.New("block not found") } - err = api.BaseAPI.checkPruneHistory(ctx, roTx, uint64(requestedBlockNr)) + err = api.BaseAPI.checkPruneHistory(ctx, roTx, blockNumber) if err != nil { return nil, err } @@ -444,10 +447,10 @@ func (api *APIImpl) GetProof(ctx context.Context, address common.Address, storag storageKeysConverted[i].Hash.SetBytes(s) storageKeysConverted[i].KeyLength = len(s) } - return api.getProof(ctx, roTx, address, storageKeysConverted, rpc.BlockNumberOrHashWithNumber(rpc.BlockNumber(requestedBlockNr)), api.logger) + return api.getProof(ctx, roTx, address, storageKeysConverted, blockNumber, isLatest, api.logger) } -func (api *APIImpl) getProof(ctx context.Context, roTx kv.TemporalTx, address common.Address, storageKeys []StorageKeysInfo, blockNrOrHash rpc.BlockNumberOrHash, logger log.Logger) (*accounts.AccProofResult, error) { +func (api *APIImpl) getProof(ctx context.Context, roTx kv.TemporalTx, address common.Address, storageKeys []StorageKeysInfo, blockNumber uint64, isLatest bool, logger log.Logger) (*accounts.AccProofResult, error) { // Output key encoding is a bit special: if the input was a 32-byte hash, it is // returned as such. Otherwise, we apply the QUANTITY encoding mandated by the @@ -464,38 +467,29 @@ func (api *APIImpl) getProof(ctx context.Context, roTx kv.TemporalTx, address co return outputKey } - tx, err := api.db.BeginTemporalRo(ctx) - if err != nil { - return nil, err - } - defer tx.Rollback() // get the root hash from header to validate proofs along the way - header, err := api._blockReader.HeaderByNumber(ctx, roTx, blockNrOrHash.BlockNumber.Uint64()) + header, err := api._blockReader.HeaderByNumber(ctx, roTx, blockNumber) if err != nil { return nil, err } + if header == nil { + return nil, fmt.Errorf("header not found for block %d", blockNumber) + } - domains, err := execctx.NewSharedDomains(ctx, tx, log.New(), execctx.WithoutDeferredBranchUpdates(), execctx.WithSequentialCommitment()) + domains, err := execctx.NewSharedDomains(ctx, roTx, log.New(), execctx.WithoutDeferredBranchUpdates(), execctx.WithSequentialCommitment()) if err != nil { return nil, err } defer domains.Close() sdCtx := domains.GetCommitmentContext() - latestBlock, err := rpchelper.GetLatestBlockNumber(roTx) - if err != nil { - return nil, err - } - if latestBlock < blockNrOrHash.BlockNumber.Uint64() { - return nil, fmt.Errorf("block number is in the future latest=%d requested=%d", latestBlock, blockNrOrHash.BlockNumber.Uint64()) - } - if blockNrOrHash.BlockNumber.Uint64() < latestBlock { + if !isLatest { // Get first txnum of blockNumber+1 to ensure that correct state root will be restored as of blockNumber has been executed - lastTxnInBlock, err := api._txNumReader.Min(ctx, tx, blockNrOrHash.BlockNumber.Uint64()+1) + lastTxnInBlock, err := api._txNumReader.Min(ctx, roTx, blockNumber+1) if err != nil { return nil, err } - commitmentStartingTxNum := tx.Debug().HistoryStartFrom(kv.CommitmentDomain) + commitmentStartingTxNum := roTx.Debug().HistoryStartFrom(kv.CommitmentDomain) if lastTxnInBlock < commitmentStartingTxNum { return nil, fmt.Errorf("%w: commitment start: %d, last tx: %d", state.PrunedError, commitmentStartingTxNum, lastTxnInBlock) } @@ -575,9 +569,14 @@ func (api *APIImpl) getProof(ctx context.Context, roTx kv.TemporalTx, address co } } - reader, err := rpchelper.CreateStateReader(ctx, tx, api._blockReader, blockNrOrHash, 0, api.filters, api.stateCache, api._txNumReader) - if err != nil { - return nil, err + var reader state.StateReader + if isLatest { + reader = rpchelper.NewLatestStateReader(roTx) + } else { + reader, err = rpchelper.CreateHistoryStateReader(ctx, roTx, blockNumber+1, 0, api._txNumReader) + if err != nil { + return nil, err + } } // get storage key proofs @@ -652,7 +651,9 @@ func (api *BaseAPI) getWitness(ctx context.Context, db kv.TemporalRoDB, blockNrO } defer tx.Rollback() - blockNr, hash, _, err := rpchelper.GetCanonicalBlockNumber(ctx, blockNrOrHash, tx, api._blockReader, api.filters) // DoCall cannot be executed on non-canonical blocks + // nil filters: resolve on the committed view — the witness computation reads + // temporal data through the same plain tx (see rpchelper.GetBlockNumber). + blockNr, hash, _, err := rpchelper.GetCanonicalBlockNumber(ctx, blockNrOrHash, tx, api._blockReader, nil) // DoCall cannot be executed on non-canonical blocks if err != nil { return nil, err } diff --git a/rpc/jsonrpc/eth_call_test.go b/rpc/jsonrpc/eth_call_test.go index d645232f900..27657f3c848 100644 --- a/rpc/jsonrpc/eth_call_test.go +++ b/rpc/jsonrpc/eth_call_test.go @@ -39,13 +39,16 @@ import ( "github.com/erigontech/erigon/common/crypto" "github.com/erigontech/erigon/common/hexutil" "github.com/erigontech/erigon/common/log/v3" + "github.com/erigontech/erigon/db/dbservices" "github.com/erigontech/erigon/db/kv" "github.com/erigontech/erigon/db/kv/kvcache" "github.com/erigontech/erigon/db/kv/rawdbv3" "github.com/erigontech/erigon/db/rawdb" + "github.com/erigontech/erigon/db/state/execctx" "github.com/erigontech/erigon/db/state/statecfg" "github.com/erigontech/erigon/execution/chain" "github.com/erigontech/erigon/execution/commitment/trie" + "github.com/erigontech/erigon/execution/execmodule" "github.com/erigontech/erigon/execution/execmodule/execmoduletester" "github.com/erigontech/erigon/execution/protocol" "github.com/erigontech/erigon/execution/protocol/params" @@ -429,6 +432,79 @@ func TestGetProof(t *testing.T) { } } +type missingHeaderBlockReader struct { + dbservices.FullBlockReader +} + +func (missingHeaderBlockReader) HeaderByNumber(context.Context, kv.Getter, uint64) (*types.Header, error) { + return nil, nil +} + +func TestGetProofMissingHeader(t *testing.T) { + previousSchema := statecfg.Schema + statecfg.EnableHistoricalCommitment() + t.Cleanup(func() { + statecfg.Schema = previousSchema + }) + + m, bankAddr, _, _ := chainWithDeployedContract(t) + base := newBaseApiForTest(m) + base._blockReader = missingHeaderBlockReader{FullBlockReader: base._blockReader} + api := newEthApiForTest(base, m.DB, nil, nil) + + proof, err := api.GetProof( + context.Background(), + bankAddr, + nil, + bnhPtr(rpc.BlockNumberOrHashWithNumber(6)), + ) + require.EqualError(t, err, "header not found for block 6") + require.Nil(t, proof) +} + +func TestGetProofPinsReadSnapshot(t *testing.T) { + previousSchema := statecfg.Schema + statecfg.EnableHistoricalCommitment() + t.Cleanup(func() { + statecfg.Schema = previousSchema + }) + + m, _, contractAddress, _ := chainWithDeployedContract(t) + + roTx, err := m.DB.BeginTemporalRo(m.Ctx) + require.NoError(t, err) + defer roTx.Rollback() + + publishedDomains, err := execctx.NewSharedDomains(m.Ctx, roTx, m.Log) + require.NoError(t, err) + defer publishedDomains.Close() + + storageKey := common.Hash{} + compositeKey := make([]byte, 0, len(contractAddress)+len(storageKey)) + compositeKey = append(compositeKey, contractAddress[:]...) + compositeKey = append(compositeKey, storageKey[:]...) + require.NoError(t, publishedDomains.DomainPut(kv.StorageDomain, roTx, compositeKey, []byte{3}, 1, nil)) + + stateCache := &execmodule.Cache{} + stateCache.SetPublishedSD(func() *execctx.SharedDomains { return publishedDomains }) + base := newBaseApiForTest(m) + base.stateCache = stateCache + api := newEthApiForTest(base, m.DB, nil, nil) + + proof, err := api.getProof( + m.Ctx, + roTx, + contractAddress, + []StorageKeysInfo{{Hash: storageKey, KeyLength: len(storageKey)}}, + 6, + true, + log.New(), + ) + require.NoError(t, err) + require.NotNil(t, proof) + require.Equal(t, uint64(2), (*big.Int)(proof.StorageProof[0].Value).Uint64()) +} + func TestGetBlockByTimestampLatestTime(t *testing.T) { ctx := context.Background() m, _, _ := rpcdaemontest.CreateTestExecModule(t) diff --git a/rpc/jsonrpc/eth_receipts.go b/rpc/jsonrpc/eth_receipts.go index 5332e4dda11..4c58f381218 100644 --- a/rpc/jsonrpc/eth_receipts.go +++ b/rpc/jsonrpc/eth_receipts.go @@ -140,19 +140,18 @@ func exceedsLogQueryLimit(crit filters.FilterCriteria, limit int) bool { // resolveLogsRange resolves a filter's block range. A BlockHash pins the range to that // block; otherwise negative tags are resolved against the chain, defaulting to the // latest executed block. With checkFuture, ranges past the latest executed block are -// rejected as they are resolved. +// rejected as they are resolved. Tags resolve on the committed view of tx (nil +// filters — see rpchelper.GetBlockNumber): callers scan logs through the same tx. func (api *BaseAPI) resolveLogsRange(ctx context.Context, tx kv.Tx, crit filters.FilterCriteria, checkFuture bool) (begin, end uint64, err error) { if crit.BlockHash != nil { - block, err := api.blockByHashWithSenders(ctx, tx, *crit.BlockHash) + number, err := api._blockReader.HeaderNumber(ctx, tx, *crit.BlockHash) if err != nil { return 0, 0, err } - if block == nil { + if number == nil { return 0, 0, fmt.Errorf("block not found: %x", *crit.BlockHash) } - - num := block.NumberU64() - return num, num, nil + return *number, *number, nil } latest, _, _, err := rpchelper.GetBlockNumber(ctx, rpc.BlockNumberOrHashWithNumber(rpc.LatestExecutedBlockNumber), tx, api._blockReader, nil) @@ -167,7 +166,7 @@ func (api *BaseAPI) resolveLogsRange(ctx context.Context, tx kv.Tx, crit filters begin = uint64(fromBlock) } else { blockNum := rpc.BlockNumber(fromBlock) - begin, _, _, err = rpchelper.GetBlockNumber(ctx, rpc.BlockNumberOrHashWithNumber(blockNum), tx, api._blockReader, api.filters) + begin, _, _, err = rpchelper.GetBlockNumber(ctx, rpc.BlockNumberOrHashWithNumber(blockNum), tx, api._blockReader, nil) if err != nil { return 0, 0, err } @@ -184,7 +183,7 @@ func (api *BaseAPI) resolveLogsRange(ctx context.Context, tx kv.Tx, crit filters end = uint64(toBlock) } else { blockNum := rpc.BlockNumber(toBlock) - end, _, _, err = rpchelper.GetBlockNumber(ctx, rpc.BlockNumberOrHashWithNumber(blockNum), tx, api._blockReader, api.filters) + end, _, _, err = rpchelper.GetBlockNumber(ctx, rpc.BlockNumberOrHashWithNumber(blockNum), tx, api._blockReader, nil) if err != nil { return 0, 0, err } @@ -228,6 +227,7 @@ func (api *APIImpl) GetLogs(ctx context.Context, crit filters.FilterCriteria) (t return nil, &rpc.CustomError{Message: errInvalidBlockRange, Code: rpc.ErrCodeInvalidParams} } if end > roaring.MaxUint32 { + // Committed view: must agree with the scan below. latest, err := rpchelper.GetLatestBlockNumber(tx) if err != nil { return nil, err diff --git a/rpc/jsonrpc/eth_simulation.go b/rpc/jsonrpc/eth_simulation.go index be67abb6cb2..90e6bacfac7 100644 --- a/rpc/jsonrpc/eth_simulation.go +++ b/rpc/jsonrpc/eth_simulation.go @@ -125,7 +125,9 @@ func (api *APIImpl) SimulateV1(ctx context.Context, req SimulationRequest, block return nil, err } - blockNumber, blockHash, _, err := rpchelper.GetBlockNumber(ctx, blockParameter, tx, api._blockReader, api.filters) + // nil filters: resolve on the committed view — the gate and the simulator + // below read the same plain tx (see rpchelper.GetBlockNumber). + blockNumber, blockHash, _, err := rpchelper.GetBlockNumber(ctx, blockParameter, tx, api._blockReader, nil) if err != nil { return nil, err } diff --git a/rpc/jsonrpc/graphql_api.go b/rpc/jsonrpc/graphql_api.go index 8b88d2f1926..e275ce1b20d 100644 --- a/rpc/jsonrpc/graphql_api.go +++ b/rpc/jsonrpc/graphql_api.go @@ -89,7 +89,7 @@ func (api *GraphQLAPIImpl) GetLatestBlockNumber(ctx context.Context) (uint64, er return 0, err } defer tx.Rollback() - return rpchelper.GetLatestBlockNumber(tx) + return rpchelper.GetLatestBlockNumber(api.filters.WithOverlay(tx)) } func (api *GraphQLAPIImpl) GetBlockNumberForTx(ctx context.Context, hash common.Hash) (uint64, bool, error) { diff --git a/rpc/jsonrpc/overlay_api.go b/rpc/jsonrpc/overlay_api.go index efb9e5fd87c..325bc6256ee 100644 --- a/rpc/jsonrpc/overlay_api.go +++ b/rpc/jsonrpc/overlay_api.go @@ -555,6 +555,7 @@ func getBeginEnd(ctx context.Context, tx kv.Tx, api *OverlayAPIImpl, crit filter return 0, 0, fmt.Errorf("end (%d) < begin (%d)", end, begin) } if end > roaring.MaxUint32 { + // Committed view: must agree with the scan. latest, err := rpchelper.GetLatestBlockNumber(tx) if err != nil { return 0, 0, err diff --git a/rpc/jsonrpc/overlay_race_test.go b/rpc/jsonrpc/overlay_race_test.go index b37b3133fa1..322bd02641a 100644 --- a/rpc/jsonrpc/overlay_race_test.go +++ b/rpc/jsonrpc/overlay_race_test.go @@ -19,6 +19,7 @@ package jsonrpc import ( "bytes" "context" + "fmt" "strconv" "testing" @@ -29,6 +30,8 @@ import ( "github.com/erigontech/erigon/common" "github.com/erigontech/erigon/common/hexutil" + "github.com/erigontech/erigon/db/dbservices" + "github.com/erigontech/erigon/db/kv" "github.com/erigontech/erigon/db/kv/kvcache" "github.com/erigontech/erigon/db/rawdb" "github.com/erigontech/erigon/db/state/execctx" @@ -41,6 +44,7 @@ import ( "github.com/erigontech/erigon/node/gointerfaces/txpoolproto" "github.com/erigontech/erigon/node/shards" "github.com/erigontech/erigon/rpc" + "github.com/erigontech/erigon/rpc/filters" "github.com/erigontech/erigon/rpc/rpchelper" ) @@ -59,6 +63,11 @@ const ( // misc.CalcBaseFee leaves BaseFee unchanged, making overlayRaceBaseFee a // reliable, deterministic fingerprint for "the code read the overlay head". func newOverlayAheadTestAPI(t *testing.T) (base *BaseAPI, m *execmoduletester.ExecModuleTester, overlayHeader *types.Header) { + base, m, overlayHeader, _ = newOverlayAheadTestAPIWithEvents(t) + return base, m, overlayHeader +} + +func newOverlayAheadTestAPIWithEvents(t *testing.T) (base *BaseAPI, m *execmoduletester.ExecModuleTester, overlayHeader *types.Header, events *shards.Events) { t.Helper() var cfg chain.Config @@ -98,15 +107,44 @@ func newOverlayAheadTestAPI(t *testing.T) (base *BaseAPI, m *execmoduletester.Ex // enough for the reader paths under test to resolve this header as current. require.NoError(t, rawdb.WriteHeader(overlay, overlayHeader)) require.NoError(t, rawdb.WriteHeadHeaderHash(overlay, hash)) + rawdb.WriteForkchoiceHead(overlay, hash) require.NoError(t, rawdb.WriteCanonicalHash(overlay, hash, overlayNumber)) require.NoError(t, rawdb.WriteBody(overlay, hash, overlayNumber, &types.Body{})) - events := shards.NewEvents() + events = shards.NewEvents() events.PublishOverlay(doms) filters := rpchelper.New(ctx, rpchelper.DefaultFiltersConfig, nil, nil, nil, func() {}, m.Log, events) stateCache := kvcache.New(kvcache.DefaultCoherentConfig) base = newBaseApiWithFiltersForTest(filters, stateCache, m) + return base, m, overlayHeader, events +} + +type unpublishOverlayBlockReader struct { + dbservices.FullBlockReader + events *shards.Events + blockNumber uint64 +} + +func (r *unpublishOverlayBlockReader) CanonicalHash(ctx context.Context, tx kv.Getter, blockNum uint64) (common.Hash, bool, error) { + hash, ok, err := r.FullBlockReader.CanonicalHash(ctx, tx, blockNum) + if err == nil && ok && blockNum == r.blockNumber { + r.events.PublishOverlay(nil) + } + return hash, ok, err +} + +func newOverlayUnpublishTestAPI(t *testing.T) (*BaseAPI, *execmoduletester.ExecModuleTester, *types.Header) { + t.Helper() + base, m, overlayHeader, events := newOverlayAheadTestAPIWithEvents(t) + overlay := events.LatestSD().BlockOverlay() + txn := signOverlayRaceTestTx(t, m, 1) + require.NoError(t, rawdb.WriteBody(overlay, overlayHeader.Hash(), overlayHeader.Number.Uint64(), &types.Body{Transactions: []types.Transaction{txn}})) + base._blockReader = &unpublishOverlayBlockReader{ + FullBlockReader: base._blockReader, + events: events, + blockNumber: overlayHeader.Number.Uint64(), + } return base, m, overlayHeader } @@ -211,6 +249,82 @@ func TestTxPoolContent_UsesOverlayHead(t *testing.T) { "pending tx gas price must be derived from the overlay head's base fee, not the stale MDBX head") } +// TestGetBlockTransactionCountByHash_SeesOverlayHead pins that the by-hash +// count resolves the overlay head exactly like its by-number twin: the same +// in-flight block must be visible through both, not null through one of them. +func TestGetBlockTransactionCountByHash_SeesOverlayHead(t *testing.T) { + t.Parallel() + base, m, overlayHeader := newOverlayAheadTestAPI(t) + api := newEthApiForTest(base, m.DB, nil, nil) + + byNumber, err := api.GetBlockTransactionCountByNumber(m.Ctx, rpc.BlockNumber(overlayHeader.Number.Uint64())) + require.NoError(t, err) + require.NotNil(t, byNumber) + + byHash, err := api.GetBlockTransactionCountByHash(m.Ctx, overlayHeader.Hash()) + require.NoError(t, err) + require.NotNil(t, byHash, "by-hash count must see the overlay head the by-number count sees") + require.Equal(t, *byNumber, *byHash) +} + +func TestGetBlockTransactionCountByNumber_PinsOverlayView(t *testing.T) { + t.Parallel() + base, m, overlayHeader := newOverlayUnpublishTestAPI(t) + api := newEthApiForTest(base, m.DB, nil, nil) + + count, err := api.GetBlockTransactionCountByNumber(m.Ctx, rpc.BlockNumber(overlayHeader.Number.Uint64())) + require.NoError(t, err) + require.NotNil(t, count) + require.Equal(t, hexutil.Uint(1), *count) +} + +func TestGetBlockTransactionCountByHash_PinsOverlayView(t *testing.T) { + t.Parallel() + base, m, overlayHeader := newOverlayUnpublishTestAPI(t) + api := newEthApiForTest(base, m.DB, nil, nil) + + count, err := api.GetBlockTransactionCountByHash(m.Ctx, overlayHeader.Hash()) + require.NoError(t, err) + require.NotNil(t, count) + require.Equal(t, hexutil.Uint(1), *count) +} + +func TestGetRawHeader_PinsOverlayView(t *testing.T) { + t.Parallel() + base, m, overlayHeader := newOverlayUnpublishTestAPI(t) + api := NewPrivateDebugAPI(base, m.DB, nil, 0, false) + + header, err := api.GetRawHeader(m.Ctx, rpc.BlockNumberOrHashWithNumber(rpc.BlockNumber(overlayHeader.Number.Uint64()))) + require.NoError(t, err) + require.NotNil(t, header) +} + +// TestDebugAccountAt_OverlayHeadHash_CommittedView pins that debug_accountAt +// resolves the block hash on the committed view: its GetAsOf history reads can +// only see committed data, so an overlay-published head must read as an +// unknown block (null) — not resolve to a header whose canonical-hash check +// then fails. +func TestDebugAccountAt_OverlayHeadHash_CommittedView(t *testing.T) { + t.Parallel() + base, m, overlayHeader := newOverlayAheadTestAPI(t) + api := NewPrivateDebugAPI(base, m.DB, nil, 0, false) + + result, err := api.AccountAt(m.Ctx, overlayHeader.Hash(), 0, m.Address) + require.NoError(t, err, "an in-flight (uncommitted) head hash must read as unknown, not error") + require.Nil(t, result) +} + +func TestGetLogsBlockHashUsesCommittedView(t *testing.T) { + t.Parallel() + base, m, overlayHeader := newOverlayAheadTestAPI(t) + api := newEthApiForTest(base, m.DB, nil, nil) + hash := overlayHeader.Hash() + + logs, err := api.GetLogs(m.Ctx, filters.FilterCriteria{BlockHash: &hash}) + require.EqualError(t, err, fmt.Sprintf("block not found: %x", hash)) + require.Nil(t, logs) +} + // TestTxPoolContentFrom_UsesOverlayHead pins that txpool_contentFrom reads the // current header through the block overlay, matching TestTxPoolContent_UsesOverlayHead. func TestTxPoolContentFrom_UsesOverlayHead(t *testing.T) { diff --git a/rpc/jsonrpc/parity_api.go b/rpc/jsonrpc/parity_api.go index 749e364e185..36fa1d9819b 100644 --- a/rpc/jsonrpc/parity_api.go +++ b/rpc/jsonrpc/parity_api.go @@ -73,7 +73,12 @@ func (api *ParityAPIImpl) ListStorageKeys(ctx context.Context, account common.Ad return nil, errors.New("acc not found") } + // Committed view: bn must match the state version the RangeAsOf scan + // below can see (the overlay exposes block tables, not domain data). bn := rawdb.ReadCurrentBlockNumber(tx) + if bn == nil { + return nil, errors.New("current block number not found") + } minTxNum, err := api._txNumReader.Min(ctx, tx, *bn) if err != nil { return nil, err diff --git a/rpc/jsonrpc/trace_filtering.go b/rpc/jsonrpc/trace_filtering.go index b491e890a04..4dc8a424534 100644 --- a/rpc/jsonrpc/trace_filtering.go +++ b/rpc/jsonrpc/trace_filtering.go @@ -324,10 +324,12 @@ func (api *TraceAPIImpl) Filter(ctx context.Context, req TraceFilterRequest, gas var fromBlock uint64 var toBlock uint64 var err error + // nil filters: resolve tags on the committed view filterV3 scans + // (see rpchelper.GetBlockNumber). if req.FromBlock == nil { fromBlock = 0 } else { - fromBlock, _, _, err = rpchelper.GetBlockNumber(ctx, *req.FromBlock, dbtx, api._blockReader, api.filters) + fromBlock, _, _, err = rpchelper.GetBlockNumber(ctx, *req.FromBlock, dbtx, api._blockReader, nil) if err != nil { if errors.As(err, &rpc.BlockNotFoundErr{}) { stream.WriteEmptyArray() @@ -342,9 +344,12 @@ func (api *TraceAPIImpl) Filter(ctx context.Context, req TraceFilterRequest, gas if err != nil { return err } + if headNumber == nil { + return errors.New("head header not found") + } toBlock = *headNumber } else { - toBlock, _, _, err = rpchelper.GetBlockNumber(ctx, *req.ToBlock, dbtx, api._blockReader, api.filters) + toBlock, _, _, err = rpchelper.GetBlockNumber(ctx, *req.ToBlock, dbtx, api._blockReader, nil) if err != nil { if errors.As(err, &rpc.BlockNotFoundErr{}) { stream.WriteEmptyArray() diff --git a/rpc/jsonrpc/tracing.go b/rpc/jsonrpc/tracing.go index 9c5541403cc..ab41df775fe 100644 --- a/rpc/jsonrpc/tracing.go +++ b/rpc/jsonrpc/tracing.go @@ -58,7 +58,9 @@ func (api *DebugAPIImpl) traceBlock(ctx context.Context, blockNrOrHash rpc.Block } defer tx.Rollback() - blockNumber, hash, _, err := rpchelper.GetCanonicalBlockNumber(ctx, blockNrOrHash, tx, api._blockReader, api.filters) + // nil filters: resolve on the committed view — the replay below reads + // temporal data through the same plain tx (see rpchelper.GetBlockNumber). + blockNumber, hash, _, err := rpchelper.GetCanonicalBlockNumber(ctx, blockNrOrHash, tx, api._blockReader, nil) if err != nil { return err } diff --git a/rpc/rpchelper/helper.go b/rpc/rpchelper/helper.go index ce61d683eef..cfb65dea4e9 100644 --- a/rpc/rpchelper/helper.go +++ b/rpc/rpchelper/helper.go @@ -57,6 +57,16 @@ func CheckBlockExecuted(tx kv.Tx, blockNumber uint64) error { return nil } +// GetBlockNumber resolves a block number, hash, or tag to a concrete block number and hash. +// +// Tags resolve against the view tx exposes. Passing the API's Filters +// additionally wraps tx in the block overlay (which includes a head whose +// commit is still in flight) and lets "pending" resolve via LastPendingBlock. +// With nil filters tx is used exactly as passed — a plain tx for +// committed-view resolution, required when the caller then scans data through +// that same tx so the bounds and the scan agree, or a tx the caller already +// overlay-wrapped to pin one view for all its reads; "pending" then falls +// back to the latest executed block. func GetBlockNumber(ctx context.Context, blockNrOrHash rpc.BlockNumberOrHash, tx kv.Tx, br dbservices.FullBlockReader, filters *Filters) (uint64, common.Hash, bool, error) { bn, bh, latest, found, err := _GetBlockNumber(ctx, blockNrOrHash.RequireCanonical, blockNrOrHash, tx, br, filters) if err != nil { @@ -120,9 +130,9 @@ func _GetBlockNumber(ctx context.Context, requireCanonical bool, blockNrOrHash r return 0, common.Hash{}, false, false, err } case rpc.PendingBlockNumber: + // nil filters (committed-view resolution) = no pending block known. if filters != nil { - pendingBlock := filters.LastPendingBlock() - if pendingBlock != nil { + if pendingBlock := filters.LastPendingBlock(); pendingBlock != nil { return pendingBlock.NumberU64(), pendingBlock.Hash(), false, true, nil } } From 860712890caf271e1c0a23185c0aac5b051d3c13 Mon Sep 17 00:00:00 2001 From: awskii Date: Mon, 10 Aug 2026 23:13:35 +0700 Subject: [PATCH 02/11] rpc: resolve tracing replay tags on the committed view trace_block, trace_call, trace_callMany, trace_replayBlockTransactions, trace_rawTransaction and debug_traceCall{,Many} resolved head-sensitive block tags through the overlay while replaying on the committed tx, so a "latest" trace could run block N's transactions against state ending at N-1. On a mainnet archive node at the tip that made trace_block("latest") fail 129 of 4134 calls (3.1%) with "nonce too high: tx X state X-1", and occasionally "insufficient funds"; debug_traceBlockByNumber, which already builds its context from the plain tx, failed 0 of 4134 over the same run. The replay reads SD-temporal data, which the overlay does not serve: OverlayTemporalReadView.GetLatest delegates straight to the committed tx, so the state side cannot follow the overlay head. Resolving these tags on the committed view is what makes the bounds and the scan agree; pinning a single overlay view instead needs the SD-aware temporal view from #21314. Extends the same treatment already applied to trace_filter and debug_traceBlockBy*. --- rpc/jsonrpc/trace_adhoc.go | 18 ++++++++++++------ rpc/jsonrpc/trace_filtering.go | 3 ++- rpc/jsonrpc/tracing.go | 6 ++++-- 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/rpc/jsonrpc/trace_adhoc.go b/rpc/jsonrpc/trace_adhoc.go index 7421058c569..fdb11530609 100644 --- a/rpc/jsonrpc/trace_adhoc.go +++ b/rpc/jsonrpc/trace_adhoc.go @@ -985,7 +985,8 @@ func (api *TraceAPIImpl) ReplayBlockTransactions(ctx context.Context, blockNrOrH return nil, err } - blockNumber, blockHash, _, err := rpchelper.GetCanonicalBlockNumber(ctx, blockNrOrHash, tx, api._blockReader, api.filters) + // nil filters: committed view — the replay below reads temporal data through this tx. + blockNumber, blockHash, _, err := rpchelper.GetCanonicalBlockNumber(ctx, blockNrOrHash, tx, api._blockReader, nil) if err != nil { return nil, err } @@ -1115,7 +1116,8 @@ func (api *TraceAPIImpl) Call(ctx context.Context, args TraceCallParam, traceTyp blockNrOrHash = &rpc.BlockNumberOrHash{BlockNumber: &num} } - blockNumber, hash, latest, err := rpchelper.GetBlockNumber(ctx, *blockNrOrHash, tx, api._blockReader, api.filters) + // nil filters: committed view — the replay below reads temporal data through this tx. + blockNumber, hash, latest, err := rpchelper.GetBlockNumber(ctx, *blockNrOrHash, tx, api._blockReader, nil) if err != nil { return nil, err } @@ -1309,7 +1311,8 @@ func (api *TraceAPIImpl) CallMany(ctx context.Context, calls json.RawMessage, pa var num = rpc.LatestBlockNumber parentNrOrHash = &rpc.BlockNumberOrHash{BlockNumber: &num} } - blockNumber, hash, latest, err := rpchelper.GetBlockNumber(ctx, *parentNrOrHash, tx, api._blockReader, api.filters) + // nil filters: committed view — the replay below reads temporal data through this tx. + blockNumber, hash, latest, err := rpchelper.GetBlockNumber(ctx, *parentNrOrHash, tx, api._blockReader, nil) if err != nil { return nil, err } @@ -1386,7 +1389,8 @@ func (api *TraceAPIImpl) doCallBlock(ctx context.Context, dbtx kv.Tx, stateReade var num = rpc.LatestBlockNumber parentNrOrHash = &rpc.BlockNumberOrHash{BlockNumber: &num} } - parentBlockNumber, hash, _, err := rpchelper.GetBlockNumber(ctx, *parentNrOrHash, dbtx, api._blockReader, api.filters) + // nil filters: committed view — the replay below reads temporal data through this tx. + parentBlockNumber, hash, _, err := rpchelper.GetBlockNumber(ctx, *parentNrOrHash, dbtx, api._blockReader, nil) if err != nil { return nil, nil, err } @@ -1611,7 +1615,8 @@ func (api *TraceAPIImpl) doCall(ctx context.Context, dbtx kv.Tx, stateReader sta var num = rpc.LatestBlockNumber parentNrOrHash = &rpc.BlockNumberOrHash{BlockNumber: &num} } - parentBlockNumber, hash, _, err := rpchelper.GetBlockNumber(ctx, *parentNrOrHash, dbtx, api._blockReader, api.filters) + // nil filters: committed view — the replay below reads temporal data through this tx. + parentBlockNumber, hash, _, err := rpchelper.GetBlockNumber(ctx, *parentNrOrHash, dbtx, api._blockReader, nil) if err != nil { return nil, err } @@ -1821,7 +1826,8 @@ func (api *TraceAPIImpl) RawTransaction(ctx context.Context, encodedTx hexutil.B var num = rpc.LatestBlockNumber blockNrOrHash := rpc.BlockNumberOrHash{BlockNumber: &num} - blockNumber, hash, latest, err := rpchelper.GetBlockNumber(ctx, blockNrOrHash, dbtx, api._blockReader, api.filters) + // nil filters: committed view — the replay below reads temporal data through this tx. + blockNumber, hash, latest, err := rpchelper.GetBlockNumber(ctx, blockNrOrHash, dbtx, api._blockReader, nil) if err != nil { return nil, err } diff --git a/rpc/jsonrpc/trace_filtering.go b/rpc/jsonrpc/trace_filtering.go index b308606a80a..46ccfb648a5 100644 --- a/rpc/jsonrpc/trace_filtering.go +++ b/rpc/jsonrpc/trace_filtering.go @@ -195,7 +195,8 @@ func (api *TraceAPIImpl) Block(ctx context.Context, blockNr rpc.BlockNumber, gas return nil, err } defer tx.Rollback() - blockNum, hash, _, err := rpchelper.GetBlockNumber(ctx, rpc.BlockNumberOrHashWithNumber(blockNr), tx, api._blockReader, api.filters) + // nil filters: committed view — the replay below reads temporal data through this tx. + blockNum, hash, _, err := rpchelper.GetBlockNumber(ctx, rpc.BlockNumberOrHashWithNumber(blockNr), tx, api._blockReader, nil) if err != nil { return nil, err } diff --git a/rpc/jsonrpc/tracing.go b/rpc/jsonrpc/tracing.go index 2feedf48c55..9f5b0fa7afe 100644 --- a/rpc/jsonrpc/tracing.go +++ b/rpc/jsonrpc/tracing.go @@ -374,7 +374,8 @@ func (api *DebugAPIImpl) TraceCall(ctx context.Context, args ethapi.CallArgs, bl } engine := api.engine() - blockNumber, hash, isLatest, err := rpchelper.GetCanonicalBlockNumber(ctx, blockNrOrHash, dbtx, api._blockReader, api.filters) + // nil filters: committed view — the replay below reads temporal data through this tx. + blockNumber, hash, isLatest, err := rpchelper.GetCanonicalBlockNumber(ctx, blockNrOrHash, dbtx, api._blockReader, nil) if err != nil { return fmt.Errorf("get block number: %v", err) } @@ -482,7 +483,8 @@ func (api *DebugAPIImpl) TraceCallMany(ctx context.Context, bundles []Bundle, si defer func(start time.Time) { log.Trace("Tracing CallMany finished", "runtime", time.Since(start)) }(time.Now()) - blockNum, hash, isLatest, err := rpchelper.GetBlockNumber(ctx, simulateContext.BlockNumber, tx, api._blockReader, api.filters) + // nil filters: committed view — the replay below reads temporal data through this tx. + blockNum, hash, isLatest, err := rpchelper.GetBlockNumber(ctx, simulateContext.BlockNumber, tx, api._blockReader, nil) if err != nil { return err } From f52f6f0da35fe39f727777eb22413ec70c2873d4 Mon Sep 17 00:00:00 2001 From: awskii Date: Tue, 11 Aug 2026 01:00:35 +0700 Subject: [PATCH 03/11] rpc: pin one overlay view per request in the shared helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The BaseAPI helpers derived the overlay view twice — once to resolve the block tag, once to read the header or body — so an overlay unpublished between the two calls dropped the read onto the older MDBX snapshot while the number came from the newer one. headerByNumber, headerByNumberOrHash, blockByNumberWithSenders and blockByHashWithSenders now derive it once and thread it through, and erigon_getBlockByTimestamp reuses the view its search bounds came from. Also corrects a comment that claimed the overlay exposes block tables only: MemoryMutation.GetAsOf and HistorySeek do consult the SharedDomains set by InitBlockOverlay. GetLatest and RangeAsOf are the reads that stay on the committed backing tx, which is what the committed-view resolution depends on. --- rpc/jsonrpc/debug_api.go | 10 ++++------ rpc/jsonrpc/erigon_block.go | 6 +++--- rpc/jsonrpc/eth_api.go | 17 ++++++++++------- rpc/jsonrpc/eth_call.go | 6 ++---- rpc/jsonrpc/eth_receipts.go | 4 ++-- rpc/jsonrpc/eth_simulation.go | 3 +-- rpc/jsonrpc/parity_api.go | 2 +- rpc/jsonrpc/tracing.go | 3 +-- 8 files changed, 24 insertions(+), 27 deletions(-) diff --git a/rpc/jsonrpc/debug_api.go b/rpc/jsonrpc/debug_api.go index 7225a331075..84a64508d5c 100644 --- a/rpc/jsonrpc/debug_api.go +++ b/rpc/jsonrpc/debug_api.go @@ -163,8 +163,7 @@ func (api *DebugAPIImpl) StorageRangeAt(ctx context.Context, blockHash common.Ha } blockNrOrHash := rpc.BlockNumberOrHashWithHash(blockHash, true) - // nil filters: resolve on the committed view — the storage-range scan reads - // temporal data through the same plain tx (see rpchelper.GetBlockNumber). + // nil filters: committed view — the scan below reads temporal data through this tx. blockNumber, _, _, err := rpchelper.GetCanonicalBlockNumber(ctx, blockNrOrHash, tx, api._blockReader, nil) if err != nil { if errors.As(err, &rpc.BlockNotFoundErr{}) { @@ -260,8 +259,7 @@ func (api *DebugAPIImpl) AccountRange(ctx context.Context, blockNrOrHash rpc.Blo } } else if _, ok := blockNrOrHash.Hash(); ok { - // nil filters: resolve on the committed view — the dumper reads temporal - // data through the same plain tx (see rpchelper.GetBlockNumber). + // nil filters: committed view — the dumper reads temporal data through this tx. bn, _, _, err2 := rpchelper.GetCanonicalBlockNumber(ctx, blockNrOrHash, tx, api._blockReader, nil) if err2 != nil { return state.IteratorDump{}, err2 @@ -586,8 +584,8 @@ func (api *DebugAPIImpl) AccountAt(ctx context.Context, blockHash common.Hash, t } defer tx.Rollback() - // Committed view: the canonical-hash check and GetAsOf reads below use the - // same plain tx (an overlay-resolved head would have no committed history). + // Committed view: the canonical-hash check and the GetAsOf reads below all + // go through this plain tx. blockNumber, err := api._blockReader.HeaderNumber(ctx, tx, blockHash) if err != nil { return nil, err diff --git a/rpc/jsonrpc/erigon_block.go b/rpc/jsonrpc/erigon_block.go index a3f3ea1a158..64cfa6c6c46 100644 --- a/rpc/jsonrpc/erigon_block.go +++ b/rpc/jsonrpc/erigon_block.go @@ -104,7 +104,7 @@ func (api *ErigonImpl) GetBlockByTimestamp(ctx context.Context, timeStamp rpc.Ti currentHeaderTime := currentHeader.Time highestNumber := currentHeader.Number.Uint64() - firstHeader, err := api.headerByNumber(ctx, 0, tx) + firstHeader, err := api.headerByNumber(ctx, 0, overlayTx) if err != nil { return nil, err } @@ -146,7 +146,7 @@ func (api *ErigonImpl) GetBlockByTimestamp(ctx context.Context, timeStamp rpc.Ti return currentHeader.Time >= uintTimestamp }) - resultingHeader, err := api.headerByNumber(ctx, rpc.BlockNumber(blockNum), tx) + resultingHeader, err := api.headerByNumber(ctx, rpc.BlockNumber(blockNum), overlayTx) if err != nil { return nil, err } @@ -156,7 +156,7 @@ func (api *ErigonImpl) GetBlockByTimestamp(ctx context.Context, timeStamp rpc.Ti } for resultingHeader.Time > uintTimestamp { - beforeHeader, err := api.headerByNumber(ctx, rpc.BlockNumber(blockNum)-1, tx) + beforeHeader, err := api.headerByNumber(ctx, rpc.BlockNumber(blockNum)-1, overlayTx) if err != nil { return nil, err } diff --git a/rpc/jsonrpc/eth_api.go b/rpc/jsonrpc/eth_api.go index a00a278d6e1..5be884c2f50 100644 --- a/rpc/jsonrpc/eth_api.go +++ b/rpc/jsonrpc/eth_api.go @@ -296,14 +296,15 @@ func (api *BaseAPI) txnIndexInBlock(ctx context.Context, tx kv.Tx, blockNum, txN } func (api *BaseAPI) blockByNumberWithSenders(ctx context.Context, tx kv.Tx, number uint64) (*types.Block, error) { - blockNumber, hash, _, err := rpchelper.GetBlockNumber(ctx, rpc.BlockNumberOrHashWithNumber(rpc.BlockNumber(number)), tx, api._blockReader, api.filters) + overlayTx := api.filters.WithOverlay(tx) + blockNumber, hash, _, err := rpchelper.GetBlockNumber(ctx, rpc.BlockNumberOrHashWithNumber(rpc.BlockNumber(number)), overlayTx, api._blockReader, api.filters) if err != nil { if errors.As(err, &rpc.BlockNotFoundErr{}) { return nil, nil } return nil, err } - return api.blockWithSenders(ctx, tx, hash, blockNumber) + return api.blockWithSenders(ctx, overlayTx, hash, blockNumber) } func (api *BaseAPI) blockByHashWithSenders(ctx context.Context, tx kv.Tx, hash common.Hash) (*types.Block, error) { @@ -321,7 +322,7 @@ func (api *BaseAPI) blockByHashWithSenders(ctx context.Context, tx kv.Tx, hash c return nil, nil } - return api.blockWithSenders(ctx, tx, hash, *number) + return api.blockWithSenders(ctx, overlayTx, hash, *number) } func (api *BaseAPI) blockWithSenders(ctx context.Context, tx kv.Tx, hash common.Hash, number uint64) (*types.Block, error) { @@ -374,7 +375,10 @@ func (api *BaseAPI) headerNumberByHash(ctx context.Context, tx kv.Tx, hash commo // headerByNumberOrHash - intent to read recent headers only, tries from the lru cache before reading from the db func (api *BaseAPI) headerByNumberOrHash(ctx context.Context, tx kv.Tx, blockNrOrHash rpc.BlockNumberOrHash) (*types.Header, bool, error) { - blockNum, hash, isLatest, err := rpchelper.GetCanonicalBlockNumber(ctx, blockNrOrHash, tx, api._blockReader, api.filters) + // One overlay view for both the tag resolution and the read: deriving a + // second one can miss a head whose overlay was unpublished in between. + overlayTx := api.filters.WithOverlay(tx) + blockNum, hash, isLatest, err := rpchelper.GetCanonicalBlockNumber(ctx, blockNrOrHash, overlayTx, api._blockReader, api.filters) if err != nil { return nil, false, err } @@ -384,7 +388,6 @@ func (api *BaseAPI) headerByNumberOrHash(ctx context.Context, tx kv.Tx, blockNrO } } - overlayTx := api.filters.WithOverlay(tx) header, err := api._blockReader.HeaderByNumber(ctx, overlayTx, blockNum) if err != nil { return nil, false, err @@ -394,7 +397,8 @@ func (api *BaseAPI) headerByNumberOrHash(ctx context.Context, tx kv.Tx, blockNrO } func (api *BaseAPI) headerByNumber(ctx context.Context, number rpc.BlockNumber, tx kv.Tx) (*types.Header, error) { - n, h, _, err := rpchelper.GetBlockNumber(ctx, rpc.BlockNumberOrHashWithNumber(number), tx, api._blockReader, api.filters) + overlayTx := api.filters.WithOverlay(tx) + n, h, _, err := rpchelper.GetBlockNumber(ctx, rpc.BlockNumberOrHashWithNumber(number), overlayTx, api._blockReader, api.filters) if err != nil { return nil, err } @@ -404,7 +408,6 @@ func (api *BaseAPI) headerByNumber(ctx context.Context, number rpc.BlockNumber, return it.HeaderNoCopy(), nil } } - overlayTx := api.filters.WithOverlay(tx) return api._blockReader.Header(ctx, overlayTx, h, n) } diff --git a/rpc/jsonrpc/eth_call.go b/rpc/jsonrpc/eth_call.go index 7927e05de1f..1086dbfac3c 100644 --- a/rpc/jsonrpc/eth_call.go +++ b/rpc/jsonrpc/eth_call.go @@ -433,8 +433,7 @@ func (api *APIImpl) GetProof(ctx context.Context, address common.Address, storag } defer roTx.Rollback() - // nil filters: resolve on the committed view — getProof gates on and reads - // the same plain roTx (see rpchelper.GetBlockNumber). + // nil filters: committed view — getProof gates on and reads this same roTx. blockNumber, _, isLatest, err := rpchelper.GetCanonicalBlockNumber(ctx, blockNrOrHash, roTx, api._blockReader, nil) if err != nil { return nil, err @@ -654,8 +653,7 @@ func (api *BaseAPI) getWitness(ctx context.Context, db kv.TemporalRoDB, blockNrO } defer tx.Rollback() - // nil filters: resolve on the committed view — the witness computation reads - // temporal data through the same plain tx (see rpchelper.GetBlockNumber). + // nil filters: committed view — the witness computation reads temporal data through this tx. blockNr, hash, _, err := rpchelper.GetCanonicalBlockNumber(ctx, blockNrOrHash, tx, api._blockReader, nil) // DoCall cannot be executed on non-canonical blocks if err != nil { return nil, err diff --git a/rpc/jsonrpc/eth_receipts.go b/rpc/jsonrpc/eth_receipts.go index e84a79a6a82..9c054f4cec5 100644 --- a/rpc/jsonrpc/eth_receipts.go +++ b/rpc/jsonrpc/eth_receipts.go @@ -140,8 +140,8 @@ func exceedsLogQueryLimit(crit filters.FilterCriteria, limit int) bool { // resolveLogsRange resolves a filter's block range. A BlockHash pins the range to that // block; otherwise negative tags are resolved against the chain, defaulting to the // latest executed block. With checkFuture, ranges past the latest executed block are -// rejected as they are resolved. Tags resolve on the committed view of tx (nil -// filters — see rpchelper.GetBlockNumber): callers scan logs through the same tx. +// rejected as they are resolved. Tags resolve on the view tx exposes, since +// callers scan logs through that same tx. func (api *BaseAPI) resolveLogsRange(ctx context.Context, tx kv.Tx, crit filters.FilterCriteria, checkFuture bool) (begin, end uint64, err error) { if crit.BlockHash != nil { number, err := api._blockReader.HeaderNumber(ctx, tx, *crit.BlockHash) diff --git a/rpc/jsonrpc/eth_simulation.go b/rpc/jsonrpc/eth_simulation.go index 66cdd480049..8a081541c2d 100644 --- a/rpc/jsonrpc/eth_simulation.go +++ b/rpc/jsonrpc/eth_simulation.go @@ -125,8 +125,7 @@ func (api *APIImpl) SimulateV1(ctx context.Context, req SimulationRequest, block return nil, err } - // nil filters: resolve on the committed view — the gate and the simulator - // below read the same plain tx (see rpchelper.GetBlockNumber). + // nil filters: committed view — the gate and the simulator below read this same tx. blockNumber, blockHash, _, err := rpchelper.GetBlockNumber(ctx, blockParameter, tx, api._blockReader, nil) if err != nil { return nil, err diff --git a/rpc/jsonrpc/parity_api.go b/rpc/jsonrpc/parity_api.go index 5feaf1dc9bf..5ddaa68004c 100644 --- a/rpc/jsonrpc/parity_api.go +++ b/rpc/jsonrpc/parity_api.go @@ -75,7 +75,7 @@ func (api *ParityAPIImpl) ListStorageKeys(ctx context.Context, account common.Ad } // Committed view: bn must match the state version the RangeAsOf scan - // below can see (the overlay exposes block tables, not domain data). + // below can see (the overlay exposes no domain range reads). bn := rawdb.ReadCurrentBlockNumber(tx) if bn == nil { return nil, errors.New("current block number not found") diff --git a/rpc/jsonrpc/tracing.go b/rpc/jsonrpc/tracing.go index 9f5b0fa7afe..b2288c02b03 100644 --- a/rpc/jsonrpc/tracing.go +++ b/rpc/jsonrpc/tracing.go @@ -58,8 +58,7 @@ func (api *DebugAPIImpl) traceBlock(ctx context.Context, blockNrOrHash rpc.Block } defer tx.Rollback() - // nil filters: resolve on the committed view — the replay below reads - // temporal data through the same plain tx (see rpchelper.GetBlockNumber). + // nil filters: committed view — the replay below reads temporal data through this tx. blockNumber, hash, _, err := rpchelper.GetCanonicalBlockNumber(ctx, blockNrOrHash, tx, api._blockReader, nil) if err != nil { return err From 15614fb45af11ca0b6bf4bc6f5426a05d0641ada Mon Sep 17 00:00:00 2001 From: awskii Date: Tue, 11 Aug 2026 01:03:15 +0700 Subject: [PATCH 04/11] rpc: reject non-canonical block hashes in eth_getLogs Resolving the blockHash filter through HeaderNumber alone accepts any header the header-number index knows, including side-chain and header-only ones, while the log scan that follows is by block number. A non-canonical hash therefore returned the canonical block's logs instead of an error. Gate the resolved number on the canonical hash matching. --- rpc/jsonrpc/eth_receipts.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/rpc/jsonrpc/eth_receipts.go b/rpc/jsonrpc/eth_receipts.go index 9c054f4cec5..72d7f46b0e8 100644 --- a/rpc/jsonrpc/eth_receipts.go +++ b/rpc/jsonrpc/eth_receipts.go @@ -151,6 +151,16 @@ func (api *BaseAPI) resolveLogsRange(ctx context.Context, tx kv.Tx, crit filters if number == nil { return 0, 0, fmt.Errorf("block not found: %x", *crit.BlockHash) } + // The header-number index also covers non-canonical headers, and the log + // scan below is by block number: without this the caller would get the + // canonical block's logs for a side-chain hash. + canonicalHash, ok, err := api._blockReader.CanonicalHash(ctx, tx, *number) + if err != nil { + return 0, 0, err + } + if !ok || canonicalHash != *crit.BlockHash { + return 0, 0, fmt.Errorf("block not found: %x", *crit.BlockHash) + } return *number, *number, nil } From 9e36502a5718014dfbb70ac5d8e041cb91967de3 Mon Sep 17 00:00:00 2001 From: awskii Date: Tue, 11 Aug 2026 12:45:48 +0700 Subject: [PATCH 05/11] rpc: reject the pending tag in the tracing methods The tracing methods resolve and replay on the committed view, which holds no pending block, so rpchelper.GetBlockNumber silently resolved "pending" to the latest executed block: a caller asking to trace pending got a trace of a different block, reported as that block, with no error. Reject the tag instead, matching go-ethereum, which answers "tracing on top of pending is not supported" rather than substituting a block. Covers debug_traceBlockByNumber/ByHash, debug_traceCall, debug_traceCallMany, trace_block, trace_replayBlockTransactions, trace_call and trace_callMany. --- rpc/jsonrpc/trace_adhoc.go | 9 ++++ rpc/jsonrpc/trace_filtering.go | 3 ++ rpc/jsonrpc/trace_pending_test.go | 80 +++++++++++++++++++++++++++++++ rpc/jsonrpc/tracing.go | 30 ++++++++++++ 4 files changed, 122 insertions(+) create mode 100644 rpc/jsonrpc/trace_pending_test.go diff --git a/rpc/jsonrpc/trace_adhoc.go b/rpc/jsonrpc/trace_adhoc.go index fdb11530609..fceb4f50cd9 100644 --- a/rpc/jsonrpc/trace_adhoc.go +++ b/rpc/jsonrpc/trace_adhoc.go @@ -972,6 +972,9 @@ func (api *TraceAPIImpl) ReplayTransaction(ctx context.Context, txHash common.Ha } func (api *TraceAPIImpl) ReplayBlockTransactions(ctx context.Context, blockNrOrHash rpc.BlockNumberOrHash, traceTypes []string, gasBailOut *bool, traceConfig *config.TraceConfig) ([]*TraceCallResult, error) { + if err := rejectPending(blockNrOrHash); err != nil { + return nil, err + } if gasBailOut == nil { gasBailOut = new(bool) // false by default } @@ -1115,6 +1118,9 @@ func (api *TraceAPIImpl) Call(ctx context.Context, args TraceCallParam, traceTyp var num = rpc.LatestBlockNumber blockNrOrHash = &rpc.BlockNumberOrHash{BlockNumber: &num} } + if err := rejectPending(*blockNrOrHash); err != nil { + return nil, err + } // nil filters: committed view — the replay below reads temporal data through this tx. blockNumber, hash, latest, err := rpchelper.GetBlockNumber(ctx, *blockNrOrHash, tx, api._blockReader, nil) @@ -1311,6 +1317,9 @@ func (api *TraceAPIImpl) CallMany(ctx context.Context, calls json.RawMessage, pa var num = rpc.LatestBlockNumber parentNrOrHash = &rpc.BlockNumberOrHash{BlockNumber: &num} } + if err := rejectPending(*parentNrOrHash); err != nil { + return nil, err + } // nil filters: committed view — the replay below reads temporal data through this tx. blockNumber, hash, latest, err := rpchelper.GetBlockNumber(ctx, *parentNrOrHash, tx, api._blockReader, nil) if err != nil { diff --git a/rpc/jsonrpc/trace_filtering.go b/rpc/jsonrpc/trace_filtering.go index 46ccfb648a5..a2b12281102 100644 --- a/rpc/jsonrpc/trace_filtering.go +++ b/rpc/jsonrpc/trace_filtering.go @@ -187,6 +187,9 @@ func newRewardTrace(blockHash common.Hash, blockNum uint64, author common.Addres // Block implements trace_block func (api *TraceAPIImpl) Block(ctx context.Context, blockNr rpc.BlockNumber, gasBailOut *bool, traceConfig *config.TraceConfig) (ParityTraces, error) { + if err := rejectPendingNumber(blockNr); err != nil { + return nil, err + } if gasBailOut == nil { gasBailOut = new(bool) // false by default } diff --git a/rpc/jsonrpc/trace_pending_test.go b/rpc/jsonrpc/trace_pending_test.go new file mode 100644 index 00000000000..588a814c588 --- /dev/null +++ b/rpc/jsonrpc/trace_pending_test.go @@ -0,0 +1,80 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Erigon is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with Erigon. If not, see . + +package jsonrpc + +import ( + "context" + "encoding/json" + "io" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/cmd/rpcdaemon/rpcdaemontest" + "github.com/erigontech/erigon/rpc" + "github.com/erigontech/erigon/rpc/ethapi" + "github.com/erigontech/erigon/rpc/jsonstream" + "github.com/erigontech/erigon/rpc/rpccfg" +) + +// The tracing methods resolve and replay on the committed view, which has no +// pending block, so "pending" must be rejected rather than silently answered +// for the latest executed block. +func TestTracingRejectsPendingTag(t *testing.T) { + m, _, _ := rpcdaemontest.CreateTestExecModule(t) + ctx := context.Background() + pending := rpc.PendingBlockNumber + pendingNrOrHash := rpc.BlockNumberOrHashWithNumber(pending) + + debugAPI := NewPrivateDebugAPI(newBaseApiForTest(m), m.DB, nil, &rpccfg.DebugApiConfig{}) + traceAPI := newTraceApiForTest(m) + + t.Run("debug_traceBlockByNumber", func(t *testing.T) { + err := debugAPI.TraceBlockByNumber(ctx, pending, nil, jsonstream.New(io.Discard)) + require.ErrorIs(t, err, errPendingNotSupported) + }) + + t.Run("debug_traceCall", func(t *testing.T) { + err := debugAPI.TraceCall(ctx, ethapi.CallArgs{}, pendingNrOrHash, nil, jsonstream.New(io.Discard)) + require.ErrorIs(t, err, errPendingNotSupported) + }) + + t.Run("debug_traceCallMany", func(t *testing.T) { + err := debugAPI.TraceCallMany(ctx, nil, StateContext{BlockNumber: pendingNrOrHash}, nil, jsonstream.New(io.Discard)) + require.ErrorIs(t, err, errPendingNotSupported) + }) + + t.Run("trace_block", func(t *testing.T) { + _, err := traceAPI.Block(ctx, pending, nil, nil) + require.ErrorIs(t, err, errPendingNotSupported) + }) + + t.Run("trace_replayBlockTransactions", func(t *testing.T) { + _, err := traceAPI.ReplayBlockTransactions(ctx, pendingNrOrHash, []string{TraceTypeTrace}, nil, nil) + require.ErrorIs(t, err, errPendingNotSupported) + }) + + t.Run("trace_call", func(t *testing.T) { + _, err := traceAPI.Call(ctx, TraceCallParam{}, []string{TraceTypeTrace}, &pendingNrOrHash, nil) + require.ErrorIs(t, err, errPendingNotSupported) + }) + + t.Run("trace_callMany", func(t *testing.T) { + _, err := traceAPI.CallMany(ctx, json.RawMessage("[]"), &pendingNrOrHash, nil) + require.ErrorIs(t, err, errPendingNotSupported) + }) +} diff --git a/rpc/jsonrpc/tracing.go b/rpc/jsonrpc/tracing.go index b2288c02b03..b1fca048e36 100644 --- a/rpc/jsonrpc/tracing.go +++ b/rpc/jsonrpc/tracing.go @@ -18,6 +18,7 @@ package jsonrpc import ( "context" + "errors" "fmt" "time" @@ -41,6 +42,26 @@ import ( "github.com/erigontech/erigon/rpc/transactions" ) +// errPendingNotSupported is returned for the "pending" tag by the tracing methods, +// matching go-ethereum. They resolve and replay on the committed view, which holds +// no pending block, so accepting the tag would trace the latest executed block +// while reporting it as the one the caller asked for. +var errPendingNotSupported = errors.New("tracing on top of pending is not supported") + +func rejectPendingNumber(blockNr rpc.BlockNumber) error { + if blockNr == rpc.PendingBlockNumber { + return errPendingNotSupported + } + return nil +} + +func rejectPending(blockNrOrHash rpc.BlockNumberOrHash) error { + if blockNrOrHash.BlockNumber == nil { + return nil + } + return rejectPendingNumber(*blockNrOrHash.BlockNumber) +} + // TraceBlockByNumber implements debug_traceBlockByNumber. Returns Geth style block traces. func (api *DebugAPIImpl) TraceBlockByNumber(ctx context.Context, blockNum rpc.BlockNumber, config *tracersConfig.TraceConfig, stream jsonstream.Stream) error { return api.traceBlock(ctx, rpc.BlockNumberOrHashWithNumber(blockNum), config, stream) @@ -52,6 +73,9 @@ func (api *DebugAPIImpl) TraceBlockByHash(ctx context.Context, hash common.Hash, } func (api *DebugAPIImpl) traceBlock(ctx context.Context, blockNrOrHash rpc.BlockNumberOrHash, config *tracersConfig.TraceConfig, stream jsonstream.Stream) error { + if err := rejectPending(blockNrOrHash); err != nil { + return err + } tx, err := api.db.BeginTemporalRo(ctx) if err != nil { return err @@ -361,6 +385,9 @@ func (api *DebugAPIImpl) TraceTransaction(ctx context.Context, hash common.Hash, // TraceCall implements debug_traceCall. Returns Geth style call traces. func (api *DebugAPIImpl) TraceCall(ctx context.Context, args ethapi.CallArgs, blockNrOrHash rpc.BlockNumberOrHash, config *tracersConfig.TraceConfig, stream jsonstream.Stream) error { + if err := rejectPending(blockNrOrHash); err != nil { + return err + } dbtx, err := api.db.BeginTemporalRo(ctx) if err != nil { return fmt.Errorf("create ro transaction: %v", err) @@ -454,6 +481,9 @@ func (api *DebugAPIImpl) TraceCall(ctx context.Context, args ethapi.CallArgs, bl // TraceCall implements debug_traceCallMany. Returns Geth style call traces. func (api *DebugAPIImpl) TraceCallMany(ctx context.Context, bundles []Bundle, simulateContext StateContext, config *tracersConfig.TraceConfig, stream jsonstream.Stream) error { + if err := rejectPending(simulateContext.BlockNumber); err != nil { + return err + } var ( hash common.Hash evm *vm.EVM From 1fd06b73f5bdda80662e616313c4ab29e2ca62ae Mon Sep 17 00:00:00 2001 From: awskii Date: Tue, 11 Aug 2026 13:08:47 +0700 Subject: [PATCH 06/11] rpc: limit the pending rejection to the call methods debug_traceBlockByNumber("pending") answered before this series and the RPC integration suite pins that (debug_traceBlockByNumber/test_25), so rejecting the tag there broke mainnet-rpc-integ-tests. go-ethereum draws the line in the same place: it refuses to execute a call on top of pending, but traces a pending block rather than erroring. Keep the rejection on debug_traceCall, debug_traceCallMany, trace_call and trace_callMany; drop it from debug_traceBlockBy*, trace_block and trace_replayBlockTransactions. --- rpc/jsonrpc/trace_adhoc.go | 3 -- rpc/jsonrpc/trace_filtering.go | 3 -- rpc/jsonrpc/trace_pending_test.go | 48 +++++++++++++++++-------------- rpc/jsonrpc/tracing.go | 3 -- 4 files changed, 27 insertions(+), 30 deletions(-) diff --git a/rpc/jsonrpc/trace_adhoc.go b/rpc/jsonrpc/trace_adhoc.go index fceb4f50cd9..12ce1722cb1 100644 --- a/rpc/jsonrpc/trace_adhoc.go +++ b/rpc/jsonrpc/trace_adhoc.go @@ -972,9 +972,6 @@ func (api *TraceAPIImpl) ReplayTransaction(ctx context.Context, txHash common.Ha } func (api *TraceAPIImpl) ReplayBlockTransactions(ctx context.Context, blockNrOrHash rpc.BlockNumberOrHash, traceTypes []string, gasBailOut *bool, traceConfig *config.TraceConfig) ([]*TraceCallResult, error) { - if err := rejectPending(blockNrOrHash); err != nil { - return nil, err - } if gasBailOut == nil { gasBailOut = new(bool) // false by default } diff --git a/rpc/jsonrpc/trace_filtering.go b/rpc/jsonrpc/trace_filtering.go index a2b12281102..46ccfb648a5 100644 --- a/rpc/jsonrpc/trace_filtering.go +++ b/rpc/jsonrpc/trace_filtering.go @@ -187,9 +187,6 @@ func newRewardTrace(blockHash common.Hash, blockNum uint64, author common.Addres // Block implements trace_block func (api *TraceAPIImpl) Block(ctx context.Context, blockNr rpc.BlockNumber, gasBailOut *bool, traceConfig *config.TraceConfig) (ParityTraces, error) { - if err := rejectPendingNumber(blockNr); err != nil { - return nil, err - } if gasBailOut == nil { gasBailOut = new(bool) // false by default } diff --git a/rpc/jsonrpc/trace_pending_test.go b/rpc/jsonrpc/trace_pending_test.go index 588a814c588..175d977a45d 100644 --- a/rpc/jsonrpc/trace_pending_test.go +++ b/rpc/jsonrpc/trace_pending_test.go @@ -31,23 +31,18 @@ import ( "github.com/erigontech/erigon/rpc/rpccfg" ) -// The tracing methods resolve and replay on the committed view, which has no -// pending block, so "pending" must be rejected rather than silently answered -// for the latest executed block. -func TestTracingRejectsPendingTag(t *testing.T) { +// The methods that execute a call on top of a resolved state reject "pending": +// they replay on the committed view, which holds no pending block, so accepting +// the tag would run the call against the latest executed state and report it as +// pending. go-ethereum answers the same way. +func TestTraceCallRejectsPendingTag(t *testing.T) { m, _, _ := rpcdaemontest.CreateTestExecModule(t) ctx := context.Background() - pending := rpc.PendingBlockNumber - pendingNrOrHash := rpc.BlockNumberOrHashWithNumber(pending) + pendingNrOrHash := rpc.BlockNumberOrHashWithNumber(rpc.PendingBlockNumber) debugAPI := NewPrivateDebugAPI(newBaseApiForTest(m), m.DB, nil, &rpccfg.DebugApiConfig{}) traceAPI := newTraceApiForTest(m) - t.Run("debug_traceBlockByNumber", func(t *testing.T) { - err := debugAPI.TraceBlockByNumber(ctx, pending, nil, jsonstream.New(io.Discard)) - require.ErrorIs(t, err, errPendingNotSupported) - }) - t.Run("debug_traceCall", func(t *testing.T) { err := debugAPI.TraceCall(ctx, ethapi.CallArgs{}, pendingNrOrHash, nil, jsonstream.New(io.Discard)) require.ErrorIs(t, err, errPendingNotSupported) @@ -58,16 +53,6 @@ func TestTracingRejectsPendingTag(t *testing.T) { require.ErrorIs(t, err, errPendingNotSupported) }) - t.Run("trace_block", func(t *testing.T) { - _, err := traceAPI.Block(ctx, pending, nil, nil) - require.ErrorIs(t, err, errPendingNotSupported) - }) - - t.Run("trace_replayBlockTransactions", func(t *testing.T) { - _, err := traceAPI.ReplayBlockTransactions(ctx, pendingNrOrHash, []string{TraceTypeTrace}, nil, nil) - require.ErrorIs(t, err, errPendingNotSupported) - }) - t.Run("trace_call", func(t *testing.T) { _, err := traceAPI.Call(ctx, TraceCallParam{}, []string{TraceTypeTrace}, &pendingNrOrHash, nil) require.ErrorIs(t, err, errPendingNotSupported) @@ -78,3 +63,24 @@ func TestTracingRejectsPendingTag(t *testing.T) { require.ErrorIs(t, err, errPendingNotSupported) }) } + +// Block tracing keeps accepting the tag: go-ethereum traces the pending block +// rather than rejecting it, and the RPC integration suite pins that a pending +// debug_traceBlockByNumber answers instead of erroring. +func TestTraceBlockAcceptsPendingTag(t *testing.T) { + m, _, _ := rpcdaemontest.CreateTestExecModule(t) + ctx := context.Background() + pending := rpc.PendingBlockNumber + + debugAPI := NewPrivateDebugAPI(newBaseApiForTest(m), m.DB, nil, &rpccfg.DebugApiConfig{}) + traceAPI := newTraceApiForTest(m) + + err := debugAPI.TraceBlockByNumber(ctx, pending, nil, jsonstream.New(io.Discard)) + require.NotErrorIs(t, err, errPendingNotSupported) + + _, err = traceAPI.Block(ctx, pending, nil, nil) + require.NotErrorIs(t, err, errPendingNotSupported) + + _, err = traceAPI.ReplayBlockTransactions(ctx, rpc.BlockNumberOrHashWithNumber(pending), []string{TraceTypeTrace}, nil, nil) + require.NotErrorIs(t, err, errPendingNotSupported) +} diff --git a/rpc/jsonrpc/tracing.go b/rpc/jsonrpc/tracing.go index b1fca048e36..f6136881468 100644 --- a/rpc/jsonrpc/tracing.go +++ b/rpc/jsonrpc/tracing.go @@ -73,9 +73,6 @@ func (api *DebugAPIImpl) TraceBlockByHash(ctx context.Context, hash common.Hash, } func (api *DebugAPIImpl) traceBlock(ctx context.Context, blockNrOrHash rpc.BlockNumberOrHash, config *tracersConfig.TraceConfig, stream jsonstream.Stream) error { - if err := rejectPending(blockNrOrHash); err != nil { - return err - } tx, err := api.db.BeginTemporalRo(ctx) if err != nil { return err From 3e36ee368eeb341ab91ea6b582e88f89f792216f Mon Sep 17 00:00:00 2001 From: awskii Date: Tue, 11 Aug 2026 13:26:05 +0700 Subject: [PATCH 07/11] rpc, docs: tighten the pending-tag test and document the rejection TestTraceBlockAcceptsPendingTag asserted NotErrorIs, which passes on any unrelated error; all three methods return nil there, so assert NoError. The test now fails if the rejection is widened back onto block tracing, which is the regression that broke mainnet-rpc-integ-tests. trace_call and trace_callMany still advertised 'pending' as an accepted tag, so update those two parameter lists. trace_block and trace_replayBlockTransactions keep accepting it and are left alone. Versioned docs describe shipped releases and are not touched. --- docs/site/docs/interacting-with-erigon/trace.md | 4 ++-- rpc/jsonrpc/trace_pending_test.go | 9 ++++----- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/docs/site/docs/interacting-with-erigon/trace.md b/docs/site/docs/interacting-with-erigon/trace.md index 4ddce77bade..f3515ef624e 100644 --- a/docs/site/docs/interacting-with-erigon/trace.md +++ b/docs/site/docs/interacting-with-erigon/trace.md @@ -199,7 +199,7 @@ Executes the given call and returns a number of possible traces for it. 1. `Object` - \[Transaction object] where `from` field is optional and `nonce` field is omitted. 2. `Array` - Type of trace, one or more of: `"vmTrace"`, `"trace"`, `"stateDiff"`. -3. `Quantity` or `Tag` - (optional) Integer of a block number, or the string `'earliest'`, `'latest'` or `'pending'`. +3. `Quantity` or `Tag` - (optional) Integer of a block number, or the string `'earliest'` or `'latest'`. `'pending'` is not supported: the call is executed against committed state, so there is no pending block to execute on top of. #### Returns @@ -247,7 +247,7 @@ Performs multiple call traces on top of the same block. i.e. transaction `n` wil #### Parameters 1. `Array` - List of trace calls with the type of trace, one or more of: `"vmTrace"`, `"trace"`, `"stateDiff"`. -2. `Quantity` or `Tag` - (optional) integer block number, or the string `'latest'`, `'earliest'` or `'pending'` (default block parameter). +2. `Quantity` or `Tag` - (optional) integer block number, or the string `'latest'` or `'earliest'` (default block parameter). `'pending'` is not supported: the calls are executed against committed state, so there is no pending block to execute on top of. ```js params: [ diff --git a/rpc/jsonrpc/trace_pending_test.go b/rpc/jsonrpc/trace_pending_test.go index 175d977a45d..b2a4c6dfab7 100644 --- a/rpc/jsonrpc/trace_pending_test.go +++ b/rpc/jsonrpc/trace_pending_test.go @@ -75,12 +75,11 @@ func TestTraceBlockAcceptsPendingTag(t *testing.T) { debugAPI := NewPrivateDebugAPI(newBaseApiForTest(m), m.DB, nil, &rpccfg.DebugApiConfig{}) traceAPI := newTraceApiForTest(m) - err := debugAPI.TraceBlockByNumber(ctx, pending, nil, jsonstream.New(io.Discard)) - require.NotErrorIs(t, err, errPendingNotSupported) + require.NoError(t, debugAPI.TraceBlockByNumber(ctx, pending, nil, jsonstream.New(io.Discard))) - _, err = traceAPI.Block(ctx, pending, nil, nil) - require.NotErrorIs(t, err, errPendingNotSupported) + _, err := traceAPI.Block(ctx, pending, nil, nil) + require.NoError(t, err) _, err = traceAPI.ReplayBlockTransactions(ctx, rpc.BlockNumberOrHashWithNumber(pending), []string{TraceTypeTrace}, nil, nil) - require.NotErrorIs(t, err, errPendingNotSupported) + require.NoError(t, err) } From d612e84d70757a81a97970629a6db4cf028a5e36 Mon Sep 17 00:00:00 2001 From: awskii Date: Tue, 11 Aug 2026 13:38:30 +0700 Subject: [PATCH 08/11] rpc, docs: reject the pending tag on the block-tracing methods too Completes the rejection started for the call methods. debug_traceBlockBy*, trace_block and trace_replayBlockTransactions resolve tags on the committed view, where "pending" falls through to the latest executed block, so they answered for the head block and reported it as the pending request. go-ethereum either traces a real pending block or errors; it never substitutes a different one, so answering for latest matches neither branch. Real pending-block tracing needs a pending state source and is left for later; until then an explicit error beats a wrong block. CI note: this changes debug_traceBlockByNumber/test_25 in the rpc-tests suite, updated in erigontech/rpc-tests#588. mainnet-rpc-integ-tests stays red until that merges and RPC_VERSION is bumped. --- .../docs/interacting-with-erigon/trace.md | 4 +- rpc/jsonrpc/trace_adhoc.go | 3 ++ rpc/jsonrpc/trace_filtering.go | 3 ++ rpc/jsonrpc/trace_pending_test.go | 38 ++++++++----------- rpc/jsonrpc/tracing.go | 12 ++++-- 5 files changed, 32 insertions(+), 28 deletions(-) diff --git a/docs/site/docs/interacting-with-erigon/trace.md b/docs/site/docs/interacting-with-erigon/trace.md index f3515ef624e..87a32ce9c71 100644 --- a/docs/site/docs/interacting-with-erigon/trace.md +++ b/docs/site/docs/interacting-with-erigon/trace.md @@ -405,7 +405,7 @@ Replays all transactions in a block returning the requested traces for each tran #### Parameters -1. `Quantity` or `Tag` - Integer of a block number, or the string `'earliest'`, `'latest'` or `'pending'`. +1. `Quantity` or `Tag` - Integer of a block number, or the string `'earliest'` or `'latest'`. `'pending'` is not supported: tracing replays committed state, so there is no pending block to replay. 2. `Array` - Type of trace, one or more of: `"vmTrace"`, `"trace"`, `"stateDiff"`. ```js @@ -519,7 +519,7 @@ Returns traces created at given block. #### Parameters -1. `Quantity` or `Tag` - Integer of a block number, or the string `'earliest'`, `'latest'` or `'pending'`. +1. `Quantity` or `Tag` - Integer of a block number, or the string `'earliest'` or `'latest'`. `'pending'` is not supported: tracing replays committed state, so there is no pending block to replay. ```js params: [ diff --git a/rpc/jsonrpc/trace_adhoc.go b/rpc/jsonrpc/trace_adhoc.go index 12ce1722cb1..fceb4f50cd9 100644 --- a/rpc/jsonrpc/trace_adhoc.go +++ b/rpc/jsonrpc/trace_adhoc.go @@ -972,6 +972,9 @@ func (api *TraceAPIImpl) ReplayTransaction(ctx context.Context, txHash common.Ha } func (api *TraceAPIImpl) ReplayBlockTransactions(ctx context.Context, blockNrOrHash rpc.BlockNumberOrHash, traceTypes []string, gasBailOut *bool, traceConfig *config.TraceConfig) ([]*TraceCallResult, error) { + if err := rejectPending(blockNrOrHash); err != nil { + return nil, err + } if gasBailOut == nil { gasBailOut = new(bool) // false by default } diff --git a/rpc/jsonrpc/trace_filtering.go b/rpc/jsonrpc/trace_filtering.go index 46ccfb648a5..a2b12281102 100644 --- a/rpc/jsonrpc/trace_filtering.go +++ b/rpc/jsonrpc/trace_filtering.go @@ -187,6 +187,9 @@ func newRewardTrace(blockHash common.Hash, blockNum uint64, author common.Addres // Block implements trace_block func (api *TraceAPIImpl) Block(ctx context.Context, blockNr rpc.BlockNumber, gasBailOut *bool, traceConfig *config.TraceConfig) (ParityTraces, error) { + if err := rejectPendingNumber(blockNr); err != nil { + return nil, err + } if gasBailOut == nil { gasBailOut = new(bool) // false by default } diff --git a/rpc/jsonrpc/trace_pending_test.go b/rpc/jsonrpc/trace_pending_test.go index b2a4c6dfab7..e7baf270c05 100644 --- a/rpc/jsonrpc/trace_pending_test.go +++ b/rpc/jsonrpc/trace_pending_test.go @@ -31,11 +31,10 @@ import ( "github.com/erigontech/erigon/rpc/rpccfg" ) -// The methods that execute a call on top of a resolved state reject "pending": -// they replay on the committed view, which holds no pending block, so accepting -// the tag would run the call against the latest executed state and report it as -// pending. go-ethereum answers the same way. -func TestTraceCallRejectsPendingTag(t *testing.T) { +// Every tracing method rejects "pending": they replay on the committed view, which +// holds no pending block, so accepting the tag would answer for the latest executed +// block and report it as pending. +func TestTracingRejectsPendingTag(t *testing.T) { m, _, _ := rpcdaemontest.CreateTestExecModule(t) ctx := context.Background() pendingNrOrHash := rpc.BlockNumberOrHashWithNumber(rpc.PendingBlockNumber) @@ -62,24 +61,19 @@ func TestTraceCallRejectsPendingTag(t *testing.T) { _, err := traceAPI.CallMany(ctx, json.RawMessage("[]"), &pendingNrOrHash, nil) require.ErrorIs(t, err, errPendingNotSupported) }) -} - -// Block tracing keeps accepting the tag: go-ethereum traces the pending block -// rather than rejecting it, and the RPC integration suite pins that a pending -// debug_traceBlockByNumber answers instead of erroring. -func TestTraceBlockAcceptsPendingTag(t *testing.T) { - m, _, _ := rpcdaemontest.CreateTestExecModule(t) - ctx := context.Background() - pending := rpc.PendingBlockNumber - - debugAPI := NewPrivateDebugAPI(newBaseApiForTest(m), m.DB, nil, &rpccfg.DebugApiConfig{}) - traceAPI := newTraceApiForTest(m) - require.NoError(t, debugAPI.TraceBlockByNumber(ctx, pending, nil, jsonstream.New(io.Discard))) + t.Run("debug_traceBlockByNumber", func(t *testing.T) { + err := debugAPI.TraceBlockByNumber(ctx, rpc.PendingBlockNumber, nil, jsonstream.New(io.Discard)) + require.ErrorIs(t, err, errPendingNotSupported) + }) - _, err := traceAPI.Block(ctx, pending, nil, nil) - require.NoError(t, err) + t.Run("trace_block", func(t *testing.T) { + _, err := traceAPI.Block(ctx, rpc.PendingBlockNumber, nil, nil) + require.ErrorIs(t, err, errPendingNotSupported) + }) - _, err = traceAPI.ReplayBlockTransactions(ctx, rpc.BlockNumberOrHashWithNumber(pending), []string{TraceTypeTrace}, nil, nil) - require.NoError(t, err) + t.Run("trace_replayBlockTransactions", func(t *testing.T) { + _, err := traceAPI.ReplayBlockTransactions(ctx, pendingNrOrHash, []string{TraceTypeTrace}, nil, nil) + require.ErrorIs(t, err, errPendingNotSupported) + }) } diff --git a/rpc/jsonrpc/tracing.go b/rpc/jsonrpc/tracing.go index f6136881468..ad259719fc9 100644 --- a/rpc/jsonrpc/tracing.go +++ b/rpc/jsonrpc/tracing.go @@ -42,10 +42,11 @@ import ( "github.com/erigontech/erigon/rpc/transactions" ) -// errPendingNotSupported is returned for the "pending" tag by the tracing methods, -// matching go-ethereum. They resolve and replay on the committed view, which holds -// no pending block, so accepting the tag would trace the latest executed block -// while reporting it as the one the caller asked for. +// errPendingNotSupported is returned for the "pending" tag by the tracing methods. +// They resolve and replay on the committed view, which holds no pending block, so +// accepting the tag would answer for the latest executed block while reporting it +// as the one the caller asked for. go-ethereum either traces a real pending block +// or errors; it never substitutes a different one. var errPendingNotSupported = errors.New("tracing on top of pending is not supported") func rejectPendingNumber(blockNr rpc.BlockNumber) error { @@ -73,6 +74,9 @@ func (api *DebugAPIImpl) TraceBlockByHash(ctx context.Context, hash common.Hash, } func (api *DebugAPIImpl) traceBlock(ctx context.Context, blockNrOrHash rpc.BlockNumberOrHash, config *tracersConfig.TraceConfig, stream jsonstream.Stream) error { + if err := rejectPending(blockNrOrHash); err != nil { + return err + } tx, err := api.db.BeginTemporalRo(ctx) if err != nil { return err From 90120cb9700d0c8607e22206026fd065544c4cec Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Sat, 15 Aug 2026 10:17:44 +0200 Subject: [PATCH 09/11] rpc: keep committed trace calls off overlay state cache --- rpc/jsonrpc/trace_adhoc.go | 6 +- rpc/jsonrpc/trace_view_consistency_test.go | 69 ++++++++++++++++++++++ rpc/jsonrpc/tracing.go | 4 +- rpc/rpchelper/helper.go | 7 +++ 4 files changed, 81 insertions(+), 5 deletions(-) create mode 100644 rpc/jsonrpc/trace_view_consistency_test.go diff --git a/rpc/jsonrpc/trace_adhoc.go b/rpc/jsonrpc/trace_adhoc.go index e10c2f9d4b6..4a400e67aa7 100644 --- a/rpc/jsonrpc/trace_adhoc.go +++ b/rpc/jsonrpc/trace_adhoc.go @@ -1146,7 +1146,7 @@ func (api *TraceAPIImpl) Call(ctx context.Context, args TraceCallParam, traceTyp return nil, err } - stateReader, err := rpchelper.CreateStateReaderFromBlockNumber(ctx, tx, blockNumber, latest, 0, api.stateCache, api._txNumReader) + stateReader, err := rpchelper.CreateUncachedStateReaderFromBlockNumber(ctx, tx, blockNumber, latest, 0, api._txNumReader) if err != nil { return nil, err } @@ -1364,7 +1364,7 @@ func (api *TraceAPIImpl) CallMany(ctx context.Context, calls json.RawMessage, pa return nil, err } - stateReader, err := rpchelper.CreateStateReaderFromBlockNumber(ctx, tx, blockNumber, latest, 0, api.stateCache, api._txNumReader) + stateReader, err := rpchelper.CreateUncachedStateReaderFromBlockNumber(ctx, tx, blockNumber, latest, 0, api._txNumReader) if err != nil { return nil, err } @@ -1861,7 +1861,7 @@ func (api *TraceAPIImpl) RawTransaction(ctx context.Context, encodedTx hexutil.B return nil, err } - stateReader, err := rpchelper.CreateStateReaderFromBlockNumber(ctx, dbtx, blockNumber, latest, 0, api.stateCache, api._txNumReader) + stateReader, err := rpchelper.CreateUncachedStateReaderFromBlockNumber(ctx, dbtx, blockNumber, latest, 0, api._txNumReader) if err != nil { return nil, err } diff --git a/rpc/jsonrpc/trace_view_consistency_test.go b/rpc/jsonrpc/trace_view_consistency_test.go new file mode 100644 index 00000000000..b056d4ea4a2 --- /dev/null +++ b/rpc/jsonrpc/trace_view_consistency_test.go @@ -0,0 +1,69 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Erigon is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with Erigon. If not, see . + +package jsonrpc + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/common" + "github.com/erigontech/erigon/common/crypto" + "github.com/erigontech/erigon/common/hexutil" + "github.com/erigontech/erigon/db/kv" + "github.com/erigontech/erigon/db/state/execctx" + "github.com/erigontech/erigon/execution/execmodule" + "github.com/erigontech/erigon/rpc" + "github.com/erigontech/erigon/rpc/rpccfg" +) + +func TestTraceCallUsesCommittedState(t *testing.T) { + m, bankAddress, contractAddress, _ := chainWithDeployedContract(t) + + roTx, err := m.DB.BeginTemporalRo(m.Ctx) + require.NoError(t, err) + defer roTx.Rollback() + + publishedDomains, err := execctx.NewSharedDomains(m.Ctx, roTx, m.Log) + require.NoError(t, err) + defer publishedDomains.Close() + + storageKey := common.Hash{} + compositeKey := make([]byte, 0, len(contractAddress)+len(storageKey)) + compositeKey = append(compositeKey, contractAddress[:]...) + compositeKey = append(compositeKey, storageKey[:]...) + require.NoError(t, publishedDomains.DomainPut(kv.StorageDomain, roTx, compositeKey, []byte{3}, 1, nil)) + + stateCache := &execmodule.Cache{} + stateCache.SetPublishedSD(func() *execctx.SharedDomains { return publishedDomains }) + base := newBaseApiForTest(m) + base.stateCache = stateCache + api := NewTraceAPI(base, m.DB, &rpccfg.TraceApiConfig{}) + + latest := rpc.BlockNumberOrHashWithNumber(rpc.LatestBlockNumber) + input := hexutil.Bytes(crypto.Keccak256([]byte("retrieve()"))[:4]) + result, err := api.Call(m.Ctx, TraceCallParam{ + From: &bankAddress, + To: &contractAddress, + Data: input, + }, []string{TraceTypeTrace}, &latest, nil) + require.NoError(t, err) + + expected := make(hexutil.Bytes, 32) + expected[len(expected)-1] = 2 + require.Equal(t, expected, result.Output) +} diff --git a/rpc/jsonrpc/tracing.go b/rpc/jsonrpc/tracing.go index ad259719fc9..154b61fc182 100644 --- a/rpc/jsonrpc/tracing.go +++ b/rpc/jsonrpc/tracing.go @@ -419,7 +419,7 @@ func (api *DebugAPIImpl) TraceCall(ctx context.Context, args ethapi.CallArgs, bl var stateReader state.StateReader if config == nil || config.TxIndex == nil || isLatest { - stateReader, err = rpchelper.CreateStateReaderFromBlockNumber(ctx, dbtx, blockNumber, isLatest, 0, api.stateCache, api._txNumReader) + stateReader, err = rpchelper.CreateUncachedStateReaderFromBlockNumber(ctx, dbtx, blockNumber, isLatest, 0, api._txNumReader) } else { stateReader, err = rpchelper.CreateHistoryStateReader(ctx, dbtx, blockNumber, int(*config.TxIndex), api._txNumReader) } @@ -546,7 +546,7 @@ func (api *DebugAPIImpl) TraceCallMany(ctx context.Context, bundles []Bundle, si rpcBlockNumValue := rpc.BlockNumber(blockNum) blockNrOrHash.BlockNumber = &rpcBlockNumValue - stateReader, err = rpchelper.CreateStateReaderFromBlockNumber(ctx, tx, blockNum, isLatest, 0, api.stateCache, api._txNumReader) + stateReader, err = rpchelper.CreateUncachedStateReaderFromBlockNumber(ctx, tx, blockNum, isLatest, 0, api._txNumReader) } else { stateReader, err = rpchelper.CreateHistoryStateReader(ctx, tx, blockNum, *simulateContext.TransactionIndex, api._txNumReader) } diff --git a/rpc/rpchelper/helper.go b/rpc/rpchelper/helper.go index cfb65dea4e9..3fe7c0585c1 100644 --- a/rpc/rpchelper/helper.go +++ b/rpc/rpchelper/helper.go @@ -200,6 +200,13 @@ func CreateStateReaderFromBlockNumber(ctx context.Context, tx kv.TemporalTx, blo return CreateHistoryCachedStateReader(ctx, cacheView, tx, blockNumber+1, txnIndex, txNumsReader) } +func CreateUncachedStateReaderFromBlockNumber(ctx context.Context, tx kv.TemporalTx, blockNumber uint64, latest bool, txnIndex int, txNumsReader rawdbv3.TxNumsReader) (state.StateReader, error) { + if latest { + return NewLatestStateReader(tx), nil + } + return CreateHistoryStateReader(ctx, tx, blockNumber+1, txnIndex, txNumsReader) +} + func CreateHistoryStateReader(ctx context.Context, tx kv.TemporalTx, blockNumber uint64, txnIndex int, txNumsReader rawdbv3.TxNumsReader) (state.StateReader, error) { minTxNum, err := txNumsReader.Min(ctx, tx, blockNumber) if err != nil { From 0614944dd93761dd2586d245b363365b8bfd3638 Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Sat, 15 Aug 2026 10:51:05 +0200 Subject: [PATCH 10/11] rpc: keep committed tracing on one block view --- rpc/jsonrpc/trace_adhoc.go | 18 ++++----- rpc/jsonrpc/trace_filtering.go | 6 +-- rpc/jsonrpc/trace_view_consistency_test.go | 44 ++++++++++++++++++++++ rpc/jsonrpc/tracing.go | 10 ++--- 4 files changed, 61 insertions(+), 17 deletions(-) diff --git a/rpc/jsonrpc/trace_adhoc.go b/rpc/jsonrpc/trace_adhoc.go index 4a400e67aa7..38b0ced504b 100644 --- a/rpc/jsonrpc/trace_adhoc.go +++ b/rpc/jsonrpc/trace_adhoc.go @@ -926,7 +926,7 @@ func (api *TraceAPIImpl) ReplayTransaction(ctx context.Context, txHash common.Ha return nil, err } - header, err := api.headerByNumber(ctx, rpc.BlockNumber(blockNum), tx) + header, err := api._blockReader.HeaderByNumber(ctx, tx, blockNum) if err != nil { return nil, err } @@ -1133,7 +1133,7 @@ func (api *TraceAPIImpl) Call(ctx context.Context, args TraceCallParam, traceTyp return nil, err } - header, err := api.headerByNumber(ctx, rpc.BlockNumber(blockNumber), tx) + header, err := api._blockReader.Header(ctx, tx, hash, blockNumber) if err != nil { return nil, err } @@ -1141,7 +1141,7 @@ func (api *TraceAPIImpl) Call(ctx context.Context, args TraceCallParam, traceTyp return nil, fmt.Errorf("block %d(%x) not found", blockNumber, hash) } - err = rpchelper.CheckBlockExecuted(api.filters.WithOverlay(tx), blockNumber) + err = rpchelper.CheckBlockExecuted(tx, blockNumber) if err != nil { return nil, err } @@ -1331,7 +1331,7 @@ func (api *TraceAPIImpl) CallMany(ctx context.Context, calls json.RawMessage, pa return nil, err } - parentHeader, err := api.headerByNumber(ctx, rpc.BlockNumber(blockNumber), tx) + parentHeader, err := api._blockReader.Header(ctx, tx, hash, blockNumber) if err != nil { return nil, err } @@ -1359,7 +1359,7 @@ func (api *TraceAPIImpl) CallMany(ctx context.Context, calls json.RawMessage, pa } } - err = rpchelper.CheckBlockExecuted(api.filters.WithOverlay(tx), blockNumber) + err = rpchelper.CheckBlockExecuted(tx, blockNumber) if err != nil { return nil, err } @@ -1405,7 +1405,7 @@ func (api *TraceAPIImpl) doCallBlock(ctx context.Context, dbtx kv.Tx, stateReade } noop := state.NewNoopWriter() - parentHeader, err := api.headerByNumber(ctx, rpc.BlockNumber(parentBlockNumber), dbtx) + parentHeader, err := api._blockReader.Header(ctx, dbtx, hash, parentBlockNumber) if err != nil { return nil, nil, err } @@ -1633,7 +1633,7 @@ func (api *TraceAPIImpl) doCall(ctx context.Context, dbtx kv.Tx, stateReader sta } noop := state.NewNoopWriter() - parentHeader, err := api.headerByNumber(ctx, rpc.BlockNumber(parentBlockNumber), dbtx) + parentHeader, err := api._blockReader.Header(ctx, dbtx, hash, parentBlockNumber) if err != nil { return nil, err } @@ -1848,7 +1848,7 @@ func (api *TraceAPIImpl) RawTransaction(ctx context.Context, encodedTx hexutil.B return nil, err } - header, err := api.headerByNumber(ctx, rpc.BlockNumber(blockNumber), dbtx) + header, err := api._blockReader.Header(ctx, dbtx, hash, blockNumber) if err != nil { return nil, err } @@ -1856,7 +1856,7 @@ func (api *TraceAPIImpl) RawTransaction(ctx context.Context, encodedTx hexutil.B return nil, fmt.Errorf("block %d(%x) not found", blockNumber, hash) } - err = rpchelper.CheckBlockExecuted(api.filters.WithOverlay(dbtx), blockNumber) + err = rpchelper.CheckBlockExecuted(dbtx, blockNumber) if err != nil { return nil, err } diff --git a/rpc/jsonrpc/trace_filtering.go b/rpc/jsonrpc/trace_filtering.go index 2fc1cfe7ee8..b8827f95481 100644 --- a/rpc/jsonrpc/trace_filtering.go +++ b/rpc/jsonrpc/trace_filtering.go @@ -99,7 +99,7 @@ func (api *TraceAPIImpl) Transaction(ctx context.Context, txHash common.Hash, ga return nil, err } - header, err := api.headerByNumber(ctx, rpc.BlockNumber(blockNumber), tx) + header, err := api._blockReader.HeaderByNumber(ctx, tx, blockNumber) if err != nil { return nil, err } @@ -828,7 +828,7 @@ func (api *TraceAPIImpl) callBlock( RequireCanonical: true, } - err := rpchelper.CheckBlockExecuted(api.filters.WithOverlay(dbtx), blockNumber) + err := rpchelper.CheckBlockExecuted(dbtx, blockNumber) if err != nil { return nil, nil, err } @@ -1173,7 +1173,7 @@ func (api *TraceAPIImpl) callTransaction( RequireCanonical: true, } - err := rpchelper.CheckBlockExecuted(api.filters.WithOverlay(dbtx), blockNumber) + err := rpchelper.CheckBlockExecuted(dbtx, blockNumber) if err != nil { return nil, err } diff --git a/rpc/jsonrpc/trace_view_consistency_test.go b/rpc/jsonrpc/trace_view_consistency_test.go index b056d4ea4a2..4dd043641cd 100644 --- a/rpc/jsonrpc/trace_view_consistency_test.go +++ b/rpc/jsonrpc/trace_view_consistency_test.go @@ -25,9 +25,15 @@ import ( "github.com/erigontech/erigon/common/crypto" "github.com/erigontech/erigon/common/hexutil" "github.com/erigontech/erigon/db/kv" + "github.com/erigontech/erigon/db/rawdb" "github.com/erigontech/erigon/db/state/execctx" "github.com/erigontech/erigon/execution/execmodule" + "github.com/erigontech/erigon/execution/tracing/tracers/config" + "github.com/erigontech/erigon/execution/types" + "github.com/erigontech/erigon/execution/types/accounts" + "github.com/erigontech/erigon/execution/vm" "github.com/erigontech/erigon/rpc" + "github.com/erigontech/erigon/rpc/ethapi" "github.com/erigontech/erigon/rpc/rpccfg" ) @@ -67,3 +73,41 @@ func TestTraceCallUsesCommittedState(t *testing.T) { expected[len(expected)-1] = 2 require.Equal(t, expected, result.Output) } + +func TestTraceCallUsesCommittedHeader(t *testing.T) { + base, m, _, events := newOverlayAheadTestAPIWithEvents(t) + + tx, err := m.DB.BeginTemporalRo(m.Ctx) + require.NoError(t, err) + defer tx.Rollback() + + committedHeader, err := m.BlockReader.HeaderByNumber(m.Ctx, tx, overlayRaceChainSize) + require.NoError(t, err) + require.NotNil(t, committedHeader) + committedHash := committedHeader.Hash() + + overlayHeader := types.CopyHeader(committedHeader) + overlayHeader.Coinbase = common.Address{2} + overlay := events.LatestSD().BlockOverlay() + require.NoError(t, rawdb.WriteHeader(overlay, overlayHeader)) + require.NoError(t, rawdb.WriteCanonicalHash(overlay, overlayHeader.Hash(), overlayRaceChainSize)) + + contractAddress := common.Address{3} + coinbaseCode := hexutil.Bytes{byte(vm.COINBASE), 0x60, 0x00, 0x52, 0x60, 0x20, 0x60, 0x00, 0xf3} + traceConfig := &config.TraceConfig{ + StateOverrides: ðapi.StateOverrides{ + accounts.InternAddress(contractAddress): {Code: &coinbaseCode}, + }, + } + requestedBlock := rpc.BlockNumberOrHashWithHash(committedHash, true) + api := NewTraceAPI(base, m.DB, &rpccfg.TraceApiConfig{}) + result, err := api.Call(m.Ctx, TraceCallParam{ + From: &m.Address, + To: &contractAddress, + }, []string{TraceTypeTrace}, &requestedBlock, traceConfig) + require.NoError(t, err) + + expected := make(hexutil.Bytes, 32) + copy(expected[len(expected)-len(committedHeader.Coinbase):], committedHeader.Coinbase[:]) + require.Equal(t, expected, result.Output) +} diff --git a/rpc/jsonrpc/tracing.go b/rpc/jsonrpc/tracing.go index 154b61fc182..3d2ef82362a 100644 --- a/rpc/jsonrpc/tracing.go +++ b/rpc/jsonrpc/tracing.go @@ -127,7 +127,7 @@ func (api *DebugAPIImpl) traceBlock(ctx context.Context, blockNrOrHash rpc.Block } engine := api.engine() - err = rpchelper.CheckBlockExecuted(api.filters.WithOverlay(tx), blockNumber) + err = rpchelper.CheckBlockExecuted(tx, blockNumber) if err != nil { return err } @@ -412,7 +412,7 @@ func (api *DebugAPIImpl) TraceCall(ctx context.Context, args ethapi.CallArgs, bl return err } - err = rpchelper.CheckBlockExecuted(api.filters.WithOverlay(dbtx), blockNumber) + err = rpchelper.CheckBlockExecuted(dbtx, blockNumber) if err != nil { return err } @@ -426,7 +426,7 @@ func (api *DebugAPIImpl) TraceCall(ctx context.Context, args ethapi.CallArgs, bl if err != nil { return fmt.Errorf("create state reader: %v", err) } - header, err := api.headerByNumber(ctx, rpc.BlockNumber(blockNumber), dbtx) + header, err := api._blockReader.Header(ctx, dbtx, hash, blockNumber) if err != nil { return fmt.Errorf("could not fetch header %d(%x): %v", blockNumber, hash, err) } @@ -525,7 +525,7 @@ func (api *DebugAPIImpl) TraceCallMany(ctx context.Context, bundles []Bundle, si } var header *types.Header - header, err = api.headerByNumber(ctx, rpc.BlockNumber(blockNum), tx) + header, err = api._blockReader.Header(ctx, tx, hash, blockNum) if err != nil { return err } @@ -535,7 +535,7 @@ func (api *DebugAPIImpl) TraceCallMany(ctx context.Context, bundles []Bundle, si var stateReader state.StateReader - err = rpchelper.CheckBlockExecuted(api.filters.WithOverlay(tx), blockNum) + err = rpchelper.CheckBlockExecuted(tx, blockNum) if err != nil { return err } From f079312c2772d9285a07c117db93628833f37364 Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:44:12 +0200 Subject: [PATCH 11/11] membatchwithdb, rpchelper: preserve pinned overlay views --- db/kv/membatchwithdb/memory_mutation.go | 5 ++++ rpc/jsonrpc/overlay_race_test.go | 35 +++++++++++++++++++++++++ rpc/rpchelper/filters.go | 16 ++++++----- 3 files changed, 50 insertions(+), 6 deletions(-) diff --git a/db/kv/membatchwithdb/memory_mutation.go b/db/kv/membatchwithdb/memory_mutation.go index b054c6ab6c9..14823ed2f78 100644 --- a/db/kv/membatchwithdb/memory_mutation.go +++ b/db/kv/membatchwithdb/memory_mutation.go @@ -1097,6 +1097,11 @@ func (m *MemoryMutation) NewReadView(tx kv.Tx) kv.TemporalTx { return m.newReadViewMut(tx) } +// IsOverlayReadView reports whether this mutation is a view created from an overlay. +func (m *MemoryMutation) IsOverlayReadView() bool { + return m != nil && m.memDb == nil +} + // newReadViewMut is the internal constructor that returns the full // *MemoryMutation. Used by NewTemporalReadView which needs to embed it. func (m *MemoryMutation) newReadViewMut(tx kv.Tx) *MemoryMutation { diff --git a/rpc/jsonrpc/overlay_race_test.go b/rpc/jsonrpc/overlay_race_test.go index 05d79f41983..c966681b3a7 100644 --- a/rpc/jsonrpc/overlay_race_test.go +++ b/rpc/jsonrpc/overlay_race_test.go @@ -321,6 +321,41 @@ func TestGetRawHeader_PinsOverlayView(t *testing.T) { require.NotNil(t, header) } +func TestGetBlockNumberPreservesPinnedOverlayView(t *testing.T) { + base, m, firstHeader, events := newOverlayAheadTestAPIWithEvents(t) + + tx, err := m.DB.BeginTemporalRo(m.Ctx) + require.NoError(t, err) + defer tx.Rollback() + pinnedTx := base.filters.WithOverlay(tx) + + replacementTx, err := m.DB.BeginTemporalRo(m.Ctx) + require.NoError(t, err) + defer replacementTx.Rollback() + replacementDomains, err := execctx.NewSharedDomains(m.Ctx, replacementTx, m.Log) + require.NoError(t, err) + defer replacementDomains.Close() + require.NoError(t, replacementDomains.InitBlockOverlay(replacementTx, m.Dirs.Tmp)) + + replacementHeader := types.CopyHeader(firstHeader) + replacementHeader.Coinbase = common.Address{2} + replacementOverlay := replacementDomains.BlockOverlay() + require.NoError(t, rawdb.WriteHeader(replacementOverlay, replacementHeader)) + require.NoError(t, rawdb.WriteCanonicalHash(replacementOverlay, replacementHeader.Hash(), replacementHeader.Number.Uint64())) + events.PublishOverlay(replacementDomains) + defer events.PublishOverlay(nil) + + _, hash, _, err := rpchelper.GetBlockNumber( + m.Ctx, + rpc.BlockNumberOrHashWithNumber(rpc.BlockNumber(firstHeader.Number.Uint64())), + pinnedTx, + m.BlockReader, + base.filters, + ) + require.NoError(t, err) + require.Equal(t, firstHeader.Hash(), hash) +} + // TestDebugAccountAt_OverlayHeadHash_CommittedView pins that debug_accountAt // resolves the block hash on the committed view: its GetAsOf history reads can // only see committed data, so an overlay-published head must read as an diff --git a/rpc/rpchelper/filters.go b/rpc/rpchelper/filters.go index 4645e6661f9..6ead45a4bad 100644 --- a/rpc/rpchelper/filters.go +++ b/rpc/rpchelper/filters.go @@ -1169,13 +1169,17 @@ func (ff *Filters) LatestSD() *execctx.SharedDomains { return ff.latestSD.Load() } -// WithOverlay returns a read view backed by the latest block overlay if one -// is available, otherwise returns the given tx unchanged. The read view uses -// the overlay's in-memory data for table lookups, falling back to the caller's tx -// for data not in the overlay. +func isOverlayReadView(tx kv.Tx) bool { + view, ok := tx.(interface{ IsOverlayReadView() bool }) + return ok && view.IsOverlayReadView() +} + +// WithOverlay returns an existing overlay view unchanged, or creates a read +// view backed by the latest block overlay. The first view pins the overlay for +// subsequent reads in the same operation. // Safe to call on a nil receiver. func (ff *Filters) WithOverlay(tx kv.Tx) kv.Tx { - if ff == nil { + if ff == nil || isOverlayReadView(tx) { return tx } sd := ff.LatestSD() @@ -1191,7 +1195,7 @@ func (ff *Filters) WithOverlay(tx kv.Tx) kv.Tx { // WithTemporalOverlay is like WithOverlay but returns kv.TemporalTx directly, // avoiding repeated type assertions at callsites that need temporal access. func (ff *Filters) WithTemporalOverlay(tx kv.TemporalTx) kv.TemporalTx { - if ff == nil { + if ff == nil || isOverlayReadView(tx) { return tx } sd := ff.LatestSD()