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() +}