Skip to content

Commit 0428bb8

Browse files
yperbasisclaude
andcommitted
state: fix BAL initialBalanceValue set from post-write reads
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 like the system address that were already written during the initialize phase. These post-write reads were incorrectly used as the "initial" (pre-block) balance by updateRead, triggering the net-zero filter in applyToBalance and dropping legitimate balance changes from the BAL. Fix: only set initialBalanceValue from reads that arrive before any balance writes have been recorded. Post-write reads reflect post-write state, not the pre-block balance. Add TestEngineApiBALMultiSenderBlock which creates 10 independent senders and packs their transfers into a single block, exercising both the assembler and parallel executor BAL paths. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 211ed5d commit 0428bb8

2 files changed

Lines changed: 73 additions & 2 deletions

File tree

execution/engineapi/engine_api_bal_test.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package engineapi_test
1818

1919
import (
2020
"context"
21+
"crypto/ecdsa"
2122
"math/big"
2223
"testing"
2324

@@ -27,6 +28,8 @@ import (
2728
"github.com/erigontech/erigon/common"
2829
"github.com/erigontech/erigon/common/crypto"
2930
"github.com/erigontech/erigon/common/dbg"
31+
"github.com/erigontech/erigon/common/log/v3"
32+
"github.com/erigontech/erigon/common/testlog"
3033
"github.com/erigontech/erigon/execution/abi/bind"
3134
"github.com/erigontech/erigon/execution/engineapi/engineapitester"
3235
"github.com/erigontech/erigon/execution/protocol/params"
@@ -37,6 +40,70 @@ import (
3740
"github.com/erigontech/erigon/rpc/jsonrpc/contracts"
3841
)
3942

43+
// TestEngineApiBALMultiSenderBlock packs transfers from many independent senders
44+
// into a single block. Because the senders are independent the parallel executor
45+
// speculatively executes them concurrently, exercising the coinbase-balance
46+
// strip→rebase→merge path in finalizeWithIBS. Any divergence between the
47+
// assembler's BAL (sequential) and the parallel executor's BAL surfaces as a
48+
// BAL hash mismatch returned by ProcessBAL.
49+
func TestEngineApiBALMultiSenderBlock(t *testing.T) {
50+
if !dbg.Exec3Parallel {
51+
t.Skip("requires parallel exec")
52+
}
53+
const numSenders = 10
54+
senderKeys := make([]*ecdsa.PrivateKey, numSenders)
55+
for i := range senderKeys {
56+
key, err := crypto.GenerateKey()
57+
require.NoError(t, err)
58+
senderKeys[i] = key
59+
}
60+
61+
genesis, coinbaseKey := engineapitester.DefaultEngineApiTesterGenesis(t)
62+
for _, key := range senderKeys {
63+
addr := crypto.PubkeyToAddress(key.PublicKey)
64+
genesis.Alloc[addr] = types.GenesisAccount{
65+
Balance: new(big.Int).Exp(big.NewInt(10), big.NewInt(20), nil), // 100 ETH each
66+
}
67+
}
68+
69+
eat := engineapitester.InitialiseEngineApiTester(t, engineapitester.EngineApiTesterInitArgs{
70+
Logger: testlog.Logger(t, log.LvlDebug),
71+
DataDir: t.TempDir(),
72+
Genesis: genesis,
73+
CoinbaseKey: coinbaseKey,
74+
})
75+
76+
receiver := common.HexToAddress("0xaaaa")
77+
78+
eat.Run(t, func(ctx context.Context, t *testing.T, eat engineapitester.EngineApiTester) {
79+
// Submit one transfer from each independent sender.
80+
txHashes := make([]common.Hash, numSenders)
81+
for i, key := range senderKeys {
82+
txn, err := eat.Transactor.SubmitSimpleTransfer(key, receiver, big.NewInt(1))
83+
require.NoError(t, err)
84+
txHashes[i] = txn.Hash()
85+
}
86+
87+
// BuildCanonicalBlock assembles (sequential) then validates via
88+
// newPayload (parallel executor). A BAL hash mismatch will surface
89+
// as an INVALID payload status, which BuildCanonicalBlock returns
90+
// as an error.
91+
payload, err := eat.MockCl.BuildCanonicalBlock(ctx)
92+
require.NoError(t, err)
93+
94+
err = eat.TxnInclusionVerifier.VerifyTxnsInclusion(ctx, payload.ExecutionPayload, txHashes...)
95+
require.NoError(t, err)
96+
97+
bal := decodeAndValidateBAL(t, payload)
98+
99+
blockNumber := rpc.BlockNumber(payload.ExecutionPayload.BlockNumber)
100+
block, err := eat.RpcApiClient.GetBlockByNumber(ctx, blockNumber, false)
101+
require.NoError(t, err)
102+
require.NotNil(t, block.BlockAccessListHash)
103+
require.Equal(t, bal.Hash(), *block.BlockAccessListHash)
104+
})
105+
}
106+
40107
func TestEngineApiGeneratedPayloadIncludesBlockAccessList(t *testing.T) {
41108
if !dbg.Exec3Parallel {
42109
t.Skip("requires parallel exec")

execution/state/versionedio.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1417,8 +1417,12 @@ func (account *accountState) updateRead(vr *VersionedRead) {
14171417
case BalancePath:
14181418
if val, ok := vr.Val.(uint256.Int); ok {
14191419
// Record the initial (pre-block) balance for net-zero detection.
1420-
// Only the first read is the original pre-block value.
1421-
if account.initialBalanceValue == nil {
1420+
// Only set from the first read AND only before any writes have
1421+
// been recorded. A read that arrives after a write (e.g. the
1422+
// block-end finalize in the parallel executor reading from a
1423+
// fresh IBS) reflects post-write state, not the pre-block
1424+
// balance, and must not be used for net-zero filtering.
1425+
if account.initialBalanceValue == nil && account.balanceValue == nil {
14221426
v := val
14231427
account.initialBalanceValue = &v
14241428
}

0 commit comments

Comments
 (0)