From 4abb4a902f3a6825a748db3b93ab56ba758f32be Mon Sep 17 00:00:00 2001 From: yperbasis Date: Fri, 3 Jul 2026 11:09:52 +0200 Subject: [PATCH 01/18] execution/builder: detach payload builds from the shared BranchCache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #22152. A payload build runs outside the exec-module semaphore on its own read snapshot, and its SharedDomains auto-attached the aggregator-lifetime BranchCache, so the build's commitment fold read-filled the shared cache with branch bytes from that snapshot. A build outliving head progression (max-build-time self-stop; CLs pre-request payloads every slot) then wrote stale branches which, once the fresher entries were absent (LRU eviction, or the fill landing after the FCU flush refresh under last-Put-wins), every subsequent commitment on the node folded: the node computed self-consistently wrong trie roots, rejected other nodes' valid blocks and built blocks they reject — two identically-built nodes disagreeing at the tip, on both serial and parallel executors. Detach the builder's SharedDomains from the cache: builds neither populate it nor read entries newer than their snapshot; canonical validation/FCU SDs keep the warm cache. DetachBranchCache existed for exactly this hazard but had no callers. The regression test reproduces the poisoning deterministically: a real build parked via CustomTxnProvider pins a block-1 snapshot while the chain advances two blocks, the cache is cleared to model eviction, the released fold repopulates it stale, and the next canonical block — folding a branch row the stale fill covers — must validate. Before the fix it fails with the production signature (wrong trie root); recipient addresses are ground so the poisoned row is exactly the one that block folds and no per-block actor refreshes it in between. --- db/state/execctx/domain_shared.go | 13 +- execution/builder/builder.go | 5 + .../exec_module_branchcache_test.go | 209 ++++++++++++++++++ 3 files changed, 221 insertions(+), 6 deletions(-) create mode 100644 execution/execmodule/exec_module_branchcache_test.go diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index 4ef84238d38..3c01bcb84dc 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -1022,12 +1022,13 @@ func (sd *SharedDomains) ClearBranchCache() { // DetachBranchCache makes this SharedDomains ignore the aggregator-scope // BranchCache: commitment branch reads go straight to sd.mem/overlay/MDBX and -// no read populates the shared cache. Used for fork-validation SDs, which read -// transient fork state — sharing the canonical BranchCache let them read stale -// committed branches (wrong trie root → INVALID payload) and pollute the cache -// with fork-transient branches. Only sd.branchCache (the read/populate path, -// domain_shared GetLatest) is consulted, so nil-ing it fully detaches; the -// canonical SDs keep their warm cache. +// no read populates the shared cache. Required for SDs whose read snapshot can +// diverge from the canonical head — notably the payload builder, which runs +// concurrently with head progression: sharing the cache would let it read +// branches newer than its snapshot and pollute the cache with stale +// read-fills. Only sd.branchCache (the read/populate path, domain_shared +// GetLatest) is consulted, so nil-ing it fully detaches; the canonical SDs +// keep their warm cache. func (sd *SharedDomains) DetachBranchCache() { sd.branchCache = nil } diff --git a/execution/builder/builder.go b/execution/builder/builder.go index 6b53e8b6616..7aa9c6c0f8f 100644 --- a/execution/builder/builder.go +++ b/execution/builder/builder.go @@ -151,6 +151,11 @@ func (b *Builder) Build(param *Parameters, interrupt *atomic.Bool) (result *type } defer sd.Close() + // The build runs outside the exec-module semaphore on its own read + // snapshot: the shared BranchCache can be ahead of that snapshot, and the + // build's read-fills could shadow fresher canonical entries. + sd.DetachBranchCache() + if parentSD != nil { sd.SetParent(parentSD) } diff --git a/execution/execmodule/exec_module_branchcache_test.go b/execution/execmodule/exec_module_branchcache_test.go new file mode 100644 index 00000000000..3ff9649347a --- /dev/null +++ b/execution/execmodule/exec_module_branchcache_test.go @@ -0,0 +1,209 @@ +// 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_test + +import ( + "context" + "crypto/rand" + "sync" + "sync/atomic" + "testing" + + "github.com/holiman/uint256" + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/common" + "github.com/erigontech/erigon/common/crypto" + "github.com/erigontech/erigon/common/dbg" + "github.com/erigontech/erigon/common/log/v3" + "github.com/erigontech/erigon/db/kv" + "github.com/erigontech/erigon/db/state/execctx" + "github.com/erigontech/erigon/execution/builder" + "github.com/erigontech/erigon/execution/chain" + "github.com/erigontech/erigon/execution/execmodule/execmoduletester" + "github.com/erigontech/erigon/execution/protocol/params" + "github.com/erigontech/erigon/execution/tests/blockgen" + "github.com/erigontech/erigon/execution/types" + "github.com/erigontech/erigon/txnprovider" +) + +// gateTxnProvider parks the payload builder inside its transaction loop: +// the first ProvideTxns call signals entered and blocks until release, then +// serves txn once. This pins the build's read snapshot while the test +// advances the chain underneath it. +type gateTxnProvider struct { + entered chan struct{} + release chan struct{} + txn types.Transaction + once sync.Once + served atomic.Bool +} + +func newGateTxnProvider(txn types.Transaction) *gateTxnProvider { + return &gateTxnProvider{entered: make(chan struct{}), release: make(chan struct{}), txn: txn} +} + +func (p *gateTxnProvider) ProvideTxns(ctx context.Context, _ ...txnprovider.ProvideOption) ([]types.Transaction, error) { + p.once.Do(func() { close(p.entered) }) + select { + case <-p.release: + case <-ctx.Done(): + return nil, ctx.Err() + } + if p.served.CompareAndSwap(false, true) { + return []types.Transaction{p.txn}, nil + } + return nil, nil +} + +func keccakNibble0(addr common.Address) byte { + return crypto.Keccak256(addr[:])[0] >> 4 +} + +// grindRecipients picks recipients whose state-trie positions isolate one +// branch row: a and b share the first hashed-key nibble (their own depth-1 +// branch row), c and every per-block actor (sender, coinbases, system +// contracts) live under other nibbles, so only transfers to a or b rewrite +// that row. +func grindRecipients(t *testing.T, sender common.Address) (a, b, c common.Address) { + t.Helper() + forbidden := map[byte]bool{ + keccakNibble0(sender): true, + keccakNibble0(common.Address{}): true, // blockgen coinbase + keccakNibble0(common.Address{1}): true, // junk-build fee recipient + keccakNibble0(params.BeaconRootsAddress.Value()): true, + keccakNibble0(params.HistoryStorageAddress.Value()): true, + keccakNibble0(params.WithdrawalRequestAddress.Value()): true, + keccakNibble0(params.ConsolidationRequestAddress.Value()): true, + keccakNibble0(params.SystemAddress.Value()): true, + } + var abNibble byte + var haveA, haveB, haveC bool + for i := 1; i < 1<<16; i++ { + addr := common.Address{0xAA, byte(i >> 8), byte(i)} + n := keccakNibble0(addr) + if forbidden[n] { + continue + } + switch { + case !haveA: + a, abNibble, haveA = addr, n, true + case !haveB && n == abNibble && crypto.Keccak256(addr[:])[0] != crypto.Keccak256(a[:])[0]: + b, haveB = addr, true + case !haveC && n != abNibble: + c, haveC = addr, true + } + if haveA && haveB && haveC { + return a, b, c + } + } + t.Fatal("could not grind recipient addresses") + return +} + +func clearSharedBranchCache(t *testing.T, db kv.TemporalRwDB) { + t.Helper() + roTx, err := db.BeginTemporalRo(t.Context()) + require.NoError(t, err) + defer roTx.Rollback() + sd, err := execctx.NewSharedDomains(t.Context(), roTx, log.New()) + require.NoError(t, err) + defer sd.Close() + sd.ClearBranchCache() +} + +// TestBuilderStaleSnapshotMustNotPoisonBranchCache pins the cache-poisoning +// mechanism behind https://github.com/erigontech/erigon/issues/22152: a +// payload build runs outside the exec-module semaphore on its own read +// snapshot, and its commitment fold read-fills the shared BranchCache. When +// the build outlives head progression, those read-fills write branches from +// the older snapshot; once the fresher entries are gone (LRU eviction, or the +// fill landing after the flush refresh), every later commitment folds the +// stale branch and the node computes wrong trie roots for valid blocks — +// two identically-built nodes then disagree at the tip. +// +// The build is parked inside its transaction loop while the chain advances +// two blocks, the cache is cleared to model the eviction, and the release +// lets the fold repopulate it from the stale snapshot. Block 4 rewrites the +// branch row that blocks 1-2 shaped; a node with coherent caches must accept +// it. +func TestBuilderStaleSnapshotMustNotPoisonBranchCache(t *testing.T) { + prevParallel := dbg.Exec3Parallel + dbg.Exec3Parallel = false // serial executor, as in the kurtosis serial job + t.Cleanup(func() { dbg.Exec3Parallel = prevParallel }) + + ctx := t.Context() + m := execmoduletester.New(t, execmoduletester.WithChainConfig(chain.AllProtocolChanges)) + exec := m.ExecModule + addrA, addrB, addrC := grindRecipients(t, m.Address) + + signer := types.LatestSignerForChainID(m.ChainConfig.ChainID) + gasPrice := uint256.NewInt(m.Genesis.BaseFee().Uint64()) + chainPack, err := blockgen.GenerateChain(m.ChainConfig, m.Genesis, m.Engine, m.DB, 4, func(i int, gen *blockgen.BlockGen) { + transfer := func(to common.Address) { + txn, err := types.SignTx(types.NewTransaction(gen.TxNonce(m.Address), to, uint256.NewInt(10_000), params.TxGas, gasPrice, nil), *signer, m.Key) + require.NoError(t, err) + gen.AddTx(txn) + } + switch i { + case 0: + transfer(addrA) // A and B exist from block 1 on: their shared + transfer(addrB) // depth-1 branch row is materialized + case 1: + transfer(addrA) // rewrites the shared row + case 2: + transfer(addrC) // leaves the shared row untouched + case 3: + transfer(addrB) // block 4's commitment folds the shared row + } + }) + require.NoError(t, err) + + require.NoError(t, insertValidateAndUfc1By1(ctx, exec, chainPack.Blocks[:1])) + + // Junk build on head 1, parked before it pulls transactions. Its transfer + // to A makes the eventual commitment fold walk A's path, reading the + // shared branch row through the pinned head-1 snapshot. + junkTxn, err := types.SignTx(types.NewTransaction(2, addrA, uint256.NewInt(1), params.TxGas, gasPrice, nil), *signer, m.Key) + require.NoError(t, err) + gate := newGateTxnProvider(junkTxn) + var parentBeaconBlockRoot common.Hash + _, err = rand.Read(parentBeaconBlockRoot[:]) + require.NoError(t, err) + head := chainPack.Blocks[0] + payloadId, err := assembleBlock(ctx, exec, &builder.Parameters{ + ParentHash: head.Hash(), + Timestamp: head.Header().Time + 1, + PrevRandao: head.Header().MixDigest, + SuggestedFeeRecipient: common.Address{1}, + Withdrawals: make([]*types.Withdrawal, 0), + ParentBeaconBlockRoot: &parentBeaconBlockRoot, + CustomTxnProvider: gate, + }) + require.NoError(t, err) + <-gate.entered + + require.NoError(t, insertValidateAndUfc1By1(ctx, exec, chainPack.Blocks[1:3])) + + clearSharedBranchCache(t, m.DB) + + close(gate.release) + _, err = getAssembledBlock(ctx, exec, payloadId) + require.NoError(t, err) + + require.NoError(t, insertValidateAndUfc1By1(ctx, exec, chainPack.Blocks[3:4])) +} From a08d6975861f64c9e5ba3a7147ce0141359afc3b Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Fri, 3 Jul 2026 12:26:27 +0200 Subject: [PATCH 02/18] db/state/execctx, execution/builder, rpc/jsonrpc: WithoutBranchCache option, applied to builds and getProof MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WithoutBranchCache SharedDomains option replaces DetachBranchCache: detachment is constructional (covers the constructor's SeekCommitment window, which used to run attached) and cannot be dropped by a refactor. Applied to the builder SD, the build's filterSd, and the getProof/ getWitness SDs — eth_getProof("latest") read-fills the shared cache from an unsynchronized RPC snapshot, the same class as the builder. - WithSequentialCommitment for payload builds: the parallel trie's per-worker contexts open fresh transactions at the current head, not the build's snapshot (#22209 tracks snapshot-pinned worker views). - Test: the junk build's txn inclusion is now asserted, which exposed that the hand-built txn lacked a recovered sender and was silently filtered out (SetSender applied, as the txpool does). New TestWithoutBranchCacheNeverTouchesSharedCache pins the option contract. clearSharedBranchCache clears via AggTx directly instead of constructing a SharedDomains. Issue URL and incident narration dropped from comments. --- db/state/execctx/domain_shared.go | 21 +--- db/state/execctx/options.go | 13 ++- execution/builder/builder.go | 15 ++- execution/builder/exec.go | 2 +- .../exec_module_branchcache_test.go | 100 +++++++++++++++--- rpc/jsonrpc/eth_call.go | 4 +- 6 files changed, 111 insertions(+), 44 deletions(-) diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index 3c01bcb84dc..1c6b9fd2653 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -200,11 +200,11 @@ func NewSharedDomains(ctx context.Context, tx kv.TemporalTx, logger log.Logger, // aggregator). The duck-typed BranchCacheProvider lookup avoids // importing db/state directly — db/state already imports execctx, so // the reverse import would create a cycle. - var branchCache *commitment.BranchCache - if p, ok := tx.AggTx().(commitment.BranchCacheProvider); ok { - branchCache = p.BranchCache() + if !o.noBranchCache { + if p, ok := tx.AggTx().(commitment.BranchCacheProvider); ok { + sd.branchCache = p.BranchCache() + } } - sd.branchCache = branchCache if p, ok := tx.AggTx().(kvmetrics.MetricsCollectorProvider); ok { sd.collector = p.MetricsCollector() } @@ -1020,19 +1020,6 @@ func (sd *SharedDomains) ClearBranchCache() { } } -// DetachBranchCache makes this SharedDomains ignore the aggregator-scope -// BranchCache: commitment branch reads go straight to sd.mem/overlay/MDBX and -// no read populates the shared cache. Required for SDs whose read snapshot can -// diverge from the canonical head — notably the payload builder, which runs -// concurrently with head progression: sharing the cache would let it read -// branches newer than its snapshot and pollute the cache with stale -// read-fills. Only sd.branchCache (the read/populate path, domain_shared -// GetLatest) is consulted, so nil-ing it fully detaches; the canonical SDs -// keep their warm cache. -func (sd *SharedDomains) DetachBranchCache() { - sd.branchCache = nil -} - // TemporalDomain satisfaction. Collects no read metrics — see // temporalGetter.GetLatest for why there is no process-wide accumulator. func (sd *SharedDomains) GetLatest(domain kv.Domain, tx kv.TemporalTx, k []byte) (v []byte, step kv.Step, err error) { diff --git a/db/state/execctx/options.go b/db/state/execctx/options.go index 1c48ebbb8b6..a4d20d80414 100644 --- a/db/state/execctx/options.go +++ b/db/state/execctx/options.go @@ -19,7 +19,8 @@ package execctx import "github.com/erigontech/erigon/execution/commitment" type sharedDomainOptions struct { - trieCfg commitment.TrieConfig + trieCfg commitment.TrieConfig + noBranchCache bool } // SharedDomainOption configures NewSharedDomains. @@ -41,3 +42,13 @@ func WithoutDeferredBranchUpdates() SharedDomainOption { func WithSequentialCommitment() SharedDomainOption { return func(o *sharedDomainOptions) { o.trieCfg.Variant = commitment.VariantHexPatriciaTrie } } + +// WithoutBranchCache leaves the SharedDomains detached from the aggregator-scope +// BranchCache: commitment branch reads go straight to sd.mem/overlay/MDBX and no +// read populates the shared cache. Required for SDs that read concurrently with +// head progression (payload builds, latest-state RPC readers) — the shared cache +// can be ahead of their snapshot, and their read-fills could shadow fresher +// canonical entries. SDs serialized with head progression keep the warm cache. +func WithoutBranchCache() SharedDomainOption { + return func(o *sharedDomainOptions) { o.noBranchCache = true } +} diff --git a/execution/builder/builder.go b/execution/builder/builder.go index 7aa9c6c0f8f..c8995f6430a 100644 --- a/execution/builder/builder.go +++ b/execution/builder/builder.go @@ -145,24 +145,21 @@ func (b *Builder) Build(param *Parameters, interrupt *atomic.Bool) (result *type } } - sd, err := execctx.NewSharedDomains(b.ctx, compositeTx, b.logger, execctx.WithoutDeferredBranchUpdates()) + // WithSequentialCommitment: the parallel trie's per-worker readers open + // fresh transactions at the current head, not this build's snapshot. + sd, err := execctx.NewSharedDomains(b.ctx, compositeTx, b.logger, + execctx.WithoutDeferredBranchUpdates(), execctx.WithoutBranchCache(), execctx.WithSequentialCommitment()) if err != nil { return nil, err } defer sd.Close() - // The build runs outside the exec-module semaphore on its own read - // snapshot: the shared BranchCache can be ahead of that snapshot, and the - // build's read-fills could shadow fresher canonical entries. - sd.DetachBranchCache() - if parentSD != nil { sd.SetParent(parentSD) } - // Wire the parallel commitment trie's context factory. Values still resolve - // through the sd/parent mem-batch overlay chain; b.db only backs the fresh - // per-worker readers. Mirrors exec3; no-op for the sequential trie. + // Backs the trie warmuper's per-worker readers; their reads only heat the + // page cache, so the current-head view is fine. sd.EnableParaTrieDB(b.db) executionAt, err := stages.GetStageProgress(compositeTx, stages.Execution) diff --git a/execution/builder/exec.go b/execution/builder/exec.go index 665929b76c5..aa8a5eccc75 100644 --- a/execution/builder/exec.go +++ b/execution/builder/exec.go @@ -128,7 +128,7 @@ func execBlock(ctx context0.Context, sd *execctx.SharedDomains, tx kv.TemporalTx return err } defer filterMb.Close() - filterSd, err := execctx.NewSharedDomains(ctx, filterMb, logger, execctx.WithoutDeferredBranchUpdates()) + filterSd, err := execctx.NewSharedDomains(ctx, filterMb, logger, execctx.WithoutDeferredBranchUpdates(), execctx.WithoutBranchCache()) if err != nil { return err } diff --git a/execution/execmodule/exec_module_branchcache_test.go b/execution/execmodule/exec_module_branchcache_test.go index 3ff9649347a..b4856cd2e87 100644 --- a/execution/execmodule/exec_module_branchcache_test.go +++ b/execution/execmodule/exec_module_branchcache_test.go @@ -17,6 +17,7 @@ package execmodule_test import ( + "bytes" "context" "crypto/rand" "sync" @@ -34,10 +35,12 @@ import ( "github.com/erigontech/erigon/db/state/execctx" "github.com/erigontech/erigon/execution/builder" "github.com/erigontech/erigon/execution/chain" + "github.com/erigontech/erigon/execution/commitment" "github.com/erigontech/erigon/execution/execmodule/execmoduletester" "github.com/erigontech/erigon/execution/protocol/params" "github.com/erigontech/erigon/execution/tests/blockgen" "github.com/erigontech/erigon/execution/types" + "github.com/erigontech/erigon/execution/types/accounts" "github.com/erigontech/erigon/txnprovider" ) @@ -115,26 +118,31 @@ func grindRecipients(t *testing.T, sender common.Address) (a, b, c common.Addres return } +func sharedBranchCache(t *testing.T, tx kv.TemporalTx) *commitment.BranchCache { + t.Helper() + provider, ok := tx.AggTx().(commitment.BranchCacheProvider) + require.True(t, ok) + bc := provider.BranchCache() + require.NotNil(t, bc) + return bc +} + func clearSharedBranchCache(t *testing.T, db kv.TemporalRwDB) { t.Helper() roTx, err := db.BeginTemporalRo(t.Context()) require.NoError(t, err) defer roTx.Rollback() - sd, err := execctx.NewSharedDomains(t.Context(), roTx, log.New()) - require.NoError(t, err) - defer sd.Close() - sd.ClearBranchCache() + sharedBranchCache(t, roTx).Clear() } // TestBuilderStaleSnapshotMustNotPoisonBranchCache pins the cache-poisoning -// mechanism behind https://github.com/erigontech/erigon/issues/22152: a -// payload build runs outside the exec-module semaphore on its own read -// snapshot, and its commitment fold read-fills the shared BranchCache. When -// the build outlives head progression, those read-fills write branches from -// the older snapshot; once the fresher entries are gone (LRU eviction, or the -// fill landing after the flush refresh), every later commitment folds the -// stale branch and the node computes wrong trie roots for valid blocks — -// two identically-built nodes then disagree at the tip. +// mechanism: a payload build runs outside the exec-module semaphore on its +// own read snapshot, and its commitment fold read-fills the shared +// BranchCache. When the build outlives head progression, those read-fills +// write branches from the older snapshot; once the fresher entries are gone +// (LRU eviction, or the fill landing after the flush refresh), every later +// commitment folds the stale branch and the node computes wrong trie roots +// for valid blocks — two identically-built nodes then disagree at the tip. // // The build is parked inside its transaction loop while the chain advances // two blocks, the cache is cleared to model the eviction, and the release @@ -143,7 +151,7 @@ func clearSharedBranchCache(t *testing.T, db kv.TemporalRwDB) { // it. func TestBuilderStaleSnapshotMustNotPoisonBranchCache(t *testing.T) { prevParallel := dbg.Exec3Parallel - dbg.Exec3Parallel = false // serial executor, as in the kurtosis serial job + dbg.Exec3Parallel = false t.Cleanup(func() { dbg.Exec3Parallel = prevParallel }) ctx := t.Context() @@ -180,6 +188,9 @@ func TestBuilderStaleSnapshotMustNotPoisonBranchCache(t *testing.T) { // shared branch row through the pinned head-1 snapshot. junkTxn, err := types.SignTx(types.NewTransaction(2, addrA, uint256.NewInt(1), params.TxGas, gasPrice, nil), *signer, m.Key) require.NoError(t, err) + // The builder drops transactions without a recovered sender; the txpool + // normally guarantees it. + junkTxn.SetSender(accounts.InternAddress(m.Address)) gate := newGateTxnProvider(junkTxn) var parentBeaconBlockRoot common.Hash _, err = rand.Read(parentBeaconBlockRoot[:]) @@ -202,8 +213,69 @@ func TestBuilderStaleSnapshotMustNotPoisonBranchCache(t *testing.T) { clearSharedBranchCache(t, m.DB) close(gate.release) - _, err = getAssembledBlock(ctx, exec, payloadId) + junkBlock, err := getAssembledBlock(ctx, exec, payloadId) require.NoError(t, err) + require.Len(t, junkBlock.Transactions(), 1) require.NoError(t, insertValidateAndUfc1By1(ctx, exec, chainPack.Blocks[3:4])) } + +// someBranchKey returns a commitment branch row present in the latest state. +func someBranchKey(t *testing.T, tx kv.TemporalTx) []byte { + t.Helper() + it, err := tx.Debug().RangeLatest(kv.CommitmentDomain, nil, nil, 1<<20) + require.NoError(t, err) + defer it.Close() + for it.HasNext() { + k, v, err := it.Next() + require.NoError(t, err) + if !bytes.Equal(k, commitment.KeyCommitmentState) && len(v) > 0 { + return bytes.Clone(k) + } + } + t.Fatal("no commitment branch row found") + return nil +} + +// TestWithoutBranchCacheNeverTouchesSharedCache pins the WithoutBranchCache +// contract: reads through such a SharedDomains must not read-fill the shared +// BranchCache, while a default SharedDomains does. +func TestWithoutBranchCacheNeverTouchesSharedCache(t *testing.T) { + ctx := t.Context() + m := execmoduletester.New(t, execmoduletester.WithChainConfig(chain.AllProtocolChanges)) + + signer := types.LatestSignerForChainID(m.ChainConfig.ChainID) + gasPrice := uint256.NewInt(m.Genesis.BaseFee().Uint64()) + chainPack, err := blockgen.GenerateChain(m.ChainConfig, m.Genesis, m.Engine, m.DB, 2, func(i int, gen *blockgen.BlockGen) { + txn, err := types.SignTx(types.NewTransaction(gen.TxNonce(m.Address), common.Address{0xAA, byte(i)}, uint256.NewInt(10_000), params.TxGas, gasPrice, nil), *signer, m.Key) + require.NoError(t, err) + gen.AddTx(txn) + }) + require.NoError(t, err) + require.NoError(t, insertValidateAndUfc1By1(ctx, m.ExecModule, chainPack.Blocks)) + + roTx, err := m.DB.BeginTemporalRo(ctx) + require.NoError(t, err) + defer roTx.Rollback() + + branchKey := someBranchKey(t, roTx) + bc := sharedBranchCache(t, roTx) + + readBranch := func(opts ...execctx.SharedDomainOption) { + sd, err := execctx.NewSharedDomains(ctx, roTx, log.New(), opts...) + require.NoError(t, err) + defer sd.Close() + v, _, err := sd.GetLatest(kv.CommitmentDomain, roTx, branchKey) + require.NoError(t, err) + require.NotEmpty(t, v) + } + + bc.Clear() + readBranch(execctx.WithoutBranchCache()) + _, _, ok := bc.Get(branchKey) + require.False(t, ok, "detached SharedDomains read-filled the shared BranchCache") + + readBranch() + _, _, ok = bc.Get(branchKey) + require.True(t, ok, "default SharedDomains should read-fill the shared BranchCache") +} diff --git a/rpc/jsonrpc/eth_call.go b/rpc/jsonrpc/eth_call.go index 9b092f8154d..456ede9dcd5 100644 --- a/rpc/jsonrpc/eth_call.go +++ b/rpc/jsonrpc/eth_call.go @@ -470,7 +470,7 @@ func (api *APIImpl) getProof(ctx context.Context, roTx kv.TemporalTx, address co return nil, err } - domains, err := execctx.NewSharedDomains(ctx, tx, log.New(), execctx.WithoutDeferredBranchUpdates(), execctx.WithSequentialCommitment()) + domains, err := execctx.NewSharedDomains(ctx, tx, log.New(), execctx.WithoutDeferredBranchUpdates(), execctx.WithoutBranchCache(), execctx.WithSequentialCommitment()) if err != nil { return nil, err } @@ -761,7 +761,7 @@ func (api *BaseAPI) getWitness(ctx context.Context, db kv.TemporalRoDB, blockNrO it.Close() } - domains, err := execctx.NewSharedDomains(ctx, tx, log.New(), execctx.WithoutDeferredBranchUpdates(), execctx.WithSequentialCommitment()) + domains, err := execctx.NewSharedDomains(ctx, tx, log.New(), execctx.WithoutDeferredBranchUpdates(), execctx.WithoutBranchCache(), execctx.WithSequentialCommitment()) if err != nil { return nil, err } From a094277174b89f7fd6b92731e68427cfd97e2db9 Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Fri, 3 Jul 2026 12:32:33 +0200 Subject: [PATCH 03/18] execution/execmodule: bound the wait for the parked build A pre-loop build error would leave the test blocked on gate.entered until the package timeout; fail fast with a message instead. --- execution/execmodule/exec_module_branchcache_test.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/execution/execmodule/exec_module_branchcache_test.go b/execution/execmodule/exec_module_branchcache_test.go index b4856cd2e87..69f9e580458 100644 --- a/execution/execmodule/exec_module_branchcache_test.go +++ b/execution/execmodule/exec_module_branchcache_test.go @@ -23,6 +23,7 @@ import ( "sync" "sync/atomic" "testing" + "time" "github.com/holiman/uint256" "github.com/stretchr/testify/require" @@ -206,7 +207,11 @@ func TestBuilderStaleSnapshotMustNotPoisonBranchCache(t *testing.T) { CustomTxnProvider: gate, }) require.NoError(t, err) - <-gate.entered + select { + case <-gate.entered: + case <-time.After(time.Minute): + t.Fatal("build did not reach its transaction loop") + } require.NoError(t, insertValidateAndUfc1By1(ctx, exec, chainPack.Blocks[1:3])) From 1514c0f073dc481fe1887ec7a25b98ae96c1a0e8 Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Fri, 3 Jul 2026 12:42:07 +0200 Subject: [PATCH 04/18] rpc/jsonrpc, rpc/rpchelper: WithoutBranchCache for the remaining RPC-side SharedDomains RPC request SDs run outside the exec-module semaphore; keep them all off the shared BranchCache uniformly, as getProof/getWitness already are. --- rpc/jsonrpc/debug_execution_witness.go | 2 +- rpc/jsonrpc/eth_simulation.go | 2 +- rpc/jsonrpc/receipts/receipts_generator.go | 4 ++-- rpc/rpchelper/commitment.go | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/rpc/jsonrpc/debug_execution_witness.go b/rpc/jsonrpc/debug_execution_witness.go index 5f31f22c5ce..9456e86fc67 100644 --- a/rpc/jsonrpc/debug_execution_witness.go +++ b/rpc/jsonrpc/debug_execution_witness.go @@ -750,7 +750,7 @@ func (api *DebugAPIImpl) ExecutionWitness(ctx context.Context, blockNrOrHash rpc // Use the proof infrastructure from the commitment context. // Witness generation requires the sequential HexPatriciaHashed (Witness() // type-asserts it); the parallel trie cannot serve it. - domains, err := execctx.NewSharedDomains(ctx, tx, log.New(), execctx.WithoutDeferredBranchUpdates(), execctx.WithSequentialCommitment()) + domains, err := execctx.NewSharedDomains(ctx, tx, log.New(), execctx.WithoutDeferredBranchUpdates(), execctx.WithoutBranchCache(), execctx.WithSequentialCommitment()) if err != nil { return nil, err } diff --git a/rpc/jsonrpc/eth_simulation.go b/rpc/jsonrpc/eth_simulation.go index 780811060f3..a1ffaaaed08 100644 --- a/rpc/jsonrpc/eth_simulation.go +++ b/rpc/jsonrpc/eth_simulation.go @@ -164,7 +164,7 @@ func (api *APIImpl) SimulateV1(ctx context.Context, req SimulationRequest, block return nil, err } - sharedDomains, err := execctx.NewSharedDomains(ctx, tx, api.logger, execctx.WithoutDeferredBranchUpdates(), execctx.WithSequentialCommitment()) + sharedDomains, err := execctx.NewSharedDomains(ctx, tx, api.logger, execctx.WithoutDeferredBranchUpdates(), execctx.WithoutBranchCache(), execctx.WithSequentialCommitment()) if err != nil { return nil, err } diff --git a/rpc/jsonrpc/receipts/receipts_generator.go b/rpc/jsonrpc/receipts/receipts_generator.go index e5782f8b512..6d03d624c6e 100644 --- a/rpc/jsonrpc/receipts/receipts_generator.go +++ b/rpc/jsonrpc/receipts/receipts_generator.go @@ -324,7 +324,7 @@ func (g *Generator) GetReceipt(ctx context.Context, cfg *chain.Config, tx kv.Tem var stateWriter state.StateWriter if calculatePostState && postState.CommitmentHistory { - sharedDomains, err = execctx.NewSharedDomains(ctx, tx, log.Root(), execctx.WithoutDeferredBranchUpdates(), execctx.WithSequentialCommitment()) + sharedDomains, err = execctx.NewSharedDomains(ctx, tx, log.Root(), execctx.WithoutDeferredBranchUpdates(), execctx.WithoutBranchCache(), execctx.WithSequentialCommitment()) if err != nil { return nil, err } @@ -542,7 +542,7 @@ func (g *Generator) GetReceipts(ctx context.Context, cfg *chain.Config, tx kv.Te var stateWriter state.StateWriter if opts.CommitmentHistoryEnabled { - sharedDomains, err = execctx.NewSharedDomains(ctx, tx, log.Root(), execctx.WithoutDeferredBranchUpdates(), execctx.WithSequentialCommitment()) + sharedDomains, err = execctx.NewSharedDomains(ctx, tx, log.Root(), execctx.WithoutDeferredBranchUpdates(), execctx.WithoutBranchCache(), execctx.WithSequentialCommitment()) if err != nil { return nil, err } diff --git a/rpc/rpchelper/commitment.go b/rpc/rpchelper/commitment.go index 152608731b2..dafb8f1654f 100644 --- a/rpc/rpchelper/commitment.go +++ b/rpc/rpchelper/commitment.go @@ -94,7 +94,7 @@ func (r *CommitmentReplay) ComputeCustomCommitmentFromStateHistory( } defer ttx.Rollback() - tsd, err := execctx.NewSharedDomains(ctx, ttx, r.logger, execctx.WithoutDeferredBranchUpdates(), execctx.WithSequentialCommitment()) + tsd, err := execctx.NewSharedDomains(ctx, ttx, r.logger, execctx.WithoutDeferredBranchUpdates(), execctx.WithoutBranchCache(), execctx.WithSequentialCommitment()) if err != nil { return nil, err } From 1181e4f3fa5384285de060b50d515db024bdc827 Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:28:10 +0200 Subject: [PATCH 05/18] execution/execmodule: self-check the shared branch row in the poisoning test Assert the A+B depth-1 commitment branch row is rewritten by block 2 and left untouched by block 3, pinning the head-1 vs head-3 staleness the repro depends on instead of trusting grindRecipients' forbidden-nibble set to stay exhaustive. --- .../exec_module_branchcache_test.go | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/execution/execmodule/exec_module_branchcache_test.go b/execution/execmodule/exec_module_branchcache_test.go index 69f9e580458..952b36dec27 100644 --- a/execution/execmodule/exec_module_branchcache_test.go +++ b/execution/execmodule/exec_module_branchcache_test.go @@ -37,6 +37,7 @@ import ( "github.com/erigontech/erigon/execution/builder" "github.com/erigontech/erigon/execution/chain" "github.com/erigontech/erigon/execution/commitment" + "github.com/erigontech/erigon/execution/commitment/nibbles" "github.com/erigontech/erigon/execution/execmodule/execmoduletester" "github.com/erigontech/erigon/execution/protocol/params" "github.com/erigontech/erigon/execution/tests/blockgen" @@ -136,6 +137,18 @@ func clearSharedBranchCache(t *testing.T, db kv.TemporalRwDB) { sharedBranchCache(t, roTx).Clear() } +// latestBranchRow returns the committed value of one commitment branch row. +func latestBranchRow(t *testing.T, db kv.TemporalRwDB, key []byte) []byte { + t.Helper() + roTx, err := db.BeginTemporalRo(t.Context()) + require.NoError(t, err) + defer roTx.Rollback() + v, _, err := roTx.GetLatest(kv.CommitmentDomain, key) + require.NoError(t, err) + require.NotEmpty(t, v, "commitment branch row missing") + return bytes.Clone(v) +} + // TestBuilderStaleSnapshotMustNotPoisonBranchCache pins the cache-poisoning // mechanism: a payload build runs outside the exec-module semaphore on its // own read snapshot, and its commitment fold read-fills the shared @@ -184,6 +197,14 @@ func TestBuilderStaleSnapshotMustNotPoisonBranchCache(t *testing.T) { require.NoError(t, insertValidateAndUfc1By1(ctx, exec, chainPack.Blocks[:1])) + // The row shared by A and B is the depth-1 commitment branch at their common + // first hashed nibble. Asserting how blocks 2 and 3 touch it makes + // grindRecipients' isolation self-checking and pins the staleness the repro + // needs: the head-1 row the build reads must differ from the head-3 row + // block 4 folds. + abBranchKey := nibbles.HexToCompact([]byte{keccakNibble0(addrA)}) + rowAfterBlock1 := latestBranchRow(t, m.DB, abBranchKey) + // Junk build on head 1, parked before it pulls transactions. Its transfer // to A makes the eventual commitment fold walk A's path, reading the // shared branch row through the pinned head-1 snapshot. @@ -213,7 +234,13 @@ func TestBuilderStaleSnapshotMustNotPoisonBranchCache(t *testing.T) { t.Fatal("build did not reach its transaction loop") } - require.NoError(t, insertValidateAndUfc1By1(ctx, exec, chainPack.Blocks[1:3])) + require.NoError(t, insertValidateAndUfc1By1(ctx, exec, chainPack.Blocks[1:2])) + rowAfterBlock2 := latestBranchRow(t, m.DB, abBranchKey) + require.NotEqual(t, rowAfterBlock1, rowAfterBlock2, "block 2 (transfer to A) must rewrite the shared branch row") + + require.NoError(t, insertValidateAndUfc1By1(ctx, exec, chainPack.Blocks[2:3])) + rowAfterBlock3 := latestBranchRow(t, m.DB, abBranchKey) + require.Equal(t, rowAfterBlock2, rowAfterBlock3, "block 3 (transfer to C) must leave the shared branch row untouched") clearSharedBranchCache(t, m.DB) From a986e30e9c3a770920286ded9a5a98d6316c6c0e Mon Sep 17 00:00:00 2001 From: yperbasis Date: Tue, 14 Jul 2026 13:31:18 +0200 Subject: [PATCH 06/18] execution/engineapi: detach testing-API SharedDomains from the shared BranchCache --- execution/engineapi/testing_api.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/execution/engineapi/testing_api.go b/execution/engineapi/testing_api.go index 31c891e277b..18e3523dd3d 100644 --- a/execution/engineapi/testing_api.go +++ b/execution/engineapi/testing_api.go @@ -96,7 +96,7 @@ func (t *testingImpl) decodeTxnProvider(ctx context.Context, transactions *[]hex return nil, fmt.Errorf("could not begin temporal transaction: %w", err) } defer dbTx.Rollback() - sd, err := execctx.NewSharedDomains(ctx, dbTx, t.logger, execctx.WithoutDeferredBranchUpdates()) + sd, err := execctx.NewSharedDomains(ctx, dbTx, t.logger, execctx.WithoutDeferredBranchUpdates(), execctx.WithoutBranchCache()) if err != nil { return nil, fmt.Errorf("NewSharedDomains error: %w", err) } From a511c9a70716b66f1de5d5957a8e909361fa0d8d Mon Sep 17 00:00:00 2001 From: yperbasis Date: Thu, 16 Jul 2026 16:34:28 +0200 Subject: [PATCH 07/18] db/state/execctx: mention WithoutBranchCache in branchCache field doc --- db/state/execctx/domain_shared.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index 49d35f03640..0e5f1c554ef 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -141,8 +141,9 @@ type SharedDomains struct { // behind sd.mem and sd.parent.mem in the read chain (consulted only after // both miss, before the aggTx files/MDBX read), so writers' in-flight // bytes always mask the cache and cross-SD pollution is impossible. - // May be nil for test setups whose AggTx doesn't implement - // commitment.BranchCacheProvider. + // nil for SDs constructed with WithoutBranchCache (snapshot readers that + // must not touch the shared cache) and for test setups whose AggTx + // doesn't implement commitment.BranchCacheProvider. branchCache *commitment.BranchCache // collector is the process-level KV-read metrics collector (aggregator From 28e650ceb876b9305d992f8897143f21f9fe6fa3 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Fri, 14 Aug 2026 16:49:41 +0200 Subject: [PATCH 08/18] execution: separate builder mitigation from cache isolation --- execution/builder/builder.go | 10 +- .../exec_module_branchcache_test.go | 227 ++---------------- 2 files changed, 18 insertions(+), 219 deletions(-) diff --git a/execution/builder/builder.go b/execution/builder/builder.go index ee4860196e7..5150348b674 100644 --- a/execution/builder/builder.go +++ b/execution/builder/builder.go @@ -145,10 +145,7 @@ func (b *Builder) Build(param *Parameters, interrupt *atomic.Bool) (result *type } } - // WithSequentialCommitment: the parallel trie's per-worker readers open - // fresh transactions at the current head, not this build's snapshot. - sd, err := execctx.NewSharedDomains(b.ctx, compositeTx, b.logger, - execctx.WithoutDeferredBranchUpdates(), execctx.WithoutSharedBranchCache(), execctx.WithSequentialCommitment()) + sd, err := execctx.NewSharedDomains(b.ctx, compositeTx, b.logger, execctx.WithoutDeferredBranchUpdates(), execctx.WithoutSharedBranchCache()) if err != nil { return nil, err } @@ -158,8 +155,9 @@ func (b *Builder) Build(param *Parameters, interrupt *atomic.Bool) (result *type sd.SetParent(parentSD) } - // Backs the trie warmuper's per-worker readers; their reads only heat the - // page cache, so the current-head view is fine. + // Wire the parallel commitment trie's context factory. Values still resolve + // through the sd/parent mem-batch overlay chain; b.db only backs the fresh + // per-worker readers. Mirrors exec3; no-op for the sequential trie. sd.EnableParaTrieDB(b.db) executionAt, err := stages.GetStageProgress(compositeTx, stages.Execution) diff --git a/execution/execmodule/exec_module_branchcache_test.go b/execution/execmodule/exec_module_branchcache_test.go index 9978ec61b60..9c04c298871 100644 --- a/execution/execmodule/exec_module_branchcache_test.go +++ b/execution/execmodule/exec_module_branchcache_test.go @@ -18,108 +18,23 @@ package execmodule_test import ( "bytes" - "context" - "crypto/rand" - "sync" - "sync/atomic" "testing" - "time" "github.com/holiman/uint256" "github.com/stretchr/testify/require" "github.com/erigontech/erigon/common" - "github.com/erigontech/erigon/common/crypto" - "github.com/erigontech/erigon/common/dbg" "github.com/erigontech/erigon/common/log/v3" "github.com/erigontech/erigon/db/kv" "github.com/erigontech/erigon/db/state/execctx" - "github.com/erigontech/erigon/execution/builder" "github.com/erigontech/erigon/execution/chain" "github.com/erigontech/erigon/execution/commitment" - "github.com/erigontech/erigon/execution/commitment/nibbles" "github.com/erigontech/erigon/execution/execmodule/execmoduletester" "github.com/erigontech/erigon/execution/protocol/params" "github.com/erigontech/erigon/execution/tests/blockgen" "github.com/erigontech/erigon/execution/types" - "github.com/erigontech/erigon/execution/types/accounts" - "github.com/erigontech/erigon/txnprovider" ) -// gateTxnProvider parks the payload builder inside its transaction loop: -// the first ProvideTxns call signals entered and blocks until release, then -// serves txn once. This pins the build's read snapshot while the test -// advances the chain underneath it. -type gateTxnProvider struct { - entered chan struct{} - release chan struct{} - txn types.Transaction - once sync.Once - served atomic.Bool -} - -func newGateTxnProvider(txn types.Transaction) *gateTxnProvider { - return &gateTxnProvider{entered: make(chan struct{}), release: make(chan struct{}), txn: txn} -} - -func (p *gateTxnProvider) ProvideTxns(ctx context.Context, _ ...txnprovider.ProvideOption) ([]types.Transaction, error) { - p.once.Do(func() { close(p.entered) }) - select { - case <-p.release: - case <-ctx.Done(): - return nil, ctx.Err() - } - if p.served.CompareAndSwap(false, true) { - return []types.Transaction{p.txn}, nil - } - return nil, nil -} - -func keccakNibble0(addr common.Address) byte { - return crypto.Keccak256(addr[:])[0] >> 4 -} - -// grindRecipients picks recipients whose state-trie positions isolate one -// branch row: a and b share the first hashed-key nibble (their own depth-1 -// branch row), c and every per-block actor (sender, coinbases, system -// contracts) live under other nibbles, so only transfers to a or b rewrite -// that row. -func grindRecipients(t *testing.T, sender common.Address) (a, b, c common.Address) { - t.Helper() - forbidden := map[byte]bool{ - keccakNibble0(sender): true, - keccakNibble0(common.Address{}): true, // blockgen coinbase - keccakNibble0(common.Address{1}): true, // junk-build fee recipient - keccakNibble0(params.BeaconRootsAddress.Value()): true, - keccakNibble0(params.HistoryStorageAddress.Value()): true, - keccakNibble0(params.WithdrawalRequestAddress.Value()): true, - keccakNibble0(params.ConsolidationRequestAddress.Value()): true, - keccakNibble0(params.SystemAddress.Value()): true, - } - var abNibble byte - var haveA, haveB, haveC bool - for i := 1; i < 1<<16; i++ { - addr := common.Address{0xAA, byte(i >> 8), byte(i)} - n := keccakNibble0(addr) - if forbidden[n] { - continue - } - switch { - case !haveA: - a, abNibble, haveA = addr, n, true - case !haveB && n == abNibble && crypto.Keccak256(addr[:])[0] != crypto.Keccak256(a[:])[0]: - b, haveB = addr, true - case !haveC && n != abNibble: - c, haveC = addr, true - } - if haveA && haveB && haveC { - return a, b, c - } - } - t.Fatal("could not grind recipient addresses") - return -} - func sharedBranchCache(t *testing.T, tx kv.TemporalTx) *commitment.BranchCache { t.Helper() provider, ok := tx.AggTx().(commitment.BranchCacheProvider) @@ -129,129 +44,6 @@ func sharedBranchCache(t *testing.T, tx kv.TemporalTx) *commitment.BranchCache { return bc } -func clearSharedBranchCache(t *testing.T, db kv.TemporalRwDB) { - t.Helper() - roTx, err := db.BeginTemporalRo(t.Context()) - require.NoError(t, err) - defer roTx.Rollback() - sharedBranchCache(t, roTx).Clear() -} - -// latestBranchRow returns the committed value of one commitment branch row. -func latestBranchRow(t *testing.T, db kv.TemporalRwDB, key []byte) []byte { - t.Helper() - roTx, err := db.BeginTemporalRo(t.Context()) - require.NoError(t, err) - defer roTx.Rollback() - v, _, err := roTx.GetLatest(kv.CommitmentDomain, key) - require.NoError(t, err) - require.NotEmpty(t, v, "commitment branch row missing") - return bytes.Clone(v) -} - -// TestBuilderStaleSnapshotMustNotPoisonBranchCache pins the cache-poisoning -// mechanism: a payload build runs outside the exec-module semaphore on its -// own read snapshot, and its commitment fold read-fills the shared -// BranchCache. When the build outlives head progression, those read-fills -// write branches from the older snapshot; once the fresher entries are gone -// (LRU eviction, or the fill landing after the flush refresh), every later -// commitment folds the stale branch and the node computes wrong trie roots -// for valid blocks — two identically-built nodes then disagree at the tip. -// -// The build is parked inside its transaction loop while the chain advances -// two blocks, the cache is cleared to model the eviction, and the release -// lets the fold repopulate it from the stale snapshot. Block 4 rewrites the -// branch row that blocks 1-2 shaped; a node with coherent caches must accept -// it. -func TestBuilderStaleSnapshotMustNotPoisonBranchCache(t *testing.T) { - prevParallel := dbg.Exec3Parallel - dbg.Exec3Parallel = false - t.Cleanup(func() { dbg.Exec3Parallel = prevParallel }) - - ctx := t.Context() - m := execmoduletester.New(t, execmoduletester.WithChainConfig(chain.AllProtocolChanges)) - addrA, addrB, addrC := grindRecipients(t, m.Address) - - signer := types.LatestSignerForChainID(m.ChainConfig.ChainID) - gasPrice := uint256.NewInt(m.Genesis.BaseFee().Uint64()) - chainPack, err := blockgen.GenerateChain(m.ChainConfig, m.Genesis, m.Engine, m.DB, 4, func(i int, gen *blockgen.BlockGen) { - transfer := func(to common.Address) { - txn, err := types.SignTx(types.NewTransaction(gen.TxNonce(m.Address), to, uint256.NewInt(10_000), params.TxGas+params.StateGasNewAccount, gasPrice, nil), *signer, m.Key) - require.NoError(t, err) - gen.AddTx(txn) - } - switch i { - case 0: - transfer(addrA) // A and B exist from block 1 on: their shared - transfer(addrB) // depth-1 branch row is materialized - case 1: - transfer(addrA) // rewrites the shared row - case 2: - transfer(addrC) // leaves the shared row untouched - case 3: - transfer(addrB) // block 4's commitment folds the shared row - } - }) - require.NoError(t, err) - - require.NoError(t, m.InsertValidateAndUfc1By1(ctx, chainPack.Blocks[:1])) - - // The row shared by A and B is the depth-1 commitment branch at their common - // first hashed nibble. Asserting how blocks 2 and 3 touch it makes - // grindRecipients' isolation self-checking and pins the staleness the repro - // needs: the head-1 row the build reads must differ from the head-3 row - // block 4 folds. - abBranchKey := nibbles.HexToCompact([]byte{keccakNibble0(addrA)}) - rowAfterBlock1 := latestBranchRow(t, m.DB, abBranchKey) - - // Junk build on head 1, parked before it pulls transactions. Its transfer - // to A makes the eventual commitment fold walk A's path, reading the - // shared branch row through the pinned head-1 snapshot. - junkTxn, err := types.SignTx(types.NewTransaction(2, addrA, uint256.NewInt(1), params.TxGas, gasPrice, nil), *signer, m.Key) - require.NoError(t, err) - // The builder drops transactions without a recovered sender; the txpool - // normally guarantees it. - junkTxn.SetSender(accounts.InternAddress(m.Address)) - gate := newGateTxnProvider(junkTxn) - var parentBeaconBlockRoot common.Hash - _, err = rand.Read(parentBeaconBlockRoot[:]) - require.NoError(t, err) - head := chainPack.Blocks[0] - payloadId, err := m.AssembleBlock(ctx, &builder.Parameters{ - ParentHash: head.Hash(), - Timestamp: head.Header().Time + 1, - PrevRandao: head.Header().MixDigest, - SuggestedFeeRecipient: common.Address{1}, - Withdrawals: make([]*types.Withdrawal, 0), - ParentBeaconBlockRoot: &parentBeaconBlockRoot, - CustomTxnProvider: gate, - }) - require.NoError(t, err) - select { - case <-gate.entered: - case <-time.After(time.Minute): - t.Fatal("build did not reach its transaction loop") - } - - require.NoError(t, m.InsertValidateAndUfc1By1(ctx, chainPack.Blocks[1:2])) - rowAfterBlock2 := latestBranchRow(t, m.DB, abBranchKey) - require.NotEqual(t, rowAfterBlock1, rowAfterBlock2, "block 2 (transfer to A) must rewrite the shared branch row") - - require.NoError(t, m.InsertValidateAndUfc1By1(ctx, chainPack.Blocks[2:3])) - rowAfterBlock3 := latestBranchRow(t, m.DB, abBranchKey) - require.Equal(t, rowAfterBlock2, rowAfterBlock3, "block 3 (transfer to C) must leave the shared branch row untouched") - - clearSharedBranchCache(t, m.DB) - - close(gate.release) - junkBlock, err := m.GetAssembledBlock(ctx, payloadId) - require.NoError(t, err) - require.Len(t, junkBlock.Transactions(), 1) - - require.NoError(t, m.InsertValidateAndUfc1By1(ctx, chainPack.Blocks[3:4])) -} - -// someBranchKey returns a commitment branch row present in the latest state. func someBranchKey(t *testing.T, tx kv.TemporalTx) []byte { t.Helper() it, err := tx.Debug().RangeLatest(kv.CommitmentDomain, nil, nil, 1<<20) @@ -268,9 +60,6 @@ func someBranchKey(t *testing.T, tx kv.TemporalTx) []byte { return nil } -// TestWithoutSharedBranchCacheNeverTouchesSharedCache pins the WithoutSharedBranchCache -// contract: reads through such a SharedDomains must not read-fill the shared -// BranchCache, while a default SharedDomains does. func TestWithoutSharedBranchCacheNeverTouchesSharedCache(t *testing.T) { ctx := t.Context() m := execmoduletester.New(t, execmoduletester.WithChainConfig(chain.AllProtocolChanges)) @@ -292,18 +81,30 @@ func TestWithoutSharedBranchCacheNeverTouchesSharedCache(t *testing.T) { branchKey := someBranchKey(t, roTx) bc := sharedBranchCache(t, roTx) - readBranch := func(opts ...execctx.SharedDomainOption) { + readBranch := func(opts ...execctx.SharedDomainOption) []byte { sd, err := execctx.NewSharedDomains(ctx, roTx, log.New(), opts...) require.NoError(t, err) defer sd.Close() v, _, err := sd.GetLatest(kv.CommitmentDomain, roTx, branchKey) require.NoError(t, err) require.NotEmpty(t, v) + return v } + authoritative, _, err := roTx.GetLatest(kv.CommitmentDomain, branchKey) + require.NoError(t, err) + cacheOnly := []byte("cache-only") + bc.Clear() + bc.Put(branchKey, cacheOnly, 0, 0) + require.Equal(t, authoritative, readBranch(execctx.WithoutSharedBranchCache())) + cached, _, ok := bc.Get(branchKey) + require.True(t, ok) + require.Equal(t, cacheOnly, cached) + require.Equal(t, cacheOnly, readBranch()) + bc.Clear() readBranch(execctx.WithoutSharedBranchCache()) - _, _, ok := bc.Get(branchKey) + _, _, ok = bc.Get(branchKey) require.False(t, ok, "detached SharedDomains read-filled the shared BranchCache") readBranch() From ef4cdc005472be45a7481769236ca7c43cec3153 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Sat, 15 Aug 2026 09:04:54 +0200 Subject: [PATCH 09/18] db/state/execctx, execution, rpc: narrow branch cache isolation --- db/state/execctx/domain_shared.go | 9 ++++----- db/state/execctx/options.go | 3 ++- execution/builder/exec.go | 2 +- execution/engineapi/testing_api.go | 2 +- rpc/jsonrpc/debug_execution_witness.go | 2 +- rpc/jsonrpc/eth_call.go | 2 +- rpc/jsonrpc/receipts/receipts_generator.go | 4 ++-- rpc/rpchelper/commitment.go | 2 +- 8 files changed, 13 insertions(+), 13 deletions(-) diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index 0e5b6762c62..b717c7069ba 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -296,11 +296,10 @@ type SharedDomains struct { // swap+compute+restore window, so a later unwind reads stale prev-values. changesetMu sync.Mutex - // branchCache is the aggregator-scope commitment-branch cache. It sits - // behind sd.mem and sd.parent.mem in the read chain (consulted only after - // both miss, before the aggTx files/MDBX read), so writers' in-flight - // bytes always mask the cache and cross-SD pollution is impossible. - // Nil for snapshot-isolated readers and test AggTx implementations without a provider. + // branchCache is the aggregator-scoped commitment branch cache consulted + // after local and parent memory. Its entries are not bound to a transaction + // view, so snapshot-isolated readers must disable it. It is nil when disabled + // or when the transaction does not provide a cache. branchCache *commitment.BranchCache // collector is the process-level KV-read metrics collector (aggregator diff --git a/db/state/execctx/options.go b/db/state/execctx/options.go index 689d032199c..8087aade3ee 100644 --- a/db/state/execctx/options.go +++ b/db/state/execctx/options.go @@ -40,7 +40,8 @@ func WithoutDeferredBranchUpdates() SharedDomainOption { return func(o *sharedDomainOptions) { o.trieCfg.DeferBranchUpdates = false } } -// WithoutSharedBranchCache keeps commitment reads within the transaction snapshot. +// WithoutSharedBranchCache disables all use of the aggregator-scoped commitment +// branch cache, including its adaptive pin controller. func WithoutSharedBranchCache() SharedDomainOption { return func(o *sharedDomainOptions) { o.useSharedBranchCache = false } } diff --git a/execution/builder/exec.go b/execution/builder/exec.go index 5c629741163..fd8112e9be6 100644 --- a/execution/builder/exec.go +++ b/execution/builder/exec.go @@ -128,7 +128,7 @@ func execBlock(ctx context0.Context, sd *execctx.SharedDomains, tx kv.TemporalTx return err } defer filterMb.Close() - filterSd, err := execctx.NewSharedDomains(ctx, filterMb, logger, execctx.WithoutDeferredBranchUpdates(), execctx.WithoutSharedBranchCache()) + filterSd, err := execctx.NewSharedDomains(ctx, filterMb, logger, execctx.WithoutDeferredBranchUpdates()) if err != nil { return err } diff --git a/execution/engineapi/testing_api.go b/execution/engineapi/testing_api.go index f12b5a87994..b7ea7c8f0aa 100644 --- a/execution/engineapi/testing_api.go +++ b/execution/engineapi/testing_api.go @@ -96,7 +96,7 @@ func (t *testingImpl) decodeTxnProvider(ctx context.Context, transactions *[]hex return nil, fmt.Errorf("could not begin temporal transaction: %w", err) } defer dbTx.Rollback() - sd, err := execctx.NewSharedDomains(ctx, dbTx, t.logger, execctx.WithoutDeferredBranchUpdates(), execctx.WithoutSharedBranchCache()) + sd, err := execctx.NewSharedDomains(ctx, dbTx, t.logger, execctx.WithoutDeferredBranchUpdates()) if err != nil { return nil, fmt.Errorf("NewSharedDomains error: %w", err) } diff --git a/rpc/jsonrpc/debug_execution_witness.go b/rpc/jsonrpc/debug_execution_witness.go index 29f7620a900..61c057eac7c 100644 --- a/rpc/jsonrpc/debug_execution_witness.go +++ b/rpc/jsonrpc/debug_execution_witness.go @@ -900,7 +900,7 @@ func (api *DebugAPIImpl) buildWitnessResult(ctx context.Context, tx kv.TemporalT // Use the proof infrastructure from the commitment context. // Witness generation requires the sequential HexPatriciaHashed (Witness() // type-asserts it); the parallel trie cannot serve it. - domains, err := execctx.NewSharedDomains(ctx, tx, log.New(), execctx.WithoutDeferredBranchUpdates(), execctx.WithoutSharedBranchCache(), execctx.WithSequentialCommitment()) + domains, err := execctx.NewSharedDomains(ctx, tx, log.New(), execctx.WithoutDeferredBranchUpdates(), execctx.WithSequentialCommitment()) if err != nil { return nil, err } diff --git a/rpc/jsonrpc/eth_call.go b/rpc/jsonrpc/eth_call.go index 3af24a4d52d..964e21460fd 100644 --- a/rpc/jsonrpc/eth_call.go +++ b/rpc/jsonrpc/eth_call.go @@ -782,7 +782,7 @@ func (api *BaseAPI) getWitness(ctx context.Context, db kv.TemporalRoDB, blockNrO it.Close() } - domains, err := execctx.NewSharedDomains(ctx, tx, log.New(), execctx.WithoutDeferredBranchUpdates(), execctx.WithoutSharedBranchCache(), execctx.WithSequentialCommitment()) + domains, err := execctx.NewSharedDomains(ctx, tx, log.New(), execctx.WithoutDeferredBranchUpdates(), execctx.WithSequentialCommitment()) if err != nil { return nil, err } diff --git a/rpc/jsonrpc/receipts/receipts_generator.go b/rpc/jsonrpc/receipts/receipts_generator.go index 2183922477e..cf48f8f85f3 100644 --- a/rpc/jsonrpc/receipts/receipts_generator.go +++ b/rpc/jsonrpc/receipts/receipts_generator.go @@ -330,7 +330,7 @@ func (g *Generator) GetReceipt(ctx context.Context, cfg *chain.Config, tx kv.Tem var stateWriter state.StateWriter if calculatePostState && postState.CommitmentHistory { - sharedDomains, err = execctx.NewSharedDomains(ctx, tx, log.Root(), execctx.WithoutDeferredBranchUpdates(), execctx.WithoutSharedBranchCache(), execctx.WithSequentialCommitment()) + sharedDomains, err = execctx.NewSharedDomains(ctx, tx, log.Root(), execctx.WithoutDeferredBranchUpdates(), execctx.WithSequentialCommitment()) if err != nil { return nil, err } @@ -550,7 +550,7 @@ func (g *Generator) GetReceipts(ctx context.Context, cfg *chain.Config, tx kv.Te var stateWriter state.StateWriter if opts.CommitmentHistoryEnabled { - sharedDomains, err = execctx.NewSharedDomains(ctx, tx, log.Root(), execctx.WithoutDeferredBranchUpdates(), execctx.WithoutSharedBranchCache(), execctx.WithSequentialCommitment()) + sharedDomains, err = execctx.NewSharedDomains(ctx, tx, log.Root(), execctx.WithoutDeferredBranchUpdates(), execctx.WithSequentialCommitment()) if err != nil { return nil, err } diff --git a/rpc/rpchelper/commitment.go b/rpc/rpchelper/commitment.go index b347d110cfd..63c3a5e3437 100644 --- a/rpc/rpchelper/commitment.go +++ b/rpc/rpchelper/commitment.go @@ -94,7 +94,7 @@ func (r *CommitmentReplay) ComputeCustomCommitmentFromStateHistory( } defer ttx.Rollback() - tsd, err := execctx.NewSharedDomains(ctx, ttx, r.logger, execctx.WithoutDeferredBranchUpdates(), execctx.WithoutSharedBranchCache(), execctx.WithSequentialCommitment()) + tsd, err := execctx.NewSharedDomains(ctx, ttx, r.logger, execctx.WithoutDeferredBranchUpdates(), execctx.WithSequentialCommitment()) if err != nil { return nil, err } From 9f4198ba5c47cbabf4d66dd26d42f815994853d6 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Sat, 15 Aug 2026 09:22:10 +0200 Subject: [PATCH 10/18] rpc/jsonrpc: test request BranchCache isolation --- .../exec_module_branchcache_test.go | 113 ------------------ rpc/jsonrpc/rpc_branch_cache_test.go | 110 +++++++++++++++++ 2 files changed, 110 insertions(+), 113 deletions(-) delete mode 100644 execution/execmodule/exec_module_branchcache_test.go create mode 100644 rpc/jsonrpc/rpc_branch_cache_test.go diff --git a/execution/execmodule/exec_module_branchcache_test.go b/execution/execmodule/exec_module_branchcache_test.go deleted file mode 100644 index 9c04c298871..00000000000 --- a/execution/execmodule/exec_module_branchcache_test.go +++ /dev/null @@ -1,113 +0,0 @@ -// 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_test - -import ( - "bytes" - "testing" - - "github.com/holiman/uint256" - "github.com/stretchr/testify/require" - - "github.com/erigontech/erigon/common" - "github.com/erigontech/erigon/common/log/v3" - "github.com/erigontech/erigon/db/kv" - "github.com/erigontech/erigon/db/state/execctx" - "github.com/erigontech/erigon/execution/chain" - "github.com/erigontech/erigon/execution/commitment" - "github.com/erigontech/erigon/execution/execmodule/execmoduletester" - "github.com/erigontech/erigon/execution/protocol/params" - "github.com/erigontech/erigon/execution/tests/blockgen" - "github.com/erigontech/erigon/execution/types" -) - -func sharedBranchCache(t *testing.T, tx kv.TemporalTx) *commitment.BranchCache { - t.Helper() - provider, ok := tx.AggTx().(commitment.BranchCacheProvider) - require.True(t, ok) - bc := provider.BranchCache() - require.NotNil(t, bc) - return bc -} - -func someBranchKey(t *testing.T, tx kv.TemporalTx) []byte { - t.Helper() - it, err := tx.Debug().RangeLatest(kv.CommitmentDomain, nil, nil, 1<<20) - require.NoError(t, err) - defer it.Close() - for it.HasNext() { - k, v, err := it.Next() - require.NoError(t, err) - if !bytes.Equal(k, commitment.KeyCommitmentState) && len(v) > 0 { - return bytes.Clone(k) - } - } - t.Fatal("no commitment branch row found") - return nil -} - -func TestWithoutSharedBranchCacheNeverTouchesSharedCache(t *testing.T) { - ctx := t.Context() - m := execmoduletester.New(t, execmoduletester.WithChainConfig(chain.AllProtocolChanges)) - - signer := types.LatestSignerForChainID(m.ChainConfig.ChainID) - gasPrice := uint256.NewInt(m.Genesis.BaseFee().Uint64()) - chainPack, err := blockgen.GenerateChain(m.ChainConfig, m.Genesis, m.Engine, m.DB, 2, func(i int, gen *blockgen.BlockGen) { - txn, err := types.SignTx(types.NewTransaction(gen.TxNonce(m.Address), common.Address{0xAA, byte(i)}, uint256.NewInt(10_000), params.TxGas+params.StateGasNewAccount, gasPrice, nil), *signer, m.Key) - require.NoError(t, err) - gen.AddTx(txn) - }) - require.NoError(t, err) - require.NoError(t, m.InsertValidateAndUfc1By1(ctx, chainPack.Blocks)) - - roTx, err := m.DB.BeginTemporalRo(ctx) - require.NoError(t, err) - defer roTx.Rollback() - - branchKey := someBranchKey(t, roTx) - bc := sharedBranchCache(t, roTx) - - readBranch := func(opts ...execctx.SharedDomainOption) []byte { - sd, err := execctx.NewSharedDomains(ctx, roTx, log.New(), opts...) - require.NoError(t, err) - defer sd.Close() - v, _, err := sd.GetLatest(kv.CommitmentDomain, roTx, branchKey) - require.NoError(t, err) - require.NotEmpty(t, v) - return v - } - - authoritative, _, err := roTx.GetLatest(kv.CommitmentDomain, branchKey) - require.NoError(t, err) - cacheOnly := []byte("cache-only") - bc.Clear() - bc.Put(branchKey, cacheOnly, 0, 0) - require.Equal(t, authoritative, readBranch(execctx.WithoutSharedBranchCache())) - cached, _, ok := bc.Get(branchKey) - require.True(t, ok) - require.Equal(t, cacheOnly, cached) - require.Equal(t, cacheOnly, readBranch()) - - bc.Clear() - readBranch(execctx.WithoutSharedBranchCache()) - _, _, ok = bc.Get(branchKey) - require.False(t, ok, "detached SharedDomains read-filled the shared BranchCache") - - readBranch() - _, _, ok = bc.Get(branchKey) - require.True(t, ok, "default SharedDomains should read-fill the shared BranchCache") -} diff --git a/rpc/jsonrpc/rpc_branch_cache_test.go b/rpc/jsonrpc/rpc_branch_cache_test.go new file mode 100644 index 00000000000..0d927524046 --- /dev/null +++ b/rpc/jsonrpc/rpc_branch_cache_test.go @@ -0,0 +1,110 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Erigon is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with Erigon. If not, see . + +package jsonrpc + +import ( + "bytes" + "math/big" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/cmd/rpcdaemon/rpcdaemontest" + "github.com/erigontech/erigon/common" + "github.com/erigontech/erigon/common/hexutil" + "github.com/erigontech/erigon/db/kv" + "github.com/erigontech/erigon/db/state/statecfg" + "github.com/erigontech/erigon/execution/commitment" + "github.com/erigontech/erigon/rpc" + "github.com/erigontech/erigon/rpc/ethapi" +) + +func poisonSharedBranchCache(t *testing.T, db kv.TemporalRoDB) func() { + t.Helper() + tx, err := db.BeginTemporalRo(t.Context()) + require.NoError(t, err) + defer tx.Rollback() + + provider, ok := tx.AggTx().(commitment.BranchCacheProvider) + require.True(t, ok) + cache := provider.BranchCache() + require.NotNil(t, cache) + cache.Clear() + t.Cleanup(cache.Clear) + + poison := []byte("invalid commitment branch") + var poisonedKeys [][]byte + it, err := tx.Debug().RangeLatest(kv.CommitmentDomain, nil, nil, 1<<20) + require.NoError(t, err) + defer it.Close() + for it.HasNext() { + key, value, err := it.Next() + require.NoError(t, err) + if bytes.Equal(key, commitment.KeyCommitmentState) || len(value) == 0 { + continue + } + key = bytes.Clone(key) + cache.Put(key, poison, 0, 0) + poisonedKeys = append(poisonedKeys, key) + } + require.NotEmpty(t, poisonedKeys) + + return func() { + for _, key := range poisonedKeys { + cached, _, ok := cache.Get(key) + require.True(t, ok) + require.Equal(t, poison, cached) + } + } +} + +func TestGetProofIgnoresSharedBranchCache(t *testing.T) { + previousSchema := statecfg.Schema + statecfg.EnableHistoricalCommitment() + t.Cleanup(func() { statecfg.Schema = previousSchema }) + + m, _, contractAddress, _ := chainWithDeployedContract(t) + assertPoisoned := poisonSharedBranchCache(t, m.DB) + api := newEthApiForTest(newBaseApiForTest(m), m.DB, nil, nil) + + proof, err := api.GetProof(t.Context(), contractAddress, nil, bnhPtr(rpc.BlockNumberOrHashWithNumber(rpc.LatestBlockNumber))) + require.NoError(t, err) + require.NotNil(t, proof) + assertPoisoned() +} + +func TestSimulateV1IgnoresSharedBranchCache(t *testing.T) { + m, _, _ := rpcdaemontest.CreateTestExecModule(t) + assertPoisoned := poisonSharedBranchCache(t, m.DB) + api := newEthApiForTest(newBaseApiForTest(m), m.DB, nil, nil) + + from := common.HexToAddress("0x71562b71999873db5b286df957af199ec94617f7") + to := common.HexToAddress("0x0000000000000000000000000000000000000001") + value := (*hexutil.Big)(big.NewInt(100)) + gas := hexutil.Uint64(100_000) + result, err := api.SimulateV1(t.Context(), SimulationRequest{ + BlockStateCalls: []SimulatedBlock{{Calls: []ethapi.CallArgs{{ + From: &from, + To: &to, + Value: value, + Gas: &gas, + }}}}, + }, rpc.BlockNumberOrHashWithNumber(rpc.LatestBlockNumber)) + require.NoError(t, err) + require.Len(t, result, 1) + assertPoisoned() +} From 0ba6b507936e9b7a674e1306c1929758e68e026b Mon Sep 17 00:00:00 2001 From: yperbasis Date: Sat, 15 Aug 2026 09:32:11 +0200 Subject: [PATCH 11/18] rpc/jsonrpc: scan all branches in cache tests --- rpc/jsonrpc/rpc_branch_cache_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rpc/jsonrpc/rpc_branch_cache_test.go b/rpc/jsonrpc/rpc_branch_cache_test.go index 0d927524046..dcdea256fca 100644 --- a/rpc/jsonrpc/rpc_branch_cache_test.go +++ b/rpc/jsonrpc/rpc_branch_cache_test.go @@ -48,7 +48,7 @@ func poisonSharedBranchCache(t *testing.T, db kv.TemporalRoDB) func() { poison := []byte("invalid commitment branch") var poisonedKeys [][]byte - it, err := tx.Debug().RangeLatest(kv.CommitmentDomain, nil, nil, 1<<20) + it, err := tx.Debug().RangeLatest(kv.CommitmentDomain, nil, nil, kv.Unlim) require.NoError(t, err) defer it.Close() for it.HasNext() { From ec930941baa7bb9a39e1bd1d3452452e732c725c Mon Sep 17 00:00:00 2001 From: yperbasis Date: Sun, 16 Aug 2026 10:28:12 +0200 Subject: [PATCH 12/18] rpc/jsonrpc: isolate execution witness branch reads --- rpc/jsonrpc/debug_execution_witness.go | 6 ++++- rpc/jsonrpc/rpc_branch_cache_test.go | 36 ++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/rpc/jsonrpc/debug_execution_witness.go b/rpc/jsonrpc/debug_execution_witness.go index 61c057eac7c..b3ed477b484 100644 --- a/rpc/jsonrpc/debug_execution_witness.go +++ b/rpc/jsonrpc/debug_execution_witness.go @@ -863,6 +863,10 @@ func (api *DebugAPIImpl) buildWitnessResultHeadCapture(ctx context.Context, comm return api.buildWitnessResult(ctx, committedTx, hc, info, mode) } +func newExecutionWitnessDomains(ctx context.Context, tx kv.TemporalTx) (*execctx.SharedDomains, error) { + return execctx.NewSharedDomains(ctx, tx, log.New(), execctx.WithoutDeferredBranchUpdates(), execctx.WithoutSharedBranchCache(), execctx.WithSequentialCommitment()) +} + // buildWitnessResult runs the witness-building pipeline for an already-resolved block // against an open temporal tx: re-execute to record accesses, fold the commitment trie, // collect ancestor headers, verify statelessly, then append the legacy empty-storage node @@ -900,7 +904,7 @@ func (api *DebugAPIImpl) buildWitnessResult(ctx context.Context, tx kv.TemporalT // Use the proof infrastructure from the commitment context. // Witness generation requires the sequential HexPatriciaHashed (Witness() // type-asserts it); the parallel trie cannot serve it. - domains, err := execctx.NewSharedDomains(ctx, tx, log.New(), execctx.WithoutDeferredBranchUpdates(), execctx.WithSequentialCommitment()) + domains, err := newExecutionWitnessDomains(ctx, tx) if err != nil { return nil, err } diff --git a/rpc/jsonrpc/rpc_branch_cache_test.go b/rpc/jsonrpc/rpc_branch_cache_test.go index dcdea256fca..590d79afcb8 100644 --- a/rpc/jsonrpc/rpc_branch_cache_test.go +++ b/rpc/jsonrpc/rpc_branch_cache_test.go @@ -25,6 +25,7 @@ import ( "github.com/erigontech/erigon/cmd/rpcdaemon/rpcdaemontest" "github.com/erigontech/erigon/common" + "github.com/erigontech/erigon/common/dbg" "github.com/erigontech/erigon/common/hexutil" "github.com/erigontech/erigon/db/kv" "github.com/erigontech/erigon/db/state/statecfg" @@ -108,3 +109,38 @@ func TestSimulateV1IgnoresSharedBranchCache(t *testing.T) { require.Len(t, result, 1) assertPoisoned() } + +func TestExecutionWitnessDomainsIgnoreSharedBranchCache(t *testing.T) { + previousUseStateCache := dbg.UseStateCache + dbg.SetUseStateCache(true) + t.Cleanup(func() { dbg.SetUseStateCache(previousUseStateCache) }) + + m, _, _ := rpcdaemontest.CreateTestExecModule(t) + assertPoisoned := poisonSharedBranchCache(t, m.DB) + tx, err := m.DB.BeginTemporalRo(t.Context()) + require.NoError(t, err) + defer tx.Rollback() + + it, err := tx.Debug().RangeLatest(kv.CommitmentDomain, nil, nil, kv.Unlim) + require.NoError(t, err) + defer it.Close() + var branchKey, branchValue []byte + for it.HasNext() { + key, value, err := it.Next() + require.NoError(t, err) + if !bytes.Equal(key, commitment.KeyCommitmentState) && len(value) > 0 { + branchKey = bytes.Clone(key) + branchValue = bytes.Clone(value) + break + } + } + require.NotEmpty(t, branchKey) + + domains, err := newExecutionWitnessDomains(t.Context(), tx) + require.NoError(t, err) + defer domains.Close() + got, _, err := domains.GetLatest(kv.CommitmentDomain, tx, branchKey) + require.NoError(t, err) + require.Equal(t, branchValue, got) + assertPoisoned() +} From e3b0d4058503faa4f9737ab0d23dd1d6c7e197e6 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Sun, 16 Aug 2026 10:37:23 +0200 Subject: [PATCH 13/18] rpc/jsonrpc: stabilize branch cache regression tests --- rpc/jsonrpc/rpc_branch_cache_test.go | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/rpc/jsonrpc/rpc_branch_cache_test.go b/rpc/jsonrpc/rpc_branch_cache_test.go index 590d79afcb8..6a280e5f6e9 100644 --- a/rpc/jsonrpc/rpc_branch_cache_test.go +++ b/rpc/jsonrpc/rpc_branch_cache_test.go @@ -73,7 +73,16 @@ func poisonSharedBranchCache(t *testing.T, db kv.TemporalRoDB) func() { } } +func enableStateCacheForTest(t *testing.T) { + t.Helper() + previous := dbg.UseStateCache + dbg.SetUseStateCache(true) + t.Cleanup(func() { dbg.SetUseStateCache(previous) }) +} + func TestGetProofIgnoresSharedBranchCache(t *testing.T) { + enableStateCacheForTest(t) + previousSchema := statecfg.Schema statecfg.EnableHistoricalCommitment() t.Cleanup(func() { statecfg.Schema = previousSchema }) @@ -89,6 +98,8 @@ func TestGetProofIgnoresSharedBranchCache(t *testing.T) { } func TestSimulateV1IgnoresSharedBranchCache(t *testing.T) { + enableStateCacheForTest(t) + m, _, _ := rpcdaemontest.CreateTestExecModule(t) assertPoisoned := poisonSharedBranchCache(t, m.DB) api := newEthApiForTest(newBaseApiForTest(m), m.DB, nil, nil) @@ -111,9 +122,7 @@ func TestSimulateV1IgnoresSharedBranchCache(t *testing.T) { } func TestExecutionWitnessDomainsIgnoreSharedBranchCache(t *testing.T) { - previousUseStateCache := dbg.UseStateCache - dbg.SetUseStateCache(true) - t.Cleanup(func() { dbg.SetUseStateCache(previousUseStateCache) }) + enableStateCacheForTest(t) m, _, _ := rpcdaemontest.CreateTestExecModule(t) assertPoisoned := poisonSharedBranchCache(t, m.DB) From a451883048fbd68e623291d17b57bd2f70371af3 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Sun, 16 Aug 2026 10:41:10 +0200 Subject: [PATCH 14/18] rpc/jsonrpc: simplify branch cache proof test --- rpc/jsonrpc/rpc_branch_cache_test.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/rpc/jsonrpc/rpc_branch_cache_test.go b/rpc/jsonrpc/rpc_branch_cache_test.go index 6a280e5f6e9..caf1637287e 100644 --- a/rpc/jsonrpc/rpc_branch_cache_test.go +++ b/rpc/jsonrpc/rpc_branch_cache_test.go @@ -28,7 +28,6 @@ import ( "github.com/erigontech/erigon/common/dbg" "github.com/erigontech/erigon/common/hexutil" "github.com/erigontech/erigon/db/kv" - "github.com/erigontech/erigon/db/state/statecfg" "github.com/erigontech/erigon/execution/commitment" "github.com/erigontech/erigon/rpc" "github.com/erigontech/erigon/rpc/ethapi" @@ -83,10 +82,6 @@ func enableStateCacheForTest(t *testing.T) { func TestGetProofIgnoresSharedBranchCache(t *testing.T) { enableStateCacheForTest(t) - previousSchema := statecfg.Schema - statecfg.EnableHistoricalCommitment() - t.Cleanup(func() { statecfg.Schema = previousSchema }) - m, _, contractAddress, _ := chainWithDeployedContract(t) assertPoisoned := poisonSharedBranchCache(t, m.DB) api := newEthApiForTest(newBaseApiForTest(m), m.DB, nil, nil) From 4cae0023bb636a4180f6a44ce0fc0624abc63f01 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Mon, 17 Aug 2026 09:52:03 +0200 Subject: [PATCH 15/18] db/state/execctx: document branch cache isolation requirement --- db/state/execctx/options.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/db/state/execctx/options.go b/db/state/execctx/options.go index 8087aade3ee..4c9da3ec875 100644 --- a/db/state/execctx/options.go +++ b/db/state/execctx/options.go @@ -40,8 +40,9 @@ func WithoutDeferredBranchUpdates() SharedDomainOption { return func(o *sharedDomainOptions) { o.trieCfg.DeferBranchUpdates = false } } -// WithoutSharedBranchCache disables all use of the aggregator-scoped commitment -// branch cache, including its adaptive pin controller. +// WithoutSharedBranchCache disables the aggregator-scoped commitment branch cache +// and its adaptive pin controller. Callers that require commitment reads to stay +// within a transaction snapshot must use it because cache entries are not view-bound. func WithoutSharedBranchCache() SharedDomainOption { return func(o *sharedDomainOptions) { o.useSharedBranchCache = false } } From 534702ffacb3911e9dc1ec2aa0b9e17f629945df Mon Sep 17 00:00:00 2001 From: yperbasis Date: Mon, 17 Aug 2026 10:26:18 +0200 Subject: [PATCH 16/18] execution/commitment: keep branch child reads in computed view --- .../commitmentdb/commitment_context.go | 18 +++- .../commitmentdb/commitment_context_test.go | 97 +++++++++++++++++++ 2 files changed, 112 insertions(+), 3 deletions(-) diff --git a/execution/commitment/commitmentdb/commitment_context.go b/execution/commitment/commitmentdb/commitment_context.go index e419d543b2f..0d98a7c0fc4 100644 --- a/execution/commitment/commitmentdb/commitment_context.go +++ b/execution/commitment/commitmentdb/commitment_context.go @@ -39,6 +39,7 @@ type sd interface { SetTxNum(blockNum uint64) AsGetter(tx kv.TemporalTx) kv.TemporalGetter AsPutDel(tx kv.TemporalTx) kv.TemporalPutDel + GetMemBatch() kv.TemporalMemBatch // MergeMetrics hands a finished worker's lock-free metrics accumulator to // the per-batch aggregate and the process-level collector (once, not per // read), tagged with source. @@ -407,11 +408,22 @@ func (sdc *SharedDomainsCommitmentContext) SetCollapseTracer(tracer commitment.C } } -// BranchChildCount returns the child count of the branch at nibblePrefix, read -// from the in-memory commitment domain (post-compute state). +// BranchChildCount returns the child count from the post-compute commitment view. +// Modified branches come from memory; an unchanged branch falls back to the +// installed reader, or to transaction-latest state when no reader is installed. func (sdc *SharedDomainsCommitmentContext) BranchChildCount(tx kv.TemporalTx, nibblePrefix []byte) (int, error) { key := nibbles.HexToCompact(nibblePrefix) - enc, _, err := sdc.sharedDomains.AsGetter(tx).GetLatest(kv.CommitmentDomain, key) + enc, _, ok := sdc.sharedDomains.GetMemBatch().GetLatest(kv.CommitmentDomain, key) + if ok { + return commitment.BranchData(enc).ChildCount(), nil + } + + var err error + if sdc.stateReader != nil { + enc, _, err = sdc.stateReader.Read(kv.CommitmentDomain, key, sdc.sharedDomains.StepSize()) + } else { + enc, _, err = sdc.sharedDomains.AsGetter(tx).GetLatest(kv.CommitmentDomain, key) + } if err != nil { return 0, err } diff --git a/execution/commitment/commitmentdb/commitment_context_test.go b/execution/commitment/commitmentdb/commitment_context_test.go index 11d04181f11..133f9cb02e6 100644 --- a/execution/commitment/commitmentdb/commitment_context_test.go +++ b/execution/commitment/commitmentdb/commitment_context_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/erigontech/erigon/db/kv" + "github.com/erigontech/erigon/execution/commitment/nibbles" "github.com/stretchr/testify/require" ) @@ -83,3 +84,99 @@ func Test_TrieContext_BranchCopiesData(t *testing.T) { branch[1] = 8 require.Equal(t, []byte{9, 2, 3}, reader.branchData) } + +type branchMemBatch struct { + kv.TemporalMemBatch + value []byte + ok bool + calls int + key []byte +} + +func (m *branchMemBatch) GetLatest(domain kv.Domain, key []byte) ([]byte, kv.Step, bool) { + m.calls++ + m.key = append(m.key[:0], key...) + if domain != kv.CommitmentDomain || !m.ok { + return nil, kv.NoStepBound, false + } + return m.value, 0, true +} + +type branchGetter struct { + value []byte + calls int + key []byte +} + +func (g *branchGetter) GetLatest(domain kv.Domain, key []byte) ([]byte, kv.Step, error) { + g.calls++ + g.key = append(g.key[:0], key...) + if domain != kv.CommitmentDomain { + return nil, 0, nil + } + return g.value, 0, nil +} + +func (g *branchGetter) HasPrefix(kv.Domain, []byte) ([]byte, []byte, bool, error) { + return nil, nil, false, nil +} + +func (g *branchGetter) StepsInFiles(...kv.Domain) kv.Step { return 0 } + +type branchChildCountDomains struct { + sd + mem *branchMemBatch + getter *branchGetter +} + +func (d *branchChildCountDomains) AsGetter(kv.TemporalTx) kv.TemporalGetter { + return d.getter +} + +func (d *branchChildCountDomains) GetMemBatch() kv.TemporalMemBatch { return d.mem } +func (d *branchChildCountDomains) StepSize() uint64 { return 1 } + +func TestBranchChildCountReadsPostComputeView(t *testing.T) { + t.Parallel() + + prefix := []byte{0x0a} + compactKey := nibbles.HexToCompact(prefix) + + t.Run("changed branch comes from memory", func(t *testing.T) { + mem := &branchMemBatch{value: []byte{0, 0, 0, 0b0000_0111}, ok: true} + getter := &branchGetter{value: mem.value} + reader := &testStateReader{branchData: []byte{0, 0, 0, 0b0000_0011}} + sdc := &SharedDomainsCommitmentContext{ + sharedDomains: &branchChildCountDomains{mem: mem, getter: getter}, + stateReader: reader, + } + + count, err := sdc.BranchChildCount(nil, prefix) + require.NoError(t, err) + require.Equal(t, 3, count) + require.Equal(t, 1, mem.calls) + require.Equal(t, compactKey, mem.key) + require.Zero(t, getter.calls) + require.Zero(t, reader.readStepSize) + }) + + t.Run("unchanged branch comes from installed reader", func(t *testing.T) { + mem := &branchMemBatch{} + getter := &branchGetter{value: []byte{0, 0, 0, 0b0000_0001}} + reader := &testStateReader{branchData: []byte{0, 0, 0, 0b0000_0011}} + sdc := &SharedDomainsCommitmentContext{ + sharedDomains: &branchChildCountDomains{mem: mem, getter: getter}, + stateReader: reader, + } + + count, err := sdc.BranchChildCount(nil, prefix) + require.NoError(t, err) + require.Equal(t, 2, count) + require.Equal(t, 1, mem.calls) + require.Equal(t, compactKey, mem.key) + require.Zero(t, getter.calls) + require.Equal(t, kv.CommitmentDomain, reader.readDomain) + require.Equal(t, compactKey, reader.readKey) + require.Equal(t, uint64(1), reader.readStepSize) + }) +} From cff22222f6db6b47fd3cea02eb0683f2f3e9f03b Mon Sep 17 00:00:00 2001 From: yperbasis Date: Mon, 17 Aug 2026 11:51:49 +0200 Subject: [PATCH 17/18] db/state/execctx: clarify branch cache isolation scope --- db/state/execctx/domain_shared.go | 6 +++--- db/state/execctx/options.go | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index b717c7069ba..dfb3bd3f941 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -297,9 +297,9 @@ type SharedDomains struct { changesetMu sync.Mutex // branchCache is the aggregator-scoped commitment branch cache consulted - // after local and parent memory. Its entries are not bound to a transaction - // view, so snapshot-isolated readers must disable it. It is nil when disabled - // or when the transaction does not provide a cache. + // after local and parent memory. Its entries are not view-bound, so readers + // that can overlap cache writes from another transaction must disable it. It + // is nil when disabled or when the transaction does not provide a cache. branchCache *commitment.BranchCache // collector is the process-level KV-read metrics collector (aggregator diff --git a/db/state/execctx/options.go b/db/state/execctx/options.go index 4c9da3ec875..26899643739 100644 --- a/db/state/execctx/options.go +++ b/db/state/execctx/options.go @@ -41,8 +41,8 @@ func WithoutDeferredBranchUpdates() SharedDomainOption { } // WithoutSharedBranchCache disables the aggregator-scoped commitment branch cache -// and its adaptive pin controller. Callers that require commitment reads to stay -// within a transaction snapshot must use it because cache entries are not view-bound. +// and its adaptive pin controller. Cache entries are not view-bound, so callers +// whose reads can overlap cache writes from another transaction must use it. func WithoutSharedBranchCache() SharedDomainOption { return func(o *sharedDomainOptions) { o.useSharedBranchCache = false } } From cd71e4a75d73fc20e03d0c39d5b59777617c6840 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Mon, 17 Aug 2026 12:46:23 +0200 Subject: [PATCH 18/18] rpc/jsonrpc, execution/commitment: harden request commitment views --- db/state/execctx/domain_shared.go | 5 + db/state/execctx/options.go | 2 +- .../commitmentdb/commitment_context.go | 30 +++-- .../commitmentdb/commitment_context_test.go | 105 ++++++++++++------ rpc/jsonrpc/debug_execution_witness.go | 8 +- rpc/jsonrpc/eth_call.go | 5 +- rpc/jsonrpc/eth_simulation.go | 2 +- rpc/jsonrpc/rpc_branch_cache.go | 29 +++++ rpc/jsonrpc/rpc_branch_cache_test.go | 5 +- 9 files changed, 129 insertions(+), 62 deletions(-) create mode 100644 rpc/jsonrpc/rpc_branch_cache.go diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index dfb3bd3f941..b3e4d21936a 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -874,6 +874,11 @@ func (sd *SharedDomains) GetMemBatch() kv.TemporalMemBatch { return sd.mem } func (sd *SharedDomains) SetInMemHistoryReads(v bool) { sd.mem.SetInMemHistoryReads(v) } func (sd *SharedDomains) InMemHistoryReads() bool { return sd.mem.InMemHistoryReads() } +// GetLatestFromMemory reads local and parent memory and returns any bound on a fallback read. +func (sd *SharedDomains) GetLatestFromMemory(domain kv.Domain, key []byte) (v []byte, step, maxStep kv.Step, ok bool) { + return sd.latestFromMem(domain, key) +} + // SetParent sets a parent SD for read-through domain chaining. Domain reads // that miss in the local mem batch will check the parent's mem batch before // falling through to the underlying tx/aggregator. diff --git a/db/state/execctx/options.go b/db/state/execctx/options.go index 26899643739..f781833b372 100644 --- a/db/state/execctx/options.go +++ b/db/state/execctx/options.go @@ -42,7 +42,7 @@ func WithoutDeferredBranchUpdates() SharedDomainOption { // WithoutSharedBranchCache disables the aggregator-scoped commitment branch cache // and its adaptive pin controller. Cache entries are not view-bound, so callers -// whose reads can overlap cache writes from another transaction must use it. +// whose reads can overlap cache writes from another transaction must pass this option. func WithoutSharedBranchCache() SharedDomainOption { return func(o *sharedDomainOptions) { o.useSharedBranchCache = false } } diff --git a/execution/commitment/commitmentdb/commitment_context.go b/execution/commitment/commitmentdb/commitment_context.go index 0d98a7c0fc4..6bb5a5c4013 100644 --- a/execution/commitment/commitmentdb/commitment_context.go +++ b/execution/commitment/commitmentdb/commitment_context.go @@ -39,7 +39,7 @@ type sd interface { SetTxNum(blockNum uint64) AsGetter(tx kv.TemporalTx) kv.TemporalGetter AsPutDel(tx kv.TemporalTx) kv.TemporalPutDel - GetMemBatch() kv.TemporalMemBatch + GetLatestFromMemory(domain kv.Domain, key []byte) (v []byte, step, maxStep kv.Step, ok bool) // MergeMetrics hands a finished worker's lock-free metrics accumulator to // the per-batch aggregate and the process-level collector (once, not per // read), tagged with source. @@ -408,22 +408,28 @@ func (sdc *SharedDomainsCommitmentContext) SetCollapseTracer(tracer commitment.C } } -// BranchChildCount returns the child count from the post-compute commitment view. -// Modified branches come from memory; an unchanged branch falls back to the -// installed reader, or to transaction-latest state when no reader is installed. -func (sdc *SharedDomainsCommitmentContext) BranchChildCount(tx kv.TemporalTx, nibblePrefix []byte) (int, error) { +// BranchChildCount returns a branch's child count from a complete post-compute view. +func (sdc *SharedDomainsCommitmentContext) BranchChildCount(nibblePrefix []byte) (int, error) { + if sdc.stateReader == nil { + return 0, errors.New("BranchChildCount requires an installed state reader") + } + if sdc.stateReader.WithHistory() { + return 0, errors.New("BranchChildCount requires a reader that permits branch writes") + } + if sdc.pendingUpdate != nil { + return 0, errors.New("BranchChildCount cannot read while deferred branch updates are pending") + } + key := nibbles.HexToCompact(nibblePrefix) - enc, _, ok := sdc.sharedDomains.GetMemBatch().GetLatest(kv.CommitmentDomain, key) + enc, _, maxStep, ok := sdc.sharedDomains.GetLatestFromMemory(kv.CommitmentDomain, key) if ok { return commitment.BranchData(enc).ChildCount(), nil } - - var err error - if sdc.stateReader != nil { - enc, _, err = sdc.stateReader.Read(kv.CommitmentDomain, key, sdc.sharedDomains.StepSize()) - } else { - enc, _, err = sdc.sharedDomains.AsGetter(tx).GetLatest(kv.CommitmentDomain, key) + if maxStep != kv.NoStepBound { + return 0, fmt.Errorf("BranchChildCount cannot fall through a staged unwind at step %d", maxStep) } + + enc, _, err := sdc.stateReader.Read(kv.CommitmentDomain, key, sdc.sharedDomains.StepSize()) if err != nil { return 0, err } diff --git a/execution/commitment/commitmentdb/commitment_context_test.go b/execution/commitment/commitmentdb/commitment_context_test.go index 133f9cb02e6..b66eca27593 100644 --- a/execution/commitment/commitmentdb/commitment_context_test.go +++ b/execution/commitment/commitmentdb/commitment_context_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/erigontech/erigon/db/kv" + "github.com/erigontech/erigon/execution/commitment" "github.com/erigontech/erigon/execution/commitment/nibbles" "github.com/stretchr/testify/require" ) @@ -37,11 +38,12 @@ type testStateReader struct { readDomain kv.Domain readKey []byte readStepSize uint64 + withHistory bool } var _ StateReader = (*testStateReader)(nil) -func (r *testStateReader) WithHistory() bool { return false } +func (r *testStateReader) WithHistory() bool { return r.withHistory } func (r *testStateReader) CheckDataAvailable(kv.Domain, kv.Step) error { return nil } @@ -89,6 +91,8 @@ type branchMemBatch struct { kv.TemporalMemBatch value []byte ok bool + bound bool + step kv.Step calls int key []byte } @@ -97,44 +101,28 @@ func (m *branchMemBatch) GetLatest(domain kv.Domain, key []byte) ([]byte, kv.Ste m.calls++ m.key = append(m.key[:0], key...) if domain != kv.CommitmentDomain || !m.ok { + if m.bound { + return nil, m.step, false + } return nil, kv.NoStepBound, false } - return m.value, 0, true -} - -type branchGetter struct { - value []byte - calls int - key []byte -} - -func (g *branchGetter) GetLatest(domain kv.Domain, key []byte) ([]byte, kv.Step, error) { - g.calls++ - g.key = append(g.key[:0], key...) - if domain != kv.CommitmentDomain { - return nil, 0, nil - } - return g.value, 0, nil + return m.value, m.step, true } -func (g *branchGetter) HasPrefix(kv.Domain, []byte) ([]byte, []byte, bool, error) { - return nil, nil, false, nil -} - -func (g *branchGetter) StepsInFiles(...kv.Domain) kv.Step { return 0 } - type branchChildCountDomains struct { sd - mem *branchMemBatch - getter *branchGetter + mem *branchMemBatch } -func (d *branchChildCountDomains) AsGetter(kv.TemporalTx) kv.TemporalGetter { - return d.getter +func (d *branchChildCountDomains) GetLatestFromMemory(domain kv.Domain, key []byte) (v []byte, step, maxStep kv.Step, ok bool) { + v, step, ok = d.mem.GetLatest(domain, key) + if ok { + return v, step, kv.NoStepBound, true + } + return nil, 0, step, false } -func (d *branchChildCountDomains) GetMemBatch() kv.TemporalMemBatch { return d.mem } -func (d *branchChildCountDomains) StepSize() uint64 { return 1 } +func (d *branchChildCountDomains) StepSize() uint64 { return 1 } func TestBranchChildCountReadsPostComputeView(t *testing.T) { t.Parallel() @@ -144,39 +132,82 @@ func TestBranchChildCountReadsPostComputeView(t *testing.T) { t.Run("changed branch comes from memory", func(t *testing.T) { mem := &branchMemBatch{value: []byte{0, 0, 0, 0b0000_0111}, ok: true} - getter := &branchGetter{value: mem.value} reader := &testStateReader{branchData: []byte{0, 0, 0, 0b0000_0011}} sdc := &SharedDomainsCommitmentContext{ - sharedDomains: &branchChildCountDomains{mem: mem, getter: getter}, + sharedDomains: &branchChildCountDomains{mem: mem}, stateReader: reader, } - count, err := sdc.BranchChildCount(nil, prefix) + count, err := sdc.BranchChildCount(prefix) require.NoError(t, err) require.Equal(t, 3, count) require.Equal(t, 1, mem.calls) require.Equal(t, compactKey, mem.key) - require.Zero(t, getter.calls) require.Zero(t, reader.readStepSize) }) t.Run("unchanged branch comes from installed reader", func(t *testing.T) { mem := &branchMemBatch{} - getter := &branchGetter{value: []byte{0, 0, 0, 0b0000_0001}} reader := &testStateReader{branchData: []byte{0, 0, 0, 0b0000_0011}} sdc := &SharedDomainsCommitmentContext{ - sharedDomains: &branchChildCountDomains{mem: mem, getter: getter}, + sharedDomains: &branchChildCountDomains{mem: mem}, stateReader: reader, } - count, err := sdc.BranchChildCount(nil, prefix) + count, err := sdc.BranchChildCount(prefix) require.NoError(t, err) require.Equal(t, 2, count) require.Equal(t, 1, mem.calls) require.Equal(t, compactKey, mem.key) - require.Zero(t, getter.calls) require.Equal(t, kv.CommitmentDomain, reader.readDomain) require.Equal(t, compactKey, reader.readKey) require.Equal(t, uint64(1), reader.readStepSize) }) } + +func TestBranchChildCountRejectsIncompleteComputedView(t *testing.T) { + t.Parallel() + + prefix := []byte{0x0a} + branch := []byte{0, 0, 0, 0b0000_0011} + + t.Run("missing state reader", func(t *testing.T) { + sdc := &SharedDomainsCommitmentContext{ + sharedDomains: &branchChildCountDomains{mem: &branchMemBatch{}}, + } + + _, err := sdc.BranchChildCount(prefix) + require.ErrorContains(t, err, "installed state reader") + }) + + t.Run("history reader suppresses branch writes", func(t *testing.T) { + sdc := &SharedDomainsCommitmentContext{ + sharedDomains: &branchChildCountDomains{mem: &branchMemBatch{}}, + stateReader: &testStateReader{branchData: branch, withHistory: true}, + } + + _, err := sdc.BranchChildCount(prefix) + require.ErrorContains(t, err, "reader that permits branch writes") + }) + + t.Run("deferred branch updates are pending", func(t *testing.T) { + sdc := &SharedDomainsCommitmentContext{ + sharedDomains: &branchChildCountDomains{mem: &branchMemBatch{}}, + stateReader: &testStateReader{branchData: branch}, + pendingUpdate: &commitment.PendingCommitmentUpdate{}, + } + + _, err := sdc.BranchChildCount(prefix) + require.ErrorContains(t, err, "deferred branch updates are pending") + }) + + t.Run("staged unwind bounds the fallback", func(t *testing.T) { + sdc := &SharedDomainsCommitmentContext{ + sharedDomains: &branchChildCountDomains{mem: &branchMemBatch{bound: true, step: 1}}, + stateReader: &testStateReader{branchData: branch}, + } + + _, err := sdc.BranchChildCount(prefix) + require.ErrorContains(t, err, "staged unwind") + }) +} diff --git a/rpc/jsonrpc/debug_execution_witness.go b/rpc/jsonrpc/debug_execution_witness.go index b3ed477b484..82c4567c78e 100644 --- a/rpc/jsonrpc/debug_execution_witness.go +++ b/rpc/jsonrpc/debug_execution_witness.go @@ -863,10 +863,6 @@ func (api *DebugAPIImpl) buildWitnessResultHeadCapture(ctx context.Context, comm return api.buildWitnessResult(ctx, committedTx, hc, info, mode) } -func newExecutionWitnessDomains(ctx context.Context, tx kv.TemporalTx) (*execctx.SharedDomains, error) { - return execctx.NewSharedDomains(ctx, tx, log.New(), execctx.WithoutDeferredBranchUpdates(), execctx.WithoutSharedBranchCache(), execctx.WithSequentialCommitment()) -} - // buildWitnessResult runs the witness-building pipeline for an already-resolved block // against an open temporal tx: re-execute to record accesses, fold the commitment trie, // collect ancestor headers, verify statelessly, then append the legacy empty-storage node @@ -904,7 +900,7 @@ func (api *DebugAPIImpl) buildWitnessResult(ctx context.Context, tx kv.TemporalT // Use the proof infrastructure from the commitment context. // Witness generation requires the sequential HexPatriciaHashed (Witness() // type-asserts it); the parallel trie cannot serve it. - domains, err := newExecutionWitnessDomains(ctx, tx) + domains, err := newSnapshotCommitmentDomains(ctx, tx, log.New()) if err != nil { return nil, err } @@ -1289,7 +1285,7 @@ func detectCollapseSiblings( siblingPaths = make([][]byte, 0, len(candidates)) for _, c := range candidates { if mode == witnessModeCanonical { - childCount, err := sdCtx.BranchChildCount(tx, c.branchPrefix) + childCount, err := sdCtx.BranchChildCount(c.branchPrefix) if err != nil { return nil, fmt.Errorf("[debug_executionWitness] read post-state branch for collapse filter: %w", err) } diff --git a/rpc/jsonrpc/eth_call.go b/rpc/jsonrpc/eth_call.go index 964e21460fd..d269d6eccc1 100644 --- a/rpc/jsonrpc/eth_call.go +++ b/rpc/jsonrpc/eth_call.go @@ -35,7 +35,6 @@ import ( "github.com/erigontech/erigon/db/kv" "github.com/erigontech/erigon/db/kv/order" "github.com/erigontech/erigon/db/rawdb" - "github.com/erigontech/erigon/db/state/execctx" "github.com/erigontech/erigon/execution/commitment/trie" "github.com/erigontech/erigon/execution/protocol" "github.com/erigontech/erigon/execution/protocol/params" @@ -483,7 +482,7 @@ func (api *APIImpl) getProof(ctx context.Context, roTx kv.TemporalTx, address co return nil, fmt.Errorf("header not found for block %d", blockNrOrHash.BlockNumber.Uint64()) } - domains, err := execctx.NewSharedDomains(ctx, tx, log.New(), execctx.WithoutDeferredBranchUpdates(), execctx.WithoutSharedBranchCache(), execctx.WithSequentialCommitment()) + domains, err := newSnapshotCommitmentDomains(ctx, tx, logger) if err != nil { return nil, err } @@ -782,7 +781,7 @@ func (api *BaseAPI) getWitness(ctx context.Context, db kv.TemporalRoDB, blockNrO it.Close() } - domains, err := execctx.NewSharedDomains(ctx, tx, log.New(), execctx.WithoutDeferredBranchUpdates(), execctx.WithSequentialCommitment()) + domains, err := newSnapshotCommitmentDomains(ctx, tx, logger) if err != nil { return nil, err } diff --git a/rpc/jsonrpc/eth_simulation.go b/rpc/jsonrpc/eth_simulation.go index b4913da6cfe..6a6a9120ac9 100644 --- a/rpc/jsonrpc/eth_simulation.go +++ b/rpc/jsonrpc/eth_simulation.go @@ -164,7 +164,7 @@ func (api *APIImpl) SimulateV1(ctx context.Context, req SimulationRequest, block return nil, err } - sharedDomains, err := execctx.NewSharedDomains(ctx, tx, api.logger, execctx.WithoutDeferredBranchUpdates(), execctx.WithoutSharedBranchCache(), execctx.WithSequentialCommitment()) + sharedDomains, err := newSnapshotCommitmentDomains(ctx, tx, api.logger) if err != nil { return nil, err } diff --git a/rpc/jsonrpc/rpc_branch_cache.go b/rpc/jsonrpc/rpc_branch_cache.go new file mode 100644 index 00000000000..1086dd3beaa --- /dev/null +++ b/rpc/jsonrpc/rpc_branch_cache.go @@ -0,0 +1,29 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Erigon is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with Erigon. If not, see . + +package jsonrpc + +import ( + "context" + + "github.com/erigontech/erigon/common/log/v3" + "github.com/erigontech/erigon/db/kv" + "github.com/erigontech/erigon/db/state/execctx" +) + +func newSnapshotCommitmentDomains(ctx context.Context, tx kv.TemporalTx, logger log.Logger) (*execctx.SharedDomains, error) { + return execctx.NewSharedDomains(ctx, tx, logger, execctx.WithoutDeferredBranchUpdates(), execctx.WithoutSharedBranchCache(), execctx.WithSequentialCommitment()) +} diff --git a/rpc/jsonrpc/rpc_branch_cache_test.go b/rpc/jsonrpc/rpc_branch_cache_test.go index caf1637287e..2db420a9312 100644 --- a/rpc/jsonrpc/rpc_branch_cache_test.go +++ b/rpc/jsonrpc/rpc_branch_cache_test.go @@ -27,6 +27,7 @@ import ( "github.com/erigontech/erigon/common" "github.com/erigontech/erigon/common/dbg" "github.com/erigontech/erigon/common/hexutil" + "github.com/erigontech/erigon/common/log/v3" "github.com/erigontech/erigon/db/kv" "github.com/erigontech/erigon/execution/commitment" "github.com/erigontech/erigon/rpc" @@ -116,7 +117,7 @@ func TestSimulateV1IgnoresSharedBranchCache(t *testing.T) { assertPoisoned() } -func TestExecutionWitnessDomainsIgnoreSharedBranchCache(t *testing.T) { +func TestSnapshotCommitmentDomainsIgnoreSharedBranchCache(t *testing.T) { enableStateCacheForTest(t) m, _, _ := rpcdaemontest.CreateTestExecModule(t) @@ -140,7 +141,7 @@ func TestExecutionWitnessDomainsIgnoreSharedBranchCache(t *testing.T) { } require.NotEmpty(t, branchKey) - domains, err := newExecutionWitnessDomains(t.Context(), tx) + domains, err := newSnapshotCommitmentDomains(t.Context(), tx, log.New()) require.NoError(t, err) defer domains.Close() got, _, err := domains.GetLatest(kv.CommitmentDomain, tx, branchKey)