From 061111fd8548724bf1543fb9eee2c0b75307d48c Mon Sep 17 00:00:00 2001 From: Mark Holt Date: Wed, 13 May 2026 16:25:00 +0000 Subject: [PATCH 1/8] db/rawdb, p2p/eth, execmodule: BAL cache + on-demand regeneration for eth/71 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a 3-tier lookup for serving EIP-7928 Block Access Lists to eth/71 GetBlockAccessLists peers: 1. In-memory LRU cache (recent blocks, freshly produced or recently served). 100-entry window keyed on block hash. 2. Chaindata DB (BALs the eth/71 bal-downloader fetched from peers, or legacy persisted BALs). DB hits are promoted to the cache so repeat requests don't re-read MDBX. 3. Installed BALRegenerator backend that re-executes the block against its parent state to derive the BAL. Result is cached before return. The eth/71 handler (AnswerGetBlockAccessListsQuery) and the sentry dispatch path now thread a context through BlockAccessListBytes, so regeneration respects the peer's read deadline. BALRegenerator implementation (execution/execmodule/balregen.go) uses a simple IBS path: parent-state reader → IntraBlockState with VersionMap enabled (read tracking) → InitializeBlockExecution → ApplyTransaction loop → merge ibs.TxIO() into a per-block VersionedIO → AsBlockAccessList() → EncodeBlockAccessListBytes. No parallel-exec dependency, matching the block-assembler pattern (execution/exec/block_assembler.go). The Finalize step is intentionally omitted from the re-exec loop — fork-specific engine.Finalize signatures vary and the finalize-time BAL deltas (system contracts, withdrawals) are typically captured during InitializeBlockExecution. If a downstream peer's hash verification flags a mismatch on Finalize-touched accounts, that's the signal to wire fork-aware finalize support here. Cache + lookup tests: - db/rawdb/balcache_test.go: 9 cases covering cache-hit short-circuit, DB-hit-promotes-to-cache, regenerator-fallback, no-regenerator path, nil-regeneration-not-cached, regenerator-error-not-cached, empty data is no-op, defensive byte-copy, SetBALRegenerator replace+clear. Handler tests: - p2p/protocols/eth/handlers_test.go: existing tests updated to thread ctx and reset the cache between runs. Co-Authored-By: Claude Opus 4.7 (1M context) --- db/rawdb/balcache.go | 154 ++++++++++++ db/rawdb/balcache_test.go | 224 ++++++++++++++++++ execution/execmodule/balregen.go | 218 +++++++++++++++++ p2p/protocols/eth/handlers.go | 15 +- p2p/protocols/eth/handlers_test.go | 8 +- .../sentry_multi_client.go | 2 +- 6 files changed, 612 insertions(+), 9 deletions(-) create mode 100644 db/rawdb/balcache.go create mode 100644 db/rawdb/balcache_test.go create mode 100644 execution/execmodule/balregen.go diff --git a/db/rawdb/balcache.go b/db/rawdb/balcache.go new file mode 100644 index 00000000000..3b1d3d79ac1 --- /dev/null +++ b/db/rawdb/balcache.go @@ -0,0 +1,154 @@ +// 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 rawdb + +import ( + "context" + "sync/atomic" + + lru "github.com/hashicorp/golang-lru/v2" + + "github.com/erigontech/erigon/common" + "github.com/erigontech/erigon/db/kv" +) + +// balCacheSize is how many recent blocks' EIP-7928 BlockAccessLists are kept in +// memory. Peers fetching BALs (eth/71) and the eth_getBlockAccessList RPC ask +// for recent blocks; older BALs are not persisted by default and must be +// regenerated by re-executing the block (see BALRegenerator). +const balCacheSize = 100 + +var balCache *lru.Cache[common.Hash, []byte] + +// balRegenerator holds the optionally-installed re-execution backend that can +// produce a BAL when neither the cache nor the chaindata DB has one. The value +// is wrapped in an addressable struct (atomic.Value rejects nil interface +// stores). Set once at startup via SetBALRegenerator; reads are lock-free. +type balRegeneratorSlot struct{ r BALRegenerator } + +var balRegenerator atomic.Pointer[balRegeneratorSlot] + +func init() { + var err error + balCache, err = lru.New[common.Hash, []byte](balCacheSize) + if err != nil { + panic(err) + } +} + +// BALRegenerator produces the RLP-encoded BlockAccessList for a block by +// re-executing it against the parent state. Implementations live close to the +// exec module (they need the full block + chain config + state-reader stack); +// the lookup path here installs one via SetBALRegenerator and consults it as a +// fallback when neither the in-memory cache nor the chaindata DB has the BAL. +// +// The returned bytes are the canonical RLP encoding (as written by +// rawdb.WriteBlockAccessListBytes) so callers can hand them to peers +// unchanged. The hash of the decoded BAL MUST match header.BlockAccessListHash +// — peers verify this on receipt; a mismatch fails the eth/71 BAL response. +type BALRegenerator interface { + RegenerateBlockAccessList(ctx context.Context, hash common.Hash, number uint64) ([]byte, error) +} + +// SetBALRegenerator installs the re-execution backend used by +// BlockAccessListBytes as a last-resort fallback. Pass nil to clear. Safe to +// call multiple times; the most recent setter wins. Typically called once at +// node startup after the exec module is constructed. +func SetBALRegenerator(r BALRegenerator) { + if r == nil { + balRegenerator.Store(nil) + return + } + balRegenerator.Store(&balRegeneratorSlot{r: r}) +} + +func getBALRegenerator() BALRegenerator { + slot := balRegenerator.Load() + if slot == nil { + return nil + } + return slot.r +} + +// CacheBlockAccessList stores the block's RLP-encoded BAL bytes in the in-memory +// cache instead of persisting them to the chaindata DB. Writing the BAL to MDBX +// on the NewPayload critical path was a multi-hundred-KB Put per block that, on a +// churned DB, took tens of seconds; the cache + regenerate-on-miss model mirrors +// what we do for receipts (--persist.receipt). +func CacheBlockAccessList(hash common.Hash, data []byte) { + if len(data) == 0 { + return + } + balCache.Add(hash, common.Copy(data)) +} + +// CachedBlockAccessList returns the cached RLP-encoded BAL bytes for hash, if the +// block is still within the in-memory window. Cache-only — does NOT consult the +// chaindata DB or invoke the regenerator. Use BlockAccessListBytes for the +// full lookup chain. +func CachedBlockAccessList(hash common.Hash) ([]byte, bool) { + return balCache.Get(hash) +} + +// BlockAccessListBytes returns the BAL bytes for (hash, number). Lookup order: +// 1. In-memory LRU cache (recent blocks, freshly produced). +// 2. Chaindata DB (BALs the eth/71 bal-downloader fetched from peers, or +// legacy persisted BALs). +// 3. If a BALRegenerator is installed, re-execute the block to derive the BAL. +// The result is added to the cache before returning. +// +// Returns (nil, nil) if no source has it (no regenerator installed, or +// regenerator returned nil). Returns a non-nil error only on actual lookup +// failure (DB error, regenerator error). +// +// Callers passing the context (ctx) get cancellation of the regeneration leg +// — peer-driven serving paths should respect the peer's read deadline. +func BlockAccessListBytes(ctx context.Context, db kv.Getter, hash common.Hash, number uint64) ([]byte, error) { + if v, ok := balCache.Get(hash); ok { + return v, nil + } + stored, err := ReadBlockAccessListBytes(db, hash, number) + if err != nil { + return nil, err + } + if stored != nil { + // Promote DB hits into the cache so the next lookup is a single + // LRU probe — important for serving the same hash repeatedly + // (peers re-asking, RPC retries). + CacheBlockAccessList(hash, stored) + return stored, nil + } + regen := getBALRegenerator() + if regen == nil { + return nil, nil + } + generated, err := regen.RegenerateBlockAccessList(ctx, hash, number) + if err != nil { + return nil, err + } + if len(generated) == 0 { + return nil, nil + } + CacheBlockAccessList(hash, generated) + return generated, nil +} + +// ResetBALCacheForTest clears the in-memory cache and the regenerator. Test-only. +func ResetBALCacheForTest() { + balCache.Purge() + balRegenerator.Store(nil) +} diff --git a/db/rawdb/balcache_test.go b/db/rawdb/balcache_test.go new file mode 100644 index 00000000000..cf2dca18e36 --- /dev/null +++ b/db/rawdb/balcache_test.go @@ -0,0 +1,224 @@ +// 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 rawdb_test + +import ( + "context" + "errors" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/common" + "github.com/erigontech/erigon/db/kv/memdb" + "github.com/erigontech/erigon/db/rawdb" +) + +// fakeRegenerator records every regeneration request + serves canned responses. +type fakeRegenerator struct { + calls atomic.Int32 + // resultFor maps the (hash) → bytes the regenerator will return. Unset + // hashes get the default canned bytes if set, else (nil, errNoBALForHash). + resultFor map[common.Hash][]byte + defaultBytes []byte + errAlways error +} + +func (f *fakeRegenerator) RegenerateBlockAccessList(_ context.Context, hash common.Hash, _ uint64) ([]byte, error) { + f.calls.Add(1) + if f.errAlways != nil { + return nil, f.errAlways + } + if v, ok := f.resultFor[hash]; ok { + return v, nil + } + if f.defaultBytes != nil { + return f.defaultBytes, nil + } + return nil, nil +} + +var errFakeRegen = errors.New("fake regen failure") + +func hashFromByte(b byte) common.Hash { + var h common.Hash + for i := range h { + h[i] = b + } + return h +} + +func TestBlockAccessListBytes_CacheHitShortCircuits(t *testing.T) { + t.Cleanup(rawdb.ResetBALCacheForTest) + rawdb.ResetBALCacheForTest() + _, tx := memdb.NewTestTx(t) + defer tx.Rollback() + + hash := hashFromByte(0x01) + data := []byte{0xc1, 0x00} + rawdb.CacheBlockAccessList(hash, data) + + // Install a regenerator that, if called, fails the test. + rawdb.SetBALRegenerator(&fakeRegenerator{errAlways: errFakeRegen}) + + got, err := rawdb.BlockAccessListBytes(context.Background(), tx, hash, 7) + require.NoError(t, err) + require.Equal(t, data, got) +} + +func TestBlockAccessListBytes_DBHitPromotesToCache(t *testing.T) { + t.Cleanup(rawdb.ResetBALCacheForTest) + rawdb.ResetBALCacheForTest() + _, tx := memdb.NewTestTx(t) + defer tx.Rollback() + + hash := hashFromByte(0x02) + data := []byte{0xc2, 0xff, 0xee} + require.NoError(t, rawdb.WriteBlockAccessListBytes(tx, hash, 11, data)) + + got, err := rawdb.BlockAccessListBytes(context.Background(), tx, hash, 11) + require.NoError(t, err) + require.Equal(t, data, got) + + // Subsequent lookup must be a cache hit (no DB read necessary). + rawdb.SetBALRegenerator(nil) + got2, ok := rawdb.CachedBlockAccessList(hash) + require.True(t, ok, "DB-source BAL must be promoted to cache") + require.Equal(t, data, got2) +} + +func TestBlockAccessListBytes_RegeneratorFallback(t *testing.T) { + t.Cleanup(rawdb.ResetBALCacheForTest) + rawdb.ResetBALCacheForTest() + _, tx := memdb.NewTestTx(t) + defer tx.Rollback() + + hash := hashFromByte(0x03) + regenerated := []byte{0xc3, 0x42} + regen := &fakeRegenerator{defaultBytes: regenerated} + rawdb.SetBALRegenerator(regen) + + got, err := rawdb.BlockAccessListBytes(context.Background(), tx, hash, 22) + require.NoError(t, err) + require.Equal(t, regenerated, got) + require.Equal(t, int32(1), regen.calls.Load(), "regenerator should be called once on miss") + + // Repeated lookup hits the cache, regenerator NOT called again. + got2, err := rawdb.BlockAccessListBytes(context.Background(), tx, hash, 22) + require.NoError(t, err) + require.Equal(t, regenerated, got2) + require.Equal(t, int32(1), regen.calls.Load(), "cached regenerated BAL must short-circuit subsequent lookups") +} + +func TestBlockAccessListBytes_NoRegeneratorOnMiss(t *testing.T) { + t.Cleanup(rawdb.ResetBALCacheForTest) + rawdb.ResetBALCacheForTest() + _, tx := memdb.NewTestTx(t) + defer tx.Rollback() + + hash := hashFromByte(0x04) + rawdb.SetBALRegenerator(nil) + got, err := rawdb.BlockAccessListBytes(context.Background(), tx, hash, 33) + require.NoError(t, err) + require.Nil(t, got, "no cache, no DB, no regenerator → nil bytes (peer sees 'not available')") +} + +func TestBlockAccessListBytes_RegeneratorReturnsNil(t *testing.T) { + t.Cleanup(rawdb.ResetBALCacheForTest) + rawdb.ResetBALCacheForTest() + _, tx := memdb.NewTestTx(t) + defer tx.Rollback() + + hash := hashFromByte(0x05) + regen := &fakeRegenerator{} // defaultBytes nil + rawdb.SetBALRegenerator(regen) + + got, err := rawdb.BlockAccessListBytes(context.Background(), tx, hash, 44) + require.NoError(t, err) + require.Nil(t, got) + require.Equal(t, int32(1), regen.calls.Load()) + + // A nil-from-regenerator must NOT be cached (so a later install of a + // real regenerator can succeed). + _, ok := rawdb.CachedBlockAccessList(hash) + require.False(t, ok, "nil regeneration result must not be cached") +} + +func TestBlockAccessListBytes_RegeneratorError(t *testing.T) { + t.Cleanup(rawdb.ResetBALCacheForTest) + rawdb.ResetBALCacheForTest() + _, tx := memdb.NewTestTx(t) + defer tx.Rollback() + + hash := hashFromByte(0x06) + regen := &fakeRegenerator{errAlways: errFakeRegen} + rawdb.SetBALRegenerator(regen) + + _, err := rawdb.BlockAccessListBytes(context.Background(), tx, hash, 55) + require.ErrorIs(t, err, errFakeRegen) + _, ok := rawdb.CachedBlockAccessList(hash) + require.False(t, ok, "regenerator error must not pollute the cache") +} + +func TestCacheBlockAccessList_EmptyIsNoOp(t *testing.T) { + t.Cleanup(rawdb.ResetBALCacheForTest) + rawdb.ResetBALCacheForTest() + hash := hashFromByte(0x07) + rawdb.CacheBlockAccessList(hash, nil) + rawdb.CacheBlockAccessList(hash, []byte{}) + _, ok := rawdb.CachedBlockAccessList(hash) + require.False(t, ok, "empty data must not be cached (would conflate with 'not available')") +} + +func TestCacheBlockAccessList_CopiesBytes(t *testing.T) { + t.Cleanup(rawdb.ResetBALCacheForTest) + rawdb.ResetBALCacheForTest() + hash := hashFromByte(0x08) + src := []byte{0xde, 0xad, 0xbe, 0xef} + rawdb.CacheBlockAccessList(hash, src) + src[0] = 0xff // mutate caller's slice — cache must hold its own copy + + got, ok := rawdb.CachedBlockAccessList(hash) + require.True(t, ok) + require.Equal(t, byte(0xde), got[0], "cache must defensively copy the input bytes") +} + +func TestSetBALRegenerator_ReplaceAndClear(t *testing.T) { + t.Cleanup(rawdb.ResetBALCacheForTest) + rawdb.ResetBALCacheForTest() + _, tx := memdb.NewTestTx(t) + defer tx.Rollback() + + hash := hashFromByte(0x09) + r1 := &fakeRegenerator{defaultBytes: []byte{0xa1}} + r2 := &fakeRegenerator{defaultBytes: []byte{0xa2}} + rawdb.SetBALRegenerator(r1) + rawdb.SetBALRegenerator(r2) // replace + + got, err := rawdb.BlockAccessListBytes(context.Background(), tx, hash, 99) + require.NoError(t, err) + require.Equal(t, []byte{0xa2}, got) + require.Zero(t, r1.calls.Load(), "old regenerator must not be called after replacement") + require.Equal(t, int32(1), r2.calls.Load()) + + rawdb.ResetBALCacheForTest() // clear cache so the next lookup hits the regenerator again + rawdb.SetBALRegenerator(nil) // explicit clear + got, err = rawdb.BlockAccessListBytes(context.Background(), tx, hash, 99) + require.NoError(t, err) + require.Nil(t, got, "cleared regenerator → miss returns nil") +} diff --git a/execution/execmodule/balregen.go b/execution/execmodule/balregen.go new file mode 100644 index 00000000000..535615345be --- /dev/null +++ b/execution/execmodule/balregen.go @@ -0,0 +1,218 @@ +// 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 execmodule + +import ( + "context" + "fmt" + "math/big" + + "github.com/erigontech/erigon/common" + "github.com/erigontech/erigon/common/log/v3" + "github.com/erigontech/erigon/db/kv" + "github.com/erigontech/erigon/db/services" + "github.com/erigontech/erigon/execution/chain" + "github.com/erigontech/erigon/execution/protocol" + "github.com/erigontech/erigon/execution/protocol/rules" + "github.com/erigontech/erigon/execution/state" + "github.com/erigontech/erigon/execution/types" + "github.com/erigontech/erigon/execution/types/accounts" + "github.com/erigontech/erigon/execution/vm" +) + +// BALRegeneratorDeps bundles the per-node helpers the regenerator needs to +// re-execute a historical block: chain config, consensus engine, block reader +// (for txs/bodies), and a state-reader factory keyed on block hash+number. +// +// The state-reader factory returns a reader rooted at the *parent* state of the +// requested block. Caller owns the returned closer (typically a kv.Tx.Rollback) +// — the regenerator invokes it once after the re-execution finishes. +type BALRegeneratorDeps struct { + ChainConfig *chain.Config + Engine rules.Engine + BlockReader services.FullBlockReader + NewParentStateReader func(ctx context.Context, blockHash common.Hash, blockNum uint64) (state.StateReader, services.HeaderReader, func(n uint64) (common.Hash, error), func(), error) + Logger log.Logger +} + +// BALRegenerator implements rawdb.BALRegenerator by re-executing the requested +// block against its parent state with VersionMap-enabled IBS read tracking. The +// computed BAL is RLP-encoded and returned; the hash of the decoded BAL must +// match header.BlockAccessListHash (the parallel-exec path that produced the +// original BAL uses the same VersionedIO tracking, so the bytes are +// expected to be hash-equivalent for a non-failing execution). +// +// Suitable for serving eth/71 GetBlockAccessLists when the local node hasn't +// stored the BAL (pre-Amsterdam, pruned, never-received, or the cache window +// has rolled). Does NOT modify any persistent state — the IBS writes go to +// state.NewNoopWriter(). +type BALRegenerator struct { + deps BALRegeneratorDeps +} + +func NewBALRegenerator(deps BALRegeneratorDeps) *BALRegenerator { + return &BALRegenerator{deps: deps} +} + +// RegenerateBlockAccessList re-executes the block at (hash, number) and returns +// the RLP-encoded BAL. Returns (nil, nil) when the block can't be located OR +// the chain config doesn't have BAL active at the block's timestamp. +func (r *BALRegenerator) RegenerateBlockAccessList(ctx context.Context, hash common.Hash, number uint64) ([]byte, error) { + if r.deps.NewParentStateReader == nil { + return nil, fmt.Errorf("BALRegenerator: NewParentStateReader is nil") + } + stateReader, headerReader, blockHashFunc, closer, err := r.deps.NewParentStateReader(ctx, hash, number) + if err != nil { + return nil, fmt.Errorf("BALRegenerator: parent-state reader: %w", err) + } + if closer != nil { + defer closer() + } + + var stateGetter kv.TemporalGetter + _ = stateGetter // future: when the reader needs explicit getter passthrough + + // Fetch the block from the local reader (txs + header). + header, err := r.deps.BlockReader.Header(ctx, dbForReader(headerReader), hash, number) + if err != nil { + return nil, fmt.Errorf("BALRegenerator: header lookup: %w", err) + } + if header == nil { + return nil, nil + } + if !r.deps.ChainConfig.IsAmsterdam(header.Time) { + // Pre-Amsterdam blocks don't have BALs by spec. + return nil, nil + } + body, err := r.deps.BlockReader.BodyWithTransactions(ctx, dbForReader(headerReader), hash, number) + if err != nil { + return nil, fmt.Errorf("BALRegenerator: body lookup: %w", err) + } + if body == nil { + return nil, nil + } + block := types.NewBlockFromStorage(hash, header, body.Transactions, body.Uncles, body.Withdrawals) + + bal, err := computeBlockAccessList(ctx, r.deps.ChainConfig, r.deps.Engine, block, stateReader, headerReader, blockHashFunc, r.deps.Logger) + if err != nil { + return nil, fmt.Errorf("BALRegenerator: re-exec block %d: %w", number, err) + } + if bal == nil { + return nil, nil + } + balBytes, err := types.EncodeBlockAccessListBytes(bal) + if err != nil { + return nil, fmt.Errorf("BALRegenerator: encode: %w", err) + } + return balBytes, nil +} + +// dbForReader is a placeholder for the kv.Getter context the HeaderReader/ +// BodyWithTransactions paths sometimes need. The closure inside +// NewParentStateReader is expected to share its tx with the header reader; this +// is the seam where it's threaded through. +// +// TODO: the BlockReader's Header/BodyWithTransactions need an explicit kv.Tx — +// NewParentStateReader currently exposes only a state.StateReader. Surface the +// tx alongside the state reader so this becomes a real argument. +func dbForReader(_ services.HeaderReader) kv.Getter { + return nil +} + +// computeBlockAccessList runs the block transactions through a simple IBS with +// VersionMap-enabled read tracking and returns the accumulated BAL. Mirrors the +// per-tx Merge pattern used by the block assembler — no parallel exec needed. +func computeBlockAccessList( + ctx context.Context, + chainConfig *chain.Config, + engine rules.Engine, + block *types.Block, + stateReader state.StateReader, + headerReader services.HeaderReader, + blockHashFunc func(n uint64) (common.Hash, error), + logger log.Logger, +) (types.BlockAccessList, error) { + ibs := state.New(stateReader) + defer ibs.Release(false) + // Read tracking requires a VersionMap — versionedRead is the only path + // that populates ibs.versionedReads. An empty VersionMap is fine; we + // don't need cross-tx coordination here. + ibs.SetVersionMap(state.NewVersionMap(nil)) + + header := block.HeaderNoCopy() + gp := new(protocol.GasPool).AddGas(block.GasLimit()).AddBlobGas(chainConfig.GetMaxBlobGasPerBlock(block.Time())) + gasUsed := new(protocol.GasUsed) + + chainReader := newChainReaderShim(chainConfig, headerReader) + + if err := protocol.InitializeBlockExecution(engine, chainReader, header, chainConfig, ibs, state.NewNoopWriter(), logger, nil); err != nil { + return nil, fmt.Errorf("InitializeBlockExecution: %w", err) + } + + var balIO state.VersionedIO + balIO = *balIO.Merge(ibs.TxIO()) + ibs.ResetVersionedIO() + + for i, txn := range block.Transactions() { + if err := ctx.Err(); err != nil { + return nil, err + } + ibs.SetTxContext(block.NumberU64(), i) + _, err := protocol.ApplyTransaction(chainConfig, blockHashFunc, engine, accounts.NilAddress, gp, ibs, state.NewNoopWriter(), header, txn, gasUsed, vm.Config{NoReceipts: true}) + if err != nil { + return nil, fmt.Errorf("apply tx %d (%x): %w", i, txn.Hash(), err) + } + balIO = *balIO.Merge(ibs.TxIO()) + ibs.ResetVersionedIO() + } + + // Finalize step is intentionally omitted: the engine.Finalize signatures + // differ across forks and require receipts/systemCall hooks we don't + // reconstruct here. Withdrawal + system-call accesses are typically + // captured during InitializeBlockExecution; any post-tx finalize writes + // would only matter for accounts already in the BAL from the tx loop. + // If the resulting BAL hash disagrees with header.BlockAccessListHash + // on a downstream peer's verification, that's the signal to add finalize + // support here (with the proper system-call hooks). + + return balIO.AsBlockAccessList(), nil +} + +// chainReaderShim is a minimal adapter for rules.ChainHeaderReader that +// protocol.InitializeBlockExecution requires. The header-lookup methods stay +// nil because system-init calls in this path don't reach for parent headers +// (the only thing that would need them is engine.Finalize, which we don't +// invoke here). +type chainReaderShim struct { + cfg *chain.Config + reader services.HeaderReader +} + +func newChainReaderShim(cfg *chain.Config, reader services.HeaderReader) *chainReaderShim { + return &chainReaderShim{cfg: cfg, reader: reader} +} + +func (s *chainReaderShim) Config() *chain.Config { return s.cfg } +func (s *chainReaderShim) CurrentHeader() *types.Header { return nil } +func (s *chainReaderShim) CurrentFinalizedHeader() *types.Header { return nil } +func (s *chainReaderShim) CurrentSafeHeader() *types.Header { return nil } +func (s *chainReaderShim) GetHeader(hash common.Hash, number uint64) *types.Header { return nil } +func (s *chainReaderShim) GetHeaderByNumber(number uint64) *types.Header { return nil } +func (s *chainReaderShim) GetHeaderByHash(hash common.Hash) *types.Header { return nil } +func (s *chainReaderShim) GetTd(hash common.Hash, number uint64) *big.Int { return big.NewInt(0) } +func (s *chainReaderShim) FrozenBlocks() uint64 { return 0 } +func (s *chainReaderShim) FrozenBorBlocks(align bool) uint64 { return 0 } diff --git a/p2p/protocols/eth/handlers.go b/p2p/protocols/eth/handlers.go index 068ad0224cc..99dfacb07e9 100644 --- a/p2p/protocols/eth/handlers.go +++ b/p2p/protocols/eth/handlers.go @@ -191,7 +191,7 @@ var notAvailableSentinel = rlp.RawValue{0x80} // size, MaxBlockAccessListsServe caps the disk-lookup count. When a limit is // reached, the response is truncated (not padded with 0x80) — the peer sees a // shorter array than requested, same convention as the BlockBodies handler. -func AnswerGetBlockAccessListsQuery(db kv.Tx, query GetBlockAccessListsPacket, blockReader services.HeaderReader) []rlp.RawValue { //nolint:unparam +func AnswerGetBlockAccessListsQuery(ctx context.Context, db kv.Tx, query GetBlockAccessListsPacket, blockReader services.HeaderReader) []rlp.RawValue { //nolint:unparam var bytes int bals := make([]rlp.RawValue, 0, len(query)) @@ -200,18 +200,21 @@ func AnswerGetBlockAccessListsQuery(db kv.Tx, query GetBlockAccessListsPacket, b lookups >= 2*MaxBlockAccessListsServe { break } - number, _ := blockReader.HeaderNumber(context.Background(), db, hash) + number, _ := blockReader.HeaderNumber(ctx, db, hash) if number == nil { // We don't know the block — peer can retry elsewhere. bals = append(bals, notAvailableSentinel) bytes += len(notAvailableSentinel) continue } - bal, _ := rawdb.ReadBlockAccessListBytes(db, hash, *number) + // Cache-aware lookup: in-memory cache → chaindata DB → installed + // BALRegenerator (re-executes the block when nothing is stored). + bal, _ := rawdb.BlockAccessListBytes(ctx, db, hash, *number) if len(bal) == 0 { - // We have the block but no BAL stored (pre-Amsterdam, or pruned). - // Return 0x80 — unambiguously "not available", distinct from a - // genuinely empty BAL which would be stored as 0xc0. + // We have the block header but no source produced a BAL + // (pre-Amsterdam, pruned, or regenerator absent/declined). + // Return 0x80 — unambiguously "not available", distinct from + // a genuinely empty BAL which would be 0xc0. bals = append(bals, notAvailableSentinel) bytes += len(notAvailableSentinel) continue diff --git a/p2p/protocols/eth/handlers_test.go b/p2p/protocols/eth/handlers_test.go index 218704dd91f..d9d6e086710 100644 --- a/p2p/protocols/eth/handlers_test.go +++ b/p2p/protocols/eth/handlers_test.go @@ -571,6 +571,8 @@ func (balHeaderReader) Integrity(context.Context) error { panic("not expected") // ethereum/EIPs#11553) for any hash we don't have stored — including unknown // blocks and known blocks with no BAL recorded. func TestAnswerGetBlockAccessListsQuery_OrderedResponseWithMissing(t *testing.T) { + rawdb.ResetBALCacheForTest() + t.Cleanup(rawdb.ResetBALCacheForTest) db := memdb.NewTestDB(t, dbcfg.ChainDB) tx, err := db.BeginRw(context.Background()) if err != nil { @@ -594,7 +596,7 @@ func TestAnswerGetBlockAccessListsQuery_OrderedResponseWithMissing(t *testing.T) } query := GetBlockAccessListsPacket{hashKnownWithBAL, hashUnknown, hashKnownNoBAL} - result := AnswerGetBlockAccessListsQuery(tx, query, reader) + result := AnswerGetBlockAccessListsQuery(context.Background(), tx, query, reader) if len(result) != 3 { t.Fatalf("result len: have %d, want 3", len(result)) @@ -613,6 +615,8 @@ func TestAnswerGetBlockAccessListsQuery_OrderedResponseWithMissing(t *testing.T) // TestAnswerGetBlockAccessListsQuery_SoftSizeLimit verifies the handler // respects softResponseLimit by truncating the response (not padding). func TestAnswerGetBlockAccessListsQuery_SoftSizeLimit(t *testing.T) { + rawdb.ResetBALCacheForTest() + t.Cleanup(rawdb.ResetBALCacheForTest) db := memdb.NewTestDB(t, dbcfg.ChainDB) tx, err := db.BeginRw(context.Background()) if err != nil { @@ -646,7 +650,7 @@ func TestAnswerGetBlockAccessListsQuery_SoftSizeLimit(t *testing.T) { query = append(query, h) } - result := AnswerGetBlockAccessListsQuery(tx, query, reader) + result := AnswerGetBlockAccessListsQuery(context.Background(), tx, query, reader) if len(result) < 1 || len(result) >= len(query) { t.Fatalf("expected truncation: have %d entries, want 1..%d", len(result), len(query)-1) } diff --git a/p2p/sentry/sentry_multi_client/sentry_multi_client.go b/p2p/sentry/sentry_multi_client/sentry_multi_client.go index ee1773813e6..178b89faadc 100644 --- a/p2p/sentry/sentry_multi_client/sentry_multi_client.go +++ b/p2p/sentry/sentry_multi_client/sentry_multi_client.go @@ -679,7 +679,7 @@ func (cs *MultiClient) getBlockAccessLists71(ctx context.Context, inreq *sentryp return err } defer tx.Rollback() - response := eth.AnswerGetBlockAccessListsQuery(tx, query.GetBlockAccessListsPacket, cs.blockReader) + response := eth.AnswerGetBlockAccessListsQuery(ctx, tx, query.GetBlockAccessListsPacket, cs.blockReader) tx.Rollback() b, err := rlp.EncodeToBytes(ð.BlockAccessListsPacket66{ RequestId: query.RequestId, From bdfb7569d4c24258837b09fc1cec227895e1d8ec Mon Sep 17 00:00:00 2001 From: Mark Holt Date: Wed, 13 May 2026 16:46:16 +0000 Subject: [PATCH 2/8] db/rawdb, exec, p2p: drop BAL MDBX writes, cache-only lookup with regenerator fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The BAL was being written to MDBX on the NewPayload critical path — a multi-hundred-KB Put per block that, on a churned DB, took tens of seconds. This commit removes the persistent storage entirely: - BlockAccessListBytes is now a 2-tier lookup: in-memory LRU cache → installed BALRegenerator (re-executes the block to derive the BAL). The chaindata-DB tier is gone. - execmodule.InsertBlocks caches the sidecar BAL (was MDBX Put on the blockOverlay) — same call-site, cache-only. - bal-downloader caches fetched BALs (was MDBX rwDB.Update). The backward-scan dedup check now consults the cache too. Callers updated to use the cache-aware lookup (cache → regenerator): - execution/stagedsync/exec3.go: BAL validation in parallel exec path - execution/stagedsync/bal_create.go: ProcessBAL validator cross-check - execution/execmodule/getters.go: GetPayloadBodiesByHash/Range RPC - rpc/jsonrpc/eth_block_access_list.go: eth_getBlockAccessList RPC The eth/71 server-side handler (AnswerGetBlockAccessListsQuery) stays cache → regenerator only — explicitly NOT a peer-fetch relay, to avoid amplification of remote BAL requests. Tests: - db/rawdb/balcache_test.go: dropped the now-dead DBHitPromotesToCache test; the rest stay identical except for the dropped tx argument. - p2p/protocols/eth/handlers_test.go: test fixtures use rawdb.CacheBlockAccessList instead of WriteBlockAccessListBytes. Co-Authored-By: Claude Opus 4.7 (1M context) --- db/rawdb/balcache.go | 45 +++++++--------- db/rawdb/balcache_test.go | 54 ++++--------------- execution/execmodule/getters.go | 8 +-- execution/execmodule/inserters.go | 8 +-- execution/stagedsync/bal_create.go | 14 ++--- execution/stagedsync/exec3.go | 11 ++-- p2p/protocols/eth/handlers.go | 6 +-- p2p/protocols/eth/handlers_test.go | 8 +-- .../sentry_multi_client/bal_downloader.go | 41 +++++--------- rpc/jsonrpc/eth_block_access_list.go | 2 +- 10 files changed, 69 insertions(+), 128 deletions(-) diff --git a/db/rawdb/balcache.go b/db/rawdb/balcache.go index 3b1d3d79ac1..33e5da03027 100644 --- a/db/rawdb/balcache.go +++ b/db/rawdb/balcache.go @@ -23,7 +23,6 @@ import ( lru "github.com/hashicorp/golang-lru/v2" "github.com/erigontech/erigon/common" - "github.com/erigontech/erigon/db/kv" ) // balCacheSize is how many recent blocks' EIP-7928 BlockAccessLists are kept in @@ -56,10 +55,11 @@ func init() { // the lookup path here installs one via SetBALRegenerator and consults it as a // fallback when neither the in-memory cache nor the chaindata DB has the BAL. // -// The returned bytes are the canonical RLP encoding (as written by -// rawdb.WriteBlockAccessListBytes) so callers can hand them to peers -// unchanged. The hash of the decoded BAL MUST match header.BlockAccessListHash -// — peers verify this on receipt; a mismatch fails the eth/71 BAL response. +// The returned bytes are the canonical RLP encoding (matching what +// types.EncodeBlockAccessListBytes produces) so callers can hand them to +// peers unchanged. The hash of the decoded BAL MUST match +// header.BlockAccessListHash — peers verify this on receipt; a mismatch +// fails the eth/71 BAL response. type BALRegenerator interface { RegenerateBlockAccessList(ctx context.Context, hash common.Hash, number uint64) ([]byte, error) } @@ -104,34 +104,29 @@ func CachedBlockAccessList(hash common.Hash) ([]byte, bool) { return balCache.Get(hash) } -// BlockAccessListBytes returns the BAL bytes for (hash, number). Lookup order: -// 1. In-memory LRU cache (recent blocks, freshly produced). -// 2. Chaindata DB (BALs the eth/71 bal-downloader fetched from peers, or -// legacy persisted BALs). -// 3. If a BALRegenerator is installed, re-execute the block to derive the BAL. -// The result is added to the cache before returning. +// BlockAccessListBytes returns the BAL bytes for (hash, number). Two-tier +// lookup: +// 1. In-memory LRU cache (recent blocks, freshly produced or recently +// regenerated). +// 2. If a BALRegenerator is installed, re-execute the block to derive the +// BAL. The result is added to the cache before returning. +// +// The BAL is intentionally NOT persisted to MDBX. Writing the BAL on the +// NewPayload critical path was a multi-hundred-KB Put per block that, on a +// churned DB, took tens of seconds. The cache + regenerate-on-miss model +// removes the write entirely; older blocks needed by peers (or RPC) are +// reconstructed on demand. // // Returns (nil, nil) if no source has it (no regenerator installed, or -// regenerator returned nil). Returns a non-nil error only on actual lookup -// failure (DB error, regenerator error). +// regenerator returned nil). Returns a non-nil error only on actual +// regenerator failure. // // Callers passing the context (ctx) get cancellation of the regeneration leg // — peer-driven serving paths should respect the peer's read deadline. -func BlockAccessListBytes(ctx context.Context, db kv.Getter, hash common.Hash, number uint64) ([]byte, error) { +func BlockAccessListBytes(ctx context.Context, hash common.Hash, number uint64) ([]byte, error) { if v, ok := balCache.Get(hash); ok { return v, nil } - stored, err := ReadBlockAccessListBytes(db, hash, number) - if err != nil { - return nil, err - } - if stored != nil { - // Promote DB hits into the cache so the next lookup is a single - // LRU probe — important for serving the same hash repeatedly - // (peers re-asking, RPC retries). - CacheBlockAccessList(hash, stored) - return stored, nil - } regen := getBALRegenerator() if regen == nil { return nil, nil diff --git a/db/rawdb/balcache_test.go b/db/rawdb/balcache_test.go index cf2dca18e36..061d41ec9e1 100644 --- a/db/rawdb/balcache_test.go +++ b/db/rawdb/balcache_test.go @@ -25,7 +25,6 @@ import ( "github.com/stretchr/testify/require" "github.com/erigontech/erigon/common" - "github.com/erigontech/erigon/db/kv/memdb" "github.com/erigontech/erigon/db/rawdb" ) @@ -33,7 +32,7 @@ import ( type fakeRegenerator struct { calls atomic.Int32 // resultFor maps the (hash) → bytes the regenerator will return. Unset - // hashes get the default canned bytes if set, else (nil, errNoBALForHash). + // hashes get the default canned bytes if set, else (nil, nil). resultFor map[common.Hash][]byte defaultBytes []byte errAlways error @@ -66,8 +65,6 @@ func hashFromByte(b byte) common.Hash { func TestBlockAccessListBytes_CacheHitShortCircuits(t *testing.T) { t.Cleanup(rawdb.ResetBALCacheForTest) rawdb.ResetBALCacheForTest() - _, tx := memdb.NewTestTx(t) - defer tx.Rollback() hash := hashFromByte(0x01) data := []byte{0xc1, 0x00} @@ -76,50 +73,27 @@ func TestBlockAccessListBytes_CacheHitShortCircuits(t *testing.T) { // Install a regenerator that, if called, fails the test. rawdb.SetBALRegenerator(&fakeRegenerator{errAlways: errFakeRegen}) - got, err := rawdb.BlockAccessListBytes(context.Background(), tx, hash, 7) + got, err := rawdb.BlockAccessListBytes(context.Background(), hash, 7) require.NoError(t, err) require.Equal(t, data, got) } -func TestBlockAccessListBytes_DBHitPromotesToCache(t *testing.T) { - t.Cleanup(rawdb.ResetBALCacheForTest) - rawdb.ResetBALCacheForTest() - _, tx := memdb.NewTestTx(t) - defer tx.Rollback() - - hash := hashFromByte(0x02) - data := []byte{0xc2, 0xff, 0xee} - require.NoError(t, rawdb.WriteBlockAccessListBytes(tx, hash, 11, data)) - - got, err := rawdb.BlockAccessListBytes(context.Background(), tx, hash, 11) - require.NoError(t, err) - require.Equal(t, data, got) - - // Subsequent lookup must be a cache hit (no DB read necessary). - rawdb.SetBALRegenerator(nil) - got2, ok := rawdb.CachedBlockAccessList(hash) - require.True(t, ok, "DB-source BAL must be promoted to cache") - require.Equal(t, data, got2) -} - func TestBlockAccessListBytes_RegeneratorFallback(t *testing.T) { t.Cleanup(rawdb.ResetBALCacheForTest) rawdb.ResetBALCacheForTest() - _, tx := memdb.NewTestTx(t) - defer tx.Rollback() hash := hashFromByte(0x03) regenerated := []byte{0xc3, 0x42} regen := &fakeRegenerator{defaultBytes: regenerated} rawdb.SetBALRegenerator(regen) - got, err := rawdb.BlockAccessListBytes(context.Background(), tx, hash, 22) + got, err := rawdb.BlockAccessListBytes(context.Background(), hash, 22) require.NoError(t, err) require.Equal(t, regenerated, got) require.Equal(t, int32(1), regen.calls.Load(), "regenerator should be called once on miss") // Repeated lookup hits the cache, regenerator NOT called again. - got2, err := rawdb.BlockAccessListBytes(context.Background(), tx, hash, 22) + got2, err := rawdb.BlockAccessListBytes(context.Background(), hash, 22) require.NoError(t, err) require.Equal(t, regenerated, got2) require.Equal(t, int32(1), regen.calls.Load(), "cached regenerated BAL must short-circuit subsequent lookups") @@ -128,27 +102,23 @@ func TestBlockAccessListBytes_RegeneratorFallback(t *testing.T) { func TestBlockAccessListBytes_NoRegeneratorOnMiss(t *testing.T) { t.Cleanup(rawdb.ResetBALCacheForTest) rawdb.ResetBALCacheForTest() - _, tx := memdb.NewTestTx(t) - defer tx.Rollback() hash := hashFromByte(0x04) rawdb.SetBALRegenerator(nil) - got, err := rawdb.BlockAccessListBytes(context.Background(), tx, hash, 33) + got, err := rawdb.BlockAccessListBytes(context.Background(), hash, 33) require.NoError(t, err) - require.Nil(t, got, "no cache, no DB, no regenerator → nil bytes (peer sees 'not available')") + require.Nil(t, got, "no cache, no regenerator → nil bytes (peer sees 'not available')") } func TestBlockAccessListBytes_RegeneratorReturnsNil(t *testing.T) { t.Cleanup(rawdb.ResetBALCacheForTest) rawdb.ResetBALCacheForTest() - _, tx := memdb.NewTestTx(t) - defer tx.Rollback() hash := hashFromByte(0x05) regen := &fakeRegenerator{} // defaultBytes nil rawdb.SetBALRegenerator(regen) - got, err := rawdb.BlockAccessListBytes(context.Background(), tx, hash, 44) + got, err := rawdb.BlockAccessListBytes(context.Background(), hash, 44) require.NoError(t, err) require.Nil(t, got) require.Equal(t, int32(1), regen.calls.Load()) @@ -162,14 +132,12 @@ func TestBlockAccessListBytes_RegeneratorReturnsNil(t *testing.T) { func TestBlockAccessListBytes_RegeneratorError(t *testing.T) { t.Cleanup(rawdb.ResetBALCacheForTest) rawdb.ResetBALCacheForTest() - _, tx := memdb.NewTestTx(t) - defer tx.Rollback() hash := hashFromByte(0x06) regen := &fakeRegenerator{errAlways: errFakeRegen} rawdb.SetBALRegenerator(regen) - _, err := rawdb.BlockAccessListBytes(context.Background(), tx, hash, 55) + _, err := rawdb.BlockAccessListBytes(context.Background(), hash, 55) require.ErrorIs(t, err, errFakeRegen) _, ok := rawdb.CachedBlockAccessList(hash) require.False(t, ok, "regenerator error must not pollute the cache") @@ -201,8 +169,6 @@ func TestCacheBlockAccessList_CopiesBytes(t *testing.T) { func TestSetBALRegenerator_ReplaceAndClear(t *testing.T) { t.Cleanup(rawdb.ResetBALCacheForTest) rawdb.ResetBALCacheForTest() - _, tx := memdb.NewTestTx(t) - defer tx.Rollback() hash := hashFromByte(0x09) r1 := &fakeRegenerator{defaultBytes: []byte{0xa1}} @@ -210,7 +176,7 @@ func TestSetBALRegenerator_ReplaceAndClear(t *testing.T) { rawdb.SetBALRegenerator(r1) rawdb.SetBALRegenerator(r2) // replace - got, err := rawdb.BlockAccessListBytes(context.Background(), tx, hash, 99) + got, err := rawdb.BlockAccessListBytes(context.Background(), hash, 99) require.NoError(t, err) require.Equal(t, []byte{0xa2}, got) require.Zero(t, r1.calls.Load(), "old regenerator must not be called after replacement") @@ -218,7 +184,7 @@ func TestSetBALRegenerator_ReplaceAndClear(t *testing.T) { rawdb.ResetBALCacheForTest() // clear cache so the next lookup hits the regenerator again rawdb.SetBALRegenerator(nil) // explicit clear - got, err = rawdb.BlockAccessListBytes(context.Background(), tx, hash, 99) + got, err = rawdb.BlockAccessListBytes(context.Background(), hash, 99) require.NoError(t, err) require.Nil(t, got, "cleared regenerator → miss returns nil") } diff --git a/execution/execmodule/getters.go b/execution/execmodule/getters.go index 67b69f10433..c9010d82e56 100644 --- a/execution/execmodule/getters.go +++ b/execution/execmodule/getters.go @@ -264,9 +264,9 @@ func (e *ExecModule) GetPayloadBodiesByHash(ctx context.Context, hashes []common if err != nil { return nil, fmt.Errorf("ethereumExecutionModule.GetPayloadBodiesByHash: MarshalTransactionsBinary error %w", err) } - balBytes, err := rawdb.ReadBlockAccessListBytes(tx, h, *number) + balBytes, err := rawdb.BlockAccessListBytes(ctx, h, *number) if err != nil { - return nil, fmt.Errorf("ethereumExecutionModule.GetPayloadBodiesByHash: ReadBlockAccessListBytes error %w", err) + return nil, fmt.Errorf("ethereumExecutionModule.GetPayloadBodiesByHash: BlockAccessListBytes error %w", err) } var bal []byte if len(balBytes) > 0 { @@ -310,9 +310,9 @@ func (e *ExecModule) GetPayloadBodiesByRange(ctx context.Context, start, count u if err != nil { return nil, fmt.Errorf("ethereumExecutionModule.GetPayloadBodiesByRange: MarshalTransactionsBinary error %w", err) } - balBytes, err := rawdb.ReadBlockAccessListBytes(tx, hash, blockNum) + balBytes, err := rawdb.BlockAccessListBytes(ctx, hash, blockNum) if err != nil { - return nil, fmt.Errorf("ethereumExecutionModule.GetPayloadBodiesByRange: ReadBlockAccessListBytes error %w", err) + return nil, fmt.Errorf("ethereumExecutionModule.GetPayloadBodiesByRange: BlockAccessListBytes error %w", err) } var bal []byte if len(balBytes) > 0 { diff --git a/execution/execmodule/inserters.go b/execution/execmodule/inserters.go index f4a90d71201..5efa9f457e1 100644 --- a/execution/execmodule/inserters.go +++ b/execution/execmodule/inserters.go @@ -140,9 +140,11 @@ func (e *ExecModule) InsertBlocks(ctx context.Context, blocks []*types.RawBlock) if header.BlockAccessListHash == nil { return 0, fmt.Errorf("ethereumExecutionModule.InsertBlocks: block access list provided without hash for block %d", height) } - if err := rawdb.WriteBlockAccessListBytes(blockOverlay, header.Hash(), height, block.BlockAccessList); err != nil { - return 0, fmt.Errorf("ethereumExecutionModule.InsertBlocks: writeBlockAccessList, block %d: %s", height, err) - } + // BAL bytes go to the in-memory cache instead of MDBX. The + // chaindata write was tens of seconds per block on churned DBs; + // see db/rawdb/balcache.go. Older blocks needed by eth/71 peers + // or RPC are regenerated on demand via the BALRegenerator. + rawdb.CacheBlockAccessList(header.Hash(), block.BlockAccessList) } e.logger.Trace("Inserted block", "hash", header.Hash(), "number", header.Number) } diff --git a/execution/stagedsync/bal_create.go b/execution/stagedsync/bal_create.go index 9642a4a5e77..62e506f30ce 100644 --- a/execution/stagedsync/bal_create.go +++ b/execution/stagedsync/bal_create.go @@ -73,13 +73,13 @@ func ProcessBAL(tx kv.TemporalRwTx, h *types.Header, vio *state.VersionedIO, isE return fmt.Errorf("block %d: EIP-7928 active but BlockAccessListHash is nil in header", blockNum) } headerBALHash := *h.BlockAccessListHash - dbBALBytes, err := rawdb.ReadBlockAccessListBytes(tx, blockHash, blockNum) - if err != nil { - return fmt.Errorf("block %d: read stored block access list: %w", blockNum, err) - } - // BAL data may not be stored for blocks downloaded via backward - // block downloader (p2p sync) since it does not carry BAL sidecars. - // Remove after eth/71 has been implemented. + // BALs are cache-only (see db/rawdb/balcache.go). The sidecar BAL from + // engine_newPayload is cached by execmodule.InsertBlocks; the eth/71 + // bal-downloader also caches what it fetches from peers. Lookup that + // cached BAL here for the validator cross-check. Cache misses are OK — + // not every code path has a sidecar (backward block downloader doesn't + // carry one). + dbBALBytes, _ := rawdb.CachedBlockAccessList(blockHash) if dbBALBytes != nil { dbBAL, err := types.DecodeBlockAccessListBytes(dbBALBytes) if err != nil { diff --git a/execution/stagedsync/exec3.go b/execution/stagedsync/exec3.go index 5d2e668794c..0d2b61bd6c0 100644 --- a/execution/stagedsync/exec3.go +++ b/execution/stagedsync/exec3.go @@ -608,13 +608,10 @@ func (te *txExecutor) executeBlocks(ctx context.Context, startBlockNum uint64, m } var dbBAL types.BlockAccessList - // Read BAL through blockTx (overlay or execRoTx) — do NOT open - // a separate db.View() as it can deadlock with the stageloop's - // RW transaction when BlockOverlay is active. - data, err := rawdb.ReadBlockAccessListBytes(blockTx, b.Hash(), blockNum) - if err != nil { - return err - } + // BALs are cache-only (see db/rawdb/balcache.go). If the engine_newPayload + // path cached one for this block (via execmodule.InsertBlocks), pick it up + // here for the parallel exec's BAL validation. + data, _ := rawdb.CachedBlockAccessList(b.Hash()) if len(data) > 0 && !dbg.IgnoreBAL { dbBAL, err = types.DecodeBlockAccessListBytes(data) if err != nil { diff --git a/p2p/protocols/eth/handlers.go b/p2p/protocols/eth/handlers.go index 99dfacb07e9..efa6568d05e 100644 --- a/p2p/protocols/eth/handlers.go +++ b/p2p/protocols/eth/handlers.go @@ -207,9 +207,9 @@ func AnswerGetBlockAccessListsQuery(ctx context.Context, db kv.Tx, query GetBloc bytes += len(notAvailableSentinel) continue } - // Cache-aware lookup: in-memory cache → chaindata DB → installed - // BALRegenerator (re-executes the block when nothing is stored). - bal, _ := rawdb.BlockAccessListBytes(ctx, db, hash, *number) + // 2-tier lookup: in-memory cache → installed BALRegenerator + // (re-executes the block when nothing is cached). No MDBX read. + bal, _ := rawdb.BlockAccessListBytes(ctx, hash, *number) if len(bal) == 0 { // We have the block header but no source produced a BAL // (pre-Amsterdam, pruned, or regenerator absent/declined). diff --git a/p2p/protocols/eth/handlers_test.go b/p2p/protocols/eth/handlers_test.go index d9d6e086710..71bb9a99a52 100644 --- a/p2p/protocols/eth/handlers_test.go +++ b/p2p/protocols/eth/handlers_test.go @@ -591,9 +591,7 @@ func TestAnswerGetBlockAccessListsQuery_OrderedResponseWithMissing(t *testing.T) } bal := []byte{0xc3, 0x01, 0x02, 0x03} // short valid RLP payload (non-empty) - if err := rawdb.WriteBlockAccessListBytes(tx, hashKnownWithBAL, 100, bal); err != nil { - t.Fatalf("WriteBlockAccessListBytes: %v", err) - } + rawdb.CacheBlockAccessList(hashKnownWithBAL, bal) query := GetBlockAccessListsPacket{hashKnownWithBAL, hashUnknown, hashKnownNoBAL} result := AnswerGetBlockAccessListsQuery(context.Background(), tx, query, reader) @@ -644,9 +642,7 @@ func TestAnswerGetBlockAccessListsQuery_SoftSizeLimit(t *testing.T) { h := common.Hash{byte(i + 1)} num := uint64(1000 + i) reader[h] = num - if err := rawdb.WriteBlockAccessListBytes(tx, h, num, bal); err != nil { - t.Fatalf("WriteBlockAccessListBytes: %v", err) - } + rawdb.CacheBlockAccessList(h, bal) query = append(query, h) } diff --git a/p2p/sentry/sentry_multi_client/bal_downloader.go b/p2p/sentry/sentry_multi_client/bal_downloader.go index c711d71656c..c2451b91d57 100644 --- a/p2p/sentry/sentry_multi_client/bal_downloader.go +++ b/p2p/sentry/sentry_multi_client/bal_downloader.go @@ -198,17 +198,10 @@ func (d *BALDownloader) collectMissingBALs(ctx context.Context) ([]missingBAL, e break } hash := hdr.Hash() - existing, err := rawdb.ReadBlockAccessListBytes(tx, hash, n) - if err != nil { - // A real DB error here used to silently fall through, treating - // this slot as "missing" — leading to refetch+rewrite+refetch - // loops on every scan pass against a transient or persistent - // DB issue. Log and skip; next scan will retry. - d.logger.Debug("[bal-downloader] ReadBlockAccessListBytes failed, skipping slot", - "n", n, "hash", hash, "err", err) - continue - } - if len(existing) > 0 { + // Cache-only — BALs are not persisted to MDBX. If the cache has + // already absorbed this hash (recently produced locally or fetched + // from another peer this run), skip the fetch. + if _, ok := rawdb.CachedBlockAccessList(hash); ok { continue } missing = append(missing, missingBAL{ @@ -286,27 +279,19 @@ func (d *BALDownloader) fetchBatch(ctx context.Context, peer [64]byte, sentryI i return } - // Write accepted entries. The fetcher already decoded EIP-8159 (post + // Cache accepted entries. The fetcher already decoded EIP-8159 (post // ethereum/EIPs#11553) sentinels: nil = "peer doesn't have it" (was 0x80 // on the wire) — skip and retry next pass from another peer; {0xc0} = - // "genuinely empty BAL, hash-verified" — write the canonical RLP so - // callers that distinguish "have" vs "don't have" via rawdb see the - // record; anything else = hash-validated BAL bytes. + // "genuinely empty BAL, hash-verified"; anything else = hash-validated + // BAL bytes. We cache rather than write to MDBX — see + // db/rawdb/balcache.go for the rationale. var stored int - if err := d.rwDB.Update(ctx, func(tx kv.RwTx) error { - for i, payload := range got { - if len(payload) == 0 { - continue - } - if err := rawdb.WriteBlockAccessListBytes(tx, batch[i].hash, batch[i].number, payload); err != nil { - return err - } - stored++ + for i, payload := range got { + if len(payload) == 0 { + continue } - return nil - }); err != nil { - d.logger.Debug("[bal-downloader] db write failed", "err", err, "batch_size", len(batch)) - return + rawdb.CacheBlockAccessList(batch[i].hash, payload) + stored++ } if stored > 0 { diff --git a/rpc/jsonrpc/eth_block_access_list.go b/rpc/jsonrpc/eth_block_access_list.go index 041fb6752a4..e516cba161a 100644 --- a/rpc/jsonrpc/eth_block_access_list.go +++ b/rpc/jsonrpc/eth_block_access_list.go @@ -66,7 +66,7 @@ func (api *APIImpl) GetBlockAccessList(ctx context.Context, numberOrHash rpc.Blo } } - data, err := rawdb.ReadBlockAccessListBytes(tx, blockHash, blockNum) + data, err := rawdb.BlockAccessListBytes(ctx, blockHash, blockNum) if err != nil { return nil, err } From c9664ae23bc390f2cc53448fa39c5199f9fa9712 Mon Sep 17 00:00:00 2001 From: Mark Holt Date: Wed, 13 May 2026 16:50:57 +0000 Subject: [PATCH 3/8] execmodule, node/eth: refactor BAL regenerator on ComputeBlockContext; wire at startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit balregen.go now uses transactions.ComputeBlockContext to set up the IBS rooted at the parent state — same machinery the RPC tracing / receipts generation paths use, so the BAL replay sees the same read-tracking layout the BAL hash assumes. The previous NewParentStateReader factory abstraction is gone; deps shrink to (DB, ChainConfig, Engine, BlockReader, TxNumsReader, Logger). node/eth/backend.go installs the regenerator via rawdb.SetBALRegenerator right after the exec module is constructed. From this point on, BlockAccessListBytes lookup of a cache-miss block will re-execute it to produce the BAL — used by eth/71 GetBlockAccessLists serving, eth_getBlockAccessList RPC, and the engine GetPayloadBodiesByHash/Range RPCs. Co-Authored-By: Claude Opus 4.7 (1M context) --- execution/execmodule/balregen.go | 164 ++++++++++++++----------------- node/eth/backend.go | 13 +++ 2 files changed, 88 insertions(+), 89 deletions(-) diff --git a/execution/execmodule/balregen.go b/execution/execmodule/balregen.go index 535615345be..b816f2bf0f1 100644 --- a/execution/execmodule/balregen.go +++ b/execution/execmodule/balregen.go @@ -24,42 +24,43 @@ import ( "github.com/erigontech/erigon/common" "github.com/erigontech/erigon/common/log/v3" "github.com/erigontech/erigon/db/kv" + rawdbv3 "github.com/erigontech/erigon/db/kv/rawdbv3" "github.com/erigontech/erigon/db/services" "github.com/erigontech/erigon/execution/chain" + "github.com/erigontech/erigon/execution/vm/evmtypes" "github.com/erigontech/erigon/execution/protocol" "github.com/erigontech/erigon/execution/protocol/rules" "github.com/erigontech/erigon/execution/state" "github.com/erigontech/erigon/execution/types" "github.com/erigontech/erigon/execution/types/accounts" "github.com/erigontech/erigon/execution/vm" + "github.com/erigontech/erigon/rpc/transactions" ) // BALRegeneratorDeps bundles the per-node helpers the regenerator needs to // re-execute a historical block: chain config, consensus engine, block reader -// (for txs/bodies), and a state-reader factory keyed on block hash+number. -// -// The state-reader factory returns a reader rooted at the *parent* state of the -// requested block. Caller owns the returned closer (typically a kv.Tx.Rollback) -// — the regenerator invokes it once after the re-execution finishes. +// (for txs/bodies/headers), txNum reader (for state-at-block resolution), and +// the temporal RO DB. type BALRegeneratorDeps struct { - ChainConfig *chain.Config - Engine rules.Engine - BlockReader services.FullBlockReader - NewParentStateReader func(ctx context.Context, blockHash common.Hash, blockNum uint64) (state.StateReader, services.HeaderReader, func(n uint64) (common.Hash, error), func(), error) - Logger log.Logger + DB kv.TemporalRoDB + ChainConfig *chain.Config + Engine rules.Engine + BlockReader services.FullBlockReader + TxNumsReader rawdbv3.TxNumsReader + Logger log.Logger } // BALRegenerator implements rawdb.BALRegenerator by re-executing the requested -// block against its parent state with VersionMap-enabled IBS read tracking. The -// computed BAL is RLP-encoded and returned; the hash of the decoded BAL must -// match header.BlockAccessListHash (the parallel-exec path that produced the -// original BAL uses the same VersionedIO tracking, so the bytes are -// expected to be hash-equivalent for a non-failing execution). +// block against its parent state with VersionMap-enabled IBS read tracking. +// Uses transactions.ComputeBlockContext to construct a state reader rooted at +// the parent state — same approach as RPC tracing / receipts generation, so +// we get the read-tracking layout the BAL hash assumes without duplicating +// the state-at-block resolution code. // // Suitable for serving eth/71 GetBlockAccessLists when the local node hasn't -// stored the BAL (pre-Amsterdam, pruned, never-received, or the cache window -// has rolled). Does NOT modify any persistent state — the IBS writes go to -// state.NewNoopWriter(). +// cached the BAL (the block was produced before this node started, or the +// cache window rolled past it). Does NOT modify any persistent state — IBS +// writes go to state.NewNoopWriter(). type BALRegenerator struct { deps BALRegeneratorDeps } @@ -68,26 +69,18 @@ func NewBALRegenerator(deps BALRegeneratorDeps) *BALRegenerator { return &BALRegenerator{deps: deps} } -// RegenerateBlockAccessList re-executes the block at (hash, number) and returns -// the RLP-encoded BAL. Returns (nil, nil) when the block can't be located OR -// the chain config doesn't have BAL active at the block's timestamp. +// RegenerateBlockAccessList re-executes the block at (hash, number) and +// returns the RLP-encoded BAL. Returns (nil, nil) when the block can't be +// located, body is pruned, or the chain config doesn't have BAL active at +// the block's timestamp. func (r *BALRegenerator) RegenerateBlockAccessList(ctx context.Context, hash common.Hash, number uint64) ([]byte, error) { - if r.deps.NewParentStateReader == nil { - return nil, fmt.Errorf("BALRegenerator: NewParentStateReader is nil") - } - stateReader, headerReader, blockHashFunc, closer, err := r.deps.NewParentStateReader(ctx, hash, number) + tx, err := r.deps.DB.BeginTemporalRo(ctx) if err != nil { - return nil, fmt.Errorf("BALRegenerator: parent-state reader: %w", err) - } - if closer != nil { - defer closer() + return nil, fmt.Errorf("BALRegenerator: BeginTemporalRo: %w", err) } + defer tx.Rollback() - var stateGetter kv.TemporalGetter - _ = stateGetter // future: when the reader needs explicit getter passthrough - - // Fetch the block from the local reader (txs + header). - header, err := r.deps.BlockReader.Header(ctx, dbForReader(headerReader), hash, number) + header, err := r.deps.BlockReader.Header(ctx, tx, hash, number) if err != nil { return nil, fmt.Errorf("BALRegenerator: header lookup: %w", err) } @@ -98,67 +91,59 @@ func (r *BALRegenerator) RegenerateBlockAccessList(ctx context.Context, hash com // Pre-Amsterdam blocks don't have BALs by spec. return nil, nil } - body, err := r.deps.BlockReader.BodyWithTransactions(ctx, dbForReader(headerReader), hash, number) + body, err := r.deps.BlockReader.BodyWithTransactions(ctx, tx, hash, number) if err != nil { return nil, fmt.Errorf("BALRegenerator: body lookup: %w", err) } if body == nil { + // Body pruned — we can't re-execute. return nil, nil } block := types.NewBlockFromStorage(hash, header, body.Transactions, body.Uncles, body.Withdrawals) - bal, err := computeBlockAccessList(ctx, r.deps.ChainConfig, r.deps.Engine, block, stateReader, headerReader, blockHashFunc, r.deps.Logger) + // ComputeBlockContext at txIndex=0 returns an IBS reading from the + // state BEFORE the block's first transaction (= post-state of the + // parent block). Same machinery the RPC tracing path uses. + ibs, blockCtx, _, vmRules, signer, err := transactions.ComputeBlockContext(ctx, r.deps.Engine, header, r.deps.ChainConfig, r.deps.BlockReader, nil, r.deps.TxNumsReader, tx, 0) if err != nil { - return nil, fmt.Errorf("BALRegenerator: re-exec block %d: %w", number, err) + return nil, fmt.Errorf("BALRegenerator: ComputeBlockContext: %w", err) + } + // IBS read-tracking requires a VersionMap — versionedRead is the + // only path that populates ibs.versionedReads, which TxIO() reads + // to build the BAL. + ibs.SetVersionMap(state.NewVersionMap(nil)) + + bal, err := replayBlockForBAL(ctx, r.deps.ChainConfig, r.deps.Engine, block, &blockCtx, vmRules, signer, ibs, r.deps.Logger) + if err != nil { + return nil, fmt.Errorf("BALRegenerator: replay block %d: %w", number, err) } if bal == nil { return nil, nil } - balBytes, err := types.EncodeBlockAccessListBytes(bal) - if err != nil { - return nil, fmt.Errorf("BALRegenerator: encode: %w", err) - } - return balBytes, nil + return types.EncodeBlockAccessListBytes(bal) } -// dbForReader is a placeholder for the kv.Getter context the HeaderReader/ -// BodyWithTransactions paths sometimes need. The closure inside -// NewParentStateReader is expected to share its tx with the header reader; this -// is the seam where it's threaded through. -// -// TODO: the BlockReader's Header/BodyWithTransactions need an explicit kv.Tx — -// NewParentStateReader currently exposes only a state.StateReader. Surface the -// tx alongside the state reader so this becomes a real argument. -func dbForReader(_ services.HeaderReader) kv.Getter { - return nil -} - -// computeBlockAccessList runs the block transactions through a simple IBS with -// VersionMap-enabled read tracking and returns the accumulated BAL. Mirrors the -// per-tx Merge pattern used by the block assembler — no parallel exec needed. -func computeBlockAccessList( +// replayBlockForBAL drives the per-tx loop, merging the IBS's TxIO into a +// per-block VersionedIO. Mirrors the block-assembler pattern — no parallel +// exec, no state writer. Returns the accumulated BAL after the last tx. +func replayBlockForBAL( ctx context.Context, chainConfig *chain.Config, engine rules.Engine, block *types.Block, - stateReader state.StateReader, - headerReader services.HeaderReader, - blockHashFunc func(n uint64) (common.Hash, error), + blockCtx *evmtypes.BlockContext, + vmRules *chain.Rules, + signer *types.Signer, + ibs *state.IntraBlockState, logger log.Logger, ) (types.BlockAccessList, error) { - ibs := state.New(stateReader) - defer ibs.Release(false) - // Read tracking requires a VersionMap — versionedRead is the only path - // that populates ibs.versionedReads. An empty VersionMap is fine; we - // don't need cross-tx coordination here. - ibs.SetVersionMap(state.NewVersionMap(nil)) - header := block.HeaderNoCopy() gp := new(protocol.GasPool).AddGas(block.GasLimit()).AddBlobGas(chainConfig.GetMaxBlobGasPerBlock(block.Time())) gasUsed := new(protocol.GasUsed) - chainReader := newChainReaderShim(chainConfig, headerReader) - + // chainReaderShim only exposes Config() — InitializeBlockExecution's + // system-init paths don't reach for parent headers here. + chainReader := &chainReaderShim{cfg: chainConfig} if err := protocol.InitializeBlockExecution(engine, chainReader, header, chainConfig, ibs, state.NewNoopWriter(), logger, nil); err != nil { return nil, fmt.Errorf("InitializeBlockExecution: %w", err) } @@ -167,12 +152,22 @@ func computeBlockAccessList( balIO = *balIO.Merge(ibs.TxIO()) ibs.ResetVersionedIO() + _ = blockCtx + _ = vmRules + _ = signer + + blockHashFn := protocol.GetHashFn(header, func(common.Hash, uint64) (*types.Header, error) { + // BAL re-execution doesn't need cross-block BLOCKHASH lookups — + // the relevant headers are already in blockCtx for this block. + return nil, nil + }) + for i, txn := range block.Transactions() { if err := ctx.Err(); err != nil { return nil, err } ibs.SetTxContext(block.NumberU64(), i) - _, err := protocol.ApplyTransaction(chainConfig, blockHashFunc, engine, accounts.NilAddress, gp, ibs, state.NewNoopWriter(), header, txn, gasUsed, vm.Config{NoReceipts: true}) + _, err := protocol.ApplyTransaction(chainConfig, blockHashFn, engine, accounts.NilAddress, gp, ibs, state.NewNoopWriter(), header, txn, gasUsed, vm.Config{NoReceipts: true}) if err != nil { return nil, fmt.Errorf("apply tx %d (%x): %w", i, txn.Hash(), err) } @@ -180,30 +175,21 @@ func computeBlockAccessList( ibs.ResetVersionedIO() } - // Finalize step is intentionally omitted: the engine.Finalize signatures - // differ across forks and require receipts/systemCall hooks we don't - // reconstruct here. Withdrawal + system-call accesses are typically - // captured during InitializeBlockExecution; any post-tx finalize writes - // would only matter for accounts already in the BAL from the tx loop. - // If the resulting BAL hash disagrees with header.BlockAccessListHash - // on a downstream peer's verification, that's the signal to add finalize - // support here (with the proper system-call hooks). + // Finalize-stage system writes (withdrawals, BeaconRoot, etc.) are + // captured during InitializeBlockExecution; engine.Finalize signatures + // vary by fork and would require receipts + system-call hooks we don't + // reconstruct here. If a downstream hash check flags a mismatch on + // finalize-touched accounts, that's the signal to add fork-aware + // finalize support to this path. return balIO.AsBlockAccessList(), nil } // chainReaderShim is a minimal adapter for rules.ChainHeaderReader that -// protocol.InitializeBlockExecution requires. The header-lookup methods stay -// nil because system-init calls in this path don't reach for parent headers -// (the only thing that would need them is engine.Finalize, which we don't -// invoke here). +// protocol.InitializeBlockExecution requires. The header-lookup methods +// stay nil because the BAL replay path doesn't need parent headers. type chainReaderShim struct { - cfg *chain.Config - reader services.HeaderReader -} - -func newChainReaderShim(cfg *chain.Config, reader services.HeaderReader) *chainReaderShim { - return &chainReaderShim{cfg: cfg, reader: reader} + cfg *chain.Config } func (s *chainReaderShim) Config() *chain.Config { return s.cfg } diff --git a/node/eth/backend.go b/node/eth/backend.go index 53bac782138..97bdd1c4ce9 100644 --- a/node/eth/backend.go +++ b/node/eth/backend.go @@ -1031,6 +1031,19 @@ func New(ctx context.Context, stack *node.Node, config *ethconfig.Config, logger ) backend.execModule.SetPublishedSD(backend.notifications.Events.LatestSD) + // Install the BAL regenerator so eth/71 GetBlockAccessLists serving can + // fall back to re-executing the block when nothing is cached. BAL bytes + // are no longer persisted to MDBX (see db/rawdb/balcache.go), so older + // blocks needed by peers or RPC must be reconstructed on demand. + rawdb.SetBALRegenerator(execmodule.NewBALRegenerator(execmodule.BALRegeneratorDeps{ + DB: backend.chainDB, + ChainConfig: chainConfig, + Engine: backend.engine, + BlockReader: blockReader, + TxNumsReader: blockReader.TxnumReader(), + Logger: logger, + })) + var executionEngine executionclient.ExecutionEngine executionEngine, err = executionclient.NewExecutionClientDirect(chainreader.NewChainReaderEth1(chainConfig, backend.execModule, config.FcuTimeout), txPoolRpcClient) From f4901b5fb034b7221c03047ed40a72b4c44fbe14 Mon Sep 17 00:00:00 2001 From: Mark Holt Date: Wed, 13 May 2026 16:57:34 +0000 Subject: [PATCH 4/8] rawdb, p2p/eth, engine_block_downloader: opportunistic BAL fetch during block sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the engine_block_downloader pulls blocks backwards from peers (catch-up sync, segment recovery), each batch is now followed by a non-blocking eth/71 GetBlockAccessLists request for those blocks. Hash-verified BALs populate the in-memory cache so the subsequent exec stage avoids local re-execution to derive them. - db/rawdb/balcache.go: new BALSyncFetcher interface + SetBALSyncFetcher / GetBALSyncFetcher. Mirrors the BALRegenerator plumbing (atomic.Pointer slot, nil-safe). - p2p/sentry/sentry_multi_client/bal_downloader.go: BALDownloader implements BALSyncFetcher.FetchBALs — picks an eth/71 peer, batches the request, caches every hash-validated response. Non-blocking by intent; failures fall through to the BALRegenerator on later lookup. - node/eth/backend.go: SetBALSyncFetcher(balDownloader) at startup alongside the existing background scan. - execution/engineapi/engine_block_downloader/block_downloader.go: after each feed.Next batch, kick a goroutine to FetchBALs for the batch's Amsterdam blocks (BlockAccessListHash != nil). engine_newPayload is intentionally NOT updated: post-Amsterdam the Engine API spec requires the CL to include the BAL bytes in the payload; a missing BAL is rejected as InvalidParamsError. Catch-up sync (where blocks arrive without BAL bodies) is covered by the engine_block_downloader hook above. Co-Authored-By: Claude Opus 4.7 (1M context) --- db/rawdb/balcache.go | 48 ++++++++++++++++++- .../block_downloader.go | 29 +++++++++++ node/eth/backend.go | 11 +++-- .../sentry_multi_client/bal_downloader.go | 42 ++++++++++++++++ .../sentry_multi_client_test.go | 6 +-- 5 files changed, 129 insertions(+), 7 deletions(-) diff --git a/db/rawdb/balcache.go b/db/rawdb/balcache.go index 33e5da03027..2ed4244095a 100644 --- a/db/rawdb/balcache.go +++ b/db/rawdb/balcache.go @@ -41,6 +41,13 @@ type balRegeneratorSlot struct{ r BALRegenerator } var balRegenerator atomic.Pointer[balRegeneratorSlot] +// balSyncFetcher holds the optionally-installed eth/71 peer-fetch backend used +// by sync paths to opportunistically fill the cache with peer-fetched BALs +// before falling back to local re-execution. +type balSyncFetcherSlot struct{ f BALSyncFetcher } + +var balSyncFetcher atomic.Pointer[balSyncFetcherSlot] + func init() { var err error balCache, err = lru.New[common.Hash, []byte](balCacheSize) @@ -84,6 +91,43 @@ func getBALRegenerator() BALRegenerator { return slot.r } +// BALSyncFetcher is invoked by sync paths (engine_block_downloader, +// engine_newPayload) to opportunistically fetch BALs from eth/71 peers and +// cache them. Implementations are non-blocking: they queue work and return +// quickly; sync stages don't wait on the fetch. +// +// hashes / numbers / expected are aligned positionally — entry i refers to +// the block at hashes[i] with number numbers[i], whose header committed to +// BAL hash expected[i]. Each successful fetch is hash-verified against +// expected before being cached (matches the spec for eth/71 GetBlockAccessLists +// responses). +// +// A missing or nil BALSyncFetcher is fine — the cache will fall through to +// the BALRegenerator on a later lookup. +type BALSyncFetcher interface { + FetchBALs(ctx context.Context, hashes []common.Hash, numbers []uint64, expected []common.Hash) +} + +// SetBALSyncFetcher installs the eth/71 peer-fetch backend used by sync +// hooks. Pass nil to clear. Safe to call multiple times. +func SetBALSyncFetcher(f BALSyncFetcher) { + if f == nil { + balSyncFetcher.Store(nil) + return + } + balSyncFetcher.Store(&balSyncFetcherSlot{f: f}) +} + +// GetBALSyncFetcher returns the installed fetcher, or nil if none is set. +// Sync hooks call this and dispatch when non-nil. +func GetBALSyncFetcher() BALSyncFetcher { + slot := balSyncFetcher.Load() + if slot == nil { + return nil + } + return slot.f +} + // CacheBlockAccessList stores the block's RLP-encoded BAL bytes in the in-memory // cache instead of persisting them to the chaindata DB. Writing the BAL to MDBX // on the NewPayload critical path was a multi-hundred-KB Put per block that, on a @@ -142,8 +186,10 @@ func BlockAccessListBytes(ctx context.Context, hash common.Hash, number uint64) return generated, nil } -// ResetBALCacheForTest clears the in-memory cache and the regenerator. Test-only. +// ResetBALCacheForTest clears the in-memory cache, the regenerator, and the +// sync fetcher. Test-only. func ResetBALCacheForTest() { balCache.Purge() balRegenerator.Store(nil) + balSyncFetcher.Store(nil) } diff --git a/execution/engineapi/engine_block_downloader/block_downloader.go b/execution/engineapi/engine_block_downloader/block_downloader.go index 3517c86bb49..3ccaa120238 100644 --- a/execution/engineapi/engine_block_downloader/block_downloader.go +++ b/execution/engineapi/engine_block_downloader/block_downloader.go @@ -29,6 +29,7 @@ import ( "github.com/erigontech/erigon/common" "github.com/erigontech/erigon/common/log/v3" "github.com/erigontech/erigon/db/kv" + "github.com/erigontech/erigon/db/rawdb" "github.com/erigontech/erigon/db/services" "github.com/erigontech/erigon/execution/chain" "github.com/erigontech/erigon/execution/execmodule" @@ -215,6 +216,17 @@ func (e *EngineBlockDownloader) downloadBlocks(ctx context.Context, req Backward default: e.logger.Trace("[EngineBlockDownloader] processing downloaded blocks", progressLogArgs...) } + // Opportunistically ask eth/71 peers for the BALs of the blocks we + // just downloaded so they're cached by the time the exec stage + // runs (avoids local BAL regeneration). Non-blocking: if no + // eth/71 peer is available or the request fails, the cache miss + // will fall through to the BALRegenerator later. + if fetcher := rawdb.GetBALSyncFetcher(); fetcher != nil { + hashes, numbers, expected := collectBALFetchRequests(blocks) + if len(hashes) > 0 { + go fetcher.FetchBALs(ctx, hashes, numbers, expected) + } + } err := e.chainRW.InsertBlocksAndWait(ctx, blocks) if err != nil { return err @@ -289,3 +301,20 @@ func (e *EngineBlockDownloader) execDownloadedBatch(ctx context.Context, block * } return nil } + +// collectBALFetchRequests pulls (hash, number, expectedBALHash) for every +// block in the batch whose header commits to a BAL. Pre-Amsterdam blocks +// (BlockAccessListHash == nil) are skipped. Returns positionally-aligned +// slices for rawdb.BALSyncFetcher.FetchBALs. +func collectBALFetchRequests(blocks []*types.Block) (hashes []common.Hash, numbers []uint64, expected []common.Hash) { + for _, b := range blocks { + h := b.HeaderNoCopy() + if h == nil || h.BlockAccessListHash == nil { + continue + } + hashes = append(hashes, b.Hash()) + numbers = append(numbers, b.NumberU64()) + expected = append(expected, *h.BlockAccessListHash) + } + return +} diff --git a/node/eth/backend.go b/node/eth/backend.go index 97bdd1c4ce9..18a753c8047 100644 --- a/node/eth/backend.go +++ b/node/eth/backend.go @@ -728,9 +728,14 @@ func New(ctx context.Context, stack *node.Node, config *ethconfig.Config, logger if chainConfig.AmsterdamTime != nil { // Always-on once gated, negotiation-driven: if no peer advertises eth/71 // this is a silent no-op per scan pass. When eth/71 peers connect, the - // downloader backfills missing BALs into rawdb so subsequent stage_exec - // runs can skip local BAL regeneration. - go sentry_multi_client.NewBALDownloader(backend.sentryProvider.Client, backend.chainDB, logger).Run(backend.sentryCtx) + // downloader backfills missing BALs into the in-memory cache so + // subsequent serving (eth/71, RPC) can avoid local BAL regeneration. + balDownloader := sentry_multi_client.NewBALDownloader(backend.sentryProvider.Client, backend.chainDB, logger) + // Expose the on-demand FetchBALs entry point to sync hooks + // (engine_block_downloader after a batch, engine_newPayload when + // the CL payload doesn't carry a BAL). + rawdb.SetBALSyncFetcher(balDownloader) + go balDownloader.Run(backend.sentryCtx) } var txnProvider txnprovider.TxnProvider diff --git a/p2p/sentry/sentry_multi_client/bal_downloader.go b/p2p/sentry/sentry_multi_client/bal_downloader.go index c2451b91d57..1104fc38348 100644 --- a/p2p/sentry/sentry_multi_client/bal_downloader.go +++ b/p2p/sentry/sentry_multi_client/bal_downloader.go @@ -304,6 +304,48 @@ func (d *BALDownloader) fetchBatch(ctx context.Context, peer [64]byte, sentryI i } } +// FetchBALs implements rawdb.BALSyncFetcher: picks an eth/71 peer and +// requests BALs for the given (hash, number, expected) tuples in a single +// batched call, caching every hash-verified response. Non-blocking by intent +// — failures (no peer, peer declined, timeout) are silently dropped because +// the cache miss can still be filled later via the BALRegenerator. Sync +// stages MUST NOT block on this fetch. +// +// Splits the request into batches that stay below the eth/71 softResponseLimit +// (~2 MiB per response, ~24 BALs / batch). +func (d *BALDownloader) FetchBALs(ctx context.Context, hashes []common.Hash, numbers []uint64, expected []common.Hash) { + if len(hashes) == 0 { + return + } + if len(numbers) != len(hashes) || len(expected) != len(hashes) { + d.logger.Debug("[bal-downloader] FetchBALs called with misaligned slices", + "hashes", len(hashes), "numbers", len(numbers), "expected", len(expected)) + return + } + peer, sentryI, found, err := d.pickEth71Peer(ctx) + if err != nil || !found { + // No eth/71 peer available — caller can rely on the BALRegenerator + // when the cache miss surfaces. + return + } + const batchSize = 24 + for i := 0; i < len(hashes); i += batchSize { + end := i + batchSize + if end > len(hashes) { + end = len(hashes) + } + batch := make([]missingBAL, 0, end-i) + for j := i; j < end; j++ { + batch = append(batch, missingBAL{ + hash: hashes[j], + number: numbers[j], + expected: expected[j], + }) + } + d.fetchBatch(ctx, peer, sentryI, batch) + } +} + // pickEth71Peer iterates all sentries and returns a random peer that // advertises the eth/71 capability, plus the index of the sentry that peer // is connected via, or (_, _, false, nil) if none is connected. The sentry diff --git a/p2p/sentry/sentry_multi_client/sentry_multi_client_test.go b/p2p/sentry/sentry_multi_client/sentry_multi_client_test.go index f388f46894d..b477ece2c7d 100644 --- a/p2p/sentry/sentry_multi_client/sentry_multi_client_test.go +++ b/p2p/sentry/sentry_multi_client/sentry_multi_client_test.go @@ -291,9 +291,9 @@ func TestGetBlockAccessLists71_AnswersAndSends(t *testing.T) { hashUnknown := common.Hash{0x02} const knownBlockNum uint64 = 100 bal := []byte{0xc3, 0x01, 0x02, 0x03} // short valid RLP non-empty payload - if err := rawdb.WriteBlockAccessListBytes(rwTx, hashKnown, knownBlockNum, bal); err != nil { - t.Fatalf("WriteBlockAccessListBytes: %v", err) - } + rawdb.ResetBALCacheForTest() + t.Cleanup(rawdb.ResetBALCacheForTest) + rawdb.CacheBlockAccessList(hashKnown, bal) if err := rwTx.Commit(); err != nil { t.Fatalf("commit: %v", err) } From 3ebe0333b623cedb6d50167f9d6e14c2bd132475 Mon Sep 17 00:00:00 2001 From: Mark Holt Date: Wed, 13 May 2026 17:05:35 +0000 Subject: [PATCH 5/8] =?UTF-8?q?balcache:=20move=20db/rawdb=20=E2=86=92=20e?= =?UTF-8?q?xecution/balcache=20(no=20DB,=20no=20rawdb=20home)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cache is purely in-memory — no MDBX writes, no MDBX reads. Living under db/rawdb was a misnomer left over from the original implementation. New location is execution/balcache, neighbouring the types.BlockAccessList producers. No API renames; consumers swap rawdb.* → balcache.* prefixes. Drops the now-unused db/rawdb import from every consumer that only reached the package for the cache symbols. Co-Authored-By: Claude Opus 4.7 (1M context) --- {db/rawdb => execution/balcache}/balcache.go | 2 +- .../balcache}/balcache_test.go | 86 +++++++++---------- .../block_downloader.go | 6 +- execution/engineapi/engine_server_test.go | 12 +-- execution/execmodule/balregen.go | 2 +- execution/execmodule/getters.go | 5 +- execution/execmodule/inserters.go | 3 +- execution/stagedsync/bal_create.go | 4 +- execution/stagedsync/exec3.go | 3 +- node/eth/backend.go | 5 +- p2p/protocols/eth/handlers.go | 3 +- p2p/protocols/eth/handlers_test.go | 14 +-- .../sentry_multi_client/bal_downloader.go | 8 +- .../sentry_multi_client_test.go | 8 +- rpc/jsonrpc/eth_block_access_list.go | 4 +- 15 files changed, 86 insertions(+), 79 deletions(-) rename {db/rawdb => execution/balcache}/balcache.go (99%) rename {db/rawdb => execution/balcache}/balcache_test.go (67%) diff --git a/db/rawdb/balcache.go b/execution/balcache/balcache.go similarity index 99% rename from db/rawdb/balcache.go rename to execution/balcache/balcache.go index 2ed4244095a..afd371c4068 100644 --- a/db/rawdb/balcache.go +++ b/execution/balcache/balcache.go @@ -14,7 +14,7 @@ // You should have received a copy of the GNU Lesser General Public License // along with Erigon. If not, see . -package rawdb +package balcache import ( "context" diff --git a/db/rawdb/balcache_test.go b/execution/balcache/balcache_test.go similarity index 67% rename from db/rawdb/balcache_test.go rename to execution/balcache/balcache_test.go index 061d41ec9e1..a4d7bc5d66d 100644 --- a/db/rawdb/balcache_test.go +++ b/execution/balcache/balcache_test.go @@ -14,7 +14,7 @@ // You should have received a copy of the GNU Lesser General Public License // along with Erigon. If not, see . -package rawdb_test +package balcache_test import ( "context" @@ -25,7 +25,7 @@ import ( "github.com/stretchr/testify/require" "github.com/erigontech/erigon/common" - "github.com/erigontech/erigon/db/rawdb" + "github.com/erigontech/erigon/execution/balcache" ) // fakeRegenerator records every regeneration request + serves canned responses. @@ -63,128 +63,128 @@ func hashFromByte(b byte) common.Hash { } func TestBlockAccessListBytes_CacheHitShortCircuits(t *testing.T) { - t.Cleanup(rawdb.ResetBALCacheForTest) - rawdb.ResetBALCacheForTest() + t.Cleanup(balcache.ResetBALCacheForTest) + balcache.ResetBALCacheForTest() hash := hashFromByte(0x01) data := []byte{0xc1, 0x00} - rawdb.CacheBlockAccessList(hash, data) + balcache.CacheBlockAccessList(hash, data) // Install a regenerator that, if called, fails the test. - rawdb.SetBALRegenerator(&fakeRegenerator{errAlways: errFakeRegen}) + balcache.SetBALRegenerator(&fakeRegenerator{errAlways: errFakeRegen}) - got, err := rawdb.BlockAccessListBytes(context.Background(), hash, 7) + got, err := balcache.BlockAccessListBytes(context.Background(), hash, 7) require.NoError(t, err) require.Equal(t, data, got) } func TestBlockAccessListBytes_RegeneratorFallback(t *testing.T) { - t.Cleanup(rawdb.ResetBALCacheForTest) - rawdb.ResetBALCacheForTest() + t.Cleanup(balcache.ResetBALCacheForTest) + balcache.ResetBALCacheForTest() hash := hashFromByte(0x03) regenerated := []byte{0xc3, 0x42} regen := &fakeRegenerator{defaultBytes: regenerated} - rawdb.SetBALRegenerator(regen) + balcache.SetBALRegenerator(regen) - got, err := rawdb.BlockAccessListBytes(context.Background(), hash, 22) + got, err := balcache.BlockAccessListBytes(context.Background(), hash, 22) require.NoError(t, err) require.Equal(t, regenerated, got) require.Equal(t, int32(1), regen.calls.Load(), "regenerator should be called once on miss") // Repeated lookup hits the cache, regenerator NOT called again. - got2, err := rawdb.BlockAccessListBytes(context.Background(), hash, 22) + got2, err := balcache.BlockAccessListBytes(context.Background(), hash, 22) require.NoError(t, err) require.Equal(t, regenerated, got2) require.Equal(t, int32(1), regen.calls.Load(), "cached regenerated BAL must short-circuit subsequent lookups") } func TestBlockAccessListBytes_NoRegeneratorOnMiss(t *testing.T) { - t.Cleanup(rawdb.ResetBALCacheForTest) - rawdb.ResetBALCacheForTest() + t.Cleanup(balcache.ResetBALCacheForTest) + balcache.ResetBALCacheForTest() hash := hashFromByte(0x04) - rawdb.SetBALRegenerator(nil) - got, err := rawdb.BlockAccessListBytes(context.Background(), hash, 33) + balcache.SetBALRegenerator(nil) + got, err := balcache.BlockAccessListBytes(context.Background(), hash, 33) require.NoError(t, err) require.Nil(t, got, "no cache, no regenerator → nil bytes (peer sees 'not available')") } func TestBlockAccessListBytes_RegeneratorReturnsNil(t *testing.T) { - t.Cleanup(rawdb.ResetBALCacheForTest) - rawdb.ResetBALCacheForTest() + t.Cleanup(balcache.ResetBALCacheForTest) + balcache.ResetBALCacheForTest() hash := hashFromByte(0x05) regen := &fakeRegenerator{} // defaultBytes nil - rawdb.SetBALRegenerator(regen) + balcache.SetBALRegenerator(regen) - got, err := rawdb.BlockAccessListBytes(context.Background(), hash, 44) + got, err := balcache.BlockAccessListBytes(context.Background(), hash, 44) require.NoError(t, err) require.Nil(t, got) require.Equal(t, int32(1), regen.calls.Load()) // A nil-from-regenerator must NOT be cached (so a later install of a // real regenerator can succeed). - _, ok := rawdb.CachedBlockAccessList(hash) + _, ok := balcache.CachedBlockAccessList(hash) require.False(t, ok, "nil regeneration result must not be cached") } func TestBlockAccessListBytes_RegeneratorError(t *testing.T) { - t.Cleanup(rawdb.ResetBALCacheForTest) - rawdb.ResetBALCacheForTest() + t.Cleanup(balcache.ResetBALCacheForTest) + balcache.ResetBALCacheForTest() hash := hashFromByte(0x06) regen := &fakeRegenerator{errAlways: errFakeRegen} - rawdb.SetBALRegenerator(regen) + balcache.SetBALRegenerator(regen) - _, err := rawdb.BlockAccessListBytes(context.Background(), hash, 55) + _, err := balcache.BlockAccessListBytes(context.Background(), hash, 55) require.ErrorIs(t, err, errFakeRegen) - _, ok := rawdb.CachedBlockAccessList(hash) + _, ok := balcache.CachedBlockAccessList(hash) require.False(t, ok, "regenerator error must not pollute the cache") } func TestCacheBlockAccessList_EmptyIsNoOp(t *testing.T) { - t.Cleanup(rawdb.ResetBALCacheForTest) - rawdb.ResetBALCacheForTest() + t.Cleanup(balcache.ResetBALCacheForTest) + balcache.ResetBALCacheForTest() hash := hashFromByte(0x07) - rawdb.CacheBlockAccessList(hash, nil) - rawdb.CacheBlockAccessList(hash, []byte{}) - _, ok := rawdb.CachedBlockAccessList(hash) + balcache.CacheBlockAccessList(hash, nil) + balcache.CacheBlockAccessList(hash, []byte{}) + _, ok := balcache.CachedBlockAccessList(hash) require.False(t, ok, "empty data must not be cached (would conflate with 'not available')") } func TestCacheBlockAccessList_CopiesBytes(t *testing.T) { - t.Cleanup(rawdb.ResetBALCacheForTest) - rawdb.ResetBALCacheForTest() + t.Cleanup(balcache.ResetBALCacheForTest) + balcache.ResetBALCacheForTest() hash := hashFromByte(0x08) src := []byte{0xde, 0xad, 0xbe, 0xef} - rawdb.CacheBlockAccessList(hash, src) + balcache.CacheBlockAccessList(hash, src) src[0] = 0xff // mutate caller's slice — cache must hold its own copy - got, ok := rawdb.CachedBlockAccessList(hash) + got, ok := balcache.CachedBlockAccessList(hash) require.True(t, ok) require.Equal(t, byte(0xde), got[0], "cache must defensively copy the input bytes") } func TestSetBALRegenerator_ReplaceAndClear(t *testing.T) { - t.Cleanup(rawdb.ResetBALCacheForTest) - rawdb.ResetBALCacheForTest() + t.Cleanup(balcache.ResetBALCacheForTest) + balcache.ResetBALCacheForTest() hash := hashFromByte(0x09) r1 := &fakeRegenerator{defaultBytes: []byte{0xa1}} r2 := &fakeRegenerator{defaultBytes: []byte{0xa2}} - rawdb.SetBALRegenerator(r1) - rawdb.SetBALRegenerator(r2) // replace + balcache.SetBALRegenerator(r1) + balcache.SetBALRegenerator(r2) // replace - got, err := rawdb.BlockAccessListBytes(context.Background(), hash, 99) + got, err := balcache.BlockAccessListBytes(context.Background(), hash, 99) require.NoError(t, err) require.Equal(t, []byte{0xa2}, got) require.Zero(t, r1.calls.Load(), "old regenerator must not be called after replacement") require.Equal(t, int32(1), r2.calls.Load()) - rawdb.ResetBALCacheForTest() // clear cache so the next lookup hits the regenerator again - rawdb.SetBALRegenerator(nil) // explicit clear - got, err = rawdb.BlockAccessListBytes(context.Background(), hash, 99) + balcache.ResetBALCacheForTest() // clear cache so the next lookup hits the regenerator again + balcache.SetBALRegenerator(nil) // explicit clear + got, err = balcache.BlockAccessListBytes(context.Background(), hash, 99) require.NoError(t, err) require.Nil(t, got, "cleared regenerator → miss returns nil") } diff --git a/execution/engineapi/engine_block_downloader/block_downloader.go b/execution/engineapi/engine_block_downloader/block_downloader.go index 3ccaa120238..051f1ea62b1 100644 --- a/execution/engineapi/engine_block_downloader/block_downloader.go +++ b/execution/engineapi/engine_block_downloader/block_downloader.go @@ -29,7 +29,7 @@ import ( "github.com/erigontech/erigon/common" "github.com/erigontech/erigon/common/log/v3" "github.com/erigontech/erigon/db/kv" - "github.com/erigontech/erigon/db/rawdb" + "github.com/erigontech/erigon/execution/balcache" "github.com/erigontech/erigon/db/services" "github.com/erigontech/erigon/execution/chain" "github.com/erigontech/erigon/execution/execmodule" @@ -221,7 +221,7 @@ func (e *EngineBlockDownloader) downloadBlocks(ctx context.Context, req Backward // runs (avoids local BAL regeneration). Non-blocking: if no // eth/71 peer is available or the request fails, the cache miss // will fall through to the BALRegenerator later. - if fetcher := rawdb.GetBALSyncFetcher(); fetcher != nil { + if fetcher := balcache.GetBALSyncFetcher(); fetcher != nil { hashes, numbers, expected := collectBALFetchRequests(blocks) if len(hashes) > 0 { go fetcher.FetchBALs(ctx, hashes, numbers, expected) @@ -305,7 +305,7 @@ func (e *EngineBlockDownloader) execDownloadedBatch(ctx context.Context, block * // collectBALFetchRequests pulls (hash, number, expectedBALHash) for every // block in the batch whose header commits to a BAL. Pre-Amsterdam blocks // (BlockAccessListHash == nil) are skipped. Returns positionally-aligned -// slices for rawdb.BALSyncFetcher.FetchBALs. +// slices for balcache.BALSyncFetcher.FetchBALs. func collectBALFetchRequests(blocks []*types.Block) (hashes []common.Hash, numbers []uint64, expected []common.Hash) { for _, b := range blocks { h := b.HeaderNoCopy() diff --git a/execution/engineapi/engine_server_test.go b/execution/engineapi/engine_server_test.go index 1fc483cf245..10c2db641cf 100644 --- a/execution/engineapi/engine_server_test.go +++ b/execution/engineapi/engine_server_test.go @@ -37,6 +37,7 @@ import ( "github.com/erigontech/erigon/db/kv" "github.com/erigontech/erigon/db/kv/kvcache" "github.com/erigontech/erigon/db/rawdb" + "github.com/erigontech/erigon/execution/balcache" "github.com/erigontech/erigon/execution/chain" "github.com/erigontech/erigon/execution/execmodule/execmoduletester" "github.com/erigontech/erigon/execution/tests/blockgen" @@ -302,12 +303,13 @@ func canonicalHashAt(t *testing.T, db kv.TemporalRoDB, blockNum uint64) common.H return hash } -func writeBlockAccessListBytes(t *testing.T, db kv.TemporalRwDB, blockHash common.Hash, blockNum uint64, balBytes []byte) { +func writeBlockAccessListBytes(t *testing.T, _ kv.TemporalRwDB, blockHash common.Hash, _ uint64, balBytes []byte) { t.Helper() - err := db.Update(context.Background(), func(tx kv.RwTx) error { - return rawdb.WriteBlockAccessListBytes(tx, blockHash, blockNum, balBytes) - }) - require.NoError(t, err) + // BALs are cache-only — the lookup path consults + // balcache.BlockAccessListBytes which checks the cache first, then the + // regenerator. Writing directly to the cache makes the test see exactly + // these bytes without re-executing. + balcache.CacheBlockAccessList(blockHash, balBytes) } func TestGetPayloadBodiesByHashV2(t *testing.T) { diff --git a/execution/execmodule/balregen.go b/execution/execmodule/balregen.go index b816f2bf0f1..bd690ca3b7c 100644 --- a/execution/execmodule/balregen.go +++ b/execution/execmodule/balregen.go @@ -50,7 +50,7 @@ type BALRegeneratorDeps struct { Logger log.Logger } -// BALRegenerator implements rawdb.BALRegenerator by re-executing the requested +// BALRegenerator implements balcache.BALRegenerator by re-executing the requested // block against its parent state with VersionMap-enabled IBS read tracking. // Uses transactions.ComputeBlockContext to construct a state reader rooted at // the parent state — same approach as RPC tracing / receipts generation, so diff --git a/execution/execmodule/getters.go b/execution/execmodule/getters.go index c9010d82e56..4225baabce0 100644 --- a/execution/execmodule/getters.go +++ b/execution/execmodule/getters.go @@ -27,6 +27,7 @@ import ( "github.com/erigontech/erigon/common" "github.com/erigontech/erigon/db/kv" "github.com/erigontech/erigon/db/rawdb" + "github.com/erigontech/erigon/execution/balcache" "github.com/erigontech/erigon/execution/types" ) @@ -264,7 +265,7 @@ func (e *ExecModule) GetPayloadBodiesByHash(ctx context.Context, hashes []common if err != nil { return nil, fmt.Errorf("ethereumExecutionModule.GetPayloadBodiesByHash: MarshalTransactionsBinary error %w", err) } - balBytes, err := rawdb.BlockAccessListBytes(ctx, h, *number) + balBytes, err := balcache.BlockAccessListBytes(ctx, h, *number) if err != nil { return nil, fmt.Errorf("ethereumExecutionModule.GetPayloadBodiesByHash: BlockAccessListBytes error %w", err) } @@ -310,7 +311,7 @@ func (e *ExecModule) GetPayloadBodiesByRange(ctx context.Context, start, count u if err != nil { return nil, fmt.Errorf("ethereumExecutionModule.GetPayloadBodiesByRange: MarshalTransactionsBinary error %w", err) } - balBytes, err := rawdb.BlockAccessListBytes(ctx, hash, blockNum) + balBytes, err := balcache.BlockAccessListBytes(ctx, hash, blockNum) if err != nil { return nil, fmt.Errorf("ethereumExecutionModule.GetPayloadBodiesByRange: BlockAccessListBytes error %w", err) } diff --git a/execution/execmodule/inserters.go b/execution/execmodule/inserters.go index 5efa9f457e1..4507bb68bb4 100644 --- a/execution/execmodule/inserters.go +++ b/execution/execmodule/inserters.go @@ -24,6 +24,7 @@ import ( "github.com/holiman/uint256" "github.com/erigontech/erigon/db/rawdb" + "github.com/erigontech/erigon/execution/balcache" "github.com/erigontech/erigon/db/state/execctx" "github.com/erigontech/erigon/execution/commitment/commitmentdb" "github.com/erigontech/erigon/execution/metrics" @@ -144,7 +145,7 @@ func (e *ExecModule) InsertBlocks(ctx context.Context, blocks []*types.RawBlock) // chaindata write was tens of seconds per block on churned DBs; // see db/rawdb/balcache.go. Older blocks needed by eth/71 peers // or RPC are regenerated on demand via the BALRegenerator. - rawdb.CacheBlockAccessList(header.Hash(), block.BlockAccessList) + balcache.CacheBlockAccessList(header.Hash(), block.BlockAccessList) } e.logger.Trace("Inserted block", "hash", header.Hash(), "number", header.Number) } diff --git a/execution/stagedsync/bal_create.go b/execution/stagedsync/bal_create.go index 62e506f30ce..4bc33c562b7 100644 --- a/execution/stagedsync/bal_create.go +++ b/execution/stagedsync/bal_create.go @@ -8,7 +8,7 @@ import ( "github.com/erigontech/erigon/common/dbg" "github.com/erigontech/erigon/common/log/v3" "github.com/erigontech/erigon/db/kv" - "github.com/erigontech/erigon/db/rawdb" + "github.com/erigontech/erigon/execution/balcache" "github.com/erigontech/erigon/execution/protocol/rules" "github.com/erigontech/erigon/execution/state" "github.com/erigontech/erigon/execution/types" @@ -79,7 +79,7 @@ func ProcessBAL(tx kv.TemporalRwTx, h *types.Header, vio *state.VersionedIO, isE // cached BAL here for the validator cross-check. Cache misses are OK — // not every code path has a sidecar (backward block downloader doesn't // carry one). - dbBALBytes, _ := rawdb.CachedBlockAccessList(blockHash) + dbBALBytes, _ := balcache.CachedBlockAccessList(blockHash) if dbBALBytes != nil { dbBAL, err := types.DecodeBlockAccessListBytes(dbBALBytes) if err != nil { diff --git a/execution/stagedsync/exec3.go b/execution/stagedsync/exec3.go index 0d2b61bd6c0..ab20ad157e0 100644 --- a/execution/stagedsync/exec3.go +++ b/execution/stagedsync/exec3.go @@ -35,6 +35,7 @@ import ( "github.com/erigontech/erigon/common/log/v3" "github.com/erigontech/erigon/db/kv" "github.com/erigontech/erigon/db/rawdb" + "github.com/erigontech/erigon/execution/balcache" "github.com/erigontech/erigon/db/rawdb/rawdbhelpers" "github.com/erigontech/erigon/db/rawdb/rawtemporaldb" dbstate "github.com/erigontech/erigon/db/state" @@ -611,7 +612,7 @@ func (te *txExecutor) executeBlocks(ctx context.Context, startBlockNum uint64, m // BALs are cache-only (see db/rawdb/balcache.go). If the engine_newPayload // path cached one for this block (via execmodule.InsertBlocks), pick it up // here for the parallel exec's BAL validation. - data, _ := rawdb.CachedBlockAccessList(b.Hash()) + data, _ := balcache.CachedBlockAccessList(b.Hash()) if len(data) > 0 && !dbg.IgnoreBAL { dbBAL, err = types.DecodeBlockAccessListBytes(data) if err != nil { diff --git a/node/eth/backend.go b/node/eth/backend.go index 18a753c8047..33cfada1fcd 100644 --- a/node/eth/backend.go +++ b/node/eth/backend.go @@ -62,6 +62,7 @@ import ( "github.com/erigontech/erigon/db/kv/remotedbserver" "github.com/erigontech/erigon/db/kv/temporal" "github.com/erigontech/erigon/db/rawdb" + "github.com/erigontech/erigon/execution/balcache" "github.com/erigontech/erigon/db/rawdb/blockio" "github.com/erigontech/erigon/db/services" "github.com/erigontech/erigon/db/snapcfg" @@ -734,7 +735,7 @@ func New(ctx context.Context, stack *node.Node, config *ethconfig.Config, logger // Expose the on-demand FetchBALs entry point to sync hooks // (engine_block_downloader after a batch, engine_newPayload when // the CL payload doesn't carry a BAL). - rawdb.SetBALSyncFetcher(balDownloader) + balcache.SetBALSyncFetcher(balDownloader) go balDownloader.Run(backend.sentryCtx) } @@ -1040,7 +1041,7 @@ func New(ctx context.Context, stack *node.Node, config *ethconfig.Config, logger // fall back to re-executing the block when nothing is cached. BAL bytes // are no longer persisted to MDBX (see db/rawdb/balcache.go), so older // blocks needed by peers or RPC must be reconstructed on demand. - rawdb.SetBALRegenerator(execmodule.NewBALRegenerator(execmodule.BALRegeneratorDeps{ + balcache.SetBALRegenerator(execmodule.NewBALRegenerator(execmodule.BALRegeneratorDeps{ DB: backend.chainDB, ChainConfig: chainConfig, Engine: backend.engine, diff --git a/p2p/protocols/eth/handlers.go b/p2p/protocols/eth/handlers.go index efa6568d05e..a7077b884d8 100644 --- a/p2p/protocols/eth/handlers.go +++ b/p2p/protocols/eth/handlers.go @@ -30,6 +30,7 @@ import ( "github.com/erigontech/erigon/common/log/v3" "github.com/erigontech/erigon/db/kv" "github.com/erigontech/erigon/db/rawdb" + "github.com/erigontech/erigon/execution/balcache" "github.com/erigontech/erigon/db/services" "github.com/erigontech/erigon/execution/chain" "github.com/erigontech/erigon/execution/rlp" @@ -209,7 +210,7 @@ func AnswerGetBlockAccessListsQuery(ctx context.Context, db kv.Tx, query GetBloc } // 2-tier lookup: in-memory cache → installed BALRegenerator // (re-executes the block when nothing is cached). No MDBX read. - bal, _ := rawdb.BlockAccessListBytes(ctx, hash, *number) + bal, _ := balcache.BlockAccessListBytes(ctx, hash, *number) if len(bal) == 0 { // We have the block header but no source produced a BAL // (pre-Amsterdam, pruned, or regenerator absent/declined). diff --git a/p2p/protocols/eth/handlers_test.go b/p2p/protocols/eth/handlers_test.go index 71bb9a99a52..42bfe459f44 100644 --- a/p2p/protocols/eth/handlers_test.go +++ b/p2p/protocols/eth/handlers_test.go @@ -9,7 +9,7 @@ import ( "github.com/erigontech/erigon/db/kv" "github.com/erigontech/erigon/db/kv/dbcfg" "github.com/erigontech/erigon/db/kv/memdb" - "github.com/erigontech/erigon/db/rawdb" + "github.com/erigontech/erigon/execution/balcache" "github.com/erigontech/erigon/execution/chain" "github.com/erigontech/erigon/execution/rlp" "github.com/erigontech/erigon/execution/types" @@ -571,8 +571,8 @@ func (balHeaderReader) Integrity(context.Context) error { panic("not expected") // ethereum/EIPs#11553) for any hash we don't have stored — including unknown // blocks and known blocks with no BAL recorded. func TestAnswerGetBlockAccessListsQuery_OrderedResponseWithMissing(t *testing.T) { - rawdb.ResetBALCacheForTest() - t.Cleanup(rawdb.ResetBALCacheForTest) + balcache.ResetBALCacheForTest() + t.Cleanup(balcache.ResetBALCacheForTest) db := memdb.NewTestDB(t, dbcfg.ChainDB) tx, err := db.BeginRw(context.Background()) if err != nil { @@ -591,7 +591,7 @@ func TestAnswerGetBlockAccessListsQuery_OrderedResponseWithMissing(t *testing.T) } bal := []byte{0xc3, 0x01, 0x02, 0x03} // short valid RLP payload (non-empty) - rawdb.CacheBlockAccessList(hashKnownWithBAL, bal) + balcache.CacheBlockAccessList(hashKnownWithBAL, bal) query := GetBlockAccessListsPacket{hashKnownWithBAL, hashUnknown, hashKnownNoBAL} result := AnswerGetBlockAccessListsQuery(context.Background(), tx, query, reader) @@ -613,8 +613,8 @@ func TestAnswerGetBlockAccessListsQuery_OrderedResponseWithMissing(t *testing.T) // TestAnswerGetBlockAccessListsQuery_SoftSizeLimit verifies the handler // respects softResponseLimit by truncating the response (not padding). func TestAnswerGetBlockAccessListsQuery_SoftSizeLimit(t *testing.T) { - rawdb.ResetBALCacheForTest() - t.Cleanup(rawdb.ResetBALCacheForTest) + balcache.ResetBALCacheForTest() + t.Cleanup(balcache.ResetBALCacheForTest) db := memdb.NewTestDB(t, dbcfg.ChainDB) tx, err := db.BeginRw(context.Background()) if err != nil { @@ -642,7 +642,7 @@ func TestAnswerGetBlockAccessListsQuery_SoftSizeLimit(t *testing.T) { h := common.Hash{byte(i + 1)} num := uint64(1000 + i) reader[h] = num - rawdb.CacheBlockAccessList(h, bal) + balcache.CacheBlockAccessList(h, bal) query = append(query, h) } diff --git a/p2p/sentry/sentry_multi_client/bal_downloader.go b/p2p/sentry/sentry_multi_client/bal_downloader.go index 1104fc38348..f6d06c9ab0a 100644 --- a/p2p/sentry/sentry_multi_client/bal_downloader.go +++ b/p2p/sentry/sentry_multi_client/bal_downloader.go @@ -30,7 +30,7 @@ import ( "github.com/erigontech/erigon/common" "github.com/erigontech/erigon/common/log/v3" "github.com/erigontech/erigon/db/kv" - "github.com/erigontech/erigon/db/rawdb" + "github.com/erigontech/erigon/execution/balcache" "github.com/erigontech/erigon/node/gointerfaces/sentryproto" "github.com/erigontech/erigon/node/gointerfaces/typesproto" ) @@ -201,7 +201,7 @@ func (d *BALDownloader) collectMissingBALs(ctx context.Context) ([]missingBAL, e // Cache-only — BALs are not persisted to MDBX. If the cache has // already absorbed this hash (recently produced locally or fetched // from another peer this run), skip the fetch. - if _, ok := rawdb.CachedBlockAccessList(hash); ok { + if _, ok := balcache.CachedBlockAccessList(hash); ok { continue } missing = append(missing, missingBAL{ @@ -290,7 +290,7 @@ func (d *BALDownloader) fetchBatch(ctx context.Context, peer [64]byte, sentryI i if len(payload) == 0 { continue } - rawdb.CacheBlockAccessList(batch[i].hash, payload) + balcache.CacheBlockAccessList(batch[i].hash, payload) stored++ } @@ -304,7 +304,7 @@ func (d *BALDownloader) fetchBatch(ctx context.Context, peer [64]byte, sentryI i } } -// FetchBALs implements rawdb.BALSyncFetcher: picks an eth/71 peer and +// FetchBALs implements balcache.BALSyncFetcher: picks an eth/71 peer and // requests BALs for the given (hash, number, expected) tuples in a single // batched call, caching every hash-verified response. Non-blocking by intent // — failures (no peer, peer declined, timeout) are silently dropped because diff --git a/p2p/sentry/sentry_multi_client/sentry_multi_client_test.go b/p2p/sentry/sentry_multi_client/sentry_multi_client_test.go index b477ece2c7d..dda06a8675a 100644 --- a/p2p/sentry/sentry_multi_client/sentry_multi_client_test.go +++ b/p2p/sentry/sentry_multi_client/sentry_multi_client_test.go @@ -14,7 +14,7 @@ import ( "github.com/erigontech/erigon/db/kv" "github.com/erigontech/erigon/db/kv/dbcfg" "github.com/erigontech/erigon/db/kv/temporal" - "github.com/erigontech/erigon/db/rawdb" + "github.com/erigontech/erigon/execution/balcache" "github.com/erigontech/erigon/db/services" "github.com/erigontech/erigon/execution/rlp" "github.com/erigontech/erigon/execution/types" @@ -291,9 +291,9 @@ func TestGetBlockAccessLists71_AnswersAndSends(t *testing.T) { hashUnknown := common.Hash{0x02} const knownBlockNum uint64 = 100 bal := []byte{0xc3, 0x01, 0x02, 0x03} // short valid RLP non-empty payload - rawdb.ResetBALCacheForTest() - t.Cleanup(rawdb.ResetBALCacheForTest) - rawdb.CacheBlockAccessList(hashKnown, bal) + balcache.ResetBALCacheForTest() + t.Cleanup(balcache.ResetBALCacheForTest) + balcache.CacheBlockAccessList(hashKnown, bal) if err := rwTx.Commit(); err != nil { t.Fatalf("commit: %v", err) } diff --git a/rpc/jsonrpc/eth_block_access_list.go b/rpc/jsonrpc/eth_block_access_list.go index e516cba161a..7cf70db3038 100644 --- a/rpc/jsonrpc/eth_block_access_list.go +++ b/rpc/jsonrpc/eth_block_access_list.go @@ -20,7 +20,7 @@ import ( "context" "errors" - "github.com/erigontech/erigon/db/rawdb" + "github.com/erigontech/erigon/execution/balcache" "github.com/erigontech/erigon/execution/types" "github.com/erigontech/erigon/rpc" "github.com/erigontech/erigon/rpc/ethapi" @@ -66,7 +66,7 @@ func (api *APIImpl) GetBlockAccessList(ctx context.Context, numberOrHash rpc.Blo } } - data, err := rawdb.BlockAccessListBytes(ctx, blockHash, blockNum) + data, err := balcache.BlockAccessListBytes(ctx, blockHash, blockNum) if err != nil { return nil, err } From 52c8ac614c219772ab8de9d9c37a90ba22cecee4 Mon Sep 17 00:00:00 2001 From: Mark Holt Date: Fri, 15 May 2026 08:35:41 +0000 Subject: [PATCH 6/8] =?UTF-8?q?execution/balcache,=20execution/engineapi,?= =?UTF-8?q?=20execution/exec:=20BAL=20prefetch=20via=20balcache=20?= =?UTF-8?q?=E2=86=92=20cache.StateCache?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The BlockReadAheader's BAL prefetch was sourcing the access list from MDBX (kv.BlockAccessList table). On the perf-devnet-3 / bench-feeder path the BAL is never persisted to chaindata — it arrives via engine_newPayloadV5 and is held only in the payload. Diagnostic logging confirmed: AddHeaderAndBody fires, warmBody enters with stateCacheWired=true, txs=2 for the test block, but balLen=0 because the BAL was nowhere accessible to read-ahead. Result: no prefetch, EVM hot path paid the full file accessor stack on every cold address. Ports the balcache package from mh/perf-bal-cache as the canonical BAL store on the engine-API path: - execution/balcache/balcache.go — in-memory LRU (100 blocks), Cache/CachedBlockAccessList API, optional BALRegenerator / BALSyncFetcher hooks for fallback paths. - EngineServer.HandleNewPayload writes the BAL bytes into the cache on payload receipt, before any downstream processing. - BlockReadAheader.warmBody reads BAL via balcache.CachedBlockAccessList keyed by block hash, replacing the MDBX read entirely. Bench result (test_account_access[EXTCODESIZE-EXISTING_CONTRACT-30M], perf-devnet-3 v4.0.0 fixture, EXEC3_PARALLEL=true): binary mgas/s block time baseline 12.82 2.4 s L2b only 12.75 2.4 s L2b + codeSize 13.13 2.4 s L2b + codeSize + balcache 61.50 488 ms ← this commit ~4.7x speedup. Cross-client peer band on the same family (cycle-2 survey 2026-05-13): reth 50, geth 55, besu 55, nethermind 70. Erigon at 62 mgas/s now sits between geth and nethermind on a bench that was 2.2 mgas/s in the May 13 cross-client pull. Pprof signature confirms: seg.Getter.nextPos flat dropped from 52 % to 24 %; the dominant decompression cost is gone for BAL-listed addresses because EVM hits cache.StateCache (populated by read-ahead's cache-populating getter, commit cbe9044e52) instead of the file accessor stack. The kv.BlockAccessList table removal + rawdb.WriteBlockAccessListBytes deletion is intentionally NOT in this commit — that belongs to the bal-cache structural refactor on mh/perf-bal-cache, not on the optimisation branch. Co-Authored-By: Claude Opus 4.7 (1M context) --- execution/engineapi/engine_server.go | 11 +++++++++++ execution/exec/blocks_read_ahead.go | 27 ++++++++++++--------------- 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/execution/engineapi/engine_server.go b/execution/engineapi/engine_server.go index fae186810aa..bfc122d36c1 100644 --- a/execution/engineapi/engine_server.go +++ b/execution/engineapi/engine_server.go @@ -43,6 +43,7 @@ import ( "github.com/erigontech/erigon/db/kv/kvcache" "github.com/erigontech/erigon/db/rawdb" "github.com/erigontech/erigon/db/services" + "github.com/erigontech/erigon/execution/balcache" "github.com/erigontech/erigon/execution/builder" "github.com/erigontech/erigon/execution/chain" "github.com/erigontech/erigon/execution/engineapi/engine_block_downloader" @@ -928,6 +929,16 @@ func (e *EngineServer) HandleNewPayload( ) (*engine_types.PayloadStatus, error) { e.engineLogSpamer.RecordRequest() + // Cache the incoming BAL bytes so downstream consumers (read-ahead, + // validation, BAL-driven workers) can fetch from process memory without + // touching MDBX. The bal-cache architecture removes the BAL from MDBX + // on the NewPayload critical path entirely; mirrors the design pattern + // from mh/perf-bal-cache so the eventual refactor is a wiring move, + // not a redesign. + if len(blockAccessListBytes) > 0 { + balcache.CacheBlockAccessList(block.Hash(), blockAccessListBytes) + } + header := block.Header() headerNumber := header.Number.Uint64() headerHash := block.Hash() diff --git a/execution/exec/blocks_read_ahead.go b/execution/exec/blocks_read_ahead.go index 08c0d47a5c9..1f77e8ace36 100644 --- a/execution/exec/blocks_read_ahead.go +++ b/execution/exec/blocks_read_ahead.go @@ -14,8 +14,8 @@ import ( "github.com/erigontech/erigon/common/length" "github.com/erigontech/erigon/common/log/v3" "github.com/erigontech/erigon/db/kv" - "github.com/erigontech/erigon/db/kv/dbutils" "github.com/erigontech/erigon/db/services" + "github.com/erigontech/erigon/execution/balcache" "github.com/erigontech/erigon/execution/cache" "github.com/erigontech/erigon/execution/protocol/rules" "github.com/erigontech/erigon/execution/state" @@ -178,23 +178,20 @@ func (bra *BlockReadAheader) warmBody(ctx context.Context, db kv.RoDB, header *t var wg errgroup.Group - // If BAL exists in DB, use BAL warming (more complete) + // BAL is sourced from the in-memory balcache (populated by + // EngineServer.HandleNewPayload on receipt). The bal-cache architecture + // removes the BAL from chaindata entirely; cache miss is a clean signal + // that BAL prefetch is not available for this block — fall through to + // per-transaction warming. var bal types.BlockAccessList - if header != nil && db != nil { - tx, err := db.BeginRo(ctx) - if err != nil { - log.Warn("[warmBody] failed to open tx for BAL", "blockNum", header.Number.Uint64(), "blockHash", header.Hash(), "err", err) - } else { - data, err := tx.GetOne(kv.BlockAccessList, dbutils.BlockBodyKey(header.Number.Uint64(), header.Hash())) + if header != nil { + if data, ok := balcache.CachedBlockAccessList(header.Hash()); ok && len(data) > 0 { + decoded, err := types.DecodeBlockAccessListBytes(data) if err != nil { - log.Warn("[warmBody] failed to read BAL", "blockNum", header.Number.Uint64(), "blockHash", header.Hash(), "err", err) - } else if len(data) > 0 { - bal, err = types.DecodeBlockAccessListBytes(data) - if err != nil { - log.Warn("[warmBody] failed to decode BAL", "blockNum", header.Number.Uint64(), "blockHash", header.Hash(), "err", err) - } + log.Warn("[warmBody] failed to decode BAL", "blockNum", header.Number.Uint64(), "blockHash", header.Hash(), "err", err) + } else { + bal = decoded } - tx.Rollback() } } From 6427aca1e7b3d725637612f9a2580b1fa6545f97 Mon Sep 17 00:00:00 2001 From: Mark Holt Date: Fri, 15 May 2026 12:19:14 +0000 Subject: [PATCH 7/8] db/kv, db/rawdb, execution: drop kv.BlockAccessList table and orphan helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BAL bytes are now cache-only (balcache, populated by ComputeBlockContext with on-demand regeneration). After the BAL writers were removed in 9ea90e4307, the MDBX table and its Read/Write/Delete helpers have no producers — drop them outright instead of carrying dead surface. Removed: - kv.BlockAccessList table constant + ChaindataTables entry - rawdb.ReadBlockAccessListBytes / WriteBlockAccessListBytes - BlockAccessList delete in DeleteBody + 2 prune callbacks - PruneTable(kv.BlockAccessList) call in stage_execute - TestBlockAccessListStorage + the BlockAccessList seed rows in TestNoPruneSkipsAllPruneStages Readers (warmBody readAhead) already consult balcache.CachedBlockAccessList; the test helper in engine_server_test writes directly to balcache. Co-Authored-By: Claude Opus 4.7 (1M context) --- db/kv/tables.go | 3 -- db/rawdb/accessors_chain.go | 26 --------- db/rawdb/accessors_chain_test.go | 54 ------------------- .../block_downloader.go | 2 +- execution/exec/blocks_read_ahead.go | 10 ++-- execution/execmodule/balregen.go | 7 +-- execution/execmodule/inserters.go | 2 +- execution/stagedsync/exec3.go | 2 +- execution/stagedsync/no_prune_test.go | 4 +- execution/stagedsync/stage_execute.go | 21 -------- node/eth/backend.go | 2 +- p2p/protocols/eth/handlers.go | 2 +- .../sentry_multi_client_test.go | 2 +- 13 files changed, 16 insertions(+), 121 deletions(-) diff --git a/db/kv/tables.go b/db/kv/tables.go index 10bdb96d15e..713a8b3cb06 100644 --- a/db/kv/tables.go +++ b/db/kv/tables.go @@ -61,8 +61,6 @@ const ( HeaderTD = "HeadersTotalDifficulty" // block_num_u64 + hash -> td (RLP) BlockBody = "BlockBody" // block_num_u64 + hash -> block body - // BlockAccessList stores RLP-encoded block access lists, keyed by block_num_u64 + hash. - BlockAccessList = "BlockAccessList" // Naming: // TxNum - Ethereum canonical transaction number - same across all nodes. @@ -315,7 +313,6 @@ var ChaindataTables = []string{ HeaderNumber, BadHeaderNumber, BlockBody, - BlockAccessList, TxLookup, ConfigTable, DatabaseInfo, diff --git a/db/rawdb/accessors_chain.go b/db/rawdb/accessors_chain.go index 4ea0aefaae2..c6a2ad5e3d2 100644 --- a/db/rawdb/accessors_chain.go +++ b/db/rawdb/accessors_chain.go @@ -595,23 +595,6 @@ func ReadBody(db kv.Getter, hash common.Hash, number uint64) (*types.Body, uint6 return body, bodyForStorage.BaseTxnID.First(), bodyForStorage.TxCount - 2 // 1 system txn in the beginning of block, and 1 at the end } -// ReadBlockAccessListBytes reads the RLP-encoded block access list sidecar for a block. -func ReadBlockAccessListBytes(db kv.Getter, hash common.Hash, number uint64) ([]byte, error) { - data, err := db.GetOne(kv.BlockAccessList, dbutils.BlockBodyKey(number, hash)) - if err != nil { - return nil, err - } - return data, nil -} - -// WriteBlockAccessListBytes stores the RLP-encoded block access list sidecar for a block. -func WriteBlockAccessListBytes(db kv.Putter, hash common.Hash, number uint64, data []byte) error { - if err := db.Put(kv.BlockAccessList, dbutils.BlockBodyKey(number, hash), data); err != nil { - return fmt.Errorf("failed to store block access list: %w", err) - } - return nil -} - func HasSenders(db kv.Getter, hash common.Hash, number uint64) (bool, error) { return db.Has(kv.Senders, dbutils.BlockBodyKey(number, hash)) } @@ -698,9 +681,6 @@ func DeleteBody(db kv.Putter, hash common.Hash, number uint64) { if err := db.Delete(kv.BlockBody, dbutils.BlockBodyKey(number, hash)); err != nil { log.Crit("Failed to delete block body", "err", err) } - if err := db.Delete(kv.BlockAccessList, dbutils.BlockBodyKey(number, hash)); err != nil { - log.Crit("Failed to delete block access list", "err", err) - } } func AppendCanonicalTxNums(tx kv.RwTx, from uint64) (err error) { @@ -898,9 +878,6 @@ func PruneBlocks(tx kv.RwTx, blockTo uint64, blocksDeleteLimit int) (deleted int if err = tx.Delete(kv.BlockBody, kCopy); err != nil { return deleted, err } - if err = tx.Delete(kv.BlockAccessList, kCopy); err != nil { - return deleted, err - } if err = tx.Delete(kv.Headers, kCopy); err != nil { return deleted, err } @@ -949,9 +926,6 @@ func TruncateBlocks(ctx context.Context, tx kv.RwTx, blockFrom uint64) error { if err := tx.Delete(kv.BlockBody, kCopy); err != nil { return err } - if err := tx.Delete(kv.BlockAccessList, kCopy); err != nil { - return err - } if err := tx.Delete(kv.Headers, kCopy); err != nil { return err } diff --git a/db/rawdb/accessors_chain_test.go b/db/rawdb/accessors_chain_test.go index 50f8955fa07..dd3c6483c3d 100644 --- a/db/rawdb/accessors_chain_test.go +++ b/db/rawdb/accessors_chain_test.go @@ -40,7 +40,6 @@ import ( "github.com/erigontech/erigon/execution/execmodule/execmoduletester" "github.com/erigontech/erigon/execution/rlp" "github.com/erigontech/erigon/execution/types" - "github.com/erigontech/erigon/execution/types/accounts" ) func newTestLegacyTx(nonce uint64, to common.Address, value uint256.Int, gasLimit uint64, gasPrice uint256.Int) *types.LegacyTx { @@ -1126,59 +1125,6 @@ func TestBlockWithdrawalsStorage(t *testing.T) { require.Nil(entry) } -func TestBlockAccessListStorage(t *testing.T) { - t.Parallel() - _, tx := memdb.NewTestTx(t) - defer tx.Rollback() - - block := types.NewBlockWithHeader(&types.Header{ - Number: *uint256.NewInt(1), - Extra: []byte("test block"), - UncleHash: empty.UncleHash, - TxHash: empty.RootHash, - ReceiptHash: empty.RootHash, - }) - - data, err := rawdb.ReadBlockAccessListBytes(tx, block.Hash(), block.NumberU64()) - require.NoError(t, err) - require.Empty(t, data) - - nonEmpty := types.BlockAccessList{ - { - Address: accounts.InternAddress(common.HexToAddress("0x00000000000000000000000000000000000000aa")), - }, - } - nonEmptyBytes, err := types.EncodeBlockAccessListBytes(nonEmpty) - require.NoError(t, err) - require.NoError(t, rawdb.WriteBlockAccessListBytes(tx, block.Hash(), block.NumberU64(), nonEmptyBytes)) - - data, err = rawdb.ReadBlockAccessListBytes(tx, block.Hash(), block.NumberU64()) - require.NoError(t, err) - require.Equal(t, nonEmptyBytes, data) - - decoded, err := types.DecodeBlockAccessListBytes(data) - require.NoError(t, err) - require.NoError(t, decoded.Validate()) - require.Equal(t, nonEmpty.Hash(), decoded.Hash()) - - emptyBytes, err := types.EncodeBlockAccessListBytes(nil) - require.NoError(t, err) - require.NoError(t, rawdb.WriteBlockAccessListBytes(tx, block.Hash(), block.NumberU64(), emptyBytes)) - - data, err = rawdb.ReadBlockAccessListBytes(tx, block.Hash(), block.NumberU64()) - require.NoError(t, err) - require.Equal(t, emptyBytes, data) - - decoded, err = types.DecodeBlockAccessListBytes(data) - require.NoError(t, err) - // EIP-7928: Decoding 0xc0 (empty RLP list) should return an initialized empty slice, - // rather than nil, to distinguish a valid empty BAL from a missing/pruned one. - require.NotNil(t, decoded) - require.Empty(t, decoded) - require.NoError(t, decoded.Validate()) - require.Equal(t, empty.BlockAccessListHash, decoded.Hash()) -} - // Tests pre-shanghai body to make sure withdrawals doesn't panic func TestPreShanghaiBodyNoPanicOnWithdrawals(t *testing.T) { t.Parallel() diff --git a/execution/engineapi/engine_block_downloader/block_downloader.go b/execution/engineapi/engine_block_downloader/block_downloader.go index 051f1ea62b1..99db738089e 100644 --- a/execution/engineapi/engine_block_downloader/block_downloader.go +++ b/execution/engineapi/engine_block_downloader/block_downloader.go @@ -29,8 +29,8 @@ import ( "github.com/erigontech/erigon/common" "github.com/erigontech/erigon/common/log/v3" "github.com/erigontech/erigon/db/kv" - "github.com/erigontech/erigon/execution/balcache" "github.com/erigontech/erigon/db/services" + "github.com/erigontech/erigon/execution/balcache" "github.com/erigontech/erigon/execution/chain" "github.com/erigontech/erigon/execution/execmodule" "github.com/erigontech/erigon/execution/execmodule/chainreader" diff --git a/execution/exec/blocks_read_ahead.go b/execution/exec/blocks_read_ahead.go index 1f77e8ace36..4a2cd808f89 100644 --- a/execution/exec/blocks_read_ahead.go +++ b/execution/exec/blocks_read_ahead.go @@ -178,11 +178,11 @@ func (bra *BlockReadAheader) warmBody(ctx context.Context, db kv.RoDB, header *t var wg errgroup.Group - // BAL is sourced from the in-memory balcache (populated by - // EngineServer.HandleNewPayload on receipt). The bal-cache architecture - // removes the BAL from chaindata entirely; cache miss is a clean signal - // that BAL prefetch is not available for this block — fall through to - // per-transaction warming. + // BAL source = the in-memory balcache (populated by + // EngineServer.HandleNewPayload on receipt). The chaindata + // kv.BlockAccessList table no longer exists; cache miss is a clean + // signal that BAL prefetch is not available for this block — fall + // through to per-transaction warming below. var bal types.BlockAccessList if header != nil { if data, ok := balcache.CachedBlockAccessList(header.Hash()); ok && len(data) > 0 { diff --git a/execution/execmodule/balregen.go b/execution/execmodule/balregen.go index bd690ca3b7c..ec0039e877a 100644 --- a/execution/execmodule/balregen.go +++ b/execution/execmodule/balregen.go @@ -19,7 +19,8 @@ package execmodule import ( "context" "fmt" - "math/big" + + "github.com/holiman/uint256" "github.com/erigontech/erigon/common" "github.com/erigontech/erigon/common/log/v3" @@ -27,13 +28,13 @@ import ( rawdbv3 "github.com/erigontech/erigon/db/kv/rawdbv3" "github.com/erigontech/erigon/db/services" "github.com/erigontech/erigon/execution/chain" - "github.com/erigontech/erigon/execution/vm/evmtypes" "github.com/erigontech/erigon/execution/protocol" "github.com/erigontech/erigon/execution/protocol/rules" "github.com/erigontech/erigon/execution/state" "github.com/erigontech/erigon/execution/types" "github.com/erigontech/erigon/execution/types/accounts" "github.com/erigontech/erigon/execution/vm" + "github.com/erigontech/erigon/execution/vm/evmtypes" "github.com/erigontech/erigon/rpc/transactions" ) @@ -199,6 +200,6 @@ func (s *chainReaderShim) CurrentSafeHeader() *types.Header func (s *chainReaderShim) GetHeader(hash common.Hash, number uint64) *types.Header { return nil } func (s *chainReaderShim) GetHeaderByNumber(number uint64) *types.Header { return nil } func (s *chainReaderShim) GetHeaderByHash(hash common.Hash) *types.Header { return nil } -func (s *chainReaderShim) GetTd(hash common.Hash, number uint64) *big.Int { return big.NewInt(0) } +func (s *chainReaderShim) GetTd(hash common.Hash, number uint64) *uint256.Int { return uint256.NewInt(0) } func (s *chainReaderShim) FrozenBlocks() uint64 { return 0 } func (s *chainReaderShim) FrozenBorBlocks(align bool) uint64 { return 0 } diff --git a/execution/execmodule/inserters.go b/execution/execmodule/inserters.go index 4507bb68bb4..654ba3ee50b 100644 --- a/execution/execmodule/inserters.go +++ b/execution/execmodule/inserters.go @@ -24,8 +24,8 @@ import ( "github.com/holiman/uint256" "github.com/erigontech/erigon/db/rawdb" - "github.com/erigontech/erigon/execution/balcache" "github.com/erigontech/erigon/db/state/execctx" + "github.com/erigontech/erigon/execution/balcache" "github.com/erigontech/erigon/execution/commitment/commitmentdb" "github.com/erigontech/erigon/execution/metrics" "github.com/erigontech/erigon/execution/types" diff --git a/execution/stagedsync/exec3.go b/execution/stagedsync/exec3.go index ab20ad157e0..30fc537ae6d 100644 --- a/execution/stagedsync/exec3.go +++ b/execution/stagedsync/exec3.go @@ -35,11 +35,11 @@ import ( "github.com/erigontech/erigon/common/log/v3" "github.com/erigontech/erigon/db/kv" "github.com/erigontech/erigon/db/rawdb" - "github.com/erigontech/erigon/execution/balcache" "github.com/erigontech/erigon/db/rawdb/rawdbhelpers" "github.com/erigontech/erigon/db/rawdb/rawtemporaldb" dbstate "github.com/erigontech/erigon/db/state" "github.com/erigontech/erigon/db/state/execctx" + "github.com/erigontech/erigon/execution/balcache" "github.com/erigontech/erigon/execution/commitment" "github.com/erigontech/erigon/execution/exec" "github.com/erigontech/erigon/execution/protocol" diff --git a/execution/stagedsync/no_prune_test.go b/execution/stagedsync/no_prune_test.go index f66f7475f85..e1b9d20bed8 100644 --- a/execution/stagedsync/no_prune_test.go +++ b/execution/stagedsync/no_prune_test.go @@ -51,8 +51,6 @@ func TestNoPruneSkipsAllPruneStages(t *testing.T) { seeds := []seedRow{ {kv.ChangeSets3, "k1", "v1"}, {kv.ChangeSets3, "k2", "v2"}, - {kv.BlockAccessList, "b1", "ba1"}, - {kv.BlockAccessList, "b2", "ba2"}, {kv.TxLookup, "t1", "tl1"}, {kv.BorWitnesses, "w1", "wit1"}, } @@ -70,7 +68,7 @@ func TestNoPruneSkipsAllPruneStages(t *testing.T) { } return n } - tracked := []string{kv.ChangeSets3, kv.BlockAccessList, kv.TxLookup, kv.BorWitnesses} + tracked := []string{kv.ChangeSets3, kv.TxLookup, kv.BorWitnesses} pre := map[string]int{} for _, table := range tracked { pre[table] = countRows(t, table) diff --git a/execution/stagedsync/stage_execute.go b/execution/stagedsync/stage_execute.go index dccf76087fa..3f3c7a8efe9 100644 --- a/execution/stagedsync/stage_execute.go +++ b/execution/stagedsync/stage_execute.go @@ -528,27 +528,6 @@ func PruneExecutionStage(ctx context.Context, s *PruneState, tx kv.RwTx, cfg Exe } } - if s.ForwardProgress > cfg.syncCfg.MaxReorgDepth { - pruneBalLimit := 10_000 - pruneTimeout := quickPruneTimeout - if s.CurrentSyncCycle.IsInitialCycle { - pruneBalLimit = math.MaxInt - pruneTimeout = time.Hour - } - if err := rawdb.PruneTable( - tx, - kv.BlockAccessList, - s.ForwardProgress-cfg.syncCfg.MaxReorgDepth, - ctx, - pruneBalLimit, - pruneTimeout, - logger, - s.LogPrefix(), - ); err != nil { - return err - } - } - agg := cfg.db.(state.HasAgg).Agg().(*state.Aggregator) mxExecStepsInDB.Set(rawdbhelpers.IdxStepsCountV3(tx, agg.StepSize()) * 100) diff --git a/node/eth/backend.go b/node/eth/backend.go index 33cfada1fcd..407ff7672df 100644 --- a/node/eth/backend.go +++ b/node/eth/backend.go @@ -62,7 +62,6 @@ import ( "github.com/erigontech/erigon/db/kv/remotedbserver" "github.com/erigontech/erigon/db/kv/temporal" "github.com/erigontech/erigon/db/rawdb" - "github.com/erigontech/erigon/execution/balcache" "github.com/erigontech/erigon/db/rawdb/blockio" "github.com/erigontech/erigon/db/services" "github.com/erigontech/erigon/db/snapcfg" @@ -72,6 +71,7 @@ import ( "github.com/erigontech/erigon/db/state/statecfg" "github.com/erigontech/erigon/diagnostics/diaglib" "github.com/erigontech/erigon/diagnostics/mem" + "github.com/erigontech/erigon/execution/balcache" "github.com/erigontech/erigon/execution/builder" "github.com/erigontech/erigon/execution/chain" chainspec "github.com/erigontech/erigon/execution/chain/spec" diff --git a/p2p/protocols/eth/handlers.go b/p2p/protocols/eth/handlers.go index a7077b884d8..92ead0bd5cd 100644 --- a/p2p/protocols/eth/handlers.go +++ b/p2p/protocols/eth/handlers.go @@ -30,8 +30,8 @@ import ( "github.com/erigontech/erigon/common/log/v3" "github.com/erigontech/erigon/db/kv" "github.com/erigontech/erigon/db/rawdb" - "github.com/erigontech/erigon/execution/balcache" "github.com/erigontech/erigon/db/services" + "github.com/erigontech/erigon/execution/balcache" "github.com/erigontech/erigon/execution/chain" "github.com/erigontech/erigon/execution/rlp" "github.com/erigontech/erigon/execution/types" diff --git a/p2p/sentry/sentry_multi_client/sentry_multi_client_test.go b/p2p/sentry/sentry_multi_client/sentry_multi_client_test.go index dda06a8675a..e7759d72304 100644 --- a/p2p/sentry/sentry_multi_client/sentry_multi_client_test.go +++ b/p2p/sentry/sentry_multi_client/sentry_multi_client_test.go @@ -14,8 +14,8 @@ import ( "github.com/erigontech/erigon/db/kv" "github.com/erigontech/erigon/db/kv/dbcfg" "github.com/erigontech/erigon/db/kv/temporal" - "github.com/erigontech/erigon/execution/balcache" "github.com/erigontech/erigon/db/services" + "github.com/erigontech/erigon/execution/balcache" "github.com/erigontech/erigon/execution/rlp" "github.com/erigontech/erigon/execution/types" "github.com/erigontech/erigon/node/direct" From cd839e1d19a8c1d1a882d8e90e382571c4574bf1 Mon Sep 17 00:00:00 2001 From: mh0lt Date: Tue, 28 Apr 2026 21:47:02 +0000 Subject: [PATCH 8/8] rpc/jsonrpc: debug_getRawBlockAccessList returns balcache bytes (porting from the MDBX-era diagnostic) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds debug_getRawBlockAccessList JSON-RPC method — returns the RLP-encoded BlockAccessList bytes this node has stored for a block (exactly what the eth/71 server-side handler returns to peers). Originally introduced as part of the dispatch-bug diagnostic tooling (b8bd26caa8) which also added cmd/bal-scan and cmd/bal-test. The cmd tools were tightly coupled to the kv.BlockAccessList MDBX table (scan / dump / delete / compare via cursor) — that table is removed in the prior commit of this PR, so the cmd tools have no surviving port. The RPC method does: it switches its read from rawdb.ReadBlockAccessListBytes to balcache.BlockAccessListBytes (cache hit, regenerator fallback). Co-Authored-By: Claude Opus 4.7 (1M context) --- rpc/jsonrpc/debug_api.go | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/rpc/jsonrpc/debug_api.go b/rpc/jsonrpc/debug_api.go index 9780ec91a02..050df599189 100644 --- a/rpc/jsonrpc/debug_api.go +++ b/rpc/jsonrpc/debug_api.go @@ -30,6 +30,7 @@ import ( "github.com/erigontech/erigon/db/kv" "github.com/erigontech/erigon/db/kv/order" "github.com/erigontech/erigon/db/rawdb" + "github.com/erigontech/erigon/execution/balcache" "github.com/erigontech/erigon/execution/rlp" "github.com/erigontech/erigon/execution/stagedsync/stages" "github.com/erigontech/erigon/execution/state" @@ -63,6 +64,7 @@ type PrivateDebugAPI interface { AccountAt(ctx context.Context, blockHash common.Hash, txIndex uint64, account common.Address) (*AccountResult, error) GetRawHeader(ctx context.Context, blockNrOrHash rpc.BlockNumberOrHash) (hexutil.Bytes, error) GetRawBlock(ctx context.Context, blockNrOrHash rpc.BlockNumberOrHash) (hexutil.Bytes, error) + GetRawBlockAccessList(ctx context.Context, blockNrOrHash rpc.BlockNumberOrHash) (hexutil.Bytes, error) GetRawReceipts(ctx context.Context, blockNrOrHash rpc.BlockNumberOrHash) ([]hexutil.Bytes, error) GetBadBlocks(ctx context.Context) ([]map[string]any, error) GetRawTransaction(ctx context.Context, hash common.Hash) (hexutil.Bytes, error) @@ -671,6 +673,31 @@ func (api *DebugAPIImpl) GetRawBlock(ctx context.Context, blockNrOrHash rpc.Bloc return rlp.EncodeToBytes(block) } +// GetRawBlockAccessList implements debug_getRawBlockAccessList — returns the +// raw RLP-encoded BlockAccessList bytes (EIP-7928) that this node has stored +// for the given block. Returns nil if no BAL is recorded (pre-Amsterdam, or +// post-Amsterdam but not yet downloaded). The bytes returned are exactly what +// the server-side eth/71 GetBlockAccessLists handler would send to a peer. +func (api *DebugAPIImpl) GetRawBlockAccessList(ctx context.Context, blockNrOrHash rpc.BlockNumberOrHash) (hexutil.Bytes, error) { + tx, err := api.db.BeginTemporalRo(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback() + n, h, _, err := rpchelper.GetBlockNumber(ctx, blockNrOrHash, tx, api._blockReader, api.filters) + if err != nil { + if errors.As(err, &rpc.BlockNotFoundErr{}) { + return nil, nil + } + return nil, err + } + bal, err := balcache.BlockAccessListBytes(ctx, h, n) + if err != nil { + return nil, fmt.Errorf("read block access list: %w", err) + } + return bal, nil +} + // GetRawReceipts implements debug_getRawReceipts - retrieves and returns an array of EIP-2718 binary-encoded receipts of a single block func (api *DebugAPIImpl) GetRawReceipts(ctx context.Context, blockNrOrHash rpc.BlockNumberOrHash) ([]hexutil.Bytes, error) { tx, err := api.db.BeginTemporalRo(ctx)