diff --git a/vms/evm/sync/message/block_request.go b/vms/evm/sync/message/block_request.go new file mode 100644 index 000000000000..d31c3fc12db1 --- /dev/null +++ b/vms/evm/sync/message/block_request.go @@ -0,0 +1,41 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package message + +import ( + "context" + "fmt" + + "github.com/ava-labs/libevm/common" + + "github.com/ava-labs/avalanchego/ids" +) + +var _ Request = (*BlockRequest)(nil) + +// BlockRequest is a request to retrieve the specified number of parent blocks +// starting from the given hash, ordered from newest to oldest. +type BlockRequest struct { + Hash common.Hash `serialize:"true"` + Height uint64 `serialize:"true"` + Parents uint16 `serialize:"true"` +} + +func (b BlockRequest) String() string { + return fmt.Sprintf( + "BlockRequest(Hash=%s, Height=%d, Parents=%d)", + b.Hash, b.Height, b.Parents, + ) +} + +func (b BlockRequest) Handle(ctx context.Context, nodeID ids.NodeID, requestID uint32, handler RequestHandler) ([]byte, error) { + return handler.HandleBlockRequest(ctx, nodeID, requestID, b) +} + +// BlockResponse is a response to a BlockRequest. +// Blocks is a slice of RLP-encoded blocks starting with the block +// requested in BlockRequest.Hash. The next block is the parent, etc. +type BlockResponse struct { + Blocks [][]byte `serialize:"true"` +} diff --git a/vms/evm/sync/message/block_request_test.go b/vms/evm/sync/message/block_request_test.go new file mode 100644 index 000000000000..9ddecb313014 --- /dev/null +++ b/vms/evm/sync/message/block_request_test.go @@ -0,0 +1,65 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package message + +import ( + "encoding/base64" + "math/rand" + "testing" + + "github.com/ava-labs/libevm/common" + "github.com/stretchr/testify/require" +) + +// TestMarshalBlockRequest requires that the structure or serialization logic hasn't changed, primarily to +// ensure compatibility with the network. +func TestMarshalBlockRequest(t *testing.T) { + blockRequest := BlockRequest{ + Hash: common.BytesToHash([]byte("some hash is here yo")), + Height: 1337, + Parents: 64, + } + + base64BlockRequest := "AAAAAAAAAAAAAAAAAABzb21lIGhhc2ggaXMgaGVyZSB5bwAAAAAAAAU5AEA=" + + blockRequestBytes, err := Codec().Marshal(Version, blockRequest) + require.NoError(t, err) + require.Equal(t, base64BlockRequest, base64.StdEncoding.EncodeToString(blockRequestBytes)) + + var b BlockRequest + _, err = Codec().Unmarshal(blockRequestBytes, &b) + require.NoError(t, err) + require.Equal(t, blockRequest.Hash, b.Hash) + require.Equal(t, blockRequest.Height, b.Height) + require.Equal(t, blockRequest.Parents, b.Parents) +} + +// TestMarshalBlockResponse requires that the structure or serialization logic hasn't changed, primarily to +// ensure compatibility with the network. +func TestMarshalBlockResponse(t *testing.T) { + // create some random bytes + // set seed to ensure deterministic random behaviour + r := rand.New(rand.NewSource(1)) //nolint:gosec // deterministic bytes for golden assertion + blocksBytes := make([][]byte, 32) + for i := range blocksBytes { + blocksBytes[i] = make([]byte, r.Intn(32)+32) + _, err := r.Read(blocksBytes[i]) + require.NoError(t, err) + } + + blockResponse := BlockResponse{ + Blocks: blocksBytes, + } + + base64BlockResponse := "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" + + blockResponseBytes, err := Codec().Marshal(Version, blockResponse) + require.NoError(t, err) + require.Equal(t, base64BlockResponse, base64.StdEncoding.EncodeToString(blockResponseBytes)) + + var b BlockResponse + _, err = Codec().Unmarshal(blockResponseBytes, &b) + require.NoError(t, err) + require.Equal(t, blockResponse.Blocks, b.Blocks) +} diff --git a/vms/evm/sync/message/block_sync_summary.go b/vms/evm/sync/message/block_sync_summary.go new file mode 100644 index 000000000000..b6f019350d3d --- /dev/null +++ b/vms/evm/sync/message/block_sync_summary.go @@ -0,0 +1,143 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package message + +import ( + "context" + "errors" + "fmt" + + "github.com/ava-labs/libevm/common" + "github.com/ava-labs/libevm/core/types" + "github.com/ava-labs/libevm/crypto" + + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/snow/engine/snowman/block" +) + +var ( + _ Syncable = (*BlockSyncSummary)(nil) + _ SyncableParser = (*BlockSyncSummaryParser)(nil) + + // errInvalidBlockSyncSummary is returned when the provided bytes cannot be + // parsed into a valid BlockSyncSummary. + errInvalidBlockSyncSummary = errors.New("invalid block sync summary") + + // errAcceptImplNotSpecified is returned when Accept is called on a BlockSyncSummary + // that doesn't have an acceptImpl set. + errAcceptImplNotSpecified = errors.New("accept implementation not specified") +) + +// Syncable extends [block.StateSummary] with EVM-specific block information. +type Syncable interface { + block.StateSummary + GetBlockHash() common.Hash + GetBlockRoot() common.Hash +} + +// SyncableParser parses raw bytes into a [Syncable] instance. +type SyncableParser interface { + Parse(summaryBytes []byte, acceptImpl AcceptImplFn) (Syncable, error) +} + +// AcceptImplFn is a function that determines the state sync mode for a given [Syncable]. +type AcceptImplFn func(Syncable) (block.StateSyncMode, error) + +// BlockSyncSummary provides the information necessary to sync a node starting +// at the given block. +type BlockSyncSummary struct { + BlockNumber uint64 `serialize:"true"` + BlockHash common.Hash `serialize:"true"` + BlockRoot common.Hash `serialize:"true"` + + summaryID ids.ID + bytes []byte + acceptImpl AcceptImplFn +} + +// NewBlockSyncSummary creates a new [BlockSyncSummary] for the given block. +// The acceptImpl is intentionally left unset and should be set by the parser. +func NewBlockSyncSummary(blockHash common.Hash, blockNumber uint64, blockRoot common.Hash) (*BlockSyncSummary, error) { + summary := BlockSyncSummary{ + BlockNumber: blockNumber, + BlockHash: blockHash, + BlockRoot: blockRoot, + } + bytes, err := Codec().Marshal(Version, &summary) + if err != nil { + return nil, fmt.Errorf("failed to marshal syncable summary: %w", err) + } + + summary.bytes = bytes + summaryID, err := ids.ToID(crypto.Keccak256(bytes)) + if err != nil { + return nil, fmt.Errorf("failed to compute summary ID: %w", err) + } + summary.summaryID = summaryID + + return &summary, nil +} + +func (s *BlockSyncSummary) GetBlockHash() common.Hash { + return s.BlockHash +} + +func (s *BlockSyncSummary) GetBlockRoot() common.Hash { + return s.BlockRoot +} + +func (s *BlockSyncSummary) Bytes() []byte { + return s.bytes +} + +func (s *BlockSyncSummary) Height() uint64 { + return s.BlockNumber +} + +func (s *BlockSyncSummary) ID() ids.ID { + return s.summaryID +} + +func (s *BlockSyncSummary) String() string { + return fmt.Sprintf("BlockSyncSummary(BlockHash=%s, BlockNumber=%d, BlockRoot=%s)", s.BlockHash, s.BlockNumber, s.BlockRoot) +} + +func (s *BlockSyncSummary) Accept(context.Context) (block.StateSyncMode, error) { + if s.acceptImpl == nil { + return block.StateSyncSkipped, errAcceptImplNotSpecified + } + return s.acceptImpl(s) +} + +// BlockSyncSummaryParser parses [BlockSyncSummary] instances from raw bytes. +type BlockSyncSummaryParser struct{} + +// NewBlockSyncSummaryParser creates a new [BlockSyncSummaryParser]. +func NewBlockSyncSummaryParser() *BlockSyncSummaryParser { + return &BlockSyncSummaryParser{} +} + +func (*BlockSyncSummaryParser) Parse(summaryBytes []byte, acceptImpl AcceptImplFn) (Syncable, error) { + summary := BlockSyncSummary{} + if _, err := Codec().Unmarshal(summaryBytes, &summary); err != nil { + return nil, fmt.Errorf("%w: %w", errInvalidBlockSyncSummary, err) + } + + summary.bytes = summaryBytes + summaryID, err := ids.ToID(crypto.Keccak256(summaryBytes)) + if err != nil { + return nil, fmt.Errorf("failed to compute summary ID: %w", err) + } + summary.summaryID = summaryID + summary.acceptImpl = acceptImpl + return &summary, nil +} + +// BlockSyncSummaryProvider provides state summaries for blocks. +type BlockSyncSummaryProvider struct{} + +// StateSummaryAtBlock returns the block state summary for the given block if valid. +func (*BlockSyncSummaryProvider) StateSummaryAtBlock(blk *types.Block) (block.StateSummary, error) { + return NewBlockSyncSummary(blk.Hash(), blk.NumberU64(), blk.Root()) +} diff --git a/vms/evm/sync/message/block_sync_summary_test.go b/vms/evm/sync/message/block_sync_summary_test.go new file mode 100644 index 000000000000..c28cd3aeb17f --- /dev/null +++ b/vms/evm/sync/message/block_sync_summary_test.go @@ -0,0 +1,158 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package message + +import ( + "encoding/base64" + "errors" + "testing" + + "github.com/ava-labs/libevm/common" + "github.com/stretchr/testify/require" + + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/snow/engine/snowman/block" +) + +func TestBlockSyncSummary_MarshalGolden(t *testing.T) { + t.Parallel() + + blockSyncSummary, err := NewBlockSyncSummary(common.Hash{1}, 2, common.Hash{3}) + require.NoError(t, err) + + require.Equal(t, common.Hash{1}, blockSyncSummary.GetBlockHash()) + require.Equal(t, uint64(2), blockSyncSummary.Height()) + require.Equal(t, common.Hash{3}, blockSyncSummary.GetBlockRoot()) + + expectedBase64Bytes := "AAAAAAAAAAAAAgEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + require.Equal(t, expectedBase64Bytes, base64.StdEncoding.EncodeToString(blockSyncSummary.Bytes())) +} + +func TestBlockSyncSummary_Methods(t *testing.T) { + t.Parallel() + + blockHash := common.Hash{1, 2, 3} + blockNumber := uint64(42) + blockRoot := common.Hash{4, 5, 6} + + summary, err := NewBlockSyncSummary(blockHash, blockNumber, blockRoot) + require.NoError(t, err) + + require.Equal(t, blockHash, summary.GetBlockHash()) + require.Equal(t, blockRoot, summary.GetBlockRoot()) + require.Equal(t, blockNumber, summary.Height()) + require.NotNil(t, summary.Bytes()) + require.NotEqual(t, ids.ID{}, summary.ID()) + + // Test String() method + str := summary.String() + require.Contains(t, str, "BlockSyncSummary") + require.Contains(t, str, blockHash.String()) +} + +func TestBlockSyncSummary_Accept(t *testing.T) { + t.Parallel() + + errTestError := errors.New("test error") + + tests := []struct { + name string + acceptImpl AcceptImplFn + wantErr error + }{ + { + name: "nil_acceptImpl", + wantErr: errAcceptImplNotSpecified, + }, + { + name: "with_acceptImpl_error", + acceptImpl: func(Syncable) (block.StateSyncMode, error) { + return block.StateSyncSkipped, errTestError + }, + wantErr: errTestError, + }, + { + name: "with_acceptImpl_success", + acceptImpl: func(Syncable) (block.StateSyncMode, error) { + return block.StateSyncSkipped, nil + }, + wantErr: nil, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + summary, err := NewBlockSyncSummary(common.Hash{1}, 1, common.Hash{2}) + require.NoError(t, err) + + summary.acceptImpl = tc.acceptImpl + + mode, err := summary.Accept(t.Context()) + require.Equal(t, block.StateSyncSkipped, mode) + require.ErrorIs(t, err, tc.wantErr) + }) + } +} + +func TestBlockSyncSummaryParser_ParseValid(t *testing.T) { + t.Parallel() + + blockSyncSummary, err := NewBlockSyncSummary(common.Hash{1}, 2, common.Hash{3}) + require.NoError(t, err) + + parser := NewBlockSyncSummaryParser() + called := false + acceptImplTest := func(Syncable) (block.StateSyncMode, error) { + called = true + return block.StateSyncSkipped, nil + } + s, err := parser.Parse(blockSyncSummary.Bytes(), acceptImplTest) + require.NoError(t, err) + require.Equal(t, blockSyncSummary.GetBlockHash(), s.GetBlockHash()) + require.Equal(t, blockSyncSummary.Height(), s.Height()) + require.Equal(t, blockSyncSummary.GetBlockRoot(), s.GetBlockRoot()) + require.Equal(t, blockSyncSummary.Bytes(), s.Bytes()) + + mode, err := s.Accept(t.Context()) + require.NoError(t, err) + require.Equal(t, block.StateSyncSkipped, mode) + require.True(t, called) +} + +func TestBlockSyncSummaryParser_ParseInvalid(t *testing.T) { + t.Parallel() + + parser := NewBlockSyncSummaryParser() + + tests := []struct { + name string + summaryBytes []byte + wantErr error + }{ + { + name: "invalid_bytes", + summaryBytes: []byte("not-a-summary"), + wantErr: errInvalidBlockSyncSummary, + }, + { + name: "empty_bytes", + summaryBytes: []byte{}, + wantErr: errInvalidBlockSyncSummary, + }, + { + name: "nil_bytes", + summaryBytes: nil, + wantErr: errInvalidBlockSyncSummary, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + _, err := parser.Parse(tc.summaryBytes, nil) + require.ErrorIs(t, err, tc.wantErr) + }) + } +} diff --git a/vms/evm/sync/message/code_request.go b/vms/evm/sync/message/code_request.go new file mode 100644 index 000000000000..879cbd49dae1 --- /dev/null +++ b/vms/evm/sync/message/code_request.go @@ -0,0 +1,48 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package message + +import ( + "context" + "fmt" + "strings" + + "github.com/ava-labs/libevm/common" + + "github.com/ava-labs/avalanchego/ids" +) + +var _ Request = (*CodeRequest)(nil) + +// CodeRequest is a request to retrieve contract code for the specified hashes. +type CodeRequest struct { + // Hashes is a list of contract code hashes to retrieve. + Hashes []common.Hash `serialize:"true"` +} + +func (c CodeRequest) String() string { + hashStrs := make([]string, len(c.Hashes)) + for i, hash := range c.Hashes { + hashStrs[i] = hash.String() + } + return fmt.Sprintf("CodeRequest(Hashes=%s)", strings.Join(hashStrs, ", ")) +} + +func (c CodeRequest) Handle(ctx context.Context, nodeID ids.NodeID, requestID uint32, handler RequestHandler) ([]byte, error) { + return handler.HandleCodeRequest(ctx, nodeID, requestID, c) +} + +// NewCodeRequest creates a new CodeRequest with the given hashes. +func NewCodeRequest(hashes []common.Hash) CodeRequest { + return CodeRequest{ + Hashes: hashes, + } +} + +// CodeResponse is a response to a CodeRequest. +// The crypto.Keccak256Hash of each element in Data is expected to equal +// the corresponding element in CodeRequest.Hashes. +type CodeResponse struct { + Data [][]byte `serialize:"true"` +} diff --git a/vms/evm/sync/message/code_request_test.go b/vms/evm/sync/message/code_request_test.go new file mode 100644 index 000000000000..87cf6f480b26 --- /dev/null +++ b/vms/evm/sync/message/code_request_test.go @@ -0,0 +1,58 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package message + +import ( + "encoding/base64" + "math/rand" + "testing" + + "github.com/ava-labs/libevm/common" + "github.com/stretchr/testify/require" +) + +// TestMarshalCodeRequest requires that the structure or serialization logic hasn't changed, primarily to +// ensure compatibility with the network. +func TestMarshalCodeRequest(t *testing.T) { + codeRequest := CodeRequest{ + Hashes: []common.Hash{common.BytesToHash([]byte("some code pls"))}, + } + + base64CodeRequest := "AAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAHNvbWUgY29kZSBwbHM=" + + codeRequestBytes, err := Codec().Marshal(Version, codeRequest) + require.NoError(t, err) + require.Equal(t, base64CodeRequest, base64.StdEncoding.EncodeToString(codeRequestBytes)) + + var c CodeRequest + _, err = Codec().Unmarshal(codeRequestBytes, &c) + require.NoError(t, err) + require.Equal(t, codeRequest.Hashes, c.Hashes) +} + +// TestMarshalCodeResponse requires that the structure or serialization logic hasn't changed, primarily to +// ensure compatibility with the network. +func TestMarshalCodeResponse(t *testing.T) { + // generate some random code data + // set random seed for deterministic random + codeData := make([]byte, 50) + r := rand.New(rand.NewSource(1)) //nolint:gosec // deterministic bytes for golden assertion + _, err := r.Read(codeData) + require.NoError(t, err) + + codeResponse := CodeResponse{ + Data: [][]byte{codeData}, + } + + base64CodeResponse := "AAAAAAABAAAAMlL9/AchgmVPFj9fD5piHXKVZsdNEAN8TXu7BAfR4sZJgYVa2GgdDYbR6R4AFnk5y2aU" + + codeResponseBytes, err := Codec().Marshal(Version, codeResponse) + require.NoError(t, err) + require.Equal(t, base64CodeResponse, base64.StdEncoding.EncodeToString(codeResponseBytes)) + + var c CodeResponse + _, err = Codec().Unmarshal(codeResponseBytes, &c) + require.NoError(t, err) + require.Equal(t, codeResponse.Data, c.Data) +} diff --git a/vms/evm/sync/message/codec.go b/vms/evm/sync/message/codec.go new file mode 100644 index 000000000000..d50844bf66ac --- /dev/null +++ b/vms/evm/sync/message/codec.go @@ -0,0 +1,60 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package message + +import ( + "fmt" + "sync" + + "github.com/ava-labs/avalanchego/codec" + "github.com/ava-labs/avalanchego/codec/linearcodec" + "github.com/ava-labs/avalanchego/utils/units" + "github.com/ava-labs/avalanchego/utils/wrappers" +) + +const ( + Version = uint16(0) + maxMessageSize = 2*units.MiB - 64*units.KiB // Subtract 64 KiB from p2p network cap to leave room for encoding overhead from AvalancheGo +) + +var ( + codecOnce sync.Once + manager codec.Manager +) + +// Codec returns the codec manager for this package, initializing it lazily on first access. +// This avoids using init() and initializes the codec only when needed. +// NOTE: Panics if codec initialization fails (e.g., duplicate type registration). +// Such errors indicate programming bugs and should never occur at runtime. +func Codec() codec.Manager { + codecOnce.Do(func() { + c := codec.NewManager(maxMessageSize) + lc := linearcodec.NewDefault() + + errs := wrappers.Errs{} + // Gossip types and sync summary type removed from codec + lc.SkipRegistrations(3) + errs.Add( + // state sync types + lc.RegisterType(BlockRequest{}), + lc.RegisterType(BlockResponse{}), + lc.RegisterType(LeafsRequest{}), + lc.RegisterType(LeafsResponse{}), + lc.RegisterType(CodeRequest{}), + lc.RegisterType(CodeResponse{}), + ) + + // Deprecated Warp request/response types are skipped + // See https://github.com/ava-labs/coreth/pull/999 + lc.SkipRegistrations(3) + + errs.Add(c.RegisterCodec(Version, lc)) + + if errs.Errored() { + panic(fmt.Errorf("failed to initialize message codec: %w", errs.Err)) + } + manager = c + }) + return manager +} diff --git a/vms/evm/sync/message/codec_test.go b/vms/evm/sync/message/codec_test.go new file mode 100644 index 000000000000..77d5cafecdfb --- /dev/null +++ b/vms/evm/sync/message/codec_test.go @@ -0,0 +1,46 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package message + +import ( + "sync" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCodec_LazyInitialization(t *testing.T) { + t.Parallel() + + // First call should initialize. + c1 := Codec() + require.NotNil(t, c1) + + // Subsequent calls should return the same instance. + c2 := Codec() + require.Equal(t, c1, c2) +} + +func TestCodec_ThreadSafety(t *testing.T) { + t.Parallel() + + const numGoroutines = 100 + var wg sync.WaitGroup + results := make([]any, numGoroutines) + + wg.Add(numGoroutines) + for i := 0; i < numGoroutines; i++ { + go func(idx int) { + defer wg.Done() + results[idx] = Codec() + }(i) + } + wg.Wait() + + // All results should be the same instance. + first := results[0] + for i := 1; i < numGoroutines; i++ { + require.Equal(t, first, results[i], "all goroutines should get the same codec instance") + } +} diff --git a/vms/evm/sync/message/leafs_request.go b/vms/evm/sync/message/leafs_request.go new file mode 100644 index 000000000000..2a79d6a25bbc --- /dev/null +++ b/vms/evm/sync/message/leafs_request.go @@ -0,0 +1,82 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package message + +import ( + "context" + "fmt" + + "github.com/ava-labs/libevm/common" + + "github.com/ava-labs/avalanchego/ids" +) + +const ( + // MaxCodeHashesPerRequest limits the number of code hashes per request to bound + // bandwidth and CPU for verification. + MaxCodeHashesPerRequest = 5 + + // StateTrieNode represents a node in the state trie. + StateTrieNode = NodeType(1) + + // StateTrieKeyLength is the length of a state trie key. + StateTrieKeyLength = common.HashLength +) + +var _ Request = (*LeafsRequest)(nil) + +// NodeType indicates which trie a leaf node belongs to. +// This is used by handlers to determine which trie to read from (state/atomic). +type NodeType uint8 + +// LeafsRequest is a request to retrieve trie leaves at the specified root +// within the Start and End byte range. +// Limit specifies the maximum number of leaves to return starting at Start. +// NodeType indicates which trie to read from (state/atomic). +type LeafsRequest struct { + Root common.Hash `serialize:"true"` + Account common.Hash `serialize:"true"` + Start []byte `serialize:"true"` + End []byte `serialize:"true"` + Limit uint16 `serialize:"true"` + NodeType NodeType `serialize:"true"` +} + +func (l LeafsRequest) String() string { + return fmt.Sprintf( + "LeafsRequest(Root=%s, Account=%s, Start=%s, End=%s, Limit=%d, NodeType=%d)", + l.Root, l.Account, common.Bytes2Hex(l.Start), common.Bytes2Hex(l.End), l.Limit, l.NodeType, + ) +} + +func (l LeafsRequest) Handle(ctx context.Context, nodeID ids.NodeID, requestID uint32, handler RequestHandler) ([]byte, error) { + return handler.HandleLeafsRequest(ctx, nodeID, requestID, l) +} + +// LeafsResponse is a response to a LeafsRequest +// Keys must be within LeafsRequest.Start and LeafsRequest.End and sorted in lexicographical order. +// +// ProofVals must be non-empty and contain a valid range proof unless the key-value pairs in the +// response are the entire trie. +// If the key-value pairs make up the entire trie, ProofVals should be empty since the root will be +// sufficient to prove that the leaves are included in the trie. +// +// More is a flag set in the client after verifying the response, which indicates if the last key-value +// pair in the response has any more elements to its right within the trie. +type LeafsResponse struct { + // Keys and Vals provides the key-value pairs in the trie in the response. + Keys [][]byte `serialize:"true"` + Vals [][]byte `serialize:"true"` + + // More indicates if there are more leaves to the right of the last value in this response. + // + // This is not serialized since it is set in the client after verifying the response via + // VerifyRangeProof and determining if there are in fact more leaves to the right of the + // last value in this response. + More bool + + // ProofVals contain the edge merkle-proofs for the range of keys included in the response. + // The keys for the proof are simply the keccak256 hashes of the values, so they are not included in the response to save bandwidth. + ProofVals [][]byte `serialize:"true"` +} diff --git a/vms/evm/sync/message/leafs_request_test.go b/vms/evm/sync/message/leafs_request_test.go new file mode 100644 index 000000000000..98d8a429e005 --- /dev/null +++ b/vms/evm/sync/message/leafs_request_test.go @@ -0,0 +1,108 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package message + +import ( + "encoding/base64" + "math/rand" + "testing" + + "github.com/ava-labs/libevm/common" + "github.com/stretchr/testify/require" +) + +// TestMarshalLeafsRequest requires that the structure or serialization logic hasn't changed, primarily to +// ensure compatibility with the network. +func TestMarshalLeafsRequest(t *testing.T) { + t.Parallel() + + startBytes := make([]byte, common.HashLength) + endBytes := make([]byte, common.HashLength) + + r := rand.New(rand.NewSource(1)) //nolint:gosec // deterministic bytes for golden assertion + _, err := r.Read(startBytes) + require.NoError(t, err) + _, err = r.Read(endBytes) + require.NoError(t, err) + require.NotEmpty(t, startBytes) + require.NotEmpty(t, endBytes) + + leafsRequest := LeafsRequest{ + Root: common.BytesToHash([]byte("im ROOTing for ya")), + Account: common.Hash{}, // Account defaults to zero value + Start: startBytes, + End: endBytes, + Limit: 1024, + NodeType: StateTrieNode, + } + + base64LeafsRequest := "AAAAAAAAAAAAAAAAAAAAAABpbSBST09UaW5nIGZvciB5YQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIFL9/AchgmVPFj9fD5piHXKVZsdNEAN8TXu7BAfR4sZJAAAAIIGFWthoHQ2G0ekeABZ5OctmlNLEIqzSCKAHKTlIf2mZBAAB" + + leafsRequestBytes, err := Codec().Marshal(Version, leafsRequest) + require.NoError(t, err) + require.Equal(t, base64LeafsRequest, base64.StdEncoding.EncodeToString(leafsRequestBytes)) + + var l LeafsRequest + _, err = Codec().Unmarshal(leafsRequestBytes, &l) + require.NoError(t, err) + require.Equal(t, leafsRequest.Root, l.Root) + require.Equal(t, leafsRequest.Start, l.Start) + require.Equal(t, leafsRequest.End, l.End) + require.Equal(t, leafsRequest.Limit, l.Limit) + require.Equal(t, leafsRequest.NodeType, l.NodeType) +} + +// TestMarshalLeafsResponse requires that the structure or serialization logic hasn't changed, primarily to +// ensure compatibility with the network. +func TestMarshalLeafsResponse(t *testing.T) { + t.Parallel() + + keysBytes := make([][]byte, 16) + valsBytes := make([][]byte, 16) + r := rand.New(rand.NewSource(1)) //nolint:gosec // deterministic bytes for golden assertion + for i := range keysBytes { + keysBytes[i] = make([]byte, common.HashLength) + n := r.Intn(8) + valsBytes[i] = make([]byte, n+8) + _, err := r.Read(keysBytes[i]) + require.NoError(t, err) + _, err = r.Read(valsBytes[i]) + require.NoError(t, err) + } + + nextKey := make([]byte, common.HashLength) + _, err := r.Read(nextKey) + require.NoError(t, err) + require.NotEmpty(t, nextKey) + + proofVals := make([][]byte, 4) + r2 := rand.New(rand.NewSource(2)) //nolint:gosec // deterministic bytes for golden assertion + for i := range proofVals { + n := r2.Intn(8) + proofVals[i] = make([]byte, n+8) + _, err := r2.Read(proofVals[i]) + require.NoError(t, err) + } + + leafsResponse := LeafsResponse{ + Keys: keysBytes, + Vals: valsBytes, + More: true, + ProofVals: proofVals, + } + + base64LeafsResponse := "AAAAAAAQAAAAIE8WP18PmmIdcpVmx00QA3xNe7sEB9HixkmBhVrYaB0NAAAAIGagByk5SH9pmeudGKRHhARdh/PGfPInRumVr1olNnlRAAAAIK2zfFghtmgLTnyLdjobHUnUlVyEhiFjJSU/7HON16niAAAAIIYVu9oIMfUFmHWSHmaKW98sf8SERZLSVyvNBmjS1sUvAAAAIHHb2Wiw9xcu2FeUuzWLDDtSXaF4b5//CUJ52xlE69ehAAAAIPhMiSs77qX090OR9EXRWv1ClAQDdPaSS5jL+HE/jZYtAAAAIMr8yuOmvI+effHZKTM/+ZOTO+pvWzr23gN0NmxHGeQ6AAAAIBZZpE856x5YScYHfbtXIvVxeiiaJm+XZHmBmY6+qJwLAAAAIHOq53hmZ/fpNs1PJKv334ZrqlYDg2etYUXeHuj0qLCZAAAAIHiN5WOvpGfUnexqQOmh0AfwM8KCMGG90Oqln45NpkMBAAAAIKAQ13yW6oCnpmX2BvamO389/SVnwYl55NYPJmhtm/L7AAAAIAfuKbpk+Eq0PKDG5rkcH9O+iZBDQXnTr0SRo2kBLbktAAAAILsXyQKL6ZFOt2ScbJNHgAl50YMDVvKlTD3qsqS0R11jAAAAIOqxOTXzHYRIRRfpJK73iuFRwAdVklg2twdYhWUMMOwpAAAAIHnqPf5BNqv3UrO4Jx0D6USzyds2a3UEX479adIq5UEZAAAAIDLWEMqsbjP+qjJjo5lDcCS6nJsUZ4onTwGpEK4pX277AAAAEAAAAAmG0ekeABZ5OcsAAAAMuqL/bNRxxIPxX7kLAAAACov5IRGcFg8HAkQAAAAIUFTi0INr+EwAAAAOnQ97usvgJVqlt9RL7EAAAAAJfI0BkZLCQiTiAAAACxsGfYm8fwHx9XOYAAAADUs3OXARXoLtb0ElyPoAAAAKPr34iDoK2L6cOQAAAAoFIg0LKWiLc0uOAAAACCbJAf81TN4WAAAADBhPw50XNP9XFkKJUwAAAAuvvo+1aYfHf1gYUgAAAAqjcDk0v1CijaECAAAADkfLVT12lCZ670686kBrAAAADf5fWr9EzN4mO1YGYz4AAAAEAAAACm8xRMCqTO1W29kAAAAIZ9wol8oW4YsAAAAOaGugcKI9oAJrZhCPutAAAAAPhENjuCNqN/goPvsnNn9u" + + leafsResponseBytes, err := Codec().Marshal(Version, leafsResponse) + require.NoError(t, err) + require.Equal(t, base64LeafsResponse, base64.StdEncoding.EncodeToString(leafsResponseBytes)) + + var l LeafsResponse + _, err = Codec().Unmarshal(leafsResponseBytes, &l) + require.NoError(t, err) + require.Equal(t, leafsResponse.Keys, l.Keys) + require.Equal(t, leafsResponse.Vals, l.Vals) + require.False(t, l.More) // make sure it is not serialized + require.Equal(t, leafsResponse.ProofVals, l.ProofVals) +} diff --git a/vms/evm/sync/message/request.go b/vms/evm/sync/message/request.go new file mode 100644 index 000000000000..657eecca71f1 --- /dev/null +++ b/vms/evm/sync/message/request.go @@ -0,0 +1,62 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package message + +import ( + "context" + "fmt" + + "github.com/ava-labs/avalanchego/codec" + "github.com/ava-labs/avalanchego/ids" +) + +// Request represents a Network request type. +type Request interface { + // Stringer enables requests to implement String() for logging. + fmt.Stringer + + // Handle allows `Request` to call respective methods on handler to handle + // this particular request type. + Handle(ctx context.Context, nodeID ids.NodeID, requestID uint32, handler RequestHandler) ([]byte, error) +} + +// RequestToBytes marshals the given request object into bytes using the provided codec. +func RequestToBytes(c codec.Manager, request Request) ([]byte, error) { + return c.Marshal(Version, &request) +} + +var _ RequestHandler = NoopRequestHandler{} + +// RequestHandler handles incoming requests from peers. +// Each request type has a corresponding handler method that processes the request +// and returns a response or an error. +type RequestHandler interface { + HandleLeafsRequest(ctx context.Context, nodeID ids.NodeID, requestID uint32, leafsRequest LeafsRequest) ([]byte, error) + HandleBlockRequest(ctx context.Context, nodeID ids.NodeID, requestID uint32, request BlockRequest) ([]byte, error) + HandleCodeRequest(ctx context.Context, nodeID ids.NodeID, requestID uint32, codeRequest CodeRequest) ([]byte, error) +} + +// ResponseHandler handles responses for sent requests. +// Only one of [ResponseHandler.OnResponse] or [ResponseHandler.OnFailure] is called for a given requestID, not both. +type ResponseHandler interface { + // OnResponse is invoked when the peer responded to a request. + OnResponse(response []byte) error + // OnFailure is invoked when there was a failure in processing a request. + OnFailure() error +} + +// NoopRequestHandler is a no-op implementation of RequestHandler that does nothing. +type NoopRequestHandler struct{} + +func (NoopRequestHandler) HandleLeafsRequest(context.Context, ids.NodeID, uint32, LeafsRequest) ([]byte, error) { + return nil, nil +} + +func (NoopRequestHandler) HandleBlockRequest(context.Context, ids.NodeID, uint32, BlockRequest) ([]byte, error) { + return nil, nil +} + +func (NoopRequestHandler) HandleCodeRequest(context.Context, ids.NodeID, uint32, CodeRequest) ([]byte, error) { + return nil, nil +} diff --git a/vms/evm/sync/message/request_test.go b/vms/evm/sync/message/request_test.go new file mode 100644 index 000000000000..532c28f50d4e --- /dev/null +++ b/vms/evm/sync/message/request_test.go @@ -0,0 +1,193 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package message + +import ( + "context" + "testing" + + "github.com/ava-labs/libevm/common" + "github.com/stretchr/testify/require" + + "github.com/ava-labs/avalanchego/ids" +) + +type recordingHandler struct { + leafsArgs struct { + ctx context.Context + nodeID ids.NodeID + requestID uint32 + req LeafsRequest + } + + blockArgs struct { + ctx context.Context + nodeID ids.NodeID + requestID uint32 + req BlockRequest + } + + codeArgs struct { + ctx context.Context + nodeID ids.NodeID + requestID uint32 + req CodeRequest + } + + leafsCalled bool + blockCalled bool + codeCalled bool +} + +func (r *recordingHandler) HandleLeafsRequest(ctx context.Context, nodeID ids.NodeID, requestID uint32, req LeafsRequest) ([]byte, error) { + r.leafsCalled = true + r.leafsArgs.ctx = ctx + r.leafsArgs.nodeID = nodeID + r.leafsArgs.requestID = requestID + r.leafsArgs.req = req + return nil, nil +} + +func (r *recordingHandler) HandleBlockRequest(ctx context.Context, nodeID ids.NodeID, requestID uint32, req BlockRequest) ([]byte, error) { + r.blockCalled = true + r.blockArgs.ctx = ctx + r.blockArgs.nodeID = nodeID + r.blockArgs.requestID = requestID + r.blockArgs.req = req + return nil, nil +} + +func (r *recordingHandler) HandleCodeRequest(ctx context.Context, nodeID ids.NodeID, requestID uint32, req CodeRequest) ([]byte, error) { + r.codeCalled = true + r.codeArgs.ctx = ctx + r.codeArgs.nodeID = nodeID + r.codeArgs.requestID = requestID + r.codeArgs.req = req + return nil, nil +} + +func TestRequest_HandleDispatchesToCorrectHandler(t *testing.T) { + t.Parallel() + + ctx := t.Context() + nodeID := ids.EmptyNodeID + const requestID uint32 = 42 + + tests := []struct { + name string + req Request + }{ + { + name: "leafs_request", + req: LeafsRequest{ + Root: common.Hash{1}, + Start: make([]byte, common.HashLength), + End: make([]byte, common.HashLength), + Limit: 1, + NodeType: StateTrieNode, + }, + }, + { + name: "block_request", + req: BlockRequest{Hash: common.Hash{2}, Height: 3, Parents: 1}, + }, + { + name: "code_request", + req: CodeRequest{Hashes: []common.Hash{{3}}}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + h := new(recordingHandler) + _, err := tc.req.Handle(ctx, nodeID, requestID, h) + require.NoError(t, err) + + switch tc.req.(type) { + case LeafsRequest: + require.True(t, h.leafsCalled) + require.Equal(t, requestID, h.leafsArgs.requestID) + case BlockRequest: + require.True(t, h.blockCalled) + require.Equal(t, requestID, h.blockArgs.requestID) + case CodeRequest: + require.True(t, h.codeCalled) + require.Equal(t, requestID, h.codeArgs.requestID) + } + }) + } +} + +func TestRequestToBytes_InterfaceRoundTrip(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + req Request + }{ + { + name: "code", + req: CodeRequest{ + Hashes: []common.Hash{{1}}, + }, + }, + { + name: "leafs", + req: LeafsRequest{ + Root: common.Hash{2}, + Start: make([]byte, common.HashLength), + End: make([]byte, common.HashLength), + Limit: 1, + NodeType: StateTrieNode, + }, + }, + { + name: "block", + req: BlockRequest{ + Hash: common.Hash{3}, + Height: 4, + Parents: 1, + }, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + b, err := RequestToBytes(Codec(), c.req) + require.NoError(t, err) + + var out Request + _, err = Codec().Unmarshal(b, &out) + require.NoError(t, err) + require.IsType(t, c.req, out) + }) + } +} + +func TestNoopRequestHandler(t *testing.T) { + t.Parallel() + + handler := NoopRequestHandler{} + ctx := t.Context() + nodeID := ids.EmptyNodeID + + t.Run("HandleLeafsRequest", func(t *testing.T) { + resp, err := handler.HandleLeafsRequest(ctx, nodeID, 1, LeafsRequest{}) + require.NoError(t, err) + require.Nil(t, resp) + }) + + t.Run("HandleBlockRequest", func(t *testing.T) { + resp, err := handler.HandleBlockRequest(ctx, nodeID, 1, BlockRequest{}) + require.NoError(t, err) + require.Nil(t, resp) + }) + + t.Run("HandleCodeRequest", func(t *testing.T) { + resp, err := handler.HandleCodeRequest(ctx, nodeID, 1, CodeRequest{}) + require.NoError(t, err) + require.Nil(t, resp) + }) +}