diff --git a/changelog.md b/changelog.md index ae097c3163..39fff5b0d5 100644 --- a/changelog.md +++ b/changelog.md @@ -43,7 +43,8 @@ * [2518](https://github.com/zeta-chain/node/pull/2518) - add support for Solana address in zetacore * [2483](https://github.com/zeta-chain/node/pull/2483) - add priorityFee (gasTipCap) gas to the state * [2567](https://github.com/zeta-chain/node/pull/2567) - add sign latency metric to zetaclient (zetaclient_sign_latency) -* [2524](https://github.com/zeta-chain/node/pull/2524) - add inscription envolop parsing +* [2524](https://github.com/zeta-chain/node/pull/2524) - add inscription envolop parsing +* [2560](https://github.com/zeta-chain/node/pull/2560) - add support for Solana SOL token withdraw ### Refactor diff --git a/cmd/zetaclientd/init.go b/cmd/zetaclientd/init.go index c6c231bf77..1b58265f90 100644 --- a/cmd/zetaclientd/init.go +++ b/cmd/zetaclientd/init.go @@ -1,6 +1,8 @@ package main import ( + "path" + "github.com/rs/zerolog" "github.com/spf13/cobra" @@ -36,6 +38,7 @@ type initArguments struct { KeyringBackend string HsmMode bool HsmHotKey string + SolanaKey string } func init() { @@ -69,6 +72,7 @@ func init() { InitCmd.Flags().BoolVar(&initArgs.HsmMode, "hsm-mode", false, "enable hsm signer, default disabled") InitCmd.Flags(). StringVar(&initArgs.HsmHotKey, "hsm-hotkey", "hsm-hotkey", "name of hotkey associated with hardware security module") + InitCmd.Flags().StringVar(&initArgs.SolanaKey, "solana-key", "solana-key.json", "solana key file name") } func Initialize(_ *cobra.Command, _ []string) error { @@ -106,8 +110,16 @@ func Initialize(_ *cobra.Command, _ []string) error { configData.KeyringBackend = config.KeyringBackend(initArgs.KeyringBackend) configData.HsmMode = initArgs.HsmMode configData.HsmHotKey = initArgs.HsmHotKey + configData.SolanaKeyFile = initArgs.SolanaKey configData.ComplianceConfig = testutils.ComplianceConfigTest() - //Save config file + // Save solana test fee payer key file + keyFile := path.Join(rootArgs.zetaCoreHome, initArgs.SolanaKey) + err = createSolanaTestKeyFile(keyFile) + if err != nil { + return err + } + + // Save config file return config.Save(&configData, rootArgs.zetaCoreHome) } diff --git a/cmd/zetaclientd/solana_test_key.go b/cmd/zetaclientd/solana_test_key.go new file mode 100644 index 0000000000..12a266dd9d --- /dev/null +++ b/cmd/zetaclientd/solana_test_key.go @@ -0,0 +1,37 @@ +package main + +import ( + "encoding/json" + "os" +) + +// solanaTestKey is a local test private key for Solana +// TODO: use separate keys for each zetaclient in Solana E2E tests +// https://github.com/zeta-chain/node/issues/2614 +var solanaTestKey = []uint8{ + 199, 16, 63, 28, 125, 103, 131, 13, 6, 94, 68, 109, 13, 68, 132, 17, + 71, 33, 216, 51, 49, 103, 146, 241, 245, 162, 90, 228, 71, 177, 32, 199, + 31, 128, 124, 2, 23, 207, 48, 93, 141, 113, 91, 29, 196, 95, 24, 137, + 170, 194, 90, 4, 124, 113, 12, 222, 166, 209, 119, 19, 78, 20, 99, 5, +} + +// createSolanaTestKeyFile creates a solana test key json file +func createSolanaTestKeyFile(keyFile string) error { + // marshal the byte array to JSON + keyBytes, err := json.Marshal(solanaTestKey) + if err != nil { + return err + } + + // create file (or overwrite if it already exists) + // #nosec G304 -- for E2E testing purposes only + file, err := os.Create(keyFile) + if err != nil { + return err + } + defer file.Close() + + // write the key bytes to the file + _, err = file.Write(keyBytes) + return err +} diff --git a/cmd/zetaclientd/utils.go b/cmd/zetaclientd/utils.go index d2b5801bef..b25de0a2b5 100644 --- a/cmd/zetaclientd/utils.go +++ b/cmd/zetaclientd/utils.go @@ -41,5 +41,3 @@ func CreateZetacoreClient(cfg config.Config, hotkeyPassword string, logger zerol return client, nil } - -// TODO diff --git a/cmd/zetae2e/local/local.go b/cmd/zetae2e/local/local.go index 72d01e4584..77041b00f4 100644 --- a/cmd/zetae2e/local/local.go +++ b/cmd/zetae2e/local/local.go @@ -321,7 +321,11 @@ func localE2ETest(cmd *cobra.Command, _ []string) { logger.Print("❌ solana client is nil, maybe solana rpc is not set") os.Exit(1) } - eg.Go(solanaTestRoutine(conf, deployerRunner, verbose, e2etests.TestSolanaDepositName)) + solanaTests := []string{ + e2etests.TestSolanaDepositName, + e2etests.TestSolanaWithdrawName, + } + eg.Go(solanaTestRoutine(conf, deployerRunner, verbose, solanaTests...)) } // while tests are executed, monitor blocks in parallel to check if system txs are on top and they have biggest priority diff --git a/contrib/localnet/orchestrator/start-zetae2e.sh b/contrib/localnet/orchestrator/start-zetae2e.sh index 5c91edbd2e..dda5b12cd7 100644 --- a/contrib/localnet/orchestrator/start-zetae2e.sh +++ b/contrib/localnet/orchestrator/start-zetae2e.sh @@ -66,6 +66,11 @@ address=$(yq -r '.additional_accounts.user_bitcoin.evm_address' config.yml) echo "funding bitcoin tester address ${address} with 10000 Ether" geth --exec "eth.sendTransaction({from: eth.coinbase, to: '${address}', value: web3.toWei(10000,'ether')})" attach http://eth:8545 +# unlock solana tester accounts +address=$(yq -r '.additional_accounts.user_solana.evm_address' config.yml) +echo "funding solana tester address ${address} with 10000 Ether" +geth --exec "eth.sendTransaction({from: eth.coinbase, to: '${address}', value: web3.toWei(10000,'ether')})" attach http://eth:8545 + # unlock ethers tester accounts address=$(yq -r '.additional_accounts.user_ether.evm_address' config.yml) echo "funding ether tester address ${address} with 10000 Ether" diff --git a/contrib/localnet/scripts/start-zetacored.sh b/contrib/localnet/scripts/start-zetacored.sh index fca8e25152..43c4ee72e2 100755 --- a/contrib/localnet/scripts/start-zetacored.sh +++ b/contrib/localnet/scripts/start-zetacored.sh @@ -247,6 +247,9 @@ then # bitcoin tester address=$(yq -r '.additional_accounts.user_bitcoin.bech32_address' /root/config.yml) zetacored add-genesis-account "$address" 100000000000000000000000000azeta +# solana tester + address=$(yq -r '.additional_accounts.user_solana.bech32_address' /root/config.yml) + zetacored add-genesis-account "$address" 100000000000000000000000000azeta # ethers tester address=$(yq -r '.additional_accounts.user_ether.bech32_address' /root/config.yml) zetacored add-genesis-account "$address" 100000000000000000000000000azeta diff --git a/docs/cli/zetacored/zetacored_tx_fungible_update-gateway-contract.md b/docs/cli/zetacored/zetacored_tx_fungible_update-gateway-contract.md index d75877c440..0b4217c65b 100644 --- a/docs/cli/zetacored/zetacored_tx_fungible_update-gateway-contract.md +++ b/docs/cli/zetacored/zetacored_tx_fungible_update-gateway-contract.md @@ -3,7 +3,7 @@ Broadcast message UpdateGatewayContract to update the gateway contract address ``` -zetacored tx fungible update-gateway-contract [contract-address] [flags] +zetacored tx fungible update-gateway-contract [contract-address] [flags] ``` ### Options diff --git a/e2e/e2etests/e2etests.go b/e2e/e2etests/e2etests.go index ace69bd84f..fc0f0988dc 100644 --- a/e2e/e2etests/e2etests.go +++ b/e2e/e2etests/e2etests.go @@ -54,7 +54,8 @@ const ( /* Solana tests */ - TestSolanaDepositName = "solana_deposit" + TestSolanaDepositName = "solana_deposit" + TestSolanaWithdrawName = "solana_withdraw" /* Bitcoin tests @@ -338,10 +339,18 @@ var AllE2ETests = []runner.E2ETest{ TestSolanaDepositName, "deposit SOL into ZEVM", []runner.ArgDefinition{ - {Description: "amount in SOL", DefaultValue: "0.1"}, + {Description: "amount in lamport", DefaultValue: "13370000"}, }, TestSolanaDeposit, ), + runner.NewE2ETest( + TestSolanaWithdrawName, + "withdraw SOL from ZEVM", + []runner.ArgDefinition{ + {Description: "amount in lamport", DefaultValue: "1336000"}, + }, + TestSolanaWithdraw, + ), /* Bitcoin tests */ diff --git a/e2e/e2etests/test_solana_deposit.go b/e2e/e2etests/test_solana_deposit.go index 581a420776..486ae89782 100644 --- a/e2e/e2etests/test_solana_deposit.go +++ b/e2e/e2etests/test_solana_deposit.go @@ -1,20 +1,29 @@ package e2etests import ( + "math/big" + "github.com/gagliardetto/solana-go" + "github.com/stretchr/testify/require" "github.com/zeta-chain/zetacore/e2e/runner" "github.com/zeta-chain/zetacore/e2e/utils" crosschaintypes "github.com/zeta-chain/zetacore/x/crosschain/types" ) -func TestSolanaDeposit(r *runner.E2ERunner, _ []string) { +func TestSolanaDeposit(r *runner.E2ERunner, args []string) { + require.Len(r, args, 1) + + // parse deposit amount (in lamports) + // #nosec G115 e2e - always in range + depositAmount := big.NewInt(int64(parseInt(r, args[0]))) + // load deployer private key - privkey := solana.MustPrivateKeyFromBase58(r.Account.SolanaPrivateKey.String()) + privkey, err := solana.PrivateKeyFromBase58(r.Account.SolanaPrivateKey.String()) + require.NoError(r, err) // create 'deposit' instruction - amount := uint64(13370000) - instruction := r.CreateDepositInstruction(privkey.PublicKey(), r.EVMAddress(), amount) + instruction := r.CreateDepositInstruction(privkey.PublicKey(), r.EVMAddress(), depositAmount.Uint64()) // create and sign the transaction signedTx := r.CreateSignedTransaction([]solana.Instruction{instruction}, privkey) diff --git a/e2e/e2etests/test_solana_withdraw.go b/e2e/e2etests/test_solana_withdraw.go new file mode 100644 index 0000000000..776944c9b3 --- /dev/null +++ b/e2e/e2etests/test_solana_withdraw.go @@ -0,0 +1,47 @@ +package e2etests + +import ( + "math/big" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/gagliardetto/solana-go" + "github.com/stretchr/testify/require" + + "github.com/zeta-chain/zetacore/e2e/runner" +) + +func TestSolanaWithdraw(r *runner.E2ERunner, args []string) { + require.Len(r, args, 1) + + // print balanceAfter of from address + balanceBefore, err := r.SOLZRC20.BalanceOf(&bind.CallOpts{}, r.ZEVMAuth.From) + require.NoError(r, err) + r.Logger.Info("from address %s balance of SOL before: %d", r.ZEVMAuth.From, balanceBefore) + + // parse withdraw amount (in lamports), approve amount is 1 SOL + approvedAmount := new(big.Int).SetUint64(solana.LAMPORTS_PER_SOL) + // #nosec G115 e2e - always in range + withdrawAmount := big.NewInt(int64(parseInt(r, args[0]))) + require.Equal( + r, + -1, + withdrawAmount.Cmp(approvedAmount), + "Withdrawal amount must be less than the approved amount (1e9).", + ) + + // load deployer private key + privkey, err := solana.PrivateKeyFromBase58(r.Account.SolanaPrivateKey.String()) + require.NoError(r, err) + + // withdraw + r.WithdrawSOLZRC20(privkey.PublicKey(), withdrawAmount, approvedAmount) + + // print balance of from address after withdraw + balanceAfter, err := r.SOLZRC20.BalanceOf(&bind.CallOpts{}, r.ZEVMAuth.From) + require.NoError(r, err) + r.Logger.Info("from address %s balance of SOL after: %d", r.ZEVMAuth.From, balanceAfter) + + // check if the balance is reduced correctly + amountReduced := new(big.Int).Sub(balanceBefore, balanceAfter) + require.True(r, amountReduced.Cmp(withdrawAmount) >= 0, "balance is not reduced correctly") +} diff --git a/e2e/runner/setup_solana.go b/e2e/runner/setup_solana.go index c9663dc3b8..10aa4b1a15 100644 --- a/e2e/runner/setup_solana.go +++ b/e2e/runner/setup_solana.go @@ -1,8 +1,6 @@ package runner import ( - "time" - ethcommon "github.com/ethereum/go-ethereum/common" "github.com/gagliardetto/solana-go" "github.com/gagliardetto/solana-go/rpc" @@ -10,22 +8,13 @@ import ( "github.com/stretchr/testify/require" "github.com/zeta-chain/zetacore/pkg/chains" - solanacontract "github.com/zeta-chain/zetacore/pkg/contract/solana" + solanacontracts "github.com/zeta-chain/zetacore/pkg/contracts/solana" ) +// SetupSolanaAccount imports the deployer's private key func (r *E2ERunner) SetupSolanaAccount() { - r.Logger.Print("⚙️ setting up Solana account") - startTime := time.Now() - defer func() { - r.Logger.Print("✅ Solana account setup in %s", time.Since(startTime)) - }() - - r.SetSolanaAddress() -} - -// SetSolanaAddress imports the deployer's private key -func (r *E2ERunner) SetSolanaAddress() { - privateKey := solana.MustPrivateKeyFromBase58(r.Account.SolanaPrivateKey.String()) + privateKey, err := solana.PrivateKeyFromBase58(r.Account.SolanaPrivateKey.String()) + require.NoError(r, err) r.SolanaDeployerAddress = privateKey.PublicKey() r.Logger.Info("SolanaDeployerAddress: %s", r.SolanaDeployerAddress) @@ -33,13 +22,14 @@ func (r *E2ERunner) SetSolanaAddress() { // SetSolanaContracts set Solana contracts func (r *E2ERunner) SetSolanaContracts(deployerPrivateKey string) { - r.Logger.Print("⚙️ setting up Solana contracts") + r.Logger.Print("⚙️ initializing gateway program on Solana") // set Solana contracts - r.GatewayProgram = solana.MustPublicKeyFromBase58(solanacontract.SolanaGatewayProgramID) + r.GatewayProgram = solana.MustPublicKeyFromBase58(solanacontracts.SolanaGatewayProgramID) // get deployer account balance - privkey := solana.MustPrivateKeyFromBase58(deployerPrivateKey) + privkey, err := solana.PrivateKeyFromBase58(deployerPrivateKey) + require.NoError(r, err) bal, err := r.SolanaClient.GetBalance(r.Ctx, privkey.PublicKey(), rpc.CommitmentFinalized) require.NoError(r, err) r.Logger.Info("deployer address: %s, balance: %f SOL", privkey.PublicKey().String(), float64(bal.Value)/1e9) @@ -57,8 +47,8 @@ func (r *E2ERunner) SetSolanaContracts(deployerPrivateKey string) { inst.ProgID = r.GatewayProgram inst.AccountValues = accountSlice - inst.DataBytes, err = borsh.Serialize(solanacontract.InitializeParams{ - Discriminator: solanacontract.DiscriminatorInitialize(), + inst.DataBytes, err = borsh.Serialize(solanacontracts.InitializeParams{ + Discriminator: solanacontracts.DiscriminatorInitialize(), TssAddress: r.TSSAddress, ChainID: uint64(chains.SolanaLocalnet.ChainId), }) @@ -76,7 +66,7 @@ func (r *E2ERunner) SetSolanaContracts(deployerPrivateKey string) { require.NoError(r, err) // deserialize the PDA info - pda := solanacontract.PdaInfo{} + pda := solanacontracts.PdaInfo{} err = borsh.Deserialize(&pda, pdaInfo.Bytes()) require.NoError(r, err) tssAddress := ethcommon.BytesToAddress(pda.TssAddress[:]) diff --git a/e2e/runner/solana.go b/e2e/runner/solana.go index 3338eaa6f4..71d2cfb629 100644 --- a/e2e/runner/solana.go +++ b/e2e/runner/solana.go @@ -1,6 +1,7 @@ package runner import ( + "math/big" "time" ethcommon "github.com/ethereum/go-ethereum/common" @@ -9,7 +10,9 @@ import ( "github.com/near/borsh-go" "github.com/stretchr/testify/require" - solanacontract "github.com/zeta-chain/zetacore/pkg/contract/solana" + "github.com/zeta-chain/zetacore/e2e/utils" + solanacontract "github.com/zeta-chain/zetacore/pkg/contracts/solana" + crosschaintypes "github.com/zeta-chain/zetacore/x/crosschain/types" ) // ComputePdaAddress computes the PDA address for the gateway program @@ -105,3 +108,26 @@ func (r *E2ERunner) BroadcastTxSync(tx *solana.Transaction) (solana.Signature, * return sig, out } + +// WithdrawSOLZRC20 withdraws an amount of ZRC20 SOL tokens +func (r *E2ERunner) WithdrawSOLZRC20(to solana.PublicKey, amount *big.Int, approveAmount *big.Int) { + // approve + tx, err := r.SOLZRC20.Approve(r.ZEVMAuth, r.SOLZRC20Addr, approveAmount) + require.NoError(r, err) + receipt := utils.MustWaitForTxReceipt(r.Ctx, r.ZEVMClient, tx, r.Logger, r.ReceiptTimeout) + utils.RequireTxSuccessful(r, receipt) + + // withdraw + tx, err = r.SOLZRC20.Withdraw(r.ZEVMAuth, []byte(to.String()), amount) + require.NoError(r, err) + r.Logger.EVMTransaction(*tx, "withdraw") + + // wait for tx receipt + receipt = utils.MustWaitForTxReceipt(r.Ctx, r.ZEVMClient, tx, r.Logger, r.ReceiptTimeout) + utils.RequireTxSuccessful(r, receipt) + r.Logger.Info("Receipt txhash %s status %d", receipt.TxHash, receipt.Status) + + // wait for the cctx to be mined + cctx := utils.WaitCctxMinedByInboundHash(r.Ctx, tx.Hash().Hex(), r.CctxClient, r.Logger, r.CctxTimeout) + utils.RequireCCTXStatus(r, cctx, crosschaintypes.CctxStatus_OutboundMined) +} diff --git a/e2e/txserver/zeta_tx_server.go b/e2e/txserver/zeta_tx_server.go index 2ce07f0fc5..8ec4496539 100644 --- a/e2e/txserver/zeta_tx_server.go +++ b/e2e/txserver/zeta_tx_server.go @@ -431,7 +431,7 @@ func (zts ZetaTxServer) DeploySystemContractsAndZRC20( 100000, )) if err != nil { - return SystemContractAddresses{}, fmt.Errorf("failed to deploy btc zrc20: %s", err.Error()) + return SystemContractAddresses{}, fmt.Errorf("failed to deploy sol zrc20: %s", err.Error()) } // deploy erc20 zrc20 diff --git a/pkg/contract/solana/types.go b/pkg/contract/solana/types.go deleted file mode 100644 index eede621c06..0000000000 --- a/pkg/contract/solana/types.go +++ /dev/null @@ -1,44 +0,0 @@ -package solana - -// PdaInfo represents the PDA for the gateway program -type PdaInfo struct { - // Discriminator is the unique identifier for the PDA - Discriminator [8]byte - - // Nonce is the current nonce for the PDA - Nonce uint64 - - // TssAddress is the TSS address for the PDA - TssAddress [20]byte - - // Authority is the authority for the PDA - Authority [32]byte - - // ChainId is the chain ID for the gateway program - // Note: this field exists in latest version of gateway program, but not in the current e2e test program - // ChainId uint64 -} - -// InitializeParams contains the parameters for a gateway initialize instruction -type InitializeParams struct { - // Discriminator is the unique identifier for the initialize instruction - Discriminator [8]byte - - // TssAddress is the TSS address - TssAddress [20]byte - - // ChainID is the chain ID for the gateway program - ChainID uint64 -} - -// DepositInstructionParams contains the parameters for a gateway deposit instruction -type DepositInstructionParams struct { - // Discriminator is the unique identifier for the deposit instruction - Discriminator [8]byte - - // Amount is the lamports amount for the deposit - Amount uint64 - - // Memo is the memo for the deposit - Memo []byte -} diff --git a/pkg/contract/solana/contract.go b/pkg/contracts/solana/gateway.go similarity index 65% rename from pkg/contract/solana/contract.go rename to pkg/contracts/solana/gateway.go index 2e2acd7ad6..356614e4ce 100644 --- a/pkg/contract/solana/contract.go +++ b/pkg/contracts/solana/gateway.go @@ -1,5 +1,11 @@ +// Package solana privides structures and constants that are used when interacting with the gateway program on Solana chain. package solana +import ( + "github.com/gagliardetto/solana-go" + "github.com/pkg/errors" +) + const ( // SolanaGatewayProgramID is the program ID of the Solana gateway program SolanaGatewayProgramID = "94U5AHQMKkV5txNJ17QPXWoh474PheGou6cNP2FEuL1d" @@ -36,3 +42,20 @@ func DiscriminatorWithdraw() [8]byte { func DiscriminatorWithdrawSPL() [8]byte { return [8]byte{156, 234, 11, 89, 235, 246, 32} } + +// ParseGatewayAddressAndPda parses the gateway id and program derived address from the given string +func ParseGatewayIDAndPda(address string) (solana.PublicKey, solana.PublicKey, error) { + var gatewayID, pda solana.PublicKey + + // decode gateway address + gatewayID, err := solana.PublicKeyFromBase58(address) + if err != nil { + return gatewayID, pda, errors.Wrap(err, "unable to decode address") + } + + // compute gateway PDA + seed := []byte(PDASeed) + pda, _, err = solana.FindProgramAddress([][]byte{seed}, gatewayID) + + return gatewayID, pda, err +} diff --git a/pkg/contract/solana/gateway.json b/pkg/contracts/solana/gateway.json similarity index 100% rename from pkg/contract/solana/gateway.json rename to pkg/contracts/solana/gateway.json diff --git a/pkg/contracts/solana/gateway_message.go b/pkg/contracts/solana/gateway_message.go new file mode 100644 index 0000000000..021af3cf1f --- /dev/null +++ b/pkg/contracts/solana/gateway_message.go @@ -0,0 +1,107 @@ +package solana + +import ( + "encoding/binary" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/gagliardetto/solana-go" +) + +// MsgWithdraw is the message for the Solana gateway withdraw/withdraw_spl instruction +type MsgWithdraw struct { + // chainID is the chain ID of Solana chain + chainID uint64 + + // Nonce is the nonce for the withdraw/withdraw_spl + nonce uint64 + + // amount is the lamports amount for the withdraw/withdraw_spl + amount uint64 + + // To is the recipient address for the withdraw/withdraw_spl + to solana.PublicKey + + // signature is the signature of the message + signature [65]byte +} + +// NewMsgWithdraw returns a new withdraw message +func NewMsgWithdraw(chainID, nonce, amount uint64, to solana.PublicKey) *MsgWithdraw { + return &MsgWithdraw{ + chainID: chainID, + nonce: nonce, + amount: amount, + to: to, + } +} + +// ChainID returns the chain ID of the message +func (msg *MsgWithdraw) ChainID() uint64 { + return msg.chainID +} + +// Nonce returns the nonce of the message +func (msg *MsgWithdraw) Nonce() uint64 { + return msg.nonce +} + +// Amount returns the amount of the message +func (msg *MsgWithdraw) Amount() uint64 { + return msg.amount +} + +// To returns the recipient address of the message +func (msg *MsgWithdraw) To() solana.PublicKey { + return msg.to +} + +// Hash packs the withdraw message and computes the hash +func (msg *MsgWithdraw) Hash() [32]byte { + var message []byte + buff := make([]byte, 8) + + binary.BigEndian.PutUint64(buff, msg.chainID) + message = append(message, buff...) + + binary.BigEndian.PutUint64(buff, msg.nonce) + message = append(message, buff...) + + binary.BigEndian.PutUint64(buff, msg.amount) + message = append(message, buff...) + + message = append(message, msg.to.Bytes()...) + + return crypto.Keccak256Hash(message) +} + +// SetSignature attaches the signature to the message +func (msg *MsgWithdraw) SetSignature(signature [65]byte) *MsgWithdraw { + msg.signature = signature + return msg +} + +// SigRSV returns the full 65-byte [R+S+V] signature +func (msg *MsgWithdraw) SigRSV() [65]byte { + return msg.signature +} + +// SigRS returns the 64-byte [R+S] core part of the signature +func (msg *MsgWithdraw) SigRS() [64]byte { + var sig [64]byte + copy(sig[:], msg.signature[:64]) + return sig +} + +// SigV returns the V part (recovery ID) of the signature +func (msg *MsgWithdraw) SigV() uint8 { + return msg.signature[64] +} + +// Signer returns the signer of the message +func (msg *MsgWithdraw) Signer() (common.Address, error) { + msgHash := msg.Hash() + msgSig := msg.SigRSV() + + return RecoverSigner(msgHash[:], msgSig[:]) +} diff --git a/pkg/contracts/solana/gateway_message_test.go b/pkg/contracts/solana/gateway_message_test.go new file mode 100644 index 0000000000..b2ffc24489 --- /dev/null +++ b/pkg/contracts/solana/gateway_message_test.go @@ -0,0 +1,31 @@ +package solana_test + +import ( + "bytes" + "encoding/hex" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/gagliardetto/solana-go" + "github.com/zeta-chain/zetacore/pkg/chains" + contracts "github.com/zeta-chain/zetacore/pkg/contracts/solana" +) + +func Test_MsgWithdrawHash(t *testing.T) { + t.Run("should pass for archived inbound, receipt and cctx", func(t *testing.T) { + // #nosec G115 always positive + chainID := uint64(chains.SolanaLocalnet.ChainId) + nonce := uint64(0) + amount := uint64(1336000) + to := solana.MustPublicKeyFromBase58("37yGiHAnLvWZUNVwu9esp74YQFqxU1qHCbABkDvRddUQ") + + wantHash := "a20cddb3f888f4064ced892a477101f45469a8c50f783b966d3fec2455887c05" + wantHashBytes, err := hex.DecodeString(wantHash) + require.NoError(t, err) + + // create new withdraw message + hash := contracts.NewMsgWithdraw(chainID, nonce, amount, to).Hash() + require.True(t, bytes.Equal(hash[:], wantHashBytes)) + }) +} diff --git a/pkg/contract/solana/idl.go b/pkg/contracts/solana/idl.go similarity index 100% rename from pkg/contract/solana/idl.go rename to pkg/contracts/solana/idl.go diff --git a/pkg/contracts/solana/instruction.go b/pkg/contracts/solana/instruction.go new file mode 100644 index 0000000000..f338129c9b --- /dev/null +++ b/pkg/contracts/solana/instruction.go @@ -0,0 +1,118 @@ +package solana + +import ( + "fmt" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/gagliardetto/solana-go" + "github.com/near/borsh-go" + "github.com/pkg/errors" +) + +// InitializeParams contains the parameters for a gateway initialize instruction +type InitializeParams struct { + // Discriminator is the unique identifier for the initialize instruction + Discriminator [8]byte + + // TssAddress is the TSS address + TssAddress [20]byte + + // ChainID is the chain ID for the gateway program + ChainID uint64 +} + +// DepositInstructionParams contains the parameters for a gateway deposit instruction +type DepositInstructionParams struct { + // Discriminator is the unique identifier for the deposit instruction + Discriminator [8]byte + + // Amount is the lamports amount for the deposit + Amount uint64 + + // Memo is the memo for the deposit + Memo []byte +} + +// OutboundInstruction is the interface for all gateway outbound instructions +type OutboundInstruction interface { + // Signer returns the signer of the instruction + Signer() (common.Address, error) + + // GatewayNonce returns the nonce of the instruction + GatewayNonce() uint64 + + // TokenAmount returns the amount of the instruction + TokenAmount() uint64 +} + +var _ OutboundInstruction = (*WithdrawInstructionParams)(nil) + +// WithdrawInstructionParams contains the parameters for a gateway withdraw instruction +type WithdrawInstructionParams struct { + // Discriminator is the unique identifier for the withdraw instruction + Discriminator [8]byte + + // Amount is the lamports amount for the withdraw + Amount uint64 + + // Signature is the ECDSA signature (by TSS) for the withdraw + Signature [64]byte + + // RecoveryID is the recovery ID used to recover the public key from ECDSA signature + RecoveryID uint8 + + // MessageHash is the hash of the message signed by TSS + MessageHash [32]byte + + // Nonce is the nonce for the withdraw + Nonce uint64 +} + +// Signer returns the signer of the signature contained +func (inst *WithdrawInstructionParams) Signer() (signer common.Address, err error) { + var signature [65]byte + copy(signature[:], inst.Signature[:64]) + signature[64] = inst.RecoveryID + + return RecoverSigner(inst.MessageHash[:], signature[:]) +} + +// GatewayNonce returns the nonce of the instruction +func (inst *WithdrawInstructionParams) GatewayNonce() uint64 { + return inst.Nonce +} + +// TokenAmount returns the amount of the instruction +func (inst *WithdrawInstructionParams) TokenAmount() uint64 { + return inst.Amount +} + +// ParseInstructionWithdraw tries to parse the instruction as a 'withdraw'. +// It returns nil if the instruction can't be parsed as a 'withdraw'. +func ParseInstructionWithdraw(instruction solana.CompiledInstruction) (*WithdrawInstructionParams, error) { + // try deserializing instruction as a 'withdraw' + inst := &WithdrawInstructionParams{} + err := borsh.Deserialize(inst, instruction.Data) + if err != nil { + return nil, errors.Wrap(err, "error deserializing instruction") + } + + // check the discriminator to ensure it's a 'withdraw' instruction + if inst.Discriminator != DiscriminatorWithdraw() { + return nil, fmt.Errorf("not a withdraw instruction: %v", inst.Discriminator) + } + + return inst, nil +} + +// RecoverSigner recover the ECDSA signer from given message hash and signature +func RecoverSigner(msgHash []byte, msgSig []byte) (signer common.Address, err error) { + // recover the public key + pubKey, err := crypto.SigToPub(msgHash, msgSig) + if err != nil { + return + } + + return crypto.PubkeyToAddress(*pubKey), nil +} diff --git a/pkg/contracts/solana/instruction_test.go b/pkg/contracts/solana/instruction_test.go new file mode 100644 index 0000000000..7f284169f1 --- /dev/null +++ b/pkg/contracts/solana/instruction_test.go @@ -0,0 +1,80 @@ +package solana_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + ethcommon "github.com/ethereum/go-ethereum/common" + contracts "github.com/zeta-chain/zetacore/pkg/contracts/solana" +) + +const ( + // testSigner is the address of the signer for unit tests + testSigner = "0xaD32427bA235a8350b7805C1b85147c8ea03F437" +) + +// getTestSignature returns the signature produced by 'testSigner' for the withdraw instruction: +// ChainID: 902 +// Nonce: 0 +// Amount: 1336000 +// To: 37yGiHAnLvWZUNVwu9esp74YQFqxU1qHCbABkDvRddUQ +func getTestSignature() [65]byte { + return [65]byte{ + 57, 160, 150, 241, 113, 78, 5, 205, 104, 97, 176, 136, 113, 84, 183, 119, + 213, 119, 29, 1, 183, 3, 43, 27, 140, 39, 33, 185, 6, 122, 69, 140, + 42, 102, 187, 143, 110, 9, 106, 162, 158, 26, 135, 253, 130, 157, 216, 191, + 117, 23, 179, 243, 109, 175, 101, 19, 95, 192, 16, 240, 40, 99, 105, 216, 0, + } +} + +// getTestmessageHash returns the message hash used to produce 'testSignature' +func getTestmessageHash() [32]byte { + return [32]byte{ + 162, 12, 221, 179, 248, 136, 244, 6, 76, 237, 137, 42, 71, 113, 1, 244, + 84, 105, 168, 197, 15, 120, 59, 150, 109, 63, 236, 36, 85, 136, 124, 5, + } +} + +func Test_SignerWithdraw(t *testing.T) { + var sigRS [64]byte + sigTest := getTestSignature() + copy(sigRS[:], sigTest[:64]) + + // create a withdraw instruction + inst := contracts.WithdrawInstructionParams{ + Signature: sigRS, + RecoveryID: 0, + MessageHash: getTestmessageHash(), + } + + // recover signer + signer, err := inst.Signer() + require.NoError(t, err) + require.EqualValues(t, testSigner, signer.String()) +} + +func Test_RecoverSigner(t *testing.T) { + sigTest := getTestSignature() + hashTest := getTestmessageHash() + + // recover the signer from the test message hash and signature + signer, err := contracts.RecoverSigner(hashTest[:], sigTest[:]) + require.NoError(t, err) + require.EqualValues(t, testSigner, signer.String()) + + // slightly modify the signature and recover the signer + sigFake := sigTest + sigFake[0]++ + signer, err = contracts.RecoverSigner(hashTest[:], sigFake[:]) + require.Error(t, err) + require.Equal(t, ethcommon.Address{}, signer) + + // slightly modify the message hash and recover the signer + hashFake := hashTest + hashFake[0]++ + signer, err = contracts.RecoverSigner(hashFake[:], sigTest[:]) + require.NoError(t, err) + require.NotEqual(t, ethcommon.Address{}, signer) + require.NotEqual(t, testSigner, signer.String()) +} diff --git a/pkg/contracts/solana/pda.go b/pkg/contracts/solana/pda.go new file mode 100644 index 0000000000..0452d23a15 --- /dev/null +++ b/pkg/contracts/solana/pda.go @@ -0,0 +1,19 @@ +package solana + +// PdaInfo represents the PDA for the gateway program +type PdaInfo struct { + // Discriminator is the unique identifier for the PDA + Discriminator [8]byte + + // Nonce is the current nonce for the PDA + Nonce uint64 + + // TssAddress is the TSS address for the PDA + TssAddress [20]byte + + // Authority is the authority for the PDA + Authority [32]byte + + // ChainId is the Solana chain id + ChainID uint64 +} diff --git a/x/observer/types/chain_params.go b/x/observer/types/chain_params.go index b2cbb63c32..b35d7da930 100644 --- a/x/observer/types/chain_params.go +++ b/x/observer/types/chain_params.go @@ -11,7 +11,7 @@ import ( "github.com/pkg/errors" "github.com/zeta-chain/zetacore/pkg/chains" - solanacontract "github.com/zeta-chain/zetacore/pkg/contract/solana" + solanacontracts "github.com/zeta-chain/zetacore/pkg/contracts/solana" ) const ( @@ -163,6 +163,7 @@ func GetDefaultChainParams() ChainParamsList { GetDefaultMumbaiTestnetChainParams(), GetDefaultBtcTestnetChainParams(), GetDefaultBtcRegtestChainParams(), + GetDefaultSolanaLocalnetChainParams(), GetDefaultGoerliLocalnetChainParams(), GetDefaultZetaPrivnetChainParams(), }, @@ -321,16 +322,16 @@ func GetDefaultSolanaLocalnetChainParams() *ChainParams { ZetaTokenContractAddress: zeroAddress, ConnectorContractAddress: zeroAddress, Erc20CustodyContractAddress: zeroAddress, - GasPriceTicker: 100, + GasPriceTicker: 5, WatchUtxoTicker: 0, - InboundTicker: 5, - OutboundTicker: 5, - OutboundScheduleInterval: 10, - OutboundScheduleLookahead: 10, + InboundTicker: 2, + OutboundTicker: 2, + OutboundScheduleInterval: 2, + OutboundScheduleLookahead: 5, BallotThreshold: DefaultBallotThreshold, MinObserverDelegation: DefaultMinObserverDelegation, IsSupported: false, - GatewayAddress: solanacontract.SolanaGatewayProgramID, + GatewayAddress: solanacontracts.SolanaGatewayProgramID, } } func GetDefaultGoerliLocalnetChainParams() *ChainParams { @@ -385,5 +386,6 @@ func ChainParamsEqual(params1, params2 ChainParams) bool { params1.OutboundScheduleLookahead == params2.OutboundScheduleLookahead && params1.BallotThreshold.Equal(params2.BallotThreshold) && params1.MinObserverDelegation.Equal(params2.MinObserverDelegation) && - params1.IsSupported == params2.IsSupported + params1.IsSupported == params2.IsSupported && + params1.GatewayAddress == params2.GatewayAddress } diff --git a/zetaclient/chains/base/observer.go b/zetaclient/chains/base/observer.go index eb0adbe5d8..428946f0bf 100644 --- a/zetaclient/chains/base/observer.go +++ b/zetaclient/chains/base/observer.go @@ -267,6 +267,11 @@ func (ob *Observer) WithHeaderCache(cache *lru.Cache) *Observer { return ob } +// OutboundID returns a unique identifier for the outbound transaction. +func (ob *Observer) OutboundID(nonce uint64) string { + return fmt.Sprintf("%d-%d", ob.chain.ChainId, nonce) +} + // DB returns the database for the observer. func (ob *Observer) DB() *db.DB { return ob.db diff --git a/zetaclient/chains/base/signer.go b/zetaclient/chains/base/signer.go index 2545c3639a..6618c338de 100644 --- a/zetaclient/chains/base/signer.go +++ b/zetaclient/chains/base/signer.go @@ -23,12 +23,15 @@ type Signer struct { // logger contains the loggers used by signer logger Logger + // outboundBeingReported is a map of outbound being reported to tracker + outboundBeingReported map[string]bool + // mu protects fields from concurrent access // Note: base signer simply provides the mutex. It's the sub-struct's responsibility to use it to be thread-safe mu sync.Mutex } -// NewSigner creates a new base signer +// NewSigner creates a new base signer. func NewSigner(chain chains.Chain, tss interfaces.TSSSigner, ts *metrics.TelemetryServer, logger Logger) *Signer { return &Signer{ chain: chain, @@ -38,53 +41,85 @@ func NewSigner(chain chains.Chain, tss interfaces.TSSSigner, ts *metrics.Telemet Std: logger.Std.With().Int64("chain", chain.ChainId).Str("module", "signer").Logger(), Compliance: logger.Compliance, }, + outboundBeingReported: make(map[string]bool), } } -// Chain returns the chain for the signer +// Chain returns the chain for the signer. func (s *Signer) Chain() chains.Chain { return s.chain } -// WithChain attaches a new chain to the signer +// WithChain attaches a new chain to the signer. func (s *Signer) WithChain(chain chains.Chain) *Signer { s.chain = chain return s } -// Tss returns the tss signer for the signer +// Tss returns the tss signer for the signer. func (s *Signer) TSS() interfaces.TSSSigner { return s.tss } -// WithTSS attaches a new tss signer to the signer +// WithTSS attaches a new tss signer to the signer. func (s *Signer) WithTSS(tss interfaces.TSSSigner) *Signer { s.tss = tss return s } -// TelemetryServer returns the telemetry server for the signer +// TelemetryServer returns the telemetry server for the signer. func (s *Signer) TelemetryServer() *metrics.TelemetryServer { return s.ts } -// WithTelemetryServer attaches a new telemetry server to the signer +// WithTelemetryServer attaches a new telemetry server to the signer. func (s *Signer) WithTelemetryServer(ts *metrics.TelemetryServer) *Signer { s.ts = ts return s } -// Logger returns the logger for the signer +// Logger returns the logger for the signer. func (s *Signer) Logger() *Logger { return &s.logger } -// Lock locks the signer +// SetBeingReportedFlag sets the outbound as being reported if not already set. +// Returns true if the outbound is already being reported. +// This method is used by outbound tracker reporter to avoid repeated reporting of same hash. +func (s *Signer) SetBeingReportedFlag(hash string) (alreadySet bool) { + s.Lock() + defer s.Unlock() + + alreadySet = s.outboundBeingReported[hash] + if !alreadySet { + // mark as being reported + s.outboundBeingReported[hash] = true + } + return +} + +// ClearBeingReportedFlag clears the being reported flag for the outbound. +func (s *Signer) ClearBeingReportedFlag(hash string) { + s.Lock() + defer s.Unlock() + delete(s.outboundBeingReported, hash) +} + +// Exported for unit tests + +// GetReportedTxList returns a list of outboundHash being reported. +// TODO: investigate pointer usage +// https://github.com/zeta-chain/node/issues/2084 +func (s *Signer) GetReportedTxList() *map[string]bool { + return &s.outboundBeingReported +} + +// Lock locks the signer. func (s *Signer) Lock() { s.mu.Lock() } -// Unlock unlocks the signer +// Unlock unlocks the signer. func (s *Signer) Unlock() { s.mu.Unlock() } diff --git a/zetaclient/chains/base/signer_test.go b/zetaclient/chains/base/signer_test.go index e35ed01792..dde542a4a0 100644 --- a/zetaclient/chains/base/signer_test.go +++ b/zetaclient/chains/base/signer_test.go @@ -61,3 +61,21 @@ func TestSignerGetterAndSetter(t *testing.T) { logger.Compliance.Info().Msg("print compliance log") }) } + +func Test_BeingReportedFlag(t *testing.T) { + signer := createSigner(t) + + // hash to be reported + hash := "0x1234" + alreadySet := signer.SetBeingReportedFlag(hash) + require.False(t, alreadySet) + + // set reported outbound again and check + alreadySet = signer.SetBeingReportedFlag(hash) + require.True(t, alreadySet) + + // clear reported outbound and check again + signer.ClearBeingReportedFlag(hash) + alreadySet = signer.SetBeingReportedFlag(hash) + require.False(t, alreadySet) +} diff --git a/zetaclient/chains/bitcoin/observer/observer.go b/zetaclient/chains/bitcoin/observer/observer.go index 519b3b99d8..8b4c79ba39 100644 --- a/zetaclient/chains/bitcoin/observer/observer.go +++ b/zetaclient/chains/bitcoin/observer/observer.go @@ -324,8 +324,7 @@ func (ob *Observer) WatchGasPrice(ctx context.Context) error { // start gas price ticker ticker, err := clienttypes.NewDynamicTicker("Bitcoin_WatchGasPrice", ob.GetChainParams().GasPriceTicker) if err != nil { - ob.logger.GasPrice.Error().Err(err).Msg("error creating ticker") - return err + return errors.Wrapf(err, "NewDynamicTicker error") } ob.logger.GasPrice.Info().Msgf("WatchGasPrice started for chain %d with interval %d", ob.Chain().ChainId, ob.GetChainParams().GasPriceTicker) @@ -383,7 +382,7 @@ func (ob *Observer) PostGasPrice(ctx context.Context) error { // query the current block number blockNumber, err := ob.btcClient.GetBlockCount() if err != nil { - return err + return errors.Wrap(err, "GetBlockCount error") } // UTXO has no concept of priority fee (like eth) @@ -392,8 +391,7 @@ func (ob *Observer) PostGasPrice(ctx context.Context) error { // #nosec G115 always positive _, err = ob.ZetacoreClient().PostVoteGasPrice(ctx, ob.Chain(), feeRateEstimated, priorityFee, uint64(blockNumber)) if err != nil { - ob.logger.GasPrice.Err(err).Msg("err PostGasPrice") - return err + return errors.Wrap(err, "PostVoteGasPrice error") } return nil @@ -537,7 +535,7 @@ func (ob *Observer) FetchUTXOs(ctx context.Context) error { // SaveBroadcastedTx saves successfully broadcasted transaction // TODO(revamp): move to db file func (ob *Observer) SaveBroadcastedTx(txHash string, nonce uint64) { - outboundID := ob.GetTxID(nonce) + outboundID := ob.OutboundID(nonce) ob.Mu().Lock() ob.broadcastedTx[outboundID] = txHash ob.Mu().Unlock() diff --git a/zetaclient/chains/bitcoin/observer/outbound.go b/zetaclient/chains/bitcoin/observer/outbound.go index c7c1c649d7..d6dd003caa 100644 --- a/zetaclient/chains/bitcoin/observer/outbound.go +++ b/zetaclient/chains/bitcoin/observer/outbound.go @@ -23,12 +23,6 @@ import ( "github.com/zeta-chain/zetacore/zetaclient/zetacore" ) -// GetTxID returns a unique id for outbound tx -func (ob *Observer) GetTxID(nonce uint64) string { - tssAddr := ob.TSS().BTCAddress() - return fmt.Sprintf("%d-%s-%d", ob.Chain().ChainId, tssAddr, nonce) -} - // WatchOutbound watches Bitcoin chain for outgoing txs status // TODO(revamp): move ticker functions to a specific file // TODO(revamp): move into a separate package @@ -66,7 +60,7 @@ func (ob *Observer) WatchOutbound(ctx context.Context) error { } for _, tracker := range trackers { // get original cctx parameters - outboundID := ob.GetTxID(tracker.Nonce) + outboundID := ob.OutboundID(tracker.Nonce) cctx, err := ob.ZetacoreClient().GetCctxByNonce(ctx, chainID, tracker.Nonce) if err != nil { ob.logger.Outbound.Info(). @@ -120,13 +114,11 @@ func (ob *Observer) WatchOutbound(ctx context.Context) error { } } -// IsOutboundProcessed returns isIncluded(or inMempool), isConfirmed, Error -// TODO(revamp): rename as it vote the outbound and doesn't only check if outbound is processed -func (ob *Observer) IsOutboundProcessed( +// VoteOutboundIfConfirmed checks outbound status and returns (continueKeysign, error) +func (ob *Observer) VoteOutboundIfConfirmed( ctx context.Context, cctx *crosschaintypes.CrossChainTx, - logger zerolog.Logger, -) (bool, bool, error) { +) (bool, error) { const ( // not used with Bitcoin outboundGasUsed = 0 @@ -141,8 +133,8 @@ func (ob *Observer) IsOutboundProcessed( nonce := cctx.GetCurrentOutboundParam().TssNonce // get broadcasted outbound and tx result - outboundID := ob.GetTxID(nonce) - logger.Info().Msgf("IsOutboundProcessed %s", outboundID) + outboundID := ob.OutboundID(nonce) + ob.Logger().Outbound.Info().Msgf("VoteOutboundIfConfirmed %s", outboundID) ob.Mu().Lock() txnHash, broadcasted := ob.broadcastedTx[outboundID] @@ -151,7 +143,7 @@ func (ob *Observer) IsOutboundProcessed( if !included { if !broadcasted { - return false, false, nil + return true, nil } // If the broadcasted outbound is nonce 0, just wait for inclusion and don't schedule more keysign // Schedule more than one keysign for nonce 0 can lead to duplicate payments. @@ -159,16 +151,16 @@ func (ob *Observer) IsOutboundProcessed( // prevents double spending of same UTXO. However, for nonce 0, we don't have a prior nonce (e.g., -1) // for the signer to check against when making the payment. Signer treats nonce 0 as a special case in downstream code. if nonce == 0 { - return true, false, nil + return false, nil } // Try including this outbound broadcasted by myself txResult, inMempool := ob.checkIncludedTx(ctx, cctx, txnHash) if txResult == nil { // check failed, try again next time - return false, false, nil + return true, nil } else if inMempool { // still in mempool (should avoid unnecessary Tss keysign) - ob.logger.Outbound.Info().Msgf("IsOutboundProcessed: outbound %s is still in mempool", outboundID) - return true, false, nil + ob.logger.Outbound.Info().Msgf("VoteOutboundIfConfirmed: outbound %s is still in mempool", outboundID) + return false, nil } // included ob.setIncludedTx(nonce, txResult) @@ -176,9 +168,9 @@ func (ob *Observer) IsOutboundProcessed( // Get tx result again in case it is just included res = ob.getIncludedTx(nonce) if res == nil { - return false, false, nil + return true, nil } - ob.logger.Outbound.Info().Msgf("IsOutboundProcessed: setIncludedTx succeeded for outbound %s", outboundID) + ob.logger.Outbound.Info().Msgf("VoteOutboundIfConfirmed: setIncludedTx succeeded for outbound %s", outboundID) } // It's safe to use cctx's amount to post confirmation because it has already been verified in observeOutbound() @@ -187,22 +179,24 @@ func (ob *Observer) IsOutboundProcessed( ob.logger.Outbound.Debug(). Int64("currentConfirmations", res.Confirmations). Int64("requiredConfirmations", ob.ConfirmationsThreshold(amountInSat)). - Msg("IsOutboundProcessed: outbound not confirmed yet") + Msg("VoteOutboundIfConfirmed: outbound not confirmed yet") - return true, false, nil + return false, nil } // Get outbound block height blockHeight, err := rpc.GetBlockHeightByHash(ob.btcClient, res.BlockHash) if err != nil { - return true, false, errors.Wrapf( + return false, errors.Wrapf( err, - "IsOutboundProcessed: error getting block height by hash %s", + "VoteOutboundIfConfirmed: error getting block height by hash %s", res.BlockHash, ) } - logger.Debug().Msgf("Bitcoin outbound confirmed: txid %s, amount %s\n", res.TxID, amountInSat.String()) + ob.Logger(). + Outbound.Debug(). + Msgf("Bitcoin outbound confirmed: txid %s, amount %s\n", res.TxID, amountInSat.String()) signer := ob.ZetacoreClient().GetKeys().GetOperatorAddress() @@ -236,12 +230,16 @@ func (ob *Observer) IsOutboundProcessed( } if err != nil { - logger.Error().Err(err).Fields(logFields).Msg("IsOutboundProcessed: error confirming bitcoin outbound") + ob.Logger(). + Outbound.Error(). + Err(err). + Fields(logFields). + Msg("VoteOutboundIfConfirmed: error confirming bitcoin outbound") } else if zetaHash != "" { - logger.Info().Fields(logFields).Msgf("IsOutboundProcessed: confirmed Bitcoin outbound") + ob.Logger().Outbound.Info().Fields(logFields).Msgf("VoteOutboundIfConfirmed: confirmed Bitcoin outbound") } - return true, true, nil + return false, nil } // SelectUTXOs selects a sublist of utxos to be used as inputs. @@ -438,7 +436,7 @@ func (ob *Observer) checkIncludedTx( cctx *crosschaintypes.CrossChainTx, txHash string, ) (*btcjson.GetTransactionResult, bool) { - outboundID := ob.GetTxID(cctx.GetCurrentOutboundParam().TssNonce) + outboundID := ob.OutboundID(cctx.GetCurrentOutboundParam().TssNonce) hash, getTxResult, err := rpc.GetTxResultByHash(ob.btcClient, txHash) if err != nil { ob.logger.Outbound.Error().Err(err).Msgf("checkIncludedTx: error GetTxResultByHash: %s", txHash) @@ -467,7 +465,7 @@ func (ob *Observer) checkIncludedTx( // setIncludedTx saves included tx result in memory func (ob *Observer) setIncludedTx(nonce uint64, getTxResult *btcjson.GetTransactionResult) { txHash := getTxResult.TxID - outboundID := ob.GetTxID(nonce) + outboundID := ob.OutboundID(nonce) ob.Mu().Lock() defer ob.Mu().Unlock() @@ -496,17 +494,17 @@ func (ob *Observer) setIncludedTx(nonce uint64, getTxResult *btcjson.GetTransact func (ob *Observer) getIncludedTx(nonce uint64) *btcjson.GetTransactionResult { ob.Mu().Lock() defer ob.Mu().Unlock() - return ob.includedTxResults[ob.GetTxID(nonce)] + return ob.includedTxResults[ob.OutboundID(nonce)] } // removeIncludedTx removes included tx from memory func (ob *Observer) removeIncludedTx(nonce uint64) { ob.Mu().Lock() defer ob.Mu().Unlock() - txResult, found := ob.includedTxResults[ob.GetTxID(nonce)] + txResult, found := ob.includedTxResults[ob.OutboundID(nonce)] if found { delete(ob.includedTxHashes, txResult.TxID) - delete(ob.includedTxResults, ob.GetTxID(nonce)) + delete(ob.includedTxResults, ob.OutboundID(nonce)) } } diff --git a/zetaclient/chains/bitcoin/observer/outbound_test.go b/zetaclient/chains/bitcoin/observer/outbound_test.go index d661b1c7bb..b1f7ae309b 100644 --- a/zetaclient/chains/bitcoin/observer/outbound_test.go +++ b/zetaclient/chains/bitcoin/observer/outbound_test.go @@ -71,7 +71,7 @@ func createObserverWithUTXOs(t *testing.T) *Observer { func mineTxNSetNonceMark(ob *Observer, nonce uint64, txid string, preMarkIndex int) { // Mine transaction - outboundID := ob.GetTxID(nonce) + outboundID := ob.OutboundID(nonce) ob.includedTxResults[outboundID] = &btcjson.GetTransactionResult{TxID: txid} // Set nonce mark diff --git a/zetaclient/chains/bitcoin/signer/signer.go b/zetaclient/chains/bitcoin/signer/signer.go index 7d1cfc366e..7e701e523d 100644 --- a/zetaclient/chains/bitcoin/signer/signer.go +++ b/zetaclient/chains/bitcoin/signer/signer.go @@ -28,7 +28,6 @@ import ( "github.com/zeta-chain/zetacore/zetaclient/chains/interfaces" "github.com/zeta-chain/zetacore/zetaclient/compliance" "github.com/zeta-chain/zetacore/zetaclient/config" - zctx "github.com/zeta-chain/zetacore/zetaclient/context" "github.com/zeta-chain/zetacore/zetaclient/metrics" "github.com/zeta-chain/zetacore/zetaclient/outboundprocessor" ) @@ -47,7 +46,7 @@ const ( broadcastRetries = 5 ) -var _ interfaces.ChainSigner = &Signer{} +var _ interfaces.ChainSigner = (*Signer)(nil) // Signer deals with signing BTC transactions and implements the ChainSigner interface type Signer struct { @@ -63,7 +62,8 @@ func NewSigner( tss interfaces.TSSSigner, ts *metrics.TelemetryServer, logger base.Logger, - cfg config.BTCConfig) (*Signer, error) { + cfg config.BTCConfig, +) (*Signer, error) { // create base signer baseSigner := base.NewSigner(chain, tss, ts, logger) @@ -87,6 +87,8 @@ func NewSigner( }, nil } +// TODO: get rid of below four get/set functions for Bitcoin, as they are not needed in future +// https://github.com/zeta-chain/node/issues/2532 // SetZetaConnectorAddress does nothing for BTC func (signer *Signer) SetZetaConnectorAddress(_ ethcommon.Address) { } @@ -105,6 +107,17 @@ func (signer *Signer) GetERC20CustodyAddress() ethcommon.Address { return ethcommon.Address{} } +// SetGatewayAddress does nothing for BTC +// Note: TSS address will be used as gateway address for Bitcoin +func (signer *Signer) SetGatewayAddress(_ string) { +} + +// GetGatewayAddress returns empty address +// Note: same as SetGatewayAddress +func (signer *Signer) GetGatewayAddress() string { + return "" +} + // AddWithdrawTxOutputs adds the 3 outputs to the withdraw tx // 1st output: the nonce-mark btc to TSS itself // 2nd output: the payment to the recipient @@ -328,12 +341,7 @@ func (signer *Signer) TryProcessOutbound( zetacoreClient interfaces.ZetacoreClient, height uint64, ) { - app, err := zctx.FromContext(ctx) - if err != nil { - signer.Logger().Std.Error().Msgf("BTC TryProcessOutbound: %s, cannot get app context", cctx.Index) - return - } - + // end outbound process on panic defer func() { outboundProcessor.EndTryProcess(outboundID) if err := recover(); err != nil { @@ -341,35 +349,34 @@ func (signer *Signer) TryProcessOutbound( } }() + // prepare logger + params := cctx.GetCurrentOutboundParam() logger := signer.Logger().Std.With(). - Str("OutboundID", outboundID). - Str("SendHash", cctx.Index). + Str("method", "TryProcessOutbound"). + Int64("chain", signer.Chain().ChainId). + Uint64("nonce", params.TssNonce). + Str("cctx", cctx.Index). Logger() - params := cctx.GetCurrentOutboundParam() + // support gas token only for Bitcoin outbound coinType := cctx.InboundParams.CoinType if coinType == coin.CoinType_Zeta || coinType == coin.CoinType_ERC20 { - logger.Error().Msgf("BTC TryProcessOutbound: can only send BTC to a BTC network") + logger.Error().Msg("can only send BTC to a BTC network") return } - logger.Info(). - Msgf("BTC TryProcessOutbound: %s, value %d to %s", cctx.Index, params.Amount.BigInt(), params.Receiver) + // convert chain observer to BTC observer btcObserver, ok := chainObserver.(*observer.Observer) if !ok { - logger.Error().Msgf("chain observer is not a bitcoin observer") - return - } - flags := app.GetCrossChainFlags() - if !flags.IsOutboundEnabled { - logger.Info().Msgf("outbound is disabled") + logger.Error().Msg("chain observer is not a bitcoin observer") return } + chain := btcObserver.Chain() outboundTssNonce := params.TssNonce signerAddress, err := zetacoreClient.GetKeys().GetAddress() if err != nil { - logger.Error().Err(err).Msgf("cannot get signer address") + logger.Error().Err(err).Msg("cannot get signer address") return } diff --git a/zetaclient/chains/evm/observer/observer.go b/zetaclient/chains/evm/observer/observer.go index d49e5a53a3..b6ff80c769 100644 --- a/zetaclient/chains/evm/observer/observer.go +++ b/zetaclient/chains/evm/observer/observer.go @@ -236,31 +236,31 @@ func (ob *Observer) WatchRPCStatus(ctx context.Context) error { func (ob *Observer) SetPendingTx(nonce uint64, transaction *ethtypes.Transaction) { ob.Mu().Lock() defer ob.Mu().Unlock() - ob.outboundPendingTransactions[ob.GetTxID(nonce)] = transaction + ob.outboundPendingTransactions[ob.OutboundID(nonce)] = transaction } // GetPendingTx gets the pending transaction from memory func (ob *Observer) GetPendingTx(nonce uint64) *ethtypes.Transaction { ob.Mu().Lock() defer ob.Mu().Unlock() - return ob.outboundPendingTransactions[ob.GetTxID(nonce)] + return ob.outboundPendingTransactions[ob.OutboundID(nonce)] } // SetTxNReceipt sets the receipt and transaction in memory func (ob *Observer) SetTxNReceipt(nonce uint64, receipt *ethtypes.Receipt, transaction *ethtypes.Transaction) { ob.Mu().Lock() defer ob.Mu().Unlock() - delete(ob.outboundPendingTransactions, ob.GetTxID(nonce)) // remove pending transaction, if any - ob.outboundConfirmedReceipts[ob.GetTxID(nonce)] = receipt - ob.outboundConfirmedTransactions[ob.GetTxID(nonce)] = transaction + delete(ob.outboundPendingTransactions, ob.OutboundID(nonce)) // remove pending transaction, if any + ob.outboundConfirmedReceipts[ob.OutboundID(nonce)] = receipt + ob.outboundConfirmedTransactions[ob.OutboundID(nonce)] = transaction } // GetTxNReceipt gets the receipt and transaction from memory func (ob *Observer) GetTxNReceipt(nonce uint64) (*ethtypes.Receipt, *ethtypes.Transaction) { ob.Mu().Lock() defer ob.Mu().Unlock() - receipt := ob.outboundConfirmedReceipts[ob.GetTxID(nonce)] - transaction := ob.outboundConfirmedTransactions[ob.GetTxID(nonce)] + receipt := ob.outboundConfirmedReceipts[ob.OutboundID(nonce)] + transaction := ob.outboundConfirmedTransactions[ob.OutboundID(nonce)] return receipt, transaction } @@ -268,8 +268,8 @@ func (ob *Observer) GetTxNReceipt(nonce uint64) (*ethtypes.Receipt, *ethtypes.Tr func (ob *Observer) IsTxConfirmed(nonce uint64) bool { ob.Mu().Lock() defer ob.Mu().Unlock() - return ob.outboundConfirmedReceipts[ob.GetTxID(nonce)] != nil && - ob.outboundConfirmedTransactions[ob.GetTxID(nonce)] != nil + return ob.outboundConfirmedReceipts[ob.OutboundID(nonce)] != nil && + ob.outboundConfirmedTransactions[ob.OutboundID(nonce)] != nil } // CheckTxInclusion returns nil only if tx is included at the position indicated by the receipt ([block, index]) diff --git a/zetaclient/chains/evm/observer/outbound.go b/zetaclient/chains/evm/observer/outbound.go index 7b4f200d52..ff256613a9 100644 --- a/zetaclient/chains/evm/observer/outbound.go +++ b/zetaclient/chains/evm/observer/outbound.go @@ -28,12 +28,6 @@ import ( "github.com/zeta-chain/zetacore/zetaclient/zetacore" ) -// GetTxID returns a unique id for outbound tx -func (ob *Observer) GetTxID(nonce uint64) string { - tssAddr := ob.TSS().EVMAddress().String() - return fmt.Sprintf("%d-%s-%d", ob.Chain().ChainId, tssAddr, nonce) -} - // WatchOutbound watches evm chain for outgoing txs status // TODO(revamp): move ticker function to ticker file // TODO(revamp): move inner logic to a separate function @@ -116,11 +110,6 @@ func (ob *Observer) PostVoteOutbound( logger zerolog.Logger, ) { chainID := ob.Chain().ChainId - logFields := map[string]any{ - "outbound.chain_id": chainID, - "outbound.external_tx_hash": receipt.TxHash.String(), - "outbound.nonce": nonce, - } signerAddress := ob.ZetacoreClient().GetKeys().GetOperatorAddress() @@ -146,47 +135,51 @@ func (ob *Observer) PostVoteOutbound( retryGasLimit = zetacore.PostVoteOutboundRevertGasLimit } + // post vote to zetacore + logFields := map[string]any{ + "chain": chainID, + "nonce": nonce, + "outbound": receipt.TxHash.String(), + } zetaTxHash, ballot, err := ob.ZetacoreClient().PostVoteOutbound(ctx, gasLimit, retryGasLimit, msg) if err != nil { - logger.Error().Err(err).Fields(logFields).Msgf("PostVoteOutbound: error posting vote for chain %d", chainID) + logger.Error(). + Err(err). + Fields(logFields). + Msgf("PostVoteOutbound: error posting vote for chain %d", chainID) return } - if zetaTxHash == "" { - return + // print vote tx hash and ballot + if zetaTxHash != "" { + logFields["vote"] = zetaTxHash + logFields["ballot"] = ballot + logger.Info().Fields(logFields).Msgf("PostVoteOutbound: posted vote for chain %d", chainID) } - - logFields["outbound.zeta_tx_hash"] = zetaTxHash - logFields["outbound.ballot"] = ballot - - logger.Info().Fields(logFields).Msgf("PostVoteOutbound: posted vote for chain %d", chainID) } -// IsOutboundProcessed checks outbound status and returns (isIncluded, isConfirmed, error) -// It also posts vote to zetacore if the tx is confirmed -// TODO(revamp): rename as it also vote the outbound -func (ob *Observer) IsOutboundProcessed( +// VoteOutboundIfConfirmed checks outbound status and returns (continueKeysign, error) +func (ob *Observer) VoteOutboundIfConfirmed( ctx context.Context, cctx *crosschaintypes.CrossChainTx, - logger zerolog.Logger, -) (bool, bool, error) { +) (bool, error) { // skip if outbound is not confirmed nonce := cctx.GetCurrentOutboundParam().TssNonce if !ob.IsTxConfirmed(nonce) { - return false, false, nil + return true, nil } receipt, transaction := ob.GetTxNReceipt(nonce) sendID := fmt.Sprintf("%d-%d", ob.Chain().ChainId, nonce) - logger = logger.With().Str("sendID", sendID).Logger() + logger := ob.Logger().Outbound.With().Str("sendID", sendID).Logger() // get connector and erce20Custody contracts connectorAddr, connector, err := ob.GetConnectorContract() if err != nil { - return false, false, errors.Wrapf(err, "error getting zeta connector for chain %d", ob.Chain().ChainId) + return true, errors.Wrapf(err, "error getting zeta connector for chain %d", ob.Chain().ChainId) } custodyAddr, custody, err := ob.GetERC20CustodyContract() if err != nil { - return false, false, errors.Wrapf(err, "error getting erc20 custody for chain %d", ob.Chain().ChainId) + return true, errors.Wrapf(err, "error getting erc20 custody for chain %d", ob.Chain().ChainId) } // define a few common variables @@ -203,7 +196,7 @@ func (ob *Observer) IsOutboundProcessed( receiveStatus = chains.ReceiveStatus_success } ob.PostVoteOutbound(ctx, cctx.Index, receipt, transaction, receiveValue, receiveStatus, nonce, cointype, logger) - return true, true, nil + return false, nil } // parse the received value from the outbound receipt @@ -220,13 +213,13 @@ func (ob *Observer) IsOutboundProcessed( if err != nil { logger.Error(). Err(err). - Msgf("IsOutboundProcessed: error parsing outbound event for chain %d txhash %s", ob.Chain().ChainId, receipt.TxHash) - return false, false, err + Msgf("VoteOutboundIfConfirmed: error parsing outbound event for chain %d txhash %s", ob.Chain().ChainId, receipt.TxHash) + return true, err } // post vote to zetacore ob.PostVoteOutbound(ctx, cctx.Index, receipt, transaction, receiveValue, receiveStatus, nonce, cointype, logger) - return true, true, nil + return false, nil } // ParseAndCheckZetaEvent parses and checks ZetaReceived/ZetaReverted event from the outbound receipt diff --git a/zetaclient/chains/evm/observer/outbound_test.go b/zetaclient/chains/evm/observer/outbound_test.go index 8b0ad1573c..a2aae00433 100644 --- a/zetaclient/chains/evm/observer/outbound_test.go +++ b/zetaclient/chains/evm/observer/outbound_test.go @@ -64,10 +64,9 @@ func Test_IsOutboundProcessed(t *testing.T) { ob.SetTxNReceipt(nonce, receipt, outbound) // post outbound vote - isIncluded, isConfirmed, err := ob.IsOutboundProcessed(ctx, cctx, zerolog.Nop()) + continueKeysign, err := ob.VoteOutboundIfConfirmed(ctx, cctx) require.NoError(t, err) - require.True(t, isIncluded) - require.True(t, isConfirmed) + require.False(t, continueKeysign) }) t.Run("should post vote and return true on restricted address", func(t *testing.T) { // load cctx and modify sender address to arbitrary address @@ -88,18 +87,16 @@ func Test_IsOutboundProcessed(t *testing.T) { config.LoadComplianceConfig(cfg) // post outbound vote - isIncluded, isConfirmed, err := ob.IsOutboundProcessed(ctx, cctx, zerolog.Nop()) + continueKeysign, err := ob.VoteOutboundIfConfirmed(ctx, cctx) require.NoError(t, err) - require.True(t, isIncluded) - require.True(t, isConfirmed) + require.False(t, continueKeysign) }) t.Run("should return false if outbound is not confirmed", func(t *testing.T) { // create evm observer and DO NOT set outbound as confirmed ob := MockEVMObserver(t, chain, nil, nil, nil, nil, 1, chainParam) - isIncluded, isConfirmed, err := ob.IsOutboundProcessed(ctx, cctx, zerolog.Nop()) + continueKeysign, err := ob.VoteOutboundIfConfirmed(ctx, cctx) require.NoError(t, err) - require.False(t, isIncluded) - require.False(t, isConfirmed) + require.True(t, continueKeysign) }) t.Run("should fail if unable to parse ZetaReceived event", func(t *testing.T) { // create evm observer and set outbound and receipt @@ -110,10 +107,9 @@ func Test_IsOutboundProcessed(t *testing.T) { chainParamsNew := ob.GetChainParams() chainParamsNew.ConnectorContractAddress = sample.EthAddress().Hex() ob.SetChainParams(chainParamsNew) - isIncluded, isConfirmed, err := ob.IsOutboundProcessed(ctx, cctx, zerolog.Nop()) + continueKeysign, err := ob.VoteOutboundIfConfirmed(ctx, cctx) require.Error(t, err) - require.False(t, isIncluded) - require.False(t, isConfirmed) + require.True(t, continueKeysign) }) } @@ -160,18 +156,16 @@ func Test_IsOutboundProcessed_ContractError(t *testing.T) { // set invalid connector ABI zetaconnector.ZetaConnectorNonEthMetaData.ABI = "invalid abi" - isIncluded, isConfirmed, err := ob.IsOutboundProcessed(ctx, cctx, zerolog.Nop()) + continueKeysign, err := ob.VoteOutboundIfConfirmed(ctx, cctx) zetaconnector.ZetaConnectorNonEthMetaData.ABI = abiConnector // reset connector ABI require.ErrorContains(t, err, "error getting zeta connector") - require.False(t, isIncluded) - require.False(t, isConfirmed) + require.True(t, continueKeysign) // set invalid custody ABI erc20custody.ERC20CustodyMetaData.ABI = "invalid abi" - isIncluded, isConfirmed, err = ob.IsOutboundProcessed(ctx, cctx, zerolog.Nop()) + continueKeysign, err = ob.VoteOutboundIfConfirmed(ctx, cctx) require.ErrorContains(t, err, "error getting erc20 custody") - require.False(t, isIncluded) - require.False(t, isConfirmed) + require.True(t, continueKeysign) erc20custody.ERC20CustodyMetaData.ABI = abiCustody // reset custody ABI }) } diff --git a/zetaclient/chains/evm/signer/outbound_data.go b/zetaclient/chains/evm/signer/outbound_data.go index 2509b79198..58ade4faf6 100644 --- a/zetaclient/chains/evm/signer/outbound_data.go +++ b/zetaclient/chains/evm/signer/outbound_data.go @@ -126,7 +126,6 @@ func NewOutboundData( txData.sender = ethcommon.HexToAddress(cctx.InboundParams.Sender) txData.srcChainID = big.NewInt(cctx.InboundParams.SenderChainId) txData.asset = ethcommon.HexToAddress(cctx.InboundParams.Asset) - txData.height = height skipTx := txData.SetChainAndSender(cctx, logger) @@ -139,22 +138,12 @@ func NewOutboundData( return nil, false, err } + nonce := cctx.GetCurrentOutboundParam().TssNonce toChain, found := chains.GetChainFromChainID(txData.toChainID.Int64(), app.GetAdditionalChains()) if !found { return nil, true, fmt.Errorf("unknown chain: %d", txData.toChainID.Int64()) } - // Get nonce, Early return if the cctx is already processed - nonce := cctx.GetCurrentOutboundParam().TssNonce - included, confirmed, err := evmObserver.IsOutboundProcessed(ctx, cctx, logger) - if err != nil { - return nil, true, errors.New("IsOutboundProcessed failed") - } - if included || confirmed { - logger.Info().Msgf("CCTX already processed; exit signer") - return nil, true, nil - } - // Set up gas limit and gas price err = txData.SetupGas(cctx, logger, evmRPC, toChain) if err != nil { diff --git a/zetaclient/chains/evm/signer/signer.go b/zetaclient/chains/evm/signer/signer.go index 7235aa4456..f0f00aa237 100644 --- a/zetaclient/chains/evm/signer/signer.go +++ b/zetaclient/chains/evm/signer/signer.go @@ -26,7 +26,6 @@ import ( "github.com/zeta-chain/zetacore/pkg/constant" crosschainkeeper "github.com/zeta-chain/zetacore/x/crosschain/keeper" "github.com/zeta-chain/zetacore/x/crosschain/types" - observertypes "github.com/zeta-chain/zetacore/x/observer/types" "github.com/zeta-chain/zetacore/zetaclient/chains/base" "github.com/zeta-chain/zetacore/zetaclient/chains/evm" "github.com/zeta-chain/zetacore/zetaclient/chains/evm/observer" @@ -48,7 +47,7 @@ const ( ) var ( - _ interfaces.ChainSigner = &Signer{} + _ interfaces.ChainSigner = (*Signer)(nil) // zeroValue is for outbounds that carry no ETH (gas token) value zeroValue = big.NewInt(0) @@ -75,9 +74,6 @@ type Signer struct { // er20CustodyAddress is the address of the ERC20Custody contract er20CustodyAddress ethcommon.Address - - // outboundHashBeingReported is a map of outboundHash being reported - outboundHashBeingReported map[string]bool } // NewSigner creates a new EVM signer @@ -114,14 +110,13 @@ func NewSigner( } return &Signer{ - Signer: baseSigner, - client: client, - ethSigner: ethSigner, - zetaConnectorABI: connectorABI, - erc20CustodyABI: custodyABI, - zetaConnectorAddress: zetaConnectorAddress, - er20CustodyAddress: erc20CustodyAddress, - outboundHashBeingReported: make(map[string]bool), + Signer: baseSigner, + client: client, + ethSigner: ethSigner, + zetaConnectorABI: connectorABI, + erc20CustodyABI: custodyABI, + zetaConnectorAddress: zetaConnectorAddress, + er20CustodyAddress: erc20CustodyAddress, }, nil } @@ -139,6 +134,12 @@ func (signer *Signer) SetERC20CustodyAddress(addr ethcommon.Address) { signer.er20CustodyAddress = addr } +// SetGatewayAddress sets the gateway address +func (signer *Signer) SetGatewayAddress(_ string) { + // Note: do nothing for now + // gateway address will be needed in the future contract architecture +} + // GetZetaConnectorAddress returns the zeta connector address func (signer *Signer) GetZetaConnectorAddress() ethcommon.Address { signer.Lock() @@ -153,6 +154,13 @@ func (signer *Signer) GetERC20CustodyAddress() ethcommon.Address { return signer.er20CustodyAddress } +// GetGatewayAddress returns the gateway address +func (signer *Signer) GetGatewayAddress() string { + // Note: return empty string for now + // gateway address will be needed in the future contract architecture + return "" +} + // Sign given data, and metadata (gas, nonce, etc) // returns a signed transaction, sig bytes, hash bytes, and error func (signer *Signer) Sign( @@ -354,28 +362,26 @@ func (signer *Signer) TryProcessOutbound( zetacoreClient interfaces.ZetacoreClient, height uint64, ) { - app, err := zctx.FromContext(ctx) - if err != nil { - signer.Logger().Std.Error().Err(err).Msg("error getting app context") - return - } + // end outbound process on panic + defer func() { + outboundProc.EndTryProcess(outboundID) + if err := recover(); err != nil { + signer.Logger().Std.Error().Msgf("EVM TryProcessOutbound: %s, caught panic error: %v", cctx.Index, err) + } + }() + // prepare logger + params := cctx.GetCurrentOutboundParam() logger := signer.Logger().Std.With(). - Str("outboundID", outboundID). - Str("SendHash", cctx.Index). + Str("method", "TryProcessOutbound"). + Int64("chain", signer.Chain().ChainId). + Uint64("nonce", params.TssNonce). + Str("cctx", cctx.Index). Logger() - logger.Info().Msgf("start processing outboundID %s", outboundID) - logger.Info().Msgf( - "EVM Chain TryProcessOutbound: %s, value %d to %s", - cctx.Index, - cctx.GetCurrentOutboundParam().Amount.BigInt(), - cctx.GetCurrentOutboundParam().Receiver, - ) - defer func() { - outboundProc.EndTryProcess(outboundID) - }() myID := zetacoreClient.GetKeys().GetOperatorAddress() + logger.Info(). + Msgf("EVM TryProcessOutbound: %s, value %d to %s", cctx.Index, params.Amount.BigInt(), params.Receiver) evmObserver, ok := chainObserver.(*observer.Observer) if !ok { @@ -393,14 +399,6 @@ func (signer *Signer) TryProcessOutbound( return } - toChain, found := chains.GetChainFromChainID(txData.toChainID.Int64(), app.GetAdditionalChains()) - if !found { - logger.Warn().Msgf("unknown chain: %d", txData.toChainID.Int64()) - return - } - - // Get cross-chain flags - crossChainflags := app.GetCrossChainFlags() // https://github.com/zeta-chain/node/issues/2050 var tx *ethtypes.Transaction // compliance check goes first @@ -443,31 +441,31 @@ func (signer *Signer) TryProcessOutbound( logger.Warn().Err(err).Msg(ErrorMsg(cctx)) return } - } else if IsSenderZetaChain(cctx, zetacoreClient, &crossChainflags) { + } else if IsSenderZetaChain(cctx, zetacoreClient) { switch cctx.InboundParams.CoinType { case coin.CoinType_Gas: logger.Info().Msgf( - "SignWithdrawTx: %d => %s, nonce %d, gasPrice %d", + "SignWithdrawTx: %d => %d, nonce %d, gasPrice %d", cctx.InboundParams.SenderChainId, - toChain.String(), + txData.toChainID.Int64(), cctx.GetCurrentOutboundParam().TssNonce, txData.gasPrice, ) tx, err = signer.SignWithdrawTx(ctx, txData) case coin.CoinType_ERC20: logger.Info().Msgf( - "SignERC20WithdrawTx: %d => %s, nonce %d, gasPrice %d", + "SignERC20WithdrawTx: %d => %d, nonce %d, gasPrice %d", cctx.InboundParams.SenderChainId, - toChain.String(), + txData.toChainID.Int64(), cctx.GetCurrentOutboundParam().TssNonce, txData.gasPrice, ) tx, err = signer.SignERC20WithdrawTx(ctx, txData) case coin.CoinType_Zeta: logger.Info().Msgf( - "SignOutbound: %d => %s, nonce %d, gasPrice %d", + "SignOutbound: %d => %d, nonce %d, gasPrice %d", cctx.InboundParams.SenderChainId, - toChain.String(), + txData.toChainID.Int64(), cctx.GetCurrentOutboundParam().TssNonce, txData.gasPrice, ) @@ -481,9 +479,9 @@ func (signer *Signer) TryProcessOutbound( switch cctx.InboundParams.CoinType { case coin.CoinType_Zeta: logger.Info().Msgf( - "SignRevertTx: %d => %s, nonce %d, gasPrice %d", + "SignRevertTx: %d => %d, nonce %d, gasPrice %d", cctx.InboundParams.SenderChainId, - toChain.String(), cctx.GetCurrentOutboundParam().TssNonce, + txData.toChainID.Int64(), cctx.GetCurrentOutboundParam().TssNonce, txData.gasPrice, ) txData.srcChainID = big.NewInt(cctx.OutboundParams[0].ReceiverChainId) @@ -491,17 +489,17 @@ func (signer *Signer) TryProcessOutbound( tx, err = signer.SignRevertTx(ctx, txData) case coin.CoinType_Gas: logger.Info().Msgf( - "SignWithdrawTx: %d => %s, nonce %d, gasPrice %d", + "SignWithdrawTx: %d => %d, nonce %d, gasPrice %d", cctx.InboundParams.SenderChainId, - toChain.String(), + txData.toChainID.Int64(), cctx.GetCurrentOutboundParam().TssNonce, txData.gasPrice, ) tx, err = signer.SignWithdrawTx(ctx, txData) case coin.CoinType_ERC20: - logger.Info().Msgf("SignERC20WithdrawTx: %d => %s, nonce %d, gasPrice %d", + logger.Info().Msgf("SignERC20WithdrawTx: %d => %d, nonce %d, gasPrice %d", cctx.InboundParams.SenderChainId, - toChain.String(), + txData.toChainID.Int64(), cctx.GetCurrentOutboundParam().TssNonce, txData.gasPrice, ) @@ -513,9 +511,9 @@ func (signer *Signer) TryProcessOutbound( } } else if cctx.CctxStatus.Status == types.CctxStatus_PendingRevert { logger.Info().Msgf( - "SignRevertTx: %d => %s, nonce %d, gasPrice %d", + "SignRevertTx: %d => %d, nonce %d, gasPrice %d", cctx.InboundParams.SenderChainId, - toChain.String(), + txData.toChainID.Int64(), cctx.GetCurrentOutboundParam().TssNonce, txData.gasPrice, ) @@ -529,9 +527,9 @@ func (signer *Signer) TryProcessOutbound( } } else if cctx.CctxStatus.Status == types.CctxStatus_PendingOutbound { logger.Info().Msgf( - "SignOutbound: %d => %s, nonce %d, gasPrice %d", + "SignOutbound: %d => %d, nonce %d, gasPrice %d", cctx.InboundParams.SenderChainId, - toChain.String(), + txData.toChainID.Int64(), cctx.GetCurrentOutboundParam().TssNonce, txData.gasPrice, ) @@ -543,9 +541,9 @@ func (signer *Signer) TryProcessOutbound( } logger.Info().Msgf( - "Key-sign success: %d => %s, nonce %d", + "Key-sign success: %d => %d, nonce %d", cctx.InboundParams.SenderChainId, - toChain.String(), + txData.toChainID.Int64(), cctx.GetCurrentOutboundParam().TssNonce, ) @@ -647,15 +645,6 @@ func (signer *Signer) SignERC20WithdrawTx(ctx context.Context, txData *OutboundD return tx, nil } -// Exported for unit tests - -// GetReportedTxList returns a list of outboundHash being reported -// TODO: investigate pointer usage -// https://github.com/zeta-chain/node/issues/2084 -func (signer *Signer) GetReportedTxList() *map[string]bool { - return &signer.outboundHashBeingReported -} - // EvmClient returns the EVM RPC client func (signer *Signer) EvmClient() interfaces.EVMRPCClient { return signer.client @@ -672,10 +661,9 @@ func (signer *Signer) EvmSigner() ethtypes.Signer { func IsSenderZetaChain( cctx *types.CrossChainTx, zetacoreClient interfaces.ZetacoreClient, - flags *observertypes.CrosschainFlags, ) bool { return cctx.InboundParams.SenderChainId == zetacoreClient.Chain().ChainId && - cctx.CctxStatus.Status == types.CctxStatus_PendingOutbound && flags.IsOutboundEnabled + cctx.CctxStatus.Status == types.CctxStatus_PendingOutbound } // ErrorMsg returns a error message for SignOutbound failure with cctx data @@ -752,22 +740,18 @@ func (signer *Signer) reportToOutboundTracker( outboundHash string, logger zerolog.Logger, ) { - // skip if already being reported - signer.Lock() - defer signer.Unlock() - if _, found := signer.outboundHashBeingReported[outboundHash]; found { + // set being reported flag to avoid duplicate reporting + alreadySet := signer.Signer.SetBeingReportedFlag(outboundHash) + if alreadySet { logger.Info(). Msgf("reportToOutboundTracker: outboundHash %s for chain %d nonce %d is being reported", outboundHash, chainID, nonce) return } - signer.outboundHashBeingReported[outboundHash] = true // mark as being reported // report to outbound tracker with goroutine go func() { defer func() { - signer.Lock() - delete(signer.outboundHashBeingReported, outboundHash) - signer.Unlock() + signer.Signer.ClearBeingReportedFlag(outboundHash) }() // try monitoring tx inclusion status for 10 minutes diff --git a/zetaclient/chains/interfaces/interfaces.go b/zetaclient/chains/interfaces/interfaces.go index 82125ca619..4ca93b7259 100644 --- a/zetaclient/chains/interfaces/interfaces.go +++ b/zetaclient/chains/interfaces/interfaces.go @@ -41,14 +41,12 @@ const ( type ChainObserver interface { Start(ctx context.Context) Stop() - IsOutboundProcessed( + VoteOutboundIfConfirmed( ctx context.Context, cctx *crosschaintypes.CrossChainTx, - logger zerolog.Logger, - ) (bool, bool, error) + ) (bool, error) SetChainParams(observertypes.ChainParams) GetChainParams() observertypes.ChainParams - GetTxID(nonce uint64) string WatchInboundTracker(ctx context.Context) error } @@ -67,6 +65,8 @@ type ChainSigner interface { SetERC20CustodyAddress(address ethcommon.Address) GetZetaConnectorAddress() ethcommon.Address GetERC20CustodyAddress() ethcommon.Address + SetGatewayAddress(address string) + GetGatewayAddress() string } // ZetacoreVoter represents voter interface. @@ -189,19 +189,35 @@ type EVMRPCClient interface { // SolanaRPCClient is the interface for Solana RPC client type SolanaRPCClient interface { - GetVersion(ctx context.Context) (out *solrpc.GetVersionResult, err error) - GetHealth(ctx context.Context) (out string, err error) - GetAccountInfo(ctx context.Context, account solana.PublicKey) (out *solrpc.GetAccountInfoResult, err error) + GetVersion(ctx context.Context) (*solrpc.GetVersionResult, error) + GetHealth(ctx context.Context) (string, error) + GetSlot(ctx context.Context, commitment solrpc.CommitmentType) (uint64, error) + GetAccountInfo(ctx context.Context, account solana.PublicKey) (*solrpc.GetAccountInfoResult, error) + GetRecentBlockhash(ctx context.Context, commitment solrpc.CommitmentType) (*solrpc.GetRecentBlockhashResult, error) + GetRecentPrioritizationFees( + ctx context.Context, + accounts solana.PublicKeySlice, + ) ([]solrpc.PriorizationFeeResult, error) GetTransaction( ctx context.Context, txSig solana.Signature, // transaction signature opts *solrpc.GetTransactionOpts, - ) (out *solrpc.GetTransactionResult, err error) + ) (*solrpc.GetTransactionResult, error) + GetConfirmedTransactionWithOpts( + ctx context.Context, + signature solana.Signature, + opts *solrpc.GetTransactionOpts, + ) (*solrpc.TransactionWithMeta, error) GetSignaturesForAddressWithOpts( ctx context.Context, account solana.PublicKey, opts *solrpc.GetSignaturesForAddressOpts, - ) (out []*solrpc.TransactionSignature, err error) + ) ([]*solrpc.TransactionSignature, error) + SendTransactionWithOpts( + ctx context.Context, + transaction *solana.Transaction, + opts solrpc.TransactionOpts, + ) (solana.Signature, error) } // EVMJSONRPCClient is the interface for EVM JSON RPC client diff --git a/zetaclient/chains/solana/observer/inbound.go b/zetaclient/chains/solana/observer/inbound.go index 4a819ce9cb..7c14ec34c6 100644 --- a/zetaclient/chains/solana/observer/inbound.go +++ b/zetaclient/chains/solana/observer/inbound.go @@ -15,7 +15,7 @@ import ( "github.com/zeta-chain/zetacore/pkg/coin" "github.com/zeta-chain/zetacore/pkg/constant" - solanacontract "github.com/zeta-chain/zetacore/pkg/contract/solana" + solanacontracts "github.com/zeta-chain/zetacore/pkg/contracts/solana" crosschaintypes "github.com/zeta-chain/zetacore/x/crosschain/types" solanarpc "github.com/zeta-chain/zetacore/zetaclient/chains/solana/rpc" "github.com/zeta-chain/zetacore/zetaclient/compliance" @@ -274,14 +274,14 @@ func (ob *Observer) ParseInboundAsDeposit( instruction := tx.Message.Instructions[instructionIndex] // try deserializing instruction as a 'deposit' - var inst solanacontract.DepositInstructionParams + var inst solanacontracts.DepositInstructionParams err := borsh.Deserialize(&inst, instruction.Data) if err != nil { return nil, nil } // check if the instruction is a deposit or not - if inst.Discriminator != solanacontract.DiscriminatorDeposit() { + if inst.Discriminator != solanacontracts.DiscriminatorDeposit() { return nil, nil } @@ -324,8 +324,8 @@ func (ob *Observer) ParseInboundAsDepositSPL( // Note: solana-go is not able to parse the AccountMeta 'is_signer' ATM. This is a workaround. func (ob *Observer) GetSignerDeposit(tx *solana.Transaction, inst *solana.CompiledInstruction) (string, error) { // there should be 4 accounts for a deposit instruction - if len(inst.Accounts) != solanacontract.AccountsNumDeposit { - return "", fmt.Errorf("want %d accounts, got %d", solanacontract.AccountsNumDeposit, len(inst.Accounts)) + if len(inst.Accounts) != solanacontracts.AccountsNumDeposit { + return "", fmt.Errorf("want %d accounts, got %d", solanacontracts.AccountsNumDeposit, len(inst.Accounts)) } // the accounts are [signer, pda, system_program, gateway_program] diff --git a/zetaclient/chains/solana/observer/inbound_test.go b/zetaclient/chains/solana/observer/inbound_test.go index a7090d6821..40f53ce0bc 100644 --- a/zetaclient/chains/solana/observer/inbound_test.go +++ b/zetaclient/chains/solana/observer/inbound_test.go @@ -36,7 +36,7 @@ func Test_FilterInboundEventAndVote(t *testing.T) { // create observer chainParams := sample.ChainParams(chain.ChainId) - chainParams.GatewayAddress = "2kJndCL9NBR36ySiQ4bmArs4YgWQu67LmCDfLzk5Gb7s" + chainParams.GatewayAddress = GatewayAddressTest zetacoreClient := mocks.NewZetacoreClient(t) zetacoreClient.WithKeys(&keys.Keys{}).WithZetaChain().WithPostVoteInbound("", "") @@ -61,7 +61,7 @@ func Test_FilterInboundEvents(t *testing.T) { // create observer chainParams := sample.ChainParams(chain.ChainId) - chainParams.GatewayAddress = "2kJndCL9NBR36ySiQ4bmArs4YgWQu67LmCDfLzk5Gb7s" + chainParams.GatewayAddress = GatewayAddressTest ob, err := observer.NewObserver(chain, nil, *chainParams, nil, nil, database, base.DefaultLogger(), nil) require.NoError(t, err) @@ -169,7 +169,7 @@ func Test_ParseInboundAsDeposit(t *testing.T) { // create observer chainParams := sample.ChainParams(chain.ChainId) - chainParams.GatewayAddress = "2kJndCL9NBR36ySiQ4bmArs4YgWQu67LmCDfLzk5Gb7s" + chainParams.GatewayAddress = GatewayAddressTest ob, err := observer.NewObserver(chain, nil, *chainParams, nil, nil, database, base.DefaultLogger(), nil) require.NoError(t, err) diff --git a/zetaclient/chains/solana/observer/observer.go b/zetaclient/chains/solana/observer/observer.go index 1cdb3f6768..ad135aeecf 100644 --- a/zetaclient/chains/solana/observer/observer.go +++ b/zetaclient/chains/solana/observer/observer.go @@ -4,11 +4,12 @@ import ( "context" "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/rpc" "github.com/pkg/errors" "github.com/zeta-chain/zetacore/pkg/bg" "github.com/zeta-chain/zetacore/pkg/chains" - solanacontract "github.com/zeta-chain/zetacore/pkg/contract/solana" + contracts "github.com/zeta-chain/zetacore/pkg/contracts/solana" observertypes "github.com/zeta-chain/zetacore/x/observer/types" "github.com/zeta-chain/zetacore/zetaclient/chains/base" "github.com/zeta-chain/zetacore/zetaclient/chains/interfaces" @@ -31,6 +32,9 @@ type Observer struct { // pda is the program derived address of the gateway program pda solana.PublicKey + + // finalizedTxResults indexes tx results with the outbound hash + finalizedTxResults map[string]*rpc.GetTransactionResult } // NewObserver returns a new Solana chain observer @@ -60,28 +64,24 @@ func NewObserver( return nil, err } - pubKey, err := solana.PublicKeyFromBase58(chainParams.GatewayAddress) + // parse gateway ID and PDA + gatewayID, pda, err := contracts.ParseGatewayIDAndPda(chainParams.GatewayAddress) if err != nil { - return nil, errors.Wrap(err, "unable to derive public key") + return nil, errors.Wrapf(err, "cannot parse gateway address %s", chainParams.GatewayAddress) } // create solana observer - ob := Observer{ - Observer: *baseObserver, - solClient: solClient, - gatewayID: pubKey, - } - - // compute gateway PDA - seed := []byte(solanacontract.PDASeed) - ob.pda, _, err = solana.FindProgramAddress([][]byte{seed}, ob.gatewayID) - if err != nil { - return nil, err + ob := &Observer{ + Observer: *baseObserver, + solClient: solClient, + gatewayID: gatewayID, + pda: pda, + finalizedTxResults: make(map[string]*rpc.GetTransactionResult), } ob.Observer.LoadLastTxScanned() - return &ob, nil + return ob, nil } // SolClient returns the solana rpc client @@ -122,6 +122,12 @@ func (ob *Observer) Start(ctx context.Context) { // watch Solana chain for incoming txs and post votes to zetacore bg.Work(ctx, ob.WatchInbound, bg.WithName("WatchInbound"), bg.WithLogger(ob.Logger().Inbound)) + // watch Solana chain for outbound trackers + bg.Work(ctx, ob.WatchOutbound, bg.WithName("WatchOutbound"), bg.WithLogger(ob.Logger().Outbound)) + + // watch Solana chain for fee rate and post to zetacore + bg.Work(ctx, ob.WatchGasPrice, bg.WithName("WatchGasPrice"), bg.WithLogger(ob.Logger().GasPrice)) + // watch zetacore for Solana inbound trackers bg.Work(ctx, ob.WatchInboundTracker, bg.WithName("WatchInboundTracker"), bg.WithLogger(ob.Logger().Inbound)) } @@ -133,3 +139,24 @@ func (ob *Observer) LoadLastTxScanned() error { return nil } + +// SetTxResult sets the tx result for the given nonce +func (ob *Observer) SetTxResult(nonce uint64, result *rpc.GetTransactionResult) { + ob.Mu().Lock() + defer ob.Mu().Unlock() + ob.finalizedTxResults[ob.OutboundID(nonce)] = result +} + +// GetTxResult returns the tx result for the given nonce +func (ob *Observer) GetTxResult(nonce uint64) *rpc.GetTransactionResult { + ob.Mu().Lock() + defer ob.Mu().Unlock() + return ob.finalizedTxResults[ob.OutboundID(nonce)] +} + +// IsTxFinalized returns true if there is a finalized tx for nonce +func (ob *Observer) IsTxFinalized(nonce uint64) bool { + ob.Mu().Lock() + defer ob.Mu().Unlock() + return ob.finalizedTxResults[ob.OutboundID(nonce)] != nil +} diff --git a/zetaclient/chains/solana/observer/observer_gas.go b/zetaclient/chains/solana/observer/observer_gas.go new file mode 100644 index 0000000000..a323da16ec --- /dev/null +++ b/zetaclient/chains/solana/observer/observer_gas.go @@ -0,0 +1,103 @@ +package observer + +import ( + "context" + "fmt" + + "github.com/gagliardetto/solana-go/rpc" + "github.com/pkg/errors" + + zetamath "github.com/zeta-chain/zetacore/pkg/math" + clienttypes "github.com/zeta-chain/zetacore/zetaclient/types" +) + +const ( + // SolanaTransactionFee is the static fee per transaction, 5k lamports. + SolanaTransactionFee = 5000 + + // MicroLamportsPerLamport is the number of micro lamports in a lamport. + MicroLamportsPerLamport = 1_000_000 + + // SolanaDefaultComputeBudget is the default compute budget for a transaction. + SolanaDefaultComputeBudget = 200_000 + + // Solana uses micro lamports (0.000001 lamports) as the smallest unit of gas price. + // The gas fee formula 'gasFee = gasPrice * gasLimit' won't fit Solana in the ZRC20 SOL contract. + // We could use lamports as the unit of gas price and 10K CU as the smallest unit of compute units. + // SolanaDefaultGasPrice10KCUs is the default gas price (in lamports) per 10K compute units. + SolanaDefaultGasPrice10KCUs = 100 + + // SolanaDefaultGasLimit is the default compute units (in 10K CU) for a transaction. + SolanaDefaultGasLimit10KCU = 50 +) + +// WatchGasPrice watches the gas price of the chain and posts it to the zetacore +func (ob *Observer) WatchGasPrice(ctx context.Context) error { + // report gas price right away as the ticker takes time to kick in + err := ob.PostGasPrice(ctx) + if err != nil { + ob.Logger().GasPrice.Error().Err(err).Msgf("PostGasPrice error for chain %d", ob.Chain().ChainId) + } + + // start gas price ticker + ticker, err := clienttypes.NewDynamicTicker( + fmt.Sprintf("Solana_WatchGasPrice_%d", ob.Chain().ChainId), + ob.GetChainParams().GasPriceTicker, + ) + if err != nil { + return errors.Wrapf(err, "NewDynamicTicker error") + } + ob.Logger().GasPrice.Info().Msgf("WatchGasPrice started for chain %d with interval %d", + ob.Chain().ChainId, ob.GetChainParams().GasPriceTicker) + defer ticker.Stop() + + for { + select { + case <-ticker.C(): + if !ob.GetChainParams().IsSupported { + continue + } + err = ob.PostGasPrice(ctx) + if err != nil { + ob.Logger().GasPrice.Error().Err(err).Msgf("PostGasPrice error for chain %d", ob.Chain().ChainId) + } + ticker.UpdateInterval(ob.GetChainParams().GasPriceTicker, ob.Logger().GasPrice) + case <-ob.StopChannel(): + ob.Logger().GasPrice.Info().Msgf("WatchGasPrice stopped for chain %d", ob.Chain().ChainId) + return nil + } + } +} + +// PostGasPrice posts gas price to zetacore +func (ob *Observer) PostGasPrice(ctx context.Context) error { + // get current slot + slot, err := ob.solClient.GetSlot(ctx, rpc.CommitmentConfirmed) + if err != nil { + return errors.Wrap(err, "GetSlot error") + } + + // query recent priority fees + recentFees, err := ob.solClient.GetRecentPrioritizationFees(ctx, nil) + if err != nil { + return errors.Wrap(err, "GetRecentPrioritizationFees error") + } + + // locate median priority fee + priorityFees := make([]uint64, len(recentFees)) + for i, fee := range recentFees { + if fee.PrioritizationFee > 0 { + priorityFees[i] = fee.PrioritizationFee + } + } + // the priority fee is in increments of 0.000001 lamports (micro lamports) + medianFee := zetamath.SliceMedianValue(priorityFees, true) + + // there is no Ethereum-like gas price in Solana, we only post priority fee for now + _, err = ob.ZetacoreClient().PostVoteGasPrice(ctx, ob.Chain(), 1, medianFee, slot) + if err != nil { + return errors.Wrapf(err, "PostVoteGasPrice error for chain %d", ob.Chain().ChainId) + } + + return nil +} diff --git a/zetaclient/chains/solana/observer/outbound.go b/zetaclient/chains/solana/observer/outbound.go index a75641e072..59d93d2efb 100644 --- a/zetaclient/chains/solana/observer/outbound.go +++ b/zetaclient/chains/solana/observer/outbound.go @@ -2,22 +2,349 @@ package observer import ( "context" + "fmt" + "math/big" + "cosmossdk.io/math" + "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/rpc" + "github.com/pkg/errors" "github.com/rs/zerolog" - "github.com/zeta-chain/zetacore/x/crosschain/types" + "github.com/zeta-chain/zetacore/pkg/chains" + "github.com/zeta-chain/zetacore/pkg/coin" + contracts "github.com/zeta-chain/zetacore/pkg/contracts/solana" + crosschaintypes "github.com/zeta-chain/zetacore/x/crosschain/types" + "github.com/zeta-chain/zetacore/zetaclient/chains/interfaces" + zctx "github.com/zeta-chain/zetacore/zetaclient/context" + clienttypes "github.com/zeta-chain/zetacore/zetaclient/types" + "github.com/zeta-chain/zetacore/zetaclient/zetacore" ) -// GetTxID returns a unique id for Solana outbound -func (ob *Observer) GetTxID(_ uint64) string { - return "" +// WatchOutbound watches solana chain for outgoing txs status +// TODO(revamp): move ticker function to ticker file +func (ob *Observer) WatchOutbound(ctx context.Context) error { + // get app context + app, err := zctx.FromContext(ctx) + if err != nil { + return err + } + + // create outbound ticker based on chain params + chainID := ob.Chain().ChainId + ticker, err := clienttypes.NewDynamicTicker( + fmt.Sprintf("Solana_WatchOutbound_%d", chainID), + ob.GetChainParams().OutboundTicker, + ) + if err != nil { + ob.Logger().Outbound.Error().Err(err).Msg("error creating ticker") + return err + } + + ob.Logger().Outbound.Info().Msgf("WatchOutbound started for chain %d", chainID) + sampledLogger := ob.Logger().Outbound.Sample(&zerolog.BasicSampler{N: 10}) + defer ticker.Stop() + for { + select { + case <-ticker.C(): + if !app.IsOutboundObservationEnabled(ob.GetChainParams()) { + sampledLogger.Info().Msgf("WatchOutbound: outbound observation is disabled for chain %d", chainID) + continue + } + + // process outbound trackers + err := ob.ProcessOutboundTrackers(ctx) + if err != nil { + ob.Logger(). + Outbound.Error(). + Err(err). + Msgf("WatchOutbound: error ProcessOutboundTrackers for chain %d", chainID) + } + + ticker.UpdateInterval(ob.GetChainParams().OutboundTicker, ob.Logger().Outbound) + case <-ob.StopChannel(): + ob.Logger().Outbound.Info().Msgf("WatchOutbound: watcher stopped for chain %d", chainID) + return nil + } + } +} + +// ProcessOutboundTrackers processes Solana outbound trackers +func (ob *Observer) ProcessOutboundTrackers(ctx context.Context) error { + chainID := ob.Chain().ChainId + trackers, err := ob.ZetacoreClient().GetAllOutboundTrackerByChain(ctx, chainID, interfaces.Ascending) + if err != nil { + return errors.Wrap(err, "GetAllOutboundTrackerByChain error") + } + + // prepare logger fields + logger := ob.Logger().Outbound.With(). + Str("method", "ProcessOutboundTrackers"). + Int64("chain", chainID). + Logger() + + // process outbound trackers + for _, tracker := range trackers { + // go to next tracker if this one already has a finalized tx + nonce := tracker.Nonce + if ob.IsTxFinalized(tracker.Nonce) { + continue + } + + // get original cctx parameters + cctx, err := ob.ZetacoreClient().GetCctxByNonce(ctx, chainID, tracker.Nonce) + if err != nil { + // take a rest if zetacore RPC breaks + return errors.Wrapf(err, "GetCctxByNonce error for chain %d nonce %d", chainID, tracker.Nonce) + } + coinType := cctx.InboundParams.CoinType + + // check each txHash and save its txResult if it's finalized and legit + txCount := 0 + var txResult *rpc.GetTransactionResult + for _, txHash := range tracker.HashList { + if result, ok := ob.CheckFinalizedTx(ctx, txHash.TxHash, nonce, coinType); ok { + txCount++ + txResult = result + logger.Info().Msgf("confirmed outbound %s for chain %d nonce %d", txHash.TxHash, chainID, nonce) + if txCount > 1 { + logger.Error(). + Msgf("checkFinalizedTx passed, txCount %d chain %d nonce %d txResult %v", txCount, chainID, nonce, txResult) + } + } + } + // should be only one finalized txHash for each nonce + if txCount == 1 { + ob.SetTxResult(nonce, txResult) + } else if txCount > 1 { + // should not happen. We can't tell which txHash is true. It might happen (e.g. bug, glitchy/hacked endpoint) + ob.Logger().Outbound.Error().Msgf("finalized multiple (%d) outbound for chain %d nonce %d", txCount, chainID, nonce) + } + } + + return nil +} + +// VoteOutboundIfConfirmed checks outbound status and returns (continueKeysign, error) +func (ob *Observer) VoteOutboundIfConfirmed(ctx context.Context, cctx *crosschaintypes.CrossChainTx) (bool, error) { + // get outbound params + params := cctx.GetCurrentOutboundParam() + nonce := params.TssNonce + coinType := cctx.InboundParams.CoinType + + // early return if outbound is not finalized yet + txResult := ob.GetTxResult(nonce) + if txResult == nil { + return true, nil + } + + // extract tx signature from tx result + tx, err := txResult.Transaction.GetTransaction() + if err != nil { + // should not happen + return false, errors.Wrapf(err, "GetTransaction error for nonce %d", nonce) + } + txSig := tx.Signatures[0] + + // parse gateway instruction from tx result + inst, err := ParseGatewayInstruction(txResult, ob.gatewayID, coinType) + if err != nil { + // should never happen as it was already successfully parsed in CheckFinalizedTx + return false, errors.Wrapf(err, "ParseGatewayInstruction error for sig %s", txSig) + } + + // the amount and status of the outbound + outboundAmount := new(big.Int).SetUint64(inst.TokenAmount()) + // status was already verified as successful in CheckFinalizedTx + outboundStatus := chains.ReceiveStatus_success + + // post vote to zetacore + ob.PostVoteOutbound(ctx, cctx.Index, txSig.String(), txResult, outboundAmount, outboundStatus, nonce, coinType) + return false, nil +} + +// PostVoteOutbound posts vote to zetacore for the finalized outbound +func (ob *Observer) PostVoteOutbound( + ctx context.Context, + cctxIndex string, + outboundHash string, + txResult *rpc.GetTransactionResult, + valueReceived *big.Int, + status chains.ReceiveStatus, + nonce uint64, + coinType coin.CoinType, +) { + // create outbound vote message + msg := ob.CreateMsgVoteOutbound(cctxIndex, outboundHash, txResult, valueReceived, status, nonce, coinType) + + // prepare logger fields + logFields := map[string]any{ + "chain": ob.Chain().ChainId, + "nonce": nonce, + "tx": outboundHash, + } + + // so we set retryGasLimit to 0 because the solana gateway withdrawal will always succeed + // and the vote msg won't trigger ZEVM interaction + const ( + gasLimit = zetacore.PostVoteOutboundGasLimit + retryGasLimit = 0 + ) + + // post vote to zetacore + zetaTxHash, ballot, err := ob.ZetacoreClient().PostVoteOutbound(ctx, gasLimit, retryGasLimit, msg) + if err != nil { + ob.Logger().Outbound.Error().Err(err).Fields(logFields).Msg("PostVoteOutbound: error posting outbound vote") + return + } + + // print vote tx hash and ballot + if zetaTxHash != "" { + logFields["vote"] = zetaTxHash + logFields["ballot"] = ballot + ob.Logger().Outbound.Info().Fields(logFields).Msg("PostVoteOutbound: posted outbound vote successfully") + } } -// IsOutboundProcessed checks outbound status and returns (isIncluded, isConfirmed, error) -func (ob *Observer) IsOutboundProcessed( - _ context.Context, - _ *types.CrossChainTx, - _ zerolog.Logger, -) (bool, bool, error) { - return false, false, nil +// CreateMsgVoteOutbound creates a vote outbound message for Solana chain +func (ob *Observer) CreateMsgVoteOutbound( + cctxIndex string, + outboundHash string, + txResult *rpc.GetTransactionResult, + valueReceived *big.Int, + status chains.ReceiveStatus, + nonce uint64, + coinType coin.CoinType, +) *crosschaintypes.MsgVoteOutbound { + const ( + // Solana implements a different gas fee model than Ethereum, below values are not used. + // Solana tx fee is based on both static fee and dynamic fee (priority fee), setting + // zero values to by pass incorrectly funded gas stability pool. + outboundGasUsed = 0 + outboundGasPrice = 0 + outboundGasLimit = 0 + ) + + creator := ob.ZetacoreClient().GetKeys().GetOperatorAddress() + + return crosschaintypes.NewMsgVoteOutbound( + creator.String(), + cctxIndex, + outboundHash, + txResult.Slot, // instead of using block, Solana explorer uses slot for indexing + outboundGasUsed, + math.NewInt(outboundGasPrice), + outboundGasLimit, + math.NewUintFromBigInt(valueReceived), + status, + ob.Chain().ChainId, + nonce, + coinType, + ) +} + +// CheckFinalizedTx checks if a txHash is finalized for given nonce and coinType +// returns (tx result, true) if finalized or (nil, false) otherwise +func (ob *Observer) CheckFinalizedTx( + ctx context.Context, + txHash string, + nonce uint64, + coinType coin.CoinType, +) (*rpc.GetTransactionResult, bool) { + // prepare logger fields + chainID := ob.Chain().ChainId + logger := ob.Logger().Outbound.With(). + Str("method", "checkFinalizedTx"). + Int64("chain", chainID). + Uint64("nonce", nonce). + Str("tx", txHash).Logger() + + // convert txHash to signature + sig, err := solana.SignatureFromBase58(txHash) + if err != nil { + logger.Error().Err(err).Msgf("SignatureFromBase58 err for chain %d nonce %d", chainID, nonce) + return nil, false + } + + // query transaction using "finalized" commitment to avoid re-org + txResult, err := ob.solClient.GetTransaction(ctx, sig, &rpc.GetTransactionOpts{ + Commitment: rpc.CommitmentFinalized, + }) + if err != nil { + logger.Error().Err(err).Msgf("GetTransaction err for chain %d nonce %d", chainID, nonce) + return nil, false + } + + // the tx must be successful in order to effectively increment the nonce + if txResult.Meta.Err != nil { + logger.Error().Any("Err", txResult.Meta.Err).Msgf("tx is not successful for chain %d nonce %d", chainID, nonce) + return nil, false + } + + // parse gateway instruction from tx result + inst, err := ParseGatewayInstruction(txResult, ob.gatewayID, coinType) + if err != nil { + logger.Error().Err(err).Msgf("ParseGatewayInstruction err for chain %d nonce %d", chainID, nonce) + return nil, false + } + txNonce := inst.GatewayNonce() + + // recover ECDSA signer from instruction + signerECDSA, err := inst.Signer() + if err != nil { + logger.Error().Err(err).Msgf("cannot get instruction signer for chain %d nonce %d", chainID, nonce) + return nil, false + } + + // check tx authorization + if signerECDSA != ob.TSS().EVMAddress() { + logger.Error().Msgf("tx signer %s is not matching TSS, chain %d nonce %d", signerECDSA, chainID, nonce) + return nil, false + } + + // check tx nonce + if txNonce != nonce { + logger.Error().Msgf("tx nonce %d is not matching cctx, chain %d nonce %d", txNonce, chainID, nonce) + return nil, false + } + + return txResult, true +} + +// ParseGatewayInstruction parses the outbound instruction from tx result +func ParseGatewayInstruction( + txResult *rpc.GetTransactionResult, + gatewayID solana.PublicKey, + coinType coin.CoinType, +) (contracts.OutboundInstruction, error) { + // unmarshal transaction + tx, err := txResult.Transaction.GetTransaction() + if err != nil { + return nil, errors.Wrap(err, "error unmarshaling transaction") + } + + // there should be only one single instruction ('withdraw' or 'withdraw_spl_token') + if len(tx.Message.Instructions) != 1 { + return nil, fmt.Errorf("want 1 instruction, got %d", len(tx.Message.Instructions)) + } + instruction := tx.Message.Instructions[0] + + // get the program ID + programID, err := tx.Message.Program(instruction.ProgramIDIndex) + if err != nil { + return nil, errors.Wrap(err, "error getting program ID") + } + + // the instruction should be an invocation of the gateway program + if !programID.Equals(gatewayID) { + return nil, fmt.Errorf("programID %s is not matching gatewayID %s", programID, gatewayID) + } + + // parse the instruction as a 'withdraw' or 'withdraw_spl_token' + switch coinType { + case coin.CoinType_Gas: + return contracts.ParseInstructionWithdraw(instruction) + default: + return nil, fmt.Errorf("unsupported outbound coin type %s", coinType) + } } diff --git a/zetaclient/chains/solana/observer/outbound_test.go b/zetaclient/chains/solana/observer/outbound_test.go new file mode 100644 index 0000000000..654fa8f4ee --- /dev/null +++ b/zetaclient/chains/solana/observer/outbound_test.go @@ -0,0 +1,296 @@ +package observer_test + +import ( + "context" + "encoding/hex" + "testing" + + "github.com/gagliardetto/solana-go" + "github.com/pkg/errors" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "github.com/zeta-chain/zetacore/pkg/chains" + "github.com/zeta-chain/zetacore/pkg/coin" + contracts "github.com/zeta-chain/zetacore/pkg/contracts/solana" + "github.com/zeta-chain/zetacore/testutil/sample" + "github.com/zeta-chain/zetacore/zetaclient/chains/base" + "github.com/zeta-chain/zetacore/zetaclient/chains/interfaces" + "github.com/zeta-chain/zetacore/zetaclient/chains/solana/observer" + "github.com/zeta-chain/zetacore/zetaclient/db" + "github.com/zeta-chain/zetacore/zetaclient/testutils" + "github.com/zeta-chain/zetacore/zetaclient/testutils/mocks" +) + +const ( + // gatewayAddressDevnet is the gateway address on devnet for testing + GatewayAddressTest = "2kJndCL9NBR36ySiQ4bmArs4YgWQu67LmCDfLzk5Gb7s" + + // withdrawTxTest is an archived withdraw tx result on devnet for testing + // https://explorer.solana.com/tx/5iBYjBYCphzjHKfmPwddMWpV2RNssmzk9Z8NNmV9Rei71pZKBTEVdkmUeyXfn7eWbV8932uSsPfBxgA7UgERNTvq?cluster=devnet + withdrawTxTest = "5iBYjBYCphzjHKfmPwddMWpV2RNssmzk9Z8NNmV9Rei71pZKBTEVdkmUeyXfn7eWbV8932uSsPfBxgA7UgERNTvq" + + // withdrawFailedTxTest is an archived failed withdraw tx result on devnet for testing + // https://explorer.solana.com/tx/5nFUQgNSdqTd4aPS4a1xNcbehj19hDzuQLfBqFRj8g7BJdESVY6hFuTFPWFuV6aWAfzEMfVfCdNu9DfzVp5FsHg5?cluster=devnet + withdrawFailedTxTest = "5nFUQgNSdqTd4aPS4a1xNcbehj19hDzuQLfBqFRj8g7BJdESVY6hFuTFPWFuV6aWAfzEMfVfCdNu9DfzVp5FsHg5" + + // tssAddressTest is the TSS address for testing + tssAddressTest = "0x05C7dBdd1954D59c9afaB848dA7d8DD3F35e69Cd" +) + +// createTestObserver creates a test observer for testing +func createTestObserver( + t *testing.T, + chain chains.Chain, + solClient interfaces.SolanaRPCClient, + tss interfaces.TSSSigner, +) *observer.Observer { + database, err := db.NewFromSqliteInMemory(true) + require.NoError(t, err) + + // create observer + chainParams := sample.ChainParams(chain.ChainId) + chainParams.GatewayAddress = GatewayAddressTest + ob, err := observer.NewObserver(chain, solClient, *chainParams, nil, tss, database, base.DefaultLogger(), nil) + require.NoError(t, err) + + return ob +} + +func Test_CheckFinalizedTx(t *testing.T) { + // the test chain and transaction hash + chain := chains.SolanaDevnet + txHash := withdrawTxTest + txHashFailed := withdrawFailedTxTest + txSig := solana.MustSignatureFromBase58(txHash) + coinType := coin.CoinType_Gas + nonce := uint64(0) + + // load archived outbound tx result + txResult := testutils.LoadSolanaOutboundTxResult(t, TestDataDir, chain.ChainId, txHash) + + // mock GetTransaction result + solClient := mocks.NewSolanaRPCClient(t) + solClient.On("GetTransaction", mock.Anything, txSig, mock.Anything).Return(txResult, nil) + + // mock TSS + tss := mocks.NewMockTSS(chain, tssAddressTest, "") + + // create observer with and TSS + ob := createTestObserver(t, chain, solClient, tss) + ctx := context.Background() + + t.Run("should successfully check finalized tx", func(t *testing.T) { + tx, finalized := ob.CheckFinalizedTx(ctx, txHash, nonce, coinType) + require.True(t, finalized) + require.NotNil(t, tx) + }) + + t.Run("should return error on invalid tx hash", func(t *testing.T) { + tx, finalized := ob.CheckFinalizedTx(ctx, "invalid_hash_1234", nonce, coinType) + require.False(t, finalized) + require.Nil(t, tx) + }) + + t.Run("should return error on GetTransaction error", func(t *testing.T) { + // mock GetTransaction error + client := mocks.NewSolanaRPCClient(t) + client.On("GetTransaction", mock.Anything, mock.Anything, mock.Anything).Return(nil, errors.New("error")) + + // create observer + ob := createTestObserver(t, chain, client, tss) + + tx, finalized := ob.CheckFinalizedTx(ctx, txHash, nonce, coinType) + require.False(t, finalized) + require.Nil(t, tx) + }) + + t.Run("should return error on if transaction is failed", func(t *testing.T) { + // load archived outbound tx result which is failed due to nonce mismatch + failedResult := testutils.LoadSolanaOutboundTxResult(t, TestDataDir, chain.ChainId, txHashFailed) + + // mock GetTransaction result with failed status + client := mocks.NewSolanaRPCClient(t) + client.On("GetTransaction", mock.Anything, txSig, mock.Anything).Return(failedResult, nil) + + // create observer + ob := createTestObserver(t, chain, client, tss) + + tx, finalized := ob.CheckFinalizedTx(ctx, txHash, nonce, coinType) + require.False(t, finalized) + require.Nil(t, tx) + }) + + t.Run("should return error on ParseGatewayInstruction error", func(t *testing.T) { + // use CoinType_Zeta to cause ParseGatewayInstruction error + tx, finalized := ob.CheckFinalizedTx(ctx, txHash, nonce, coin.CoinType_Zeta) + require.False(t, finalized) + require.Nil(t, tx) + }) + + t.Run("should return error on ECDSA signer mismatch", func(t *testing.T) { + // create observer with other TSS address + tssOther := mocks.NewMockTSS(chain, sample.EthAddress().String(), "") + ob := createTestObserver(t, chain, solClient, tssOther) + + tx, finalized := ob.CheckFinalizedTx(ctx, txHash, nonce, coinType) + require.False(t, finalized) + require.Nil(t, tx) + }) + + t.Run("should return error on nonce mismatch", func(t *testing.T) { + // use different nonce + tx, finalized := ob.CheckFinalizedTx(ctx, txHash, nonce+1, coinType) + require.False(t, finalized) + require.Nil(t, tx) + }) +} + +func Test_ParseGatewayInstruction(t *testing.T) { + // the test chain and transaction hash + chain := chains.SolanaDevnet + txHash := withdrawTxTest + txAmount := uint64(890880) + + // gateway address + gatewayID, err := solana.PublicKeyFromBase58(GatewayAddressTest) + require.NoError(t, err) + + t.Run("should parse gateway instruction", func(t *testing.T) { + // load archived outbound tx result + txResult := testutils.LoadSolanaOutboundTxResult(t, TestDataDir, chain.ChainId, txHash) + + // parse gateway instruction + inst, err := observer.ParseGatewayInstruction(txResult, gatewayID, coin.CoinType_Gas) + require.NoError(t, err) + + // check sender, nonce and amount + sender, err := inst.Signer() + require.NoError(t, err) + require.Equal(t, tssAddressTest, sender.String()) + require.EqualValues(t, inst.GatewayNonce(), 0) + require.EqualValues(t, inst.TokenAmount(), txAmount) + }) + + t.Run("should return error on invalid number of instructions", func(t *testing.T) { + // load and unmarshal archived transaction + txResult := testutils.LoadSolanaOutboundTxResult(t, TestDataDir, chain.ChainId, txHash) + tx, err := txResult.Transaction.GetTransaction() + require.NoError(t, err) + + // remove all instructions + tx.Message.Instructions = nil + + inst, err := observer.ParseGatewayInstruction(txResult, gatewayID, coin.CoinType_Gas) + require.ErrorContains(t, err, "want 1 instruction, got 0") + require.Nil(t, inst) + }) + + t.Run("should return error on invalid program id index", func(t *testing.T) { + // load and unmarshal archived transaction + txResult := testutils.LoadSolanaOutboundTxResult(t, TestDataDir, chain.ChainId, txHash) + tx, err := txResult.Transaction.GetTransaction() + require.NoError(t, err) + + // set invalid program id index (out of range) + tx.Message.Instructions[0].ProgramIDIndex = 4 + + inst, err := observer.ParseGatewayInstruction(txResult, gatewayID, coin.CoinType_Gas) + require.ErrorContains(t, err, "error getting program ID") + require.Nil(t, inst) + }) + + t.Run("should return error when invoked program is not gateway", func(t *testing.T) { + // load and unmarshal archived transaction + txResult := testutils.LoadSolanaOutboundTxResult(t, TestDataDir, chain.ChainId, txHash) + tx, err := txResult.Transaction.GetTransaction() + require.NoError(t, err) + + // set invalid program id index (pda account index) + tx.Message.Instructions[0].ProgramIDIndex = 1 + + inst, err := observer.ParseGatewayInstruction(txResult, gatewayID, coin.CoinType_Gas) + require.ErrorContains(t, err, "not matching gatewayID") + require.Nil(t, inst) + }) + + t.Run("should return error when instruction parsing fails", func(t *testing.T) { + // load and unmarshal archived transaction + txResult := testutils.LoadSolanaOutboundTxResult(t, TestDataDir, chain.ChainId, txHash) + tx, err := txResult.Transaction.GetTransaction() + require.NoError(t, err) + + // set invalid instruction data to cause parsing error + tx.Message.Instructions[0].Data = []byte("invalid instruction data") + + inst, err := observer.ParseGatewayInstruction(txResult, gatewayID, coin.CoinType_Gas) + require.Error(t, err) + require.Nil(t, inst) + }) + + t.Run("should return error on unsupported coin type", func(t *testing.T) { + // load and unmarshal archived transaction + txResult := testutils.LoadSolanaOutboundTxResult(t, TestDataDir, chain.ChainId, txHash) + + inst, err := observer.ParseGatewayInstruction(txResult, gatewayID, coin.CoinType_ERC20) + require.ErrorContains(t, err, "unsupported outbound coin type") + require.Nil(t, inst) + }) +} + +func Test_ParseInstructionWithdraw(t *testing.T) { + // the test chain and transaction hash + chain := chains.SolanaDevnet + txHash := withdrawTxTest + txAmount := uint64(890880) + + t.Run("should parse instruction withdraw", func(t *testing.T) { + // load and unmarshal archived transaction + txResult := testutils.LoadSolanaOutboundTxResult(t, TestDataDir, chain.ChainId, txHash) + tx, err := txResult.Transaction.GetTransaction() + require.NoError(t, err) + + instruction := tx.Message.Instructions[0] + inst, err := contracts.ParseInstructionWithdraw(instruction) + require.NoError(t, err) + + // check sender, nonce and amount + sender, err := inst.Signer() + require.NoError(t, err) + require.Equal(t, tssAddressTest, sender.String()) + require.EqualValues(t, inst.GatewayNonce(), 0) + require.EqualValues(t, inst.TokenAmount(), txAmount) + }) + + t.Run("should return error on invalid instruction data", func(t *testing.T) { + // load and unmarshal archived transaction + txResult := testutils.LoadSolanaOutboundTxResult(t, TestDataDir, chain.ChainId, txHash) + txFake, err := txResult.Transaction.GetTransaction() + require.NoError(t, err) + + // set invalid instruction data + instruction := txFake.Message.Instructions[0] + instruction.Data = []byte("invalid instruction data") + + inst, err := contracts.ParseInstructionWithdraw(instruction) + require.ErrorContains(t, err, "error deserializing instruction") + require.Nil(t, inst) + }) + + t.Run("should return error on discriminator mismatch", func(t *testing.T) { + // load and unmarshal archived transaction + txResult := testutils.LoadSolanaOutboundTxResult(t, TestDataDir, chain.ChainId, txHash) + txFake, err := txResult.Transaction.GetTransaction() + require.NoError(t, err) + + // overwrite discriminator (first 8 bytes) + instruction := txFake.Message.Instructions[0] + fakeDiscriminator := "b712469c946da12100980d0000000000" + fakeDiscriminatorBytes, err := hex.DecodeString(fakeDiscriminator) + require.NoError(t, err) + copy(instruction.Data, fakeDiscriminatorBytes) + + inst, err := contracts.ParseInstructionWithdraw(instruction) + require.ErrorContains(t, err, "not a withdraw instruction") + require.Nil(t, inst) + }) +} diff --git a/zetaclient/chains/solana/signer/outbound_tracker_reporter.go b/zetaclient/chains/solana/signer/outbound_tracker_reporter.go new file mode 100644 index 0000000000..6462060beb --- /dev/null +++ b/zetaclient/chains/solana/signer/outbound_tracker_reporter.go @@ -0,0 +1,91 @@ +package signer + +import ( + "context" + "time" + + "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/rpc" + "github.com/rs/zerolog" + + "github.com/zeta-chain/zetacore/zetaclient/chains/interfaces" +) + +const ( + // SolanaTransactionTimeout is the timeout for waiting for an outbound to be confirmed + // Transaction referencing a blockhash older than 150 blocks will expire and be rejected by Solana. + SolanaTransactionTimeout = 2 * time.Minute +) + +// reportToOutboundTracker launch a go routine with timeout to check for tx confirmation; +// it reports tx to outbound tracker only if it's confirmed by the Solana network. +func (signer *Signer) reportToOutboundTracker( + ctx context.Context, + zetacoreClient interfaces.ZetacoreClient, + chainID int64, + nonce uint64, + txSig solana.Signature, + logger zerolog.Logger, +) { + // set being reported flag to avoid duplicate reporting + alreadySet := signer.Signer.SetBeingReportedFlag(txSig.String()) + if alreadySet { + logger.Info(). + Msgf("reportToOutboundTracker: outbound %s for chain %d nonce %d is being reported", txSig, chainID, nonce) + return + } + + // launch a goroutine to monitor tx confirmation status + go func() { + defer func() { + signer.Signer.ClearBeingReportedFlag(txSig.String()) + }() + + start := time.Now() + for { + // Solana block time is 0.4~0.8 seconds; wait 5 seconds between each check + time.Sleep(5 * time.Second) + + // give up if we know the tx is too old and already expired + if time.Since(start) > SolanaTransactionTimeout { + logger.Info(). + Msgf("reportToOutboundTracker: outbound %s expired for chain %d nonce %d", txSig, chainID, nonce) + return + } + + // query tx using optimistic commitment level "confirmed" + tx, err := signer.client.GetTransaction(ctx, txSig, &rpc.GetTransactionOpts{ + // commitment "processed" seems to be a better choice but it's not supported + // see: https://solana.com/docs/rpc/http/gettransaction + Commitment: rpc.CommitmentConfirmed, + }) + if err != nil { + continue + } + + // exit goroutine if tx failed. + if tx.Meta.Err != nil { + // unlike Ethereum, Solana doesn't have protocol-level nonce; the nonce is enforced by the gateway program. + // a failed outbound (e.g. signature err, balance err) will never be able to increment the gateway program nonce. + // a good/valid candidate of outbound tracker hash must come with a successful tx. + logger.Warn(). + Any("Err", tx.Meta.Err). + Msgf("reportToOutboundTracker: outbound %s failed for chain %d nonce %d", txSig, chainID, nonce) + return + } + + // report outbound hash to zetacore + zetaHash, err := zetacoreClient.AddOutboundTracker(ctx, chainID, nonce, txSig.String(), nil, "", -1) + if err != nil { + logger.Err(err). + Msgf("reportToOutboundTracker: error adding outbound %s for chain %d nonce %d", txSig, chainID, nonce) + } else if zetaHash != "" { + logger.Info().Msgf("reportToOutboundTracker: added outbound %s for chain %d nonce %d; zeta txhash %s", txSig, chainID, nonce, zetaHash) + } else { + // exit goroutine if the tracker already contains the hash (reported by other signer) + logger.Info().Msgf("reportToOutboundTracker: outbound %s already in tracker for chain %d nonce %d", txSig, chainID, nonce) + return + } + } + }() +} diff --git a/zetaclient/chains/solana/signer/signer.go b/zetaclient/chains/solana/signer/signer.go new file mode 100644 index 0000000000..3fb7512512 --- /dev/null +++ b/zetaclient/chains/solana/signer/signer.go @@ -0,0 +1,184 @@ +package signer + +import ( + "context" + + "cosmossdk.io/errors" + ethcommon "github.com/ethereum/go-ethereum/common" + "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/rpc" + + "github.com/zeta-chain/zetacore/pkg/chains" + "github.com/zeta-chain/zetacore/pkg/coin" + contracts "github.com/zeta-chain/zetacore/pkg/contracts/solana" + "github.com/zeta-chain/zetacore/x/crosschain/types" + observertypes "github.com/zeta-chain/zetacore/x/observer/types" + "github.com/zeta-chain/zetacore/zetaclient/chains/base" + "github.com/zeta-chain/zetacore/zetaclient/chains/interfaces" + "github.com/zeta-chain/zetacore/zetaclient/metrics" + "github.com/zeta-chain/zetacore/zetaclient/outboundprocessor" +) + +var _ interfaces.ChainSigner = (*Signer)(nil) + +// Signer deals with signing BTC transactions and implements the ChainSigner interface +type Signer struct { + *base.Signer + + // client is the Solana RPC client that interacts with the Solana chain + client interfaces.SolanaRPCClient + + // solanaFeePayerKey is the private key of the fee payer account on Solana chain + solanaFeePayerKey solana.PrivateKey + + // gatewayID is the program ID of gateway program on Solana chain + gatewayID solana.PublicKey + + // pda is the program derived address of the gateway program + pda solana.PublicKey +} + +// NewSigner creates a new Bitcoin signer +func NewSigner( + chain chains.Chain, + chainParams observertypes.ChainParams, + solClient interfaces.SolanaRPCClient, + tss interfaces.TSSSigner, + solanaKey solana.PrivateKey, + ts *metrics.TelemetryServer, + logger base.Logger, +) (*Signer, error) { + // create base signer + baseSigner := base.NewSigner(chain, tss, ts, logger) + + // parse gateway ID and PDA + gatewayID, pda, err := contracts.ParseGatewayIDAndPda(chainParams.GatewayAddress) + if err != nil { + return nil, errors.Wrapf(err, "cannot parse gateway address %s", chainParams.GatewayAddress) + } + logger.Std.Info().Msgf("Solana fee payer address: %s", solanaKey.PublicKey()) + + // create solana observer + return &Signer{ + Signer: baseSigner, + client: solClient, + solanaFeePayerKey: solanaKey, + gatewayID: gatewayID, + pda: pda, + }, nil +} + +// TryProcessOutbound - signer interface implementation +// This function will attempt to build and sign a Solana transaction using the TSS signer. +// It will then broadcast the signed transaction to the Solana chain. +func (signer *Signer) TryProcessOutbound( + ctx context.Context, + cctx *types.CrossChainTx, + outboundProc *outboundprocessor.Processor, + outboundID string, + _ interfaces.ChainObserver, + zetacoreClient interfaces.ZetacoreClient, + height uint64, +) { + // end outbound process on panic + defer func() { + outboundProc.EndTryProcess(outboundID) + if err := recover(); err != nil { + signer.Logger().Std.Error().Msgf("TryProcessOutbound: %s, caught panic error: %v", cctx.Index, err) + } + }() + + // prepare logger + params := cctx.GetCurrentOutboundParam() + logger := signer.Logger().Std.With(). + Str("method", "TryProcessOutbound"). + Int64("chain", signer.Chain().ChainId). + Uint64("nonce", params.TssNonce). + Str("cctx", cctx.Index). + Logger() + + // support gas token only for Solana outbound + chainID := signer.Chain().ChainId + nonce := params.TssNonce + coinType := cctx.InboundParams.CoinType + if coinType != coin.CoinType_Gas { + logger.Error(). + Msgf("TryProcessOutbound: can only send SOL to the Solana network for chain %d nonce %d", chainID, nonce) + return + } + + // sign gateway withdraw message by TSS + msg, err := signer.SignMsgWithdraw(ctx, params, height) + if err != nil { + logger.Error().Err(err).Msgf("TryProcessOutbound: SignMsgWithdraw error for chain %d nonce %d", chainID, nonce) + return + } + + // sign the withdraw transaction by fee payer + tx, err := signer.SignWithdrawTx(ctx, *msg) + if err != nil { + logger.Error().Err(err).Msgf("TryProcessOutbound: SignWithdrawTx error for chain %d nonce %d", chainID, nonce) + return + } + + // broadcast the signed tx to the Solana network with preflight check + txSig, err := signer.client.SendTransactionWithOpts( + ctx, + tx, + // Commitment "finalized" is too conservative for preflight check and + // it results in repeated broadcast attempts that only 1 will succeed. + // Commitment "processed" will simulate tx against more recent state + // thus fails faster once a tx is already broadcasted and processed by the cluster. + // This reduces the number of "failed" txs due to repeated broadcast attempts. + rpc.TransactionOpts{PreflightCommitment: rpc.CommitmentProcessed}, + ) + if err != nil { + signer.Logger(). + Std.Warn(). + Err(err). + Msgf("TryProcessOutbound: broadcast error for chain %d nonce %d", chainID, nonce) + return + } + + // report the outbound to the outbound tracker + signer.reportToOutboundTracker(ctx, zetacoreClient, chainID, nonce, txSig, logger) +} + +// SetGatewayAddress sets the gateway address +func (signer *Signer) SetGatewayAddress(address string) { + // parse gateway ID and PDA + gatewayID, pda, err := contracts.ParseGatewayIDAndPda(address) + if err != nil { + signer.Logger().Std.Error().Err(err).Msgf("cannot parse gateway address %s", address) + } + + // update gateway ID and PDA + signer.Lock() + defer signer.Unlock() + + signer.gatewayID = gatewayID + signer.pda = pda +} + +// GetGatewayAddress returns the gateway address +func (signer *Signer) GetGatewayAddress() string { + signer.Lock() + defer signer.Unlock() + return signer.gatewayID.String() +} + +// TODO: get rid of below four functions for Solana and Bitcoin +// https://github.com/zeta-chain/node/issues/2532 +func (signer *Signer) SetZetaConnectorAddress(_ ethcommon.Address) { +} + +func (signer *Signer) SetERC20CustodyAddress(_ ethcommon.Address) { +} + +func (signer *Signer) GetZetaConnectorAddress() ethcommon.Address { + return ethcommon.Address{} +} + +func (signer *Signer) GetERC20CustodyAddress() ethcommon.Address { + return ethcommon.Address{} +} diff --git a/zetaclient/chains/solana/signer/withdraw.go b/zetaclient/chains/solana/signer/withdraw.go new file mode 100644 index 0000000000..383d1c908a --- /dev/null +++ b/zetaclient/chains/solana/signer/withdraw.go @@ -0,0 +1,123 @@ +package signer + +import ( + "context" + + "cosmossdk.io/errors" + "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/rpc" + "github.com/near/borsh-go" + + "github.com/zeta-chain/zetacore/pkg/chains" + contracts "github.com/zeta-chain/zetacore/pkg/contracts/solana" + "github.com/zeta-chain/zetacore/x/crosschain/types" +) + +// SignMsgWithdraw signs a withdraw message (for gateway withdraw/withdraw_spl instruction) with TSS. +func (signer *Signer) SignMsgWithdraw( + ctx context.Context, + params *types.OutboundParams, + height uint64, +) (*contracts.MsgWithdraw, error) { + chain := signer.Chain() + // #nosec G115 always positive + chainID := uint64(signer.Chain().ChainId) + nonce := params.TssNonce + amount := params.Amount.Uint64() + + // check receiver address + to, err := chains.DecodeSolanaWalletAddress(params.Receiver) + if err != nil { + return nil, errors.Wrapf(err, "cannot decode receiver address %s", params.Receiver) + } + + // prepare withdraw msg and compute hash + msg := contracts.NewMsgWithdraw(chainID, nonce, amount, to) + msgHash := msg.Hash() + + // sign the message with TSS to get an ECDSA signature. + // the produced signature is in the [R || S || V] format where V is 0 or 1. + signature, err := signer.TSS().Sign(ctx, msgHash[:], height, nonce, chain.ChainId, "") + if err != nil { + return nil, errors.Wrap(err, "Key-sign failed") + } + signer.Logger().Std.Info().Msgf("Key-sign succeed for chain %d nonce %d", chainID, nonce) + + // attach the signature and return + return msg.SetSignature(signature), nil +} + +// SignWithdrawTx wraps the withdraw 'msg' into a Solana transaction and signs it with the fee payer key. +func (signer *Signer) SignWithdrawTx(ctx context.Context, msg contracts.MsgWithdraw) (*solana.Transaction, error) { + // create withdraw instruction with program call data + var err error + var inst solana.GenericInstruction + inst.DataBytes, err = borsh.Serialize(contracts.WithdrawInstructionParams{ + Discriminator: contracts.DiscriminatorWithdraw(), + Amount: msg.Amount(), + Signature: msg.SigRS(), + RecoveryID: msg.SigV(), + MessageHash: msg.Hash(), + Nonce: msg.Nonce(), + }) + if err != nil { + return nil, errors.Wrap(err, "cannot serialize withdraw instruction") + } + + // attach required accounts to the instruction + privkey := signer.solanaFeePayerKey + attachWithdrawAccounts(&inst, privkey.PublicKey(), signer.pda, msg.To(), signer.gatewayID) + + // get a recent blockhash + recent, err := signer.client.GetRecentBlockhash(ctx, rpc.CommitmentFinalized) + if err != nil { + return nil, errors.Wrap(err, "GetRecentBlockhash error") + } + + // create a transaction that wraps the instruction + tx, err := solana.NewTransaction( + []solana.Instruction{ + // TODO: outbound now uses 5K lamports as the fixed fee, we could explore priority fee and compute budget + // https://github.com/zeta-chain/node/issues/2599 + // programs.ComputeBudgetSetComputeUnitLimit(computeUnitLimit), + // programs.ComputeBudgetSetComputeUnitPrice(computeUnitPrice), + &inst}, + recent.Value.Blockhash, + solana.TransactionPayer(privkey.PublicKey()), + ) + if err != nil { + return nil, errors.Wrap(err, "NewTransaction error") + } + + // fee payer signs the transaction + _, err = tx.Sign(func(key solana.PublicKey) *solana.PrivateKey { + if key.Equals(privkey.PublicKey()) { + return &privkey + } + return nil + }) + if err != nil { + return nil, errors.Wrap(err, "signer unable to sign transaction") + } + + return tx, nil +} + +// attachWithdrawAccounts attaches the required accounts for the gateway withdraw instruction. +func attachWithdrawAccounts( + inst *solana.GenericInstruction, + signer solana.PublicKey, + pda solana.PublicKey, + to solana.PublicKey, + gatewayID solana.PublicKey, +) { + // attach required accounts to the instruction + var accountSlice []*solana.AccountMeta + accountSlice = append(accountSlice, solana.Meta(signer).WRITE().SIGNER()) + accountSlice = append(accountSlice, solana.Meta(pda).WRITE()) + accountSlice = append(accountSlice, solana.Meta(to).WRITE()) + accountSlice = append(accountSlice, solana.Meta(gatewayID)) + inst.ProgID = gatewayID + + inst.AccountValues = accountSlice +} diff --git a/zetaclient/common/constant.go b/zetaclient/common/constant.go index 5e410d4886..64d906ca0d 100644 --- a/zetaclient/common/constant.go +++ b/zetaclient/common/constant.go @@ -1,6 +1,9 @@ package common const ( + // DefaultGasPriceMultiplier is the default gas price multiplier for all chains + DefaultGasPriceMultiplier = 1.0 + // EVMOutboundGasPriceMultiplier is the default gas price multiplier for EVM-chain outbond txs EVMOutboundGasPriceMultiplier = 1.2 diff --git a/zetaclient/config/types.go b/zetaclient/config/types.go index b9535f3937..1ec58e12aa 100644 --- a/zetaclient/config/types.go +++ b/zetaclient/config/types.go @@ -2,9 +2,15 @@ package config import ( "encoding/json" + "fmt" + "os" + "path" "strings" "sync" + "cosmossdk.io/errors" + "github.com/gagliardetto/solana-go" + "github.com/zeta-chain/zetacore/pkg/chains" ) @@ -81,6 +87,7 @@ type Config struct { KeyringBackend KeyringBackend `json:"KeyringBackend"` HsmMode bool `json:"HsmMode"` HsmHotKey string `json:"HsmHotKey"` + SolanaKeyFile string `json:"SolanaKeyFile"` // chain configs EVMChainConfigs map[int64]EVMConfig `json:"EVMChainConfigs"` @@ -157,3 +164,33 @@ func (c Config) GetKeyringBackend() KeyringBackend { defer c.mu.RUnlock() return c.KeyringBackend } + +// LoadSolanaPrivateKey loads the Solana private key from the key file +func (c Config) LoadSolanaPrivateKey() (solana.PrivateKey, error) { + // key file path + fileName := path.Join(c.ZetaCoreHome, c.SolanaKeyFile) + + // load the gateway keypair from a JSON file + // #nosec G304 -- user is allowed to specify the key file + fileContent, err := os.ReadFile(fileName) + if err != nil { + return solana.PrivateKey{}, errors.Wrapf(err, "unable to read Solana key file: %s", fileName) + } + + // unmarshal the JSON content into a slice of bytes + var keyBytes []byte + err = json.Unmarshal(fileContent, &keyBytes) + if err != nil { + return solana.PrivateKey{}, errors.Wrap(err, "unable to unmarshal Solana key bytes") + } + + // ensure the key length is 64 bytes + if len(keyBytes) != 64 { + return solana.PrivateKey{}, fmt.Errorf("invalid Solana key length: %d", len(keyBytes)) + } + + // create private key from the key bytes + privKey := solana.PrivateKey(keyBytes) + + return privKey, nil +} diff --git a/zetaclient/orchestrator/bootstrap.go b/zetaclient/orchestrator/bootstrap.go index 04cd4646ea..cffb9085c7 100644 --- a/zetaclient/orchestrator/bootstrap.go +++ b/zetaclient/orchestrator/bootstrap.go @@ -17,6 +17,7 @@ import ( evmsigner "github.com/zeta-chain/zetacore/zetaclient/chains/evm/signer" "github.com/zeta-chain/zetacore/zetaclient/chains/interfaces" solbserver "github.com/zeta-chain/zetacore/zetaclient/chains/solana/observer" + solanasigner "github.com/zeta-chain/zetacore/zetaclient/chains/solana/signer" "github.com/zeta-chain/zetacore/zetaclient/config" zctx "github.com/zeta-chain/zetacore/zetaclient/context" "github.com/zeta-chain/zetacore/zetaclient/db" @@ -151,15 +152,72 @@ func syncSignerMap( continue } - cfg, _ := app.Config().GetBTCConfig() + // get BTC config + cfg, found := app.Config().GetBTCConfig() + if !found { + logger.Std.Error().Msgf("Unable to find BTC config for chain %d", chainID) + continue + } - utxoSigner, err := btcsigner.NewSigner(btcChain, tss, ts, logger, cfg) + signer, err := btcsigner.NewSigner(btcChain, tss, ts, logger, cfg) if err != nil { - logger.Std.Error().Err(err).Msgf("Unable to construct signer for UTXO chain %d", chainID) + logger.Std.Error().Err(err).Msgf("Unable to construct signer for BTC chain %d", chainID) continue } - addSigner(chainID, utxoSigner) + addSigner(chainID, signer) + } + + // Solana signer + // Emulate same loop semantics as for EVM chains + for i := 0; i < 1; i++ { + solChain, solChainParams, solChainParamsFound := app.GetSolanaChainParams() + switch { + case !solChainParamsFound: + logger.Std.Warn().Msgf("Unable to find chain params for Solana chain") + continue + case !solChainParams.IsSupported: + logger.Std.Warn().Msgf("Solana chain is not supported") + continue + } + + chainID := solChainParams.ChainId + presentChainIDs = append(presentChainIDs, chainID) + + // noop + if mapHas(signers, chainID) { + continue + } + + // get Solana config + cfg, found := app.Config().GetSolanaConfig() + if !found { + logger.Std.Error().Msgf("Unable to find Solana config for chain %d", chainID) + continue + } + + // create Solana client + rpcClient := solrpc.New(cfg.Endpoint) + if rpcClient == nil { + // should never happen + logger.Std.Error().Msgf("Unable to create Solana client from endpoint %s", cfg.Endpoint) + continue + } + + // load the Solana private key + solanaKey, err := app.Config().LoadSolanaPrivateKey() + if err != nil { + logger.Std.Error().Err(err).Msg("Unable to get Solana private key") + } + + // create Solana signer + signer, err := solanasigner.NewSigner(solChain, *solChainParams, rpcClient, tss, solanaKey, ts, logger) + if err != nil { + logger.Std.Error().Err(err).Msgf("Unable to construct signer for Solana chain %d", chainID) + continue + } + + addSigner(chainID, signer) } // Remove all disabled signers @@ -381,7 +439,7 @@ func syncObserverMap( rpcClient := solrpc.New(solConfig.Endpoint) if rpcClient == nil { // should never happen - logger.Std.Error().Msg("solana create Solana client error") + logger.Std.Error().Msgf("Unable to create Solana client from endpoint %s", solConfig.Endpoint) continue } diff --git a/zetaclient/orchestrator/orchestrator.go b/zetaclient/orchestrator/orchestrator.go index e96935509e..edcaa3d9d5 100644 --- a/zetaclient/orchestrator/orchestrator.go +++ b/zetaclient/orchestrator/orchestrator.go @@ -22,6 +22,7 @@ import ( btcobserver "github.com/zeta-chain/zetacore/zetaclient/chains/bitcoin/observer" "github.com/zeta-chain/zetacore/zetaclient/chains/evm" "github.com/zeta-chain/zetacore/zetaclient/chains/interfaces" + solanaobserver "github.com/zeta-chain/zetacore/zetaclient/chains/solana/observer" zctx "github.com/zeta-chain/zetacore/zetaclient/context" "github.com/zeta-chain/zetacore/zetaclient/metrics" "github.com/zeta-chain/zetacore/zetaclient/outboundprocessor" @@ -153,31 +154,38 @@ func (oc *Orchestrator) resolveSigner(app *zctx.AppContext, chainID int64) (inte return nil, err } - // noop for non-EVM chains - if !chains.IsEVMChain(chainID, app.GetAdditionalChains()) { - return signer, nil - } - - evmParams, found := app.GetEVMChainParams(chainID) - if !found { - return signer, nil - } - - // update zeta connector and ERC20 custody addresses - zetaConnectorAddress := ethcommon.HexToAddress(evmParams.GetConnectorContractAddress()) - if zetaConnectorAddress != signer.GetZetaConnectorAddress() { - signer.SetZetaConnectorAddress(zetaConnectorAddress) - oc.logger.Info(). - Str("signer.connector_address", zetaConnectorAddress.String()). - Msgf("updated zeta connector address for chain %d", chainID) - } + // update signer chain parameters + if chains.IsEVMChain(chainID, app.GetAdditionalChains()) { + evmParams, found := app.GetEVMChainParams(chainID) + if found { + // update zeta connector and ERC20 custody addresses + zetaConnectorAddress := ethcommon.HexToAddress(evmParams.GetConnectorContractAddress()) + if zetaConnectorAddress != signer.GetZetaConnectorAddress() { + signer.SetZetaConnectorAddress(zetaConnectorAddress) + oc.logger.Info(). + Str("signer.connector_address", zetaConnectorAddress.String()). + Msgf("updated zeta connector address for chain %d", chainID) + } - erc20CustodyAddress := ethcommon.HexToAddress(evmParams.GetErc20CustodyContractAddress()) - if erc20CustodyAddress != signer.GetERC20CustodyAddress() { - signer.SetERC20CustodyAddress(erc20CustodyAddress) - oc.logger.Info(). - Str("signer.erc20_custody", erc20CustodyAddress.String()). - Msgf("updated zeta connector address for chain %d", chainID) + erc20CustodyAddress := ethcommon.HexToAddress(evmParams.GetErc20CustodyContractAddress()) + if erc20CustodyAddress != signer.GetERC20CustodyAddress() { + signer.SetERC20CustodyAddress(erc20CustodyAddress) + oc.logger.Info(). + Str("signer.erc20_custody", erc20CustodyAddress.String()). + Msgf("updated zeta connector address for chain %d", chainID) + } + } + } else if chains.IsSolanaChain(chainID, app.GetAdditionalChains()) { + _, solParams, found := app.GetSolanaChainParams() + if found { + // update solana gateway address + if solParams.GatewayAddress != signer.GetGatewayAddress() { + signer.SetGatewayAddress(solParams.GatewayAddress) + oc.logger.Info(). + Str("signer.gateway_address", solParams.GatewayAddress). + Msgf("updated gateway address for chain %d", chainID) + } + } } return signer, nil @@ -220,6 +228,13 @@ func (oc *Orchestrator) resolveObserver(app *zctx.AppContext, chainID int64) (in Interface("observer.chain_params", *btcParams). Msgf("updated chain params for UTXO chainID %d", btcParams.ChainId) } + } else if chains.IsSolanaChain(chainID, app.GetAdditionalChains()) { + _, solParams, found := app.GetSolanaChainParams() + if found && !observertypes.ChainParamsEqual(curParams, *solParams) { + observer.SetChainParams(*solParams) + oc.logger.Info().Msgf( + "updated chain params for Solana, new params: %v", *solParams) + } } return observer, nil @@ -378,8 +393,10 @@ func (oc *Orchestrator) runScheduler(ctx context.Context) error { oc.ScheduleCctxEVM(ctx, zetaHeight, c.ChainId, cctxList, ob, signer) } else if chains.IsBitcoinChain(c.ChainId, app.GetAdditionalChains()) { oc.ScheduleCctxBTC(ctx, zetaHeight, c.ChainId, cctxList, ob, signer) + } else if chains.IsSolanaChain(c.ChainId, app.GetAdditionalChains()) { + oc.ScheduleCctxSolana(ctx, zetaHeight, c.ChainId, cctxList, ob, signer) } else { - oc.logger.Error().Msgf("StartCctxScheduler: unsupported chain %d", c.ChainId) + oc.logger.Error().Msgf("runScheduler: unsupported chain %d", c.ChainId) continue } } @@ -433,17 +450,17 @@ func (oc *Orchestrator) ScheduleCctxEVM( break } - // try confirming the outbound - included, _, err := observer.IsOutboundProcessed(ctx, cctx, oc.logger.Logger) + // vote outbound if it's already confirmed + continueKeysign, err := observer.VoteOutboundIfConfirmed(ctx, cctx) if err != nil { oc.logger.Error(). Err(err). - Msgf("ScheduleCctxEVM: IsOutboundProcessed faild for chain %d nonce %d", chainID, nonce) + Msgf("ScheduleCctxEVM: VoteOutboundIfConfirmed failed for chain %d nonce %d", chainID, nonce) continue } - if included { + if !continueKeysign { oc.logger.Info(). - Msgf("ScheduleCctxEVM: outbound %s already included; do not schedule keysign", outboundID) + Msgf("ScheduleCctxEVM: outbound %s already processed; do not schedule keysign", outboundID) continue } @@ -473,7 +490,7 @@ func (oc *Orchestrator) ScheduleCctxEVM( !oc.outboundProc.IsOutboundActive(outboundID) { oc.outboundProc.StartTryProcess(outboundID) oc.logger.Debug(). - Msgf("ScheduleCctxEVM: sign outbound %s with value %d\n", outboundID, cctx.GetCurrentOutboundParam().Amount) + Msgf("ScheduleCctxEVM: sign outbound %s with value %d", outboundID, cctx.GetCurrentOutboundParam().Amount) go signer.TryProcessOutbound( ctx, cctx, @@ -525,16 +542,16 @@ func (oc *Orchestrator) ScheduleCctxBTC( continue } // try confirming the outbound - included, confirmed, err := btcObserver.IsOutboundProcessed(ctx, cctx, oc.logger.Logger) + continueKeysign, err := btcObserver.VoteOutboundIfConfirmed(ctx, cctx) if err != nil { oc.logger.Error(). Err(err). - Msgf("ScheduleCctxBTC: IsOutboundProcessed faild for chain %d nonce %d", chainID, nonce) + Msgf("ScheduleCctxBTC: VoteOutboundIfConfirmed failed for chain %d nonce %d", chainID, nonce) continue } - if included || confirmed { + if !continueKeysign { oc.logger.Info(). - Msgf("ScheduleCctxBTC: outbound %s already included; do not schedule keysign", outboundID) + Msgf("ScheduleCctxBTC: outbound %s already processed; do not schedule keysign", outboundID) continue } @@ -550,10 +567,70 @@ func (oc *Orchestrator) ScheduleCctxBTC( Msgf("ScheduleCctxBTC: lookahead reached, signing %d, earliest pending %d", nonce, cctxList[0].GetCurrentOutboundParam().TssNonce) break } - // try confirming the outbound or scheduling a keysign + // schedule a TSS keysign + if nonce%interval == zetaHeight%interval && !oc.outboundProc.IsOutboundActive(outboundID) { + oc.outboundProc.StartTryProcess(outboundID) + oc.logger.Debug().Msgf("ScheduleCctxBTC: sign outbound %s with value %d", outboundID, params.Amount) + go signer.TryProcessOutbound( + ctx, + cctx, + oc.outboundProc, + outboundID, + observer, + oc.zetacoreClient, + zetaHeight, + ) + } + } +} + +// ScheduleCctxSolana schedules solana outbound keysign on each ZetaChain block (the ticker) +func (oc *Orchestrator) ScheduleCctxSolana( + ctx context.Context, + zetaHeight uint64, + chainID int64, + cctxList []*types.CrossChainTx, + observer interfaces.ChainObserver, + signer interfaces.ChainSigner, +) { + solObserver, ok := observer.(*solanaobserver.Observer) + if !ok { // should never happen + oc.logger.Error().Msgf("ScheduleCctxSolana: chain observer is not a solana observer") + return + } + // #nosec G701 positive + interval := uint64(observer.GetChainParams().OutboundScheduleInterval) + + // schedule keysign for each pending cctx + for _, cctx := range cctxList { + params := cctx.GetCurrentOutboundParam() + nonce := params.TssNonce + outboundID := outboundprocessor.ToOutboundID(cctx.Index, params.ReceiverChainId, nonce) + + if params.ReceiverChainId != chainID { + oc.logger.Error(). + Msgf("ScheduleCctxSolana: outbound %s chainid mismatch: want %d, got %d", outboundID, chainID, params.ReceiverChainId) + continue + } + + // vote outbound if it's already confirmed + continueKeysign, err := solObserver.VoteOutboundIfConfirmed(ctx, cctx) + if err != nil { + oc.logger.Error(). + Err(err). + Msgf("ScheduleCctxSolana: VoteOutboundIfConfirmed failed for chain %d nonce %d", chainID, nonce) + continue + } + if !continueKeysign { + oc.logger.Info(). + Msgf("ScheduleCctxSolana: outbound %s already processed; do not schedule keysign", outboundID) + continue + } + + // schedule a TSS keysign if nonce%interval == zetaHeight%interval && !oc.outboundProc.IsOutboundActive(outboundID) { oc.outboundProc.StartTryProcess(outboundID) - oc.logger.Debug().Msgf("ScheduleCctxBTC: sign outbound %s with value %d\n", outboundID, params.Amount) + oc.logger.Debug().Msgf("ScheduleCctxSolana: sign outbound %s with value %d", outboundID, params.Amount) go signer.TryProcessOutbound( ctx, cctx, diff --git a/zetaclient/orchestrator/orchestrator_test.go b/zetaclient/orchestrator/orchestrator_test.go index 94c134cad6..af4ca5c346 100644 --- a/zetaclient/orchestrator/orchestrator_test.go +++ b/zetaclient/orchestrator/orchestrator_test.go @@ -13,6 +13,7 @@ import ( "github.com/zeta-chain/zetacore/pkg/chains" "github.com/zeta-chain/zetacore/pkg/coin" + solanacontracts "github.com/zeta-chain/zetacore/pkg/contracts/solana" "github.com/zeta-chain/zetacore/testutil/sample" crosschainkeeper "github.com/zeta-chain/zetacore/x/crosschain/keeper" crosschaintypes "github.com/zeta-chain/zetacore/x/crosschain/types" @@ -27,37 +28,56 @@ import ( func MockOrchestrator( t *testing.T, zetacoreClient interfaces.ZetacoreClient, - evmChain, btcChain chains.Chain, - evmChainParams, btcChainParams *observertypes.ChainParams, + evmChain, btcChain, solChain *chains.Chain, + evmChainParams, btcChainParams, solChainParams *observertypes.ChainParams, ) *Orchestrator { - // create mock signers and clients - evmSigner := mocks.NewEVMSigner( - evmChain, - ethcommon.HexToAddress(evmChainParams.ConnectorContractAddress), - ethcommon.HexToAddress(evmChainParams.Erc20CustodyContractAddress), - ) - btcSigner := mocks.NewBTCSigner() - evmObserver := mocks.NewEVMObserver(evmChainParams) - btcObserver := mocks.NewBTCObserver(btcChainParams) + // create maps to store signers and observers + signerMap := make(map[int64]interfaces.ChainSigner) + observerMap := make(map[int64]interfaces.ChainObserver) + + // a functor to add a signer and observer to the maps + addSignerObserver := func(chain *chains.Chain, signer interfaces.ChainSigner, observer interfaces.ChainObserver) { + signerMap[chain.ChainId] = signer + observerMap[chain.ChainId] = observer + } + + // create evm mock signer/observer + if evmChain != nil { + evmSigner := mocks.NewEVMSigner( + *evmChain, + ethcommon.HexToAddress(evmChainParams.ConnectorContractAddress), + ethcommon.HexToAddress(evmChainParams.Erc20CustodyContractAddress), + ) + evmObserver := mocks.NewEVMObserver(evmChainParams) + addSignerObserver(evmChain, evmSigner, evmObserver) + } + + // create btc mock signer/observer + if btcChain != nil { + btcSigner := mocks.NewBTCSigner() + btcObserver := mocks.NewBTCObserver(btcChainParams) + addSignerObserver(btcChain, btcSigner, btcObserver) + } + + // create solana mock signer/observer + if solChain != nil { + solSigner := mocks.NewSolanaSigner() + solObserver := mocks.NewSolanaObserver(solChainParams) + addSignerObserver(solChain, solSigner, solObserver) + } // create orchestrator orchestrator := &Orchestrator{ zetacoreClient: zetacoreClient, - signerMap: map[int64]interfaces.ChainSigner{ - evmChain.ChainId: evmSigner, - btcChain.ChainId: btcSigner, - }, - observerMap: map[int64]interfaces.ChainObserver{ - evmChain.ChainId: evmObserver, - btcChain.ChainId: btcObserver, - }, + signerMap: signerMap, + observerMap: observerMap, } return orchestrator } func CreateAppContext( - evmChain, btcChain chains.Chain, - evmChainParams, btcChainParams *observertypes.ChainParams, + evmChain, btcChain, solChain chains.Chain, + evmChainParams, btcChainParams, solChainParams *observertypes.ChainParams, ) *zctx.AppContext { // new config cfg := config.New(false) @@ -77,10 +97,10 @@ func CreateAppContext( // feed chain params appContext.Update( &observertypes.Keygen{}, - []chains.Chain{evmChain, btcChain}, + []chains.Chain{evmChain, btcChain, solChain}, evmChainParamsMap, btcChainParams, - nil, + solChainParams, "", *ccFlags, []chains.Chain{}, @@ -94,42 +114,92 @@ func Test_GetUpdatedSigner(t *testing.T) { // initial parameters for orchestrator creation evmChain := chains.Ethereum btcChain := chains.BitcoinMainnet + solChain := chains.SolanaMainnet evmChainParams := &observertypes.ChainParams{ ChainId: evmChain.ChainId, ConnectorContractAddress: testutils.ConnectorAddresses[evmChain.ChainId].Hex(), Erc20CustodyContractAddress: testutils.CustodyAddresses[evmChain.ChainId].Hex(), } btcChainParams := &observertypes.ChainParams{} + solChainParams := &observertypes.ChainParams{ + ChainId: solChain.ChainId, + GatewayAddress: solanacontracts.SolanaGatewayProgramID, + } - // new chain params in AppContext + // new evm chain params in AppContext evmChainParamsNew := &observertypes.ChainParams{ ChainId: evmChain.ChainId, ConnectorContractAddress: testutils.OtherAddress1, Erc20CustodyContractAddress: testutils.OtherAddress2, } - t.Run("signer should not be found", func(t *testing.T) { - orchestrator := MockOrchestrator(t, nil, evmChain, btcChain, evmChainParams, btcChainParams) - context := CreateAppContext(evmChain, btcChain, evmChainParamsNew, btcChainParams) + // new solana chain params in AppContext + solChainParamsNew := &observertypes.ChainParams{ + ChainId: solChain.ChainId, + GatewayAddress: sample.SolanaAddress(t), + } + + t.Run("evm signer should not be found", func(t *testing.T) { + orchestrator := MockOrchestrator( + t, + nil, + &evmChain, + &btcChain, + &solChain, + evmChainParams, + btcChainParams, + solChainParams, + ) + context := CreateAppContext(evmChain, btcChain, solChain, evmChainParamsNew, btcChainParams, solChainParams) + // BSC signer should not be found _, err := orchestrator.resolveSigner(context, chains.BscMainnet.ChainId) require.ErrorContains(t, err, "signer not found") }) - t.Run("should be able to update connector and erc20 custody address", func(t *testing.T) { - orchestrator := MockOrchestrator(t, nil, evmChain, btcChain, evmChainParams, btcChainParams) - context := CreateAppContext(evmChain, btcChain, evmChainParamsNew, btcChainParams) + t.Run("should be able to update evm connector and erc20 custody address", func(t *testing.T) { + orchestrator := MockOrchestrator( + t, + nil, + &evmChain, + &btcChain, + &solChain, + evmChainParams, + btcChainParams, + solChainParams, + ) + context := CreateAppContext(evmChain, btcChain, solChain, evmChainParamsNew, btcChainParams, solChainParams) + // update signer with new connector and erc20 custody address signer, err := orchestrator.resolveSigner(context, evmChain.ChainId) require.NoError(t, err) require.Equal(t, testutils.OtherAddress1, signer.GetZetaConnectorAddress().Hex()) require.Equal(t, testutils.OtherAddress2, signer.GetERC20CustodyAddress().Hex()) }) + t.Run("should be able to update solana gateway address", func(t *testing.T) { + orchestrator := MockOrchestrator( + t, + nil, + &evmChain, + &btcChain, + &solChain, + evmChainParams, + btcChainParams, + solChainParams, + ) + context := CreateAppContext(evmChain, btcChain, solChain, evmChainParams, btcChainParams, solChainParamsNew) + + // update signer with new gateway address + signer, err := orchestrator.resolveSigner(context, solChain.ChainId) + require.NoError(t, err) + require.Equal(t, solChainParamsNew.GatewayAddress, signer.GetGatewayAddress()) + }) } func Test_GetUpdatedChainObserver(t *testing.T) { // initial parameters for orchestrator creation evmChain := chains.Ethereum btcChain := chains.BitcoinMainnet + solChain := chains.SolanaMainnet evmChainParams := &observertypes.ChainParams{ ChainId: evmChain.ChainId, ConnectorContractAddress: testutils.ConnectorAddresses[evmChain.ChainId].Hex(), @@ -138,6 +208,10 @@ func Test_GetUpdatedChainObserver(t *testing.T) { btcChainParams := &observertypes.ChainParams{ ChainId: btcChain.ChainId, } + solChainParams := &observertypes.ChainParams{ + ChainId: solChain.ChainId, + GatewayAddress: solanacontracts.SolanaGatewayProgramID, + } // new chain params in AppContext evmChainParamsNew := &observertypes.ChainParams{ @@ -172,17 +246,51 @@ func Test_GetUpdatedChainObserver(t *testing.T) { MinObserverDelegation: sdk.OneDec(), IsSupported: true, } + solChainParamsNew := &observertypes.ChainParams{ + ChainId: solChain.ChainId, + ConfirmationCount: 10, + GasPriceTicker: 5, + InboundTicker: 6, + OutboundTicker: 6, + WatchUtxoTicker: 1, + ZetaTokenContractAddress: "", + ConnectorContractAddress: "", + Erc20CustodyContractAddress: "", + OutboundScheduleInterval: 10, + OutboundScheduleLookahead: 10, + BallotThreshold: sdk.OneDec(), + MinObserverDelegation: sdk.OneDec(), + IsSupported: true, + } t.Run("evm chain observer should not be found", func(t *testing.T) { - orchestrator := MockOrchestrator(t, nil, evmChain, btcChain, evmChainParams, btcChainParams) - appContext := CreateAppContext(evmChain, btcChain, evmChainParamsNew, btcChainParams) + orchestrator := MockOrchestrator( + t, + nil, + &evmChain, + &btcChain, + &solChain, + evmChainParams, + btcChainParams, + solChainParams, + ) + appContext := CreateAppContext(evmChain, btcChain, solChain, evmChainParamsNew, btcChainParams, solChainParams) // BSC chain observer should not be found _, err := orchestrator.resolveObserver(appContext, chains.BscMainnet.ChainId) require.ErrorContains(t, err, "observer not found") }) t.Run("chain params in evm chain observer should be updated successfully", func(t *testing.T) { - orchestrator := MockOrchestrator(t, nil, evmChain, btcChain, evmChainParams, btcChainParams) - appContext := CreateAppContext(evmChain, btcChain, evmChainParamsNew, btcChainParams) + orchestrator := MockOrchestrator( + t, + nil, + &evmChain, + &btcChain, + &solChain, + evmChainParams, + btcChainParams, + solChainParams, + ) + appContext := CreateAppContext(evmChain, btcChain, solChain, evmChainParamsNew, btcChainParams, solChainParams) // update evm chain observer with new chain params chainOb, err := orchestrator.resolveObserver(appContext, evmChain.ChainId) require.NoError(t, err) @@ -190,21 +298,73 @@ func Test_GetUpdatedChainObserver(t *testing.T) { require.True(t, observertypes.ChainParamsEqual(*evmChainParamsNew, chainOb.GetChainParams())) }) t.Run("btc chain observer should not be found", func(t *testing.T) { - orchestrator := MockOrchestrator(t, nil, evmChain, btcChain, evmChainParams, btcChainParams) - appContext := CreateAppContext(btcChain, btcChain, evmChainParams, btcChainParamsNew) + orchestrator := MockOrchestrator( + t, + nil, + &evmChain, + &btcChain, + &solChain, + evmChainParams, + btcChainParams, + solChainParams, + ) + appContext := CreateAppContext(btcChain, btcChain, solChain, evmChainParams, btcChainParamsNew, solChainParams) // BTC testnet chain observer should not be found _, err := orchestrator.resolveObserver(appContext, chains.BitcoinTestnet.ChainId) require.ErrorContains(t, err, "observer not found") }) t.Run("chain params in btc chain observer should be updated successfully", func(t *testing.T) { - orchestrator := MockOrchestrator(t, nil, evmChain, btcChain, evmChainParams, btcChainParams) - appContext := CreateAppContext(btcChain, btcChain, evmChainParams, btcChainParamsNew) + orchestrator := MockOrchestrator( + t, + nil, + &evmChain, + &btcChain, + &solChain, + evmChainParams, + btcChainParams, + solChainParams, + ) + appContext := CreateAppContext(btcChain, btcChain, solChain, evmChainParams, btcChainParamsNew, solChainParams) // update btc chain observer with new chain params chainOb, err := orchestrator.resolveObserver(appContext, btcChain.ChainId) require.NoError(t, err) require.NotNil(t, chainOb) require.True(t, observertypes.ChainParamsEqual(*btcChainParamsNew, chainOb.GetChainParams())) }) + t.Run("solana chain observer should not be found", func(t *testing.T) { + orchestrator := MockOrchestrator( + t, + nil, + &evmChain, + &btcChain, + &solChain, + evmChainParams, + btcChainParams, + solChainParams, + ) + appContext := CreateAppContext(solChain, btcChain, solChain, evmChainParams, btcChainParams, solChainParamsNew) + // Solana Devnet chain observer should not be found + _, err := orchestrator.resolveObserver(appContext, chains.SolanaDevnet.ChainId) + require.ErrorContains(t, err, "observer not found") + }) + t.Run("chain params in solana chain observer should be updated successfully", func(t *testing.T) { + orchestrator := MockOrchestrator( + t, + nil, + &evmChain, + &btcChain, + &solChain, + evmChainParams, + btcChainParams, + solChainParams, + ) + appContext := CreateAppContext(solChain, btcChain, solChain, evmChainParams, btcChainParams, solChainParamsNew) + // update solana chain observer with new chain params + chainOb, err := orchestrator.resolveObserver(appContext, solChain.ChainId) + require.NoError(t, err) + require.NotNil(t, chainOb) + require.True(t, observertypes.ChainParamsEqual(*solChainParamsNew, chainOb.GetChainParams())) + }) } func Test_GetPendingCctxsWithinRateLimit(t *testing.T) { @@ -367,7 +527,7 @@ func Test_GetPendingCctxsWithinRateLimit(t *testing.T) { client.WithPendingCctx(btcChain.ChainId, tt.btcCctxsFallback) // create orchestrator - orchestrator := MockOrchestrator(t, client, ethChain, btcChain, ethChainParams, btcChainParams) + orchestrator := MockOrchestrator(t, client, ðChain, &btcChain, nil, ethChainParams, btcChainParams, nil) // run the test cctxsMap, err := orchestrator.GetPendingCctxsWithinRateLimit(ctx, foreignChains) diff --git a/zetaclient/testdata/solana/chain_901_outbound_tx_result_5iBYjBYCphzjHKfmPwddMWpV2RNssmzk9Z8NNmV9Rei71pZKBTEVdkmUeyXfn7eWbV8932uSsPfBxgA7UgERNTvq.json b/zetaclient/testdata/solana/chain_901_outbound_tx_result_5iBYjBYCphzjHKfmPwddMWpV2RNssmzk9Z8NNmV9Rei71pZKBTEVdkmUeyXfn7eWbV8932uSsPfBxgA7UgERNTvq.json new file mode 100644 index 0000000000..068a1bcea9 --- /dev/null +++ b/zetaclient/testdata/solana/chain_901_outbound_tx_result_5iBYjBYCphzjHKfmPwddMWpV2RNssmzk9Z8NNmV9Rei71pZKBTEVdkmUeyXfn7eWbV8932uSsPfBxgA7UgERNTvq.json @@ -0,0 +1,52 @@ +{ + "slot": 313938016, + "blockTime": 1721837007, + "transaction": { + "signatures": [ + "5iBYjBYCphzjHKfmPwddMWpV2RNssmzk9Z8NNmV9Rei71pZKBTEVdkmUeyXfn7eWbV8932uSsPfBxgA7UgERNTvq" + ], + "message": { + "accountKeys": [ + "AKbG83jg2V65R7XvaPFrnUvUTWsFENEzDPbLJFEiAk6L", + "4pA5vqGeo4ipLoJzH3rdvguhifj1tCzoNM8vDRc4Xbmq", + "63AA8QAokSfoTDxqPXn8UsnYLSFas6MuGt6PyAAkfnr8", + "2kJndCL9NBR36ySiQ4bmArs4YgWQu67LmCDfLzk5Gb7s" + ], + "header": { + "numRequiredSignatures": 1, + "numReadonlySignedAccounts": 0, + "numReadonlyUnsignedAccounts": 1 + }, + "recentBlockhash": "FuPpUtJXMbtM4tP4dhLi5nJKYANmVHfmogov8exp1uYB", + "instructions": [ + { + "programIdIndex": 3, + "accounts": [0, 1, 2, 3], + "data": "2w17NoTLyzKUXw8xjdycsviQm9PxEF1weowhTVmYRKQaNkDVmEZnyeZEBFvu6qeYMAEKyRNdodzyzDgerQT96zmkRg2ECU4XPJ1GdsaiLDArWxB4WR8AaUFDnLwvkwM2kYZb133VKo2GD1Uz63sEiTn6aUGh4ptdiCd9R1" + } + ] + } + }, + "meta": { + "err": null, + "fee": 5000, + "preBalances": [7171092800, 1001448960, 0, 1141440], + "postBalances": [7171087800, 1000558080, 890880, 1141440], + "innerInstructions": [], + "preTokenBalances": [], + "postTokenBalances": [], + "logMessages": [ + "Program 2kJndCL9NBR36ySiQ4bmArs4YgWQu67LmCDfLzk5Gb7s invoke [1]", + "Program log: Instruction: Withdraw", + "Program log: recovered address [5, 199, 219, 221, 25, 84, 213, 156, 154, 250, 184, 72, 218, 125, 141, 211, 243, 94, 105, 205]", + "Program log: recovered address [5, 199, 219, 221, 25, 84, 213, 156, 154, 250, 184, 72, 218, 125, 141, 211, 243, 94, 105, 205]", + "Program 2kJndCL9NBR36ySiQ4bmArs4YgWQu67LmCDfLzk5Gb7s consumed 40719 of 200000 compute units", + "Program 2kJndCL9NBR36ySiQ4bmArs4YgWQu67LmCDfLzk5Gb7s success" + ], + "status": { "Ok": null }, + "rewards": [], + "loadedAddresses": { "readonly": [], "writable": [] }, + "computeUnitsConsumed": 40719 + }, + "version": 0 +} diff --git a/zetaclient/testdata/solana/chain_901_outbound_tx_result_5nFUQgNSdqTd4aPS4a1xNcbehj19hDzuQLfBqFRj8g7BJdESVY6hFuTFPWFuV6aWAfzEMfVfCdNu9DfzVp5FsHg5.json b/zetaclient/testdata/solana/chain_901_outbound_tx_result_5nFUQgNSdqTd4aPS4a1xNcbehj19hDzuQLfBqFRj8g7BJdESVY6hFuTFPWFuV6aWAfzEMfVfCdNu9DfzVp5FsHg5.json new file mode 100644 index 0000000000..bf9182a719 --- /dev/null +++ b/zetaclient/testdata/solana/chain_901_outbound_tx_result_5nFUQgNSdqTd4aPS4a1xNcbehj19hDzuQLfBqFRj8g7BJdESVY6hFuTFPWFuV6aWAfzEMfVfCdNu9DfzVp5FsHg5.json @@ -0,0 +1,52 @@ +{ + "slot": 314424406, + "blockTime": 1722020059, + "transaction": { + "signatures": [ + "5nFUQgNSdqTd4aPS4a1xNcbehj19hDzuQLfBqFRj8g7BJdESVY6hFuTFPWFuV6aWAfzEMfVfCdNu9DfzVp5FsHg5" + ], + "message": { + "accountKeys": [ + "AKbG83jg2V65R7XvaPFrnUvUTWsFENEzDPbLJFEiAk6L", + "4pA5vqGeo4ipLoJzH3rdvguhifj1tCzoNM8vDRc4Xbmq", + "9fA4vYZfCa9k9UHjnvYCk4YoipsooapGciKMgaTBw9UH", + "2kJndCL9NBR36ySiQ4bmArs4YgWQu67LmCDfLzk5Gb7s" + ], + "header": { + "numRequiredSignatures": 1, + "numReadonlySignedAccounts": 0, + "numReadonlyUnsignedAccounts": 1 + }, + "recentBlockhash": "77YRY3YAi9Yvp7V68QJHMkNKRqYAZ7GSAYVDPomCs74J", + "instructions": [ + { + "programIdIndex": 3, + "accounts": [0, 1, 2, 3], + "data": "2w17NoTLyzKYBFJZsc7f4RUTW5fBUqVdzamb3zt37gRNDgSipheFFs83ZMzPDBM363UDMRAcVrEcdcdpnD7nooKwsZRogVp3xv1zgVE9t95m7KrL7DZgHh85wvzePeVoraWzLgQLPuktDHg5FkeDN2L14xjeBu1S8ZQPHH" + } + ] + } + }, + "meta": { + "err": { "InstructionError": [0, { "Custom": 6002 }] }, + "fee": 5000, + "preBalances": [7171082800, 1000558080, 0, 1141440], + "postBalances": [7171077800, 1000558080, 0, 1141440], + "innerInstructions": [], + "preTokenBalances": [], + "postTokenBalances": [], + "logMessages": [ + "Program 2kJndCL9NBR36ySiQ4bmArs4YgWQu67LmCDfLzk5Gb7s invoke [1]", + "Program log: Instruction: Withdraw", + "Program log: mismatch nonce", + "Program log: AnchorError thrown in programs/protocol-contracts-solana/src/lib.rs:109. Error Code: NonceMismatch. Error Number: 6002. Error Message: NonceMismatch.", + "Program 2kJndCL9NBR36ySiQ4bmArs4YgWQu67LmCDfLzk5Gb7s consumed 4801 of 200000 compute units", + "Program 2kJndCL9NBR36ySiQ4bmArs4YgWQu67LmCDfLzk5Gb7s failed: custom program error: 0x1772" + ], + "status": { "Err": { "InstructionError": [0, { "Custom": 6002 }] } }, + "rewards": [], + "loadedAddresses": { "readonly": [], "writable": [] }, + "computeUnitsConsumed": 4801 + }, + "version": 0 +} diff --git a/zetaclient/testutils/mocks/chain_clients.go b/zetaclient/testutils/mocks/chain_clients.go index 6f004420e5..0c02dff5ca 100644 --- a/zetaclient/testutils/mocks/chain_clients.go +++ b/zetaclient/testutils/mocks/chain_clients.go @@ -3,8 +3,6 @@ package mocks import ( "context" - "github.com/rs/zerolog" - crosschaintypes "github.com/zeta-chain/zetacore/x/crosschain/types" observertypes "github.com/zeta-chain/zetacore/x/observer/types" "github.com/zeta-chain/zetacore/zetaclient/chains/interfaces" @@ -29,12 +27,11 @@ func NewEVMObserver(chainParams *observertypes.ChainParams) *EVMObserver { func (ob *EVMObserver) Start(_ context.Context) {} func (ob *EVMObserver) Stop() {} -func (ob *EVMObserver) IsOutboundProcessed( +func (ob *EVMObserver) VoteOutboundIfConfirmed( _ context.Context, _ *crosschaintypes.CrossChainTx, - _ zerolog.Logger, -) (bool, bool, error) { - return false, false, nil +) (bool, error) { + return false, nil } func (ob *EVMObserver) SetChainParams(chainParams observertypes.ChainParams) { @@ -73,12 +70,11 @@ func (ob *BTCObserver) Start(_ context.Context) {} func (ob *BTCObserver) Stop() {} -func (ob *BTCObserver) IsOutboundProcessed( +func (ob *BTCObserver) VoteOutboundIfConfirmed( _ context.Context, _ *crosschaintypes.CrossChainTx, - _ zerolog.Logger, -) (bool, bool, error) { - return false, false, nil +) (bool, error) { + return false, nil } func (ob *BTCObserver) SetChainParams(chainParams observertypes.ChainParams) { @@ -94,3 +90,44 @@ func (ob *BTCObserver) GetTxID(_ uint64) string { } func (ob *BTCObserver) WatchInboundTracker(_ context.Context) error { return nil } + +// ---------------------------------------------------------------------------- +// SolanaObserver +// ---------------------------------------------------------------------------- +var _ interfaces.ChainObserver = (*SolanaObserver)(nil) + +// SolanaObserver is a mock of solana chain observer for testing +type SolanaObserver struct { + ChainParams observertypes.ChainParams +} + +func NewSolanaObserver(chainParams *observertypes.ChainParams) *SolanaObserver { + return &SolanaObserver{ + ChainParams: *chainParams, + } +} + +func (ob *SolanaObserver) Start(_ context.Context) {} + +func (ob *SolanaObserver) Stop() {} + +func (ob *SolanaObserver) VoteOutboundIfConfirmed( + _ context.Context, + _ *crosschaintypes.CrossChainTx, +) (bool, error) { + return false, nil +} + +func (ob *SolanaObserver) SetChainParams(chainParams observertypes.ChainParams) { + ob.ChainParams = chainParams +} + +func (ob *SolanaObserver) GetChainParams() observertypes.ChainParams { + return ob.ChainParams +} + +func (ob *SolanaObserver) GetTxID(_ uint64) string { + return "" +} + +func (ob *SolanaObserver) WatchInboundTracker(_ context.Context) error { return nil } diff --git a/zetaclient/testutils/mocks/chain_signer.go b/zetaclient/testutils/mocks/chain_signer.go index 3785c34ee0..cb8080a958 100644 --- a/zetaclient/testutils/mocks/chain_signer.go +++ b/zetaclient/testutils/mocks/chain_signer.go @@ -46,6 +46,13 @@ func (s *EVMSigner) TryProcessOutbound( ) { } +func (s *EVMSigner) SetGatewayAddress(_ string) { +} + +func (s *EVMSigner) GetGatewayAddress() string { + return "" +} + func (s *EVMSigner) SetZetaConnectorAddress(address ethcommon.Address) { s.ZetaConnectorAddress = address } @@ -86,6 +93,13 @@ func (s *BTCSigner) TryProcessOutbound( ) { } +func (s *BTCSigner) SetGatewayAddress(_ string) { +} + +func (s *BTCSigner) GetGatewayAddress() string { + return "" +} + func (s *BTCSigner) SetZetaConnectorAddress(_ ethcommon.Address) { } @@ -99,3 +113,50 @@ func (s *BTCSigner) GetZetaConnectorAddress() ethcommon.Address { func (s *BTCSigner) GetERC20CustodyAddress() ethcommon.Address { return ethcommon.Address{} } + +// ---------------------------------------------------------------------------- +// SolanaSigner +// ---------------------------------------------------------------------------- +var _ interfaces.ChainSigner = (*SolanaSigner)(nil) + +// SolanaSigner is a mock of solana chain signer for testing +type SolanaSigner struct { + GatewayAddress string +} + +func NewSolanaSigner() *SolanaSigner { + return &SolanaSigner{} +} + +func (s *SolanaSigner) TryProcessOutbound( + _ context.Context, + _ *crosschaintypes.CrossChainTx, + _ *outboundprocessor.Processor, + _ string, + _ interfaces.ChainObserver, + _ interfaces.ZetacoreClient, + _ uint64, +) { +} + +func (s *SolanaSigner) SetGatewayAddress(address string) { + s.GatewayAddress = address +} + +func (s *SolanaSigner) GetGatewayAddress() string { + return s.GatewayAddress +} + +func (s *SolanaSigner) SetZetaConnectorAddress(_ ethcommon.Address) { +} + +func (s *SolanaSigner) SetERC20CustodyAddress(_ ethcommon.Address) { +} + +func (s *SolanaSigner) GetZetaConnectorAddress() ethcommon.Address { + return ethcommon.Address{} +} + +func (s *SolanaSigner) GetERC20CustodyAddress() ethcommon.Address { + return ethcommon.Address{} +} diff --git a/zetaclient/testutils/mocks/solana_rpc.go b/zetaclient/testutils/mocks/solana_rpc.go new file mode 100644 index 0000000000..953bde87e5 --- /dev/null +++ b/zetaclient/testutils/mocks/solana_rpc.go @@ -0,0 +1,328 @@ +// Code generated by mockery v2.38.0. DO NOT EDIT. + +package mocks + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" + + rpc "github.com/gagliardetto/solana-go/rpc" + + solana "github.com/gagliardetto/solana-go" +) + +// SolanaRPCClient is an autogenerated mock type for the SolanaRPCClient type +type SolanaRPCClient struct { + mock.Mock +} + +// GetAccountInfo provides a mock function with given fields: ctx, account +func (_m *SolanaRPCClient) GetAccountInfo(ctx context.Context, account solana.PublicKey) (*rpc.GetAccountInfoResult, error) { + ret := _m.Called(ctx, account) + + if len(ret) == 0 { + panic("no return value specified for GetAccountInfo") + } + + var r0 *rpc.GetAccountInfoResult + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, solana.PublicKey) (*rpc.GetAccountInfoResult, error)); ok { + return rf(ctx, account) + } + if rf, ok := ret.Get(0).(func(context.Context, solana.PublicKey) *rpc.GetAccountInfoResult); ok { + r0 = rf(ctx, account) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*rpc.GetAccountInfoResult) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, solana.PublicKey) error); ok { + r1 = rf(ctx, account) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetConfirmedTransactionWithOpts provides a mock function with given fields: ctx, signature, opts +func (_m *SolanaRPCClient) GetConfirmedTransactionWithOpts(ctx context.Context, signature solana.Signature, opts *rpc.GetTransactionOpts) (*rpc.TransactionWithMeta, error) { + ret := _m.Called(ctx, signature, opts) + + if len(ret) == 0 { + panic("no return value specified for GetConfirmedTransactionWithOpts") + } + + var r0 *rpc.TransactionWithMeta + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, solana.Signature, *rpc.GetTransactionOpts) (*rpc.TransactionWithMeta, error)); ok { + return rf(ctx, signature, opts) + } + if rf, ok := ret.Get(0).(func(context.Context, solana.Signature, *rpc.GetTransactionOpts) *rpc.TransactionWithMeta); ok { + r0 = rf(ctx, signature, opts) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*rpc.TransactionWithMeta) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, solana.Signature, *rpc.GetTransactionOpts) error); ok { + r1 = rf(ctx, signature, opts) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetHealth provides a mock function with given fields: ctx +func (_m *SolanaRPCClient) GetHealth(ctx context.Context) (string, error) { + ret := _m.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for GetHealth") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func(context.Context) (string, error)); ok { + return rf(ctx) + } + if rf, ok := ret.Get(0).(func(context.Context) string); ok { + r0 = rf(ctx) + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetRecentBlockhash provides a mock function with given fields: ctx, commitment +func (_m *SolanaRPCClient) GetRecentBlockhash(ctx context.Context, commitment rpc.CommitmentType) (*rpc.GetRecentBlockhashResult, error) { + ret := _m.Called(ctx, commitment) + + if len(ret) == 0 { + panic("no return value specified for GetRecentBlockhash") + } + + var r0 *rpc.GetRecentBlockhashResult + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, rpc.CommitmentType) (*rpc.GetRecentBlockhashResult, error)); ok { + return rf(ctx, commitment) + } + if rf, ok := ret.Get(0).(func(context.Context, rpc.CommitmentType) *rpc.GetRecentBlockhashResult); ok { + r0 = rf(ctx, commitment) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*rpc.GetRecentBlockhashResult) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, rpc.CommitmentType) error); ok { + r1 = rf(ctx, commitment) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetRecentPrioritizationFees provides a mock function with given fields: ctx, accounts +func (_m *SolanaRPCClient) GetRecentPrioritizationFees(ctx context.Context, accounts solana.PublicKeySlice) ([]rpc.PriorizationFeeResult, error) { + ret := _m.Called(ctx, accounts) + + if len(ret) == 0 { + panic("no return value specified for GetRecentPrioritizationFees") + } + + var r0 []rpc.PriorizationFeeResult + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, solana.PublicKeySlice) ([]rpc.PriorizationFeeResult, error)); ok { + return rf(ctx, accounts) + } + if rf, ok := ret.Get(0).(func(context.Context, solana.PublicKeySlice) []rpc.PriorizationFeeResult); ok { + r0 = rf(ctx, accounts) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]rpc.PriorizationFeeResult) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, solana.PublicKeySlice) error); ok { + r1 = rf(ctx, accounts) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetSignaturesForAddressWithOpts provides a mock function with given fields: ctx, account, opts +func (_m *SolanaRPCClient) GetSignaturesForAddressWithOpts(ctx context.Context, account solana.PublicKey, opts *rpc.GetSignaturesForAddressOpts) ([]*rpc.TransactionSignature, error) { + ret := _m.Called(ctx, account, opts) + + if len(ret) == 0 { + panic("no return value specified for GetSignaturesForAddressWithOpts") + } + + var r0 []*rpc.TransactionSignature + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, solana.PublicKey, *rpc.GetSignaturesForAddressOpts) ([]*rpc.TransactionSignature, error)); ok { + return rf(ctx, account, opts) + } + if rf, ok := ret.Get(0).(func(context.Context, solana.PublicKey, *rpc.GetSignaturesForAddressOpts) []*rpc.TransactionSignature); ok { + r0 = rf(ctx, account, opts) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*rpc.TransactionSignature) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, solana.PublicKey, *rpc.GetSignaturesForAddressOpts) error); ok { + r1 = rf(ctx, account, opts) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetSlot provides a mock function with given fields: ctx, commitment +func (_m *SolanaRPCClient) GetSlot(ctx context.Context, commitment rpc.CommitmentType) (uint64, error) { + ret := _m.Called(ctx, commitment) + + if len(ret) == 0 { + panic("no return value specified for GetSlot") + } + + var r0 uint64 + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, rpc.CommitmentType) (uint64, error)); ok { + return rf(ctx, commitment) + } + if rf, ok := ret.Get(0).(func(context.Context, rpc.CommitmentType) uint64); ok { + r0 = rf(ctx, commitment) + } else { + r0 = ret.Get(0).(uint64) + } + + if rf, ok := ret.Get(1).(func(context.Context, rpc.CommitmentType) error); ok { + r1 = rf(ctx, commitment) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetTransaction provides a mock function with given fields: ctx, txSig, opts +func (_m *SolanaRPCClient) GetTransaction(ctx context.Context, txSig solana.Signature, opts *rpc.GetTransactionOpts) (*rpc.GetTransactionResult, error) { + ret := _m.Called(ctx, txSig, opts) + + if len(ret) == 0 { + panic("no return value specified for GetTransaction") + } + + var r0 *rpc.GetTransactionResult + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, solana.Signature, *rpc.GetTransactionOpts) (*rpc.GetTransactionResult, error)); ok { + return rf(ctx, txSig, opts) + } + if rf, ok := ret.Get(0).(func(context.Context, solana.Signature, *rpc.GetTransactionOpts) *rpc.GetTransactionResult); ok { + r0 = rf(ctx, txSig, opts) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*rpc.GetTransactionResult) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, solana.Signature, *rpc.GetTransactionOpts) error); ok { + r1 = rf(ctx, txSig, opts) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetVersion provides a mock function with given fields: ctx +func (_m *SolanaRPCClient) GetVersion(ctx context.Context) (*rpc.GetVersionResult, error) { + ret := _m.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for GetVersion") + } + + var r0 *rpc.GetVersionResult + var r1 error + if rf, ok := ret.Get(0).(func(context.Context) (*rpc.GetVersionResult, error)); ok { + return rf(ctx) + } + if rf, ok := ret.Get(0).(func(context.Context) *rpc.GetVersionResult); ok { + r0 = rf(ctx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*rpc.GetVersionResult) + } + } + + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// SendTransactionWithOpts provides a mock function with given fields: ctx, transaction, opts +func (_m *SolanaRPCClient) SendTransactionWithOpts(ctx context.Context, transaction *solana.Transaction, opts rpc.TransactionOpts) (solana.Signature, error) { + ret := _m.Called(ctx, transaction, opts) + + if len(ret) == 0 { + panic("no return value specified for SendTransactionWithOpts") + } + + var r0 solana.Signature + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *solana.Transaction, rpc.TransactionOpts) (solana.Signature, error)); ok { + return rf(ctx, transaction, opts) + } + if rf, ok := ret.Get(0).(func(context.Context, *solana.Transaction, rpc.TransactionOpts) solana.Signature); ok { + r0 = rf(ctx, transaction, opts) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(solana.Signature) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *solana.Transaction, rpc.TransactionOpts) error); ok { + r1 = rf(ctx, transaction, opts) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// NewSolanaRPCClient creates a new instance of SolanaRPCClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewSolanaRPCClient(t interface { + mock.TestingT + Cleanup(func()) +}) *SolanaRPCClient { + mock := &SolanaRPCClient{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/zetaclient/testutils/testdata.go b/zetaclient/testutils/testdata.go index ec97d6d1c4..146a73e243 100644 --- a/zetaclient/testutils/testdata.go +++ b/zetaclient/testutils/testdata.go @@ -309,6 +309,19 @@ func LoadSolanaInboundTxResult( return txResult } +// LoadSolanaOutboundTxResult loads archived Solana outbound tx result from file +func LoadSolanaOutboundTxResult( + t *testing.T, + dir string, + chainID int64, + txHash string, +) *rpc.GetTransactionResult { + name := path.Join(dir, TestDataPathSolana, FileNameSolanaOutbound(chainID, txHash)) + txResult := &rpc.GetTransactionResult{} + LoadObjectFromJSONFile(t, txResult, name) + return txResult +} + //============================================================================== // other helpers methods diff --git a/zetaclient/testutils/testdata_naming.go b/zetaclient/testutils/testdata_naming.go index 940d475780..10a705ccef 100644 --- a/zetaclient/testutils/testdata_naming.go +++ b/zetaclient/testutils/testdata_naming.go @@ -88,3 +88,8 @@ func FileNameSolanaInbound(chainID int64, inboundHash string, donation bool) str } return fmt.Sprintf("chain_%d_inbound_tx_result_donation_%s.json", chainID, inboundHash) } + +// FileNameSolanaOutbound returns archive file name for outbound tx result +func FileNameSolanaOutbound(chainID int64, txHash string) string { + return fmt.Sprintf("chain_%d_outbound_tx_result_%s.json", chainID, txHash) +} diff --git a/zetaclient/zetacore/client_vote.go b/zetaclient/zetacore/client_vote.go index 49eea1f813..4883eb7677 100644 --- a/zetaclient/zetacore/client_vote.go +++ b/zetaclient/zetacore/client_vote.go @@ -48,11 +48,8 @@ func (c *Client) PostVoteGasPrice( chain chains.Chain, gasPrice uint64, priorityFee, blockNum uint64, ) (string, error) { - // apply gas price multiplier for the chain - multiplier, err := GasPriceMultiplier(chain) - if err != nil { - return "", err - } + // get gas price multiplier for the chain + multiplier := GasPriceMultiplier(chain) // #nosec G115 always in range gasPrice = uint64(float64(gasPrice) * multiplier) diff --git a/zetaclient/zetacore/tx.go b/zetaclient/zetacore/tx.go index 90a6b1d136..fb57cd449b 100644 --- a/zetaclient/zetacore/tx.go +++ b/zetaclient/zetacore/tx.go @@ -2,7 +2,6 @@ package zetacore import ( "context" - "fmt" "strings" "cosmossdk.io/errors" @@ -56,13 +55,15 @@ func GetInboundVoteMessage( } // GasPriceMultiplier returns the gas price multiplier for the given chain -func GasPriceMultiplier(chain chains.Chain) (float64, error) { - if chain.IsEVMChain() { - return clientcommon.EVMOutboundGasPriceMultiplier, nil - } else if chain.IsBitcoinChain() { - return clientcommon.BTCOutboundGasPriceMultiplier, nil +func GasPriceMultiplier(chain chains.Chain) float64 { + switch chain.Consensus { + case chains.Consensus_ethereum: + return clientcommon.EVMOutboundGasPriceMultiplier + case chains.Consensus_bitcoin: + return clientcommon.BTCOutboundGasPriceMultiplier + default: + return clientcommon.DefaultGasPriceMultiplier } - return 0, fmt.Errorf("cannot get gas price multiplier for unknown chain %d", chain.ChainId) } // WrapMessageWithAuthz wraps a message with an authz message diff --git a/zetaclient/zetacore/tx_test.go b/zetaclient/zetacore/tx_test.go index 651e39266e..e5100ef3c7 100644 --- a/zetaclient/zetacore/tx_test.go +++ b/zetaclient/zetacore/tx_test.go @@ -46,73 +46,61 @@ func Test_GasPriceMultiplier(t *testing.T) { name string chain chains.Chain multiplier float64 - fail bool }{ { name: "get Ethereum multiplier", chain: chains.Ethereum, multiplier: 1.2, - fail: false, }, { name: "get Goerli multiplier", chain: chains.Goerli, multiplier: 1.2, - fail: false, }, { name: "get BSC multiplier", chain: chains.BscMainnet, multiplier: 1.2, - fail: false, }, { name: "get BSC Testnet multiplier", chain: chains.BscTestnet, multiplier: 1.2, - fail: false, }, { name: "get Polygon multiplier", chain: chains.Polygon, multiplier: 1.2, - fail: false, }, { name: "get Mumbai Testnet multiplier", chain: chains.Mumbai, multiplier: 1.2, - fail: false, }, { name: "get Bitcoin multiplier", chain: chains.BitcoinMainnet, multiplier: 2.0, - fail: false, }, { name: "get Bitcoin Testnet multiplier", chain: chains.BitcoinTestnet, multiplier: 2.0, - fail: false, }, { - name: "get unknown chain gas price multiplier", - chain: chains.Chain{ - Consensus: chains.Consensus_tendermint, - }, + name: "get Solana multiplier", + chain: chains.SolanaMainnet, + multiplier: 1.0, + }, + { + name: "get Solana devnet multiplier", + chain: chains.SolanaDevnet, multiplier: 1.0, - fail: true, }, } for _, tc := range tt { t.Run(tc.name, func(t *testing.T) { - multiplier, err := GasPriceMultiplier(tc.chain) - if tc.fail { - require.Error(t, err) - return - } - require.NoError(t, err) + multiplier := GasPriceMultiplier(tc.chain) require.Equal(t, tc.multiplier, multiplier) }) }