Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
207 changes: 207 additions & 0 deletions vms/evm/warp/backend.go
Original file line number Diff line number Diff line change
@@ -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
}
182 changes: 182 additions & 0 deletions vms/evm/warp/backend_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
Loading
Loading