From 7f8a59d75ff23d54710e6b354b02f3427e6c1e0a Mon Sep 17 00:00:00 2001 From: yperbasis Date: Mon, 27 Apr 2026 16:12:00 +0200 Subject: [PATCH 1/2] execution/state: don't seed initial BAL balance from post-write reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parallel executor's block-end finalize creates a fresh IBS (unlike the assembler which reuses the same IBS with cached state objects). This fresh IBS generates BalancePath reads for accounts that were already written during the same block — the initialize-phase system calls (EIP-4788 beacon root, EIP-2935 history storage), the burnt contract (EIP-1559 base-fee burn), or any account whose tx-1 write was pre-populated into the version map from a stored BAL sidecar in the chain-tip FCU validation path. These post-write reads were treated as the "initial" (pre-block) balance by `updateRead`, so `applyToBalance`'s net-zero filter would later drop the legitimate write whose value happened to equal the post-write read. The block's computed BAL hash then disagreed with the header-attested BAL hash, putting the node in an unrecoverable retry loop on the offending payload. Tighten the guard to only set `initialBalanceValue` from a balance read that arrives BEFORE any balance writes have been recorded. Reads arriving after a write reflect post-write state and cannot be used as a pre-block reference. Reproduced on bal-devnet-3 at block 91648 where the node looped on "BAL mismatch: got 0x973750... expected 0xff40db...". The diff was a single missing entry: balanceChanges=[1:0x16eaeb76] for the zero address (the EIP-1559 burnt contract on this devnet). The fresh IBS read at finalize observed the value already pre-populated from the sidecar BAL for tx 1 (0x16eaeb76), set initialBalanceValue to that, and the net-zero filter then dropped the very write whose value the read had borrowed. Co-Authored-By: Claude Opus 4.7 (1M context) --- execution/state/versionedio.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/execution/state/versionedio.go b/execution/state/versionedio.go index eac30942a49..26cc51a99a3 100644 --- a/execution/state/versionedio.go +++ b/execution/state/versionedio.go @@ -1425,8 +1425,13 @@ func (account *accountState) updateRead(vr *VersionedRead) { case BalancePath: if val, ok := vr.Val.(uint256.Int); ok { // Record the initial (pre-block) balance for net-zero detection. - // Only the first read is the original pre-block value. - if account.initialBalanceValue == nil { + // Only set from the first read AND only before any writes have + // been recorded. A read that arrives after a write (e.g. the + // block-end finalize in the parallel executor reading from a + // fresh IBS, or a BAL-prepopulated read of a tx's predicted + // write) reflects post-write state, not the pre-block balance, + // and must not be used for net-zero filtering. + if account.initialBalanceValue == nil && account.balanceValue == nil { v := val account.initialBalanceValue = &v } From 2d50ffcb373330616efd5b7284293049ecc56f05 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Mon, 27 Apr 2026 16:21:52 +0200 Subject: [PATCH 2/2] execution/state: regression test for post-write BAL balance read poisoning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures the bal-devnet-3 block-91648 BAL hash mismatch in a single-file test: a balance write at txIndex=1 followed by a later BalancePath read of the same value (the pattern emitted by the parallel executor's fresh-IBS finalize, or by a BAL-prepopulated read of a tx's predicted write). Without the previous commit's `updateRead` guard, the late read seeds `initialBalanceValue` and `applyToBalance`'s net-zero filter then drops the legitimate write — the exact symptom that put `lighthouse-erigon-super-1` into a retry loop on the offending payload. Co-Authored-By: Claude Opus 4.7 (1M context) --- execution/state/versionedio_test.go | 53 +++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/execution/state/versionedio_test.go b/execution/state/versionedio_test.go index 6a914e1608a..4bb3ae6a2cd 100644 --- a/execution/state/versionedio_test.go +++ b/execution/state/versionedio_test.go @@ -378,6 +378,59 @@ func TestVersionedIO_StaleBalanceReadAfterWriteDoesNotCorruptNoOpCheck(t *testin require.True(t, found, "address must appear in BAL (tx0's write is a real change)") } +// TestVersionedIO_PostWriteBalanceReadDoesNotPoisonInitialBalance is the +// regression test for the bal-devnet-3 block-91648 BAL hash mismatch. +// +// Pattern reproduced from the production failure: a single user-tx balance +// write to an address (the EIP-1559 burnt contract on this devnet — the zero +// address) followed by a *later* BalancePath read of the same address with +// the same value. The later read is the one the parallel executor's block-end +// finalize emits when it spins up a fresh IntraBlockState — the cached state +// objects from the per-tx execution aren't shared, so the finalize hits the +// underlying state and observes the post-write value (or, in the validator +// path, the value that was pre-populated into the version map from the BAL +// sidecar). +// +// Before the fix, updateRead would seed initialBalanceValue from this +// post-write read; applyToBalance's net-zero filter would then see the very +// first recorded write equal to "initialBalanceValue" and drop it, producing +// a BAL hash that disagreed with the header. +func TestVersionedIO_PostWriteBalanceReadDoesNotPoisonInitialBalance(t *testing.T) { + t.Parallel() + + addr := accounts.InternAddress(common.HexToAddress("0x0000000000000000000000000000000000000000")) + burned := *uint256.NewInt(0x16eaeb76) // value pulled from bal-devnet-3 block 91648 + + io := NewVersionedIO(2) + + // Tx 1 burns the base fee to addr (value goes from pre-block balance to `burned`). + io.RecordWrites(Version{TxIndex: 1}, VersionedWrites{ + &VersionedWrite{Address: addr, Path: BalancePath, Version: Version{TxIndex: 1}, Val: burned}, + }) + + // A later BalancePath read of the same value — emitted by the fresh-IBS + // finalize / BAL pre-pop. Before the fix, this poisoned initialBalanceValue. + reads := ReadSet{} + reads.Set(VersionedRead{Address: addr, Path: BalancePath, Val: burned}) + io.RecordReads(Version{TxIndex: 2}, reads) + + bal := io.AsBlockAccessList() + + found := false + for _, ac := range bal { + if ac.Address == addr { + found = true + require.Len(t, ac.BalanceChanges, 1, + "tx 1's burn write must remain in BAL — a post-write read with the same value must not seed initialBalanceValue and trigger the net-zero filter") + // blockAccessIndex = TxIndex+1, so tx 1 → Index=2. + require.Equal(t, uint32(2), ac.BalanceChanges[0].Index) + require.True(t, ac.BalanceChanges[0].Value.Eq(&burned), + "the surviving balance change must hold the actual burn value") + } + } + require.True(t, found, "burn target address must appear in BAL") +} + // TestIBSVersionedWrites_SelfdestructRetainsBalanceDropsOtherPaths verifies // that IntraBlockState.VersionedWrites retains SelfDestructPath, BalancePath // (including non-zero residual balances — EIP-7708 case 2), and IncarnationPath