// 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 . package snap import ( "bytes" "errors" "fmt" "math/big" "slices" "sync" "sync/atomic" "testing" "time" "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/ethdb" "github.com/ethereum/go-ethereum/log" "github.com/ethereum/go-ethereum/rlp" "github.com/ethereum/go-ethereum/trie" "github.com/ethereum/go-ethereum/trie/trienode" "github.com/ethereum/go-ethereum/triedb" "github.com/holiman/uint256" ) type ( accountHandlerFuncV2 func(t *testPeerV2, requestId uint64, root common.Hash, origin common.Hash, limit common.Hash, cap int) error storageHandlerFuncV2 func(t *testPeerV2, requestId uint64, root common.Hash, accounts []common.Hash, origin, limit []byte, max int) error codeHandlerFuncV2 func(t *testPeerV2, id uint64, hashes []common.Hash, max int) error accessListHandlerFunc func(t *testPeerV2, id uint64, hashes []common.Hash, max int) error ) type testPeerV2 struct { id string test *testing.T remote *syncerV2 logger log.Logger accountTrie *trie.Trie accountValues []*kv storageTries map[common.Hash]*trie.Trie storageValues map[common.Hash][]*kv accessLists map[common.Hash]rlp.RawValue // block hash -> RLP-encoded BAL accountRequestV2Handler accountHandlerFuncV2 storageRequestV2Handler storageHandlerFuncV2 codeRequestHandler codeHandlerFuncV2 accessListRequestHandler accessListHandlerFunc term func() // counters nAccountRequests atomic.Int64 nStorageRequests atomic.Int64 nBytecodeRequests atomic.Int64 nAccessListRequests atomic.Int64 } func newTestPeerV2(id string, t *testing.T, term func()) *testPeerV2 { peer := &testPeerV2{ id: id, test: t, logger: log.New("id", id), accountRequestV2Handler: defaultAccountRequestHandlerV2, storageRequestV2Handler: defaultStorageRequestHandlerV2, codeRequestHandler: defaultCodeRequestHandlerV2, accessListRequestHandler: defaultAccessListRequestHandler, term: term, } return peer } func (t *testPeerV2) setStorageTries(tries map[common.Hash]*trie.Trie) { t.storageTries = make(map[common.Hash]*trie.Trie) for root, trie := range tries { t.storageTries[root] = trie.Copy() } } func (t *testPeerV2) ID() string { return t.id } func (t *testPeerV2) Log() log.Logger { return t.logger } func (t *testPeerV2) Stats() string { return fmt.Sprintf(`Account requests: %d Storage requests: %d Bytecode requests: %d`, t.nAccountRequests.Load(), t.nStorageRequests.Load(), t.nBytecodeRequests.Load()) } func (t *testPeerV2) RequestAccountRange(id uint64, root, origin, limit common.Hash, bytes int) error { t.logger.Trace("Fetching range of accounts", "reqid", id, "root", root, "origin", origin, "limit", limit, "bytes", common.StorageSize(bytes)) t.nAccountRequests.Add(1) go t.accountRequestV2Handler(t, id, root, origin, limit, bytes) return nil } func (t *testPeerV2) RequestStorageRanges(id uint64, root common.Hash, accounts []common.Hash, origin, limit []byte, bytes int) error { t.nStorageRequests.Add(1) if len(accounts) == 1 && origin != nil { t.logger.Trace("Fetching range of large storage slots", "reqid", id, "root", root, "account", accounts[0], "origin", common.BytesToHash(origin), "limit", common.BytesToHash(limit), "bytes", common.StorageSize(bytes)) } else { t.logger.Trace("Fetching ranges of small storage slots", "reqid", id, "root", root, "accounts", len(accounts), "first", accounts[0], "bytes", common.StorageSize(bytes)) } go t.storageRequestV2Handler(t, id, root, accounts, origin, limit, bytes) return nil } func (t *testPeerV2) RequestByteCodes(id uint64, hashes []common.Hash, bytes int) error { t.nBytecodeRequests.Add(1) t.logger.Trace("Fetching set of byte codes", "reqid", id, "hashes", len(hashes), "bytes", common.StorageSize(bytes)) go t.codeRequestHandler(t, id, hashes, bytes) return nil } func (t *testPeerV2) RequestTrieNodes(id uint64, root common.Hash, count int, paths []TrieNodePathSet, bytes int) error { // snap/2 never requests trie nodes. return nil } func (t *testPeerV2) RequestAccessLists(id uint64, hashes []common.Hash, bytes int) error { t.nAccessListRequests.Add(1) t.logger.Trace("Fetching set of BALs", "reqid", id, "hashes", len(hashes), "bytes", common.StorageSize(bytes)) go t.accessListRequestHandler(t, id, hashes, bytes) return nil } func createAccountRequestResponseV2(t *testPeerV2, root common.Hash, origin common.Hash, limit common.Hash, cap int) (keys []common.Hash, vals [][]byte, proofs [][]byte) { var size int if limit == (common.Hash{}) { limit = common.MaxHash } for _, entry := range t.accountValues { if size > cap { break } if bytes.Compare(origin[:], entry.k) <= 0 { keys = append(keys, common.BytesToHash(entry.k)) vals = append(vals, entry.v) size += 32 + len(entry.v) } if bytes.Compare(entry.k, limit[:]) >= 0 { break } } proof := trienode.NewProofSet() if err := t.accountTrie.Prove(origin[:], proof); err != nil { t.logger.Error("Could not prove inexistence of origin", "origin", origin, "error", err) } if len(keys) > 0 { lastK := (keys[len(keys)-1])[:] if err := t.accountTrie.Prove(lastK, proof); err != nil { t.logger.Error("Could not prove last item", "error", err) } } return keys, vals, proof.List() } func createStorageRequestResponseV2(t *testPeerV2, root common.Hash, accounts []common.Hash, origin, limit []byte, max int) (hashes [][]common.Hash, slots [][][]byte, proofs [][]byte) { var size int for _, account := range accounts { var originHash common.Hash if len(origin) > 0 { originHash = common.BytesToHash(origin) } var limitHash = common.MaxHash if len(limit) > 0 { limitHash = common.BytesToHash(limit) } var ( keys []common.Hash vals [][]byte abort bool ) for _, entry := range t.storageValues[account] { if size >= max { abort = true break } if bytes.Compare(entry.k, originHash[:]) < 0 { continue } keys = append(keys, common.BytesToHash(entry.k)) vals = append(vals, entry.v) size += 32 + len(entry.v) if bytes.Compare(entry.k, limitHash[:]) >= 0 { break } } if len(keys) > 0 { hashes = append(hashes, keys) slots = append(slots, vals) } if originHash != (common.Hash{}) || (abort && len(keys) > 0) { proof := trienode.NewProofSet() stTrie := t.storageTries[account] if err := stTrie.Prove(originHash[:], proof); err != nil { t.logger.Error("Could not prove inexistence of origin", "origin", originHash, "error", err) } if len(keys) > 0 { lastK := (keys[len(keys)-1])[:] if err := stTrie.Prove(lastK, proof); err != nil { t.logger.Error("Could not prove last item", "error", err) } } proofs = append(proofs, proof.List()...) break } } return hashes, slots, proofs } func createStorageRequestResponseAlwaysProveV2(t *testPeerV2, root common.Hash, accounts []common.Hash, bOrigin, bLimit []byte, max int) (hashes [][]common.Hash, slots [][][]byte, proofs [][]byte) { var size int max = max * 3 / 4 var origin common.Hash if len(bOrigin) > 0 { origin = common.BytesToHash(bOrigin) } var exit bool for i, account := range accounts { var keys []common.Hash var vals [][]byte for _, entry := range t.storageValues[account] { if bytes.Compare(entry.k, origin[:]) < 0 { exit = true } keys = append(keys, common.BytesToHash(entry.k)) vals = append(vals, entry.v) size += 32 + len(entry.v) if size > max { exit = true } } if i == len(accounts)-1 { exit = true } hashes = append(hashes, keys) slots = append(slots, vals) if exit { proof := trienode.NewProofSet() stTrie := t.storageTries[account] if err := stTrie.Prove(origin[:], proof); err != nil { t.logger.Error("Could not prove inexistence of origin", "origin", origin, "error", err) } if len(keys) > 0 { lastK := (keys[len(keys)-1])[:] if err := stTrie.Prove(lastK, proof); err != nil { t.logger.Error("Could not prove last item", "error", err) } } proofs = append(proofs, proof.List()...) break } } return hashes, slots, proofs } // defaultAccountRequestHandlerV2 is a well-behaving handler for AccountRangeRequests. func defaultAccountRequestHandlerV2(t *testPeerV2, id uint64, root common.Hash, origin common.Hash, limit common.Hash, cap int) error { keys, vals, proofs := createAccountRequestResponseV2(t, root, origin, limit, cap) if err := t.remote.OnAccounts(t, id, keys, vals, proofs); err != nil { t.test.Errorf("Remote side rejected our delivery: %v", err) t.term() return err } return nil } func defaultStorageRequestHandlerV2(t *testPeerV2, requestId uint64, root common.Hash, accounts []common.Hash, bOrigin, bLimit []byte, max int) error { hashes, slots, proofs := createStorageRequestResponseV2(t, root, accounts, bOrigin, bLimit, max) if err := t.remote.OnStorage(t, requestId, hashes, slots, proofs); err != nil { t.test.Errorf("Remote side rejected our delivery: %v", err) t.term() } return nil } func defaultCodeRequestHandlerV2(t *testPeerV2, id uint64, hashes []common.Hash, max int) error { var bytecodes [][]byte for _, h := range hashes { bytecodes = append(bytecodes, getCodeByHash(h)) } if err := t.remote.OnByteCodes(t, id, bytecodes); err != nil { t.test.Errorf("Remote side rejected our delivery: %v", err) t.term() } return nil } // defaultAccessListRequestHandler serves BALs from the peer's accessLists map. // If the peer has no BAL data, it returns empty (peer rejection). func defaultAccessListRequestHandler(t *testPeerV2, id uint64, hashes []common.Hash, max int) error { var results []rlp.RawValue if t.accessLists != nil { for _, h := range hashes { if raw, ok := t.accessLists[h]; ok { results = append(results, raw) } } } rawList, _ := rlp.EncodeToRawList(results) if err := t.remote.OnAccessLists(t, id, rawList); err != nil { t.test.Errorf("Remote side rejected our delivery: %v", err) t.term() } return nil } // emptyRequestAccountRangeFnV2 is a rejects AccountRangeRequests func emptyRequestAccountRangeFnV2(t *testPeerV2, requestId uint64, root common.Hash, origin common.Hash, limit common.Hash, cap int) error { t.remote.OnAccounts(t, requestId, nil, nil, nil) return nil } func nonResponsiveRequestAccountRangeFnV2(t *testPeerV2, requestId uint64, root common.Hash, origin common.Hash, limit common.Hash, cap int) error { return nil } func emptyStorageRequestHandlerV2(t *testPeerV2, requestId uint64, root common.Hash, accounts []common.Hash, origin, limit []byte, max int) error { t.remote.OnStorage(t, requestId, nil, nil, nil) return nil } func nonResponsiveStorageRequestHandlerV2(t *testPeerV2, requestId uint64, root common.Hash, accounts []common.Hash, origin, limit []byte, max int) error { return nil } func proofHappyStorageRequestHandlerV2(t *testPeerV2, requestId uint64, root common.Hash, accounts []common.Hash, origin, limit []byte, max int) error { hashes, slots, proofs := createStorageRequestResponseAlwaysProveV2(t, root, accounts, origin, limit, max) if err := t.remote.OnStorage(t, requestId, hashes, slots, proofs); err != nil { t.test.Errorf("Remote side rejected our delivery: %v", err) t.term() } return nil } func corruptCodeRequestHandlerV2(t *testPeerV2, id uint64, hashes []common.Hash, max int) error { var bytecodes [][]byte for _, h := range hashes { bytecodes = append(bytecodes, h[:]) } if err := t.remote.OnByteCodes(t, id, bytecodes); err != nil { t.logger.Info("remote error on delivery (as expected)", "error", err) t.remote.Unregister(t.id) } return nil } func cappedCodeRequestHandlerV2(t *testPeerV2, id uint64, hashes []common.Hash, max int) error { var bytecodes [][]byte for _, h := range hashes[:1] { bytecodes = append(bytecodes, getCodeByHash(h)) } if err := t.remote.OnByteCodes(t, id, bytecodes); err != nil { t.test.Errorf("Remote side rejected our delivery: %v", err) t.term() } return nil } func starvingStorageRequestHandlerV2(t *testPeerV2, requestId uint64, root common.Hash, accounts []common.Hash, origin, limit []byte, max int) error { return defaultStorageRequestHandlerV2(t, requestId, root, accounts, origin, limit, 500) } func starvingAccountRequestHandlerV2(t *testPeerV2, requestId uint64, root common.Hash, origin common.Hash, limit common.Hash, cap int) error { return defaultAccountRequestHandlerV2(t, requestId, root, origin, limit, 500) } func corruptAccountRequestHandlerV2(t *testPeerV2, requestId uint64, root common.Hash, origin common.Hash, limit common.Hash, cap int) error { hashes, accounts, proofs := createAccountRequestResponseV2(t, root, origin, limit, cap) if len(proofs) > 0 { proofs = proofs[1:] } if err := t.remote.OnAccounts(t, requestId, hashes, accounts, proofs); err != nil { t.logger.Info("remote error on delivery (as expected)", "error", err) t.remote.Unregister(t.id) } return nil } func corruptStorageRequestHandlerV2(t *testPeerV2, requestId uint64, root common.Hash, accounts []common.Hash, origin, limit []byte, max int) error { hashes, slots, proofs := createStorageRequestResponseV2(t, root, accounts, origin, limit, max) if len(proofs) > 0 { proofs = proofs[1:] } if err := t.remote.OnStorage(t, requestId, hashes, slots, proofs); err != nil { t.logger.Info("remote error on delivery (as expected)", "error", err) t.remote.Unregister(t.id) } return nil } func noProofStorageRequestHandlerV2(t *testPeerV2, requestId uint64, root common.Hash, accounts []common.Hash, origin, limit []byte, max int) error { hashes, slots, _ := createStorageRequestResponseV2(t, root, accounts, origin, limit, max) if err := t.remote.OnStorage(t, requestId, hashes, slots, nil); err != nil { t.logger.Info("remote error on delivery (as expected)", "error", err) t.remote.Unregister(t.id) } return nil } // TestSyncBloatedProofV2 tests a scenario where we provide only _one_ value, but // also ship the entire trie inside the proof. If the attack is successful, // the remote side does not do any follow-up requests func TestSyncBloatedProofV2(t *testing.T) { t.Parallel() testSyncBloatedProofV2(t, rawdb.HashScheme) testSyncBloatedProofV2(t, rawdb.PathScheme) } func testSyncBloatedProofV2(t *testing.T, scheme string) { var ( once sync.Once cancel = make(chan struct{}) term = func() { once.Do(func() { close(cancel) }) } ) nodeScheme, sourceAccountTrie, elems := makeAccountTrieNoStorage(100, scheme) source := newTestPeerV2("source", t, term) source.accountTrie = sourceAccountTrie.Copy() source.accountValues = elems source.accountRequestV2Handler = func(t *testPeerV2, requestId uint64, root common.Hash, origin common.Hash, limit common.Hash, cap int) error { var ( keys []common.Hash vals [][]byte ) // The values for _, entry := range t.accountValues { if bytes.Compare(entry.k, origin[:]) < 0 { continue } if bytes.Compare(entry.k, limit[:]) > 0 { continue } keys = append(keys, common.BytesToHash(entry.k)) vals = append(vals, entry.v) } // The proofs proof := trienode.NewProofSet() if err := t.accountTrie.Prove(origin[:], proof); err != nil { t.logger.Error("Could not prove origin", "origin", origin, "error", err) } // The bloat: add proof of every single element for _, entry := range t.accountValues { if err := t.accountTrie.Prove(entry.k, proof); err != nil { t.logger.Error("Could not prove item", "error", err) } } // And remove one item from the elements if len(keys) > 2 { keys = append(keys[:1], keys[2:]...) vals = append(vals[:1], vals[2:]...) } if err := t.remote.OnAccounts(t, requestId, keys, vals, proof.List()); err != nil { t.logger.Info("remote error on delivery (as expected)", "error", err) t.term() // This is actually correct, signal to exit the test successfully } return nil } syncer := setupSyncerV2(nodeScheme, source) if err := syncer.Sync(mkPivot(0, sourceAccountTrie.Hash()), cancel); err == nil { t.Fatal("No error returned from incomplete/cancelled sync") } } func setupSyncerV2(scheme string, peers ...*testPeerV2) *syncerV2 { stateDb := rawdb.NewMemoryDatabase() syncer := newSyncerV2(stateDb, scheme) for _, peer := range peers { syncer.Register(peer) peer.remote = syncer } return syncer } // mkPivot builds a minimal pivot header with the given block number and state // root, suitable for test calls into syncerV2.Sync. func mkPivot(num uint64, root common.Hash) *types.Header { return &types.Header{ Number: new(big.Int).SetUint64(num), Root: root, Difficulty: common.Big0, } } // makeAccessListHeaders builds a header map keyed by block hash where each // header's BlockAccessListHash matches the BAL it points to. fetchAccessLists // uses these headers to verify peer responses, so tests need to provide them // alongside any BALs they expect to be accepted. func makeAccessListHeaders(bals map[common.Hash]rlp.RawValue) map[common.Hash]*types.Header { headers := make(map[common.Hash]*types.Header, len(bals)) for h, raw := range bals { var b bal.BlockAccessList if err := rlp.DecodeBytes(raw, &b); err != nil { continue } bh := b.Hash() headers[h] = &types.Header{BlockAccessListHash: &bh} } return headers } // TestSyncV2 tests a basic sync with one peer func TestSyncV2(t *testing.T) { t.Parallel() testSyncV2(t, rawdb.HashScheme) testSyncV2(t, rawdb.PathScheme) } func testSyncV2(t *testing.T, scheme string) { var ( once sync.Once cancel = make(chan struct{}) term = func() { once.Do(func() { close(cancel) }) } ) nodeScheme, sourceAccountTrie, elems := makeAccountTrieNoStorage(100, scheme) mkSource := func(name string) *testPeerV2 { source := newTestPeerV2(name, t, term) source.accountTrie = sourceAccountTrie.Copy() source.accountValues = elems return source } syncer := setupSyncerV2(nodeScheme, mkSource("source")) if err := syncer.Sync(mkPivot(0, sourceAccountTrie.Hash()), cancel); err != nil { t.Fatalf("sync failed: %v", err) } verifyTrie(scheme, syncer.db, sourceAccountTrie.Hash(), t) verifyAdoptedSyncedState(scheme, syncer.db, sourceAccountTrie.Hash(), elems, t) } // verifyAdoptedSyncedState exercises the snap/2 completion contract end-to-end: // after a real sync, opening a fresh triedb and calling AdoptSyncedState must // (a) succeed and (b) leave flat-state reads serving immediately, with no // background regeneration gating them. func verifyAdoptedSyncedState(scheme string, db ethdb.KeyValueStore, root common.Hash, elems []*kv, t *testing.T) { t.Helper() if scheme != rawdb.PathScheme { return } tdb := triedb.NewDatabase(rawdb.NewDatabase(db), newDbConfig(scheme)) defer tdb.Close() if err := tdb.AdoptSyncedState(root); err != nil { t.Fatalf("AdoptSyncedState failed: %v", err) } // Read one of the synced accounts via the public flat-state API. If this // returned errNotCoveredYet we'd know AdoptSyncedState left a generator // gating reads, exactly the bug we're trying to prevent. sr, err := tdb.StateReader(root) if err != nil { t.Fatalf("StateReader: %v", err) } if len(elems) == 0 { return } acc, err := sr.Account(common.BytesToHash(elems[0].k)) if err != nil { t.Fatalf("flat-state read failed after AdoptSyncedState: %v", err) } if acc == nil { t.Fatal("flat-state read returned nil account; sync did not populate the snapshot namespace") } } // TestSyncTinyTriePanicV2 tests a basic sync with one peer, and a tiny trie. This caused a // panic within the prover func TestSyncTinyTriePanicV2(t *testing.T) { t.Parallel() testSyncTinyTriePanicV2(t, rawdb.HashScheme) testSyncTinyTriePanicV2(t, rawdb.PathScheme) } func testSyncTinyTriePanicV2(t *testing.T, scheme string) { var ( once sync.Once cancel = make(chan struct{}) term = func() { once.Do(func() { close(cancel) }) } ) nodeScheme, sourceAccountTrie, elems := makeAccountTrieNoStorage(1, scheme) mkSource := func(name string) *testPeerV2 { source := newTestPeerV2(name, t, term) source.accountTrie = sourceAccountTrie.Copy() source.accountValues = elems return source } syncer := setupSyncerV2(nodeScheme, mkSource("source")) done := checkStall(t, term) if err := syncer.Sync(mkPivot(0, sourceAccountTrie.Hash()), cancel); err != nil { t.Fatalf("sync failed: %v", err) } close(done) verifyTrie(scheme, syncer.db, sourceAccountTrie.Hash(), t) } // TestMultiSyncV2 tests a basic sync with multiple peers func TestMultiSyncV2(t *testing.T) { t.Parallel() testMultiSyncV2(t, rawdb.HashScheme) testMultiSyncV2(t, rawdb.PathScheme) } func testMultiSyncV2(t *testing.T, scheme string) { var ( once sync.Once cancel = make(chan struct{}) term = func() { once.Do(func() { close(cancel) }) } ) nodeScheme, sourceAccountTrie, elems := makeAccountTrieNoStorage(100, scheme) mkSource := func(name string) *testPeerV2 { source := newTestPeerV2(name, t, term) source.accountTrie = sourceAccountTrie.Copy() source.accountValues = elems return source } syncer := setupSyncerV2(nodeScheme, mkSource("sourceA"), mkSource("sourceB")) done := checkStall(t, term) if err := syncer.Sync(mkPivot(0, sourceAccountTrie.Hash()), cancel); err != nil { t.Fatalf("sync failed: %v", err) } close(done) verifyTrie(scheme, syncer.db, sourceAccountTrie.Hash(), t) } // TestSyncWithStorageV2 tests basic sync using accounts + storage + code func TestSyncWithStorageV2(t *testing.T) { t.Parallel() testSyncWithStorageV2(t, rawdb.HashScheme) testSyncWithStorageV2(t, rawdb.PathScheme) } func testSyncWithStorageV2(t *testing.T, scheme string) { var ( once sync.Once cancel = make(chan struct{}) term = func() { once.Do(func() { close(cancel) }) } ) sourceAccountTrie, elems, storageTries, storageElems := makeAccountTrieWithStorage(scheme, 3, 3000, true, false, false) mkSource := func(name string) *testPeerV2 { source := newTestPeerV2(name, t, term) source.accountTrie = sourceAccountTrie.Copy() source.accountValues = elems source.setStorageTries(storageTries) source.storageValues = storageElems return source } syncer := setupSyncerV2(scheme, mkSource("sourceA")) done := checkStall(t, term) if err := syncer.Sync(mkPivot(0, sourceAccountTrie.Hash()), cancel); err != nil { t.Fatalf("sync failed: %v", err) } close(done) verifyTrie(scheme, syncer.db, sourceAccountTrie.Hash(), t) } // TestMultiSyncManyUselessV2 contains one good peer, and many which doesn't return anything valuable at all func TestMultiSyncManyUselessV2(t *testing.T) { t.Parallel() testMultiSyncManyUselessV2(t, rawdb.HashScheme) testMultiSyncManyUselessV2(t, rawdb.PathScheme) } func testMultiSyncManyUselessV2(t *testing.T, scheme string) { var ( once sync.Once cancel = make(chan struct{}) term = func() { once.Do(func() { close(cancel) }) } ) sourceAccountTrie, elems, storageTries, storageElems := makeAccountTrieWithStorage(scheme, 100, 300, true, false, false) mkSource := func(name string, noAccount, noStorage bool) *testPeerV2 { source := newTestPeerV2(name, t, term) source.accountTrie = sourceAccountTrie.Copy() source.accountValues = elems source.setStorageTries(storageTries) source.storageValues = storageElems if !noAccount { source.accountRequestV2Handler = emptyRequestAccountRangeFnV2 } if !noStorage { source.storageRequestV2Handler = emptyStorageRequestHandlerV2 } return source } syncer := setupSyncerV2( scheme, mkSource("full", true, true), mkSource("noAccounts", false, true), mkSource("noStorage", true, false), ) done := checkStall(t, term) if err := syncer.Sync(mkPivot(0, sourceAccountTrie.Hash()), cancel); err != nil { t.Fatalf("sync failed: %v", err) } close(done) verifyTrie(scheme, syncer.db, sourceAccountTrie.Hash(), t) } // TestMultiSyncManyUselessWithLowTimeoutV2 contains one good peer, and many which doesn't return anything valuable at all func TestMultiSyncManyUselessWithLowTimeoutV2(t *testing.T) { t.Parallel() testMultiSyncManyUselessWithLowTimeoutV2(t, rawdb.HashScheme) testMultiSyncManyUselessWithLowTimeoutV2(t, rawdb.PathScheme) } func testMultiSyncManyUselessWithLowTimeoutV2(t *testing.T, scheme string) { var ( once sync.Once cancel = make(chan struct{}) term = func() { once.Do(func() { close(cancel) }) } ) sourceAccountTrie, elems, storageTries, storageElems := makeAccountTrieWithStorage(scheme, 100, 300, true, false, false) mkSource := func(name string, noAccount, noStorage bool) *testPeerV2 { source := newTestPeerV2(name, t, term) source.accountTrie = sourceAccountTrie.Copy() source.accountValues = elems source.setStorageTries(storageTries) source.storageValues = storageElems if !noAccount { source.accountRequestV2Handler = emptyRequestAccountRangeFnV2 } if !noStorage { source.storageRequestV2Handler = emptyStorageRequestHandlerV2 } return source } syncer := setupSyncerV2( scheme, mkSource("full", true, true), mkSource("noAccounts", false, true), mkSource("noStorage", true, false), ) syncer.rates.OverrideTTLLimit = time.Millisecond done := checkStall(t, term) if err := syncer.Sync(mkPivot(0, sourceAccountTrie.Hash()), cancel); err != nil { t.Fatalf("sync failed: %v", err) } close(done) verifyTrie(scheme, syncer.db, sourceAccountTrie.Hash(), t) } // TestMultiSyncManyUnresponsiveV2 contains one good peer, and many which doesn't respond at all func TestMultiSyncManyUnresponsiveV2(t *testing.T) { t.Parallel() testMultiSyncManyUnresponsiveV2(t, rawdb.HashScheme) testMultiSyncManyUnresponsiveV2(t, rawdb.PathScheme) } func testMultiSyncManyUnresponsiveV2(t *testing.T, scheme string) { var ( once sync.Once cancel = make(chan struct{}) term = func() { once.Do(func() { close(cancel) }) } ) sourceAccountTrie, elems, storageTries, storageElems := makeAccountTrieWithStorage(scheme, 100, 300, true, false, false) mkSource := func(name string, noAccount, noStorage bool) *testPeerV2 { source := newTestPeerV2(name, t, term) source.accountTrie = sourceAccountTrie.Copy() source.accountValues = elems source.setStorageTries(storageTries) source.storageValues = storageElems if !noAccount { source.accountRequestV2Handler = nonResponsiveRequestAccountRangeFnV2 } if !noStorage { source.storageRequestV2Handler = nonResponsiveStorageRequestHandlerV2 } return source } syncer := setupSyncerV2( scheme, mkSource("full", true, true), mkSource("noAccounts", false, true), mkSource("noStorage", true, false), ) syncer.rates.OverrideTTLLimit = time.Millisecond done := checkStall(t, term) if err := syncer.Sync(mkPivot(0, sourceAccountTrie.Hash()), cancel); err != nil { t.Fatalf("sync failed: %v", err) } close(done) verifyTrie(scheme, syncer.db, sourceAccountTrie.Hash(), t) } // TestSyncBoundaryAccountTrieV2 tests sync against a few normal peers, but the // account trie has a few boundary elements. func TestSyncBoundaryAccountTrieV2(t *testing.T) { t.Parallel() testSyncBoundaryAccountTrieV2(t, rawdb.HashScheme) testSyncBoundaryAccountTrieV2(t, rawdb.PathScheme) } func testSyncBoundaryAccountTrieV2(t *testing.T, scheme string) { var ( once sync.Once cancel = make(chan struct{}) term = func() { once.Do(func() { close(cancel) }) } ) nodeScheme, sourceAccountTrie, elems := makeBoundaryAccountTrie(scheme, 3000) mkSource := func(name string) *testPeerV2 { source := newTestPeerV2(name, t, term) source.accountTrie = sourceAccountTrie.Copy() source.accountValues = elems return source } syncer := setupSyncerV2( nodeScheme, mkSource("peer-a"), mkSource("peer-b"), ) done := checkStall(t, term) if err := syncer.Sync(mkPivot(0, sourceAccountTrie.Hash()), cancel); err != nil { t.Fatalf("sync failed: %v", err) } close(done) verifyTrie(scheme, syncer.db, sourceAccountTrie.Hash(), t) } // TestSyncNoStorageAndOneCappedPeerV2 tests sync using accounts and no storage, where one peer is // consistently returning very small results func TestSyncNoStorageAndOneCappedPeerV2(t *testing.T) { t.Parallel() testSyncNoStorageAndOneCappedPeerV2(t, rawdb.HashScheme) testSyncNoStorageAndOneCappedPeerV2(t, rawdb.PathScheme) } func testSyncNoStorageAndOneCappedPeerV2(t *testing.T, scheme string) { var ( once sync.Once cancel = make(chan struct{}) term = func() { once.Do(func() { close(cancel) }) } ) nodeScheme, sourceAccountTrie, elems := makeAccountTrieNoStorage(3000, scheme) mkSource := func(name string, slow bool) *testPeerV2 { source := newTestPeerV2(name, t, term) source.accountTrie = sourceAccountTrie.Copy() source.accountValues = elems if slow { source.accountRequestV2Handler = starvingAccountRequestHandlerV2 } return source } syncer := setupSyncerV2( nodeScheme, mkSource("nice-a", false), mkSource("nice-b", false), mkSource("nice-c", false), mkSource("capped", true), ) done := checkStall(t, term) if err := syncer.Sync(mkPivot(0, sourceAccountTrie.Hash()), cancel); err != nil { t.Fatalf("sync failed: %v", err) } close(done) verifyTrie(scheme, syncer.db, sourceAccountTrie.Hash(), t) } // TestSyncNoStorageAndOneCodeCorruptPeerV2 has one peer which doesn't deliver // code requests properly. func TestSyncNoStorageAndOneCodeCorruptPeerV2(t *testing.T) { t.Parallel() testSyncNoStorageAndOneCodeCorruptPeerV2(t, rawdb.HashScheme) testSyncNoStorageAndOneCodeCorruptPeerV2(t, rawdb.PathScheme) } func testSyncNoStorageAndOneCodeCorruptPeerV2(t *testing.T, scheme string) { var ( once sync.Once cancel = make(chan struct{}) term = func() { once.Do(func() { close(cancel) }) } ) nodeScheme, sourceAccountTrie, elems := makeAccountTrieNoStorage(3000, scheme) mkSource := func(name string, codeFn codeHandlerFuncV2) *testPeerV2 { source := newTestPeerV2(name, t, term) source.accountTrie = sourceAccountTrie.Copy() source.accountValues = elems source.codeRequestHandler = codeFn return source } syncer := setupSyncerV2( nodeScheme, mkSource("capped", cappedCodeRequestHandlerV2), mkSource("corrupt", corruptCodeRequestHandlerV2), ) done := checkStall(t, term) if err := syncer.Sync(mkPivot(0, sourceAccountTrie.Hash()), cancel); err != nil { t.Fatalf("sync failed: %v", err) } close(done) verifyTrie(scheme, syncer.db, sourceAccountTrie.Hash(), t) } func TestSyncNoStorageAndOneAccountCorruptPeerV2(t *testing.T) { t.Parallel() testSyncNoStorageAndOneAccountCorruptPeerV2(t, rawdb.HashScheme) testSyncNoStorageAndOneAccountCorruptPeerV2(t, rawdb.PathScheme) } func testSyncNoStorageAndOneAccountCorruptPeerV2(t *testing.T, scheme string) { var ( once sync.Once cancel = make(chan struct{}) term = func() { once.Do(func() { close(cancel) }) } ) nodeScheme, sourceAccountTrie, elems := makeAccountTrieNoStorage(3000, scheme) mkSource := func(name string, accFn accountHandlerFuncV2) *testPeerV2 { source := newTestPeerV2(name, t, term) source.accountTrie = sourceAccountTrie.Copy() source.accountValues = elems source.accountRequestV2Handler = accFn return source } syncer := setupSyncerV2( nodeScheme, mkSource("capped", defaultAccountRequestHandlerV2), mkSource("corrupt", corruptAccountRequestHandlerV2), ) done := checkStall(t, term) if err := syncer.Sync(mkPivot(0, sourceAccountTrie.Hash()), cancel); err != nil { t.Fatalf("sync failed: %v", err) } close(done) verifyTrie(scheme, syncer.db, sourceAccountTrie.Hash(), t) } // TestSyncNoStorageAndOneCodeCappedPeerV2 has one peer which delivers code hashes // one by one func TestSyncNoStorageAndOneCodeCappedPeerV2(t *testing.T) { t.Parallel() testSyncNoStorageAndOneCodeCappedPeerV2(t, rawdb.HashScheme) testSyncNoStorageAndOneCodeCappedPeerV2(t, rawdb.PathScheme) } func testSyncNoStorageAndOneCodeCappedPeerV2(t *testing.T, scheme string) { var ( once sync.Once cancel = make(chan struct{}) term = func() { once.Do(func() { close(cancel) }) } ) nodeScheme, sourceAccountTrie, elems := makeAccountTrieNoStorage(3000, scheme) mkSource := func(name string, codeFn codeHandlerFuncV2) *testPeerV2 { source := newTestPeerV2(name, t, term) source.accountTrie = sourceAccountTrie.Copy() source.accountValues = elems source.codeRequestHandler = codeFn return source } var counter int syncer := setupSyncerV2( nodeScheme, mkSource("capped", func(t *testPeerV2, id uint64, hashes []common.Hash, max int) error { counter++ return cappedCodeRequestHandlerV2(t, id, hashes, max) }), ) done := checkStall(t, term) if err := syncer.Sync(mkPivot(0, sourceAccountTrie.Hash()), cancel); err != nil { t.Fatalf("sync failed: %v", err) } close(done) if threshold := 100; counter > threshold { t.Logf("Error, expected < %d invocations, got %d", threshold, counter) } verifyTrie(scheme, syncer.db, sourceAccountTrie.Hash(), t) } // TestSyncBoundaryStorageTrieV2 tests sync against a few normal peers, but the // storage trie has a few boundary elements. func TestSyncBoundaryStorageTrieV2(t *testing.T) { t.Parallel() testSyncBoundaryStorageTrieV2(t, rawdb.HashScheme) testSyncBoundaryStorageTrieV2(t, rawdb.PathScheme) } func testSyncBoundaryStorageTrieV2(t *testing.T, scheme string) { var ( once sync.Once cancel = make(chan struct{}) term = func() { once.Do(func() { close(cancel) }) } ) sourceAccountTrie, elems, storageTries, storageElems := makeAccountTrieWithStorage(scheme, 10, 1000, false, true, false) mkSource := func(name string) *testPeerV2 { source := newTestPeerV2(name, t, term) source.accountTrie = sourceAccountTrie.Copy() source.accountValues = elems source.setStorageTries(storageTries) source.storageValues = storageElems return source } syncer := setupSyncerV2( scheme, mkSource("peer-a"), mkSource("peer-b"), ) done := checkStall(t, term) if err := syncer.Sync(mkPivot(0, sourceAccountTrie.Hash()), cancel); err != nil { t.Fatalf("sync failed: %v", err) } close(done) verifyTrie(scheme, syncer.db, sourceAccountTrie.Hash(), t) } // TestSyncWithStorageAndOneCappedPeerV2 tests sync using accounts + storage, where one peer is // consistently returning very small results func TestSyncWithStorageAndOneCappedPeerV2(t *testing.T) { t.Parallel() testSyncWithStorageAndOneCappedPeerV2(t, rawdb.HashScheme) testSyncWithStorageAndOneCappedPeerV2(t, rawdb.PathScheme) } func testSyncWithStorageAndOneCappedPeerV2(t *testing.T, scheme string) { var ( once sync.Once cancel = make(chan struct{}) term = func() { once.Do(func() { close(cancel) }) } ) sourceAccountTrie, elems, storageTries, storageElems := makeAccountTrieWithStorage(scheme, 300, 100, false, false, false) mkSource := func(name string, slow bool) *testPeerV2 { source := newTestPeerV2(name, t, term) source.accountTrie = sourceAccountTrie.Copy() source.accountValues = elems source.setStorageTries(storageTries) source.storageValues = storageElems if slow { source.storageRequestV2Handler = starvingStorageRequestHandlerV2 } return source } syncer := setupSyncerV2( scheme, mkSource("nice-a", false), mkSource("slow", true), ) done := checkStall(t, term) if err := syncer.Sync(mkPivot(0, sourceAccountTrie.Hash()), cancel); err != nil { t.Fatalf("sync failed: %v", err) } close(done) verifyTrie(scheme, syncer.db, sourceAccountTrie.Hash(), t) } // TestSyncWithStorageAndCorruptPeerV2 tests sync using accounts + storage, where one peer is // sometimes sending bad proofs func TestSyncWithStorageAndCorruptPeerV2(t *testing.T) { t.Parallel() testSyncWithStorageAndCorruptPeerV2(t, rawdb.HashScheme) testSyncWithStorageAndCorruptPeerV2(t, rawdb.PathScheme) } func testSyncWithStorageAndCorruptPeerV2(t *testing.T, scheme string) { var ( once sync.Once cancel = make(chan struct{}) term = func() { once.Do(func() { close(cancel) }) } ) sourceAccountTrie, elems, storageTries, storageElems := makeAccountTrieWithStorage(scheme, 100, 300, true, false, false) mkSource := func(name string, handler storageHandlerFuncV2) *testPeerV2 { source := newTestPeerV2(name, t, term) source.accountTrie = sourceAccountTrie.Copy() source.accountValues = elems source.setStorageTries(storageTries) source.storageValues = storageElems source.storageRequestV2Handler = handler return source } syncer := setupSyncerV2( scheme, mkSource("nice-a", defaultStorageRequestHandlerV2), mkSource("nice-b", defaultStorageRequestHandlerV2), mkSource("nice-c", defaultStorageRequestHandlerV2), mkSource("corrupt", corruptStorageRequestHandlerV2), ) done := checkStall(t, term) if err := syncer.Sync(mkPivot(0, sourceAccountTrie.Hash()), cancel); err != nil { t.Fatalf("sync failed: %v", err) } close(done) verifyTrie(scheme, syncer.db, sourceAccountTrie.Hash(), t) } func TestSyncWithStorageAndNonProvingPeerV2(t *testing.T) { t.Parallel() testSyncWithStorageAndNonProvingPeerV2(t, rawdb.HashScheme) testSyncWithStorageAndNonProvingPeerV2(t, rawdb.PathScheme) } func testSyncWithStorageAndNonProvingPeerV2(t *testing.T, scheme string) { var ( once sync.Once cancel = make(chan struct{}) term = func() { once.Do(func() { close(cancel) }) } ) sourceAccountTrie, elems, storageTries, storageElems := makeAccountTrieWithStorage(scheme, 100, 300, true, false, false) mkSource := func(name string, handler storageHandlerFuncV2) *testPeerV2 { source := newTestPeerV2(name, t, term) source.accountTrie = sourceAccountTrie.Copy() source.accountValues = elems source.setStorageTries(storageTries) source.storageValues = storageElems source.storageRequestV2Handler = handler return source } syncer := setupSyncerV2( scheme, mkSource("nice-a", defaultStorageRequestHandlerV2), mkSource("nice-b", defaultStorageRequestHandlerV2), mkSource("nice-c", defaultStorageRequestHandlerV2), mkSource("corrupt", noProofStorageRequestHandlerV2), ) done := checkStall(t, term) if err := syncer.Sync(mkPivot(0, sourceAccountTrie.Hash()), cancel); err != nil { t.Fatalf("sync failed: %v", err) } close(done) verifyTrie(scheme, syncer.db, sourceAccountTrie.Hash(), t) } // TestSyncWithStorageMisbehavingProveV2 tests basic sync using accounts + storage + code, against // a peer who insists on delivering full storage sets _and_ proofs. This triggered // an error, where the recipient erroneously clipped the boundary nodes, but // did not mark the account for healing. func TestSyncWithStorageMisbehavingProveV2(t *testing.T) { t.Parallel() testSyncWithStorageMisbehavingProveV2(t, rawdb.HashScheme) testSyncWithStorageMisbehavingProveV2(t, rawdb.PathScheme) } func testSyncWithStorageMisbehavingProveV2(t *testing.T, scheme string) { var ( once sync.Once cancel = make(chan struct{}) term = func() { once.Do(func() { close(cancel) }) } ) nodeScheme, sourceAccountTrie, elems, storageTries, storageElems := makeAccountTrieWithStorageWithUniqueStorage(scheme, 10, 30, false) mkSource := func(name string) *testPeerV2 { source := newTestPeerV2(name, t, term) source.accountTrie = sourceAccountTrie.Copy() source.accountValues = elems source.setStorageTries(storageTries) source.storageValues = storageElems source.storageRequestV2Handler = proofHappyStorageRequestHandlerV2 return source } syncer := setupSyncerV2(nodeScheme, mkSource("sourceA")) if err := syncer.Sync(mkPivot(0, sourceAccountTrie.Hash()), cancel); err != nil { t.Fatalf("sync failed: %v", err) } verifyTrie(scheme, syncer.db, sourceAccountTrie.Hash(), t) } // TestSyncWithUnevenStorageV2 tests sync where the storage trie is not even // and with a few empty ranges. func TestSyncWithUnevenStorageV2(t *testing.T) { t.Parallel() testSyncWithUnevenStorageV2(t, rawdb.HashScheme) testSyncWithUnevenStorageV2(t, rawdb.PathScheme) } func testSyncWithUnevenStorageV2(t *testing.T, scheme string) { var ( once sync.Once cancel = make(chan struct{}) term = func() { once.Do(func() { close(cancel) }) } ) accountTrie, accounts, storageTries, storageElems := makeAccountTrieWithStorage(scheme, 3, 256, false, false, true) mkSource := func(name string) *testPeerV2 { source := newTestPeerV2(name, t, term) source.accountTrie = accountTrie.Copy() source.accountValues = accounts source.setStorageTries(storageTries) source.storageValues = storageElems source.storageRequestV2Handler = func(t *testPeerV2, reqId uint64, root common.Hash, accounts []common.Hash, origin, limit []byte, max int) error { return defaultStorageRequestHandlerV2(t, reqId, root, accounts, origin, limit, 128) // retrieve storage in large mode } return source } syncer := setupSyncerV2(scheme, mkSource("source")) if err := syncer.Sync(mkPivot(0, accountTrie.Hash()), cancel); err != nil { t.Fatalf("sync failed: %v", err) } verifyTrie(scheme, syncer.db, accountTrie.Hash(), t) } // makeAccountTrieWithAddresses creates an account trie keyed by keccak(address), // matching production behavior. Returns the trie, sorted entries, and the // addresses used. This allows BAL-based tests to target specific addresses and // have applyAccessList write to the same snapshot keys as the download. func makeAccountTrieWithAddresses(n int, scheme string) (string, *trie.Trie, []*kv, []common.Address) { var ( db = triedb.NewDatabase(rawdb.NewMemoryDatabase(), newDbConfig(scheme)) accTrie = trie.NewEmpty(db) entries []*kv addrs []common.Address ) for i := uint64(1); i <= uint64(n); i++ { // Deterministic address from index addr := common.BigToAddress(new(big.Int).SetUint64(i)) addrs = append(addrs, addr) value, _ := rlp.EncodeToBytes(&types.StateAccount{ Nonce: i, Balance: uint256.NewInt(i), Root: types.EmptyRootHash, CodeHash: types.EmptyCodeHash[:], }) key := crypto.Keccak256(addr[:]) elem := &kv{key, value} accTrie.MustUpdate(elem.k, elem.v) entries = append(entries, elem) } slices.SortFunc(entries, (*kv).cmp) root, nodes := accTrie.Commit(false) db.Update(root, types.EmptyRootHash, 0, trienode.NewWithNodeSet(nodes), triedb.NewStateSet()) accTrie, _ = trie.New(trie.StateTrieID(root), db) return db.Scheme(), accTrie, entries, addrs } // TestIsPivotReorged verifies the four conditions isPivotReorged covers: // reorged out, non-advancing pivot, missing canonical, and the happy path // where the previous pivot is still canonical and the new pivot advances. func TestIsPivotReorged(t *testing.T) { t.Parallel() // Reorged: canonical hash at prev's height differs from prev. The // previous pivot was reorged out by an alternate chain at the same // (or higher) height. t.Run("Reorged_DifferentHash", func(t *testing.T) { db := rawdb.NewMemoryDatabase() prev := mkPivot(100, common.HexToHash("0xaaaa")) curr := mkPivot(105, common.HexToHash("0xcccc")) canonical := mkPivot(100, common.HexToHash("0xbbbb")) rawdb.WriteHeader(db, canonical) rawdb.WriteCanonicalHash(db, canonical.Hash(), canonical.Number.Uint64()) if !isPivotReorged(db, prev, curr) { t.Fatal("expected reorg detection when canonical hash differs") } }) // NonAdvancingPivot: new pivot is at or below the old one. There's // nothing for catchUp to roll forward, regardless of canonical state. t.Run("NonAdvancingPivot", func(t *testing.T) { db := rawdb.NewMemoryDatabase() prev := mkPivot(100, common.HexToHash("0xaaaa")) curr := mkPivot(95, common.HexToHash("0xcccc")) rawdb.WriteHeader(db, prev) rawdb.WriteCanonicalHash(db, prev.Hash(), prev.Number.Uint64()) if !isPivotReorged(db, prev, curr) { t.Fatal("expected reorg detection when new pivot is at or below the old one") } }) // MissingCanonical: canonical hash at prev's height is absent while // curr advances past it. By the time Sync is called, headers up to // curr should be indexed, so this implies broken chain state. t.Run("MissingCanonical", func(t *testing.T) { db := rawdb.NewMemoryDatabase() prev := mkPivot(100, common.HexToHash("0xaaaa")) curr := mkPivot(105, common.HexToHash("0xcccc")) if !isPivotReorged(db, prev, curr) { t.Fatal("expected reorg detection when canonical hash is missing at prev's height") } }) // NotReorged_SameHash: prev is still canonical and curr advances past // it. Catch-up is feasible. t.Run("NotReorged_SameHash", func(t *testing.T) { db := rawdb.NewMemoryDatabase() prev := mkPivot(100, common.HexToHash("0xaaaa")) curr := mkPivot(105, common.HexToHash("0xcccc")) rawdb.WriteHeader(db, prev) rawdb.WriteCanonicalHash(db, prev.Hash(), prev.Number.Uint64()) if isPivotReorged(db, prev, curr) { t.Fatal("should not detect reorg when prev is canonical and curr advances") } }) } // TestSyncDetectsPivotReorged exercises the reorg-handling branch in Sync // end-to-end. // // Setup: persisted progress points at an orphan pivot at block 100; the new // canonical header at block 100 has a different hash. Sync is then called with // a new pivot at the same height. // // If isPivotReorged works, loadSyncStatus restores previousPivot, the check // flags it as reorged, resetSyncState clears previousPivot, catchUp is // skipped, and the fresh download proceeds to completion. // // If detection doesn't fire, the pivot-move check would call catchUp with // from = 101 and to = 100 — the inverted-range guard surfaces that as an // error, failing the test. So Sync returning nil is the positive signal that // reorg detection and the reset worked. func TestSyncDetectsPivotReorged(t *testing.T) { t.Parallel() nodeScheme, sourceAccountTrie, elems := makeAccountTrieNoStorage(100, rawdb.HashScheme) root := sourceAccountTrie.Hash() db := rawdb.NewMemoryDatabase() // Persist progress against an orphan pivot — same height as the new // canonical pivot we'll sync to, different hash. Populate a partial task // and non-zero counter so the reset path has something to clean up. orphanPivot := mkPivot(100, common.HexToHash("0xdead")) seed := newSyncerV2(db, nodeScheme) // previousPivot reflects where flat state matches and it is what // saveSyncStatus persists. Set it to simulate a prior sync reaching // orphanPivot. seed.previousPivot = orphanPivot seed.pivot = orphanPivot seed.accountSynced = 42 seed.tasks = []*accountTaskV2{{ Next: common.HexToHash("0x80"), Last: common.MaxHash, SubTasks: make(map[common.Hash][]*storageTaskV2), stateCompleted: make(map[common.Hash]struct{}), }} seed.saveSyncStatus() // Pre-write orphan flat-state entries at hashes the test peer won't // re-serve. After resetSyncState wipes the snapshot ranges, these // should be gone. orphanAccountHash := common.HexToHash("0xdeadbeef") rawdb.WriteAccountSnapshot(db, orphanAccountHash, []byte{0xde, 0xad}) orphanStorageAccount := common.HexToHash("0xfeedfacefeedfacefeedfacefeedfacefeedfacefeedfacefeedfacefeedface") orphanStorageSlot := common.HexToHash("0xabcd") rawdb.WriteStorageSnapshot(db, orphanStorageAccount, orphanStorageSlot, []byte{0xff, 0xff}) // Canonical header at block 100 is newPivot — different hash from the // orphan pivot, which is what isPivotReorged will detect. newPivot := mkPivot(100, root) rawdb.WriteHeader(db, newPivot) rawdb.WriteCanonicalHash(db, newPivot.Hash(), newPivot.Number.Uint64()) var ( once sync.Once cancel = make(chan struct{}) term = func() { once.Do(func() { close(cancel) }) } ) syncer := newSyncerV2(db, nodeScheme) src := newTestPeerV2("source", t, term) src.accountTrie = sourceAccountTrie.Copy() src.accountValues = elems syncer.Register(src) src.remote = syncer if err := syncer.Sync(newPivot, cancel); err != nil { t.Fatalf("sync failed (reorg detection likely broken): %v", err) } // After successful completion, status should be marked Complete=true // against the new (canonical) pivot. loader := newSyncerV2(db, nodeScheme) loader.loadSyncStatus() if !loader.complete { t.Fatal("sync status should be marked Complete=true after successful completion") } if loader.previousPivot == nil || loader.previousPivot.Hash() != newPivot.Hash() { t.Fatalf("expected persisted pivot to match new pivot") } if data := rawdb.ReadAccountSnapshot(db, orphanAccountHash); len(data) != 0 { t.Errorf("orphan account snapshot should be wiped, got %x", data) } if val := rawdb.ReadStorageSnapshot(db, orphanStorageAccount, orphanStorageSlot); len(val) != 0 { t.Errorf("orphan storage snapshot should be wiped, got %x", val) } } // TestInterruptedDownloadRecovery verifies that partially completed download // state is persisted and resumed on restart. func TestInterruptedDownloadRecovery(t *testing.T) { t.Parallel() testInterruptedDownloadRecovery(t, rawdb.HashScheme) testInterruptedDownloadRecovery(t, rawdb.PathScheme) } func testInterruptedDownloadRecovery(t *testing.T, scheme string) { nodeScheme, sourceAccountTrie, elems := makeAccountTrieNoStorage(100, scheme) root := sourceAccountTrie.Hash() // Cancel after exactly 2 account range responses, guaranteeing partial // completion without any timing dependency. var ( once1 sync.Once cancel1 = make(chan struct{}) term1 = func() { once1.Do(func() { close(cancel1) }) } responses atomic.Int32 ) cancelAfterHandler := func(tp *testPeerV2, id uint64, root common.Hash, origin common.Hash, limit common.Hash, cap int) error { if responses.Add(1) > 2 { term1() return nil } return defaultAccountRequestHandlerV2(tp, id, root, origin, limit, cap) } db := rawdb.NewMemoryDatabase() syncer1 := newSyncerV2(db, nodeScheme) src1 := newTestPeerV2("source1", t, term1) src1.accountTrie = sourceAccountTrie.Copy() src1.accountValues = elems src1.accountRequestV2Handler = cancelAfterHandler syncer1.Register(src1) src1.remote = syncer1 pivot := mkPivot(0, root) syncer1.pivot = pivot syncer1.previousPivot = pivot // Sync sets this before downloadState syncer1.loadSyncStatus() syncer1.downloadState(cancel1) // Save progress for _, task := range syncer1.tasks { syncer1.forwardAccountTask(task) } syncer1.cleanAccountTasks() syncer1.saveSyncStatus() // Count how many accounts were downloaded in the first run. // Due to the async nature of response processing, the cancel may race // with delivery so 0 accounts may be written. firstRunCount := 0 for _, entry := range elems { if data := rawdb.ReadAccountSnapshot(db, common.BytesToHash(entry.k)); len(data) > 0 { firstRunCount++ } } if firstRunCount == len(elems) { t.Fatal("first run should not have downloaded everything") } // Second run: resume with same root, should complete the download var ( once2 sync.Once cancel2 = make(chan struct{}) term2 = func() { once2.Do(func() { close(cancel2) }) } ) syncer2 := newSyncerV2(db, nodeScheme) src2 := newTestPeerV2("source2", t, term2) src2.accountTrie = sourceAccountTrie.Copy() src2.accountValues = elems syncer2.Register(src2) src2.remote = syncer2 pivot2 := mkPivot(0, root) syncer2.pivot = pivot2 syncer2.previousPivot = pivot2 // Sync sets this before downloadState syncer2.loadSyncStatus() if err := syncer2.downloadState(cancel2); err != nil { t.Fatalf("resumed download failed: %v", err) } // Verify all accounts are now present for _, entry := range elems { if data := rawdb.ReadAccountSnapshot(db, common.BytesToHash(entry.k)); len(data) == 0 { t.Errorf("missing account after resumed download: %x", entry.k) } } } // TestSyncPersistsPivotDuringDownload verifies that after a fresh Sync is // interrupted mid-download, the persisted previousPivot equals the current // pivot (not nil). Without this, a follow-up Sync at a different pivot // would not see that the partial flat state belongs to the old pivot, and // would mix old-pivot accounts with new-pivot data. func TestSyncPersistsPivotDuringDownload(t *testing.T) { t.Parallel() nodeScheme, sourceAccountTrie, elems := makeAccountTrieNoStorage(100, rawdb.HashScheme) var ( once sync.Once cancel = make(chan struct{}) term = func() { once.Do(func() { close(cancel) }) } responses atomic.Int32 ) db := rawdb.NewMemoryDatabase() syncer := newSyncerV2(db, nodeScheme) src := newTestPeerV2("source", t, term) src.accountTrie = sourceAccountTrie.Copy() src.accountValues = elems src.accountRequestV2Handler = func(tp *testPeerV2, id uint64, root common.Hash, origin common.Hash, limit common.Hash, cap int) error { if responses.Add(1) > 2 { term() return nil } return defaultAccountRequestHandlerV2(tp, id, root, origin, limit, cap) } syncer.Register(src) src.remote = syncer pivot := mkPivot(0, sourceAccountTrie.Hash()) // Sync should be interrupted by the cancel after a couple of responses. _ = syncer.Sync(pivot, cancel) // Persisted previousPivot must equal the pivot, so a follow-up Sync at a // different pivot can recognize the partial flat state belongs to this one. loader := newSyncerV2(db, nodeScheme) loader.loadSyncStatus() if loader.previousPivot == nil { t.Fatal("expected persisted previousPivot to be set after interrupted download, got nil") } if loader.previousPivot.Hash() != pivot.Hash() { t.Errorf("persisted previousPivot mismatch: got %v, want %v", loader.previousPivot.Hash(), pivot.Hash()) } } // TestPivotMovement verifies the full pivot move flow: download with rootA, // cancel+restart with rootB, catch-up applies BAL diffs, download resumes // and completes against the new state. func TestPivotMovement(t *testing.T) { t.Parallel() testPivotMovement(t, rawdb.HashScheme, 1) testPivotMovement(t, rawdb.PathScheme, 1) } // TestPivotMovementRepeated verifies that multiple pivot moves work correctly. func TestPivotMovementRepeated(t *testing.T) { t.Parallel() testPivotMovement(t, rawdb.HashScheme, 2) testPivotMovement(t, rawdb.PathScheme, 2) } func testPivotMovement(t *testing.T, scheme string, pivotMoves int) { // Use makeAccountTrieWithAddresses so trie keys are keccak(addr), // matching what applyAccessList writes to the snapshot DB. nodeScheme, sourceAccountTrie, elems, addrs := makeAccountTrieWithAddresses(100, scheme) numA := uint64(100) // Target account 50 for BAL changes targetAddr := addrs[49] targetHash := crypto.Keccak256Hash(targetAddr[:]) type pivotMove struct { blockNum uint64 trie *trie.Trie elems []*kv root common.Hash bals map[common.Hash]rlp.RawValue // header hash -> encoded BAL balance *uint256.Int } // Build each pivot move: update account 50's balance in both the trie // and a BAL, write the header, and record everything. db := rawdb.NewMemoryDatabase() currentElems := elems moves := make([]pivotMove, pivotMoves) emptyHash := common.Hash{} zero := uint64(0) for m := 0; m < pivotMoves; m++ { blockNum := numA + uint64(m) + 1 balance := uint256.NewInt(uint64(1000 * (m + 1))) // Build updated trie with new balance for account 50 trieDB := triedb.NewDatabase(rawdb.NewMemoryDatabase(), newDbConfig(scheme)) newTrie := trie.NewEmpty(trieDB) newElems := make([]*kv, len(currentElems)) for i, entry := range currentElems { if bytes.Equal(entry.k, targetHash[:]) { val, _ := rlp.EncodeToBytes(&types.StateAccount{ Nonce: 50, Balance: balance, Root: types.EmptyRootHash, CodeHash: types.EmptyCodeHash[:], }) newElems[i] = &kv{entry.k, val} } else { newElems[i] = entry } newTrie.MustUpdate(newElems[i].k, newElems[i].v) } newRoot, nodes := newTrie.Commit(false) trieDB.Update(newRoot, types.EmptyRootHash, 0, trienode.NewWithNodeSet(nodes), triedb.NewStateSet()) resultTrie, _ := trie.New(trie.StateTrieID(newRoot), trieDB) // Build BAL matching the trie diff cb := bal.NewConstructionBlockAccessList() cb.BalanceChange(0, targetAddr, balance) var buf bytes.Buffer if err := cb.EncodeRLP(&buf); err != nil { t.Fatal(err) } // Compute BAL hash, write header, store BAL keyed by header hash var b bal.BlockAccessList if err := rlp.DecodeBytes(buf.Bytes(), &b); err != nil { t.Fatal(err) } balHash := b.Hash() header := &types.Header{ Number: new(big.Int).SetUint64(blockNum), Difficulty: common.Big0, BaseFee: common.Big0, WithdrawalsHash: &emptyHash, BlobGasUsed: &zero, ExcessBlobGas: &zero, ParentBeaconRoot: &emptyHash, RequestsHash: &emptyHash, BlockAccessListHash: &balHash, } rawdb.WriteHeader(db, header) headerHash := header.Hash() rawdb.WriteCanonicalHash(db, headerHash, blockNum) moves[m] = pivotMove{ blockNum: blockNum, trie: resultTrie, elems: newElems, root: newRoot, bals: map[common.Hash]rlp.RawValue{headerHash: buf.Bytes()}, balance: balance, } currentElems = newElems } // First run: download against rootA, cancel after 2 responses rootA := sourceAccountTrie.Hash() var ( once1 sync.Once cancel1 = make(chan struct{}) term1 = func() { once1.Do(func() { close(cancel1) }) } responses atomic.Int32 ) syncer1 := newSyncerV2(db, nodeScheme) src1 := newTestPeerV2("source1", t, term1) src1.accountTrie = sourceAccountTrie.Copy() src1.accountValues = elems src1.accountRequestV2Handler = func(tp *testPeerV2, id uint64, root common.Hash, origin common.Hash, limit common.Hash, cap int) error { if responses.Add(1) > 2 { term1() return nil } return defaultAccountRequestHandlerV2(tp, id, root, origin, limit, cap) } syncer1.Register(src1) src1.remote = syncer1 syncer1.Sync(mkPivot(numA, rootA), cancel1) // Subsequent runs: each move triggers catch-up then resumes download for i, move := range moves { var ( once sync.Once cancel = make(chan struct{}) term = func() { once.Do(func() { close(cancel) }) } ) syncer := newSyncerV2(db, nodeScheme) src := newTestPeerV2(fmt.Sprintf("source-%d", i+2), t, term) src.accountTrie = move.trie.Copy() src.accountValues = move.elems src.accessLists = move.bals syncer.Register(src) src.remote = syncer if err := syncer.Sync(mkPivot(move.blockNum, move.root), cancel); err != nil { t.Fatalf("pivot move %d: sync failed: %v", i+1, err) } // Verify account 50's balance was updated by catch-up data := rawdb.ReadAccountSnapshot(db, targetHash) if len(data) == 0 { t.Fatalf("pivot move %d: account 50 not found after sync", i+1) } account, aErr := types.FullAccount(data) if aErr != nil { t.Fatalf("pivot move %d: failed to decode account: %v", i+1, aErr) } if account.Balance.Cmp(move.balance) != 0 { t.Errorf("pivot move %d: balance wrong: got %v, want %v", i+1, account.Balance, move.balance) } } } // TestCatchUpPersistsIncrementally verifies that catchUp updates and persists // previousPivot after each successfully applied BAL. If a later block in the // gap fails to apply, the persisted state reflects the last successful block, // so a follow-up Sync can resume from there rather than reapplying everything. func TestCatchUpPersistsIncrementally(t *testing.T) { t.Parallel() testCatchUpPersistsIncrementally(t, rawdb.HashScheme) testCatchUpPersistsIncrementally(t, rawdb.PathScheme) } func testCatchUpPersistsIncrementally(t *testing.T, scheme string) { nodeScheme, sourceAccountTrie, elems, addrs := makeAccountTrieWithAddresses(100, scheme) rootA := sourceAccountTrie.Hash() numA := uint64(100) goodAddr := addrs[0] corruptAddr := addrs[1] type balBlock struct { header *types.Header bal rlp.RawValue } db := rawdb.NewMemoryDatabase() emptyHash := common.Hash{} zero := uint64(0) // Write the header and canonical hash for block A so the reorg-detection // canonical-lookup in Sync passes (otherwise it'd treat A as reorged out // and reset instead of running catchUp). pivotAHeader := &types.Header{ Number: new(big.Int).SetUint64(numA), Root: rootA, Difficulty: common.Big0, BaseFee: common.Big0, WithdrawalsHash: &emptyHash, BlobGasUsed: &zero, ExcessBlobGas: &zero, ParentBeaconRoot: &emptyHash, RequestsHash: &emptyHash, } rawdb.WriteHeader(db, pivotAHeader) rawdb.WriteCanonicalHash(db, pivotAHeader.Hash(), numA) pivotA := pivotAHeader // Build three sequential BAL blocks (A+1, A+2, A+3). The first two touch // goodAddr, the third touches corruptAddr so that block's apply fails // once we've corrupted that account's snapshot. blocks := make([]balBlock, 3) for i := 0; i < 3; i++ { blockNum := numA + uint64(i) + 1 target := goodAddr if i == 2 { target = corruptAddr } balance := uint256.NewInt(uint64(1000 * (i + 1))) cb := bal.NewConstructionBlockAccessList() cb.BalanceChange(0, target, balance) var buf bytes.Buffer if err := cb.EncodeRLP(&buf); err != nil { t.Fatal(err) } var b bal.BlockAccessList if err := rlp.DecodeBytes(buf.Bytes(), &b); err != nil { t.Fatal(err) } balHash := b.Hash() header := &types.Header{ Number: new(big.Int).SetUint64(blockNum), Difficulty: common.Big0, BaseFee: common.Big0, WithdrawalsHash: &emptyHash, BlobGasUsed: &zero, ExcessBlobGas: &zero, ParentBeaconRoot: &emptyHash, RequestsHash: &emptyHash, BlockAccessListHash: &balHash, } rawdb.WriteHeader(db, header) rawdb.WriteCanonicalHash(db, header.Hash(), blockNum) blocks[i] = balBlock{header: header, bal: buf.Bytes()} } // First sync: complete sync to A so persisted state has previousPivot=A, // flat state covers all accounts. { var ( once sync.Once cancel = make(chan struct{}) term = func() { once.Do(func() { close(cancel) }) } ) syncer := newSyncerV2(db, nodeScheme) src := newTestPeerV2("seed", t, term) src.accountTrie = sourceAccountTrie.Copy() src.accountValues = elems syncer.Register(src) src.remote = syncer if err := syncer.Sync(pivotA, cancel); err != nil { t.Fatalf("seed sync failed: %v", err) } } // Corrupt the flat-state snapshot for corruptAddr so applyAccessList will // fail when block A+3's BAL touches it. types.FullAccount rejects this // payload as undecodable. rawdb.WriteAccountSnapshot(db, crypto.Keccak256Hash(corruptAddr[:]), []byte{0xff, 0xff, 0xff, 0xff}) // Second sync: target is A+3. catchUp should apply A+1 and A+2 (good // account), persist after each, then fail on A+3 (corrupt account). pivotB := blocks[2].header balsByHash := map[common.Hash]rlp.RawValue{ blocks[0].header.Hash(): blocks[0].bal, blocks[1].header.Hash(): blocks[1].bal, blocks[2].header.Hash(): blocks[2].bal, } var ( once sync.Once cancel = make(chan struct{}) term = func() { once.Do(func() { close(cancel) }) } ) syncer := newSyncerV2(db, nodeScheme) src := newTestPeerV2("catchup", t, term) src.accountTrie = sourceAccountTrie.Copy() src.accountValues = elems src.accessLists = balsByHash syncer.Register(src) src.remote = syncer if err := syncer.Sync(pivotB, cancel); err == nil { t.Fatal("expected Sync to fail when applyAccessList hits corrupt flat state") } // Persisted previousPivot should now reflect the last successfully applied // block (A+2). Without per-iteration saves, it would still be at A. loader := newSyncerV2(db, nodeScheme) loader.loadSyncStatus() if loader.previousPivot == nil { t.Fatal("expected persisted previousPivot to be set after partial catchUp") } wantHash := blocks[1].header.Hash() if loader.previousPivot.Hash() != wantHash { t.Errorf("persisted previousPivot mismatch after partial catchUp: got %v, want %v (block A+2)", loader.previousPivot.Hash(), wantHash) } } // TestSyncStatusMarkedCompleteAfterCompletion verifies that after a full sync // completes, the persisted sync status has Complete=true. This lets a // subsequent Sync call distinguish "already done" from "fresh node" and skip. func TestSyncStatusMarkedCompleteAfterCompletion(t *testing.T) { t.Parallel() testSyncStatusMarkedCompleteAfterCompletion(t, rawdb.HashScheme) testSyncStatusMarkedCompleteAfterCompletion(t, rawdb.PathScheme) } func testSyncStatusMarkedCompleteAfterCompletion(t *testing.T, scheme string) { var ( once sync.Once cancel = make(chan struct{}) term = func() { once.Do(func() { close(cancel) }) } ) nodeScheme, sourceAccountTrie, elems := makeAccountTrieNoStorage(100, scheme) mkSource := func(name string) *testPeerV2 { source := newTestPeerV2(name, t, term) source.accountTrie = sourceAccountTrie.Copy() source.accountValues = elems return source } syncer := setupSyncerV2(nodeScheme, mkSource("source")) pivot := mkPivot(0, sourceAccountTrie.Hash()) if err := syncer.Sync(pivot, cancel); err != nil { t.Fatalf("sync failed: %v", err) } // After successful sync, persisted status should be present with // Complete=true and the pivot we synced to. loader := newSyncerV2(syncer.db, nodeScheme) loader.loadSyncStatus() if !loader.complete { t.Fatal("expected persisted status to have Complete=true after successful sync") } if loader.previousPivot == nil || loader.previousPivot.Hash() != pivot.Hash() { t.Fatalf("expected persisted pivot to match synced pivot") } } // TestSyncSkipsIfAlreadyComplete verifies that a follow-up Sync call for the // same pivot returns immediately without doing any work, since the persisted // status indicates the sync is already complete. To prove the skip path actually // fires, we deliberately wipe the flat state between the two calls. If it skips, // Sync returns nil without touching flat state. If it doesn't kip, GenerateTrie // would run against an empty snapshot and fail with a root mismatch. func TestSyncSkipsIfAlreadyComplete(t *testing.T) { t.Parallel() nodeScheme, sourceAccountTrie, elems := makeAccountTrieNoStorage(100, rawdb.HashScheme) pivot := mkPivot(0, sourceAccountTrie.Hash()) var ( once1 sync.Once cancel1 = make(chan struct{}) term1 = func() { once1.Do(func() { close(cancel1) }) } ) src1 := newTestPeerV2("source1", t, term1) src1.accountTrie = sourceAccountTrie.Copy() src1.accountValues = elems syncer := setupSyncerV2(nodeScheme, src1) if err := syncer.Sync(pivot, cancel1); err != nil { t.Fatalf("first sync failed: %v", err) } // Wipe the flat state. The persisted status (with Complete=true) stays. if err := syncer.db.DeleteRange(rawdb.SnapshotAccountPrefix, []byte{rawdb.SnapshotAccountPrefix[0] + 1}); err != nil { t.Fatalf("failed to wipe account snapshot: %v", err) } if err := syncer.db.DeleteRange(rawdb.SnapshotStoragePrefix, []byte{rawdb.SnapshotStoragePrefix[0] + 1}); err != nil { t.Fatalf("failed to wipe storage snapshot: %v", err) } // Second sync must take the skip path. If it didn't, the empty flat // state would cause GenerateTrie to fail with a root mismatch. cancel2 := make(chan struct{}) if err := syncer.Sync(pivot, cancel2); err != nil { t.Fatalf("second sync should have skipped, got error: %v", err) } } // TestInterruptedRebuildRecovery verifies that if sync is interrupted after // download completes but before trie rebuild finishes, the next Sync() call // re-runs the download (which completes immediately) and rebuild. func TestInterruptedRebuildRecovery(t *testing.T) { t.Parallel() nodeScheme, sourceAccountTrie, elems := makeAccountTrieNoStorage(100, rawdb.HashScheme) root := sourceAccountTrie.Hash() // First run: complete download, save status, simulate interruption // before rebuild by calling downloadState() directly var ( once1 sync.Once cancel1 = make(chan struct{}) term1 = func() { once1.Do(func() { close(cancel1) }) } ) db := rawdb.NewMemoryDatabase() syncer1 := newSyncerV2(db, nodeScheme) src1 := newTestPeerV2("source1", t, term1) src1.accountTrie = sourceAccountTrie.Copy() src1.accountValues = elems syncer1.Register(src1) src1.remote = syncer1 pivot := mkPivot(0, root) syncer1.pivot = pivot syncer1.previousPivot = pivot // Sync sets this before downloadState syncer1.loadSyncStatus() if err := syncer1.downloadState(cancel1); err != nil { t.Fatalf("download failed: %v", err) } // Save status (simulating what Sync's defer does) for _, task := range syncer1.tasks { syncer1.forwardAccountTask(task) } syncer1.cleanAccountTasks() syncer1.saveSyncStatus() // Status should exist (rebuild hasn't run yet) if rawdb.ReadSnapshotSyncStatus(db) == nil { t.Fatal("sync status should exist after download") } // Second run: full Sync should detect tasks are done, run rebuild var ( once2 sync.Once cancel2 = make(chan struct{}) term2 = func() { once2.Do(func() { close(cancel2) }) } ) syncer2 := newSyncerV2(db, nodeScheme) src2 := newTestPeerV2("source2", t, term2) src2.accountTrie = sourceAccountTrie.Copy() src2.accountValues = elems syncer2.Register(src2) src2.remote = syncer2 if err := syncer2.Sync(mkPivot(0, root), cancel2); err != nil { t.Fatalf("resumed sync failed: %v", err) } // After rebuild completes, status should be marked Complete=true. loader := newSyncerV2(db, nodeScheme) loader.loadSyncStatus() if !loader.complete { t.Fatal("sync status should be marked Complete=true after rebuild completes") } } // TestFetchAccessListsMultiplePeers verifies that fetch distributes work // across multiple idle peers. func TestFetchAccessListsMultiplePeers(t *testing.T) { t.Parallel() var ( once sync.Once cancel = make(chan struct{}) term = func() { once.Do(func() { close(cancel) }) } ) // Create enough BALs to potentially split across peers var hashes []common.Hash bals := make(map[common.Hash]rlp.RawValue) for i := 0; i < 10; i++ { h := common.HexToHash(fmt.Sprintf("0x%02x", i+1)) hashes = append(hashes, h) cb := bal.NewConstructionBlockAccessList() cb.BalanceChange(0, common.HexToAddress("0xaa"), uint256.NewInt(uint64(i))) var buf bytes.Buffer if err := cb.EncodeRLP(&buf); err != nil { t.Fatal(err) } bals[h] = buf.Bytes() } mkSource := func(name string) *testPeerV2 { source := newTestPeerV2(name, t, term) source.accessLists = bals return source } syncer := setupSyncerV2(rawdb.HashScheme, mkSource("peer-a"), mkSource("peer-b"), mkSource("peer-c")) results, err := syncer.fetchAccessLists(hashes, makeAccessListHeaders(bals), cancel) if err != nil { t.Fatalf("fetchAccessLists failed: %v", err) } if len(results) != len(hashes) { t.Fatalf("result count mismatch: got %d, want %d", len(results), len(hashes)) } // Verify results match expected content in request order for i, h := range hashes { if !bytes.Equal(results[i], bals[h]) { t.Errorf("result %d content mismatch for hash %v", i, h) } } } // TestFetchAccessListsPeerTimeout verifies that timed-out requests are retried // with a different peer. func TestFetchAccessListsPeerTimeout(t *testing.T) { t.Parallel() var ( once sync.Once cancel = make(chan struct{}) term = func() { once.Do(func() { close(cancel) }) } ) hashes := []common.Hash{common.HexToHash("0x01")} bals := make(map[common.Hash]rlp.RawValue) cb := bal.NewConstructionBlockAccessList() cb.BalanceChange(0, common.HexToAddress("0xaa"), uint256.NewInt(42)) var buf bytes.Buffer if err := cb.EncodeRLP(&buf); err != nil { t.Fatal(err) } bals[hashes[0]] = buf.Bytes() // First peer never responds nonResponsive := newTestPeerV2("non-responsive", t, term) nonResponsive.accessListRequestHandler = func(t *testPeerV2, id uint64, hashes []common.Hash, max int) error { // Don't respond — let it time out return nil } // Second peer serves correctly good := newTestPeerV2("good", t, term) good.accessLists = bals syncer := setupSyncerV2(rawdb.HashScheme, nonResponsive, good) syncer.rates.OverrideTTLLimit = time.Millisecond // Fast timeout results, err := syncer.fetchAccessLists(hashes, makeAccessListHeaders(bals), cancel) if err != nil { t.Fatalf("fetchAccessLists failed: %v", err) } if len(results) != 1 { t.Fatalf("result count mismatch: got %d, want 1", len(results)) } } // TestFetchAccessListsPeerRejection verifies that peers returning empty // responses are marked stateless and work is retried with another peer. func TestFetchAccessListsPeerRejection(t *testing.T) { t.Parallel() var ( once sync.Once cancel = make(chan struct{}) term = func() { once.Do(func() { close(cancel) }) } ) hashes := []common.Hash{common.HexToHash("0x01")} bals := make(map[common.Hash]rlp.RawValue) cb := bal.NewConstructionBlockAccessList() cb.BalanceChange(0, common.HexToAddress("0xaa"), uint256.NewInt(42)) var buf bytes.Buffer if err := cb.EncodeRLP(&buf); err != nil { t.Fatal(err) } bals[hashes[0]] = buf.Bytes() // First peer rejects (has no BAL data, returns empty) // accessLists is nil, so defaultAccessListRequestHandler returns empty rejector := newTestPeerV2("rejector", t, term) // Second peer serves correctly good := newTestPeerV2("good", t, term) good.accessLists = bals syncer := setupSyncerV2(rawdb.HashScheme, rejector, good) results, err := syncer.fetchAccessLists(hashes, makeAccessListHeaders(bals), cancel) if err != nil { t.Fatalf("fetchAccessLists failed: %v", err) } if len(results) != 1 { t.Fatalf("result count mismatch: got %d, want 1", len(results)) } } // TestFetchAccessListsCancel verifies that fetchAccessLists returns promptly // when cancelled. func TestFetchAccessListsCancel(t *testing.T) { t.Parallel() cancel := make(chan struct{}) // Peer that never responds nonResponsive := newTestPeerV2("non-responsive", t, func() {}) nonResponsive.accessListRequestHandler = func(t *testPeerV2, id uint64, hashes []common.Hash, max int) error { return nil // never deliver } syncer := setupSyncerV2(rawdb.HashScheme, nonResponsive) hashes := []common.Hash{common.HexToHash("0x01")} // Cancel after a short delay go func() { time.Sleep(50 * time.Millisecond) close(cancel) }() _, err := syncer.fetchAccessLists(hashes, nil, cancel) if err != ErrCancelled { t.Fatalf("expected ErrCancelled, got %v", err) } } // TestFetchAccessListsPeerDrop verifies that dropping a peer mid-request // causes the request to be retried with a different peer. func TestFetchAccessListsPeerDrop(t *testing.T) { t.Parallel() var ( once sync.Once cancel = make(chan struct{}) term = func() { once.Do(func() { close(cancel) }) } ) hashes := []common.Hash{common.HexToHash("0x01")} bals := make(map[common.Hash]rlp.RawValue) cb := bal.NewConstructionBlockAccessList() cb.BalanceChange(0, common.HexToAddress("0xaa"), uint256.NewInt(42)) var buf bytes.Buffer if err := cb.EncodeRLP(&buf); err != nil { t.Fatal(err) } bals[hashes[0]] = buf.Bytes() // First peer will be dropped mid-request dropped := newTestPeerV2("dropped", t, term) dropped.accessListRequestHandler = func(tp *testPeerV2, id uint64, hashes []common.Hash, max int) error { // Simulate peer dropping by unregistering tp.remote.Unregister(tp.id) return nil } // Second peer serves correctly good := newTestPeerV2("good", t, term) good.accessLists = bals syncer := setupSyncerV2(rawdb.HashScheme, dropped, good) results, err := syncer.fetchAccessLists(hashes, makeAccessListHeaders(bals), cancel) if err != nil { t.Fatalf("fetchAccessLists failed: %v", err) } if len(results) != 1 { t.Fatalf("result count mismatch: got %d, want 1", len(results)) } } // TestFetchAccessListsShortResponse verifies that when a peer returns fewer // BALs than requested (a short/partial response), the un-served hashes are // retried and eventually all results are collected. func TestFetchAccessListsShortResponse(t *testing.T) { t.Parallel() var ( once sync.Once cancel = make(chan struct{}) term = func() { once.Do(func() { close(cancel) }) } ) // Request 4 hashes but the peer only returns the first 2. hashes := []common.Hash{ common.HexToHash("0x01"), common.HexToHash("0x02"), common.HexToHash("0x03"), common.HexToHash("0x04"), } allBALs := make(map[common.Hash]rlp.RawValue) for _, h := range hashes { cb := bal.NewConstructionBlockAccessList() cb.BalanceChange(0, common.HexToAddress("0xaa"), uint256.NewInt(uint64(h[31]))) var buf bytes.Buffer if err := cb.EncodeRLP(&buf); err != nil { t.Fatal(err) } allBALs[h] = buf.Bytes() } // shortPeer returns only the first 2 BALs regardless of how many are // requested. This simulates a peer that truncates its response (e.g., // hitting the 2 MiB response soft limit). shortPeer := newTestPeerV2("short", t, term) shortPeer.accessListRequestHandler = func(tp *testPeerV2, id uint64, reqHashes []common.Hash, max int) error { // Return only the first 2 of however many were requested. limit := 2 if len(reqHashes) < limit { limit = len(reqHashes) } var results []rlp.RawValue for i := 0; i < limit; i++ { results = append(results, allBALs[reqHashes[i]]) } rawList, _ := rlp.EncodeToRawList(results) if err := tp.remote.OnAccessLists(tp, id, rawList); err != nil { tp.test.Errorf("delivery rejected: %v", err) tp.term() } return nil } syncer := setupSyncerV2(rawdb.HashScheme, shortPeer) // Pre-seed the rate tracker so the peer's capacity for AccessListsMsg is // high enough to get all 4 hashes assigned in a single request. Without // this, the default capacity is 1, so the peer would only get 1 hash per // round and the short-response scenario never triggers. syncer.rates.Update(shortPeer.id, AccessListsMsg, time.Millisecond, 100) // If the bug exists, this will hang. done := make(chan struct{}) var ( results []rlp.RawValue fetchErr error ) go func() { results, fetchErr = syncer.fetchAccessLists(hashes, makeAccessListHeaders(allBALs), cancel) close(done) }() select { case <-done: // fetchAccessLists returned case <-time.After(5 * time.Second): t.Fatal("fetchAccessLists has hung. This means unserved hashes were never re-added to pending.") } if fetchErr != nil { t.Fatalf("fetchAccessLists failed: %v", fetchErr) } if len(results) != len(hashes) { t.Fatalf("result count mismatch: got %d, want %d", len(results), len(hashes)) } // Verify all results are non-nil and in correct order for i, h := range hashes { if results[i] == nil { t.Errorf("result %d (hash %v) is nil", i, h) } } } // TestFetchAccessListsEmptyPlaceholder verifies that when a peer returns // rlp.EmptyString placeholders for BALs it doesn't have, those placeholders // are not silently accepted as valid results. func TestFetchAccessListsEmptyPlaceholder(t *testing.T) { t.Parallel() var ( once sync.Once cancel = make(chan struct{}) term = func() { once.Do(func() { close(cancel) }) } ) hashes := []common.Hash{ common.HexToHash("0x01"), common.HexToHash("0x02"), common.HexToHash("0x03"), } // Build BALs for all 3 hashes allBALs := make(map[common.Hash]rlp.RawValue) for _, h := range hashes { cb := bal.NewConstructionBlockAccessList() cb.BalanceChange(0, common.HexToAddress("0xaa"), uint256.NewInt(uint64(h[31]))) var buf bytes.Buffer if err := cb.EncodeRLP(&buf); err != nil { t.Fatal(err) } allBALs[h] = buf.Bytes() } // partialPeer has BALs for hashes 0 and 2. The server // handler returns rlp.EmptyString for the missing BAL. partialPeer := newTestPeerV2("partial", t, term) partialPeer.accessListRequestHandler = func(tp *testPeerV2, id uint64, reqHashes []common.Hash, max int) error { var results []rlp.RawValue for _, h := range reqHashes { if raw, ok := allBALs[h]; ok && h != hashes[1] { results = append(results, raw) } else { results = append(results, rlp.EmptyString) } } rawList, _ := rlp.EncodeToRawList(results) if err := tp.remote.OnAccessLists(tp, id, rawList); err != nil { tp.test.Errorf("delivery rejected: %v", err) tp.term() } return nil } // fullPeer has all BALs fullPeer := newTestPeerV2("full", t, term) fullPeer.accessLists = allBALs syncer := setupSyncerV2(rawdb.HashScheme, partialPeer, fullPeer) // Pre-seed capacity so partialPeer gets all 3 hashes syncer.rates.Update(partialPeer.id, AccessListsMsg, time.Millisecond, 100) done := make(chan struct{}) var ( results []rlp.RawValue fetchErr error ) go func() { results, fetchErr = syncer.fetchAccessLists(hashes, makeAccessListHeaders(allBALs), cancel) close(done) }() // Wait for fetch to complete select { case <-done: case <-time.After(5 * time.Second): t.Fatal("fetchAccessLists hung") } if fetchErr != nil { t.Fatalf("fetchAccessLists failed: %v", fetchErr) } // Verify the results are valid. for i, raw := range results { var accessList bal.BlockAccessList if err := rlp.DecodeBytes(raw, &accessList); err != nil { t.Errorf("result %d (hash %v) is not a valid BAL: %v (got raw bytes %x)", i, hashes[i], err, raw) } } } // TestFetchAccessListsRejectsBadBAL verifies that when a peer delivers a BAL // whose hash doesn't match the canonical block header, fetchAccessLists marks // the peer stateless, drops the response, and surfaces the exhaustion error // once no other peers can serve the work. func TestFetchAccessListsRejectsBadBAL(t *testing.T) { t.Parallel() var ( once sync.Once cancel = make(chan struct{}) term = func() { once.Do(func() { close(cancel) }) } ) hash := common.HexToHash("0x01") hashes := []common.Hash{hash} // Build a BAL we'll actually serve. cb := bal.NewConstructionBlockAccessList() cb.BalanceChange(0, common.HexToAddress("0xaa"), uint256.NewInt(42)) var buf bytes.Buffer if err := cb.EncodeRLP(&buf); err != nil { t.Fatal(err) } served := buf.Bytes() // Build a header whose BlockAccessListHash points at something else, so // the served BAL fails verification. mismatch := common.HexToHash("0xdeadbeef") headers := map[common.Hash]*types.Header{ hash: {BlockAccessListHash: &mismatch}, } peer := newTestPeerV2("liar", t, term) peer.accessLists = map[common.Hash]rlp.RawValue{hash: served} syncer := setupSyncerV2(rawdb.HashScheme, peer) results, err := syncer.fetchAccessLists(hashes, headers, cancel) if !errors.Is(err, errAccessListPeersExhausted) { t.Fatalf("expected errAccessListPeersExhausted, got %v", err) } if results != nil { t.Errorf("expected nil results on error, got %v", results) } syncer.lock.RLock() _, stateless := syncer.statelessPeers[peer.id] syncer.lock.RUnlock() if !stateless { t.Error("expected liar peer to be marked stateless after bad BAL") } } // TestCatchUpRetriesOnBadBAL verifies that when one peer serves a BAL that // fails verification but another serves a valid one, fetchAccessLists routes // the work around the bad peer and returns the verified BAL. func TestCatchUpRetriesOnBadBAL(t *testing.T) { t.Parallel() var ( once sync.Once cancel = make(chan struct{}) term = func() { once.Do(func() { close(cancel) }) } ) hash := common.HexToHash("0x01") hashes := []common.Hash{hash} cb := bal.NewConstructionBlockAccessList() cb.BalanceChange(0, common.HexToAddress("0xaa"), uint256.NewInt(42)) var buf bytes.Buffer if err := cb.EncodeRLP(&buf); err != nil { t.Fatal(err) } good := buf.Bytes() // A second BAL with different content used as the "bad" payload. It // decodes cleanly but its hash will not match the header. other := bal.NewConstructionBlockAccessList() other.BalanceChange(0, common.HexToAddress("0xbb"), uint256.NewInt(99)) var otherBuf bytes.Buffer if err := other.EncodeRLP(&otherBuf); err != nil { t.Fatal(err) } bad := otherBuf.Bytes() headers := makeAccessListHeaders(map[common.Hash]rlp.RawValue{hash: good}) liar := newTestPeerV2("liar", t, term) liar.accessLists = map[common.Hash]rlp.RawValue{hash: bad} honest := newTestPeerV2("honest", t, term) honest.accessLists = map[common.Hash]rlp.RawValue{hash: good} syncer := setupSyncerV2(rawdb.HashScheme, liar, honest) // Bias the capacity sort so the liar is asked first, exercising the // reject-and-retry path rather than getting lucky on assignment order. syncer.rates.Update(liar.id, AccessListsMsg, time.Millisecond, 1000) results, err := syncer.fetchAccessLists(hashes, headers, cancel) if err != nil { t.Fatalf("fetchAccessLists failed: %v", err) } if !bytes.Equal(results[0], good) { t.Errorf("expected the honest BAL, got %x", results[0]) } syncer.lock.RLock() _, liarStateless := syncer.statelessPeers[liar.id] _, honestStateless := syncer.statelessPeers[honest.id] syncer.lock.RUnlock() if !liarStateless { t.Error("expected liar to be marked stateless") } if honestStateless { t.Error("expected honest peer to remain in good standing") } } // makeStorageTrieFromSlots builds a storage trie for owner from raw slot // key->value pairs, using the exact on-disk encoding the flat snapshot and the // trie rebuild expect: each leaf is keyed by keccak256(slotKey) and its value is // rlp(TrimLeftZeroes(value)). Zero-valued slots are skipped (an unset slot has // no leaf). It returns the storage root, the dirty node set, and the sorted // snapshot leaves (which a test peer serves verbatim). func makeStorageTrieFromSlots(db *triedb.Database, owner common.Hash, slots map[common.Hash]common.Hash) (common.Hash, *trienode.NodeSet, []*kv) { st, _ := trie.New(trie.StorageTrieID(types.EmptyRootHash, owner, types.EmptyRootHash), db) var entries []*kv for rawKey, value := range slots { if value == (common.Hash{}) { continue // unset slot: no leaf } slotHash := crypto.Keccak256Hash(rawKey[:]) enc, _ := rlp.EncodeToBytes(common.TrimLeftZeroes(value[:])) st.MustUpdate(slotHash[:], enc) entries = append(entries, &kv{slotHash[:], enc}) } slices.SortFunc(entries, (*kv).cmp) root, nodes := st.Commit(false) return root, nodes, entries } // makeStateWithStorageContract builds an account trie holding the given // storage-less accounts plus a single contract account whose storage trie is // built from slots. Everything is committed into a fresh triedb so the tries // can be served by a test peer. It returns the recreated account trie, the // sorted account leaves, the recreated contract storage trie, the sorted // storage leaves, and the resulting state root. func makeStateWithStorageContract(scheme string, plain []*kv, contractAddr common.Address, contract types.StateAccount, slots map[common.Hash]common.Hash) (*trie.Trie, []*kv, *trie.Trie, []*kv, common.Hash) { db := triedb.NewDatabase(rawdb.NewMemoryDatabase(), newDbConfig(scheme)) accTrie := trie.NewEmpty(db) merged := trienode.NewMergedNodeSet() // Contract storage trie. contractHash := crypto.Keccak256Hash(contractAddr[:]) stRoot, stNodes, stEntries := makeStorageTrieFromSlots(db, contractHash, slots) if stNodes != nil { merged.Merge(stNodes) } // Contract account leaf carries the (live) storage root. contract.Root = stRoot cval, _ := rlp.EncodeToBytes(&contract) accTrie.MustUpdate(contractHash[:], cval) accEntries := []*kv{{contractHash[:], cval}} // Storage-less filler accounts. for _, e := range plain { accTrie.MustUpdate(e.k, e.v) accEntries = append(accEntries, &kv{e.k, e.v}) } slices.SortFunc(accEntries, (*kv).cmp) // Commit account + storage nodes together, then re-open for serving. root, set := accTrie.Commit(true) merged.Merge(set) db.Update(root, types.EmptyRootHash, 0, merged, triedb.NewStateSet()) accTrie, _ = trie.New(trie.StateTrieID(root), db) stTrie, _ := trie.New(trie.StorageTrieID(root, contractHash, stRoot), db) return accTrie, accEntries, stTrie, stEntries, root } // TestCatchUpAppliesStorageBALs exercises the snap/2 catch-up path with a BAL // that mutates storage slots (not just balances): a non-zero write to a fresh // slot, an overwrite of an existing slot, a write of zero (deletion), and a // multi-tx write where the post-block value wins. // // It fully syncs pivot A (flat-state download + trie rebuild), then moves the // pivot to A+1. The move triggers catchUp, which fetches the A+1 BAL, applies // the storage diffs to the flat state, and rebuilds the trie. The rebuild // verifies the recomputed root against the pivot's expected post-catch-up root, // so a successful Sync proves the storage mutations were applied in the exact // encoding the trie rebuild consumes. verifyTrie re-walks the result as an // independent confirmation. func TestCatchUpAppliesStorageBALs(t *testing.T) { t.Parallel() testCatchUpAppliesStorageBALs(t, rawdb.HashScheme) testCatchUpAppliesStorageBALs(t, rawdb.PathScheme) } func testCatchUpAppliesStorageBALs(t *testing.T, scheme string) { // The contract whose storage the A+1 BAL mutates. contractAddr := common.HexToAddress("0x00000000000000000000000000000000c0ffee01") contractHash := crypto.Keccak256Hash(contractAddr[:]) // Raw storage slot keys. var ( slotKeep = common.HexToHash("0x01") // untouched by the BAL slotOver = common.HexToHash("0x02") // overwritten with a new non-zero value slotZero = common.HexToHash("0x03") // written to zero (deletion) slotNew = common.HexToHash("0x04") // unset in A, written non-zero in A+1 slotMultiTx = common.HexToHash("0x05") // written several times within the block ) // Slot values. Multi-byte values force RLP length prefixes, so the encoding // differs sharply from the raw 32-byte form and a format mismatch surfaces. var ( vKeep = common.HexToHash("0x1111") vOver0 = common.HexToHash("0x2222") vOver1 = common.HexToHash("0x22220000aaaa") vZero0 = common.HexToHash("0x3333") vNew = common.HexToHash("0x4444") vMulti0 = common.HexToHash("0x5555") vMultiMid = common.HexToHash("0x5556") vMultiFinal = common.HexToHash("0x55570000bbbb") ) // Storage at pivot A. slotsA := map[common.Hash]common.Hash{ slotKeep: vKeep, slotOver: vOver0, slotZero: vZero0, slotMultiTx: vMulti0, } // Expected storage at pivot A+1 after applying the BAL writes below. slotsB := map[common.Hash]common.Hash{ slotKeep: vKeep, // unchanged slotOver: vOver1, // overwritten slotNew: vNew, // newly written slotMultiTx: vMultiFinal, // post-block (highest-tx) value wins // slotZero deleted } contractTmpl := types.StateAccount{ Nonce: 7, Balance: uint256.NewInt(123456), CodeHash: types.EmptyCodeHash[:], } // Storage-less filler accounts, identical in A and A+1. _, _, plain, _ := makeAccountTrieWithAddresses(20, scheme) // Build the state at pivot A (served by the seed peer) and the expected // state at pivot A+1 (only its root is needed). accTrieA, accElemsA, stTrieA, stElemsA, rootA := makeStateWithStorageContract(scheme, plain, contractAddr, contractTmpl, slotsA) _, _, _, _, rootB := makeStateWithStorageContract(scheme, plain, contractAddr, contractTmpl, slotsB) if rootA == rootB { t.Fatal("test bug: pivot A and A+1 must have different state roots") } // Build the A+1 BAL describing the storage mutations. cb := bal.NewConstructionBlockAccessList() cb.StorageWrite(0, contractAddr, slotOver, vOver1) // overwrite cb.StorageWrite(0, contractAddr, slotZero, common.Hash{}) // write zero -> delete cb.StorageWrite(0, contractAddr, slotNew, vNew) // new non-zero cb.StorageWrite(0, contractAddr, slotMultiTx, vMultiMid) // tx 0 cb.StorageWrite(2, contractAddr, slotMultiTx, vMultiFinal) // tx 2 (post-block) var balBuf bytes.Buffer if err := cb.EncodeRLP(&balBuf); err != nil { t.Fatal(err) } var decodedBAL bal.BlockAccessList if err := rlp.DecodeBytes(balBuf.Bytes(), &decodedBAL); err != nil { t.Fatal(err) } balHash := decodedBAL.Hash() // Chain headers. The pivot-A header is the same object passed to the first // Sync, so the follow-up Sync's reorg check sees A as still-canonical and // runs catchUp instead of resetting. The A+1 header carries the BAL hash // (verified during catch-up) and the expected post-catch-up state root // (verified by the trie rebuild). db := rawdb.NewMemoryDatabase() numA := uint64(128) emptyH := common.Hash{} zero := uint64(0) hdrA := &types.Header{ Number: new(big.Int).SetUint64(numA), Root: rootA, Difficulty: common.Big0, BaseFee: common.Big0, WithdrawalsHash: &emptyH, BlobGasUsed: &zero, ExcessBlobGas: &zero, ParentBeaconRoot: &emptyH, RequestsHash: &emptyH, } rawdb.WriteHeader(db, hdrA) rawdb.WriteCanonicalHash(db, hdrA.Hash(), numA) hdrB := &types.Header{ Number: new(big.Int).SetUint64(numA + 1), Root: rootB, Difficulty: common.Big0, BaseFee: common.Big0, WithdrawalsHash: &emptyH, BlobGasUsed: &zero, ExcessBlobGas: &zero, ParentBeaconRoot: &emptyH, RequestsHash: &emptyH, BlockAccessListHash: &balHash, } rawdb.WriteHeader(db, hdrB) rawdb.WriteCanonicalHash(db, hdrB.Hash(), numA+1) // Sync 1: full flat-state download + trie rebuild against pivot A. { var ( once sync.Once cancel = make(chan struct{}) term = func() { once.Do(func() { close(cancel) }) } ) syncer := newSyncerV2(db, scheme) src := newTestPeerV2("seed", t, term) src.accountTrie = accTrieA.Copy() src.accountValues = accElemsA src.setStorageTries(map[common.Hash]*trie.Trie{contractHash: stTrieA}) src.storageValues = map[common.Hash][]*kv{contractHash: stElemsA} syncer.Register(src) src.remote = syncer done := checkStall(t, term) if err := syncer.Sync(hdrA, cancel); err != nil { t.Fatalf("pivot A sync failed: %v", err) } close(done) } // Sanity: the rebuilt trie for pivot A is complete and matches rootA. This // also confirms the test fixture itself is internally consistent. verifyTrie(scheme, db, rootA, t) // Sync 2: the pivot moves to A+1, exercising the BAL catch-up path. { var ( once sync.Once cancel = make(chan struct{}) term = func() { once.Do(func() { close(cancel) }) } ) syncer := newSyncerV2(db, scheme) src := newTestPeerV2("catchup", t, term) // Pivot A is fully synced, so no download tasks remain; the peer only // needs to serve the A+1 BAL. The trie data is provided defensively in // case a stray account request is issued. src.accountTrie = accTrieA.Copy() src.accountValues = accElemsA src.accessLists = map[common.Hash]rlp.RawValue{hdrB.Hash(): balBuf.Bytes()} syncer.Register(src) src.remote = syncer done := checkStall(t, term) if err := syncer.Sync(hdrB, cancel); err != nil { t.Fatalf("pivot A+1 catch-up sync failed: %v", err) } close(done) } // A successful Sync already means GenerateTrie reproduced rootB from the // BAL-updated flat state (it errors on root mismatch). Re-walk the trie as // an independent confirmation that rootB is fully materialized. verifyTrie(scheme, db, rootB, t) // Spot-check each storage mutation landed in the flat snapshot in the // canonical encoding. checkSlot := func(raw common.Hash, want common.Hash, present bool) { t.Helper() got := rawdb.ReadStorageSnapshot(db, contractHash, crypto.Keccak256Hash(raw[:])) if !present { if len(got) != 0 { t.Errorf("slot %x: expected deletion, got %x", raw, got) } return } wantEnc, _ := rlp.EncodeToBytes(common.TrimLeftZeroes(want[:])) if !bytes.Equal(got, wantEnc) { t.Errorf("slot %x: got %x, want %x", raw, got, wantEnc) } } checkSlot(slotKeep, vKeep, true) checkSlot(slotOver, vOver1, true) checkSlot(slotZero, common.Hash{}, false) checkSlot(slotNew, vNew, true) checkSlot(slotMultiTx, vMultiFinal, true) }