diff --git a/rpc/jsonrpc/debug_api.go b/rpc/jsonrpc/debug_api.go index 783c53e2cd1..54e068f7676 100644 --- a/rpc/jsonrpc/debug_api.go +++ b/rpc/jsonrpc/debug_api.go @@ -530,6 +530,9 @@ func (api *DebugAPIImpl) GetModifiedAccountsByHash(ctx context.Context, startHas if err != nil { return nil, fmt.Errorf("start block %x not found", startHash) } + if startNum > latestBlock { + return nil, fmt.Errorf("start block (%d) is later than the latest block (%d)", startNum, latestBlock) + } if endHash == nil { // Single param: cover exactly block startNum. diff --git a/rpc/jsonrpc/eth_api_test.go b/rpc/jsonrpc/eth_api_test.go index bc692f0b6a1..46a699c7dd0 100644 --- a/rpc/jsonrpc/eth_api_test.go +++ b/rpc/jsonrpc/eth_api_test.go @@ -34,7 +34,6 @@ import ( "github.com/erigontech/erigon/db/kv/kvcache" "github.com/erigontech/erigon/execution/execmodule/execmoduletester" "github.com/erigontech/erigon/execution/tests/blockgen" - "github.com/erigontech/erigon/execution/types" "github.com/erigontech/erigon/node/ethconfig" "github.com/erigontech/erigon/node/gointerfaces/txpoolproto" "github.com/erigontech/erigon/rpc" @@ -310,18 +309,6 @@ func TestCall_ByBlockHash_WithRequireCanonicalTrue_NonCanonicalBlock(t *testing. } } -var _ bridgeReader = mockBridgeReader{} - -type mockBridgeReader struct{} - -func (m mockBridgeReader) Events(context.Context, common.Hash, uint64) ([]*types.Message, error) { - panic("mock") -} - -func (m mockBridgeReader) EventTxnLookup(context.Context, common.Hash) (uint64, bool, error) { - panic("mock") -} - func TestGetStorageValues_HappyPath(t *testing.T) { m, _, _ := rpcdaemontest.CreateTestExecModule(t) api := newEthApiForTest(newBaseApiForTest(m), m.DB, nil, nil) diff --git a/rpc/jsonrpc/eth_receipts.go b/rpc/jsonrpc/eth_receipts.go index 91e995147e6..44615ca4593 100644 --- a/rpc/jsonrpc/eth_receipts.go +++ b/rpc/jsonrpc/eth_receipts.go @@ -27,7 +27,6 @@ import ( "github.com/erigontech/erigon/rpc/jsonrpc/receipts" "github.com/erigontech/erigon/common" - "github.com/erigontech/erigon/common/hexutil" "github.com/erigontech/erigon/common/log/v3" "github.com/erigontech/erigon/db/kv" "github.com/erigontech/erigon/db/kv/order" @@ -167,7 +166,8 @@ 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) + // nil filters: resolve on the committed view, like the baseline above. + begin, _, _, err = rpchelper.GetBlockNumber(ctx, rpc.BlockNumberOrHashWithNumber(blockNum), tx, api._blockReader, nil) if err != nil { return 0, 0, err } @@ -184,7 +184,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 } @@ -368,7 +368,6 @@ func (api *BaseAPI) getLogsV3(ctx context.Context, tx kv.TemporalTx, begin, end return nil, err } - //var blockHash common.Hash var header *types.Header txNumbers, err := applyFiltersV3(api._txNumReader, tx, begin, end, crit, order.Asc) @@ -400,54 +399,24 @@ func (api *BaseAPI) getLogsV3(ctx context.Context, tx kv.TemporalTx, begin, end } if isFinalTxn { - if chainConfig.Bor != nil { - if header == nil { - header, err = api._blockReader.HeaderByNumber(ctx, tx, blockNum) - if err != nil { - return nil, err - } - } - // check for state sync event logs - events, err := api.bridgeReader.Events(ctx, header.Hash(), blockNum) - if err != nil { - return logs, err - } - - if len(events) == 0 { - continue - } - - borLogs, err := api.borReceiptGenerator.GenerateBorLogs(ctx, events, api._txNumReader, tx, header, chainConfig, txIndex, txNum) - if err != nil { - return logs, err - } - - borLogs = borLogs.FilterWithTopicMap(addrMap, topicMap, 0) - - for _, filteredLog := range borLogs { - if maxResults != 0 && len(logs) >= maxResults { - return nil, &rpc.InvalidParamsError{ - Message: fmt.Sprintf("%s: %d", errExceedLogResults, maxResults), - } - } - logs = append(logs, &types.ErigonLog{ - Log: *filteredLog, - Timestamp: hexutil.Uint64(header.Time), - }) - } + if chainConfig.Bor == nil { + continue + } + borLogs, err := api.borStateSyncLogs(ctx, tx, chainConfig, header, txIndex, txNum) + if err != nil { + return logs, err + } + logs, err = appendErigonLogs(logs, borLogs.FilterWithTopicMap(addrMap, topicMap, 0), header.Time, maxResults) + if err != nil { + return nil, err } - continue } - //fmt.Printf("txNum=%d, blockNum=%d, txIndex=%d, maxTxNumInBlock=%d,mixTxNumInBlock=%d\n", txNum, blockNum, txIndex, maxTxNumInBlock, minTxNumInBlock) - if r, ok := api.receiptsGenerator.TryGetCachedReceipt(header.Hash(), txNum, txIndex); ok { - for _, filteredLog := range r.Logs.FilterWithTopicMap(addrMap, topicMap, 0) { - if maxResults != 0 && len(logs) >= maxResults { - return nil, &rpc.InvalidParamsError{Message: fmt.Sprintf("%s: %d", errExceedLogResults, maxResults)} - } - logs = append(logs, &types.ErigonLog{Log: *filteredLog, Timestamp: hexutil.Uint64(header.Time)}) + logs, err = appendErigonLogs(logs, r.Logs.FilterWithTopicMap(addrMap, topicMap, 0), header.Time, maxResults) + if err != nil { + return nil, err } continue } @@ -467,24 +436,36 @@ func (api *BaseAPI) getLogsV3(ctx context.Context, tx kv.TemporalTx, begin, end if r == nil { return nil, err } - filtered := r.Logs.FilterWithTopicMap(addrMap, topicMap, 0) - for _, filteredLog := range filtered { - if maxResults != 0 && len(logs) >= maxResults { - return nil, &rpc.InvalidParamsError{ - Message: fmt.Sprintf("%s: %d", errExceedLogResults, maxResults), - } - } - logs = append(logs, &types.ErigonLog{ - Log: *filteredLog, - Timestamp: hexutil.Uint64(header.Time), - }) + logs, err = appendErigonLogs(logs, r.Logs.FilterWithTopicMap(addrMap, topicMap, 0), header.Time, maxResults) + if err != nil { + return nil, err } } return logs, nil } +func appendErigonLogs(logs []*types.ErigonLog, filtered types.Logs, blockTime uint64, maxResults int) ([]*types.ErigonLog, error) { + if maxResults != 0 && len(logs)+len(filtered) > maxResults { + return nil, &rpc.InvalidParamsError{ + Message: fmt.Sprintf("%s: %d", errExceedLogResults, maxResults), + } + } + return append(logs, filtered.ToErigonLogs(blockTime)...), nil +} + +func (api *BaseAPI) borStateSyncLogs(ctx context.Context, tx kv.TemporalTx, chainConfig *chain.Config, header *types.Header, txIndex int, txNum uint64) (types.Logs, error) { + events, err := api.bridgeReader.Events(ctx, header.Hash(), header.Number.Uint64()) + if err != nil { + return nil, err + } + if len(events) == 0 { + return nil, nil + } + return api.borReceiptGenerator.GenerateBorLogs(ctx, events, api._txNumReader, tx, header, chainConfig, txIndex, txNum) +} + // The Topic list restricts matches to particular event topics. Each event has a list // of topics. Topics matches a prefix of that list. An empty element slice matches any // topic. Non-empty elements represent an alternative that matches any of the diff --git a/rpc/jsonrpc/eth_receipts_test.go b/rpc/jsonrpc/eth_receipts_test.go new file mode 100644 index 00000000000..91d2a2fe457 --- /dev/null +++ b/rpc/jsonrpc/eth_receipts_test.go @@ -0,0 +1,118 @@ +// 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" + "errors" + "fmt" + "testing" + + "github.com/holiman/uint256" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/common" + "github.com/erigontech/erigon/common/hexutil" + "github.com/erigontech/erigon/execution/types" + "github.com/erigontech/erigon/rpc" +) + +func logsWithIndexes(n int) types.Logs { + logs := make(types.Logs, n) + for i := range logs { + logs[i] = &types.Log{Index: hexutil.Uint(i)} + } + return logs +} + +func erigonLogsWithIndexes(n int) []*types.ErigonLog { + logs := make([]*types.ErigonLog, n) + for i, l := range logsWithIndexes(n) { + logs[i] = &types.ErigonLog{Log: *l} + } + return logs +} + +func TestAppendErigonLogs(t *testing.T) { + const blockTime = 42 + + cases := []struct { + name string + logs []*types.ErigonLog + filtered types.Logs + maxResults int + wantLen int + wantErr bool + }{ + {name: "unlimited", filtered: logsWithIndexes(3), maxResults: 0, wantLen: 3}, + {name: "below limit", filtered: logsWithIndexes(3), maxResults: 5, wantLen: 3}, + {name: "at limit", filtered: logsWithIndexes(3), maxResults: 3, wantLen: 3}, + {name: "above limit", filtered: logsWithIndexes(4), maxResults: 3, wantErr: true}, + {name: "limit counts logs appended earlier", logs: erigonLogsWithIndexes(2), filtered: logsWithIndexes(2), maxResults: 3, wantErr: true}, + {name: "nothing to append at limit", logs: erigonLogsWithIndexes(2), maxResults: 2, wantLen: 2}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := appendErigonLogs(tc.logs, tc.filtered, blockTime, tc.maxResults) + if tc.wantErr { + require.Nil(t, got) + var rpcErr rpc.Error + require.ErrorAs(t, err, &rpcErr) + assert.Equal(t, rpc.ErrCodeInvalidParams, rpcErr.ErrorCode()) + assert.Equal(t, fmt.Sprintf("%s: %d", errExceedLogResults, tc.maxResults), rpcErr.Error()) + return + } + require.NoError(t, err) + require.Len(t, got, tc.wantLen) + for i, l := range got[len(tc.logs):] { + assert.Equal(t, tc.filtered[i].Index, l.Log.Index) + assert.Equal(t, hexutil.Uint64(blockTime), l.Timestamp) + } + }) + } +} + +var _ bridgeReader = mockBridgeReader{} + +type mockBridgeReader struct { + events []*types.Message + err error +} + +func (b mockBridgeReader) Events(context.Context, common.Hash, uint64) ([]*types.Message, error) { + return b.events, b.err +} + +func (b mockBridgeReader) EventTxnLookup(context.Context, common.Hash) (uint64, bool, error) { + panic("not called") +} + +func TestBorStateSyncLogs_NoEvents(t *testing.T) { + api := &BaseAPI{bridgeReader: mockBridgeReader{}} + logs, err := api.borStateSyncLogs(context.Background(), nil, nil, &types.Header{Number: *uint256.NewInt(1)}, 0, 0) + require.NoError(t, err) + assert.Empty(t, logs) +} + +func TestBorStateSyncLogs_EventsError(t *testing.T) { + wantErr := errors.New("bridge down") + api := &BaseAPI{bridgeReader: mockBridgeReader{err: wantErr}} + _, err := api.borStateSyncLogs(context.Background(), nil, nil, &types.Header{Number: *uint256.NewInt(1)}, 0, 0) + require.ErrorIs(t, err, wantErr) +} diff --git a/rpc/jsonrpc/overlay_race_test.go b/rpc/jsonrpc/overlay_race_test.go index 1ec6fd9de58..316afd071a7 100644 --- a/rpc/jsonrpc/overlay_race_test.go +++ b/rpc/jsonrpc/overlay_race_test.go @@ -19,16 +19,19 @@ package jsonrpc import ( "bytes" "context" + "math/big" "strconv" "testing" "github.com/holiman/uint256" "github.com/jinzhu/copier" + jsoniter "github.com/json-iterator/go" "github.com/stretchr/testify/require" "google.golang.org/grpc" "github.com/erigontech/erigon/common" "github.com/erigontech/erigon/common/hexutil" + "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,8 @@ 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/jsonstream" "github.com/erigontech/erigon/rpc/rpccfg" "github.com/erigontech/erigon/rpc/rpchelper" ) @@ -50,6 +55,16 @@ const ( overlayRaceBaseFee = 424242 ) +func insertOverlayRaceChain(t *testing.T, m *execmoduletester.ExecModuleTester) *blockgen.ChainPack { + t.Helper() + c, err := m.GenerateChain(overlayRaceChainSize, func(i int, gen *blockgen.BlockGen) { + gen.SetCoinbase(common.Address{1}) + }) + require.NoError(t, err) + require.NoError(t, m.InsertChain(c)) + return c +} + // newOverlayAheadTestAPI builds overlayRaceChainSize committed MDBX blocks, // then publishes a fabricated block one past them (overlayRaceChainSize+1) // into the block overlay only, never committed to MDBX. This reproduces the @@ -67,11 +82,7 @@ func newOverlayAheadTestAPI(t *testing.T) (base *BaseAPI, m *execmoduletester.Ex cfg.LondonBlock = common.NewUint64(0) m = execmoduletester.New(t, execmoduletester.WithChainConfig(&cfg)) - c, err := m.GenerateChain(overlayRaceChainSize, func(i int, gen *blockgen.BlockGen) { - gen.SetCoinbase(common.Address{1}) - }) - require.NoError(t, err) - require.NoError(t, m.InsertChain(c)) + c := insertOverlayRaceChain(t, m) ctx := m.Ctx overlayRoTx, err := m.DB.BeginTemporalRo(ctx) @@ -105,9 +116,9 @@ func newOverlayAheadTestAPI(t *testing.T) (base *BaseAPI, m *execmoduletester.Ex events := shards.NewEvents() events.PublishOverlay(doms) - filters := rpchelper.New(ctx, rpchelper.DefaultFiltersConfig, nil, nil, nil, func() {}, m.Log, events) + ff := rpchelper.New(ctx, rpchelper.DefaultFiltersConfig, nil, nil, nil, func() {}, m.Log, events) stateCache := kvcache.New(kvcache.DefaultCoherentConfig) - base = newBaseApiWithFiltersForTest(filters, stateCache, m) + base = newBaseApiWithFiltersForTest(ff, stateCache, m) return base, m, overlayHeader } @@ -234,6 +245,107 @@ 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") } +// TestGetLogs_UsesCommittedFromTag pins that eth_getLogs resolves a "latest" +// fromBlock on the committed view: with the overlay head published ahead of +// MDBX, the tag must not resolve past the executed head and fail the request. +func TestGetLogs_UsesCommittedFromTag(t *testing.T) { + t.Parallel() + base, m, _ := newOverlayAheadTestAPI(t) + api := newEthApiForTest(base, m.DB, nil, nil) + + _, err := api.GetLogs(m.Ctx, filters.FilterCriteria{FromBlock: big.NewInt(int64(rpc.LatestBlockNumber))}) + require.NoError(t, err) +} + +// TestGetLogs_UsesCommittedToTag is the toBlock counterpart of +// TestGetLogs_UsesCommittedFromTag. +func TestGetLogs_UsesCommittedToTag(t *testing.T) { + t.Parallel() + base, m, _ := newOverlayAheadTestAPI(t) + api := newEthApiForTest(base, m.DB, nil, nil) + + _, err := api.GetLogs(m.Ctx, filters.FilterCriteria{ + FromBlock: big.NewInt(1), + ToBlock: big.NewInt(int64(rpc.LatestBlockNumber)), + }) + require.NoError(t, err) +} + +// TestTraceFilter_UsesCommittedFromTag pins that trace_filter resolves a +// "latest" fromBlock on the committed view: with the overlay head published +// ahead of MDBX, the tag must not resolve past a numeric toBlock at the +// executed head. +func TestTraceFilter_UsesCommittedFromTag(t *testing.T) { + t.Parallel() + base, m, _ := newOverlayAheadTestAPI(t) + api := NewTraceAPI(base, m.DB, &rpccfg.TraceApiConfig{}) + + s := jsoniter.ConfigDefault.BorrowStream(nil) + defer jsoniter.ConfigDefault.ReturnStream(s) + stream := jsonstream.Wrap(s) + + from := rpc.BlockNumberOrHashWithNumber(rpc.LatestBlockNumber) + to := rpc.BlockNumberOrHashWithNumber(rpc.BlockNumber(overlayRaceChainSize)) + err := api.Filter(m.Ctx, TraceFilterRequest{FromBlock: &from, ToBlock: &to}, new(bool), nil, stream) + require.NoError(t, err) +} + +// newHeaderAheadTester commits overlayRaceChainSize executed blocks, then +// writes a canonical header one past execution progress directly to the DB. +// This reproduces the window where the headers stage is ahead of execution, +// so a block number resolves while its data is not yet available. +func newHeaderAheadTester(t *testing.T) (m *execmoduletester.ExecModuleTester, aheadHash common.Hash) { + t.Helper() + m = execmoduletester.New(t) + c := insertOverlayRaceChain(t, m) + + aheadNumber := uint64(overlayRaceChainSize) + 1 + header := &types.Header{ + ParentHash: c.TopBlock.Hash(), + Number: *uint256.NewInt(aheadNumber), + Difficulty: *uint256.NewInt(0), + Time: c.TopBlock.Time() + 10, + GasLimit: 30_000_000, + } + aheadHash = header.Hash() + require.NoError(t, m.DB.Update(m.Ctx, func(tx kv.RwTx) error { + if err := rawdb.WriteHeader(tx, header); err != nil { + return err + } + return rawdb.WriteCanonicalHash(tx, aheadHash, aheadNumber) + })) + return m, aheadHash +} + +// TestTraceFilter_FutureToBlockErrors pins that an explicit toBlock past the +// executed head errors instead of silently clamping the scan to the last +// available txnum, which would make an omitted head block look empty. +func TestTraceFilter_FutureToBlockErrors(t *testing.T) { + t.Parallel() + m, _ := newHeaderAheadTester(t) + api := newTraceApiForTest(m) + + s := jsoniter.ConfigDefault.BorrowStream(nil) + defer jsoniter.ConfigDefault.ReturnStream(s) + stream := jsonstream.Wrap(s) + + to := rpc.BlockNumberOrHashWithNumber(rpc.BlockNumber(overlayRaceChainSize + 1)) + err := api.Filter(m.Ctx, TraceFilterRequest{ToBlock: &to}, new(bool), nil, stream) + require.ErrorContains(t, err, "not executed") +} + +// TestGetModifiedAccountsByHash_FutureStartBlockErrors pins that ByHash rejects +// a not-yet-executed start block like its ByNumber twin, instead of returning +// a silent result from a clamped txnum range. +func TestGetModifiedAccountsByHash_FutureStartBlockErrors(t *testing.T) { + t.Parallel() + m, aheadHash := newHeaderAheadTester(t) + api := newDebugApiForTest(m) + + _, err := api.GetModifiedAccountsByHash(m.Ctx, aheadHash, nil) + require.ErrorContains(t, err, "later than the latest block") +} + // 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/trace_filtering.go b/rpc/jsonrpc/trace_filtering.go index 49e9cd286db..2fbc0738260 100644 --- a/rpc/jsonrpc/trace_filtering.go +++ b/rpc/jsonrpc/trace_filtering.go @@ -325,7 +325,8 @@ func (api *TraceAPIImpl) Filter(ctx context.Context, req TraceFilterRequest, gas if req.FromBlock == nil { fromBlock = 0 } else { - fromBlock, _, _, err = rpchelper.GetBlockNumber(ctx, *req.FromBlock, dbtx, api._blockReader, api.filters) + // nil filters: resolve on the committed view, like the scan below. + fromBlock, _, _, err = rpchelper.GetBlockNumber(ctx, *req.FromBlock, dbtx, api._blockReader, nil) if err != nil { if errors.As(err, &rpc.BlockNotFoundErr{}) { stream.WriteEmptyArray() @@ -342,7 +343,7 @@ func (api *TraceAPIImpl) Filter(ctx context.Context, req TraceFilterRequest, gas } 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() @@ -355,6 +356,14 @@ func (api *TraceAPIImpl) Filter(ctx context.Context, req TraceFilterRequest, gas return errors.New("invalid parameters: fromBlock cannot be greater than toBlock") } + // The txnum index silently clamps a missing block to the last available + // txnum, so a not-yet-executed toBlock would be omitted without a trace. + if req.ToBlock != nil { + if err := rpchelper.CheckBlockExecuted(dbtx, toBlock); err != nil { + return err + } + } + // if we've pruned this history away for this block then just return early // to save any red herring errors