diff --git a/db/kv/membatchwithdb/memory_mutation.go b/db/kv/membatchwithdb/memory_mutation.go index a75da5e6cfe..fe3bddabb89 100644 --- a/db/kv/membatchwithdb/memory_mutation.go +++ b/db/kv/membatchwithdb/memory_mutation.go @@ -953,8 +953,12 @@ func (m *MemoryMutation) GetLatest(name kv.Domain, k []byte) (v []byte, step kv. func (m *MemoryMutation) GetAsOf(name kv.Domain, k []byte, ts uint64) (v []byte, ok bool, err error) { if m.DomainReader != nil { - if val, ok, err := m.DomainReader.GetAsOf(name, k, ts); err == nil && ok { - return val, ok, nil + val, ok, err := m.DomainReader.GetAsOf(name, k, ts) + if err != nil { + return nil, false, err + } + if ok { + return val, true, nil } } if m.db == nil { @@ -986,8 +990,12 @@ func (m *MemoryMutation) RangeAsOf(name kv.Domain, fromKey, toKey []byte, ts uin func (m *MemoryMutation) HistorySeek(name kv.Domain, k []byte, ts uint64) (v []byte, ok bool, err error) { if m.DomainReader != nil { - if val, ok, err := m.DomainReader.HistorySeek(name, k, ts); err == nil && ok { - return val, ok, nil + val, ok, err := m.DomainReader.HistorySeek(name, k, ts) + if err != nil { + return nil, false, err + } + if ok { + return val, true, nil } } if m.db == nil { @@ -1152,8 +1160,12 @@ func (v *OverlayTemporalReadView) GetAsOf(name kv.Domain, k []byte, ts uint64) ( // Check DomainReader independently — this method shadows MemoryMutation.GetAsOf // and falls through to v.temporalTx (not m.db), so the embedded check never fires. if v.MemoryMutation != nil && v.MemoryMutation.DomainReader != nil { - if val, ok, err := v.MemoryMutation.DomainReader.GetAsOf(name, k, ts); err == nil && ok { - return val, ok, nil + val, ok, err := v.MemoryMutation.DomainReader.GetAsOf(name, k, ts) + if err != nil { + return nil, false, err + } + if ok { + return val, true, nil } } return v.temporalTx.GetAsOf(name, k, ts) @@ -1171,8 +1183,12 @@ func (v *OverlayTemporalReadView) HistorySeek(name kv.Domain, k []byte, ts uint6 // Check DomainReader independently — this method shadows MemoryMutation.HistorySeek // and falls through to v.temporalTx (not m.db), so the embedded check never fires. if v.MemoryMutation != nil && v.MemoryMutation.DomainReader != nil { - if val, ok, err := v.MemoryMutation.DomainReader.HistorySeek(name, k, ts); err == nil && ok { - return val, ok, nil + val, ok, err := v.MemoryMutation.DomainReader.HistorySeek(name, k, ts) + if err != nil { + return nil, false, err + } + if ok { + return val, true, nil } } return v.temporalTx.HistorySeek(name, k, ts) diff --git a/db/kv/membatchwithdb/memory_mutation_test.go b/db/kv/membatchwithdb/memory_mutation_test.go index 8de0c0c86ad..45ddc89b449 100644 --- a/db/kv/membatchwithdb/memory_mutation_test.go +++ b/db/kv/membatchwithdb/memory_mutation_test.go @@ -17,6 +17,7 @@ package membatchwithdb_test import ( + "errors" "fmt" "sync" "testing" @@ -851,3 +852,44 @@ func TestMemoryMutationConcurrentDeleteAndRead(t *testing.T) { wg.Wait() } + +// erroringDomainReader fails every domain read, so a caller that swallows the +// error is indistinguishable from a caller that saw no value at all. +type erroringDomainReader struct{ err error } + +func (r erroringDomainReader) GetAsOf(kv.Domain, []byte, uint64) ([]byte, bool, error) { + return nil, false, r.err +} + +func (r erroringDomainReader) HistorySeek(kv.Domain, []byte, uint64) ([]byte, bool, error) { + return nil, false, r.err +} + +// TestDomainReadErrorsPropagate covers both overlay read views: a DomainReader +// error must reach the caller rather than fall through to the committed tx, +// which would silently answer with stale data. +func TestDomainReadErrorsPropagate(t *testing.T) { + t.Parallel() + + _, rwTx := newTestTx(t) + batch, err := membatchwithdb.NewMemoryBatch(rwTx, "", log.Root()) + require.NoError(t, err) + defer batch.Close() + + wantErr := errors.New("domain reader unavailable") + batch.DomainReader = erroringDomainReader{err: wantErr} + + key := []byte{0x2} + for name, tx := range map[string]kv.TemporalTx{ + "MemoryMutation": batch, + "OverlayTemporalReadView": batch.NewTemporalReadView(rwTx), + } { + t.Run(name, func(t *testing.T) { + _, _, err := tx.GetAsOf(kv.ReceiptDomain, key, 1) + require.ErrorIs(t, err, wantErr, "GetAsOf must propagate the DomainReader error") + + _, _, err = tx.HistorySeek(kv.ReceiptDomain, key, 1) + require.ErrorIs(t, err, wantErr, "HistorySeek must propagate the DomainReader error") + }) + } +} diff --git a/db/state/execctx/domain_shared_test.go b/db/state/execctx/domain_shared_test.go index a6eed09b19e..1fee2b931e9 100644 --- a/db/state/execctx/domain_shared_test.go +++ b/db/state/execctx/domain_shared_test.go @@ -41,6 +41,7 @@ import ( "github.com/erigontech/erigon/db/kv/rawdbv3" "github.com/erigontech/erigon/db/kv/stream" "github.com/erigontech/erigon/db/kv/temporal" + "github.com/erigontech/erigon/db/rawdb/rawtemporaldb" "github.com/erigontech/erigon/db/state" "github.com/erigontech/erigon/db/state/changeset" "github.com/erigontech/erigon/db/state/execctx" @@ -1994,3 +1995,48 @@ func TestBlockOverlay_DomainReadsRegression(t *testing.T) { require.True(t, ok, "NewTemporalReadView HistorySeek must find in-memory receipt data") require.Equal(t, value, gotValHist2) } + +// TestReceiptAsOf_InFlightBlockLogIndex pins the read that seeds per-transaction log +// indexes. A block whose commit is in flight has its receipt metadata only in +// SharedDomains, and on a history miss DomainRoTx.GetAsOf falls back to GetLatest — so +// a bare read answers with the last committed block's value. The overlay read view must +// see the in-flight value instead. +func TestReceiptAsOf_InFlightBlockLogIndex(t *testing.T) { + t.Parallel() + + ctx := t.Context() + logger := log.New() + db := newTestDb(t, 10) + + tx, err := db.BeginTemporalRw(ctx) + require.NoError(t, err) + defer tx.Rollback() + + const ( + committedTxNum = uint64(5) + committedLogIdx = uint32(7) + inFlightTxNum = uint64(9) + inFlightLogIdx = uint32(3) + ) + + committed, err := execctx.NewSharedDomains(ctx, tx, logger) + require.NoError(t, err) + defer committed.Close() + require.NoError(t, rawtemporaldb.AppendReceipt(committed.AsPutDel(tx), committedLogIdx, 0, 0, committedTxNum)) + require.NoError(t, committed.Flush(ctx, tx)) + committed.Close() + + _, _, stale, err := rawtemporaldb.ReceiptAsOf(tx, inFlightTxNum+1) + require.NoError(t, err) + require.Equal(t, committedLogIdx, stale, "precondition: a bare read must return the stale committed value") + + sd, err := execctx.NewSharedDomains(ctx, tx, logger) + require.NoError(t, err) + defer sd.Close() + require.NoError(t, sd.InitBlockOverlay(tx, t.TempDir())) + require.NoError(t, rawtemporaldb.AppendReceipt(sd.AsPutDel(tx), inFlightLogIdx, 0, 0, inFlightTxNum)) + + _, _, got, err := rawtemporaldb.ReceiptAsOf(sd.BlockOverlay().NewReadView(tx), inFlightTxNum+1) + require.NoError(t, err) + require.Equal(t, inFlightLogIdx, got, "must serve the in-flight block's log index, not the last committed one") +} diff --git a/rpc/jsonrpc/receipts/receipts_generator_overlay_test.go b/rpc/jsonrpc/receipts/receipts_generator_overlay_test.go new file mode 100644 index 00000000000..3211edf019e --- /dev/null +++ b/rpc/jsonrpc/receipts/receipts_generator_overlay_test.go @@ -0,0 +1,81 @@ +// 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 receipts_test + +import ( + "testing" + "time" + + "github.com/holiman/uint256" + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/db/rawdb/rawtemporaldb" + "github.com/erigontech/erigon/db/state/execctx" + "github.com/erigontech/erigon/execution/protocol/params" + "github.com/erigontech/erigon/execution/tests/blockgen" + "github.com/erigontech/erigon/execution/types" + "github.com/erigontech/erigon/node/shards" + "github.com/erigontech/erigon/rpc/jsonrpc/receipts" + "github.com/erigontech/erigon/rpc/rpchelper" +) + +// TestGetReceiptLogIndexThroughOverlay pins the wiring that lets GetReceipt see a +// block whose commit is in flight: the log index must be resolved through +// Filters.WithTemporalOverlay, not read from the committed tx. The overlay is +// seeded with a value the committed tx does not hold, so only a routed read can +// produce it. +func TestGetReceiptLogIndexThroughOverlay(t *testing.T) { + signer := types.LatestSignerForChainID(nil) + m := mockWithGenerator(t, 2, func(i int, block *blockgen.BlockGen) { + txn, err := types.SignTx( + types.NewTransaction(block.TxNonce(testAddr), testAddr, uint256.NewInt(1), params.TxGas, nil, nil), + *signer, testKey) + require.NoError(t, err) + block.AddTx(txn) + }) + + tx, err := m.DB.BeginTemporalRw(m.Ctx) + require.NoError(t, err) + defer tx.Rollback() + + const blockNum = uint64(2) + block, err := m.BlockReader.BlockByNumber(m.Ctx, tx, blockNum) + require.NoError(t, err) + require.Len(t, block.Transactions(), 1) + + minTxNum, err := m.BlockReader.TxnumReader().Min(m.Ctx, tx, blockNum) + require.NoError(t, err) + txNum := minTxNum + 1 // txIndex 0, past the block's system tx + + const overlayLogIdx = uint32(41) + + sd, err := execctx.NewSharedDomains(m.Ctx, tx, m.Log) + require.NoError(t, err) + defer sd.Close() + require.NoError(t, sd.InitBlockOverlay(tx, t.TempDir())) + require.NoError(t, rawtemporaldb.AppendReceipt(sd.AsPutDel(tx), overlayLogIdx, 0, 0, txNum)) + + events := shards.NewEvents() + events.PublishOverlay(sd) + ff := rpchelper.New(m.Ctx, rpchelper.DefaultFiltersConfig, nil, nil, nil, func() {}, m.Log, events) + + gen := receipts.NewGenerator(m.Dirs, m.BlockReader, m.Engine, nil, time.Minute, ff) + receipt, err := gen.GetReceipt(m.Ctx, m.ChainConfig, tx, block.HeaderNoCopy(), block.Transactions()[0], 0, txNum, nil) + require.NoError(t, err) + require.Equal(t, overlayLogIdx, receipt.FirstLogIndexWithinBlock, + "GetReceipt must resolve the log index through the block overlay") +}