diff --git a/db/kv/membatchwithdb/carries_overlay_view_test.go b/db/kv/membatchwithdb/carries_overlay_view_test.go
new file mode 100644
index 00000000000..aeee94be0c6
--- /dev/null
+++ b/db/kv/membatchwithdb/carries_overlay_view_test.go
@@ -0,0 +1,42 @@
+// 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 membatchwithdb_test
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/erigontech/erigon/common/log/v3"
+ "github.com/erigontech/erigon/db/kv/membatchwithdb"
+)
+
+// TestCarriesOverlayView pins the predicate overlay wrap points rely on to
+// avoid re-wrapping: read views over an overlay are recognized, while raw txs
+// and writable batches are not.
+func TestCarriesOverlayView(t *testing.T) {
+ _, rwTx := newTestTx(t)
+
+ overlay, err := membatchwithdb.NewMemoryBatch(rwTx, "", log.Root())
+ require.NoError(t, err)
+ defer overlay.Close()
+
+ require.False(t, membatchwithdb.CarriesOverlayView(rwTx), "a raw tx is not an overlay view")
+ require.False(t, membatchwithdb.CarriesOverlayView(overlay), "a writable batch is not an overlay view")
+ require.True(t, membatchwithdb.CarriesOverlayView(overlay.NewReadView(rwTx)))
+ require.True(t, membatchwithdb.CarriesOverlayView(overlay.NewTemporalReadView(rwTx)))
+}
diff --git a/db/kv/membatchwithdb/memory_mutation.go b/db/kv/membatchwithdb/memory_mutation.go
index b054c6ab6c9..5fec8e34398 100644
--- a/db/kv/membatchwithdb/memory_mutation.go
+++ b/db/kv/membatchwithdb/memory_mutation.go
@@ -60,6 +60,7 @@ type MemoryMutation struct {
db kv.TemporalTx
statelessCursors map[string]kv.RwCursor
DomainReader DomainReader
+ overlay *MemoryMutation // non-nil marks a read view, pointing at the overlay it was created from
}
// NewMemoryBatch creates a pure Go in-memory batch with no OS-thread affinity.
@@ -1097,6 +1098,39 @@ func (m *MemoryMutation) NewReadView(tx kv.Tx) kv.TemporalTx {
return m.newReadViewMut(tx)
}
+// OverlayViewCarrier is implemented by txs that are pinned overlay views.
+// A wrapper that embeds a concrete view type keeps the marker through method
+// promotion; one that embeds the bare tx interface must forward OverlayView
+// explicitly, or the wrap points will treat it as unpinned.
+type OverlayViewCarrier interface {
+ // OverlayView returns the overlay the tx was pinned to and whether the
+ // tx is a pinned view at all. A pinned view with a nil overlay resolved
+ // "no overlay published" and must keep reading committed data only.
+ OverlayView() (overlay *MemoryMutation, pinned bool)
+}
+
+// CarriesOverlayView reports whether tx is already a pinned overlay view, so
+// wrap points leave it alone (rationale on rpchelper.PinToOverlay).
+func CarriesOverlayView(tx kv.Tx) bool {
+ _, ok := ViewOverlay(tx)
+ return ok
+}
+
+// ViewOverlay returns the overlay tx was pinned to, and whether tx is a
+// pinned view at all.
+func ViewOverlay(tx kv.Tx) (*MemoryMutation, bool) {
+ if c, ok := tx.(OverlayViewCarrier); ok {
+ return c.OverlayView()
+ }
+ return nil, false
+}
+
+// OverlayView implements OverlayViewCarrier for read views; a MemoryMutation
+// that owns its overlay data is not a view and carries no pin.
+func (m *MemoryMutation) OverlayView() (*MemoryMutation, bool) {
+ return m.overlay, m.overlay != 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 {
@@ -1114,6 +1148,7 @@ func (m *MemoryMutation) newReadViewMut(tx kv.Tx) *MemoryMutation {
readTx: tx,
db: dbTx,
DomainReader: m.DomainReader,
+ overlay: m,
}
}
diff --git a/node/shards/events.go b/node/shards/events.go
index c2f3329bb40..be92c8ae887 100644
--- a/node/shards/events.go
+++ b/node/shards/events.go
@@ -58,9 +58,19 @@ type Events struct {
hasReceiptSubscriptions bool
lock sync.RWMutex
- // latestSD holds the most recently published SharedDomains from FCU.
+ // latestPub holds the most recently published SharedDomains from FCU
+ // together with its publish sequence number, as one atomic snapshot so
+ // readers can never observe the SD and the sequence out of step.
// Accessible lock-free for the builder and RPC layer.
- latestSD atomic.Pointer[execctx.SharedDomains]
+ latestPub atomic.Pointer[overlayPub]
+}
+
+// overlayPub pairs a published SharedDomains with a monotonic publish
+// sequence number. The first publish gets seq 1, so seq 0 uniquely means
+// "nothing was ever published".
+type overlayPub struct {
+ sd *execctx.SharedDomains
+ seq uint64
}
func NewEvents() *Events {
@@ -266,9 +276,13 @@ func (e *Events) AddOverlaySubscription() (chan *execctx.SharedDomains, func())
// PublishOverlay sends the SharedDomains to all in-process subscribers.
// The SD is shared read-only; the background commit goroutine owns its lifecycle.
func (e *Events) PublishOverlay(sd *execctx.SharedDomains) {
- e.latestSD.Store(sd)
e.lock.Lock()
defer e.lock.Unlock()
+ seq := uint64(1)
+ if prev := e.latestPub.Load(); prev != nil {
+ seq = prev.seq + 1
+ }
+ e.latestPub.Store(&overlayPub{sd: sd, seq: seq})
for _, ch := range e.overlaySubscriptions {
common.PrioritizedSend(ch, sd)
}
@@ -276,7 +290,20 @@ func (e *Events) PublishOverlay(sd *execctx.SharedDomains) {
// LatestSD returns the most recently published SharedDomains, or nil.
func (e *Events) LatestSD() *execctx.SharedDomains {
- return e.latestSD.Load()
+ sd, _ := e.OverlaySnapshot()
+ return sd
+}
+
+// OverlaySnapshot returns the published SharedDomains together with its
+// publish sequence number as one coherent pair. Comparing the sequence around
+// a tx open detects any publish landing in between — including a
+// publish/unpublish cycle that leaves the SD pointer unchanged.
+func (e *Events) OverlaySnapshot() (*execctx.SharedDomains, uint64) {
+ p := e.latestPub.Load()
+ if p == nil {
+ return nil, 0
+ }
+ return p.sd, p.seq
}
func (e *Events) OnNewPendingLogs(logs types.Logs) {
diff --git a/rpc/gasprice/bench_test.go b/rpc/gasprice/bench_test.go
index 173bc6090a6..aded83628d6 100644
--- a/rpc/gasprice/bench_test.go
+++ b/rpc/gasprice/bench_test.go
@@ -38,6 +38,7 @@ import (
"github.com/erigontech/erigon/rpc/gasprice/gaspricecfg"
"github.com/erigontech/erigon/rpc/jsonrpc"
"github.com/erigontech/erigon/rpc/rpccfg"
+ "github.com/erigontech/erigon/rpc/rpchelper"
)
const txsPerBlock = 100
@@ -128,7 +129,7 @@ func BenchmarkSuggestTipCap(b *testing.B) {
// Fresh cache every iteration → cold path, no cache hits.
cache := jsonrpc.NewGasPriceCache()
oracle := gasprice.NewOracle(
- jsonrpc.NewGasPriceOracleBackend(dbArg, tx, baseApi),
+ jsonrpc.NewGasPriceOracleBackend(dbArg, rpchelper.PinToOverlay(tx, nil), baseApi),
cfg,
cache,
nil,
@@ -187,7 +188,7 @@ func BenchmarkFeeHistory(b *testing.B) {
// starts cold every time (nil historyCache). This ensures we
// measure DB round-trips, not cache hits.
oracle := gasprice.NewOracle(
- jsonrpc.NewGasPriceOracleBackend(m.DB, tx, baseApi),
+ jsonrpc.NewGasPriceOracleBackend(m.DB, rpchelper.PinToOverlay(tx, nil), baseApi),
gaspricecfg.Config{MaxHeaderHistory: 0, MaxBlockHistory: 0},
gasCache,
nil, // cold: no history cache
diff --git a/rpc/gasprice/feehistory.go b/rpc/gasprice/feehistory.go
index dc6b72d192e..132f49bd599 100644
--- a/rpc/gasprice/feehistory.go
+++ b/rpc/gasprice/feehistory.go
@@ -57,10 +57,17 @@ const (
maxBlockFetchers = 4
)
-// cacheKey identifies a processed block in the fee history cache.
+// cacheKey identifies a processed block in the fee history cache. Above the
+// frozen boundary the header hash is part of the key, so an entry lives
+// exactly as long as the block itself: a same-height sibling (reorg, or an
+// in-flight block replaced before its commit lands) has a different hash and
+// misses. At or below the boundary the mapping is immutable and the hash
+// stays zero: the number alone identifies the block, with no per-block
+// resolution on the hit path.
// The percentiles string is a binary encoding of the requested percentile slice,
// so identical percentile arrays produce the same key.
type cacheKey struct {
+ hash common.Hash
number uint64
percentiles string
}
@@ -359,6 +366,28 @@ func (oracle *Oracle) FeeHistory(ctx context.Context, blocks int, unresolvedLast
// Pre-fetch chain config once using the main backend (safe: single goroutine).
chainconfig := oracle.backend.ChainConfig()
+ var frozenBound uint64
+ if oracle.historyCache != nil {
+ if fb, err := oracle.backend.FrozenBlocks(); err == nil {
+ frozenBound = fb
+ }
+ }
+
+ // Unfrozen heights are cached by hash, so a reorged-out block can no longer
+ // be found under its number. The hashes come from one range scan per request
+ // — resolving them per block costs a remote round trip each in rpcdaemon
+ // mode, which the cache-hit path used to be free of. Heights left unresolved
+ // (scan error, beyond the head) are simply not cached.
+ var hotFrom uint64
+ var hotHashes []common.Hash
+ if oracle.historyCache != nil && lastBlock > frozenBound {
+ hotFrom = max(oldestBlock, frozenBound+1)
+ hotHashes, err = oracle.backend.CanonicalHashes(ctx, hotFrom, lastBlock)
+ if err != nil {
+ oracle.log.Debug("fee history: canonical range unresolved, serving uncached", "from", hotFrom, "to", lastBlock, "err", err)
+ hotHashes = nil
+ }
+ }
// Launch up to maxBlockFetchers goroutines. Each goroutine opens its own
// TemporalTx via Fork so MDBX transactions are never shared across goroutines.
@@ -394,24 +423,44 @@ func (oracle *Oracle) FeeHistory(ctx context.Context, blocks int, unresolvedLast
}
idx := int(blockNumber - oldestBlock)
- // Try the LRU cache first (skip for pending blocks — they are ephemeral).
+ // The pending block comes from the mining cache and is rebuilt
+ // continuously, so its results are never memoized.
isPending := pendingBlock != nil && blockNumber >= pendingBlock.NumberU64()
- if !isPending && oracle.historyCache != nil {
- if cached, ok := oracle.historyCache.get(cacheKey{blockNumber, percentileKey}); ok {
+ cacheable := !isPending && oracle.historyCache != nil
+ byHash := false
+ key := cacheKey{number: blockNumber, percentiles: percentileKey}
+ if cacheable && blockNumber > frozenBound {
+ hotIdx := int(blockNumber - hotFrom)
+ if hotIdx < len(hotHashes) && hotHashes[hotIdx] != (common.Hash{}) {
+ key.hash = hotHashes[hotIdx]
+ byHash = true
+ } else {
+ cacheable = false
+ }
+ }
+ if cacheable {
+ if cached, ok := oracle.historyCache.get(key); ok {
blockResults[idx] = blockResult{processed: cached, hasResult: true}
continue
}
}
+ // Fetch by the resolved pair to skip a second canonical resolution.
fees := &blockFees{blockNumber: blockNumber}
switch {
case isPending:
fees.block, fees.receipts = pendingBlock, pendingReceipts
case len(rewardPercentiles) != 0:
- fees.block, fees.err = localBackend.BlockByNumber(fetchCtx, rpc.BlockNumber(blockNumber))
+ if byHash {
+ fees.block, fees.err = localBackend.BlockByHashNumber(fetchCtx, key.hash, blockNumber)
+ } else {
+ fees.block, fees.err = localBackend.BlockByNumber(fetchCtx, rpc.BlockNumber(blockNumber))
+ }
if fees.block != nil && fees.err == nil {
fees.receipts, fees.err = localBackend.GetReceiptsGasUsed(fetchCtx, fees.block)
}
+ case byHash:
+ fees.header, fees.err = localBackend.HeaderByHashNumber(fetchCtx, key.hash, blockNumber)
default:
fees.header, fees.err = localBackend.HeaderByNumber(fetchCtx, rpc.BlockNumber(blockNumber))
}
@@ -434,8 +483,8 @@ func (oracle *Oracle) FeeHistory(ctx context.Context, blocks int, unresolvedLast
}
blockResults[idx] = blockResult{processed: fees.results, hasResult: true}
- if !isPending && oracle.historyCache != nil {
- oracle.historyCache.add(cacheKey{blockNumber, percentileKey}, fees.results)
+ if cacheable {
+ oracle.historyCache.add(key, fees.results)
}
}
})
diff --git a/rpc/gasprice/feehistory_test.go b/rpc/gasprice/feehistory_test.go
index 46d3105a192..df41417ed13 100644
--- a/rpc/gasprice/feehistory_test.go
+++ b/rpc/gasprice/feehistory_test.go
@@ -33,6 +33,7 @@ import (
"github.com/erigontech/erigon/rpc/gasprice/gaspricecfg"
"github.com/erigontech/erigon/rpc/jsonrpc"
"github.com/erigontech/erigon/rpc/rpccfg"
+ "github.com/erigontech/erigon/rpc/rpchelper"
)
func TestFeeHistory(t *testing.T) {
@@ -86,7 +87,7 @@ func TestFeeHistory(t *testing.T) {
defer tx.Rollback()
cache := jsonrpc.NewGasPriceCache()
- oracle := gasprice.NewOracle(jsonrpc.NewGasPriceOracleBackend(m.DB, tx, baseApi), config, cache, gasprice.NewFeeHistoryCache(), log.New())
+ oracle := gasprice.NewOracle(jsonrpc.NewGasPriceOracleBackend(m.DB, rpchelper.PinToOverlay(tx, nil), baseApi), config, cache, gasprice.NewFeeHistoryCache(), log.New())
first, reward, baseFee, ratio, blobBaseFee, blobBaseFeeRatio, err := oracle.FeeHistory(context.Background(), c.count, c.last, c.percent)
diff --git a/rpc/gasprice/gasprice.go b/rpc/gasprice/gasprice.go
index 41cf722e7d7..dcbb0e5c1d0 100644
--- a/rpc/gasprice/gasprice.go
+++ b/rpc/gasprice/gasprice.go
@@ -47,6 +47,23 @@ type OracleBackend interface {
GetReceiptsGasUsed(ctx context.Context, block *types.Block) (types.Receipts, error)
PendingBlockAndReceipts() (*types.Block, types.Receipts)
+ // CanonicalHashes returns the canonical hashes of [from, to] on the
+ // backend's view, one entry per height. Heights the view has no canonical
+ // marker for (beyond the head, or pruned) get the zero hash. It resolves
+ // the whole range at once because a per-height lookup is a remote round
+ // trip in rpcdaemon mode.
+ CanonicalHashes(ctx context.Context, from, to uint64) ([]common.Hash, error)
+
+ // FrozenBlocks returns the frozen (snapshot) boundary: the canonical
+ // number-to-hash mapping at or below it is immutable.
+ FrozenBlocks() (uint64, error)
+
+ // HeaderByHashNumber and BlockByHashNumber fetch by an already-resolved
+ // canonical (hash, number) pair, so a cached entry can never name a
+ // different block than the one processed.
+ HeaderByHashNumber(ctx context.Context, hash common.Hash, number uint64) (*types.Header, error)
+ BlockByHashNumber(ctx context.Context, hash common.Hash, number uint64) (*types.Block, error)
+
// Fork opens a new TemporalTx and returns a goroutine-local backend together
// with a cleanup function (call via defer cleanup()).
// If the backend does not support forking, it returns (nil, nil, nil) and
diff --git a/rpc/gasprice/gasprice_test.go b/rpc/gasprice/gasprice_test.go
index 43f0c77244d..b2b9d24c9ef 100644
--- a/rpc/gasprice/gasprice_test.go
+++ b/rpc/gasprice/gasprice_test.go
@@ -22,9 +22,14 @@ package gasprice_test
import (
"container/heap"
"context"
+ "encoding/binary"
+ "errors"
"math"
"math/big"
"math/rand"
+ "slices"
+ "sync"
+ "sync/atomic"
"testing"
"github.com/holiman/uint256"
@@ -43,6 +48,7 @@ import (
"github.com/erigontech/erigon/rpc/gasprice/gaspricecfg"
"github.com/erigontech/erigon/rpc/jsonrpc"
"github.com/erigontech/erigon/rpc/rpccfg"
+ "github.com/erigontech/erigon/rpc/rpchelper"
)
func newTestBackend(t *testing.T) *execmoduletester.ExecModuleTester {
@@ -95,7 +101,7 @@ func TestSuggestPrice(t *testing.T) {
defer tx.Rollback()
cache := jsonrpc.NewGasPriceCache()
- oracle := gasprice.NewOracle(jsonrpc.NewGasPriceOracleBackend(nil, tx, baseApi), config, cache, nil, log.New())
+ oracle := gasprice.NewOracle(jsonrpc.NewGasPriceOracleBackend(nil, rpchelper.PinToOverlay(tx, nil), baseApi), config, cache, nil, log.New())
// The gas price sampled is: 32G, 31G, 30G, 29G, 28G, 27G
got, err := oracle.SuggestTipCap(context.Background())
@@ -273,11 +279,26 @@ func BenchmarkKthPercentile(b *testing.B) {
// through fetchBlockPricesParallel.
type mockOracleBackend struct {
head *types.Header
+ frozen uint64
+ canonicalErr error
+ headerCalls atomic.Int32
safeBlock uint64
finalizedBlock uint64
+
+ canonicalMu sync.Mutex
+ canonicalRanges [][2]uint64
+}
+
+// mockHeightHash derives a distinct hash per height, honoring the "one hash
+// per block" contract the fee-history cache keys rely on.
+func mockHeightHash(number uint64) common.Hash {
+ var h common.Hash
+ binary.BigEndian.PutUint64(h[:8], number+1)
+ return h
}
func (m *mockOracleBackend) HeaderByNumber(_ context.Context, number rpc.BlockNumber) (*types.Header, error) {
+ m.headerCalls.Add(1)
header := types.CopyHeader(m.head)
switch number {
case rpc.SafeBlockNumber:
@@ -309,10 +330,112 @@ func (m *mockOracleBackend) PendingBlockAndReceipts() (*types.Block, types.Recei
return nil, nil
}
+func (m *mockOracleBackend) CanonicalHashes(_ context.Context, from, to uint64) ([]common.Hash, error) {
+ m.canonicalMu.Lock()
+ m.canonicalRanges = append(m.canonicalRanges, [2]uint64{from, to})
+ m.canonicalMu.Unlock()
+ if m.canonicalErr != nil {
+ return nil, m.canonicalErr
+ }
+ hashes := make([]common.Hash, to-from+1)
+ for number := from; number <= min(to, m.head.Number.Uint64()); number++ {
+ hashes[number-from] = mockHeightHash(number)
+ }
+ return hashes, nil
+}
+
+func (m *mockOracleBackend) resolvedRanges() [][2]uint64 {
+ m.canonicalMu.Lock()
+ defer m.canonicalMu.Unlock()
+ return slices.Clone(m.canonicalRanges)
+}
+
+func (m *mockOracleBackend) FrozenBlocks() (uint64, error) {
+ return m.frozen, nil
+}
+
+func (m *mockOracleBackend) HeaderByHashNumber(ctx context.Context, _ common.Hash, _ uint64) (*types.Header, error) {
+ return m.HeaderByNumber(ctx, 0)
+}
+
+func (m *mockOracleBackend) BlockByHashNumber(ctx context.Context, _ common.Hash, _ uint64) (*types.Block, error) {
+ return m.BlockByNumber(ctx, 0)
+}
+
func (m *mockOracleBackend) Fork(_ context.Context) (gasprice.OracleBackend, func(), error) {
return nil, nil, nil // sequential mode
}
+// TestFeeHistory_CanonicalHashErrorDegradesToUncached pins that a transient
+// error on the auxiliary cache-key resolution degrades the block to uncached
+// instead of failing the whole request — the number-keyed hit path had no
+// failure mode at all.
+func TestFeeHistory_CanonicalHashErrorDegradesToUncached(t *testing.T) {
+ head := types.NewEmptyHeaderForAssembling()
+ head.Number.SetUint64(10)
+ head.GasLimit = 30_000_000
+ head.BaseFee = uint256.NewInt(1_000_000_000)
+
+ backend := &mockOracleBackend{head: head, canonicalErr: errors.New("transient lookup failure")}
+ oracle := gasprice.NewOracle(backend, gaspricecfg.Config{Blocks: 2, Percentile: 60}, jsonrpc.NewGasPriceCache(), gasprice.NewFeeHistoryCache(), log.New())
+
+ _, _, baseFee, _, _, _, err := oracle.FeeHistory(context.Background(), 3, rpc.LatestBlockNumber, nil)
+ require.NoError(t, err, "a cache-key resolution error must degrade to uncached, not fail the request")
+ require.Len(t, baseFee, 4)
+}
+
+// TestFeeHistory_FrozenRangeCachesByNumberWithoutResolution pins that at or
+// below the frozen boundary — where the number-to-hash mapping is immutable —
+// no per-block hash resolution happens and repeat requests hit the cache.
+func TestFeeHistory_FrozenRangeCachesByNumberWithoutResolution(t *testing.T) {
+ head := types.NewEmptyHeaderForAssembling()
+ head.Number.SetUint64(10)
+ head.GasLimit = 30_000_000
+ head.BaseFee = uint256.NewInt(1_000_000_000)
+
+ backend := &mockOracleBackend{head: head, frozen: 10}
+ oracle := gasprice.NewOracle(backend, gaspricecfg.Config{Blocks: 2, Percentile: 60}, jsonrpc.NewGasPriceCache(), gasprice.NewFeeHistoryCache(), log.New())
+
+ _, _, _, _, _, _, err := oracle.FeeHistory(context.Background(), 4, rpc.LatestBlockNumber, nil)
+ require.NoError(t, err)
+ require.Empty(t, backend.resolvedRanges(),
+ "the frozen range must not resolve hashes: the mapping is immutable")
+ fetchesAfterFirst := backend.headerCalls.Load()
+
+ _, _, _, _, _, _, err = oracle.FeeHistory(context.Background(), 4, rpc.LatestBlockNumber, nil)
+ require.NoError(t, err)
+ require.Empty(t, backend.resolvedRanges())
+ require.Equal(t, fetchesAfterFirst, backend.headerCalls.Load(),
+ "the second request must be served from number-keyed cache entries")
+}
+
+// TestFeeHistory_HotRangeResolvedInOneScan pins the cost of the cache-key
+// resolution above the frozen boundary: one range resolution per request,
+// whatever the window size. Per-block resolution would turn a memoized
+// eth_feeHistory into one remote round trip per block in rpcdaemon mode.
+func TestFeeHistory_HotRangeResolvedInOneScan(t *testing.T) {
+ head := types.NewEmptyHeaderForAssembling()
+ head.Number.SetUint64(20)
+ head.GasLimit = 30_000_000
+ head.BaseFee = uint256.NewInt(1_000_000_000)
+
+ backend := &mockOracleBackend{head: head}
+ oracle := gasprice.NewOracle(backend, gaspricecfg.Config{Blocks: 2, Percentile: 60}, jsonrpc.NewGasPriceCache(), gasprice.NewFeeHistoryCache(), log.New())
+
+ _, _, _, _, _, _, err := oracle.FeeHistory(context.Background(), 8, rpc.LatestBlockNumber, nil)
+ require.NoError(t, err)
+ require.Equal(t, [][2]uint64{{13, 20}}, backend.resolvedRanges(),
+ "the whole hot window must be resolved by a single scan")
+ fetchesAfterFirst := backend.headerCalls.Load()
+
+ _, _, _, _, _, _, err = oracle.FeeHistory(context.Background(), 8, rpc.LatestBlockNumber, nil)
+ require.NoError(t, err)
+ require.Equal(t, [][2]uint64{{13, 20}, {13, 20}}, backend.resolvedRanges(),
+ "a warm request must not cost more than its one scan")
+ require.Equal(t, fetchesAfterFirst, backend.headerCalls.Load(),
+ "the second request must be served from hash-keyed cache entries")
+}
+
func TestFeeHistoryResolvesSafeAndFinalizedBlocks(t *testing.T) {
head := types.NewEmptyHeaderForAssembling()
head.Number.SetUint64(25)
@@ -467,7 +590,7 @@ func TestSuggestTipCap_SparseBlocks(t *testing.T) {
defer dbTx.Rollback()
cache := jsonrpc.NewGasPriceCache()
- oracle := gasprice.NewOracle(jsonrpc.NewGasPriceOracleBackend(nil, dbTx, baseApi), cfg, cache, nil, log.New())
+ oracle := gasprice.NewOracle(jsonrpc.NewGasPriceOracleBackend(nil, rpchelper.PinToOverlay(dbTx, nil), baseApi), cfg, cache, nil, log.New())
got, err := oracle.SuggestTipCap(context.Background())
require.NoError(t, err)
@@ -504,7 +627,7 @@ func TestSuggestTipCap_AllEmptyBlocks(t *testing.T) {
defer dbTx.Rollback()
cache := jsonrpc.NewGasPriceCache()
- oracle := gasprice.NewOracle(jsonrpc.NewGasPriceOracleBackend(nil, dbTx, baseApi), cfg, cache, nil, log.New())
+ oracle := gasprice.NewOracle(jsonrpc.NewGasPriceOracleBackend(nil, rpchelper.PinToOverlay(dbTx, nil), baseApi), cfg, cache, nil, log.New())
// With no transactions anywhere, the oracle returns (nil, nil): no price, no error.
_, err = oracle.SuggestTipCap(context.Background())
diff --git a/rpc/jsonrpc/eth_api.go b/rpc/jsonrpc/eth_api.go
index fcdf6ccf729..71c6e9cc4f8 100644
--- a/rpc/jsonrpc/eth_api.go
+++ b/rpc/jsonrpc/eth_api.go
@@ -522,7 +522,7 @@ type APIImpl struct {
ethBackend rpchelper.ApiBackend
txPool txpoolproto.TxpoolClient
mining txpoolproto.MiningClient
- gasCache *GasPriceCache
+ gasCache gasprice.Cache
feeHistoryCache *gasprice.FeeHistoryCache
db kv.TemporalRoDB
GasCap uint64
diff --git a/rpc/jsonrpc/eth_fill_transaction.go b/rpc/jsonrpc/eth_fill_transaction.go
index ff62814e011..f62fc60fee9 100644
--- a/rpc/jsonrpc/eth_fill_transaction.go
+++ b/rpc/jsonrpc/eth_fill_transaction.go
@@ -50,15 +50,14 @@ func (api *APIImpl) FillTransaction(ctx context.Context, args ethapi.CallArgs) (
return nil, errors.New("maxFeePerBlobGas, if specified, must be non-zero")
}
- dbTx, err := api.db.BeginTemporalRo(ctx)
+ // The pinned view keeps ReadCurrentHeader and the gas-oracle fee defaults
+ // on one head (including the in-flight overlay block); the nonce and
+ // gas-estimate sub-calls still open their own txs.
+ overlayTx, err := api.filters.BeginTemporalRoWithOverlay(ctx, api.db)
if err != nil {
return nil, err
}
- defer dbTx.Rollback()
-
- // Use the overlay so ReadCurrentHeader and the gas oracle see the latest
- // in-flight block (which may not yet be committed to MDBX).
- overlayTx := api.filters.WithTemporalOverlay(dbTx)
+ defer overlayTx.Rollback()
cc, err := api.chainConfig(ctx, overlayTx)
if err != nil {
@@ -165,7 +164,8 @@ func (api *APIImpl) FillTransaction(ctx context.Context, args ethapi.CallArgs) (
}
func (api *APIImpl) newGasOracle(dbTx kv.TemporalTx) *gasprice.Oracle {
- return gasprice.NewOracle(NewGasPriceOracleBackend(api.db, dbTx, api.BaseAPI), ethconfig.Defaults.GPO, api.gasCache, api.feeHistoryCache, api.logger.New("app", "gasPriceOracle"))
+ backend := NewGasPriceOracleBackend(api.db, dbTx, api.BaseAPI)
+ return gasprice.NewOracle(backend, ethconfig.Defaults.GPO, api.gasCache, api.feeHistoryCache, api.logger.New("app", "gasPriceOracle"))
}
func (api *APIImpl) fillFeeDefaults(ctx context.Context, args *ethapi.CallArgs, head *types.Header, dbTx kv.TemporalTx) error {
diff --git a/rpc/jsonrpc/eth_system.go b/rpc/jsonrpc/eth_system.go
index 9292a6f23de..e1633f1e18e 100644
--- a/rpc/jsonrpc/eth_system.go
+++ b/rpc/jsonrpc/eth_system.go
@@ -18,6 +18,7 @@ package jsonrpc
import (
"context"
+ "encoding/binary"
"errors"
"fmt"
"math"
@@ -26,13 +27,17 @@ import (
"github.com/erigontech/erigon/common"
"github.com/erigontech/erigon/common/hexutil"
+ "github.com/erigontech/erigon/common/length"
"github.com/erigontech/erigon/db/kv"
"github.com/erigontech/erigon/db/kv/kvcfg"
+ "github.com/erigontech/erigon/db/kv/membatchwithdb"
+ "github.com/erigontech/erigon/db/kv/order"
"github.com/erigontech/erigon/db/kv/prune"
"github.com/erigontech/erigon/db/rawdb"
"github.com/erigontech/erigon/execution/chain"
"github.com/erigontech/erigon/execution/protocol/misc"
"github.com/erigontech/erigon/execution/protocol/params"
+ "github.com/erigontech/erigon/execution/stagedsync/stages"
"github.com/erigontech/erigon/execution/types"
"github.com/erigontech/erigon/execution/vm"
"github.com/erigontech/erigon/execution/vm/evmtypes"
@@ -192,12 +197,12 @@ func (api *APIImpl) Capabilities(ctx context.Context) (*CapabilitiesResult, erro
// BlockNumber implements eth_blockNumber. Returns the block number of most recent block.
func (api *APIImpl) BlockNumber(ctx context.Context) (hexutil.Uint64, error) {
- tx, err := api.db.BeginTemporalRo(ctx)
+ tx, err := api.filters.BeginTemporalRoWithOverlay(ctx, api.db)
if err != nil {
return 0, err
}
defer tx.Rollback()
- blockNum, err := rpchelper.GetLatestBlockNumber(api.filters.WithOverlay(tx))
+ blockNum, err := rpchelper.GetLatestBlockNumber(tx)
if err != nil {
return 0, err
}
@@ -257,7 +262,7 @@ func (api *APIImpl) ProtocolVersion(ctx context.Context) (hexutil.Uint, error) {
// GasPrice implements eth_gasPrice. Returns the current price per gas in wei.
func (api *APIImpl) GasPrice(ctx context.Context) (*hexutil.Big, error) {
- tx, err := api.db.BeginTemporalRo(ctx)
+ tx, err := api.filters.BeginTemporalRoWithOverlay(ctx, api.db)
if err != nil {
return nil, err
}
@@ -269,8 +274,7 @@ func (api *APIImpl) GasPrice(ctx context.Context) (*hexutil.Big, error) {
}
gasResult := uint256.NewInt(0)
gasResult.Set(tipcap)
- overlayTx := api.filters.WithTemporalOverlay(tx)
- if head := rawdb.ReadCurrentHeader(overlayTx); head != nil && head.BaseFee != nil {
+ if head := rawdb.ReadCurrentHeader(tx); head != nil && head.BaseFee != nil {
gasResult.Add(tipcap, head.BaseFee)
}
@@ -279,7 +283,7 @@ func (api *APIImpl) GasPrice(ctx context.Context) (*hexutil.Big, error) {
// MaxPriorityFeePerGas returns a suggestion for a gas tip cap for dynamic fee transactions.
func (api *APIImpl) MaxPriorityFeePerGas(ctx context.Context) (*hexutil.Big, error) {
- tx, err := api.db.BeginTemporalRo(ctx)
+ tx, err := api.filters.BeginTemporalRoWithOverlay(ctx, api.db)
if err != nil {
return nil, err
}
@@ -302,7 +306,7 @@ type feeHistoryResult struct {
}
func (api *APIImpl) FeeHistory(ctx context.Context, blockCount rpc.DecimalOrHex, lastBlock rpc.BlockNumber, rewardPercentiles []float64) (*feeHistoryResult, error) {
- tx, err := api.db.BeginTemporalRo(ctx)
+ tx, err := api.filters.BeginTemporalRoWithOverlay(ctx, api.db)
if err != nil {
return nil, err
}
@@ -346,13 +350,12 @@ func (api *APIImpl) FeeHistory(ctx context.Context, blockCount rpc.DecimalOrHex,
// BlobBaseFee returns the base fee for blob gas at the current head.
func (api *APIImpl) BlobBaseFee(ctx context.Context) (*hexutil.Big, error) {
- tx, err := api.db.BeginTemporalRo(ctx)
+ tx, err := api.filters.BeginTemporalRoWithOverlay(ctx, api.db)
if err != nil {
return nil, err
}
defer tx.Rollback()
- overlayTx := api.filters.WithTemporalOverlay(tx)
- header := rawdb.ReadCurrentHeader(overlayTx)
+ header := rawdb.ReadCurrentHeader(tx)
if header == nil || header.ExcessBlobGas == nil {
return nil, nil
}
@@ -373,13 +376,12 @@ func (api *APIImpl) BlobBaseFee(ctx context.Context) (*hexutil.Big, error) {
// BaseFee returns the base fee at the current head.
func (api *APIImpl) BaseFee(ctx context.Context) (*hexutil.Big, error) {
- tx, err := api.db.BeginTemporalRo(ctx)
+ tx, err := api.filters.BeginTemporalRoWithOverlay(ctx, api.db)
if err != nil {
return nil, err
}
defer tx.Rollback()
- overlayTx := api.filters.WithTemporalOverlay(tx)
- header := rawdb.ReadCurrentHeader(overlayTx)
+ header := rawdb.ReadCurrentHeader(tx)
if header == nil {
return nil, nil
}
@@ -493,11 +495,18 @@ func fillForkConfig(chainConfig *chain.Config, forkId [4]byte, activationTime ui
type GasPriceOracleBackend struct {
db kv.TemporalRoDB // nil if Fork is not supported
- tx kv.TemporalTx
+ tx kv.TemporalTx // always a pinned view; carries the request's overlay resolution
baseApi *BaseAPI
}
+// NewGasPriceOracleBackend requires a tx already pinned at acquisition (see
+// rpchelper.BeginTemporalRoWithOverlay): resolving the overlay here, after
+// the caller opened the tx, would re-open the torn (tx, overlay) window the
+// pinned acquisition exists to close.
func NewGasPriceOracleBackend(db kv.TemporalRoDB, tx kv.TemporalTx, baseApi *BaseAPI) *GasPriceOracleBackend {
+ if !membatchwithdb.CarriesOverlayView(tx) {
+ panic("NewGasPriceOracleBackend: tx must be pinned via rpchelper.BeginTemporalRoWithOverlay or PinToOverlay")
+ }
return &GasPriceOracleBackend{db: db, tx: tx, baseApi: baseApi}
}
@@ -509,11 +518,53 @@ func (b *GasPriceOracleBackend) Fork(ctx context.Context) (gasprice.OracleBacken
if err != nil {
return nil, nil, err
}
- return &GasPriceOracleBackend{db: b.db, tx: tx, baseApi: b.baseApi},
+ // Reuse the parent's pin (rationale on rpchelper.PinToOverlay).
+ overlay, _ := membatchwithdb.ViewOverlay(b.tx)
+ return &GasPriceOracleBackend{db: b.db, tx: rpchelper.PinToOverlay(tx, overlay), baseApi: b.baseApi},
func() { tx.Rollback() },
nil
}
+// CanonicalHashes scans the canonical markers of [from, to] on the pinned tx:
+// resolving through the block reader would hit a live service in rpcdaemon
+// mode, un-pinning the fee-history cache key, and a per-height read there is a
+// round trip each. Callers only ask for unfrozen heights, whose markers are
+// always in the db.
+func (b *GasPriceOracleBackend) CanonicalHashes(_ context.Context, from, to uint64) ([]common.Hash, error) {
+ hashes := make([]common.Hash, to-from+1)
+ it, err := b.tx.Range(kv.HeaderCanonical, hexutil.EncodeTs(from), hexutil.EncodeTs(to+1), order.Asc, kv.Unlim)
+ if err != nil {
+ return nil, err
+ }
+ defer it.Close()
+ for it.HasNext() {
+ k, v, err := it.Next()
+ if err != nil {
+ return nil, err
+ }
+ if len(k) != 8 || len(v) != length.Hash {
+ continue
+ }
+ hashes[binary.BigEndian.Uint64(k)-from] = common.BytesToHash(v)
+ }
+ return hashes, nil
+}
+
+// FrozenBlocks reads the Snapshots stage progress through the pinned tx — one
+// KV read that works in both embedded and remote mode, unlike the block
+// reader's FrozenBlocks which panics remotely.
+func (b *GasPriceOracleBackend) FrozenBlocks() (uint64, error) {
+ return stages.GetStageProgress(b.tx, stages.Snapshots)
+}
+
+func (b *GasPriceOracleBackend) HeaderByHashNumber(ctx context.Context, hash common.Hash, number uint64) (*types.Header, error) {
+ return b.baseApi._blockReader.Header(ctx, b.tx, hash, number)
+}
+
+func (b *GasPriceOracleBackend) BlockByHashNumber(ctx context.Context, hash common.Hash, number uint64) (*types.Block, error) {
+ return b.baseApi.blockWithSenders(ctx, b.tx, hash, number)
+}
+
func (b *GasPriceOracleBackend) HeaderByNumber(ctx context.Context, number rpc.BlockNumber) (*types.Header, error) {
header, err := b.baseApi.headerByNumber(ctx, number, b.tx)
if err != nil {
diff --git a/rpc/jsonrpc/overlay_race_test.go b/rpc/jsonrpc/overlay_race_test.go
index 316afd071a7..41b61798b3d 100644
--- a/rpc/jsonrpc/overlay_race_test.go
+++ b/rpc/jsonrpc/overlay_race_test.go
@@ -20,7 +20,9 @@ import (
"bytes"
"context"
"math/big"
+ "slices"
"strconv"
+ "sync"
"testing"
"github.com/holiman/uint256"
@@ -31,20 +33,26 @@ 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/kv/membatchwithdb"
"github.com/erigontech/erigon/db/rawdb"
+ "github.com/erigontech/erigon/db/rawdb/rawtemporaldb"
"github.com/erigontech/erigon/db/state/execctx"
"github.com/erigontech/erigon/execution/chain"
"github.com/erigontech/erigon/execution/execmodule/execmoduletester"
"github.com/erigontech/erigon/execution/protocol/params"
+ "github.com/erigontech/erigon/execution/stagedsync/stages"
"github.com/erigontech/erigon/execution/tests/blockgen"
"github.com/erigontech/erigon/execution/types"
"github.com/erigontech/erigon/node/gointerfaces"
"github.com/erigontech/erigon/node/gointerfaces/txpoolproto"
"github.com/erigontech/erigon/node/shards"
"github.com/erigontech/erigon/rpc"
+ "github.com/erigontech/erigon/rpc/ethapi"
"github.com/erigontech/erigon/rpc/filters"
+ "github.com/erigontech/erigon/rpc/gasprice"
"github.com/erigontech/erigon/rpc/jsonstream"
"github.com/erigontech/erigon/rpc/rpccfg"
"github.com/erigontech/erigon/rpc/rpchelper"
@@ -53,6 +61,8 @@ import (
const (
overlayRaceChainSize = 5
overlayRaceBaseFee = 424242
+ overlayRaceLowTip = 1_000_000
+ overlayRaceHighTip = 2_000_000
)
func insertOverlayRaceChain(t *testing.T, m *execmoduletester.ExecModuleTester) *blockgen.ChainPack {
@@ -65,7 +75,47 @@ func insertOverlayRaceChain(t *testing.T, m *execmoduletester.ExecModuleTester)
return c
}
-// newOverlayAheadTestAPI builds overlayRaceChainSize committed MDBX blocks,
+// writeHeadBlockMarkers writes the minimal subset of what InsertBlocks and
+// updateForkChoice persist for a new head — header, canonical marker, body,
+// head-header and forkchoice markers. The forkchoice marker is what
+// rpchelper.GetLatestBlockNumber resolves the head from, so reader paths under
+// test (the gas oracle, "latest" tag resolution) see this block as current.
+func writeHeadBlockMarkers(t *testing.T, tx kv.RwTx, header *types.Header, body *types.Body) {
+ t.Helper()
+ require.NoError(t, writeHeadBlockMarkersE(tx, header, body))
+}
+
+// writeHeadBlockMarkersE is the error-returning core, safe to call from hooks
+// running on non-test goroutines (testify's FailNow contract).
+func writeHeadBlockMarkersE(tx kv.RwTx, header *types.Header, body *types.Body) error {
+ hash := header.Hash()
+ num := header.Number.Uint64()
+ if err := rawdb.WriteHeader(tx, header); err != nil {
+ return err
+ }
+ if err := rawdb.WriteHeadHeaderHash(tx, hash); err != nil {
+ return err
+ }
+ if err := rawdb.WriteCanonicalHash(tx, hash, num); err != nil {
+ return err
+ }
+ if err := rawdb.WriteBody(tx, hash, num, body); err != nil {
+ return err
+ }
+ rawdb.WriteForkchoiceHead(tx, hash)
+ return nil
+}
+
+type overlayAheadHarness struct {
+ t *testing.T
+ base *BaseAPI
+ m *execmoduletester.ExecModuleTester
+ overlayHeader *types.Header
+ events *shards.Events
+ doms *execctx.SharedDomains
+}
+
+// newOverlayAheadHarness 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
// window where forkchoice publishes the overlay before the MDBX commit
@@ -74,13 +124,16 @@ func insertOverlayRaceChain(t *testing.T, m *execmoduletester.ExecModuleTester)
// The overlay block's GasUsed is set to exactly its EIP-1559 target so
// 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) {
+// With withOverlayTxs the block instead carries two transactions with distinct
+// tips plus their receipt-domain entries, and GasUsed equals the transactions'
+// real total so reward percentile thresholds relate to the receipts' gas.
+func newOverlayAheadHarness(t *testing.T, withOverlayTxs bool) *overlayAheadHarness {
t.Helper()
var cfg chain.Config
require.NoError(t, copier.CopyWithOption(&cfg, chain.TestChainBerlinConfig, copier.Option{DeepCopy: true}))
cfg.LondonBlock = common.NewUint64(0)
- m = execmoduletester.New(t, execmoduletester.WithChainConfig(&cfg))
+ m := execmoduletester.New(t, execmoduletester.WithChainConfig(&cfg))
c := insertOverlayRaceChain(t, m)
@@ -95,32 +148,52 @@ func newOverlayAheadTestAPI(t *testing.T) (base *BaseAPI, m *execmoduletester.Ex
const overlayGasLimit = 30_000_000
overlayNumber := uint64(overlayRaceChainSize) + 1
- overlayHeader = &types.Header{
+
+ var overlayTxs types.Transactions
+ overlayGasUsed := uint64(overlayGasLimit / params.ElasticityMultiplier) // == target: CalcBaseFee leaves BaseFee unchanged
+ if withOverlayTxs {
+ overlayTxs = types.Transactions{
+ signOverlayRaceTestTxWithTip(t, m, 0, overlayRaceLowTip),
+ signOverlayRaceTestTxWithTip(t, m, 1, overlayRaceHighTip),
+ }
+ overlayGasUsed = uint64(len(overlayTxs)) * params.TxGas
+ }
+
+ overlayHeader := &types.Header{
ParentHash: c.TopBlock.Hash(),
Number: *uint256.NewInt(overlayNumber),
Difficulty: *uint256.NewInt(0),
Time: c.TopBlock.Time() + 10,
GasLimit: overlayGasLimit,
- GasUsed: overlayGasLimit / params.ElasticityMultiplier, // == target: CalcBaseFee leaves BaseFee unchanged
+ GasUsed: overlayGasUsed,
BaseFee: uint256.NewInt(overlayRaceBaseFee),
}
hash := overlayHeader.Hash()
overlay := doms.BlockOverlay()
- // Minimal subset of what InsertBlocks/updateForkChoice write in production,
- // 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{}))
+ writeHeadBlockMarkers(t, overlay, overlayHeader, &types.Body{Transactions: overlayTxs})
+
+ if withOverlayTxs {
+ senders := slices.Repeat([]common.Address{m.Address}, len(overlayTxs))
+ require.NoError(t, rawdb.WriteSenders(overlay, hash, overlayNumber, senders))
+ // Receipt-domain entries go through the SharedDomains (like execution writes
+ // them), so readers reach them via the view's DomainReader, not the overlay tables.
+ minTxNum, err := m.BlockReader.TxnumReader().Min(ctx, overlayRoTx, overlayNumber)
+ require.NoError(t, err)
+ putDel := doms.AsPutDel(overlayRoTx)
+ var cumGas uint64
+ for i := range overlayTxs {
+ cumGas += params.TxGas
+ require.NoError(t, rawtemporaldb.AppendReceiptMetadata(putDel, 0, cumGas, 0, minTxNum+1+uint64(i)))
+ }
+ }
events := shards.NewEvents()
events.PublishOverlay(doms)
ff := rpchelper.New(ctx, rpchelper.DefaultFiltersConfig, nil, nil, nil, func() {}, m.Log, events)
stateCache := kvcache.New(kvcache.DefaultCoherentConfig)
- base = newBaseApiWithFiltersForTest(ff, stateCache, m)
+ base := newBaseApiWithFiltersForTest(ff, stateCache, m)
- return base, m, overlayHeader
+ return &overlayAheadHarness{t: t, base: base, m: m, overlayHeader: overlayHeader, events: events, doms: doms}
}
// overlayRaceTxPoolClient extends stubTxPoolClient with canned replies for
@@ -140,10 +213,15 @@ func (c *overlayRaceTxPoolClient) All(context.Context, *txpoolproto.AllRequest,
}
func signOverlayRaceTestTx(t *testing.T, m *execmoduletester.ExecModuleTester, nonce uint64) types.Transaction {
+ t.Helper()
+ return signOverlayRaceTestTxWithTip(t, m, nonce, 0)
+}
+
+func signOverlayRaceTestTxWithTip(t *testing.T, m *execmoduletester.ExecModuleTester, nonce uint64, tip uint64) types.Transaction {
t.Helper()
signer := types.LatestSigner(m.ChainConfig)
txn, err := types.SignTx(
- types.NewEIP1559Transaction(*m.ChainConfig.ChainID, nonce, common.HexToAddress("deadbeef"), uint256.NewInt(1), 21000, nil, uint256.NewInt(0), uint256.NewInt(1_000_000_000_000), nil),
+ types.NewEIP1559Transaction(*m.ChainConfig.ChainID, nonce, common.HexToAddress("deadbeef"), uint256.NewInt(1), 21000, nil, uint256.NewInt(tip), uint256.NewInt(1_000_000_000_000), nil),
*signer, m.Key,
)
require.NoError(t, err)
@@ -162,33 +240,33 @@ func marshalOverlayRaceTestTx(t *testing.T, txn types.Transaction) []byte {
// overlay head must return that in-flight block, not the last MDBX-committed one.
func TestGetBlockByTimestamp_SeesOverlayHead(t *testing.T) {
t.Parallel()
- base, m, overlayHeader := newOverlayAheadTestAPI(t)
- api := NewErigonAPI(base, m.DB, nil)
+ h := newOverlayAheadHarness(t, false)
+ api := NewErigonAPI(h.base, h.m.DB, nil)
- resp, err := api.GetBlockByTimestamp(m.Ctx, rpc.Timestamp(overlayHeader.Time), false)
+ resp, err := api.GetBlockByTimestamp(h.m.Ctx, rpc.Timestamp(h.overlayHeader.Time), false)
require.NoError(t, err)
require.NotNil(t, resp)
- require.Equal(t, overlayHeader.Number.ToBig(), resp["number"].(*hexutil.Big).ToInt(),
+ require.Equal(t, h.overlayHeader.Number.ToBig(), resp["number"].(*hexutil.Big).ToInt(),
"must resolve to the overlay head block, not the stale MDBX-committed head")
}
func TestGetModifiedAccountsByNumber_UsesCommittedStartTag(t *testing.T) {
t.Parallel()
- base, m, _ := newOverlayAheadTestAPI(t)
- api := NewPrivateDebugAPI(base, m.DB, nil, &rpccfg.DebugApiConfig{})
+ h := newOverlayAheadHarness(t, false)
+ api := NewPrivateDebugAPI(h.base, h.m.DB, nil, &rpccfg.DebugApiConfig{})
- result, err := api.GetModifiedAccountsByNumber(m.Ctx, rpc.LatestBlockNumber, nil)
+ result, err := api.GetModifiedAccountsByNumber(h.m.Ctx, rpc.LatestBlockNumber, nil)
require.NoError(t, err)
require.NotEmpty(t, result)
}
func TestGetModifiedAccountsByNumber_UsesCommittedEndTag(t *testing.T) {
t.Parallel()
- base, m, _ := newOverlayAheadTestAPI(t)
- api := NewPrivateDebugAPI(base, m.DB, nil, &rpccfg.DebugApiConfig{})
+ h := newOverlayAheadHarness(t, false)
+ api := NewPrivateDebugAPI(h.base, h.m.DB, nil, &rpccfg.DebugApiConfig{})
latest := rpc.LatestBlockNumber
- result, err := api.GetModifiedAccountsByNumber(m.Ctx, rpc.EarliestBlockNumber, &latest)
+ result, err := api.GetModifiedAccountsByNumber(h.m.Ctx, rpc.EarliestBlockNumber, &latest)
require.NoError(t, err)
require.NotEmpty(t, result)
}
@@ -199,18 +277,18 @@ func TestGetModifiedAccountsByNumber_UsesCommittedEndTag(t *testing.T) {
// must reflect the overlay head, not the stale MDBX-committed head.
func TestGetTransactionByHash_PendingTx_UsesOverlayHead(t *testing.T) {
t.Parallel()
- base, m, overlayHeader := newOverlayAheadTestAPI(t)
+ h := newOverlayAheadHarness(t, false)
- pendingTxn := signOverlayRaceTestTx(t, m, 1)
+ pendingTxn := signOverlayRaceTestTx(t, h.m, 1)
pool := &overlayRaceTxPoolClient{
transactionsReply: &txpoolproto.TransactionsReply{RlpTxs: [][]byte{marshalOverlayRaceTestTx(t, pendingTxn)}},
}
- api := newEthApiForTest(base, m.DB, pool, nil)
+ api := newEthApiForTest(h.base, h.m.DB, pool, nil)
- got, err := api.GetTransactionByHash(m.Ctx, pendingTxn.Hash())
+ got, err := api.GetTransactionByHash(h.m.Ctx, pendingTxn.Hash())
require.NoError(t, err)
require.NotNil(t, got)
- require.Equal(t, overlayHeader.BaseFee.ToBig(), got.GasPrice.ToInt(),
+ require.Equal(t, h.overlayHeader.BaseFee.ToBig(), got.GasPrice.ToInt(),
"pending tx gas price must be derived from the overlay head's base fee, not the stale MDBX head")
}
@@ -233,15 +311,15 @@ func newOverlayRacePendingPool(t *testing.T, m *execmoduletester.ExecModuleTeste
// header through the block overlay, matching TestGetTransactionByHash_PendingTx_UsesOverlayHead.
func TestTxPoolContent_UsesOverlayHead(t *testing.T) {
t.Parallel()
- base, m, overlayHeader := newOverlayAheadTestAPI(t)
- pool, txn := newOverlayRacePendingPool(t, m)
- api := NewTxPoolAPI(base, m.DB, pool)
+ h := newOverlayAheadHarness(t, false)
+ pool, txn := newOverlayRacePendingPool(t, h.m)
+ api := NewTxPoolAPI(h.base, h.m.DB, pool)
- content, err := api.Content(m.Ctx)
+ content, err := api.Content(h.m.Ctx)
require.NoError(t, err)
- got := content["pending"][m.Address.Hex()][strconv.FormatUint(txn.GetNonce(), 10)]
+ got := content["pending"][h.m.Address.Hex()][strconv.FormatUint(txn.GetNonce(), 10)]
require.NotNil(t, got)
- require.Equal(t, overlayHeader.BaseFee.ToBig(), got.GasPrice.ToInt(),
+ require.Equal(t, h.overlayHeader.BaseFee.ToBig(), got.GasPrice.ToInt(),
"pending tx gas price must be derived from the overlay head's base fee, not the stale MDBX head")
}
@@ -250,10 +328,10 @@ func TestTxPoolContent_UsesOverlayHead(t *testing.T) {
// 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)
+ h := newOverlayAheadHarness(t, false)
+ api := newEthApiForTest(h.base, h.m.DB, nil, nil)
- _, err := api.GetLogs(m.Ctx, filters.FilterCriteria{FromBlock: big.NewInt(int64(rpc.LatestBlockNumber))})
+ _, err := api.GetLogs(h.m.Ctx, filters.FilterCriteria{FromBlock: big.NewInt(int64(rpc.LatestBlockNumber))})
require.NoError(t, err)
}
@@ -261,10 +339,10 @@ func TestGetLogs_UsesCommittedFromTag(t *testing.T) {
// TestGetLogs_UsesCommittedFromTag.
func TestGetLogs_UsesCommittedToTag(t *testing.T) {
t.Parallel()
- base, m, _ := newOverlayAheadTestAPI(t)
- api := newEthApiForTest(base, m.DB, nil, nil)
+ h := newOverlayAheadHarness(t, false)
+ api := newEthApiForTest(h.base, h.m.DB, nil, nil)
- _, err := api.GetLogs(m.Ctx, filters.FilterCriteria{
+ _, err := api.GetLogs(h.m.Ctx, filters.FilterCriteria{
FromBlock: big.NewInt(1),
ToBlock: big.NewInt(int64(rpc.LatestBlockNumber)),
})
@@ -277,8 +355,8 @@ func TestGetLogs_UsesCommittedToTag(t *testing.T) {
// executed head.
func TestTraceFilter_UsesCommittedFromTag(t *testing.T) {
t.Parallel()
- base, m, _ := newOverlayAheadTestAPI(t)
- api := NewTraceAPI(base, m.DB, &rpccfg.TraceApiConfig{})
+ h := newOverlayAheadHarness(t, false)
+ api := NewTraceAPI(h.base, h.m.DB, &rpccfg.TraceApiConfig{})
s := jsoniter.ConfigDefault.BorrowStream(nil)
defer jsoniter.ConfigDefault.ReturnStream(s)
@@ -286,7 +364,7 @@ func TestTraceFilter_UsesCommittedFromTag(t *testing.T) {
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)
+ err := api.Filter(h.m.Ctx, TraceFilterRequest{FromBlock: &from, ToBlock: &to}, new(bool), nil, stream)
require.NoError(t, err)
}
@@ -350,14 +428,612 @@ func TestGetModifiedAccountsByHash_FutureStartBlockErrors(t *testing.T) {
// current header through the block overlay, matching TestTxPoolContent_UsesOverlayHead.
func TestTxPoolContentFrom_UsesOverlayHead(t *testing.T) {
t.Parallel()
- base, m, overlayHeader := newOverlayAheadTestAPI(t)
- pool, txn := newOverlayRacePendingPool(t, m)
- api := NewTxPoolAPI(base, m.DB, pool)
+ h := newOverlayAheadHarness(t, false)
+ pool, txn := newOverlayRacePendingPool(t, h.m)
+ api := NewTxPoolAPI(h.base, h.m.DB, pool)
- content, err := api.ContentFrom(m.Ctx, m.Address)
+ content, err := api.ContentFrom(h.m.Ctx, h.m.Address)
require.NoError(t, err)
got := content["pending"][strconv.FormatUint(txn.GetNonce(), 10)]
require.NotNil(t, got)
- require.Equal(t, overlayHeader.BaseFee.ToBig(), got.GasPrice.ToInt(),
+ require.Equal(t, h.overlayHeader.BaseFee.ToBig(), got.GasPrice.ToInt(),
"pending tx gas price must be derived from the overlay head's base fee, not the stale MDBX head")
}
+
+// TestFeeHistory_SeesOverlayHead pins that eth_feeHistory resolves "latest" through the
+// block overlay: the gas oracle's head must be the in-flight block, so the window ends
+// there and oldestBlock is that block. Resolving on the committed view instead leaves the
+// whole window one block (or more, while a commit backlog drains) behind the head the node
+// publishes via eth_blockNumber.
+func TestFeeHistory_SeesOverlayHead(t *testing.T) {
+ t.Parallel()
+ h := newOverlayAheadHarness(t, false)
+ api := newEthApiForTest(h.base, h.m.DB, nil, nil)
+
+ got, err := api.FeeHistory(h.m.Ctx, 1, rpc.LatestBlockNumber, nil)
+ require.NoError(t, err)
+ require.NotNil(t, got)
+ require.Equal(t, h.overlayHeader.Number.ToBig(), got.OldestBlock.ToInt(),
+ "the fee history window must end on the overlay head, not the stale MDBX-committed head")
+ require.NotEmpty(t, got.BaseFee)
+ require.Equal(t, h.overlayHeader.BaseFee.ToBig(), got.BaseFee[0].ToInt(),
+ "the first base fee must come from the overlay head's header")
+}
+
+// TestFeeHistory_OverlayHeadWithRewards is TestFeeHistory_SeesOverlayHead with reward
+// percentiles requested, which makes the per-block fetch read the in-flight block and its
+// receipts instead of just the header — the path that runs on the tx opened by Fork.
+// The overlay block carries two txs with distinct tips: a low percentile lands on the
+// cheap tx only when the receipts' gas is actually read (zero-filled gas walks the
+// percentile cursor to the most expensive tx instead), so the assert pins the receipt
+// values, not just the window bounds.
+func TestFeeHistory_OverlayHeadWithRewards(t *testing.T) {
+ t.Parallel()
+ h := newOverlayAheadHarness(t, true)
+ api := newEthApiForTest(h.base, h.m.DB, nil, nil)
+
+ got, err := api.FeeHistory(h.m.Ctx, 1, rpc.LatestBlockNumber, []float64{10})
+ require.NoError(t, err)
+ require.NotNil(t, got)
+ require.Equal(t, h.overlayHeader.Number.ToBig(), got.OldestBlock.ToInt(),
+ "the fee history window must end on the overlay head, not the stale MDBX-committed head")
+ require.Len(t, got.Reward, 1)
+ require.Len(t, got.Reward[0], 1)
+ require.Equal(t, big.NewInt(overlayRaceLowTip), got.Reward[0][0].ToInt(),
+ "the 10th percentile must be the cheap tx's tip, weighted by the receipts' real gas")
+}
+
+// TestGasPriceOracle_ForkKeepsOverlayAfterUnpublish pins that Fork reuses the overlay
+// resolved when the backend was built: when the overlay is unpublished between the
+// request start and the fork (the commit window closing), the forked backend must
+// still serve the head the parent resolved instead of failing with block-not-found.
+// The SharedDomains is also closed, as production teardown does right after the
+// unpublish, so the test covers reads on a closed-but-pinned overlay.
+func TestGasPriceOracle_ForkKeepsOverlayAfterUnpublish(t *testing.T) {
+ t.Parallel()
+ h := newOverlayAheadHarness(t, false)
+ tx, err := h.m.DB.BeginTemporalRo(h.m.Ctx)
+ require.NoError(t, err)
+ defer tx.Rollback()
+ backend := NewGasPriceOracleBackend(h.m.DB, h.base.filters.WithTemporalOverlay(tx), h.base)
+
+ h.events.PublishOverlay(nil)
+ h.doms.Close()
+
+ forked, cleanup, err := backend.Fork(h.m.Ctx)
+ require.NoError(t, err)
+ require.NotNil(t, forked)
+ defer cleanup()
+
+ latest, err := forked.GetLatestBlockNumber()
+ require.NoError(t, err)
+ require.Equal(t, h.overlayHeader.Number.Uint64(), latest,
+ "the forked backend must keep resolving the overlay head the parent pinned")
+
+ got, err := forked.HeaderByNumber(h.m.Ctx, rpc.BlockNumber(h.overlayHeader.Number.Uint64()))
+ require.NoError(t, err)
+ require.NotNil(t, got)
+ require.Equal(t, h.overlayHeader.Hash(), got.Hash())
+}
+
+// publishOverlayHead publishes a fresh overlay whose forkchoice head is the
+// given header, with an empty body.
+func publishOverlayHead(t *testing.T, h *overlayAheadHarness, head *types.Header) {
+ t.Helper()
+ require.NoError(t, publishOverlayHeadE(h, head))
+}
+
+// publishOverlayHeadE is the hook-safe core: it reports errors instead of
+// calling testify's FailNow family, which is test-goroutine-only. t.Cleanup
+// is safe from any goroutine.
+func publishOverlayHeadE(h *overlayAheadHarness, head *types.Header) error {
+ ctx := h.m.Ctx
+ roTx, err := h.m.DB.BeginTemporalRo(ctx)
+ if err != nil {
+ return err
+ }
+ h.t.Cleanup(roTx.Rollback)
+ doms, err := execctx.NewSharedDomains(ctx, roTx, h.m.Log)
+ if err != nil {
+ return err
+ }
+ h.t.Cleanup(doms.Close)
+ if err := doms.InitBlockOverlay(roTx, h.m.Dirs.Tmp); err != nil {
+ return err
+ }
+ if err := writeHeadBlockMarkersE(doms.BlockOverlay(), head, &types.Body{}); err != nil {
+ return err
+ }
+ h.events.PublishOverlay(doms)
+ return nil
+}
+
+// publishSiblingOverlay publishes a second overlay holding a same-height
+// sibling of the harness's overlay head, with a different base fee so its
+// hash and header are distinguishable from the original.
+func publishSiblingOverlay(t *testing.T, h *overlayAheadHarness) *types.Header {
+ t.Helper()
+ sibling := siblingOfOverlayHead(h)
+ require.NotEqual(t, h.overlayHeader.Hash(), sibling.Hash())
+ publishOverlayHead(t, h, sibling)
+ return sibling
+}
+
+func siblingOfOverlayHead(h *overlayAheadHarness) *types.Header {
+ sibling := types.CopyHeader(h.overlayHeader)
+ sibling.BaseFee = uint256.NewInt(overlayRaceBaseFee + 1111)
+ return sibling
+}
+
+// publishCommittedSiblingOverlay publishes an overlay whose forkchoice head is
+// a same-height sibling of an already-committed block — an in-RAM reorg below
+// the committed head.
+func publishCommittedSiblingOverlay(t *testing.T, h *overlayAheadHarness, number uint64) *types.Header {
+ t.Helper()
+ roTx, err := h.m.DB.BeginTemporalRo(h.m.Ctx)
+ require.NoError(t, err)
+ defer roTx.Rollback()
+ committed := rawdb.ReadHeaderByNumber(roTx, number)
+ require.NotNil(t, committed)
+
+ sibling := types.CopyHeader(committed)
+ sibling.BaseFee = uint256.NewInt(overlayRaceBaseFee + 2222)
+ require.NotEqual(t, committed.Hash(), sibling.Hash())
+ publishOverlayHead(t, h, sibling)
+ return sibling
+}
+
+// TestGasPriceOracle_PinnedViewIgnoresLaterOverlayPublish pins that a backend
+// which resolved overlay A at construction keeps serving A's head even after a
+// different overlay B (a same-height sibling, as after an in-RAM reorg) is
+// published mid-request: downstream helpers must not layer the live overlay
+// over the already-pinned view.
+func TestGasPriceOracle_PinnedViewIgnoresLaterOverlayPublish(t *testing.T) {
+ t.Parallel()
+ h := newOverlayAheadHarness(t, false)
+ tx, err := h.m.DB.BeginTemporalRo(h.m.Ctx)
+ require.NoError(t, err)
+ defer tx.Rollback()
+ backend := NewGasPriceOracleBackend(h.m.DB, h.base.filters.WithTemporalOverlay(tx), h.base)
+
+ sibling := publishSiblingOverlay(t, h)
+
+ got, err := backend.HeaderByNumber(h.m.Ctx, rpc.LatestBlockNumber)
+ require.NoError(t, err)
+ require.NotNil(t, got)
+ require.NotEqual(t, sibling.Hash(), got.Hash(),
+ "the pinned request must not pick up the sibling head published after it started")
+ require.Equal(t, h.overlayHeader.Hash(), got.Hash(),
+ "the pinned request must keep serving the head it resolved at construction")
+}
+
+// TestFeeHistory_DeadOverlayBlockNotServedFromCache pins that fee data computed
+// for a not-yet-committed overlay block does not outlive that block: when a
+// same-height sibling replaces it (in-RAM reorg, or the commit failing), a new
+// request must serve the sibling's fees, not a memoized result keyed only by
+// block number.
+func TestFeeHistory_DeadOverlayBlockNotServedFromCache(t *testing.T) {
+ t.Parallel()
+ h := newOverlayAheadHarness(t, false)
+ api := newEthApiForTest(h.base, h.m.DB, nil, nil)
+
+ first, err := api.FeeHistory(h.m.Ctx, 1, rpc.LatestBlockNumber, nil)
+ require.NoError(t, err)
+ require.Equal(t, h.overlayHeader.BaseFee.ToBig(), first.BaseFee[0].ToInt())
+
+ sibling := publishSiblingOverlay(t, h)
+
+ second, err := api.FeeHistory(h.m.Ctx, 1, rpc.LatestBlockNumber, nil)
+ require.NoError(t, err)
+ require.Equal(t, sibling.Number.ToBig(), second.OldestBlock.ToInt())
+ require.Equal(t, sibling.BaseFee.ToBig(), second.BaseFee[0].ToInt(),
+ "fees cached for the dead overlay block must not be served for its same-height sibling")
+}
+
+// TestGasPriceOracle_ForkSharesCallerPinnedOverlay pins that when the caller
+// hands the backend a tx already pinned to an overlay (as FillTransaction
+// does), Fork wraps its fresh txs with that same overlay: re-capturing the
+// live one would let parent and fork resolve two different heads within one
+// oracle operation.
+func TestGasPriceOracle_ForkSharesCallerPinnedOverlay(t *testing.T) {
+ t.Parallel()
+ h := newOverlayAheadHarness(t, false)
+ tx, err := h.m.DB.BeginTemporalRo(h.m.Ctx)
+ require.NoError(t, err)
+ defer tx.Rollback()
+ pinnedTx := h.base.filters.WithTemporalOverlay(tx)
+
+ sibling := publishSiblingOverlay(t, h)
+
+ backend := NewGasPriceOracleBackend(h.m.DB, pinnedTx, h.base)
+ parentHead, err := backend.HeaderByNumber(h.m.Ctx, rpc.LatestBlockNumber)
+ require.NoError(t, err)
+ require.Equal(t, h.overlayHeader.Hash(), parentHead.Hash())
+
+ forked, cleanup, err := backend.Fork(h.m.Ctx)
+ require.NoError(t, err)
+ require.NotNil(t, forked)
+ defer cleanup()
+
+ forkHead, err := forked.HeaderByNumber(h.m.Ctx, rpc.LatestBlockNumber)
+ require.NoError(t, err)
+ require.NotEqual(t, sibling.Hash(), forkHead.Hash(),
+ "the fork must not re-capture the overlay published after the caller pinned its tx")
+ require.Equal(t, parentHead.Hash(), forkHead.Hash(),
+ "parent and fork must resolve the same pinned head")
+}
+
+// TestGasPriceOracle_NilOverlayPinIgnoresLaterPublish pins that a backend built
+// while no overlay was published keeps reading only committed data: an overlay
+// published mid-request (a same-height sibling of the committed head) must not
+// leak in through downstream helpers re-resolving the live overlay.
+func TestGasPriceOracle_NilOverlayPinIgnoresLaterPublish(t *testing.T) {
+ t.Parallel()
+ h := newOverlayAheadHarness(t, false)
+ commitOverlayBlock(t, h)
+ h.events.PublishOverlay(nil)
+ h.doms.Close()
+
+ tx, err := h.m.DB.BeginTemporalRo(h.m.Ctx)
+ require.NoError(t, err)
+ defer tx.Rollback()
+ backend := NewGasPriceOracleBackend(h.m.DB, rpchelper.PinToOverlay(tx, nil), h.base)
+
+ sibling := publishSiblingOverlay(t, h)
+
+ got, err := backend.HeaderByNumber(h.m.Ctx, rpc.LatestBlockNumber)
+ require.NoError(t, err)
+ require.NotNil(t, got)
+ require.NotEqual(t, sibling.Hash(), got.Hash(),
+ "the request pinned to no overlay must not pick up one published mid-request")
+ require.Equal(t, h.overlayHeader.Hash(), got.Hash(),
+ "the request must keep serving the committed head it resolved at construction")
+}
+
+// TestBeginTemporalRoWithOverlay_PreservesOptionalInterfaces pins that the
+// pinned handle keeps the tx-scoped block-files view and hands itself — not
+// the raw tx — to Apply callbacks, in both the overlay and no-overlay cases:
+// dropping either silently degrades every read (per-read view acquisition,
+// snapshot-merge straddling) or unpins downstream code.
+func TestBeginTemporalRoWithOverlay_PreservesOptionalInterfaces(t *testing.T) {
+ t.Parallel()
+ h := newOverlayAheadHarness(t, false)
+
+ check := func(t *testing.T) {
+ pinnedTx, err := h.base.filters.BeginTemporalRoWithOverlay(h.m.Ctx, h.m.DB)
+ require.NoError(t, err)
+ defer pinnedTx.Rollback()
+
+ bf, ok := pinnedTx.(membatchwithdb.HasBlockFilesRoTx)
+ require.True(t, ok, "the pinned handle must keep the tx-scoped block-files view")
+ require.NotNil(t, bf.BlockFilesRoTx())
+
+ require.NoError(t, pinnedTx.Apply(h.m.Ctx, func(inner kv.Tx) error {
+ require.True(t, membatchwithdb.CarriesOverlayView(inner),
+ "Apply must hand the pinned view to the callback, not the raw tx")
+ return nil
+ }))
+
+ require.NotPanics(t, func() { pinnedTx.FreezeInfo() },
+ "FreezeInfo must delegate to the raw tx, not promote the view's panic")
+ ut, ok := pinnedTx.(interface{ UnderlyingTx() kv.TemporalTx })
+ require.True(t, ok, "the pinned handle must not hide UnderlyingTx")
+ require.NotNil(t, ut.UnderlyingTx())
+ _, ok = pinnedTx.(interface{ Pin() kv.TemporalFilesPin })
+ require.True(t, ok, "the pinned handle must not hide Pin")
+ }
+
+ t.Run("overlay published", check)
+ h.events.PublishOverlay(nil)
+ h.doms.Close()
+ t.Run("no overlay", check)
+}
+
+// TestGasPriceOracleBackend_RequiresPinnedTx pins that the constructor rejects
+// an unpinned tx: resolving the overlay after the caller opened the tx would
+// re-open the torn (tx, overlay) window the pinned acquisition closes.
+func TestGasPriceOracleBackend_RequiresPinnedTx(t *testing.T) {
+ t.Parallel()
+ h := newOverlayAheadHarness(t, false)
+ tx, err := h.m.DB.BeginTemporalRo(h.m.Ctx)
+ require.NoError(t, err)
+ defer tx.Rollback()
+
+ require.Panics(t, func() { NewGasPriceOracleBackend(h.m.DB, tx, h.base) },
+ "an unpinned tx must be rejected, not silently re-pinned to the live overlay")
+}
+
+// remoteishBlockReader simulates the rpcdaemon RemoteBlockReader: CanonicalHash
+// ignores the caller's tx and resolves on the live view instead.
+type remoteishBlockReader struct {
+ dbservices.FullBlockReader
+ h *overlayAheadHarness
+}
+
+func (r *remoteishBlockReader) CanonicalHash(ctx context.Context, _ kv.Getter, blockNum uint64) (common.Hash, bool, error) {
+ tx, err := r.h.m.DB.BeginTemporalRo(ctx)
+ if err != nil {
+ return common.Hash{}, false, err
+ }
+ defer tx.Rollback()
+ return r.FullBlockReader.CanonicalHash(ctx, r.h.base.filters.WithTemporalOverlay(tx), blockNum)
+}
+
+// TestGasPriceOracle_CanonicalHashUsesPinnedView pins that the fee-history
+// cache key resolves on the pinned view even when the block reader ignores
+// the caller's tx (as the rpcdaemon RemoteBlockReader does): a sibling
+// published mid-request must not swap the hash under the pinned head.
+func TestGasPriceOracle_CanonicalHashUsesPinnedView(t *testing.T) {
+ t.Parallel()
+ h := newOverlayAheadHarness(t, false)
+ h.base._blockReader = &remoteishBlockReader{FullBlockReader: h.m.BlockReader, h: h}
+
+ tx, err := h.m.DB.BeginTemporalRo(h.m.Ctx)
+ require.NoError(t, err)
+ defer tx.Rollback()
+ backend := NewGasPriceOracleBackend(h.m.DB, h.base.filters.WithTemporalOverlay(tx), h.base)
+
+ sibling := publishSiblingOverlay(t, h)
+
+ overlayNumber := h.overlayHeader.Number.Uint64()
+ hashes, err := backend.CanonicalHashes(h.m.Ctx, overlayNumber, overlayNumber)
+ require.NoError(t, err)
+ require.Len(t, hashes, 1)
+ require.NotEqual(t, sibling.Hash(), hashes[0],
+ "the cache key must not come from the live view the reader resolves on")
+ require.Equal(t, h.overlayHeader.Hash(), hashes[0],
+ "the cache key must resolve on the pinned view")
+}
+
+// TestFeeHistory_ReorgedCommittedBlockNotServedFromCache pins that fee data
+// memoized for a committed block stops being served once an overlay reorg
+// replaces that height: a block number alone does not identify a block.
+func TestFeeHistory_ReorgedCommittedBlockNotServedFromCache(t *testing.T) {
+ t.Parallel()
+ h := newOverlayAheadHarness(t, false)
+ api := newEthApiForTest(h.base, h.m.DB, nil, nil)
+
+ first, err := api.FeeHistory(h.m.Ctx, 2, rpc.LatestBlockNumber, nil)
+ require.NoError(t, err)
+ require.Len(t, first.BaseFee, 3)
+
+ sibling := publishCommittedSiblingOverlay(t, h, overlayRaceChainSize)
+
+ second, err := api.FeeHistory(h.m.Ctx, 1, rpc.LatestBlockNumber, nil)
+ require.NoError(t, err)
+ require.Equal(t, sibling.Number.ToBig(), second.OldestBlock.ToInt())
+ require.Equal(t, sibling.BaseFee.ToBig(), second.BaseFee[0].ToInt(),
+ "fees cached for the reorged-out committed block must not be served for its same-height sibling")
+}
+
+// commitOverlayBlock writes the harness's overlay head into MDBX the way the
+// background commit would, so txs opened afterwards resolve it as the
+// committed head.
+func commitOverlayBlock(t *testing.T, h *overlayAheadHarness) {
+ t.Helper()
+ require.NoError(t, commitOverlayBlockE(h))
+}
+
+// commitOverlayBlockE writes head markers plus the Execution stage progress
+// and the canonical TxNums entry production advances with them: without those
+// two, state readers reject the new head.
+func commitOverlayBlockE(h *overlayAheadHarness) error {
+ rwTx, err := h.m.DB.BeginRw(h.m.Ctx)
+ if err != nil {
+ return err
+ }
+ defer rwTx.Rollback()
+ if err := writeHeadBlockMarkersE(rwTx, h.overlayHeader, &types.Body{}); err != nil {
+ return err
+ }
+ num := h.overlayHeader.Number.Uint64()
+ if err := stages.SaveStageProgress(rwTx, stages.Execution, num); err != nil {
+ return err
+ }
+ if err := rawdb.AppendCanonicalTxNums(rwTx, num); err != nil {
+ return err
+ }
+ return rwTx.Commit()
+}
+
+// beginHookDB runs a hook right after each BeginTemporalRo returns, simulating
+// commit/publish activity landing while a request acquires its tx. Hooks also
+// run on the oracle's errgroup goroutines (via Fork), so they must not use
+// testify's FailNow family; failures are reported with the goroutine-safe
+// t.Errorf.
+type beginHookDB struct {
+ kv.TemporalRoDB
+ t *testing.T
+ hook func() error
+}
+
+func (db *beginHookDB) BeginTemporalRo(ctx context.Context) (kv.TemporalTx, error) {
+ tx, err := db.TemporalRoDB.BeginTemporalRo(ctx) //nolint:gocritic
+ if err != nil {
+ return nil, err
+ }
+ if hookErr := db.hook(); hookErr != nil {
+ db.t.Errorf("begin hook: %v", hookErr)
+ }
+ return tx, nil
+}
+
+// newCycleHookDB returns a DB whose first BeginTemporalRo runs the
+// publish/commit/unpublish cycle (optionally skipping the initial publish):
+// the window closing while a request acquires its tx.
+func newCycleHookDB(h *overlayAheadHarness, publishFirst bool) *beginHookDB {
+ return &beginHookDB{TemporalRoDB: h.m.DB, t: h.t, hook: sync.OnceValue(func() error {
+ if publishFirst {
+ if err := publishOverlayHeadE(h, h.overlayHeader); err != nil {
+ return err
+ }
+ }
+ if err := commitOverlayBlockE(h); err != nil {
+ return err
+ }
+ h.events.PublishOverlay(nil)
+ return nil
+ })}
+}
+
+// TestFeeHistory_PublishCycleDuringTxAcquisition pins that overlay-capture
+// stability is tracked with a publish sequence number: a full publish/commit/
+// unpublish cycle landing during tx acquisition leaves the overlay nil on both
+// sides of the open, so pointer identity alone would accept a tx snapshot that
+// predates the commit and hides the head block.
+func TestFeeHistory_PublishCycleDuringTxAcquisition(t *testing.T) {
+ t.Parallel()
+ h := newOverlayAheadHarness(t, false)
+ h.events.PublishOverlay(nil)
+ h.doms.Close()
+
+ db := newCycleHookDB(h, true)
+ api := newEthApiForTest(h.base, db, nil, nil)
+
+ got, err := api.FeeHistory(h.m.Ctx, 1, rpc.LatestBlockNumber, nil)
+ require.NoError(t, err)
+ require.Equal(t, h.overlayHeader.Number.ToBig(), got.OldestBlock.ToInt(),
+ "a publish/commit/unpublish cycle during tx acquisition must not hide the head block")
+ require.Equal(t, h.overlayHeader.BaseFee.ToBig(), got.BaseFee[0].ToInt())
+}
+
+// TestFeeHistory_OverlayUnstableDuringTxAcquisition pins that when the overlay
+// keeps changing across every acquisition attempt (a fresh sibling published
+// on every open), the request still serves the last capture as one pinned
+// view instead of failing: under FCU churn a slightly stale answer beats a
+// client-visible error.
+func TestFeeHistory_OverlayUnstableDuringTxAcquisition(t *testing.T) {
+ t.Parallel()
+ h := newOverlayAheadHarness(t, false)
+ db := &beginHookDB{TemporalRoDB: h.m.DB, t: h.t, hook: func() error { return publishOverlayHeadE(h, siblingOfOverlayHead(h)) }}
+ api := newEthApiForTest(h.base, db, nil, nil)
+
+ got, err := api.FeeHistory(h.m.Ctx, 1, rpc.LatestBlockNumber, nil)
+ require.NoError(t, err,
+ "a request whose overlay capture never stabilizes must serve the last capture, not fail")
+ require.Equal(t, h.overlayHeader.Number.ToBig(), got.OldestBlock.ToInt())
+ require.Equal(t, big.NewInt(overlayRaceBaseFee+1111), got.BaseFee[0].ToInt(),
+ "the window must come from one pinned sibling view")
+}
+
+// publishSiblingOnGetCache publishes the sibling overlay the first time the
+// gas price oracle consults its cache — after the request has pinned its
+// overlay, before the baseFee addend is read.
+type publishSiblingOnGetCache struct {
+ inner gasprice.Cache
+ t *testing.T
+ h *overlayAheadHarness
+ once sync.Once
+}
+
+func (c *publishSiblingOnGetCache) GetLatest() (common.Hash, *uint256.Int) {
+ c.once.Do(func() { publishSiblingOverlay(c.t, c.h) })
+ return c.inner.GetLatest()
+}
+
+func (c *publishSiblingOnGetCache) SetLatest(hash common.Hash, price *uint256.Int) {
+ c.inner.SetLatest(hash, price)
+}
+
+// TestGasPrice_BaseFeeFromPinnedOverlay pins that eth_gasPrice derives its
+// baseFee addend from the same overlay the tip was sampled on: an overlay
+// published mid-request (a same-height sibling with a different base fee)
+// must not leak into the sum.
+func TestGasPrice_BaseFeeFromPinnedOverlay(t *testing.T) {
+ t.Parallel()
+ h := newOverlayAheadHarness(t, false)
+ api := newEthApiForTest(h.base, h.m.DB, nil, nil)
+
+ tip, err := api.MaxPriorityFeePerGas(h.m.Ctx)
+ require.NoError(t, err)
+
+ api.gasCache = &publishSiblingOnGetCache{inner: api.gasCache, t: t, h: h}
+
+ got, err := api.GasPrice(h.m.Ctx)
+ require.NoError(t, err)
+ want := new(big.Int).Add(tip.ToInt(), h.overlayHeader.BaseFee.ToBig())
+ require.Equal(t, want, got.ToInt(),
+ "the baseFee addend must come from the pinned overlay head, not one published mid-request")
+}
+
+// TestBlockNumber_PublishCycleDuringTxAcquisition pins that eth_blockNumber
+// acquires (tx, overlay) atomically like the fee endpoints: a publish/commit/
+// unpublish cycle landing during the open must not hide the head block.
+func TestBlockNumber_PublishCycleDuringTxAcquisition(t *testing.T) {
+ t.Parallel()
+ h := newOverlayAheadHarness(t, false)
+ h.events.PublishOverlay(nil)
+ h.doms.Close()
+
+ db := newCycleHookDB(h, true)
+ api := newEthApiForTest(h.base, db, nil, nil)
+
+ got, err := api.BlockNumber(h.m.Ctx)
+ require.NoError(t, err)
+ require.Equal(t, hexutil.Uint64(h.overlayHeader.Number.Uint64()), got,
+ "a publish/commit/unpublish cycle during tx acquisition must not hide the head block")
+}
+
+// TestBaseFee_PublishCycleDuringTxAcquisition pins the same atomic acquisition
+// for eth_baseFee: the next-block base fee must derive from the real head.
+func TestBaseFee_PublishCycleDuringTxAcquisition(t *testing.T) {
+ t.Parallel()
+ h := newOverlayAheadHarness(t, false)
+ h.events.PublishOverlay(nil)
+ h.doms.Close()
+
+ db := newCycleHookDB(h, true)
+ api := newEthApiForTest(h.base, db, nil, nil)
+
+ got, err := api.BaseFee(h.m.Ctx)
+ require.NoError(t, err)
+ // the overlay head's GasUsed sits exactly on target, so the next base fee
+ // equals its own — the deterministic fingerprint for "derived from head 6"
+ require.Equal(t, h.overlayHeader.BaseFee.ToBig(), got.ToInt(),
+ "the next-block base fee must derive from the head the cycle committed")
+}
+
+// TestFillTransaction_PublishCycleDuringTxAcquisition pins that fee defaults
+// price on the head resolved by an atomic acquisition: maxFeePerGas embeds
+// 2×baseFee of the head, so a publish/commit/unpublish cycle landing during
+// the open must not leave it derived from the previous head.
+func TestFillTransaction_PublishCycleDuringTxAcquisition(t *testing.T) {
+ t.Parallel()
+ h := newOverlayAheadHarness(t, false)
+ h.events.PublishOverlay(nil)
+ h.doms.Close()
+
+ db := newCycleHookDB(h, true)
+ api := newEthApiForTest(h.base, db, stubTxPoolClient{}, nil)
+
+ to := common.HexToAddress("0x0d3ab14bbad3d99f4203bd7a11acb94882050e7e")
+ gas := hexutil.Uint64(21000)
+ nonce := hexutil.Uint64(0)
+ result, err := api.FillTransaction(h.m.Ctx, ethapi.CallArgs{From: &h.m.Address, To: &to, Gas: &gas, Nonce: &nonce})
+ require.NoError(t, err)
+ require.NotNil(t, result.Tx.MaxFeePerGas)
+ require.NotNil(t, result.Tx.MaxPriorityFeePerGas)
+
+ baseFeeComponent := new(big.Int).Sub(result.Tx.MaxFeePerGas.ToInt(), result.Tx.MaxPriorityFeePerGas.ToInt())
+ require.Equal(t, new(big.Int).Lsh(h.overlayHeader.BaseFee.ToBig(), 1), baseFeeComponent,
+ "maxFeePerGas must embed 2×baseFee of the head the cycle committed")
+}
+
+// TestFeeHistory_HeadCommittedDuringTxAcquisition pins that the overlay must
+// be captured atomically with the tx: when the commit lands and the overlay is
+// unpublished between the tx open and the overlay capture, the request would
+// otherwise see neither layer and serve a head one block behind the one the
+// node already published via eth_blockNumber.
+func TestFeeHistory_HeadCommittedDuringTxAcquisition(t *testing.T) {
+ t.Parallel()
+ h := newOverlayAheadHarness(t, false)
+ db := newCycleHookDB(h, false)
+ api := newEthApiForTest(h.base, db, nil, nil)
+
+ got, err := api.FeeHistory(h.m.Ctx, 1, rpc.LatestBlockNumber, nil)
+ require.NoError(t, err)
+ require.NotNil(t, got)
+ require.Equal(t, h.overlayHeader.Number.ToBig(), got.OldestBlock.ToInt(),
+ "a commit and unpublish landing during tx acquisition must not hide the head block")
+ require.Equal(t, h.overlayHeader.BaseFee.ToBig(), got.BaseFee[0].ToInt())
+}
diff --git a/rpc/rpchelper/filters.go b/rpc/rpchelper/filters.go
index 4645e6661f9..5e7dbe3d70d 100644
--- a/rpc/rpchelper/filters.go
+++ b/rpc/rpchelper/filters.go
@@ -36,6 +36,7 @@ import (
"github.com/erigontech/erigon/common/concurrent"
"github.com/erigontech/erigon/common/log/v3"
"github.com/erigontech/erigon/db/kv"
+ "github.com/erigontech/erigon/db/kv/membatchwithdb"
"github.com/erigontech/erigon/db/state/execctx"
"github.com/erigontech/erigon/execution/rlp"
"github.com/erigontech/erigon/execution/types"
@@ -1172,36 +1173,69 @@ func (ff *Filters) LatestSD() *execctx.SharedDomains {
// 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.
+// for data not in the overlay. A tx that is already an overlay view is
+// returned unchanged (see membatchwithdb.CarriesOverlayView).
// Safe to call on a nil receiver.
func (ff *Filters) WithOverlay(tx kv.Tx) kv.Tx {
- if ff == nil {
+ if membatchwithdb.CarriesOverlayView(tx) {
return tx
}
- sd := ff.LatestSD()
- if sd == nil {
- return tx
- }
- if overlay := sd.BlockOverlay(); overlay != nil {
+ if overlay := ff.LatestOverlay(); overlay != nil {
return overlay.NewReadView(tx)
}
return 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 {
- return tx
+// OverlaySnapshot returns the published block overlay together with its
+// publish sequence number as one coherent pair, or (nil, 0) in remote mode
+// where no overlay is ever published.
+func (ff *Filters) OverlaySnapshot() (*membatchwithdb.MemoryMutation, uint64) {
+ if ff == nil || ff.events == nil {
+ return nil, 0
}
- sd := ff.LatestSD()
+ sd, seq := ff.events.OverlaySnapshot()
if sd == nil {
- return tx
+ return nil, seq
}
- if overlay := sd.BlockOverlay(); overlay != nil {
- return overlay.NewReadView(tx)
+ return sd.BlockOverlay(), seq
+}
+
+// BeginTemporalRoWithOverlay opens a read tx and pins it to the block overlay
+// published at that moment, as one consistent pair: a commit or (un)publish
+// landing between the overlay capture and the tx open can leave a head block
+// visible in neither layer, so the tx is reopened whenever the publish
+// sequence number moves around the open. Under sustained publish churn the
+// last capture is served anyway — a slightly stale pinned view beats a
+// client-visible error. The returned handle reads through the pinned view
+// and its Rollback releases the underlying tx.
+func (ff *Filters) BeginTemporalRoWithOverlay(ctx context.Context, db kv.TemporalRoDB) (kv.TemporalTx, error) {
+ const maxAttempts = 3
+ for attempt := 1; ; attempt++ {
+ overlay, seq := ff.OverlaySnapshot()
+ tx, err := db.BeginTemporalRo(ctx) //nolint:gocritic
+ if err != nil {
+ return nil, err
+ }
+ if _, current := ff.OverlaySnapshot(); current == seq || attempt == maxAttempts {
+ return PinToOverlay(tx, overlay), nil
+ }
+ tx.Rollback()
}
- return tx
+}
+
+// LatestOverlay returns the block overlay behind the latest published SD, or nil.
+// Callers that must keep serving one consistent head across several txs (e.g. a
+// forked backend) pin this instance instead of re-resolving, which could observe
+// the overlay being unpublished mid-request.
+func (ff *Filters) LatestOverlay() *membatchwithdb.MemoryMutation {
+ overlay, _ := ff.OverlaySnapshot()
+ return overlay
+}
+
+// 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 {
+ return ff.WithOverlay(tx).(kv.TemporalTx)
}
func (ff *Filters) incrementMetrics(ft FilterType, protocol SubProtocol) {
diff --git a/rpc/rpchelper/pinned_tx.go b/rpc/rpchelper/pinned_tx.go
new file mode 100644
index 00000000000..ca23d3d05fa
--- /dev/null
+++ b/rpc/rpchelper/pinned_tx.go
@@ -0,0 +1,96 @@
+// 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 rpchelper
+
+import (
+ "context"
+
+ "github.com/erigontech/erigon/db/kv"
+ "github.com/erigontech/erigon/db/kv/membatchwithdb"
+ "github.com/erigontech/erigon/db/snapshotsync/blocksnapshots"
+)
+
+// PinnedRoTx pins a read tx to the overlay resolution made at acquisition:
+// reads go through the pinned view (or the raw tx when no overlay was
+// published), and Rollback releases the raw tx — a view does not own the
+// underlying resources, so this is the one handle callers need.
+type PinnedRoTx struct {
+ kv.TemporalTx // the pinned read view, or the raw tx when overlay is nil
+ raw kv.TemporalTx
+ overlay *membatchwithdb.MemoryMutation
+}
+
+// PinToOverlay pins tx to the given overlay for the rest of the request: a
+// read view when overlay is non-nil, an explicit no-overlay pin otherwise, so
+// an overlay published mid-request cannot leak in through downstream wrap
+// points. A tx already carrying a pinned view is returned unchanged.
+func PinToOverlay(tx kv.TemporalTx, overlay *membatchwithdb.MemoryMutation) kv.TemporalTx {
+ if membatchwithdb.CarriesOverlayView(tx) {
+ return tx
+ }
+ view := tx
+ if overlay != nil {
+ view = overlay.NewReadView(tx)
+ }
+ return &PinnedRoTx{TemporalTx: view, raw: tx, overlay: overlay}
+}
+
+func (t *PinnedRoTx) Rollback() {
+ t.raw.Rollback()
+}
+
+// OverlayView implements membatchwithdb.OverlayViewCarrier.
+func (t *PinnedRoTx) OverlayView() (*membatchwithdb.MemoryMutation, bool) {
+ return t.overlay, true
+}
+
+// BlockFilesRoTx keeps the tx-scoped block-files view across the pin, so a
+// request cannot straddle a snapshot merge.
+func (t *PinnedRoTx) BlockFilesRoTx() *blocksnapshots.View {
+ if p, ok := t.TemporalTx.(membatchwithdb.HasBlockFilesRoTx); ok {
+ return p.BlockFilesRoTx()
+ }
+ return nil
+}
+
+// Apply goes through the raw tx's guard (closed-tx checks) but hands the
+// pinned handle to the callback, not the raw tx.
+func (t *PinnedRoTx) Apply(ctx context.Context, f func(tx kv.Tx) error) error {
+ return t.raw.Apply(ctx, func(kv.Tx) error { return f(t) })
+}
+
+// FreezeInfo delegates to the raw tx: the overlay read view in between does
+// not support it.
+func (t *PinnedRoTx) FreezeInfo() kv.FreezeInfo {
+ return t.raw.FreezeInfo()
+}
+
+// UnderlyingTx exposes the fallback tx like the wrapped view would.
+func (t *PinnedRoTx) UnderlyingTx() kv.TemporalTx {
+ if p, ok := t.TemporalTx.(interface{ UnderlyingTx() kv.TemporalTx }); ok {
+ return p.UnderlyingTx()
+ }
+ return t.raw
+}
+
+// Pin forwards the files-pin capability of the wrapped view (or the raw tx).
+func (t *PinnedRoTx) Pin() kv.TemporalFilesPin {
+ if p, ok := t.TemporalTx.(interface{ Pin() kv.TemporalFilesPin }); ok {
+ return p.Pin()
+ }
+ return nil
+}