diff --git a/cmd/ethereum/broadcast.go b/cmd/ethereum/broadcast.go new file mode 100644 index 0000000..d2d170a --- /dev/null +++ b/cmd/ethereum/broadcast.go @@ -0,0 +1,104 @@ +package ethereum + +import ( + "context" + "cosmossdk.io/log" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + ctypes "github.com/cometbft/cometbft/rpc/core/types" + "github.com/cosmos/cosmos-sdk/crypto/keys/secp256k1" + "github.com/cosmos/cosmos-sdk/types/bech32" + "github.com/strangelove-ventures/noble-cctp-relayer/config" + "github.com/strangelove-ventures/noble-cctp-relayer/types" + "io" + "net/http" + "strconv" + "time" +) + +// Broadcast broadcasts a message to Ethereum +func Broadcast( + cfg config.Config, + logger log.Logger, + msg *types.MessageState, + sequenceMap *types.SequenceMap, +) (*ctypes.ResultBroadcastTx, error) { + + // build txn + attestationBytes, err := hex.DecodeString(msg.Attestation[2:]) + if err != nil { + return nil, errors.New("unable to decode message attestation") + } + + // get priv key + ethereumAddress := cfg.Networks.Minters[0].MinterAddress + keyBz, _ := hex.DecodeString(cfg.Networks.Minters[4].MinterPrivateKey) + privKey := secp256k1.PrivKey{Key: keyBz} + + // sign tx + addr, _ := bech32.ConvertAndEncode("noble", privKey.PubKey().Address()) + if addr != ethereumAddress { + return nil, fmt.Errorf("private key (%s) does not match noble address (%s)", addr, ethereumAddress) + } + + // set up eth client + + // broadcast txn + + for attempt := 0; attempt <= cfg.Networks.Destination.Ethereum.BroadcastRetries; attempt++ { + logger.Debug(fmt.Sprintf( + "Broadcasting %s message from %d to %d: with source tx hash %s", + msg.Type, + msg.SourceDomain, + msg.DestDomain, + msg.SourceTxHash)) + + // TODO Account sequence lock is implemented but gets out of sync with remote. + // accountSequence := sequenceMap.Next(cfg.Networks.Destination.Noble.DomainId) + nonce, err := GetEthereumAccountNonce(cfg.Networks.Destination.Ethereum.RPC, ethereumAddress) + if err != nil { + logger.Error("unable to retrieve account nonce") + } + + // broadcast txn + // TODO do for Erh + rpcResponse, err := rpcClient.BroadcastTxSync(context.Background(), txBytes) + if err == nil && rpcResponse.Code == 0 { + msg.Status = types.Complete + return rpcResponse, nil + } + if err != nil { + logger.Error(fmt.Sprintf("error during broadcast: %s", err.Error())) + logger.Info(fmt.Sprintf("Retrying in %d seconds", cfg.Networks.Destination.Noble.BroadcastRetryInterval)) + time.Sleep(time.Duration(cfg.Networks.Destination.Noble.BroadcastRetryInterval) * time.Second) + continue + } + // check tx response code + logger.Error(fmt.Sprintf("received non zero : %d - %s", rpcResponse.Code, rpcResponse.Log)) + logger.Info(fmt.Sprintf("Retrying in %d seconds", cfg.Networks.Destination.Noble.BroadcastRetryInterval)) + time.Sleep(time.Duration(cfg.Networks.Destination.Noble.BroadcastRetryInterval) * time.Second) + } + msg.Status = types.Failed + + return nil, errors.New("reached max number of broadcast attempts") +} + +// TODO +func GetEthereumAccountNonce(urlBase string, address string) (int64, error) { + rawResp, err := http.Get(fmt.Sprintf("%s/cosmos/auth/v1beta1/accounts/%s", urlBase, address)) + if err != nil { + return 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, errors.New("unable to parse account number, sequence") + } + accountNumber, _ := strconv.ParseInt(resp.AccountNumber, 10, 0) + accountSequence, _ := strconv.ParseInt(resp.Sequence, 10, 0) + + return accountNumber, nil +} diff --git a/cmd/noble/listener.go b/cmd/noble/listener.go new file mode 100644 index 0000000..1eb6e43 --- /dev/null +++ b/cmd/noble/listener.go @@ -0,0 +1,20 @@ +package noble + +import ( + "cosmossdk.io/log" + "fmt" + "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)) + + // constantly query for blocks + +} diff --git a/cmd/noble/listener_test.go b/cmd/noble/listener_test.go new file mode 100644 index 0000000..1c3156f --- /dev/null +++ b/cmd/noble/listener_test.go @@ -0,0 +1,51 @@ +package noble + +import ( + "cosmossdk.io/log" + "github.com/rs/zerolog" + "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) +} + +// TODO +func TestStartListener(t *testing.T) { + + cfg.Networks.Source.Noble.StartBlock = 9702735 + cfg.Networks.Source.Noble.LookbackPeriod = 0 + go 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/process.go b/cmd/process.go index e755e7d..48270db 100644 --- a/cmd/process.go +++ b/cmd/process.go @@ -64,6 +64,9 @@ func Start(cmd *cobra.Command, args []string) { if Cfg.Networks.Source.Ethereum.Enabled { ethereum.StartListener(Cfg, Logger, processingQueue) } + if Cfg.Networks.Source.Noble.Enabled { + noble.StartListener(Cfg, Logger, processingQueue) + } // ...register more chain listeners here wg.Wait() diff --git a/cmd/root.go b/cmd/root.go index ab0f333..3868205 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -52,7 +52,7 @@ func init() { Logger.Info("successfully parsed config file", "location", cfgFile) // set defaults - // if start block not set, default to latest + // if Ethereum start block not set, default to latest if Cfg.Networks.Source.Ethereum.StartBlock == 0 { client, _ := ethclient.Dial(Cfg.Networks.Source.Ethereum.RPC) defer client.Close() @@ -60,6 +60,15 @@ func init() { Cfg.Networks.Source.Ethereum.StartBlock = header.Number.Uint64() } + // if Noble start block not set, default to latest + if Cfg.Networks.Source.Ethereum.StartBlock == 0 { + // TODO set to latest block + 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() + } + // start api server go startApi() }) diff --git a/config/config.go b/config/config.go index f459e1d..27a191e 100644 --- a/config/config.go +++ b/config/config.go @@ -18,8 +18,22 @@ type Config struct { 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"` + Enabled bool `yaml:"enabled"` + } `yaml:"noble"` } `yaml:"source"` Destination struct { + Ethereum struct { + DomainId uint32 `yaml:"domain-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"` diff --git a/integration/generate_eth_goerli_deposit_for_burn_with_forward_test.go b/integration/eth_burn_to_noble_mint_and_forward_test.go similarity index 79% rename from integration/generate_eth_goerli_deposit_for_burn_with_forward_test.go rename to integration/eth_burn_to_noble_mint_and_forward_test.go index 013160d..93b4729 100644 --- a/integration/generate_eth_goerli_deposit_for_burn_with_forward_test.go +++ b/integration/eth_burn_to_noble_mint_and_forward_test.go @@ -1,8 +1,6 @@ package integration_testing import ( - "context" - "encoding/json" "fmt" "github.com/cosmos/cosmos-sdk/testutil/testdata" "github.com/cosmos/cosmos-sdk/types/bech32" @@ -14,16 +12,13 @@ import ( eth "github.com/strangelove-ventures/noble-cctp-relayer/cmd/ethereum" "github.com/strangelove-ventures/noble-cctp-relayer/types" "github.com/stretchr/testify/require" - "io" "math/big" - "net/http" - "strconv" "testing" "time" ) -// TestGenerateEthDepositForBurn generates and broadcasts a depositForBurnWithMetadata on Ethereum Goerli -func TestGenerateEthDepositForBurnWithForward(t *testing.T) { +// TestEthBurnToNobleMintAndForward generates a depositForBurn on Ethereum Goerli and mints + forwards on Noble +func TestEthBurnToNobleMintAndForward(t *testing.T) { setupTest() // start up relayer @@ -102,22 +97,3 @@ func TestGenerateEthDepositForBurnWithForward(t *testing.T) { // verify dydx balance require.Equal(t, originalDydx+BurnAmount.Uint64(), getDydxBalance(dydxAddress)) } - -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)) - body, _ := io.ReadAll(rawResponse.Body) - response := BalanceResponse{} - _ = json.Unmarshal(body, &response) - res, _ := strconv.ParseInt(response.Balance.Amount, 0, 0) - return uint64(res) -} - -func getEthereumLatestBlockHeight(t *testing.T) uint64 { - client, err := ethclient.Dial(cfg.Networks.Source.Ethereum.RPC) - require.Nil(t, err) - - header, err := client.HeaderByNumber(context.Background(), nil) - require.Nil(t, err) - return header.Number.Uint64() -} diff --git a/integration/generate_eth_goerli_deposit_for_burn_test.go b/integration/eth_burn_to_noble_mint_test.go similarity index 83% rename from integration/generate_eth_goerli_deposit_for_burn_test.go rename to integration/eth_burn_to_noble_mint_test.go index a113ed9..72ee943 100644 --- a/integration/generate_eth_goerli_deposit_for_burn_test.go +++ b/integration/eth_burn_to_noble_mint_test.go @@ -1,7 +1,6 @@ package integration_testing import ( - "encoding/json" "fmt" "github.com/cosmos/cosmos-sdk/testutil/testdata" "github.com/cosmos/cosmos-sdk/types/bech32" @@ -13,16 +12,13 @@ import ( eth "github.com/strangelove-ventures/noble-cctp-relayer/cmd/ethereum" "github.com/strangelove-ventures/noble-cctp-relayer/types" "github.com/stretchr/testify/require" - "io" "math/big" - "net/http" - "strconv" "testing" "time" ) -// TestGenerateEthDepositForBurn generates and broadcasts a depositForBurn on Ethereum Goerli -func TestGenerateEthDepositForBurn(t *testing.T) { +// TestEthBurnToNobleMint generates a depositForBurn on Ethereum Goerli and mints on Noble +func TestEthBurnToNobleMint(t *testing.T) { setupTest() // start up relayer @@ -89,12 +85,3 @@ func TestGenerateEthDepositForBurn(t *testing.T) { // verify noble balance require.Equal(t, originalNobleBalance+burnAmount.Uint64(), getNobleBalance(nobleAddress)) } - -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) -} diff --git a/integration/noble_burn_to_eth_mint_test.go b/integration/noble_burn_to_eth_mint_test.go new file mode 100644 index 0000000..4ec6ff1 --- /dev/null +++ b/integration/noble_burn_to_eth_mint_test.go @@ -0,0 +1,95 @@ +package integration_testing + +import ( + "context" + "fmt" + "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/types" + "github.com/stretchr/testify/require" + "log" + "math/big" + "strings" + "testing" + "time" +) + +// TestNobleBurnToEthMint generates and broadcasts a depositForBurn on Noble +// and broadcasts on Ethereum Goerli +func TestNobleBurnToEthMint(t *testing.T) { + setupTest() + + // start up relayer + cfg.Networks.Source.Noble.StartBlock = getNobleLatestBlockHeight() + cfg.Networks.Source.Noble.LookbackPeriod = 0 + + fmt.Println("Starting relayer...") + processingQueue := make(chan *types.MessageState, 10) + go noble.StartListener(cfg, logger, processingQueue) + go cmd.StartProcessor(cfg, logger, processingQueue, sequenceMap) + + fmt.Println("Building Noble depositForBurn txn...") + ethAddress := "0x971c54a6Eb782fAccD00bc3Ed5E934Cc5bD8e3Ef" + fmt.Println("Minting on Ethereum to https://goerli.etherscan.io/address/" + ethAddress) + + // verify ethereum usdc amount + client, _ := ethclient.Dial(testCfg.Networks.Ethereum.RPC) + defer client.Close() + originalEthBalance := getEthBalance(client, ethAddress) + + // deposit for burn + client, err := ethclient.Dial(testCfg.Networks.Ethereum.RPC) + require.Nil(t, err) + defer client.Close() + + var burnAmount = big.NewInt(1) + + // TODO sample deposit for burn noble + + time.Sleep(5 * time.Second) + //fmt.Printf("Update pending: https://goerli.etherscan.io/tx/%s\n", tx.Hash().String()) + + fmt.Println("Checking eth wallet...") + for i := 0; i < 60; i++ { + if originalEthBalance+burnAmount.Uint64() == getEthBalance(client, ethAddress) { + fmt.Println("Successfully minted at https://goerli.etherscan.io/address/" + ethAddress) + return + } + time.Sleep(1 * time.Second) + } + // verify eth balance + require.Equal(t, originalEthBalance+burnAmount.Uint64(), getEthBalance(client, ethAddress)) +} + +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() +} diff --git a/integration/types.go b/integration/types.go index 477192f..773a82b 100644 --- a/integration/types.go +++ b/integration/types.go @@ -1,12 +1,20 @@ package integration_testing type BalanceResponse struct { - Balance Coin `json:"balance"` + Balance struct { + Denom string `json:"denom"` + Amount string `json:"amount"` + } `json:"balance"` } -type Coin struct { - Denom string `json:"denom"` - Amount string `json:"amount"` +type NobleBlockResponse struct { + Result struct { + Block struct { + Header struct { + Height string `json:"height"` + } `json:"header"` + } `json:"block"` + } `json:"result"` } type EthereumRPCPayload struct { diff --git a/integration/util.go b/integration/util.go new file mode 100644 index 0000000..1b0421a --- /dev/null +++ b/integration/util.go @@ -0,0 +1,50 @@ +package integration_testing + +import ( + "context" + "encoding/json" + "fmt" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/stretchr/testify/require" + "io" + "net/http" + "strconv" + "testing" +) + +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)) + body, _ := io.ReadAll(rawResponse.Body) + response := BalanceResponse{} + _ = json.Unmarshal(body, &response) + res, _ := strconv.ParseInt(response.Balance.Amount, 0, 0) + return uint64(res) +} + +func getEthereumLatestBlockHeight(t *testing.T) uint64 { + client, err := ethclient.Dial(cfg.Networks.Source.Ethereum.RPC) + require.Nil(t, err) + + header, err := client.HeaderByNumber(context.Background(), nil) + require.Nil(t, err) + 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) + response := NobleBlockResponse{} + _ = json.Unmarshal(body, &response) + res, _ := strconv.ParseInt(response.Result.Block.Header.Height, 0, 0) + return uint64(res) +}