Summary
Parallel-exec misses journal.dirties membership for addresses recreated via createObject(addr, previous != nil) in some scenarios, causing the worker IBS to drop the post-revival writeset.
Diagnosis (from PR #21207 comment thread): resetObjectChange.dirtied() returns (NilAddress, false) while its sibling createObjectChange.dirtied() returns (account, true). Both journal entries represent the same logical "stateObject placed at this address" operation; they differ only in revert behaviour. The asymmetry means SD-revival via GetOrNewStateObject / AddBalance doesn't mark the address dirty, so MakeWriteSet skips writing it (the test_double_kill / test_dynamic_create2_selfdestruct_collision_two_different_transactions failures from the EEST parallel shard).
Why this is filed instead of fixed
Both narrow shapes I tried for the fix regress TestSelfDestructReceive under EXEC3_PARALLEL=true on f05a65e691:
- Unconditional symmetry —
func (ch resetObjectChange) dirtied() (accounts.Address, bool) { return ch.account, true }. Test fails: wrong trie root for block 1.
- Conditional SD-revival + SelfDestructPath=false re-emit — inside
createObject only when previous.selfdestructed, do sdb.journal.dirty(addr) + versionWritten(SelfDestructPath=false). Same regression.
Diff of the validator's MakeWriteSet emission for the recreated contract addr (3a220f351252089d385b29beca14e27f204c296a):
Unfixed (passing): 1 (3.0) Update Account Data (*state.Writer): 3a220f... balance:1000,nonce:0,codehash:<nil>
Fixed (failing): 1 (3.0) Update Account Data (*state.Writer): 3a220f... balance:1000,nonce:1,codehash:emptyHash
The unfixed validator emits the canonical post-revival state (nonce=0, codeHash=<nil> — the empty Handle, i.e. unset). The fixed validator emits the SD'd contract's prior nonce=1, codeHash=emptyHash. Something in the validator's stateObject reconstruction path picks up different field values depending on what's in journal.dirties at MakeWriteSet time.
Test scenarios
| Scenario |
Path |
Without fix |
With fix |
test_double_kill (Frontier / Homestead, EEST) |
tx1 SD's, tx2 empty-touch |
❌ missing empty-removal write |
✅ (per the PR #21207 comment author's local verification) |
TestSelfDestructReceive (execution/tests/, SpuriousDragon) |
tx1 SD's, tx2 AddBalance(non-zero) |
✅ passes |
❌ wrong trie root |
What's needed
A narrower fix that gets the dirty-mark right for the empty-touch / CreateAccount path without changing the validator's stateObject reconstruction for the AddBalance(non-zero) path. Likely needs:
- Tracing the validator's read of the recreated stateObject to find where
nonce=0,codeHash=<nil> vs nonce=1,codeHash=emptyHash diverges.
- Identifying whether the right place to mark dirty is in
createObject itself, in the caller (e.g. CreateAccount already does this conditionally at intra_block_state.go:1876), or only on specific journal-entry paths.
Repro
# Pin to current main
git checkout f05a65e691
# Apply unconditional fix
sed -i '/^func (ch resetObjectChange) dirtied/,/^}$/{s/return accounts.NilAddress, false/return ch.account, true/}' execution/state/journal.go
# Confirm regression
ERIGON_EXEC3_PARALLEL=true go test -run '^TestSelfDestructReceive$' -count=1 -timeout 30s ./execution/tests/
# → FAIL: Wrong trie root of block 1
Related
Summary
Parallel-exec misses
journal.dirtiesmembership for addresses recreated viacreateObject(addr, previous != nil)in some scenarios, causing the worker IBS to drop the post-revival writeset.Diagnosis (from PR #21207 comment thread):
resetObjectChange.dirtied()returns(NilAddress, false)while its siblingcreateObjectChange.dirtied()returns(account, true). Both journal entries represent the same logical "stateObject placed at this address" operation; they differ only in revert behaviour. The asymmetry means SD-revival viaGetOrNewStateObject/AddBalancedoesn't mark the address dirty, soMakeWriteSetskips writing it (thetest_double_kill/test_dynamic_create2_selfdestruct_collision_two_different_transactionsfailures from the EEST parallel shard).Why this is filed instead of fixed
Both narrow shapes I tried for the fix regress
TestSelfDestructReceiveunderEXEC3_PARALLEL=trueonf05a65e691:func (ch resetObjectChange) dirtied() (accounts.Address, bool) { return ch.account, true }. Test fails: wrong trie root for block 1.createObjectonly whenprevious.selfdestructed, dosdb.journal.dirty(addr)+versionWritten(SelfDestructPath=false). Same regression.Diff of the validator's MakeWriteSet emission for the recreated contract addr (
3a220f351252089d385b29beca14e27f204c296a):The unfixed validator emits the canonical post-revival state (
nonce=0, codeHash=<nil>— the empty Handle, i.e. unset). The fixed validator emits the SD'd contract's priornonce=1, codeHash=emptyHash. Something in the validator's stateObject reconstruction path picks up different field values depending on what's injournal.dirtiesat MakeWriteSet time.Test scenarios
test_double_kill(Frontier / Homestead, EEST)TestSelfDestructReceive(execution/tests/, SpuriousDragon)AddBalance(non-zero)What's needed
A narrower fix that gets the dirty-mark right for the empty-touch /
CreateAccountpath without changing the validator's stateObject reconstruction for theAddBalance(non-zero)path. Likely needs:nonce=0,codeHash=<nil>vsnonce=1,codeHash=emptyHashdiverges.createObjectitself, in the caller (e.g.CreateAccountalready does this conditionally atintra_block_state.go:1876), or only on specific journal-entry paths.Repro
Related
test_double_killdiagnosis