diff --git a/README.md b/README.md index 3012fd8..103698e 100644 --- a/README.md +++ b/README.md @@ -42,10 +42,10 @@ localhost:8000/tx/?type=forward ### Generating Go ABI bindings ```shell -abigen --abi cmd/ethereum/abi/TokenMessenger.json --pkg cmd --type TokenMessenger --out cmd/TokenMessenger.go -abigen --abi cmd/ethereum/abi/TokenMessengerWithMetadata.json --pkg cmd --type TokenMessengerWithMetadata --out cmd/TokenMessengerWithMetadata.go -abigen --abi cmd/ethereum/abi/ERC20.json --pkg integration_testing --type ERC20 --out integration/ERC20.go -abigen --abi cmd/ethereum/abi/MessageTransmitter.json --pkg cmd --type MessageTransmitter --out cmd/MessageTransmitter.go +abigen --abi ethereum/abi/TokenMessenger.json --pkg contracts --type TokenMessenger --out ethereum/contracts/TokenMessenger.go +abigen --abi ethereum/abi/TokenMessengerWithMetadata.json --pkg contracts --type TokenMessengerWithMetadata --out ethereum/contracts/TokenMessengerWithMetadata.go +abigen --abi ethereum/abi/ERC20.json --pkg integration_testing --type ERC20 --out integration/ERC20.go +abigen --abi ethereum/abi/MessageTransmitter.json --pkg contracts- --type MessageTransmitter --out ethereum/contracts/MessageTransmitter.go ``` ### Useful links diff --git a/cmd/circle/attestation.go b/circle/attestation.go similarity index 57% rename from cmd/circle/attestation.go rename to circle/attestation.go index 7436a63..ca069fc 100644 --- a/cmd/circle/attestation.go +++ b/circle/attestation.go @@ -1,29 +1,29 @@ package circle import ( - "cosmossdk.io/log" "encoding/json" "fmt" - "github.com/strangelove-ventures/noble-cctp-relayer/config" - "github.com/strangelove-ventures/noble-cctp-relayer/types" "io" "net/http" "time" + + "cosmossdk.io/log" + "github.com/strangelove-ventures/noble-cctp-relayer/types" ) // CheckAttestation checks the iris api for attestation status and returns true if attestation is complete -func CheckAttestation(cfg config.Config, logger log.Logger, irisLookupId string) *types.AttestationResponse { - logger.Debug(fmt.Sprintf("Checking attestation for %s%s%s", cfg.Circle.AttestationBaseUrl, "0x", irisLookupId)) +func CheckAttestation(attestationURL string, logger log.Logger, irisLookupId string, txHash string, sourceDomain, destDomain types.Domain) *types.AttestationResponse { + logger.Debug(fmt.Sprintf("Checking attestation for %s%s%s for source tx %s from %d to %d", attestationURL, "0x", irisLookupId, txHash, sourceDomain, destDomain)) client := http.Client{Timeout: 2 * time.Second} - rawResponse, err := client.Get(cfg.Circle.AttestationBaseUrl + "0x" + irisLookupId) + rawResponse, err := client.Get(attestationURL + "0x" + irisLookupId) if err != nil { logger.Debug("error during request: " + err.Error()) return nil } if rawResponse.StatusCode != http.StatusOK { - logger.Debug("non 200 response received") + logger.Debug("non 200 response received from Circles attestation API") return nil } body, err := io.ReadAll(rawResponse.Body) @@ -38,7 +38,7 @@ func CheckAttestation(cfg config.Config, logger log.Logger, irisLookupId string) logger.Debug("unable to unmarshal response") return nil } - logger.Info(fmt.Sprintf("Attestation found for %s%s%s", cfg.Circle.AttestationBaseUrl, "0x", irisLookupId)) + logger.Info(fmt.Sprintf("Attestation found for %s%s%s", attestationURL, "0x", irisLookupId)) return &response } diff --git a/cmd/circle/attestation_test.go b/circle/attestation_test.go similarity index 56% rename from cmd/circle/attestation_test.go rename to circle/attestation_test.go index 26e7e0c..a2fa252 100644 --- a/cmd/circle/attestation_test.go +++ b/circle/attestation_test.go @@ -1,16 +1,17 @@ package circle_test import ( + "os" + "testing" + "cosmossdk.io/log" "github.com/rs/zerolog" - "github.com/strangelove-ventures/noble-cctp-relayer/cmd/circle" - "github.com/strangelove-ventures/noble-cctp-relayer/config" + "github.com/strangelove-ventures/noble-cctp-relayer/circle" + "github.com/strangelove-ventures/noble-cctp-relayer/types" "github.com/stretchr/testify/require" - "os" - "testing" ) -var cfg config.Config +var cfg types.Config var logger log.Logger func init() { @@ -19,12 +20,12 @@ func init() { } func TestAttestationIsReady(t *testing.T) { - resp := circle.CheckAttestation(cfg, logger, "85bbf7e65a5992e6317a61f005e06d9972a033d71b514be183b179e1b47723fe") + resp := circle.CheckAttestation(cfg.Circle.AttestationBaseUrl, logger, "85bbf7e65a5992e6317a61f005e06d9972a033d71b514be183b179e1b47723fe", "", 0, 4) require.NotNil(t, resp) require.Equal(t, "complete", resp.Status) } func TestAttestationNotFound(t *testing.T) { - resp := circle.CheckAttestation(cfg, logger, "not an attestation") + resp := circle.CheckAttestation(cfg.Circle.AttestationBaseUrl, logger, "not an attestation", "", 0, 4) require.Nil(t, resp) } diff --git a/cmd/ethereum/broadcast.go b/cmd/ethereum/broadcast.go deleted file mode 100644 index a345437..0000000 --- a/cmd/ethereum/broadcast.go +++ /dev/null @@ -1,101 +0,0 @@ -package ethereum - -import ( - "cosmossdk.io/log" - "encoding/hex" - "errors" - "fmt" - "github.com/ethereum/go-ethereum/accounts/abi/bind" - "github.com/ethereum/go-ethereum/common" - ethtypes "github.com/ethereum/go-ethereum/core/types" - "github.com/ethereum/go-ethereum/ethclient" - "github.com/strangelove-ventures/noble-cctp-relayer/config" - "github.com/strangelove-ventures/noble-cctp-relayer/types" - "math/big" - "regexp" - "strconv" - "time" -) - -// Broadcast broadcasts a message to Ethereum -func Broadcast( - cfg config.Config, - logger log.Logger, - msg *types.MessageState, - sequenceMap *types.SequenceMap, -) (*ethtypes.Transaction, error) { - - // set up eth client - client, err := ethclient.Dial(cfg.Networks.Destination.Ethereum.RPC) - defer client.Close() - - privEcdsaKey, ethereumAddress, err := GetEcdsaKeyAddress(cfg.Networks.Minters[0].MinterPrivateKey) - if err != nil { - return nil, err - } - - auth, err := bind.NewKeyedTransactorWithChainID(privEcdsaKey, big.NewInt(cfg.Networks.Destination.Ethereum.ChainId)) - messageTransmitter, err := NewMessageTransmitter(common.HexToAddress(cfg.Networks.Source.Ethereum.MessageTransmitter), client) - attestationBytes, err := hex.DecodeString(msg.Attestation[2:]) - if err != nil { - return nil, errors.New("unable to decode message attestation") - } - - for attempt := 0; attempt <= cfg.Networks.Destination.Ethereum.BroadcastRetries; attempt++ { - logger.Info(fmt.Sprintf( - "Broadcasting %s message from %d to %d: with source tx hash %s", - msg.Type, - msg.SourceDomain, - msg.DestDomain, - msg.SourceTxHash)) - - nonce := sequenceMap.Next(cfg.Networks.Destination.Ethereum.DomainId) - auth.Nonce = big.NewInt(nonce) - - // broadcast txn - tx, err := messageTransmitter.ReceiveMessage( - auth, - msg.MsgSentBytes, - attestationBytes, - ) - if err == nil { - msg.Status = types.Complete - return tx, nil - } else { - logger.Error(fmt.Sprintf("error during broadcast: %s", err.Error())) - if parsedErr, ok := err.(JsonError); ok { - if parsedErr.ErrorCode() == 3 && parsedErr.Error() == "execution reverted: Nonce already used" { - nonce, err = GetEthereumAccountNonce(cfg.Networks.Destination.Ethereum.RPC, ethereumAddress) - if err != nil { - logger.Error("unable to retrieve account number") - } - logger.Debug(fmt.Sprintf("retrying with new nonce: %d", nonce)) - sequenceMap.Put(cfg.Networks.Destination.Ethereum.DomainId, nonce) - } - - match, _ := regexp.MatchString("nonce too low: next nonce [0-9]+, tx nonce [0-9]+", parsedErr.Error()) - if match { - numberRegex := regexp.MustCompile("[0-9]+") - nextNonce, err := strconv.ParseInt(numberRegex.FindAllString(parsedErr.Error(), 1)[0], 10, 0) - if err != nil { - nonce, err = GetEthereumAccountNonce(cfg.Networks.Destination.Ethereum.RPC, ethereumAddress) - if err != nil { - logger.Error("unable to retrieve account number") - } - } - sequenceMap.Put(cfg.Networks.Destination.Ethereum.DomainId, nextNonce) - } - } - - // if it's not the last attempt, retry - if attempt != cfg.Networks.Destination.Ethereum.BroadcastRetries { - logger.Info(fmt.Sprintf("Retrying in %d seconds", cfg.Networks.Destination.Ethereum.BroadcastRetryInterval)) - time.Sleep(time.Duration(cfg.Networks.Destination.Ethereum.BroadcastRetryInterval) * time.Second) - } - continue - } - } - msg.Status = types.Failed - - return nil, errors.New("reached max number of broadcast attempts") -} diff --git a/cmd/ethereum/listener.go b/cmd/ethereum/listener.go deleted file mode 100644 index d1ca85b..0000000 --- a/cmd/ethereum/listener.go +++ /dev/null @@ -1,105 +0,0 @@ -package ethereum - -import ( - "bytes" - "context" - "embed" - "fmt" - "math/big" - "os" - - "cosmossdk.io/log" - "github.com/ethereum/go-ethereum" - "github.com/ethereum/go-ethereum/accounts/abi" - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/ethclient" - "github.com/pascaldekloe/etherstream" - "github.com/strangelove-ventures/noble-cctp-relayer/config" - "github.com/strangelove-ventures/noble-cctp-relayer/types" -) - -//go:embed abi/MessageTransmitter.json -var content embed.FS - -func StartListener(cfg config.Config, logger log.Logger, processingQueue chan *types.MessageState) { - // set up client - messageTransmitter, err := content.ReadFile("abi/MessageTransmitter.json") - if err != nil { - logger.Error("unable to read MessageTransmitter abi", "err", err) - os.Exit(1) - } - messageTransmitterABI, err := abi.JSON(bytes.NewReader(messageTransmitter)) - if err != nil { - logger.Error("unable to parse MessageTransmitter abi", "err", err) - } - - messageSent := messageTransmitterABI.Events["MessageSent"] - - ethClient, err := ethclient.DialContext(context.Background(), cfg.Networks.Source.Ethereum.RPC) - if err != nil { - logger.Error("unable to initialize ethereum client", "err", err) - os.Exit(1) - } - - messageTransmitterAddress := common.HexToAddress(cfg.Networks.Source.Ethereum.MessageTransmitter) - etherReader := etherstream.Reader{Backend: ethClient} - - query := ethereum.FilterQuery{ - Addresses: []common.Address{messageTransmitterAddress}, - Topics: [][]common.Hash{{messageSent.ID}}, - FromBlock: big.NewInt(int64(cfg.Networks.Source.Ethereum.StartBlock - cfg.Networks.Source.Ethereum.LookbackPeriod)), - } - - logger.Info(fmt.Sprintf( - "Starting Ethereum listener at block %d looking back %d blocks", - cfg.Networks.Source.Ethereum.StartBlock, - cfg.Networks.Source.Ethereum.LookbackPeriod)) - - // websockets do not query history - // https://github.com/ethereum/go-ethereum/issues/15063 - stream, sub, history, err := etherReader.QueryWithHistory(context.Background(), &query) - if err != nil { - logger.Error("unable to subscribe to logs", "err", err) - os.Exit(1) - } - - // process history - for _, historicalLog := range history { - parsedMsg, err := types.EvmLogToMessageState(messageTransmitterABI, messageSent, &historicalLog) - if err != nil { - logger.Error("Unable to parse history log into MessageState, skipping", "err", err) - continue - } - logger.Info(fmt.Sprintf("New historical msg from source domain %d with tx hash %s", parsedMsg.SourceDomain, parsedMsg.SourceTxHash)) - - processingQueue <- parsedMsg - - // It might help to wait a small amount of time between sending messages into the processing queue - // so that account sequences / nonces are set correctly - // time.Sleep(10 * time.Millisecond) - } - - // consume stream - go func() { - for { - select { - case err := <-sub.Err(): - logger.Error("connection closed", "err", err) - os.Exit(1) - case streamLog := <-stream: - parsedMsg, err := types.EvmLogToMessageState(messageTransmitterABI, messageSent, &streamLog) - if err != nil { - logger.Error("Unable to parse ws log into MessageState, skipping") - continue - } - logger.Info(fmt.Sprintf("New stream msg from %d with tx hash %s", parsedMsg.SourceDomain, parsedMsg.SourceTxHash)) - - processingQueue <- parsedMsg - - // It might help to wait a small amount of time between sending messages into the processing queue - // so that account sequences / nonces are set correctly - // time.Sleep(10 * time.Millisecond) - } - } - }() -} diff --git a/cmd/ethereum/listener_test.go b/cmd/ethereum/listener_test.go deleted file mode 100644 index 24367db..0000000 --- a/cmd/ethereum/listener_test.go +++ /dev/null @@ -1,52 +0,0 @@ -package ethereum_test - -import ( - "cosmossdk.io/log" - "github.com/rs/zerolog" - eth "github.com/strangelove-ventures/noble-cctp-relayer/cmd/ethereum" - "github.com/strangelove-ventures/noble-cctp-relayer/config" - "github.com/strangelove-ventures/noble-cctp-relayer/types" - "github.com/stretchr/testify/require" - "os" - "testing" - "time" -) - -var cfg config.Config -var logger log.Logger -var processingQueue chan *types.MessageState - -func init() { - cfg = config.Parse("../../.ignore/unit_tests.yaml") - - logger = log.NewLogger(os.Stdout, log.LevelOption(zerolog.ErrorLevel)) - processingQueue = make(chan *types.MessageState, 10000) -} - -// tests for a historical log -func TestStartListener(t *testing.T) { - - cfg.Networks.Source.Ethereum.StartBlock = 9702735 - cfg.Networks.Source.Ethereum.LookbackPeriod = 0 - go eth.StartListener(cfg, logger, processingQueue) - - time.Sleep(5 * time.Second) - - msg := <-processingQueue - - expectedMsg := &types.MessageState{ - IrisLookupId: "a404f4155166a1fc7ffee145b5cac6d0f798333745289ab1db171344e226ef0c", - Type: "mint", - Status: "created", - SourceDomain: 0, - DestDomain: 4, - SourceTxHash: "0xe1d7729de300274ee3a2fd20ba179b14a8e3ffcd9d847c506b06760f0dad7802", - } - require.Equal(t, expectedMsg.IrisLookupId, msg.IrisLookupId) - require.Equal(t, expectedMsg.Type, msg.Type) - require.Equal(t, expectedMsg.Status, msg.Status) - require.Equal(t, expectedMsg.SourceDomain, msg.SourceDomain) - require.Equal(t, expectedMsg.DestDomain, msg.DestDomain) - require.Equal(t, expectedMsg.SourceTxHash, msg.SourceTxHash) - -} diff --git a/cmd/noble/broadcast.go b/cmd/noble/broadcast.go deleted file mode 100644 index 64c242d..0000000 --- a/cmd/noble/broadcast.go +++ /dev/null @@ -1,236 +0,0 @@ -package noble - -import ( - "context" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "io" - "math/rand" - "net/http" - "regexp" - "strconv" - "time" - - "cosmossdk.io/log" - nobletypes "github.com/circlefin/noble-cctp/x/cctp/types" - rpchttp "github.com/cometbft/cometbft/rpc/client/http" - ctypes "github.com/cometbft/cometbft/rpc/core/types" - libclient "github.com/cometbft/cometbft/rpc/jsonrpc/client" - sdkClient "github.com/cosmos/cosmos-sdk/client" - clientTx "github.com/cosmos/cosmos-sdk/client/tx" - "github.com/cosmos/cosmos-sdk/codec" - codectypes "github.com/cosmos/cosmos-sdk/codec/types" - "github.com/cosmos/cosmos-sdk/crypto/keys/secp256k1" - "github.com/cosmos/cosmos-sdk/types/tx/signing" - xauthsigning "github.com/cosmos/cosmos-sdk/x/auth/signing" - xauthtx "github.com/cosmos/cosmos-sdk/x/auth/tx" - "github.com/strangelove-ventures/noble-cctp-relayer/config" - "github.com/strangelove-ventures/noble-cctp-relayer/types" -) - -// Broadcast broadcasts a message to Noble -func Broadcast( - cfg config.Config, - logger log.Logger, - msg *types.MessageState, - sequenceMap *types.SequenceMap, -) (*ctypes.ResultBroadcastTx, error) { - // set up sdk context - interfaceRegistry := codectypes.NewInterfaceRegistry() - nobletypes.RegisterInterfaces(interfaceRegistry) - cdc := codec.NewProtoCodec(interfaceRegistry) - sdkContext := sdkClient.Context{ - TxConfig: xauthtx.NewTxConfig(cdc, xauthtx.DefaultSignModes), - } - - // build txn - txBuilder := sdkContext.TxConfig.NewTxBuilder() - attestationBytes, err := hex.DecodeString(msg.Attestation[2:]) - if err != nil { - return nil, errors.New("unable to decode message attestation") - } - - // get priv key - nobleAddress := cfg.Networks.Minters[4].MinterAddress - keyBz, err := hex.DecodeString(cfg.Networks.Minters[4].MinterPrivateKey) - if err != nil { - return nil, errors.New(fmt.Sprintf("Unable to parse Noble private key")) - } - privKey := secp256k1.PrivKey{Key: keyBz} - - receiveMsg := nobletypes.NewMsgReceiveMessage( - nobleAddress, - msg.MsgSentBytes, - attestationBytes, - ) - err = txBuilder.SetMsgs(receiveMsg) - if err != nil { - return nil, err - } - - txBuilder.SetGasLimit(cfg.Networks.Destination.Noble.GasLimit) - txBuilder.SetMemo("Thank you for relaying with Strangelove") - - // sign and broadcast txn - rpcClient, err := NewRPCClient(cfg.Networks.Destination.Noble.RPC, 10*time.Second) - if err != nil { - return nil, errors.New("failed to set up rpc client") - } - - for attempt := 0; attempt <= cfg.Networks.Destination.Noble.BroadcastRetries; attempt++ { - logger.Info(fmt.Sprintf( - "Broadcasting %s message from %d to %d: with source tx hash %s", - msg.Type, - msg.SourceDomain, - msg.DestDomain, - msg.SourceTxHash)) - - accountSequence := sequenceMap.Next(cfg.Networks.Destination.Noble.DomainId) - accountNumber, _, err := GetNobleAccountNumberSequence(cfg.Networks.Destination.Noble.API, nobleAddress) - - if err != nil { - logger.Error("unable to retrieve account number") - } - - sigV2 := signing.SignatureV2{ - PubKey: privKey.PubKey(), - Data: &signing.SingleSignatureData{ - SignMode: sdkContext.TxConfig.SignModeHandler().DefaultMode(), - Signature: nil, - }, - Sequence: uint64(accountSequence), - } - - signerData := xauthsigning.SignerData{ - ChainID: cfg.Networks.Destination.Noble.ChainId, - AccountNumber: uint64(accountNumber), - Sequence: uint64(accountSequence), - } - - txBuilder.SetSignatures(sigV2) - - sigV2, err = clientTx.SignWithPrivKey( - sdkContext.TxConfig.SignModeHandler().DefaultMode(), - signerData, - txBuilder, - &privKey, - sdkContext.TxConfig, - uint64(accountSequence), - ) - - err = txBuilder.SetSignatures(sigV2) - if err != nil { - return nil, err - } - - // Generated Protobuf-encoded bytes. - txBytes, err := sdkContext.TxConfig.TxEncoder()(txBuilder.GetTx()) - if err != nil { - return nil, err - } - - rpcResponse, err := rpcClient.BroadcastTxSync(context.Background(), txBytes) - if err != nil || (rpcResponse != nil && rpcResponse.Code != 0) { - // Log the error - logger.Error(fmt.Sprintf("error during broadcast: %s", getErrorString(err, rpcResponse))) - - if err != nil || rpcResponse == nil { - // Log retry information - logger.Info(fmt.Sprintf("Retrying in %d seconds", cfg.Networks.Destination.Noble.BroadcastRetryInterval)) - time.Sleep(time.Duration(cfg.Networks.Destination.Noble.BroadcastRetryInterval) * time.Second) - // wait a random amount of time to lower probability of concurrent message nonce collision - time.Sleep(time.Duration(rand.Intn(5)) * time.Second) - continue - } - - // Log details for non-zero response code - logger.Error(fmt.Sprintf("received non-zero: %d - %s", rpcResponse.Code, rpcResponse.Log)) - - // Handle specific error code (32) - if rpcResponse.Code == 32 { - newAccountSequence := extractAccountSequence(logger, rpcResponse.Log, nobleAddress, cfg.Networks.Destination.Noble.API) - logger.Debug(fmt.Sprintf("retrying with new account sequence: %d", newAccountSequence)) - sequenceMap.Put(cfg.Networks.Destination.Noble.DomainId, newAccountSequence) - } - - // Log retry information - logger.Info(fmt.Sprintf("Retrying in %d seconds", cfg.Networks.Destination.Noble.BroadcastRetryInterval)) - time.Sleep(time.Duration(cfg.Networks.Destination.Noble.BroadcastRetryInterval) * time.Second) - // wait a random amount of time to lower probability of concurrent message nonce collision - time.Sleep(time.Duration(rand.Intn(5)) * time.Second) - continue - } - - // Tx was successfully broadcast - msg.Status = types.Complete - return rpcResponse, nil - } - - msg.Status = types.Failed - - return nil, errors.New("reached max number of broadcast attempts") -} - -// getErrorString returns the appropriate value to log when tx broadcast errors are encountered. -func getErrorString(err error, rpcResponse *ctypes.ResultBroadcastTx) string { - if rpcResponse != nil { - return rpcResponse.Log - } - return err.Error() -} - -// extractAccountSequence attempts to extract the account sequence number from the RPC response logs when -// account sequence mismatch errors are encountered. If the account sequence number cannot be extracted from the logs, -// it is retrieved by making a request to the API endpoint. -func extractAccountSequence(logger log.Logger, rpcResponseLog, nobleAddress, nobleAPI string) int64 { - pattern := `expected (\d+), got (\d+)` - re := regexp.MustCompile(pattern) - match := re.FindStringSubmatch(rpcResponseLog) - - if len(match) == 3 { - // Extract the numbers from the match. - newAccountSequence, _ := strconv.ParseInt(match[1], 10, 0) - return newAccountSequence - } - - // Otherwise, just request the account sequence - _, newAccountSequence, err := GetNobleAccountNumberSequence(nobleAPI, nobleAddress) - if err != nil { - logger.Error("unable to retrieve account number") - } - - return newAccountSequence -} - -// NewRPCClient initializes a new tendermint RPC client connected to the specified address. -func NewRPCClient(addr string, timeout time.Duration) (*rpchttp.HTTP, error) { - httpClient, err := libclient.DefaultHTTPClient(addr) - if err != nil { - return nil, err - } - httpClient.Timeout = timeout - rpcClient, err := rpchttp.NewWithClient(addr, "/websocket", httpClient) - if err != nil { - return nil, err - } - return rpcClient, nil -} - -func GetNobleAccountNumberSequence(urlBase string, address string) (int64, int64, error) { - rawResp, err := http.Get(fmt.Sprintf("%s/cosmos/auth/v1beta1/accounts/%s", urlBase, address)) - if err != nil { - return 0, 0, errors.New("unable to fetch account number, sequence") - } - body, _ := io.ReadAll(rawResp.Body) - var resp types.AccountResp - err = json.Unmarshal(body, &resp) - if err != nil { - return 0, 0, errors.New("unable to parse account number, sequence") - } - accountNumber, _ := strconv.ParseInt(resp.AccountNumber, 10, 0) - accountSequence, _ := strconv.ParseInt(resp.Sequence, 10, 0) - - return accountNumber, accountSequence, nil -} diff --git a/cmd/noble/listener.go b/cmd/noble/listener.go deleted file mode 100644 index 27f70c0..0000000 --- a/cmd/noble/listener.go +++ /dev/null @@ -1,110 +0,0 @@ -package noble - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - "strconv" - "sync" - "time" - - "cosmossdk.io/log" - "github.com/strangelove-ventures/noble-cctp-relayer/config" - "github.com/strangelove-ventures/noble-cctp-relayer/types" -) - -func StartListener(cfg config.Config, logger log.Logger, processingQueue chan *types.MessageState) { - // set up client - - logger.Info(fmt.Sprintf("Starting Noble listener at block %d looking back %d blocks", - cfg.Networks.Source.Noble.StartBlock, - cfg.Networks.Source.Noble.LookbackPeriod)) - - var wg sync.WaitGroup - wg.Add(1) - - // enqueue block heights - currentBlock := cfg.Networks.Source.Noble.StartBlock - lookback := cfg.Networks.Source.Noble.LookbackPeriod - chainTip := GetNobleChainTip(cfg) - blockQueue := make(chan uint64, 1000000) - - // history - currentBlock = currentBlock - lookback - for currentBlock <= chainTip { - blockQueue <- currentBlock - currentBlock++ - } - - // listen for new blocks - go func() { - for { - chainTip = GetNobleChainTip(cfg) - if chainTip >= currentBlock { - for i := currentBlock; i <= chainTip; i++ { - blockQueue <- i - } - currentBlock = chainTip + 1 - } - time.Sleep(6 * time.Second) - } - }() - - // constantly query for blocks - for i := 0; i < int(cfg.Networks.Source.Noble.Workers); i++ { - go func() { - for { - block := <-blockQueue - rawResponse, err := http.Get(fmt.Sprintf("%s/tx_search?query=\"tx.height=%d\"", cfg.Networks.Source.Noble.RPC, block)) - if err != nil { - logger.Debug(fmt.Sprintf("unable to query Noble block %d", block)) - continue - } - if rawResponse.StatusCode != http.StatusOK { - logger.Debug(fmt.Sprintf("non 200 response received for Noble block %d", block)) - time.Sleep(5 * time.Second) - blockQueue <- block - continue - } - - body, err := io.ReadAll(rawResponse.Body) - if err != nil { - logger.Debug(fmt.Sprintf("unable to parse Noble block %d", block)) - continue - } - - response := types.BlockResultsResponse{} - err = json.Unmarshal(body, &response) - if err != nil { - logger.Debug(fmt.Sprintf("unable to unmarshal Noble block %d", block)) - continue - } - - for _, tx := range response.Result.Txs { - parsedMsg, err := types.NobleLogToMessageState(tx) - if err != nil { - continue - } - logger.Info(fmt.Sprintf("New stream msg from %d with tx hash %s", parsedMsg.SourceDomain, parsedMsg.SourceTxHash)) - processingQueue <- parsedMsg - } - } - }() - } - - wg.Wait() -} - -func GetNobleChainTip(cfg config.Config) uint64 { - rawResponse, _ := http.Get(cfg.Networks.Source.Noble.RPC + "/block") - body, _ := io.ReadAll(rawResponse.Body) - - response := types.BlockResponse{} - err := json.Unmarshal(body, &response) - if err != nil { - fmt.Println(err.Error()) - } - res, _ := strconv.ParseInt(response.Result.Block.Header.Height, 10, 0) - return uint64(res) -} diff --git a/cmd/noble/listener_test.go b/cmd/noble/listener_test.go deleted file mode 100644 index f92616c..0000000 --- a/cmd/noble/listener_test.go +++ /dev/null @@ -1,50 +0,0 @@ -package noble_test - -import ( - "cosmossdk.io/log" - "github.com/rs/zerolog" - "github.com/strangelove-ventures/noble-cctp-relayer/cmd/noble" - "github.com/strangelove-ventures/noble-cctp-relayer/config" - "github.com/strangelove-ventures/noble-cctp-relayer/types" - "github.com/stretchr/testify/require" - "os" - "testing" - "time" -) - -var cfg config.Config -var logger log.Logger -var processingQueue chan *types.MessageState - -func init() { - cfg = config.Parse("../../.ignore/unit_tests.yaml") - - logger = log.NewLogger(os.Stdout, log.LevelOption(zerolog.DebugLevel)) - processingQueue = make(chan *types.MessageState, 10000) - cfg.Networks.Source.Noble.Workers = 1 -} - -func TestStartListener(t *testing.T) { - cfg.Networks.Source.Noble.StartBlock = 3273557 - go noble.StartListener(cfg, logger, processingQueue) - - time.Sleep(20 * time.Second) - - msg := <-processingQueue - - expectedMsg := &types.MessageState{ - IrisLookupId: "efe7cea3fd4785c3beab7f37876bdd48c5d4689c84d85a250813a2a7f01fe765", - Type: "mint", - Status: "created", - SourceDomain: 4, - DestDomain: 0, - SourceTxHash: "5002A249B1353FA59C1660EBAE5FA7FC652AC1E77F69CEF3A4533B0DF2864012", - } - require.Equal(t, expectedMsg.IrisLookupId, msg.IrisLookupId) - require.Equal(t, expectedMsg.Type, msg.Type) - require.Equal(t, expectedMsg.Status, msg.Status) - require.Equal(t, expectedMsg.SourceDomain, msg.SourceDomain) - require.Equal(t, expectedMsg.DestDomain, msg.DestDomain) - require.Equal(t, expectedMsg.SourceTxHash, msg.SourceTxHash) - -} diff --git a/cmd/process.go b/cmd/process.go index aca800e..1d6572d 100644 --- a/cmd/process.go +++ b/cmd/process.go @@ -1,20 +1,15 @@ package cmd import ( - "bytes" - "cosmossdk.io/log" - "encoding/hex" + "context" "fmt" - "github.com/spf13/cobra" - "github.com/strangelove-ventures/noble-cctp-relayer/cmd/circle" - "github.com/strangelove-ventures/noble-cctp-relayer/cmd/ethereum" - "github.com/strangelove-ventures/noble-cctp-relayer/cmd/noble" - "github.com/strangelove-ventures/noble-cctp-relayer/config" - "github.com/strangelove-ventures/noble-cctp-relayer/types" "os" - "strings" - "sync" "time" + + "cosmossdk.io/log" + "github.com/spf13/cobra" + "github.com/strangelove-ventures/noble-cctp-relayer/circle" + "github.com/strangelove-ventures/noble-cctp-relayer/types" ) var startCmd = &cobra.Command{ @@ -33,143 +28,135 @@ var sequenceMap = types.NewSequenceMap() func Start(cmd *cobra.Command, args []string) { - var wg sync.WaitGroup - wg.Add(1) + // messageState processing queue + var processingQueue = make(chan *types.TxState, 10000) - // initialize minter account sequences - for key, _ := range Cfg.Networks.Minters { - switch key { - case 0: - ethNonce, err := ethereum.GetEthereumAccountNonce( - Cfg.Networks.Destination.Ethereum.RPC, - Cfg.Networks.Minters[0].MinterAddress) + registeredDomains := make(map[types.Domain]types.Chain) - if err != nil { - Logger.Error("Error retrieving Ethereum account nonce") - os.Exit(1) - } - sequenceMap.Put(key, ethNonce) - case 4: - _, nextMinterSequence, err := noble.GetNobleAccountNumberSequence( - Cfg.Networks.Destination.Noble.API, - Cfg.Networks.Minters[4].MinterAddress) - - if err != nil { - Logger.Error("Error retrieving Noble account sequence") - os.Exit(1) - } - sequenceMap.Put(key, nextMinterSequence) + for name, cfg := range Cfg.Chains { + c, err := cfg.Chain(name) + if err != nil { + Logger.Error("Error creating chain", "err: ", err) + os.Exit(1) } - // ...initialize more here - } + if err := c.InitializeBroadcaster(cmd.Context(), Logger, sequenceMap); err != nil { + Logger.Error("Error initializing broadcaster", "err: ", err) + os.Exit(1) + } - // messageState processing queue - var processingQueue = make(chan *types.MessageState, 10000) + go c.StartListener(cmd.Context(), Logger, processingQueue) - // spin up Processor worker pool - for i := 0; i < int(Cfg.ProcessorWorkerCount); i++ { - go StartProcessor(Cfg, Logger, processingQueue, sequenceMap) - } + if _, ok := registeredDomains[c.Domain()]; ok { + Logger.Error("Duplicate domain found", "domain", c.Domain()) + os.Exit(1) + } - // listeners listen for events, parse them, and enqueue them to processingQueue - if Cfg.Networks.Source.Ethereum.Enabled { - ethereum.StartListener(Cfg, Logger, processingQueue) + registeredDomains[c.Domain()] = c } - if Cfg.Networks.Source.Noble.Enabled { - noble.StartListener(Cfg, Logger, processingQueue) + + // spin up Processor worker pool + for i := 0; i < int(Cfg.ProcessorWorkerCount); i++ { + go StartProcessor(cmd.Context(), Cfg, Logger, registeredDomains, processingQueue, sequenceMap) } - // ...register more chain listeners here - wg.Wait() + <-cmd.Context().Done() } // StartProcessor is the main processing pipeline. -func StartProcessor(cfg config.Config, logger log.Logger, processingQueue chan *types.MessageState, sequenceMap *types.SequenceMap) { +func StartProcessor( + ctx context.Context, + cfg *types.Config, + logger log.Logger, + registeredDomains map[types.Domain]types.Chain, + processingQueue chan *types.TxState, + sequenceMap *types.SequenceMap, +) { for { - dequeuedMsg := <-processingQueue + dequeuedTx := <-processingQueue + // if this is the first time seeing this message, add it to the State - msg, ok := State.Load(LookupKey(dequeuedMsg.SourceTxHash, dequeuedMsg.Type)) + tx, ok := State.Load(LookupKey(dequeuedTx.TxHash)) if !ok { - State.Store(LookupKey(dequeuedMsg.SourceTxHash, dequeuedMsg.Type), dequeuedMsg) - msg, _ = State.Load(LookupKey(dequeuedMsg.SourceTxHash, dequeuedMsg.Type)) - msg.Status = types.Created + State.Store(LookupKey(dequeuedTx.TxHash), dequeuedTx) + tx, _ = State.Load(LookupKey(dequeuedTx.TxHash)) + for _, msg := range tx.Msgs { + msg.Status = types.Created + } } - // if a filter's condition is met, mark as filtered - if filterDisabledCCTPRoutes(cfg, logger, msg) || - filterInvalidDestinationCallers(cfg, logger, msg) || - filterNonWhitelistedChannels(cfg, logger, msg) || - filterMessages(cfg, logger, msg) { - msg.Status = types.Filtered - } + var broadcastMsgs = make(map[types.Domain][]*types.MessageState) + var requeue bool + for _, msg := range tx.Msgs { - // if the message is burned or pending, check for an attestation - if msg.Status == types.Created || msg.Status == types.Pending { - response := circle.CheckAttestation(cfg, logger, msg.IrisLookupId) - if response != nil { - if msg.Status == types.Created && response.Status == "pending_confirmations" { - msg.Status = types.Pending - msg.Updated = time.Now() - time.Sleep(10 * time.Second) - processingQueue <- msg - continue - } else if response.Status == "pending_confirmations" { + // if a filter's condition is met, mark as filtered + if filterDisabledCCTPRoutes(cfg, logger, msg) || + filterInvalidDestinationCallers(registeredDomains, logger, msg) { + msg.Status = types.Filtered + } + + // if the message is burned or pending, check for an attestation + if msg.Status == types.Created || msg.Status == types.Pending { + response := circle.CheckAttestation(cfg.Circle.AttestationBaseUrl, logger, msg.IrisLookupId, msg.SourceTxHash, msg.SourceDomain, msg.DestDomain) + if response != nil { + if msg.Status == types.Created && response.Status == "pending_confirmations" { + logger.Debug("Attestation is created but still pending confirmations for 0x" + msg.IrisLookupId + ". Retrying...") + msg.Status = types.Pending + msg.Updated = time.Now() + time.Sleep(10 * time.Second) + requeue = true + continue + } else if response.Status == "pending_confirmations" { + logger.Debug("Attestation is still pending for 0x" + msg.IrisLookupId + ". Retrying...") + time.Sleep(10 * time.Second) + requeue = true + continue + } else if response.Status == "complete" { + logger.Debug("Attestation is complete for 0x" + msg.IrisLookupId + ". Retrying...") + msg.Status = types.Attested + msg.Attestation = response.Attestation + msg.Updated = time.Now() + broadcastMsgs[msg.DestDomain] = append(broadcastMsgs[msg.DestDomain], msg) + } + } else { + // add attestation retry intervals per domain here + logger.Debug("Attestation is still processing for 0x" + msg.IrisLookupId + ". Retrying...") time.Sleep(10 * time.Second) - processingQueue <- msg + // retry + requeue = true continue - } else if response.Status == "complete" { - msg.Status = types.Attested - msg.Attestation = response.Attestation - msg.Updated = time.Now() } - } else { - // add attestation retry intervals per domain here - logger.Debug("Attestation is still processing for 0x" + msg.IrisLookupId + ". Retrying...") - time.Sleep(10 * time.Second) - // retry - processingQueue <- msg - continue } } // if the message is attested to, try to broadcast - if msg.Status == types.Attested { - switch msg.DestDomain { - case 0: // ethereum - response, err := ethereum.Broadcast(cfg, logger, msg, sequenceMap) - if err != nil { - logger.Error("unable to mint on Ethereum", "err", err) - processingQueue <- msg - continue - } - msg.DestTxHash = response.Hash().Hex() - logger.Info(fmt.Sprintf("Successfully broadcast %s to Ethereum. Tx hash: %s", msg.SourceTxHash, msg.DestTxHash)) - case 4: // noble - response, err := noble.Broadcast(cfg, logger, msg, sequenceMap) - if err != nil { - logger.Error("unable to mint on Noble", "err", err) - processingQueue <- msg - continue - } - if response.Code != 0 { - logger.Error("nonzero response code received", "err", err) - processingQueue <- msg - continue - } - // success! - msg.DestTxHash = response.Hash.String() + for domain, msgs := range broadcastMsgs { + chain, ok := registeredDomains[domain] + if !ok { + logger.Error("No chain registered for domain", "domain", domain) + continue + } + + if err := chain.Broadcast(ctx, logger, msgs, sequenceMap); err != nil { + logger.Error("unable to mint one or more transfers", "error(s)", err, "total_transfers", len(msgs), "name", chain.Name(), "domain", domain) + requeue = true + continue + } + + for _, msg := range msgs { + msg.Status = types.Complete + msg.Updated = time.Now() } - // ...add minters for different domains here - msg.Status = types.Complete - msg.Updated = time.Now() + } + if requeue { + processingQueue <- tx } } } // filterDisabledCCTPRoutes returns true if we haven't enabled relaying from a source domain to a destination domain -func filterDisabledCCTPRoutes(cfg config.Config, logger log.Logger, msg *types.MessageState) bool { - val, ok := cfg.Networks.EnabledRoutes[msg.SourceDomain] +func filterDisabledCCTPRoutes(cfg *types.Config, logger log.Logger, msg *types.MessageState) bool { + val, ok := cfg.EnabledRoutes[msg.SourceDomain] result := !(ok && val == msg.DestDomain) if result { logger.Info(fmt.Sprintf("Filtered tx %s because relaying from %d to %d is not enabled", @@ -179,69 +166,19 @@ func filterDisabledCCTPRoutes(cfg config.Config, logger log.Logger, msg *types.M } // filterInvalidDestinationCallers returns true if the minter is not the destination caller for the specified domain -func filterInvalidDestinationCallers(cfg config.Config, logger log.Logger, msg *types.MessageState) bool { - zeroByteArr := make([]byte, 32) - result := false - - switch msg.DestDomain { - case 4: - bech32DestinationCaller, err := types.DecodeDestinationCaller(msg.DestinationCaller) - if err != nil { - result = true - } - if !bytes.Equal(msg.DestinationCaller, zeroByteArr) && - bech32DestinationCaller != cfg.Networks.Minters[msg.DestDomain].MinterAddress { - result = true - } - if result { - logger.Info(fmt.Sprintf("Filtered tx %s because the destination caller %s is specified and it's not the minter %s", - msg.SourceTxHash, msg.DestinationCaller, cfg.Networks.Minters[msg.DestDomain].MinterAddress)) - } - - default: // minting to evm - decodedMinter, err := hex.DecodeString(strings.ReplaceAll(cfg.Networks.Minters[0].MinterAddress, "0x", "")) - if err != nil { - return !bytes.Equal(msg.DestinationCaller, zeroByteArr) - } - - decodedMinterPadded := make([]byte, 32) - copy(decodedMinterPadded[12:], decodedMinter) - - if !bytes.Equal(msg.DestinationCaller, zeroByteArr) && !bytes.Equal(msg.DestinationCaller, decodedMinterPadded) { - result = true - } - } - - return result -} - -// filterNonWhitelistedChannels is a Noble specific filter that returns true -// if the channel is not in the forwarding_channel_whitelist -func filterNonWhitelistedChannels(cfg config.Config, logger log.Logger, msg *types.MessageState) bool { - if !cfg.Networks.Destination.Noble.FilterForwardsByIbcChannel { - return false - } - for _, channel := range cfg.Networks.Destination.Noble.ForwardingChannelWhitelist { - if msg.Channel == channel { - return false - } +func filterInvalidDestinationCallers(registeredDomains map[types.Domain]types.Chain, logger log.Logger, msg *types.MessageState) bool { + chain, ok := registeredDomains[msg.DestDomain] + if !ok { + logger.Error("No chain registered for domain", "domain", msg.DestDomain) + return true } - logger.Info(fmt.Sprintf("Filtered tx %s because channel whitelisting is enabled and the tx's channel is not in the whitelist: %s", - msg.SourceTxHash, msg.Channel)) - return true -} -// filterMessages filters out non-burn messages. It returns true if the message is not a burn. -func filterMessages(_ config.Config, logger log.Logger, msg *types.MessageState) bool { - result := msg.Type != types.Mint - if result { - logger.Info(fmt.Sprintf("Filtered tx %s because it's a not a burn", msg.SourceTxHash)) - } - return result + return !chain.IsDestinationCaller(msg.DestinationCaller) } -func LookupKey(sourceTxHash string, messageType string) string { - return fmt.Sprintf("%s-%s", sourceTxHash, messageType) +func LookupKey(sourceTxHash string) string { + // return fmt.Sprintf("%s-%s", sourceTxHash, messageType) + return sourceTxHash } func init() { diff --git a/cmd/process_test.go b/cmd/process_test.go index 34249fb..afb98a1 100644 --- a/cmd/process_test.go +++ b/cmd/process_test.go @@ -1,200 +1,241 @@ package cmd_test import ( + "context" + "os" + "testing" + "time" + "cosmossdk.io/log" "github.com/rs/zerolog" "github.com/strangelove-ventures/noble-cctp-relayer/cmd" - "github.com/strangelove-ventures/noble-cctp-relayer/cmd/noble" - "github.com/strangelove-ventures/noble-cctp-relayer/config" + "github.com/strangelove-ventures/noble-cctp-relayer/noble" "github.com/strangelove-ventures/noble-cctp-relayer/types" "github.com/stretchr/testify/require" - "os" - "testing" - "time" ) -var cfg config.Config +var cfg *types.Config var logger log.Logger -var processingQueue chan *types.MessageState +var processingQueue chan *types.TxState var sequenceMap *types.SequenceMap -func setupTest() { - cfg = config.Parse("../.ignore/unit_tests.yaml") +func setupTest(t *testing.T) map[types.Domain]types.Chain { + var err error + cfg, err = cmd.Parse("../.ignore/unit_tests.yaml") + require.NoError(t, err, "Error parsing config") + logger = log.NewLogger(os.Stdout, log.LevelOption(zerolog.DebugLevel)) - processingQueue = make(chan *types.MessageState, 10000) + processingQueue = make(chan *types.TxState, 10000) - _, nextMinterSequence, err := noble.GetNobleAccountNumberSequence( - cfg.Networks.Destination.Noble.API, - cfg.Networks.Minters[4].MinterAddress) + n, err := cfg.Chains["noble"].(*noble.ChainConfig).Chain("noble") + require.NoError(t, err, "Error creating noble chain") + + _, nextMinterSequence, err := n.(*noble.Noble).AccountInfo(context.TODO()) + require.NoError(t, err, "Error retrieving account sequence") - if err != nil { - logger.Error("Error retrieving account sequence") - os.Exit(1) - } sequenceMap = types.NewSequenceMap() - sequenceMap.Put(uint32(4), nextMinterSequence) + sequenceMap.Put(types.Domain(4), nextMinterSequence) + + registeredDomains := make(map[types.Domain]types.Chain) + for name, cfgg := range cfg.Chains { + c, err := cfgg.Chain(name) + require.NoError(t, err, "Error creating chain") + + registeredDomains[c.Domain()] = c + } + + return registeredDomains } // new log -> create state entry func TestProcessNewLog(t *testing.T) { - setupTest() + registeredDomains := setupTest(t) - go cmd.StartProcessor(cfg, logger, processingQueue, sequenceMap) + go cmd.StartProcessor(context.TODO(), cfg, logger, registeredDomains, processingQueue, sequenceMap) emptyBz := make([]byte, 32) - expectedState := &types.MessageState{ - SourceTxHash: "1", - Type: types.Mint, - SourceDomain: 0, - DestDomain: 4, - DestinationCaller: emptyBz, + expectedState := &types.TxState{ + TxHash: "1", + Msgs: []*types.MessageState{ + &types.MessageState{ + SourceTxHash: "1", + SourceDomain: 0, + DestDomain: 4, + DestinationCaller: emptyBz, + }, + }, } processingQueue <- expectedState - time.Sleep(2 * time.Second) - - actualState, _ := cmd.State.Load(cmd.LookupKey(expectedState.SourceTxHash, expectedState.Type)) + time.Sleep(5 * time.Second) - require.Equal(t, types.Created, actualState.Status) + actualState, _ := cmd.State.Load(expectedState.TxHash) + require.Equal(t, types.Created, actualState.Msgs[0].Status) } // created message -> check attestation -> mark as attested -> mark as complete -> remove from state func TestProcessCreatedLog(t *testing.T) { - setupTest() - cfg.Networks.EnabledRoutes[0] = 5 // skip mint + registeredDomains := setupTest(t) + cfg.EnabledRoutes[0] = 5 // skip mint - go cmd.StartProcessor(cfg, logger, processingQueue, sequenceMap) + go cmd.StartProcessor(context.TODO(), cfg, logger, registeredDomains, processingQueue, sequenceMap) emptyBz := make([]byte, 32) - expectedState := &types.MessageState{ - SourceTxHash: "123", - Type: types.Mint, - IrisLookupId: "a404f4155166a1fc7ffee145b5cac6d0f798333745289ab1db171344e226ef0c", - Status: types.Created, - SourceDomain: 0, - DestDomain: 5, - DestinationCaller: emptyBz, + + expectedState := &types.TxState{ + TxHash: "123", + Msgs: []*types.MessageState{ + &types.MessageState{ + SourceTxHash: "123", + IrisLookupId: "a404f4155166a1fc7ffee145b5cac6d0f798333745289ab1db171344e226ef0c", + Status: types.Created, + SourceDomain: 0, + DestDomain: 5, + DestinationCaller: emptyBz, + }, + }, } processingQueue <- expectedState time.Sleep(2 * time.Second) - actualState, ok := cmd.State.Load(cmd.LookupKey(expectedState.SourceTxHash, expectedState.Type)) + actualState, ok := cmd.State.Load(expectedState.TxHash) require.True(t, ok) - require.Equal(t, types.Complete, actualState.Status) - + require.Equal(t, types.Complete, actualState.Msgs[0].Status) } // created message -> disabled cctp route -> filtered func TestProcessDisabledCctpRoute(t *testing.T) { - setupTest() + registeredDomains := setupTest(t) - delete(cfg.Networks.EnabledRoutes, 0) + delete(cfg.EnabledRoutes, 0) - go cmd.StartProcessor(cfg, logger, processingQueue, sequenceMap) + go cmd.StartProcessor(context.TODO(), cfg, logger, registeredDomains, processingQueue, sequenceMap) emptyBz := make([]byte, 32) - expectedState := &types.MessageState{ - SourceTxHash: "123", - IrisLookupId: "a404f4155166a1fc7ffee145b5cac6d0f798333745289ab1db171344e226ef0c", - Status: types.Created, - SourceDomain: 0, - DestDomain: 5, - DestinationCaller: emptyBz, + expectedState := &types.TxState{ + TxHash: "123", + Msgs: []*types.MessageState{ + &types.MessageState{ + SourceTxHash: "123", + IrisLookupId: "a404f4155166a1fc7ffee145b5cac6d0f798333745289ab1db171344e226ef0c", + Status: types.Created, + SourceDomain: 0, + DestDomain: 5, + DestinationCaller: emptyBz, + }, + }, } processingQueue <- expectedState time.Sleep(2 * time.Second) - actualState, ok := cmd.State.Load(cmd.LookupKey(expectedState.SourceTxHash, expectedState.Type)) + actualState, ok := cmd.State.Load(expectedState.TxHash) require.True(t, ok) - require.Equal(t, types.Filtered, actualState.Status) - + require.Equal(t, types.Filtered, actualState.Msgs[0].Status) } // created message -> different destination caller -> filtered func TestProcessInvalidDestinationCaller(t *testing.T) { - setupTest() + registeredDomains := setupTest(t) - go cmd.StartProcessor(cfg, logger, processingQueue, sequenceMap) + go cmd.StartProcessor(context.TODO(), cfg, logger, registeredDomains, processingQueue, sequenceMap) nonEmptyBytes := make([]byte, 31) nonEmptyBytes = append(nonEmptyBytes, 0x1) - expectedState := &types.MessageState{ - SourceTxHash: "123", - IrisLookupId: "a404f4155166a1fc7ffee145b5cac6d0f798333745289ab1db171344e226ef0c", - Status: types.Created, - SourceDomain: 0, - DestDomain: 4, - DestinationCaller: nonEmptyBytes, + expectedState := &types.TxState{ + TxHash: "123", + Msgs: []*types.MessageState{ + &types.MessageState{ + SourceTxHash: "123", + IrisLookupId: "a404f4155166a1fc7ffee145b5cac6d0f798333745289ab1db171344e226ef0c", + Status: types.Created, + SourceDomain: 0, + DestDomain: 4, + DestinationCaller: nonEmptyBytes, + }, + }, } processingQueue <- expectedState time.Sleep(2 * time.Second) - actualState, ok := cmd.State.Load(cmd.LookupKey(expectedState.SourceTxHash, expectedState.Type)) + actualState, ok := cmd.State.Load(expectedState.TxHash) require.True(t, ok) - require.Equal(t, types.Filtered, actualState.Status) - + require.Equal(t, types.Filtered, actualState.Msgs[0].Status) } -// created message -> nonwhitelisted channel -> filtered -func TestProcessNonWhitelistedChannel(t *testing.T) { - setupTest() - cfg.Networks.Destination.Noble.FilterForwardsByIbcChannel = true +// created message -> not \ -> filtered +func TestProcessNonBurnMessageWhenDisabled(t *testing.T) { + registeredDomains := setupTest(t) - go cmd.StartProcessor(cfg, logger, processingQueue, sequenceMap) + go cmd.StartProcessor(context.TODO(), cfg, logger, registeredDomains, processingQueue, sequenceMap) emptyBz := make([]byte, 32) - expectedState := &types.MessageState{ - SourceTxHash: "123", - IrisLookupId: "a404f4155166a1fc7ffee145b5cac6d0f798333745289ab1db171344e226ef0c", - Status: types.Created, - SourceDomain: 0, - DestDomain: 4, - DestinationCaller: emptyBz, + expectedState := &types.TxState{ + TxHash: "123", + Msgs: []*types.MessageState{ + &types.MessageState{ + SourceTxHash: "123", + IrisLookupId: "a404f4155166a1fc7ffee145b5cac6d0f798333745289ab1db171344e226ef0c", + Status: types.Created, + SourceDomain: 0, + DestDomain: 4, + DestinationCaller: emptyBz, + }, + }, } processingQueue <- expectedState time.Sleep(2 * time.Second) - actualState, ok := cmd.State.Load(cmd.LookupKey(expectedState.SourceTxHash, expectedState.Type)) + actualState, ok := cmd.State.Load(expectedState.TxHash) require.True(t, ok) - require.Equal(t, types.Filtered, actualState.Status) - + require.Equal(t, types.Filtered, actualState.Msgs[0].Status) } -// created message -> not \ -> filtered -func TestProcessNonBurnMessageWhenDisabled(t *testing.T) { - setupTest() +// test batch transactions where multiple messages can be sent with the same tx hash +// MsgSentBytes defer between messages +func TestBatchTx(t *testing.T) { + registeredDomains := setupTest(t) - go cmd.StartProcessor(cfg, logger, processingQueue, sequenceMap) + go cmd.StartProcessor(context.TODO(), cfg, logger, registeredDomains, processingQueue, sequenceMap) emptyBz := make([]byte, 32) - expectedState := &types.MessageState{ - SourceTxHash: "123", - Type: "", - IrisLookupId: "a404f4155166a1fc7ffee145b5cac6d0f798333745289ab1db171344e226ef0c", - Status: types.Created, - SourceDomain: 0, - DestDomain: 4, - DestinationCaller: emptyBz, + expectedState := &types.TxState{ + TxHash: "123", + Msgs: []*types.MessageState{ + &types.MessageState{ + SourceTxHash: "123", + IrisLookupId: "a404f4155166a1fc7ffee145b5cac6d0f798333745289ab1db171344e226ef0c", + Status: types.Created, + SourceDomain: 0, + DestDomain: 4, + MsgSentBytes: []byte("mock bytes 1"), // different message sent bytes + }, + &types.MessageState{ + SourceTxHash: "123", // same source tx hash + IrisLookupId: "a404f4155166a1fc7ffee145b5cac6d0f798333745289ab1db171344e226ef0c", + Status: types.Created, + SourceDomain: 0, + DestDomain: 4, + DestinationCaller: emptyBz, + MsgSentBytes: []byte("mock bytes 2"), // different message sent bytes + }, + }, } processingQueue <- expectedState - time.Sleep(2 * time.Second) - - actualState, ok := cmd.State.Load(cmd.LookupKey(expectedState.SourceTxHash, expectedState.Type)) + actualState, ok := cmd.State.Load(expectedState.TxHash) require.True(t, ok) - require.Equal(t, types.Filtered, actualState.Status) - + require.Equal(t, 2, len(actualState.Msgs)) } diff --git a/cmd/root.go b/cmd/root.go index 0d18aa6..d98f8f5 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -2,28 +2,24 @@ package cmd import ( "context" - "encoding/hex" - "encoding/json" "fmt" - "github.com/cosmos/cosmos-sdk/crypto/keys/secp256k1" - "github.com/cosmos/cosmos-sdk/types/bech32" - "github.com/ethereum/go-ethereum/ethclient" - "github.com/gin-gonic/gin" - "github.com/strangelove-ventures/noble-cctp-relayer/cmd/ethereum" - "github.com/strangelove-ventures/noble-cctp-relayer/types" - "io" "net/http" "os" "strconv" + "github.com/gin-gonic/gin" + "github.com/strangelove-ventures/noble-cctp-relayer/ethereum" + "github.com/strangelove-ventures/noble-cctp-relayer/noble" + "github.com/strangelove-ventures/noble-cctp-relayer/types" + "gopkg.in/yaml.v2" + "cosmossdk.io/log" "github.com/rs/zerolog" "github.com/spf13/cobra" - "github.com/strangelove-ventures/noble-cctp-relayer/config" ) var ( - Cfg config.Config + Cfg *types.Config cfgFile string verbose bool @@ -35,8 +31,8 @@ var rootCmd = &cobra.Command{ Short: "A CLI tool for relaying CCTP messages", } -func Execute() { - if err := rootCmd.Execute(); err != nil { +func Execute(ctx context.Context) { + if err := rootCmd.ExecuteContext(ctx); err != nil { Logger.Error(err.Error()) os.Exit(1) } @@ -55,57 +51,13 @@ func init() { Logger = log.NewLogger(os.Stdout, log.LevelOption(zerolog.InfoLevel)) } - Cfg = config.Parse(cfgFile) - Logger.Info("successfully parsed config file", "location", cfgFile) - - Logger.Info(Cfg.Networks.Source.Ethereum.RPC) - // Set minter addresses from priv keys - for i, minter := range Cfg.Networks.Minters { - switch i { - case 0: - _, address, err := ethereum.GetEcdsaKeyAddress(minter.MinterPrivateKey) - if err != nil { - Logger.Error(fmt.Sprintf("Unable to parse ecdsa key from source %d", i)) - os.Exit(1) - } - minter.MinterAddress = address - Cfg.Networks.Minters[0] = minter - case 4: - keyBz, err := hex.DecodeString(minter.MinterPrivateKey) - if err != nil { - Logger.Error(fmt.Sprintf("Unable to parse key from source %d", i)) - os.Exit(1) - } - privKey := secp256k1.PrivKey{Key: keyBz} - address, err := bech32.ConvertAndEncode("noble", privKey.PubKey().Address()) - if err != nil { - Logger.Error(fmt.Sprintf("Unable to parse ecdsa key from source %d", i)) - os.Exit(1) - } - minter.MinterAddress = address - Cfg.Networks.Minters[4] = minter - } - } - - // Set default listener blocks - - // if Ethereum start block not set, default to latest - if Cfg.Networks.Source.Ethereum.Enabled && Cfg.Networks.Source.Ethereum.StartBlock == 0 { - client, _ := ethclient.Dial(Cfg.Networks.Source.Ethereum.RPC) - defer client.Close() - header, _ := client.HeaderByNumber(context.Background(), nil) - Cfg.Networks.Source.Ethereum.StartBlock = header.Number.Uint64() - } - - // if Noble start block not set, default to latest - if Cfg.Networks.Source.Noble.Enabled && Cfg.Networks.Source.Noble.StartBlock == 0 { - rawResponse, _ := http.Get(Cfg.Networks.Source.Noble.RPC + "/block") - body, _ := io.ReadAll(rawResponse.Body) - response := types.BlockResponse{} - _ = json.Unmarshal(body, &response) - height, _ := strconv.ParseInt(response.Result.Block.Header.Height, 10, 0) - Cfg.Networks.Source.Noble.StartBlock = uint64(height) + var err error + Cfg, err = Parse(cfgFile) + if err != nil { + Logger.Error("unable to parse config file", "location", cfgFile, "err", err) + os.Exit(1) } + Logger.Info("successfully parsed config file", "location", cfgFile) // start api server go startApi() @@ -135,29 +87,53 @@ func getTxByHash(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"message": "unable to parse domain"}) } - found := false - var result []types.MessageState - msgType := c.Query("type") // mint or forward - if msgType == types.Mint || msgType == "" { - if message, ok := State.Load(LookupKey(txHash, types.Mint)); ok { - if domain == "" || (domain != "" && message.SourceDomain == uint32(domainInt)) { - result = append(result, *message) - found = true - } - } + if tx, ok := State.Load(txHash); ok && domain == "" || (domain != "" && tx.Msgs[0].SourceDomain == types.Domain(domainInt)) { + c.JSON(http.StatusOK, tx.Msgs) + return } - if msgType == types.Forward || msgType == "" { - if message, ok := State.Load(LookupKey(txHash, types.Forward)); ok { - if domain == "" || (domain != "" && message.SourceDomain == uint32(domainInt)) { - result = append(result, *message) - found = true - } - } + + c.JSON(http.StatusNotFound, gin.H{"message": "message not found"}) +} + +func Parse(file string) (*types.Config, error) { + data, err := os.ReadFile(file) + if err != nil { + return nil, fmt.Errorf("failed to read file %w", err) + } + + var cfg types.ConfigWrapper + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("error unmarshalling config: %w", err) } - if found { - c.JSON(http.StatusOK, result) - } else { - c.JSON(http.StatusNotFound, gin.H{"message": "message not found"}) + c := types.Config{ + EnabledRoutes: cfg.EnabledRoutes, + Circle: cfg.Circle, + ProcessorWorkerCount: cfg.ProcessorWorkerCount, + Api: cfg.Api, + Chains: make(map[string]types.ChainConfig), + } + + for name, chain := range cfg.Chains { + yamlbz, err := yaml.Marshal(chain) + if err != nil { + return nil, err + } + + switch name { + case "noble": + var cc noble.ChainConfig + if err := yaml.Unmarshal(yamlbz, &cc); err != nil { + return nil, err + } + c.Chains[name] = &cc + default: + var cc ethereum.ChainConfig + if err := yaml.Unmarshal(yamlbz, &cc); err != nil { + return nil, err + } + c.Chains[name] = &cc + } } + return &c, err } diff --git a/cmd/root_test.go b/cmd/root_test.go new file mode 100644 index 0000000..6e4388c --- /dev/null +++ b/cmd/root_test.go @@ -0,0 +1,25 @@ +package cmd_test + +import ( + "testing" + + "github.com/strangelove-ventures/noble-cctp-relayer/cmd" + "github.com/strangelove-ventures/noble-cctp-relayer/ethereum" + "github.com/strangelove-ventures/noble-cctp-relayer/noble" + "github.com/stretchr/testify/require" +) + +func TestConfig(t *testing.T) { + file, err := cmd.Parse("../config/sample.yaml") + require.NoError(t, err, "Error parsing config") + + // assert noble chainConfig correctly parsed + var nobleType interface{} = file.Chains["noble"] + _, ok := nobleType.(*noble.ChainConfig) + require.True(t, ok) + + // assert ethereum chainConfig correctly parsed + var ethType interface{} = file.Chains["ethereum"] + _, ok = ethType.(*ethereum.ChainConfig) + require.True(t, ok) +} diff --git a/config/config.go b/config/config.go deleted file mode 100644 index b0c29b6..0000000 --- a/config/config.go +++ /dev/null @@ -1,72 +0,0 @@ -package config - -import ( - "os" - - "gopkg.in/yaml.v3" -) - -type Config struct { - Networks struct { - Source struct { - Ethereum struct { - DomainId uint32 `yaml:"domain-id"` - RPC string `yaml:"rpc"` - MessageTransmitter string `yaml:"message-transmitter"` - RequestQueueSize uint32 `yaml:"request-queue-size"` - StartBlock uint64 `yaml:"start-block"` - LookbackPeriod uint64 `yaml:"lookback-period"` - Enabled bool `yaml:"enabled"` - } `yaml:"ethereum"` - Noble struct { - DomainId uint32 `yaml:"domain-id"` - RPC string `yaml:"rpc"` - RequestQueueSize uint32 `yaml:"request-queue-size"` - StartBlock uint64 `yaml:"start-block"` - LookbackPeriod uint64 `yaml:"lookback-period"` - Workers uint32 `yaml:"workers"` - Enabled bool `yaml:"enabled"` - } `yaml:"noble"` - } `yaml:"source"` - Destination struct { - Ethereum struct { - DomainId uint32 `yaml:"domain-id"` - ChainId int64 `yaml:"chain-id"` - RPC string `yaml:"rpc"` - BroadcastRetries int `yaml:"broadcast-retries"` - BroadcastRetryInterval int `yaml:"broadcast-retry-interval"` - } `yaml:"ethereum"` - Noble struct { - DomainId uint32 `yaml:"domain-id"` - RPC string `yaml:"rpc"` - API string `yaml:"api"` - ChainId string `yaml:"chain-id"` - GasLimit uint64 `yaml:"gas-limit"` - BroadcastRetries int `yaml:"broadcast-retries"` - BroadcastRetryInterval int `yaml:"broadcast-retry-interval"` - FilterForwardsByIbcChannel bool `yaml:"filter-forwards-by-ibc-channel"` - ForwardingChannelWhitelist []string `yaml:"forwarding-channel-whitelist"` - } `yaml:"noble"` - } `yaml:"destination"` - EnabledRoutes map[uint32]uint32 `yaml:"enabled-routes"` - Minters map[uint32]struct { - MinterAddress string `yaml:"minter-address"` - MinterPrivateKey string `yaml:"minter-private-key"` - } `yaml:"minters"` - } `yaml:"networks"` - Circle struct { - AttestationBaseUrl string `yaml:"attestation-base-url"` - FetchRetries int `yaml:"fetch-retries"` - FetchRetryInterval int `yaml:"fetch-retry-interval"` - } `yaml:"circle"` - ProcessorWorkerCount uint32 `yaml:"processor-worker-count"` - Api struct { - TrustedProxies []string `yaml:"trusted-proxies"` - } `yaml:"api"` -} - -func Parse(file string) (cfg Config) { - data, _ := os.ReadFile(file) - _ = yaml.Unmarshal(data, &cfg) - return -} diff --git a/config/sample-app-config.yaml b/config/sample-app-config.yaml deleted file mode 100644 index c1ececd..0000000 --- a/config/sample-app-config.yaml +++ /dev/null @@ -1,39 +0,0 @@ -networks: - source: - ethereum: - enabled: true - domain-id: 0 - rpc: "wss://goerli.infura.io/ws/v3/" - message-transmitter: "0x26413e8157CD32011E726065a5462e97dD4d03D9" - request-queue-size: 1000 - start-block: 0 # set to 0 to default to latest block - lookback-period: 20 # historical blocks to look back on launch - destination: - noble: - domain-id: 4 - api: "https://lcd.testnet.noble.strange.love:443" - rpc: "https://rpc.testnet.noble.strange.love:443" - chain-id: "grand-1" - gas-limit: 200000 - broadcast-retries: 5 # number of times to attempt the broadcast - broadcast-retry-interval: 5 # time between retries in seconds - filter-forwards-by-ibc-channel: false - forwarding-channel-whitelist: - - "channel-10" # osmo-test-5 - - "channel-15" # dydx-testnet-2 - # source domain id -> destination domain id - enabled-routes: - 0: 4 # ethereum to noble - # destination domain -> minter metadata - minters: - 4: - minter-address: "noble1...." - minter-mnemonic: "12345" # hex encoded, no prepended 0x -circle: - attestation-base-url: "https://iris-api-sandbox.circle.com/attestations/" - fetch-retries: 10 # additional times to fetch an attestation - fetch-retry-interval: 10 # time between retries in seconds -processor-worker-count: 16 -api: - trusted-proxies: - - "1.2.3.4" # add trusted proxy IPs here \ No newline at end of file diff --git a/config/sample-config.yaml b/config/sample-config.yaml new file mode 100644 index 0000000..d6169e4 --- /dev/null +++ b/config/sample-config.yaml @@ -0,0 +1,43 @@ +chains: + ethereum: + chain-id: 5 + domain: 0 + rpc: # Ethereum RPC + ws: # Ethereum Websocket + message-transmitter: "0x26413e8157CD32011E726065a5462e97dD4d03D9" + + start-block: 0 # set to 0 to default to latest block + lookback-period: 5 # historical blocks to look back on launch + + broadcast-retries: 5 # number of times to attempt the broadcast + broadcast-retry-interval: 10 # time between retries in seconds + + minter-private-key: # private key + + + noble: + rpc: #noble RPC; for stability, use a reliable private node + chain-id: "grand-1" + + start-block: 0 # set to 0 to default to latest block + lookback-period: 5 # historical blocks to look back on launch + workers: 8 + + tx-memo: "Relayed by Strangelove" + gas-limit: 200000 + broadcast-retries: 5 # number of times to attempt the broadcast + broadcast-retry-interval: 5 # time between retries in seconds + + minter-private-key: # hex encoded privateKey + +# source domain id -> destination domain id +enabled-routes: + 0: 4 # ethereum to noble + 4: 0 # noble to ethereum + +circle: + attestation-base-url: "https://iris-api-sandbox.circle.com/attestations/" + fetch-retries: 0 # additional times to fetch an attestation + fetch-retry-interval: 3 # time between retries in seconds + +processor-worker-count: 16 diff --git a/config/sample-integration-config.yaml b/config/sample-integration-config.yaml index b7ca432..467a0c1 100644 --- a/config/sample-integration-config.yaml +++ b/config/sample-integration-config.yaml @@ -1,5 +1,12 @@ +# This file is for integration testing. +# These extra wallets keep the relayer wallet separate from the wallet used to send test transactions + networks: ethereum: - rpc: "https://goerli.infura.io/v3/" - address: "noble1...." - private_key: "..." # hex encoded, no 0x prefix \ No newline at end of file + # Sepolia + address: + private_key: + + noble: + address: + private_key: \ No newline at end of file diff --git a/cosmos/codec.go b/cosmos/codec.go new file mode 100644 index 0000000..6ac6f0d --- /dev/null +++ b/cosmos/codec.go @@ -0,0 +1,50 @@ +package cosmos + +import ( + "github.com/circlefin/noble-cctp/x/cctp" + "github.com/cosmos/cosmos-sdk/client" + "github.com/cosmos/cosmos-sdk/codec" + "github.com/cosmos/cosmos-sdk/codec/types" + "github.com/cosmos/cosmos-sdk/std" + "github.com/cosmos/cosmos-sdk/types/module" + "github.com/cosmos/cosmos-sdk/x/auth" + "github.com/cosmos/cosmos-sdk/x/auth/tx" + // authz "github.com/cosmos/cosmos-sdk/x/authz/module" + //"github.com/cosmos/cosmos-sdk/x/bank" +) + +var ModuleBasics = []module.AppModuleBasic{ + auth.AppModuleBasic{}, + // authz.AppModuleBasic{}, + // bank.AppModuleBasic{}, + cctp.AppModuleBasic{}, +} + +type Codec struct { + InterfaceRegistry types.InterfaceRegistry + Marshaler codec.Codec + TxConfig client.TxConfig + Amino *codec.LegacyAmino +} + +func makeCodec(moduleBasics []module.AppModuleBasic) Codec { + modBasic := module.NewBasicManager(moduleBasics...) + encodingConfig := makeCodecConfig() + std.RegisterLegacyAminoCodec(encodingConfig.Amino) + std.RegisterInterfaces(encodingConfig.InterfaceRegistry) + modBasic.RegisterLegacyAminoCodec(encodingConfig.Amino) + modBasic.RegisterInterfaces(encodingConfig.InterfaceRegistry) + + return encodingConfig +} + +func makeCodecConfig() Codec { + interfaceRegistry := types.NewInterfaceRegistry() + marshaler := codec.NewProtoCodec(interfaceRegistry) + return Codec{ + InterfaceRegistry: interfaceRegistry, + Marshaler: marshaler, + TxConfig: tx.NewTxConfig(marshaler, tx.DefaultSignModes), + Amino: codec.NewLegacyAmino(), + } +} diff --git a/cosmos/cosmosprovider.go b/cosmos/cosmosprovider.go new file mode 100644 index 0000000..39495a7 --- /dev/null +++ b/cosmos/cosmosprovider.go @@ -0,0 +1,51 @@ +package cosmos + +import ( + "time" + + rpcclient "github.com/cometbft/cometbft/rpc/client" + rpchttp "github.com/cometbft/cometbft/rpc/client/http" + libclient "github.com/cometbft/cometbft/rpc/jsonrpc/client" + gogogrpc "github.com/cosmos/gogoproto/grpc" + + "google.golang.org/grpc/encoding" + "google.golang.org/grpc/encoding/proto" +) + +var _ gogogrpc.ClientConn = &CosmosProvider{} + +var protoCodec = encoding.GetCodec(proto.Name) + +type CosmosProvider struct { + Cdc Codec + RPCClient rpcclient.Client +} + +// NewProvider validates the CosmosProviderConfig, instantiates a ChainClient and then instantiates a CosmosProvider +func NewProvider(rpcURL string) (*CosmosProvider, error) { + rpcClient, err := newRPCClient(rpcURL, 5*time.Second) + if err != nil { + return nil, err + } + + cp := &CosmosProvider{ + Cdc: makeCodec(ModuleBasics), + RPCClient: rpcClient, + } + + return cp, nil +} + +// NewRPCClient initializes a new tendermint RPC client connected to the specified address. +func newRPCClient(addr string, timeout time.Duration) (*rpchttp.HTTP, error) { + httpClient, err := libclient.DefaultHTTPClient(addr) + if err != nil { + return nil, err + } + httpClient.Timeout = timeout + rpcClient, err := rpchttp.NewWithClient(addr, "/websocket", httpClient) + if err != nil { + return nil, err + } + return rpcClient, nil +} diff --git a/cosmos/grpc_shim.go b/cosmos/grpc_shim.go new file mode 100644 index 0000000..008985a --- /dev/null +++ b/cosmos/grpc_shim.go @@ -0,0 +1,150 @@ +package cosmos + +import ( + "context" + "fmt" + "reflect" + "strconv" + + abci "github.com/cometbft/cometbft/abci/types" + "github.com/cosmos/cosmos-sdk/codec/types" + sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" + grpctypes "github.com/cosmos/cosmos-sdk/types/grpc" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" +) + +// Invoke implements the grpc ClientConn.Invoke method +func (cc *CosmosProvider) Invoke(ctx context.Context, method string, req, reply interface{}, opts ...grpc.CallOption) (err error) { + // Two things can happen here: + // 1. either we're broadcasting a Tx, in which call we call Tendermint's broadcast endpoint directly, + // 2. or we are querying for state, in which case we call ABCI's Querier. + + // In both cases, we don't allow empty request req (it will panic unexpectedly). + if reflect.ValueOf(req).IsNil() { + return sdkerrors.ErrInvalidRequest.Wrap("request cannot be nil") + } + + md := make(metadata.MD) + for _, callOpt := range opts { + if h, ok := callOpt.(grpc.HeaderCallOption); ok { + for k, v := range *h.HeaderAddr { + md[k] = append(md[k], v...) + } + } + } + + abciRes, outMd, err := cc.runGRPCQuery(ctx, method, req, md) + if err != nil { + return err + } + + if err = protoCodec.Unmarshal(abciRes.Value, reply); err != nil { + return err + } + + for _, callOpt := range opts { + header, ok := callOpt.(grpc.HeaderCallOption) + if !ok { + continue + } + + *header.HeaderAddr = outMd + } + + if cc.Cdc.InterfaceRegistry != nil { + return types.UnpackInterfaces(reply, cc.Cdc.Marshaler) + } + + return nil +} + +// NewStream implements the grpc ClientConn.NewStream method +func (cc *CosmosProvider) NewStream(context.Context, *grpc.StreamDesc, string, ...grpc.CallOption) (grpc.ClientStream, error) { + return nil, fmt.Errorf("streaming rpc not supported") +} + +// RunGRPCQuery runs a gRPC query from the clientCtx, given all necessary +// arguments for the gRPC method, and returns the ABCI response. It is used +// to factorize code between client (Invoke) and server (RegisterGRPCServer) +// gRPC handlers. +func (cc *CosmosProvider) runGRPCQuery(ctx context.Context, method string, req interface{}, md metadata.MD) (abci.ResponseQuery, metadata.MD, error) { + reqBz, err := protoCodec.Marshal(req) + if err != nil { + return abci.ResponseQuery{}, nil, err + } + + // parse height header + if heights := md.Get(grpctypes.GRPCBlockHeightHeader); len(heights) > 0 { + height, err := strconv.ParseInt(heights[0], 10, 64) + if err != nil { + return abci.ResponseQuery{}, nil, err + } + if height < 0 { + return abci.ResponseQuery{}, nil, sdkerrors.ErrInvalidRequest.Wrapf( + "client.Context.Invoke: height (%d) from %q must be >= 0", height, grpctypes.GRPCBlockHeightHeader) + } + + } + + height, err := heightFromMetadata(md) + if err != nil { + return abci.ResponseQuery{}, nil, err + } + + prove, err := proveFromMetadata(md) + if err != nil { + return abci.ResponseQuery{}, nil, err + } + + abciReq := abci.RequestQuery{ + Path: method, + Data: reqBz, + Height: height, + Prove: prove, + } + + abciRes, err := cc.QueryABCI(ctx, abciReq) + if err != nil { + return abci.ResponseQuery{}, nil, err + } + + // Create header metadata. For now the headers contain: + // - block height + // We then parse all the call options, if the call option is a + // HeaderCallOption, then we manually set the value of that header to the + // metadata. + md = metadata.Pairs(grpctypes.GRPCBlockHeightHeader, strconv.FormatInt(abciRes.Height, 10)) + return abciRes, md, nil +} + +func heightFromMetadata(md metadata.MD) (int64, error) { + height := md.Get(grpctypes.GRPCBlockHeightHeader) + if len(height) == 1 { + return strconv.ParseInt(height[0], 10, 64) + } + return 0, nil +} + +func proveFromMetadata(md metadata.MD) (bool, error) { + prove := md.Get("x-cosmos-query-prove") + if len(prove) == 1 { + return strconv.ParseBool(prove[0]) + } + return false, nil +} + +func sdkErrorToGRPCError(resp abci.ResponseQuery) error { + switch resp.Code { + case sdkerrors.ErrInvalidRequest.ABCICode(): + return status.Error(codes.InvalidArgument, resp.Log) + case sdkerrors.ErrUnauthorized.ABCICode(): + return status.Error(codes.Unauthenticated, resp.Log) + case sdkerrors.ErrKeyNotFound.ABCICode(): + return status.Error(codes.NotFound, resp.Log) + default: + return status.Error(codes.Unknown, resp.Log) + } +} diff --git a/cosmos/query.go b/cosmos/query.go new file mode 100644 index 0000000..448d728 --- /dev/null +++ b/cosmos/query.go @@ -0,0 +1,77 @@ +package cosmos + +import ( + "context" + "fmt" + + cctptypes "github.com/circlefin/noble-cctp/x/cctp/types" + abci "github.com/cometbft/cometbft/abci/types" + rpcclient "github.com/cometbft/cometbft/rpc/client" + coretypes "github.com/cometbft/cometbft/rpc/core/types" + "github.com/strangelove-ventures/noble-cctp-relayer/types" +) + +// func defaultPageRequest() *querytypes.PageRequest { +// return &querytypes.PageRequest{ +// Key: []byte(""), +// Offset: 0, +// Limit: 1000, +// CountTotal: false, +// } +// } + +// QueryABCI performs an ABCI query and returns the appropriate response and error sdk error code. +func (cc *CosmosProvider) QueryABCI(ctx context.Context, req abci.RequestQuery) (abci.ResponseQuery, error) { + opts := rpcclient.ABCIQueryOptions{ + Height: req.Height, + Prove: req.Prove, + } + result, err := cc.RPCClient.ABCIQueryWithOptions(ctx, req.Path, req.Data, opts) + if err != nil { + return abci.ResponseQuery{}, err + } + + if !result.Response.IsOK() { + return abci.ResponseQuery{}, sdkErrorToGRPCError(result.Response) + } + + return result.Response, nil +} + +func (cc *CosmosProvider) QueryUsedNonce(ctx context.Context, sourceDomain types.Domain, nonce uint64) (bool, error) { + qc := cctptypes.NewQueryClient(cc) + + params := &cctptypes.QueryGetUsedNonceRequest{ + SourceDomain: uint32(sourceDomain), + Nonce: nonce, + } + + _, err := qc.UsedNonce(ctx, params) + if err != nil { + if err.Error() == "rpc error: code = NotFound desc = rpc error: code = NotFound desc = not found: key not found" { + return false, nil + } + + return false, err + } + + return true, nil +} + +// QueryLatestHeight queries the latest height from the RPC client +func (cc *CosmosProvider) QueryLatestHeight(ctx context.Context) (int64, error) { + status, err := cc.RPCClient.Status(ctx) + if err != nil { + return 0, err + } + return status.SyncInfo.LatestBlockHeight, nil +} + +// GetBlockAtHeight queries the block at a given height +func (cc *CosmosProvider) GetBlockAtHeight(ctx context.Context, height int64) (*coretypes.ResultBlock, error) { + block, err := cc.RPCClient.Block(ctx, &height) + if err != nil { + return nil, fmt.Errorf("error querying block at height %d: %w", height, err) + } + return block, nil +} diff --git a/cosmos/query_test.go b/cosmos/query_test.go new file mode 100644 index 0000000..0ecd20f --- /dev/null +++ b/cosmos/query_test.go @@ -0,0 +1,22 @@ +package cosmos_test + +import ( + "context" + "testing" + + "github.com/strangelove-ventures/noble-cctp-relayer/cosmos" + "github.com/stretchr/testify/require" +) + +func TestUsedNonce(t *testing.T) { + cc, err := cosmos.NewProvider("https://rpc.noble.strange.love:443") + require.NoError(t, err) + + used, err := cc.QueryUsedNonce(context.TODO(), 0, 15365) + require.NoError(t, err) + require.True(t, used) + + used, err = cc.QueryUsedNonce(context.TODO(), 0, 100) + require.NoError(t, err) + require.False(t, used) +} diff --git a/cmd/ethereum/abi/ERC20.json b/ethereum/abi/ERC20.json similarity index 100% rename from cmd/ethereum/abi/ERC20.json rename to ethereum/abi/ERC20.json diff --git a/cmd/ethereum/abi/MessageTransmitter.json b/ethereum/abi/MessageTransmitter.json similarity index 100% rename from cmd/ethereum/abi/MessageTransmitter.json rename to ethereum/abi/MessageTransmitter.json diff --git a/cmd/ethereum/abi/TokenMessenger.json b/ethereum/abi/TokenMessenger.json similarity index 100% rename from cmd/ethereum/abi/TokenMessenger.json rename to ethereum/abi/TokenMessenger.json diff --git a/cmd/ethereum/abi/TokenMessengerWithMetadata.json b/ethereum/abi/TokenMessengerWithMetadata.json similarity index 100% rename from cmd/ethereum/abi/TokenMessengerWithMetadata.json rename to ethereum/abi/TokenMessengerWithMetadata.json diff --git a/ethereum/broadcast_test.go b/ethereum/broadcast_test.go new file mode 100644 index 0000000..40e1fd7 --- /dev/null +++ b/ethereum/broadcast_test.go @@ -0,0 +1,43 @@ +package ethereum_test + +import ( + "context" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/strangelove-ventures/noble-cctp-relayer/ethereum/contracts" + "github.com/stretchr/testify/require" +) + +func TestEthUsedNonce(t *testing.T) { + sourceDomain := uint32(4) + nonce := uint64(2970) + + key := append( + common.LeftPadBytes((big.NewInt(int64(sourceDomain))).Bytes(), 4), + common.LeftPadBytes((big.NewInt(int64(nonce))).Bytes(), 8)..., + ) + + require.Equal(t, []byte("\x00\x00\x00\x04\x00\x00\x00\x00\x00\x00\x0B\x9A"), key) + + client, err := ethclient.Dial("https://mainnet.infura.io/v3/e44b63a541e94b30a74a97447518c0ec") + require.NoError(t, err) + defer client.Close() + + messageTransmitter, err := contracts.NewMessageTransmitter(common.HexToAddress("0x0a992d191deec32afe36203ad87d7d289a738f81"), client) + require.NoError(t, err) + + co := &bind.CallOpts{ + Pending: true, + Context: context.TODO(), + } + + response, err := messageTransmitter.UsedNonces(co, [32]byte(crypto.Keccak256(key))) + require.NoError(t, err) + + require.Equal(t, big.NewInt(1), response) +} diff --git a/ethereum/chain.go b/ethereum/chain.go new file mode 100644 index 0000000..749006f --- /dev/null +++ b/ethereum/chain.go @@ -0,0 +1,380 @@ +package ethereum + +import ( + "bytes" + "context" + "crypto/ecdsa" + "embed" + "encoding/hex" + "errors" + "fmt" + "math/big" + "os" + "regexp" + "strconv" + "strings" + "sync" + "time" + + "cosmossdk.io/log" + ethereum "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/pascaldekloe/etherstream" + "github.com/strangelove-ventures/noble-cctp-relayer/ethereum/contracts" + "github.com/strangelove-ventures/noble-cctp-relayer/types" +) + +//go:embed abi/MessageTransmitter.json +var content embed.FS + +var _ types.Chain = (*Ethereum)(nil) + +type Ethereum struct { + name string + chainID int64 + domain types.Domain + rpcURL string + wsURL string + messageTransmitterAddress string + startBlock uint64 + lookbackPeriod uint64 + privateKey *ecdsa.PrivateKey + minterAddress string + maxRetries int + retryIntervalSeconds int + + mu sync.Mutex +} + +func NewChain( + name string, + domain types.Domain, + chainID int64, + rpcURL string, + wsURL string, + messageTransmitterAddress string, + startBlock uint64, + lookbackPeriod uint64, + privateKey string, + maxRetries int, + retryIntervalSeconds int, +) (*Ethereum, error) { + privEcdsaKey, ethereumAddress, err := GetEcdsaKeyAddress(privateKey) + if err != nil { + return nil, err + } + return &Ethereum{ + name: name, + chainID: chainID, + rpcURL: rpcURL, + wsURL: wsURL, + messageTransmitterAddress: messageTransmitterAddress, + startBlock: startBlock, + lookbackPeriod: lookbackPeriod, + privateKey: privEcdsaKey, + minterAddress: ethereumAddress, + maxRetries: maxRetries, + retryIntervalSeconds: retryIntervalSeconds, + }, nil +} + +func (e *Ethereum) Name() string { + return e.name +} + +func (e *Ethereum) Domain() types.Domain { + return e.domain +} + +func (e *Ethereum) IsDestinationCaller(destinationCaller []byte) bool { + zeroByteArr := make([]byte, 32) + + decodedMinter, err := hex.DecodeString(strings.ReplaceAll(e.minterAddress, "0x", "")) + if err != nil && bytes.Equal(destinationCaller, zeroByteArr) { + return true + } + + decodedMinterPadded := make([]byte, 32) + copy(decodedMinterPadded[12:], decodedMinter) + + return bytes.Equal(destinationCaller, zeroByteArr) || bytes.Equal(destinationCaller, decodedMinterPadded) +} + +func (e *Ethereum) InitializeBroadcaster( + ctx context.Context, + logger log.Logger, + sequenceMap *types.SequenceMap, +) error { + nextNonce, err := GetEthereumAccountNonce(e.rpcURL, e.minterAddress) + if err != nil { + return fmt.Errorf("unable to retrieve evm account nonce: %w", err) + } + sequenceMap.Put(e.Domain(), uint64(nextNonce)) + + return nil +} + +func (e *Ethereum) StartListener( + ctx context.Context, + logger log.Logger, + processingQueue chan *types.TxState, +) { + logger = logger.With("chain", e.name, "chain_id", e.chainID, "domain", e.domain) + + // set up client + messageTransmitter, err := content.ReadFile("abi/MessageTransmitter.json") + if err != nil { + logger.Error("unable to read MessageTransmitter abi", "err", err) + os.Exit(1) + } + messageTransmitterABI, err := abi.JSON(bytes.NewReader(messageTransmitter)) + if err != nil { + logger.Error("unable to parse MessageTransmitter abi", "err", err) + } + + messageSent := messageTransmitterABI.Events["MessageSent"] + + ethClient, err := ethclient.DialContext(ctx, e.wsURL) + if err != nil { + logger.Error("unable to initialize ethereum client", "err", err) + os.Exit(1) + } + + // defer ethClient.Close() + + messageTransmitterAddress := common.HexToAddress(e.messageTransmitterAddress) + etherReader := etherstream.Reader{Backend: ethClient} + + if e.startBlock == 0 { + header, err := ethClient.HeaderByNumber(ctx, nil) + if err != nil { + logger.Error("unable to retrieve latest eth block header", "err", err) + os.Exit(1) + } + + e.startBlock = header.Number.Uint64() + } + + query := ethereum.FilterQuery{ + Addresses: []common.Address{messageTransmitterAddress}, + Topics: [][]common.Hash{{messageSent.ID}}, + FromBlock: big.NewInt(int64(e.startBlock - e.lookbackPeriod)), + } + + logger.Info(fmt.Sprintf( + "Starting Ethereum listener at block %d looking back %d blocks", + e.startBlock, + e.lookbackPeriod)) + + // websockets do not query history + // https://github.com/ethereum/go-ethereum/issues/15063 + stream, sub, history, err := etherReader.QueryWithHistory(ctx, &query) + if err != nil { + logger.Error("unable to subscribe to logs", "err", err) + os.Exit(1) + } + + // process history + for _, historicalLog := range history { + parsedMsg, err := types.EvmLogToMessageState(messageTransmitterABI, messageSent, &historicalLog) + if err != nil { + logger.Error("Unable to parse history log into MessageState, skipping", "err", err) + continue + } + logger.Info(fmt.Sprintf("New historical msg from source domain %d with tx hash %s", parsedMsg.SourceDomain, parsedMsg.SourceTxHash)) + + processingQueue <- &types.TxState{TxHash: parsedMsg.SourceTxHash, Msgs: []*types.MessageState{parsedMsg}} + + // It might help to wait a small amount of time between sending messages into the processing queue + // so that account sequences / nonces are set correctly + // time.Sleep(10 * time.Millisecond) + } + + // consume stream + go func() { + var txState *types.TxState + for { + select { + case <-ctx.Done(): + ethClient.Close() + return + case err := <-sub.Err(): + logger.Error("connection closed", "err", err) + ethClient.Close() + os.Exit(1) + case streamLog := <-stream: + parsedMsg, err := types.EvmLogToMessageState(messageTransmitterABI, messageSent, &streamLog) + if err != nil { + logger.Error("Unable to parse ws log into MessageState, skipping") + continue + } + logger.Info(fmt.Sprintf("New stream msg from %d with tx hash %s", parsedMsg.SourceDomain, parsedMsg.SourceTxHash)) + if txState == nil { + txState = &types.TxState{TxHash: parsedMsg.SourceTxHash, Msgs: []*types.MessageState{parsedMsg}} + } else if parsedMsg.SourceTxHash != txState.TxHash { + processingQueue <- txState + txState = &types.TxState{TxHash: parsedMsg.SourceTxHash, Msgs: []*types.MessageState{parsedMsg}} + } else { + txState.Msgs = append(txState.Msgs, parsedMsg) + + } + default: + if txState != nil { + processingQueue <- txState + txState = nil + } + } + } + }() +} + +func (e *Ethereum) Broadcast( + ctx context.Context, + logger log.Logger, + msgs []*types.MessageState, + sequenceMap *types.SequenceMap, +) error { + + // set up eth client + client, err := ethclient.Dial(e.rpcURL) + if err != nil { + return fmt.Errorf("unable to dial ethereum client: %w", err) + } + defer client.Close() + + backend := NewContractBackendWrapper(client) + + auth, err := bind.NewKeyedTransactorWithChainID(e.privateKey, big.NewInt(e.chainID)) + if err != nil { + return fmt.Errorf("unable to create auth: %w", err) + } + + messageTransmitter, err := contracts.NewMessageTransmitter(common.HexToAddress(e.messageTransmitterAddress), backend) + if err != nil { + return fmt.Errorf("unable to create message transmitter: %w", err) + } + + var broadcastErrors error +MsgLoop: + for _, msg := range msgs { + + if msg.Status == types.Complete { + continue MsgLoop + } + + attestationBytes, err := hex.DecodeString(msg.Attestation[2:]) + if err != nil { + return errors.New("unable to decode message attestation") + } + + for attempt := 0; attempt <= e.maxRetries; attempt++ { + logger.Info(fmt.Sprintf( + "Broadcasting message from %d to %d: with source tx hash %s", + msg.SourceDomain, + msg.DestDomain, + msg.SourceTxHash)) + + nonce := sequenceMap.Next(e.domain) + auth.Nonce = big.NewInt(int64(nonce)) + + e.mu.Lock() + + // TODO remove + nextNonce, err := GetEthereumAccountNonce(e.rpcURL, e.minterAddress) + if err != nil { + logger.Error("unable to retrieve account number") + } else { + auth.Nonce = big.NewInt(nextNonce) + } + // TODO end remove + + // check if nonce already used + co := &bind.CallOpts{ + Pending: true, + Context: ctx, + } + + logger.Debug("Checking if nonce was used for broadcast to Ethereum", "source_domain", msg.SourceDomain, "nonce", msg.Nonce) + + key := append( + common.LeftPadBytes((big.NewInt(int64(msg.SourceDomain))).Bytes(), 4), + common.LeftPadBytes((big.NewInt(int64(msg.Nonce))).Bytes(), 8)..., + ) + + response, nonceErr := messageTransmitter.UsedNonces(co, [32]byte(crypto.Keccak256(key))) + if nonceErr != nil { + logger.Debug("Error querying whether nonce was used. Continuing...") + } else { + fmt.Printf("received used nonce response: %d\n", response) + if response.Uint64() == uint64(1) { + // nonce has already been used, mark as complete + logger.Debug(fmt.Sprintf("This source domain/nonce has already been used: %d %d", + msg.SourceDomain, msg.Nonce)) + msg.Status = types.Complete + e.mu.Unlock() + continue MsgLoop + } + } + + // broadcast txn + tx, err := messageTransmitter.ReceiveMessage( + auth, + msg.MsgSentBytes, + attestationBytes, + ) + if err == nil { + msg.Status = types.Complete + + fullLog, err := tx.MarshalJSON() + if err != nil { + logger.Error("error marshalling eth tx log", err) + } + + msg.DestTxHash = tx.Hash().Hex() + + logger.Info(fmt.Sprintf("Successfully broadcast %s to Ethereum. Tx hash: %s, FULL LOG: %s", msg.SourceTxHash, msg.DestTxHash, string(fullLog))) + e.mu.Unlock() + continue MsgLoop + } + + logger.Error(fmt.Sprintf("error during broadcast: %s", err.Error())) + if parsedErr, ok := err.(JsonError); ok { + if parsedErr.ErrorCode() == 3 && parsedErr.Error() == "execution reverted: Nonce already used" { + msg.Status = types.Complete + logger.Error(fmt.Sprintf("This account nonce has already been used: %d", nonce)) + e.mu.Unlock() + continue MsgLoop + } + + match, _ := regexp.MatchString("nonce too low: next nonce [0-9]+, tx nonce [0-9]+", parsedErr.Error()) + if match { + numberRegex := regexp.MustCompile("[0-9]+") + nextNonce, err := strconv.ParseInt(numberRegex.FindAllString(parsedErr.Error(), 1)[0], 10, 0) + if err != nil { + nextNonce, err = GetEthereumAccountNonce(e.rpcURL, e.minterAddress) + if err != nil { + logger.Error("unable to retrieve account number") + } + } + sequenceMap.Put(e.domain, uint64(nextNonce)) + } + } + e.mu.Unlock() + + // if it's not the last attempt, retry + // TODO increase the destination.ethereum.broadcast retries (3-5) and retry interval (15s). By checking for used nonces, there is no gas cost for failed mints. + if attempt != e.maxRetries { + logger.Info(fmt.Sprintf("Retrying in %d seconds", e.retryIntervalSeconds)) + time.Sleep(time.Duration(e.retryIntervalSeconds) * time.Second) + } + } + // retried max times with failure + msg.Status = types.Failed + broadcastErrors = errors.Join(broadcastErrors, errors.New("reached max number of broadcast attempts")) + } + return broadcastErrors +} diff --git a/ethereum/config.go b/ethereum/config.go new file mode 100644 index 0000000..74ae6fe --- /dev/null +++ b/ethereum/config.go @@ -0,0 +1,40 @@ +package ethereum + +import ( + "github.com/strangelove-ventures/noble-cctp-relayer/types" +) + +var _ types.ChainConfig = (*ChainConfig)(nil) + +type ChainConfig struct { + RPC string `yaml:"rpc"` + WS string `yaml:"ws"` + domain types.Domain + ChainID int64 `yaml:"chain-id"` + MessageTransmitter string `yaml:"message-transmitter"` + + StartBlock uint64 `yaml:"start-block"` + LookbackPeriod uint64 `yaml:"lookback-period"` + + BroadcastRetries int `yaml:"broadcast-retries"` + BroadcastRetryInterval int `yaml:"broadcast-retry-interval"` + + // TODO move to keyring + MinterPrivateKey string `yaml:"minter-private-key"` +} + +func (c *ChainConfig) Chain(name string) (types.Chain, error) { + return NewChain( + name, + c.domain, + c.ChainID, + c.RPC, + c.WS, + c.MessageTransmitter, + c.StartBlock, + c.LookbackPeriod, + c.MinterPrivateKey, + c.BroadcastRetries, + c.BroadcastRetryInterval, + ) +} diff --git a/ethereum/contract_backend_wrapper.go b/ethereum/contract_backend_wrapper.go new file mode 100644 index 0000000..53f7908 --- /dev/null +++ b/ethereum/contract_backend_wrapper.go @@ -0,0 +1,28 @@ +package ethereum + +import ( + "context" + "fmt" + + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" +) + +type ContractBackendWrapper struct { + *ethclient.Client +} + +func NewContractBackendWrapper(client *ethclient.Client) *ContractBackendWrapper { + return &ContractBackendWrapper{ + Client: client, + } +} + +func (c *ContractBackendWrapper) SendTransaction(ctx context.Context, tx *types.Transaction) error { + json, err := tx.MarshalJSON() + if err != nil { + return err + } + fmt.Printf("SendTransaction: %+v\n\nRAW: %s\n", tx, json) + return c.Client.SendTransaction(ctx, tx) +} diff --git a/cmd/ethereum/MessageTransmitter.go b/ethereum/contracts/MessageTransmitter.go similarity index 99% rename from cmd/ethereum/MessageTransmitter.go rename to ethereum/contracts/MessageTransmitter.go index 6f448db..7a49132 100644 --- a/cmd/ethereum/MessageTransmitter.go +++ b/ethereum/contracts/MessageTransmitter.go @@ -1,7 +1,7 @@ // Code generated - DO NOT EDIT. // This file is a generated binding and any manual changes will be lost. -package ethereum +package contracts import ( "errors" diff --git a/cmd/TokenMessenger.go b/ethereum/contracts/TokenMessenger.go similarity index 99% rename from cmd/TokenMessenger.go rename to ethereum/contracts/TokenMessenger.go index a168c20..92aede5 100644 --- a/cmd/TokenMessenger.go +++ b/ethereum/contracts/TokenMessenger.go @@ -1,7 +1,7 @@ // Code generated - DO NOT EDIT. // This file is a generated binding and any manual changes will be lost. -package cmd +package contracts import ( "errors" diff --git a/cmd/TokenMessengerWithMetadata.go b/ethereum/contracts/TokenMessengerWithMetadata.go similarity index 99% rename from cmd/TokenMessengerWithMetadata.go rename to ethereum/contracts/TokenMessengerWithMetadata.go index 86bfa73..aaa66ab 100644 --- a/cmd/TokenMessengerWithMetadata.go +++ b/ethereum/contracts/TokenMessengerWithMetadata.go @@ -1,7 +1,7 @@ // Code generated - DO NOT EDIT. // This file is a generated binding and any manual changes will be lost. -package cmd +package contracts import ( "errors" diff --git a/ethereum/listener_test.go b/ethereum/listener_test.go new file mode 100644 index 0000000..32b076a --- /dev/null +++ b/ethereum/listener_test.go @@ -0,0 +1,63 @@ +package ethereum_test + +import ( + "context" + "os" + "testing" + "time" + + "cosmossdk.io/log" + "github.com/rs/zerolog" + "github.com/strangelove-ventures/noble-cctp-relayer/cmd" + "github.com/strangelove-ventures/noble-cctp-relayer/ethereum" + "github.com/strangelove-ventures/noble-cctp-relayer/types" + "github.com/stretchr/testify/require" +) + +var cfg *types.Config +var logger log.Logger +var processingQueue chan *types.TxState + +func init() { + var err error + cfg, err = cmd.Parse("../.ignore/unit_tests.yaml") + if err != nil { + panic(err) + } + + logger = log.NewLogger(os.Stdout, log.LevelOption(zerolog.ErrorLevel)) + processingQueue = make(chan *types.TxState, 10000) +} + +// tests for a historical log +func TestStartListener(t *testing.T) { + ethCfg := ethereum.ChainConfig{ + StartBlock: 9702735, + LookbackPeriod: 0, + } + eth, err := ethCfg.Chain("ethereum") + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go eth.StartListener(ctx, logger, processingQueue) + + time.Sleep(5 * time.Second) + + tx := <-processingQueue + + expectedMsg := &types.MessageState{ + IrisLookupId: "a404f4155166a1fc7ffee145b5cac6d0f798333745289ab1db171344e226ef0c", + Status: "created", + SourceDomain: 0, + DestDomain: 4, + SourceTxHash: "0xe1d7729de300274ee3a2fd20ba179b14a8e3ffcd9d847c506b06760f0dad7802", + } + require.Equal(t, expectedMsg.IrisLookupId, tx.Msgs[0].IrisLookupId) + require.Equal(t, expectedMsg.Status, tx.Msgs[0].Status) + require.Equal(t, expectedMsg.SourceDomain, tx.Msgs[0].SourceDomain) + require.Equal(t, expectedMsg.DestDomain, tx.Msgs[0].DestDomain) + require.Equal(t, expectedMsg.SourceTxHash, tx.Msgs[0].SourceTxHash) + +} diff --git a/cmd/ethereum/util.go b/ethereum/util.go similarity index 100% rename from cmd/ethereum/util.go rename to ethereum/util.go diff --git a/cmd/ethereum/util_test.go b/ethereum/util_test.go similarity index 50% rename from cmd/ethereum/util_test.go rename to ethereum/util_test.go index b39cb2d..e4e8f83 100644 --- a/cmd/ethereum/util_test.go +++ b/ethereum/util_test.go @@ -1,31 +1,36 @@ package ethereum_test import ( + "os" + "testing" + "cosmossdk.io/log" "github.com/rs/zerolog" - "github.com/strangelove-ventures/noble-cctp-relayer/cmd/ethereum" - "github.com/strangelove-ventures/noble-cctp-relayer/config" + "github.com/strangelove-ventures/noble-cctp-relayer/cmd" + "github.com/strangelove-ventures/noble-cctp-relayer/ethereum" "github.com/strangelove-ventures/noble-cctp-relayer/types" "github.com/stretchr/testify/require" - "os" - "testing" ) func init() { - cfg = config.Parse("../../.ignore/unit_tests.yaml") + var err error + cfg, err = cmd.Parse("../.ignore/unit_tests.yaml") + if err != nil { + panic(err) + } logger = log.NewLogger(os.Stdout, log.LevelOption(zerolog.ErrorLevel)) - processingQueue = make(chan *types.MessageState, 10000) + processingQueue = make(chan *types.TxState, 10000) } func TestGetEthereumAccountNonce(t *testing.T) { - _, err := ethereum.GetEthereumAccountNonce(cfg.Networks.Destination.Ethereum.RPC, "0x4996f29b254c77972fff8f25e6f7797b3c9a0eb6") + _, err := ethereum.GetEthereumAccountNonce(cfg.Chains["ethereum"].(*ethereum.ChainConfig).RPC, "0x4996f29b254c77972fff8f25e6f7797b3c9a0eb6") require.Nil(t, err) } // Return public ecdsa key and address given the private key func TestGetEcdsaKeyAddress(t *testing.T) { - key, addr, err := ethereum.GetEcdsaKeyAddress(cfg.Networks.Minters[0].MinterPrivateKey) + key, addr, err := ethereum.GetEcdsaKeyAddress(cfg.Chains["ethereum"].(*ethereum.ChainConfig).MinterPrivateKey) require.NotNil(t, key) require.NotNil(t, addr) require.Nil(t, err) diff --git a/go.mod b/go.mod index 8036948..9219d50 100644 --- a/go.mod +++ b/go.mod @@ -21,8 +21,11 @@ require ( cosmossdk.io/math v1.1.2 github.com/circlefin/noble-cctp v0.0.0-20230911222715-829029fbba29 github.com/cometbft/cometbft v0.38.0 + github.com/cosmos/gogoproto v1.4.10 github.com/gin-gonic/gin v1.8.1 github.com/pascaldekloe/etherstream v0.1.0 + google.golang.org/grpc v1.57.0 + gopkg.in/yaml.v2 v2.4.0 ) require ( @@ -31,6 +34,7 @@ require ( cosmossdk.io/api v0.3.1 // indirect cosmossdk.io/core v0.5.1 // indirect cosmossdk.io/depinject v1.0.0-alpha.4 // indirect + cosmossdk.io/errors v1.0.0-beta.7 // indirect filippo.io/edwards25519 v1.0.0 // indirect github.com/4meepo/tagalign v1.3.2 // indirect github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect @@ -77,7 +81,6 @@ require ( github.com/cosmos/cosmos-db v0.0.0-20221226095112-f3c38ecb5e32 // indirect github.com/cosmos/cosmos-proto v1.0.0-beta.2 // indirect github.com/cosmos/go-bip39 v1.0.0 // indirect - github.com/cosmos/gogoproto v1.4.10 // indirect github.com/cosmos/gorocksdb v1.2.0 // indirect github.com/cosmos/iavl v0.19.5 // indirect github.com/cosmos/ledger-cosmos-go v0.12.2 // indirect @@ -290,11 +293,9 @@ require ( google.golang.org/genproto v0.0.0-20230803162519-f966b187b2e5 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20230726155614-23370e0ffb3e // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20230815205213-6bfd019c3878 // indirect - google.golang.org/grpc v1.57.0 // indirect google.golang.org/protobuf v1.31.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect honnef.co/go/tools v0.4.5 // indirect mvdan.cc/interfacer v0.0.0-20180901003855-c20040233aed // indirect mvdan.cc/lint v0.0.0-20170908181259-adc824a0674b // indirect diff --git a/go.sum b/go.sum index d686b6f..15389ef 100644 --- a/go.sum +++ b/go.sum @@ -45,6 +45,8 @@ cosmossdk.io/core v0.5.1 h1:vQVtFrIYOQJDV3f7rw4pjjVqc1id4+mE0L9hHP66pyI= cosmossdk.io/core v0.5.1/go.mod h1:KZtwHCLjcFuo0nmDc24Xy6CRNEL9Vl/MeimQ2aC7NLE= cosmossdk.io/depinject v1.0.0-alpha.4 h1:PLNp8ZYAMPTUKyG9IK2hsbciDWqna2z1Wsl98okJopc= cosmossdk.io/depinject v1.0.0-alpha.4/go.mod h1:HeDk7IkR5ckZ3lMGs/o91AVUc7E596vMaOmslGFM3yU= +cosmossdk.io/errors v1.0.0-beta.7 h1:gypHW76pTQGVnHKo6QBkb4yFOJjC+sUGRc5Al3Odj1w= +cosmossdk.io/errors v1.0.0-beta.7/go.mod h1:mz6FQMJRku4bY7aqS/Gwfcmr/ue91roMEKAmDUDpBfE= cosmossdk.io/log v1.2.1 h1:Xc1GgTCicniwmMiKwDxUjO4eLhPxoVdI9vtMW8Ti/uk= cosmossdk.io/log v1.2.1/go.mod h1:GNSCc/6+DhFIj1aLn/j7Id7PaO8DzNylUZoOYBL9+I4= cosmossdk.io/math v1.1.2 h1:ORZetZCTyWkI5GlZ6CZS28fMHi83ZYf+A2vVnHNzZBM= @@ -212,6 +214,8 @@ github.com/cosmos/gorocksdb v1.2.0 h1:d0l3jJG8M4hBouIZq0mDUHZ+zjOx044J3nGRskwTb4 github.com/cosmos/gorocksdb v1.2.0/go.mod h1:aaKvKItm514hKfNJpUJXnnOWeBnk2GL4+Qw9NHizILw= github.com/cosmos/iavl v0.19.5 h1:rGA3hOrgNxgRM5wYcSCxgQBap7fW82WZgY78V9po/iY= github.com/cosmos/iavl v0.19.5/go.mod h1:X9PKD3J0iFxdmgNLa7b2LYWdsGd90ToV5cAONApkEPw= +github.com/cosmos/ibc-go/v3 v3.4.0 h1:ha3cqEG36pqMWqA1D+kxDWBTZXpeFMd/aZIQF7I0xro= +github.com/cosmos/ibc-go/v3 v3.4.0/go.mod h1:VwB/vWu4ysT5DN2aF78d17LYmx3omSAdq6gpKvM7XRA= github.com/cosmos/ledger-cosmos-go v0.12.2 h1:/XYaBlE2BJxtvpkHiBm97gFGSGmYGKunKyF3nNqAXZA= github.com/cosmos/ledger-cosmos-go v0.12.2/go.mod h1:ZcqYgnfNJ6lAXe4HPtWgarNEY+B74i+2/8MhZw4ziiI= github.com/cpuguy83/go-md2man v1.0.10 h1:BSKMNlYxDvnunlTymqtgONjNnaRV1sTpcovwwjF22jk= @@ -548,6 +552,8 @@ github.com/holiman/uint256 v1.2.3/go.mod h1:SC8Ryt4n+UBbPbIBKaG9zbbDlp4jOru9xFZm github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/huin/goupnp v1.0.3 h1:N8No57ls+MnjlB+JPiCVSOyy/ot7MJTqlo7rn+NYSqQ= github.com/huin/goupnp v1.0.3/go.mod h1:ZxNlw5WqJj6wSsRK5+YfflQGXYfccj5VgQsMNixHM7Y= +github.com/iancoleman/orderedmap v0.2.0 h1:sq1N/TFpYH++aViPcaKjys3bDClUEU7s5B+z6jq8pNA= +github.com/iancoleman/orderedmap v0.2.0/go.mod h1:N0Wam8K1arqPXNWjMo21EXnBPOPp36vB07FNRdD2geA= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/improbable-eng/grpc-web v0.14.1 h1:NrN4PY71A6tAz2sKDvC5JCauENWp0ykG8Oq1H3cpFvw= @@ -867,6 +873,10 @@ github.com/stbenjam/no-sprintf-host-port v0.1.1 h1:tYugd/yrm1O0dV+ThCbaKZh195Dfm github.com/stbenjam/no-sprintf-host-port v0.1.1/go.mod h1:TLhvtIvONRzdmkFiio4O8LHsN9N74I+PhRquPsxpL0I= github.com/strangelove-ventures/noble v1.0.1-0.20230526193404-1e5ed6be44fd h1:AIhElW32Jfq7u/T50uPZIquBOvct7lGMFEmGrYnkxsE= github.com/strangelove-ventures/noble v1.0.1-0.20230526193404-1e5ed6be44fd/go.mod h1:dqmrjSJ7zEPfk9ssbnsvXVnYiuvmGZmaqAtkhEbKrKs= +github.com/strangelove-ventures/packet-forward-middleware/v3 v3.1.5 h1:iXXjziCSAebzuRUPFSnqD7epSDB8LEPgkh9zhbj7ha4= +github.com/strangelove-ventures/packet-forward-middleware/v3 v3.1.5/go.mod h1:ncgsf5rykh36HkM16BNcKKx1XzVRdWXt+4pph1syDHE= +github.com/strangelove-ventures/paramauthority v0.2.0 h1:h/ApdnvwV0gAjgQAFJ0Z2U6xuARvBnpmzhkvJRdkJZU= +github.com/strangelove-ventures/paramauthority v0.2.0/go.mod h1:31HVpoItQMa4Wj2BimVhQWbIYeb+kdUDJ8MzBEbGj28= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= diff --git a/integration/config.go b/integration/config.go index 8ddec3d..ee30584 100644 --- a/integration/config.go +++ b/integration/config.go @@ -1,53 +1,68 @@ package integration_testing import ( + "os" + "cosmossdk.io/log" "github.com/rs/zerolog" - "github.com/strangelove-ventures/noble-cctp-relayer/cmd/noble" - "github.com/strangelove-ventures/noble-cctp-relayer/config" + "github.com/strangelove-ventures/noble-cctp-relayer/cmd" + "github.com/strangelove-ventures/noble-cctp-relayer/ethereum" + "github.com/strangelove-ventures/noble-cctp-relayer/noble" "github.com/strangelove-ventures/noble-cctp-relayer/types" - "os" - "gopkg.in/yaml.v3" ) -var testCfg Config // for testing secrets -var cfg config.Config // app config +var cfg *types.Config // app config +var integrationWallets *IntegrationWallets // for testing secrets + +var nobleCfg *noble.ChainConfig +var ethCfg *ethereum.ChainConfig + var logger log.Logger +var err error -// goerli -const TokenMessengerAddress = "0xd0c3da58f55358142b8d3e06c1c30c5c6114efe8" -const TokenMessengerWithMetadataAddress = "0x1ae045d99236365cbdc1855acd2d2cfc232d04d1" -const UsdcAddress = "0x07865c6e87b9f70255377e024ace6630c1eaa37f" +var nobleChain types.Chain +var ethChain types.Chain + +// Sepolia +const TokenMessengerAddress = "0x9f3B8679c73C2Fef8b59B4f3444d4e156fb70AA5" +const UsdcAddress = "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238" var sequenceMap *types.SequenceMap -func setupTest() func() { - // setup - testCfg = Parse("../.ignore/integration.yaml") - cfg = config.Parse("../.ignore/testnet.yaml") +func setupTestIntegration() func() { logger = log.NewLogger(os.Stdout, log.LevelOption(zerolog.DebugLevel)) - _, nextMinterSequence, err := noble.GetNobleAccountNumberSequence( - cfg.Networks.Destination.Noble.API, - cfg.Networks.Minters[4].MinterAddress) + // cctp relayer app config setup for sepolia netowrk + cfg, err = cmd.Parse("../.ignore/testnet.yaml") + if err != nil { + logger.Error("Error parsing relayer config") + os.Exit(1) + } + // extra wallets to keep relayer wallet separate from test transaction + // see config/sample-integration-config.yaml + err = ParseIntegration("../.ignore/integration.yaml") if err != nil { - logger.Error("Error retrieving account sequence") + logger.Error("Error parsing integration wallets") os.Exit(1) } + + nobleCfg = cfg.Chains["noble"].(*noble.ChainConfig) + ethCfg = cfg.Chains["ethereum"].(*ethereum.ChainConfig) + sequenceMap = types.NewSequenceMap() - sequenceMap.Put(uint32(4), nextMinterSequence) - - for i, minter := range cfg.Networks.Minters { - switch i { - case 0: - minter.MinterAddress = "0x971c54a6Eb782fAccD00bc3Ed5E934Cc5bD8e3Ef" - cfg.Networks.Minters[0] = minter - case 4: - minter.MinterAddress = "noble1ar2gaqww6aphxd9qve5qglj8kqq96je6a4yrhj" - cfg.Networks.Minters[4] = minter - } + + nobleChain, err = nobleCfg.Chain("noble") + if err != nil { + logger.Error("Error creating new chain", "err", err) + os.Exit(1) + } + + ethChain, err = ethCfg.Chain("eth") + if err != nil { + logger.Error("Error creating new chain", "err", err) + os.Exit(1) } return func() { @@ -55,22 +70,29 @@ func setupTest() func() { } } -type Config struct { +// Wallets used for integration testing +type IntegrationWallets struct { Networks struct { Ethereum struct { - RPC string `yaml:"rpc"` + Address string `yaml:"address"` PrivateKey string `yaml:"private_key"` } `yaml:"ethereum"` Noble struct { - RPC string `yaml:"rpc"` + Address string `yaml:"address"` PrivateKey string `yaml:"private_key"` } `yaml:"noble"` } `yaml:"networks"` } -func Parse(file string) (cfg Config) { - data, _ := os.ReadFile(file) - _ = yaml.Unmarshal(data, &cfg) +func ParseIntegration(file string) (err error) { + data, err := os.ReadFile(file) + if err != nil { + return err + } + err = yaml.Unmarshal(data, &integrationWallets) + if err != nil { + return err + } - return + return nil } diff --git a/integration/eth_burn_to_noble_mint_and_forward_test.go b/integration/eth_burn_to_noble_mint_and_forward_test.go deleted file mode 100644 index 93b4729..0000000 --- a/integration/eth_burn_to_noble_mint_and_forward_test.go +++ /dev/null @@ -1,99 +0,0 @@ -package integration_testing - -import ( - "fmt" - "github.com/cosmos/cosmos-sdk/testutil/testdata" - "github.com/cosmos/cosmos-sdk/types/bech32" - "github.com/ethereum/go-ethereum/accounts/abi/bind" - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/crypto" - "github.com/ethereum/go-ethereum/ethclient" - "github.com/strangelove-ventures/noble-cctp-relayer/cmd" - eth "github.com/strangelove-ventures/noble-cctp-relayer/cmd/ethereum" - "github.com/strangelove-ventures/noble-cctp-relayer/types" - "github.com/stretchr/testify/require" - "math/big" - "testing" - "time" -) - -// TestEthBurnToNobleMintAndForward generates a depositForBurn on Ethereum Goerli and mints + forwards on Noble -func TestEthBurnToNobleMintAndForward(t *testing.T) { - setupTest() - - // start up relayer - cfg.Networks.Source.Ethereum.StartBlock = getEthereumLatestBlockHeight(t) - cfg.Networks.Source.Ethereum.LookbackPeriod = 0 - - fmt.Println("Starting relayer...") - processingQueue := make(chan *types.MessageState, 10) - go eth.StartListener(cfg, logger, processingQueue) - go cmd.StartProcessor(cfg, logger, processingQueue, sequenceMap) - - fmt.Println("Building Ethereum depositForBurnWithMetadata txn...") - _, _, cosmosAddress := testdata.KeyTestPubAddr() - nobleAddress, _ := bech32.ConvertAndEncode("noble", cosmosAddress) - fmt.Println("Intermediately minting on Noble to " + nobleAddress) - - _, _, cosmosAddress2 := testdata.KeyTestPubAddr() - dydxAddress, _ := bech32.ConvertAndEncode("dydx", cosmosAddress2) - fmt.Println("Forwarding funds to " + dydxAddress) - - // verify dydx usdc amount - originalDydx := getDydxBalance(dydxAddress) - - // deposit for burn with metadata - client, err := ethclient.Dial(testCfg.Networks.Ethereum.RPC) - require.Nil(t, err) - defer client.Close() - - privateKey, err := crypto.HexToECDSA(testCfg.Networks.Ethereum.PrivateKey) - require.Nil(t, err) - auth, err := bind.NewKeyedTransactorWithChainID(privateKey, big.NewInt(5)) - require.Nil(t, err) - - tokenMessengerWithMetadata, err := cmd.NewTokenMessengerWithMetadata(common.HexToAddress(TokenMessengerWithMetadataAddress), client) - require.Nil(t, err) - - mintRecipientPadded := append([]byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, cosmosAddress...) - require.Nil(t, err) - - erc20, err := NewERC20(common.HexToAddress(UsdcAddress), client) - _, err = erc20.Approve(auth, common.HexToAddress(TokenMessengerWithMetadataAddress), big.NewInt(99999)) - require.Nil(t, err) - - channel := uint64(20) - destinationBech32Prefix := - append([]byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, []byte("dydx")...) - destinationRecipient := append([]byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, cosmosAddress2...) - - var BurnAmount = big.NewInt(1) - - tx, err := tokenMessengerWithMetadata.DepositForBurn( - auth, - channel, // channel - [32]byte(destinationBech32Prefix), // destinationBech32Prefix - [32]byte(destinationRecipient), // destinationRecipient - BurnAmount, // amount - [32]byte(mintRecipientPadded), // mint recipient - common.HexToAddress(UsdcAddress), // burn token - []byte{}, // memo - ) - if err != nil { - logger.Error("Failed to update value: %v", err) - } - - time.Sleep(5 * time.Second) - fmt.Printf("Update pending: https://goerli.etherscan.io/tx/%s\n", tx.Hash().String()) - - fmt.Println("Checking dydx wallet...") - for i := 0; i < 250; i++ { - if originalDydx+BurnAmount.Uint64() == getDydxBalance(dydxAddress) { - fmt.Println("Successfully minted at https://testnet.mintscan.io/dydx-testnet/account/" + dydxAddress) - return - } - time.Sleep(1 * time.Second) - } - // verify dydx balance - require.Equal(t, originalDydx+BurnAmount.Uint64(), getDydxBalance(dydxAddress)) -} diff --git a/integration/eth_burn_to_noble_mint_test.go b/integration/eth_burn_to_noble_mint_test.go index 72ee943..5c52194 100644 --- a/integration/eth_burn_to_noble_mint_test.go +++ b/integration/eth_burn_to_noble_mint_test.go @@ -1,7 +1,12 @@ package integration_testing import ( + "context" "fmt" + "math/big" + "testing" + "time" + "github.com/cosmos/cosmos-sdk/testutil/testdata" "github.com/cosmos/cosmos-sdk/types/bech32" "github.com/ethereum/go-ethereum/accounts/abi/bind" @@ -9,53 +14,66 @@ import ( "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/ethclient" "github.com/strangelove-ventures/noble-cctp-relayer/cmd" - eth "github.com/strangelove-ventures/noble-cctp-relayer/cmd/ethereum" + "github.com/strangelove-ventures/noble-cctp-relayer/cosmos" + "github.com/strangelove-ventures/noble-cctp-relayer/ethereum/contracts" "github.com/strangelove-ventures/noble-cctp-relayer/types" "github.com/stretchr/testify/require" - "math/big" - "testing" - "time" ) +const uusdcDenom = "uusdc" + // TestEthBurnToNobleMint generates a depositForBurn on Ethereum Goerli and mints on Noble func TestEthBurnToNobleMint(t *testing.T) { - setupTest() - - // start up relayer - cfg.Networks.Source.Ethereum.StartBlock = getEthereumLatestBlockHeight(t) - cfg.Networks.Source.Ethereum.LookbackPeriod = 0 + ctx := context.Background() + setupTestIntegration() fmt.Println("Starting relayer...") - processingQueue := make(chan *types.MessageState, 10) - go eth.StartListener(cfg, logger, processingQueue) - go cmd.StartProcessor(cfg, logger, processingQueue, sequenceMap) + processingQueue := make(chan *types.TxState, 10) + + registeredDomains := make(map[types.Domain]types.Chain) + registeredDomains[0] = ethChain + registeredDomains[4] = nobleChain + + nobleChain.InitializeBroadcaster(ctx, logger, sequenceMap) + + go ethChain.StartListener(ctx, logger, processingQueue) + go cmd.StartProcessor(ctx, cfg, logger, registeredDomains, processingQueue, sequenceMap) - fmt.Println("Building Ethereum depositForBurnWithMetadata txn...") + fmt.Println("Building Ethereum depositForBurn txn...") _, _, cosmosAddress := testdata.KeyTestPubAddr() nobleAddress, _ := bech32.ConvertAndEncode("noble", cosmosAddress) fmt.Println("Minting on Noble to https://testnet.mintscan.io/noble-testnet/account/" + nobleAddress) // verify noble usdc amount - originalNobleBalance := getNobleBalance(nobleAddress) + cc, err := cosmos.NewProvider(nobleCfg.RPC) + require.Nil(t, err) + // originalNobleBalance := getNobleBalance(nobleAddress) + originalNobleBalance, err := getNobleAccountBalance(ctx, cc, nobleAddress, uusdcDenom) + require.NoError(t, err) // deposit for burn with metadata - client, err := ethclient.Dial(testCfg.Networks.Ethereum.RPC) + client, err := ethclient.Dial(ethCfg.RPC) require.Nil(t, err) defer client.Close() - privateKey, err := crypto.HexToECDSA(testCfg.Networks.Ethereum.PrivateKey) + privateKey, err := crypto.HexToECDSA(integrationWallets.Networks.Ethereum.PrivateKey) + require.Nil(t, err) - auth, err := bind.NewKeyedTransactorWithChainID(privateKey, big.NewInt(5)) + + sepoliaChainID := big.NewInt(ethCfg.ChainID) + auth, err := bind.NewKeyedTransactorWithChainID(privateKey, sepoliaChainID) require.Nil(t, err) - tokenMessenger, err := cmd.NewTokenMessenger(common.HexToAddress(TokenMessengerAddress), client) + tokenMessenger, err := contracts.NewTokenMessenger(common.HexToAddress(TokenMessengerAddress), client) require.Nil(t, err) mintRecipientPadded := append([]byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, cosmosAddress...) require.Nil(t, err) erc20, err := NewERC20(common.HexToAddress(UsdcAddress), client) - _, err = erc20.Approve(auth, common.HexToAddress(TokenMessengerWithMetadataAddress), big.NewInt(99999)) + require.NoError(t, err) + + _, err = erc20.Approve(auth, common.HexToAddress(TokenMessengerAddress), big.NewInt(99999)) require.Nil(t, err) var burnAmount = big.NewInt(1) @@ -68,20 +86,23 @@ func TestEthBurnToNobleMint(t *testing.T) { common.HexToAddress(UsdcAddress), ) if err != nil { - logger.Error("Failed to update value: %v", err) + logger.Error("Failed to update value", "err", err) } time.Sleep(5 * time.Second) - fmt.Printf("Update pending: https://goerli.etherscan.io/tx/%s\n", tx.Hash().String()) + fmt.Printf("Update pending: https://sepolia.etherscan.io/tx/%s\n", tx.Hash().String()) + var newBalance uint64 fmt.Println("Checking noble wallet...") for i := 0; i < 250; i++ { - if originalNobleBalance+burnAmount.Uint64() == getNobleBalance(nobleAddress) { + newBalance, err = getNobleAccountBalance(ctx, cc, nobleAddress, uusdcDenom) + require.NoError(t, err) + if originalNobleBalance+burnAmount.Uint64() == newBalance { fmt.Println("Successfully minted at https://testnet.mintscan.io/noble-testnet/account/" + nobleAddress) return } time.Sleep(1 * time.Second) } // verify noble balance - require.Equal(t, originalNobleBalance+burnAmount.Uint64(), getNobleBalance(nobleAddress)) + require.Equal(t, originalNobleBalance+burnAmount.Uint64(), newBalance) } diff --git a/integration/eth_multi_send_test.go b/integration/eth_multi_send_test.go.bak similarity index 98% rename from integration/eth_multi_send_test.go rename to integration/eth_multi_send_test.go.bak index 6049281..9e36746 100644 --- a/integration/eth_multi_send_test.go +++ b/integration/eth_multi_send_test.go.bak @@ -2,10 +2,13 @@ package integration_testing import ( "context" - "cosmossdk.io/math" "crypto/ecdsa" "encoding/hex" "fmt" + "testing" + "time" + + "cosmossdk.io/math" nobletypes "github.com/circlefin/noble-cctp/x/cctp/types" sdkClient "github.com/cosmos/cosmos-sdk/client" clientTx "github.com/cosmos/cosmos-sdk/client/tx" @@ -24,8 +27,6 @@ import ( "github.com/strangelove-ventures/noble-cctp-relayer/cmd/noble" "github.com/strangelove-ventures/noble-cctp-relayer/types" "github.com/stretchr/testify/require" - "testing" - "time" ) // TestEthereumMultiSend broadcasts N depositForBurnWithCaller messages on Noble, and then tries to receive them all at once on Ethereum. @@ -175,8 +176,10 @@ func TestEthereumMultiSend(t *testing.T) { processingQueue := make(chan *types.MessageState, 100) + p := cmd.NewProcessor() + go noble.StartListener(cfg, logger, processingQueue) - go cmd.StartProcessor(cfg, logger, processingQueue, sequenceMap) + go p.StartProcessor(context.TODO(), cfg, logger, processingQueue, sequenceMap) fmt.Println("Checking eth wallet...") for i := 0; i < 60; i++ { diff --git a/integration/noble_burn_to_eth_mint_test.go b/integration/noble_burn_to_eth_mint_test.go index 9e96944..38b2957 100644 --- a/integration/noble_burn_to_eth_mint_test.go +++ b/integration/noble_burn_to_eth_mint_test.go @@ -2,14 +2,13 @@ package integration_testing import ( "context" - "cosmossdk.io/math" "encoding/hex" - "encoding/json" - "errors" "fmt" + "testing" + "time" + + "cosmossdk.io/math" nobletypes "github.com/circlefin/noble-cctp/x/cctp/types" - rpchttp "github.com/cometbft/cometbft/rpc/client/http" - libclient "github.com/cometbft/cometbft/rpc/jsonrpc/client" sdkClient "github.com/cosmos/cosmos-sdk/client" clientTx "github.com/cosmos/cosmos-sdk/client/tx" "github.com/cosmos/cosmos-sdk/codec" @@ -19,44 +18,41 @@ import ( "github.com/cosmos/cosmos-sdk/types/tx/signing" xauthsigning "github.com/cosmos/cosmos-sdk/x/auth/signing" xauthtx "github.com/cosmos/cosmos-sdk/x/auth/tx" - "github.com/ethereum/go-ethereum" - "github.com/ethereum/go-ethereum/accounts/abi" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/ethclient" "github.com/strangelove-ventures/noble-cctp-relayer/cmd" - "github.com/strangelove-ventures/noble-cctp-relayer/cmd/noble" + "github.com/strangelove-ventures/noble-cctp-relayer/cosmos" "github.com/strangelove-ventures/noble-cctp-relayer/types" + "github.com/stretchr/testify/require" - "io" - "log" - "math/big" - "net/http" - "strconv" - "strings" - "testing" - "time" ) // TestNobleBurnToEthMint generates and broadcasts a depositForBurn on Noble // and broadcasts on Ethereum Goerli func TestNobleBurnToEthMint(t *testing.T) { - setupTest() - cfg.Networks.Source.Ethereum.Enabled = false + ctx := context.Background() - // start up relayer - cfg.Networks.Source.Noble.StartBlock = getNobleLatestBlockHeight() + setupTestIntegration() fmt.Println("Starting relayer...") - processingQueue := make(chan *types.MessageState, 10) - go noble.StartListener(cfg, logger, processingQueue) - go cmd.StartProcessor(cfg, logger, processingQueue, sequenceMap) + processingQueue := make(chan *types.TxState, 10) + + registeredDomains := make(map[types.Domain]types.Chain) + registeredDomains[0] = ethChain + registeredDomains[4] = nobleChain + + err := ethChain.InitializeBroadcaster(ctx, logger, sequenceMap) + require.NoError(t, err) + + go nobleChain.StartListener(ctx, logger, processingQueue) + go cmd.StartProcessor(context.TODO(), cfg, logger, registeredDomains, processingQueue, sequenceMap) fmt.Println("Building Noble depositForBurn txn...") - ethDestinationAddress := "0x971c54a6Eb782fAccD00bc3Ed5E934Cc5bD8e3Ef" - fmt.Println("Minting on Ethereum to https://goerli.etherscan.io/address/" + ethDestinationAddress) + ethDestinationAddress := integrationWallets.Networks.Ethereum.Address + fmt.Println("Minting on Ethereum to https://sepolia.etherscan.io/address/" + ethDestinationAddress) // verify ethereum usdc amount - client, _ := ethclient.Dial(testCfg.Networks.Ethereum.RPC) + client, _ := ethclient.Dial(ethCfg.RPC) defer client.Close() originalEthBalance := getEthBalance(client, ethDestinationAddress) @@ -71,7 +67,7 @@ func TestNobleBurnToEthMint(t *testing.T) { } txBuilder := sdkContext.TxConfig.NewTxBuilder() // get priv key - keyBz, _ := hex.DecodeString(testCfg.Networks.Noble.PrivateKey) + keyBz, _ := hex.DecodeString(integrationWallets.Networks.Noble.PrivateKey) privKey := secp256k1.PrivKey{Key: keyBz} nobleAddress, err := bech32.ConvertAndEncode("noble", privKey.PubKey().Address()) require.Nil(t, err) @@ -91,13 +87,14 @@ func TestNobleBurnToEthMint(t *testing.T) { err = txBuilder.SetMsgs(burnMsg) require.Nil(t, err) - txBuilder.SetGasLimit(cfg.Networks.Destination.Noble.GasLimit) + txBuilder.SetGasLimit(nobleCfg.GasLimit) // sign + broadcast txn - rpcClient, err := NewRPCClient(testCfg.Networks.Noble.RPC, 10*time.Second) + cc, err := cosmos.NewProvider(nobleCfg.RPC) require.Nil(t, err) - accountNumber, accountSequence, err := GetNobleAccountNumberSequence(cfg.Networks.Destination.Noble.API, nobleAddress) + accountNumber, accountSequence, err := getNobleAccountNumberSequenceGRPC(cc, nobleAddress) + require.Nil(t, err) sigV2 := signing.SignatureV2{ @@ -110,7 +107,7 @@ func TestNobleBurnToEthMint(t *testing.T) { } signerData := xauthsigning.SignerData{ - ChainID: cfg.Networks.Destination.Noble.ChainId, + ChainID: nobleCfg.ChainID, AccountNumber: uint64(accountNumber), Sequence: uint64(accountSequence), } @@ -124,6 +121,7 @@ func TestNobleBurnToEthMint(t *testing.T) { sdkContext.TxConfig, uint64(accountSequence), ) + require.Nil(t, err) err = txBuilder.SetSignatures(sigV2) require.Nil(t, err) @@ -132,78 +130,18 @@ func TestNobleBurnToEthMint(t *testing.T) { txBytes, err := sdkContext.TxConfig.TxEncoder()(txBuilder.GetTx()) require.Nil(t, err) - rpcResponse, err := rpcClient.BroadcastTxSync(context.Background(), txBytes) + rpcResponse, err := cc.RPCClient.BroadcastTxSync(context.Background(), txBytes) require.Nil(t, err) fmt.Printf("Update pending: https://testnet.mintscan.io/noble-testnet/txs/%s\n", rpcResponse.Hash.String()) fmt.Println("Checking eth wallet...") for i := 0; i < 60; i++ { if originalEthBalance+burnAmount.Uint64() == getEthBalance(client, ethDestinationAddress) { - fmt.Println("Successfully minted at https://goerli.etherscan.io/address/" + ethDestinationAddress) + fmt.Println("Successfully minted at https://sepolia.etherscan.io/address/" + ethDestinationAddress) return } - time.Sleep(1 * time.Second) + time.Sleep(3 * time.Second) } // verify eth balance require.Equal(t, originalEthBalance+burnAmount.Uint64(), getEthBalance(client, ethDestinationAddress)) } - -func getEthBalance(client *ethclient.Client, address string) uint64 { - accountAddress := common.HexToAddress(address) - tokenAddress := common.HexToAddress("0x07865c6e87b9f70255377e024ace6630c1eaa37f") // USDC goerli - erc20ABI := `[{"constant":true,"inputs":[{"name":"_owner","type":"address"}],"name":"balanceOf","outputs":[{"name":"balance","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"}]` - parsedABI, err := abi.JSON(strings.NewReader(erc20ABI)) - if err != nil { - log.Fatalf("Failed to parse contract ABI: %v", err) - } - - data, err := parsedABI.Pack("balanceOf", accountAddress) - if err != nil { - log.Fatalf("Failed to pack data into ABI interface: %v", err) - } - - result, err := client.CallContract(context.Background(), ethereum.CallMsg{To: &tokenAddress, Data: data}, nil) - if err != nil { - log.Fatalf("Failed to call contract: %v", err) - } - - balance := new(big.Int) - err = parsedABI.UnpackIntoInterface(&balance, "balanceOf", result) - if err != nil { - log.Fatalf("Failed to unpack data from ABI interface: %v", err) - } - - // Convert to uint64 - return balance.Uint64() -} - -// NewRPCClient initializes a new tendermint RPC client connected to the specified address. -func NewRPCClient(addr string, timeout time.Duration) (*rpchttp.HTTP, error) { - httpClient, err := libclient.DefaultHTTPClient(addr) - if err != nil { - return nil, err - } - httpClient.Timeout = timeout - rpcClient, err := rpchttp.NewWithClient(addr, "/websocket", httpClient) - if err != nil { - return nil, err - } - return rpcClient, nil -} - -func GetNobleAccountNumberSequence(urlBase string, address string) (int64, int64, error) { - rawResp, err := http.Get(fmt.Sprintf("%s/cosmos/auth/v1beta1/accounts/%s", urlBase, address)) - if err != nil { - return 0, 0, errors.New("unable to fetch account number, sequence") - } - body, _ := io.ReadAll(rawResp.Body) - var resp types.AccountResp - err = json.Unmarshal(body, &resp) - if err != nil { - return 0, 0, errors.New("unable to parse account number, sequence") - } - accountNumber, _ := strconv.ParseInt(resp.AccountNumber, 10, 0) - accountSequence, _ := strconv.ParseInt(resp.Sequence, 10, 0) - - return accountNumber, accountSequence, nil -} diff --git a/integration/noble_multi_send_test.go b/integration/noble_multi_send_test.go.bak similarity index 97% rename from integration/noble_multi_send_test.go rename to integration/noble_multi_send_test.go.bak index 0b1bbb6..a965650 100644 --- a/integration/noble_multi_send_test.go +++ b/integration/noble_multi_send_test.go.bak @@ -1,8 +1,13 @@ package integration_testing import ( + "context" "encoding/hex" "fmt" + "math/big" + "testing" + "time" + "github.com/cosmos/cosmos-sdk/crypto/keys/secp256k1" "github.com/cosmos/cosmos-sdk/testutil/testdata" "github.com/cosmos/cosmos-sdk/types/bech32" @@ -15,9 +20,6 @@ import ( "github.com/strangelove-ventures/noble-cctp-relayer/cmd/noble" "github.com/strangelove-ventures/noble-cctp-relayer/types" "github.com/stretchr/testify/require" - "math/big" - "testing" - "time" ) // TestNobleMultiSend broadcasts N depositForBurnWithCaller messages on Ethereum, and then tries to receive them all at once on Noble. @@ -124,8 +126,10 @@ func TestNobleMultiSend(t *testing.T) { fmt.Println("Starting relayer...") processingQueue := make(chan *types.MessageState, 100) + p := cmd.NewProcessor() + go eth.StartListener(cfg, logger, processingQueue) - go cmd.StartProcessor(cfg, logger, processingQueue, sequenceMap) + go p.StartProcessor(context.TODO(), cfg, logger, processingQueue, sequenceMap) fmt.Println("Checking noble wallet...") for i := 0; i < 250; i++ { diff --git a/integration/util.go b/integration/util.go index 1b0421a..6af1569 100644 --- a/integration/util.go +++ b/integration/util.go @@ -4,14 +4,27 @@ import ( "context" "encoding/json" "fmt" - "github.com/ethereum/go-ethereum/ethclient" - "github.com/stretchr/testify/require" "io" + "log" + "math/big" "net/http" "strconv" + "strings" "testing" + + authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" + bankTypes "github.com/cosmos/cosmos-sdk/x/bank/types" + ethereum "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/strangelove-ventures/noble-cctp-relayer/cosmos" + "github.com/stretchr/testify/require" ) +// USDC Token Address on Sepolia +const usdcTokenAddress = "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238" + func getDydxBalance(address string) uint64 { rawResponse, _ := http.Get(fmt.Sprintf( "https://dydx-testnet-api.polkachu.com/cosmos/bank/v1beta1/balances/%s/by_denom?denom=ibc/8E27BA2D5493AF5636760E354E46004562C46AB7EC0CC4C1CA14E9E20E2545B5", address)) @@ -23,7 +36,7 @@ func getDydxBalance(address string) uint64 { } func getEthereumLatestBlockHeight(t *testing.T) uint64 { - client, err := ethclient.Dial(cfg.Networks.Source.Ethereum.RPC) + client, err := ethclient.Dial(ethCfg.RPC) require.Nil(t, err) header, err := client.HeaderByNumber(context.Background(), nil) @@ -31,15 +44,6 @@ func getEthereumLatestBlockHeight(t *testing.T) uint64 { return header.Number.Uint64() } -func getNobleBalance(address string) uint64 { - rawResponse, _ := http.Get(fmt.Sprintf("https://lcd.testnet.noble.strange.love/cosmos/bank/v1beta1/balances/%s/by_denom?denom=uusdc", address)) - body, _ := io.ReadAll(rawResponse.Body) - response := BalanceResponse{} - _ = json.Unmarshal(body, &response) - result, _ := strconv.ParseInt(response.Balance.Amount, 10, 0) - return uint64(result) -} - func getNobleLatestBlockHeight() uint64 { rawResponse, _ := http.Get("https://rpc.testnet.noble.strange.love/block") body, _ := io.ReadAll(rawResponse.Body) @@ -48,3 +52,61 @@ func getNobleLatestBlockHeight() uint64 { res, _ := strconv.ParseInt(response.Result.Block.Header.Height, 0, 0) return uint64(res) } + +func getNobleAccountBalance(ctx context.Context, cc *cosmos.CosmosProvider, address, denom string) (uint64, error) { + qc := bankTypes.NewQueryClient(cc) + res, err := qc.Balance(ctx, &bankTypes.QueryBalanceRequest{ + Address: address, + Denom: denom, + }) + if err != nil { + return 0, err + } + + return res.Balance.Amount.Uint64(), nil +} + +func getNobleAccountNumberSequenceGRPC(cc *cosmos.CosmosProvider, address string) (uint64, uint64, error) { + res, err := authtypes.NewQueryClient(cc).Account(context.Background(), &authtypes.QueryAccountRequest{ + Address: address, + }) + if err != nil { + return 0, 0, fmt.Errorf("unable to query account for noble: %w", err) + } + var acc authtypes.AccountI + if err := cc.Cdc.InterfaceRegistry.UnpackAny(res.Account, &acc); err != nil { + return 0, 0, fmt.Errorf("unable to unpack account for noble: %w", err) + } + + return acc.GetAccountNumber(), acc.GetSequence(), nil + +} + +func getEthBalance(client *ethclient.Client, address string) uint64 { + accountAddress := common.HexToAddress(address) + tokenAddress := common.HexToAddress(usdcTokenAddress) + erc20ABI := `[{"constant":true,"inputs":[{"name":"_owner","type":"address"}],"name":"balanceOf","outputs":[{"name":"balance","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"}]` + parsedABI, err := abi.JSON(strings.NewReader(erc20ABI)) + if err != nil { + log.Fatalf("Failed to parse contract ABI: %v", err) + } + + data, err := parsedABI.Pack("balanceOf", accountAddress) + if err != nil { + log.Fatalf("Failed to pack data into ABI interface: %v", err) + } + + result, err := client.CallContract(context.Background(), ethereum.CallMsg{To: &tokenAddress, Data: data}, nil) + if err != nil { + log.Fatalf("Failed to call contract: %v", err) + } + + balance := new(big.Int) + err = parsedABI.UnpackIntoInterface(&balance, "balanceOf", result) + if err != nil { + log.Fatalf("Failed to unpack data from ABI interface: %v", err) + } + + // Convert to uint64 + return balance.Uint64() +} diff --git a/main.go b/main.go index 69c01be..109bf99 100644 --- a/main.go +++ b/main.go @@ -1,7 +1,15 @@ package main -import "github.com/strangelove-ventures/noble-cctp-relayer/cmd" +import ( + "context" + "os" + "os/signal" + + "github.com/strangelove-ventures/noble-cctp-relayer/cmd" +) func main() { - cmd.Execute() + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + defer cancel() + cmd.Execute(ctx) } diff --git a/noble/chain.go b/noble/chain.go new file mode 100644 index 0000000..6fcf883 --- /dev/null +++ b/noble/chain.go @@ -0,0 +1,476 @@ +package noble + +import ( + "bytes" + "context" + "encoding/hex" + "errors" + "fmt" + "math/rand" + "regexp" + "strconv" + "sync" + "time" + + "cosmossdk.io/log" + nobletypes "github.com/circlefin/noble-cctp/x/cctp/types" + ctypes "github.com/cometbft/cometbft/rpc/core/types" + sdkClient "github.com/cosmos/cosmos-sdk/client" + clientTx "github.com/cosmos/cosmos-sdk/client/tx" + "github.com/cosmos/cosmos-sdk/codec" + codectypes "github.com/cosmos/cosmos-sdk/codec/types" + "github.com/cosmos/cosmos-sdk/crypto/keys/secp256k1" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/types/bech32" + "github.com/cosmos/cosmos-sdk/types/tx/signing" + xauthsigning "github.com/cosmos/cosmos-sdk/x/auth/signing" + xauthtx "github.com/cosmos/cosmos-sdk/x/auth/tx" + authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" + "github.com/strangelove-ventures/noble-cctp-relayer/cosmos" + "github.com/strangelove-ventures/noble-cctp-relayer/types" +) + +var _ types.Chain = (*Noble)(nil) + +type Noble struct { + cc *cosmos.CosmosProvider + chainID string + + privateKey *secp256k1.PrivKey + minterAddress string + accountNumber uint64 + + startBlock uint64 + lookbackPeriod uint64 + workers uint32 + + gasLimit uint64 + txMemo string + maxRetries int + retryIntervalSeconds int + + mu sync.Mutex +} + +func NewChain( + rpcURL string, + chainID string, + privateKey string, + startBlock uint64, + lookbackPeriod uint64, + workers uint32, + gasLimit uint64, + txMemo string, + maxRetries int, + retryIntervalSeconds int, +) (*Noble, error) { + cc, err := cosmos.NewProvider(rpcURL) + if err != nil { + return nil, fmt.Errorf("unable to build cosmos provider for noble: %w", err) + } + + keyBz, err := hex.DecodeString(privateKey) + if err != nil { + return nil, fmt.Errorf("unable to parse noble private key: %w", err) + } + + privKey := secp256k1.PrivKey{Key: keyBz} + + address := privKey.PubKey().Address() + minterAddress := sdk.MustBech32ifyAddressBytes("noble", address) + + return &Noble{ + cc: cc, + chainID: chainID, + startBlock: startBlock, + lookbackPeriod: lookbackPeriod, + workers: workers, + privateKey: &privKey, + minterAddress: minterAddress, + gasLimit: gasLimit, + txMemo: txMemo, + maxRetries: maxRetries, + retryIntervalSeconds: retryIntervalSeconds, + }, nil +} + +func (n *Noble) AccountInfo(ctx context.Context) (uint64, uint64, error) { + res, err := authtypes.NewQueryClient(n.cc).Account(ctx, &authtypes.QueryAccountRequest{ + Address: n.minterAddress, + }) + if err != nil { + return 0, 0, fmt.Errorf("unable to query account for noble: %w", err) + } + var acc authtypes.AccountI + if err := n.cc.Cdc.InterfaceRegistry.UnpackAny(res.Account, &acc); err != nil { + return 0, 0, fmt.Errorf("unable to unpack account for noble: %w", err) + } + + return acc.GetAccountNumber(), acc.GetSequence(), nil +} + +func (n *Noble) Name() string { + return "Noble" +} + +func (n *Noble) Domain() types.Domain { + return 4 +} + +func (n *Noble) IsDestinationCaller(destinationCaller []byte) bool { + zeroByteArr := make([]byte, 32) + + if bytes.Equal(destinationCaller, zeroByteArr) { + return true + } + + bech32DestinationCaller, err := decodeDestinationCaller(destinationCaller) + if err != nil { + return false + } + + return bech32DestinationCaller == n.minterAddress +} + +// DecodeDestinationCaller transforms an encoded Noble cctp address into a noble bech32 address +// left padded input -> bech32 output +func decodeDestinationCaller(input []byte) (string, error) { + if len(input) <= 12 { + return "", errors.New("destinationCaller is too short") + } + output, err := bech32.ConvertAndEncode("noble", input[12:]) + if err != nil { + return "", errors.New("unable to encode destination caller") + } + return output, nil +} + +func (n *Noble) InitializeBroadcaster( + ctx context.Context, + logger log.Logger, + sequenceMap *types.SequenceMap, +) error { + accountNumber, accountSequence, err := n.AccountInfo(ctx) + if err != nil { + return fmt.Errorf("unable to get account info for noble: %w", err) + } + + n.accountNumber = accountNumber + sequenceMap.Put(n.Domain(), accountSequence) + + return nil +} + +func (n *Noble) StartListener( + ctx context.Context, + logger log.Logger, + processingQueue chan *types.TxState, +) { + logger = logger.With("chain", n.Name(), "chain_id", n.chainID, "domain", n.Domain()) + + if n.startBlock == 0 { + // get the latest block + chainTip, err := n.chainTip(ctx) + if err != nil { + panic(fmt.Errorf("unable to get chain tip for noble: %w", err)) + } + n.startBlock = chainTip + } + + logger.Info(fmt.Sprintf("Starting Noble listener at block %d looking back %d blocks", + n.startBlock, + n.lookbackPeriod)) + + accountNumber, _, err := n.AccountInfo(ctx) + if err != nil { + panic(fmt.Errorf("unable to get account info for noble: %w", err)) + } + + n.accountNumber = accountNumber + + // enqueue block heights + currentBlock := n.startBlock + lookback := n.lookbackPeriod + chainTip, err := n.chainTip(ctx) + blockQueue := make(chan uint64, 1000000) + + // history + currentBlock = currentBlock - lookback + for currentBlock <= chainTip { + blockQueue <- currentBlock + currentBlock++ + } + + // listen for new blocks + go func() { + first := make(chan struct{}, 1) + first <- struct{}{} + for { + timer := time.NewTimer(6 * time.Second) + select { + case <-first: + timer.Stop() + chainTip, err = n.chainTip(ctx) + if err == nil { + if chainTip >= currentBlock { + for i := currentBlock; i <= chainTip; i++ { + blockQueue <- i + } + currentBlock = chainTip + 1 + } + } + case <-timer.C: + chainTip, err = n.chainTip(ctx) + if err == nil { + if chainTip >= currentBlock { + for i := currentBlock; i <= chainTip; i++ { + blockQueue <- i + } + currentBlock = chainTip + 1 + } + } + case <-ctx.Done(): + timer.Stop() + return + } + } + }() + + // constantly query for blocks + for i := 0; i < int(n.workers); i++ { + go func() { + for { + select { + case <-ctx.Done(): + return + default: + block := <-blockQueue + res, err := n.cc.RPCClient.TxSearch(ctx, fmt.Sprintf("tx.height=%d", block), false, nil, nil, "") + if err != nil { + logger.Debug(fmt.Sprintf("unable to query Noble block %d", block)) + blockQueue <- block + } + + for _, tx := range res.Txs { + parsedMsgs, err := txToMessageState(tx) + if err != nil { + logger.Error("unable to parse Noble log to message state", "err", err.Error()) + continue + } + for _, parsedMsg := range parsedMsgs { + logger.Info(fmt.Sprintf("New stream msg with nonce %d from %d with tx hash %s", parsedMsg.Nonce, parsedMsg.SourceDomain, parsedMsg.SourceTxHash)) + } + processingQueue <- &types.TxState{TxHash: tx.Hash.String(), Msgs: parsedMsgs} + } + } + } + }() + } + + <-ctx.Done() +} + +func (n *Noble) chainTip(ctx context.Context) (uint64, error) { + res, err := n.cc.RPCClient.Status(ctx) + if err != nil { + return 0, fmt.Errorf("unable to query status for noble: %w", err) + } + return uint64(res.SyncInfo.LatestBlockHeight), nil +} + +func (n *Noble) Broadcast( + ctx context.Context, + logger log.Logger, + msgs []*types.MessageState, + sequenceMap *types.SequenceMap, +) error { + // set up sdk context + interfaceRegistry := codectypes.NewInterfaceRegistry() + nobletypes.RegisterInterfaces(interfaceRegistry) + cdc := codec.NewProtoCodec(interfaceRegistry) + sdkContext := sdkClient.Context{ + TxConfig: xauthtx.NewTxConfig(cdc, xauthtx.DefaultSignModes), + } + + // build txn + txBuilder := sdkContext.TxConfig.NewTxBuilder() + + // sign and broadcast txn + for attempt := 0; attempt <= n.maxRetries; attempt++ { + + //TODO: MOVE EVERYTHING IN FOR LOOP TO FUNCTION. Same for ETH. + // see todo below. + + var receiveMsgs []sdk.Msg + for _, msg := range msgs { + + used, err := n.cc.QueryUsedNonce(ctx, types.Domain(msg.SourceDomain), msg.Nonce) + if err != nil { + return fmt.Errorf("unable to query used nonce: %w", err) + } + + if used { + msg.Status = types.Complete + logger.Info(fmt.Sprintf("Noble cctp minter nonce %d already used", msg.Nonce)) + continue + } + + attestationBytes, err := hex.DecodeString(msg.Attestation[2:]) + if err != nil { + return fmt.Errorf("unable to decode message attestation") + } + + receiveMsgs = append(receiveMsgs, nobletypes.NewMsgReceiveMessage( + n.minterAddress, + msg.MsgSentBytes, + attestationBytes, + )) + + logger.Info(fmt.Sprintf( + "Broadcasting message from %d to %d: with source tx hash %s", + msg.SourceDomain, + msg.DestDomain, + msg.SourceTxHash)) + } + + if err := txBuilder.SetMsgs(receiveMsgs...); err != nil { + return fmt.Errorf("failed to set messages on tx: %w", err) + } + + txBuilder.SetGasLimit(n.gasLimit) + + txBuilder.SetMemo(n.txMemo) + + n.mu.Lock() + // TODO: uncomment this & remove all remainin n.mu.Unlock() 's after moving loop body to its own function + // defer n.mu.Unlock() + + accountSequence := sequenceMap.Next(n.Domain()) + + sigV2 := signing.SignatureV2{ + PubKey: n.privateKey.PubKey(), + Data: &signing.SingleSignatureData{ + SignMode: sdkContext.TxConfig.SignModeHandler().DefaultMode(), + Signature: nil, + }, + Sequence: uint64(accountSequence), + } + + signerData := xauthsigning.SignerData{ + ChainID: n.chainID, + AccountNumber: uint64(n.accountNumber), + Sequence: uint64(accountSequence), + } + + txBuilder.SetSignatures(sigV2) + + sigV2, err := clientTx.SignWithPrivKey( + sdkContext.TxConfig.SignModeHandler().DefaultMode(), + signerData, + txBuilder, + n.privateKey, + sdkContext.TxConfig, + uint64(accountSequence), + ) + if err != nil { + n.mu.Unlock() + return fmt.Errorf("failed to sign tx: %w", err) + } + + if err := txBuilder.SetSignatures(sigV2); err != nil { + n.mu.Unlock() + return fmt.Errorf("failed to set signatures: %w", err) + } + + // Generated Protobuf-encoded bytes. + txBytes, err := sdkContext.TxConfig.TxEncoder()(txBuilder.GetTx()) + if err != nil { + n.mu.Unlock() + return fmt.Errorf("failed to proto encode tx: %w", err) + } + + rpcResponse, err := n.cc.RPCClient.BroadcastTxSync(ctx, txBytes) + if err != nil || (rpcResponse != nil && rpcResponse.Code != 0) { + // Log the error + logger.Error(fmt.Sprintf("error during broadcast: %s", getErrorString(err, rpcResponse))) + + if err != nil || rpcResponse == nil { + // Log retry information + logger.Info(fmt.Sprintf("Retrying in %d seconds", n.retryIntervalSeconds)) + time.Sleep(time.Duration(n.retryIntervalSeconds) * time.Second) + // wait a random amount of time to lower probability of concurrent message nonce collision + time.Sleep(time.Duration(rand.Intn(5)) * time.Second) + n.mu.Unlock() + continue + } + + // Log details for non-zero response code + logger.Error(fmt.Sprintf("received non-zero: %d - %s", rpcResponse.Code, rpcResponse.Log)) + + // Handle specific error code (32) + if rpcResponse.Code == 32 { + newAccountSequence := n.extractAccountSequence(ctx, logger, rpcResponse.Log) + logger.Debug(fmt.Sprintf("retrying with new account sequence: %d", newAccountSequence)) + sequenceMap.Put(n.Domain(), newAccountSequence) + } + + // Log retry information + logger.Info(fmt.Sprintf("Retrying in %d seconds", n.retryIntervalSeconds)) + time.Sleep(time.Duration(n.retryIntervalSeconds) * time.Second) + // wait a random amount of time to lower probability of concurrent message nonce collision + time.Sleep(time.Duration(rand.Intn(5)) * time.Second) + n.mu.Unlock() + continue + } + + n.mu.Unlock() + + // Tx was successfully broadcast + for _, msg := range msgs { + msg.DestTxHash = rpcResponse.Hash.String() + msg.Status = types.Complete + } + logger.Info(fmt.Sprintf("Successfully broadcast %s to Noble. Tx hash: %s", msgs[0].SourceTxHash, msgs[0].DestTxHash)) + + return nil + } + + for _, msg := range msgs { + if msg.Status != types.Complete { + msg.Status = types.Failed + } + } + + return errors.New("reached max number of broadcast attempts") +} + +// getErrorString returns the appropriate value to log when tx broadcast errors are encountered. +func getErrorString(err error, rpcResponse *ctypes.ResultBroadcastTx) string { + if rpcResponse != nil { + return rpcResponse.Log + } + return err.Error() +} + +// extractAccountSequence attempts to extract the account sequence number from the RPC response logs when +// account sequence mismatch errors are encountered. If the account sequence number cannot be extracted from the logs, +// it is retrieved by making a request to the API endpoint. +func (n *Noble) extractAccountSequence(ctx context.Context, logger log.Logger, rpcResponseLog string) uint64 { + pattern := `expected (\d+), got (\d+)` + re := regexp.MustCompile(pattern) + match := re.FindStringSubmatch(rpcResponseLog) + + if len(match) == 3 { + // Extract the numbers from the match. + newAccountSequence, _ := strconv.ParseUint(match[1], 10, 64) + return newAccountSequence + } + + // Otherwise, just request the account sequence + _, newAccountSequence, err := n.AccountInfo(ctx) + if err != nil { + logger.Error("unable to retrieve account sequence") + } + + return newAccountSequence +} diff --git a/noble/config.go b/noble/config.go new file mode 100644 index 0000000..a9085a1 --- /dev/null +++ b/noble/config.go @@ -0,0 +1,37 @@ +package noble + +import "github.com/strangelove-ventures/noble-cctp-relayer/types" + +var _ types.ChainConfig = (*ChainConfig)(nil) + +type ChainConfig struct { + RPC string `yaml:"rpc"` + ChainID string `yaml:"chain-id"` + + StartBlock uint64 `yaml:"start-block"` + LookbackPeriod uint64 `yaml:"lookback-period"` + Workers uint32 `yaml:"workers"` + + TxMemo string `yaml:"tx-memo"` + GasLimit uint64 `yaml:"gas-limit"` + BroadcastRetries int `yaml:"broadcast-retries"` + BroadcastRetryInterval int `yaml:"broadcast-retry-interval"` + + // TODO move to keyring + MinterPrivateKey string `yaml:"minter-private-key"` +} + +func (c *ChainConfig) Chain(name string) (types.Chain, error) { + return NewChain( + c.RPC, + c.ChainID, + c.MinterPrivateKey, + c.StartBlock, + c.LookbackPeriod, + c.Workers, + c.GasLimit, + c.TxMemo, + c.BroadcastRetries, + c.BroadcastRetryInterval, + ) +} diff --git a/noble/listener_test.go b/noble/listener_test.go new file mode 100644 index 0000000..459e5b2 --- /dev/null +++ b/noble/listener_test.go @@ -0,0 +1,60 @@ +package noble_test + +import ( + "context" + "os" + "testing" + "time" + + "cosmossdk.io/log" + "github.com/rs/zerolog" + "github.com/strangelove-ventures/noble-cctp-relayer/cmd" + "github.com/strangelove-ventures/noble-cctp-relayer/noble" + "github.com/strangelove-ventures/noble-cctp-relayer/types" + "github.com/stretchr/testify/require" +) + +var cfg *types.Config +var logger log.Logger +var processingQueue chan *types.TxState + +func init() { + var err error + cfg, err = cmd.Parse("../.ignore/testnet.yaml") + if err != nil { + panic(err) + } + + logger = log.NewLogger(os.Stdout, log.LevelOption(zerolog.DebugLevel)) + processingQueue = make(chan *types.TxState, 10000) + cfg.Chains["noble"].(*noble.ChainConfig).Workers = 1 +} + +func TestStartListener(t *testing.T) { + cfg.Chains["noble"].(*noble.ChainConfig).StartBlock = 3273557 + n, err := cfg.Chains["noble"].(*noble.ChainConfig).Chain("noble") + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go n.StartListener(ctx, logger, processingQueue) + + time.Sleep(20 * time.Second) + + tx := <-processingQueue + + expectedMsg := &types.MessageState{ + IrisLookupId: "efe7cea3fd4785c3beab7f37876bdd48c5d4689c84d85a250813a2a7f01fe765", + Status: "created", + SourceDomain: 4, + DestDomain: 0, + SourceTxHash: "5002A249B1353FA59C1660EBAE5FA7FC652AC1E77F69CEF3A4533B0DF2864012", + } + require.Equal(t, expectedMsg.IrisLookupId, tx.Msgs[0].IrisLookupId) + require.Equal(t, expectedMsg.Status, tx.Msgs[0].Status) + require.Equal(t, expectedMsg.SourceDomain, tx.Msgs[0].SourceDomain) + require.Equal(t, expectedMsg.DestDomain, tx.Msgs[0].DestDomain) + require.Equal(t, expectedMsg.SourceTxHash, tx.Msgs[0].SourceTxHash) + +} diff --git a/noble/message_state.go b/noble/message_state.go new file mode 100644 index 0000000..8866c6a --- /dev/null +++ b/noble/message_state.go @@ -0,0 +1,87 @@ +package noble + +import ( + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "time" + + ctypes "github.com/cometbft/cometbft/rpc/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/strangelove-ventures/noble-cctp-relayer/types" +) + +// NobleLogToMessageState transforms a Noble log into a messageState +func txToMessageState(tx *ctypes.ResultTx) ([]*types.MessageState, error) { + if tx.TxResult.Code != 0 { + return nil, nil + } + + var messageStates []*types.MessageState + + for _, event := range tx.TxResult.Events { + if event.Type == "circle.cctp.v1.MessageSent" { + //fmt.Printf("Saw cctp message %s - %d:%d\n", tx., i, j) + var parsed bool + var parseErrs error + for _, attr := range event.Attributes { + decodedKey, err := base64.StdEncoding.DecodeString(attr.Key) + if err != nil { + parseErrs = errors.Join(parseErrs, fmt.Errorf("failed to decode attribue key: %w", err)) + } + if string(decodedKey) == "message" { + // fmt.Printf("Saw message attribute %s - %d\n", tx.Hash, i) + decodedValue, err := base64.StdEncoding.DecodeString(attr.Value) + if err != nil { + parseErrs = errors.Join(parseErrs, fmt.Errorf("error decoding attr.value: %w", err)) + continue + } + encoded := decodedValue[1 : len(decodedValue)-1] + // Because we are using cometBFT v0.38, we need to decode the value twice. + rawMessageSentBytes, err := base64.StdEncoding.DecodeString(string(encoded)) + if err != nil { + parseErrs = errors.Join(parseErrs, fmt.Errorf("failed to decode message: %w", err)) + continue + } + + hashed := crypto.Keccak256(rawMessageSentBytes) + hashedHexStr := hex.EncodeToString(hashed) + + msg, err := new(types.Message).Parse(rawMessageSentBytes) + if err != nil { + parseErrs = errors.Join(parseErrs, fmt.Errorf("failed to parse message: %w", err)) + continue + } + + parsed = true + + now := time.Now() + + messageState := &types.MessageState{ + IrisLookupId: hashedHexStr, + Status: types.Created, + SourceDomain: types.Domain(msg.SourceDomain), + DestDomain: types.Domain(msg.DestinationDomain), + Nonce: msg.Nonce, + SourceTxHash: tx.Hash.String(), + MsgSentBytes: rawMessageSentBytes, + DestinationCaller: msg.DestinationCaller, + Created: now, + Updated: now, + } + + messageStates = append(messageStates, messageState) + + fmt.Printf("Appended transfer from 4 to %d\n", msg.DestinationDomain) + } + } + if !parsed { + return nil, fmt.Errorf("unable to parse cctp message. tx hash %s: %w", tx.Hash, parseErrs) + } + } + } + + return messageStates, nil + +} diff --git a/types/chain.go b/types/chain.go new file mode 100644 index 0000000..12dba4a --- /dev/null +++ b/types/chain.go @@ -0,0 +1,41 @@ +package types + +import ( + "context" + + "cosmossdk.io/log" +) + +// Chain is an interface for common CCTP source and destination chain operations. +type Chain interface { + // Name returns the name of the chain. + Name() string + + // Domain returns the domain ID of the chain. + Domain() Domain + + // IsDestinationCaller returns true if the specified destination caller is the minter for the specified domain. + IsDestinationCaller(destinationCaller []byte) bool + + // InitializeBroadcaster initializes the minter account info for the chain. + InitializeBroadcaster( + ctx context.Context, + logger log.Logger, + sequenceMap *SequenceMap, + ) error + + // StartListener starts a listener for observing new CCTP burn messages. + StartListener( + ctx context.Context, + logger log.Logger, + processingQueue chan *TxState, + ) + + // Broadcast broadcasts CCTP mint messages to the chain. + Broadcast( + ctx context.Context, + logger log.Logger, + msgs []*MessageState, + sequenceMap *SequenceMap, + ) error +} diff --git a/types/config.go b/types/config.go new file mode 100644 index 0000000..92d2c09 --- /dev/null +++ b/types/config.go @@ -0,0 +1,33 @@ +package types + +type Config struct { + Chains map[string]ChainConfig `yaml:"chains"` + EnabledRoutes map[Domain]Domain `yaml:"enabled-routes"` + Circle struct { + AttestationBaseUrl string `yaml:"attestation-base-url"` + FetchRetries int `yaml:"fetch-retries"` + FetchRetryInterval int `yaml:"fetch-retry-interval"` + } `yaml:"circle"` + ProcessorWorkerCount uint32 `yaml:"processor-worker-count"` + Api struct { + TrustedProxies []string `yaml:"trusted-proxies"` + } `yaml:"api"` +} + +type ConfigWrapper struct { + Chains map[string]map[string]any `yaml:"chains"` + EnabledRoutes map[Domain]Domain `yaml:"enabled-routes"` + Circle struct { + AttestationBaseUrl string `yaml:"attestation-base-url"` + FetchRetries int `yaml:"fetch-retries"` + FetchRetryInterval int `yaml:"fetch-retry-interval"` + } `yaml:"circle"` + ProcessorWorkerCount uint32 `yaml:"processor-worker-count"` + Api struct { + TrustedProxies []string `yaml:"trusted-proxies"` + } `yaml:"api"` +} + +type ChainConfig interface { + Chain(name string) (Chain, error) +} diff --git a/types/message_state.go b/types/message_state.go index dd96007..3bfcd0d 100644 --- a/types/message_state.go +++ b/types/message_state.go @@ -1,16 +1,12 @@ package types import ( - "encoding/base64" + "bytes" "encoding/hex" - "encoding/json" - "errors" "fmt" - "strconv" "time" "github.com/circlefin/noble-cctp/x/cctp/types" - "github.com/cosmos/cosmos-sdk/types/bech32" "github.com/ethereum/go-ethereum/accounts/abi" ethtypes "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/crypto" @@ -28,13 +24,20 @@ const ( Forward string = "forward" ) +type Domain uint32 + +type TxState struct { + TxHash string + Msgs []*MessageState +} + type MessageState struct { - IrisLookupId string // hex encoded MessageSent bytes - Type string // 'mint' or 'forward' + IrisLookupId string // hex encoded MessageSent bytes + // Type string // 'mint' or 'forward' Status string // created, pending, attested, complete, failed, filtered Attestation string // hex encoded attestation - SourceDomain uint32 // source domain id - DestDomain uint32 // destination domain id + SourceDomain Domain // uint32 source domain id + DestDomain Domain // uint32 destination domain id SourceTxHash string DestTxHash string MsgSentBytes []byte // bytes of the MessageSent message transmitter event @@ -42,6 +45,7 @@ type MessageState struct { Channel string // "channel-%d" if a forward, empty if not a forward Created time.Time Updated time.Time + Nonce uint64 } // EvmLogToMessageState transforms an evm log into a messageState given an ABI @@ -58,90 +62,35 @@ func EvmLogToMessageState(abi abi.ABI, messageSent abi.Event, log *ethtypes.Log) messageState = &MessageState{ IrisLookupId: hashedHexStr, Status: Created, - SourceDomain: message.SourceDomain, - DestDomain: message.DestinationDomain, + SourceDomain: Domain(message.SourceDomain), + DestDomain: Domain(message.DestinationDomain), SourceTxHash: log.TxHash.Hex(), MsgSentBytes: rawMessageSentBytes, DestinationCaller: message.DestinationCaller, + Nonce: message.Nonce, Created: time.Now(), Updated: time.Now(), } if _, err := new(BurnMessage).Parse(message.MessageBody); err == nil { - messageState.Type = Mint - return messageState, nil - } - - if forward, err := new(MetadataMessage).Parse(message.MessageBody); err == nil { - messageState.Type = Forward - // add forward channel to object so we can filter later - messageState.Channel = "channel-" + strconv.Itoa(int(forward.Channel)) return messageState, nil } - return nil, errors.New(fmt.Sprintf("unable to parse tx into message, tx hash %s", log.TxHash.Hex())) -} - -// NobleLogToMessageState transforms a Noble log into a messageState -func NobleLogToMessageState(tx Tx) (messageState *MessageState, err error) { - - var eventsList []struct { - Events []Event `json:"events"` - } - err = json.Unmarshal([]byte(tx.TxResult.Log), &eventsList) - if err != nil { - return nil, errors.New("unable to parse log events") - } - - for _, event := range eventsList[0].Events { - if event.Type == "circle.cctp.v1.MessageSent" { - for _, attr := range event.Attributes { - if attr.Key == "message" { - encoded := attr.Value[1 : len(attr.Value)-1] - rawMessageSentBytes, err := base64.StdEncoding.DecodeString(encoded) - if err != nil { - continue - } - - hashed := crypto.Keccak256(rawMessageSentBytes) - hashedHexStr := hex.EncodeToString(hashed) - - msg, err := new(types.Message).Parse(rawMessageSentBytes) - if err != nil { - continue - } - - messageState = &MessageState{ - IrisLookupId: hashedHexStr, - Type: Mint, - Status: Created, - SourceDomain: msg.SourceDomain, - DestDomain: msg.DestinationDomain, - SourceTxHash: tx.Hash, - MsgSentBytes: rawMessageSentBytes, - DestinationCaller: msg.DestinationCaller, - Created: time.Now(), - Updated: time.Now(), - } - - return messageState, nil - } - } - } - } - - return nil, errors.New(fmt.Sprintf("unable to parse txn into message. tx hash %s", tx.Hash)) + return nil, fmt.Errorf("unable to parse tx into message, tx hash %s", log.TxHash.Hex()) } -// DecodeDestinationCaller transforms an encoded Noble cctp address into a noble bech32 address -// left padded input -> bech32 output -func DecodeDestinationCaller(input []byte) (string, error) { - if len(input) <= 12 { - return "", errors.New("destinationCaller is too short") - } - output, err := bech32.ConvertAndEncode("noble", input[12:]) - if err != nil { - return "", errors.New("unable to encode destination caller") - } - return output, nil +// Equal checks if two MessageState instances are equal +func (m *MessageState) Equal(other *MessageState) bool { + return (m.IrisLookupId == other.IrisLookupId && + m.Status == other.Status && + m.Attestation == other.Attestation && + m.SourceDomain == other.SourceDomain && + m.DestDomain == other.DestDomain && + m.SourceTxHash == other.SourceTxHash && + m.DestTxHash == other.DestTxHash && + bytes.Equal(m.MsgSentBytes, other.MsgSentBytes) && + bytes.Equal(m.DestinationCaller, other.DestinationCaller) && + m.Channel == other.Channel && + m.Created == other.Created && + m.Updated == other.Updated) } diff --git a/types/message_state_test.go b/types/message_state_test.go index 1917948..348959c 100644 --- a/types/message_state_test.go +++ b/types/message_state_test.go @@ -3,29 +3,34 @@ package types_test import ( "context" "fmt" + "math/big" + "os" + "testing" + "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/accounts/abi" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/ethclient" "github.com/pascaldekloe/etherstream" - "github.com/strangelove-ventures/noble-cctp-relayer/config" + "github.com/strangelove-ventures/noble-cctp-relayer/cmd" + ethinternal "github.com/strangelove-ventures/noble-cctp-relayer/ethereum" "github.com/strangelove-ventures/noble-cctp-relayer/types" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "math/big" - "os" - "testing" ) -var cfg config.Config +var cfg *types.Config func init() { - cfg = config.Parse("../.ignore/unit_tests.yaml") + var err error + cfg, err = cmd.Parse("../.ignore/unit_tests.yaml") + if err != nil { + panic(err) + } } func TestToMessageStateSuccess(t *testing.T) { - messageTransmitter, err := os.Open("../cmd/ethereum/abi/MessageTransmitter.json") + messageTransmitter, err := os.Open("../ethereum/abi/MessageTransmitter.json") require.Nil(t, err) messageTransmitterABI, err := abi.JSON(messageTransmitter) @@ -33,16 +38,18 @@ func TestToMessageStateSuccess(t *testing.T) { messageSent := messageTransmitterABI.Events["MessageSent"] - ethClient, err := ethclient.DialContext(context.Background(), cfg.Networks.Source.Ethereum.RPC) + ethClient, err := ethclient.DialContext(context.Background(), cfg.Chains["ethereum"].(*ethinternal.ChainConfig).RPC) require.Nil(t, err) - messageTransmitterAddress := common.HexToAddress("0x26413e8157CD32011E726065a5462e97dD4d03D9") + // changed to mainnet address + messageTransmitterAddress := common.HexToAddress("0x0a992d191deec32afe36203ad87d7d289a738f81") query := ethereum.FilterQuery{ Addresses: []common.Address{messageTransmitterAddress}, Topics: [][]common.Hash{{messageSent.ID}}, - FromBlock: big.NewInt(9573853), - ToBlock: big.NewInt(9573853), + // Changed + FromBlock: big.NewInt(18685801), + ToBlock: big.NewInt(18685801), } etherReader := etherstream.Reader{Backend: ethClient} @@ -51,24 +58,58 @@ func TestToMessageStateSuccess(t *testing.T) { require.Nil(t, err) messageState, err := types.EvmLogToMessageState(messageTransmitterABI, messageSent, &history[0]) + require.NoError(t, err) - event := make(map[string]interface{}) - _ = messageTransmitterABI.UnpackIntoMap(event, messageSent.Name, history[0].Data) - - rawMessageSentBytes := event["message"].([]byte) - - destCaller := make([]byte, 32) - assert.Equal(t, "e40ed0e983675678715972bd50d6abc417735051b0255f3c0916911957eda603", messageState.IrisLookupId) - assert.Equal(t, "mint", messageState.Type) - assert.Equal(t, "created", messageState.Status) - assert.Equal(t, "", messageState.Attestation) - assert.Equal(t, uint32(0), messageState.SourceDomain) - assert.Equal(t, uint32(4), messageState.DestDomain) - assert.Equal(t, "0xed567f5a62166d0a5df6cdcec710640b1c8079758cd1e1ac95085742f06afb04", messageState.SourceTxHash) - assert.Equal(t, "", messageState.DestTxHash) - assert.Equal(t, rawMessageSentBytes, messageState.MsgSentBytes) - assert.Equal(t, destCaller, messageState.DestinationCaller) - assert.Equal(t, "", messageState.Channel) fmt.Println(messageState) - require.Nil(t, err) + } + +// func TestToMessageStateSuccess(t *testing.T) { + +// messageTransmitter, err := os.Open("../cmd/ethereum/abi/MessageTransmitter.json") +// require.Nil(t, err) + +// messageTransmitterABI, err := abi.JSON(messageTransmitter) +// require.Nil(t, err) + +// messageSent := messageTransmitterABI.Events["MessageSent"] + +// ethClient, err := ethclient.DialContext(context.Background(), cfg.Networks.Source.Ethereum.RPC) +// require.Nil(t, err) + +// messageTransmitterAddress := common.HexToAddress("0x26413e8157CD32011E726065a5462e97dD4d03D9") + +// query := ethereum.FilterQuery{ +// Addresses: []common.Address{messageTransmitterAddress}, +// Topics: [][]common.Hash{{messageSent.ID}}, +// FromBlock: big.NewInt(9573853), +// ToBlock: big.NewInt(9573853), +// } + +// etherReader := etherstream.Reader{Backend: ethClient} + +// _, _, history, err := etherReader.QueryWithHistory(context.Background(), &query) +// require.Nil(t, err) + +// messageState, err := types.EvmLogToMessageState(messageTransmitterABI, messageSent, &history[0]) + +// event := make(map[string]interface{}) +// _ = messageTransmitterABI.UnpackIntoMap(event, messageSent.Name, history[0].Data) + +// rawMessageSentBytes := event["message"].([]byte) + +// destCaller := make([]byte, 32) +// assert.Equal(t, "e40ed0e983675678715972bd50d6abc417735051b0255f3c0916911957eda603", messageState.IrisLookupId) +// assert.Equal(t, "mint", messageState.Type) +// assert.Equal(t, "created", messageState.Status) +// assert.Equal(t, "", messageState.Attestation) +// assert.Equal(t, uint32(0), messageState.SourceDomain) +// assert.Equal(t, uint32(4), messageState.DestDomain) +// assert.Equal(t, "0xed567f5a62166d0a5df6cdcec710640b1c8079758cd1e1ac95085742f06afb04", messageState.SourceTxHash) +// assert.Equal(t, "", messageState.DestTxHash) +// assert.Equal(t, rawMessageSentBytes, messageState.MsgSentBytes) +// assert.Equal(t, destCaller, messageState.DestinationCaller) +// assert.Equal(t, "", messageState.Channel) +// fmt.Println(messageState) +// require.Nil(t, err) +// } diff --git a/types/sequence_map.go b/types/sequence_map.go index dab63f1..e997b4b 100644 --- a/types/sequence_map.go +++ b/types/sequence_map.go @@ -8,22 +8,22 @@ import ( type SequenceMap struct { mu sync.Mutex // map destination domain -> minter account sequence - sequenceMap map[uint32]int64 + sequenceMap map[Domain]uint64 } func NewSequenceMap() *SequenceMap { return &SequenceMap{ - sequenceMap: map[uint32]int64{}, + sequenceMap: map[Domain]uint64{}, } } -func (m *SequenceMap) Put(destDomain uint32, val int64) { +func (m *SequenceMap) Put(destDomain Domain, val uint64) { m.mu.Lock() defer m.mu.Unlock() m.sequenceMap[destDomain] = val } -func (m *SequenceMap) Next(destDomain uint32) int64 { +func (m *SequenceMap) Next(destDomain Domain) uint64 { m.mu.Lock() defer m.mu.Unlock() result := m.sequenceMap[destDomain] diff --git a/types/state.go b/types/state.go index a957b0f..c687241 100644 --- a/types/state.go +++ b/types/state.go @@ -5,7 +5,7 @@ import ( ) // StateMap wraps sync.Map with type safety -// maps source tx hash -> MessageState +// maps source tx hash -> TxState type StateMap struct { internal sync.Map } @@ -16,18 +16,20 @@ func NewStateMap() *StateMap { } } -func (sm *StateMap) Load(key string) (value *MessageState, ok bool) { +// load loads the message states tied to a specific transaction hash +func (sm *StateMap) Load(key string) (value *TxState, ok bool) { internalResult, ok := sm.internal.Load(key) if !ok { return nil, ok } - return internalResult.(*MessageState), ok + return internalResult.(*TxState), ok } func (sm *StateMap) Delete(key string) { sm.internal.Delete(key) } -func (sm *StateMap) Store(key string, value *MessageState) { +// store stores the message states tied to a specific transaction hash +func (sm *StateMap) Store(key string, value *TxState) { sm.internal.Store(key, value) } diff --git a/types/state_test.go b/types/state_test.go index 40971e5..7bb5e1e 100644 --- a/types/state_test.go +++ b/types/state_test.go @@ -1,28 +1,50 @@ package types import ( - "fmt" "testing" -) -var stateMap StateMap + "github.com/stretchr/testify/require" +) -func TestX(t *testing.T) { +func TestStateHandling(t *testing.T) { stateMap := NewStateMap() - msg := MessageState{IrisLookupId: "123", Status: Filtered} - stateMap.Store("123", &msg) - - lMsg, _ := stateMap.Load("123") - fmt.Println(lMsg) - - msg.Status = Complete - - f, _ := stateMap.Load("123") - - lMsg.Status = Created - - f, _ = stateMap.Load("123") - - fmt.Println(f) + txHash := "123456789" + msg := MessageState{ + SourceTxHash: txHash, + IrisLookupId: "123", + Status: Filtered, + MsgSentBytes: []byte("i like turtles"), + } + + stateMap.Store(txHash, &TxState{ + TxHash: txHash, + Msgs: []*MessageState{ + &msg, + }, + }) + + loadedMsg, _ := stateMap.Load(txHash) + require.True(t, msg.Equal(loadedMsg.Msgs[0])) + + loadedMsg.Msgs[0].Status = Complete + + // Becasue it is a pointer, no need to re-store to state + // message status should be updated with out re-storing. + loadedMsg2, _ := stateMap.Load(txHash) + require.Equal(t, Complete, loadedMsg2.Msgs[0].Status) + + // even though loadedMsg is a pointer, if we add to the array, we need to re-store in cache. + msg2 := MessageState{ + SourceTxHash: txHash, + IrisLookupId: "123", + Status: Filtered, + MsgSentBytes: []byte("mock bytes 2"), + } + + loadedMsg.Msgs = append(loadedMsg.Msgs, &msg2) + stateMap.Store(txHash, loadedMsg) + + loadedMsg3, _ := stateMap.Load(txHash) + require.Equal(t, 2, len(loadedMsg3.Msgs)) }