diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go
index 13ac1835d17..b3e4d21936a 100644
--- a/db/state/execctx/domain_shared.go
+++ b/db/state/execctx/domain_shared.go
@@ -296,12 +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.
- // May be nil for test setups whose AggTx doesn't implement
- // commitment.BranchCacheProvider.
+ // branchCache is the aggregator-scoped commitment branch cache consulted
+ // 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
@@ -876,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 689d032199c..f781833b372 100644
--- a/db/state/execctx/options.go
+++ b/db/state/execctx/options.go
@@ -40,7 +40,9 @@ func WithoutDeferredBranchUpdates() SharedDomainOption {
return func(o *sharedDomainOptions) { o.trieCfg.DeferBranchUpdates = false }
}
-// WithoutSharedBranchCache keeps commitment reads within the transaction snapshot.
+// 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 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 e419d543b2f..6bb5a5c4013 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
+ 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.
@@ -407,11 +408,28 @@ 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).
-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, _, err := sdc.sharedDomains.AsGetter(tx).GetLatest(kv.CommitmentDomain, key)
+ enc, _, maxStep, ok := sdc.sharedDomains.GetLatestFromMemory(kv.CommitmentDomain, key)
+ if ok {
+ return commitment.BranchData(enc).ChildCount(), nil
+ }
+ 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 11d04181f11..b66eca27593 100644
--- a/execution/commitment/commitmentdb/commitment_context_test.go
+++ b/execution/commitment/commitmentdb/commitment_context_test.go
@@ -6,6 +6,8 @@ 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"
)
@@ -36,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 }
@@ -83,3 +86,128 @@ 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
+ bound bool
+ step kv.Step
+ 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 {
+ if m.bound {
+ return nil, m.step, false
+ }
+ return nil, kv.NoStepBound, false
+ }
+ return m.value, m.step, true
+}
+
+type branchChildCountDomains struct {
+ sd
+ mem *branchMemBatch
+}
+
+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) 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}
+ reader := &testStateReader{branchData: []byte{0, 0, 0, 0b0000_0011}}
+ sdc := &SharedDomainsCommitmentContext{
+ sharedDomains: &branchChildCountDomains{mem: mem},
+ stateReader: reader,
+ }
+
+ 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, reader.readStepSize)
+ })
+
+ t.Run("unchanged branch comes from installed reader", func(t *testing.T) {
+ mem := &branchMemBatch{}
+ reader := &testStateReader{branchData: []byte{0, 0, 0, 0b0000_0011}}
+ sdc := &SharedDomainsCommitmentContext{
+ sharedDomains: &branchChildCountDomains{mem: mem},
+ stateReader: reader,
+ }
+
+ 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.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 61c057eac7c..82c4567c78e 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.WithSequentialCommitment())
+ domains, err := newSnapshotCommitmentDomains(ctx, tx, log.New())
if err != nil {
return nil, err
}
@@ -1285,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 22f11699234..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.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 2489a08993c..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.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
new file mode 100644
index 00000000000..2db420a9312
--- /dev/null
+++ b/rpc/jsonrpc/rpc_branch_cache_test.go
@@ -0,0 +1,151 @@
+// 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/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"
+ "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, kv.Unlim)
+ 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 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)
+
+ 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) {
+ enableStateCacheForTest(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()
+}
+
+func TestSnapshotCommitmentDomainsIgnoreSharedBranchCache(t *testing.T) {
+ enableStateCacheForTest(t)
+
+ 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 := newSnapshotCommitmentDomains(t.Context(), tx, log.New())
+ 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()
+}