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 59% rename from cmd/circle/attestation.go rename to circle/attestation.go index e083ce3..ca069fc 100644 --- a/cmd/circle/attestation.go +++ b/circle/attestation.go @@ -8,23 +8,22 @@ import ( "time" "cosmossdk.io/log" - "github.com/strangelove-ventures/noble-cctp-relayer/config" "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, txHash string, sourceDomain, destDomain uint32) *types.AttestationResponse { - logger.Debug(fmt.Sprintf("Checking attestation for %s%s%s for source tx %s from %d to %d", cfg.Circle.AttestationBaseUrl, "0x", irisLookupId, txHash, sourceDomain, destDomain)) +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) @@ -39,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 1e68cc5..a2fa252 100644 --- a/cmd/circle/attestation_test.go +++ b/circle/attestation_test.go @@ -6,12 +6,12 @@ import ( "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" ) -var cfg config.Config +var cfg types.Config var logger log.Logger func init() { @@ -20,12 +20,12 @@ func init() { } func TestAttestationIsReady(t *testing.T) { - resp := circle.CheckAttestation(cfg, logger, "85bbf7e65a5992e6317a61f005e06d9972a033d71b514be183b179e1b47723fe", "", 0, 4) + 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", "", 0, 4) + 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 2fe1a95..0000000 --- a/cmd/ethereum/broadcast.go +++ /dev/null @@ -1,151 +0,0 @@ -package ethereum - -import ( - "context" - "encoding/hex" - "errors" - "fmt" - "math/big" - "regexp" - "strconv" - "time" - - "cosmossdk.io/log" - "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/crypto" - "github.com/ethereum/go-ethereum/ethclient" - "github.com/strangelove-ventures/noble-cctp-relayer/config" - "github.com/strangelove-ventures/noble-cctp-relayer/types" -) - -// Broadcast broadcasts a message to Ethereum -func Broadcast( - ctx context.Context, - 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) - if err != nil { - return nil, fmt.Errorf("unable to dial ethereum client: %w", err) - } - defer client.Close() - - backend := NewContractBackendWrapper(client) - - 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)) - if err != nil { - return nil, fmt.Errorf("unable to create auth: %w", err) - } - - messageTransmitter, err := NewMessageTransmitter(common.HexToAddress(cfg.Networks.Source.Ethereum.MessageTransmitter), backend) - if err != nil { - return nil, fmt.Errorf("unable to create message transmitter: %w", err) - } - - 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) - - // TODO remove - nextNonce, err := GetEthereumAccountNonce(cfg.Networks.Destination.Ethereum.RPC, ethereumAddress) - 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 - return nil, errors.New("receive message was already broadcasted") - } - } - - // 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" { - msg.Status = types.Complete - return nil, parsedErr - } - - 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(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 - // 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 != 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 4848489..0000000 --- a/cmd/noble/broadcast.go +++ /dev/null @@ -1,255 +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/cmd/noble/cosmos" - "github.com/strangelove-ventures/noble-cctp-relayer/config" - "github.com/strangelove-ventures/noble-cctp-relayer/types" -) - -// Broadcast broadcasts a message to Noble -func Broadcast( - ctx context.Context, - 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, fmt.Errorf("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, fmt.Errorf("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") - } - - cc, err := cosmos.NewProvider(cfg.Networks.Source.Noble.RPC) - if err != nil { - return nil, fmt.Errorf("unable to build cosmos provider for noble: %w", err) - } - - for attempt := 0; attempt <= cfg.Networks.Destination.Noble.BroadcastRetries; attempt++ { - used, err := cc.QueryUsedNonce(ctx, msg.SourceDomain, msg.Nonce) - if err != nil { - return nil, fmt.Errorf("unable to query used nonce: %w", err) - } - - if used { - msg.Status = types.Complete - return nil, fmt.Errorf("noble cctp minter nonce %d already used", msg.Nonce) - } - - 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 { - return nil, fmt.Errorf("failed to retrieve account number and sequence: %w", err) - } - - 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), - ) - if err != nil { - return nil, fmt.Errorf("failed to sign tx: %w", err) - } - - if err := txBuilder.SetSignatures(sigV2); err != nil { - return nil, fmt.Errorf("failed to set signatures: %w", err) - } - - // Generated Protobuf-encoded bytes. - txBytes, err := sdkContext.TxConfig.TxEncoder()(txBuilder.GetTx()) - if err != nil { - return nil, fmt.Errorf("failed to proto encode tx: %w", 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 b52e824..0000000 --- a/cmd/noble/listener.go +++ /dev/null @@ -1,113 +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 { - parsedMsgs, err := types.NobleLogToMessageState(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 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 ad7adc5..1d6572d 100644 --- a/cmd/process.go +++ b/cmd/process.go @@ -1,21 +1,14 @@ package cmd import ( - "bytes" "context" - "encoding/hex" "fmt" "os" - "strings" - "sync" "time" "cosmossdk.io/log" "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/circle" "github.com/strangelove-ventures/noble-cctp-relayer/types" ) @@ -35,151 +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) + for name, cfg := range Cfg.Chains { + c, err := cfg.Chain(name) + if err != nil { + Logger.Error("Error creating chain", "err: ", err) + os.Exit(1) + } - if err != nil { - Logger.Error("Error retrieving Noble account sequence") - os.Exit(1) - } - sequenceMap.Put(key, nextMinterSequence) + if err := c.InitializeBroadcaster(cmd.Context(), Logger, sequenceMap); err != nil { + Logger.Error("Error initializing broadcaster", "err: ", err) + os.Exit(1) } - // ...initialize more here - } + go c.StartListener(cmd.Context(), Logger, processingQueue) - // messageState processing queue - var processingQueue = make(chan *types.MessageState, 10000) + if _, ok := registeredDomains[c.Domain()]; ok { + Logger.Error("Duplicate domain found", "domain", c.Domain()) + os.Exit(1) + } - // spin up Processor worker pool - for i := 0; i < int(Cfg.ProcessorWorkerCount); i++ { - go StartProcessor(cmd.Context(), Cfg, Logger, processingQueue, sequenceMap) + registeredDomains[c.Domain()] = c } - // listeners listen for events, parse them, and enqueue them to processingQueue - if Cfg.Networks.Source.Ethereum.Enabled { - ethereum.StartListener(Cfg, Logger, processingQueue) - } - 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(ctx context.Context, 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, 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) - processingQueue <- msg - continue - } else if response.Status == "pending_confirmations" { - logger.Debug("Attestation is still pending for 0x" + msg.IrisLookupId + ". Retrying...") + // 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" { - logger.Debug("Attestation is complete for 0x" + msg.IrisLookupId + ". Retrying...") - 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(ctx, cfg, logger, msg, sequenceMap) - if err != nil { - logger.Error("unable to mint on Ethereum", "err", err) - processingQueue <- msg - continue - } - fullLog, err := response.MarshalJSON() - if err != nil { - logger.Error("error on marshall", err) - } - msg.DestTxHash = response.Hash().Hex() - logger.Info(fmt.Sprintf("Successfully broadcast %s to Ethereum. Tx hash: %s, FULL LOG: %s", msg.SourceTxHash, msg.DestTxHash, string(fullLog))) - case 4: // noble - response, err := noble.Broadcast(ctx, 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() - logger.Info(fmt.Sprintf("Successfully broadcast %s to Noble. Tx hash: %s", msg.SourceTxHash, msg.DestTxHash)) + for domain, msgs := range broadcastMsgs { + chain, ok := registeredDomains[domain] + if !ok { + logger.Error("No chain registered for domain", "domain", domain) + continue } - // ...add minters for different domains here - msg.Status = types.Complete - msg.Updated = time.Now() + 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() + } + + } + 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", @@ -189,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 - } - } - 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 { - if msg.Type != types.Mint { - logger.Info(fmt.Sprintf("Filtered tx %s because it's a not a burn", msg.SourceTxHash)) +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 } - return false + + 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 de02250..afb98a1 100644 --- a/cmd/process_test.go +++ b/cmd/process_test.go @@ -9,194 +9,233 @@ import ( "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" ) -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(context.TODO(), 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(context.TODO(), 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(context.TODO(), 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(context.TODO(), 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(context.TODO(), 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(context.TODO(), 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/cmd/noble/cosmos/codec.go b/cosmos/codec.go similarity index 95% rename from cmd/noble/cosmos/codec.go rename to cosmos/codec.go index 73e8ff0..6ac6f0d 100644 --- a/cmd/noble/cosmos/codec.go +++ b/cosmos/codec.go @@ -7,15 +7,14 @@ import ( "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" "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{}, + auth.AppModuleBasic{}, // authz.AppModuleBasic{}, // bank.AppModuleBasic{}, cctp.AppModuleBasic{}, diff --git a/cmd/noble/cosmos/cosmosprovider.go b/cosmos/cosmosprovider.go similarity index 100% rename from cmd/noble/cosmos/cosmosprovider.go rename to cosmos/cosmosprovider.go diff --git a/cmd/noble/cosmos/grpc_shim.go b/cosmos/grpc_shim.go similarity index 100% rename from cmd/noble/cosmos/grpc_shim.go rename to cosmos/grpc_shim.go diff --git a/cmd/noble/cosmos/query.go b/cosmos/query.go similarity index 89% rename from cmd/noble/cosmos/query.go rename to cosmos/query.go index 804c806..448d728 100644 --- a/cmd/noble/cosmos/query.go +++ b/cosmos/query.go @@ -4,11 +4,11 @@ import ( "context" "fmt" - "github.com/circlefin/noble-cctp/x/cctp/types" 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 { @@ -38,15 +38,15 @@ func (cc *CosmosProvider) QueryABCI(ctx context.Context, req abci.RequestQuery) return result.Response, nil } -func (cc *CosmosProvider) QueryUsedNonce(ctx context.Context, sourceDomain uint32, nonce uint64) (bool, error) { +func (cc *CosmosProvider) QueryUsedNonce(ctx context.Context, sourceDomain types.Domain, nonce uint64) (bool, error) { qc := cctptypes.NewQueryClient(cc) - params := &types.QueryGetUsedNonceRequest{ - SourceDomain: sourceDomain, + params := &cctptypes.QueryGetUsedNonceRequest{ + SourceDomain: uint32(sourceDomain), Nonce: nonce, } - _, err := qc.UsedNonce(context.Background(), params) + _, 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 diff --git a/cmd/noble/cosmos/query_test.go b/cosmos/query_test.go similarity index 86% rename from cmd/noble/cosmos/query_test.go rename to cosmos/query_test.go index d0156c5..0ecd20f 100644 --- a/cmd/noble/cosmos/query_test.go +++ b/cosmos/query_test.go @@ -4,7 +4,7 @@ import ( "context" "testing" - "github.com/strangelove-ventures/noble-cctp-relayer/cmd/noble/cosmos" + "github.com/strangelove-ventures/noble-cctp-relayer/cosmos" "github.com/stretchr/testify/require" ) 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/cmd/ethereum/broadcast_test.go b/ethereum/broadcast_test.go similarity index 82% rename from cmd/ethereum/broadcast_test.go rename to ethereum/broadcast_test.go index 76ad9f6..40e1fd7 100644 --- a/cmd/ethereum/broadcast_test.go +++ b/ethereum/broadcast_test.go @@ -9,7 +9,7 @@ import ( "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/ethereum" + "github.com/strangelove-ventures/noble-cctp-relayer/ethereum/contracts" "github.com/stretchr/testify/require" ) @@ -28,7 +28,7 @@ func TestEthUsedNonce(t *testing.T) { require.NoError(t, err) defer client.Close() - messageTransmitter, err := ethereum.NewMessageTransmitter(common.HexToAddress("0x0a992d191deec32afe36203ad87d7d289a738f81"), client) + messageTransmitter, err := contracts.NewMessageTransmitter(common.HexToAddress("0x0a992d191deec32afe36203ad87d7d289a738f81"), client) require.NoError(t, err) co := &bind.CallOpts{ 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/cmd/ethereum/contract_backend_wrapper.go b/ethereum/contract_backend_wrapper.go similarity index 100% rename from cmd/ethereum/contract_backend_wrapper.go rename to ethereum/contract_backend_wrapper.go 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 adb5d77..9219d50 100644 --- a/go.mod +++ b/go.mod @@ -25,6 +25,7 @@ require ( 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 ( @@ -295,7 +296,6 @@ require ( 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/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 9c1d1cb..0000000 --- a/integration/eth_burn_to_noble_mint_and_forward_test.go +++ /dev/null @@ -1,101 +0,0 @@ -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" - "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" -) - -// 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(context.TODO(), 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 9be6c05..5c52194 100644 --- a/integration/eth_burn_to_noble_mint_test.go +++ b/integration/eth_burn_to_noble_mint_test.go @@ -14,50 +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" ) +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(context.TODO(), 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) @@ -70,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 224cf50..9e36746 100644 --- a/integration/eth_multi_send_test.go +++ b/integration/eth_multi_send_test.go.bak @@ -176,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(context.TODO(), 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 1aa6c5b..38b2957 100644 --- a/integration/noble_burn_to_eth_mint_test.go +++ b/integration/noble_burn_to_eth_mint_test.go @@ -3,22 +3,12 @@ package integration_testing import ( "context" "encoding/hex" - "encoding/json" - "errors" "fmt" - "io" - "log" - "math/big" - "net/http" - "strconv" - "strings" "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" @@ -28,36 +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" ) // 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(context.TODO(), 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) @@ -72,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) @@ -92,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{ @@ -111,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), } @@ -125,6 +121,7 @@ func TestNobleBurnToEthMint(t *testing.T) { sdkContext.TxConfig, uint64(accountSequence), ) + require.Nil(t, err) err = txBuilder.SetSignatures(sigV2) require.Nil(t, err) @@ -133,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 c8a4293..a965650 100644 --- a/integration/noble_multi_send_test.go +++ b/integration/noble_multi_send_test.go.bak @@ -126,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(context.TODO(), 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 d28c0d3..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 @@ -59,8 +62,8 @@ 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, @@ -70,100 +73,24 @@ func EvmLogToMessageState(abi abi.ABI, messageSent abi.Event, log *ethtypes.Log) } 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, fmt.Errorf("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, error) { - var eventsList []struct { - Events []Event `json:"events"` - } - if tx.TxResult.Code != 0 { - return nil, nil - } - if err := json.Unmarshal([]byte(tx.TxResult.Log), &eventsList); err != nil { - return nil, fmt.Errorf("unable to parse log events: %s", tx.TxResult.Log) - } - - var messageStates []*MessageState - - for i, log := range eventsList { - for j, event := range log.Events { - if event.Type == "circle.cctp.v1.MessageSent" { - fmt.Printf("Saw cctp message %s - %d:%d\n", tx.Hash, i, j) - var parsed bool - var parseErrs error - for _, attr := range event.Attributes { - if attr.Key == "message" { - fmt.Printf("Saw message attribute %s - %d:%d\n", tx.Hash, i, j) - encoded := attr.Value[1 : len(attr.Value)-1] - rawMessageSentBytes, err := base64.StdEncoding.DecodeString(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 - - messageState := &MessageState{ - IrisLookupId: hashedHexStr, - Type: Mint, - Status: Created, - SourceDomain: msg.SourceDomain, - DestDomain: msg.DestinationDomain, - Nonce: msg.Nonce, - SourceTxHash: tx.Hash, - MsgSentBytes: rawMessageSentBytes, - DestinationCaller: msg.DestinationCaller, - Created: time.Now(), - Updated: time.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 - -} - -// 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)) }