diff --git a/vms/evm/warp/backend.go b/vms/evm/warp/backend.go new file mode 100644 index 000000000000..c28767f258a7 --- /dev/null +++ b/vms/evm/warp/backend.go @@ -0,0 +1,207 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package warp + +import ( + "context" + "errors" + "fmt" + + "github.com/ava-labs/avalanchego/cache" + "github.com/ava-labs/avalanchego/cache/lru" + "github.com/ava-labs/avalanchego/database" + "github.com/ava-labs/avalanchego/ids" + + "github.com/ava-labs/avalanchego/network/p2p/acp118" + "github.com/ava-labs/avalanchego/snow/consensus/snowman" + "github.com/ava-labs/avalanchego/vms/evm/warp/warptest" + avalancheWarp "github.com/ava-labs/avalanchego/vms/platformvm/warp" + "github.com/ava-labs/avalanchego/vms/platformvm/warp/payload" + "github.com/ava-labs/libevm/log" +) + +var ( + _ Backend = (*backend)(nil) + errParsingOffChainMessage = errors.New("failed to parse off-chain message") + + messageCacheSize = 500 +) + +type BlockClient interface { + GetAcceptedBlock(ctx context.Context, blockID ids.ID) (snowman.Block, error) +} + +// Backend tracks signature-eligible warp messages and provides an interface to fetch them. +// The backend is also used to query for warp message signatures by the signature request handler. +type Backend interface { + // AddMessage signs [unsignedMessage] and adds it to the warp backend database + AddMessage(unsignedMessage *avalancheWarp.UnsignedMessage) error + + // GetMessageSignature validates the message and returns the signature of the requested message. + GetMessageSignature(ctx context.Context, message *avalancheWarp.UnsignedMessage) ([]byte, error) + + // GetBlockSignature returns the signature of a hash payload containing blockID if it's the ID of an accepted block. + GetBlockSignature(ctx context.Context, blockID ids.ID) ([]byte, error) + + // GetMessage retrieves the [unsignedMessage] from the warp backend database if available + GetMessage(messageHash ids.ID) (*avalancheWarp.UnsignedMessage, error) + + acp118.Verifier +} + +// backend implements Backend, keeps track of warp messages, and generates message signatures. +type backend struct { + networkID uint32 + sourceChainID ids.ID + db database.Database + warpSigner avalancheWarp.Signer + blockClient BlockClient + validatorReader warptest.ValidatorReader + signatureCache cache.Cacher[ids.ID, []byte] + messageCache *lru.Cache[ids.ID, *avalancheWarp.UnsignedMessage] + offchainAddressedCallMsgs map[ids.ID]*avalancheWarp.UnsignedMessage + stats *verifierStats +} + +// NewBackend creates a new Backend, and initializes the signature cache and message tracking database. +func NewBackend( + networkID uint32, + sourceChainID ids.ID, + warpSigner avalancheWarp.Signer, + blockClient BlockClient, + validatorReader warptest.ValidatorReader, + db database.Database, + signatureCache cache.Cacher[ids.ID, []byte], + offchainMessages [][]byte, +) (Backend, error) { + b := &backend{ + networkID: networkID, + sourceChainID: sourceChainID, + db: db, + warpSigner: warpSigner, + blockClient: blockClient, + signatureCache: signatureCache, + validatorReader: validatorReader, + messageCache: lru.NewCache[ids.ID, *avalancheWarp.UnsignedMessage](messageCacheSize), + stats: newVerifierStats(), + offchainAddressedCallMsgs: make(map[ids.ID]*avalancheWarp.UnsignedMessage), + } + return b, b.initOffChainMessages(offchainMessages) +} + +func (b *backend) initOffChainMessages(offchainMessages [][]byte) error { + for i, offchainMsg := range offchainMessages { + unsignedMsg, err := avalancheWarp.ParseUnsignedMessage(offchainMsg) + if err != nil { + return fmt.Errorf("%w at index %d: %w", errParsingOffChainMessage, i, err) + } + + if unsignedMsg.NetworkID != b.networkID { + return fmt.Errorf("%w at index %d", avalancheWarp.ErrWrongNetworkID, i) + } + + if unsignedMsg.SourceChainID != b.sourceChainID { + return fmt.Errorf("%w at index %d", avalancheWarp.ErrWrongSourceChainID, i) + } + + _, err = payload.ParseAddressedCall(unsignedMsg.Payload) + if err != nil { + return fmt.Errorf("%w at index %d as AddressedCall: %w", errParsingOffChainMessage, i, err) + } + b.offchainAddressedCallMsgs[unsignedMsg.ID()] = unsignedMsg + } + + return nil +} + +func (b *backend) AddMessage(unsignedMessage *avalancheWarp.UnsignedMessage) error { + messageID := unsignedMessage.ID() + log.Debug("Adding warp message to backend", "messageID", messageID) + + // In the case when a node restarts, and possibly changes its bls key, the cache gets emptied but the database does not. + // So to avoid having incorrect signatures saved in the database after a bls key change, we save the full message in the database. + // Whereas for the cache, after the node restart, the cache would be emptied so we can directly save the signatures. + if err := b.db.Put(messageID[:], unsignedMessage.Bytes()); err != nil { + return fmt.Errorf("failed to put warp signature in db: %w", err) + } + + if _, err := b.signMessage(unsignedMessage); err != nil { + return fmt.Errorf("failed to sign warp message: %w", err) + } + return nil +} + +func (b *backend) GetMessageSignature(ctx context.Context, unsignedMessage *avalancheWarp.UnsignedMessage) ([]byte, error) { + messageID := unsignedMessage.ID() + + log.Debug("Getting warp message from backend", "messageID", messageID) + if sig, ok := b.signatureCache.Get(messageID); ok { + return sig, nil + } + + if err := b.Verify(ctx, unsignedMessage, nil); err != nil { + return nil, fmt.Errorf("failed to validate warp message: %w", err) + } + return b.signMessage(unsignedMessage) +} + +func (b *backend) GetBlockSignature(ctx context.Context, blockID ids.ID) ([]byte, error) { + log.Debug("Getting block from backend", "blockID", blockID) + + blockHashPayload, err := payload.NewHash(blockID) + if err != nil { + return nil, fmt.Errorf("failed to create new block hash payload: %w", err) + } + + unsignedMessage, err := avalancheWarp.NewUnsignedMessage(b.networkID, b.sourceChainID, blockHashPayload.Bytes()) + if err != nil { + return nil, fmt.Errorf("failed to create new unsigned warp message: %w", err) + } + + if sig, ok := b.signatureCache.Get(unsignedMessage.ID()); ok { + return sig, nil + } + + if err := b.verifyBlockMessage(ctx, blockHashPayload); err != nil { + return nil, fmt.Errorf("failed to validate block message: %w", err) + } + + sig, err := b.signMessage(unsignedMessage) + if err != nil { + return nil, fmt.Errorf("failed to sign block message: %w", err) + } + return sig, nil +} + +func (b *backend) GetMessage(messageID ids.ID) (*avalancheWarp.UnsignedMessage, error) { + if message, ok := b.messageCache.Get(messageID); ok { + return message, nil + } + if message, ok := b.offchainAddressedCallMsgs[messageID]; ok { + return message, nil + } + + unsignedMessageBytes, err := b.db.Get(messageID[:]) + if err != nil { + return nil, err + } + + unsignedMessage, err := avalancheWarp.ParseUnsignedMessage(unsignedMessageBytes) + if err != nil { + return nil, fmt.Errorf("failed to parse unsigned message %s: %w", messageID.String(), err) + } + b.messageCache.Put(messageID, unsignedMessage) + + return unsignedMessage, nil +} + +func (b *backend) signMessage(unsignedMessage *avalancheWarp.UnsignedMessage) ([]byte, error) { + sig, err := b.warpSigner.Sign(unsignedMessage) + if err != nil { + return nil, fmt.Errorf("failed to sign warp message: %w", err) + } + + b.signatureCache.Put(unsignedMessage.ID(), sig) + return sig, nil +} diff --git a/vms/evm/warp/backend_test.go b/vms/evm/warp/backend_test.go new file mode 100644 index 000000000000..2c7f599d15df --- /dev/null +++ b/vms/evm/warp/backend_test.go @@ -0,0 +1,182 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package warp + +import ( + "context" + "testing" + + "github.com/ava-labs/avalanchego/cache/lru" + "github.com/ava-labs/avalanchego/database" + "github.com/ava-labs/avalanchego/database/memdb" + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/utils" + "github.com/ava-labs/avalanchego/utils/crypto/bls/signer/localsigner" + "github.com/ava-labs/avalanchego/vms/evm/warp/warptest" + "github.com/ava-labs/avalanchego/vms/platformvm/warp" + "github.com/ava-labs/avalanchego/vms/platformvm/warp/payload" + "github.com/stretchr/testify/require" +) + +var ( + networkID uint32 = 54321 + sourceChainID = ids.GenerateTestID() + testSourceAddress = utils.RandomBytes(20) + testPayload = []byte("test") + testUnsignedMessage *warp.UnsignedMessage +) + +func init() { + testAddressedCallPayload, err := payload.NewAddressedCall(testSourceAddress, testPayload) + if err != nil { + panic(err) + } + testUnsignedMessage, err = warp.NewUnsignedMessage(networkID, sourceChainID, testAddressedCallPayload.Bytes()) + if err != nil { + panic(err) + } +} + +func TestAddAndGetValidMessage(t *testing.T) { + db := memdb.New() + + sk, err := localsigner.New() + require.NoError(t, err) + warpSigner := warp.NewSigner(sk, networkID, sourceChainID) + messageSignatureCache := lru.NewCache[ids.ID, []byte](500) + backend, err := NewBackend(networkID, sourceChainID, warpSigner, nil, warptest.NoOpValidatorReader{}, db, messageSignatureCache, nil) + require.NoError(t, err) + + // Add testUnsignedMessage to the warp backend + require.NoError(t, backend.AddMessage(testUnsignedMessage)) + + // Verify that a signature is returned successfully, and compare to expected signature. + signature, err := backend.GetMessageSignature(context.TODO(), testUnsignedMessage) + require.NoError(t, err) + + expectedSig, err := warpSigner.Sign(testUnsignedMessage) + require.NoError(t, err) + require.Equal(t, expectedSig, signature[:]) +} + +func TestAddAndGetUnknownMessage(t *testing.T) { + db := memdb.New() + + sk, err := localsigner.New() + require.NoError(t, err) + warpSigner := warp.NewSigner(sk, networkID, sourceChainID) + messageSignatureCache := lru.NewCache[ids.ID, []byte](500) + backend, err := NewBackend(networkID, sourceChainID, warpSigner, nil, warptest.NoOpValidatorReader{}, db, messageSignatureCache, nil) + require.NoError(t, err) + + // Try getting a signature for a message that was not added. + _, err = backend.GetMessageSignature(context.TODO(), testUnsignedMessage) + require.Error(t, err) +} + +func TestGetBlockSignature(t *testing.T) { + require := require.New(t) + + blkID := ids.GenerateTestID() + blockClient := warptest.MakeBlockClient(blkID) + db := memdb.New() + + sk, err := localsigner.New() + require.NoError(err) + warpSigner := warp.NewSigner(sk, networkID, sourceChainID) + messageSignatureCache := lru.NewCache[ids.ID, []byte](500) + backend, err := NewBackend(networkID, sourceChainID, warpSigner, blockClient, warptest.NoOpValidatorReader{}, db, messageSignatureCache, nil) + require.NoError(err) + + blockHashPayload, err := payload.NewHash(blkID) + require.NoError(err) + unsignedMessage, err := warp.NewUnsignedMessage(networkID, sourceChainID, blockHashPayload.Bytes()) + require.NoError(err) + expectedSig, err := warpSigner.Sign(unsignedMessage) + require.NoError(err) + + signature, err := backend.GetBlockSignature(context.TODO(), blkID) + require.NoError(err) + require.Equal(expectedSig, signature[:]) + + _, err = backend.GetBlockSignature(context.TODO(), ids.GenerateTestID()) + require.Error(err) +} + +func TestZeroSizedCache(t *testing.T) { + db := memdb.New() + + sk, err := localsigner.New() + require.NoError(t, err) + warpSigner := warp.NewSigner(sk, networkID, sourceChainID) + + // Verify zero sized cache works normally, because the lru cache will be initialized to size 1 for any size parameter <= 0. + messageSignatureCache := lru.NewCache[ids.ID, []byte](0) + backend, err := NewBackend(networkID, sourceChainID, warpSigner, nil, warptest.NoOpValidatorReader{}, db, messageSignatureCache, nil) + require.NoError(t, err) + + // Add testUnsignedMessage to the warp backend + require.NoError(t, backend.AddMessage(testUnsignedMessage)) + + // Verify that a signature is returned successfully, and compare to expected signature. + signature, err := backend.GetMessageSignature(context.TODO(), testUnsignedMessage) + require.NoError(t, err) + + expectedSig, err := warpSigner.Sign(testUnsignedMessage) + require.NoError(t, err) + require.Equal(t, expectedSig, signature[:]) +} + +func TestOffChainMessages(t *testing.T) { + type test struct { + offchainMessages [][]byte + check func(require *require.Assertions, b Backend) + err error + } + sk, err := localsigner.New() + require.NoError(t, err) + warpSigner := warp.NewSigner(sk, networkID, sourceChainID) + + for name, test := range map[string]test{ + "no offchain messages": {}, + "single off-chain message": { + offchainMessages: [][]byte{ + testUnsignedMessage.Bytes(), + }, + check: func(require *require.Assertions, b Backend) { + msg, err := b.GetMessage(testUnsignedMessage.ID()) + require.NoError(err) + require.Equal(testUnsignedMessage.Bytes(), msg.Bytes()) + + signature, err := b.GetMessageSignature(context.TODO(), testUnsignedMessage) + require.NoError(err) + expectedSignatureBytes, err := warpSigner.Sign(msg) + require.NoError(err) + require.Equal(expectedSignatureBytes, signature[:]) + }, + }, + "unknown message": { + check: func(require *require.Assertions, b Backend) { + _, err := b.GetMessage(testUnsignedMessage.ID()) + require.ErrorIs(err, database.ErrNotFound) + }, + }, + "invalid message": { + offchainMessages: [][]byte{{1, 2, 3}}, + err: errParsingOffChainMessage, + }, + } { + t.Run(name, func(t *testing.T) { + require := require.New(t) + db := memdb.New() + + messageSignatureCache := lru.NewCache[ids.ID, []byte](0) + backend, err := NewBackend(networkID, sourceChainID, warpSigner, nil, warptest.NoOpValidatorReader{}, db, messageSignatureCache, test.offchainMessages) + require.ErrorIs(err, test.err) + if test.check != nil { + test.check(require, backend) + } + }) + } +} diff --git a/vms/evm/warp/client.go b/vms/evm/warp/client.go new file mode 100644 index 000000000000..f59db180b993 --- /dev/null +++ b/vms/evm/warp/client.go @@ -0,0 +1,79 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package warp + +import ( + "context" + "fmt" + + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/libevm/common/hexutil" + "github.com/ava-labs/libevm/rpc" +) + +var _ Client = (*client)(nil) + +type Client interface { + GetMessage(ctx context.Context, messageID ids.ID) ([]byte, error) + GetMessageSignature(ctx context.Context, messageID ids.ID) ([]byte, error) + GetMessageAggregateSignature(ctx context.Context, messageID ids.ID, quorumNum uint64, subnetIDStr string) ([]byte, error) + GetBlockSignature(ctx context.Context, blockID ids.ID) ([]byte, error) + GetBlockAggregateSignature(ctx context.Context, blockID ids.ID, quorumNum uint64, subnetIDStr string) ([]byte, error) +} + +// client implementation for interacting with EVM [chain] +type client struct { + client *rpc.Client +} + +// NewClient returns a Client for interacting with EVM [chain] +func NewClient(uri, chain string) (Client, error) { + innerClient, err := rpc.Dial(fmt.Sprintf("%s/ext/bc/%s/rpc", uri, chain)) + if err != nil { + return nil, fmt.Errorf("failed to dial client. err: %w", err) + } + return &client{ + client: innerClient, + }, nil +} + +func (c *client) GetMessage(ctx context.Context, messageID ids.ID) ([]byte, error) { + var res hexutil.Bytes + if err := c.client.CallContext(ctx, &res, "warp_getMessage", messageID); err != nil { + return nil, fmt.Errorf("call to warp_getMessage failed. err: %w", err) + } + return res, nil +} + +func (c *client) GetMessageSignature(ctx context.Context, messageID ids.ID) ([]byte, error) { + var res hexutil.Bytes + if err := c.client.CallContext(ctx, &res, "warp_getMessageSignature", messageID); err != nil { + return nil, fmt.Errorf("call to warp_getMessageSignature failed. err: %w", err) + } + return res, nil +} + +func (c *client) GetMessageAggregateSignature(ctx context.Context, messageID ids.ID, quorumNum uint64, subnetIDStr string) ([]byte, error) { + var res hexutil.Bytes + if err := c.client.CallContext(ctx, &res, "warp_getMessageAggregateSignature", messageID, quorumNum, subnetIDStr); err != nil { + return nil, fmt.Errorf("call to warp_getMessageAggregateSignature failed. err: %w", err) + } + return res, nil +} + +func (c *client) GetBlockSignature(ctx context.Context, blockID ids.ID) ([]byte, error) { + var res hexutil.Bytes + if err := c.client.CallContext(ctx, &res, "warp_getBlockSignature", blockID); err != nil { + return nil, fmt.Errorf("call to warp_getBlockSignature failed. err: %w", err) + } + return res, nil +} + +func (c *client) GetBlockAggregateSignature(ctx context.Context, blockID ids.ID, quorumNum uint64, subnetIDStr string) ([]byte, error) { + var res hexutil.Bytes + if err := c.client.CallContext(ctx, &res, "warp_getBlockAggregateSignature", blockID, quorumNum, subnetIDStr); err != nil { + return nil, fmt.Errorf("call to warp_getBlockAggregateSignature failed. err: %w", err) + } + return res, nil +} diff --git a/vms/evm/warp/service.go b/vms/evm/warp/service.go new file mode 100644 index 000000000000..9dee0be6a878 --- /dev/null +++ b/vms/evm/warp/service.go @@ -0,0 +1,144 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package warp + +import ( + "context" + "errors" + "fmt" + + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/network/p2p/acp118" + "github.com/ava-labs/avalanchego/snow" + "github.com/ava-labs/avalanchego/vms/evm/warp/validators" + "github.com/ava-labs/avalanchego/vms/platformvm/txs/executor" + "github.com/ava-labs/avalanchego/vms/platformvm/warp" + "github.com/ava-labs/avalanchego/vms/platformvm/warp/payload" + "github.com/ava-labs/libevm/common/hexutil" + "github.com/ava-labs/libevm/log" +) + +var errNoValidators = errors.New("cannot aggregate signatures from subnet with no validators") + +// API introduces snowman specific functionality to the evm +type API struct { + chainContext *snow.Context + backend Backend + signatureAggregator *acp118.SignatureAggregator + requirePrimaryNetworkSigners func() bool +} + +func NewAPI(chainCtx *snow.Context, backend Backend, signatureAggregator *acp118.SignatureAggregator, requirePrimaryNetworkSigners func() bool) *API { + return &API{ + backend: backend, + chainContext: chainCtx, + signatureAggregator: signatureAggregator, + requirePrimaryNetworkSigners: requirePrimaryNetworkSigners, + } +} + +// GetMessage returns the Warp message associated with a messageID. +func (a *API) GetMessage(ctx context.Context, messageID ids.ID) (hexutil.Bytes, error) { + message, err := a.backend.GetMessage(messageID) + if err != nil { + return nil, fmt.Errorf("failed to get message %s with error %w", messageID, err) + } + return hexutil.Bytes(message.Bytes()), nil +} + +// GetMessageSignature returns the BLS signature associated with a messageID. +func (a *API) GetMessageSignature(ctx context.Context, messageID ids.ID) (hexutil.Bytes, error) { + unsignedMessage, err := a.backend.GetMessage(messageID) + if err != nil { + return nil, fmt.Errorf("failed to get message %s with error %w", messageID, err) + } + signature, err := a.backend.GetMessageSignature(ctx, unsignedMessage) + if err != nil { + return nil, fmt.Errorf("failed to get signature for message %s with error %w", messageID, err) + } + return signature[:], nil +} + +// GetBlockSignature returns the BLS signature associated with a blockID. +func (a *API) GetBlockSignature(ctx context.Context, blockID ids.ID) (hexutil.Bytes, error) { + signature, err := a.backend.GetBlockSignature(ctx, blockID) + if err != nil { + return nil, fmt.Errorf("failed to get signature for block %s with error %w", blockID, err) + } + return signature[:], nil +} + +// GetMessageAggregateSignature fetches the aggregate signature for the requested [messageID] +func (a *API) GetMessageAggregateSignature(ctx context.Context, messageID ids.ID, quorumNum uint64, subnetIDStr string) (signedMessageBytes hexutil.Bytes, err error) { + unsignedMessage, err := a.backend.GetMessage(messageID) + if err != nil { + return nil, err + } + return a.aggregateSignatures(ctx, unsignedMessage, quorumNum, subnetIDStr) +} + +// GetBlockAggregateSignature fetches the aggregate signature for the requested [blockID] +func (a *API) GetBlockAggregateSignature(ctx context.Context, blockID ids.ID, quorumNum uint64, subnetIDStr string) (signedMessageBytes hexutil.Bytes, err error) { + blockHashPayload, err := payload.NewHash(blockID) + if err != nil { + return nil, err + } + unsignedMessage, err := warp.NewUnsignedMessage(a.chainContext.NetworkID, a.chainContext.ChainID, blockHashPayload.Bytes()) + if err != nil { + return nil, err + } + + return a.aggregateSignatures(ctx, unsignedMessage, quorumNum, subnetIDStr) +} + +func (a *API) aggregateSignatures(ctx context.Context, unsignedMessage *warp.UnsignedMessage, quorumNum uint64, subnetIDStr string) (hexutil.Bytes, error) { + subnetID := a.chainContext.SubnetID + if len(subnetIDStr) > 0 { + sid, err := ids.FromString(subnetIDStr) + if err != nil { + return nil, fmt.Errorf("failed to parse subnetID: %q", subnetIDStr) + } + subnetID = sid + } + validatorState := a.chainContext.ValidatorState + pChainHeight, err := validatorState.GetCurrentHeight(ctx) + if err != nil { + return nil, err + } + + state := validators.NewState(validatorState, a.chainContext.SubnetID, a.chainContext.ChainID, a.requirePrimaryNetworkSigners()) + validatorSet, err := warp.GetCanonicalValidatorSetFromSubnetID(ctx, state, pChainHeight, subnetID) + if err != nil { + return nil, fmt.Errorf("failed to get validator set: %w", err) + } + if len(validatorSet.Validators) == 0 { + return nil, fmt.Errorf("%w (SubnetID: %s, Height: %d)", errNoValidators, subnetID, pChainHeight) + } + + log.Debug("Fetching signature", + "sourceSubnetID", subnetID, + "height", pChainHeight, + "numValidators", len(validatorSet.Validators), + "totalWeight", validatorSet.TotalWeight, + ) + warpMessage := &warp.Message{ + UnsignedMessage: *unsignedMessage, + Signature: &warp.BitSetSignature{}, + } + signedMessage, _, _, err := a.signatureAggregator.AggregateSignatures( + ctx, + warpMessage, + nil, + validatorSet.Validators, + quorumNum, + executor.WarpQuorumDenominator, + ) + if err != nil { + return nil, err + } + // TODO: return the signature and total weight as well to the caller for more complete details + // Need to decide on the best UI for this and write up documentation with the potential + // gotchas that could impact signed messages becoming invalid. + return hexutil.Bytes(signedMessage.Bytes()), nil +} diff --git a/vms/evm/warp/validators/state.go b/vms/evm/warp/validators/state.go new file mode 100644 index 000000000000..58530750698f --- /dev/null +++ b/vms/evm/warp/validators/state.go @@ -0,0 +1,55 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package validators + +import ( + "context" + + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/snow/validators" + "github.com/ava-labs/avalanchego/utils/constants" +) + +var _ validators.State = (*State)(nil) + +// State provides a special case used to handle Avalanche Warp Message verification for messages sent +// from the Primary Network. Subnets have strictly fewer validators than the Primary Network, so we require +// signatures from a threshold of the RECEIVING subnet validator set rather than the full Primary Network +// since the receiving subnet already relies on a majority of its validators being correct. +type State struct { + validators.State + mySubnetID ids.ID + sourceChainID ids.ID + requirePrimaryNetworkSigners bool +} + +// NewState returns a wrapper of [validators.State] which special cases the handling of the Primary Network. +// +// The wrapped state will return the [mySubnetID's] validator set instead of the Primary Network when +// the Primary Network SubnetID is passed in. +func NewState(state validators.State, mySubnetID ids.ID, sourceChainID ids.ID, requirePrimaryNetworkSigners bool) *State { + return &State{ + State: state, + mySubnetID: mySubnetID, + sourceChainID: sourceChainID, + requirePrimaryNetworkSigners: requirePrimaryNetworkSigners, + } +} + +func (s *State) GetValidatorSet( + ctx context.Context, + height uint64, + subnetID ids.ID, +) (map[ids.NodeID]*validators.GetValidatorOutput, error) { + // If the subnetID is anything other than the Primary Network, or Primary + // Network signers are required (except P-Chain), this is a direct passthrough. + usePrimary := s.requirePrimaryNetworkSigners && s.sourceChainID != constants.PlatformChainID + if usePrimary || subnetID != constants.PrimaryNetworkID { + return s.State.GetValidatorSet(ctx, height, subnetID) + } + + // If the requested subnet is the primary network, then we return the validator + // set for the Subnet that is receiving the message instead. + return s.State.GetValidatorSet(ctx, height, s.mySubnetID) +} diff --git a/vms/evm/warp/validators/state_test.go b/vms/evm/warp/validators/state_test.go new file mode 100644 index 000000000000..49ffaf2e3a07 --- /dev/null +++ b/vms/evm/warp/validators/state_test.go @@ -0,0 +1,48 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package validators + +import ( + "context" + "testing" + + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/snow/snowtest" + "github.com/ava-labs/avalanchego/snow/validators" + "github.com/ava-labs/avalanchego/snow/validators/validatorsmock" + "github.com/ava-labs/avalanchego/utils/constants" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func TestGetValidatorSetPrimaryNetwork(t *testing.T) { + require := require.New(t) + ctrl := gomock.NewController(t) + + mySubnetID := ids.GenerateTestID() + otherSubnetID := ids.GenerateTestID() + + mockState := validatorsmock.NewState(ctrl) + snowCtx := snowtest.Context(t, snowtest.CChainID) + snowCtx.SubnetID = mySubnetID + snowCtx.ValidatorState = mockState + state := NewState(snowCtx.ValidatorState, snowCtx.SubnetID, snowCtx.ChainID, false) + // Expect that requesting my validator set returns my validator set + mockState.EXPECT().GetValidatorSet(gomock.Any(), gomock.Any(), mySubnetID).Return(make(map[ids.NodeID]*validators.GetValidatorOutput), nil) + output, err := state.GetValidatorSet(context.Background(), 10, mySubnetID) + require.NoError(err) + require.Len(output, 0) + + // Expect that requesting the Primary Network validator set overrides and returns my validator set + mockState.EXPECT().GetValidatorSet(gomock.Any(), gomock.Any(), mySubnetID).Return(make(map[ids.NodeID]*validators.GetValidatorOutput), nil) + output, err = state.GetValidatorSet(context.Background(), 10, constants.PrimaryNetworkID) + require.NoError(err) + require.Len(output, 0) + + // Expect that requesting other validator set returns that validator set + mockState.EXPECT().GetValidatorSet(gomock.Any(), gomock.Any(), otherSubnetID).Return(make(map[ids.NodeID]*validators.GetValidatorOutput), nil) + output, err = state.GetValidatorSet(context.Background(), 10, otherSubnetID) + require.NoError(err) + require.Len(output, 0) +} diff --git a/vms/evm/warp/verifier_backend.go b/vms/evm/warp/verifier_backend.go new file mode 100644 index 000000000000..9192744c0f26 --- /dev/null +++ b/vms/evm/warp/verifier_backend.go @@ -0,0 +1,131 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package warp + +import ( + "context" + "fmt" + + "github.com/ava-labs/avalanchego/vms/platformvm/warp/message" + + "github.com/ava-labs/avalanchego/database" + "github.com/ava-labs/avalanchego/snow/engine/common" + avalancheWarp "github.com/ava-labs/avalanchego/vms/platformvm/warp" + "github.com/ava-labs/avalanchego/vms/platformvm/warp/payload" +) + +const ( + ParseErrCode = iota + 1 + VerifyErrCode +) + +// Verify verifies the signature of the message +// It also implements the acp118.Verifier interface +func (b *backend) Verify(ctx context.Context, unsignedMessage *avalancheWarp.UnsignedMessage, _ []byte) *common.AppError { + messageID := unsignedMessage.ID() + // Known on-chain messages should be signed + if _, err := b.GetMessage(messageID); err == nil { + return nil + } else if err != database.ErrNotFound { + return &common.AppError{ + Code: ParseErrCode, + Message: fmt.Sprintf("failed to get message %s: %s", messageID, err.Error()), + } + } + + parsed, err := payload.Parse(unsignedMessage.Payload) + if err != nil { + b.stats.IncMessageParseFail() + return &common.AppError{ + Code: ParseErrCode, + Message: "failed to parse payload: " + err.Error(), + } + } + + switch p := parsed.(type) { + case *payload.AddressedCall: + return b.verifyOffchainAddressedCall(p) + case *payload.Hash: + return b.verifyBlockMessage(ctx, p) + default: + b.stats.IncMessageParseFail() + return &common.AppError{ + Code: ParseErrCode, + Message: fmt.Sprintf("unknown payload type: %T", p), + } + } +} + +// verifyBlockMessage returns nil if blockHashPayload contains the ID +// of an accepted block indicating it should be signed by the VM. +func (b *backend) verifyBlockMessage(ctx context.Context, blockHashPayload *payload.Hash) *common.AppError { + blockID := blockHashPayload.Hash + _, err := b.blockClient.GetAcceptedBlock(ctx, blockID) + if err != nil { + b.stats.IncBlockValidationFail() + return &common.AppError{ + Code: VerifyErrCode, + Message: fmt.Sprintf("failed to get block %s: %s", blockID, err.Error()), + } + } + + return nil +} + +// verifyOffchainAddressedCall verifies the addressed call message +func (b *backend) verifyOffchainAddressedCall(addressedCall *payload.AddressedCall) *common.AppError { + // Further, parse the payload to see if it is a known type. + parsed, err := message.Parse(addressedCall.Payload) + if err != nil { + b.stats.IncMessageParseFail() + return &common.AppError{ + Code: ParseErrCode, + Message: "failed to parse addressed call message: " + err.Error(), + } + } + + if len(addressedCall.SourceAddress) != 0 { + return &common.AppError{ + Code: VerifyErrCode, + Message: "source address should be empty for offchain addressed messages", + } + } + + switch p := parsed.(type) { + case *message.ValidatorUptime: + if err := b.verifyUptimeMessage(p); err != nil { + b.stats.IncUptimeValidationFail() + return err + } + default: + b.stats.IncMessageParseFail() + return &common.AppError{ + Code: ParseErrCode, + Message: fmt.Sprintf("unknown message type: %T", p), + } + } + + return nil +} + +func (b *backend) verifyUptimeMessage(uptimeMsg *message.ValidatorUptime) *common.AppError { + vdr, currentUptime, _, err := b.validatorReader.GetValidatorAndUptime(uptimeMsg.ValidationID) + if err != nil { + return &common.AppError{ + Code: VerifyErrCode, + Message: fmt.Sprintf("failed to get uptime for validationID %s: %s", uptimeMsg.ValidationID, err.Error()), + } + } + + currentUptimeSeconds := uint64(currentUptime.Seconds()) + // verify the current uptime against the total uptime in the message + if currentUptimeSeconds < uptimeMsg.TotalUptime { + return &common.AppError{ + Code: VerifyErrCode, + Message: fmt.Sprintf("current uptime %d is less than queried uptime %d for nodeID %s", currentUptimeSeconds, uptimeMsg.TotalUptime, vdr.NodeID), + } + } + + return nil +} diff --git a/vms/evm/warp/verifier_backend_test.go b/vms/evm/warp/verifier_backend_test.go new file mode 100644 index 000000000000..3cc5c3e1301a --- /dev/null +++ b/vms/evm/warp/verifier_backend_test.go @@ -0,0 +1,366 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package warp + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/ava-labs/avalanchego/cache" + "github.com/ava-labs/avalanchego/cache/lru" + "github.com/ava-labs/avalanchego/database/memdb" + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/network/p2p/acp118" + "github.com/ava-labs/avalanchego/proto/pb/sdk" + "github.com/ava-labs/avalanchego/snow/engine/common" + "github.com/ava-labs/avalanchego/snow/snowtest" + "github.com/ava-labs/avalanchego/utils/timer/mockable" + "github.com/ava-labs/avalanchego/vms/evm/metrics/metricstest" + "github.com/ava-labs/avalanchego/vms/evm/warp/warptest" + "github.com/ava-labs/avalanchego/vms/platformvm/warp" + avalancheWarp "github.com/ava-labs/avalanchego/vms/platformvm/warp" + "github.com/ava-labs/avalanchego/vms/platformvm/warp/message" + "github.com/ava-labs/avalanchego/vms/platformvm/warp/payload" + + // TODO: FIGURE OUT HOW TO GET RID OF THIS IMPORT + "github.com/ava-labs/subnet-evm/plugin/evm/validators" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" +) + +func TestAddressedCallSignatures(t *testing.T) { + metricstest.WithMetrics(t) + + database := memdb.New() + snowCtx := snowtest.Context(t, snowtest.CChainID) + + offChainPayload, err := payload.NewAddressedCall([]byte{1, 2, 3}, []byte{1, 2, 3}) + require.NoError(t, err) + offchainMessage, err := warp.NewUnsignedMessage(snowCtx.NetworkID, snowCtx.ChainID, offChainPayload.Bytes()) + require.NoError(t, err) + offchainSignature, err := snowCtx.WarpSigner.Sign(offchainMessage) + require.NoError(t, err) + + tests := map[string]struct { + setup func(backend Backend) (request []byte, expectedResponse []byte) + verifyStats func(t *testing.T, stats *verifierStats) + err error + }{ + "known message": { + setup: func(backend Backend) (request []byte, expectedResponse []byte) { + knownPayload, err := payload.NewAddressedCall([]byte{0, 0, 0}, []byte("test")) + require.NoError(t, err) + msg, err := warp.NewUnsignedMessage(snowCtx.NetworkID, snowCtx.ChainID, knownPayload.Bytes()) + require.NoError(t, err) + signature, err := snowCtx.WarpSigner.Sign(msg) + require.NoError(t, err) + + backend.AddMessage(msg) + return msg.Bytes(), signature[:] + }, + verifyStats: func(t *testing.T, stats *verifierStats) { + require.EqualValues(t, 0, stats.messageParseFail.Snapshot().Count()) + require.EqualValues(t, 0, stats.blockValidationFail.Snapshot().Count()) + }, + }, + "offchain message": { + setup: func(_ Backend) (request []byte, expectedResponse []byte) { + return offchainMessage.Bytes(), offchainSignature[:] + }, + verifyStats: func(t *testing.T, stats *verifierStats) { + require.EqualValues(t, 0, stats.messageParseFail.Snapshot().Count()) + require.EqualValues(t, 0, stats.blockValidationFail.Snapshot().Count()) + }, + }, + "unknown message": { + setup: func(_ Backend) (request []byte, expectedResponse []byte) { + unknownPayload, err := payload.NewAddressedCall([]byte{0, 0, 0}, []byte("unknown message")) + require.NoError(t, err) + unknownMessage, err := warp.NewUnsignedMessage(snowCtx.NetworkID, snowCtx.ChainID, unknownPayload.Bytes()) + require.NoError(t, err) + return unknownMessage.Bytes(), nil + }, + verifyStats: func(t *testing.T, stats *verifierStats) { + require.EqualValues(t, 1, stats.messageParseFail.Snapshot().Count()) + require.EqualValues(t, 0, stats.blockValidationFail.Snapshot().Count()) + }, + err: &common.AppError{Code: ParseErrCode}, + }, + } + + for name, test := range tests { + for _, withCache := range []bool{true, false} { + if withCache { + name += "_with_cache" + } else { + name += "_no_cache" + } + t.Run(name, func(t *testing.T) { + var sigCache cache.Cacher[ids.ID, []byte] + if withCache { + sigCache = lru.NewCache[ids.ID, []byte](100) + } else { + sigCache = &cache.Empty[ids.ID, []byte]{} + } + warpBackend, err := NewBackend( + snowCtx.NetworkID, + snowCtx.ChainID, + snowCtx.WarpSigner, + warptest.EmptyBlockClient, + nil, + database, + sigCache, + [][]byte{offchainMessage.Bytes()}, + ) + require.NoError(t, err) + handler := acp118.NewCachedHandler(sigCache, warpBackend, snowCtx.WarpSigner) + + requestBytes, expectedResponse := test.setup(warpBackend) + protoMsg := &sdk.SignatureRequest{Message: requestBytes} + protoBytes, err := proto.Marshal(protoMsg) + require.NoError(t, err) + responseBytes, appErr := handler.AppRequest(context.Background(), ids.GenerateTestNodeID(), time.Time{}, protoBytes) + if test.err != nil { + require.Error(t, appErr) + require.ErrorIs(t, appErr, test.err) + } else { + require.Nil(t, appErr) + } + + test.verifyStats(t, warpBackend.(*backend).stats) + + // If the expected response is empty, assert that the handler returns an empty response and return early. + if len(expectedResponse) == 0 { + require.Len(t, responseBytes, 0, "expected response to be empty") + return + } + // check cache is populated + if withCache { + require.NotZero(t, warpBackend.(*backend).signatureCache.Len()) + } else { + require.Zero(t, warpBackend.(*backend).signatureCache.Len()) + } + response := &sdk.SignatureResponse{} + require.NoError(t, proto.Unmarshal(responseBytes, response)) + require.NoError(t, err, "error unmarshalling SignatureResponse") + + require.Equal(t, expectedResponse, response.Signature) + }) + } + } +} + +func TestBlockSignatures(t *testing.T) { + metricstest.WithMetrics(t) + + database := memdb.New() + snowCtx := snowtest.Context(t, snowtest.CChainID) + + knownBlkID := ids.GenerateTestID() + blockClient := warptest.MakeBlockClient(knownBlkID) + + toMessageBytes := func(id ids.ID) []byte { + idPayload, err := payload.NewHash(id) + if err != nil { + panic(err) + } + + msg, err := warp.NewUnsignedMessage(snowCtx.NetworkID, snowCtx.ChainID, idPayload.Bytes()) + if err != nil { + panic(err) + } + + return msg.Bytes() + } + + tests := map[string]struct { + setup func() (request []byte, expectedResponse []byte) + verifyStats func(t *testing.T, stats *verifierStats) + err error + }{ + "known block": { + setup: func() (request []byte, expectedResponse []byte) { + hashPayload, err := payload.NewHash(knownBlkID) + require.NoError(t, err) + unsignedMessage, err := warp.NewUnsignedMessage(snowCtx.NetworkID, snowCtx.ChainID, hashPayload.Bytes()) + require.NoError(t, err) + signature, err := snowCtx.WarpSigner.Sign(unsignedMessage) + require.NoError(t, err) + return toMessageBytes(knownBlkID), signature[:] + }, + verifyStats: func(t *testing.T, stats *verifierStats) { + require.EqualValues(t, 0, stats.blockValidationFail.Snapshot().Count()) + require.EqualValues(t, 0, stats.messageParseFail.Snapshot().Count()) + }, + }, + "unknown block": { + setup: func() (request []byte, expectedResponse []byte) { + unknownBlockID := ids.GenerateTestID() + return toMessageBytes(unknownBlockID), nil + }, + verifyStats: func(t *testing.T, stats *verifierStats) { + require.EqualValues(t, 1, stats.blockValidationFail.Snapshot().Count()) + require.EqualValues(t, 0, stats.messageParseFail.Snapshot().Count()) + }, + err: &common.AppError{Code: VerifyErrCode}, + }, + } + + for name, test := range tests { + for _, withCache := range []bool{true, false} { + if withCache { + name += "_with_cache" + } else { + name += "_no_cache" + } + t.Run(name, func(t *testing.T) { + var sigCache cache.Cacher[ids.ID, []byte] + if withCache { + sigCache = lru.NewCache[ids.ID, []byte](100) + } else { + sigCache = &cache.Empty[ids.ID, []byte]{} + } + warpBackend, err := NewBackend( + snowCtx.NetworkID, + snowCtx.ChainID, + snowCtx.WarpSigner, + blockClient, + warptest.NoOpValidatorReader{}, + database, + sigCache, + nil, + ) + require.NoError(t, err) + handler := acp118.NewCachedHandler(sigCache, warpBackend, snowCtx.WarpSigner) + + requestBytes, expectedResponse := test.setup() + protoMsg := &sdk.SignatureRequest{Message: requestBytes} + protoBytes, err := proto.Marshal(protoMsg) + require.NoError(t, err) + responseBytes, appErr := handler.AppRequest(context.Background(), ids.GenerateTestNodeID(), time.Time{}, protoBytes) + if test.err != nil { + require.NotNil(t, appErr) + require.ErrorIs(t, test.err, appErr) + } else { + require.Nil(t, appErr) + } + + test.verifyStats(t, warpBackend.(*backend).stats) + + // If the expected response is empty, assert that the handler returns an empty response and return early. + if len(expectedResponse) == 0 { + require.Len(t, responseBytes, 0, "expected response to be empty") + return + } + // check cache is populated + if withCache { + require.NotZero(t, warpBackend.(*backend).signatureCache.Len()) + } else { + require.Zero(t, warpBackend.(*backend).signatureCache.Len()) + } + var response sdk.SignatureResponse + err = proto.Unmarshal(responseBytes, &response) + require.NoError(t, err, "error unmarshalling SignatureResponse") + require.Equal(t, expectedResponse, response.Signature) + }) + } + } +} + +func TestUptimeSignatures(t *testing.T) { + database := memdb.New() + snowCtx := snowtest.Context(t, snowtest.CChainID) + + getUptimeMessageBytes := func(sourceAddress []byte, vID ids.ID, totalUptime uint64) ([]byte, *avalancheWarp.UnsignedMessage) { + uptimePayload, err := message.NewValidatorUptime(vID, 80) + require.NoError(t, err) + addressedCall, err := payload.NewAddressedCall(sourceAddress, uptimePayload.Bytes()) + require.NoError(t, err) + unsignedMessage, err := avalancheWarp.NewUnsignedMessage(snowCtx.NetworkID, snowCtx.ChainID, addressedCall.Bytes()) + require.NoError(t, err) + + protoMsg := &sdk.SignatureRequest{Message: unsignedMessage.Bytes()} + protoBytes, err := proto.Marshal(protoMsg) + require.NoError(t, err) + return protoBytes, unsignedMessage + } + + for _, withCache := range []bool{true, false} { + var sigCache cache.Cacher[ids.ID, []byte] + if withCache { + sigCache = lru.NewCache[ids.ID, []byte](100) + } else { + sigCache = &cache.Empty[ids.ID, []byte]{} + } + chainCtx := snowtest.Context(t, snowtest.CChainID) + clk := &mockable.Clock{} + validatorsManager, err := validators.NewManager(chainCtx, memdb.New(), clk) + require.NoError(t, err) + lock := &sync.RWMutex{} + newLockedValidatorManager := validators.NewLockedValidatorReader(validatorsManager, lock) + validatorsManager.StartTracking([]ids.NodeID{}) + warpBackend, err := NewBackend( + snowCtx.NetworkID, + snowCtx.ChainID, + snowCtx.WarpSigner, + warptest.EmptyBlockClient, + newLockedValidatorManager, + database, + sigCache, + nil, + ) + require.NoError(t, err) + handler := acp118.NewCachedHandler(sigCache, warpBackend, snowCtx.WarpSigner) + + // sourceAddress nonZero + protoBytes, _ := getUptimeMessageBytes([]byte{1, 2, 3}, ids.GenerateTestID(), 80) + _, appErr := handler.AppRequest(context.Background(), ids.GenerateTestNodeID(), time.Time{}, protoBytes) + require.ErrorIs(t, appErr, &common.AppError{Code: VerifyErrCode}) + require.Contains(t, appErr.Error(), "source address should be empty") + + // not existing validationID + vID := ids.GenerateTestID() + protoBytes, _ = getUptimeMessageBytes([]byte{}, vID, 80) + _, appErr = handler.AppRequest(context.Background(), ids.GenerateTestNodeID(), time.Time{}, protoBytes) + require.ErrorIs(t, appErr, &common.AppError{Code: VerifyErrCode}) + require.Contains(t, appErr.Error(), "failed to get validator") + + // uptime is less than requested (not connected) + validationID := ids.GenerateTestID() + nodeID := ids.GenerateTestNodeID() + require.NoError(t, validatorsManager.AddValidator(warptest.Validator{ + ValidationID: validationID, + NodeID: nodeID, + Weight: 1, + StartTimestamp: clk.Unix(), + IsActive: true, + IsL1Validator: true, + })) + protoBytes, _ = getUptimeMessageBytes([]byte{}, validationID, 80) + _, appErr = handler.AppRequest(context.Background(), nodeID, time.Time{}, protoBytes) + require.ErrorIs(t, appErr, &common.AppError{Code: VerifyErrCode}) + require.Contains(t, appErr.Error(), "current uptime 0 is less than queried uptime 80") + + // uptime is less than requested (not enough) + require.NoError(t, validatorsManager.Connect(nodeID)) + clk.Set(clk.Time().Add(40 * time.Second)) + protoBytes, _ = getUptimeMessageBytes([]byte{}, validationID, 80) + _, appErr = handler.AppRequest(context.Background(), nodeID, time.Time{}, protoBytes) + require.ErrorIs(t, appErr, &common.AppError{Code: VerifyErrCode}) + require.Contains(t, appErr.Error(), "current uptime 40 is less than queried uptime 80") + + // valid uptime + clk.Set(clk.Time().Add(40 * time.Second)) + protoBytes, msg := getUptimeMessageBytes([]byte{}, validationID, 80) + responseBytes, appErr := handler.AppRequest(context.Background(), nodeID, time.Time{}, protoBytes) + require.Nil(t, appErr) + expectedSignature, err := snowCtx.WarpSigner.Sign(msg) + require.NoError(t, err) + response := &sdk.SignatureResponse{} + require.NoError(t, proto.Unmarshal(responseBytes, response)) + require.Equal(t, expectedSignature[:], response.Signature) + } +} diff --git a/vms/evm/warp/verifier_stats.go b/vms/evm/warp/verifier_stats.go new file mode 100644 index 000000000000..6c870ce848fa --- /dev/null +++ b/vms/evm/warp/verifier_stats.go @@ -0,0 +1,41 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package warp + +import "github.com/ava-labs/libevm/metrics" + +type verifierStats struct { + messageParseFail metrics.Counter + // AddressedCall metrics + addressedCallValidationFail metrics.Counter + // BlockRequest metrics + blockValidationFail metrics.Counter + // Uptime metrics + uptimeValidationFail metrics.Counter +} + +func newVerifierStats() *verifierStats { + return &verifierStats{ + messageParseFail: metrics.NewRegisteredCounter("warp_backend_message_parse_fail", nil), + addressedCallValidationFail: metrics.NewRegisteredCounter("warp_backend_addressed_call_validation_fail", nil), + blockValidationFail: metrics.NewRegisteredCounter("warp_backend_block_validation_fail", nil), + uptimeValidationFail: metrics.NewRegisteredCounter("warp_backend_uptime_validation_fail", nil), + } +} + +func (h *verifierStats) IncAddressedCallValidationFail() { + h.addressedCallValidationFail.Inc(1) +} + +func (h *verifierStats) IncBlockValidationFail() { + h.blockValidationFail.Inc(1) +} + +func (h *verifierStats) IncMessageParseFail() { + h.messageParseFail.Inc(1) +} + +func (h *verifierStats) IncUptimeValidationFail() { + h.uptimeValidationFail.Inc(1) +} diff --git a/vms/evm/warp/warptest/block_client.go b/vms/evm/warp/warptest/block_client.go new file mode 100644 index 000000000000..7f98d4f79786 --- /dev/null +++ b/vms/evm/warp/warptest/block_client.go @@ -0,0 +1,43 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +// warptest exposes common functionality for testing the warp package. +package warptest + +import ( + "context" + "slices" + + "github.com/ava-labs/avalanchego/database" + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/snow/consensus/snowman" + "github.com/ava-labs/avalanchego/snow/consensus/snowman/snowmantest" + "github.com/ava-labs/avalanchego/snow/snowtest" +) + +// EmptyBlockClient returns an error if a block is requested +var EmptyBlockClient BlockClient = MakeBlockClient() + +type BlockClient func(ctx context.Context, blockID ids.ID) (snowman.Block, error) + +func (f BlockClient) GetAcceptedBlock(ctx context.Context, blockID ids.ID) (snowman.Block, error) { + return f(ctx, blockID) +} + +// MakeBlockClient returns a new BlockClient that returns the provided blocks. +// If a block is requested that isn't part of the provided blocks, an error is +// returned. +func MakeBlockClient(blkIDs ...ids.ID) BlockClient { + return func(_ context.Context, blkID ids.ID) (snowman.Block, error) { + if !slices.Contains(blkIDs, blkID) { + return nil, database.ErrNotFound + } + + return &snowmantest.Block{ + Decidable: snowtest.Decidable{ + IDV: blkID, + Status: snowtest.Accepted, + }, + }, nil + } +} diff --git a/vms/evm/warp/warptest/noop_validator_reader.go b/vms/evm/warp/warptest/noop_validator_reader.go new file mode 100644 index 000000000000..69d598996fb8 --- /dev/null +++ b/vms/evm/warp/warptest/noop_validator_reader.go @@ -0,0 +1,35 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +// warptest exposes common functionality for testing the warp package. +package warptest + +import ( + "time" + + "github.com/ava-labs/avalanchego/ids" +) + +var _ ValidatorReader = (*NoOpValidatorReader)(nil) + +type Validator struct { + ValidationID ids.ID `json:"validationID"` + NodeID ids.NodeID `json:"nodeID"` + Weight uint64 `json:"weight"` + StartTimestamp uint64 `json:"startTimestamp"` + IsActive bool `json:"isActive"` + IsL1Validator bool `json:"isL1Validator"` +} + +type ValidatorReader interface { + // GetValidatorAndUptime returns the calculated uptime of the validator specified by validationID + // and the last updated time. + // GetValidatorAndUptime holds the VM lock while performing the operation and can be called concurrently. + GetValidatorAndUptime(validationID ids.ID) (Validator, time.Duration, time.Time, error) +} + +type NoOpValidatorReader struct{} + +func (NoOpValidatorReader) GetValidatorAndUptime(ids.ID) (Validator, time.Duration, time.Time, error) { + return Validator{}, 0, time.Time{}, nil +} diff --git a/vms/platformvm/warp/message/validator_uptime.go b/vms/platformvm/warp/message/validator_uptime.go new file mode 100644 index 000000000000..47d12f4306a3 --- /dev/null +++ b/vms/platformvm/warp/message/validator_uptime.go @@ -0,0 +1,51 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package message + +import ( + "fmt" + + "github.com/ava-labs/avalanchego/ids" +) + +// ValidatorUptime is signed when the ValidationID is known and the validator +// has been up for TotalUptime seconds. +type ValidatorUptime struct { + ValidationID ids.ID `serialize:"true"` + TotalUptime uint64 `serialize:"true"` // in seconds + + bytes []byte +} + +// NewValidatorUptime creates a new *ValidatorUptime and initializes it. +func NewValidatorUptime(validationID ids.ID, totalUptime uint64) (*ValidatorUptime, error) { + bhp := &ValidatorUptime{ + ValidationID: validationID, + TotalUptime: totalUptime, + } + return bhp, Initialize(bhp) +} + +// ParseValidatorUptime converts a slice of bytes into an initialized ValidatorUptime. +func ParseValidatorUptime(b []byte) (*ValidatorUptime, error) { + payloadIntf, err := Parse(b) + if err != nil { + return nil, err + } + payload, ok := payloadIntf.(*ValidatorUptime) + if !ok { + return nil, fmt.Errorf("%w: %T", ErrWrongType, payloadIntf) + } + return payload, nil +} + +// Bytes returns the binary representation of this payload. It assumes that the +// payload is initialized from either NewValidatorUptime or Parse. +func (b *ValidatorUptime) Bytes() []byte { + return b.bytes +} + +func (b *ValidatorUptime) initialize(bytes []byte) { + b.bytes = bytes +}