Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
4abb4a9
execution/builder: detach payload builds from the shared BranchCache
yperbasis Jul 3, 2026
a08d697
db/state/execctx, execution/builder, rpc/jsonrpc: WithoutBranchCache …
yperbasis Jul 3, 2026
a094277
execution/execmodule: bound the wait for the parked build
yperbasis Jul 3, 2026
1514c0f
rpc/jsonrpc, rpc/rpchelper: WithoutBranchCache for the remaining RPC-…
yperbasis Jul 3, 2026
1181e4f
execution/execmodule: self-check the shared branch row in the poisoni…
yperbasis Jul 7, 2026
623668d
Merge origin/main into yperbasis/commitment-wrongroot-22152
yperbasis Jul 14, 2026
a986e30
execution/engineapi: detach testing-API SharedDomains from the shared…
yperbasis Jul 14, 2026
fce4873
Merge branch 'main' into yperbasis/commitment-wrongroot-22152
yperbasis Jul 14, 2026
6e34782
Merge remote-tracking branch 'origin/main' into yperbasis/commitment-…
yperbasis Jul 16, 2026
a511c9a
db/state/execctx: mention WithoutBranchCache in branchCache field doc
yperbasis Jul 16, 2026
b675b1d
Merge remote-tracking branch 'origin/main' into yperbasis/commitment-…
yperbasis Aug 14, 2026
28e650c
execution: separate builder mitigation from cache isolation
yperbasis Aug 14, 2026
ef4cdc0
db/state/execctx, execution, rpc: narrow branch cache isolation
yperbasis Aug 15, 2026
9f4198b
rpc/jsonrpc: test request BranchCache isolation
yperbasis Aug 15, 2026
0ba6b50
rpc/jsonrpc: scan all branches in cache tests
yperbasis Aug 15, 2026
ec93094
rpc/jsonrpc: isolate execution witness branch reads
yperbasis Aug 16, 2026
e3b0d40
rpc/jsonrpc: stabilize branch cache regression tests
yperbasis Aug 16, 2026
a451883
rpc/jsonrpc: simplify branch cache proof test
yperbasis Aug 16, 2026
4cae002
db/state/execctx: document branch cache isolation requirement
yperbasis Aug 17, 2026
534702f
execution/commitment: keep branch child reads in computed view
yperbasis Aug 17, 2026
cff2222
db/state/execctx: clarify branch cache isolation scope
yperbasis Aug 17, 2026
cd71e4a
rpc/jsonrpc, execution/commitment: harden request commitment views
yperbasis Aug 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions db/state/execctx/domain_shared.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion db/state/execctx/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This rule isn't followed by two request-owned SharedDomains in rpc/jsonrpc/receipts:

  • receipts_generator.go:333calculatePostState && postState.CommitmentHistory
  • receipts_generator.go:553 — pre-Byzantium slow path, opts.CommitmentHistoryEnabled

Both pass WithoutDeferredBranchUpdates(), WithSequentialCommitment() on the live RPC temporal tx — the pre-PR option pair of the four sites fixed here, minus WithoutSharedBranchCache() — so sd.branchCache is attached at domain_shared.go:392.

SetHistoryStateReader lands after construction, so the constructor's own SeekCommitment (domain_shared.go:404) reads through getLatestMetered, which consults the cache at :1488 and read-fills at :1533. An RO request tx pinned at a lagging view, serving a pre-Byzantium receipt on a node with commitment history, publishes its KeyCommitmentState under its own step/txN into an aggregator-lifetime cache.

The installed history reader bypasses sd for branch reads afterwards, so today it's the one construction-time entry — but the consume direction is the class this PR closes, and a later latest-reader on those sites brings it back whole.

Scope lists only #22533 and #22211. Deliberate, or missed? Either move newSnapshotCommitmentDomains to rpc/rpchelper (both packages already import it) and route both sites through it, or name the issue in Scope.

func WithoutSharedBranchCache() SharedDomainOption {
return func(o *sharedDomainOptions) { o.useSharedBranchCache = false }
}
Expand Down
26 changes: 22 additions & 4 deletions execution/commitment/commitmentdb/commitment_context.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
Expand Down
130 changes: 129 additions & 1 deletion execution/commitment/commitmentdb/commitment_context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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 }

Expand Down Expand Up @@ -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")
})
}
4 changes: 2 additions & 2 deletions rpc/jsonrpc/debug_execution_witness.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
}
Expand Down
5 changes: 2 additions & 3 deletions rpc/jsonrpc/eth_call.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@
"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"
Expand Down Expand Up @@ -150,7 +149,7 @@
}

// EstimateGas implements eth_estimateGas. Returns an estimate of how much gas is necessary to allow the transaction to complete. The transaction will not be added to the blockchain.
func (api *APIImpl) EstimateGas(ctx context.Context, argsOrNil *ethapi2.CallArgs, blockNrOrHash *rpc.BlockNumberOrHash, stateOverrides *ethapi2.StateOverrides, blockOverrides *ethapi2.BlockOverrides) (hexutil.Uint64, error) {

Check failure on line 152 in rpc/jsonrpc/eth_call.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 66 to the 60 allowed.

See more on https://sonarcloud.io/project/issues?id=erigontech_erigon&issues=AaAA794562bxI1xkKkGJ&open=AaAA794562bxI1xkKkGJ&pullRequest=22198
var args ethapi2.CallArgs
// if we actually get CallArgs here, we use them
if argsOrNil != nil {
Expand Down Expand Up @@ -483,7 +482,7 @@
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
}
Expand Down Expand Up @@ -782,7 +781,7 @@
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
}
Expand Down
2 changes: 1 addition & 1 deletion rpc/jsonrpc/eth_simulation.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
29 changes: 29 additions & 0 deletions rpc/jsonrpc/rpc_branch_cache.go
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NewSharedDomains returns a non-nil *SharedDomains alongside its error on the SeekCommitment and ErrBehindCommitment paths (domain_shared.go:404-417) — its own comment there says "sd is fully initialized". This helper forwards that pair unchanged, and all four callers return nil, err with defer domains.Close() only after the check, so a persistent state/index error leaks the mem batch's ETL collectors and the commitment trie once per failed request. IsDomainAheadOfBlocks (domain_shared.go:227) closes a non-nil result before it looks at err, which is the constructor contract.

The leak predates this file, but the helper is now the single choke point where it can be closed:

sd, err := execctx.NewSharedDomains(ctx, tx, logger, ...)
if err != nil {
	if sd != nil {
		sd.Close()
	}
	return nil, err
}
return sd, nil

}
Loading
Loading