diff --git a/cmd/peggo/options.go b/cmd/peggo/options.go index a7a31737..60e05e69 100644 --- a/cmd/peggo/options.go +++ b/cmd/peggo/options.go @@ -137,50 +137,6 @@ func initCosmosKeyOptions( }) } -func initEthereumOptions( - cmd *cli.Cmd, - ethChainID **int, - ethNodeRPC **string, - ethNodeAlchemyWS **string, - ethGasPriceAdjustment **float64, - ethMaxGasPrice **string, -) { - *ethChainID = cmd.Int(cli.IntOpt{ - Name: "eth-chain-id", - Desc: "Specify Chain ID of the Ethereum network.", - EnvVar: "PEGGO_ETH_CHAIN_ID", - Value: 42, - }) - - *ethNodeRPC = cmd.String(cli.StringOpt{ - Name: "eth-node-http", - Desc: "Specify HTTP endpoint for an Ethereum node.", - EnvVar: "PEGGO_ETH_RPC", - Value: "http://localhost:1317", - }) - - *ethNodeAlchemyWS = cmd.String(cli.StringOpt{ - Name: "eth-node-alchemy-ws", - Desc: "Specify websocket url for an Alchemy ethereum node.", - EnvVar: "PEGGO_ETH_ALCHEMY_WS", - Value: "", - }) - - *ethGasPriceAdjustment = cmd.Float64(cli.Float64Opt{ - Name: "eth_gas_price_adjustment", - Desc: "gas price adjustment for Ethereum transactions", - EnvVar: "PEGGO_ETH_GAS_PRICE_ADJUSTMENT", - Value: float64(1.3), - }) - - *ethMaxGasPrice = cmd.String(cli.StringOpt{ - Name: "eth-max-gas-price", - Desc: "Specify Max gas price for Ethereum Transactions in GWei", - EnvVar: "PEGGO_ETH_MAX_GAS_PRICE", - Value: "500gwei", - }) -} - func initEthereumKeyOptions( cmd *cli.Cmd, ethKeystoreDir **string, @@ -274,73 +230,252 @@ func initStatsdOptions( }) } -// initRelayerOption sets options for relayer. -func initRelayerOptions( - cmd *cli.Cmd, - relayValsets **bool, - relayValsetOffsetDur **string, - relayBatches **bool, - relayBatchOffsetDur **string, - pendingTxWaitDuration **string, -) { - *relayValsets = cmd.Bool(cli.BoolOpt{ +type Config struct { + // Cosmos params + cosmosChainID *string + cosmosGRPC *string + tendermintRPC *string + cosmosGasPrices *string + + // Cosmos Key Management + cosmosKeyringDir *string + cosmosKeyringAppName *string + cosmosKeyringBackend *string + + cosmosKeyFrom *string + cosmosKeyPassphrase *string + cosmosPrivKey *string + cosmosUseLedger *bool + + // Ethereum params + ethChainID *int + ethNodeRPC *string + ethNodeAlchemyWS *string + ethGasPriceAdjustment *float64 + ethMaxGasPrice *string + + // Ethereum Key Management + ethKeystoreDir *string + ethKeyFrom *string + ethPassphrase *string + ethPrivKey *string + ethUseLedger *bool + + // Relayer config + relayValsets *bool + relayValsetOffsetDur *string + relayBatches *bool + relayBatchOffsetDur *string + pendingTxWaitDuration *string + + // Batch requester config + minBatchFeeUSD *float64 + + coingeckoApi *string +} + +func initConfig(cmd *cli.Cmd) Config { + cfg := Config{} + + /** Injective **/ + + cfg.cosmosChainID = cmd.String(cli.StringOpt{ + Name: "cosmos-chain-id", + Desc: "Specify Chain ID of the Cosmos network.", + EnvVar: "PEGGO_COSMOS_CHAIN_ID", + Value: "888", + }) + + cfg.cosmosGRPC = cmd.String(cli.StringOpt{ + Name: "cosmos-grpc", + Desc: "Cosmos GRPC querying endpoint", + EnvVar: "PEGGO_COSMOS_GRPC", + Value: "tcp://localhost:9900", + }) + + cfg.tendermintRPC = cmd.String(cli.StringOpt{ + Name: "tendermint-rpc", + Desc: "Tendermint RPC endpoint", + EnvVar: "PEGGO_TENDERMINT_RPC", + Value: "http://localhost:26657", + }) + + cfg.cosmosGasPrices = cmd.String(cli.StringOpt{ + Name: "cosmos-gas-prices", + Desc: "Specify Cosmos chain transaction fees as DecCoins gas prices", + EnvVar: "PEGGO_COSMOS_GAS_PRICES", + Value: "", // example: 500000000inj + }) + + cfg.cosmosKeyringBackend = cmd.String(cli.StringOpt{ + Name: "cosmos-keyring", + Desc: "Specify Cosmos keyring backend (os|file|kwallet|pass|test)", + EnvVar: "PEGGO_COSMOS_KEYRING", + Value: "file", + }) + + cfg.cosmosKeyringDir = cmd.String(cli.StringOpt{ + Name: "cosmos-keyring-dir", + Desc: "Specify Cosmos keyring dir, if using file keyring.", + EnvVar: "PEGGO_COSMOS_KEYRING_DIR", + Value: "", + }) + + cfg.cosmosKeyringAppName = cmd.String(cli.StringOpt{ + Name: "cosmos-keyring-app", + Desc: "Specify Cosmos keyring app name.", + EnvVar: "PEGGO_COSMOS_KEYRING_APP", + Value: "peggo", + }) + + cfg.cosmosKeyFrom = cmd.String(cli.StringOpt{ + Name: "cosmos-from", + Desc: "Specify the Cosmos validator key name or address. If specified, must exist in keyring, ledger or match the privkey.", + EnvVar: "PEGGO_COSMOS_FROM", + }) + + cfg.cosmosKeyPassphrase = cmd.String(cli.StringOpt{ + Name: "cosmos-from-passphrase", + Desc: "Specify keyring passphrase, otherwise Stdin will be used.", + EnvVar: "PEGGO_COSMOS_FROM_PASSPHRASE", + Value: "peggo", + }) + + cfg.cosmosPrivKey = cmd.String(cli.StringOpt{ + Name: "cosmos-pk", + Desc: "Provide a raw Cosmos account private key of the validator in hex. USE FOR TESTING ONLY!", + EnvVar: "PEGGO_COSMOS_PK", + }) + + cfg.cosmosUseLedger = cmd.Bool(cli.BoolOpt{ + Name: "cosmos-use-ledger", + Desc: "Use the Cosmos app on hardware ledger to sign transactions.", + EnvVar: "PEGGO_COSMOS_USE_LEDGER", + Value: false, + }) + + /** Ethereum **/ + + cfg.ethChainID = cmd.Int(cli.IntOpt{ + Name: "eth-chain-id", + Desc: "Specify Chain ID of the Ethereum network.", + EnvVar: "PEGGO_ETH_CHAIN_ID", + Value: 42, + }) + + cfg.ethNodeRPC = cmd.String(cli.StringOpt{ + Name: "eth-node-http", + Desc: "Specify HTTP endpoint for an Ethereum node.", + EnvVar: "PEGGO_ETH_RPC", + Value: "http://localhost:1317", + }) + + cfg.ethNodeAlchemyWS = cmd.String(cli.StringOpt{ + Name: "eth-node-alchemy-ws", + Desc: "Specify websocket url for an Alchemy ethereum node.", + EnvVar: "PEGGO_ETH_ALCHEMY_WS", + Value: "", + }) + + cfg.ethGasPriceAdjustment = cmd.Float64(cli.Float64Opt{ + Name: "eth_gas_price_adjustment", + Desc: "gas price adjustment for Ethereum transactions", + EnvVar: "PEGGO_ETH_GAS_PRICE_ADJUSTMENT", + Value: float64(1.3), + }) + + cfg.ethMaxGasPrice = cmd.String(cli.StringOpt{ + Name: "eth-max-gas-price", + Desc: "Specify Max gas price for Ethereum Transactions in GWei", + EnvVar: "PEGGO_ETH_MAX_GAS_PRICE", + Value: "500gwei", + }) + + cfg.ethKeystoreDir = cmd.String(cli.StringOpt{ + Name: "eth-keystore-dir", + Desc: "Specify Ethereum keystore dir (Geth-format) prefix.", + EnvVar: "PEGGO_ETH_KEYSTORE_DIR", + }) + + cfg.ethKeyFrom = cmd.String(cli.StringOpt{ + Name: "eth-from", + Desc: "Specify the from address. If specified, must exist in keystore, ledger or match the privkey.", + EnvVar: "PEGGO_ETH_FROM", + }) + + cfg.ethPassphrase = cmd.String(cli.StringOpt{ + Name: "eth-passphrase", + Desc: "Passphrase to unlock the private key from armor, if empty then stdin is used.", + EnvVar: "PEGGO_ETH_PASSPHRASE", + }) + + cfg.ethPrivKey = cmd.String(cli.StringOpt{ + Name: "eth-pk", + Desc: "Provide a raw Ethereum private key of the validator in hex. USE FOR TESTING ONLY!", + EnvVar: "PEGGO_ETH_PK", + }) + + cfg.ethUseLedger = cmd.Bool(cli.BoolOpt{ + Name: "eth-use-ledger", + Desc: "Use the Ethereum app on hardware ledger to sign transactions.", + EnvVar: "PEGGO_ETH_USE_LEDGER", + Value: false, + }) + + /** Relayer **/ + + cfg.relayValsets = cmd.Bool(cli.BoolOpt{ Name: "relay_valsets", Desc: "If enabled, relayer will relay valsets to ethereum", EnvVar: "PEGGO_RELAY_VALSETS", Value: false, }) - *relayValsetOffsetDur = cmd.String(cli.StringOpt{ + cfg.relayValsetOffsetDur = cmd.String(cli.StringOpt{ Name: "relay_valset_offset_dur", Desc: "If set, relayer will broadcast valsetUpdate only after relayValsetOffsetDur has passed from time of valsetUpdate creation", EnvVar: "PEGGO_RELAY_VALSET_OFFSET_DUR", Value: "5m", }) - *relayBatches = cmd.Bool(cli.BoolOpt{ + cfg.relayBatches = cmd.Bool(cli.BoolOpt{ Name: "relay_batches", Desc: "If enabled, relayer will relay batches to ethereum", EnvVar: "PEGGO_RELAY_BATCHES", Value: false, }) - *relayBatchOffsetDur = cmd.String(cli.StringOpt{ + cfg.relayBatchOffsetDur = cmd.String(cli.StringOpt{ Name: "relay_batch_offset_dur", Desc: "If set, relayer will broadcast batches only after relayBatchOffsetDur has passed from time of batch creation", EnvVar: "PEGGO_RELAY_BATCH_OFFSET_DUR", Value: "5m", }) - *pendingTxWaitDuration = cmd.String(cli.StringOpt{ + cfg.pendingTxWaitDuration = cmd.String(cli.StringOpt{ Name: "relay_pending_tx_wait_duration", Desc: "If set, relayer will broadcast pending batches/valsetupdate only after pendingTxWaitDuration has passed", EnvVar: "PEGGO_RELAY_PENDING_TX_WAIT_DURATION", Value: "20m", }) -} -// initBatchRequesterOptions sets options for batch requester. -func initBatchRequesterOptions( - cmd *cli.Cmd, - minBatchFeeUSD **float64, -) { - *minBatchFeeUSD = cmd.Float64(cli.Float64Opt{ + /** Batch Requester **/ + + cfg.minBatchFeeUSD = cmd.Float64(cli.Float64Opt{ Name: "min_batch_fee_usd", Desc: "If set, batch request will create batches only if fee threshold exceeds", EnvVar: "PEGGO_MIN_BATCH_FEE_USD", Value: float64(23.3), }) -} -// initCoingeckoOptions sets options for coingecko. -func initCoingeckoOptions( - cmd *cli.Cmd, - baseUrl **string, -) { - *baseUrl = cmd.String(cli.StringOpt{ + /** Coingecko **/ + + cfg.coingeckoApi = cmd.String(cli.StringOpt{ Name: "coingecko_api", Desc: "Specify HTTP endpoint for coingecko api.", EnvVar: "PEGGO_COINGECKO_API", Value: "https://api.coingecko.com/api/v3", }) + + return cfg } diff --git a/cmd/peggo/orchestrator.go b/cmd/peggo/orchestrator.go index 781af3ac..7aac5956 100644 --- a/cmd/peggo/orchestrator.go +++ b/cmd/peggo/orchestrator.go @@ -2,30 +2,20 @@ package main import ( "context" + "github.com/InjectiveLabs/peggo/orchestrator/version" "os" "time" - rpchttp "github.com/cometbft/cometbft/rpc/client/http" + ctypes "github.com/InjectiveLabs/sdk-go/chain/types" ethcmn "github.com/ethereum/go-ethereum/common" cli "github.com/jawher/mow.cli" "github.com/xlab/closer" log "github.com/xlab/suplog" - "github.com/InjectiveLabs/sdk-go/chain/peggy/types" - chainclient "github.com/InjectiveLabs/sdk-go/client/chain" - "github.com/InjectiveLabs/sdk-go/client/common" - "github.com/InjectiveLabs/peggo/orchestrator" "github.com/InjectiveLabs/peggo/orchestrator/coingecko" "github.com/InjectiveLabs/peggo/orchestrator/cosmos" - "github.com/InjectiveLabs/peggo/orchestrator/cosmos/tmclient" - "github.com/InjectiveLabs/peggo/orchestrator/ethereum/committer" - "github.com/InjectiveLabs/peggo/orchestrator/ethereum/peggy" - "github.com/InjectiveLabs/peggo/orchestrator/ethereum/provider" - "github.com/InjectiveLabs/peggo/orchestrator/relayer" - - ctypes "github.com/InjectiveLabs/sdk-go/chain/types" - "github.com/ethereum/go-ethereum/rpc" + "github.com/InjectiveLabs/peggo/orchestrator/ethereum" ) // startOrchestrator action runs an infinite loop, @@ -34,105 +24,7 @@ import ( // $ peggo orchestrator func orchestratorCmd(cmd *cli.Cmd) { // orchestrator-specific CLI options - var ( - // Cosmos params - cosmosChainID *string - cosmosGRPC *string - tendermintRPC *string - cosmosGasPrices *string - - // Cosmos Key Management - cosmosKeyringDir *string - cosmosKeyringAppName *string - cosmosKeyringBackend *string - - cosmosKeyFrom *string - cosmosKeyPassphrase *string - cosmosPrivKey *string - cosmosUseLedger *bool - - // Ethereum params - ethChainID *int - ethNodeRPC *string - ethNodeAlchemyWS *string - ethGasPriceAdjustment *float64 - ethMaxGasPrice *string - - // Ethereum Key Management - ethKeystoreDir *string - ethKeyFrom *string - ethPassphrase *string - ethPrivKey *string - ethUseLedger *bool - - // Relayer config - relayValsets *bool - relayValsetOffsetDur *string - relayBatches *bool - relayBatchOffsetDur *string - pendingTxWaitDuration *string - - // Batch requester config - minBatchFeeUSD *float64 - - coingeckoApi *string - ) - - initCosmosOptions( - cmd, - &cosmosChainID, - &cosmosGRPC, - &tendermintRPC, - &cosmosGasPrices, - ) - - initCosmosKeyOptions( - cmd, - &cosmosKeyringDir, - &cosmosKeyringAppName, - &cosmosKeyringBackend, - &cosmosKeyFrom, - &cosmosKeyPassphrase, - &cosmosPrivKey, - &cosmosUseLedger, - ) - - initEthereumOptions( - cmd, - ðChainID, - ðNodeRPC, - ðNodeAlchemyWS, - ðGasPriceAdjustment, - ðMaxGasPrice, - ) - - initEthereumKeyOptions( - cmd, - ðKeystoreDir, - ðKeyFrom, - ðPassphrase, - ðPrivKey, - ðUseLedger, - ) - - initRelayerOptions( - cmd, - &relayValsets, - &relayValsetOffsetDur, - &relayBatches, - &relayBatchOffsetDur, - &pendingTxWaitDuration, - ) - - initBatchRequesterOptions( - cmd, - &minBatchFeeUSD, - ) - - initCoingeckoOptions( - cmd, - &coingeckoApi, - ) + cfg := initConfig(cmd) cmd.Before = func() { initMetrics(cmd) @@ -142,145 +34,114 @@ func orchestratorCmd(cmd *cli.Cmd) { // ensure a clean exit defer closer.Close() - if *cosmosUseLedger || *ethUseLedger { - log.Fatalln("cannot really use Ledger for orchestrator, since signatures msut be realtime") + log.WithFields(log.Fields{ + "version": version.AppVersion, + "git": version.GitCommit, + "build_date": version.BuildDate, + "go_version": version.GoVersion, + "go_arch": version.GoArch, + }).Infoln("peggo - peggy binary for Ethereum bridge") + + if *cfg.cosmosUseLedger || *cfg.ethUseLedger { + log.Fatalln("cannot use Ledger for peggo, since signatures must be realtime") } valAddress, cosmosKeyring, err := initCosmosKeyring( - cosmosKeyringDir, - cosmosKeyringAppName, - cosmosKeyringBackend, - cosmosKeyFrom, - cosmosKeyPassphrase, - cosmosPrivKey, - cosmosUseLedger, + cfg.cosmosKeyringDir, + cfg.cosmosKeyringAppName, + cfg.cosmosKeyringBackend, + cfg.cosmosKeyFrom, + cfg.cosmosKeyPassphrase, + cfg.cosmosPrivKey, + cfg.cosmosUseLedger, ) if err != nil { - log.WithError(err).Fatalln("failed to init Cosmos keyring") + log.WithError(err).Fatalln("failed to initialize Injective keyring") } ethKeyFromAddress, signerFn, personalSignFn, err := initEthereumAccountsManager( - uint64(*ethChainID), - ethKeystoreDir, - ethKeyFrom, - ethPassphrase, - ethPrivKey, - ethUseLedger, + uint64(*cfg.ethChainID), + cfg.ethKeystoreDir, + cfg.ethKeyFrom, + cfg.ethPassphrase, + cfg.ethPrivKey, + cfg.ethUseLedger, ) if err != nil { - log.WithError(err).Fatalln("failed to init Ethereum account") + log.WithError(err).Fatalln("failed to initialize Ethereum account") } - log.Infoln("Using Cosmos ValAddress", valAddress.String()) - log.Infoln("Using Ethereum address", ethKeyFromAddress.String()) - - clientCtx, err := chainclient.NewClientContext(*cosmosChainID, valAddress.String(), cosmosKeyring) - if err != nil { - log.WithError(err).Fatalln("failed to initialize cosmos client context") - } - clientCtx = clientCtx.WithNodeURI(*tendermintRPC) - tmRPC, err := rpchttp.New(*tendermintRPC, "/websocket") - if err != nil { - log.WithError(err) - } - clientCtx = clientCtx.WithClient(tmRPC) - - daemonClient, err := chainclient.NewChainClient(clientCtx, *cosmosGRPC, common.OptionGasPrices(*cosmosGasPrices)) - if err != nil { - log.WithError(err).WithFields(log.Fields{ - "endpoint": *cosmosGRPC, - }).Fatalln("failed to connect to daemon, is injectived running?") - } - - log.Infoln("Waiting for injectived GRPC") - time.Sleep(1 * time.Second) - - daemonWaitCtx, cancelWait := context.WithTimeout(context.Background(), time.Minute) - grpcConn := daemonClient.QueryClient() - waitForService(daemonWaitCtx, grpcConn) - peggyQuerier := types.NewQueryClient(grpcConn) - peggyBroadcaster := cosmos.NewPeggyBroadcastClient( - peggyQuerier, - daemonClient, + log.WithFields(log.Fields{ + "inj_addr": valAddress.String(), + "eth_addr": ethKeyFromAddress.String(), + }).Infoln("starting peggo service") + + // Connect to Injective network + injNetwork, err := cosmos.NewNetwork( + *cfg.cosmosChainID, + valAddress.String(), + *cfg.cosmosGRPC, + *cfg.cosmosGasPrices, + *cfg.tendermintRPC, + cosmosKeyring, signerFn, personalSignFn, ) - cancelWait() + orShutdown(err) + + // See if the provided ETH address belongs to a validator and determine in which mode peggo should run + isValidator, err := isValidatorAddress(injNetwork.PeggyQueryClient, ethKeyFromAddress) + if err != nil { + log.WithError(err).Fatalln("failed to query current validator set on Injective") + } - // Query peggy params - cosmosQueryClient := cosmos.NewPeggyQueryClient(peggyQuerier) ctx, cancelFn := context.WithCancel(context.Background()) closer.Bind(cancelFn) - peggyParams, err := cosmosQueryClient.PeggyParams(ctx) + // Construct erc20 token mapping + peggyParams, err := injNetwork.PeggyParams(ctx) if err != nil { log.WithError(err).Fatalln("failed to query peggy params, is injectived running?") } - peggyAddress := ethcmn.HexToAddress(peggyParams.BridgeEthereumAddress) - injAddress := ethcmn.HexToAddress(peggyParams.CosmosCoinErc20Contract) - - // Check if the provided ETH address belongs to a validator - isValidator, err := isValidatorAddress(cosmosQueryClient, ethKeyFromAddress) - if err != nil { - log.WithError(err).Fatalln("failed to query the current validator set from injective") - - return - } + peggyContractAddr := ethcmn.HexToAddress(peggyParams.BridgeEthereumAddress) + injTokenAddr := ethcmn.HexToAddress(peggyParams.CosmosCoinErc20Contract) erc20ContractMapping := make(map[ethcmn.Address]string) - erc20ContractMapping[injAddress] = ctypes.InjectiveCoin - - evmRPC, err := rpc.Dial(*ethNodeRPC) - if err != nil { - log.WithField("endpoint", *ethNodeRPC).WithError(err).Fatalln("Failed to connect to Ethereum RPC") - return - } - ethProvider := provider.NewEVMProvider(evmRPC) - log.Infoln("Connected to Ethereum RPC at", *ethNodeRPC) - - ethCommitter, err := committer.NewEthCommitter(ethKeyFromAddress, *ethGasPriceAdjustment, *ethMaxGasPrice, signerFn, ethProvider) - orShutdown(err) - - pendingTxInputList := peggy.PendingTxInputList{} - - pendingTxWaitDuration, err := time.ParseDuration(*pendingTxWaitDuration) - orShutdown(err) + erc20ContractMapping[injTokenAddr] = ctypes.InjectiveCoin - peggyContract, err := peggy.NewPeggyContract(ethCommitter, peggyAddress, pendingTxInputList, pendingTxWaitDuration) + // Connect to ethereum network + ethNetwork, err := ethereum.NewNetwork( + *cfg.ethNodeRPC, + peggyContractAddr, + ethKeyFromAddress, + signerFn, + *cfg.ethGasPriceAdjustment, + *cfg.ethMaxGasPrice, + *cfg.pendingTxWaitDuration, + *cfg.ethNodeAlchemyWS, + ) orShutdown(err) - // If Alchemy Websocket URL is set, then Subscribe to Pending Transaction of Peggy Contract. - if *ethNodeAlchemyWS != "" { - go peggyContract.SubscribeToPendingTxs(*ethNodeAlchemyWS) - } - - relayer := relayer.NewPeggyRelayer(cosmosQueryClient, tmclient.NewRPCClient(*tendermintRPC), peggyContract, *relayValsets, *relayValsetOffsetDur, *relayBatches, *relayBatchOffsetDur) - - coingeckoConfig := coingecko.Config{ - BaseURL: *coingeckoApi, - } - coingeckoFeed := coingecko.NewCoingeckoPriceFeed(100, &coingeckoConfig) + coingeckoFeed := coingecko.NewCoingeckoPriceFeed(100, &coingecko.Config{BaseURL: *cfg.coingeckoApi}) - svc := orchestrator.NewPeggyOrchestrator( - cosmosQueryClient, - peggyBroadcaster, - tmclient.NewRPCClient(*tendermintRPC), - peggyContract, - ethKeyFromAddress, - signerFn, - personalSignFn, - erc20ContractMapping, - relayer, - *minBatchFeeUSD, + // Create peggo and run it + peggo, err := orchestrator.NewPeggyOrchestrator( + injNetwork, + ethNetwork, coingeckoFeed, + erc20ContractMapping, + *cfg.minBatchFeeUSD, + *cfg.relayValsets, + *cfg.relayBatches, + *cfg.relayValsetOffsetDur, + *cfg.relayBatchOffsetDur, ) + orShutdown(err) go func() { - if err := svc.Start(ctx, isValidator); err != nil { + if err := peggo.Run(ctx, isValidator); err != nil { log.Errorln(err) - - // signal there that the app failed os.Exit(1) } }() diff --git a/cmd/peggo/tx.go b/cmd/peggo/tx.go index dcb33272..b8f4b643 100644 --- a/cmd/peggo/tx.go +++ b/cmd/peggo/tx.go @@ -5,6 +5,9 @@ import ( "time" rpchttp "github.com/cometbft/cometbft/rpc/client/http" + "google.golang.org/grpc" + "google.golang.org/grpc/connectivity" + cli "github.com/jawher/mow.cli" "github.com/xlab/closer" log "github.com/xlab/suplog" @@ -13,6 +16,7 @@ import ( chainclient "github.com/InjectiveLabs/sdk-go/client/chain" "github.com/InjectiveLabs/sdk-go/client/common" + "github.com/InjectiveLabs/peggo/orchestrator/cosmos" ) @@ -178,3 +182,23 @@ func registerEthKeyCmd(cmd *cli.Cmd) { ethKeyFromAddress, valAddress.String()) } } + +// waitForService awaits an active ClientConn to a GRPC service. +func waitForService(ctx context.Context, clientconn *grpc.ClientConn) { + for { + select { + case <-ctx.Done(): + log.Fatalln("GRPC service wait timed out") + default: + state := clientconn.GetState() + + if state != connectivity.Ready { + log.WithField("state", state.String()).Warningln("state of GRPC connection not ready") + time.Sleep(5 * time.Second) + continue + } + + return + } + } +} diff --git a/cmd/peggo/util.go b/cmd/peggo/util.go index 222c8c97..d6df7c36 100644 --- a/cmd/peggo/util.go +++ b/cmd/peggo/util.go @@ -3,7 +3,6 @@ package main import ( "bufio" "bytes" - "context" "encoding/hex" "fmt" "io/ioutil" @@ -14,7 +13,6 @@ import ( ethcmn "github.com/ethereum/go-ethereum/common" log "github.com/xlab/suplog" "google.golang.org/grpc" - "google.golang.org/grpc/connectivity" ) // readEnv is a special utility that reads `.env` file into actual environment variables @@ -131,29 +129,9 @@ func hexToBytes(str string) ([]byte, error) { return data, nil } -// waitForService awaits an active ClientConn to a GRPC service. -func waitForService(ctx context.Context, clientconn *grpc.ClientConn) { - for { - select { - case <-ctx.Done(): - log.Fatalln("GRPC service wait timed out") - default: - state := clientconn.GetState() - - if state != connectivity.Ready { - log.WithField("state", state.String()).Warningln("state of GRPC connection not ready") - time.Sleep(5 * time.Second) - continue - } - - return - } - } -} - // orShutdown fatals the app if there was an error. func orShutdown(err error) { if err != nil && err != grpc.ErrServerStopped { - log.WithError(err).Fatalln("unable to start peggo orchestrator") + log.WithError(err).Fatalln("unable to start peggo") } } diff --git a/orchestrator/batch_request.go b/orchestrator/batch_request.go new file mode 100644 index 00000000..7537c2f4 --- /dev/null +++ b/orchestrator/batch_request.go @@ -0,0 +1,143 @@ +package orchestrator + +import ( + "context" + "github.com/avast/retry-go" + eth "github.com/ethereum/go-ethereum/common" + "github.com/shopspring/decimal" + log "github.com/xlab/suplog" + + "github.com/InjectiveLabs/peggo/orchestrator/loops" + "github.com/InjectiveLabs/sdk-go/chain/peggy/types" + cosmtypes "github.com/cosmos/cosmos-sdk/types" +) + +func (s *PeggyOrchestrator) BatchRequesterLoop(ctx context.Context) (err error) { + requester := &batchRequester{ + log: log.WithField("loop", "BatchRequester"), + retries: s.maxAttempts, + minBatchFee: s.minBatchFeeUSD, + erc20ContractMapping: s.erc20ContractMapping, + } + + return loops.RunLoop( + ctx, + defaultLoopDur, + func() error { return requester.run(ctx, s.injective, s.pricefeed) }, + ) +} + +type batchRequester struct { + log log.Logger + retries uint + minBatchFee float64 + erc20ContractMapping map[eth.Address]string +} + +func (r *batchRequester) run( + ctx context.Context, + injective InjectiveNetwork, + feed PriceFeed, +) error { + r.log.WithField("min_batch_fee", r.minBatchFee).Infoln("scanning Injective for potential batches") + + unbatchedFees, err := r.getUnbatchedFeesByToken(ctx, injective) + if err != nil { + // non-fatal, just alert + r.log.WithError(err).Warningln("unable to get unbatched fees from Injective") + return nil + } + + if len(unbatchedFees) == 0 { + r.log.Debugln("no outgoing withdrawals or minimum batch fee is not met") + return nil + } + + for _, tokenFee := range unbatchedFees { + r.requestBatchCreation(ctx, injective, feed, tokenFee) + } + + return nil +} + +func (r *batchRequester) getUnbatchedFeesByToken(ctx context.Context, injective InjectiveNetwork) ([]*types.BatchFees, error) { + var unbatchedFees []*types.BatchFees + retryFn := func() (err error) { + unbatchedFees, err = injective.UnbatchedTokenFees(ctx) + return err + } + + if err := retry.Do(retryFn, + retry.Context(ctx), + retry.Attempts(r.retries), + retry.OnRetry(func(n uint, err error) { + log.WithError(err).Errorf("failed to get unbatched fees, will retry (%d)", n) + }), + ); err != nil { + return nil, err + } + + return unbatchedFees, nil +} + +func (r *batchRequester) requestBatchCreation( + ctx context.Context, + injective InjectiveNetwork, + feed PriceFeed, + batchFee *types.BatchFees, +) { + var ( + tokenAddr = eth.HexToAddress(batchFee.Token) + denom = r.tokenDenom(tokenAddr) + ) + + if thresholdMet := r.checkFeeThreshold(feed, tokenAddr, batchFee.TotalFees); !thresholdMet { + r.log.WithFields(log.Fields{ + "denom": denom, + "token_contract": tokenAddr.String(), + "total_fees": batchFee.TotalFees.String(), + }).Debugln("skipping underpriced batch") + return + } + + r.log.WithFields(log.Fields{ + "denom": denom, + "token_contract": tokenAddr.String(), + }).Infoln("requesting batch creation on Injective") + + _ = injective.SendRequestBatch(ctx, denom) +} + +func (r *batchRequester) tokenDenom(tokenAddr eth.Address) string { + if cosmosDenom, ok := r.erc20ContractMapping[tokenAddr]; ok { + return cosmosDenom + } + + // peggy denom + return types.PeggyDenomString(tokenAddr) +} + +func (r *batchRequester) checkFeeThreshold( + feed PriceFeed, + tokenAddr eth.Address, + totalFees cosmtypes.Int, +) bool { + if r.minBatchFee == 0 { + return true + } + + tokenPriceInUSD, err := feed.QueryUSDPrice(tokenAddr) + if err != nil { + return false + } + + tokenPriceInUSDDec := decimal.NewFromFloat(tokenPriceInUSD) + totalFeeInUSDDec := decimal.NewFromBigInt(totalFees.BigInt(), -18).Mul(tokenPriceInUSDDec) + minFeeInUSDDec := decimal.NewFromFloat(r.minBatchFee) + + if totalFeeInUSDDec.GreaterThan(minFeeInUSDDec) { + return true + } + + return false +} diff --git a/orchestrator/batch_request_test.go b/orchestrator/batch_request_test.go new file mode 100644 index 00000000..29fb387a --- /dev/null +++ b/orchestrator/batch_request_test.go @@ -0,0 +1,157 @@ +package orchestrator + +import ( + "context" + "errors" + "testing" + + eth "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/assert" + "github.com/xlab/suplog" + + peggy "github.com/InjectiveLabs/sdk-go/chain/peggy/types" + cosmtypes "github.com/cosmos/cosmos-sdk/types" +) + +func TestRequestBatches(t *testing.T) { + t.Parallel() + + t.Run("failed to get unbatched tokens from injective", func(t *testing.T) { + t.Parallel() + + r := &batchRequester{ + log: suplog.DefaultLogger, + retries: 1, + } + + inj := &mockInjective{ + unbatchedTokenFeesFn: func(context.Context) ([]*peggy.BatchFees, error) { + return nil, errors.New("fail") + }, + } + feed := mockPriceFeed{} + + assert.NoError(t, r.run(context.TODO(), inj, feed)) + }) + + t.Run("no unbatched tokens", func(t *testing.T) { + t.Parallel() + + r := &batchRequester{ + log: suplog.DefaultLogger, + retries: 1, + } + + inj := &mockInjective{ + unbatchedTokenFeesFn: func(context.Context) ([]*peggy.BatchFees, error) { + return nil, nil + }, + } + feed := mockPriceFeed{} + + assert.NoError(t, r.run(context.TODO(), inj, feed)) + }) + + t.Run("batch does not meet fee threshold", func(t *testing.T) { + t.Parallel() + + tokenAddr := "0xe28b3B32B6c345A34Ff64674606124Dd5Aceca30" + + r := &batchRequester{ + log: suplog.DefaultLogger, + minBatchFee: 51.0, + retries: 1, + erc20ContractMapping: map[eth.Address]string{ + eth.HexToAddress(tokenAddr): "inj", + }, + } + + inj := &mockInjective{ + sendRequestBatchFn: func(context.Context, string) error { return nil }, + unbatchedTokenFeesFn: func(context.Context) ([]*peggy.BatchFees, error) { + fees, _ := cosmtypes.NewIntFromString("50000000000000000000") + return []*peggy.BatchFees{ + { + Token: eth.HexToAddress(tokenAddr).String(), + TotalFees: fees, + }, + }, nil + }, + } + + feed := mockPriceFeed{queryFn: func(_ eth.Address) (float64, error) { return 1, nil }} + + assert.NoError(t, r.run(context.TODO(), inj, feed)) + assert.Equal(t, inj.sendRequestBatchCallCount, 0) + }) + + t.Run("batch meets threshold and a request is sent", func(t *testing.T) { + t.Parallel() + + tokenAddr := "0xe28b3B32B6c345A34Ff64674606124Dd5Aceca30" + + r := &batchRequester{ + log: suplog.DefaultLogger, + minBatchFee: 49.0, + retries: 1, + erc20ContractMapping: map[eth.Address]string{ + eth.HexToAddress(tokenAddr): "inj", + }, + } + + inj := &mockInjective{ + sendRequestBatchFn: func(context.Context, string) error { return nil }, + unbatchedTokenFeesFn: func(_ context.Context) ([]*peggy.BatchFees, error) { + fees, _ := cosmtypes.NewIntFromString("50000000000000000000") + return []*peggy.BatchFees{ + { + Token: eth.HexToAddress(tokenAddr).String(), + TotalFees: fees, + }, + }, nil + }, + } + + feed := mockPriceFeed{queryFn: func(_ eth.Address) (float64, error) { return 1, nil }} + + assert.NoError(t, r.run(context.TODO(), inj, feed)) + assert.Equal(t, inj.sendRequestBatchCallCount, 1) + }) + +} + +func TestCheckFeeThreshold(t *testing.T) { + t.Parallel() + + t.Run("fee threshold is met", func(t *testing.T) { + t.Parallel() + + var ( + requester = &batchRequester{minBatchFee: 21} + tokenAddr = eth.HexToAddress("0xe28b3B32B6c345A34Ff64674606124Dd5Aceca30") + totalFees, _ = cosmtypes.NewIntFromString("10000000000000000000") // 10inj + feed = mockPriceFeed{queryFn: func(_ eth.Address) (float64, error) { + return 2.5, nil + }} + ) + + // 2.5 * 10 > 21 + assert.True(t, requester.checkFeeThreshold(feed, tokenAddr, totalFees)) + }) + + t.Run("fee threshold is met", func(t *testing.T) { + t.Parallel() + + var ( + requester = &batchRequester{minBatchFee: 333.333} + tokenAddr = eth.HexToAddress("0xe28b3B32B6c345A34Ff64674606124Dd5Aceca30") + totalFees, _ = cosmtypes.NewIntFromString("100000000000000000000") // 10inj + feed = mockPriceFeed{queryFn: func(_ eth.Address) (float64, error) { + return 2.5, nil + }} + ) + + // 2.5 * 100 < 333.333 + assert.False(t, requester.checkFeeThreshold(feed, tokenAddr, totalFees)) + }) +} diff --git a/orchestrator/coingecko/coingecko.go b/orchestrator/coingecko/coingecko.go index fde0206e..f10f69c1 100644 --- a/orchestrator/coingecko/coingecko.go +++ b/orchestrator/coingecko/coingecko.go @@ -92,11 +92,32 @@ func (cp *CoingeckoPriceFeed) QueryUSDPrice(erc20Contract common.Address) (float _ = resp.Body.Close() var f interface{} - err = json.Unmarshal(respBody, &f) - m := f.(map[string]interface{}) + if err := json.Unmarshal(respBody, &f); err != nil { + metrics.ReportFuncError(cp.svcTags) + cp.logger.WithError(err).Errorln("failed to unmarshal response") + return zeroPrice, err + } + + m, ok := f.(map[string]interface{}) + if !ok { + metrics.ReportFuncError(cp.svcTags) + cp.logger.WithError(err).Errorln("failed to cast response type: map[string]interface{}") + return zeroPrice, err + } v := m[strings.ToLower(erc20Contract.String())] - n := v.(map[string]interface{}) + if v == nil { + metrics.ReportFuncError(cp.svcTags) + cp.logger.WithError(err).Errorln("failed to get contract address") + return zeroPrice, err + } + + n, ok := v.(map[string]interface{}) + if !ok { + metrics.ReportFuncError(cp.svcTags) + cp.logger.WithError(err).Errorln("failed to cast value type: map[string]interface{}") + return zeroPrice, err + } tokenPriceInUSD := n["usd"].(float64) return tokenPriceInUSD, nil diff --git a/orchestrator/cosmos/network.go b/orchestrator/cosmos/network.go new file mode 100644 index 00000000..cc019a2a --- /dev/null +++ b/orchestrator/cosmos/network.go @@ -0,0 +1,188 @@ +package cosmos + +import ( + "context" + "time" + + rpchttp "github.com/cometbft/cometbft/rpc/client/http" + tmctypes "github.com/cometbft/cometbft/rpc/core/types" + "github.com/cosmos/cosmos-sdk/crypto/keyring" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + ethcmn "github.com/ethereum/go-ethereum/common" + "github.com/pkg/errors" + log "github.com/xlab/suplog" + "google.golang.org/grpc" + "google.golang.org/grpc/connectivity" + + "github.com/InjectiveLabs/peggo/orchestrator/cosmos/tmclient" + "github.com/InjectiveLabs/peggo/orchestrator/ethereum/keystore" + peggyevents "github.com/InjectiveLabs/peggo/solidity/wrappers/Peggy.sol" + "github.com/InjectiveLabs/sdk-go/chain/peggy/types" + peggy "github.com/InjectiveLabs/sdk-go/chain/peggy/types" + chainclient "github.com/InjectiveLabs/sdk-go/client/chain" + "github.com/InjectiveLabs/sdk-go/client/common" +) + +type Network struct { + tmclient.TendermintClient + PeggyQueryClient + PeggyBroadcastClient +} + +func NewNetwork( + chainID, + validatorAddress, + injectiveGRPC, + injectiveGasPrices, + tendermintRPC string, + keyring keyring.Keyring, + signerFn bind.SignerFn, + personalSignerFn keystore.PersonalSignFn, +) (*Network, error) { + clientCtx, err := chainclient.NewClientContext(chainID, validatorAddress, keyring) + if err != nil { + return nil, errors.Wrapf(err, "failed to create client context for Injective chain") + } + + clientCtx = clientCtx.WithNodeURI(tendermintRPC) + + tmRPC, err := rpchttp.New(tendermintRPC, "/websocket") + if err != nil { + return nil, errors.Wrapf(err, "failed to connect to Tendermint RPC %s", tendermintRPC) + } + + clientCtx = clientCtx.WithClient(tmRPC) + + daemonClient, err := chainclient.NewChainClient(clientCtx, injectiveGRPC, common.OptionGasPrices(injectiveGasPrices)) + if err != nil { + return nil, errors.Wrapf(err, "failed to connect to Injective GRPC %s", injectiveGRPC) + } + + time.Sleep(1 * time.Second) + + daemonWaitCtx, cancelWait := context.WithTimeout(context.Background(), time.Minute) + defer cancelWait() + + grpcConn := daemonClient.QueryClient() + waitForService(daemonWaitCtx, grpcConn) + peggyQuerier := types.NewQueryClient(grpcConn) + + n := &Network{ + TendermintClient: tmclient.NewRPCClient(tendermintRPC), + PeggyQueryClient: NewPeggyQueryClient(peggyQuerier), + PeggyBroadcastClient: NewPeggyBroadcastClient(peggyQuerier, daemonClient, signerFn, personalSignerFn), + } + + log.WithFields(log.Fields{ + "chain_id": chainID, + "injective": injectiveGRPC, + "tendermint": tendermintRPC, + }).Infoln("connected to Injective network") + + return n, nil +} + +func (n *Network) GetBlock(ctx context.Context, height int64) (*tmctypes.ResultBlock, error) { + return n.TendermintClient.GetBlock(ctx, height) +} + +func (n *Network) PeggyParams(ctx context.Context) (*peggy.Params, error) { + return n.PeggyQueryClient.PeggyParams(ctx) +} + +func (n *Network) LastClaimEvent(ctx context.Context) (*peggy.LastClaimEvent, error) { + return n.LastClaimEventByAddr(ctx, n.AccFromAddress()) +} + +func (n *Network) SendEthereumClaims( + ctx context.Context, + lastClaimEvent uint64, + oldDeposits []*peggyevents.PeggySendToCosmosEvent, + deposits []*peggyevents.PeggySendToInjectiveEvent, + withdraws []*peggyevents.PeggyTransactionBatchExecutedEvent, + erc20Deployed []*peggyevents.PeggyERC20DeployedEvent, + valsetUpdates []*peggyevents.PeggyValsetUpdatedEvent, +) error { + return n.PeggyBroadcastClient.SendEthereumClaims(ctx, + lastClaimEvent, + oldDeposits, + deposits, + withdraws, + erc20Deployed, + valsetUpdates, + ) +} + +func (n *Network) UnbatchedTokenFees(ctx context.Context) ([]*peggy.BatchFees, error) { + return n.PeggyQueryClient.UnbatchedTokensWithFees(ctx) +} + +func (n *Network) SendRequestBatch(ctx context.Context, denom string) error { + return n.PeggyBroadcastClient.SendRequestBatch(ctx, denom) +} + +func (n *Network) OldestUnsignedValsets(ctx context.Context) ([]*peggy.Valset, error) { + return n.PeggyQueryClient.OldestUnsignedValsets(ctx, n.AccFromAddress()) +} + +func (n *Network) LatestValsets(ctx context.Context) ([]*peggy.Valset, error) { + return n.PeggyQueryClient.LatestValsets(ctx) +} + +func (n *Network) AllValsetConfirms(ctx context.Context, nonce uint64) ([]*peggy.MsgValsetConfirm, error) { + return n.PeggyQueryClient.AllValsetConfirms(ctx, nonce) +} + +func (n *Network) ValsetAt(ctx context.Context, nonce uint64) (*peggy.Valset, error) { + return n.PeggyQueryClient.ValsetAt(ctx, nonce) +} + +func (n *Network) SendValsetConfirm( + ctx context.Context, + peggyID ethcmn.Hash, + valset *peggy.Valset, + ethFrom ethcmn.Address, +) error { + return n.PeggyBroadcastClient.SendValsetConfirm(ctx, ethFrom, peggyID, valset) +} + +func (n *Network) OldestUnsignedTransactionBatch(ctx context.Context) (*peggy.OutgoingTxBatch, error) { + return n.PeggyQueryClient.OldestUnsignedTransactionBatch(ctx, n.AccFromAddress()) +} + +func (n *Network) LatestTransactionBatches(ctx context.Context) ([]*peggy.OutgoingTxBatch, error) { + return n.PeggyQueryClient.LatestTransactionBatches(ctx) +} + +func (n *Network) TransactionBatchSignatures(ctx context.Context, nonce uint64, tokenContract ethcmn.Address) ([]*peggy.MsgConfirmBatch, error) { + return n.PeggyQueryClient.TransactionBatchSignatures(ctx, nonce, tokenContract) +} + +func (n *Network) SendBatchConfirm( + ctx context.Context, + peggyID ethcmn.Hash, + batch *peggy.OutgoingTxBatch, + ethFrom ethcmn.Address, +) error { + return n.PeggyBroadcastClient.SendBatchConfirm(ctx, ethFrom, peggyID, batch) +} + +// waitForService awaits an active ClientConn to a GRPC service. +func waitForService(ctx context.Context, clientconn *grpc.ClientConn) { + for { + select { + case <-ctx.Done(): + log.Fatalln("GRPC service wait timed out") + default: + state := clientconn.GetState() + + if state != connectivity.Ready { + log.WithField("state", state.String()).Warningln("state of GRPC connection not ready") + time.Sleep(5 * time.Second) + continue + } + + return + } + } +} diff --git a/orchestrator/eth_event_watcher.go b/orchestrator/eth_event_watcher.go deleted file mode 100644 index 8be16582..00000000 --- a/orchestrator/eth_event_watcher.go +++ /dev/null @@ -1,344 +0,0 @@ -package orchestrator - -import ( - "context" - "strings" - - "github.com/ethereum/go-ethereum/accounts/abi/bind" - "github.com/pkg/errors" - log "github.com/xlab/suplog" - - "github.com/InjectiveLabs/metrics" - - wrappers "github.com/InjectiveLabs/peggo/solidity/wrappers/Peggy.sol" -) - -// Considering blocktime of up to 3 seconds approx on the Injective Chain and an oracle loop duration = 1 minute, -// we broadcast only 20 events in each iteration. -// So better to search only 20 blocks to ensure all the events are broadcast to Injective Chain without misses. -const defaultBlocksToSearch = 20 - -const ethBlockConfirmationDelay = 12 - -// CheckForEvents checks for events such as a deposit to the Peggy Ethereum contract or a validator set update -// or a transaction batch update. It then responds to these events by performing actions on the Cosmos chain if required -func (s *peggyOrchestrator) CheckForEvents( - ctx context.Context, - startingBlock uint64, -) (currentBlock uint64, err error) { - metrics.ReportFuncCall(s.svcTags) - doneFn := metrics.ReportFuncTiming(s.svcTags) - defer doneFn() - - latestHeader, err := s.ethProvider.HeaderByNumber(ctx, nil) - if err != nil { - metrics.ReportFuncError(s.svcTags) - err = errors.Wrap(err, "failed to get latest header") - return 0, err - } - - // add delay to ensure minimum confirmations are received and block is finalised - currentBlock = latestHeader.Number.Uint64() - uint64(ethBlockConfirmationDelay) - - if currentBlock < startingBlock { - return currentBlock, nil - } - - if (currentBlock - startingBlock) > defaultBlocksToSearch { - currentBlock = startingBlock + defaultBlocksToSearch - } - - peggyFilterer, err := wrappers.NewPeggyFilterer(s.peggyContract.Address(), s.ethProvider) - if err != nil { - metrics.ReportFuncError(s.svcTags) - err = errors.Wrap(err, "failed to init Peggy events filterer") - return 0, err - } - - var sendToCosmosEvents []*wrappers.PeggySendToCosmosEvent - { - - iter, err := peggyFilterer.FilterSendToCosmosEvent(&bind.FilterOpts{ - Start: startingBlock, - End: ¤tBlock, - }, nil, nil, nil) - if err != nil { - metrics.ReportFuncError(s.svcTags) - log.WithFields(log.Fields{ - "start": startingBlock, - "end": currentBlock, - }).Errorln("failed to scan past SendToCosmos events from Ethereum") - - if !isUnknownBlockErr(err) { - err = errors.Wrap(err, "failed to scan past SendToCosmos events from Ethereum") - return 0, err - } else if iter == nil { - return 0, errors.New("no iterator returned") - } - } - - for iter.Next() { - sendToCosmosEvents = append(sendToCosmosEvents, iter.Event) - } - - iter.Close() - } - - log.WithFields(log.Fields{ - "start": startingBlock, - "end": currentBlock, - "OldDeposits": sendToCosmosEvents, - }).Debugln("Scanned SendToCosmos events from Ethereum") - - var sendToInjectiveEvents []*wrappers.PeggySendToInjectiveEvent - { - - iter, err := peggyFilterer.FilterSendToInjectiveEvent(&bind.FilterOpts{ - Start: startingBlock, - End: ¤tBlock, - }, nil, nil, nil) - if err != nil { - metrics.ReportFuncError(s.svcTags) - log.WithFields(log.Fields{ - "start": startingBlock, - "end": currentBlock, - }).Errorln("failed to scan past SendToInjective events from Ethereum") - - if !isUnknownBlockErr(err) { - err = errors.Wrap(err, "failed to scan past SendToInjective events from Ethereum") - return 0, err - } else if iter == nil { - return 0, errors.New("no iterator returned") - } - } - - for iter.Next() { - sendToInjectiveEvents = append(sendToInjectiveEvents, iter.Event) - } - - iter.Close() - } - - log.WithFields(log.Fields{ - "start": startingBlock, - "end": currentBlock, - "Deposits": sendToInjectiveEvents, - }).Debugln("Scanned SendToInjective events from Ethereum") - - var transactionBatchExecutedEvents []*wrappers.PeggyTransactionBatchExecutedEvent - { - iter, err := peggyFilterer.FilterTransactionBatchExecutedEvent(&bind.FilterOpts{ - Start: startingBlock, - End: ¤tBlock, - }, nil, nil) - if err != nil { - metrics.ReportFuncError(s.svcTags) - log.WithFields(log.Fields{ - "start": startingBlock, - "end": currentBlock, - }).Errorln("failed to scan past TransactionBatchExecuted events from Ethereum") - - if !isUnknownBlockErr(err) { - err = errors.Wrap(err, "failed to scan past TransactionBatchExecuted events from Ethereum") - return 0, err - } else if iter == nil { - return 0, errors.New("no iterator returned") - } - } - - for iter.Next() { - transactionBatchExecutedEvents = append(transactionBatchExecutedEvents, iter.Event) - } - - iter.Close() - } - log.WithFields(log.Fields{ - "start": startingBlock, - "end": currentBlock, - "Withdraws": transactionBatchExecutedEvents, - }).Debugln("Scanned TransactionBatchExecuted events from Ethereum") - - var erc20DeployedEvents []*wrappers.PeggyERC20DeployedEvent - { - iter, err := peggyFilterer.FilterERC20DeployedEvent(&bind.FilterOpts{ - Start: startingBlock, - End: ¤tBlock, - }, nil) - if err != nil { - metrics.ReportFuncError(s.svcTags) - log.WithFields(log.Fields{ - "start": startingBlock, - "end": currentBlock, - }).Errorln("failed to scan past FilterERC20Deployed events from Ethereum") - - if !isUnknownBlockErr(err) { - err = errors.Wrap(err, "failed to scan past FilterERC20Deployed events from Ethereum") - return 0, err - } else if iter == nil { - return 0, errors.New("no iterator returned") - } - } - - for iter.Next() { - erc20DeployedEvents = append(erc20DeployedEvents, iter.Event) - } - - iter.Close() - } - log.WithFields(log.Fields{ - "start": startingBlock, - "end": currentBlock, - "erc20Deployed": erc20DeployedEvents, - }).Debugln("Scanned FilterERC20Deployed events from Ethereum") - - var valsetUpdatedEvents []*wrappers.PeggyValsetUpdatedEvent - { - iter, err := peggyFilterer.FilterValsetUpdatedEvent(&bind.FilterOpts{ - Start: startingBlock, - End: ¤tBlock, - }, nil) - if err != nil { - metrics.ReportFuncError(s.svcTags) - log.WithFields(log.Fields{ - "start": startingBlock, - "end": currentBlock, - }).Errorln("failed to scan past ValsetUpdatedEvent events from Ethereum") - - if !isUnknownBlockErr(err) { - err = errors.Wrap(err, "failed to scan past ValsetUpdatedEvent events from Ethereum") - return 0, err - } else if iter == nil { - return 0, errors.New("no iterator returned") - } - } - - for iter.Next() { - valsetUpdatedEvents = append(valsetUpdatedEvents, iter.Event) - } - - iter.Close() - } - - log.WithFields(log.Fields{ - "start": startingBlock, - "end": currentBlock, - "valsetUpdates": valsetUpdatedEvents, - }).Debugln("Scanned ValsetUpdatedEvents events from Ethereum") - - // note that starting block overlaps with our last checked block, because we have to deal with - // the possibility that the relayer was killed after relaying only one of multiple events in a single - // block, so we also need this routine so make sure we don't send in the first event in this hypothetical - // multi event block again. In theory we only send all events for every block and that will pass of fail - // atomically but lets not take that risk. - lastClaimEvent, err := s.cosmosQueryClient.LastClaimEventByAddr(ctx, s.peggyBroadcastClient.AccFromAddress()) - if err != nil { - metrics.ReportFuncError(s.svcTags) - err = errors.New("failed to query last claim event from backend") - return 0, err - } - - oldDeposits := filterSendToCosmosEventsByNonce(sendToCosmosEvents, lastClaimEvent.EthereumEventNonce) - deposits := filterSendToInjectiveEventsByNonce(sendToInjectiveEvents, lastClaimEvent.EthereumEventNonce) - withdraws := filterTransactionBatchExecutedEventsByNonce(transactionBatchExecutedEvents, lastClaimEvent.EthereumEventNonce) - erc20Deployments := filterERC20DeployedEventsByNonce(erc20DeployedEvents, lastClaimEvent.EthereumEventNonce) - valsetUpdates := filterValsetUpdateEventsByNonce(valsetUpdatedEvents, lastClaimEvent.EthereumEventNonce) - - if len(oldDeposits) > 0 || len(deposits) > 0 || len(withdraws) > 0 || len(erc20Deployments) > 0 || len(valsetUpdates) > 0 { - // todo get eth chain id from the chain - if err := s.peggyBroadcastClient.SendEthereumClaims(ctx, lastClaimEvent.EthereumEventNonce, oldDeposits, deposits, withdraws, erc20Deployments, valsetUpdates); err != nil { - metrics.ReportFuncError(s.svcTags) - err = errors.Wrap(err, "failed to send ethereum claims to Cosmos chain") - return 0, err - } - } - - return currentBlock, nil -} - -func filterSendToCosmosEventsByNonce( - events []*wrappers.PeggySendToCosmosEvent, - nonce uint64, -) []*wrappers.PeggySendToCosmosEvent { - res := make([]*wrappers.PeggySendToCosmosEvent, 0, len(events)) - - for _, ev := range events { - if ev.EventNonce.Uint64() > nonce { - res = append(res, ev) - } - } - - return res -} - -func filterSendToInjectiveEventsByNonce( - events []*wrappers.PeggySendToInjectiveEvent, - nonce uint64, -) []*wrappers.PeggySendToInjectiveEvent { - res := make([]*wrappers.PeggySendToInjectiveEvent, 0, len(events)) - - for _, ev := range events { - if ev.EventNonce.Uint64() > nonce { - res = append(res, ev) - } - } - - return res -} - -func filterTransactionBatchExecutedEventsByNonce( - events []*wrappers.PeggyTransactionBatchExecutedEvent, - nonce uint64, -) []*wrappers.PeggyTransactionBatchExecutedEvent { - res := make([]*wrappers.PeggyTransactionBatchExecutedEvent, 0, len(events)) - - for _, ev := range events { - if ev.EventNonce.Uint64() > nonce { - res = append(res, ev) - } - } - - return res -} - -func filterERC20DeployedEventsByNonce( - events []*wrappers.PeggyERC20DeployedEvent, - nonce uint64, -) []*wrappers.PeggyERC20DeployedEvent { - res := make([]*wrappers.PeggyERC20DeployedEvent, 0, len(events)) - - for _, ev := range events { - if ev.EventNonce.Uint64() > nonce { - res = append(res, ev) - } - } - - return res -} - -func filterValsetUpdateEventsByNonce( - events []*wrappers.PeggyValsetUpdatedEvent, - nonce uint64, -) []*wrappers.PeggyValsetUpdatedEvent { - res := make([]*wrappers.PeggyValsetUpdatedEvent, 0, len(events)) - - for _, ev := range events { - if ev.EventNonce.Uint64() > nonce { - res = append(res, ev) - } - } - return res -} - -func isUnknownBlockErr(err error) bool { - // Geth error - if strings.Contains(err.Error(), "unknown block") { - return true - } - - // Parity error - if strings.Contains(err.Error(), "One of the blocks specified in filter") { - return true - } - - return false -} diff --git a/orchestrator/ethereum/committer/eth_committer.go b/orchestrator/ethereum/committer/eth_committer.go index ca29d9ca..b231f1b8 100644 --- a/orchestrator/ethereum/committer/eth_committer.go +++ b/orchestrator/ethereum/committer/eth_committer.go @@ -150,9 +150,8 @@ func (e *ethCommitter) SendTx( return nil } else { log.WithFields(log.Fields{ - "txHash": txHash.Hex(), - "txHashRet": txHashRet.Hex(), - }).WithError(err).Warningln("SendTransaction failed with error") + "tx_hash": txHash.Hex(), + }).WithError(err).Warningln("failed to send tx") } switch { @@ -199,6 +198,8 @@ func (e *ethCommitter) SendTx( }); err != nil { metrics.ReportFuncError(e.svcTags) + log.WithError(err).Errorln("SendTx serialize failed") + return common.Hash{}, err } diff --git a/orchestrator/ethereum/network.go b/orchestrator/ethereum/network.go new file mode 100644 index 00000000..2d3b72f0 --- /dev/null +++ b/orchestrator/ethereum/network.go @@ -0,0 +1,269 @@ +package ethereum + +import ( + "context" + "math/big" + "strings" + "time" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + ethcmn "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/rpc" + "github.com/pkg/errors" + log "github.com/xlab/suplog" + + "github.com/InjectiveLabs/peggo/orchestrator/ethereum/committer" + "github.com/InjectiveLabs/peggo/orchestrator/ethereum/peggy" + "github.com/InjectiveLabs/peggo/orchestrator/ethereum/provider" + wrappers "github.com/InjectiveLabs/peggo/solidity/wrappers/Peggy.sol" + peggytypes "github.com/InjectiveLabs/sdk-go/chain/peggy/types" +) + +type Network struct { + peggy.PeggyContract +} + +func NewNetwork( + ethNodeRPC string, + peggyContractAddr, + fromAddr ethcmn.Address, + signerFn bind.SignerFn, + gasPriceAdjustment float64, + maxGasPrice string, + pendingTxWaitDuration string, + ethNodeAlchemyWS string, +) (*Network, error) { + evmRPC, err := rpc.Dial(ethNodeRPC) + if err != nil { + return nil, errors.Wrapf(err, "failed to connect to ethereum RPC: %s", ethNodeRPC) + } + + ethCommitter, err := committer.NewEthCommitter( + fromAddr, + gasPriceAdjustment, + maxGasPrice, + signerFn, + provider.NewEVMProvider(evmRPC), + ) + if err != nil { + return nil, err + } + + pendingTxDuration, err := time.ParseDuration(pendingTxWaitDuration) + if err != nil { + return nil, err + } + + peggyContract, err := peggy.NewPeggyContract(ethCommitter, peggyContractAddr, peggy.PendingTxInputList{}, pendingTxDuration) + if err != nil { + return nil, err + } + + log.WithFields(log.Fields{ + "rpc": ethNodeRPC, + "peggy_contract_addr": peggyContractAddr, + }).Infoln("connected to Ethereum network") + + // If Alchemy Websocket URL is set, then Subscribe to Pending Transaction of Peggy Contract. + if ethNodeAlchemyWS != "" { + log.WithFields(log.Fields{ + "url": ethNodeAlchemyWS, + }).Infoln("subscribing to Alchemy websocket") + go peggyContract.SubscribeToPendingTxs(ethNodeAlchemyWS) + } + + return &Network{PeggyContract: peggyContract}, nil +} + +func (n *Network) FromAddress() ethcmn.Address { + return n.PeggyContract.FromAddress() +} + +func (n *Network) HeaderByNumber(ctx context.Context, number *big.Int) (*types.Header, error) { + return n.Provider().HeaderByNumber(ctx, number) +} + +func (n *Network) GetPeggyID(ctx context.Context) (ethcmn.Hash, error) { + return n.PeggyContract.GetPeggyID(ctx, n.FromAddress()) +} + +func (n *Network) GetSendToCosmosEvents(startBlock, endBlock uint64) ([]*wrappers.PeggySendToCosmosEvent, error) { + peggyFilterer, err := wrappers.NewPeggyFilterer(n.Address(), n.Provider()) + if err != nil { + return nil, errors.Wrap(err, "failed to init Peggy events filterer") + } + + iter, err := peggyFilterer.FilterSendToCosmosEvent(&bind.FilterOpts{ + Start: startBlock, + End: &endBlock, + }, nil, nil, nil) + if err != nil { + if !isUnknownBlockErr(err) { + return nil, errors.Wrap(err, "failed to scan past SendToCosmos events from Ethereum") + } else if iter == nil { + return nil, errors.New("no iterator returned") + } + } + + defer iter.Close() + + var sendToCosmosEvents []*wrappers.PeggySendToCosmosEvent + for iter.Next() { + sendToCosmosEvents = append(sendToCosmosEvents, iter.Event) + } + + return sendToCosmosEvents, nil +} + +func (n *Network) GetSendToInjectiveEvents(startBlock, endBlock uint64) ([]*wrappers.PeggySendToInjectiveEvent, error) { + peggyFilterer, err := wrappers.NewPeggyFilterer(n.Address(), n.Provider()) + if err != nil { + return nil, errors.Wrap(err, "failed to init Peggy events filterer") + } + + iter, err := peggyFilterer.FilterSendToInjectiveEvent(&bind.FilterOpts{ + Start: startBlock, + End: &endBlock, + }, nil, nil, nil) + if err != nil { + if !isUnknownBlockErr(err) { + return nil, errors.Wrap(err, "failed to scan past SendToCosmos events from Ethereum") + } else if iter == nil { + return nil, errors.New("no iterator returned") + } + } + + defer iter.Close() + + var sendToInjectiveEvents []*wrappers.PeggySendToInjectiveEvent + for iter.Next() { + sendToInjectiveEvents = append(sendToInjectiveEvents, iter.Event) + } + + return sendToInjectiveEvents, nil +} + +func (n *Network) GetPeggyERC20DeployedEvents(startBlock, endBlock uint64) ([]*wrappers.PeggyERC20DeployedEvent, error) { + peggyFilterer, err := wrappers.NewPeggyFilterer(n.Address(), n.Provider()) + if err != nil { + return nil, errors.Wrap(err, "failed to init Peggy events filterer") + } + + iter, err := peggyFilterer.FilterERC20DeployedEvent(&bind.FilterOpts{ + Start: startBlock, + End: &endBlock, + }, nil) + if err != nil { + if !isUnknownBlockErr(err) { + return nil, errors.Wrap(err, "failed to scan past TransactionBatchExecuted events from Ethereum") + } else if iter == nil { + return nil, errors.New("no iterator returned") + } + } + + defer iter.Close() + + var transactionBatchExecutedEvents []*wrappers.PeggyERC20DeployedEvent + for iter.Next() { + transactionBatchExecutedEvents = append(transactionBatchExecutedEvents, iter.Event) + } + + return transactionBatchExecutedEvents, nil +} + +func (n *Network) GetValsetUpdatedEvents(startBlock, endBlock uint64) ([]*wrappers.PeggyValsetUpdatedEvent, error) { + peggyFilterer, err := wrappers.NewPeggyFilterer(n.Address(), n.Provider()) + if err != nil { + return nil, errors.Wrap(err, "failed to init Peggy events filterer") + } + + iter, err := peggyFilterer.FilterValsetUpdatedEvent(&bind.FilterOpts{ + Start: startBlock, + End: &endBlock, + }, nil) + if err != nil { + if !isUnknownBlockErr(err) { + return nil, errors.Wrap(err, "failed to scan past ValsetUpdatedEvent events from Ethereum") + } else if iter == nil { + return nil, errors.New("no iterator returned") + } + } + + defer iter.Close() + + var valsetUpdatedEvents []*wrappers.PeggyValsetUpdatedEvent + for iter.Next() { + valsetUpdatedEvents = append(valsetUpdatedEvents, iter.Event) + } + + return valsetUpdatedEvents, nil +} + +func (n *Network) GetTransactionBatchExecutedEvents(startBlock, endBlock uint64) ([]*wrappers.PeggyTransactionBatchExecutedEvent, error) { + peggyFilterer, err := wrappers.NewPeggyFilterer(n.Address(), n.Provider()) + if err != nil { + return nil, errors.Wrap(err, "failed to init Peggy events filterer") + } + + iter, err := peggyFilterer.FilterTransactionBatchExecutedEvent(&bind.FilterOpts{ + Start: startBlock, + End: &endBlock, + }, nil, nil) + if err != nil { + if !isUnknownBlockErr(err) { + return nil, errors.Wrap(err, "failed to scan past TransactionBatchExecuted events from Ethereum") + } else if iter == nil { + return nil, errors.New("no iterator returned") + } + } + + defer iter.Close() + + var transactionBatchExecutedEvents []*wrappers.PeggyTransactionBatchExecutedEvent + for iter.Next() { + transactionBatchExecutedEvents = append(transactionBatchExecutedEvents, iter.Event) + } + + return transactionBatchExecutedEvents, nil +} + +func (n *Network) GetValsetNonce(ctx context.Context) (*big.Int, error) { + return n.PeggyContract.GetValsetNonce(ctx, n.FromAddress()) +} + +func (n *Network) SendEthValsetUpdate( + ctx context.Context, + oldValset *peggytypes.Valset, + newValset *peggytypes.Valset, + confirms []*peggytypes.MsgValsetConfirm, +) (*ethcmn.Hash, error) { + return n.PeggyContract.SendEthValsetUpdate(ctx, oldValset, newValset, confirms) +} + +func (n *Network) GetTxBatchNonce(ctx context.Context, erc20ContractAddress ethcmn.Address) (*big.Int, error) { + return n.PeggyContract.GetTxBatchNonce(ctx, erc20ContractAddress, n.FromAddress()) +} + +func (n *Network) SendTransactionBatch( + ctx context.Context, + currentValset *peggytypes.Valset, + batch *peggytypes.OutgoingTxBatch, + confirms []*peggytypes.MsgConfirmBatch, +) (*ethcmn.Hash, error) { + return n.PeggyContract.SendTransactionBatch(ctx, currentValset, batch, confirms) +} + +func isUnknownBlockErr(err error) bool { + // Geth error + if strings.Contains(err.Error(), "unknown block") { + return true + } + + // Parity error + if strings.Contains(err.Error(), "One of the blocks specified in filter") { + return true + } + + return false +} diff --git a/orchestrator/ethereum/peggy/submit_batch.go b/orchestrator/ethereum/peggy/submit_batch.go index 29d3aeb2..09b7d17b 100644 --- a/orchestrator/ethereum/peggy/submit_batch.go +++ b/orchestrator/ethereum/peggy/submit_batch.go @@ -24,9 +24,10 @@ func (s *peggyContract) SendTransactionBatch( log.WithFields(log.Fields{ "token_contract": batch.TokenContract, - "new_nonce": batch.BatchNonce, - }).Infoln("Checking signatures and submitting TransactionBatch to Ethereum") - log.Debugf("Batch %#v", batch) + "nonce": batch.BatchNonce, + "transactions": len(batch.Transactions), + "confirmations": len(confirms), + }).Debugln("checking signatures and submitting batch to Ethereum") validators, powers, sigV, sigR, sigS, err := checkBatchSigsAndRepack(currentValset, confirms) if err != nil { @@ -96,8 +97,6 @@ func (s *peggyContract) SendTransactionBatch( return nil, err } - log.Infoln("Sent Tx (Peggy submitBatch):", txHash.Hex()) - // let before_nonce = get_tx_batch_nonce( // peggy_contract_address, // batch.token_contract, diff --git a/orchestrator/main_loops.go b/orchestrator/main_loops.go deleted file mode 100644 index a05dc99c..00000000 --- a/orchestrator/main_loops.go +++ /dev/null @@ -1,426 +0,0 @@ -package orchestrator - -import ( - "context" - "errors" - "math" - "math/big" - "time" - - "github.com/avast/retry-go" - "github.com/ethereum/go-ethereum/common" - "github.com/shopspring/decimal" - log "github.com/xlab/suplog" - - "github.com/InjectiveLabs/sdk-go/chain/peggy/types" - - "github.com/InjectiveLabs/peggo/orchestrator/cosmos" - "github.com/InjectiveLabs/peggo/orchestrator/loops" - - cosmtypes "github.com/cosmos/cosmos-sdk/types" - ethcmn "github.com/ethereum/go-ethereum/common" -) - -const defaultLoopDur = 60 * time.Second - -// Start combines the all major roles required to make -// up the Orchestrator, all of these are async loops. -func (s *peggyOrchestrator) Start(ctx context.Context, validatorMode bool) error { - if !validatorMode { - log.Infoln("Starting peggo in relayer (non-validator) mode") - return s.startRelayerMode(ctx) - } - - log.Infoln("Starting peggo in validator mode") - return s.startValidatorMode(ctx) -} - -// EthOracleMainLoop is responsible for making sure that Ethereum events are retrieved from the Ethereum blockchain -// and ferried over to Cosmos where they will be used to issue tokens or process batches. -// -// TODO this loop requires a method to bootstrap back to the correct event nonce when restarted -func (s *peggyOrchestrator) EthOracleMainLoop(ctx context.Context) (err error) { - logger := log.WithField("loop", "EthOracleMainLoop") - lastResync := time.Now() - var lastCheckedBlock uint64 - - if err := retry.Do(func() (err error) { - lastCheckedBlock, err = s.GetLastCheckedBlock(ctx) - if lastCheckedBlock == 0 { - peggyParams, err := s.cosmosQueryClient.PeggyParams(ctx) - if err != nil { - log.WithError(err).Fatalln("failed to query peggy params, is injectived running?") - } - lastCheckedBlock = peggyParams.BridgeContractStartHeight - } - return - }, retry.Context(ctx), retry.OnRetry(func(n uint, err error) { - logger.WithError(err).Warningf("failed to get last checked block, will retry (%d)", n) - })); err != nil { - logger.WithError(err).Errorln("got error, loop exits") - return err - } - - logger.WithField("lastCheckedBlock", lastCheckedBlock).Infoln("Start scanning for events") - - return loops.RunLoop(ctx, defaultLoopDur, func() error { - // Relays events from Ethereum -> Cosmos - var currentBlock uint64 - if err := retry.Do(func() (err error) { - currentBlock, err = s.CheckForEvents(ctx, lastCheckedBlock) - return - }, retry.Context(ctx), retry.OnRetry(func(n uint, err error) { - logger.WithError(err).Warningf("error during Eth event checking, will retry (%d)", n) - })); err != nil { - logger.WithError(err).Errorln("got error, loop exits") - return err - } - - lastCheckedBlock = currentBlock - - /* - Auto re-sync to catch up the nonce. Reasons why event nonce fall behind. - 1. It takes some time for events to be indexed on Ethereum. So if peggo queried events immediately as block produced, there is a chance the event is missed. - we need to re-scan this block to ensure events are not missed due to indexing delay. - 2. if validator was in UnBonding state, the claims broadcasted in last iteration are failed. - 3. if infura call failed while filtering events, the peggo missed to broadcast claim events occured in last iteration. - **/ - if time.Since(lastResync) >= 48*time.Hour { - if err := retry.Do(func() (err error) { - lastCheckedBlock, err = s.GetLastCheckedBlock(ctx) - return - }, retry.Context(ctx), retry.OnRetry(func(n uint, err error) { - logger.WithError(err).Warningf("failed to get last checked block, will retry (%d)", n) - })); err != nil { - logger.WithError(err).Errorln("got error, loop exits") - return err - } - lastResync = time.Now() - logger.WithFields(log.Fields{"lastResync": lastResync, "lastCheckedBlock": lastCheckedBlock}).Infoln("Auto resync") - } - - return nil - }) -} - -// EthSignerMainLoop simply signs off on any batches or validator sets provided by the validator -// since these are provided directly by a trusted Cosmsos node they can simply be assumed to be -// valid and signed off on. -func (s *peggyOrchestrator) EthSignerMainLoop(ctx context.Context) (err error) { - logger := log.WithField("loop", "EthSignerMainLoop") - - var peggyID common.Hash - if err := retry.Do(func() (err error) { - peggyID, err = s.peggyContract.GetPeggyID(ctx, s.peggyContract.FromAddress()) - return - }, retry.Context(ctx), retry.OnRetry(func(n uint, err error) { - logger.WithError(err).Warningf("failed to get PeggyID from Ethereum contract, will retry (%d)", n) - })); err != nil { - logger.WithError(err).Errorln("got error, loop exits") - return err - } - logger.Debugf("received peggyID %s", peggyID.Hex()) - - return loops.RunLoop(ctx, defaultLoopDur, func() error { - var oldestUnsignedValsets []*types.Valset - if err := retry.Do(func() error { - oldestValsets, err := s.cosmosQueryClient.OldestUnsignedValsets(ctx, s.peggyBroadcastClient.AccFromAddress()) - if err != nil { - if err == cosmos.ErrNotFound || oldestValsets == nil { - logger.Debugln("no Valset waiting to be signed") - return nil - } - - return err - } - oldestUnsignedValsets = oldestValsets - return nil - }, retry.Context(ctx), retry.OnRetry(func(n uint, err error) { - logger.WithError(err).Warningf("failed to get unsigned Valset for signing, will retry (%d)", n) - })); err != nil { - logger.WithError(err).Errorln("got error, loop exits") - return err - } - - for _, oldestValset := range oldestUnsignedValsets { - logger.Infoln("Sending Valset confirm for %d", oldestValset.Nonce) - if err := retry.Do(func() error { - return s.peggyBroadcastClient.SendValsetConfirm(ctx, s.ethFrom, peggyID, oldestValset) - }, retry.Context(ctx), retry.OnRetry(func(n uint, err error) { - logger.WithError(err).Warningf("failed to sign and send Valset confirmation to Cosmos, will retry (%d)", n) - })); err != nil { - logger.WithError(err).Errorln("got error, loop exits") - return err - } - } - - var oldestUnsignedTransactionBatch *types.OutgoingTxBatch - if err := retry.Do(func() error { - // sign the last unsigned batch, TODO check if we already have signed this - txBatch, err := s.cosmosQueryClient.OldestUnsignedTransactionBatch(ctx, s.peggyBroadcastClient.AccFromAddress()) - if err != nil { - if err == cosmos.ErrNotFound || txBatch == nil { - logger.Debugln("no TransactionBatch waiting to be signed") - return nil - } - return err - } - oldestUnsignedTransactionBatch = txBatch - return nil - }, retry.Context(ctx), retry.OnRetry(func(n uint, err error) { - logger.WithError(err).Warningf("failed to get unsigned TransactionBatch for signing, will retry (%d)", n) - })); err != nil { - logger.WithError(err).Errorln("got error, loop exits") - return err - } - - if oldestUnsignedTransactionBatch != nil { - logger.Infoln("Sending TransactionBatch confirm for BatchNonce %d", oldestUnsignedTransactionBatch.BatchNonce) - if err := retry.Do(func() error { - return s.peggyBroadcastClient.SendBatchConfirm(ctx, s.ethFrom, peggyID, oldestUnsignedTransactionBatch) - }, retry.Context(ctx), retry.OnRetry(func(n uint, err error) { - logger.WithError(err).Warningf("failed to sign and send TransactionBatch confirmation to Cosmos, will retry (%d)", n) - })); err != nil { - logger.WithError(err).Errorln("got error, loop exits") - return err - } - } - return nil - }) -} - -// This loop doesn't have a formal role per say, anyone can request a valset -// but there does need to be some strategy to ensure requests are made. Having it -// be a function of the orchestrator makes a lot of sense as they are already online -// and have all the required funds, keys, and rpc servers setup -// -// Exactly how to balance optimizing this versus testing is an interesting discussion -// in testing we want to make sure requests are made without any powers changing on the chain -// just to simplify the test environment. But in production that's somewhat wasteful. What this -// routine does it check the current valset versus the last requested valset, if power has changed -// significantly we send in a request. - -/* -Not required any more. The valset request are generated in endblocker of peggy module automatically. Also MsgSendValsetRequest is removed on peggy module. - -func (s *peggyOrchestrator) ValsetRequesterLoop(ctx context.Context) (err error) { - logger := log.WithField("loop", "ValsetRequesterLoop") - - return loops.RunLoop(ctx, defaultLoopDur, func() error { - var latestValsets []*types.Valset - var currentValset *types.Valset - - var pg loops.ParanoidGroup - - pg.Go(func() error { - return retry.Do(func() (err error) { - latestValsets, err = s.cosmosQueryClient.LatestValsets(ctx) - return - }, retry.Context(ctx), retry.OnRetry(func(n uint, err error) { - logger.WithError(err).Warningf("failed to get latest valsets, will retry (%d)", n) - })) - }) - - pg.Go(func() error { - return retry.Do(func() (err error) { - currentValset, err = s.cosmosQueryClient.CurrentValset(ctx) - return - }, retry.Context(ctx), retry.OnRetry(func(n uint, err error) { - logger.WithError(err).Warningf("failed to get current valset, will retry (%d)", n) - })) - }) - - if err := pg.Wait(); err != nil { - logger.WithError(err).Errorln("got error, loop exits") - return err - } - - if len(latestValsets) == 0 { - retry.Do(func() error { - return s.peggyBroadcastClient.SendValsetRequest(ctx) - }, retry.Context(ctx), retry.OnRetry(func(n uint, err error) { - logger.WithError(err).Warningf("failed to request Valset to be formed, will retry (%d)", n) - })) - } else { - // if the power difference is more than 1% different than the last valset - if valPowerDiff(latestValsets[0], currentValset) > 0.01 { - log.Debugln("power difference is more than 1%% different than the last valset. Sending valset request") - - retry.Do(func() error { - return s.peggyBroadcastClient.SendValsetRequest(ctx) - }, retry.Context(ctx), retry.OnRetry(func(n uint, err error) { - logger.WithError(err).Warningf("failed to request Valset to be formed, will retry (%d)", n) - })) - } - } - - return nil - }) -} -**/ - -func (s *peggyOrchestrator) BatchRequesterLoop(ctx context.Context) (err error) { - logger := log.WithField("loop", "BatchRequesterLoop") - return loops.RunLoop(ctx, defaultLoopDur, func() error { - // get All the denominations - // check if threshold is met - // broadcast Request batch - - var pg loops.ParanoidGroup - - pg.Go(func() error { - - var unbatchedTokensWithFees []*types.BatchFees - - if err := retry.Do(func() (err error) { - unbatchedTokensWithFees, err = s.cosmosQueryClient.UnbatchedTokensWithFees(ctx) - return - }, retry.Context(ctx), retry.OnRetry(func(n uint, err error) { - logger.WithError(err).Errorf("failed to get UnbatchedTokensWithFees, will retry (%d)", n) - })); err != nil { - // non-fatal, just alert - logger.Warningln("unable to get UnbatchedTokensWithFees for the token") - return nil - } - - if len(unbatchedTokensWithFees) > 0 { - logger.WithField("unbatchedTokensWithFees", unbatchedTokensWithFees).Debugln("Check if token fees meets set threshold amount and send batch request") - for _, unbatchedToken := range unbatchedTokensWithFees { - return retry.Do(func() (err error) { - // check if the token is present in cosmos denom. if so, send batch request with cosmosDenom - tokenAddr := ethcmn.HexToAddress(unbatchedToken.Token) - - var denom string - if cosmosDenom, ok := s.erc20ContractMapping[tokenAddr]; ok { - // cosmos denom - denom = cosmosDenom - } else { - // peggy denom - denom = types.PeggyDenomString(tokenAddr) - } - - // send batch request only if fee threshold is met. - if s.CheckFeeThreshold(tokenAddr, unbatchedToken.TotalFees, s.minBatchFeeUSD) { - logger.WithFields(log.Fields{"tokenContract": tokenAddr, "denom": denom}).Infoln("sending batch request") - _ = s.peggyBroadcastClient.SendRequestBatch(ctx, denom) - } - - return nil - }, retry.Context(ctx), retry.OnRetry(func(n uint, err error) { - logger.WithError(err).Errorf("failed to get LatestUnbatchOutgoingTx, will retry (%d)", n) - })) - } - } else { - logger.Debugln("No outgoing withdraw tx or Unbatched token fee less than threshold") - } - return nil - }) - return pg.Wait() - }) -} - -func (s *peggyOrchestrator) CheckFeeThreshold(erc20Contract common.Address, totalFee cosmtypes.Int, minFeeInUSD float64) bool { - if minFeeInUSD == 0 { - return true - } - - tokenPriceInUSD, err := s.priceFeeder.QueryUSDPrice(erc20Contract) - if err != nil { - return false - } - - tokenPriceInUSDDec := decimal.NewFromFloat(tokenPriceInUSD) - totalFeeInUSDDec := decimal.NewFromBigInt(totalFee.BigInt(), -18).Mul(tokenPriceInUSDDec) - minFeeInUSDDec := decimal.NewFromFloat(minFeeInUSD) - - if totalFeeInUSDDec.GreaterThan(minFeeInUSDDec) { - return true - } - return false -} - -func (s *peggyOrchestrator) RelayerMainLoop(ctx context.Context) (err error) { - if s.relayer != nil { - return s.relayer.Start(ctx) - } else { - return errors.New("relayer is nil") - } -} - -// valPowerDiff returns the difference in power between two bridge validator sets -// TODO: this needs to be potentially refactored -func valPowerDiff(old *types.Valset, new *types.Valset) float64 { - powers := map[string]int64{} - var totalB int64 - // loop over b and initialize the map with their powers - for _, bv := range old.GetMembers() { - powers[bv.EthereumAddress] = int64(bv.Power) - totalB += int64(bv.Power) - } - - // subtract c powers from powers in the map, initializing - // uninitialized keys with negative numbers - for _, bv := range new.GetMembers() { - if val, ok := powers[bv.EthereumAddress]; ok { - powers[bv.EthereumAddress] = val - int64(bv.Power) - } else { - powers[bv.EthereumAddress] = -int64(bv.Power) - } - } - - var delta float64 - for _, v := range powers { - // NOTE: we care about the absolute value of the changes - delta += math.Abs(float64(v)) - } - - return math.Abs(delta / float64(totalB)) -} - -func calculateTotalValsetPower(valset *types.Valset) *big.Int { - totalValsetPower := new(big.Int) - for _, m := range valset.Members { - mPower := big.NewInt(0).SetUint64(m.Power) - totalValsetPower.Add(totalValsetPower, mPower) - } - - return totalValsetPower -} - -// startValidatorMode runs all orchestrator processes. This is called -// when peggo is run alongside a validator injective node. -func (s *peggyOrchestrator) startValidatorMode(ctx context.Context) error { - var pg loops.ParanoidGroup - - pg.Go(func() error { - return s.EthOracleMainLoop(ctx) - }) - pg.Go(func() error { - return s.BatchRequesterLoop(ctx) - }) - pg.Go(func() error { - return s.EthSignerMainLoop(ctx) - }) - pg.Go(func() error { - return s.RelayerMainLoop(ctx) - }) - - return pg.Wait() -} - -// startRelayerMode runs orchestrator processes that only relay specific -// messages that do not require a validator's signature. This mode is run -// alongside a non-validator injective node -func (s *peggyOrchestrator) startRelayerMode(ctx context.Context) error { - var pg loops.ParanoidGroup - - pg.Go(func() error { - return s.BatchRequesterLoop(ctx) - }) - - pg.Go(func() error { - return s.RelayerMainLoop(ctx) - }) - - return pg.Wait() -} diff --git a/orchestrator/mocks_test.go b/orchestrator/mocks_test.go new file mode 100644 index 00000000..e33dedb0 --- /dev/null +++ b/orchestrator/mocks_test.go @@ -0,0 +1,211 @@ +package orchestrator + +import ( + "context" + peggyevents "github.com/InjectiveLabs/peggo/solidity/wrappers/Peggy.sol" + tmctypes "github.com/cometbft/cometbft/rpc/core/types" + eth "github.com/ethereum/go-ethereum/common" + ethtypes "github.com/ethereum/go-ethereum/core/types" + "math/big" + + peggytypes "github.com/InjectiveLabs/sdk-go/chain/peggy/types" +) + +type mockPriceFeed struct { + queryFn func(eth.Address) (float64, error) +} + +func (p mockPriceFeed) QueryUSDPrice(address eth.Address) (float64, error) { + return p.queryFn(address) +} + +type mockInjective struct { + unbatchedTokenFeesFn func(context.Context) ([]*peggytypes.BatchFees, error) + unbatchedTokenFeesCallCount int + + sendRequestBatchFn func(context.Context, string) error + sendRequestBatchCallCount int + + peggyParamsFn func(context.Context) (*peggytypes.Params, error) + lastClaimEventFn func(context.Context) (*peggytypes.LastClaimEvent, error) + sendEthereumClaimsFn func( + ctx context.Context, + lastClaimEvent uint64, + oldDeposits []*peggyevents.PeggySendToCosmosEvent, + deposits []*peggyevents.PeggySendToInjectiveEvent, + withdraws []*peggyevents.PeggyTransactionBatchExecutedEvent, + erc20Deployed []*peggyevents.PeggyERC20DeployedEvent, + valsetUpdates []*peggyevents.PeggyValsetUpdatedEvent, + ) error + sendEthereumClaimsCallCount int + + oldestUnsignedValsetsFn func(context.Context) ([]*peggytypes.Valset, error) + sendValsetConfirmFn func(context.Context, eth.Hash, *peggytypes.Valset, eth.Address) error + + oldestUnsignedTransactionBatchFn func(context.Context) (*peggytypes.OutgoingTxBatch, error) + sendBatchConfirmFn func(context.Context, eth.Hash, *peggytypes.OutgoingTxBatch, eth.Address) error + + latestValsetsFn func(context.Context) ([]*peggytypes.Valset, error) + getBlockFn func(context.Context, int64) (*tmctypes.ResultBlock, error) + + allValsetConfirmsFn func(context.Context, uint64) ([]*peggytypes.MsgValsetConfirm, error) + valsetAtFn func(context.Context, uint64) (*peggytypes.Valset, error) + + latestTransactionBatchesFn func(context.Context) ([]*peggytypes.OutgoingTxBatch, error) + transactionBatchSignaturesFn func(context.Context, uint64, eth.Address) ([]*peggytypes.MsgConfirmBatch, error) +} + +func (i *mockInjective) UnbatchedTokenFees(ctx context.Context) ([]*peggytypes.BatchFees, error) { + i.unbatchedTokenFeesCallCount++ + return i.unbatchedTokenFeesFn(ctx) +} + +func (i *mockInjective) SendRequestBatch(ctx context.Context, denom string) error { + i.sendRequestBatchCallCount++ + return i.sendRequestBatchFn(ctx, denom) +} + +func (i *mockInjective) PeggyParams(ctx context.Context) (*peggytypes.Params, error) { + return i.peggyParamsFn(ctx) +} + +func (i *mockInjective) LastClaimEvent(ctx context.Context) (*peggytypes.LastClaimEvent, error) { + return i.lastClaimEventFn(ctx) +} + +func (i *mockInjective) SendEthereumClaims( + ctx context.Context, + lastClaimEvent uint64, + oldDeposits []*peggyevents.PeggySendToCosmosEvent, + deposits []*peggyevents.PeggySendToInjectiveEvent, + withdraws []*peggyevents.PeggyTransactionBatchExecutedEvent, + erc20Deployed []*peggyevents.PeggyERC20DeployedEvent, + valsetUpdates []*peggyevents.PeggyValsetUpdatedEvent, +) error { + i.sendEthereumClaimsCallCount++ + return i.sendEthereumClaimsFn( + ctx, + lastClaimEvent, + oldDeposits, + deposits, + withdraws, + erc20Deployed, + valsetUpdates, + ) +} + +func (i *mockInjective) OldestUnsignedValsets(ctx context.Context) ([]*peggytypes.Valset, error) { + return i.oldestUnsignedValsetsFn(ctx) +} + +func (i *mockInjective) SendValsetConfirm(ctx context.Context, peggyID eth.Hash, valset *peggytypes.Valset, ethFrom eth.Address) error { + return i.sendValsetConfirmFn(ctx, peggyID, valset, ethFrom) +} + +func (i *mockInjective) OldestUnsignedTransactionBatch(ctx context.Context) (*peggytypes.OutgoingTxBatch, error) { + return i.oldestUnsignedTransactionBatchFn(ctx) +} + +func (i *mockInjective) GetBlock(ctx context.Context, height int64) (*tmctypes.ResultBlock, error) { + return i.getBlockFn(ctx, height) +} + +func (i *mockInjective) LatestValsets(ctx context.Context) ([]*peggytypes.Valset, error) { + return i.latestValsetsFn(ctx) +} + +func (i *mockInjective) AllValsetConfirms(ctx context.Context, nonce uint64) ([]*peggytypes.MsgValsetConfirm, error) { + return i.allValsetConfirmsFn(ctx, nonce) +} + +func (i *mockInjective) SendBatchConfirm(ctx context.Context, peggyID eth.Hash, batch *peggytypes.OutgoingTxBatch, ethFrom eth.Address) error { + return i.sendBatchConfirmFn(ctx, peggyID, batch, ethFrom) +} + +func (i *mockInjective) ValsetAt(ctx context.Context, nonce uint64) (*peggytypes.Valset, error) { + return i.valsetAtFn(ctx, nonce) +} + +func (i *mockInjective) LatestTransactionBatches(ctx context.Context) ([]*peggytypes.OutgoingTxBatch, error) { + return i.latestTransactionBatchesFn(ctx) +} + +func (i *mockInjective) TransactionBatchSignatures(ctx context.Context, nonce uint64, tokenContract eth.Address) ([]*peggytypes.MsgConfirmBatch, error) { + return i.transactionBatchSignaturesFn(ctx, nonce, tokenContract) +} + +type mockEthereum struct { + fromAddressFn func() eth.Address + headerByNumberFn func(context.Context, *big.Int) (*ethtypes.Header, error) + getSendToCosmosEventsFn func(uint64, uint64) ([]*peggyevents.PeggySendToCosmosEvent, error) + getSendToInjectiveEventsFn func(uint64, uint64) ([]*peggyevents.PeggySendToInjectiveEvent, error) + getPeggyERC20DeployedEventsFn func(uint64, uint64) ([]*peggyevents.PeggyERC20DeployedEvent, error) + getValsetUpdatedEventsFn func(uint64, uint64) ([]*peggyevents.PeggyValsetUpdatedEvent, error) + getTransactionBatchExecutedEventsFn func(uint64, uint64) ([]*peggyevents.PeggyTransactionBatchExecutedEvent, error) + getPeggyIDFn func(context.Context) (eth.Hash, error) + getValsetNonceFn func(context.Context) (*big.Int, error) + sendEthValsetUpdateFn func(context.Context, *peggytypes.Valset, *peggytypes.Valset, []*peggytypes.MsgValsetConfirm) (*eth.Hash, error) + getTxBatchNonceFn func(context.Context, eth.Address) (*big.Int, error) + sendTransactionBatchFn func(context.Context, *peggytypes.Valset, *peggytypes.OutgoingTxBatch, []*peggytypes.MsgConfirmBatch) (*eth.Hash, error) +} + +func (e mockEthereum) FromAddress() eth.Address { + return e.fromAddressFn() +} + +func (e mockEthereum) HeaderByNumber(ctx context.Context, number *big.Int) (*ethtypes.Header, error) { + return e.headerByNumberFn(ctx, number) +} + +func (e mockEthereum) GetSendToCosmosEvents(startBlock, endBlock uint64) ([]*peggyevents.PeggySendToCosmosEvent, error) { + return e.getSendToCosmosEventsFn(startBlock, endBlock) +} + +func (e mockEthereum) GetSendToInjectiveEvents(startBlock, endBlock uint64) ([]*peggyevents.PeggySendToInjectiveEvent, error) { + return e.getSendToInjectiveEventsFn(startBlock, endBlock) +} + +func (e mockEthereum) GetPeggyERC20DeployedEvents(startBlock, endBlock uint64) ([]*peggyevents.PeggyERC20DeployedEvent, error) { + return e.getPeggyERC20DeployedEventsFn(startBlock, endBlock) +} + +func (e mockEthereum) GetValsetUpdatedEvents(startBlock, endBlock uint64) ([]*peggyevents.PeggyValsetUpdatedEvent, error) { + return e.getValsetUpdatedEventsFn(startBlock, endBlock) +} + +func (e mockEthereum) GetTransactionBatchExecutedEvents(startBlock, endBlock uint64) ([]*peggyevents.PeggyTransactionBatchExecutedEvent, error) { + return e.getTransactionBatchExecutedEventsFn(startBlock, endBlock) +} + +func (e mockEthereum) GetPeggyID(ctx context.Context) (eth.Hash, error) { + return e.getPeggyIDFn(ctx) +} + +func (e mockEthereum) GetValsetNonce(ctx context.Context) (*big.Int, error) { + return e.getValsetNonceFn(ctx) +} + +func (e mockEthereum) SendEthValsetUpdate( + ctx context.Context, + oldValset *peggytypes.Valset, + newValset *peggytypes.Valset, + confirms []*peggytypes.MsgValsetConfirm, +) (*eth.Hash, error) { + return e.sendEthValsetUpdateFn(ctx, oldValset, newValset, confirms) +} + +func (e mockEthereum) GetTxBatchNonce( + ctx context.Context, + erc20ContractAddress eth.Address, +) (*big.Int, error) { + return e.getTxBatchNonceFn(ctx, erc20ContractAddress) +} + +func (e mockEthereum) SendTransactionBatch( + ctx context.Context, + currentValset *peggytypes.Valset, + batch *peggytypes.OutgoingTxBatch, + confirms []*peggytypes.MsgConfirmBatch, +) (*eth.Hash, error) { + return e.sendTransactionBatchFn(ctx, currentValset, batch, confirms) +} diff --git a/orchestrator/oracle.go b/orchestrator/oracle.go new file mode 100644 index 00000000..9ac8976d --- /dev/null +++ b/orchestrator/oracle.go @@ -0,0 +1,368 @@ +package orchestrator + +import ( + "context" + "time" + + "github.com/avast/retry-go" + "github.com/pkg/errors" + log "github.com/xlab/suplog" + + "github.com/InjectiveLabs/peggo/orchestrator/loops" + wrappers "github.com/InjectiveLabs/peggo/solidity/wrappers/Peggy.sol" +) + +// Considering blocktime of up to 3 seconds approx on the Injective Chain and an oracle loop duration = 1 minute, +// we broadcast only 20 events in each iteration. +// So better to search only 20 blocks to ensure all the events are broadcast to Injective Chain without misses. +const ( + ethBlockConfirmationDelay uint64 = 96 + defaultBlocksToSearch uint64 = 2000 +) + +// EthOracleMainLoop is responsible for making sure that Ethereum events are retrieved from the Ethereum blockchain +// and ferried over to Cosmos where they will be used to issue tokens or process batches. +func (s *PeggyOrchestrator) EthOracleMainLoop(ctx context.Context) error { + lastConfirmedEthHeight, err := s.getLastConfirmedEthHeightOnInjective(ctx) + if err != nil { + return err + } + + oracle := ðOracle{ + log: log.WithField("loop", "EthOracle"), + retries: s.maxAttempts, + lastResyncWithInjective: time.Now(), + lastCheckedEthHeight: lastConfirmedEthHeight, + } + + return loops.RunLoop( + ctx, + defaultLoopDur, + func() error { return oracle.run(ctx, s.injective, s.ethereum) }, + ) +} + +func (s *PeggyOrchestrator) getLastConfirmedEthHeightOnInjective(ctx context.Context) (uint64, error) { + var lastConfirmedEthHeight uint64 + retryFn := func() error { + lastClaimEvent, err := s.injective.LastClaimEvent(ctx) + if err == nil && lastClaimEvent != nil && lastClaimEvent.EthereumEventHeight != 0 { + lastConfirmedEthHeight = lastClaimEvent.EthereumEventHeight + return nil + + } + + log.WithError(err).Warningf("failed to get last claim from Injective. Querying peggy params...") + + peggyParams, err := s.injective.PeggyParams(ctx) + if err != nil { + log.WithError(err).Fatalln("failed to query peggy module params, is injectived running?") + return err + } + + lastConfirmedEthHeight = peggyParams.BridgeContractStartHeight + return nil + } + + if err := retry.Do(retryFn, + retry.Context(ctx), + retry.Attempts(s.maxAttempts), + retry.OnRetry(func(n uint, err error) { + log.WithError(err).Warningf("failed to get last confirmed Ethereum height on Injective, will retry (%d)", n) + }), + ); err != nil { + log.WithError(err).Errorln("got error, loop exits") + return 0, err + } + + return lastConfirmedEthHeight, nil +} + +type ethOracle struct { + log log.Logger + retries uint + lastResyncWithInjective time.Time + lastCheckedEthHeight uint64 +} + +func (o *ethOracle) run( + ctx context.Context, + injective InjectiveNetwork, + ethereum EthereumNetwork, +) error { + o.log.WithField("last_checked_eth_height", o.lastCheckedEthHeight).Infoln("scanning Ethereum for events") + + // Relays events from Ethereum -> Cosmos + newHeight, err := o.relayEvents(ctx, injective, ethereum) + if err != nil { + return err + } + + o.lastCheckedEthHeight = newHeight + + if time.Since(o.lastResyncWithInjective) >= 48*time.Hour { + /** + Auto re-sync to catch up the nonce. Reasons why event nonce fall behind. + 1. It takes some time for events to be indexed on Ethereum. So if peggo queried events immediately as block produced, there is a chance the event is missed. + we need to re-scan this block to ensure events are not missed due to indexing delay. + 2. if validator was in UnBonding state, the claims broadcasted in last iteration are failed. + 3. if infura call failed while filtering events, the peggo missed to broadcast claim events occured in last iteration. + **/ + if err := o.autoResync(ctx, injective); err != nil { + return err + } + } + + return nil +} + +func (o *ethOracle) relayEvents( + ctx context.Context, + injective InjectiveNetwork, + ethereum EthereumNetwork, +) (uint64, error) { + // Relays events from Ethereum -> Cosmos + var ( + latestHeight uint64 + currentHeight = o.lastCheckedEthHeight + ) + + retryFn := func() error { + latestHeader, err := ethereum.HeaderByNumber(ctx, nil) + if err != nil { + return errors.Wrap(err, "failed to get latest ethereum header") + } + + // add delay to ensure minimum confirmations are received and block is finalised + latestHeight = latestHeader.Number.Uint64() - ethBlockConfirmationDelay + if latestHeight < currentHeight { + println(latestHeight) + return nil + } + + if latestHeight > currentHeight+defaultBlocksToSearch { + latestHeight = currentHeight + defaultBlocksToSearch + } + + legacyDeposits, err := ethereum.GetSendToCosmosEvents(currentHeight, latestHeight) + if err != nil { + return errors.Wrap(err, "failed to get SendToCosmos events") + } + + deposits, err := ethereum.GetSendToInjectiveEvents(currentHeight, latestHeight) + if err != nil { + return errors.Wrap(err, "failed to get SendToInjective events") + } + + withdrawals, err := ethereum.GetTransactionBatchExecutedEvents(currentHeight, latestHeight) + if err != nil { + return errors.Wrap(err, "failed to get TransactionBatchExecuted events") + } + + erc20Deployments, err := ethereum.GetPeggyERC20DeployedEvents(currentHeight, latestHeight) + if err != nil { + return errors.Wrap(err, "failed to get ERC20Deployed events") + } + + valsetUpdates, err := ethereum.GetValsetUpdatedEvents(currentHeight, latestHeight) + if err != nil { + return errors.Wrap(err, "failed to get ValsetUpdated events") + } + + // note that starting block overlaps with our last checked block, because we have to deal with + // the possibility that the relayer was killed after relaying only one of multiple events in a single + // block, so we also need this routine so make sure we don't send in the first event in this hypothetical + // multi event block again. In theory we only send all events for every block and that will pass of fail + // atomically but lets not take that risk. + lastClaimEvent, err := injective.LastClaimEvent(ctx) + if err != nil { + return errors.New("failed to query last claim event from Injective") + } + + legacyDeposits = filterSendToCosmosEventsByNonce(legacyDeposits, lastClaimEvent.EthereumEventNonce) + o.log.WithFields(log.Fields{ + "block_start": currentHeight, + "block_end": latestHeight, + "events": legacyDeposits, + }).Debugln("scanned SendToCosmos events") + + deposits = filterSendToInjectiveEventsByNonce(deposits, lastClaimEvent.EthereumEventNonce) + o.log.WithFields(log.Fields{ + "block_start": currentHeight, + "block_end": latestHeight, + "events": deposits, + }).Debugln("scanned SendToInjective events") + + withdrawals = filterTransactionBatchExecutedEventsByNonce(withdrawals, lastClaimEvent.EthereumEventNonce) + o.log.WithFields(log.Fields{ + "block_start": currentHeight, + "block_end": latestHeight, + "events": withdrawals, + }).Debugln("scanned TransactionBatchExecuted events") + + erc20Deployments = filterERC20DeployedEventsByNonce(erc20Deployments, lastClaimEvent.EthereumEventNonce) + o.log.WithFields(log.Fields{ + "block_start": currentHeight, + "block_end": latestHeight, + "events": erc20Deployments, + }).Debugln("scanned FilterERC20Deployed events") + + valsetUpdates = filterValsetUpdateEventsByNonce(valsetUpdates, lastClaimEvent.EthereumEventNonce) + o.log.WithFields(log.Fields{ + "block_start": currentHeight, + "block_end": latestHeight, + "events": valsetUpdates, + }).Debugln("scanned ValsetUpdated events") + + if len(legacyDeposits) == 0 && + len(deposits) == 0 && + len(withdrawals) == 0 && + len(erc20Deployments) == 0 && + len(valsetUpdates) == 0 { + return nil + } + + if err := injective.SendEthereumClaims(ctx, + lastClaimEvent.EthereumEventNonce, + legacyDeposits, + deposits, + withdrawals, + erc20Deployments, + valsetUpdates, + ); err != nil { + return errors.Wrap(err, "failed to send event claims to Injective") + } + + o.log.WithFields(log.Fields{ + "last_claim_event_nonce": lastClaimEvent.EthereumEventNonce, + "legacy_deposits": len(legacyDeposits), + "deposits": len(deposits), + "withdrawals": len(withdrawals), + "erc20Deployments": len(erc20Deployments), + "valsetUpdates": len(valsetUpdates), + }).Infoln("sent new claims to Injective") + + return nil + } + + if err := retry.Do(retryFn, + retry.Context(ctx), + retry.Attempts(o.retries), + retry.OnRetry(func(n uint, err error) { + o.log.WithError(err).Warningf("error during Ethereum event checking, will retry (%d)", n) + }), + ); err != nil { + o.log.WithError(err).Errorln("got error, loop exits") + return 0, err + } + + return latestHeight, nil +} + +func (o *ethOracle) autoResync(ctx context.Context, injective InjectiveNetwork) error { + var latestHeight uint64 + retryFn := func() error { + lastClaimEvent, err := injective.LastClaimEvent(ctx) + if err != nil { + return err + } + + latestHeight = lastClaimEvent.EthereumEventHeight + return nil + } + + if err := retry.Do(retryFn, + retry.Context(ctx), + retry.Attempts(o.retries), + retry.OnRetry(func(n uint, err error) { + o.log.WithError(err).Warningf("failed to get last confirmed eth height, will retry (%d)", n) + }), + ); err != nil { + o.log.WithError(err).Errorln("got error, loop exits") + return err + } + + o.lastCheckedEthHeight = latestHeight + o.lastResyncWithInjective = time.Now() + + o.log.WithFields(log.Fields{ + "last_resync": o.lastResyncWithInjective.String(), + "last_confirmed_eth_height": o.lastCheckedEthHeight, + }).Infoln("auto resync") + + return nil +} + +func filterSendToCosmosEventsByNonce( + events []*wrappers.PeggySendToCosmosEvent, + nonce uint64, +) []*wrappers.PeggySendToCosmosEvent { + res := make([]*wrappers.PeggySendToCosmosEvent, 0, len(events)) + + for _, ev := range events { + if ev.EventNonce.Uint64() > nonce { + res = append(res, ev) + } + } + + return res +} + +func filterSendToInjectiveEventsByNonce( + events []*wrappers.PeggySendToInjectiveEvent, + nonce uint64, +) []*wrappers.PeggySendToInjectiveEvent { + res := make([]*wrappers.PeggySendToInjectiveEvent, 0, len(events)) + + for _, ev := range events { + if ev.EventNonce.Uint64() > nonce { + res = append(res, ev) + } + } + + return res +} + +func filterTransactionBatchExecutedEventsByNonce( + events []*wrappers.PeggyTransactionBatchExecutedEvent, + nonce uint64, +) []*wrappers.PeggyTransactionBatchExecutedEvent { + res := make([]*wrappers.PeggyTransactionBatchExecutedEvent, 0, len(events)) + + for _, ev := range events { + if ev.EventNonce.Uint64() > nonce { + res = append(res, ev) + } + } + + return res +} + +func filterERC20DeployedEventsByNonce( + events []*wrappers.PeggyERC20DeployedEvent, + nonce uint64, +) []*wrappers.PeggyERC20DeployedEvent { + res := make([]*wrappers.PeggyERC20DeployedEvent, 0, len(events)) + + for _, ev := range events { + if ev.EventNonce.Uint64() > nonce { + res = append(res, ev) + } + } + + return res +} + +func filterValsetUpdateEventsByNonce( + events []*wrappers.PeggyValsetUpdatedEvent, + nonce uint64, +) []*wrappers.PeggyValsetUpdatedEvent { + res := make([]*wrappers.PeggyValsetUpdatedEvent, 0, len(events)) + + for _, ev := range events { + if ev.EventNonce.Uint64() > nonce { + res = append(res, ev) + } + } + return res +} diff --git a/orchestrator/oracle_resync.go b/orchestrator/oracle_resync.go deleted file mode 100644 index 1a93178f..00000000 --- a/orchestrator/oracle_resync.go +++ /dev/null @@ -1,21 +0,0 @@ -package orchestrator - -import ( - "context" - "github.com/InjectiveLabs/metrics" -) - -// GetLastCheckedBlock retrieves the last claim event this oracle has relayed to Cosmos. -func (s *peggyOrchestrator) GetLastCheckedBlock(ctx context.Context) (uint64, error) { - metrics.ReportFuncCall(s.svcTags) - doneFn := metrics.ReportFuncTiming(s.svcTags) - defer doneFn() - - lastClaimEvent, err := s.cosmosQueryClient.LastClaimEventByAddr(ctx, s.peggyBroadcastClient.AccFromAddress()) - if err != nil { - metrics.ReportFuncError(s.svcTags) - return uint64(0), err - } - - return lastClaimEvent.EthereumEventHeight, nil -} diff --git a/orchestrator/oracle_test.go b/orchestrator/oracle_test.go new file mode 100644 index 00000000..c4f8ffed --- /dev/null +++ b/orchestrator/oracle_test.go @@ -0,0 +1,257 @@ +package orchestrator + +import ( + "context" + "errors" + "math/big" + "testing" + "time" + + peggytypes "github.com/InjectiveLabs/sdk-go/chain/peggy/types" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/assert" + "github.com/xlab/suplog" + + wrappers "github.com/InjectiveLabs/peggo/solidity/wrappers/Peggy.sol" +) + +func TestEthOracle(t *testing.T) { + t.Parallel() + + t.Run("failed to get latest header from ethereum", func(t *testing.T) { + t.Parallel() + + orch := &PeggyOrchestrator{ + ethereum: mockEthereum{ + headerByNumberFn: func(context.Context, *big.Int) (*types.Header, error) { + return nil, errors.New("fail") + }, + }, + } + + assert.Error(t, orch.EthOracleMainLoop(context.TODO())) + }) + + t.Run("latest ethereum header is old", func(t *testing.T) { + t.Parallel() + + ethereum := mockEthereum{ + headerByNumberFn: func(context.Context, *big.Int) (*types.Header, error) { + return &types.Header{Number: big.NewInt(50)}, nil + }, + } + + o := ðOracle{ + log: suplog.DefaultLogger, + retries: 1, + lastResyncWithInjective: time.Now(), + lastCheckedEthHeight: 100, + } + + assert.NoError(t, o.run(context.TODO(), nil, ethereum)) + assert.Equal(t, o.lastCheckedEthHeight, uint64(38)) + }) + + t.Run("failed to get SendToCosmos events", func(t *testing.T) { + t.Parallel() + + ethereum := mockEthereum{ + headerByNumberFn: func(context.Context, *big.Int) (*types.Header, error) { + return &types.Header{Number: big.NewInt(200)}, nil + }, + getSendToCosmosEventsFn: func(uint64, uint64) ([]*wrappers.PeggySendToCosmosEvent, error) { + return nil, errors.New("fail") + }, + } + + o := ðOracle{ + log: suplog.DefaultLogger, + retries: 1, + lastResyncWithInjective: time.Now(), + lastCheckedEthHeight: 100, + } + + assert.Error(t, o.run(context.TODO(), nil, ethereum)) + assert.Equal(t, o.lastCheckedEthHeight, uint64(100)) + }) + + t.Run("failed to get last claim event from injective", func(t *testing.T) { + t.Parallel() + + ethereum := mockEthereum{ + headerByNumberFn: func(context.Context, *big.Int) (*types.Header, error) { + return &types.Header{Number: big.NewInt(200)}, nil + }, + + // no-ops + getSendToCosmosEventsFn: func(uint64, uint64) ([]*wrappers.PeggySendToCosmosEvent, error) { + return nil, nil + }, + getTransactionBatchExecutedEventsFn: func(uint64, uint64) ([]*wrappers.PeggyTransactionBatchExecutedEvent, error) { + return nil, nil + }, + getValsetUpdatedEventsFn: func(uint64, uint64) ([]*wrappers.PeggyValsetUpdatedEvent, error) { + return nil, nil + }, + getPeggyERC20DeployedEventsFn: func(uint64, uint64) ([]*wrappers.PeggyERC20DeployedEvent, error) { + return nil, nil + }, + getSendToInjectiveEventsFn: func(uint64, uint64) ([]*wrappers.PeggySendToInjectiveEvent, error) { + return nil, nil + }, + } + + injective := &mockInjective{ + lastClaimEventFn: func(context.Context) (*peggytypes.LastClaimEvent, error) { + return nil, errors.New("fail") + }, + } + + o := ðOracle{ + log: suplog.DefaultLogger, + retries: 1, + lastResyncWithInjective: time.Now(), + lastCheckedEthHeight: 100, + } + + assert.Error(t, o.run(context.TODO(), injective, ethereum)) + assert.Equal(t, o.lastCheckedEthHeight, uint64(100)) + }) + + t.Run("old events are pruned", func(t *testing.T) { + t.Parallel() + + inj := &mockInjective{ + lastClaimEventFn: func(context.Context) (*peggytypes.LastClaimEvent, error) { + return &peggytypes.LastClaimEvent{EthereumEventNonce: 6}, nil + }, + sendEthereumClaimsFn: func( + context.Context, + uint64, + []*wrappers.PeggySendToCosmosEvent, + []*wrappers.PeggySendToInjectiveEvent, + []*wrappers.PeggyTransactionBatchExecutedEvent, + []*wrappers.PeggyERC20DeployedEvent, + []*wrappers.PeggyValsetUpdatedEvent, + ) error { + return nil + }, + } + + eth := mockEthereum{ + headerByNumberFn: func(context.Context, *big.Int) (*types.Header, error) { + return &types.Header{Number: big.NewInt(200)}, nil + }, + getSendToCosmosEventsFn: func(uint64, uint64) ([]*wrappers.PeggySendToCosmosEvent, error) { + return []*wrappers.PeggySendToCosmosEvent{{EventNonce: big.NewInt(5)}}, nil + }, + + // no-ops + getTransactionBatchExecutedEventsFn: func(uint64, uint64) ([]*wrappers.PeggyTransactionBatchExecutedEvent, error) { + return nil, nil + }, + getValsetUpdatedEventsFn: func(uint64, uint64) ([]*wrappers.PeggyValsetUpdatedEvent, error) { + return nil, nil + }, + getPeggyERC20DeployedEventsFn: func(uint64, uint64) ([]*wrappers.PeggyERC20DeployedEvent, error) { + return nil, nil + }, + getSendToInjectiveEventsFn: func(uint64, uint64) ([]*wrappers.PeggySendToInjectiveEvent, error) { + return nil, nil + }, + } + + o := ðOracle{ + log: suplog.DefaultLogger, + retries: 1, + lastResyncWithInjective: time.Now(), + lastCheckedEthHeight: 100, + } + + assert.NoError(t, o.run(context.TODO(), inj, eth)) + assert.Equal(t, o.lastCheckedEthHeight, uint64(120)) + assert.Equal(t, inj.sendEthereumClaimsCallCount, 0) + }) + + t.Run("new events are sent to injective", func(t *testing.T) { + t.Parallel() + + inj := &mockInjective{ + lastClaimEventFn: func(context.Context) (*peggytypes.LastClaimEvent, error) { + return &peggytypes.LastClaimEvent{EthereumEventNonce: 6}, nil + }, + sendEthereumClaimsFn: func( + context.Context, + uint64, + []*wrappers.PeggySendToCosmosEvent, + []*wrappers.PeggySendToInjectiveEvent, + []*wrappers.PeggyTransactionBatchExecutedEvent, + []*wrappers.PeggyERC20DeployedEvent, + []*wrappers.PeggyValsetUpdatedEvent, + ) error { + return nil + }, + } + + eth := mockEthereum{ + headerByNumberFn: func(context.Context, *big.Int) (*types.Header, error) { + return &types.Header{Number: big.NewInt(200)}, nil + }, + getSendToCosmosEventsFn: func(uint64, uint64) ([]*wrappers.PeggySendToCosmosEvent, error) { + return []*wrappers.PeggySendToCosmosEvent{{EventNonce: big.NewInt(10)}}, nil + }, + + // no-ops + getTransactionBatchExecutedEventsFn: func(uint64, uint64) ([]*wrappers.PeggyTransactionBatchExecutedEvent, error) { + return nil, nil + }, + getValsetUpdatedEventsFn: func(uint64, uint64) ([]*wrappers.PeggyValsetUpdatedEvent, error) { + return nil, nil + }, + getPeggyERC20DeployedEventsFn: func(uint64, uint64) ([]*wrappers.PeggyERC20DeployedEvent, error) { + return nil, nil + }, + getSendToInjectiveEventsFn: func(uint64, uint64) ([]*wrappers.PeggySendToInjectiveEvent, error) { + return nil, nil + }, + } + + o := ðOracle{ + log: suplog.DefaultLogger, + retries: 1, + lastResyncWithInjective: time.Now(), + lastCheckedEthHeight: 100, + } + + assert.NoError(t, o.run(context.TODO(), inj, eth)) + assert.Equal(t, o.lastCheckedEthHeight, uint64(120)) + assert.Equal(t, inj.sendEthereumClaimsCallCount, 1) + }) + + t.Run("auto resync", func(t *testing.T) { + t.Parallel() + + inj := &mockInjective{ + lastClaimEventFn: func(_ context.Context) (*peggytypes.LastClaimEvent, error) { + return &peggytypes.LastClaimEvent{EthereumEventHeight: 101}, nil + }, + } + + eth := mockEthereum{ + headerByNumberFn: func(context.Context, *big.Int) (*types.Header, error) { + return &types.Header{Number: big.NewInt(50)}, nil + }, + } + + o := ðOracle{ + log: suplog.DefaultLogger, + retries: 1, + lastResyncWithInjective: time.Date(2009, time.November, 10, 23, 0, 0, 0, time.UTC), + lastCheckedEthHeight: 100, + } + + assert.NoError(t, o.run(context.TODO(), inj, eth)) + assert.Equal(t, o.lastCheckedEthHeight, uint64(101)) + assert.True(t, time.Since(o.lastResyncWithInjective) < 1*time.Second) + }) +} diff --git a/orchestrator/orchestrator.go b/orchestrator/orchestrator.go index 9e466364..bff2bad2 100644 --- a/orchestrator/orchestrator.go +++ b/orchestrator/orchestrator.go @@ -2,78 +2,202 @@ package orchestrator import ( "context" + "math/big" + "time" - ethcmn "github.com/ethereum/go-ethereum/common" - - "github.com/InjectiveLabs/peggo/orchestrator/coingecko" - "github.com/InjectiveLabs/peggo/orchestrator/cosmos/tmclient" + tmctypes "github.com/cometbft/cometbft/rpc/core/types" + eth "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/pkg/errors" + log "github.com/xlab/suplog" "github.com/InjectiveLabs/metrics" - sidechain "github.com/InjectiveLabs/peggo/orchestrator/cosmos" - "github.com/InjectiveLabs/peggo/orchestrator/ethereum/keystore" - "github.com/InjectiveLabs/peggo/orchestrator/ethereum/peggy" - "github.com/InjectiveLabs/peggo/orchestrator/ethereum/provider" - "github.com/InjectiveLabs/peggo/orchestrator/relayer" + "github.com/InjectiveLabs/peggo/orchestrator/loops" + peggyevents "github.com/InjectiveLabs/peggo/solidity/wrappers/Peggy.sol" + peggytypes "github.com/InjectiveLabs/sdk-go/chain/peggy/types" ) -type PeggyOrchestrator interface { - Start(ctx context.Context, validatorMode bool) error +type PriceFeed interface { + QueryUSDPrice(address eth.Address) (float64, error) +} + +type InjectiveNetwork interface { + PeggyParams(ctx context.Context) (*peggytypes.Params, error) + GetBlock(ctx context.Context, height int64) (*tmctypes.ResultBlock, error) + + // claims + LastClaimEvent(ctx context.Context) (*peggytypes.LastClaimEvent, error) + SendEthereumClaims( + ctx context.Context, + lastClaimEvent uint64, + oldDeposits []*peggyevents.PeggySendToCosmosEvent, + deposits []*peggyevents.PeggySendToInjectiveEvent, + withdraws []*peggyevents.PeggyTransactionBatchExecutedEvent, + erc20Deployed []*peggyevents.PeggyERC20DeployedEvent, + valsetUpdates []*peggyevents.PeggyValsetUpdatedEvent, + ) error + + // batches + UnbatchedTokenFees(ctx context.Context) ([]*peggytypes.BatchFees, error) + SendRequestBatch(ctx context.Context, denom string) error + OldestUnsignedTransactionBatch(ctx context.Context) (*peggytypes.OutgoingTxBatch, error) + SendBatchConfirm(ctx context.Context, peggyID eth.Hash, batch *peggytypes.OutgoingTxBatch, ethFrom eth.Address) error + LatestTransactionBatches(ctx context.Context) ([]*peggytypes.OutgoingTxBatch, error) + TransactionBatchSignatures(ctx context.Context, nonce uint64, tokenContract eth.Address) ([]*peggytypes.MsgConfirmBatch, error) + + // valsets + OldestUnsignedValsets(ctx context.Context) ([]*peggytypes.Valset, error) + SendValsetConfirm(ctx context.Context, peggyID eth.Hash, valset *peggytypes.Valset, ethFrom eth.Address) error + LatestValsets(ctx context.Context) ([]*peggytypes.Valset, error) + AllValsetConfirms(ctx context.Context, nonce uint64) ([]*peggytypes.MsgValsetConfirm, error) + ValsetAt(ctx context.Context, nonce uint64) (*peggytypes.Valset, error) +} + +type EthereumNetwork interface { + FromAddress() eth.Address + HeaderByNumber(ctx context.Context, number *big.Int) (*types.Header, error) + GetPeggyID(ctx context.Context) (eth.Hash, error) + + // events + GetSendToCosmosEvents(startBlock, endBlock uint64) ([]*peggyevents.PeggySendToCosmosEvent, error) + GetSendToInjectiveEvents(startBlock, endBlock uint64) ([]*peggyevents.PeggySendToInjectiveEvent, error) + GetPeggyERC20DeployedEvents(startBlock, endBlock uint64) ([]*peggyevents.PeggyERC20DeployedEvent, error) + GetValsetUpdatedEvents(startBlock, endBlock uint64) ([]*peggyevents.PeggyValsetUpdatedEvent, error) + GetTransactionBatchExecutedEvents(startBlock, endBlock uint64) ([]*peggyevents.PeggyTransactionBatchExecutedEvent, error) - CheckForEvents(ctx context.Context, startingBlock uint64) (currentBlock uint64, err error) - GetLastCheckedBlock(ctx context.Context) (uint64, error) + // valsets + GetValsetNonce(ctx context.Context) (*big.Int, error) + SendEthValsetUpdate( + ctx context.Context, + oldValset *peggytypes.Valset, + newValset *peggytypes.Valset, + confirms []*peggytypes.MsgValsetConfirm, + ) (*eth.Hash, error) - EthOracleMainLoop(ctx context.Context) error - EthSignerMainLoop(ctx context.Context) error - BatchRequesterLoop(ctx context.Context) error - RelayerMainLoop(ctx context.Context) error + // batches + GetTxBatchNonce( + ctx context.Context, + erc20ContractAddress eth.Address, + ) (*big.Int, error) + SendTransactionBatch( + ctx context.Context, + currentValset *peggytypes.Valset, + batch *peggytypes.OutgoingTxBatch, + confirms []*peggytypes.MsgConfirmBatch, + ) (*eth.Hash, error) } -type peggyOrchestrator struct { +const defaultLoopDur = 60 * time.Second + +type PeggyOrchestrator struct { svcTags metrics.Tags - tmClient tmclient.TendermintClient - cosmosQueryClient sidechain.PeggyQueryClient - peggyBroadcastClient sidechain.PeggyBroadcastClient - peggyContract peggy.PeggyContract - ethProvider provider.EVMProvider - ethFrom ethcmn.Address - ethSignerFn keystore.SignerFn - ethPersonalSignFn keystore.PersonalSignFn - erc20ContractMapping map[ethcmn.Address]string - relayer relayer.PeggyRelayer + injective InjectiveNetwork + ethereum EthereumNetwork + pricefeed PriceFeed + + erc20ContractMapping map[eth.Address]string + relayValsetOffsetDur time.Duration + relayBatchOffsetDur time.Duration minBatchFeeUSD float64 - priceFeeder *coingecko.CoingeckoPriceFeed + maxAttempts uint // max number of times a retry func will be called before exiting + + valsetRelayEnabled bool + batchRelayEnabled bool + periodicBatchRequesting bool } func NewPeggyOrchestrator( - cosmosQueryClient sidechain.PeggyQueryClient, - peggyBroadcastClient sidechain.PeggyBroadcastClient, - tmClient tmclient.TendermintClient, - peggyContract peggy.PeggyContract, - ethFrom ethcmn.Address, - ethSignerFn keystore.SignerFn, - ethPersonalSignFn keystore.PersonalSignFn, - erc20ContractMapping map[ethcmn.Address]string, - relayer relayer.PeggyRelayer, + injective InjectiveNetwork, + ethereum EthereumNetwork, + priceFeed PriceFeed, + erc20ContractMapping map[eth.Address]string, minBatchFeeUSD float64, - priceFeeder *coingecko.CoingeckoPriceFeed, - -) PeggyOrchestrator { - return &peggyOrchestrator{ - tmClient: tmClient, - cosmosQueryClient: cosmosQueryClient, - peggyBroadcastClient: peggyBroadcastClient, - peggyContract: peggyContract, - ethProvider: peggyContract.Provider(), - ethFrom: ethFrom, - ethSignerFn: ethSignerFn, - ethPersonalSignFn: ethPersonalSignFn, + valsetRelayingEnabled, + batchRelayingEnabled bool, + valsetRelayingOffset, + batchRelayingOffset string, +) (*PeggyOrchestrator, error) { + orch := &PeggyOrchestrator{ + svcTags: metrics.Tags{"svc": "peggy_orchestrator"}, + injective: injective, + ethereum: ethereum, + pricefeed: priceFeed, erc20ContractMapping: erc20ContractMapping, - relayer: relayer, minBatchFeeUSD: minBatchFeeUSD, - priceFeeder: priceFeeder, - svcTags: metrics.Tags{ - "svc": "peggy_orchestrator", - }, + valsetRelayEnabled: valsetRelayingEnabled, + batchRelayEnabled: batchRelayingEnabled, + maxAttempts: 10, // default is 10 for retry pkg + } + + if valsetRelayingEnabled { + dur, err := time.ParseDuration(valsetRelayingOffset) + if err != nil { + return nil, errors.Wrapf(err, "valset relaying enabled but offset duration is not properly set") + } + + orch.relayValsetOffsetDur = dur } + + if batchRelayingEnabled { + dur, err := time.ParseDuration(batchRelayingOffset) + if err != nil { + return nil, errors.Wrapf(err, "batch relaying enabled but offset duration is not properly set") + } + + orch.relayBatchOffsetDur = dur + } + + return orch, nil +} + +// Run starts all major loops required to make +// up the Orchestrator, all of these are async loops. +func (s *PeggyOrchestrator) Run(ctx context.Context, validatorMode bool) error { + if !validatorMode { + return s.startRelayerMode(ctx) + } + + return s.startValidatorMode(ctx) +} + +// startValidatorMode runs all orchestrator processes. This is called +// when peggo is run alongside a validator injective node. +func (s *PeggyOrchestrator) startValidatorMode(ctx context.Context) error { + log.WithFields(log.Fields{ + "BatchRequesterEnabled": true, + "EthOracleEnabled": true, + "EthSignerEnabled": true, + "ValsetRelayerEnabled": s.valsetRelayEnabled, + "BatchRelayerEnabled": s.batchRelayEnabled, + }).Infoln("running in validator mode") + + var pg loops.ParanoidGroup + + pg.Go(func() error { return s.EthOracleMainLoop(ctx) }) + pg.Go(func() error { return s.BatchRequesterLoop(ctx) }) + pg.Go(func() error { return s.EthSignerMainLoop(ctx) }) + pg.Go(func() error { return s.RelayerMainLoop(ctx) }) + + return pg.Wait() +} + +// startRelayerMode runs orchestrator processes that only relay specific +// messages that do not require a validator's signature. This mode is run +// alongside a non-validator injective node +func (s *PeggyOrchestrator) startRelayerMode(ctx context.Context) error { + log.WithFields(log.Fields{ + "BatchRequesterEnabled": true, + "EthOracleEnabled": false, + "EthSignerEnabled": false, + "ValsetRelayerEnabled": s.valsetRelayEnabled, + "BatchRelayerEnabled": s.batchRelayEnabled, + }).Infoln("running in relayer mode") + + var pg loops.ParanoidGroup + + pg.Go(func() error { return s.BatchRequesterLoop(ctx) }) + pg.Go(func() error { return s.RelayerMainLoop(ctx) }) + + return pg.Wait() } diff --git a/orchestrator/relayer.go b/orchestrator/relayer.go new file mode 100644 index 00000000..04c97e64 --- /dev/null +++ b/orchestrator/relayer.go @@ -0,0 +1,450 @@ +package orchestrator + +import ( + "context" + "sort" + "time" + + "github.com/avast/retry-go" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/ethereum/go-ethereum/common" + "github.com/pkg/errors" + log "github.com/xlab/suplog" + + "github.com/InjectiveLabs/peggo/orchestrator/ethereum/util" + "github.com/InjectiveLabs/peggo/orchestrator/loops" + wrappers "github.com/InjectiveLabs/peggo/solidity/wrappers/Peggy.sol" + "github.com/InjectiveLabs/sdk-go/chain/peggy/types" +) + +func (s *PeggyOrchestrator) RelayerMainLoop(ctx context.Context) (err error) { + rel := &relayer{ + log: log.WithField("loop", "Relayer"), + retries: s.maxAttempts, + relayValsetOffsetDur: s.relayValsetOffsetDur, + relayBatchOffsetDur: s.relayBatchOffsetDur, + valsetRelaying: s.valsetRelayEnabled, + batchRelaying: s.batchRelayEnabled, + } + + return loops.RunLoop( + ctx, + defaultLoopDur, + func() error { return rel.run(ctx, s.injective, s.ethereum) }, + ) +} + +type relayer struct { + log log.Logger + retries uint + relayValsetOffsetDur time.Duration + relayBatchOffsetDur time.Duration + valsetRelaying bool + batchRelaying bool +} + +func (r *relayer) run( + ctx context.Context, + injective InjectiveNetwork, + ethereum EthereumNetwork, +) error { + var pg loops.ParanoidGroup + + if r.valsetRelaying { + r.log.Infoln("scanning Injective for confirmed valset updates") + pg.Go(func() error { + return retry.Do( + func() error { return r.relayValsets(ctx, injective, ethereum) }, + retry.Context(ctx), + retry.Attempts(r.retries), + retry.OnRetry(func(n uint, err error) { + r.log.WithError(err).Warningf("failed to relay valsets, will retry (%d)", n) + }), + ) + }) + } + + if r.batchRelaying { + r.log.Infoln("scanning Injective for confirmed batches") + pg.Go(func() error { + return retry.Do( + func() error { return r.relayBatches(ctx, injective, ethereum) }, + retry.Context(ctx), + retry.Attempts(r.retries), + retry.OnRetry(func(n uint, err error) { + r.log.WithError(err).Warningf("failed to relay batches, will retry (%d)", n) + }), + ) + }) + } + + if pg.Initialized() { + if err := pg.Wait(); err != nil { + r.log.WithError(err).Errorln("got error, loop exits") + return err + } + } + + return nil +} + +func (r *relayer) relayValsets( + ctx context.Context, + injective InjectiveNetwork, + ethereum EthereumNetwork, +) error { + // we should determine if we need to relay one + // to Ethereum for that we will find the latest confirmed valset and compare it to the ethereum chain + latestValsets, err := injective.LatestValsets(ctx) + if err != nil { + return errors.Wrap(err, "failed to get latest valset updates from Injective") + } + + var ( + oldestConfirmedValset *types.Valset + oldestConfirmedValsetSigs []*types.MsgValsetConfirm + ) + + for _, set := range latestValsets { + sigs, err := injective.AllValsetConfirms(ctx, set.Nonce) + if err != nil { + return errors.Wrapf(err, "failed to get valset confirmations for nonce %d", set.Nonce) + } else if len(sigs) == 0 { + continue + } + + oldestConfirmedValsetSigs = sigs + oldestConfirmedValset = set + break + } + + if oldestConfirmedValset == nil { + r.log.Debugln("no confirmed valset updates to relay") + return nil + } + + currentEthValset, err := r.findLatestValsetOnEth(ctx, injective, ethereum) + if err != nil { + return errors.Wrap(err, "failed to find latest confirmed valset update on Ethereum") + } + + r.log.WithFields(log.Fields{ + "inj_valset": oldestConfirmedValset, + "eth_valset": currentEthValset, + }).Debugln("latest valset updates") + + if oldestConfirmedValset.Nonce <= currentEthValset.Nonce { + return nil + } + + latestEthereumValsetNonce, err := ethereum.GetValsetNonce(ctx) + if err != nil { + return errors.Wrap(err, "failed to get latest valset nonce from Ethereum") + } + + // Check if other validators already updated the valset + if oldestConfirmedValset.Nonce <= latestEthereumValsetNonce.Uint64() { + return nil + } + + // Check custom time delay offset + blockResult, err := injective.GetBlock(ctx, int64(oldestConfirmedValset.Height)) + if err != nil { + return errors.Wrapf(err, "failed to get block %d from Injective", oldestConfirmedValset.Height) + } + + if timeElapsed := time.Since(blockResult.Block.Time); timeElapsed <= r.relayValsetOffsetDur { + timeRemaining := time.Duration(int64(r.relayBatchOffsetDur) - int64(timeElapsed)) + r.log.WithField("time_remaining", timeRemaining.String()).Debugln("valset relay offset duration not expired") + return nil + } + + r.log.WithFields(log.Fields{ + "inj_valset": oldestConfirmedValset.Nonce, + "eth_valset": latestEthereumValsetNonce.Uint64(), + }).Infoln("detected new valset on Injective") + + txHash, err := ethereum.SendEthValsetUpdate( + ctx, + currentEthValset, + oldestConfirmedValset, + oldestConfirmedValsetSigs, + ) + + if err != nil { + return err + } + + r.log.WithField("tx_hash", txHash.Hex()).Infoln("updated valset on Ethereum") + + return nil +} + +func (r *relayer) relayBatches( + ctx context.Context, + injective InjectiveNetwork, + ethereum EthereumNetwork, +) error { + latestBatches, err := injective.LatestTransactionBatches(ctx) + if err != nil { + return err + } + + var ( + oldestConfirmedBatch *types.OutgoingTxBatch + oldestConfirmedBatchSigs []*types.MsgConfirmBatch + ) + + for _, batch := range latestBatches { + sigs, err := injective.TransactionBatchSignatures(ctx, batch.BatchNonce, common.HexToAddress(batch.TokenContract)) + if err != nil { + return err + } else if len(sigs) == 0 { + continue + } + + oldestConfirmedBatch = batch + oldestConfirmedBatchSigs = sigs + } + + if oldestConfirmedBatch == nil { + r.log.Debugln("no confirmed transaction batches on Injective, nothing to relay...") + return nil + } + + latestEthereumBatch, err := ethereum.GetTxBatchNonce( + ctx, + common.HexToAddress(oldestConfirmedBatch.TokenContract), + ) + if err != nil { + return err + } + + currentValset, err := r.findLatestValsetOnEth(ctx, injective, ethereum) + if err != nil { + return errors.Wrap(err, "failed to find latest valset") + } else if currentValset == nil { + return errors.Wrap(err, "latest valset not found") + } + + r.log.WithFields(log.Fields{ + "inj_batch": oldestConfirmedBatch.BatchNonce, + "eth_batch": latestEthereumBatch.Uint64(), + }).Debugln("latest batches") + + if oldestConfirmedBatch.BatchNonce <= latestEthereumBatch.Uint64() { + return nil + } + + latestEthereumBatch, err = ethereum.GetTxBatchNonce(ctx, common.HexToAddress(oldestConfirmedBatch.TokenContract)) + if err != nil { + return err + } + + // Check if ethereum batch was updated by other validators + if oldestConfirmedBatch.BatchNonce <= latestEthereumBatch.Uint64() { + return nil + } + + // Check custom time delay offset + blockResult, err := injective.GetBlock(ctx, int64(oldestConfirmedBatch.Block)) + if err != nil { + return errors.Wrapf(err, "failed to get block %d from Injective", oldestConfirmedBatch.Block) + } + + if timeElapsed := time.Since(blockResult.Block.Time); timeElapsed <= r.relayValsetOffsetDur { + timeRemaining := time.Duration(int64(r.relayBatchOffsetDur) - int64(timeElapsed)) + r.log.WithField("time_remaining", timeRemaining.String()).Debugln("batch relay offset duration not expired") + return nil + } + + r.log.WithFields(log.Fields{ + "inj_batch": oldestConfirmedBatch.BatchNonce, + "eth_batch": latestEthereumBatch.Uint64(), + "token_contract": common.HexToAddress(oldestConfirmedBatch.TokenContract), + }).Infoln("detected new batch on Injective") + + // Send SendTransactionBatch to Ethereum + txHash, err := ethereum.SendTransactionBatch(ctx, currentValset, oldestConfirmedBatch, oldestConfirmedBatchSigs) + if err != nil { + return err + } + + r.log.WithField("tx_hash", txHash.Hex()).Infoln("sent batch tx to Ethereum") + + return nil +} + +const valsetBlocksToSearch = 2000 + +// FindLatestValset finds the latest valset on the Peggy contract by looking back through the event +// history and finding the most recent ValsetUpdatedEvent. Most of the time this will be very fast +// as the latest update will be in recent blockchain history and the search moves from the present +// backwards in time. In the case that the validator set has not been updated for a very long time +// this will take longer. +func (r *relayer) findLatestValsetOnEth( + ctx context.Context, + injective InjectiveNetwork, + ethereum EthereumNetwork, +) (*types.Valset, error) { + latestHeader, err := ethereum.HeaderByNumber(ctx, nil) + if err != nil { + return nil, errors.Wrap(err, "failed to get latest eth header") + } + + latestEthereumValsetNonce, err := ethereum.GetValsetNonce(ctx) + if err != nil { + return nil, errors.Wrap(err, "failed to get latest valset nonce on Ethereum") + } + + cosmosValset, err := injective.ValsetAt(ctx, latestEthereumValsetNonce.Uint64()) + if err != nil { + return nil, errors.Wrap(err, "failed to get Injective valset") + } + + currentBlock := latestHeader.Number.Uint64() + + for currentBlock > 0 { + var startSearchBlock uint64 + if currentBlock <= valsetBlocksToSearch { + startSearchBlock = 0 + } else { + startSearchBlock = currentBlock - valsetBlocksToSearch + } + + r.log.WithFields(log.Fields{ + "block_start": startSearchBlock, + "block_end": currentBlock, + }).Debugln("looking for the most recent ValsetUpdatedEvent on Ethereum") + + valsetUpdatedEvents, err := ethereum.GetValsetUpdatedEvents(startSearchBlock, currentBlock) + if err != nil { + return nil, errors.Wrap(err, "failed to filter past ValsetUpdated events from Ethereum") + } + + // by default the lowest found valset goes first, we want the highest + // + // TODO(xlab): this follows the original impl, but sort might be skipped there: + // we could access just the latest element later. + sort.Sort(sort.Reverse(PeggyValsetUpdatedEvents(valsetUpdatedEvents))) + + if len(valsetUpdatedEvents) == 0 { + currentBlock = startSearchBlock + continue + } + + // we take only the first event if we find any at all. + event := valsetUpdatedEvents[0] + valset := &types.Valset{ + Nonce: event.NewValsetNonce.Uint64(), + Members: make([]*types.BridgeValidator, 0, len(event.Powers)), + RewardAmount: sdk.NewIntFromBigInt(event.RewardAmount), + RewardToken: event.RewardToken.Hex(), + } + + for idx, p := range event.Powers { + valset.Members = append(valset.Members, &types.BridgeValidator{ + Power: p.Uint64(), + EthereumAddress: event.Validators[idx].Hex(), + }) + } + + checkIfValsetsDiffer(cosmosValset, valset) + + return valset, nil + + } + + return nil, ErrNotFound +} + +var ErrNotFound = errors.New("not found") + +type PeggyValsetUpdatedEvents []*wrappers.PeggyValsetUpdatedEvent + +func (a PeggyValsetUpdatedEvents) Len() int { return len(a) } +func (a PeggyValsetUpdatedEvents) Less(i, j int) bool { + return a[i].NewValsetNonce.Cmp(a[j].NewValsetNonce) < 0 +} +func (a PeggyValsetUpdatedEvents) Swap(i, j int) { a[i], a[j] = a[j], a[i] } + +// This function exists to provide a warning if Cosmos and Ethereum have different validator sets +// for a given nonce. In the mundane version of this warning the validator sets disagree on sorting order +// which can happen if some relayer uses an unstable sort, or in a case of a mild griefing attack. +// The Peggy contract validates signatures in order of highest to lowest power. That way it can exit +// the loop early once a vote has enough power, if a relayer where to submit things in the reverse order +// they could grief users of the contract into paying more in gas. +// The other (and far worse) way a disagreement here could occur is if validators are colluding to steal +// funds from the Peggy contract and have submitted a hijacking update. If slashing for off Cosmos chain +// Ethereum signatures is implemented you would put that handler here. +func checkIfValsetsDiffer(cosmosValset, ethereumValset *types.Valset) { + if cosmosValset == nil && ethereumValset.Nonce == 0 { + // bootstrapping case + return + } else if cosmosValset == nil { + log.WithField( + "eth_valset_nonce", + ethereumValset.Nonce, + ).Errorln("Cosmos does not have a valset for nonce from Ethereum chain. Possible bridge hijacking!") + return + } + + if cosmosValset.Nonce != ethereumValset.Nonce { + log.WithFields(log.Fields{ + "cosmos_valset_nonce": cosmosValset.Nonce, + "eth_valset_nonce": ethereumValset.Nonce, + }).Errorln("Cosmos does have a wrong valset nonce, differs from Ethereum chain. Possible bridge hijacking!") + return + } + + if len(cosmosValset.Members) != len(ethereumValset.Members) { + log.WithFields(log.Fields{ + "cosmos_valset": len(cosmosValset.Members), + "eth_valset": len(ethereumValset.Members), + }).Errorln("Cosmos and Ethereum Valsets have different length. Possible bridge hijacking!") + return + } + + BridgeValidators(cosmosValset.Members).Sort() + BridgeValidators(ethereumValset.Members).Sort() + + for idx, member := range cosmosValset.Members { + if ethereumValset.Members[idx].EthereumAddress != member.EthereumAddress { + log.Errorln("Valsets are different, a sorting error?") + } + if ethereumValset.Members[idx].Power != member.Power { + log.Errorln("Valsets are different, a sorting error?") + } + } +} + +type BridgeValidators []*types.BridgeValidator + +// Sort sorts the validators by power +func (b BridgeValidators) Sort() { + sort.Slice(b, func(i, j int) bool { + if b[i].Power == b[j].Power { + // Secondary sort on eth address in case powers are equal + return util.EthAddrLessThan(b[i].EthereumAddress, b[j].EthereumAddress) + } + return b[i].Power > b[j].Power + }) +} + +// HasDuplicates returns true if there are duplicates in the set +func (b BridgeValidators) HasDuplicates() bool { + m := make(map[string]struct{}, len(b)) + for i := range b { + m[b[i].EthereumAddress] = struct{}{} + } + return len(m) != len(b) +} + +// GetPowers returns only the power values for all members +func (b BridgeValidators) GetPowers() []uint64 { + r := make([]uint64, len(b)) + for i := range b { + r[i] = b[i].Power + } + return r +} diff --git a/orchestrator/relayer/batch_relaying.go b/orchestrator/relayer/batch_relaying.go deleted file mode 100644 index f6ab6dd1..00000000 --- a/orchestrator/relayer/batch_relaying.go +++ /dev/null @@ -1,109 +0,0 @@ -package relayer - -import ( - "context" - "time" - - "github.com/ethereum/go-ethereum/common" - "github.com/pkg/errors" - log "github.com/xlab/suplog" - - "github.com/InjectiveLabs/metrics" - "github.com/InjectiveLabs/sdk-go/chain/peggy/types" -) - -// RelayBatches checks the last validator set on Ethereum, if it's lower than our latest valida -// set then we should package and submit the update as an Ethereum transaction -func (s *peggyRelayer) RelayBatches(ctx context.Context) error { - metrics.ReportFuncCall(s.svcTags) - doneFn := metrics.ReportFuncTiming(s.svcTags) - defer doneFn() - - latestBatches, err := s.cosmosQueryClient.LatestTransactionBatches(ctx) - if err != nil { - metrics.ReportFuncError(s.svcTags) - return err - } - var oldestSignedBatch *types.OutgoingTxBatch - var oldestSigs []*types.MsgConfirmBatch - for _, batch := range latestBatches { - sigs, err := s.cosmosQueryClient.TransactionBatchSignatures(ctx, batch.BatchNonce, common.HexToAddress(batch.TokenContract)) - if err != nil { - metrics.ReportFuncError(s.svcTags) - return err - } else if len(sigs) == 0 { - continue - } - - oldestSignedBatch = batch - oldestSigs = sigs - } - if oldestSignedBatch == nil { - log.Debugln("could not find batch with signatures, nothing to relay") - return nil - } - - latestEthereumBatch, err := s.peggyContract.GetTxBatchNonce( - ctx, - common.HexToAddress(oldestSignedBatch.TokenContract), - s.peggyContract.FromAddress(), - ) - if err != nil { - metrics.ReportFuncError(s.svcTags) - return err - } - - currentValset, err := s.FindLatestValset(ctx) - if err != nil { - metrics.ReportFuncError(s.svcTags) - return errors.New("failed to find latest valset") - } else if currentValset == nil { - metrics.ReportFuncError(s.svcTags) - return errors.New("latest valset not found") - } - - log.WithFields(log.Fields{"oldestSignedBatchNonce": oldestSignedBatch.BatchNonce, "latestEthereumBatchNonce": latestEthereumBatch.Uint64()}).Debugln("Found Latest valsets") - - if oldestSignedBatch.BatchNonce > latestEthereumBatch.Uint64() { - - latestEthereumBatch, err := s.peggyContract.GetTxBatchNonce( - ctx, - common.HexToAddress(oldestSignedBatch.TokenContract), - s.peggyContract.FromAddress(), - ) - if err != nil { - metrics.ReportFuncError(s.svcTags) - return err - } - // Check if oldestSignedBatch already submitted by other validators in mean time - if oldestSignedBatch.BatchNonce > latestEthereumBatch.Uint64() { - - // Check custom time delay offset - blockResult, err := s.tmClient.GetBlock(ctx, int64(oldestSignedBatch.Block)) - if err != nil { - return err - } - batchCreatedAt := blockResult.Block.Time - relayBatchOffsetDur, err := time.ParseDuration(s.relayBatchOffsetDur) - if err != nil { - return err - } - customTimeDelay := batchCreatedAt.Add(relayBatchOffsetDur) - if time.Now().Sub(customTimeDelay) <= 0 { - return nil - } - - log.Infof("We have detected latest batch %d but latest on Ethereum is %d sending an update!", oldestSignedBatch.BatchNonce, latestEthereumBatch) - - // Send SendTransactionBatch to Ethereum - txHash, err := s.peggyContract.SendTransactionBatch(ctx, currentValset, oldestSignedBatch, oldestSigs) - if err != nil { - metrics.ReportFuncError(s.svcTags) - return err - } - log.WithField("tx_hash", txHash.Hex()).Infoln("Sent Ethereum Tx (TransactionBatch)") - } - } - - return nil -} diff --git a/orchestrator/relayer/find_latest_valset.go b/orchestrator/relayer/find_latest_valset.go deleted file mode 100644 index 5e88825d..00000000 --- a/orchestrator/relayer/find_latest_valset.go +++ /dev/null @@ -1,212 +0,0 @@ -package relayer - -import ( - "context" - "sort" - - "github.com/ethereum/go-ethereum/accounts/abi/bind" - "github.com/pkg/errors" - log "github.com/xlab/suplog" - - "github.com/InjectiveLabs/metrics" - "github.com/InjectiveLabs/peggo/orchestrator/ethereum/util" - "github.com/InjectiveLabs/sdk-go/chain/peggy/types" - sdk "github.com/cosmos/cosmos-sdk/types" - - wrappers "github.com/InjectiveLabs/peggo/solidity/wrappers/Peggy.sol" -) - -const defaultBlocksToSearch = 2000 - -// FindLatestValset finds the latest valset on the Peggy contract by looking back through the event -// history and finding the most recent ValsetUpdatedEvent. Most of the time this will be very fast -// as the latest update will be in recent blockchain history and the search moves from the present -// backwards in time. In the case that the validator set has not been updated for a very long time -// this will take longer. -func (s *peggyRelayer) FindLatestValset(ctx context.Context) (*types.Valset, error) { - metrics.ReportFuncCall(s.svcTags) - doneFn := metrics.ReportFuncTiming(s.svcTags) - defer doneFn() - - latestHeader, err := s.ethProvider.HeaderByNumber(ctx, nil) - if err != nil { - metrics.ReportFuncError(s.svcTags) - err = errors.Wrap(err, "failed to get latest header") - return nil, err - } - currentBlock := latestHeader.Number.Uint64() - - peggyFilterer, err := wrappers.NewPeggyFilterer(s.peggyContract.Address(), s.ethProvider) - if err != nil { - metrics.ReportFuncError(s.svcTags) - err = errors.Wrap(err, "failed to init Peggy events filterer") - return nil, err - } - - latestEthereumValsetNonce, err := s.peggyContract.GetValsetNonce(ctx, s.peggyContract.FromAddress()) - if err != nil { - metrics.ReportFuncError(s.svcTags) - err = errors.Wrap(err, "failed to get latest Valset nonce") - return nil, err - } - - cosmosValset, err := s.cosmosQueryClient.ValsetAt(ctx, latestEthereumValsetNonce.Uint64()) - if err != nil { - metrics.ReportFuncError(s.svcTags) - err = errors.Wrap(err, "failed to get cosmos Valset") - return nil, err - } - - for currentBlock > 0 { - log.WithField("current_block", currentBlock). - Debugln("About to submit a Valset or Batch looking back into the history to find the last Valset Update") - - var endSearchBlock uint64 - if currentBlock <= defaultBlocksToSearch { - endSearchBlock = 0 - } else { - endSearchBlock = currentBlock - defaultBlocksToSearch - } - - var valsetUpdatedEvents []*wrappers.PeggyValsetUpdatedEvent - iter, err := peggyFilterer.FilterValsetUpdatedEvent(&bind.FilterOpts{ - Start: endSearchBlock, - End: ¤tBlock, - }, nil) - if err != nil { - metrics.ReportFuncError(s.svcTags) - err = errors.Wrap(err, "failed to filter past ValsetUpdated events from Ethereum") - return nil, err - } else { - for iter.Next() { - valsetUpdatedEvents = append(valsetUpdatedEvents, iter.Event) - } - - iter.Close() - } - - // by default the lowest found valset goes first, we want the highest - // - // TODO(xlab): this follows the original impl, but sort might be skipped there: - // we could access just the latest element later. - sort.Sort(sort.Reverse(PeggyValsetUpdatedEvents(valsetUpdatedEvents))) - - log.Debugln("found events", valsetUpdatedEvents) - - // we take only the first event if we find any at all. - if len(valsetUpdatedEvents) > 0 { - event := valsetUpdatedEvents[0] - valset := &types.Valset{ - Nonce: event.NewValsetNonce.Uint64(), - Members: make([]*types.BridgeValidator, 0, len(event.Powers)), - RewardAmount: sdk.NewIntFromBigInt(event.RewardAmount), - RewardToken: event.RewardToken.Hex(), - } - - for idx, p := range event.Powers { - valset.Members = append(valset.Members, &types.BridgeValidator{ - Power: p.Uint64(), - EthereumAddress: event.Validators[idx].Hex(), - }) - } - - s.checkIfValsetsDiffer(cosmosValset, valset) - return valset, nil - } - - currentBlock = endSearchBlock - } - - return nil, ErrNotFound -} - -var ErrNotFound = errors.New("not found") - -type PeggyValsetUpdatedEvents []*wrappers.PeggyValsetUpdatedEvent - -func (a PeggyValsetUpdatedEvents) Len() int { return len(a) } -func (a PeggyValsetUpdatedEvents) Less(i, j int) bool { - return a[i].NewValsetNonce.Cmp(a[j].NewValsetNonce) < 0 -} -func (a PeggyValsetUpdatedEvents) Swap(i, j int) { a[i], a[j] = a[j], a[i] } - -// This function exists to provide a warning if Cosmos and Ethereum have different validator sets -// for a given nonce. In the mundane version of this warning the validator sets disagree on sorting order -// which can happen if some relayer uses an unstable sort, or in a case of a mild griefing attack. -// The Peggy contract validates signatures in order of highest to lowest power. That way it can exit -// the loop early once a vote has enough power, if a relayer where to submit things in the reverse order -// they could grief users of the contract into paying more in gas. -// The other (and far worse) way a disagreement here could occur is if validators are colluding to steal -// funds from the Peggy contract and have submitted a hijacking update. If slashing for off Cosmos chain -// Ethereum signatures is implemented you would put that handler here. -func (s *peggyRelayer) checkIfValsetsDiffer(cosmosValset, ethereumValset *types.Valset) { - if cosmosValset == nil && ethereumValset.Nonce == 0 { - // bootstrapping case - return - } else if cosmosValset == nil { - log.WithField( - "eth_valset_nonce", - ethereumValset.Nonce, - ).Errorln("Cosmos does not have a valset for nonce from Ethereum chain. Possible bridge hijacking!") - return - } - - if cosmosValset.Nonce != ethereumValset.Nonce { - log.WithFields(log.Fields{ - "cosmos_valset_nonce": cosmosValset.Nonce, - "eth_valset_nonce": ethereumValset.Nonce, - }).Errorln("Cosmos does have a wrong valset nonce, differs from Ethereum chain. Possible bridge hijacking!") - return - } - - if len(cosmosValset.Members) != len(ethereumValset.Members) { - log.WithFields(log.Fields{ - "cosmos_valset": len(cosmosValset.Members), - "eth_valset": len(ethereumValset.Members), - }).Errorln("Cosmos and Ethereum Valsets have different length. Possible bridge hijacking!") - return - } - - BridgeValidators(cosmosValset.Members).Sort() - BridgeValidators(ethereumValset.Members).Sort() - - for idx, member := range cosmosValset.Members { - if ethereumValset.Members[idx].EthereumAddress != member.EthereumAddress { - log.Errorln("Valsets are different, a sorting error?") - } - if ethereumValset.Members[idx].Power != member.Power { - log.Errorln("Valsets are different, a sorting error?") - } - } -} - -type BridgeValidators []*types.BridgeValidator - -// Sort sorts the validators by power -func (b BridgeValidators) Sort() { - sort.Slice(b, func(i, j int) bool { - if b[i].Power == b[j].Power { - // Secondary sort on eth address in case powers are equal - return util.EthAddrLessThan(b[i].EthereumAddress, b[j].EthereumAddress) - } - return b[i].Power > b[j].Power - }) -} - -// HasDuplicates returns true if there are duplicates in the set -func (b BridgeValidators) HasDuplicates() bool { - m := make(map[string]struct{}, len(b)) - for i := range b { - m[b[i].EthereumAddress] = struct{}{} - } - return len(m) != len(b) -} - -// GetPowers returns only the power values for all members -func (b BridgeValidators) GetPowers() []uint64 { - r := make([]uint64, len(b)) - for i := range b { - r[i] = b[i].Power - } - return r -} diff --git a/orchestrator/relayer/main_loop.go b/orchestrator/relayer/main_loop.go deleted file mode 100644 index 3dcdb46e..00000000 --- a/orchestrator/relayer/main_loop.go +++ /dev/null @@ -1,50 +0,0 @@ -package relayer - -import ( - "context" - "time" - - retry "github.com/avast/retry-go" - log "github.com/xlab/suplog" - - "github.com/InjectiveLabs/peggo/orchestrator/loops" -) - -const defaultLoopDur = 5 * time.Minute - -func (s *peggyRelayer) Start(ctx context.Context) error { - logger := log.WithField("loop", "RelayerMainLoop") - - return loops.RunLoop(ctx, defaultLoopDur, func() error { - var pg loops.ParanoidGroup - if s.valsetRelayEnabled { - logger.Info("Valset Relay Enabled. Starting to relay valsets to Ethereum") - pg.Go(func() error { - return retry.Do(func() error { - return s.RelayValsets(ctx) - }, retry.Context(ctx), retry.OnRetry(func(n uint, err error) { - logger.WithError(err).Warningf("failed to relay Valsets, will retry (%d)", n) - })) - }) - } - - if s.batchRelayEnabled { - logger.Info("Batch Relay Enabled. Starting to relay batches to Ethereum") - pg.Go(func() error { - return retry.Do(func() error { - return s.RelayBatches(ctx) - }, retry.Context(ctx), retry.OnRetry(func(n uint, err error) { - logger.WithError(err).Warningf("failed to relay TxBatches, will retry (%d)", n) - })) - }) - } - - if pg.Initialized() { - if err := pg.Wait(); err != nil { - logger.WithError(err).Errorln("got error, loop exits") - return err - } - } - return nil - }) -} diff --git a/orchestrator/relayer/relayer.go b/orchestrator/relayer/relayer.go deleted file mode 100644 index fb4fd042..00000000 --- a/orchestrator/relayer/relayer.go +++ /dev/null @@ -1,57 +0,0 @@ -package relayer - -import ( - "context" - - "github.com/InjectiveLabs/metrics" - "github.com/InjectiveLabs/peggo/orchestrator/cosmos" - "github.com/InjectiveLabs/peggo/orchestrator/cosmos/tmclient" - "github.com/InjectiveLabs/peggo/orchestrator/ethereum/peggy" - "github.com/InjectiveLabs/peggo/orchestrator/ethereum/provider" - "github.com/InjectiveLabs/sdk-go/chain/peggy/types" -) - -type PeggyRelayer interface { - Start(ctx context.Context) error - - FindLatestValset(ctx context.Context) (*types.Valset, error) - RelayBatches(ctx context.Context) error - RelayValsets(ctx context.Context) error -} - -type peggyRelayer struct { - svcTags metrics.Tags - - tmClient tmclient.TendermintClient - cosmosQueryClient cosmos.PeggyQueryClient - peggyContract peggy.PeggyContract - ethProvider provider.EVMProvider - valsetRelayEnabled bool - relayValsetOffsetDur string - batchRelayEnabled bool - relayBatchOffsetDur string -} - -func NewPeggyRelayer( - cosmosQueryClient cosmos.PeggyQueryClient, - tmClient tmclient.TendermintClient, - peggyContract peggy.PeggyContract, - valsetRelayEnabled bool, - relayValsetOffsetDur string, - batchRelayEnabled bool, - relayBatchOffsetDur string, -) PeggyRelayer { - return &peggyRelayer{ - tmClient: tmClient, - cosmosQueryClient: cosmosQueryClient, - peggyContract: peggyContract, - ethProvider: peggyContract.Provider(), - valsetRelayEnabled: valsetRelayEnabled, - relayValsetOffsetDur: relayValsetOffsetDur, - batchRelayEnabled: batchRelayEnabled, - relayBatchOffsetDur: relayBatchOffsetDur, - svcTags: metrics.Tags{ - "svc": "peggy_relayer", - }, - } -} diff --git a/orchestrator/relayer/valset_relaying.go b/orchestrator/relayer/valset_relaying.go deleted file mode 100644 index 38ede30f..00000000 --- a/orchestrator/relayer/valset_relaying.go +++ /dev/null @@ -1,108 +0,0 @@ -package relayer - -import ( - "context" - "time" - - "github.com/pkg/errors" - log "github.com/xlab/suplog" - - "github.com/InjectiveLabs/metrics" - "github.com/InjectiveLabs/sdk-go/chain/peggy/types" -) - -// RelayValsets checks the last validator set on Ethereum, if it's lower than our latest validator -// set then we should package and submit the update as an Ethereum transaction -func (s *peggyRelayer) RelayValsets(ctx context.Context) error { - metrics.ReportFuncCall(s.svcTags) - doneFn := metrics.ReportFuncTiming(s.svcTags) - defer doneFn() - - // we should determine if we need to relay one - // to Ethereum for that we will find the latest confirmed valset and compare it to the ethereum chain - latestValsets, err := s.cosmosQueryClient.LatestValsets(ctx) - if err != nil { - metrics.ReportFuncError(s.svcTags) - err = errors.Wrap(err, "failed to fetch latest valsets from cosmos") - return err - } - - var latestCosmosSigs []*types.MsgValsetConfirm - var latestCosmosConfirmed *types.Valset - for _, set := range latestValsets { - sigs, err := s.cosmosQueryClient.AllValsetConfirms(ctx, set.Nonce) - if err != nil { - metrics.ReportFuncError(s.svcTags) - err = errors.Wrapf(err, "failed to get valset confirms at nonce %d", set.Nonce) - return err - } else if len(sigs) == 0 { - continue - } - - latestCosmosSigs = sigs - latestCosmosConfirmed = set - break - } - - if latestCosmosConfirmed == nil { - log.Debugln("no confirmed valsets found, nothing to relay") - return nil - } - - currentEthValset, err := s.FindLatestValset(ctx) - if err != nil { - metrics.ReportFuncError(s.svcTags) - err = errors.Wrap(err, "couldn't find latest confirmed valset on Ethereum") - return err - } - log.WithFields(log.Fields{"currentEthValset": currentEthValset, "latestCosmosConfirmed": latestCosmosConfirmed}).Debugln("Found Latest valsets") - - if latestCosmosConfirmed.Nonce > currentEthValset.Nonce { - - latestEthereumValsetNonce, err := s.peggyContract.GetValsetNonce(ctx, s.peggyContract.FromAddress()) - if err != nil { - metrics.ReportFuncError(s.svcTags) - err = errors.Wrap(err, "failed to get latest Valset nonce") - return err - } - - // Check if latestCosmosConfirmed already submitted by other validators in mean time - if latestCosmosConfirmed.Nonce > latestEthereumValsetNonce.Uint64() { - - // Check custom time delay offset - blockResult, err := s.tmClient.GetBlock(ctx, int64(latestCosmosConfirmed.Height)) - if err != nil { - return err - } - valsetCreatedAt := blockResult.Block.Time - relayValsetOffsetDur, err := time.ParseDuration(s.relayValsetOffsetDur) - if err != nil { - return err - } - customTimeDelay := valsetCreatedAt.Add(relayValsetOffsetDur) - if time.Now().Sub(customTimeDelay) <= 0 { - return nil - } - - log.Infof("Detected latest cosmos valset nonce %d, but latest valset on Ethereum is %d. Sending update to Ethereum\n", - latestCosmosConfirmed.Nonce, latestEthereumValsetNonce.Uint64()) - - // Send Valset Update to Ethereum - txHash, err := s.peggyContract.SendEthValsetUpdate( - ctx, - currentEthValset, - latestCosmosConfirmed, - latestCosmosSigs, - ) - if err != nil { - metrics.ReportFuncError(s.svcTags) - return err - } - - log.WithField("tx_hash", txHash.Hex()).Infoln("Sent Ethereum Tx (EthValsetUpdate)") - } - - } - - return nil -} diff --git a/orchestrator/relayer_test.go b/orchestrator/relayer_test.go new file mode 100644 index 00000000..43043ab2 --- /dev/null +++ b/orchestrator/relayer_test.go @@ -0,0 +1,1152 @@ +package orchestrator + +import ( + "context" + "math/big" + "testing" + "time" + + tmctypes "github.com/cometbft/cometbft/rpc/core/types" + tmtypes "github.com/cometbft/cometbft/types" + cosmtypes "github.com/cosmos/cosmos-sdk/types" + "github.com/ethereum/go-ethereum/common" + ctypes "github.com/ethereum/go-ethereum/core/types" + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" + "github.com/xlab/suplog" + + wrappers "github.com/InjectiveLabs/peggo/solidity/wrappers/Peggy.sol" + "github.com/InjectiveLabs/sdk-go/chain/peggy/types" +) + +func TestValsetRelaying(t *testing.T) { + t.Parallel() + + t.Run("failed to fetch latest valsets from injective", func(t *testing.T) { + t.Parallel() + + injective := &mockInjective{ + latestValsetsFn: func(_ context.Context) ([]*types.Valset, error) { + return nil, errors.New("fail") + }, + } + + rel := &relayer{ + log: suplog.DefaultLogger, + retries: 1, + valsetRelaying: true, + } + + assert.Error(t, rel.relayValsets(context.TODO(), injective, nil)) + }) + + t.Run("failed to fetch confirms for a valset", func(t *testing.T) { + t.Parallel() + + inj := &mockInjective{ + latestValsetsFn: func(_ context.Context) ([]*types.Valset, error) { + return []*types.Valset{{}}, nil // non-empty will do + }, + allValsetConfirmsFn: func(_ context.Context, _ uint64) ([]*types.MsgValsetConfirm, error) { + return nil, errors.New("fail") + }, + } + + rel := &relayer{ + log: suplog.DefaultLogger, + retries: 1, + valsetRelaying: true, + } + + assert.Error(t, rel.relayValsets(context.TODO(), inj, nil)) + }) + + t.Run("no confirms for valset", func(t *testing.T) { + t.Parallel() + + inj := &mockInjective{ + latestValsetsFn: func(_ context.Context) ([]*types.Valset, error) { + return []*types.Valset{{}}, nil // non-empty will do + }, + allValsetConfirmsFn: func(_ context.Context, _ uint64) ([]*types.MsgValsetConfirm, error) { + return nil, nil + }, + } + + rel := &relayer{ + log: suplog.DefaultLogger, + retries: 1, + valsetRelaying: true, + } + + assert.NoError(t, rel.relayValsets(context.TODO(), inj, nil)) + }) + + t.Run("failed to get latest ethereum header", func(t *testing.T) { + t.Parallel() + + inj := &mockInjective{ + latestValsetsFn: func(_ context.Context) ([]*types.Valset, error) { + return []*types.Valset{{}}, nil // non-empty will do + }, + allValsetConfirmsFn: func(_ context.Context, _ uint64) ([]*types.MsgValsetConfirm, error) { + return []*types.MsgValsetConfirm{ + { + Nonce: 5, + Orchestrator: "orch", + EthAddress: "eth", + Signature: "sig", + }, + }, nil + }, + } + + eth := mockEthereum{ + headerByNumberFn: func(_ context.Context, _ *big.Int) (*ctypes.Header, error) { + return nil, errors.New("fail") + }, + } + + rel := &relayer{ + log: suplog.DefaultLogger, + retries: 1, + valsetRelaying: true, + } + + assert.Error(t, rel.relayValsets(context.TODO(), inj, eth)) + }) + + t.Run("failed to get latest ethereum header", func(t *testing.T) { + t.Parallel() + + inj := &mockInjective{ + latestValsetsFn: func(_ context.Context) ([]*types.Valset, error) { + return []*types.Valset{{}}, nil // non-empty will do + }, + allValsetConfirmsFn: func(_ context.Context, _ uint64) ([]*types.MsgValsetConfirm, error) { + return []*types.MsgValsetConfirm{ + { + Nonce: 5, + Orchestrator: "orch", + EthAddress: "eth", + Signature: "sig", + }, + }, nil + }, + } + + eth := mockEthereum{ + headerByNumberFn: func(_ context.Context, _ *big.Int) (*ctypes.Header, error) { + return nil, errors.New("fail") + }, + } + + rel := &relayer{ + log: suplog.DefaultLogger, + retries: 1, + valsetRelaying: true, + } + + assert.Error(t, rel.relayValsets(context.TODO(), inj, eth)) + }) + + t.Run("failed to get valset nonce from peggy contract", func(t *testing.T) { + t.Parallel() + + inj := &mockInjective{ + latestValsetsFn: func(_ context.Context) ([]*types.Valset, error) { + return []*types.Valset{{}}, nil // non-empty will do + }, + allValsetConfirmsFn: func(_ context.Context, _ uint64) ([]*types.MsgValsetConfirm, error) { + return []*types.MsgValsetConfirm{ + { + Nonce: 5, + Orchestrator: "orch", + EthAddress: "eth", + Signature: "sig", + }, + }, nil + }, + } + + eth := mockEthereum{ + headerByNumberFn: func(_ context.Context, _ *big.Int) (*ctypes.Header, error) { + return &ctypes.Header{Number: big.NewInt(123)}, nil + }, + getValsetNonceFn: func(_ context.Context) (*big.Int, error) { + return nil, errors.New("fail") + }, + } + + rel := &relayer{ + log: suplog.DefaultLogger, + retries: 1, + valsetRelaying: true, + } + + assert.Error(t, rel.relayValsets(context.TODO(), inj, eth)) + }) + + t.Run("failed to get specific valset from injective", func(t *testing.T) { + t.Parallel() + + inj := &mockInjective{ + latestValsetsFn: func(_ context.Context) ([]*types.Valset, error) { + return []*types.Valset{{}}, nil // non-empty will do + }, + allValsetConfirmsFn: func(_ context.Context, _ uint64) ([]*types.MsgValsetConfirm, error) { + return []*types.MsgValsetConfirm{ + { + Nonce: 5, + Orchestrator: "orch", + EthAddress: "eth", + Signature: "sig", + }, + }, nil + }, + valsetAtFn: func(_ context.Context, _ uint64) (*types.Valset, error) { + return nil, errors.New("fail") + }, + } + + eth := mockEthereum{ + headerByNumberFn: func(_ context.Context, _ *big.Int) (*ctypes.Header, error) { + return &ctypes.Header{Number: big.NewInt(123)}, nil + }, + getValsetNonceFn: func(_ context.Context) (*big.Int, error) { + return big.NewInt(100), nil + }, + } + + rel := &relayer{ + log: suplog.DefaultLogger, + retries: 1, + valsetRelaying: true, + } + + assert.Error(t, rel.relayValsets(context.TODO(), inj, eth)) + }) + + t.Run("failed to get valset update events from ethereum", func(t *testing.T) { + t.Parallel() + + inj := &mockInjective{ + latestValsetsFn: func(_ context.Context) ([]*types.Valset, error) { + return []*types.Valset{{}}, nil // non-empty will do + }, + allValsetConfirmsFn: func(_ context.Context, _ uint64) ([]*types.MsgValsetConfirm, error) { + return []*types.MsgValsetConfirm{ + { + Nonce: 5, + Orchestrator: "orch", + EthAddress: "eth", + Signature: "sig", + }, + }, nil + }, + valsetAtFn: func(_ context.Context, _ uint64) (*types.Valset, error) { + return &types.Valset{}, nil // non-empty will do + }, + } + + eth := mockEthereum{ + headerByNumberFn: func(_ context.Context, _ *big.Int) (*ctypes.Header, error) { + return &ctypes.Header{Number: big.NewInt(123)}, nil + }, + getValsetNonceFn: func(_ context.Context) (*big.Int, error) { + return big.NewInt(100), nil + }, + getValsetUpdatedEventsFn: func(_ uint64, _ uint64) ([]*wrappers.PeggyValsetUpdatedEvent, error) { + return nil, errors.New("fail") + }, + } + + rel := &relayer{ + log: suplog.DefaultLogger, + retries: 1, + valsetRelaying: true, + } + + assert.Error(t, rel.relayValsets(context.TODO(), inj, eth)) + }) + + t.Run("ethereum valset is not higher than injective valset", func(t *testing.T) { + t.Parallel() + + inj := &mockInjective{ + latestValsetsFn: func(_ context.Context) ([]*types.Valset, error) { + return []*types.Valset{ + { + Nonce: 333, + RewardAmount: cosmtypes.NewInt(1000), + RewardToken: "0xfafafafafafafafa", + }, + }, nil + }, + allValsetConfirmsFn: func(_ context.Context, _ uint64) ([]*types.MsgValsetConfirm, error) { + return []*types.MsgValsetConfirm{ + { + Nonce: 5, + Orchestrator: "orch", + EthAddress: "eth", + Signature: "sig", + }, + }, nil + }, + valsetAtFn: func(_ context.Context, _ uint64) (*types.Valset, error) { + return &types.Valset{ + Nonce: 333, + RewardAmount: cosmtypes.NewInt(1000), + RewardToken: "0xfafafafafafafafa", + }, nil // non-empty will do + }, + } + + eth := mockEthereum{ + headerByNumberFn: func(_ context.Context, _ *big.Int) (*ctypes.Header, error) { + return &ctypes.Header{Number: big.NewInt(123)}, nil + }, + getValsetNonceFn: func(_ context.Context) (*big.Int, error) { + return big.NewInt(100), nil + }, + getValsetUpdatedEventsFn: func(_ uint64, _ uint64) ([]*wrappers.PeggyValsetUpdatedEvent, error) { + return []*wrappers.PeggyValsetUpdatedEvent{ + { + NewValsetNonce: big.NewInt(333), + RewardAmount: big.NewInt(1000), + RewardToken: common.HexToAddress("0xfafafafafafafafa"), + }, + }, nil + }, + } + + rel := &relayer{ + log: suplog.DefaultLogger, + retries: 1, + valsetRelaying: true, + } + + assert.NoError(t, rel.relayValsets(context.TODO(), inj, eth)) + }) + + t.Run("injective valset is higher than ethereum but failed to get block from injective", func(t *testing.T) { + t.Parallel() + + inj := &mockInjective{ + latestValsetsFn: func(_ context.Context) ([]*types.Valset, error) { + return []*types.Valset{ + { + Nonce: 444, + RewardAmount: cosmtypes.NewInt(1000), + RewardToken: "0xfafafafafafafafa", + }, + }, nil + }, + allValsetConfirmsFn: func(_ context.Context, _ uint64) ([]*types.MsgValsetConfirm, error) { + return []*types.MsgValsetConfirm{ + { + Nonce: 5, + Orchestrator: "orch", + EthAddress: "eth", + Signature: "sig", + }, + }, nil + }, + valsetAtFn: func(_ context.Context, _ uint64) (*types.Valset, error) { + return &types.Valset{ + Nonce: 333, + RewardAmount: cosmtypes.NewInt(1000), + RewardToken: "0xfafafafafafafafa", + }, nil // non-empty will do + }, + getBlockFn: func(_ context.Context, _ int64) (*tmctypes.ResultBlock, error) { + return nil, errors.New("fail") + }, + } + + eth := mockEthereum{ + headerByNumberFn: func(_ context.Context, _ *big.Int) (*ctypes.Header, error) { + return &ctypes.Header{Number: big.NewInt(123)}, nil + }, + getValsetNonceFn: func(_ context.Context) (*big.Int, error) { + return big.NewInt(100), nil + }, + getValsetUpdatedEventsFn: func(_ uint64, _ uint64) ([]*wrappers.PeggyValsetUpdatedEvent, error) { + return []*wrappers.PeggyValsetUpdatedEvent{ + { + NewValsetNonce: big.NewInt(333), + RewardAmount: big.NewInt(1000), + RewardToken: common.HexToAddress("0xfafafafafafafafa"), + }, + }, nil + }, + } + + rel := &relayer{ + log: suplog.DefaultLogger, + retries: 1, + valsetRelaying: true, + } + + assert.Error(t, rel.relayValsets(context.TODO(), inj, eth)) + }) + + t.Run("injective valset is higher than ethereum but valsetOffsetDur has not expired", func(t *testing.T) { + t.Parallel() + + inj := &mockInjective{ + latestValsetsFn: func(_ context.Context) ([]*types.Valset, error) { + return []*types.Valset{ + { + Nonce: 444, + RewardAmount: cosmtypes.NewInt(1000), + RewardToken: "0xfafafafafafafafa", + }, + }, nil + }, + allValsetConfirmsFn: func(_ context.Context, _ uint64) ([]*types.MsgValsetConfirm, error) { + return []*types.MsgValsetConfirm{ + { + Nonce: 5, + Orchestrator: "orch", + EthAddress: "eth", + Signature: "sig", + }, + }, nil + }, + valsetAtFn: func(_ context.Context, _ uint64) (*types.Valset, error) { + return &types.Valset{ + Nonce: 333, + RewardAmount: cosmtypes.NewInt(1000), + RewardToken: "0xfafafafafafafafa", + }, nil // non-empty will do + }, + getBlockFn: func(_ context.Context, _ int64) (*tmctypes.ResultBlock, error) { + return &tmctypes.ResultBlock{ + Block: &tmtypes.Block{ + Header: tmtypes.Header{ + Time: time.Now().Add(time.Hour), + }, + }, + }, nil + }, + } + + eth := mockEthereum{ + headerByNumberFn: func(_ context.Context, _ *big.Int) (*ctypes.Header, error) { + return &ctypes.Header{Number: big.NewInt(123)}, nil + }, + getValsetNonceFn: func(_ context.Context) (*big.Int, error) { + return big.NewInt(100), nil + }, + getValsetUpdatedEventsFn: func(_ uint64, _ uint64) ([]*wrappers.PeggyValsetUpdatedEvent, error) { + return []*wrappers.PeggyValsetUpdatedEvent{ + { + NewValsetNonce: big.NewInt(333), + RewardAmount: big.NewInt(1000), + RewardToken: common.HexToAddress("0xfafafafafafafafa"), + }, + }, nil + }, + } + + rel := &relayer{ + log: suplog.DefaultLogger, + retries: 1, + valsetRelaying: true, + relayValsetOffsetDur: time.Second * 5, + } + + assert.NoError(t, rel.relayValsets(context.TODO(), inj, eth)) + }) + + t.Run("injective valset is higher than ethereum but failed to send update tx to ethereum", func(t *testing.T) { + t.Parallel() + + inj := &mockInjective{ + latestValsetsFn: func(_ context.Context) ([]*types.Valset, error) { + return []*types.Valset{ + { + Nonce: 444, + RewardAmount: cosmtypes.NewInt(1000), + RewardToken: "0xfafafafafafafafa", + }, + }, nil + }, + allValsetConfirmsFn: func(_ context.Context, _ uint64) ([]*types.MsgValsetConfirm, error) { + return []*types.MsgValsetConfirm{ + { + Nonce: 5, + Orchestrator: "orch", + EthAddress: "eth", + Signature: "sig", + }, + }, nil + }, + valsetAtFn: func(_ context.Context, _ uint64) (*types.Valset, error) { + return &types.Valset{ + Nonce: 333, + RewardAmount: cosmtypes.NewInt(1000), + RewardToken: "0xfafafafafafafafa", + }, nil // non-empty will do + }, + getBlockFn: func(_ context.Context, _ int64) (*tmctypes.ResultBlock, error) { + return &tmctypes.ResultBlock{ + Block: &tmtypes.Block{ + Header: tmtypes.Header{ + Time: time.Date(1970, 1, 0, 0, 0, 0, 0, time.UTC), + }, + }, + }, nil + }, + } + + eth := mockEthereum{ + headerByNumberFn: func(_ context.Context, _ *big.Int) (*ctypes.Header, error) { + return &ctypes.Header{Number: big.NewInt(123)}, nil + }, + getValsetNonceFn: func(_ context.Context) (*big.Int, error) { + return big.NewInt(100), nil + }, + getValsetUpdatedEventsFn: func(_ uint64, _ uint64) ([]*wrappers.PeggyValsetUpdatedEvent, error) { + return []*wrappers.PeggyValsetUpdatedEvent{ + { + NewValsetNonce: big.NewInt(333), + RewardAmount: big.NewInt(1000), + RewardToken: common.HexToAddress("0xfafafafafafafafa"), + }, + }, nil + }, + sendEthValsetUpdateFn: func(_ context.Context, _ *types.Valset, _ *types.Valset, _ []*types.MsgValsetConfirm) (*common.Hash, error) { + return nil, errors.New("fail") + }, + } + + rel := &relayer{ + log: suplog.DefaultLogger, + retries: 1, + valsetRelaying: true, + relayValsetOffsetDur: time.Second * 5, + } + + assert.Error(t, rel.relayValsets(context.TODO(), inj, eth)) + }) + + t.Run("new valset update is sent to ethereum", func(t *testing.T) { + t.Parallel() + + inj := &mockInjective{ + latestValsetsFn: func(_ context.Context) ([]*types.Valset, error) { + return []*types.Valset{ + { + Nonce: 444, + RewardAmount: cosmtypes.NewInt(1000), + RewardToken: "0xfafafafafafafafa", + }, + }, nil + }, + allValsetConfirmsFn: func(_ context.Context, _ uint64) ([]*types.MsgValsetConfirm, error) { + return []*types.MsgValsetConfirm{ + { + Nonce: 5, + Orchestrator: "orch", + EthAddress: "eth", + Signature: "sig", + }, + }, nil + }, + valsetAtFn: func(_ context.Context, _ uint64) (*types.Valset, error) { + return &types.Valset{ + Nonce: 333, + RewardAmount: cosmtypes.NewInt(1000), + RewardToken: "0xfafafafafafafafa", + }, nil + }, + getBlockFn: func(_ context.Context, _ int64) (*tmctypes.ResultBlock, error) { + return &tmctypes.ResultBlock{ + Block: &tmtypes.Block{ + Header: tmtypes.Header{ + Time: time.Date(1970, 1, 0, 0, 0, 0, 0, time.UTC), + }, + }, + }, nil + }, + } + + eth := mockEthereum{ + headerByNumberFn: func(_ context.Context, _ *big.Int) (*ctypes.Header, error) { + return &ctypes.Header{Number: big.NewInt(123)}, nil + }, + getValsetNonceFn: func(_ context.Context) (*big.Int, error) { + return big.NewInt(100), nil + }, + getValsetUpdatedEventsFn: func(_ uint64, _ uint64) ([]*wrappers.PeggyValsetUpdatedEvent, error) { + return []*wrappers.PeggyValsetUpdatedEvent{ + { + NewValsetNonce: big.NewInt(333), + RewardAmount: big.NewInt(1000), + RewardToken: common.HexToAddress("0xfafafafafafafafa"), + }, + }, nil + }, + sendEthValsetUpdateFn: func(_ context.Context, _ *types.Valset, _ *types.Valset, _ []*types.MsgValsetConfirm) (*common.Hash, error) { + return &common.Hash{}, nil + }, + } + + rel := &relayer{ + log: suplog.DefaultLogger, + retries: 1, + valsetRelaying: true, + relayValsetOffsetDur: time.Second * 5, + } + + assert.NoError(t, rel.relayValsets(context.TODO(), inj, eth)) + }) +} + +func TestBatchRelaying(t *testing.T) { + t.Parallel() + + t.Run("failed to get latest batches from injective", func(t *testing.T) { + t.Parallel() + + inj := &mockInjective{ + latestTransactionBatchesFn: func(_ context.Context) ([]*types.OutgoingTxBatch, error) { + return nil, errors.New("fail") + }, + } + + rel := &relayer{ + log: suplog.DefaultLogger, + retries: 1, + batchRelaying: true, + } + + assert.Error(t, rel.relayBatches(context.TODO(), inj, nil)) + }) + + t.Run("failed to get latest batches from injective", func(t *testing.T) { + t.Parallel() + + inj := &mockInjective{ + latestTransactionBatchesFn: func(_ context.Context) ([]*types.OutgoingTxBatch, error) { + return []*types.OutgoingTxBatch{{}}, nil // non-empty will do + }, + transactionBatchSignaturesFn: func(_ context.Context, _ uint64, _ common.Address) ([]*types.MsgConfirmBatch, error) { + return nil, errors.New("fail") + }, + } + + rel := &relayer{ + log: suplog.DefaultLogger, + retries: 1, + batchRelaying: true, + } + + assert.Error(t, rel.relayBatches(context.TODO(), inj, nil)) + }) + + t.Run("no batch confirms", func(t *testing.T) { + t.Parallel() + + inj := &mockInjective{ + latestTransactionBatchesFn: func(_ context.Context) ([]*types.OutgoingTxBatch, error) { + return []*types.OutgoingTxBatch{{}}, nil // non-empty will do + }, + transactionBatchSignaturesFn: func(_ context.Context, _ uint64, _ common.Address) ([]*types.MsgConfirmBatch, error) { + return nil, nil + }, + } + + rel := &relayer{ + log: suplog.DefaultLogger, + retries: 1, + batchRelaying: true, + } + + assert.NoError(t, rel.relayBatches(context.TODO(), inj, nil)) + }) + + t.Run("failed to get batch nonce from ethereum", func(t *testing.T) { + t.Parallel() + + inj := &mockInjective{ + latestTransactionBatchesFn: func(_ context.Context) ([]*types.OutgoingTxBatch, error) { + return []*types.OutgoingTxBatch{{}}, nil // non-empty will do + }, + transactionBatchSignaturesFn: func(_ context.Context, _ uint64, _ common.Address) ([]*types.MsgConfirmBatch, error) { + return []*types.MsgConfirmBatch{{}}, nil // non-nil will do + }, + } + + eth := mockEthereum{ + getTxBatchNonceFn: func(_ context.Context, _ common.Address) (*big.Int, error) { + return nil, errors.New("fail") + }, + } + + rel := &relayer{ + log: suplog.DefaultLogger, + retries: 1, + batchRelaying: true, + } + + assert.Error(t, rel.relayBatches(context.TODO(), inj, eth)) + }) + + t.Run("failed to get latest ethereum header", func(t *testing.T) { + t.Parallel() + + inj := &mockInjective{ + latestTransactionBatchesFn: func(_ context.Context) ([]*types.OutgoingTxBatch, error) { + return []*types.OutgoingTxBatch{ + { + TokenContract: "tokenContract", + BatchNonce: 100, + }, + }, nil + }, + transactionBatchSignaturesFn: func(_ context.Context, _ uint64, _ common.Address) ([]*types.MsgConfirmBatch, error) { + return []*types.MsgConfirmBatch{{}}, nil // non-nil will do + }, + } + + eth := mockEthereum{ + getTxBatchNonceFn: func(_ context.Context, _ common.Address) (*big.Int, error) { + return big.NewInt(99), nil + }, + headerByNumberFn: func(_ context.Context, _ *big.Int) (*ctypes.Header, error) { + return nil, errors.New("fail") + }, + } + + rel := &relayer{ + log: suplog.DefaultLogger, + retries: 1, + batchRelaying: true, + } + + assert.Error(t, rel.relayBatches(context.TODO(), inj, eth)) + }) + + t.Run("failed to get valset nonce from ethereum", func(t *testing.T) { + t.Parallel() + + inj := &mockInjective{ + latestTransactionBatchesFn: func(_ context.Context) ([]*types.OutgoingTxBatch, error) { + return []*types.OutgoingTxBatch{ + { + TokenContract: "tokenContract", + BatchNonce: 100, + }, + }, nil + }, + transactionBatchSignaturesFn: func(_ context.Context, _ uint64, _ common.Address) ([]*types.MsgConfirmBatch, error) { + return []*types.MsgConfirmBatch{{}}, nil // non-nil will do + }, + } + + eth := mockEthereum{ + getTxBatchNonceFn: func(_ context.Context, _ common.Address) (*big.Int, error) { + return big.NewInt(99), nil + }, + headerByNumberFn: func(_ context.Context, _ *big.Int) (*ctypes.Header, error) { + return &ctypes.Header{Number: big.NewInt(100)}, nil + }, + + getValsetNonceFn: func(_ context.Context) (*big.Int, error) { + return nil, errors.New("fail") + }, + } + + rel := &relayer{ + log: suplog.DefaultLogger, + retries: 1, + batchRelaying: true, + } + + assert.Error(t, rel.relayBatches(context.TODO(), inj, eth)) + }) + + t.Run("failed to get specific valset from injective", func(t *testing.T) { + t.Parallel() + + inj := &mockInjective{ + latestTransactionBatchesFn: func(_ context.Context) ([]*types.OutgoingTxBatch, error) { + return []*types.OutgoingTxBatch{ + { + TokenContract: "tokenContract", + BatchNonce: 100, + }, + }, nil + }, + transactionBatchSignaturesFn: func(_ context.Context, _ uint64, _ common.Address) ([]*types.MsgConfirmBatch, error) { + return []*types.MsgConfirmBatch{{}}, nil // non-nil will do + }, + valsetAtFn: func(_ context.Context, _ uint64) (*types.Valset, error) { + return nil, errors.New("fail") + }, + } + + eth := mockEthereum{ + getTxBatchNonceFn: func(_ context.Context, _ common.Address) (*big.Int, error) { + return big.NewInt(99), nil + }, + headerByNumberFn: func(_ context.Context, _ *big.Int) (*ctypes.Header, error) { + return &ctypes.Header{Number: big.NewInt(100)}, nil + }, + + getValsetNonceFn: func(_ context.Context) (*big.Int, error) { + return big.NewInt(100), nil + }, + } + + rel := &relayer{ + log: suplog.DefaultLogger, + retries: 1, + batchRelaying: true, + } + + assert.Error(t, rel.relayBatches(context.TODO(), inj, eth)) + }) + + t.Run("failed to get valset updated events from ethereum", func(t *testing.T) { + t.Parallel() + + inj := &mockInjective{ + latestTransactionBatchesFn: func(_ context.Context) ([]*types.OutgoingTxBatch, error) { + return []*types.OutgoingTxBatch{ + { + TokenContract: "tokenContract", + BatchNonce: 100, + }, + }, nil + }, + transactionBatchSignaturesFn: func(_ context.Context, _ uint64, _ common.Address) ([]*types.MsgConfirmBatch, error) { + return []*types.MsgConfirmBatch{{}}, nil // non-nil will do + }, + valsetAtFn: func(_ context.Context, _ uint64) (*types.Valset, error) { + return &types.Valset{}, nil + }, + } + + eth := mockEthereum{ + getTxBatchNonceFn: func(_ context.Context, _ common.Address) (*big.Int, error) { + return big.NewInt(99), nil + }, + headerByNumberFn: func(_ context.Context, _ *big.Int) (*ctypes.Header, error) { + return &ctypes.Header{Number: big.NewInt(100)}, nil + }, + + getValsetNonceFn: func(_ context.Context) (*big.Int, error) { + return big.NewInt(100), nil + }, + getValsetUpdatedEventsFn: func(_ uint64, _ uint64) ([]*wrappers.PeggyValsetUpdatedEvent, error) { + return nil, errors.New("fail") + }, + } + + rel := &relayer{ + log: suplog.DefaultLogger, + retries: 1, + batchRelaying: true, + } + + assert.Error(t, rel.relayBatches(context.TODO(), inj, eth)) + }) + + t.Run("ethereum batch is not lower than injective batch", func(t *testing.T) { + t.Parallel() + + inj := &mockInjective{ + latestTransactionBatchesFn: func(_ context.Context) ([]*types.OutgoingTxBatch, error) { + return []*types.OutgoingTxBatch{ + { + TokenContract: "tokenContract", + BatchNonce: 202, + }, + }, nil + }, + transactionBatchSignaturesFn: func(_ context.Context, _ uint64, _ common.Address) ([]*types.MsgConfirmBatch, error) { + return []*types.MsgConfirmBatch{{}}, nil // non-nil will do + }, + valsetAtFn: func(_ context.Context, _ uint64) (*types.Valset, error) { + return &types.Valset{Nonce: 202}, nil + }, + } + + eth := mockEthereum{ + getTxBatchNonceFn: func(_ context.Context, _ common.Address) (*big.Int, error) { + return big.NewInt(202), nil + }, + headerByNumberFn: func(_ context.Context, _ *big.Int) (*ctypes.Header, error) { + return &ctypes.Header{Number: big.NewInt(100)}, nil + }, + + getValsetNonceFn: func(_ context.Context) (*big.Int, error) { + return big.NewInt(100), nil + }, + getValsetUpdatedEventsFn: func(_ uint64, _ uint64) ([]*wrappers.PeggyValsetUpdatedEvent, error) { + return []*wrappers.PeggyValsetUpdatedEvent{ + { + NewValsetNonce: big.NewInt(202), + RewardAmount: big.NewInt(1000), + RewardToken: common.HexToAddress("0xcafecafecafecafe"), + }, + }, nil + }, + } + + rel := &relayer{ + log: suplog.DefaultLogger, + retries: 1, + batchRelaying: true, + } + + assert.NoError(t, rel.relayBatches(context.TODO(), inj, eth)) + }) + + t.Run("ethereum batch is lower than injective batch but failed to get block from injhective", func(t *testing.T) { + t.Parallel() + + inj := &mockInjective{ + latestTransactionBatchesFn: func(_ context.Context) ([]*types.OutgoingTxBatch, error) { + return []*types.OutgoingTxBatch{ + { + TokenContract: "tokenContract", + BatchNonce: 202, + }, + }, nil + }, + transactionBatchSignaturesFn: func(_ context.Context, _ uint64, _ common.Address) ([]*types.MsgConfirmBatch, error) { + return []*types.MsgConfirmBatch{{}}, nil // non-nil will do + }, + valsetAtFn: func(_ context.Context, _ uint64) (*types.Valset, error) { + return &types.Valset{Nonce: 202}, nil + }, + getBlockFn: func(_ context.Context, _ int64) (*tmctypes.ResultBlock, error) { + return nil, errors.New("fail") + }, + } + + eth := mockEthereum{ + getTxBatchNonceFn: func(_ context.Context, _ common.Address) (*big.Int, error) { + return big.NewInt(201), nil + }, + headerByNumberFn: func(_ context.Context, _ *big.Int) (*ctypes.Header, error) { + return &ctypes.Header{Number: big.NewInt(100)}, nil + }, + + getValsetNonceFn: func(_ context.Context) (*big.Int, error) { + return big.NewInt(100), nil + }, + getValsetUpdatedEventsFn: func(_ uint64, _ uint64) ([]*wrappers.PeggyValsetUpdatedEvent, error) { + return []*wrappers.PeggyValsetUpdatedEvent{ + { + NewValsetNonce: big.NewInt(202), + RewardAmount: big.NewInt(1000), + RewardToken: common.HexToAddress("0xcafecafecafecafe"), + }, + }, nil + }, + } + + rel := &relayer{ + log: suplog.DefaultLogger, + retries: 1, + batchRelaying: true, + } + + assert.Error(t, rel.relayBatches(context.TODO(), inj, eth)) + }) + + t.Run("ethereum batch is lower than injective batch but relayBatchOffsetDur has not expired", func(t *testing.T) { + t.Parallel() + + inj := &mockInjective{ + latestTransactionBatchesFn: func(_ context.Context) ([]*types.OutgoingTxBatch, error) { + return []*types.OutgoingTxBatch{ + { + TokenContract: "tokenContract", + BatchNonce: 202, + }, + }, nil + }, + transactionBatchSignaturesFn: func(_ context.Context, _ uint64, _ common.Address) ([]*types.MsgConfirmBatch, error) { + return []*types.MsgConfirmBatch{{}}, nil // non-nil will do + }, + valsetAtFn: func(_ context.Context, _ uint64) (*types.Valset, error) { + return &types.Valset{Nonce: 202}, nil + }, + getBlockFn: func(_ context.Context, _ int64) (*tmctypes.ResultBlock, error) { + return &tmctypes.ResultBlock{ + Block: &tmtypes.Block{ + Header: tmtypes.Header{ + Time: time.Now().Add(time.Hour), + }, + }, + }, nil + }, + } + + eth := mockEthereum{ + getTxBatchNonceFn: func(_ context.Context, _ common.Address) (*big.Int, error) { + return big.NewInt(201), nil + }, + headerByNumberFn: func(_ context.Context, _ *big.Int) (*ctypes.Header, error) { + return &ctypes.Header{Number: big.NewInt(100)}, nil + }, + + getValsetNonceFn: func(_ context.Context) (*big.Int, error) { + return big.NewInt(100), nil + }, + getValsetUpdatedEventsFn: func(_ uint64, _ uint64) ([]*wrappers.PeggyValsetUpdatedEvent, error) { + return []*wrappers.PeggyValsetUpdatedEvent{ + { + NewValsetNonce: big.NewInt(202), + RewardAmount: big.NewInt(1000), + RewardToken: common.HexToAddress("0xcafecafecafecafe"), + }, + }, nil + }, + } + + rel := &relayer{ + log: suplog.DefaultLogger, + retries: 1, + batchRelaying: true, + relayBatchOffsetDur: 5 * time.Second, + } + + assert.NoError(t, rel.relayBatches(context.TODO(), inj, eth)) + }) + + t.Run("ethereum batch is lower than injective batch but failed to send batch update", func(t *testing.T) { + t.Parallel() + + inj := &mockInjective{ + latestTransactionBatchesFn: func(_ context.Context) ([]*types.OutgoingTxBatch, error) { + return []*types.OutgoingTxBatch{ + { + TokenContract: "tokenContract", + BatchNonce: 202, + }, + }, nil + }, + transactionBatchSignaturesFn: func(_ context.Context, _ uint64, _ common.Address) ([]*types.MsgConfirmBatch, error) { + return []*types.MsgConfirmBatch{{}}, nil // non-nil will do + }, + valsetAtFn: func(_ context.Context, _ uint64) (*types.Valset, error) { + return &types.Valset{Nonce: 202}, nil + }, + getBlockFn: func(_ context.Context, _ int64) (*tmctypes.ResultBlock, error) { + return &tmctypes.ResultBlock{ + Block: &tmtypes.Block{ + Header: tmtypes.Header{ + Time: time.Date(1970, 1, 0, 0, 0, 0, 0, time.UTC), + }, + }, + }, nil + }, + } + + eth := mockEthereum{ + getTxBatchNonceFn: func(_ context.Context, _ common.Address) (*big.Int, error) { + return big.NewInt(201), nil + }, + headerByNumberFn: func(_ context.Context, _ *big.Int) (*ctypes.Header, error) { + return &ctypes.Header{Number: big.NewInt(100)}, nil + }, + + getValsetNonceFn: func(_ context.Context) (*big.Int, error) { + return big.NewInt(100), nil + }, + getValsetUpdatedEventsFn: func(_ uint64, _ uint64) ([]*wrappers.PeggyValsetUpdatedEvent, error) { + return []*wrappers.PeggyValsetUpdatedEvent{ + { + NewValsetNonce: big.NewInt(202), + RewardAmount: big.NewInt(1000), + RewardToken: common.HexToAddress("0xcafecafecafecafe"), + }, + }, nil + }, + sendTransactionBatchFn: func(_ context.Context, _ *types.Valset, _ *types.OutgoingTxBatch, _ []*types.MsgConfirmBatch) (*common.Hash, error) { + return nil, errors.New("fail") + }, + } + + rel := &relayer{ + log: suplog.DefaultLogger, + retries: 1, + batchRelaying: true, + relayBatchOffsetDur: 5 * time.Second, + } + + assert.Error(t, rel.relayBatches(context.TODO(), inj, eth)) + }) + + t.Run("sending a batch update to ethereum", func(t *testing.T) { + t.Parallel() + + inj := &mockInjective{ + latestTransactionBatchesFn: func(_ context.Context) ([]*types.OutgoingTxBatch, error) { + return []*types.OutgoingTxBatch{ + { + TokenContract: "tokenContract", + BatchNonce: 202, + }, + }, nil + }, + transactionBatchSignaturesFn: func(_ context.Context, _ uint64, _ common.Address) ([]*types.MsgConfirmBatch, error) { + return []*types.MsgConfirmBatch{{}}, nil // non-nil will do + }, + valsetAtFn: func(_ context.Context, _ uint64) (*types.Valset, error) { + return &types.Valset{Nonce: 202}, nil + }, + getBlockFn: func(_ context.Context, _ int64) (*tmctypes.ResultBlock, error) { + return &tmctypes.ResultBlock{ + Block: &tmtypes.Block{ + Header: tmtypes.Header{ + Time: time.Date(1970, 1, 0, 0, 0, 0, 0, time.UTC), + }, + }, + }, nil + }, + } + + eth := mockEthereum{ + getTxBatchNonceFn: func(_ context.Context, _ common.Address) (*big.Int, error) { + return big.NewInt(201), nil + }, + headerByNumberFn: func(_ context.Context, _ *big.Int) (*ctypes.Header, error) { + return &ctypes.Header{Number: big.NewInt(100)}, nil + }, + + getValsetNonceFn: func(_ context.Context) (*big.Int, error) { + return big.NewInt(100), nil + }, + getValsetUpdatedEventsFn: func(_ uint64, _ uint64) ([]*wrappers.PeggyValsetUpdatedEvent, error) { + return []*wrappers.PeggyValsetUpdatedEvent{ + { + NewValsetNonce: big.NewInt(202), + RewardAmount: big.NewInt(1000), + RewardToken: common.HexToAddress("0xcafecafecafecafe"), + }, + }, nil + }, + sendTransactionBatchFn: func(_ context.Context, _ *types.Valset, _ *types.OutgoingTxBatch, _ []*types.MsgConfirmBatch) (*common.Hash, error) { + return &common.Hash{}, nil + }, + } + + rel := &relayer{ + log: suplog.DefaultLogger, + retries: 1, + batchRelaying: true, + relayBatchOffsetDur: 5 * time.Second, + } + + assert.NoError(t, rel.relayBatches(context.TODO(), inj, eth)) + }) +} diff --git a/orchestrator/signer.go b/orchestrator/signer.go new file mode 100644 index 00000000..a2d376b2 --- /dev/null +++ b/orchestrator/signer.go @@ -0,0 +1,216 @@ +package orchestrator + +import ( + "context" + + "github.com/avast/retry-go" + "github.com/ethereum/go-ethereum/common" + log "github.com/xlab/suplog" + + "github.com/InjectiveLabs/peggo/orchestrator/cosmos" + "github.com/InjectiveLabs/peggo/orchestrator/loops" + "github.com/InjectiveLabs/sdk-go/chain/peggy/types" +) + +// EthSignerMainLoop simply signs off on any batches or validator sets provided by the validator +// since these are provided directly by a trusted Injective node they can simply be assumed to be +// valid and signed off on. +func (s *PeggyOrchestrator) EthSignerMainLoop(ctx context.Context) error { + peggyID, err := s.getPeggyID(ctx) + if err != nil { + return err + } + + signer := ðSigner{ + log: log.WithField("loop", "EthSigner"), + peggyID: peggyID, + ethFrom: s.ethereum.FromAddress(), + retries: s.maxAttempts, + } + + return loops.RunLoop( + ctx, + defaultLoopDur, + func() error { return signer.run(ctx, s.injective) }, + ) +} + +func (s *PeggyOrchestrator) getPeggyID(ctx context.Context) (common.Hash, error) { + var peggyID common.Hash + retryFn := func() (err error) { + peggyID, err = s.ethereum.GetPeggyID(ctx) + return err + } + + if err := retry.Do(retryFn, + retry.Context(ctx), + retry.Attempts(s.maxAttempts), + retry.OnRetry(func(n uint, err error) { + log.WithError(err).Warningf("failed to get peggy ID from Ethereum contract, will retry (%d)", n) + }), + ); err != nil { + log.WithError(err).Errorln("got error, loop exits") + return [32]byte{}, err + } + + log.WithField("id", peggyID.Hex()).Debugln("got peggy ID from Ethereum contract") + + return peggyID, nil +} + +type ethSigner struct { + log log.Logger + peggyID common.Hash + ethFrom common.Address + retries uint +} + +func (s *ethSigner) run(ctx context.Context, injective InjectiveNetwork) error { + s.log.Infoln("scanning Injective for unconfirmed batches and valset updates") + + if err := s.signNewValsetUpdates(ctx, injective); err != nil { + return err + } + + if err := s.signNewBatches(ctx, injective); err != nil { + return err + } + + return nil +} + +func (s *ethSigner) signNewBatches(ctx context.Context, injective InjectiveNetwork) error { + oldestUnsignedTransactionBatch, err := s.getUnsignedBatch(ctx, injective) + if err != nil { + return err + } + + if oldestUnsignedTransactionBatch == nil { + s.log.Debugln("no batch to confirm") + return nil + } + + if err := s.signBatch(ctx, injective, oldestUnsignedTransactionBatch); err != nil { + return err + } + + return nil +} + +func (s *ethSigner) getUnsignedBatch(ctx context.Context, injective InjectiveNetwork) (*types.OutgoingTxBatch, error) { + var oldestUnsignedTransactionBatch *types.OutgoingTxBatch + retryFn := func() (err error) { + // sign the last unsigned batch, TODO check if we already have signed this + oldestUnsignedTransactionBatch, err = injective.OldestUnsignedTransactionBatch(ctx) + if err == cosmos.ErrNotFound || oldestUnsignedTransactionBatch == nil { + return nil + } + + return err + } + + if err := retry.Do(retryFn, + retry.Context(ctx), + retry.Attempts(s.retries), + retry.OnRetry(func(n uint, err error) { + s.log.WithError(err).Warningf("failed to get unconfirmed batch, will retry (%d)", n) + }), + ); err != nil { + s.log.WithError(err).Errorln("got error, loop exits") + return nil, err + } + + return oldestUnsignedTransactionBatch, nil +} + +func (s *ethSigner) signBatch( + ctx context.Context, + injective InjectiveNetwork, + batch *types.OutgoingTxBatch, +) error { + if err := retry.Do( + func() error { return injective.SendBatchConfirm(ctx, s.peggyID, batch, s.ethFrom) }, + retry.Context(ctx), + retry.Attempts(s.retries), + retry.OnRetry(func(n uint, err error) { + s.log.WithError(err).Warningf("failed to confirm batch on Injective, will retry (%d)", n) + }), + ); err != nil { + s.log.WithError(err).Errorln("got error, loop exits") + return err + } + + s.log.WithField("batch_nonce", batch.BatchNonce).Infoln("confirmed batch on Injective") + + return nil +} + +func (s *ethSigner) signNewValsetUpdates( + ctx context.Context, + injective InjectiveNetwork, +) error { + oldestUnsignedValsets, err := s.getUnsignedValsets(ctx, injective) + if err != nil { + return err + } + + if len(oldestUnsignedValsets) == 0 { + s.log.Debugln("no valset updates to confirm") + return nil + } + + for _, vs := range oldestUnsignedValsets { + if err := s.signValset(ctx, injective, vs); err != nil { + return err + } + } + + return nil +} + +func (s *ethSigner) getUnsignedValsets(ctx context.Context, injective InjectiveNetwork) ([]*types.Valset, error) { + var oldestUnsignedValsets []*types.Valset + retryFn := func() (err error) { + oldestUnsignedValsets, err = injective.OldestUnsignedValsets(ctx) + if err == cosmos.ErrNotFound || oldestUnsignedValsets == nil { + return nil + } + + return err + } + + if err := retry.Do(retryFn, + retry.Context(ctx), + retry.Attempts(s.retries), + retry.OnRetry(func(n uint, err error) { + s.log.WithError(err).Warningf("failed to get unconfirmed valset updates, will retry (%d)", n) + }), + ); err != nil { + s.log.WithError(err).Errorln("got error, loop exits") + return nil, err + } + + return oldestUnsignedValsets, nil +} + +func (s *ethSigner) signValset( + ctx context.Context, + injective InjectiveNetwork, + vs *types.Valset, +) error { + if err := retry.Do( + func() error { return injective.SendValsetConfirm(ctx, s.peggyID, vs, s.ethFrom) }, + retry.Context(ctx), + retry.Attempts(s.retries), + retry.OnRetry(func(n uint, err error) { + s.log.WithError(err).Warningf("failed to confirm valset update on Injective, will retry (%d)", n) + }), + ); err != nil { + s.log.WithError(err).Errorln("got error, loop exits") + return err + } + + s.log.WithField("valset_nonce", vs.Nonce).Infoln("confirmed valset update on Injective") + + return nil +} diff --git a/orchestrator/signer_test.go b/orchestrator/signer_test.go new file mode 100644 index 00000000..c0c8bd05 --- /dev/null +++ b/orchestrator/signer_test.go @@ -0,0 +1,139 @@ +package orchestrator + +import ( + "context" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" + log "github.com/xlab/suplog" + + "github.com/InjectiveLabs/sdk-go/chain/peggy/types" + cosmtypes "github.com/cosmos/cosmos-sdk/types" +) + +func TestEthSignerLoop(t *testing.T) { + t.Parallel() + + t.Run("failed to fetch peggy id from contract", func(t *testing.T) { + t.Parallel() + + orch := &PeggyOrchestrator{ + maxAttempts: 1, + ethereum: mockEthereum{ + getPeggyIDFn: func(context.Context) (common.Hash, error) { + return [32]byte{}, errors.New("fail") + }, + }, + } + + assert.Error(t, orch.EthSignerMainLoop(context.TODO())) + }) + + t.Run("no valset to sign", func(t *testing.T) { + t.Parallel() + + injective := &mockInjective{ + oldestUnsignedValsetsFn: func(context.Context) ([]*types.Valset, error) { + return nil, errors.New("fail") + }, + sendValsetConfirmFn: func(context.Context, common.Hash, *types.Valset, common.Address) error { + return nil + }, + oldestUnsignedTransactionBatchFn: func(context.Context) (*types.OutgoingTxBatch, error) { + return nil, nil + }, + sendBatchConfirmFn: func(context.Context, common.Hash, *types.OutgoingTxBatch, common.Address) error { + return nil + }, + } + + sig := ðSigner{log: log.DefaultLogger, retries: 1} + + assert.NoError(t, sig.run(context.TODO(), injective)) + }) + + t.Run("failed to send valset confirm", func(t *testing.T) { + t.Parallel() + + injective := &mockInjective{ + oldestUnsignedValsetsFn: func(context.Context) ([]*types.Valset, error) { + return []*types.Valset{ + { + Nonce: 5, + Members: []*types.BridgeValidator{ + { + Power: 100, + EthereumAddress: "abcd", + }, + }, + Height: 500, + RewardAmount: cosmtypes.NewInt(123), + RewardToken: "dusanToken", + }, + }, nil + }, + sendValsetConfirmFn: func(context.Context, common.Hash, *types.Valset, common.Address) error { + return errors.New("fail") + }, + } + + sig := ðSigner{log: log.DefaultLogger, retries: 1} + + assert.Error(t, sig.run(context.TODO(), injective)) + }) + + t.Run("no transaction batch sign", func(t *testing.T) { + t.Parallel() + + injective := &mockInjective{ + oldestUnsignedValsetsFn: func(_ context.Context) ([]*types.Valset, error) { return nil, nil }, + sendValsetConfirmFn: func(context.Context, common.Hash, *types.Valset, common.Address) error { return nil }, + oldestUnsignedTransactionBatchFn: func(_ context.Context) (*types.OutgoingTxBatch, error) { return nil, errors.New("fail") }, + sendBatchConfirmFn: func(context.Context, common.Hash, *types.OutgoingTxBatch, common.Address) error { return nil }, + } + + sig := ðSigner{log: log.DefaultLogger, retries: 1} + + assert.NoError(t, sig.run(context.TODO(), injective)) + }) + + t.Run("failed to send batch confirm", func(t *testing.T) { + t.Parallel() + + injective := &mockInjective{ + oldestUnsignedValsetsFn: func(_ context.Context) ([]*types.Valset, error) { return nil, nil }, + sendValsetConfirmFn: func(context.Context, common.Hash, *types.Valset, common.Address) error { return nil }, + oldestUnsignedTransactionBatchFn: func(_ context.Context) (*types.OutgoingTxBatch, error) { + return &types.OutgoingTxBatch{}, nil // non-empty will do + }, + sendBatchConfirmFn: func(context.Context, common.Hash, *types.OutgoingTxBatch, common.Address) error { + return errors.New("fail") + }, + } + + sig := ðSigner{log: log.DefaultLogger, retries: 1} + + assert.Error(t, sig.run(context.TODO(), injective)) + }) + + t.Run("valset update and transaction batch are confirmed", func(t *testing.T) { + t.Parallel() + + injective := &mockInjective{ + oldestUnsignedValsetsFn: func(_ context.Context) ([]*types.Valset, error) { + return []*types.Valset{}, nil // non-empty will do + }, + oldestUnsignedTransactionBatchFn: func(_ context.Context) (*types.OutgoingTxBatch, error) { + return &types.OutgoingTxBatch{}, nil // non-empty will do + }, + sendValsetConfirmFn: func(context.Context, common.Hash, *types.Valset, common.Address) error { return nil }, + sendBatchConfirmFn: func(context.Context, common.Hash, *types.OutgoingTxBatch, common.Address) error { return nil }, + } + + sig := ðSigner{log: log.DefaultLogger, retries: 1} + + assert.NoError(t, sig.run(context.TODO(), injective)) + }) +}