From 4fd4120450c79dad7340f006e1501405efd78468 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Toni=20Wahrst=C3=A4tter?= Date: Wed, 22 Apr 2026 08:28:56 +0200 Subject: [PATCH] cmd/devp2p/internal/ethtest: add snap/2 (EIP-8189) tests --- cmd/devp2p/internal/ethtest/conn.go | 22 +- cmd/devp2p/internal/ethtest/protocol.go | 6 +- cmd/devp2p/internal/ethtest/snap2.go | 397 ++++++++++++++++++++++ cmd/devp2p/internal/ethtest/suite.go | 10 + cmd/devp2p/internal/ethtest/suite_test.go | 25 ++ cmd/devp2p/rlpxcmd.go | 25 ++ 6 files changed, 483 insertions(+), 2 deletions(-) create mode 100644 cmd/devp2p/internal/ethtest/snap2.go diff --git a/cmd/devp2p/internal/ethtest/conn.go b/cmd/devp2p/internal/ethtest/conn.go index 02579f8b55..fb655eba18 100644 --- a/cmd/devp2p/internal/ethtest/conn.go +++ b/cmd/devp2p/internal/ethtest/conn.go @@ -84,6 +84,19 @@ func (s *Suite) dialSnap() (*Conn, error) { 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 type Conn struct { *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) { c.SetReadDeadline(time.Now().Add(timeout)) for { @@ -215,6 +231,10 @@ func (c *Conn) ReadSnap() (any, error) { msg = new(snap.GetTrieNodesPacket) case snap.TrieNodesMsg: msg = new(snap.TrieNodesPacket) + case snap.GetAccessListsMsg: + msg = new(snap.GetAccessListsPacket) + case snap.AccessListsMsg: + msg = new(snap.AccessListsPacket) default: panic(fmt.Errorf("unhandled snap code: %d", code)) } diff --git a/cmd/devp2p/internal/ethtest/protocol.go b/cmd/devp2p/internal/ethtest/protocol.go index a21d1ca7a1..f865869093 100644 --- a/cmd/devp2p/internal/ethtest/protocol.go +++ b/cmd/devp2p/internal/ethtest/protocol.go @@ -33,7 +33,11 @@ const ( const ( baseProtoLen = 16 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. diff --git a/cmd/devp2p/internal/ethtest/snap2.go b/cmd/devp2p/internal/ethtest/snap2.go new file mode 100644 index 0000000000..bc2d3cc013 --- /dev/null +++ b/cmd/devp2p/internal/ethtest/snap2.go @@ -0,0 +1,397 @@ +// 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 . + +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{}{} + } + + 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(); 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 +} diff --git a/cmd/devp2p/internal/ethtest/suite.go b/cmd/devp2p/internal/ethtest/suite.go index d710f98428..1dedd84bec 100644 --- a/cmd/devp2p/internal/ethtest/suite.go +++ b/cmd/devp2p/internal/ethtest/suite.go @@ -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) { t.Log(`This test is just a sanity check. It performs an eth protocol handshake.`) conn, err := s.dialAndPeer(nil) diff --git a/cmd/devp2p/internal/ethtest/suite_test.go b/cmd/devp2p/internal/ethtest/suite_test.go index a6fca0e524..ca9c2138a6 100644 --- a/cmd/devp2p/internal/ethtest/suite_test.go +++ b/cmd/devp2p/internal/ethtest/suite_test.go @@ -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 func runGeth(dir string, jwtPath string) (*node.Node, error) { stack, err := node.New(&node.Config{ diff --git a/cmd/devp2p/rlpxcmd.go b/cmd/devp2p/rlpxcmd.go index ec73171e76..a08fe707ba 100644 --- a/cmd/devp2p/rlpxcmd.go +++ b/cmd/devp2p/rlpxcmd.go @@ -64,6 +64,7 @@ var ( rlpxPingCommand, rlpxEthTestCommand, rlpxSnapTestCommand, + rlpxSnap2TestCommand, }, } rlpxPingCommand = &cli.Command{ @@ -99,6 +100,20 @@ var ( 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 { @@ -164,6 +179,16 @@ func rlpxSnapTest(ctx *cli.Context) error { 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 { node *enode.Node engineAPI string