Skip to content

execution/state: skip the self-destruct storage walk when the address has no account - #23604

Draft
AskAlexSharov wants to merge 4 commits into
mainfrom
alex/sd_skip_empty_storage_37
Draft

execution/state: skip the self-destruct storage walk when the address has no account#23604
AskAlexSharov wants to merge 4 commits into
mainfrom
alex/sd_skip_empty_storage_37

Conversation

@AskAlexSharov

Copy link
Copy Markdown
Collaborator

Self-destruct emits DELETE for every committed storage slot, so it walks the storage-domain prefix for the address. That walk seeks the .bt index of every storage .kv file, so it costs the same whether the address owns a thousand slots or none.

An address with no committed account owns none — storage is only written for an account that exists, and deleting an account wipes its storage prefix. Probing the account first is served by the per-file existence filters, so the walk is skipped for a contract created and destroyed inside one batch, which never has a committed account.

That is exactly the traffic this was found on. Early-2019 gas-token burns destroy 42–44 storage-less children per transaction. On mainnet blocks 7319406–7320491 (integration stage_exec --limit=3000 on n5):

  • 168 slow WriteSet.Normalize warnings, and sdDomainCalls == sdDomainEmpty in all 167 parseable ones, without exception — every walk returned nothing, sdSlots=0 throughout
  • sdTook is ~98% of Normalize (took=9.71ms sdTook=9.53ms sdDomainTook=9.52ms sdVMTook=7µs), 480–675 µs per call
  • 1453.9 ms total against a 12.65 s execution phase — ≥11.5% of exec wall, and that counts only transactions over the 5 ms log threshold

Same argument and the same assertNoCommittedStorage guard as #23506, which did this on the create path. The four copies of the walk collapse into one helper.

Draft until it is measured on n5 — the A/B needs this merged into the branch carrying the sdCascadeStats instrumentation.

… has no account

A self-destruct emits DELETE for every committed storage slot, which means a
prefix walk over the storage domain. That walk seeks the .bt index of every
storage .kv file, so it costs the same whether the address owns a thousand slots
or none.

An address with no committed account owns none: storage is only written for an
account that exists, and deleting an account wipes its storage prefix. Probing
the account first is served by the per-file existence filters, so the walk is
skipped for a contract created and destroyed inside one batch, which never has a
committed account. That is the whole of the traffic this was found on -- early
2019 gas-token burns destroy 42-44 storage-less children per transaction, and on
mainnet blocks 7319406-7320491 every one of those walks came back empty:
sdDomainCalls == sdDomainEmpty in all 167 logged cascades, 1.45s of a 12.65s
execution phase.

Same argument and the same assert as the create-time wipe, so the four copies of
the walk collapse into one helper.
@AskAlexSharov

Copy link
Copy Markdown
Collaborator Author

CI found a real defect, not a flake.

eest-spec-enginextests-devnet-parallel: 1 failure out of 79121 tests, and it is exactly the case this change is about —

FAIL tests/cancun/eip6780_selfdestruct/test_selfdestruct.py::test_recreate_self_destructed_contract_different_txs
  [fork_Amsterdam-blockchain_test_engine_x-call_times_1-recreate_times_1-
   selfdestruct_contract_initial_balance_100000-selfdestruct_to_self-create_opcode_CREATE2]
  error: payload status is not valid: INVALID

Self-destruct in one tx, recreate at the same address in a later tx of the same block. The guard's premise — no committed account implies no committed storage — holds for state committed across batches, but not inside one: once the self-destruct has removed the account, a later tx's Normalize sees no account and skips the walk, so the storage-delete cascade the trie needs is never emitted, and the root goes wrong.

Do not merge as-is. The probe has to distinguish 'never had an account' from 'account was removed earlier in this batch'.

@AskAlexSharov

Copy link
Copy Markdown
Collaborator Author

Root cause found: the self-destruct apply path does not wipe the storage prefix when the block-state cache is active.

rw_v3.go:195-203, the blockCache != nil branch:

blockCache.DeleteAccount(addr, txNum)
...
if pureDelete { continue }   // no DomainDelPrefix

against the else branch, which does DomainDel(Code) + DomainDelPrefix(Storage) + DomainDel(Accounts) together. And BlockStateCache.DeleteAccount (rw_v3.go:1169) only sets currentAccounts[addr] = nil, drops in-block storage from currentStorage, and appends bcOpDeleteAccount to the writeLog — replayed at the block-end Flush.

So between a self-destruct and the end of the block: the account reads empty while the domain still holds the pre-block storage. That is the window the probe cannot see, and it is why only the different_txs variant fails — inside one tx there is no intervening apply.

The discriminator exists, though: BlockStateCache stores a deleted address as present but nil, so it can tell "deleted in this block" from "never existed". A correct guard skips only when the account is absent and the cache has no delete recorded for the address — pre-block storage then genuinely cannot exist, while in-block storage is already covered by vm.StorageKeys.

That needs the cache threaded into CommittedStorageKeys, which the callers hold. Gas-token children are created and destroyed inside one tx and are never marked deleted-from-a-previous-state, so the win should survive the narrower condition.

AskAlexSharov and others added 3 commits August 26, 2026 21:45
… in this block

The account probe alone was wrong. With a block cache active the self-destruct
apply path routes the delete through BlockStateCache.DeleteAccount and returns
before DomainDelPrefix, so the storage prefix only reaches the domain at the
block-end flush. Until then the account reads absent while its pre-block storage
is still there and still owed a trie delete, and skipping the walk lost that
cascade -- EEST test_recreate_self_destructed_contract_different_txs returned
INVALID.

A destroy is recorded as a present-but-nil current entry, which separates
'destroyed here' from 'never existed', so the skip now needs both. A nil cache
keeps the plain probe: that path wipes the prefix inline, so the window does not
exist.

Gas-token children are created and destroyed inside one transaction and are never
carried into the block cache as a destroy of prior state, so the case this
targets still skips.
Erigon records history per transaction, so an RPC re-exec of a later transaction
must see a destroyed contract's storage already gone while a read at the
destroying txNum still sees it. Those history records exist only because the
cascade enumerates the committed slots, and nothing covered that: the existing
cascade tests stop at Normalize's output, and a trie-root check compares only the
block's final state, where the prefix wipe hides a missing per-txn delete.

Drives Normalize through Apply and Flush, then reads GetAsOf on both sides of the
destroying txNum. Forcing CommittedStorageKeys to always skip turns it red.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant