diff --git a/contracts/contracts/ExampleWarp.sol b/contracts/contracts/ExampleWarp.sol new file mode 100644 index 0000000000..cbfb60ac73 --- /dev/null +++ b/contracts/contracts/ExampleWarp.sol @@ -0,0 +1,42 @@ +//SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; +pragma experimental ABIEncoderV2; + +import "./interfaces/IWarpMessenger.sol"; + +contract ExampleWarp { + address constant WARP_ADDRESS = 0x0200000000000000000000000000000000000005; + WarpMessenger warp = WarpMessenger(WARP_ADDRESS); + + // sendWarpMessage sends a warp message to the specified destination chain and address pair containing the payload + function sendWarpMessage( + bytes32 destinationChainID, + address destinationAddress, + bytes calldata payload + ) external { + warp.sendWarpMessage(destinationChainID, destinationAddress, payload); + } + + + // validateWarpMessage retrieves the warp message attached to the transaction and verifies all of its attributes. + function validateWarpMessage( + bytes32 originChainID, + address originSenderAddress, + bytes32 destinationChainID, + address destinationAddress, + bytes calldata payload + ) external view { + (WarpMessage memory message, bool exists) = warp.getVerifiedWarpMessage(); + require(exists); + require(message.originChainID == originChainID); + require(message.originSenderAddress == originSenderAddress); + require(message.destinationChainID == destinationChainID); + require(message.destinationAddress == destinationAddress); + require(keccak256(message.payload) == keccak256(payload)); + } + + // validateGetBlockchainID checks that the blockchainID returned by warp matches the argument + function validateGetBlockchainID(bytes32 blockchainID) external view { + require(blockchainID == warp.getBlockchainID()); + } +} diff --git a/contracts/contracts/interfaces/IWarpMessenger.sol b/contracts/contracts/interfaces/IWarpMessenger.sol new file mode 100644 index 0000000000..38a8d6e3cd --- /dev/null +++ b/contracts/contracts/interfaces/IWarpMessenger.sol @@ -0,0 +1,56 @@ +// (c) 2022-2023, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +struct WarpMessage { + bytes32 originChainID; + address originSenderAddress; + bytes32 destinationChainID; + address destinationAddress; + bytes payload; +} + +interface WarpMessenger { + event SendWarpMessage( + bytes32 indexed destinationChainID, + address indexed destinationAddress, + address indexed sender, + bytes message + ); + + // sendWarpMessage emits a request for the subnet to send a warp message from [msg.sender] + // with the specified parameters. + // This emits a SendWarpMessage log from the precompile. When the corresponding block is accepted + // the Accept hook of the Warp precompile is invoked with all accepted logs emitted by the Warp + // precompile. + // Each validator then adds the UnsignedWarpMessage encoded in the log to the set of messages + // it is willing to sign for an off-chain relayer to aggregate Warp signatures. + function sendWarpMessage( + bytes32 destinationChainID, + address destinationAddress, + bytes calldata payload + ) external; + + // getVerifiedWarpMessage parses the pre-verified warp message in the + // predicate storage slots as a WarpMessage and returns it to the caller. + // Returns false if no such predicate exists. + function getVerifiedWarpMessage() + external view + returns (WarpMessage calldata message, bool exists); + + // Note: getVerifiedWarpMessage takes no arguments because it returns a single verified + // message that is encoded in the predicate (inside the tx access list) of the transaction. + // The alternative design to this is to verify messages during the EVM's execution in which + // case there would be no predicate and the block would encode the hits/misses that occur + // throughout its execution. + // This would result in the following alternative function signature: + // function verifyMessage(bytes calldata signedWarpMsg) external returns (WarpMessage calldata message); + + // getBlockchainID returns the snow.Context BlockchainID of this chain. + // This blockchainID is the hash of the transaction that created this blockchain on the P-Chain + // and is not related to the Ethereum ChainID. + function getBlockchainID() external view returns (bytes32 blockchainID); +} diff --git a/core/state/statedb.go b/core/state/statedb.go index 45870a38ad..a6b48ee995 100644 --- a/core/state/statedb.go +++ b/core/state/statedb.go @@ -159,19 +159,20 @@ func NewWithSnapshot(root common.Hash, db Database, snap snapshot.Snapshot) (*St return nil, err } sdb := &StateDB{ - db: db, - trie: tr, - originalRoot: root, - stateObjects: make(map[common.Address]*stateObject), - stateObjectsPending: make(map[common.Address]struct{}), - stateObjectsDirty: make(map[common.Address]struct{}), - stateObjectsDestruct: make(map[common.Address]struct{}), - logs: make(map[common.Hash][]*types.Log), - preimages: make(map[common.Hash][]byte), - journal: newJournal(), - accessList: newAccessList(), - transientStorage: newTransientStorage(), - hasher: crypto.NewKeccakState(), + db: db, + trie: tr, + originalRoot: root, + stateObjects: make(map[common.Address]*stateObject), + stateObjectsPending: make(map[common.Address]struct{}), + stateObjectsDirty: make(map[common.Address]struct{}), + stateObjectsDestruct: make(map[common.Address]struct{}), + logs: make(map[common.Hash][]*types.Log), + preimages: make(map[common.Hash][]byte), + journal: newJournal(), + predicateStorageSlots: make(map[common.Address][]byte), + accessList: newAccessList(), + transientStorage: newTransientStorage(), + hasher: crypto.NewKeccakState(), } if snap != nil { if snap.Root() != root { diff --git a/params/avalanche_params.go b/params/avalanche_params.go index 1cb35ee5c0..fbda5b01c0 100644 --- a/params/avalanche_params.go +++ b/params/avalanche_params.go @@ -4,5 +4,7 @@ package params const ( - WarpQuorumDenominator uint64 = 100 + WarpDefaultQuorumNumerator uint64 = 67 + WarpQuorumNumeratorMinimum uint64 = 33 + WarpQuorumDenominator uint64 = 100 ) diff --git a/plugin/evm/network_handler.go b/plugin/evm/network_handler.go index 93bcbdc0f9..b78684938d 100644 --- a/plugin/evm/network_handler.go +++ b/plugin/evm/network_handler.go @@ -14,7 +14,9 @@ import ( syncHandlers "github.com/ava-labs/subnet-evm/sync/handlers" syncStats "github.com/ava-labs/subnet-evm/sync/handlers/stats" "github.com/ava-labs/subnet-evm/trie" + "github.com/ava-labs/subnet-evm/warp" warpHandlers "github.com/ava-labs/subnet-evm/warp/handlers" + warpStats "github.com/ava-labs/subnet-evm/warp/handlers/stats" ) var _ message.RequestHandler = &networkHandler{} @@ -31,17 +33,15 @@ func newNetworkHandler( provider syncHandlers.SyncDataProvider, diskDB ethdb.KeyValueReader, evmTrieDB *trie.Database, + warpBackend warp.WarpBackend, networkCodec codec.Manager, ) message.RequestHandler { syncStats := syncStats.NewHandlerStats(metrics.Enabled) return &networkHandler{ - // State sync handlers stateTrieLeafsRequestHandler: syncHandlers.NewLeafsRequestHandler(evmTrieDB, provider, networkCodec, syncStats), blockRequestHandler: syncHandlers.NewBlockRequestHandler(provider, networkCodec, syncStats), codeRequestHandler: syncHandlers.NewCodeRequestHandler(diskDB, networkCodec, syncStats), - - // TODO: initialize actual signature request handler when warp is ready - signatureRequestHandler: &warpHandlers.NoopSignatureRequestHandler{}, + signatureRequestHandler: warpHandlers.NewSignatureRequestHandler(warpBackend, networkCodec, warpStats.NewStats()), } } diff --git a/plugin/evm/vm.go b/plugin/evm/vm.go index f5afc1f13f..a45648a766 100644 --- a/plugin/evm/vm.go +++ b/plugin/evm/vm.go @@ -621,7 +621,7 @@ func (vm *VM) setAppRequestHandlers() { }, ) - networkHandler := newNetworkHandler(vm.blockChain, vm.chaindb, evmTrieDB, vm.networkCodec) + networkHandler := newNetworkHandler(vm.blockChain, vm.chaindb, evmTrieDB, vm.warpBackend, vm.networkCodec) vm.Network.SetRequestHandler(networkHandler) } diff --git a/plugin/evm/vm_test.go b/plugin/evm/vm_test.go index f352324cf3..7e5a1bfbec 100644 --- a/plugin/evm/vm_test.go +++ b/plugin/evm/vm_test.go @@ -3156,24 +3156,61 @@ func TestCrossChainMessagestoVM(t *testing.T) { } func TestSignatureRequestsToVM(t *testing.T) { - _, vm, _, _ := GenesisVM(t, true, genesisJSONSubnetEVM, "", "") + _, vm, _, appSender := GenesisVM(t, true, genesisJSONSubnetEVM, "", "") defer func() { err := vm.Shutdown(context.Background()) require.NoError(t, err) }() - // Generate a SignatureRequest for an unknown message - var signatureRequest message.Request = message.SignatureRequest{ - MessageID: ids.GenerateTestID(), - } - - requestBytes, err := message.Codec.Marshal(message.Version, &signatureRequest) + // Generate a new warp unsigned message and add to warp backend + warpMessage, err := avalancheWarp.NewUnsignedMessage(vm.ctx.NetworkID, vm.ctx.ChainID, []byte{1, 2, 3}) require.NoError(t, err) - // Currently with warp not being initialized we just need to make sure the NoopSignatureRequestHandler does not - // panic/crash when sent a SignatureRequest. - // TODO: We will need to update the test when warp is initialized to check for expected response. - err = vm.Network.AppRequest(context.Background(), ids.GenerateTestNodeID(), 1, time.Now().Add(60*time.Second), requestBytes) + // Add the known message and get its signature to confirm. + err = vm.warpBackend.AddMessage(warpMessage) require.NoError(t, err) + signature, err := vm.warpBackend.GetSignature(warpMessage.ID()) + require.NoError(t, err) + + tests := map[string]struct { + messageID ids.ID + expectedResponse [bls.SignatureLen]byte + }{ + "known": { + messageID: warpMessage.ID(), + expectedResponse: signature, + }, + "unknown": { + messageID: ids.GenerateTestID(), + expectedResponse: [bls.SignatureLen]byte{}, + }, + } + + for name, test := range tests { + calledSendAppResponseFn := false + appSender.SendAppResponseF = func(ctx context.Context, nodeID ids.NodeID, requestID uint32, responseBytes []byte) error { + calledSendAppResponseFn = true + var response message.SignatureResponse + _, err := message.Codec.Unmarshal(responseBytes, &response) + require.NoError(t, err) + require.Equal(t, test.expectedResponse, response.Signature) + + return nil + } + t.Run(name, func(t *testing.T) { + var signatureRequest message.Request = message.SignatureRequest{ + MessageID: test.messageID, + } + + requestBytes, err := message.Codec.Marshal(message.Version, &signatureRequest) + require.NoError(t, err) + + // Send the app request and make sure we called SendAppResponseFn + deadline := time.Now().Add(60 * time.Second) + err = vm.Network.AppRequest(context.Background(), ids.GenerateTestNodeID(), 1, deadline, requestBytes) + require.NoError(t, err) + require.True(t, calledSendAppResponseFn) + }) + } } diff --git a/plugin/evm/vm_warp_test.go b/plugin/evm/vm_warp_test.go new file mode 100644 index 0000000000..84c92dbea2 --- /dev/null +++ b/plugin/evm/vm_warp_test.go @@ -0,0 +1,317 @@ +// (c) 2023, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package evm + +import ( + "context" + "errors" + "math/big" + "testing" + "time" + + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/snow/choices" + "github.com/ava-labs/avalanchego/snow/engine/snowman/block" + "github.com/ava-labs/avalanchego/snow/validators" + "github.com/ava-labs/avalanchego/utils" + "github.com/ava-labs/avalanchego/utils/crypto/bls" + "github.com/ava-labs/avalanchego/utils/set" + "github.com/ava-labs/avalanchego/vms/components/chain" + avalancheWarp "github.com/ava-labs/avalanchego/vms/platformvm/warp" + "github.com/ava-labs/subnet-evm/core" + "github.com/ava-labs/subnet-evm/core/rawdb" + "github.com/ava-labs/subnet-evm/core/types" + "github.com/ava-labs/subnet-evm/internal/ethapi" + "github.com/ava-labs/subnet-evm/params" + "github.com/ava-labs/subnet-evm/rpc" + subnetEVMUtils "github.com/ava-labs/subnet-evm/utils" + predicateutils "github.com/ava-labs/subnet-evm/utils/predicate" + warpPayload "github.com/ava-labs/subnet-evm/warp/payload" + "github.com/ava-labs/subnet-evm/x/warp" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/stretchr/testify/require" +) + +func TestSendWarpMessage(t *testing.T) { + require := require.New(t) + genesis := &core.Genesis{} + require.NoError(genesis.UnmarshalJSON([]byte(genesisJSONSubnetEVM))) + genesis.Config.GenesisPrecompiles = params.Precompiles{ + warp.ConfigKey: warp.NewDefaultConfig(subnetEVMUtils.NewUint64(0)), + } + genesisJSON, err := genesis.MarshalJSON() + require.NoError(err) + issuer, vm, _, _ := GenesisVM(t, true, string(genesisJSON), "", "") + + defer func() { + require.NoError(vm.Shutdown(context.Background())) + }() + + acceptedLogsChan := make(chan []*types.Log, 10) + logsSub := vm.eth.APIBackend.SubscribeAcceptedLogsEvent(acceptedLogsChan) + defer logsSub.Unsubscribe() + + payload := utils.RandomBytes(100) + + warpSendMessageInput, err := warp.PackSendWarpMessage(warp.SendWarpMessageInput{ + DestinationChainID: common.Hash(vm.ctx.CChainID), + DestinationAddress: testEthAddrs[1], + Payload: payload, + }) + require.NoError(err) + + // Submit a transaction to trigger sending a warp message + tx0 := types.NewTransaction(uint64(0), warp.ContractAddress, big.NewInt(1), 100_000, big.NewInt(testMinGasPrice), warpSendMessageInput) + signedTx0, err := types.SignTx(tx0, types.LatestSignerForChainID(vm.chainConfig.ChainID), testKeys[0]) + require.NoError(err) + + errs := vm.txPool.AddRemotesSync([]*types.Transaction{signedTx0}) + require.NoError(errs[0]) + + <-issuer + blk, err := vm.BuildBlock(context.Background()) + require.NoError(err) + + require.NoError(blk.Verify(context.Background())) + + require.Equal(choices.Processing, blk.Status()) + + // Verify that the constructed block contains the expected log with an unsigned warp message in the log data + ethBlock1 := blk.(*chain.BlockWrapper).Block.(*Block).ethBlock + require.Len(ethBlock1.Transactions(), 1) + receipts := rawdb.ReadReceipts(vm.chaindb, ethBlock1.Hash(), ethBlock1.NumberU64(), vm.chainConfig) + require.Len(receipts, 1) + + require.Len(receipts[0].Logs, 1) + expectedTopics := []common.Hash{ + warp.WarpABI.Events["SendWarpMessage"].ID, + common.Hash(vm.ctx.CChainID), + testEthAddrs[1].Hash(), + testEthAddrs[0].Hash(), + } + require.Equal(expectedTopics, receipts[0].Logs[0].Topics) + logData := receipts[0].Logs[0].Data + unsignedMessage, err := avalancheWarp.ParseUnsignedMessage(logData) + require.NoError(err) + unsignedMessageID := unsignedMessage.ID() + + // Verify the signature cannot be fetched before the block is accepted + _, err = vm.warpBackend.GetSignature(unsignedMessageID) + require.Error(err) + + require.NoError(vm.SetPreference(context.Background(), blk.ID())) + require.NoError(blk.Accept(context.Background())) + vm.blockChain.DrainAcceptorQueue() + rawSignatureBytes, err := vm.warpBackend.GetSignature(unsignedMessageID) + require.NoError(err) + blsSignature, err := bls.SignatureFromBytes(rawSignatureBytes[:]) + require.NoError(err) + + select { + case acceptedLogs := <-acceptedLogsChan: + require.Len(acceptedLogs, 1, "unexpected length of accepted logs") + require.Equal(acceptedLogs[0], receipts[0].Logs[0]) + case <-time.After(time.Second): + require.Fail("Failed to read accepted logs from subscription") + } + + // Verify the produced signature is valid + require.True(bls.Verify(vm.ctx.PublicKey, blsSignature, unsignedMessage.Bytes())) +} + +func TestReceiveWarpMessage(t *testing.T) { + require := require.New(t) + genesis := &core.Genesis{} + require.NoError(genesis.UnmarshalJSON([]byte(genesisJSONSubnetEVM))) + genesis.Config.GenesisPrecompiles = params.Precompiles{ + warp.ConfigKey: warp.NewDefaultConfig(subnetEVMUtils.NewUint64(0)), + } + genesisJSON, err := genesis.MarshalJSON() + require.NoError(err) + issuer, vm, _, _ := GenesisVM(t, true, string(genesisJSON), "", "") + + defer func() { + require.NoError(vm.Shutdown(context.Background())) + }() + + acceptedLogsChan := make(chan []*types.Log, 10) + logsSub := vm.eth.APIBackend.SubscribeAcceptedLogsEvent(acceptedLogsChan) + defer logsSub.Unsubscribe() + + payload := utils.RandomBytes(100) + + addressedPayload, err := warpPayload.NewAddressedPayload( + testEthAddrs[0], + common.Hash(vm.ctx.CChainID), + testEthAddrs[1], + payload, + ) + require.NoError(err) + unsignedMessage, err := avalancheWarp.NewUnsignedMessage( + vm.ctx.NetworkID, + vm.ctx.ChainID, + addressedPayload.Bytes(), + ) + require.NoError(err) + + nodeID1 := ids.GenerateTestNodeID() + blsSecretKey1, err := bls.NewSecretKey() + require.NoError(err) + blsPublicKey1 := bls.PublicFromSecretKey(blsSecretKey1) + blsSignature1 := bls.Sign(blsSecretKey1, unsignedMessage.Bytes()) + + nodeID2 := ids.GenerateTestNodeID() + blsSecretKey2, err := bls.NewSecretKey() + require.NoError(err) + blsPublicKey2 := bls.PublicFromSecretKey(blsSecretKey2) + blsSignature2 := bls.Sign(blsSecretKey2, unsignedMessage.Bytes()) + + blsAggregatedSignature, err := bls.AggregateSignatures([]*bls.Signature{blsSignature1, blsSignature2}) + require.NoError(err) + + vm.ctx.ValidatorState = &validators.TestState{ + GetSubnetIDF: func(ctx context.Context, chainID ids.ID) (ids.ID, error) { + return ids.Empty, nil + }, + GetValidatorSetF: func(ctx context.Context, height uint64, subnetID ids.ID) (map[ids.NodeID]*validators.GetValidatorOutput, error) { + return map[ids.NodeID]*validators.GetValidatorOutput{ + nodeID1: { + NodeID: nodeID1, + PublicKey: blsPublicKey1, + Weight: 50, + }, + nodeID2: { + NodeID: nodeID2, + PublicKey: blsPublicKey2, + Weight: 50, + }, + }, nil + }, + } + + signersBitSet := set.NewBits() + signersBitSet.Add(0) + signersBitSet.Add(1) + + warpSignature := &avalancheWarp.BitSetSignature{ + Signers: signersBitSet.Bytes(), + } + + blsAggregatedSignatureBytes := bls.SignatureToBytes(blsAggregatedSignature) + copy(warpSignature.Signature[:], blsAggregatedSignatureBytes) + + signedMessage, err := avalancheWarp.NewMessage( + unsignedMessage, + warpSignature, + ) + require.NoError(err) + + getWarpMsgInput, err := warp.PackGetVerifiedWarpMessage() + require.NoError(err) + getVerifiedWarpMessageTx, err := types.SignTx( + predicateutils.NewPredicateTx( + vm.chainConfig.ChainID, + 0, + &warp.Module.Address, + 1_000_000, + big.NewInt(225*params.GWei), + big.NewInt(params.GWei), + common.Big0, + getWarpMsgInput, + types.AccessList{}, + warp.ContractAddress, + signedMessage.Bytes(), + ), + types.LatestSignerForChainID(vm.chainConfig.ChainID), + testKeys[0], + ) + require.NoError(err) + errs := vm.txPool.AddRemotesSync([]*types.Transaction{getVerifiedWarpMessageTx}) + for i, err := range errs { + require.NoError(err, "failed to add tx at index %d", i) + } + + expectedOutput, err := warp.PackGetVerifiedWarpMessageOutput(warp.GetVerifiedWarpMessageOutput{ + Message: warp.WarpMessage{ + OriginChainID: common.Hash(vm.ctx.ChainID), + OriginSenderAddress: testEthAddrs[0], + DestinationChainID: common.Hash(vm.ctx.CChainID), + DestinationAddress: testEthAddrs[1], + Payload: payload, + }, + Exists: true, + }) + require.NoError(err) + + // Assert that DoCall returns the expected output + hexGetWarpMsgInput := hexutil.Bytes(getVerifiedWarpMessageTx.Data()) + hexGasLimit := hexutil.Uint64(getVerifiedWarpMessageTx.Gas()) + accessList := getVerifiedWarpMessageTx.AccessList() + blockNum := new(rpc.BlockNumber) + *blockNum = rpc.LatestBlockNumber + + executionRes, err := ethapi.DoCall( + context.Background(), + vm.eth.APIBackend, + ethapi.TransactionArgs{ + To: getVerifiedWarpMessageTx.To(), + Input: &hexGetWarpMsgInput, + AccessList: &accessList, + Gas: &hexGasLimit, + }, + rpc.BlockNumberOrHash{BlockNumber: blockNum}, + nil, + time.Second, + 10_000_000, + ) + require.NoError(err) + require.NoError(executionRes.Err) + require.Equal(expectedOutput, executionRes.ReturnData) + + // Build, verify, and accept block with valid proposer context. + validProposerCtx := &block.Context{ + PChainHeight: 10, + } + vm.clock.Set(vm.clock.Time().Add(2 * time.Second)) + <-issuer + + block2, err := vm.BuildBlockWithContext(context.Background(), validProposerCtx) + require.NoError(err) + + block2VerifyWithCtx, ok := block2.(block.WithVerifyContext) + require.True(ok) + shouldVerifyWithCtx, err := block2VerifyWithCtx.ShouldVerifyWithContext(context.Background()) + require.NoError(err) + require.True(shouldVerifyWithCtx) + require.NoError(block2VerifyWithCtx.VerifyWithContext(context.Background(), validProposerCtx)) + require.Equal(choices.Processing, block2.Status()) + require.NoError(vm.SetPreference(context.Background(), block2.ID())) + + // Verify the block with another valid context + require.NoError(block2VerifyWithCtx.VerifyWithContext(context.Background(), &block.Context{ + PChainHeight: 11, + })) + require.Equal(choices.Processing, block2.Status()) + + // Verify the block with a different context and modified ValidatorState so that it should fail verification + testErr := errors.New("test error") + vm.ctx.ValidatorState.(*validators.TestState).GetValidatorSetF = func(ctx context.Context, height uint64, subnetID ids.ID) (map[ids.NodeID]*validators.GetValidatorOutput, error) { + return nil, testErr + } + require.ErrorIs(block2VerifyWithCtx.VerifyWithContext(context.Background(), &block.Context{ + PChainHeight: 9, + }), testErr) + require.Equal(choices.Processing, block2.Status()) + + // Accept the block after performing multiple VerifyWithContext operations + require.NoError(block2.Accept(context.Background())) + vm.blockChain.DrainAcceptorQueue() + + ethBlock := block2.(*chain.BlockWrapper).Block.(*Block).ethBlock + verifiedMessageReceipts := vm.blockChain.GetReceiptsByHash(ethBlock.Hash()) + require.Len(verifiedMessageReceipts, 1) + verifiedMessageTxReceipt := verifiedMessageReceipts[0] + require.Equal(types.ReceiptStatusSuccessful, verifiedMessageTxReceipt.Status) +} diff --git a/precompile/registry/registry.go b/precompile/registry/registry.go index 273ebbcde3..2b318ee998 100644 --- a/precompile/registry/registry.go +++ b/precompile/registry/registry.go @@ -16,6 +16,8 @@ import ( _ "github.com/ava-labs/subnet-evm/precompile/contracts/feemanager" _ "github.com/ava-labs/subnet-evm/precompile/contracts/rewardmanager" + + _ "github.com/ava-labs/subnet-evm/x/warp" // ADD YOUR PRECOMPILE HERE // _ "github.com/ava-labs/subnet-evm/precompile/contracts/yourprecompile" ) @@ -37,5 +39,6 @@ import ( // TxAllowListAddress = common.HexToAddress("0x0200000000000000000000000000000000000002") // FeeManagerAddress = common.HexToAddress("0x0200000000000000000000000000000000000003") // RewardManagerAddress = common.HexToAddress("0x0200000000000000000000000000000000000004") +// WarpAddress = common.HexToAddress("0x0200000000000000000000000000000000000005") // ADD YOUR PRECOMPILE HERE // {YourPrecompile}Address = common.HexToAddress("0x03000000000000000000000000000000000000??") diff --git a/scripts/run_ginkgo.sh b/scripts/run_ginkgo.sh index a68045eead..1e283c8df0 100755 --- a/scripts/run_ginkgo.sh +++ b/scripts/run_ginkgo.sh @@ -16,13 +16,12 @@ source "$SUBNET_EVM_PATH"/scripts/constants.sh source "$SUBNET_EVM_PATH"/scripts/versions.sh # Build ginkgo -echo "building precompile.test" # to install the ginkgo binary (required for test build and run) go install -v github.com/onsi/ginkgo/v2/ginkgo@${GINKGO_VERSION} TEST_SOURCE_ROOT=$(pwd) -ACK_GINKGO_RC=true ginkgo build ./tests/load +ACK_GINKGO_RC=true ginkgo build ./tests/load ./tests/warp # By default, it runs all e2e test cases! # Use "--ginkgo.skip" to skip tests. @@ -34,3 +33,7 @@ TEST_SOURCE_ROOT="$TEST_SOURCE_ROOT" ginkgo run -procs=5 tests/precompile \ ./tests/load/load.test \ --ginkgo.vv \ --ginkgo.label-filter=${GINKGO_LABEL_FILTER:-""} + +./tests/warp/warp.test \ + --ginkgo.vv \ + --ginkgo.label-filter=${GINKGO_LABEL_FILTER:-""} diff --git a/tests/precompile/genesis/warp.json b/tests/precompile/genesis/warp.json new file mode 100644 index 0000000000..e25a18fd20 --- /dev/null +++ b/tests/precompile/genesis/warp.json @@ -0,0 +1,47 @@ +{ + "config": { + "chainId": 99999, + "homesteadBlock": 0, + "eip150Block": 0, + "eip150Hash": "0x2086799aeebeae135c246c65021c82b4e15a2c451340993aacfd2751886514f0", + "eip155Block": 0, + "eip158Block": 0, + "byzantiumBlock": 0, + "constantinopleBlock": 0, + "petersburgBlock": 0, + "istanbulBlock": 0, + "muirGlacierBlock": 0, + "subnetEVMTimestamp": 0, + "feeConfig": { + "gasLimit": 20000000, + "minBaseFee": 1000000000, + "targetGas": 100000000, + "baseFeeChangeDenominator": 48, + "minBlockGasCost": 0, + "maxBlockGasCost": 10000000, + "targetBlockRate": 2, + "blockGasCostStep": 500000 + }, + "warpConfig": { + "blockTimestamp": 0 + } + }, + "alloc": { + "8db97C7cEcE249c2b98bDC0226Cc4C2A57BF52FC": { + "balance": "0x52B7D2DCC80CD2E4000000" + }, + "0x0Fa8EA536Be85F32724D57A37758761B86416123": { + "balance": "0x52B7D2DCC80CD2E4000000" + } + }, + "nonce": "0x0", + "timestamp": "0x0", + "extraData": "0x00", + "gasLimit": "0x1312D00", + "difficulty": "0x0", + "mixHash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "coinbase": "0x0000000000000000000000000000000000000000", + "number": "0x0", + "gasUsed": "0x0", + "parentHash": "0x0000000000000000000000000000000000000000000000000000000000000000" +} diff --git a/tests/warp/warp_test.go b/tests/warp/warp_test.go new file mode 100644 index 0000000000..0ad4d741c2 --- /dev/null +++ b/tests/warp/warp_test.go @@ -0,0 +1,375 @@ +// Copyright (C) 2023, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +// Implements solidity tests. +package warp + +import ( + "context" + "crypto/ecdsa" + "fmt" + "math/big" + "os" + "strings" + "testing" + + "github.com/ava-labs/avalanche-network-runner/rpcpb" + "github.com/ava-labs/avalanchego/api/info" + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/utils/crypto/bls" + "github.com/ava-labs/avalanchego/utils/set" + avalancheWarp "github.com/ava-labs/avalanchego/vms/platformvm/warp" + "github.com/ava-labs/subnet-evm/core/types" + "github.com/ava-labs/subnet-evm/ethclient" + "github.com/ava-labs/subnet-evm/interfaces" + "github.com/ava-labs/subnet-evm/params" + "github.com/ava-labs/subnet-evm/plugin/evm" + "github.com/ava-labs/subnet-evm/tests/utils/runner" + predicateutils "github.com/ava-labs/subnet-evm/utils/predicate" + warpBackend "github.com/ava-labs/subnet-evm/warp" + "github.com/ava-labs/subnet-evm/x/warp" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/log" + ginkgo "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" +) + +var ( + config = runner.NewDefaultANRConfig() + manager = runner.NewNetworkManager(config) + warpChainConfigPath string +) + +func TestE2E(t *testing.T) { + gomega.RegisterFailHandler(ginkgo.Fail) + ginkgo.RunSpecs(t, "subnet-evm warp e2e test") +} + +func toWebsocketURI(uri string, blockchainID string) string { + return fmt.Sprintf("ws://%s/ext/bc/%s/ws", strings.TrimPrefix(uri, "http://"), blockchainID) +} + +// BeforeSuite starts the default network and adds 10 new nodes as validators with BLS keys +// registered on the P-Chain. +// Adds two disjoint sets of 5 of the new validator nodes to validate two new subnets with a +// a single Subnet-EVM blockchain. +var _ = ginkgo.BeforeSuite(func() { + ctx := context.Background() + var err error + // Name 10 new validators (which should have BLS key registered) + subnetANodeNames := make([]string, 0) + subnetBNodeNames := []string{} + for i := 1; i <= 10; i++ { + n := fmt.Sprintf("node%d-bls", i) + if i <= 5 { + subnetANodeNames = append(subnetANodeNames, n) + } else { + subnetBNodeNames = append(subnetBNodeNames, n) + } + } + f, err := os.CreateTemp(os.TempDir(), "config.json") + gomega.Expect(err).Should(gomega.BeNil()) + _, err = f.Write([]byte(`{"warp-api-enabled": true}`)) + gomega.Expect(err).Should(gomega.BeNil()) + warpChainConfigPath = f.Name() + + // Construct the network using the avalanche-network-runner + _, err = manager.StartDefaultNetwork(ctx) + gomega.Expect(err).Should(gomega.BeNil()) + err = manager.SetupNetwork( + ctx, + config.AvalancheGoExecPath, + []*rpcpb.BlockchainSpec{ + { + VmName: evm.IDStr, + Genesis: "./tests/precompile/genesis/warp.json", + ChainConfig: warpChainConfigPath, + SubnetSpec: &rpcpb.SubnetSpec{ + SubnetConfig: "", + Participants: subnetANodeNames, + }, + }, + { + VmName: evm.IDStr, + Genesis: "./tests/precompile/genesis/warp.json", + ChainConfig: warpChainConfigPath, + SubnetSpec: &rpcpb.SubnetSpec{ + SubnetConfig: "", + Participants: subnetBNodeNames, + }, + }, + }, + ) + gomega.Expect(err).Should(gomega.BeNil()) +}) + +var _ = ginkgo.AfterSuite(func() { + gomega.Expect(manager).ShouldNot(gomega.BeNil()) + gomega.Expect(manager.TeardownNetwork()).Should(gomega.BeNil()) + gomega.Expect(os.Remove(warpChainConfigPath)).Should(gomega.BeNil()) + // TODO: bootstrap an additional node to ensure that we can bootstrap the test data correctly +}) + +var _ = ginkgo.Describe("[Warp]", ginkgo.Ordered, func() { + var ( + unsignedWarpMsg *avalancheWarp.UnsignedMessage + unsignedWarpMessageID ids.ID + signedWarpMsg *avalancheWarp.Message + blockchainIDA, blockchainIDB ids.ID + chainAURIs, chainBURIs []string + chainAWSClient, chainBWSClient ethclient.Client + chainID = big.NewInt(99999) + fundedKey *ecdsa.PrivateKey + fundedAddress common.Address + payload = []byte{1, 2, 3} + txSigner = types.LatestSignerForChainID(chainID) + err error + ) + + fundedKey, err = crypto.HexToECDSA("56289e99c94b6912bfc12adc093c9b51124f0dc54ac7a766b2bc5ccf558d8027") + if err != nil { + panic(err) + } + fundedAddress = crypto.PubkeyToAddress(fundedKey.PublicKey) + + ginkgo.It("Setup URIs", ginkgo.Label("Warp", "SetupWarp"), func() { + subnetIDs := manager.GetSubnets() + gomega.Expect(len(subnetIDs)).Should(gomega.Equal(2)) + + subnetA := subnetIDs[0] + subnetADetails, ok := manager.GetSubnet(subnetA) + gomega.Expect(ok).Should(gomega.BeTrue()) + blockchainIDA = subnetADetails.BlockchainID + gomega.Expect(len(subnetADetails.ValidatorURIs)).Should(gomega.Equal(5)) + chainAURIs = append(chainAURIs, subnetADetails.ValidatorURIs...) + + subnetB := subnetIDs[1] + subnetBDetails, ok := manager.GetSubnet(subnetB) + gomega.Expect(ok).Should(gomega.BeTrue()) + blockchainIDB := subnetBDetails.BlockchainID + gomega.Expect(len(subnetBDetails.ValidatorURIs)).Should(gomega.Equal(5)) + chainBURIs = append(chainBURIs, subnetBDetails.ValidatorURIs...) + + log.Info("Created URIs for both subnets", "ChainAURIs", chainAURIs, "ChainBURIs", chainBURIs, "blockchainIDA", blockchainIDA, "blockchainIDB", blockchainIDB) + + chainAWSURI := toWebsocketURI(chainAURIs[0], blockchainIDA.String()) + log.Info("Creating ethclient for blockchainA", "wsURI", chainAWSURI) + chainAWSClient, err = ethclient.Dial(chainAWSURI) + gomega.Expect(err).Should(gomega.BeNil()) + + chainBWSURI := toWebsocketURI(chainBURIs[0], blockchainIDB.String()) + log.Info("Creating ethclient for blockchainB", "wsURI", chainBWSURI) + chainBWSClient, err = ethclient.Dial(chainBWSURI) + gomega.Expect(err).Should(gomega.BeNil()) + + }) + + // Send a transaction to Subnet A to issue a Warp Message to Subnet B + ginkgo.It("Send Message from A to B", ginkgo.Label("Warp", "SendWarp"), func() { + ctx := context.Background() + + gomega.Expect(err).Should(gomega.BeNil()) + + log.Info("Subscribing to new heads") + newHeads := make(chan *types.Header, 10) + sub, err := chainAWSClient.SubscribeNewHead(ctx, newHeads) + gomega.Expect(err).Should(gomega.BeNil()) + defer sub.Unsubscribe() + + packedInput, err := warp.PackSendWarpMessage(warp.SendWarpMessageInput{ + DestinationChainID: common.Hash(blockchainIDB), + DestinationAddress: fundedAddress, + Payload: payload, + }) + gomega.Expect(err).Should(gomega.BeNil()) + tx := types.NewTx(&types.DynamicFeeTx{ + ChainID: chainID, + Nonce: 0, + To: &warp.Module.Address, + Gas: 200_000, + GasFeeCap: big.NewInt(225 * params.GWei), + GasTipCap: big.NewInt(params.GWei), + Value: common.Big0, + Data: packedInput, + }) + signedTx, err := types.SignTx(tx, txSigner, fundedKey) + gomega.Expect(err).Should(gomega.BeNil()) + log.Info("Sending sendWarpMessage transaction", "txHash", signedTx.Hash()) + err = chainAWSClient.SendTransaction(ctx, signedTx) + gomega.Expect(err).Should(gomega.BeNil()) + + log.Info("Waiting for new block confirmation") + newHead := <-newHeads + blockHash := newHead.Hash() + + log.Info("Fetching relevant warp logs from the newly produced block") + logs, err := chainAWSClient.FilterLogs(ctx, interfaces.FilterQuery{ + BlockHash: &blockHash, + Addresses: []common.Address{warp.Module.Address}, + }) + gomega.Expect(err).Should(gomega.BeNil()) + gomega.Expect(len(logs)).Should(gomega.Equal(1)) + + // Check for relevant warp log from subscription and ensure that it matches + // the log extracted from the last block. + txLog := logs[0] + log.Info("Parsing logData as unsigned warp message") + unsignedMsg, err := avalancheWarp.ParseUnsignedMessage(txLog.Data) + gomega.Expect(err).Should(gomega.BeNil()) + + // Set local variables for the duration of the test + unsignedWarpMessageID = unsignedMsg.ID() + unsignedWarpMsg = unsignedMsg + log.Info("Parsed unsignedWarpMsg", "unsignedWarpMessageID", unsignedWarpMessageID, "unsignedWarpMessage", unsignedWarpMsg) + + // Loop over each client on chain A to ensure they all have time to accept the block. + // Note: if we did not confirm this here, the next stage could be racy since it assumes every node + // has accepted the block. + for i, uri := range chainAURIs { + chainAWSURI := toWebsocketURI(uri, blockchainIDA.String()) + log.Info("Creating ethclient for blockchainA", "wsURI", chainAWSURI) + client, err := ethclient.Dial(chainAWSURI) + gomega.Expect(err).Should(gomega.BeNil()) + + // Loop until each node has advanced to >= the height of the block that emitted the warp log + for { + block, err := client.BlockByNumber(ctx, nil) + gomega.Expect(err).Should(gomega.BeNil()) + if block.NumberU64() >= newHead.Number.Uint64() { + log.Info("client accepted the block containing SendWarpMessage", "client", i, "height", block.NumberU64()) + break + } + } + } + }) + + // Aggregate a Warp Signature by sending an API request to each node requesting its signature and manually + // constructing a valid Avalanche Warp Message + ginkgo.It("Aggregate Warp Signature via API", ginkgo.Label("Warp", "ReceiveWarp", "AggregateWarpManually"), func() { + ctx := context.Background() + + blsSignatures := make([]*bls.Signature, 0, len(chainAURIs)) + for i, uri := range chainAURIs { + warpClient, err := warpBackend.NewWarpClient(uri, blockchainIDA.String()) + gomega.Expect(err).Should(gomega.BeNil()) + log.Info("Fetching warp signature from node") + rawSignatureBytes, err := warpClient.GetSignature(ctx, unsignedWarpMessageID) + gomega.Expect(err).Should(gomega.BeNil()) + + blsSignature, err := bls.SignatureFromBytes(rawSignatureBytes) + gomega.Expect(err).Should(gomega.BeNil()) + + infoClient := info.NewClient(uri) + nodeID, blsSigner, err := infoClient.GetNodeID(ctx) + gomega.Expect(err).Should(gomega.BeNil()) + + blsSignatures = append(blsSignatures, blsSignature) + + blsPublicKey := blsSigner.Key() + log.Info("Verifying BLS Signature from node", "nodeID", nodeID, "nodeIndex", i) + gomega.Expect(bls.Verify(blsPublicKey, blsSignature, unsignedWarpMsg.Bytes())).Should(gomega.BeTrue()) + } + + blsAggregatedSignature, err := bls.AggregateSignatures(blsSignatures) + gomega.Expect(err).Should(gomega.BeNil()) + + signersBitSet := set.NewBits() + for i := 0; i < len(blsSignatures); i++ { + signersBitSet.Add(i) + } + warpSignature := &avalancheWarp.BitSetSignature{ + Signers: signersBitSet.Bytes(), + } + + blsAggregatedSignatureBytes := bls.SignatureToBytes(blsAggregatedSignature) + copy(warpSignature.Signature[:], blsAggregatedSignatureBytes) + + warpMsg, err := avalancheWarp.NewMessage( + unsignedWarpMsg, + warpSignature, + ) + gomega.Expect(err).Should(gomega.BeNil()) + signedWarpMsg = warpMsg + }) + + // Aggregate a Warp Signature using the node's Signature Aggregation API call and verifying that its output matches the + // the manual construction + ginkgo.It("Aggregate Warp Signature via Aggregator", ginkgo.Label("Warp", "ReceiveWarp", "AggregatorWarp"), func() { + ctx := context.Background() + + // Verify that the signature aggregation matches the results of manually constructing the warp message + warpClient, err := warpBackend.NewWarpClient(chainAURIs[0], blockchainIDA.String()) + gomega.Expect(err).Should(gomega.BeNil()) + + // Specify WarpQuorumDenominator to retrieve signatures from every validator + signedWarpMessageBytes, err := warpClient.GetAggregateSignature(ctx, unsignedWarpMessageID, params.WarpQuorumDenominator) + gomega.Expect(err).Should(gomega.BeNil()) + gomega.Expect(signedWarpMessageBytes).Should(gomega.Equal(signedWarpMsg.Bytes())) + }) + + // Verify successful delivery of the Avalanche Warp Message from Chain A to Chain B + ginkgo.It("Verify Message from A to B", ginkgo.Label("Warp", "VerifyMessage"), func() { + ctx := context.Background() + + log.Info("Subscribing to new heads") + newHeads := make(chan *types.Header, 10) + sub, err := chainBWSClient.SubscribeNewHead(ctx, newHeads) + gomega.Expect(err).Should(gomega.BeNil()) + defer sub.Unsubscribe() + + // Trigger building of a new block at the current timestamp. + // This timestamp should be after the ProposerVM activation time or ApricotPhase4 block timestamp. + // This should generate a PostForkBlock because its parent block (genesis) has a timestamp (0) that is greater than or equal + // to the fork activation time of 0. + // Therefore, when we build a subsequent block it should be built with BuildBlockWithContext + nonce, err := chainBWSClient.NonceAt(ctx, fundedAddress, nil) + gomega.Expect(err).Should(gomega.BeNil()) + + triggerTx, err := types.SignTx(types.NewTransaction(nonce, fundedAddress, common.Big1, 21_000, big.NewInt(225*params.GWei), nil), txSigner, fundedKey) + gomega.Expect(err).Should(gomega.BeNil()) + + err = chainBWSClient.SendTransaction(ctx, triggerTx) + gomega.Expect(err).Should(gomega.BeNil()) + newHead := <-newHeads + log.Info("Transaction triggered new block", "blockHash", newHead.Hash()) + nonce++ + + packedInput, err := warp.PackGetVerifiedWarpMessage() + gomega.Expect(err).Should(gomega.BeNil()) + tx := predicateutils.NewPredicateTx( + chainID, + nonce, + &warp.Module.Address, + 5_000_000, + big.NewInt(225*params.GWei), + big.NewInt(params.GWei), + common.Big0, + packedInput, + types.AccessList{}, + warp.ContractAddress, + signedWarpMsg.Bytes(), + ) + signedTx, err := types.SignTx(tx, txSigner, fundedKey) + gomega.Expect(err).Should(gomega.BeNil()) + txBytes, err := signedTx.MarshalBinary() + gomega.Expect(err).Should(gomega.BeNil()) + log.Info("Sending getVerifiedWarpMessage transaction", "txHash", signedTx.Hash(), "txBytes", common.Bytes2Hex(txBytes)) + err = chainBWSClient.SendTransaction(ctx, signedTx) + gomega.Expect(err).Should(gomega.BeNil()) + + log.Info("Waiting for new block confirmation") + newHead = <-newHeads + blockHash := newHead.Hash() + log.Info("Fetching relevant warp logs and receipts from new block") + logs, err := chainBWSClient.FilterLogs(ctx, interfaces.FilterQuery{ + BlockHash: &blockHash, + Addresses: []common.Address{warp.Module.Address}, + }) + gomega.Expect(err).Should(gomega.BeNil()) + gomega.Expect(len(logs)).Should(gomega.Equal(0)) + receipt, err := chainBWSClient.TransactionReceipt(ctx, signedTx.Hash()) + gomega.Expect(err).Should(gomega.BeNil()) + gomega.Expect(receipt.Status).Should(gomega.Equal(types.ReceiptStatusSuccessful)) + }) +}) diff --git a/utils/predicate/predicate_bytes.go b/utils/predicate/predicate_bytes.go index db94b14d61..608b503f12 100644 --- a/utils/predicate/predicate_bytes.go +++ b/utils/predicate/predicate_bytes.go @@ -1,7 +1,7 @@ // (c) 2023, Ava Labs, Inc. All rights reserved. // See the file LICENSE for licensing terms. -package predicateutils +package predicate import ( "fmt" diff --git a/utils/predicate/predicate_bytes_test.go b/utils/predicate/predicate_bytes_test.go index c39758edea..6200171e98 100644 --- a/utils/predicate/predicate_bytes_test.go +++ b/utils/predicate/predicate_bytes_test.go @@ -1,7 +1,7 @@ // (c) 2023, Ava Labs, Inc. All rights reserved. // See the file LICENSE for licensing terms. -package predicateutils +package predicate import ( "bytes" @@ -53,21 +53,23 @@ func FuzzPackPredicate(f *testing.F) { }) } -func FuzzUnpackInvalidPredicate(f *testing.F) { - // Seed the fuzzer with non-zero length padding of zeroes or non-zeroes. +func TestUnpackInvalidPredicate(t *testing.T) { + require := require.New(t) + // Predicate encoding requires a 0xff delimiter byte followed by padding of all zeroes, so any other + // excess padding should invalidate the predicate. + paddingCases := make([][]byte, 0, 200) for i := 1; i < 100; i++ { - f.Add(utils.RandomBytes(i)) - f.Add(make([]byte, i)) + paddingCases = append(paddingCases, bytes.Repeat([]byte{0xee}, i)) + paddingCases = append(paddingCases, make([]byte, i)) } - f.Fuzz(func(t *testing.T, b []byte) { - // Ensure that adding the invalid padding to any length correctly packed predicate - // results in failing to unpack it. - for _, l := range []int{0, 1, 31, 32, 33, 63, 64, 65} { - validPredicate := PackPredicate(utils.RandomBytes(l)) - invalidPredicate := append(validPredicate, b...) + for _, l := range []int{0, 1, 31, 32, 33, 63, 64, 65} { + validPredicate := PackPredicate(utils.RandomBytes(l)) + + for _, padding := range paddingCases { + invalidPredicate := append(validPredicate, padding...) _, err := UnpackPredicate(invalidPredicate) - require.Error(t, err) + require.Error(err, "Predicate length %d, Padding length %d (0x%x)", len(validPredicate), len(padding), invalidPredicate) } - }) + } } diff --git a/utils/predicate/predicate_tx.go b/utils/predicate/predicate_tx.go new file mode 100644 index 0000000000..f7a0e73c11 --- /dev/null +++ b/utils/predicate/predicate_tx.go @@ -0,0 +1,43 @@ +// (c) 2023, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package predicate + +import ( + "math/big" + + "github.com/ava-labs/subnet-evm/core/types" + "github.com/ethereum/go-ethereum/common" +) + +// NewPredicateTx returns a transaction with the predicateAddress/predicateBytes tuple +// packed and added to the access list of the transaction. +func NewPredicateTx( + chainID *big.Int, + nonce uint64, + to *common.Address, + gas uint64, + gasFeeCap *big.Int, + gasTipCap *big.Int, + value *big.Int, + data []byte, + accessList types.AccessList, + predicateAddress common.Address, + predicateBytes []byte, +) *types.Transaction { + accessList = append(accessList, types.AccessTuple{ + Address: predicateAddress, + StorageKeys: BytesToHashSlice(PackPredicate(predicateBytes)), + }) + return types.NewTx(&types.DynamicFeeTx{ + ChainID: chainID, + Nonce: nonce, + To: to, + Gas: gas, + GasFeeCap: gasFeeCap, + GasTipCap: gasTipCap, + Value: value, + Data: data, + AccessList: accessList, + }) +} diff --git a/warp/payload/codec.go b/warp/payload/codec.go new file mode 100644 index 0000000000..11be9b6aff --- /dev/null +++ b/warp/payload/codec.go @@ -0,0 +1,31 @@ +// Copyright (C) 2023, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package payload + +import ( + "math" + + "github.com/ava-labs/avalanchego/codec" + "github.com/ava-labs/avalanchego/codec/linearcodec" + "github.com/ava-labs/avalanchego/utils/wrappers" +) + +const codecVersion = 0 + +// Codec does serialization and deserialization for Warp messages. +var c codec.Manager + +func init() { + c = codec.NewManager(math.MaxInt) + lc := linearcodec.NewCustomMaxLength(math.MaxInt32) + + errs := wrappers.Errs{} + errs.Add( + lc.RegisterType(&AddressedPayload{}), + c.RegisterCodec(codecVersion, lc), + ) + if errs.Errored() { + panic(errs.Err) + } +} diff --git a/warp/payload/payload.go b/warp/payload/payload.go new file mode 100644 index 0000000000..c996e0687d --- /dev/null +++ b/warp/payload/payload.go @@ -0,0 +1,64 @@ +// Copyright (C) 2023, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package payload + +import ( + "fmt" + + "github.com/ethereum/go-ethereum/common" +) + +// AddressedPayload defines the format for delivering a point to point message across VMs +// ie. (ChainA, AddressA) -> (ChainB, AddressB) +type AddressedPayload struct { + SourceAddress common.Address `serialize:"true"` + DestinationChainID common.Hash `serialize:"true"` + DestinationAddress common.Address `serialize:"true"` + Payload []byte `serialize:"true"` + + bytes []byte +} + +// NewAddressedPayload creates a new *AddressedPayload and initializes it. +func NewAddressedPayload(sourceAddress common.Address, destinationChainID common.Hash, destinationAddress common.Address, payload []byte) (*AddressedPayload, error) { + ap := &AddressedPayload{ + SourceAddress: sourceAddress, + DestinationChainID: destinationChainID, + DestinationAddress: destinationAddress, + Payload: payload, + } + return ap, ap.initialize() +} + +// ParseAddressedPayload converts a slice of bytes into an initialized +// AddressedPayload. +func ParseAddressedPayload(b []byte) (*AddressedPayload, error) { + var unmarshalledPayloadIntf any + if _, err := c.Unmarshal(b, &unmarshalledPayloadIntf); err != nil { + return nil, err + } + payload, ok := unmarshalledPayloadIntf.(*AddressedPayload) + if !ok { + return nil, fmt.Errorf("failed to parse unexpected type %T as addressed payload", unmarshalledPayloadIntf) + } + payload.bytes = b + return payload, nil +} + +// initialize recalculates the result of Bytes(). +func (a *AddressedPayload) initialize() error { + aIntf := any(a) + bytes, err := c.Marshal(codecVersion, &aIntf) + if err != nil { + return fmt.Errorf("couldn't marshal warp addressed payload: %w", err) + } + a.bytes = bytes + return nil +} + +// Bytes returns the binary representation of this payload. It assumes that the +// payload is initialized from either NewAddressedPayload or ParseAddressedPayload. +func (a *AddressedPayload) Bytes() []byte { + return a.bytes +} diff --git a/warp/payload/payload_test.go b/warp/payload/payload_test.go new file mode 100644 index 0000000000..bc8d47db2d --- /dev/null +++ b/warp/payload/payload_test.go @@ -0,0 +1,54 @@ +// Copyright (C) 2023, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package payload + +import ( + "encoding/base64" + "testing" + + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/utils" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestAddressedPayload(t *testing.T) { + require := require.New(t) + + addressedPayload, err := NewAddressedPayload( + common.Address(ids.GenerateTestShortID()), + common.Hash(ids.GenerateTestID()), + common.Address(ids.GenerateTestShortID()), + []byte("payload"), + ) + require.NoError(err) + + addressedPayloadBytes := addressedPayload.Bytes() + addressedPayload2, err := ParseAddressedPayload(addressedPayloadBytes) + require.NoError(err) + require.Equal(addressedPayload, addressedPayload2) +} + +func TestParseAddressedPayloadJunk(t *testing.T) { + _, err := ParseAddressedPayload(utils.RandomBytes(1024)) + require.Error(t, err) +} + +func TestParseAddressedPayload(t *testing.T) { + base64Payload := "AAAAAAAAAQIDAAAAAAAAAAAAAAAAAAAAAAAEBQYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcICQAAAAAAAAAAAAAAAAAAAAAAAAAAAwoLDA==" + payload := &AddressedPayload{ + SourceAddress: common.Address{1, 2, 3}, + DestinationChainID: common.Hash{4, 5, 6}, + DestinationAddress: common.Address{7, 8, 9}, + Payload: []byte{10, 11, 12}, + } + + require.NoError(t, payload.initialize()) + + require.Equal(t, base64Payload, base64.StdEncoding.EncodeToString(payload.Bytes())) + + parsedPayload, err := ParseAddressedPayload(payload.Bytes()) + require.NoError(t, err) + require.Equal(t, payload, parsedPayload) +} diff --git a/x/warp/README.md b/x/warp/README.md new file mode 100644 index 0000000000..4b10920669 --- /dev/null +++ b/x/warp/README.md @@ -0,0 +1,192 @@ +# Avalanche Warp Messaging + +> **Warning** +> Avalanche Warp Messaging is currently in experimental mode to be used only on ephemeral test networks. +> +> Breaking changes to Avalanche Warp Messaging integration into Subnet-EVM may still be made. + +Avalanche Warp Messaging offers a basic primitive to enable Cross-Subnet communication on the Avalanche Network. + +It is intended to allow communication between arbitrary Custom Virtual Machines (including, but not limited to Subnet-EVM). + +## How does Avalanche Warp Messaging Work + +Avalanche Warp Messaging relies on the Avalanche P-Chain to provide a read-only view of every Subnet's validator set. In Avalanche, the P-Chain is used to maintain the Primary Network's validator set, create new subnets and blockchains, and maintain the validator sets of each Subnet. As of the Banff Upgrade, Avalanche enables registering a BLS Public Key alongside a validator. + +In order to be a validator of an Avalanche Subnet, a node must also validate the Avalanche Primary Network. This means each Subnet validator has read access to the P-Chain state. + +With just those two things: +- Read access to all Subnet validator sets through the P-Chain +- BLS Public Keys registered on the P-Chain + +We can build a generic Avalanche Warp Messaging Protocol. :point_down: + +### Subnet to Subnet + +The validator set of Subnet A can send a message on behalf of any blockchain on its Subnet. + +For example, let's say that there are two Subnets: Subnet A and Subnet B each with a single Blockchain we'll call Blockchain A and Blockchain B. + +First, Blockchain A produces a BLS Multisignature of a message to send to Blockchain B: + +1. A transaction is issued on Blockchain A to send a message to Subnet B +2. The transaction is accepted on Blockchain A (must wait for acceptance) +3. The VM powering Blockchain A (ex: Subnet-EVM) should either + a) be willing to sign the message to allow an off-chain relayer to aggregate signatures from the Subnet's validator set (existing implementation) + b) aggregate signatures from the validator set of Subnet A to produce its own aggregate signature + +The signature of Subnet A's validator set attests to the message being sent by Blockchain A. + +To receive the message, Blockchain B must be running a Snowman VM that is wrapped in the Snowman++ ProposerVM. The ProposerVM provides the P-Chain Context which a block on Blockchain B was issued in. See [here](https://github.com/ava-labs/avalanchego/tree/v1.10.4/vms/proposervm#snowman-block-extension) for more details on the ProposerVM block wrapper. The ProposerVM header includes a P-Chain height at which the block was validated. This P-Chain height determines the canonical state of the P-Chain to use for verification of all Avalanche Warp Messages. + +To validate and deliver the message, an off-chain component delivers the signed message from Blockchain A to Blockchain B (this is out of scope for Avalanche Warp Messaging itself). + +When Blockchain B receives the message, it validates and delivers/executes the message: + +1. Read the SourceChainID of the signed message (Blockchain A) +2. Look up the SubnetID that validates Blockchain A: Subnet A +3. Look up the validator set of Subnet A and the registered BLS Public Keys of Subnet A at the P-Chain height specified by the ProposerVM header +4. Filter the validators of Subnet A to include only the validators that the signed message claims as signers +5. Verify the claimed included validators represent a sufficient quorum of stake to verify the message +6. Aggregate the BLS Public Keys of the claimed signers into an aggregated BLS Public Key +7. Validate the aggregate signature matches the claimed aggregate BLS Public Key + +After verifying the message, Blockchain B can define its own semantics of how to deliver or execute the message. + +### Subnet to C-Chain (Primary Network) + +Subnet to C-Chain Warp Messaging is a special case of Avalanche Warp Messaging. The C-Chain can efficiently verify a signature produced by another Subnet, so the implementation for the C-Chain to receive an Avalanche Warp Message can and will use the same code as is planned for Subnet-EVM. + +### C-Chain to Subnet + +To support C-Chain to Subnet communication, or more generally Primary Network to Subnet communication, we special case the C-Chain for two reasons: + +1. Every Subnet validator validates the C-Chain +2. The Primary Network has the largest possible number of validators + +Since the Primary Network has the largest possible number of validators for any Subnet on Avalanche, it would also be the most expensive Subnet to verify Avalanche Warp Messages from (most signatures required to verify it). Luckily, we can do something much smarter. + +When a Subnet receives a message from a blockchain on the Primary Network, we use the validator set of the receiving Subnet instead of the entire network when validating the message. This means that the C-Chain sending a message can be the exact same as Subnet to Subnet communciation. + +However, when Subnet B receives a message from the C-Chain, it changes the semantics to the following: + +1. Read the SourceChainID of the signed message (C-Chain) +2. Look up the SubnetID that validates C-Chain: Primary Network +3. Look up the validator set of Subnet B (instead of the Primary Network) and the registered BLS Public Keys of Subnet B at the P-Chain height specified by the ProposerVM header +4. Filter the validators of Subnet B to include only the validators that the signed message claims as signers +5. Verify the claimed included validators represent a sufficient quorum of stake to verify the message +6. Aggregate the BLS Public Keys of the claimed signers into an aggregated BLS Public Key +7. Validate the aggregate signature matches the claimed aggregate BLS Public Key + +This means that if Subnet B has 10 equally weighted validators, then C-Chain to Subnet communication only requires a threshold of stake from those 10 validators rather than a threshold of the stake of the Primary Network. + +Since the security of Subnet B depends on the validators of Subnet B already, changing the requirements of verifying the message from verifying a signature from the entire Primary Network to only Subnet B's validator set does not change the security of Subnet B! + +## Warp Precompile + +The Warp Precompile is broken down into three functions defined in the Solidity interface file [here](../../../contracts/contracts/interfaces/IWarpMessenger.sol). + +### sendWarpMessage + +`sendWarpMessage` is used to send a verifiable message. Calling this function results in sending a message with the following contents: + +- `SourceChainID` - blockchainID of the sourceChain on the Avalanche P-Chain +- `SourceAddress` - `msg.sender` encoded as a 32 byte value that calls `sendWarpMessage` +- `DestinationChainID` - `bytes32` argument specifies the blockchainID on the Avalanche P-Chain that should receive the message +- `DestinationAddress` - 32 byte value that represents the destination address that should receive the message (on the EVM this is the 20 byte address left zero extended) +- `Payload` - `payload` argument specified in the call to `sendWarpMessage` emitted as the unindexed data of the resulting log + +Calling this function will issue a `SendWarpMessage` event from the Warp Precompile. Since the EVM limits the number of topics to 4 including the EventID, this message includes only the topics that would be expected to help filter messages emitted from the Warp Precompile the most. + +Specifically, the `payload` is not emitted as a topic because each topic must be encoded as a hash. It could include the warp `messageID` as a topic, but that would not add more information. Therefore, we opt to take advantage of each possible topic to maximize the possible filtering for emitted Warp Messages. + +Additionally, the `SourceChainID` is excluded because anyone parsing the chain can be expected to already know the blockchainID. Therefore, the `SendWarpMessage` event includes the indexable attributes: + +- `destinationChainID` +- `destinationAddress` +- `sender` + +The actual `message` is the entire [Avalanche Warp Unsigned Message](https://github.com/ava-labs/avalanchego/blob/master/vms/platformvm/warp/unsigned_message.go#L14) including the Subnet-EVM [Addressed Payload](../../../warp/payload/payload.go). + + +### getVerifiedMessage + +`getVerifiedMessage` is used to read the contents of the delivered Avalanche Warp Message into the expected format. + +It returns the message if present and a boolean indicating if a message is present. + +To use this function, the transaction must include the signed Avalanche Warp Message encoded in the [predicate](#predicate-encoding) of the transaction. Prior to executing a block, the VM iterates through transactions and pre-verifies all predicates. If a transaction's predicate is invalid, then it is considered invalid to include in the block and dropped. + +This gives the following properties: + +1. The EVM execution does not need to verify the Warp Message at runtime (no signature verification or external calls to the P-Chain) +2. The EVM can deterministically re-execute and re-verify blocks assuming the predicate was verified by the network (eg., in bootstrapping) + +This pre-verification is performed using the ProposerVM Block header during [block verification](../../../plugin/evm/block.go#L220) and [block building](../../../miner/worker.go#L200). + +Note: in order to support the notion of an `AnycastID` for the `DestinationChainID`, `getVerifiedMessage` and the predicate DO NOT require that the `DestinationChainID` matches the `blockchainID` currently running. Instead, callers of `getVerifiedMessage` should use `getBlockchainID()` to decide how they should interpret the message. In other words, does the `destinationChainID` match either the local `blockchainID` or the `AnycastID`. + +### getBlockchainID + +`getBlockchainID` returns the blockchainID of the blockchain that Subnet-EVM is running on. + +This is different from the conventional Ethereum ChainID registered to https://chainlist.org/. + +The `blockchainID` in Avalanche refers to the txID that created the blockchain on the Avalanche P-Chain ([docs](https://docs.avax.network/specs/platform-transaction-serialization#unsigned-create-chain-tx)). + +### Predicate Encoding + +Avalanche Warp Messages are encoded as a signed Avalanche [Warp Message](https://github.com/ava-labs/avalanchego/blob/v1.10.4/vms/platformvm/warp/message.go#L7) where the [UnsignedMessage](https://github.com/ava-labs/avalanchego/blob/v1.10.4/vms/platformvm/warp/unsigned_message.go#L14)'s payload includes an [AddressedPayload](../../../warp/payload/payload.go). + +Since the predicate is encoded into the [Transaction Access List](https://eips.ethereum.org/EIPS/eip-2930), it is packed into 32 byte hashes intended to declare storage slots that should be pre-warmed into the cache prior to transaction execution. + +Therefore, we use the [Predicate Utils](../../../utils/predicate/README.md) package to encode the actual byte slice of size N into the access list. + +## Design Considerations + +### Re-Processing Historical Blocks + +Avalanche Warp Messaging depends on the Avalanche P-Chain state at the P-Chain height specified by the ProposerVM block header. + +Verifying a message requires looking up the validator set of the source subnet on the P-Chain. To support this, Avalanche Warp Messaging uses the ProposerVM header, which includes the P-Chain height it was issued at as the canonical point to lookup the source subnet's validator set. + +This means verifying the Warp Message and therefore the state transition on a block depends on state that is external to the blockchain itself: the P-Chain. + +The Avalanche P-Chain tracks only its current state and reverse diff layers (reversing the changes from past blocks) in order to re-calculate the validator set at a historical height. This means calculating a very old validator set that is used to verify a Warp Message in an old block may become prohibitively expensive. + +Therefore, we need a heuristic to ensure that the network can correctly re-process old blocks (note: re-processing old blocks is a requirement to perform bootstrapping and is used in some VMs including Subnet-EVM to serve or verify historical data). + +As a result, we require that the block itself provides a deterministic hint which determines which Avalanche Warp Messages were considered valid/invalid during the block's execution. This ensures that we can always re-process blocks and use the hint to decide whether an Avalanche Warp Message should be treated as valid/invalid even after the P-Chain state that was used at the original execution time may no longer support fast lookups. + +To provide that hint, we've explored two designs: + +1. Include a predicate in the transaction to ensure any referenced message is valid +2. Append the results of checking whether a Warp Message is valid/invalid to the block data itself + +The current implementation uses option (1). + +The original reason for this was that the notion of predicates for precompiles was designed with Shared Memory in mind. In the case of shared memory, there is no canonical "P-Chain height" in the block which determines whether or not Avalanche Warp Messages are valid. + +Instead, the VM interprets a shared memory import operation as valid as soon as the UTXO is available in shared memory. This means that if it were up to the block producer to staple the valid/invalid results of whether or not an attempted atomic operation should be treated as valid, a byzantine block producer could arbitrarily report that such atomic operations were invalid and cause a griefing attack to burn the gas of users that attempted to perform an import. + +Therefore, a transaction specified predicate is required to implement the shared memory precompile to prevent such a griefing attack. + +In contrast, Avalanche Warp Messages are validated within the context of an exact P-Chain height. Therefore, if a block producer attempted to lie about the validity of such a message, the network would interpret that block as invalid. + +### Guarantees Offered by Warp Precompile vs. Built on Top + +#### Guarantees Offered by Warp Precompile + +The Warp Precompile was designed with the intention of minimizing the trusted computing base for Subnet-EVM. Therefore, it makes several tradeoffs which encourage users to use protocols built ON TOP of the Warp Precompile itself as opposed to directly using the Warp Precompile. + +The Warp Precompile itself provides ONLY the following ability: + +send a verified message from a caller on blockchain A to a destination address on blockchain B + +#### Explicitly Not Provided / Built on Top + +The Warp Precompile itself does not provide any guarantees of: + +- Eventual message delivery (may require re-send on blockchain A and additional assumptions about off-chain relayers and chain progress) +- Ordering of messages (requires ordering provided a layer above) +- Replay protection (requires replay protection provided a layer above) diff --git a/x/warp/config.go b/x/warp/config.go new file mode 100644 index 0000000000..e1fc7a275d --- /dev/null +++ b/x/warp/config.go @@ -0,0 +1,218 @@ +// (c) 2023, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package warp + +import ( + "context" + "errors" + "fmt" + + "github.com/ava-labs/avalanchego/vms/platformvm/warp" + "github.com/ava-labs/subnet-evm/params" + "github.com/ava-labs/subnet-evm/precompile/precompileconfig" + predicateutils "github.com/ava-labs/subnet-evm/utils/predicate" + warpPayload "github.com/ava-labs/subnet-evm/warp/payload" + warpValidators "github.com/ava-labs/subnet-evm/warp/validators" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/math" + "github.com/ethereum/go-ethereum/log" +) + +var ( + _ precompileconfig.Config = &Config{} + _ precompileconfig.ProposerPredicater = &Config{} + _ precompileconfig.Accepter = &Config{} +) + +var ( + errOverflowSignersGasCost = errors.New("overflow calculating warp signers gas cost") + errNoProposerCtxPredicate = errors.New("cannot verify warp predicate without proposer context") + errInvalidPredicateBytes = errors.New("cannot unpack predicate bytes") + errInvalidWarpMsg = errors.New("cannot unpack warp message") + errInvalidAddressedPayload = errors.New("cannot unpack addressed payload") + errCannotGetNumSigners = errors.New("cannot fetch num signers from warp message") +) + +// Config implements the precompileconfig.Config interface and +// adds specific configuration for Warp. +type Config struct { + precompileconfig.Upgrade + QuorumNumerator uint64 `json:"quorumNumerator"` +} + +// NewConfig returns a config for a network upgrade at [blockTimestamp] that enables +// Warp with the given quorum numerator. +func NewConfig(blockTimestamp *uint64, quorumNumerator uint64) *Config { + return &Config{ + Upgrade: precompileconfig.Upgrade{BlockTimestamp: blockTimestamp}, + QuorumNumerator: quorumNumerator, + } +} + +// NewDefaultConfig returns a config for a network upgrade at [blockTimestamp] that enables +// Warp with the default quorum numerator (0 denotes using the default). +func NewDefaultConfig(blockTimestamp *uint64) *Config { + return NewConfig(blockTimestamp, 0) +} + +// NewDisableConfig returns config for a network upgrade at [blockTimestamp] +// that disables Warp. +func NewDisableConfig(blockTimestamp *uint64) *Config { + return &Config{ + Upgrade: precompileconfig.Upgrade{ + BlockTimestamp: blockTimestamp, + Disable: true, + }, + } +} + +// Key returns the key for the Warp precompileconfig. +// This should be the same key as used in the precompile module. +func (*Config) Key() string { return ConfigKey } + +// Verify tries to verify Config and returns an error accordingly. +func (c *Config) Verify() error { + if c.QuorumNumerator > params.WarpQuorumDenominator { + return fmt.Errorf("cannot specify quorum numerator (%d) > quorum denominator (%d)", c.QuorumNumerator, params.WarpQuorumDenominator) + } + // If a non-default quorum numerator is specified and it is less than the minimum, return an error + if c.QuorumNumerator != 0 && c.QuorumNumerator < params.WarpQuorumNumeratorMinimum { + return fmt.Errorf("cannot specify quorum numerator (%d) < min quorum numerator (%d)", c.QuorumNumerator, params.WarpQuorumNumeratorMinimum) + } + return nil +} + +// Equal returns true if [s] is a [*Config] and it has been configured identical to [c]. +func (c *Config) Equal(s precompileconfig.Config) bool { + // typecast before comparison + other, ok := (s).(*Config) + if !ok { + return false + } + equals := c.Upgrade.Equal(&other.Upgrade) + return equals && c.QuorumNumerator == other.QuorumNumerator +} + +func (c *Config) Accept(acceptCtx *precompileconfig.AcceptContext, txHash common.Hash, logIndex int, topics []common.Hash, logData []byte) error { + unsignedMessage, err := warp.ParseUnsignedMessage(logData) + if err != nil { + return fmt.Errorf("failed to parse warp log data into unsigned message (TxHash: %s, LogIndex: %d): %w", txHash, logIndex, err) + } + log.Info("Accepted warp unsigned message", "txHash", txHash, "logIndex", logIndex, "logData", common.Bytes2Hex(logData)) + if err := acceptCtx.Warp.AddMessage(unsignedMessage); err != nil { + return fmt.Errorf("failed to add warp message during accept (TxHash: %s, LogIndex: %d): %w", txHash, logIndex, err) + } + return nil +} + +// verifyWarpMessage checks that [warpMsg] can be parsed as an addressed payload and verifies the Warp Message Signature +// within [predicateContext]. +func (c *Config) verifyWarpMessage(predicateContext *precompileconfig.ProposerPredicateContext, warpMsg *warp.Message) error { + // Use default quorum numerator unless config specifies a non-default option + quorumNumerator := params.WarpDefaultQuorumNumerator + if c.QuorumNumerator != 0 { + quorumNumerator = c.QuorumNumerator + } + + // Verify the warp payload can be decoded to the expected type + _, err := warpPayload.ParseAddressedPayload(warpMsg.UnsignedMessage.Payload) + if err != nil { + return fmt.Errorf("%w: %s", errInvalidAddressedPayload, err) + } + + log.Debug("verifying warp message", "warpMsg", warpMsg, "quorumNum", quorumNumerator, "quorumDenom", params.WarpQuorumDenominator) + if err := warpMsg.Signature.Verify( + context.Background(), + &warpMsg.UnsignedMessage, + predicateContext.SnowCtx.NetworkID, + warpValidators.NewState(predicateContext.SnowCtx), // Wrap validators.State on the chain snow context to special case the Primary Network + predicateContext.ProposerVMBlockCtx.PChainHeight, + quorumNumerator, + params.WarpQuorumDenominator, + ); err != nil { + return fmt.Errorf("warp signature verification failed: %w", err) + } + + return nil +} + +// PredicateGas returns the amount of gas necessary to verify the predicate +// PredicateGas charges for: +// 1. Base cost of the message +// 2. Size of the message +// 3. Number of signers +// 4. TODO: Lookup of the validator set +func (c *Config) PredicateGas(predicateBytes []byte) (uint64, error) { + totalGas := GasCostPerSignatureVerification + bytesGasCost, overflow := math.SafeMul(GasCostPerWarpMessageBytes, uint64(len(predicateBytes))) + if overflow { + return 0, fmt.Errorf("overflow calculating gas cost for warp message bytes of size %d", len(predicateBytes)) + } + totalGas, overflow = math.SafeAdd(totalGas, bytesGasCost) + if overflow { + return 0, fmt.Errorf("overflow adding bytes gas cost of size %d", len(predicateBytes)) + } + + unpackedPredicateBytes, err := predicateutils.UnpackPredicate(predicateBytes) + if err != nil { + return 0, fmt.Errorf("%w: %s", errInvalidPredicateBytes, err) + } + warpMessage, err := warp.ParseMessage(unpackedPredicateBytes) + if err != nil { + return 0, fmt.Errorf("%w: %s", errInvalidWarpMsg, err) + } + + numSigners, err := warpMessage.Signature.NumSigners() + if err != nil { + return 0, fmt.Errorf("%w: %s", errCannotGetNumSigners, err) + } + signerGas, overflow := math.SafeMul(uint64(numSigners), GasCostPerWarpSigner) + if overflow { + return 0, errOverflowSignersGasCost + } + totalGas, overflow = math.SafeAdd(totalGas, signerGas) + if overflow { + return 0, fmt.Errorf("overflow adding signer gas (PrevTotal: %d, VerificationGas: %d)", totalGas, signerGas) + } + + // TODO: charge for the Subnet validator set lookup + // ctx := context.Background() + // subnetID, err := predicateContext.SnowCtx.ValidatorState.GetSubnetID(ctx, warpMessage.SourceChainID) + // if err != nil { + // return 0, fmt.Errorf("failed to look up SubnetID for SourceChainID: %s", warpMessage.SourceChainID) + // } + // validatorSet, err := predicateContext.SnowCtx.ValidatorState.GetValidatorSet(ctx, predicateContext.ProposerVMBlockCtx.PChainHeight, subnetID) + // if err != nil { + // return 0, fmt.Errorf("failed to look up validator set verifying warp message: %w", err) + // } + // subnetLookupGasCost, overflow := math.SafeMul(uint64(len(validatorSet)), GasCostPerSourceSubnetValidator) + // if overflow { + // return 0, fmt.Errorf("overflow calculating gas cost for subnet (%s) validator set lookup of size %d", subnetID, len(validatorSet)) + // } + // totalGas, overflow = math.SafeAdd(totalGas, subnetLookupGasCost) + // if overflow { + // return 0, fmt.Errorf("overflow adding subnet lookup gas (PrevTotal: %d, SubnetLookupGas: %d)", totalGas, subnetLookupGasCost) + // } + + return totalGas, nil +} + +// VerifyPredicate verifies the predicate represents a valid signed and properly formatted Avalanche Warp Message. +func (c *Config) VerifyPredicate(predicateContext *precompileconfig.ProposerPredicateContext, predicateBytes []byte) error { + if predicateContext.ProposerVMBlockCtx == nil { + return errNoProposerCtxPredicate + } + // Note: PredicateGas should be called before VerifyPredicate, so we should never reach an error case here. + unpackedPredicateBytes, err := predicateutils.UnpackPredicate(predicateBytes) + if err != nil { + return err + } + + // Note: PredicateGas should be called before VerifyPredicate, so we should never reach an error case here. + warpMessage, err := warp.ParseMessage(unpackedPredicateBytes) + if err != nil { + return fmt.Errorf("%w: %s", errInvalidWarpMsg, err) + } + return c.verifyWarpMessage(predicateContext, warpMessage) +} diff --git a/x/warp/config_test.go b/x/warp/config_test.go new file mode 100644 index 0000000000..5381b612cf --- /dev/null +++ b/x/warp/config_test.go @@ -0,0 +1,110 @@ +// (c) 2023, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package warp + +import ( + "fmt" + "testing" + + "github.com/ava-labs/subnet-evm/params" + "github.com/ava-labs/subnet-evm/precompile/precompileconfig" + "github.com/ava-labs/subnet-evm/utils" + "github.com/stretchr/testify/require" +) + +func TestVerifyWarpconfig(t *testing.T) { + tests := []struct { + name string + config precompileconfig.Config + ExpectedError string + }{ + { + name: "quorum numerator less than minimum", + config: NewConfig(utils.NewUint64(3), params.WarpQuorumNumeratorMinimum-1), + ExpectedError: fmt.Sprintf("cannot specify quorum numerator (%d) < min quorum numerator (%d)", params.WarpQuorumNumeratorMinimum-1, params.WarpQuorumNumeratorMinimum), + }, + { + name: "quorum numerator greater than quorum denominator", + config: NewConfig(utils.NewUint64(3), params.WarpQuorumDenominator+1), + ExpectedError: fmt.Sprintf("cannot specify quorum numerator (%d) > quorum denominator (%d)", params.WarpQuorumDenominator+1, params.WarpQuorumDenominator), + }, + { + name: "default quorum numerator", + config: NewDefaultConfig(utils.NewUint64(3)), + }, + { + name: "valid quorum numerator 1 less than denominator", + config: NewConfig(utils.NewUint64(3), params.WarpQuorumDenominator-1), + }, + { + name: "valid quorum numerator 1 more than minimum", + config: NewConfig(utils.NewUint64(3), params.WarpQuorumNumeratorMinimum+1), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require := require.New(t) + + err := tt.config.Verify() + if tt.ExpectedError == "" { + require.NoError(err) + } else { + require.ErrorContains(err, tt.ExpectedError) + } + }) + } +} + +func TestEqualWarpConfig(t *testing.T) { + tests := []struct { + name string + config precompileconfig.Config + other precompileconfig.Config + expected bool + }{ + { + name: "non-nil config and nil other", + config: NewDefaultConfig(utils.NewUint64(3)), + other: nil, + expected: false, + }, + { + name: "different type", + config: NewDefaultConfig(utils.NewUint64(3)), + other: precompileconfig.NewNoopStatefulPrecompileConfig(), + expected: false, + }, + { + name: "different timestamp", + config: NewDefaultConfig(utils.NewUint64(3)), + other: NewDefaultConfig(utils.NewUint64(4)), + expected: false, + }, + { + name: "different quorum numerator", + config: NewConfig(utils.NewUint64(3), params.WarpQuorumNumeratorMinimum+1), + other: NewConfig(utils.NewUint64(3), params.WarpQuorumNumeratorMinimum+2), + expected: false, + }, + { + name: "same default config", + config: NewDefaultConfig(utils.NewUint64(3)), + other: NewDefaultConfig(utils.NewUint64(3)), + expected: true, + }, + { + name: "same non-default config", + config: NewConfig(utils.NewUint64(3), params.WarpQuorumNumeratorMinimum+5), + other: NewConfig(utils.NewUint64(3), params.WarpQuorumNumeratorMinimum+5), + expected: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require := require.New(t) + + require.Equal(tt.expected, tt.config.Equal(tt.other)) + }) + } +} diff --git a/x/warp/contract.abi b/x/warp/contract.abi new file mode 100644 index 0000000000..99773f9fed --- /dev/null +++ b/x/warp/contract.abi @@ -0,0 +1,114 @@ +[ + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "destinationChainID", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "address", + "name": "destinationAddress", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "sender", + "type": "address" + }, + { + "indexed": false, + "internalType": "bytes", + "name": "message", + "type": "bytes" + } + ], + "name": "SendWarpMessage", + "type": "event" + }, + { + "inputs": [], + "name": "getBlockchainID", + "outputs": [ + { + "internalType": "bytes32", + "name": "blockchainID", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getVerifiedWarpMessage", + "outputs": [ + { + "components": [ + { + "internalType": "bytes32", + "name": "originChainID", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "originSenderAddress", + "type": "address" + }, + { + "internalType": "bytes32", + "name": "destinationChainID", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "destinationAddress", + "type": "address" + }, + { + "internalType": "bytes", + "name": "payload", + "type": "bytes" + } + ], + "internalType": "struct WarpMessage", + "name": "message", + "type": "tuple" + }, + { + "internalType": "bool", + "name": "exists", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "destinationChainID", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "destinationAddress", + "type": "address" + }, + { + "internalType": "bytes", + "name": "payload", + "type": "bytes" + } + ], + "name": "sendWarpMessage", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } +] \ No newline at end of file diff --git a/x/warp/contract.go b/x/warp/contract.go new file mode 100644 index 0000000000..646accc861 --- /dev/null +++ b/x/warp/contract.go @@ -0,0 +1,283 @@ +// (c) 2023, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package warp + +import ( + "errors" + "fmt" + + "github.com/ava-labs/avalanchego/vms/platformvm/warp" + "github.com/ava-labs/subnet-evm/params" + "github.com/ava-labs/subnet-evm/precompile/contract" + predicateutils "github.com/ava-labs/subnet-evm/utils/predicate" + "github.com/ava-labs/subnet-evm/vmerrs" + warpPayload "github.com/ava-labs/subnet-evm/warp/payload" + + _ "embed" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/math" +) + +const ( + GetVerifiedWarpMessageBaseCost uint64 = 2 // Base cost of entering getVerifiedWarpMessage + GetBlockchainIDGasCost uint64 = 2 // Based on GasQuickStep used in existing EVM instructions + AddWarpMessageGasCost uint64 = 20_000 // Cost of producing and serving a BLS Signature + // Sum of base log gas cost, cost of producing 4 topics, and producing + serving a BLS Signature (sign + trie write) + // Note: using trie write for the gas cost results in a conservative overestimate since the message is stored in a + // flat database that can be cleaned up after a period of time instead of the EVM trie. + + SendWarpMessageGasCost uint64 = params.LogGas + 4*params.LogTopicGas + AddWarpMessageGasCost + contract.WriteGasCostPerSlot + // SendWarpMessageGasCostPerByte cost accounts for producing a signed message of a given size + SendWarpMessageGasCostPerByte uint64 = params.LogDataGas + + GasCostPerWarpSigner uint64 = 500 + GasCostPerWarpMessageBytes uint64 = 100 // TODO: charge O(n) cost for decoding predicate of input size n + GasCostPerSignatureVerification uint64 = 200_000 + // GasCostPerSourceSubnetValidator uint64 = 1 // TODO: charge O(n) cost for subnet validator set lookup +) + +var ( + errInvalidSendInput = errors.New("invalid sendWarpMessage input") +) + +// Singleton StatefulPrecompiledContract and signatures. +var ( + // WarpRawABI contains the raw ABI of Warp contract. + //go:embed contract.abi + WarpRawABI string + + WarpABI = contract.ParseABI(WarpRawABI) + + WarpPrecompile = createWarpPrecompile() +) + +// WarpMessage is an auto generated low-level Go binding around an user-defined struct. +type WarpMessage struct { + OriginChainID common.Hash + OriginSenderAddress common.Address + DestinationChainID common.Hash + DestinationAddress common.Address + Payload []byte +} + +type GetVerifiedWarpMessageOutput struct { + Message WarpMessage + Exists bool +} + +type SendWarpMessageInput struct { + DestinationChainID common.Hash + DestinationAddress common.Address + Payload []byte +} + +// PackGetBlockchainID packs the include selector (first 4 func signature bytes). +// This function is mostly used for tests. +func PackGetBlockchainID() ([]byte, error) { + return WarpABI.Pack("getBlockchainID") +} + +// PackGetBlockchainIDOutput attempts to pack given blockchainID of type common.Hash +// to conform the ABI outputs. +func PackGetBlockchainIDOutput(blockchainID common.Hash) ([]byte, error) { + return WarpABI.PackOutput("getBlockchainID", blockchainID) +} + +// getBlockchainID returns the snow Chain Context ChainID of this blockchain. +func getBlockchainID(accessibleState contract.AccessibleState, caller common.Address, addr common.Address, input []byte, suppliedGas uint64, readOnly bool) (ret []byte, remainingGas uint64, err error) { + if remainingGas, err = contract.DeductGas(suppliedGas, GetBlockchainIDGasCost); err != nil { + return nil, 0, err + } + packedOutput, err := PackGetBlockchainIDOutput(common.Hash(accessibleState.GetSnowContext().ChainID)) + if err != nil { + return nil, remainingGas, err + } + + // Return the packed output and the remaining gas + return packedOutput, remainingGas, nil +} + +// PackGetVerifiedWarpMessage packs the calldata for the getVerifiedWarpMessage function +// This function is mostly used for tests. +func PackGetVerifiedWarpMessage() ([]byte, error) { + return WarpABI.Pack("getVerifiedWarpMessage") +} + +// PackGetVerifiedWarpMessageOutput attempts to pack given [outputStruct] of type GetVerifiedWarpMessageOutput +// to conform the ABI outputs. +func PackGetVerifiedWarpMessageOutput(outputStruct GetVerifiedWarpMessageOutput) ([]byte, error) { + return WarpABI.PackOutput("getVerifiedWarpMessage", + outputStruct.Message, + outputStruct.Exists, + ) +} + +// getVerifiedWarpMessage retrieves the pre-verified warp message from the predicate storage slots and returns +// the expected ABI encoding of the message to the caller. +func getVerifiedWarpMessage(accessibleState contract.AccessibleState, caller common.Address, addr common.Address, _ []byte, suppliedGas uint64, readOnly bool) (ret []byte, remainingGas uint64, err error) { + remainingGas, err = contract.DeductGas(suppliedGas, GetVerifiedWarpMessageBaseCost) + if err != nil { + return nil, remainingGas, err + } + // Ignore input since there are no arguments + predicateBytes, exists := accessibleState.GetStateDB().GetPredicateStorageSlots(ContractAddress) + // If there is no such value, return false to the caller. + if !exists { + packedOutput, err := PackGetVerifiedWarpMessageOutput(GetVerifiedWarpMessageOutput{ + Exists: false, + }) + if err != nil { + return nil, remainingGas, err + } + return packedOutput, remainingGas, nil + } + + // Note: we charge for the size of the message during both predicate verification and each time the message is read during + // EVM execution because each execution incurs an additional read cost. + msgBytesGas, overflow := math.SafeMul(GasCostPerWarpMessageBytes, uint64(len(predicateBytes))) + if overflow { + return nil, remainingGas, vmerrs.ErrOutOfGas + } + if remainingGas, err = contract.DeductGas(remainingGas, msgBytesGas); err != nil { + return nil, 0, err + } + // Note: since the predicate is verified in advance of execution, the precompile should not + // hit an error during execution. + unpackedPredicateBytes, err := predicateutils.UnpackPredicate(predicateBytes) + if err != nil { + return nil, remainingGas, fmt.Errorf("%w: %s", errInvalidPredicateBytes, err) + } + warpMessage, err := warp.ParseMessage(unpackedPredicateBytes) + if err != nil { + return nil, remainingGas, fmt.Errorf("%w: %s", errInvalidWarpMsg, err) + } + + addressedPayload, err := warpPayload.ParseAddressedPayload(warpMessage.UnsignedMessage.Payload) + if err != nil { + return nil, remainingGas, fmt.Errorf("%w: %s", errInvalidAddressedPayload, err) + } + packedOutput, err := PackGetVerifiedWarpMessageOutput(GetVerifiedWarpMessageOutput{ + Message: WarpMessage{ + OriginChainID: common.Hash(warpMessage.SourceChainID), + OriginSenderAddress: addressedPayload.SourceAddress, + DestinationChainID: addressedPayload.DestinationChainID, + DestinationAddress: addressedPayload.DestinationAddress, + Payload: addressedPayload.Payload, + }, + Exists: true, + }) + if err != nil { + return nil, remainingGas, err + } + + // Return the packed output and the remaining gas + return packedOutput, remainingGas, nil +} + +// UnpackSendWarpMessageInput attempts to unpack [input] as SendWarpMessageInput +// assumes that [input] does not include selector (omits first 4 func signature bytes) +func UnpackSendWarpMessageInput(input []byte) (SendWarpMessageInput, error) { + inputStruct := SendWarpMessageInput{} + err := WarpABI.UnpackInputIntoInterface(&inputStruct, "sendWarpMessage", input) + + return inputStruct, err +} + +// PackSendWarpMessage packs [inputStruct] of type SendWarpMessageInput into the appropriate arguments for sendWarpMessage. +func PackSendWarpMessage(inputStruct SendWarpMessageInput) ([]byte, error) { + return WarpABI.Pack("sendWarpMessage", inputStruct.DestinationChainID, inputStruct.DestinationAddress, inputStruct.Payload) +} + +// sendWarpMessage constructs an Avalanche Warp Message containing an AddressedPayload and emits a log to signal validators that they should +// be willing to sign this message. +func sendWarpMessage(accessibleState contract.AccessibleState, caller common.Address, addr common.Address, input []byte, suppliedGas uint64, readOnly bool) (ret []byte, remainingGas uint64, err error) { + if remainingGas, err = contract.DeductGas(suppliedGas, SendWarpMessageGasCost); err != nil { + return nil, 0, err + } + // This gas cost includes buffer room because it is based off of the total size of the input instead of the produced payload. + // This ensures that we charge gas before we unpack the variable sized input. + payloadGas, overflow := math.SafeMul(SendWarpMessageGasCostPerByte, uint64(len(input))) + if overflow { + return nil, 0, vmerrs.ErrOutOfGas + } + if remainingGas, err = contract.DeductGas(remainingGas, payloadGas); err != nil { + return nil, 0, err + } + if readOnly { + return nil, remainingGas, vmerrs.ErrWriteProtection + } + // unpack the arguments + inputStruct, err := UnpackSendWarpMessageInput(input) + if err != nil { + return nil, remainingGas, fmt.Errorf("%w: %s", errInvalidSendInput, err) + } + + var ( + sourceChainID = accessibleState.GetSnowContext().ChainID + destinationChainID = inputStruct.DestinationChainID + sourceAddress = caller + destinationAddress = inputStruct.DestinationAddress + payload = inputStruct.Payload + ) + + addressedPayload, err := warpPayload.NewAddressedPayload( + sourceAddress, + destinationChainID, + destinationAddress, + payload, + ) + if err != nil { + return nil, remainingGas, err + } + unsignedWarpMessage, err := warp.NewUnsignedMessage( + accessibleState.GetSnowContext().NetworkID, + sourceChainID, + addressedPayload.Bytes(), + ) + if err != nil { + return nil, remainingGas, err + } + + // Add a log to be handled if this action is finalized. + accessibleState.GetStateDB().AddLog( + ContractAddress, + []common.Hash{ + WarpABI.Events["SendWarpMessage"].ID, + destinationChainID, + destinationAddress.Hash(), + sourceAddress.Hash(), + }, + unsignedWarpMessage.Bytes(), + accessibleState.GetBlockContext().Number().Uint64(), + ) + + // Return an empty output and the remaining gas + return []byte{}, remainingGas, nil +} + +// createWarpPrecompile returns a StatefulPrecompiledContract with getters and setters for the precompile. +func createWarpPrecompile() contract.StatefulPrecompiledContract { + var functions []*contract.StatefulPrecompileFunction + + abiFunctionMap := map[string]contract.RunStatefulPrecompileFunc{ + "getBlockchainID": getBlockchainID, + "getVerifiedWarpMessage": getVerifiedWarpMessage, + "sendWarpMessage": sendWarpMessage, + } + + for name, function := range abiFunctionMap { + method, ok := WarpABI.Methods[name] + if !ok { + panic(fmt.Errorf("given method (%s) does not exist in the ABI", name)) + } + functions = append(functions, contract.NewStatefulPrecompileFunction(method.ID, function)) + } + // Construct the contract with no fallback function. + statefulContract, err := contract.NewStatefulPrecompileContract(nil, functions) + if err != nil { + panic(err) + } + return statefulContract +} diff --git a/x/warp/contract_test.go b/x/warp/contract_test.go new file mode 100644 index 0000000000..39970149a9 --- /dev/null +++ b/x/warp/contract_test.go @@ -0,0 +1,303 @@ +// (c) 2023, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package warp + +import ( + "testing" + + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/snow" + "github.com/ava-labs/avalanchego/utils" + avalancheWarp "github.com/ava-labs/avalanchego/vms/platformvm/warp" + "github.com/ava-labs/subnet-evm/core/state" + "github.com/ava-labs/subnet-evm/precompile/contract" + "github.com/ava-labs/subnet-evm/precompile/testutils" + predicateutils "github.com/ava-labs/subnet-evm/utils/predicate" + "github.com/ava-labs/subnet-evm/vmerrs" + warpPayload "github.com/ava-labs/subnet-evm/warp/payload" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestGetBlockchainID(t *testing.T) { + callerAddr := common.HexToAddress("0x0123") + + defaultSnowCtx := snow.DefaultContextTest() + blockchainID := defaultSnowCtx.ChainID + + tests := map[string]testutils.PrecompileTest{ + "getBlockchainID success": { + Caller: callerAddr, + InputFn: func(t testing.TB) []byte { + input, err := PackGetBlockchainID() + require.NoError(t, err) + + return input + }, + SuppliedGas: GetBlockchainIDGasCost, + ReadOnly: false, + ExpectedRes: func() []byte { + expectedOutput, err := PackGetBlockchainIDOutput(common.Hash(blockchainID)) + require.NoError(t, err) + + return expectedOutput + }(), + }, + "getBlockchainID readOnly": { + Caller: callerAddr, + InputFn: func(t testing.TB) []byte { + input, err := PackGetBlockchainID() + require.NoError(t, err) + + return input + }, + SuppliedGas: GetBlockchainIDGasCost, + ReadOnly: true, + ExpectedRes: func() []byte { + expectedOutput, err := PackGetBlockchainIDOutput(common.Hash(blockchainID)) + require.NoError(t, err) + + return expectedOutput + }(), + }, + "getBlockchainID insufficient gas": { + Caller: callerAddr, + InputFn: func(t testing.TB) []byte { + input, err := PackGetBlockchainID() + require.NoError(t, err) + + return input + }, + SuppliedGas: GetBlockchainIDGasCost - 1, + ReadOnly: false, + ExpectedErr: vmerrs.ErrOutOfGas.Error(), + }, + } + + testutils.RunPrecompileTests(t, Module, state.NewTestStateDB, tests) +} + +func TestSendWarpMessage(t *testing.T) { + callerAddr := common.HexToAddress("0x0123") + receiverAddr := common.HexToAddress("0x456789") + + defaultSnowCtx := snow.DefaultContextTest() + blockchainID := defaultSnowCtx.ChainID + destinationChainID := ids.GenerateTestID() + sendWarpMessagePayload := utils.RandomBytes(100) + + sendWarpMessageInput, err := PackSendWarpMessage(SendWarpMessageInput{ + DestinationChainID: common.Hash(destinationChainID), + DestinationAddress: receiverAddr, + Payload: sendWarpMessagePayload, + }) + require.NoError(t, err) + + tests := map[string]testutils.PrecompileTest{ + "send warp message readOnly": { + Caller: callerAddr, + InputFn: func(t testing.TB) []byte { return sendWarpMessageInput }, + SuppliedGas: SendWarpMessageGasCost + uint64(len(sendWarpMessageInput[4:])*int(SendWarpMessageGasCostPerByte)), + ReadOnly: true, + ExpectedErr: vmerrs.ErrWriteProtection.Error(), + }, + "send warp message insufficient gas for first step": { + Caller: callerAddr, + InputFn: func(t testing.TB) []byte { return sendWarpMessageInput }, + SuppliedGas: SendWarpMessageGasCost - 1, + ReadOnly: false, + ExpectedErr: vmerrs.ErrOutOfGas.Error(), + }, + "send warp message insufficient gas for payload bytes": { + Caller: callerAddr, + InputFn: func(t testing.TB) []byte { return sendWarpMessageInput }, + SuppliedGas: SendWarpMessageGasCost + uint64(len(sendWarpMessageInput[4:])*int(SendWarpMessageGasCostPerByte)) - 1, + ReadOnly: false, + ExpectedErr: vmerrs.ErrOutOfGas.Error(), + }, + "send warp message invalid input": { + Caller: callerAddr, + InputFn: func(t testing.TB) []byte { + return sendWarpMessageInput[:4] // Include only the function selector, so that the input is invalid + }, + SuppliedGas: SendWarpMessageGasCost, + ReadOnly: false, + ExpectedErr: errInvalidSendInput.Error(), + }, + "send warp message success": { + Caller: callerAddr, + InputFn: func(t testing.TB) []byte { return sendWarpMessageInput }, + SuppliedGas: SendWarpMessageGasCost + uint64(len(sendWarpMessageInput[4:])*int(SendWarpMessageGasCostPerByte)), + ReadOnly: false, + ExpectedRes: []byte{}, + AfterHook: func(t testing.TB, state contract.StateDB) { + logsData := state.GetLogData() + require.Len(t, logsData, 1) + logData := logsData[0] + + unsignedWarpMsg, err := avalancheWarp.ParseUnsignedMessage(logData) + require.NoError(t, err) + addressedPayload, err := warpPayload.ParseAddressedPayload(unsignedWarpMsg.Payload) + require.NoError(t, err) + + require.Equal(t, addressedPayload.SourceAddress, callerAddr) + require.Equal(t, unsignedWarpMsg.SourceChainID, blockchainID) + require.Equal(t, addressedPayload.DestinationChainID, common.Hash(destinationChainID)) + require.Equal(t, addressedPayload.DestinationAddress, receiverAddr) + require.Equal(t, addressedPayload.Payload, sendWarpMessagePayload) + }, + }, + } + + testutils.RunPrecompileTests(t, Module, state.NewTestStateDB, tests) +} + +func TestGetVerifiedWarpMessage(t *testing.T) { + networkID := uint32(54321) + callerAddr := common.HexToAddress("0x0123") + sourceAddress := common.HexToAddress("0x456789") + destinationAddress := common.HexToAddress("0x987654") + sourceChainID := ids.GenerateTestID() + packagedPayloadBytes := []byte("mcsorley") + addressedPayload, err := warpPayload.NewAddressedPayload( + sourceAddress, + common.Hash(destinationChainID), + destinationAddress, + packagedPayloadBytes, + ) + require.NoError(t, err) + unsignedWarpMsg, err := avalancheWarp.NewUnsignedMessage(networkID, sourceChainID, addressedPayload.Bytes()) + require.NoError(t, err) + warpMessage, err := avalancheWarp.NewMessage(unsignedWarpMsg, &avalancheWarp.BitSetSignature{}) // Create message with empty signature for testing + require.NoError(t, err) + warpMessagePredicateBytes := predicateutils.PackPredicate(warpMessage.Bytes()) + getVerifiedWarpMsg, err := PackGetVerifiedWarpMessage() + require.NoError(t, err) + + tests := map[string]testutils.PrecompileTest{ + "get message success": { + Caller: callerAddr, + InputFn: func(t testing.TB) []byte { return getVerifiedWarpMsg }, + BeforeHook: func(t testing.TB, state contract.StateDB) { + state.SetPredicateStorageSlots(ContractAddress, warpMessagePredicateBytes) + }, + SuppliedGas: GetVerifiedWarpMessageBaseCost + GasCostPerWarpMessageBytes*uint64(len(warpMessagePredicateBytes)), + ReadOnly: false, + ExpectedRes: func() []byte { + res, err := PackGetVerifiedWarpMessageOutput(GetVerifiedWarpMessageOutput{ + Message: WarpMessage{ + OriginChainID: common.Hash(sourceChainID), + OriginSenderAddress: sourceAddress, + DestinationChainID: common.Hash(destinationChainID), + DestinationAddress: destinationAddress, + Payload: packagedPayloadBytes, + }, + Exists: true, + }) + if err != nil { + panic(err) + } + return res + }(), + }, + "get non-existent message": { + Caller: callerAddr, + InputFn: func(t testing.TB) []byte { return getVerifiedWarpMsg }, + SuppliedGas: GetVerifiedWarpMessageBaseCost, + ReadOnly: false, + ExpectedRes: func() []byte { + res, err := PackGetVerifiedWarpMessageOutput(GetVerifiedWarpMessageOutput{Exists: false}) + if err != nil { + panic(err) + } + return res + }(), + }, + "get message success readOnly": { + Caller: callerAddr, + InputFn: func(t testing.TB) []byte { return getVerifiedWarpMsg }, + BeforeHook: func(t testing.TB, state contract.StateDB) { + state.SetPredicateStorageSlots(ContractAddress, warpMessagePredicateBytes) + }, + SuppliedGas: GetVerifiedWarpMessageBaseCost + GasCostPerWarpMessageBytes*uint64(len(warpMessagePredicateBytes)), + ReadOnly: true, + ExpectedRes: func() []byte { + res, err := PackGetVerifiedWarpMessageOutput(GetVerifiedWarpMessageOutput{ + Message: WarpMessage{ + OriginChainID: common.Hash(sourceChainID), + OriginSenderAddress: sourceAddress, + DestinationChainID: common.Hash(destinationChainID), + DestinationAddress: destinationAddress, + Payload: packagedPayloadBytes, + }, + Exists: true, + }) + if err != nil { + panic(err) + } + return res + }(), + }, + "get non-existent message readOnly": { + Caller: callerAddr, + InputFn: func(t testing.TB) []byte { return getVerifiedWarpMsg }, + SuppliedGas: GetVerifiedWarpMessageBaseCost, + ReadOnly: true, + ExpectedRes: func() []byte { + res, err := PackGetVerifiedWarpMessageOutput(GetVerifiedWarpMessageOutput{Exists: false}) + if err != nil { + panic(err) + } + return res + }(), + }, + "get message out of gas": { + Caller: callerAddr, + InputFn: func(t testing.TB) []byte { return getVerifiedWarpMsg }, + BeforeHook: func(t testing.TB, state contract.StateDB) { + state.SetPredicateStorageSlots(ContractAddress, warpMessagePredicateBytes) + }, + SuppliedGas: GetVerifiedWarpMessageBaseCost + GasCostPerWarpMessageBytes*uint64(len(warpMessagePredicateBytes)) - 1, + ReadOnly: false, + ExpectedErr: vmerrs.ErrOutOfGas.Error(), + }, + "get message invalid predicate packing": { + Caller: callerAddr, + InputFn: func(t testing.TB) []byte { return getVerifiedWarpMsg }, + BeforeHook: func(t testing.TB, state contract.StateDB) { + state.SetPredicateStorageSlots(ContractAddress, warpMessage.Bytes()) + }, + SuppliedGas: GetVerifiedWarpMessageBaseCost + GasCostPerWarpMessageBytes*uint64(len(warpMessage.Bytes())), + ReadOnly: false, + ExpectedErr: errInvalidPredicateBytes.Error(), + }, + "get message invalid warp message": { + Caller: callerAddr, + InputFn: func(t testing.TB) []byte { return getVerifiedWarpMsg }, + BeforeHook: func(t testing.TB, state contract.StateDB) { + state.SetPredicateStorageSlots(ContractAddress, predicateutils.PackPredicate([]byte{1, 2, 3})) + }, + SuppliedGas: GetVerifiedWarpMessageBaseCost + GasCostPerWarpMessageBytes*uint64(32), + ReadOnly: false, + ExpectedErr: errInvalidWarpMsg.Error(), + }, + "get message invalid addressed payload": { + Caller: callerAddr, + InputFn: func(t testing.TB) []byte { return getVerifiedWarpMsg }, + BeforeHook: func(t testing.TB, state contract.StateDB) { + unsignedMessage, err := avalancheWarp.NewUnsignedMessage(networkID, sourceChainID, []byte{1, 2, 3}) // Invalid addressed payload + require.NoError(t, err) + warpMessage, err := avalancheWarp.NewMessage(unsignedMessage, &avalancheWarp.BitSetSignature{}) + require.NoError(t, err) + + state.SetPredicateStorageSlots(ContractAddress, predicateutils.PackPredicate(warpMessage.Bytes())) + }, + SuppliedGas: GetVerifiedWarpMessageBaseCost + GasCostPerWarpMessageBytes*uint64(160), + ReadOnly: false, + ExpectedErr: errInvalidAddressedPayload.Error(), + }, + } + + testutils.RunPrecompileTests(t, Module, state.NewTestStateDB, tests) +} diff --git a/x/warp/module.go b/x/warp/module.go new file mode 100644 index 0000000000..db7402ca96 --- /dev/null +++ b/x/warp/module.go @@ -0,0 +1,56 @@ +// (c) 2023, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package warp + +import ( + "fmt" + + "github.com/ava-labs/subnet-evm/precompile/contract" + "github.com/ava-labs/subnet-evm/precompile/modules" + "github.com/ava-labs/subnet-evm/precompile/precompileconfig" + + "github.com/ethereum/go-ethereum/common" +) + +var _ contract.Configurator = &configurator{} + +// ConfigKey is the key used in json config files to specify this precompile precompileconfig. +// must be unique across all precompiles. +const ConfigKey = "warpConfig" + +// ContractAddress is the address of the warp precompile contract +var ContractAddress = common.HexToAddress("0x0200000000000000000000000000000000000005") + +// Module is the precompile module. It is used to register the precompile contract. +var Module = modules.Module{ + ConfigKey: ConfigKey, + Address: ContractAddress, + Contract: WarpPrecompile, + Configurator: &configurator{}, +} + +type configurator struct{} + +func init() { + // Register the precompile module. + // Each precompile contract registers itself through [RegisterModule] function. + if err := modules.RegisterModule(Module); err != nil { + panic(err) + } +} + +// MakeConfig returns a new precompile config instance. +// This is required for Marshal/Unmarshal the precompile config. +func (*configurator) MakeConfig() precompileconfig.Config { + return new(Config) +} + +// Configure is a no-op for warp since it does not need to store any information in the state +func (*configurator) Configure(chainConfig contract.ChainConfig, cfg precompileconfig.Config, state contract.StateDB, _ contract.BlockContext) error { + config, ok := cfg.(*Config) + if !ok { + return fmt.Errorf("incorrect config %T: %v", config, config) + } + return nil +} diff --git a/x/warp/predicate_test.go b/x/warp/predicate_test.go new file mode 100644 index 0000000000..423249848d --- /dev/null +++ b/x/warp/predicate_test.go @@ -0,0 +1,608 @@ +// (c) 2023, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package warp + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/snow" + "github.com/ava-labs/avalanchego/snow/engine/snowman/block" + "github.com/ava-labs/avalanchego/snow/validators" + "github.com/ava-labs/avalanchego/utils" + "github.com/ava-labs/avalanchego/utils/crypto/bls" + "github.com/ava-labs/avalanchego/utils/set" + avalancheWarp "github.com/ava-labs/avalanchego/vms/platformvm/warp" + "github.com/ava-labs/subnet-evm/params" + "github.com/ava-labs/subnet-evm/precompile/precompileconfig" + "github.com/ava-labs/subnet-evm/precompile/testutils" + subnetEVMUtils "github.com/ava-labs/subnet-evm/utils" + predicateutils "github.com/ava-labs/subnet-evm/utils/predicate" + warpPayload "github.com/ava-labs/subnet-evm/warp/payload" + "github.com/ethereum/go-ethereum/common" + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/require" +) + +const pChainHeight uint64 = 1337 + +var ( + _ utils.Sortable[*testValidator] = (*testValidator)(nil) + + errTest = errors.New("non-nil error") + networkID = uint32(54321) + sourceChainID = ids.GenerateTestID() + sourceSubnetID = ids.GenerateTestID() + destinationChainID = ids.GenerateTestID() + + // valid unsigned warp message used throughout testing + unsignedMsg *avalancheWarp.UnsignedMessage + // valid addressed payload + addressedPayload *warpPayload.AddressedPayload + addressedPayloadBytes []byte + // blsSignatures of [unsignedMsg] from each of [testVdrs] + blsSignatures []*bls.Signature + + numTestVdrs = 10_000 + testVdrs []*testValidator + vdrs map[ids.NodeID]*validators.GetValidatorOutput + tests []signatureTest + + predicateTests = make(map[string]testutils.PredicateTest) +) + +func init() { + testVdrs = make([]*testValidator, 0, numTestVdrs) + for i := 0; i < numTestVdrs; i++ { + testVdrs = append(testVdrs, newTestValidator()) + } + utils.Sort(testVdrs) + + vdrs = map[ids.NodeID]*validators.GetValidatorOutput{ + testVdrs[0].nodeID: { + NodeID: testVdrs[0].nodeID, + PublicKey: testVdrs[0].vdr.PublicKey, + Weight: testVdrs[0].vdr.Weight, + }, + testVdrs[1].nodeID: { + NodeID: testVdrs[1].nodeID, + PublicKey: testVdrs[1].vdr.PublicKey, + Weight: testVdrs[1].vdr.Weight, + }, + testVdrs[2].nodeID: { + NodeID: testVdrs[2].nodeID, + PublicKey: testVdrs[2].vdr.PublicKey, + Weight: testVdrs[2].vdr.Weight, + }, + } + + var err error + addressedPayload, err = warpPayload.NewAddressedPayload( + common.Address(ids.GenerateTestShortID()), + common.Hash(destinationChainID), + common.Address(ids.GenerateTestShortID()), + []byte{1, 2, 3}, + ) + if err != nil { + panic(err) + } + addressedPayloadBytes = addressedPayload.Bytes() + unsignedMsg, err = avalancheWarp.NewUnsignedMessage(networkID, sourceChainID, addressedPayload.Bytes()) + if err != nil { + panic(err) + } + + for _, testVdr := range testVdrs { + blsSignature := bls.Sign(testVdr.sk, unsignedMsg.Bytes()) + blsSignatures = append(blsSignatures, blsSignature) + } + + initWarpPredicateTests() +} + +type testValidator struct { + nodeID ids.NodeID + sk *bls.SecretKey + vdr *avalancheWarp.Validator +} + +func (v *testValidator) Less(o *testValidator) bool { + return v.vdr.Less(o.vdr) +} + +func newTestValidator() *testValidator { + sk, err := bls.NewSecretKey() + if err != nil { + panic(err) + } + + nodeID := ids.GenerateTestNodeID() + pk := bls.PublicFromSecretKey(sk) + return &testValidator{ + nodeID: nodeID, + sk: sk, + vdr: &avalancheWarp.Validator{ + PublicKey: pk, + PublicKeyBytes: pk.Serialize(), + Weight: 3, + NodeIDs: []ids.NodeID{nodeID}, + }, + } +} + +type signatureTest struct { + name string + stateF func(*gomock.Controller) validators.State + quorumNum uint64 + quorumDen uint64 + msgF func(*require.Assertions) *avalancheWarp.Message + err error +} + +// createWarpMessage constructs a signed warp message using the global variable [unsignedMsg] +// and the first [numKeys] signatures from [blsSignatures] +func createWarpMessage(numKeys int) *avalancheWarp.Message { + aggregateSignature, err := bls.AggregateSignatures(blsSignatures[0:numKeys]) + if err != nil { + panic(err) + } + bitSet := set.NewBits() + for i := 0; i < numKeys; i++ { + bitSet.Add(i) + } + warpSignature := &avalancheWarp.BitSetSignature{ + Signers: bitSet.Bytes(), + } + copy(warpSignature.Signature[:], bls.SignatureToBytes(aggregateSignature)) + warpMsg, err := avalancheWarp.NewMessage(unsignedMsg, warpSignature) + if err != nil { + panic(err) + } + return warpMsg +} + +// createPredicate constructs a warp message using createWarpMessage with numKeys signers +// and packs it into predicate encoding. +func createPredicate(numKeys int) []byte { + warpMsg := createWarpMessage(numKeys) + predicateBytes := predicateutils.PackPredicate(warpMsg.Bytes()) + return predicateBytes +} + +// validatorRange specifies a range of validators to include from [start, end), a staking weight +// to specify for each validator in that range, and whether or not to include the public key. +type validatorRange struct { + start int + end int + weight uint64 + publicKey bool +} + +// createSnowCtx creates a snow.Context instance with a validator state specified by the given validatorRanges +func createSnowCtx(validatorRanges []validatorRange) *snow.Context { + getValidatorsOutput := make(map[ids.NodeID]*validators.GetValidatorOutput) + + for _, validatorRange := range validatorRanges { + for i := validatorRange.start; i < validatorRange.end; i++ { + validatorOutput := &validators.GetValidatorOutput{ + NodeID: testVdrs[i].nodeID, + Weight: validatorRange.weight, + } + if validatorRange.publicKey { + validatorOutput.PublicKey = testVdrs[i].vdr.PublicKey + } + getValidatorsOutput[testVdrs[i].nodeID] = validatorOutput + } + } + + snowCtx := snow.DefaultContextTest() + state := &validators.TestState{ + GetSubnetIDF: func(ctx context.Context, chainID ids.ID) (ids.ID, error) { + return sourceSubnetID, nil + }, + GetValidatorSetF: func(ctx context.Context, height uint64, subnetID ids.ID) (map[ids.NodeID]*validators.GetValidatorOutput, error) { + return getValidatorsOutput, nil + }, + } + snowCtx.ValidatorState = state + snowCtx.NetworkID = networkID + return snowCtx +} + +func createValidPredicateTest(snowCtx *snow.Context, numKeys uint64, predicateBytes []byte) testutils.PredicateTest { + return testutils.PredicateTest{ + Config: NewDefaultConfig(subnetEVMUtils.NewUint64(0)), + ProposerPredicateContext: &precompileconfig.ProposerPredicateContext{ + PrecompilePredicateContext: precompileconfig.PrecompilePredicateContext{ + SnowCtx: snowCtx, + }, + ProposerVMBlockCtx: &block.Context{ + PChainHeight: 1, + }, + }, + StorageSlots: predicateBytes, + Gas: GasCostPerSignatureVerification + uint64(len(predicateBytes))*GasCostPerWarpMessageBytes + numKeys*GasCostPerWarpSigner, + GasErr: nil, + PredicateErr: nil, + } +} + +func TestWarpNilProposerCtx(t *testing.T) { + numKeys := 1 + snowCtx := createSnowCtx([]validatorRange{ + { + start: 0, + end: numKeys, + weight: 20, + }, + }) + predicateBytes := createPredicate(numKeys) + test := testutils.PredicateTest{ + Config: NewDefaultConfig(subnetEVMUtils.NewUint64(0)), + ProposerPredicateContext: &precompileconfig.ProposerPredicateContext{ + PrecompilePredicateContext: precompileconfig.PrecompilePredicateContext{ + SnowCtx: snowCtx, + }, + ProposerVMBlockCtx: nil, + }, + StorageSlots: predicateBytes, + Gas: GasCostPerSignatureVerification + uint64(len(predicateBytes))*GasCostPerWarpMessageBytes + uint64(numKeys)*GasCostPerWarpSigner, + GasErr: nil, + PredicateErr: errNoProposerCtxPredicate, + } + + test.Run(t) +} + +func TestInvalidPredicatePacking(t *testing.T) { + numKeys := 1 + snowCtx := createSnowCtx([]validatorRange{ + { + start: 0, + end: numKeys, + weight: 20, + }, + }) + predicateBytes := createPredicate(numKeys) + predicateBytes = append(predicateBytes, byte(0x01)) // Invalidate the predicate byte packing + + test := testutils.PredicateTest{ + Config: NewDefaultConfig(subnetEVMUtils.NewUint64(0)), + ProposerPredicateContext: &precompileconfig.ProposerPredicateContext{ + PrecompilePredicateContext: precompileconfig.PrecompilePredicateContext{ + SnowCtx: snowCtx, + }, + ProposerVMBlockCtx: &block.Context{ + PChainHeight: 1, + }, + }, + StorageSlots: predicateBytes, + Gas: GasCostPerSignatureVerification + uint64(len(predicateBytes))*GasCostPerWarpMessageBytes + uint64(numKeys)*GasCostPerWarpSigner, + GasErr: errInvalidPredicateBytes, + PredicateErr: nil, // Won't be reached + } + + test.Run(t) +} + +func TestInvalidWarpMessage(t *testing.T) { + numKeys := 1 + snowCtx := createSnowCtx([]validatorRange{ + { + start: 0, + end: numKeys, + weight: 20, + }, + }) + warpMsg := createWarpMessage(1) + warpMsgBytes := warpMsg.Bytes() + warpMsgBytes = append(warpMsgBytes, byte(0x01)) // Invalidate warp message packing + predicateBytes := predicateutils.PackPredicate(warpMsgBytes) + + test := testutils.PredicateTest{ + Config: NewDefaultConfig(subnetEVMUtils.NewUint64(0)), + ProposerPredicateContext: &precompileconfig.ProposerPredicateContext{ + PrecompilePredicateContext: precompileconfig.PrecompilePredicateContext{ + SnowCtx: snowCtx, + }, + ProposerVMBlockCtx: &block.Context{ + PChainHeight: 1, + }, + }, + StorageSlots: predicateBytes, + Gas: GasCostPerSignatureVerification + uint64(len(predicateBytes))*GasCostPerWarpMessageBytes + uint64(numKeys)*GasCostPerWarpSigner, + GasErr: errInvalidWarpMsg, + PredicateErr: nil, // Won't be reached + } + + test.Run(t) +} + +func TestInvalidAddressedPayload(t *testing.T) { + numKeys := 1 + snowCtx := createSnowCtx([]validatorRange{ + { + start: 0, + end: numKeys, + weight: 20, + }, + }) + aggregateSignature, err := bls.AggregateSignatures(blsSignatures[0:numKeys]) + require.NoError(t, err) + bitSet := set.NewBits() + for i := 0; i < numKeys; i++ { + bitSet.Add(i) + } + warpSignature := &avalancheWarp.BitSetSignature{ + Signers: bitSet.Bytes(), + } + copy(warpSignature.Signature[:], bls.SignatureToBytes(aggregateSignature)) + // Create an unsigned message with an invalid addressed payload + unsignedMsg, err := avalancheWarp.NewUnsignedMessage(networkID, sourceChainID, []byte{1, 2, 3}) + require.NoError(t, err) + warpMsg, err := avalancheWarp.NewMessage(unsignedMsg, warpSignature) + require.NoError(t, err) + warpMsgBytes := warpMsg.Bytes() + predicateBytes := predicateutils.PackPredicate(warpMsgBytes) + + test := testutils.PredicateTest{ + Config: NewDefaultConfig(subnetEVMUtils.NewUint64(0)), + ProposerPredicateContext: &precompileconfig.ProposerPredicateContext{ + PrecompilePredicateContext: precompileconfig.PrecompilePredicateContext{ + SnowCtx: snowCtx, + }, + ProposerVMBlockCtx: &block.Context{ + PChainHeight: 1, + }, + }, + StorageSlots: predicateBytes, + Gas: GasCostPerSignatureVerification + uint64(len(predicateBytes))*GasCostPerWarpMessageBytes + uint64(numKeys)*GasCostPerWarpSigner, + GasErr: nil, + PredicateErr: errInvalidAddressedPayload, + } + + test.Run(t) +} + +func TestInvalidBitSet(t *testing.T) { + unsignedMsg, err := avalancheWarp.NewUnsignedMessage( + networkID, + sourceChainID, + []byte{1, 2, 3}, + ) + require.NoError(t, err) + + msg, err := avalancheWarp.NewMessage( + unsignedMsg, + &avalancheWarp.BitSetSignature{ + Signers: make([]byte, 1), + Signature: [bls.SignatureLen]byte{}, + }, + ) + require.NoError(t, err) + + numKeys := 1 + snowCtx := createSnowCtx([]validatorRange{ + { + start: 0, + end: numKeys, + weight: 20, + }, + }) + predicateBytes := predicateutils.PackPredicate(msg.Bytes()) + test := testutils.PredicateTest{ + Config: NewDefaultConfig(subnetEVMUtils.NewUint64(0)), + ProposerPredicateContext: &precompileconfig.ProposerPredicateContext{ + PrecompilePredicateContext: precompileconfig.PrecompilePredicateContext{ + SnowCtx: snowCtx, + }, + ProposerVMBlockCtx: &block.Context{ + PChainHeight: 1, + }, + }, + StorageSlots: predicateBytes, + Gas: GasCostPerSignatureVerification + uint64(len(predicateBytes))*GasCostPerWarpMessageBytes + uint64(numKeys)*GasCostPerWarpSigner, + GasErr: errCannotGetNumSigners, + PredicateErr: nil, // Won't be reached + } + + test.Run(t) +} + +func TestWarpSignatureWeightsDefaultQuorumNumerator(t *testing.T) { + snowCtx := createSnowCtx([]validatorRange{ + { + start: 0, + end: 100, + weight: 20, + publicKey: true, + }, + }) + + tests := make(map[string]testutils.PredicateTest) + for _, numSigners := range []int{ + 1, + int(params.WarpDefaultQuorumNumerator) - 1, + int(params.WarpDefaultQuorumNumerator), + int(params.WarpDefaultQuorumNumerator) + 1, + int(params.WarpQuorumDenominator) - 1, + int(params.WarpQuorumDenominator), + int(params.WarpQuorumDenominator) + 1, + } { + var ( + predicateBytes = createPredicate(numSigners) + expectedPredicateErr error + ) + // If the number of signers is less than the params.WarpDefaultQuorumNumerator (67) + if numSigners < int(params.WarpDefaultQuorumNumerator) { + expectedPredicateErr = avalancheWarp.ErrInsufficientWeight + } + if numSigners > int(params.WarpQuorumDenominator) { + expectedPredicateErr = avalancheWarp.ErrUnknownValidator + } + tests[fmt.Sprintf("default quorum %d signature(s)", numSigners)] = testutils.PredicateTest{ + Config: NewDefaultConfig(subnetEVMUtils.NewUint64(0)), + ProposerPredicateContext: &precompileconfig.ProposerPredicateContext{ + PrecompilePredicateContext: precompileconfig.PrecompilePredicateContext{ + SnowCtx: snowCtx, + }, + ProposerVMBlockCtx: &block.Context{ + PChainHeight: 1, + }, + }, + StorageSlots: predicateBytes, + Gas: GasCostPerSignatureVerification + uint64(len(predicateBytes))*GasCostPerWarpMessageBytes + uint64(numSigners)*GasCostPerWarpSigner, + GasErr: nil, + PredicateErr: expectedPredicateErr, + } + } + testutils.RunPredicateTests(t, tests) +} + +func TestWarpSignatureWeightsNonDefaultQuorumNumerator(t *testing.T) { + snowCtx := createSnowCtx([]validatorRange{ + { + start: 0, + end: 100, + weight: 20, + publicKey: true, + }, + }) + + tests := make(map[string]testutils.PredicateTest) + nonDefaultQuorumNumerator := 50 + // Ensure this test fails if the DefaultQuroumNumerator is changed to an unexpected value during development + require.NotEqual(t, nonDefaultQuorumNumerator, int(params.WarpDefaultQuorumNumerator)) + // Add cases with default quorum + for _, numSigners := range []int{nonDefaultQuorumNumerator, nonDefaultQuorumNumerator + 1, 99, 100, 101} { + var ( + predicateBytes = createPredicate(numSigners) + expectedPredicateErr error + ) + // If the number of signers is less than the quorum numerator, expect ErrInsufficientWeight + if numSigners < nonDefaultQuorumNumerator { + expectedPredicateErr = avalancheWarp.ErrInsufficientWeight + } + if numSigners > int(params.WarpQuorumDenominator) { + expectedPredicateErr = avalancheWarp.ErrUnknownValidator + } + name := fmt.Sprintf("non-default quorum %d signature(s)", numSigners) + tests[name] = testutils.PredicateTest{ + Config: NewConfig(subnetEVMUtils.NewUint64(0), uint64(nonDefaultQuorumNumerator)), + ProposerPredicateContext: &precompileconfig.ProposerPredicateContext{ + PrecompilePredicateContext: precompileconfig.PrecompilePredicateContext{ + SnowCtx: snowCtx, + }, + ProposerVMBlockCtx: &block.Context{ + PChainHeight: 1, + }, + }, + StorageSlots: predicateBytes, + Gas: GasCostPerSignatureVerification + uint64(len(predicateBytes))*GasCostPerWarpMessageBytes + uint64(numSigners)*GasCostPerWarpSigner, + GasErr: nil, + PredicateErr: expectedPredicateErr, + } + } + + testutils.RunPredicateTests(t, tests) +} + +func initWarpPredicateTests() { + for _, totalNodes := range []int{10, 100, 1_000, 10_000} { + testName := fmt.Sprintf("%d signers/%d validators", totalNodes, totalNodes) + + predicateBytes := createPredicate(totalNodes) + snowCtx := createSnowCtx([]validatorRange{ + { + start: 0, + end: totalNodes, + weight: 20, + publicKey: true, + }, + }) + predicateTests[testName] = createValidPredicateTest(snowCtx, uint64(totalNodes), predicateBytes) + } + + numSigners := 10 + for _, totalNodes := range []int{100, 1_000, 10_000} { + testName := fmt.Sprintf("%d signers (heavily weighted)/%d validators", numSigners, totalNodes) + + predicateBytes := createPredicate(numSigners) + snowCtx := createSnowCtx([]validatorRange{ + { + start: 0, + end: numSigners, + weight: 10_000_000, + publicKey: true, + }, + { + start: numSigners, + end: totalNodes, + weight: 20, + publicKey: true, + }, + }) + predicateTests[testName] = createValidPredicateTest(snowCtx, uint64(numSigners), predicateBytes) + } + + for _, totalNodes := range []int{100, 1_000, 10_000} { + testName := fmt.Sprintf("%d signers (heavily weighted)/%d validators (non-signers without registered PublicKey)", numSigners, totalNodes) + + predicateBytes := createPredicate(numSigners) + snowCtx := createSnowCtx([]validatorRange{ + { + start: 0, + end: numSigners, + weight: 10_000_000, + publicKey: true, + }, + { + start: numSigners, + end: totalNodes, + weight: 20, + publicKey: false, + }, + }) + predicateTests[testName] = createValidPredicateTest(snowCtx, uint64(numSigners), predicateBytes) + } + + for _, totalNodes := range []int{100, 1_000, 10_000} { + testName := fmt.Sprintf("%d validators w/ %d signers/repeated PublicKeys", totalNodes, numSigners) + + predicateBytes := createPredicate(numSigners) + getValidatorsOutput := make(map[ids.NodeID]*validators.GetValidatorOutput, totalNodes) + for i := 0; i < totalNodes; i++ { + getValidatorsOutput[testVdrs[i].nodeID] = &validators.GetValidatorOutput{ + NodeID: testVdrs[i].nodeID, + Weight: 20, + PublicKey: testVdrs[i%numSigners].vdr.PublicKey, + } + } + + snowCtx := snow.DefaultContextTest() + snowCtx.NetworkID = networkID + state := &validators.TestState{ + GetSubnetIDF: func(ctx context.Context, chainID ids.ID) (ids.ID, error) { + return sourceSubnetID, nil + }, + GetValidatorSetF: func(ctx context.Context, height uint64, subnetID ids.ID) (map[ids.NodeID]*validators.GetValidatorOutput, error) { + return getValidatorsOutput, nil + }, + } + snowCtx.ValidatorState = state + + predicateTests[testName] = createValidPredicateTest(snowCtx, uint64(numSigners), predicateBytes) + } +} + +func TestWarpPredicate(t *testing.T) { + testutils.RunPredicateTests(t, predicateTests) +} + +func BenchmarkWarpPredicate(b *testing.B) { + testutils.RunPredicateBenchmarks(b, predicateTests) +} diff --git a/x/warp/signature_verification_test.go b/x/warp/signature_verification_test.go new file mode 100644 index 0000000000..cf6b9afe81 --- /dev/null +++ b/x/warp/signature_verification_test.go @@ -0,0 +1,653 @@ +// (c) 2023, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package warp + +import ( + "context" + "math" + "testing" + + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/snow/validators" + "github.com/ava-labs/avalanchego/utils/crypto/bls" + "github.com/ava-labs/avalanchego/utils/set" + avalancheWarp "github.com/ava-labs/avalanchego/vms/platformvm/warp" + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/require" +) + +// This test copies the test coverage from https://github.com/ava-labs/avalanchego/blob/v1.10.0/vms/platformvm/warp/signature_test.go#L137. +// These tests are only expected to fail if there is a breaking change in AvalancheGo that unexpectedly changes behavior. +func TestSignatureVerification(t *testing.T) { + tests = []signatureTest{ + { + name: "can't get subnetID", + stateF: func(ctrl *gomock.Controller) validators.State { + state := validators.NewMockState(ctrl) + state.EXPECT().GetSubnetID(gomock.Any(), sourceChainID).Return(sourceSubnetID, errTest) + return state + }, + quorumNum: 1, + quorumDen: 2, + msgF: func(require *require.Assertions) *avalancheWarp.Message { + unsignedMsg, err := avalancheWarp.NewUnsignedMessage( + networkID, + sourceChainID, + addressedPayloadBytes, + ) + require.NoError(err) + + msg, err := avalancheWarp.NewMessage( + unsignedMsg, + &avalancheWarp.BitSetSignature{}, + ) + require.NoError(err) + return msg + }, + err: errTest, + }, + { + name: "can't get validator set", + stateF: func(ctrl *gomock.Controller) validators.State { + state := validators.NewMockState(ctrl) + state.EXPECT().GetSubnetID(gomock.Any(), sourceChainID).Return(sourceSubnetID, nil) + state.EXPECT().GetValidatorSet(gomock.Any(), pChainHeight, sourceSubnetID).Return(nil, errTest) + return state + }, + quorumNum: 1, + quorumDen: 2, + msgF: func(require *require.Assertions) *avalancheWarp.Message { + unsignedMsg, err := avalancheWarp.NewUnsignedMessage( + networkID, + sourceChainID, + addressedPayloadBytes, + ) + require.NoError(err) + + msg, err := avalancheWarp.NewMessage( + unsignedMsg, + &avalancheWarp.BitSetSignature{}, + ) + require.NoError(err) + return msg + }, + err: errTest, + }, + { + name: "weight overflow", + stateF: func(ctrl *gomock.Controller) validators.State { + state := validators.NewMockState(ctrl) + state.EXPECT().GetSubnetID(gomock.Any(), sourceChainID).Return(sourceSubnetID, nil) + state.EXPECT().GetValidatorSet(gomock.Any(), pChainHeight, sourceSubnetID).Return(map[ids.NodeID]*validators.GetValidatorOutput{ + testVdrs[0].nodeID: { + NodeID: testVdrs[0].nodeID, + PublicKey: testVdrs[0].vdr.PublicKey, + Weight: math.MaxUint64, + }, + testVdrs[1].nodeID: { + NodeID: testVdrs[1].nodeID, + PublicKey: testVdrs[1].vdr.PublicKey, + Weight: math.MaxUint64, + }, + }, nil) + return state + }, + quorumNum: 1, + quorumDen: 2, + msgF: func(require *require.Assertions) *avalancheWarp.Message { + unsignedMsg, err := avalancheWarp.NewUnsignedMessage( + networkID, + sourceChainID, + addressedPayloadBytes, + ) + require.NoError(err) + + msg, err := avalancheWarp.NewMessage( + unsignedMsg, + &avalancheWarp.BitSetSignature{ + Signers: make([]byte, 8), + }, + ) + require.NoError(err) + return msg + }, + err: avalancheWarp.ErrWeightOverflow, + }, + { + name: "invalid bit set index", + stateF: func(ctrl *gomock.Controller) validators.State { + state := validators.NewMockState(ctrl) + state.EXPECT().GetSubnetID(gomock.Any(), sourceChainID).Return(sourceSubnetID, nil) + state.EXPECT().GetValidatorSet(gomock.Any(), pChainHeight, sourceSubnetID).Return(vdrs, nil) + return state + }, + quorumNum: 1, + quorumDen: 2, + msgF: func(require *require.Assertions) *avalancheWarp.Message { + unsignedMsg, err := avalancheWarp.NewUnsignedMessage( + networkID, + sourceChainID, + addressedPayloadBytes, + ) + require.NoError(err) + + msg, err := avalancheWarp.NewMessage( + unsignedMsg, + &avalancheWarp.BitSetSignature{ + Signers: make([]byte, 1), + Signature: [bls.SignatureLen]byte{}, + }, + ) + require.NoError(err) + return msg + }, + err: avalancheWarp.ErrInvalidBitSet, + }, + { + name: "unknown index", + stateF: func(ctrl *gomock.Controller) validators.State { + state := validators.NewMockState(ctrl) + state.EXPECT().GetSubnetID(gomock.Any(), sourceChainID).Return(sourceSubnetID, nil) + state.EXPECT().GetValidatorSet(gomock.Any(), pChainHeight, sourceSubnetID).Return(vdrs, nil) + return state + }, + quorumNum: 1, + quorumDen: 2, + msgF: func(require *require.Assertions) *avalancheWarp.Message { + unsignedMsg, err := avalancheWarp.NewUnsignedMessage( + networkID, + sourceChainID, + addressedPayloadBytes, + ) + require.NoError(err) + + signers := set.NewBits() + signers.Add(3) // vdr oob + + msg, err := avalancheWarp.NewMessage( + unsignedMsg, + &avalancheWarp.BitSetSignature{ + Signers: signers.Bytes(), + Signature: [bls.SignatureLen]byte{}, + }, + ) + require.NoError(err) + return msg + }, + err: avalancheWarp.ErrUnknownValidator, + }, + { + name: "insufficient weight", + stateF: func(ctrl *gomock.Controller) validators.State { + state := validators.NewMockState(ctrl) + state.EXPECT().GetSubnetID(gomock.Any(), sourceChainID).Return(sourceSubnetID, nil) + state.EXPECT().GetValidatorSet(gomock.Any(), pChainHeight, sourceSubnetID).Return(vdrs, nil) + return state + }, + quorumNum: 1, + quorumDen: 1, + msgF: func(require *require.Assertions) *avalancheWarp.Message { + unsignedMsg, err := avalancheWarp.NewUnsignedMessage( + networkID, + sourceChainID, + addressedPayloadBytes, + ) + require.NoError(err) + + // [signers] has weight from [vdr[0], vdr[1]], + // which is 6, which is less than 9 + signers := set.NewBits() + signers.Add(0) + signers.Add(1) + + unsignedBytes := unsignedMsg.Bytes() + vdr0Sig := bls.Sign(testVdrs[0].sk, unsignedBytes) + vdr1Sig := bls.Sign(testVdrs[1].sk, unsignedBytes) + aggSig, err := bls.AggregateSignatures([]*bls.Signature{vdr0Sig, vdr1Sig}) + require.NoError(err) + aggSigBytes := [bls.SignatureLen]byte{} + copy(aggSigBytes[:], bls.SignatureToBytes(aggSig)) + + msg, err := avalancheWarp.NewMessage( + unsignedMsg, + &avalancheWarp.BitSetSignature{ + Signers: signers.Bytes(), + Signature: aggSigBytes, + }, + ) + require.NoError(err) + return msg + }, + err: avalancheWarp.ErrInsufficientWeight, + }, + { + name: "can't parse sig", + stateF: func(ctrl *gomock.Controller) validators.State { + state := validators.NewMockState(ctrl) + state.EXPECT().GetSubnetID(gomock.Any(), sourceChainID).Return(sourceSubnetID, nil) + state.EXPECT().GetValidatorSet(gomock.Any(), pChainHeight, sourceSubnetID).Return(vdrs, nil) + return state + }, + quorumNum: 1, + quorumDen: 2, + msgF: func(require *require.Assertions) *avalancheWarp.Message { + unsignedMsg, err := avalancheWarp.NewUnsignedMessage( + networkID, + sourceChainID, + addressedPayloadBytes, + ) + require.NoError(err) + + signers := set.NewBits() + signers.Add(0) + signers.Add(1) + + msg, err := avalancheWarp.NewMessage( + unsignedMsg, + &avalancheWarp.BitSetSignature{ + Signers: signers.Bytes(), + Signature: [bls.SignatureLen]byte{}, + }, + ) + require.NoError(err) + return msg + }, + err: avalancheWarp.ErrParseSignature, + }, + { + name: "no validators", + stateF: func(ctrl *gomock.Controller) validators.State { + state := validators.NewMockState(ctrl) + state.EXPECT().GetSubnetID(gomock.Any(), sourceChainID).Return(sourceSubnetID, nil) + state.EXPECT().GetValidatorSet(gomock.Any(), pChainHeight, sourceSubnetID).Return(nil, nil) + return state + }, + quorumNum: 1, + quorumDen: 2, + msgF: func(require *require.Assertions) *avalancheWarp.Message { + unsignedMsg, err := avalancheWarp.NewUnsignedMessage( + networkID, + sourceChainID, + addressedPayloadBytes, + ) + require.NoError(err) + + unsignedBytes := unsignedMsg.Bytes() + vdr0Sig := bls.Sign(testVdrs[0].sk, unsignedBytes) + aggSigBytes := [bls.SignatureLen]byte{} + copy(aggSigBytes[:], bls.SignatureToBytes(vdr0Sig)) + + msg, err := avalancheWarp.NewMessage( + unsignedMsg, + &avalancheWarp.BitSetSignature{ + Signers: nil, + Signature: aggSigBytes, + }, + ) + require.NoError(err) + return msg + }, + err: bls.ErrNoPublicKeys, + }, + { + name: "invalid signature (substitute)", + stateF: func(ctrl *gomock.Controller) validators.State { + state := validators.NewMockState(ctrl) + state.EXPECT().GetSubnetID(gomock.Any(), sourceChainID).Return(sourceSubnetID, nil) + state.EXPECT().GetValidatorSet(gomock.Any(), pChainHeight, sourceSubnetID).Return(vdrs, nil) + return state + }, + quorumNum: 3, + quorumDen: 5, + msgF: func(require *require.Assertions) *avalancheWarp.Message { + unsignedMsg, err := avalancheWarp.NewUnsignedMessage( + networkID, + sourceChainID, + addressedPayloadBytes, + ) + require.NoError(err) + + signers := set.NewBits() + signers.Add(0) + signers.Add(1) + + unsignedBytes := unsignedMsg.Bytes() + vdr0Sig := bls.Sign(testVdrs[0].sk, unsignedBytes) + // Give sig from vdr[2] even though the bit vector says it + // should be from vdr[1] + vdr2Sig := bls.Sign(testVdrs[2].sk, unsignedBytes) + aggSig, err := bls.AggregateSignatures([]*bls.Signature{vdr0Sig, vdr2Sig}) + require.NoError(err) + aggSigBytes := [bls.SignatureLen]byte{} + copy(aggSigBytes[:], bls.SignatureToBytes(aggSig)) + + msg, err := avalancheWarp.NewMessage( + unsignedMsg, + &avalancheWarp.BitSetSignature{ + Signers: signers.Bytes(), + Signature: aggSigBytes, + }, + ) + require.NoError(err) + return msg + }, + err: avalancheWarp.ErrInvalidSignature, + }, + { + name: "invalid signature (missing one)", + stateF: func(ctrl *gomock.Controller) validators.State { + state := validators.NewMockState(ctrl) + state.EXPECT().GetSubnetID(gomock.Any(), sourceChainID).Return(sourceSubnetID, nil) + state.EXPECT().GetValidatorSet(gomock.Any(), pChainHeight, sourceSubnetID).Return(vdrs, nil) + return state + }, + quorumNum: 3, + quorumDen: 5, + msgF: func(require *require.Assertions) *avalancheWarp.Message { + unsignedMsg, err := avalancheWarp.NewUnsignedMessage( + networkID, + sourceChainID, + addressedPayloadBytes, + ) + require.NoError(err) + + signers := set.NewBits() + signers.Add(0) + signers.Add(1) + + unsignedBytes := unsignedMsg.Bytes() + vdr0Sig := bls.Sign(testVdrs[0].sk, unsignedBytes) + // Don't give the sig from vdr[1] + aggSigBytes := [bls.SignatureLen]byte{} + copy(aggSigBytes[:], bls.SignatureToBytes(vdr0Sig)) + + msg, err := avalancheWarp.NewMessage( + unsignedMsg, + &avalancheWarp.BitSetSignature{ + Signers: signers.Bytes(), + Signature: aggSigBytes, + }, + ) + require.NoError(err) + return msg + }, + err: avalancheWarp.ErrInvalidSignature, + }, + { + name: "invalid signature (extra one)", + stateF: func(ctrl *gomock.Controller) validators.State { + state := validators.NewMockState(ctrl) + state.EXPECT().GetSubnetID(gomock.Any(), sourceChainID).Return(sourceSubnetID, nil) + state.EXPECT().GetValidatorSet(gomock.Any(), pChainHeight, sourceSubnetID).Return(vdrs, nil) + return state + }, + quorumNum: 3, + quorumDen: 5, + msgF: func(require *require.Assertions) *avalancheWarp.Message { + unsignedMsg, err := avalancheWarp.NewUnsignedMessage( + networkID, + sourceChainID, + addressedPayloadBytes, + ) + require.NoError(err) + + signers := set.NewBits() + signers.Add(0) + signers.Add(1) + + unsignedBytes := unsignedMsg.Bytes() + vdr0Sig := bls.Sign(testVdrs[0].sk, unsignedBytes) + vdr1Sig := bls.Sign(testVdrs[1].sk, unsignedBytes) + // Give sig from vdr[2] even though the bit vector doesn't have + // it + vdr2Sig := bls.Sign(testVdrs[2].sk, unsignedBytes) + aggSig, err := bls.AggregateSignatures([]*bls.Signature{vdr0Sig, vdr1Sig, vdr2Sig}) + require.NoError(err) + aggSigBytes := [bls.SignatureLen]byte{} + copy(aggSigBytes[:], bls.SignatureToBytes(aggSig)) + + msg, err := avalancheWarp.NewMessage( + unsignedMsg, + &avalancheWarp.BitSetSignature{ + Signers: signers.Bytes(), + Signature: aggSigBytes, + }, + ) + require.NoError(err) + return msg + }, + err: avalancheWarp.ErrInvalidSignature, + }, + { + name: "valid signature", + stateF: func(ctrl *gomock.Controller) validators.State { + state := validators.NewMockState(ctrl) + state.EXPECT().GetSubnetID(gomock.Any(), sourceChainID).Return(sourceSubnetID, nil) + state.EXPECT().GetValidatorSet(gomock.Any(), pChainHeight, sourceSubnetID).Return(vdrs, nil) + return state + }, + quorumNum: 1, + quorumDen: 2, + msgF: func(require *require.Assertions) *avalancheWarp.Message { + unsignedMsg, err := avalancheWarp.NewUnsignedMessage( + networkID, + sourceChainID, + addressedPayloadBytes, + ) + require.NoError(err) + + // [signers] has weight from [vdr[1], vdr[2]], + // which is 6, which is greater than 4.5 + signers := set.NewBits() + signers.Add(1) + signers.Add(2) + + unsignedBytes := unsignedMsg.Bytes() + vdr1Sig := bls.Sign(testVdrs[1].sk, unsignedBytes) + vdr2Sig := bls.Sign(testVdrs[2].sk, unsignedBytes) + aggSig, err := bls.AggregateSignatures([]*bls.Signature{vdr1Sig, vdr2Sig}) + require.NoError(err) + aggSigBytes := [bls.SignatureLen]byte{} + copy(aggSigBytes[:], bls.SignatureToBytes(aggSig)) + + msg, err := avalancheWarp.NewMessage( + unsignedMsg, + &avalancheWarp.BitSetSignature{ + Signers: signers.Bytes(), + Signature: aggSigBytes, + }, + ) + require.NoError(err) + return msg + }, + err: nil, + }, + { + name: "valid signature (boundary)", + stateF: func(ctrl *gomock.Controller) validators.State { + state := validators.NewMockState(ctrl) + state.EXPECT().GetSubnetID(gomock.Any(), sourceChainID).Return(sourceSubnetID, nil) + state.EXPECT().GetValidatorSet(gomock.Any(), pChainHeight, sourceSubnetID).Return(vdrs, nil) + return state + }, + quorumNum: 2, + quorumDen: 3, + msgF: func(require *require.Assertions) *avalancheWarp.Message { + unsignedMsg, err := avalancheWarp.NewUnsignedMessage( + networkID, + sourceChainID, + addressedPayloadBytes, + ) + require.NoError(err) + + // [signers] has weight from [vdr[1], vdr[2]], + // which is 6, which meets the minimum 6 + signers := set.NewBits() + signers.Add(1) + signers.Add(2) + + unsignedBytes := unsignedMsg.Bytes() + vdr1Sig := bls.Sign(testVdrs[1].sk, unsignedBytes) + vdr2Sig := bls.Sign(testVdrs[2].sk, unsignedBytes) + aggSig, err := bls.AggregateSignatures([]*bls.Signature{vdr1Sig, vdr2Sig}) + require.NoError(err) + aggSigBytes := [bls.SignatureLen]byte{} + copy(aggSigBytes[:], bls.SignatureToBytes(aggSig)) + + msg, err := avalancheWarp.NewMessage( + unsignedMsg, + &avalancheWarp.BitSetSignature{ + Signers: signers.Bytes(), + Signature: aggSigBytes, + }, + ) + require.NoError(err) + return msg + }, + err: nil, + }, + { + name: "valid signature (missing key)", + stateF: func(ctrl *gomock.Controller) validators.State { + state := validators.NewMockState(ctrl) + state.EXPECT().GetSubnetID(gomock.Any(), sourceChainID).Return(sourceSubnetID, nil) + state.EXPECT().GetValidatorSet(gomock.Any(), pChainHeight, sourceSubnetID).Return(map[ids.NodeID]*validators.GetValidatorOutput{ + testVdrs[0].nodeID: { + NodeID: testVdrs[0].nodeID, + PublicKey: nil, + Weight: testVdrs[0].vdr.Weight, + }, + testVdrs[1].nodeID: { + NodeID: testVdrs[1].nodeID, + PublicKey: testVdrs[1].vdr.PublicKey, + Weight: testVdrs[1].vdr.Weight, + }, + testVdrs[2].nodeID: { + NodeID: testVdrs[2].nodeID, + PublicKey: testVdrs[2].vdr.PublicKey, + Weight: testVdrs[2].vdr.Weight, + }, + }, nil) + return state + }, + quorumNum: 1, + quorumDen: 3, + msgF: func(require *require.Assertions) *avalancheWarp.Message { + unsignedMsg, err := avalancheWarp.NewUnsignedMessage( + networkID, + sourceChainID, + addressedPayloadBytes, + ) + require.NoError(err) + + // [signers] has weight from [vdr2, vdr3], + // which is 6, which is greater than 3 + signers := set.NewBits() + // Note: the bits are shifted because vdr[0]'s key was zeroed + signers.Add(0) // vdr[1] + signers.Add(1) // vdr[2] + + unsignedBytes := unsignedMsg.Bytes() + vdr1Sig := bls.Sign(testVdrs[1].sk, unsignedBytes) + vdr2Sig := bls.Sign(testVdrs[2].sk, unsignedBytes) + aggSig, err := bls.AggregateSignatures([]*bls.Signature{vdr1Sig, vdr2Sig}) + require.NoError(err) + aggSigBytes := [bls.SignatureLen]byte{} + copy(aggSigBytes[:], bls.SignatureToBytes(aggSig)) + + msg, err := avalancheWarp.NewMessage( + unsignedMsg, + &avalancheWarp.BitSetSignature{ + Signers: signers.Bytes(), + Signature: aggSigBytes, + }, + ) + require.NoError(err) + return msg + }, + err: nil, + }, + { + name: "valid signature (duplicate key)", + stateF: func(ctrl *gomock.Controller) validators.State { + state := validators.NewMockState(ctrl) + state.EXPECT().GetSubnetID(gomock.Any(), sourceChainID).Return(sourceSubnetID, nil) + state.EXPECT().GetValidatorSet(gomock.Any(), pChainHeight, sourceSubnetID).Return(map[ids.NodeID]*validators.GetValidatorOutput{ + testVdrs[0].nodeID: { + NodeID: testVdrs[0].nodeID, + PublicKey: nil, + Weight: testVdrs[0].vdr.Weight, + }, + testVdrs[1].nodeID: { + NodeID: testVdrs[1].nodeID, + PublicKey: testVdrs[2].vdr.PublicKey, + Weight: testVdrs[1].vdr.Weight, + }, + testVdrs[2].nodeID: { + NodeID: testVdrs[2].nodeID, + PublicKey: testVdrs[2].vdr.PublicKey, + Weight: testVdrs[2].vdr.Weight, + }, + }, nil) + return state + }, + quorumNum: 2, + quorumDen: 3, + msgF: func(require *require.Assertions) *avalancheWarp.Message { + unsignedMsg, err := avalancheWarp.NewUnsignedMessage( + networkID, + sourceChainID, + addressedPayloadBytes, + ) + require.NoError(err) + + // [signers] has weight from [vdr2, vdr3], + // which is 6, which meets the minimum 6 + signers := set.NewBits() + // Note: the bits are shifted because vdr[0]'s key was zeroed + // Note: vdr[1] and vdr[2] were combined because of a shared pk + signers.Add(0) // vdr[1] + vdr[2] + + unsignedBytes := unsignedMsg.Bytes() + // Because vdr[1] and vdr[2] share a key, only one of them sign. + vdr2Sig := bls.Sign(testVdrs[2].sk, unsignedBytes) + aggSigBytes := [bls.SignatureLen]byte{} + copy(aggSigBytes[:], bls.SignatureToBytes(vdr2Sig)) + + msg, err := avalancheWarp.NewMessage( + unsignedMsg, + &avalancheWarp.BitSetSignature{ + Signers: signers.Bytes(), + Signature: aggSigBytes, + }, + ) + require.NoError(err) + return msg + }, + err: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require := require.New(t) + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + msg := tt.msgF(require) + pChainState := tt.stateF(ctrl) + + err := msg.Signature.Verify( + context.Background(), + &msg.UnsignedMessage, + networkID, + pChainState, + pChainHeight, + tt.quorumNum, + tt.quorumDen, + ) + require.ErrorIs(err, tt.err) + }) + } +}