Skip to content

fix(state): omit no-op nonce change from block access list - #12830

Open
AnkushinDaniil wants to merge 1 commit into
eip8141-frame-txs-devnet7from
daniil/eip7928-suppress-noop-nonce
Open

fix(state): omit no-op nonce change from block access list#12830
AnkushinDaniil wants to merge 1 commit into
eip8141-frame-txs-devnet7from
daniil/eip7928-suppress-noop-nonce

Conversation

@AnkushinDaniil

Copy link
Copy Markdown
Contributor

Changes

TracedAccessWorldState.SetNonce recorded a nonce change into the block access list unconditionally, including when the written nonce equals the account's current nonce. EIP-7928 builds the access list from actual state transitions (if nonce_changed(addr): append), so a no-op write must not appear as a nonce_change.

This surfaces on any system path that re-asserts a nonce that is already set — e.g. a predeploy install that keeps an existing nonce (Math.Max(nonce, 1) when the nonce is already 1). The redundant SetNonce produced a spurious post == pre nonce-change entry, making the block's access list disagree with the spec-derived list.

The fix reads the current nonce and records the change only when the value actually differs, matching the existing no-op guard already used for code changes in the same tracer.

Types of changes

  • Bug fix (non-breaking change that fixes an issue)

Testing

Parameterized regression in TracedAccessWorldStateTests: an unchanged SetNonce records no nonce change, a changed one still does. Verified as a real regression by a positive control (removing the guard fails the unchanged case).

Serial suites, all green: State 1184/0, Core (block access list) 63/0, Consensus (block access list) 14/0, Evm 5188/0, Blockchain (block access list) 27/0.

Documentation

No documentation changes required.

EIP-7928 records nonce changes from actual state transitions, so a
SetNonce that writes the account's current nonce must not appear as a
nonce_change. TracedAccessWorldState.SetNonce now records only when the
value differs, matching the existing no-op guard for code changes.
@claude

claude Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Claude finished @AnkushinDaniil's task in 4m 16s —— View job


Review: fix(state): omit no-op nonce change from block access list

The core fix is correct and matches EIP-7928's if nonce_changed(addr): append construction. Ordering is right (the old nonce is read before base.SetNonce), the read is safe (StateProvider.SetNonce throws on a missing account, so the account is always present and warm in GetThroughCache), and Restore rolls back the BAL and the base state together, so the comparison can't drift across a revert.

No Critical / High / Medium findings. Five Low notes below; none block merge.


Low 1 — no-op SetNonce drops the account entry entirely (inline)

The guard skips AddNonceChange, which is what calls GetOrAddAccountChanges. For a no-op SetNonce on an address nothing else touched at that index, the address vanishes from the BAL instead of appearing with an empty change set — the precise divergence AddBalanceChange documents at BlockAccessListAtIndex.cs:88-93. Not reachable today: PredeployInstaller.cs:82 touches via CreateAccountIfNotExistsAddAccountRead, and TransactionProcessor.cs:1158 touches via GetNonce. AddCodeChange (BlockAccessListAtIndex.cs:112-117) already returns early the same way, so the PR is at least self-consistent with the code path it cites.

Low 2 — the guard compares against the current nonce, not the pre-tx nonce

AddBalanceChange and AddCodeChange collapse a value written away and back within a tx via PreTxBalance / PreTxCode; there is no PreTxNonce. So IncrementNonce(5→6) followed by SetNonce(…, 5) at the same index still leaves a nonce_change with post == pre. Unreachable in block production — intra-tx rollbacks go through Restore, which rewinds the BAL — so this is informational, not a request to widen the PR.

Low 3 — the new read-back depends on GetNonceCurrent being accurate

AddNonceChange early-returns on newNonce == 0 (BlockAccessListAtIndex.cs:140), so a real 3 → 0 change is never recorded and GetNonceCurrent keeps returning the stale 3; a later SetNonce(addr, 3) would then be misclassified as a no-op. Pre-existing, and only reachable through the nonce == ulong.MaxValue wrap at TransactionProcessor.cs:1171 under SkipValidation. Flagging only because this PR is the first code to depend on that value's accuracy.

Low 4 — extra lookup on the per-tx path

TransactionProcessor.cs:1173 runs SetNonce once per transaction, so GetNonceInternal adds one BAL dictionary probe plus a GetThroughCache hit that line 1158 already warmed. Negligible against tx cost; noted only because this class is otherwise written with explicit hot-path caching (_lastReadStorageCell, _scratchBalance). No change needed.

Low 5 — test asserts a weaker property than intended (inline)

ac?.NonceChange is not null reads false both when the account entry exists without a nonce change and when the entry is absent — the two outcomes Low 1 distinguishes. Worth asserting ac itself in the unchanged branch.


Verification

Read the change against BlockAccessListAtIndex (AddNonceChange / AddBalanceChange / AddCodeChange), every SetNonce caller (TransactionProcessor, PredeployInstaller, XdcBlockhashStore, BlockAccessListManager.StateChanges, StateProvider), and the sibling nonce mutators in the same tracer.

⚠️ I could not run dotnet build or dotnet test — both were denied by the tool permissions in this environment, so I relied on reading the code and on the suite results reported in the PR description. If you want the tests re-run here, dotnet test / dotnet build need adding to --allowedTools.

· branch daniil/eip7928-suppress-noop-nonce

Comment on lines +104 to +109
ulong oldNonce = GetNonceInternal(address);
base.SetNonce(address, nonce);
_generatingBlockAccessList.AddNonceChange(address, nonce);
if (nonce != oldNonce)
{
_generatingBlockAccessList.AddNonceChange(address, nonce);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Low — touched-but-unchanged account is now dropped entirely.

The guard skips the AddNonceChange call, and AddNonceChange is what reaches GetOrAddAccountChanges. So for a no-op SetNonce on an address nothing else touched at this index, the address disappears from the BAL rather than appearing with an empty change set. That is the exact failure mode AddBalanceChange deliberately avoids (BlockAccessListAtIndex.cs:88-93): "Don't add zero balance transfers, but DO add empty account changes: EIP-7928 includes touched-but-unchanged accounts in the suggested BAL, so dropping them diverges from the hash."

Not reachable today — both production callers touch the address first (CreateAccountIfNotExistsAddAccountRead in PredeployInstaller.cs:82, GetNonce in TransactionProcessor.cs:1158) — and AddCodeChange already returns early the same way, so this is a consistency/robustness point rather than a live bug. Worth either an AddAccountRead(address) in the skip branch or a one-line comment recording that callers guarantee the touch.

Fix this →

Comment on lines +124 to +132
AccountChangesAtIndex? ac = tws.GetGeneratingBlockAccessList()!.GetAccountChanges(TestItem.AddressA);
using (Assert.EnterMultipleScope())
{
Assert.That(ac?.NonceChange is not null, Is.EqualTo(expectRecorded));
if (expectRecorded)
{
Assert.That(ac!.NonceChange!.Value.Value, Is.EqualTo(newNonce));
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Low — the unchanged case tolerates two different outcomes.

ac?.NonceChange is not null is false both when the account entry exists with no nonce change and when the account entry is absent entirely. Today it is the latter (see the inline note on SetNonce), which is the behaviour actually worth pinning down. An explicit Assert.That(ac, ...) in the expectRecorded == false branch would make the intended BAL shape part of the contract rather than an accident.

Minor: the changed case (1 → 2) largely subsumes the existing NonceOp_RecordsNonceChange "SetNonce" case (0 → 5) — per AGENTS.md these could be one parameterized test, though keeping them separate is defensible given the different assertion shapes.

@flcl42 flcl42 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Found 1 low-severity issue in src/Nethermind/Nethermind.State.Test/TracedAccessWorldStateTests.cs.

Assert.That(ac?.NonceChange is not null, Is.EqualTo(expectRecorded));
if (expectRecorded)
{
Assert.That(ac!.NonceChange!.Value.Value, Is.EqualTo(newNonce));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[LOW] Dependent assertions can turn a nonce regression into an exception

When the changed-value case regresses so that ac is null or lacks a nonce change, the first assertion records a failure but the multiple-assert scope continues into this dereference, producing NullReferenceException or InvalidOperationException. This dependent null-check/dereference pattern makes the regression failure less diagnostic.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants