go-ethereum/eth/protocols/snap/bal_apply_test.go
jonny rhea 184bde8ca0 eth/protocols/snap, eth/downloader: version SyncProgress and use *types.Header for pivot tracking
eth/protocols/snap: remove unnecessary Sync() loop, drop errPivotStale and resetDownloadState

eth/protocols/snap: move pivot-reorg detection into Sync(), rename checkDeepReorg to isPivotReorged

eth/protocols/snap: don't apply BALs to accounts that haven't been downloaded yet.

eth/protocols/snap: drop empty accounts and zero-value storage on BAL apply

eth/protocols/snap: wipe flat state on sync reset, consolidate reorg detection

eth/protocols/snap: persist Complete=true on sync completion to skip redundant resyncs

eth/protocols/snap: persist catchUp progress incrementally to enable resume

eth/protocols/snap: verify BALs during fetch to route around bad peers, make catchUp cancelable

core/rawdb,eth/protocols/snap: add DeleteSnapshotSyncStatus helper
2026-05-07 16:42:41 -05:00

465 lines
16 KiB
Go

// Copyright 2026 The go-ethereum Authors
// This file is part of the go-ethereum library.
//
// The go-ethereum library is free software: you can redistribute it and/or modify
// it under the terms of the GNU Lesser General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// The go-ethereum library is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License
// along with the go-ethereum library. If not, see <http://www.gnu.org/licenses/>.
package snap
import (
"bytes"
"math/big"
"testing"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/rawdb"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/core/types/bal"
"github.com/ethereum/go-ethereum/crypto"
"github.com/ethereum/go-ethereum/rlp"
"github.com/holiman/uint256"
)
// buildTestBAL constructs a BlockAccessList from a ConstructionBlockAccessList
// by RLP round-tripping (construction types use unexported encoding types).
func buildTestBAL(t *testing.T, cb *bal.ConstructionBlockAccessList) *bal.BlockAccessList {
t.Helper()
var buf bytes.Buffer
if err := cb.EncodeRLP(&buf); err != nil {
t.Fatalf("failed to encode BAL: %v", err)
}
var b bal.BlockAccessList
if err := rlp.DecodeBytes(buf.Bytes(), &b); err != nil {
t.Fatalf("failed to decode BAL: %v", err)
}
return &b
}
// TestAccessListVerification checks that verifyAccessList accepts valid BALs
// and rejects tampered ones.
func TestAccessListVerification(t *testing.T) {
t.Parallel()
cb := bal.NewConstructionBlockAccessList()
addr := common.HexToAddress("0x01")
cb.BalanceChange(0, addr, uint256.NewInt(100))
b := buildTestBAL(t, &cb)
correctHash := b.Hash()
// Valid: hash matches header
header := &types.Header{
Number: big.NewInt(1),
BlockAccessListHash: &correctHash,
}
if err := verifyAccessList(b, header); err != nil {
t.Fatalf("valid access list rejected: %v", err)
}
// Invalid: wrong hash in header
wrongHash := common.HexToHash("0xdead")
badHeader := &types.Header{
Number: big.NewInt(1),
BlockAccessListHash: &wrongHash,
}
if err := verifyAccessList(b, badHeader); err == nil {
t.Fatal("tampered access list accepted")
}
// Invalid: no hash in header
noHashHeader := &types.Header{
Number: big.NewInt(1),
}
if err := verifyAccessList(b, noHashHeader); err == nil {
t.Fatal("header without access list hash accepted")
}
}
// TestAccessListApplication verifies that applyAccessList correctly updates
// flat state (balance, nonce, code, storage) and leaves storageRoot stale.
func TestAccessListApplication(t *testing.T) {
t.Parallel()
db := rawdb.NewMemoryDatabase()
syncer := NewSyncer(db, rawdb.HashScheme)
addr := common.HexToAddress("0x01")
accountHash := crypto.Keccak256Hash(addr[:])
// Write an existing account to flat state
original := types.StateAccount{
Nonce: 5,
Balance: uint256.NewInt(1000),
Root: common.HexToHash("0xbeef"), // intentionally non-empty
CodeHash: types.EmptyCodeHash[:],
}
rawdb.WriteAccountSnapshot(db, accountHash, types.SlimAccountRLP(original))
// Write an existing storage slot. The BAL uses raw slot keys, but the
// snapshot layer stores slots under keccak256(slot).
rawSlot := common.HexToHash("0xaa")
slotHash := crypto.Keccak256Hash(rawSlot[:])
rawdb.WriteStorageSnapshot(db, accountHash, slotHash, common.HexToHash("0x01").Bytes())
// Build a BAL that changes balance, nonce, code, and storage
cb := bal.NewConstructionBlockAccessList()
cb.BalanceChange(0, addr, uint256.NewInt(2000))
cb.NonceChange(addr, 0, 6)
cb.CodeChange(addr, 0, []byte{0x60, 0x00}) // PUSH1 0x00
cb.StorageWrite(0, addr, rawSlot, common.HexToHash("0x02"))
b := buildTestBAL(t, &cb)
if err := syncer.applyAccessList(b); err != nil {
t.Fatalf("applyAccessList failed: %v", err)
}
// Verify account fields updated
data := rawdb.ReadAccountSnapshot(db, accountHash)
if len(data) == 0 {
t.Fatal("account snapshot missing after apply")
}
updated, err := types.FullAccount(data)
if err != nil {
t.Fatalf("failed to decode updated account: %v", err)
}
if updated.Balance.Cmp(uint256.NewInt(2000)) != 0 {
t.Errorf("balance wrong: got %v, want 2000", updated.Balance)
}
if updated.Nonce != 6 {
t.Errorf("nonce wrong: got %d, want 6", updated.Nonce)
}
wantCodeHash := crypto.Keccak256([]byte{0x60, 0x00})
if !bytes.Equal(updated.CodeHash, wantCodeHash) {
t.Errorf("code hash wrong: got %x, want %x", updated.CodeHash, wantCodeHash)
}
// Verify code was written
if code := rawdb.ReadCode(db, common.BytesToHash(wantCodeHash)); !bytes.Equal(code, []byte{0x60, 0x00}) {
t.Errorf("code wrong: got %x, want 6000", code)
}
// Verify storage updated
storageVal := rawdb.ReadStorageSnapshot(db, accountHash, slotHash)
if !bytes.Equal(storageVal, common.HexToHash("0x02").Bytes()) {
t.Errorf("storage wrong: got %x, want %x", storageVal, common.HexToHash("0x02").Bytes())
}
// Verify storageRoot left stale (unchanged from original)
if updated.Root != original.Root {
t.Errorf("storageRoot should be stale: got %v, want %v", updated.Root, original.Root)
}
}
// TestAccessListApplicationMultiTx verifies that when an account has multiple
// changes at different transaction indices, only the highest index (post-block
// state) is applied.
func TestAccessListApplicationMultiTx(t *testing.T) {
t.Parallel()
db := rawdb.NewMemoryDatabase()
syncer := NewSyncer(db, rawdb.HashScheme)
addr := common.HexToAddress("0x02")
accountHash := crypto.Keccak256Hash(addr[:])
// Write initial account
original := types.StateAccount{
Nonce: 0,
Balance: uint256.NewInt(100),
Root: types.EmptyRootHash,
CodeHash: types.EmptyCodeHash[:],
}
rawdb.WriteAccountSnapshot(db, accountHash, types.SlimAccountRLP(original))
// Build BAL with multiple balance/nonce changes at different tx indices
cb := bal.NewConstructionBlockAccessList()
cb.BalanceChange(0, addr, uint256.NewInt(200)) // tx 0
cb.BalanceChange(3, addr, uint256.NewInt(500)) // tx 3
cb.BalanceChange(7, addr, uint256.NewInt(9999)) // tx 7 (final)
cb.NonceChange(addr, 0, 1) // tx 0
cb.NonceChange(addr, 3, 2) // tx 3
cb.NonceChange(addr, 7, 3) // tx 7 (final)
b := buildTestBAL(t, &cb)
if err := syncer.applyAccessList(b); err != nil {
t.Fatalf("applyAccessList failed: %v", err)
}
data := rawdb.ReadAccountSnapshot(db, accountHash)
updated, err := types.FullAccount(data)
if err != nil {
t.Fatalf("failed to decode updated account: %v", err)
}
// Only the highest tx index values should be applied
if updated.Balance.Cmp(uint256.NewInt(9999)) != 0 {
t.Errorf("balance wrong: got %v, want 9999", updated.Balance)
}
if updated.Nonce != 3 {
t.Errorf("nonce wrong: got %d, want 3", updated.Nonce)
}
}
// TestAccessListApplicationZeroStorage verifies that a BAL slot write with a
// zero post-value deletes the snapshot entry instead of writing 32 zero
// bytes.
func TestAccessListApplicationZeroStorage(t *testing.T) {
t.Parallel()
db := rawdb.NewMemoryDatabase()
syncer := NewSyncer(db, rawdb.HashScheme)
addr := common.HexToAddress("0x06")
accountHash := crypto.Keccak256Hash(addr[:])
// Existing account with a non-zero storage slot.
original := types.StateAccount{
Nonce: 1,
Balance: uint256.NewInt(1),
Root: types.EmptyRootHash,
CodeHash: types.EmptyCodeHash[:],
}
rawdb.WriteAccountSnapshot(db, accountHash, types.SlimAccountRLP(original))
rawSlot := common.HexToHash("0xaa")
slotHash := crypto.Keccak256Hash(rawSlot[:])
rawdb.WriteStorageSnapshot(db, accountHash, slotHash, common.HexToHash("0x42").Bytes())
// BAL writes the slot to zero (deletion).
cb := bal.NewConstructionBlockAccessList()
cb.StorageWrite(0, addr, rawSlot, common.Hash{})
b := buildTestBAL(t, &cb)
if err := syncer.applyAccessList(b); err != nil {
t.Fatalf("applyAccessList failed: %v", err)
}
if val := rawdb.ReadStorageSnapshot(db, accountHash, slotHash); len(val) != 0 {
t.Errorf("zeroed slot should have been deleted, got %x", val)
}
}
// TestAccessListApplicationNewAccount verifies that applyAccessList creates
// new accounts that don't exist in the DB yet.
func TestAccessListApplicationNewAccount(t *testing.T) {
t.Parallel()
db := rawdb.NewMemoryDatabase()
syncer := NewSyncer(db, rawdb.HashScheme)
addr := common.HexToAddress("0x03")
accountHash := crypto.Keccak256Hash(addr[:])
// Verify account doesn't exist
if data := rawdb.ReadAccountSnapshot(db, accountHash); len(data) > 0 {
t.Fatal("account should not exist yet")
}
// Build BAL for a new account. BAL uses raw slot keys.
cb := bal.NewConstructionBlockAccessList()
cb.BalanceChange(0, addr, uint256.NewInt(42))
cb.NonceChange(addr, 0, 1)
rawSlot := common.HexToHash("0xbb")
cb.StorageWrite(0, addr, rawSlot, common.HexToHash("0xff"))
b := buildTestBAL(t, &cb)
if err := syncer.applyAccessList(b); err != nil {
t.Fatalf("applyAccessList failed: %v", err)
}
// Verify account was created
data := rawdb.ReadAccountSnapshot(db, accountHash)
if len(data) == 0 {
t.Fatal("account should exist after apply")
}
account, err := types.FullAccount(data)
if err != nil {
t.Fatalf("failed to decode new account: %v", err)
}
if account.Balance.Cmp(uint256.NewInt(42)) != 0 {
t.Errorf("balance wrong: got %v, want 42", account.Balance)
}
if account.Nonce != 1 {
t.Errorf("nonce wrong: got %d, want 1", account.Nonce)
}
if account.Root != types.EmptyRootHash {
t.Errorf("root should be empty for new account: got %v", account.Root)
}
// Verify storage was written under keccak256(rawSlot)
slotHash := crypto.Keccak256Hash(rawSlot[:])
storageVal := rawdb.ReadStorageSnapshot(db, accountHash, slotHash)
if !bytes.Equal(storageVal, common.HexToHash("0xff").Bytes()) {
t.Errorf("storage wrong: got %x, want %x", storageVal, common.HexToHash("0xff").Bytes())
}
}
// TestAccessListApplicationSkipsUnfetched verifies that applyAccessList does
// not write account entries for addresses whose hash falls in a range that
// hasn't been downloaded yet.
func TestAccessListApplicationSkipsUnfetched(t *testing.T) {
t.Parallel()
db := rawdb.NewMemoryDatabase()
syncer := NewSyncer(db, rawdb.HashScheme)
// Pick two addresses and order them by hash.
addrA := common.HexToAddress("0x01")
addrB := common.HexToAddress("0x02")
hashA := crypto.Keccak256Hash(addrA[:])
hashB := crypto.Keccak256Hash(addrB[:])
fetchedAddr, fetchedHash := addrA, hashA
unfetchedAddr, unfetchedHash := addrB, hashB
if bytes.Compare(hashA[:], hashB[:]) > 0 {
fetchedAddr, fetchedHash = addrB, hashB
unfetchedAddr, unfetchedHash = addrA, hashA
}
// One remaining task covering [unfetchedHash, MaxHash]: the fetched hash
// is below Next so isFetched returns true; the unfetched hash equals Next
// so isFetched returns false.
syncer.tasks = []*accountTask{{
Next: unfetchedHash,
Last: common.MaxHash,
SubTasks: make(map[common.Hash][]*storageTask),
stateCompleted: make(map[common.Hash]struct{}),
}}
cb := bal.NewConstructionBlockAccessList()
cb.BalanceChange(0, fetchedAddr, uint256.NewInt(100))
cb.BalanceChange(0, unfetchedAddr, uint256.NewInt(200))
b := buildTestBAL(t, &cb)
if err := syncer.applyAccessList(b); err != nil {
t.Fatalf("applyAccessList failed: %v", err)
}
// The fetched account should have been written.
if data := rawdb.ReadAccountSnapshot(db, fetchedHash); len(data) == 0 {
t.Error("expected fetched account to be written")
}
// The unfetched account should not have been touched.
if data := rawdb.ReadAccountSnapshot(db, unfetchedHash); len(data) != 0 {
t.Errorf("unfetched account should not be written, got %x", data)
}
}
// TestAccessListApplicationSkipsUnfetchedStorage verifies that storage writes
// are also skipped when the parent account's hash range isn't downloaded yet.
func TestAccessListApplicationSkipsUnfetchedStorage(t *testing.T) {
t.Parallel()
db := rawdb.NewMemoryDatabase()
syncer := NewSyncer(db, rawdb.HashScheme)
addrA := common.HexToAddress("0x01")
addrB := common.HexToAddress("0x02")
hashA := crypto.Keccak256Hash(addrA[:])
hashB := crypto.Keccak256Hash(addrB[:])
unfetchedAddr, unfetchedHash := addrB, hashB
if bytes.Compare(hashA[:], hashB[:]) > 0 {
unfetchedAddr, unfetchedHash = addrA, hashA
}
syncer.tasks = []*accountTask{{
Next: unfetchedHash,
Last: common.MaxHash,
SubTasks: make(map[common.Hash][]*storageTask),
stateCompleted: make(map[common.Hash]struct{}),
}}
// BAL touches an unfetched account with a storage write AND an empty
// balance mutation. Neither should result in any flat-state writes.
rawSlot := common.HexToHash("0xaa")
slotHash := crypto.Keccak256Hash(rawSlot[:])
cb := bal.NewConstructionBlockAccessList()
cb.BalanceChange(0, unfetchedAddr, uint256.NewInt(0)) // empty mutation
cb.StorageWrite(0, unfetchedAddr, rawSlot, common.HexToHash("0xff"))
b := buildTestBAL(t, &cb)
if err := syncer.applyAccessList(b); err != nil {
t.Fatalf("applyAccessList failed: %v", err)
}
if data := rawdb.ReadAccountSnapshot(db, unfetchedHash); len(data) != 0 {
t.Errorf("unfetched account should not be written, got %x", data)
}
if val := rawdb.ReadStorageSnapshot(db, unfetchedHash, slotHash); len(val) != 0 {
t.Errorf("storage for unfetched account should not be written, got %x", val)
}
}
// TestAccessListApplicationSameTxCreateDestroy tests the edge case where an
// account is created and self-destructed in the same transaction during the
// pivot gap. Per EIP-7928, such accounts appear in the BAL with a balance
// change to zero but no nonce or code changes. Since the account didn't exist
// at the old pivot and doesn't exist at the new pivot (destroyed),
// applyAccessList should not leave a zero-balance account in the snapshot.
// Per EIP-161, empty accounts (zero balance, zero nonce, no code) must not exist
// in state.
func TestAccessListApplicationSameTxCreateDestroy(t *testing.T) {
t.Parallel()
db := rawdb.NewMemoryDatabase()
syncer := NewSyncer(db, rawdb.HashScheme)
addr := common.HexToAddress("0x04")
accountHash := crypto.Keccak256Hash(addr[:])
// Verify account doesn't exist before apply
if data := rawdb.ReadAccountSnapshot(db, accountHash); len(data) > 0 {
t.Fatal("account should not exist yet")
}
// Build a BAL mimicking same-tx create+destroy: the account appears
// with a balance change to zero and nothing else.
cb := bal.NewConstructionBlockAccessList()
cb.BalanceChange(0, addr, uint256.NewInt(0))
b := buildTestBAL(t, &cb)
if err := syncer.applyAccessList(b); err != nil {
t.Fatalf("applyAccessList failed: %v", err)
}
// Check if applyAccessList created an account.
data := rawdb.ReadAccountSnapshot(db, accountHash)
if len(data) > 0 {
// Account was created
account, err := types.FullAccount(data)
if err != nil {
t.Fatalf("failed to decode account: %v", err)
}
t.Errorf("account created for same-tx create+destroy: "+
"balance=%v, nonce=%d, codeHash=%x, root=%v",
account.Balance, account.Nonce, account.CodeHash, account.Root)
}
}
// TestAccessListApplicationDestroyExisting verifies that when a BAL reduces
// an existing flat-state account to nonce=0, balance=0, empty code (the
// pre-funded destruction pattern), applyAccessList deletes the entry rather
// than leaving it zereod.
func TestAccessListApplicationDestroyExisting(t *testing.T) {
t.Parallel()
db := rawdb.NewMemoryDatabase()
syncer := NewSyncer(db, rawdb.HashScheme)
addr := common.HexToAddress("0x05")
accountHash := crypto.Keccak256Hash(addr[:])
// Pre-funded account: has balance, no nonce, no code.
original := types.StateAccount{
Nonce: 0,
Balance: uint256.NewInt(1000),
Root: types.EmptyRootHash,
CodeHash: types.EmptyCodeHash[:],
}
rawdb.WriteAccountSnapshot(db, accountHash, types.SlimAccountRLP(original))
// The BAL zeros the balance. Nonce and code were already empty, so
// the account ends up fully empty after applying.
cb := bal.NewConstructionBlockAccessList()
cb.BalanceChange(0, addr, uint256.NewInt(0))
b := buildTestBAL(t, &cb)
if err := syncer.applyAccessList(b); err != nil {
t.Fatalf("applyAccessList failed: %v", err)
}
if data := rawdb.ReadAccountSnapshot(db, accountHash); len(data) != 0 {
account, _ := types.FullAccount(data)
t.Errorf("destroyed account should have been deleted from flat state, "+
"got balance=%v, nonce=%d, codeHash=%x",
account.Balance, account.Nonce, account.CodeHash)
}
}