This commit is contained in:
Jonny Rhea 2026-05-21 21:54:34 -07:00 committed by GitHub
commit 4a887f5854
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 3323 additions and 1623 deletions

View file

@ -84,6 +84,19 @@ func (s *Suite) dialSnap() (*Conn, error) {
return conn, nil return conn, nil
} }
// dialSnap2 creates a connection advertising snap/2 as the only snap capability.
// This is used by the snap/2 (EIP-8189) test suite to force the peer to
// negotiate snap/2 rather than falling back to snap/1.
func (s *Suite) dialSnap2() (*Conn, error) {
conn, err := s.dial()
if err != nil {
return nil, fmt.Errorf("dial failed: %v", err)
}
conn.caps = append(conn.caps, p2p.Cap{Name: "snap", Version: 2})
conn.ourHighestSnapProtoVersion = 2
return conn, nil
}
// Conn represents an individual connection with a peer // Conn represents an individual connection with a peer
type Conn struct { type Conn struct {
*rlpx.Conn *rlpx.Conn
@ -183,7 +196,10 @@ func (c *Conn) ReadEth() (any, error) {
} }
} }
// ReadSnap reads a snap/1 response with the given id from the connection. // ReadSnap reads a snap protocol response from the connection. It decodes
// the full message catalog of both snap/1 and snap/2. The caller is
// expected to only receive codes that were actually valid on the
// negotiated protocol version.
func (c *Conn) ReadSnap() (any, error) { func (c *Conn) ReadSnap() (any, error) {
c.SetReadDeadline(time.Now().Add(timeout)) c.SetReadDeadline(time.Now().Add(timeout))
for { for {
@ -215,6 +231,10 @@ func (c *Conn) ReadSnap() (any, error) {
msg = new(snap.GetTrieNodesPacket) msg = new(snap.GetTrieNodesPacket)
case snap.TrieNodesMsg: case snap.TrieNodesMsg:
msg = new(snap.TrieNodesPacket) msg = new(snap.TrieNodesPacket)
case snap.GetAccessListsMsg:
msg = new(snap.GetAccessListsPacket)
case snap.AccessListsMsg:
msg = new(snap.AccessListsPacket)
default: default:
panic(fmt.Errorf("unhandled snap code: %d", code)) panic(fmt.Errorf("unhandled snap code: %d", code))
} }

View file

@ -33,7 +33,11 @@ const (
const ( const (
baseProtoLen = 16 baseProtoLen = 16
ethProtoLen = 18 ethProtoLen = 18
snapProtoLen = 8 // snapProtoLen accommodates snap/2 (EIP-8189) which extends snap/1 with two
// additional message codes (GetBlockAccessLists=0x08, BlockAccessLists=0x09).
// Using 10 is safe for snap/1 connections because the extra codes are simply
// never used on that protocol version.
snapProtoLen = 10
) )
// Unexported handshake structure from p2p/peer.go. // Unexported handshake structure from p2p/peer.go.

View file

@ -0,0 +1,399 @@
// Copyright 2026 The go-ethereum Authors
// This file is part of go-ethereum.
//
// go-ethereum is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// go-ethereum 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 General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with go-ethereum. If not, see <http://www.gnu.org/licenses/>.
package ethtest
import (
"bytes"
"fmt"
"math/rand"
"github.com/ethereum/go-ethereum/common"
"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/eth/protocols/snap"
"github.com/ethereum/go-ethereum/internal/utesting"
"github.com/ethereum/go-ethereum/rlp"
)
// Snap/2 (EIP-8189) replaces trie node healing with BAL-based state catch-up.
// It keeps 0x00..0x05 (AccountRange/StorageRanges/ByteCodes) unchanged, removes
// GetTrieNodes (0x06) / TrieNodes (0x07), and adds GetBlockAccessLists (0x08) /
// BlockAccessLists (0x09).
//
// The tests in this file focus on the wire behavior that is new or changed in
// snap/2. Tests for the unchanged messages are already covered by the snap/1
// suite in snap.go; the harness reuses the same code paths because those
// message formats are identical across versions.
// TestSnap2Status performs an RLPx+eth+snap/2 handshake against the node,
// verifying that the node advertises and negotiates snap/2.
func (s *Suite) TestSnap2Status(t *utesting.T) {
t.Log(`This test performs a snap/2 (EIP-8189) handshake. The peer is expected to
advertise snap/2 as a p2p capability and accept the connection.`)
conn, err := s.dialSnap2()
if err != nil {
t.Fatalf("dial failed: %v", err)
}
defer conn.Close()
if err := conn.peer(s.chain, nil); err != nil {
t.Fatalf("peering failed: %v", err)
}
if conn.negotiatedSnapProtoVersion != 2 {
t.Fatalf("unexpected negotiated snap version: got %d, want 2", conn.negotiatedSnapProtoVersion)
}
}
type accessListsTest struct {
nBytes uint64
hashes []common.Hash
// minEntries/maxEntries bound the number of entries the response list
// MUST contain. Per EIP-8189 the server may truncate from the tail when
// the byte soft limit is reached, but MUST preserve request order.
minEntries int
maxEntries int
// expectAllEmpty is set for requests where every entry in the response is
// expected to be the RLP empty string (e.g. random/unknown hashes, or
// pre-Amsterdam blocks for which BALs are not available).
expectAllEmpty bool
// mustBeEmptyAt lists positions (zero-based indices into hashes) that MUST
// be returned as the RLP empty string. Used to assert that unknown hashes
// in mixed requests do not receive fabricated BAL data.
mustBeEmptyAt []int
desc string
}
// TestSnap2GetBlockAccessLists exercises various forms of GetBlockAccessLists
// requests defined in EIP-8189. Per the spec:
//
// - Nodes MUST always respond.
// - Unavailable BALs are returned as the RLP empty string (0x80) at the
// matching position.
// - The server MAY return fewer entries than requested (respecting the byte
// soft limit or QoS limits), truncating from the tail.
// - Returned entries MUST preserve request order.
// - When a BAL is returned, its keccak256(rlp.encode(bal)) MUST match the
// block-access-list-hash field of the corresponding block header.
func (s *Suite) TestSnap2GetBlockAccessLists(t *utesting.T) {
var (
head = s.chain.Head()
headHash = head.Hash()
preHash = s.chain.blocks[s.chain.Len()-2].Hash()
unknown = common.HexToHash("0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
)
// Collect a window of recent canonical block hashes. Limit to at most 16
// entries to keep the request small and well under any reasonable limit.
var recent []common.Hash
start := s.chain.Len() - 16
if start < 1 {
start = 1
}
for i := start; i < s.chain.Len(); i++ {
recent = append(recent, s.chain.blocks[i].Hash())
}
tests := []accessListsTest{
{
desc: `An empty request. The server must respond with an empty list and must
not disconnect.`,
nBytes: softResponseLimitSnap,
hashes: nil,
minEntries: 0,
maxEntries: 0,
expectAllEmpty: true,
},
{
desc: `A request for a single random/unknown block hash. Per the spec the
server must respond and include an RLP empty string (0x80) at that position.`,
nBytes: softResponseLimitSnap,
hashes: []common.Hash{unknown},
minEntries: 1,
maxEntries: 1,
expectAllEmpty: true,
},
{
desc: `A request for multiple random/unknown block hashes. The server must
preserve request order and return an RLP empty string for each position.`,
nBytes: softResponseLimitSnap,
hashes: []common.Hash{
unknown,
common.HexToHash("0x0000000000000000000000000000000000000000000000000000000000000001"),
common.HexToHash("0x0000000000000000000000000000000000000000000000000000000000000002"),
},
minEntries: 3,
maxEntries: 3,
expectAllEmpty: true,
},
{
desc: `A request for the chain head. The server must respond. If the node is
post-Amsterdam and has the BAL for this block, the returned BAL must hash to
the block-access-list-hash in the header. Otherwise an empty entry is valid.`,
nBytes: softResponseLimitSnap,
hashes: []common.Hash{headHash},
minEntries: 1,
maxEntries: 1,
},
{
desc: `A request for the chain head and its parent. The server must return
exactly two entries, in request order.`,
nBytes: softResponseLimitSnap,
hashes: []common.Hash{headHash, preHash},
minEntries: 2,
maxEntries: 2,
},
{
desc: `A mixed request with known and unknown hashes. The server must
return entries in request order, with the RLP empty string at positions
corresponding to unknown hashes.`,
nBytes: softResponseLimitSnap,
hashes: []common.Hash{headHash, unknown, preHash, unknown},
// We expect exactly 4 entries — mixed responses are small and well
// under the byte limit, so truncation is not expected.
minEntries: 4,
maxEntries: 4,
// Positions 1 and 3 are unknown hashes and MUST be empty.
mustBeEmptyAt: []int{1, 3},
},
{
desc: `A request spanning the most recent canonical window. Implementations
may serve or drop individual entries, but the entries that are returned must
preserve request order.`,
nBytes: softResponseLimitSnap,
hashes: recent,
minEntries: 0,
maxEntries: len(recent),
},
{
desc: `A request with a very small byte soft limit. The server must return
at least zero entries and no more than the requested number, truncating from
the tail. It must not disconnect.`,
nBytes: 1,
hashes: recent,
minEntries: 0,
maxEntries: len(recent),
},
{
desc: `A request with a zero byte soft limit. The server must still respond
(possibly with an empty list) and must not disconnect.`,
nBytes: 0,
hashes: recent,
minEntries: 0,
maxEntries: len(recent),
},
{
desc: `A request containing the same hash repeated. The server must treat
each position independently and preserve request order.`,
nBytes: softResponseLimitSnap,
hashes: []common.Hash{headHash, headHash, headHash},
minEntries: 3,
maxEntries: 3,
},
}
for i, tc := range tests {
if i > 0 {
t.Log("\n")
}
t.Logf("-- Test %d", i)
t.Log(tc.desc)
t.Log(" request:")
t.Logf(" hashes: %d", len(tc.hashes))
t.Logf(" responseBytes: %d", tc.nBytes)
if err := s.snapGetAccessLists(t, &tc); err != nil {
t.Errorf("test %d failed: %v", i, err)
}
}
}
// TestSnap2TrieNodesRemoved verifies that snap/2 no longer serves the
// GetTrieNodes message (0x06). Per EIP-8189, snap/2 removes GetTrieNodes and
// TrieNodes entirely. A server that negotiated snap/2 must not treat these
// codes as valid snap messages and should disconnect the peer that sends them.
func (s *Suite) TestSnap2TrieNodesRemoved(t *utesting.T) {
t.Log(`This test verifies that sending a GetTrieNodes message over a snap/2
connection causes the peer to reject the request. Per EIP-8189, GetTrieNodes
is removed in snap/2.`)
conn, err := s.dialSnap2()
if err != nil {
t.Fatalf("dial failed: %v", err)
}
defer conn.Close()
if err := conn.peer(s.chain, nil); err != nil {
t.Fatalf("peering failed: %v", err)
}
// Build a syntactically valid GetTrieNodes request to the head state root.
paths, err := rlp.EncodeToRawList([]snap.TrieNodePathSet{{[]byte{0}}})
if err != nil {
t.Fatalf("failed to encode paths: %v", err)
}
req := &snap.GetTrieNodesPacket{
ID: uint64(rand.Int63()),
Root: s.chain.Head().Root(),
Paths: paths,
Bytes: 5000,
}
if err := conn.Write(snapProto, snap.GetTrieNodesMsg, req); err != nil {
t.Fatalf("failed to write GetTrieNodes: %v", err)
}
// We expect either a disconnect or a read error/timeout. We must NOT
// receive a valid TrieNodes response. Loop a few times to consume any
// incidental messages the peer might send (e.g. block updates) before
// deciding.
for i := 0; i < 5; i++ {
msg, err := conn.ReadSnap()
if err != nil {
// Disconnect or read error — the peer rejected the request.
return
}
if _, ok := msg.(*snap.TrieNodesPacket); ok {
t.Fatal("peer responded with TrieNodes over snap/2; GetTrieNodes must be unsupported")
}
}
t.Fatal("peer did not reject GetTrieNodes over snap/2 within the observation window")
}
// softResponseLimitSnap mirrors the recommended 2 MiB soft limit for
// BlockAccessLists responses from EIP-8189 §"Response Size Limit".
const softResponseLimitSnap = 2 * 1024 * 1024
// snapGetAccessLists sends a GetBlockAccessLists request, validates the
// response structure against EIP-8189, and verifies BAL content against the
// block-access-list-hash field of the corresponding block header (when the
// block is known and a BAL was returned).
func (s *Suite) snapGetAccessLists(t *utesting.T, tc *accessListsTest) error {
conn, err := s.dialSnap2()
if err != nil {
return fmt.Errorf("dial failed: %v", err)
}
defer conn.Close()
if err = conn.peer(s.chain, nil); err != nil {
return fmt.Errorf("peering failed: %v", err)
}
req := &snap.GetAccessListsPacket{
ID: uint64(rand.Int63()),
Hashes: tc.hashes,
Bytes: tc.nBytes,
}
msg, err := conn.snapRequest(snap.GetAccessListsMsg, req)
if err != nil {
return fmt.Errorf("access list request failed: %v", err)
}
res, ok := msg.(*snap.AccessListsPacket)
if !ok {
return fmt.Errorf("unexpected response type: %T", msg)
}
if res.ID != req.ID {
return fmt.Errorf("request id mismatch: got %d, want %d", res.ID, req.ID)
}
// Check list length bounds.
got := res.AccessLists.Len()
if got < tc.minEntries || got > tc.maxEntries {
return fmt.Errorf("response has %d entries, want between %d and %d", got, tc.minEntries, tc.maxEntries)
}
// Build a map of request-index -> block so we can verify BAL hashes.
blocks := make(map[int]*types.Block)
for i, h := range tc.hashes {
for _, b := range s.chain.blocks {
if b.Hash() == h {
blocks[i] = b
break
}
}
}
// Iterate the response, validating each entry positionally.
var (
idx int
it = res.AccessLists.ContentIterator()
)
// Build a set of positions that MUST be empty (for per-position checks).
mustEmpty := make(map[int]struct{}, len(tc.mustBeEmptyAt))
for _, p := range tc.mustBeEmptyAt {
mustEmpty[p] = struct{}{}
}
head := s.chain.Head().Header()
rules := s.chain.config.Rules(head.Number, true, head.Time)
for it.Next() {
raw := it.Value()
// Empty entry: per spec, indicates BAL is unavailable for that block.
if bytes.Equal(raw, rlp.EmptyString) {
if !tc.expectAllEmpty && blocks[idx] != nil && blocks[idx].Header().BlockAccessListHash != nil {
// Not a failure — the server is allowed to legitimately not
// have the BAL. But we log it so the test output is diagnosable.
t.Logf(" entry %d: server returned empty for known post-Amsterdam block %x", idx, tc.hashes[idx])
}
idx++
continue
}
// Non-empty entry. If the requester asked for a block we do not know
// about, receiving data here is a protocol violation.
if tc.expectAllEmpty {
return fmt.Errorf("entry %d: expected empty entry, got %d bytes of BAL data", idx, len(raw))
}
if _, required := mustEmpty[idx]; required {
return fmt.Errorf("entry %d: position must be empty (unknown hash), got %d bytes of BAL data", idx, len(raw))
}
// Per EIP-8189: compute keccak256(rlp.encode(bal)) against the raw
// bytes actually received on the wire, and compare to the header
// commitment. Hashing raw bytes (rather than re-encoding after a
// decode round-trip) catches peers that send non-canonical BAL
// encodings.
block, known := blocks[idx]
if known && block.Header().BlockAccessListHash != nil {
have := crypto.Keccak256Hash(raw)
want := *block.Header().BlockAccessListHash
if have != want {
return fmt.Errorf("entry %d: BAL hash mismatch: have %x, want %x", idx, have, want)
}
}
// Also decode and validate the BAL's internal structure: ordering of
// accounts/slots/changes, code-size limits, etc. This catches
// malformed responses even when we can't compare to a header hash
// (e.g. requested hash is for a block we don't know locally).
var accessList bal.BlockAccessList
if err := rlp.DecodeBytes(raw, &accessList); err != nil {
return fmt.Errorf("entry %d: invalid BAL RLP: %v", idx, err)
}
if err := accessList.Validate(rules); err != nil {
return fmt.Errorf("entry %d: BAL failed validation: %v", idx, err)
}
idx++
}
// Sanity: iterator consumed exactly the reported number of entries.
if idx != got {
return fmt.Errorf("iterator visited %d entries, expected %d", idx, got)
}
return nil
}

View file

@ -106,6 +106,16 @@ func (s *Suite) SnapTests() []utesting.Test {
} }
} }
// Snap2Tests returns the list of tests for the snap/2 protocol (EIP-8189).
// These tests require the peer to advertise and negotiate snap/2.
func (s *Suite) Snap2Tests() []utesting.Test {
return []utesting.Test{
{Name: "Status", Fn: s.TestSnap2Status},
{Name: "GetBlockAccessLists", Fn: s.TestSnap2GetBlockAccessLists},
{Name: "TrieNodesRemoved", Fn: s.TestSnap2TrieNodesRemoved},
}
}
func (s *Suite) TestStatus(t *utesting.T) { func (s *Suite) TestStatus(t *utesting.T) {
t.Log(`This test is just a sanity check. It performs an eth protocol handshake.`) t.Log(`This test is just a sanity check. It performs an eth protocol handshake.`)
conn, err := s.dialAndPeer(nil) conn, err := s.dialAndPeer(nil)

View file

@ -99,6 +99,31 @@ func TestSnapSuite(t *testing.T) {
} }
} }
func TestSnap2Suite(t *testing.T) {
jwtPath, secret, err := makeJWTSecret(t)
if err != nil {
t.Fatalf("could not make jwt secret: %v", err)
}
geth, err := runGeth("./testdata", jwtPath)
if err != nil {
t.Fatalf("could not run geth: %v", err)
}
defer geth.Close()
suite, err := NewSuite(geth.Server().Self(), "./testdata", geth.HTTPAuthEndpoint(), common.Bytes2Hex(secret[:]))
if err != nil {
t.Fatalf("could not create new test suite: %v", err)
}
for _, test := range suite.Snap2Tests() {
t.Run(test.Name, func(t *testing.T) {
result := utesting.RunTests([]utesting.Test{{Name: test.Name, Fn: test.Fn}}, os.Stdout)
if result[0].Failed {
t.Fatal()
}
})
}
}
// runGeth creates and starts a geth node // runGeth creates and starts a geth node
func runGeth(dir string, jwtPath string) (*node.Node, error) { func runGeth(dir string, jwtPath string) (*node.Node, error) {
stack, err := node.New(&node.Config{ stack, err := node.New(&node.Config{

View file

@ -64,6 +64,7 @@ var (
rlpxPingCommand, rlpxPingCommand,
rlpxEthTestCommand, rlpxEthTestCommand,
rlpxSnapTestCommand, rlpxSnapTestCommand,
rlpxSnap2TestCommand,
}, },
} }
rlpxPingCommand = &cli.Command{ rlpxPingCommand = &cli.Command{
@ -99,6 +100,20 @@ var (
testNodeEngineFlag, testNodeEngineFlag,
}, },
} }
rlpxSnap2TestCommand = &cli.Command{
Name: "snap2-test",
Usage: "Runs snap/2 (EIP-8189) protocol tests against a node",
ArgsUsage: "",
Action: rlpxSnap2Test,
Flags: []cli.Flag{
testPatternFlag,
testTAPFlag,
testChainDirFlag,
testNodeFlag,
testNodeJWTFlag,
testNodeEngineFlag,
},
}
) )
func rlpxPing(ctx *cli.Context) error { func rlpxPing(ctx *cli.Context) error {
@ -164,6 +179,16 @@ func rlpxSnapTest(ctx *cli.Context) error {
return runTests(ctx, suite.SnapTests()) return runTests(ctx, suite.SnapTests())
} }
// rlpxSnap2Test runs the snap/2 (EIP-8189) protocol test suite.
func rlpxSnap2Test(ctx *cli.Context) error {
p := cliTestParams(ctx)
suite, err := ethtest.NewSuite(p.node, p.chainDir, p.engineAPI, p.jwt)
if err != nil {
exit(err)
}
return runTests(ctx, suite.Snap2Tests())
}
type testParams struct { type testParams struct {
node *enode.Node node *enode.Node
engineAPI string engineAPI string

View file

@ -1162,7 +1162,7 @@ func (bc *BlockChain) SnapSyncStart() error {
// given hash, regardless of the chain contents prior to snap sync. It is // given hash, regardless of the chain contents prior to snap sync. It is
// invoked once snap sync completes and assumes that SnapSyncStart was called // invoked once snap sync completes and assumes that SnapSyncStart was called
// previously. // previously.
func (bc *BlockChain) SnapSyncComplete(hash common.Hash) error { func (bc *BlockChain) SnapSyncComplete(hash common.Hash, flatStateReady bool) error {
// Make sure that both the block as well at its state trie exists // Make sure that both the block as well at its state trie exists
block := bc.GetBlockByHash(hash) block := bc.GetBlockByHash(hash)
if block == nil { if block == nil {
@ -1173,19 +1173,28 @@ func (bc *BlockChain) SnapSyncComplete(hash common.Hash) error {
} }
defer bc.chainmu.Unlock() defer bc.chainmu.Unlock()
// Reset the trie database with the fresh snap synced state. // Reset the trie database with the fresh snap synced state. Snap/1 needs
// a full trie-to-flat regeneration; snap/2 adopts the already-consistent
// flat state and skips that work.
root := block.Root() root := block.Root()
if bc.triedb.Scheme() == rawdb.PathScheme { if bc.triedb.Scheme() == rawdb.PathScheme {
if err := bc.triedb.Enable(root); err != nil { if flatStateReady {
return err if err := bc.triedb.AdoptSyncedState(root); err != nil {
return err
}
} else {
if err := bc.triedb.Enable(root); err != nil {
return err
}
} }
} }
if !bc.HasState(root) { if !bc.HasState(root) {
return fmt.Errorf("non existent state [%x..]", root[:4]) return fmt.Errorf("non existent state [%x..]", root[:4])
} }
// Destroy any existing state snapshot and regenerate it in the background,
// also resuming the normal maintenance of any previously paused snapshot. // The legacy snapshot tree needs to be wiped and rebuilt from the trie
if bc.snaps != nil { // after a snap/1 sync.
if !flatStateReady && bc.snaps != nil {
bc.snaps.Rebuild(root) bc.snaps.Rebuild(root)
} }

View file

@ -208,3 +208,10 @@ func WriteSnapshotSyncStatus(db ethdb.KeyValueWriter, status []byte) {
log.Crit("Failed to store snapshot sync status", "err", err) log.Crit("Failed to store snapshot sync status", "err", err)
} }
} }
// DeleteSnapshotSyncStatus removes the serialized sync status from the database.
func DeleteSnapshotSyncStatus(db ethdb.KeyValueWriter) {
if err := db.Delete(snapshotSyncStatusKey); err != nil {
log.Crit("Failed to remove snapshot sync status", "err", err)
}
}

View file

@ -201,7 +201,7 @@ type BlockChain interface {
SnapSyncStart() error SnapSyncStart() error
// SnapSyncComplete directly commits the head block to a certain entity. // SnapSyncComplete directly commits the head block to a certain entity.
SnapSyncComplete(common.Hash) error SnapSyncComplete(hash common.Hash, flatStateReady bool) error
// InsertHeadersBeforeCutoff inserts a batch of headers before the configured // InsertHeadersBeforeCutoff inserts a batch of headers before the configured
// chain cutoff into the ancient store. // chain cutoff into the ancient store.
@ -278,7 +278,7 @@ func (d *Downloader) Progress() ethereum.SyncProgress {
default: default:
log.Error("Unknown downloader mode", "mode", mode) log.Error("Unknown downloader mode", "mode", mode)
} }
progress, pending := d.SnapSyncer.Progress() progress := d.SnapSyncer.Progress()
return ethereum.SyncProgress{ return ethereum.SyncProgress{
StartingBlock: d.syncStatsChainOrigin, StartingBlock: d.syncStatsChainOrigin,
@ -290,12 +290,6 @@ func (d *Downloader) Progress() ethereum.SyncProgress {
SyncedBytecodeBytes: uint64(progress.BytecodeBytes), SyncedBytecodeBytes: uint64(progress.BytecodeBytes),
SyncedStorage: progress.StorageSynced, SyncedStorage: progress.StorageSynced,
SyncedStorageBytes: uint64(progress.StorageBytes), SyncedStorageBytes: uint64(progress.StorageBytes),
HealedTrienodes: progress.TrienodeHealSynced,
HealedTrienodeBytes: uint64(progress.TrienodeHealBytes),
HealedBytecodes: progress.BytecodeHealSynced,
HealedBytecodeBytes: uint64(progress.BytecodeHealBytes),
HealingTrienodes: pending.TrienodeHeal,
HealingBytecode: pending.BytecodeHeal,
} }
} }
@ -889,7 +883,7 @@ func (d *Downloader) processSnapSyncContent() error {
// Start syncing state of the reported head block. This should get us most of // Start syncing state of the reported head block. This should get us most of
// the state of the pivot block. // the state of the pivot block.
d.pivotLock.RLock() d.pivotLock.RLock()
sync := d.syncState(d.pivotHeader.Root) sync := d.syncState(d.pivotHeader)
d.pivotLock.RUnlock() d.pivotLock.RUnlock()
defer func() { defer func() {
@ -959,10 +953,9 @@ func (d *Downloader) processSnapSyncContent() error {
if oldPivot == nil { // no results piling up, we can move the pivot if oldPivot == nil { // no results piling up, we can move the pivot
if !d.committed.Load() { // not yet passed the pivot, we can move the pivot if !d.committed.Load() { // not yet passed the pivot, we can move the pivot
if pivot.Root != sync.root { // pivot position changed, we can move the pivot if pivot.Hash() != sync.pivot.Hash() { // pivot position changed, we can move the pivot
sync.Cancel() sync.Cancel()
sync = d.syncState(pivot.Root) sync = d.syncState(pivot)
go closeOnErr(sync) go closeOnErr(sync)
} }
} }
@ -977,8 +970,7 @@ func (d *Downloader) processSnapSyncContent() error {
// If new pivot block found, cancel old state retrieval and restart // If new pivot block found, cancel old state retrieval and restart
if oldPivot != P { if oldPivot != P {
sync.Cancel() sync.Cancel()
sync = d.syncState(P.Header.Root) sync = d.syncState(P.Header)
go closeOnErr(sync) go closeOnErr(sync)
oldPivot = P oldPivot = P
} }
@ -1070,7 +1062,9 @@ func (d *Downloader) commitPivotBlock(result *fetchResult) error {
if _, err := d.blockchain.InsertReceiptChain([]*types.Block{block}, []rlp.RawValue{result.Receipts}, d.ancientLimit); err != nil { if _, err := d.blockchain.InsertReceiptChain([]*types.Block{block}, []rlp.RawValue{result.Receipts}, d.ancientLimit); err != nil {
return err return err
} }
if err := d.blockchain.SnapSyncComplete(block.Hash()); err != nil {
// TODO JR: This needs to pass trie for snap/2 and false for snap/1
if err := d.blockchain.SnapSyncComplete(block.Hash(), true); err != nil {
return err return err
} }
d.committed.Store(true) d.committed.Store(true)
@ -1096,7 +1090,12 @@ func (d *Downloader) DeliverSnapPacket(peer *snap.Peer, packet snap.Packet) erro
return d.SnapSyncer.OnByteCodes(peer, packet.ID, packet.Codes) return d.SnapSyncer.OnByteCodes(peer, packet.ID, packet.Codes)
case *snap.TrieNodesPacket: case *snap.TrieNodesPacket:
return d.SnapSyncer.OnTrieNodes(peer, packet.ID, packet.Nodes) // Snap/2 no longer requests trie nodes. Stale responses from
// snap/1 peers are silently ignored.
return nil
case *snap.AccessListsPacket:
return d.SnapSyncer.OnAccessLists(peer, packet.ID, packet.AccessLists)
default: default:
return fmt.Errorf("unexpected snap packet type: %T", packet) return fmt.Errorf("unexpected snap packet type: %T", packet)

View file

@ -372,20 +372,15 @@ func (dlp *downloadTesterPeer) RequestByteCodes(id uint64, hashes []common.Hash,
return nil return nil
} }
// RequestTrieNodes fetches a batch of account or storage trie nodes. // RequestAccessLists fetches a batch of BALs by block hash.
func (dlp *downloadTesterPeer) RequestTrieNodes(id uint64, root common.Hash, count int, paths []snap.TrieNodePathSet, bytes int) error { func (dlp *downloadTesterPeer) RequestAccessLists(id uint64, hashes []common.Hash, bytes int) error {
encPaths, err := rlp.EncodeToRawList(paths) req := &snap.GetAccessListsPacket{
if err != nil { ID: id,
panic(err) Hashes: hashes,
Bytes: uint64(bytes),
} }
req := &snap.GetTrieNodesPacket{ als := snap.ServiceGetAccessListsQuery(dlp.chain, req)
ID: id, go dlp.dl.downloader.SnapSyncer.OnAccessLists(dlp, id, als)
Root: root,
Paths: encPaths,
Bytes: uint64(bytes),
}
nodes, _ := snap.ServiceGetTrieNodesQuery(dlp.chain, req)
go dlp.dl.downloader.SnapSyncer.OnTrieNodes(dlp, id, nodes)
return nil return nil
} }

View file

@ -19,14 +19,14 @@ package downloader
import ( import (
"sync" "sync"
"github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/log" "github.com/ethereum/go-ethereum/log"
) )
// syncState starts downloading state with the given root hash. // syncState starts downloading state with the given pivot header.
func (d *Downloader) syncState(root common.Hash) *stateSync { func (d *Downloader) syncState(pivot *types.Header) *stateSync {
// Create the state sync // Create the state sync
s := newStateSync(d, root) s := newStateSync(d, pivot)
select { select {
case d.stateSyncStart <- s: case d.stateSyncStart <- s:
// If we tell the statesync to restart with a new root, we also need // If we tell the statesync to restart with a new root, we also need
@ -58,7 +58,7 @@ func (d *Downloader) stateFetcher() {
// runStateSync runs a state synchronisation until it completes or another root // runStateSync runs a state synchronisation until it completes or another root
// hash is requested to be switched over to. // hash is requested to be switched over to.
func (d *Downloader) runStateSync(s *stateSync) *stateSync { func (d *Downloader) runStateSync(s *stateSync) *stateSync {
log.Trace("State sync starting", "root", s.root) log.Trace("State sync starting", "pivot", s.pivot.Hash(), "number", s.pivot.Number)
go s.run() go s.run()
defer s.Cancel() defer s.Cancel()
@ -75,10 +75,10 @@ func (d *Downloader) runStateSync(s *stateSync) *stateSync {
} }
// stateSync schedules requests for downloading a particular state trie defined // stateSync schedules requests for downloading a particular state trie defined
// by a given state root. // by a given pivot header.
type stateSync struct { type stateSync struct {
d *Downloader // Downloader instance to access and manage current peerset d *Downloader // Downloader instance to access and manage current peerset
root common.Hash // State root currently being synced pivot *types.Header // Pivot header currently being synced
started chan struct{} // Started is signalled once the sync loop starts started chan struct{} // Started is signalled once the sync loop starts
cancel chan struct{} // Channel to signal a termination request cancel chan struct{} // Channel to signal a termination request
@ -89,10 +89,10 @@ type stateSync struct {
// newStateSync creates a new state trie download scheduler. This method does not // newStateSync creates a new state trie download scheduler. This method does not
// yet start the sync. The user needs to call run to initiate. // yet start the sync. The user needs to call run to initiate.
func newStateSync(d *Downloader, root common.Hash) *stateSync { func newStateSync(d *Downloader, pivot *types.Header) *stateSync {
return &stateSync{ return &stateSync{
d: d, d: d,
root: root, pivot: pivot,
cancel: make(chan struct{}), cancel: make(chan struct{}),
done: make(chan struct{}), done: make(chan struct{}),
started: make(chan struct{}), started: make(chan struct{}),
@ -104,7 +104,7 @@ func newStateSync(d *Downloader, root common.Hash) *stateSync {
// finish. // finish.
func (s *stateSync) run() { func (s *stateSync) run() {
close(s.started) close(s.started)
s.err = s.d.SnapSyncer.Sync(s.root, s.cancel) s.err = s.d.SnapSyncer.Sync(s.pivot, s.cancel)
close(s.done) close(s.done)
} }

View file

@ -0,0 +1,152 @@
// 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"
"fmt"
"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/holiman/uint256"
)
// verifyAccessList checks that the given block access list matches the hash
// committed in the block header.
func verifyAccessList(b *bal.BlockAccessList, header *types.Header) error {
if header.BlockAccessListHash == nil {
return fmt.Errorf("header %d has no access list hash", header.Number)
}
have := b.Hash()
if have != *header.BlockAccessListHash {
return fmt.Errorf("access list hash mismatch for block %d: have %v, want %v", header.Number, have, *header.BlockAccessListHash)
}
return nil
}
// isFetched tell us if accountHash has been downloaded.
func (s *Syncer) isFetched(accountHash common.Hash) bool {
s.lock.RLock()
defer s.lock.RUnlock()
for _, task := range s.tasks {
if bytes.Compare(accountHash[:], task.Last[:]) <= 0 {
return bytes.Compare(accountHash[:], task.Next[:]) < 0
}
}
return true
}
// applyAccessList applies a single block's access list diffs to the flat state
// in the database. For each account, it applies the post-block values (highest
// TxIdx entry) for balance, nonce, code, and storage. The storageRoot field is
// intentionally left stale. It will be recomputed during the trie rebuild.
func (s *Syncer) applyAccessList(b *bal.BlockAccessList) error {
batch := s.db.NewBatch()
// Iterate over all accounts in the access list
for _, access := range *b {
addr := common.Address(access.Address)
accountHash := crypto.Keccak256Hash(addr[:])
// Skip accounts whose hash range hasn't been downloaded yet.
if !s.isFetched(accountHash) {
continue
}
// Read the existing account from flat state (may not exist yet)
var (
account types.StateAccount
isNew bool
)
if data := rawdb.ReadAccountSnapshot(s.db, accountHash); len(data) > 0 {
existing, err := types.FullAccount(data)
if err != nil {
return fmt.Errorf("failed to decode account %v: %w", addr, err)
}
account = *existing
} else {
// New account — initialize with defaults
isNew = true
account.Balance = new(uint256.Int)
account.Root = types.EmptyRootHash
account.CodeHash = types.EmptyCodeHash[:]
}
// Apply balance change (last entry = post-block state)
if n := len(access.BalanceChanges); n > 0 {
account.Balance = new(uint256.Int).Set(access.BalanceChanges[n-1].Balance)
}
// Apply nonce change (last entry = post-block state)
if n := len(access.NonceChanges); n > 0 {
account.Nonce = access.NonceChanges[n-1].Nonce
}
// Apply code change (last entry = post-block state)
if n := len(access.CodeChanges); n > 0 {
code := access.CodeChanges[n-1].Code
if len(code) > 0 {
codeHash := crypto.Keccak256(code)
rawdb.WriteCode(batch, common.BytesToHash(codeHash), code)
account.CodeHash = codeHash
} else {
account.CodeHash = types.EmptyCodeHash[:]
}
}
// Apply storage writes (last entry per slot = post-block state).
for _, slotWrites := range access.StorageWrites {
if n := len(slotWrites.Accesses); n > 0 {
value := slotWrites.Accesses[n-1].ValueAfter
slotKey := slotWrites.Slot.Bytes32()
storageHash := crypto.Keccak256Hash(slotKey[:])
if value.IsZero() {
rawdb.DeleteStorageSnapshot(batch, accountHash, storageHash)
} else {
valBytes := value.Bytes32()
rawdb.WriteStorageSnapshot(batch, accountHash, storageHash, valBytes[:])
}
}
}
// Don't create empty accounts in flat state (EIP-161).
isEmpty := account.Balance.IsZero() && account.Nonce == 0 &&
bytes.Equal(account.CodeHash, types.EmptyCodeHash[:])
switch {
case isEmpty && isNew:
// This handles the case where an account is created and
// self-destructed in the same transaction. The BAL will
// include it with a balance change to zero, but the account
// should not exist in state.
continue
case isEmpty && !isNew:
// Existing account got fully drained (e.g., pre-funded
// address that gets deployed to with init code that
// self-destructs). Delete the entry so the trie rebuild
// doesn't pick it up as an empty leaf.
rawdb.DeleteAccountSnapshot(batch, accountHash)
continue
}
// Write the updated account (storageRoot intentionally left stale)
rawdb.WriteAccountSnapshot(batch, accountHash, types.SlimAccountRLP(account))
}
return batch.Write()
}

View file

@ -0,0 +1,465 @@
// 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)
}
}

View file

@ -25,20 +25,6 @@ import (
"github.com/ethereum/go-ethereum/trie" "github.com/ethereum/go-ethereum/trie"
) )
// genTrie interface is used by the snap syncer to generate merkle tree nodes
// based on a received batch of states.
type genTrie interface {
// update inserts the state item into generator trie.
update(key, value []byte) error
// delete removes the state item from the generator trie.
delete(key []byte) error
// commit flushes the right boundary nodes if complete flag is true. This
// function must be called before flushing the associated database batch.
commit(complete bool) common.Hash
}
// pathTrie is a wrapper over the stackTrie, incorporating numerous additional // pathTrie is a wrapper over the stackTrie, incorporating numerous additional
// logics to handle the semi-completed trie and potential leftover dangling // logics to handle the semi-completed trie and potential leftover dangling
// nodes in the database. It is utilized for constructing the merkle tree nodes // nodes in the database. It is utilized for constructing the merkle tree nodes
@ -292,30 +278,3 @@ func (t *pathTrie) commit(complete bool) common.Hash {
} }
// hashTrie is a wrapper over the stackTrie for implementing genTrie interface. // hashTrie is a wrapper over the stackTrie for implementing genTrie interface.
type hashTrie struct {
tr *trie.StackTrie
}
// newHashTrie initializes the hash trie.
func newHashTrie(batch ethdb.Batch) *hashTrie {
return &hashTrie{tr: trie.NewStackTrie(func(path []byte, hash common.Hash, blob []byte) {
rawdb.WriteLegacyTrieNode(batch, hash, blob)
})}
}
// update implements genTrie interface, inserting a (key, value) pair into
// the stack trie.
func (t *hashTrie) update(key, value []byte) error {
return t.tr.Update(key, value)
}
// delete implements genTrie interface, ignoring the state item for deleting.
func (t *hashTrie) delete(key []byte) error { return nil }
// commit implements genTrie interface, committing the nodes on right boundary.
func (t *hashTrie) commit(complete bool) common.Hash {
if !complete {
return common.Hash{} // the hash is meaningless for incomplete commit
}
return t.tr.Hash() // return hash only if it's claimed as complete
}

View file

@ -141,7 +141,7 @@ var snap2 = map[uint64]msgHandler{
GetByteCodesMsg: handleGetByteCodes, GetByteCodesMsg: handleGetByteCodes,
ByteCodesMsg: handleByteCodes, ByteCodesMsg: handleByteCodes,
GetAccessListsMsg: handleGetAccessLists, GetAccessListsMsg: handleGetAccessLists,
// AccessListsMsg: TODO AccessListsMsg: handleAccessLists,
} }
// HandleMessage is invoked whenever an inbound message is received from a // HandleMessage is invoked whenever an inbound message is received from a

View file

@ -598,3 +598,16 @@ func ServiceGetAccessListsQuery(chain *core.BlockChain, req *GetAccessListsPacke
} }
return response return response
} }
// nolint:unused
func handleAccessLists(backend Backend, msg Decoder, peer *Peer) error {
res := new(AccessListsPacket)
if err := msg.Decode(res); err != nil {
return fmt.Errorf("%w: message %v: %v", errDecode, msg, err)
}
tresp := tracker.Response{ID: res.ID, MsgCode: AccessListsMsg, Size: res.AccessLists.Len()}
if err := peer.tracker.Fulfil(tresp); err != nil {
return fmt.Errorf("BALs: %w", err)
}
return backend.Handle(peer, res)
}

View file

@ -58,15 +58,8 @@ var (
// to retrieved concurrently. // to retrieved concurrently.
largeStorageGauge = metrics.NewRegisteredGauge("eth/protocols/snap/sync/storage/large", nil) largeStorageGauge = metrics.NewRegisteredGauge("eth/protocols/snap/sync/storage/large", nil)
// skipStorageHealingGauge is the metric to track how many storages are retrieved
// in multiple requests but healing is not necessary.
skipStorageHealingGauge = metrics.NewRegisteredGauge("eth/protocols/snap/sync/storage/noheal", nil)
// largeStorageDiscardGauge is the metric to track how many chunked storages are // largeStorageDiscardGauge is the metric to track how many chunked storages are
// discarded during the snap sync. // discarded during the snap sync.
largeStorageDiscardGauge = metrics.NewRegisteredGauge("eth/protocols/snap/sync/storage/chunk/discard", nil) largeStorageDiscardGauge = metrics.NewRegisteredGauge("eth/protocols/snap/sync/storage/chunk/discard", nil)
largeStorageResumedGauge = metrics.NewRegisteredGauge("eth/protocols/snap/sync/storage/chunk/resume", nil) largeStorageResumedGauge = metrics.NewRegisteredGauge("eth/protocols/snap/sync/storage/chunk/resume", nil)
stateSyncTimeGauge = metrics.NewRegisteredGauge("eth/protocols/snap/sync/time/statesync", nil)
stateHealTimeGauge = metrics.NewRegisteredGauge("eth/protocols/snap/sync/time/stateheal", nil)
) )

View file

@ -23,7 +23,6 @@ import (
"github.com/ethereum/go-ethereum/log" "github.com/ethereum/go-ethereum/log"
"github.com/ethereum/go-ethereum/p2p" "github.com/ethereum/go-ethereum/p2p"
"github.com/ethereum/go-ethereum/p2p/tracker" "github.com/ethereum/go-ethereum/p2p/tracker"
"github.com/ethereum/go-ethereum/rlp"
) )
// Peer is a collection of relevant information we have about a `snap` peer. // Peer is a collection of relevant information we have about a `snap` peer.
@ -154,25 +153,21 @@ func (p *Peer) RequestByteCodes(id uint64, hashes []common.Hash, bytes int) erro
}) })
} }
// RequestTrieNodes fetches a batch of account or storage trie nodes rooted in // RequestAccessLists fetches a batch of BALs by block hash.
// a specific state trie. The `count` is the total count of paths being requested. func (p *Peer) RequestAccessLists(id uint64, hashes []common.Hash, bytes int) error {
func (p *Peer) RequestTrieNodes(id uint64, root common.Hash, count int, paths []TrieNodePathSet, bytes int) error { p.logger.Trace("Fetching set of BALs", "reqid", id, "hashes", len(hashes), "bytes", common.StorageSize(bytes))
p.logger.Trace("Fetching set of trie nodes", "reqid", id, "root", root, "pathsets", len(paths), "bytes", common.StorageSize(bytes))
err := p.tracker.Track(tracker.Request{ err := p.tracker.Track(tracker.Request{
ReqCode: GetTrieNodesMsg, ReqCode: GetAccessListsMsg,
RespCode: TrieNodesMsg, RespCode: AccessListsMsg,
ID: id, ID: id,
Size: count, // TrieNodes is limited by number of items. Size: len(hashes),
}) })
if err != nil { if err != nil {
return err return err
} }
encPaths, _ := rlp.EncodeToRawList(paths) return p2p.Send(p.rw, GetAccessListsMsg, &GetAccessListsPacket{
return p2p.Send(p.rw, GetTrieNodesMsg, &GetTrieNodesPacket{ ID: id,
ID: id, Hashes: hashes,
Root: root, Bytes: uint64(bytes),
Paths: encPaths,
Bytes: uint64(bytes),
}) })
} }

View file

@ -18,137 +18,123 @@ package snap
import ( import (
"encoding/json" "encoding/json"
"math/big"
"testing" "testing"
"github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/rawdb"
"github.com/ethereum/go-ethereum/core/types"
) )
// Legacy sync progress definitions // TestSyncProgressV1Discarded verifies that a persisted blob written in the
type legacyStorageTask struct { // old unversioned format (raw JSON, no version prefix) is detected and
Next common.Hash // Next account to sync in this interval // discarded on load, that the syncer falls through to a fresh start, and
Last common.Hash // Last account to sync in this interval // that any orphan flat-state entries from the prior format are wiped.
} func TestSyncProgressV1Discarded(t *testing.T) {
db := rawdb.NewMemoryDatabase()
type legacyAccountTask struct { // Write a raw JSON blob (no version byte) to simulate progress persisted
Next common.Hash // Next account to sync in this interval // by a prior geth binary (snap/1 format).
Last common.Hash // Last account to sync in this interval legacy := map[string]any{
SubTasks map[common.Hash][]*legacyStorageTask // Storage intervals needing fetching for large contracts "Root": common.HexToHash("0xaaaa"),
} "BlockNumber": uint64(42),
"Tasks": []any{},
type legacyProgress struct {
Tasks []*legacyAccountTask // The suspended account tasks (contract tasks within)
}
func compareProgress(a legacyProgress, b SyncProgress) bool {
if len(a.Tasks) != len(b.Tasks) {
return false
} }
for i := 0; i < len(a.Tasks); i++ {
if a.Tasks[i].Next != b.Tasks[i].Next {
return false
}
if a.Tasks[i].Last != b.Tasks[i].Last {
return false
}
// new fields are not checked here
if len(a.Tasks[i].SubTasks) != len(b.Tasks[i].SubTasks) {
return false
}
for addrHash, subTasksA := range a.Tasks[i].SubTasks {
subTasksB, ok := b.Tasks[i].SubTasks[addrHash]
if !ok || len(subTasksB) != len(subTasksA) {
return false
}
for j := 0; j < len(subTasksA); j++ {
if subTasksA[j].Next != subTasksB[j].Next {
return false
}
if subTasksA[j].Last != subTasksB[j].Last {
return false
}
}
}
}
return true
}
func makeLegacyProgress() legacyProgress {
return legacyProgress{
Tasks: []*legacyAccountTask{
{
Next: common.Hash{},
Last: common.Hash{0x77},
SubTasks: map[common.Hash][]*legacyStorageTask{
{0x1}: {
{
Next: common.Hash{},
Last: common.Hash{0xff},
},
},
},
},
{
Next: common.Hash{0x88},
Last: common.Hash{0xff},
},
},
}
}
func convertLegacy(legacy legacyProgress) SyncProgress {
var progress SyncProgress
for i, task := range legacy.Tasks {
subTasks := make(map[common.Hash][]*storageTask)
for owner, list := range task.SubTasks {
var cpy []*storageTask
for i := 0; i < len(list); i++ {
cpy = append(cpy, &storageTask{
Next: list[i].Next,
Last: list[i].Last,
})
}
subTasks[owner] = cpy
}
accountTask := &accountTask{
Next: task.Next,
Last: task.Last,
SubTasks: subTasks,
}
if i == 0 {
accountTask.StorageCompleted = []common.Hash{{0xaa}, {0xbb}} // fulfill new fields
}
progress.Tasks = append(progress.Tasks, accountTask)
}
return progress
}
func TestSyncProgressCompatibility(t *testing.T) {
// Decode serialized bytes of legacy progress, backward compatibility
legacy := makeLegacyProgress()
blob, err := json.Marshal(legacy) blob, err := json.Marshal(legacy)
if err != nil { if err != nil {
t.Fatalf("Failed to marshal progress %v", err) t.Fatalf("marshal legacy: %v", err)
}
var dec SyncProgress
if err := json.Unmarshal(blob, &dec); err != nil {
t.Fatalf("Failed to unmarshal progress %v", err)
}
if !compareProgress(legacy, dec) {
t.Fatal("sync progress is not backward compatible")
} }
rawdb.WriteSnapshotSyncStatus(db, blob)
// Decode serialized bytes of new format progress // Pre-write orphan flat-state entries that should be wiped on fresh start.
progress := convertLegacy(legacy) orphanAccountHash := common.HexToHash("0xdeadbeef")
blob, err = json.Marshal(progress) rawdb.WriteAccountSnapshot(db, orphanAccountHash, []byte{0xde, 0xad})
if err != nil { orphanStorageAccount := common.HexToHash("0xfeedface")
t.Fatalf("Failed to marshal progress %v", err) orphanStorageSlot := common.HexToHash("0xabcd")
rawdb.WriteStorageSnapshot(db, orphanStorageAccount, orphanStorageSlot, []byte{0xff, 0xff})
syncer := NewSyncer(db, rawdb.HashScheme)
syncer.loadSyncStatus()
if syncer.previousPivot != nil {
t.Fatalf("expected previousPivot nil after discarding old format, got %+v", syncer.previousPivot)
} }
var legacyDec legacyProgress if len(syncer.tasks) != accountConcurrency {
if err := json.Unmarshal(blob, &legacyDec); err != nil { t.Fatalf("expected fresh task split of %d, got %d", accountConcurrency, len(syncer.tasks))
t.Fatalf("Failed to unmarshal progress %v", err)
} }
if !compareProgress(legacyDec, progress) { if data := rawdb.ReadAccountSnapshot(db, orphanAccountHash); len(data) != 0 {
t.Fatal("sync progress is not forward compatible") 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)
}
}
// TestSyncProgressV2RoundTrip verifies that the persisted blob is framed
// with the expected version byte at offset 0, and that all six status
// counters survive the round-trip.
func TestSyncProgressV2RoundTrip(t *testing.T) {
db := rawdb.NewMemoryDatabase()
saver := NewSyncer(db, rawdb.HashScheme)
saver.pivot = &types.Header{Number: new(big.Int).SetUint64(123), Difficulty: common.Big0}
saver.accountSynced = 1
saver.accountBytes = 2
saver.bytecodeSynced = 3
saver.bytecodeBytes = 4
saver.storageSynced = 5
saver.storageBytes = 6
saver.saveSyncStatus()
raw := rawdb.ReadSnapshotSyncStatus(db)
if len(raw) == 0 || raw[0] != syncProgressVersion {
t.Fatalf("expected version byte %d at offset 0, got blob %x", syncProgressVersion, raw)
}
loader := NewSyncer(db, rawdb.HashScheme)
loader.loadSyncStatus()
for _, c := range []struct {
name string
got uint64
want uint64
}{
{"accountSynced", loader.accountSynced, 1},
{"accountBytes", uint64(loader.accountBytes), 2},
{"bytecodeSynced", loader.bytecodeSynced, 3},
{"bytecodeBytes", uint64(loader.bytecodeBytes), 4},
{"storageSynced", loader.storageSynced, 5},
{"storageBytes", uint64(loader.storageBytes), 6},
} {
if c.got != c.want {
t.Errorf("%s mismatch: got %d, want %d", c.name, c.got, c.want)
}
}
}
// TestSyncProgressCorruptPayload verifies that a persisted blob with the
// correct version byte but unparseable JSON body is discarded, triggers a
// fresh-start fall-through (not a panic or a stale-state load), and the
// orphan flat state is wiped along with the corrupt status.
func TestSyncProgressCorruptPayload(t *testing.T) {
db := rawdb.NewMemoryDatabase()
// Version byte followed by garbage that isn't valid JSON.
rawdb.WriteSnapshotSyncStatus(db, []byte{syncProgressVersion, 0x7b, 0x7b, 0x7b})
// Pre-write orphan flat-state entries that should be wiped on fresh start.
orphanAccountHash := common.HexToHash("0xdeadbeef")
rawdb.WriteAccountSnapshot(db, orphanAccountHash, []byte{0xde, 0xad})
syncer := NewSyncer(db, rawdb.HashScheme)
syncer.loadSyncStatus()
if syncer.previousPivot != nil {
t.Fatalf("expected previousPivot nil after corrupt payload, got %+v", syncer.previousPivot)
}
if len(syncer.tasks) != accountConcurrency {
t.Fatalf("expected fresh task split of %d, got %d", accountConcurrency, len(syncer.tasks))
}
if data := rawdb.ReadAccountSnapshot(db, orphanAccountHash); len(data) != 0 {
t.Errorf("orphan account snapshot should be wiped, got %x", data)
} }
} }

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff