diff --git a/README.md b/README.md index bdccb2a0..5e56622c 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,18 @@ # awm-relayer -Standalone relayer for cross-chain Avalanche Warp Message delivery. +Reference relayer implementation for cross-chain Avalanche Warp Message delivery. ## Usage ### Building -Build the relayer by running the included build script: +Build the relayer by running the script: ```bash ./scripts/build.sh ``` -Build a Docker image by running the included build script: +Build a Docker image by running the script: ``` ./scripts/build-local-image.sh ``` @@ -25,19 +25,102 @@ The relayer binary accepts a path to a JSON configuration file as the sole argum ./build/awm-relayer --config-file path-to-config ``` -## Architecture +### Configuration + +The relayer is configured via a JSON file, the path to which is passed in via the `--config-file` command line argument. The following configuration options are available: + +`"log-level": "debug" | "info" | "warn" | "error" | "fatal" | "panic"` +- The log level for the relayer. Defaults to `info`. + +`"network-id": integer` +- The ID of the Avalanche network to which the relayer will connect. Defaults to `1` (Mainnet). + +`"p-chain-api-url": string` +- The URL of the Avalanche P-Chain API node to which the relayer will connect. This API node needs to have the following methods enabled: + - info.peers + - platform.getHeight + - platform.validatedBy + - platform.getValidatorsAt + +`"encrypt-connection": boolean` +- Whether or not to encrypt the connection to the P-Chain API node. Defaults to `true`. + +`"storage-location": string` +- The path to the directory in which the relayer will store its state. Defaults to `./awm-relayer-storage`. + +`"source-subnets": []SourceSubnets` +- The list of source subnets to support. Each `SourceSubnet` has the following configuration: + + `"subnet-id": string` + - cb58-encoded Subnet ID + + `"blockchain-id": string` + - cb58-encoded Blockchain ID + + `"vm": string` + - The VM type of the source subnet. + + `"api-node-host": string` + - The host of the source subnet's API node. + + `"api-node-port": integer` + - The port of the source subnet's API node. + + `"encrypt-connection": boolean` + - Whether or not to encrypt the connection to the source subnet's API node. -**Note:** The relayer in its current state supports Teleporter messages between `subnet-evm` instances. A handful of abstractions have been added to make the relayer extensible to other Warp message formats and VM types, but this work is ongoing. + `"rpc-endpoint": string` + - The RPC endpoint of the source subnet's API node. Used in favor of `api-node-host`, `api-node-port`, and `encrypt-connection` when constructing the endpoint + + `"ws-endpoint": string` + - The WebSocket endpoint of the source subnet's API node. Used in favor of `api-node-host`, `api-node-port`, and `encrypt-connection` when constructing the endpoint + + `"message-contracts": map[string]MessageProtocolConfig` + - Map of contract addresses to the config options of the protocol at that address. Each `MessageProtocolConfig` consists of a unique `message-format` name, and the raw JSON `settings` + + `"supported-destinations": []string` + - List of destination subnet IDs that the source subnet supports. If empty, then all destinations are supported. + +`"destination-subnets": []DestinationSubnets` +- The list of destination subnets to support. Each `DestinationSubnet` has the following configuration: + + `"subnet-id": string` + - cb58-encoded Subnet ID + + `"blockchain-id": string` + - cb58-encoded Blockchain ID + + `"vm": string` + - The VM type of the source subnet. + + `"api-node-host": string` + - The host of the source subnet's API node. + + `"api-node-port": integer` + - The port of the source subnet's API node. + + `"encrypt-connection": boolean` + - Whether or not to encrypt the connection to the source subnet's API node. + + `"rpc-endpoint": string` + - The RPC endpoint of the destination subnet's API node. Used in favor of `api-node-host`, `api-node-port`, and `encrypt-connection` when constructing the endpoint + + `"account-private-key": string` + - The hex-encoded private key to use for signing transactions on the destination subnet. May be provided by the environment variable `ACCOUNT_PRIVATE_KEY`. Each `destination-subnet` may use a separate private key by appending the blockchain ID to the private key environment variable name, e.g. `ACCOUNT_PRIVATE_KEY_11111111111111111111111111111111LpoYY` + +## Architecture ### Components The relayer consists of the following components: - At the global level: - - *P2P App Network*: issues signature `AppRequests` + - *P2P app network*: issues signature `AppRequests` - *P-Chain client*: gets the validators for a subnet + - *JSON database*: stores latest processed block for each source subnet - Per Source subnet - *Subscriber*: listens for logs pertaining to cross-chain message transactions + - *Source RPC client*: queries for missed blocks on startup - Per Destination subnet - *Destination RPC client*: broadcasts transactions to the destination @@ -51,7 +134,7 @@ The relayer consists of the following components: ### Unit tests -Unit tests can be ran locally by running the command in root of the project: +Unit tests can be ran locally by running the command in the root of the project: ```bash ./scripts/test.sh @@ -61,13 +144,13 @@ Unit tests can be ran locally by running the command in root of the project: E2E tests are ran as part of CI, but can also be ran locally with the `--local` flag. To run the E2E tests locally, you'll need to install Gingko following the intructions [here](https://onsi.github.io/ginkgo/#installing-ginkgo) -Next, provide the path to the `subnet-evm` repository and the path to a writeable data directory (here we use the `~/subnet-evm` and `~/tmp/e2e-test`) to use for the tests: +Next, provide the path to the `subnet-evm` repository and the path to a writeable data directory (this example uses `~/subnet-evm` and `~/tmp/e2e-test`) to use for the tests: ```bash ./scripts/e2e_test.sh --local --subnet-evm ~/subnet-evm --data-dir ~/tmp/e2e-test ``` ### Generate Mocks -We use [gomock](https://pkg.go.dev/go.uber.org/mock/gomock) to generate mocks for testing. To generate mocks, run the following command at the root of the project: +[Gomock](https://pkg.go.dev/go.uber.org/mock/gomock) is used to generate mocks for testing. To generate mocks, run the following command at the root of the project: ```bash go generate ./... diff --git a/config/config.go b/config/config.go index 783e11be..5cd9f112 100644 --- a/config/config.go +++ b/config/config.go @@ -37,15 +37,19 @@ type MessageProtocolConfig struct { Settings map[string]interface{} `mapstructure:"settings" json:"settings"` } type SourceSubnet struct { - SubnetID string `mapstructure:"subnet-id" json:"subnet-id"` - ChainID string `mapstructure:"chain-id" json:"chain-id"` - VM string `mapstructure:"vm" json:"vm"` - APINodeHost string `mapstructure:"api-node-host" json:"api-node-host"` - APINodePort uint32 `mapstructure:"api-node-port" json:"api-node-port"` - EncryptConnection bool `mapstructure:"encrypt-connection" json:"encrypt-connection"` - RPCEndpoint string `mapstructure:"rpc-endpoint" json:"rpc-endpoint"` - WSEndpoint string `mapstructure:"ws-endpoint" json:"ws-endpoint"` - MessageContracts map[string]MessageProtocolConfig `mapstructure:"message-contracts" json:"message-contracts"` + SubnetID string `mapstructure:"subnet-id" json:"subnet-id"` + ChainID string `mapstructure:"chain-id" json:"chain-id"` + VM string `mapstructure:"vm" json:"vm"` + APINodeHost string `mapstructure:"api-node-host" json:"api-node-host"` + APINodePort uint32 `mapstructure:"api-node-port" json:"api-node-port"` + EncryptConnection bool `mapstructure:"encrypt-connection" json:"encrypt-connection"` + RPCEndpoint string `mapstructure:"rpc-endpoint" json:"rpc-endpoint"` + WSEndpoint string `mapstructure:"ws-endpoint" json:"ws-endpoint"` + MessageContracts map[string]MessageProtocolConfig `mapstructure:"message-contracts" json:"message-contracts"` + SupportedDestinations []string `mapstructure:"supported-destinations" json:"supported-destinations"` + + // convenience field to access the supported destinations after initialization + supportedDestinations set.Set[ids.ID] } type DestinationSubnet struct { @@ -67,12 +71,15 @@ type Config struct { StorageLocation string `mapstructure:"storage-location" json:"storage-location"` SourceSubnets []SourceSubnet `mapstructure:"source-subnets" json:"source-subnets"` DestinationSubnets []DestinationSubnet `mapstructure:"destination-subnets" json:"destination-subnets"` + + // convenience fields to access the source subnet and chain IDs after initialization + sourceSubnetIDs []ids.ID + sourceChainIDs []ids.ID } func SetDefaultConfigValues(v *viper.Viper) { v.SetDefault(LogLevelKey, logging.Info.String()) v.SetDefault(NetworkIDKey, constants.MainnetID) - v.SetDefault(PChainAPIURLKey, "https://api.avax.network") v.SetDefault(EncryptConnectionKey, true) v.SetDefault(StorageLocationKey, "./.awm-relayer-storage") } @@ -165,17 +172,8 @@ func (c *Config) Validate() error { if _, err := url.ParseRequestURI(c.PChainAPIURL); err != nil { return err } - sourceChains := set.NewSet[string](len(c.SourceSubnets)) - for _, s := range c.SourceSubnets { - if err := s.Validate(); err != nil { - return err - } - if sourceChains.Contains(s.ChainID) { - return fmt.Errorf("configured source subnets must have unique chain IDs") - } - sourceChains.Add(s.ChainID) - } + // Validate the destination chains destinationChains := set.NewSet[string](len(c.DestinationSubnets)) for _, s := range c.DestinationSubnets { if err := s.Validate(); err != nil { @@ -187,30 +185,47 @@ func (c *Config) Validate() error { destinationChains.Add(s.ChainID) } - return nil -} - -// GetSourceIDs returns the Subnet and Chain IDs of all subnets configured as a source -func (cfg *Config) GetSourceIDs() ([]ids.ID, []ids.ID, error) { + // Validate the source chains and store the source subnet and chain IDs for future use + sourceChains := set.NewSet[string](len(c.SourceSubnets)) var sourceSubnetIDs []ids.ID var sourceChainIDs []ids.ID - for _, s := range cfg.SourceSubnets { + for _, s := range c.SourceSubnets { + // Validate configuration + if err := s.Validate(&destinationChains); err != nil { + return err + } + // Verify uniqueness + if sourceChains.Contains(s.ChainID) { + return fmt.Errorf("configured source subnets must have unique chain IDs") + } + sourceChains.Add(s.ChainID) + + // Save IDs for future use subnetID, err := ids.FromString(s.SubnetID) if err != nil { - return nil, nil, fmt.Errorf("invalid subnetID in configuration. error: %v", err) + return fmt.Errorf("invalid subnetID in configuration. error: %v", err) } sourceSubnetIDs = append(sourceSubnetIDs, subnetID) chainID, err := ids.FromString(s.ChainID) if err != nil { - return nil, nil, fmt.Errorf("invalid subnetID in configuration. error: %v", err) + return fmt.Errorf("invalid subnetID in configuration. error: %v", err) } sourceChainIDs = append(sourceChainIDs, chainID) } - return sourceSubnetIDs, sourceChainIDs, nil + + c.sourceSubnetIDs = sourceSubnetIDs + c.sourceChainIDs = sourceChainIDs + + return nil } -func (s *SourceSubnet) Validate() error { +func (s *SourceSubnet) GetSupportedDestinations() set.Set[ids.ID] { + return s.supportedDestinations +} + +// Validates the source subnet configuration, including verifying that the supported destinations are present in destinationChainIDs +func (s *SourceSubnet) Validate(destinationChainIDs *set.Set[string]) error { if _, err := ids.FromString(s.SubnetID); err != nil { return fmt.Errorf("invalid subnetID in source subnet configuration. Provided ID: %s", s.SubnetID) } @@ -244,6 +259,21 @@ func (s *SourceSubnet) Validate() error { } } + // Validate and store the allowed destinations for future use + s.supportedDestinations = set.Set[ids.ID]{} + for _, blockchainIDStr := range s.SupportedDestinations { + blockchainID, err := ids.FromString(blockchainIDStr) + if err != nil { + return fmt.Errorf("invalid chainID in configuration. error: %v", err) + } + if !destinationChainIDs.Contains(blockchainIDStr) { + return fmt.Errorf("configured source subnet %s has a supported destination blockchain ID %s that is not configured as a destination blockchain", + s.SubnetID, + blockchainID) + } + s.supportedDestinations.Add(blockchainID) + } + return nil } @@ -373,3 +403,12 @@ func (s *DestinationSubnet) GetRelayerAccountInfo() (*ecdsa.PrivateKey, common.A pkBytes = append(pkBytes, pk.PublicKey.Y.Bytes()...) return pk, common.BytesToAddress(crypto.Keccak256(pkBytes)), nil } + +// +// Top-level config getters +// + +// GetSourceIDs returns the Subnet and Chain IDs of all subnets configured as a source +func (c *Config) GetSourceIDs() ([]ids.ID, []ids.ID) { + return c.sourceSubnetIDs, c.sourceChainIDs +} diff --git a/main/main.go b/main/main.go index c9ef56d5..71cd4096 100644 --- a/main/main.go +++ b/main/main.go @@ -86,14 +86,7 @@ func main() { // Initialize the global app request network logger.Info("Initializing app request network") - sourceSubnetIDs, sourceChainIDs, err := cfg.GetSourceIDs() - if err != nil { - logger.Error( - "Failed to get source IDs", - zap.Error(err), - ) - return - } + sourceSubnetIDs, sourceChainIDs := cfg.GetSourceIDs() // Initialize metrics gathered through prometheus gatherer, registerer, err := initMetrics() diff --git a/messages/teleporter/message_manager.go b/messages/teleporter/message_manager.go index 1e640c1c..08ccd92c 100644 --- a/messages/teleporter/message_manager.go +++ b/messages/teleporter/message_manager.go @@ -107,6 +107,7 @@ func (m *messageManager) ShouldSendMessage(warpMessageInfo *vmtypes.WarpMessageI if !ok { return false, fmt.Errorf("relayer not configured to deliver to destination. destinationChainID=%s", destinationChainID.String()) } + senderAddress := destinationClient.SenderAddress() if !isAllowedRelayer(teleporterMessage.AllowedRelayerAddresses, senderAddress) { m.logger.Info( diff --git a/messages/teleporter/message_manager_test.go b/messages/teleporter/message_manager_test.go index f8f73507..10f3be47 100644 --- a/messages/teleporter/message_manager_test.go +++ b/messages/teleporter/message_manager_test.go @@ -159,6 +159,7 @@ func TestShouldSendMessage(t *testing.T) { if test.clientResult != nil { test.clientResult.EXPECT().CallContract(gomock.Any(), gomock.Any(), gomock.Any()).Return(test.callContractResult, nil).Times(test.callContractTimes) } + result, err := messageManager.ShouldSendMessage(test.warpMessageInfo, test.destinationChainID) if test.expectedError { require.Error(t, err) diff --git a/relayer/relayer.go b/relayer/relayer.go index 6513f3c9..aaab95a0 100644 --- a/relayer/relayer.go +++ b/relayer/relayer.go @@ -13,6 +13,7 @@ import ( "github.com/ava-labs/avalanchego/ids" "github.com/ava-labs/avalanchego/message" "github.com/ava-labs/avalanchego/utils/logging" + "github.com/ava-labs/avalanchego/utils/set" "github.com/ava-labs/avalanchego/vms/platformvm" "github.com/ava-labs/awm-relayer/config" "github.com/ava-labs/awm-relayer/database" @@ -37,6 +38,7 @@ type Relayer struct { messageManagers map[common.Hash]messages.MessageManager logger logging.Logger db database.RelayerDatabase + supportedDestinations set.Set[ids.ID] } func NewRelayer( @@ -68,11 +70,22 @@ func NewRelayer( return nil, nil, err } + var filteredDestinationClients map[ids.ID]vms.DestinationClient + supportedDestinationsChainIDs := sourceSubnetInfo.GetSupportedDestinations() + if len(supportedDestinationsChainIDs) > 0 { + filteredDestinationClients := make(map[ids.ID]vms.DestinationClient) + for id := range supportedDestinationsChainIDs { + filteredDestinationClients[id] = destinationClients[id] + } + } else { + filteredDestinationClients = destinationClients + } + // Create message managers for each supported message protocol messageManagers := make(map[common.Hash]messages.MessageManager) for address, config := range sourceSubnetInfo.MessageContracts { addressHash := common.HexToHash(address) - messageManager, err := messages.NewMessageManager(logger, addressHash, config, destinationClients) + messageManager, err := messages.NewMessageManager(logger, addressHash, config, filteredDestinationClients) if err != nil { logger.Error( "Failed to create message manager", @@ -102,6 +115,7 @@ func NewRelayer( messageManagers: messageManagers, logger: logger, db: db, + supportedDestinations: supportedDestinationsChainIDs, } // Open the subscription. We must do this before processing any missed messages, otherwise we may miss an incoming message @@ -173,11 +187,20 @@ func NewRelayer( // RelayMessage relays a single warp message to the destination chain. Warp message relay requests from the same origin chain are processed serially func (r *Relayer) RelayMessage(warpLogInfo *vmtypes.WarpLogInfo, metrics *MessageRelayerMetrics, messageCreator message.Creator) error { + // Check that the destination chain ID is supported + if !r.CheckSupportedDestination(warpLogInfo.DestinationChainID) { + r.logger.Debug( + "Message destination chain ID not supported. Not relaying.", + zap.String("chainID", r.sourceChainID.String()), + zap.String("destinationChainID", warpLogInfo.DestinationChainID.String()), + ) + return nil + } + r.logger.Info( "Relaying message", zap.String("chainID", r.sourceChainID.String()), ) - // Unpack the VM message bytes into a Warp message warpMessageInfo, err := r.contractMessage.UnpackWarpMessage(warpLogInfo.UnsignedMsgBytes) if err != nil { @@ -243,3 +266,9 @@ func (r *Relayer) RelayMessage(warpLogInfo *vmtypes.WarpLogInfo, metrics *Messag return nil } + +// Returns whether destinationChainID is a supported destination. +// If supportedDestinations is empty, then all destination chain IDs are supported. +func (r *Relayer) CheckSupportedDestination(destinationChainID ids.ID) bool { + return len(r.supportedDestinations) == 0 || r.supportedDestinations.Contains(destinationChainID) +} diff --git a/relayer/relayer_test.go b/relayer/relayer_test.go index 3407b77b..7e4d89e4 100644 --- a/relayer/relayer_test.go +++ b/relayer/relayer_test.go @@ -4,21 +4,54 @@ package relayer import ( + "fmt" "testing" - "github.com/ava-labs/awm-relayer/config" + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/utils/set" "github.com/stretchr/testify/require" ) -func TestGetRelayerAccountInfoSkipChainConfigCheckCompatible(t *testing.T) { - accountPrivateKey := "56289e99c94b6912bfc12adc093c9b51124f0dc54ac7a766b2bc5ccf558d8027" - expectedAddress := "0x8db97C7cEcE249c2b98bDC0226Cc4C2A57BF52FC" +var id1 ids.ID = ids.GenerateTestID() +var id2 ids.ID = ids.GenerateTestID() - info := config.DestinationSubnet{ - AccountPrivateKey: accountPrivateKey, +func TestCheckSupportedDestination(t *testing.T) { + testCases := []struct { + name string + relayer Relayer + destinationChainID ids.ID + expectedResult bool + }{ + { + name: "explicitly supported destination", + relayer: Relayer{ + supportedDestinations: set.Set[ids.ID]{ + id1: {}, + }, + }, + destinationChainID: id1, + expectedResult: true, + }, + { + name: "implicitly supported destination", + relayer: Relayer{}, + destinationChainID: id1, + expectedResult: true, + }, + { + name: "unsupported destination", + relayer: Relayer{ + supportedDestinations: set.Set[ids.ID]{ + id1: {}, + }, + }, + destinationChainID: id2, + expectedResult: false, + }, } - _, address, err := info.GetRelayerAccountInfo() - require.NoError(t, err) - require.Equal(t, expectedAddress, address.String()) + for _, testCase := range testCases { + result := testCase.relayer.CheckSupportedDestination(testCase.destinationChainID) + require.Equal(t, testCase.expectedResult, result, fmt.Sprintf("test failed: %s", testCase.name)) + } }