Summary
When a SELFDESTRUCT is unwound by an ancestor frame's revert, selfdestructChange.revert unconditionally deletes the account's BalancePath entry from the versioned write set. If an earlier, non-reverted part of the same transaction had written that account's balance — e.g. the CREATE endowment of a prefunded CREATE2 target — that write is deleted along with it and nothing re-records it. The in-memory state object is restored correctly by the journal, so serial execution is unaffected, but the transaction's versioned writes (TxOut) — which feed the parallel executor's version map, write-set validation, and the EIP-7928 Block Access List — permanently lose the balance update.
Mechanism
Selfdestruct marks its journal entry wasCommited: !sdb.hasWrite(addr, SelfDestructPath, NilKey) — i.e. "first SELFDESTRUCT of this address in this tx":
|
wasCommited: !sdb.hasWrite(addr, SelfDestructPath, accounts.NilKey), |
- and records
SelfDestructPath, IncarnationPath, and BalancePath=0:
|
sdb.recordWriteSelfDestruct(addr, stateObject.selfdestructed) |
|
sdb.recordWriteBalance(addr, uint256.Int{}) |
- On revert, the
wasCommited branch deletes the BalancePath and SelfDestructPath entries outright:
|
if ch.wasCommited { |
|
if trace { |
|
if v, ok := s.versionedWrites.GetSelfDestruct(ch.account); ok { |
|
sd := v.Val |
|
fmt.Printf("%s WRT Revert %x: %v -> %v\n", tracePrefix, ch.account, sd, ch.prev) |
|
} |
|
if v, ok := s.versionedWrites.GetBalance(ch.account); ok { |
|
val := v.Val |
|
fmt.Printf("%s WRT Revert %x: %d -> %d\n", tracePrefix, ch.account, &val, &ch.prevbalance) |
|
} |
|
} |
|
s.versionedWrites.DelBalance(ch.account) |
|
s.versionedWrites.DelSelfDestruct(ch.account) |
wasCommited only proves the SelfDestructPath entry was freshly authored by this SELFDESTRUCT. The BalancePath entry may have been authored earlier in the transaction (endowment, transfer) and merely overwritten by the SELFDESTRUCT's zero write — deleting it on revert loses the earlier value instead of restoring it. Account-field versioned writes are only recorded during execution via recordWrite*; MakeWriteSet/FinalizeTx do not re-emit them, so the hole is not repaired at end of tx.
Reachability (Cancun+)
Post-EIP-6780 the SELFDESTRUCT must target a same-tx-created contract for the journal entry to matter, and SELFDESTRUCT halts its own frame, so the revert must come from an ancestor frame:
- contract A CREATE2s C at a prefunded address with an endowment →
BalancePath = prefund + endowment is recorded;
- A calls B; B calls C, whose code SELFDESTRUCTs (that frame ends normally);
- B REVERTs → the snapshot unwind pops C's
selfdestructChange → DelBalance removes the balance write from step one, which predates the reverted snapshot and is never re-recorded.
Post-revert truth: C exists with its code and the endowed balance. TxOut/BAL: C's balance write is missing.
Impact
Parallel-mode only: later transactions reading C through the version map, write-set validation, and the BAL see a stale or missing balance; serial execution and receipts are unaffected, which is why this can go unnoticed.
It becomes spec-load-bearing with EIP-8246 (#22030, implementation in #22136): the reverted-SELFDESTRUCT matrix in the glamsterdam-devnet-6 fixtures exercises exactly this shape, and under the 8246 balance-preserving path the SELFDESTRUCT records no BalancePath write at all, so the revert deletes an entry it demonstrably never authored.
Suggested fix
selfdestructChange should capture at append time whether a BalancePath own-write already existed and what its value was, and the revert should restore the prior entry instead of deleting it — mirroring the !wasCommited branch, which already updates entries back to previous values rather than removing them.
Surfaced by review of #22136 (Codex review pass); confirmed by code inspection on main @ 4aad1d8.
Summary
When a SELFDESTRUCT is unwound by an ancestor frame's revert,
selfdestructChange.revertunconditionally deletes the account'sBalancePathentry from the versioned write set. If an earlier, non-reverted part of the same transaction had written that account's balance — e.g. the CREATE endowment of a prefunded CREATE2 target — that write is deleted along with it and nothing re-records it. The in-memory state object is restored correctly by the journal, so serial execution is unaffected, but the transaction's versioned writes (TxOut) — which feed the parallel executor's version map, write-set validation, and the EIP-7928 Block Access List — permanently lose the balance update.Mechanism
Selfdestructmarks its journal entrywasCommited: !sdb.hasWrite(addr, SelfDestructPath, NilKey)— i.e. "first SELFDESTRUCT of this address in this tx":erigon/execution/state/intra_block_state.go
Line 1370 in 4aad1d8
SelfDestructPath,IncarnationPath, andBalancePath=0:erigon/execution/state/intra_block_state.go
Lines 1382 to 1383 in 4aad1d8
wasCommitedbranch deletes theBalancePathandSelfDestructPathentries outright:erigon/execution/state/journal.go
Lines 252 to 264 in 4aad1d8
wasCommitedonly proves theSelfDestructPathentry was freshly authored by this SELFDESTRUCT. TheBalancePathentry may have been authored earlier in the transaction (endowment, transfer) and merely overwritten by the SELFDESTRUCT's zero write — deleting it on revert loses the earlier value instead of restoring it. Account-field versioned writes are only recorded during execution viarecordWrite*;MakeWriteSet/FinalizeTxdo not re-emit them, so the hole is not repaired at end of tx.Reachability (Cancun+)
Post-EIP-6780 the SELFDESTRUCT must target a same-tx-created contract for the journal entry to matter, and SELFDESTRUCT halts its own frame, so the revert must come from an ancestor frame:
BalancePath = prefund + endowmentis recorded;selfdestructChange→DelBalanceremoves the balance write from step one, which predates the reverted snapshot and is never re-recorded.Post-revert truth: C exists with its code and the endowed balance. TxOut/BAL: C's balance write is missing.
Impact
Parallel-mode only: later transactions reading C through the version map, write-set validation, and the BAL see a stale or missing balance; serial execution and receipts are unaffected, which is why this can go unnoticed.
It becomes spec-load-bearing with EIP-8246 (#22030, implementation in #22136): the reverted-SELFDESTRUCT matrix in the glamsterdam-devnet-6 fixtures exercises exactly this shape, and under the 8246 balance-preserving path the SELFDESTRUCT records no
BalancePathwrite at all, so the revert deletes an entry it demonstrably never authored.Suggested fix
selfdestructChangeshould capture at append time whether aBalancePathown-write already existed and what its value was, and the revert should restore the prior entry instead of deleting it — mirroring the!wasCommitedbranch, which already updates entries back to previous values rather than removing them.Surfaced by review of #22136 (Codex review pass); confirmed by code inspection on main @ 4aad1d8.