Skip to content

fix(evmrpc): mask state overrides with a simulation-local storage overlay#3722

Merged
amir-deris merged 8 commits into
mainfrom
amir/plt-365-rpc-state-override-issue
Jul 9, 2026
Merged

fix(evmrpc): mask state overrides with a simulation-local storage overlay#3722
amir-deris merged 8 commits into
mainfrom
amir/plt-365-rpc-state-override-issue

Conversation

@amir-deris

@amir-deris amir-deris commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Summary

The state/stateDiff state overrides accepted by eth_call, eth_estimateGas, and eth_estimateGasAfterCalls were applied by clearing the target account's persisted contract storage (SetStorageclearAccountState) and re-writing the override slots. During simulation this mutated the working state's view of a contract's storage, so slots that were not named in the override read back as empty instead of retaining their persisted values.

This PR replaces that purge-and-rewrite behavior with a simulation-local storage overlay that masks the account's storage for the duration of the simulation, and adds configurable caps on override size.

Changes

  • Overlay-based state override (x/evm/state): SetStorage now installs a per-account storageOverride in the transaction's TemporaryState instead of purging persisted storage. The overlay keeps a committed view (frozen at override time) and a current view (mutable during execution). While an account is masked:
    • GetState / GetCommittedState read from the overlay; slots not present in the override read as zero rather than from the persisted store.
    • SetState writes to the overlay's current view and journals the change.
    • Persisted contract storage is never deleted.
  • Journal revert support: added storageOverrideChange so overlay writes are correctly rolled back on EVM snapshot revert.
  • Deep copy: TemporaryState.DeepCopy copies the overlay maps so snapshots remain independent.
  • Refactor: extracted clearAccountCodeAndNonce (still used to clear code/nonce when masking storage on an existing account).
  • Configurable caps: new evm.max_state_override_accounts (default 100) and evm.max_state_override_slots (default 1000) config options, wired through Config, the backend, and both HTTP and WebSocket servers. validateStateOverrides rejects oversized overrides up front on all three simulation endpoints.

Testing

  • x/evm/state/state_test.go — overlay masking, zero reads for unset slots, and journal revert behavior.
  • evmrpc/simulate_test.go / evmrpc/state_override_validate_test.go — override size validation.
  • evmrpc/config/config_test.go — config parsing for the new options.

…. Install a simulation-local

storage overlay with committed/current views, journal revert support, and
configurable caps on override accounts and slots. Added/updated tests.
@cursor

cursor Bot commented Jul 7, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Changes core simulation state semantics for state/stateDiff overrides and touches EVM StateDB read/write paths; behavior shifts for partial overrides but persisted chain state is no longer purged during RPC simulation.

Overview
State override semantics change in x/evm/state: SetStorage no longer clears persisted contract storage and rewrites slots. It installs a per-account simulation-local overlay (committed / current) so overridden slots apply while unlisted slots read as zero without deleting keeper state. GetState / GetCommittedState / SetState route through the overlay; journal entries (storageOverrideChange, storageOverrideRemove) and TemporaryState deep copies keep revert and snapshot behavior correct. Account recreate/clear drops the overlay so storage reads empty instead of a stale mask; code and nonce are preserved on override (geth-style).

RPC limits: new evm.max_state_override_accounts (default 100) and evm.max_state_override_slots (default 1000), wired through config, HTTP/WS SimulateConfig, and validateStateOverrides on eth_call, eth_estimateGas, eth_estimateGasAfterCalls, and debug_traceCall.

Reviewed by Cursor Bugbot for commit 1ac7010. Bugbot is set up for automated code reviews on this repo. Configure here.

@amir-deris amir-deris changed the title fix(evmrpc): mask state overrides instead of purging contract storage… fix(evmrpc): mask state overrides with a simulation-local storage overlay Jul 7, 2026
@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown

The latest Buf updates on your PR. Results from workflow Buf / buf (pull_request).

BuildFormatLintBreakingUpdated (UTC)
✅ passed✅ passed✅ passed✅ passedJul 9, 2026, 12:52 AM

Comment thread evmrpc/simulate.go
@codecov

codecov Bot commented Jul 7, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.87342% with 8 lines in your changes missing coverage. Please review.
✅ Project coverage is 58.94%. Comparing base (a7c881d) to head (1ac7010).

Files with missing lines Patch % Lines
evmrpc/simulate.go 80.00% 2 Missing and 2 partials ⚠️
evmrpc/tracers.go 0.00% 1 Missing and 1 partial ⚠️
x/evm/state/state.go 92.85% 1 Missing and 1 partial ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #3722      +/-   ##
==========================================
- Coverage   59.83%   58.94%   -0.90%     
==========================================
  Files        2278     2191      -87     
  Lines      189132   179386    -9746     
==========================================
- Hits       113173   105738    -7435     
+ Misses      65881    64370    -1511     
+ Partials    10078     9278     -800     
Flag Coverage Δ
sei-chain-pr 70.53% <89.87%> (?)
sei-db 70.41% <ø> (ø)
sei-db-state-db ?

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
evmrpc/config/config.go 72.54% <100.00%> (+1.12%) ⬆️
evmrpc/server.go 89.18% <100.00%> (+0.19%) ⬆️
x/evm/state/journal.go 87.50% <100.00%> (+1.78%) ⬆️
x/evm/state/statedb.go 79.41% <100.00%> (+1.70%) ⬆️
evmrpc/tracers.go 68.91% <0.00%> (-0.41%) ⬇️
x/evm/state/state.go 95.97% <92.85%> (-0.86%) ⬇️
evmrpc/simulate.go 75.45% <80.00%> (+0.19%) ⬆️

... and 88 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

seidroid[bot]
seidroid Bot previously requested changes Jul 7, 2026

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The overlay-based masking of persisted storage is a solid, well-tested fix, and the configurable override caps are cleanly plumbed and validated. However, SetStorage still deletes the account's code hash (and clears code/size/nonce), which makes an eth_call/estimateGas against a state-overridden contract execute as if it had no bytecode — the same class of correctness bug the PR set out to fix.

Findings: 1 blocking | 3 non-blocking | 1 posted inline

Blockers

  • None at the file/PR level.
  • 1 blocking issue(s) flagged inline on specific lines.

Non-blocking

  • cursor-review.md is empty — the Cursor second-opinion pass produced no output; only Codex's review was available to merge.
  • validateStateOverrides bounds slots per-account and accounts separately, so a request can still carry up to MaxStateOverrideAccounts × MaxStateOverrideSlots slots (100 × 1000 = 100k by default) plus the same again via StateDiff. This is acceptable but worth noting if a global slot budget was intended.
  • No test exercises executing an overridden contract's code after a state override (only storage read/mask behavior is covered), so the code/nonce-wipe regression is not caught by the suite — consider adding an eth_call-against-overridden-contract test.

Comment thread x/evm/state/state.go Outdated
func (s *DBImpl) SetStorage(addr common.Address, states map[common.Hash]common.Hash) {
s.clearAccountState(addr)
s.k.PrepareReplayedAddr(s.ctx, addr)
if deleteIfExists(s.k.PrefixStore(s.ctx, types.CodeHashKeyPrefix), addr[:]) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[blocker] SetStorage still deletes the account's code hash and (via clearAccountCodeAndNonce) its code, code size, and nonce. This defeats the primary use of a state storage override: go-ethereum's StateOverride.Apply sets Nonce/Code before calling SetStorage for the state field, so:

  1. A state-only override on an existing contract wipes its persisted bytecode/nonce, so a subsequent eth_call/eth_estimateGas executes the contract as if it had no code (returns empty / wrong gas).
  2. An override combining code (or nonce) with state also loses the just-applied code/nonce, since SetCode writes CodeHashKeyPrefix (keeper/code.go:32) which this deleteIfExists then removes.

Geth's SetStorage only replaces storage; it does not touch code or nonce. Since the overlay now masks storage without purging it, this branch should no longer clear code/nonce — drop the code-hash delete + clearAccountCodeAndNonce here (or scope it so it doesn't undo code/nonce overrides) so overridden contracts still execute their bytecode. As-is the PR only half-fixes the state-override correctness problem it targets.

Comment thread evmrpc/simulate.go
Comment thread x/evm/state/state.go

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The PR replaces the purge-and-rewrite state-override path with a simulation-local storage overlay that masks (rather than deletes) an account's persisted storage while preserving its code/nonce/balance, fixing a real bug and matching go-ethereum semantics; it also adds configurable size caps. The change is correct and well-tested, with only minor non-blocking notes.

Findings: 0 blocking | 5 non-blocking | 1 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • debug_traceCall (DebugAPI.TraceCalltracersAPI.TraceCall) also accepts StateOverrides and routes through the same SetStorage overlay path, but validateStateOverrides is only wired into eth_call/eth_estimateGas/eth_estimateGasAfterCalls. Consider applying the same account/slot caps there so the DoS-hardening is uniform across all override-accepting endpoints.
  • Semantic nuance for awareness (not a bug): the overlay freezes committed at SetStorage time and only mutates current on subsequent SetState, so GetCommittedState for a masked account keeps returning the override-time value. go-ethereum's fakeStorage is a single map where GetCommittedState reflects later SetState writes. This can produce slightly different SSTORE gas metering (EIP-2200/3529) under eth_estimateGas for overridden accounts that are then written during execution. The PR's frozen-committed model is arguably more correct as a tx-start view; just confirm this is intended.
  • Cursor's second-opinion review (cursor-review.md) was empty — that pass produced no output. Codex's review produced one finding (the independent State/StateDiff slot counting), captured as an inline comment below.
  • Doc nit: the PR description says clearAccountCodeAndNonce is "still used to clear code/nonce when masking storage on an existing account," but the new SetStorage no longer clears code/nonce (that's the point of the fix); clearAccountCodeAndNonce is only called from clearAccountState. The description slightly misstates the refactor.
  • 1 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread evmrpc/simulate.go
return fmt.Errorf("state override has too many accounts (%d > %d)", len(*overrides), maxAccounts)
}
if maxSlots > 0 {
for addr, account := range *overrides {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] State and StateDiff are capped independently, so a single account can supply up to maxSlots entries in each map and allocate/apply up to 2× the configured per-account slot cap (and go-ethereum's Apply will process both when both are present). If the cap is meant to bound total override storage per account, consider checking len(account.State) + len(account.StateDiff) against maxSlots. Low severity since the total is still bounded, but worth aligning with intent. (Also raised by Codex.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@seidroid check the go ethereum error when both State and StateDiff are provided (which is the same for SEI's fork which this repo uses):
https://github.com/ethereum/go-ethereum/blob/master/internal/ethapi/override/override.go#L107
https://github.com/sei-protocol/go-ethereum/blob/main/internal/ethapi/override/override.go#L100
So in this case, they are not going to get processed.

@seidroid seidroid Bot Jul 8, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Claude finished @amir-deris's task in 2m 8s —— View job


Analysis: State + StateDiff both provided → rejected before either is applied

You're right, and this refutes the Cursor Bugbot "double slot cap" finding.

What go-ethereum's override.Apply does. In internal/ethapi/override/override.go (upstream master L107 / the sei-protocol/go-ethereum fork at v1.15.7-sei-17 L100), StateOverride.Apply rejects any account that supplies both state and stateDiff before touching the StateDB:

// Replace entire state if caller requires.
if account.State != nil && account.StateDiff != nil {
    return fmt.Errorf("account %s has both 'state' and 'stateDiff'", addr.Hex())
}
if account.State != nil {
    statedb.SetStorage(addr, account.State)      // full-replace path -> overlay
}
if account.StateDiff != nil {                     // else-only, additive path
    for key, value := range account.StateDiff {
        statedb.SetState(addr, key, value)
    }
}

Because that error returns early, SetStorage (which allocates the new simulation-local committed/current overlay) and the SetState loop are mutually exclusive per account — at most one of the two maps is ever applied. So the per-account overlay can never approach 2 × MaxStateOverrideSlots; the "up to roughly twice the limit" scenario is unreachable at execution time.

Why the independent caps in validateStateOverrides are fine (evmrpc/simulate.go:490-499):

if len(account.State) > maxSlots { ... }
if len(account.StateDiff) > maxSlots { ... }

A request that puts maxSlots in each map passes this pre-check, but it is then rejected by Apply during simulation before any oversized overlay is allocated. The memory bound the caps exist to protect therefore still holds: only one map per account is ever materialized, and each is individually capped at maxSlots.

Conclusion: The Bugbot finding is not valid — an account with both maps populated is rejected outright, so no code change is needed for correctness or for the overlay memory bound. Your reply on the inline thread is correct.

One optional (non-blocking) nicety: validateStateOverrides currently relies on Apply to reject the both-present case mid-simulation rather than failing fast up front. If you want a cleaner/earlier error on the RPC boundary, you could add an explicit if len(account.State) > 0 && len(account.StateDiff) > 0 check to validateStateOverrides mirroring geth's message — but this is purely cosmetic, since the request is already rejected before any oversized allocation occurs.

--- · branch amir/plt-365-rpc-state-override-issue

@seidroid seidroid Bot dismissed their stale review July 7, 2026 20:22

Superseded: latest AI review found no blocking issues.

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Replaces the purge-and-rewrite state-override behavior with a simulation-local storage overlay and adds configurable DoS caps on override size. The change is well-tested and correct for the common path; the only substantive concern is that the overlay is keyed to the address in TemporaryState and is not cleared when an overridden account is recreated (CreateAccount) or self-destructs mid-simulation, an edge-case semantic divergence.

Findings: 0 blocking | 4 non-blocking | 1 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • cursor-review.md and REVIEW_GUIDELINES.md are both empty — the Cursor second-opinion pass produced no output and there are no repo-specific review guidelines to apply, so those inputs contributed nothing to this review.
  • The overlay lives in TemporaryState.storageOverrides keyed by address and is independent of account lifecycle. If simulated bytecode self-destructs an overridden account (SelfDestruct) or recreates one (CreateAccountclearAccountState), storage reads still route through the stale overlay instead of returning empty storage, unlike go-ethereum where storage follows the state object. Both are rare in eth_call/debug_traceCall (CREATE2 collision / same-tx recreate), but consider dropping the overlay entry in those paths for full parity — this generalizes Codex's finding.
  • Only full state overrides go through the overlay (SetStorage); stateDiff overrides still write directly to the working keeper store via SetState. This matches go-ethereum's additive semantics and the PR's stated scope, but is worth a brief comment so future readers don't assume stateDiff is also masked/non-mutating.
  • 1 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread x/evm/state/state.go
deleteIfExists(s.k.PrefixStore(s.ctx, types.CodeKeyPrefix), acc[:])
deleteIfExists(s.k.PrefixStore(s.ctx, types.CodeSizeKeyPrefix), acc[:])
deleteIfExists(s.k.PrefixStore(s.ctx, types.NonceKeyPrefix), acc[:])
s.clearAccountCodeAndNonce(acc)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] clearAccountState (called from CreateAccount and clearAccountStateIfDestructed) purges only the persisted store; it does not remove s.tempState.storageOverrides[acc.Hex()]. If simulated execution recreates or self-destructs an account that carries a state override, subsequent GetState/GetCommittedState still read the frozen overlay rather than empty storage, so eth_call/debug_traceCall can report behavior that wouldn't occur on-chain (e.g. CREATE2 collision or same-tx recreation). Consider deleting the overlay entry here (and on self-destruct) to match go-ethereum's storage-follows-account semantics. Edge case, non-blocking.

@amir-deris amir-deris added this pull request to the merge queue Jul 7, 2026
@amir-deris amir-deris removed this pull request from the merge queue due to a manual request Jul 7, 2026

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 2e4925d. Configure here.

Comment thread x/evm/state/state.go
}

func (s *DBImpl) GetCommittedState(addr common.Address, hash common.Hash) common.Hash {
if ov, ok := s.tempState.storageOverrides[addr.Hex()]; ok {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Tracing skips overlay drop

High Severity

Simulation RPC contexts use tracing mode, so CreateAccount skips clearAccountState. Storage override removal now lives only in that path, so a recreated account (e.g. CREATE2 collision) can keep serving masked override slots instead of empty storage during eth_call, eth_estimateGas, and related simulations.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2e4925d. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not a regression — eth_call/eth_estimateGas don't run in tracing mode.

The claim assumes simulation RPCs set IsTracing(), but they don't. eth_call/eth_estimateGas → DoCall/DoEstimateGas → StateAndHeaderByNumberOrHash, which builds the ctx at simulate.go:323 with .WithIsEVM(true) but no .WithIsTracing(true). IsTracing() is only ever set in tracers.go:599 and trace_profile.go:143 (the debug_trace* APIs). So for these APIs IsTracing()==false, CreateAccount runs clearAccountState, and the overlay drop executes as intended.

The only context where the drop is skipped is genuine tracing (debug_traceCall), and there it's pre-existing/accepted behavior: that branch already skips all storage clearing on recreation (persisted store isn't purged either), per the documented caveat at state.go:19-20. My change doesn't touch that path or make it worse — it restores go-ethereum parity for exactly the non-tracing simulation paths the comment flags as broken.

seidroid[bot]
seidroid Bot previously requested changes Jul 8, 2026

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The PR replaces the purge-and-rewrite state-override behavior with a simulation-local storage overlay (aligning with go-ethereum semantics) and adds bounded caps on override size — a clean, well-tested change. However, the new storageOverrideRemove journal entry holds a raw pointer to an overlay object, and because Copy() shallow-copies the journal, a copied StateDB that reverts over such an entry re-installs the original DB's overlay into the copy, breaking the copy isolation that DeepCopy was added to guarantee.

Findings: 2 blocking | 4 non-blocking | 1 posted inline

Blockers

  • Copy isolation break (confirmed, matches Codex): DBImpl.Copy() deep-copies TemporaryState.storageOverrides but shallow-copies the journal slice, so the copy shares the same *storageOverrideRemove entries as the original. Each such entry's prev *storageOverride points to the original DB's overlay object; when the copy reverts past that entry (storageOverrideRemove.revert), it installs the original's overlay pointer into the copy's tempState, after which overlay writes via setOverrideState mutate a shared object and bleed between the trace/simulation copies. Fix by deep-copying the overlay carried in storageOverrideRemove.prev (either when journaling in clearAccountState, or when copying the journal in Copy), or by not storing a mutable overlay pointer in the entry.
  • 1 blocking issue(s) flagged inline on specific lines.

Non-blocking

  • Missing test for the exact isolation scenario behind the blocker: no test exercises Copy() after a clearAccountState/storageOverrideRemove on a masked account and then reverts both original and copy to observe cross-contamination. Adding one would catch regressions of the shared-pointer fix.
  • Second-opinion inputs: cursor-review.md was empty (Cursor produced no output) and REVIEW_GUIDELINES.md was empty/missing, so no repo-specific review standards were applied. Codex produced exactly one finding, which is the confirmed blocker above.
  • validateStateOverrides checks len(State) and len(StateDiff) independently against maxSlots rather than their combined total; a request supplying both could allocate up to 2×maxSlots slots for one account. Likely acceptable, but worth a comment or a combined check if the cap is meant to bound total overlay memory per account.
  • Note: during tracing, CreateAccount skips clearAccountState (pre-existing !s.ctx.IsTracing() guard), so a recreated account keeps its frozen overlay in trace mode — consistent with the existing 'tracing could be incorrect in theory' comment, but now also applies to storage overrides.

Comment thread x/evm/state/journal.go
// storage. prev holds the removed overlay for restoration on revert.
storageOverrideRemove struct {
account common.Address
prev *storageOverride

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[blocker] storageOverrideRemove stores a raw pointer to the overlay (prev *storageOverride). Combined with Copy() shallow-copying the journal (copy(journal, s.journal) in statedb.go), this breaks copy isolation: a copied StateDB shares this entry with the original, and when it reverts past this entry, revert installs the original's overlay pointer into the copy's tempState.storageOverrides. Subsequent setOverrideState writes on the copy then mutate an overlay object still referenced by the original's journal, so overlay state can bleed between trace/simulation copies — defeating the isolation that TemporaryState.DeepCopy was added to provide. Deep-copy the overlay stored in prev (when journaling in clearAccountState or when copying the journal), or avoid persisting a mutable overlay pointer here.

Comment thread x/evm/state/statedb.go
Comment on lines 270 to 276
transientAccounts map[string][]byte
transientModuleStates map[string][]byte
transientAccessLists *accessList
storageOverrides map[string]*storageOverride
surplus sdk.Int // in wei
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 debug_traceCall silently drops the 'state' state-override field: overlays installed in tempState.storageOverrides via SetStorage are wiped by Backend.PrepareTxCleanupForTracer (statedb.go:98, s.tempState = NewTemporaryState()) before ApplyTransactionWithEVM runs, so the trace returns the persisted on-chain value with no error. This is a regression introduced by this PR — pre-PR SetStorage wrote through the multistore, which flushCtxs() preserved across the reset. The 'stateDiff' / 'code' / 'nonce' / 'balance' fields still work because they go through multistore-backed writes; only 'state' regresses. Fix: carry storageOverrides (and journal entries) across the tempState reset in CleanupForTracer / ResetForTracer, or apply overrides after PrepareTx.

Extended reasoning...

What the bug is

The new overlay-based storage override is stored exclusively in tempState.storageOverrides (installed by SetStorage at x/evm/state/state.go:220-232, initialized in NewTemporaryState at statedb.go:284). Nothing is written to the multistore. This works fine for eth_call / eth_estimateGas / eth_estimateGasAfterCalls, which never invoke PrepareTx. But debug_traceCall unconditionally routes through PrepareTx before executing the traced message, and PrepareTx calls CleanupForTracer which reallocates tempState — destroying every overlay that StateOverrides.Apply just installed.

The specific code path

Traced end-to-end through the vendored sei-protocol/go-ethereum@v1.15.7-sei-17:

  1. DebugAPI.TraceCall (evmrpc/tracers.go:534) → api.tracersAPI.TraceCall (upstream eth/tracers/api.go:972).
  2. Upstream TraceCall at line 1034: config.StateOverrides.Apply(statedb, precompiles).
  3. override.Apply (internal/ethapi/override/override.go:104-105): for account.State != nil, calls statedb.SetStorage(addr, account.State).
  4. Sei's new SetStorage (x/evm/state/state.go:216-232) allocates a *storageOverride{committed, current} and stores it in s.tempState.storageOverrides[addr.Hex()]. No multistore write.
  5. Upstream TraceCall at line 1059 calls api.traceTx(...).
  6. traceTx at line 1134: api.backend.PrepareTx(statedb, tx).
  7. Sei's Backend.PrepareTx (evmrpc/simulate.go:771-773) unconditionally calls typedStateDB.CleanupForTracer().
  8. CleanupForTracer (x/evm/state/statedb.go:91-102) at line 98 executes s.tempState = NewTemporaryState() — the fresh TemporaryState has storageOverrides: make(map[string]*storageOverride), so every overlay installed in step 4 is orphaned.
  9. core.ApplyTransactionWithEVM runs at line 1137. GetState / GetCommittedState (state.go:26-38) look up s.tempState.storageOverrides[addr.Hex()], find no entry (empty map), and fall through to s.getState(s.ctx, addr, hash) — reading the persisted keeper store rather than the override.

Why existing code doesn't prevent it

  • The PR's own tests (TestSetStorageOverlay, TestSetStoragePreservesCodeAndNonce, TestSetStorageEmptyMasksAll, TestCreateAccountDropsStorageOverride) all call SetStorage directly on a raw DBImpl and never route through PrepareTx, so they can't observe the wipe.
  • TestTraceCall in evmrpc/tracers_test.go doesn't pass any stateOverrides.
  • flushCtxs() at the top of CleanupForTracer preserves multistore-backed changes (SetNonce / SetCode / SetBalance and stateDiff-triggered SetState on a non-masked account all write to the multistore). But the overlay-backed state field lives purely in tempState and is destroyed by the reallocation.
  • PrepareTxNoFlush (simulate.go:803-805) is equally affected — ResetForTracer (statedb.go:108-114) also does s.tempState = NewTemporaryState().

Impact

Silent wrong result on the exact endpoint this PR advertises support for. debug_traceCall-with-state-overrides is a primary tracing use case (masking a token balance / allowance slot to simulate a hypothetical scenario), and it now returns the on-chain persisted value with no error signal.

Pre-PR this worked: SetStorage used to call clearAccountState(addr) + SetState(...) per key, both of which wrote through s.k.PurgePrefix / s.k.SetState to the multistore. Those writes survived CleanupForTracer because flushCtxs() walks the cache multistore chain and writes each layer down before switching to snapshottedCtxs[0]. Post-PR the overlay lives only in tempState and vanishes.

Step-by-step proof

Concrete failure: a caller sends

{
  "method": "debug_traceCall",
  "params": [
    { "to": "0xTOKEN", "data": "balanceOf(user)" },
    "latest",
    { "stateOverrides": { "0xTOKEN": { "state": { "<balanceSlot>": "0x00...3635c9adc5dea00000" } } } }
  ]
}

(overriding the ERC-20 balance slot to 1000e18).

  1. DebugAPI.TraceCall accepts the config, forwards to tracersAPI.TraceCall.
  2. config.StateOverrides.Apply(statedb, ...) calls statedb.SetStorage(0xTOKEN, {slot: 1000e18}) → allocates overlay, stores in tempState.storageOverrides["0xTOKEN"].
  3. traceTx calls PrepareTxCleanupForTracertempState = NewTemporaryState()storageOverrides is now an empty map.
  4. EVM executes balanceOf(user); GetState(0xTOKEN, balanceSlot) finds no overlay entry, reads persisted keeper store → returns the real on-chain balance (typically 0 or the actual balance), NOT 1000e18.
  5. Trace completes successfully, returning the wrong result.

Contrast: the identical payload sent via eth_call on the same node correctly returns 1000e18 because DoCall never invokes PrepareTx. That divergence between eth_call and debug_traceCall on the same override input is the observable symptom.

Fix

Simplest is to preserve storageOverrides (and any journal entries that reference them) across the tempState reset in both CleanupForTracer and ResetForTracer — e.g. capture the current overlays before reallocation and copy them onto the fresh TemporaryState. Alternatively, hoist StateOverrides.Apply to run after PrepareTx (but that would require overriding the upstream trace flow), or back the overlay by the multistore rather than tempState.

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Solid, well-tested fix that replaces the destructive purge-and-rewrite state-override path with a simulation-local storage overlay (correctly preserving code/nonce per go-ethereum semantics) and adds bounded, configurable caps validated on all override-accepting endpoints. No blocking issues; a few minor observations.

Findings: 0 blocking | 5 non-blocking | 1 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • Codex flagged that validateStateOverrides caps State and StateDiff independently, so an account could theoretically pass with maxSlots in each (up to 2x). Practical impact is low: in go-ethereum's OverrideAccount.Apply, state and stateDiff are mutually exclusive per account (Apply errors if both are set), so the double-overlay is normally unreachable, and even worst-case memory is a bounded 2x of a small config default. Consider capping the combined/union slot count for defense-in-depth, or at least confirm the sei go-ethereum fork's Apply rejects both (this could not be byte-verified in the review environment).
  • Cursor's second-opinion review file (cursor-review.md) was empty — that pass produced no output. REVIEW_GUIDELINES.md was also empty, so no repo-specific guidelines were applied.
  • Minor/pre-existing: CreateAccount skips clearAccountState (and thus the new override-drop) when s.ctx.IsTracing(), so a CREATE2 re-creation of a masked account during debug_traceCall would keep reading the frozen overlay instead of empty storage. This matches the existing documented "tracing could be incorrect in theory" caveat rather than being newly introduced, but is worth noting since the new overlay extends that surface.
  • No state-layer test exercises the stateDiff (partial override, un-masked account) path or the OnStorageChange tracing hook on an overridden slot; consider adding coverage.
  • 1 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread evmrpc/simulate.go
if len(account.State) > maxSlots {
return fmt.Errorf("state override for %s has too many slots (%d > %d)", addr.Hex(), len(account.State), maxSlots)
}
if len(account.StateDiff) > maxSlots {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] State and StateDiff slot counts are checked independently, so an account can pass with up to maxSlots in each (2x the intended per-account bound). In standard go-ethereum these two fields are mutually exclusive (Apply errors if both are set), which makes this unreachable in practice — but if you want defense-in-depth against a fork that applies both, cap the combined len(account.State) + len(account.StateDiff) (or the union) instead of each field separately.

@seidroid seidroid Bot dismissed their stale review July 8, 2026 21:32

Superseded: latest AI review found no blocking issues.

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Solid, well-tested fix that replaces the storage-purging state-override implementation with a simulation-local overlay, correctly journaling overlay writes/removals and deep-copying on Copy/revert. No blocking issues found; a few minor backward-compatibility and edge-case notes.

Findings: 0 blocking | 4 non-blocking | 0 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • Backward-compat: the new default caps (max_state_override_accounts=100, max_state_override_slots=1000) reject overrides that were previously accepted (they were effectively unbounded before). Worth calling out in release/upgrade notes so operators who rely on large overrides can raise the limits.
  • validateStateOverrides checks len(account.State) and len(account.StateDiff) independently against maxSlots. Since go-ethereum treats State and StateDiff as mutually exclusive this is fine in practice, but if the fork ever allowed both, an account could carry up to 2*maxSlots overridden slots. A combined check (or an explicit assumption comment) would be more defensive.
  • Tracing edge case (pre-existing): CreateAccount only calls clearAccountState when !ctx.IsTracing(), so during tracing a recreated/colliding account keeps its frozen storage override instead of reading empty. This matches the existing documented 'tracing could be incorrect in theory' caveat, but the new overlay extends that caveat to storage overrides.
  • Second-opinion passes produced no actionable findings: codex-review.md reported no material findings (and could not run Go tests due to a sandboxed network); cursor-review.md was empty. REVIEW_GUIDELINES.md was also empty.

@amir-deris amir-deris added this pull request to the merge queue Jul 9, 2026
Merged via the queue into main with commit b118dc3 Jul 9, 2026
66 checks passed
@amir-deris amir-deris deleted the amir/plt-365-rpc-state-override-issue branch July 9, 2026 01:52
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.

2 participants