diff --git a/changelog.md b/changelog.md index e3c0336caa..ac4e4c351c 100644 --- a/changelog.md +++ b/changelog.md @@ -6,6 +6,7 @@ * [2984](https://github.com/zeta-chain/node/pull/2984) - add Whitelist message ability to whitelist SPL tokens on Solana * [3091](https://github.com/zeta-chain/node/pull/3091) - improve build reproducability. `make release{,-build-only}` checksums should now be stable. * [3124](https://github.com/zeta-chain/node/pull/3124) - integrate SPL deposits +* [3134](https://github.com/zeta-chain/node/pull/3134) - integrate SPL tokens withdraw to Solana ### Tests diff --git a/cmd/zetae2e/local/local.go b/cmd/zetae2e/local/local.go index a170929cbb..5507d95014 100644 --- a/cmd/zetae2e/local/local.go +++ b/cmd/zetae2e/local/local.go @@ -438,9 +438,11 @@ func localE2ETest(cmd *cobra.Command, _ []string) { e2etests.TestSolanaWithdrawRestrictedName, // TODO move under admin tests // https://github.com/zeta-chain/node/issues/3085 - e2etests.TestSolanaWhitelistSPLName, e2etests.TestSPLDepositName, e2etests.TestSPLDepositAndCallName, + e2etests.TestSPLWithdrawName, + e2etests.TestSPLWithdrawAndCreateReceiverAtaName, + e2etests.TestSolanaWhitelistSPLName, } eg.Go(solanaTestRoutine(conf, deployerRunner, verbose, solanaTests...)) } diff --git a/contrib/localnet/solana/start-solana.sh b/contrib/localnet/solana/start-solana.sh index d87e9672ae..73a4564f56 100644 --- a/contrib/localnet/solana/start-solana.sh +++ b/contrib/localnet/solana/start-solana.sh @@ -8,9 +8,10 @@ echo "starting solana test validator..." solana-test-validator & sleep 5 -# airdrop to e2e sol account +# airdrop to e2e sol account and rent payer (used to generate atas for withdraw spl receivers if they don't exist) solana airdrop 100 solana airdrop 100 37yGiHAnLvWZUNVwu9esp74YQFqxU1qHCbABkDvRddUQ +solana airdrop 100 C6KPvGDYfNusoE4yfRP21F8wK35bxCBMT69xk4xo3X79 solana program deploy gateway.so diff --git a/e2e/e2etests/e2etests.go b/e2e/e2etests/e2etests.go index 3b5ef2ed00..ee4b8d92ce 100644 --- a/e2e/e2etests/e2etests.go +++ b/e2e/e2etests/e2etests.go @@ -55,14 +55,16 @@ const ( /* * Solana tests */ - TestSolanaDepositName = "solana_deposit" - TestSolanaWithdrawName = "solana_withdraw" - TestSolanaDepositAndCallName = "solana_deposit_and_call" - TestSolanaDepositAndCallRefundName = "solana_deposit_and_call_refund" - TestSolanaDepositRestrictedName = "solana_deposit_restricted" - TestSolanaWithdrawRestrictedName = "solana_withdraw_restricted" - TestSPLDepositName = "spl_deposit" - TestSPLDepositAndCallName = "spl_deposit_and_call" + TestSolanaDepositName = "solana_deposit" + TestSolanaWithdrawName = "solana_withdraw" + TestSolanaDepositAndCallName = "solana_deposit_and_call" + TestSolanaDepositAndCallRefundName = "solana_deposit_and_call_refund" + TestSolanaDepositRestrictedName = "solana_deposit_restricted" + TestSolanaWithdrawRestrictedName = "solana_withdraw_restricted" + TestSPLDepositName = "spl_deposit" + TestSPLDepositAndCallName = "spl_deposit_and_call" + TestSPLWithdrawName = "spl_withdraw" + TestSPLWithdrawAndCreateReceiverAtaName = "spl_withdraw_and_create_receiver_ata" /** * TON tests @@ -434,6 +436,22 @@ var AllE2ETests = []runner.E2ETest{ }, TestSolanaDepositAndCall, ), + runner.NewE2ETest( + TestSPLWithdrawName, + "withdraw SPL from ZEVM", + []runner.ArgDefinition{ + {Description: "amount in spl tokens", DefaultValue: "1000000"}, + }, + TestSPLWithdraw, + ), + runner.NewE2ETest( + TestSPLWithdrawAndCreateReceiverAtaName, + "withdraw SPL from ZEVM and create receiver ata", + []runner.ArgDefinition{ + {Description: "amount in spl tokens", DefaultValue: "1000000"}, + }, + TestSPLWithdrawAndCreateReceiverAta, + ), runner.NewE2ETest( TestSolanaDepositAndCallRefundName, "deposit SOL into ZEVM and call a contract that reverts; should refund", @@ -470,7 +488,7 @@ var AllE2ETests = []runner.E2ETest{ TestSPLDepositName, "deposit SPL into ZEVM", []runner.ArgDefinition{ - {Description: "amount of spl tokens", DefaultValue: "500000"}, + {Description: "amount of spl tokens", DefaultValue: "12000000"}, }, TestSPLDeposit, ), @@ -478,7 +496,7 @@ var AllE2ETests = []runner.E2ETest{ TestSPLDepositAndCallName, "deposit SPL into ZEVM and call", []runner.ArgDefinition{ - {Description: "amount of spl tokens", DefaultValue: "500000"}, + {Description: "amount of spl tokens", DefaultValue: "12000000"}, }, TestSPLDepositAndCall, ), diff --git a/e2e/e2etests/test_solana_whitelist_spl.go b/e2e/e2etests/test_solana_whitelist_spl.go index fadff22805..c07bdabb12 100644 --- a/e2e/e2etests/test_solana_whitelist_spl.go +++ b/e2e/e2etests/test_solana_whitelist_spl.go @@ -16,8 +16,7 @@ func TestSolanaWhitelistSPL(r *runner.E2ERunner, _ []string) { r.Logger.Info("Deploying new SPL") // load deployer private key - privkey, err := solana.PrivateKeyFromBase58(r.Account.SolanaPrivateKey.String()) - require.NoError(r, err) + privkey := r.GetSolanaPrivKey() // deploy SPL token, but don't whitelist in gateway spl := r.DeploySPL(&privkey, false) diff --git a/e2e/e2etests/test_solana_withdraw.go b/e2e/e2etests/test_solana_withdraw.go index c7f6ccc58b..d8e427a66c 100644 --- a/e2e/e2etests/test_solana_withdraw.go +++ b/e2e/e2etests/test_solana_withdraw.go @@ -8,6 +8,8 @@ import ( "github.com/stretchr/testify/require" "github.com/zeta-chain/node/e2e/runner" + "github.com/zeta-chain/node/e2e/utils" + crosschaintypes "github.com/zeta-chain/node/x/crosschain/types" ) func TestSolanaWithdraw(r *runner.E2ERunner, args []string) { @@ -28,15 +30,19 @@ func TestSolanaWithdraw(r *runner.E2ERunner, args []string) { r, -1, withdrawAmount.Cmp(approvedAmount), - "Withdrawal amount must be less than the approved amount (1e9)", + "Withdrawal amount must be less than the approved amount: %v", + approvedAmount, ) // load deployer private key - privkey, err := solana.PrivateKeyFromBase58(r.Account.SolanaPrivateKey.String()) - require.NoError(r, err) + privkey := r.GetSolanaPrivKey() // withdraw - r.WithdrawSOLZRC20(privkey.PublicKey(), withdrawAmount, approvedAmount) + tx := r.WithdrawSOLZRC20(privkey.PublicKey(), withdrawAmount, approvedAmount) + + // 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) // get ERC20 SOL balance after withdraw balanceAfter, err := r.SOLZRC20.BalanceOf(&bind.CallOpts{}, r.EVMAddress()) diff --git a/e2e/e2etests/test_solana_withdraw_restricted_address.go b/e2e/e2etests/test_solana_withdraw_restricted_address.go index e7964f3702..af2027ac98 100644 --- a/e2e/e2etests/test_solana_withdraw_restricted_address.go +++ b/e2e/e2etests/test_solana_withdraw_restricted_address.go @@ -8,7 +8,9 @@ import ( "github.com/stretchr/testify/require" "github.com/zeta-chain/node/e2e/runner" + "github.com/zeta-chain/node/e2e/utils" "github.com/zeta-chain/node/pkg/chains" + crosschaintypes "github.com/zeta-chain/node/x/crosschain/types" ) func TestSolanaWithdrawRestricted(r *runner.E2ERunner, args []string) { @@ -29,7 +31,11 @@ func TestSolanaWithdrawRestricted(r *runner.E2ERunner, args []string) { ) // withdraw - cctx := r.WithdrawSOLZRC20(receiverRestricted, withdrawAmount, approvedAmount) + tx := r.WithdrawSOLZRC20(receiverRestricted, withdrawAmount, approvedAmount) + + // 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) // the cctx should be cancelled with zero value verifySolanaWithdrawalAmountFromCCTX(r, cctx, 0) diff --git a/e2e/e2etests/test_spl_deposit.go b/e2e/e2etests/test_spl_deposit.go index ee5013d16c..e20ff5879a 100644 --- a/e2e/e2etests/test_spl_deposit.go +++ b/e2e/e2etests/test_spl_deposit.go @@ -4,7 +4,6 @@ import ( "math/big" "github.com/ethereum/go-ethereum/accounts/abi/bind" - "github.com/gagliardetto/solana-go" "github.com/gagliardetto/solana-go/rpc" "github.com/stretchr/testify/require" @@ -18,18 +17,17 @@ func TestSPLDeposit(r *runner.E2ERunner, args []string) { amount := parseInt(r, args[0]) // load deployer private key - privKey, err := solana.PrivateKeyFromBase58(r.Account.SolanaPrivateKey.String()) - require.NoError(r, err) + privKey := r.GetSolanaPrivKey() // get SPL balance for pda and sender atas pda := r.ComputePdaAddress() - pdaAta := r.FindOrCreateAssociatedTokenAccount(privKey, pda, r.SPLAddr) + pdaAta := r.ResolveSolanaATA(privKey, pda, r.SPLAddr) - pdaBalanceBefore, err := r.SolanaClient.GetTokenAccountBalance(r.Ctx, pdaAta, rpc.CommitmentConfirmed) + pdaBalanceBefore, err := r.SolanaClient.GetTokenAccountBalance(r.Ctx, pdaAta, rpc.CommitmentFinalized) require.NoError(r, err) - senderAta := r.FindOrCreateAssociatedTokenAccount(privKey, privKey.PublicKey(), r.SPLAddr) - senderBalanceBefore, err := r.SolanaClient.GetTokenAccountBalance(r.Ctx, senderAta, rpc.CommitmentConfirmed) + senderAta := r.ResolveSolanaATA(privKey, privKey.PublicKey(), r.SPLAddr) + senderBalanceBefore, err := r.SolanaClient.GetTokenAccountBalance(r.Ctx, senderAta, rpc.CommitmentFinalized) require.NoError(r, err) // get zrc20 balance for recipient @@ -46,10 +44,10 @@ func TestSPLDeposit(r *runner.E2ERunner, args []string) { utils.RequireCCTXStatus(r, cctx, crosschaintypes.CctxStatus_OutboundMined) // verify balances are updated - pdaBalanceAfter, err := r.SolanaClient.GetTokenAccountBalance(r.Ctx, pdaAta, rpc.CommitmentConfirmed) + pdaBalanceAfter, err := r.SolanaClient.GetTokenAccountBalance(r.Ctx, pdaAta, rpc.CommitmentFinalized) require.NoError(r, err) - senderBalanceAfter, err := r.SolanaClient.GetTokenAccountBalance(r.Ctx, senderAta, rpc.CommitmentConfirmed) + senderBalanceAfter, err := r.SolanaClient.GetTokenAccountBalance(r.Ctx, senderAta, rpc.CommitmentFinalized) require.NoError(r, err) zrc20BalanceAfter, err := r.SPLZRC20.BalanceOf(&bind.CallOpts{}, r.EVMAddress()) diff --git a/e2e/e2etests/test_spl_deposit_and_call.go b/e2e/e2etests/test_spl_deposit_and_call.go index cdfc94daa1..d7e11cd999 100644 --- a/e2e/e2etests/test_spl_deposit_and_call.go +++ b/e2e/e2etests/test_spl_deposit_and_call.go @@ -4,7 +4,6 @@ import ( "math/big" "github.com/ethereum/go-ethereum/accounts/abi/bind" - "github.com/gagliardetto/solana-go" "github.com/gagliardetto/solana-go/rpc" "github.com/stretchr/testify/require" @@ -24,18 +23,17 @@ func TestSPLDepositAndCall(r *runner.E2ERunner, args []string) { r.Logger.Info("Example contract deployed at: %s", contractAddr.String()) // load deployer private key - privKey, err := solana.PrivateKeyFromBase58(r.Account.SolanaPrivateKey.String()) - require.NoError(r, err) + privKey := r.GetSolanaPrivKey() // get SPL balance for pda and sender atas pda := r.ComputePdaAddress() - pdaAta := r.FindOrCreateAssociatedTokenAccount(privKey, pda, r.SPLAddr) + pdaAta := r.ResolveSolanaATA(privKey, pda, r.SPLAddr) - pdaBalanceBefore, err := r.SolanaClient.GetTokenAccountBalance(r.Ctx, pdaAta, rpc.CommitmentConfirmed) + pdaBalanceBefore, err := r.SolanaClient.GetTokenAccountBalance(r.Ctx, pdaAta, rpc.CommitmentFinalized) require.NoError(r, err) - senderAta := r.FindOrCreateAssociatedTokenAccount(privKey, privKey.PublicKey(), r.SPLAddr) - senderBalanceBefore, err := r.SolanaClient.GetTokenAccountBalance(r.Ctx, senderAta, rpc.CommitmentConfirmed) + senderAta := r.ResolveSolanaATA(privKey, privKey.PublicKey(), r.SPLAddr) + senderBalanceBefore, err := r.SolanaClient.GetTokenAccountBalance(r.Ctx, senderAta, rpc.CommitmentFinalized) require.NoError(r, err) // get zrc20 balance for recipient @@ -56,10 +54,10 @@ func TestSPLDepositAndCall(r *runner.E2ERunner, args []string) { utils.MustHaveCalledExampleContract(r, contract, big.NewInt(int64(amount))) // verify balances are updated - pdaBalanceAfter, err := r.SolanaClient.GetTokenAccountBalance(r.Ctx, pdaAta, rpc.CommitmentConfirmed) + pdaBalanceAfter, err := r.SolanaClient.GetTokenAccountBalance(r.Ctx, pdaAta, rpc.CommitmentFinalized) require.NoError(r, err) - senderBalanceAfter, err := r.SolanaClient.GetTokenAccountBalance(r.Ctx, senderAta, rpc.CommitmentConfirmed) + senderBalanceAfter, err := r.SolanaClient.GetTokenAccountBalance(r.Ctx, senderAta, rpc.CommitmentFinalized) require.NoError(r, err) zrc20BalanceAfter, err := r.SPLZRC20.BalanceOf(&bind.CallOpts{}, contractAddr) diff --git a/e2e/e2etests/test_spl_withdraw.go b/e2e/e2etests/test_spl_withdraw.go new file mode 100644 index 0000000000..2af4ddfd94 --- /dev/null +++ b/e2e/e2etests/test_spl_withdraw.go @@ -0,0 +1,73 @@ +package e2etests + +import ( + "math/big" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/rpc" + "github.com/stretchr/testify/require" + + "github.com/zeta-chain/node/e2e/runner" + "github.com/zeta-chain/node/e2e/utils" + crosschaintypes "github.com/zeta-chain/node/x/crosschain/types" +) + +func TestSPLWithdraw(r *runner.E2ERunner, args []string) { + require.Len(r, args, 1) + + withdrawAmount := parseBigInt(r, args[0]) + + // get SPL ZRC20 balance before withdraw + zrc20BalanceBefore, err := r.SPLZRC20.BalanceOf(&bind.CallOpts{}, r.EVMAddress()) + require.NoError(r, err) + r.Logger.Info("runner balance of SPL before withdraw: %d", zrc20BalanceBefore) + + require.Equal(r, 1, zrc20BalanceBefore.Cmp(withdrawAmount), "Insufficient balance for withdrawal") + + // parse withdraw amount (in lamports), approve amount is 1 SOL + approvedAmount := new(big.Int).SetUint64(solana.LAMPORTS_PER_SOL) + require.Equal( + r, + -1, + withdrawAmount.Cmp(approvedAmount), + "Withdrawal amount must be less than the %v", + approvedAmount, + ) + + // load deployer private key + privkey := r.GetSolanaPrivKey() + + // get receiver ata balance before withdraw + receiverAta := r.ResolveSolanaATA(privkey, privkey.PublicKey(), r.SPLAddr) + receiverBalanceBefore, err := r.SolanaClient.GetTokenAccountBalance(r.Ctx, receiverAta, rpc.CommitmentFinalized) + require.NoError(r, err) + r.Logger.Info("receiver balance of SPL before withdraw: %s", receiverBalanceBefore.Value.Amount) + + // withdraw + tx := r.WithdrawSPLZRC20(privkey.PublicKey(), withdrawAmount, approvedAmount) + + // 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) + + // get SPL ZRC20 balance after withdraw + zrc20BalanceAfter, err := r.SPLZRC20.BalanceOf(&bind.CallOpts{}, r.EVMAddress()) + require.NoError(r, err) + r.Logger.Info("runner balance of SPL after withdraw: %d", zrc20BalanceAfter) + + // verify balances are updated + receiverBalanceAfter, err := r.SolanaClient.GetTokenAccountBalance(r.Ctx, receiverAta, rpc.CommitmentFinalized) + require.NoError(r, err) + r.Logger.Info("receiver balance of SPL after withdraw: %s", receiverBalanceAfter.Value.Amount) + + // verify amount is added to receiver ata + require.EqualValues( + r, + new(big.Int).Add(withdrawAmount, parseBigInt(r, receiverBalanceBefore.Value.Amount)).String(), + parseBigInt(r, receiverBalanceAfter.Value.Amount).String(), + ) + + // verify amount is subtracted on zrc20 + require.EqualValues(r, new(big.Int).Sub(zrc20BalanceBefore, withdrawAmount).String(), zrc20BalanceAfter.String()) +} diff --git a/e2e/e2etests/test_spl_withdraw_and_create_receiver_ata.go b/e2e/e2etests/test_spl_withdraw_and_create_receiver_ata.go new file mode 100644 index 0000000000..51196c8a46 --- /dev/null +++ b/e2e/e2etests/test_spl_withdraw_and_create_receiver_ata.go @@ -0,0 +1,79 @@ +package e2etests + +import ( + "math/big" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/rpc" + "github.com/stretchr/testify/require" + + "github.com/zeta-chain/node/e2e/runner" + "github.com/zeta-chain/node/e2e/utils" + crosschaintypes "github.com/zeta-chain/node/x/crosschain/types" +) + +// TestSPLWithdrawAndCreateReceiverAta withdraws spl, but letting gateway to create receiver ata using rent payer +// instead of providing receiver that has it already created +func TestSPLWithdrawAndCreateReceiverAta(r *runner.E2ERunner, args []string) { + require.Len(r, args, 1) + + withdrawAmount := parseBigInt(r, args[0]) + + // get SPL ZRC20 balance before withdraw + zrc20BalanceBefore, err := r.SPLZRC20.BalanceOf(&bind.CallOpts{}, r.EVMAddress()) + require.NoError(r, err) + r.Logger.Info("runner balance of SPL before withdraw: %d", zrc20BalanceBefore) + + require.Equal(r, 1, zrc20BalanceBefore.Cmp(withdrawAmount), "Insufficient balance for withdrawal") + + // parse withdraw amount (in lamports), approve amount is 1 SOL + approvedAmount := new(big.Int).SetUint64(solana.LAMPORTS_PER_SOL) + require.Equal( + r, + -1, + withdrawAmount.Cmp(approvedAmount), + "Withdrawal amount must be less than the %v", + approvedAmount, + ) + + // create new priv key, with empty ata + receiverPrivKey, err := solana.NewRandomPrivateKey() + require.NoError(r, err) + + // verify receiver ata account doesn't exist + receiverAta, _, err := solana.FindAssociatedTokenAddress(receiverPrivKey.PublicKey(), r.SPLAddr) + require.NoError(r, err) + + receiverAtaAcc, err := r.SolanaClient.GetAccountInfo(r.Ctx, receiverAta) + require.Error(r, err) + require.Nil(r, receiverAtaAcc) + + // withdraw + tx := r.WithdrawSPLZRC20(receiverPrivKey.PublicKey(), withdrawAmount, approvedAmount) + + // 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) + + // get SPL ZRC20 balance after withdraw + zrc20BalanceAfter, err := r.SPLZRC20.BalanceOf(&bind.CallOpts{}, r.EVMAddress()) + require.NoError(r, err) + r.Logger.Info("runner balance of SPL after withdraw: %d", zrc20BalanceAfter) + + // verify receiver ata was created + receiverAtaAcc, err = r.SolanaClient.GetAccountInfo(r.Ctx, receiverAta) + require.NoError(r, err) + require.NotNil(r, receiverAtaAcc) + + // verify balances are updated + receiverBalanceAfter, err := r.SolanaClient.GetTokenAccountBalance(r.Ctx, receiverAta, rpc.CommitmentFinalized) + require.NoError(r, err) + r.Logger.Info("receiver balance of SPL after withdraw: %s", receiverBalanceAfter.Value.Amount) + + // verify amount is added to receiver ata + require.EqualValues(r, withdrawAmount.String(), parseBigInt(r, receiverBalanceAfter.Value.Amount).String()) + + // verify amount is subtracted on zrc20 + require.EqualValues(r, new(big.Int).Sub(zrc20BalanceBefore, withdrawAmount).String(), zrc20BalanceAfter.String()) +} diff --git a/e2e/runner/runner.go b/e2e/runner/runner.go index ef68abe6a8..f117758c28 100644 --- a/e2e/runner/runner.go +++ b/e2e/runner/runner.go @@ -20,6 +20,7 @@ import ( "github.com/ethereum/go-ethereum/ethclient" "github.com/gagliardetto/solana-go" "github.com/gagliardetto/solana-go/rpc" + "github.com/stretchr/testify/require" "github.com/zeta-chain/protocol-contracts/v1/pkg/contracts/evm/erc20custody.sol" zetaeth "github.com/zeta-chain/protocol-contracts/v1/pkg/contracts/evm/zeta.eth.sol" zetaconnectoreth "github.com/zeta-chain/protocol-contracts/v1/pkg/contracts/evm/zetaconnector.eth.sol" @@ -434,3 +435,9 @@ func (r *E2ERunner) requireTxSuccessful(receipt *ethtypes.Receipt, msgAndArgs .. func (r *E2ERunner) EVMAddress() ethcommon.Address { return r.Account.EVMAddress() } + +func (r *E2ERunner) GetSolanaPrivKey() solana.PrivateKey { + privkey, err := solana.PrivateKeyFromBase58(r.Account.SolanaPrivateKey.String()) + require.NoError(r, err) + return privkey +} diff --git a/e2e/runner/setup_solana.go b/e2e/runner/setup_solana.go index a7589d6af1..1a46326d02 100644 --- a/e2e/runner/setup_solana.go +++ b/e2e/runner/setup_solana.go @@ -85,6 +85,30 @@ func (r *E2ERunner) SetupSolana(deployerPrivateKey string) { require.NoError(r, err) r.Logger.Info("initial PDA balance: %d lamports", balance.Value) + // initialize rent payer + var instRentPayer solana.GenericInstruction + rentPayerPdaComputed := r.SolanaRentPayerPDA() + + // create 'initialize_rent_payer' instruction + accountSlice = []*solana.AccountMeta{} + accountSlice = append(accountSlice, solana.Meta(rentPayerPdaComputed).WRITE()) + accountSlice = append(accountSlice, solana.Meta(privkey.PublicKey()).WRITE().SIGNER()) + accountSlice = append(accountSlice, solana.Meta(solana.SystemProgramID)) + instRentPayer.ProgID = r.GatewayProgram + instRentPayer.AccountValues = accountSlice + + instRentPayer.DataBytes, err = borsh.Serialize(solanacontracts.InitializeRentPayerParams{ + Discriminator: solanacontracts.DiscriminatorInitializeRentPayer, + }) + require.NoError(r, err) + + // create and sign the transaction + signedTx = r.CreateSignedTransaction([]solana.Instruction{&instRentPayer}, privkey, []solana.PrivateKey{}) + + // broadcast the transaction and wait for finalization + _, out = r.BroadcastTxSync(signedTx) + r.Logger.Info("initialize_rent_payer logs: %v", out.Meta.LogMessages) + err = r.ensureSolanaChainParams() require.NoError(r, err) diff --git a/e2e/runner/solana.go b/e2e/runner/solana.go index 542968938d..d395c60d76 100644 --- a/e2e/runner/solana.go +++ b/e2e/runner/solana.go @@ -5,6 +5,7 @@ import ( "time" ethcommon "github.com/ethereum/go-ethereum/common" + ethtypes "github.com/ethereum/go-ethereum/core/types" "github.com/gagliardetto/solana-go" associatedtokenaccount "github.com/gagliardetto/solana-go/programs/associated-token-account" "github.com/gagliardetto/solana-go/programs/system" @@ -15,7 +16,6 @@ import ( "github.com/zeta-chain/node/e2e/utils" solanacontract "github.com/zeta-chain/node/pkg/contracts/solana" - crosschaintypes "github.com/zeta-chain/node/x/crosschain/types" ) // ComputePdaAddress computes the PDA address for the gateway program @@ -30,6 +30,18 @@ func (r *E2ERunner) ComputePdaAddress() solana.PublicKey { return pdaComputed } +// SolanaRentPayerPDA computes the rent payer PDA (Program Derived Address) address for the gateway program +func (r *E2ERunner) SolanaRentPayerPDA() solana.PublicKey { + seed := []byte(solanacontract.RentPayerPDASeed) + GatewayProgramID := solana.MustPublicKeyFromBase58(solanacontract.SolanaGatewayProgramID) + pdaComputed, bump, err := solana.FindProgramAddress([][]byte{seed}, GatewayProgramID) + require.NoError(r, err) + + r.Logger.Info("computed rent payer pda: %s, bump %d\n", pdaComputed, bump) + + return pdaComputed +} + // CreateDepositInstruction creates a 'deposit' instruction func (r *E2ERunner) CreateDepositInstruction( signer solana.PublicKey, @@ -147,8 +159,8 @@ func (r *E2ERunner) CreateSignedTransaction( return tx } -// FindOrCreateAssociatedTokenAccount checks if ata exists, and if not creates it -func (r *E2ERunner) FindOrCreateAssociatedTokenAccount( +// ResolveSolanaATA finds or creates SOL associated token account +func (r *E2ERunner) ResolveSolanaATA( payer solana.PrivateKey, owner solana.PublicKey, tokenAccount solana.PublicKey, @@ -184,10 +196,10 @@ func (r *E2ERunner) SPLDepositAndCall( ) solana.Signature { // ata for pda pda := r.ComputePdaAddress() - pdaAta := r.FindOrCreateAssociatedTokenAccount(*privateKey, pda, tokenAccount) + pdaAta := r.ResolveSolanaATA(*privateKey, pda, tokenAccount) // deployer ata - ata := r.FindOrCreateAssociatedTokenAccount(*privateKey, privateKey.PublicKey(), tokenAccount) + ata := r.ResolveSolanaATA(*privateKey, privateKey.PublicKey(), tokenAccount) // deposit spl seed := [][]byte{[]byte("whitelist"), tokenAccount.Bytes()} @@ -248,9 +260,9 @@ func (r *E2ERunner) DeploySPL(privateKey *solana.PrivateKey, whitelist bool) *so r.Logger.Info("create spl logs: %v", out.Meta.LogMessages) // minting some tokens to deployer for testing - ata := r.FindOrCreateAssociatedTokenAccount(*privateKey, privateKey.PublicKey(), tokenAccount.PublicKey()) + ata := r.ResolveSolanaATA(*privateKey, privateKey.PublicKey(), tokenAccount.PublicKey()) - mintToInstruction := token.NewMintToInstruction(uint64(1_000_000), tokenAccount.PublicKey(), ata, privateKey.PublicKey(), []solana.PublicKey{}). + mintToInstruction := token.NewMintToInstruction(uint64(1_000_000_000), tokenAccount.PublicKey(), ata, privateKey.PublicKey(), []solana.PublicKey{}). Build() signedTx = r.CreateSignedTransaction( []solana.Instruction{mintToInstruction}, @@ -333,8 +345,7 @@ func (r *E2ERunner) SOLDepositAndCall( ) solana.Signature { // if signer is not provided, use the runner account as default if signerPrivKey == nil { - privkey, err := solana.PrivateKeyFromBase58(r.Account.SolanaPrivateKey.String()) - require.NoError(r, err) + privkey := r.GetSolanaPrivKey() signerPrivKey = &privkey } @@ -356,7 +367,7 @@ func (r *E2ERunner) WithdrawSOLZRC20( to solana.PublicKey, amount *big.Int, approveAmount *big.Int, -) *crosschaintypes.CrossChainTx { +) *ethtypes.Transaction { // approve tx, err := r.SOLZRC20.Approve(r.ZEVMAuth, r.SOLZRC20Addr, approveAmount) require.NoError(r, err) @@ -373,9 +384,30 @@ func (r *E2ERunner) WithdrawSOLZRC20( utils.RequireTxSuccessful(r, receipt, "withdraw") 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) + return tx +} + +// WithdrawSPLZRC20 withdraws an amount of ZRC20 SPL tokens +func (r *E2ERunner) WithdrawSPLZRC20( + to solana.PublicKey, + amount *big.Int, + approveAmount *big.Int, +) *ethtypes.Transaction { + // approve splzrc20 to spend gas tokens to pay gas fee + tx, err := r.SOLZRC20.Approve(r.ZEVMAuth, r.SPLZRC20Addr, approveAmount) + require.NoError(r, err) + receipt := utils.MustWaitForTxReceipt(r.Ctx, r.ZEVMClient, tx, r.Logger, r.ReceiptTimeout) + utils.RequireTxSuccessful(r, receipt, "approve") + + // withdraw + tx, err = r.SPLZRC20.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, "withdraw") + r.Logger.Info("Receipt txhash %s status %d", receipt.TxHash, receipt.Status) - return cctx + return tx } diff --git a/go.mod b/go.mod index 795cfb4528..494398e08c 100644 --- a/go.mod +++ b/go.mod @@ -318,7 +318,7 @@ require ( github.com/montanaflynn/stats v0.7.1 github.com/showa-93/go-mask v0.6.2 github.com/tonkeeper/tongo v1.9.3 - github.com/zeta-chain/protocol-contracts-solana/go-idl v0.0.0-20241025181051-d8d49e4fc85b + github.com/zeta-chain/protocol-contracts-solana/go-idl v0.0.0-20241108171442-e48d82f94892 ) require ( diff --git a/go.sum b/go.sum index 4297217034..97df1129f4 100644 --- a/go.sum +++ b/go.sum @@ -1531,8 +1531,8 @@ github.com/zeta-chain/go-tss v0.0.0-20241031223543-18765295f992 h1:jpfOoQGHQo29C github.com/zeta-chain/go-tss v0.0.0-20241031223543-18765295f992/go.mod h1:nqelgf4HKkqlXaVg8X38a61WfyYB+ivCt6nnjoTIgCc= github.com/zeta-chain/protocol-contracts v1.0.2-athens3.0.20241021075719-d40d2e28467c h1:ZoFxMMZtivRLquXVq1sEVlT45UnTPMO1MSXtc88nDv4= github.com/zeta-chain/protocol-contracts v1.0.2-athens3.0.20241021075719-d40d2e28467c/go.mod h1:SjT7QirtJE8stnAe1SlNOanxtfSfijJm3MGJ+Ax7w7w= -github.com/zeta-chain/protocol-contracts-solana/go-idl v0.0.0-20241025181051-d8d49e4fc85b h1:w4YVBbWxk9TI+7HM8hTvK66IgOo5XvEFsmH7n6WgW50= -github.com/zeta-chain/protocol-contracts-solana/go-idl v0.0.0-20241025181051-d8d49e4fc85b/go.mod h1:DcDY828o773soiU/h0XpC+naxitrIMFVZqEvq/EJxMA= +github.com/zeta-chain/protocol-contracts-solana/go-idl v0.0.0-20241108171442-e48d82f94892 h1:oI5qCrw2SXDf2a2UYAn0tpaKHbKpJcR+XDtceyY00wE= +github.com/zeta-chain/protocol-contracts-solana/go-idl v0.0.0-20241108171442-e48d82f94892/go.mod h1:DcDY828o773soiU/h0XpC+naxitrIMFVZqEvq/EJxMA= github.com/zeta-chain/tss-lib v0.0.0-20240916163010-2e6b438bd901 h1:9whtN5fjYHfk4yXIuAsYP2EHxImwDWDVUOnZJ2pfL3w= github.com/zeta-chain/tss-lib v0.0.0-20240916163010-2e6b438bd901/go.mod h1:d2iTC62s9JwKiCMPhcDDXbIZmuzAyJ4lwso0H5QyRbk= github.com/zondax/hid v0.9.2 h1:WCJFnEDMiqGF64nlZz28E9qLVZ0KSJ7xpc5DLEyma2U= diff --git a/pkg/contracts/solana/gateway.go b/pkg/contracts/solana/gateway.go index 12e3a10aa8..bf674aa081 100644 --- a/pkg/contracts/solana/gateway.go +++ b/pkg/contracts/solana/gateway.go @@ -14,6 +14,9 @@ const ( // PDASeed is the seed for the Solana gateway program derived address PDASeed = "meta" + // RentPayerPDASeed is the seed for the Solana gateway program derived address + RentPayerPDASeed = "rent-payer" + // AccountsNumberOfDeposit is the number of accounts required for Solana gateway deposit instruction // [signer, pda, system_program] accountsNumDeposit = 3 @@ -26,6 +29,8 @@ const ( var ( // DiscriminatorInitialize returns the discriminator for Solana gateway 'initialize' instruction DiscriminatorInitialize = idlgateway.IDLGateway.GetDiscriminator("initialize") + // DiscriminatorInitializeRentPayer returns the discriminator for Solana gateway 'initialize_rent_payer' instruction + DiscriminatorInitializeRentPayer = idlgateway.IDLGateway.GetDiscriminator("initialize_rent_payer") // DiscriminatorDeposit returns the discriminator for Solana gateway 'deposit' instruction DiscriminatorDeposit = idlgateway.IDLGateway.GetDiscriminator("deposit") // DiscriminatorDepositSPL returns the discriminator for Solana gateway 'deposit_spl_token' instruction @@ -38,12 +43,12 @@ var ( DiscriminatorWhitelistSplMint = idlgateway.IDLGateway.GetDiscriminator("whitelist_spl_mint") ) -// ParseGatewayAddressAndPda parses the gateway id and program derived address from the given string -func ParseGatewayIDAndPda(address string) (solana.PublicKey, solana.PublicKey, error) { +// ParseGatewayWithPDA parses the gateway id and program derived address from the given string +func ParseGatewayWithPDA(gatewayAddress string) (solana.PublicKey, solana.PublicKey, error) { var gatewayID, pda solana.PublicKey // decode gateway address - gatewayID, err := solana.PublicKeyFromBase58(address) + gatewayID, err := solana.PublicKeyFromBase58(gatewayAddress) if err != nil { return gatewayID, pda, errors.Wrap(err, "unable to decode address") } @@ -54,3 +59,12 @@ func ParseGatewayIDAndPda(address string) (solana.PublicKey, solana.PublicKey, e return gatewayID, pda, err } + +// ParseRentPayerPda parses the rent payer program derived address from the given string +func RentPayerPDA(gateway solana.PublicKey) (solana.PublicKey, error) { + var rentPayerPda solana.PublicKey + seed := []byte(RentPayerPDASeed) + rentPayerPda, _, err := solana.FindProgramAddress([][]byte{seed}, gateway) + + return rentPayerPda, err +} diff --git a/pkg/contracts/solana/gateway_message.go b/pkg/contracts/solana/gateway_message.go index 1c8abaca23..bd8cbb51af 100644 --- a/pkg/contracts/solana/gateway_message.go +++ b/pkg/contracts/solana/gateway_message.go @@ -8,18 +8,18 @@ import ( "github.com/gagliardetto/solana-go" ) -// MsgWithdraw is the message for the Solana gateway withdraw/withdraw_spl instruction +// MsgWithdraw is the message for the Solana gateway withdraw instruction type MsgWithdraw struct { // chainID is the chain ID of Solana chain chainID uint64 - // Nonce is the nonce for the withdraw/withdraw_spl + // Nonce is the nonce for the withdraw nonce uint64 - // amount is the lamports amount for the withdraw/withdraw_spl + // amount is the lamports amount for the withdraw amount uint64 - // To is the recipient address for the withdraw/withdraw_spl + // To is the recipient address for the withdraw to solana.PublicKey // signature is the signature of the message @@ -108,6 +108,136 @@ func (msg *MsgWithdraw) Signer() (common.Address, error) { return RecoverSigner(msgHash[:], msgSig[:]) } +// MsgWithdrawSPL is the message for the Solana gateway withdraw_spl instruction +type MsgWithdrawSPL struct { + // chainID is the chain ID of Solana chain + chainID uint64 + + // Nonce is the nonce for the withdraw_spl + nonce uint64 + + // amount is the lamports amount for the withdraw_spl + amount uint64 + + // tokenAccount is the address for the spl token + tokenAccount solana.PublicKey + + // decimals of spl token + decimals uint8 + + // to is the recipient address for the withdraw_spl + to solana.PublicKey + + // recipientAta is the recipient associated token account for the withdraw_spl + recipientAta solana.PublicKey + + // signature is the signature of the message + signature [65]byte +} + +// NewMsgWithdrawSPL returns a new withdraw spl message +func NewMsgWithdrawSPL( + chainID, nonce, amount uint64, + decimals uint8, + tokenAccount, to, toAta solana.PublicKey, +) *MsgWithdrawSPL { + return &MsgWithdrawSPL{ + chainID: chainID, + nonce: nonce, + amount: amount, + to: to, + recipientAta: toAta, + tokenAccount: tokenAccount, + decimals: decimals, + } +} + +// ChainID returns the chain ID of the message +func (msg *MsgWithdrawSPL) ChainID() uint64 { + return msg.chainID +} + +// Nonce returns the nonce of the message +func (msg *MsgWithdrawSPL) Nonce() uint64 { + return msg.nonce +} + +// Amount returns the amount of the message +func (msg *MsgWithdrawSPL) Amount() uint64 { + return msg.amount +} + +// To returns the recipient address of the message +func (msg *MsgWithdrawSPL) To() solana.PublicKey { + return msg.to +} + +func (msg *MsgWithdrawSPL) RecipientAta() solana.PublicKey { + return msg.recipientAta +} + +func (msg *MsgWithdrawSPL) TokenAccount() solana.PublicKey { + return msg.tokenAccount +} + +func (msg *MsgWithdrawSPL) Decimals() uint8 { + return msg.decimals +} + +// Hash packs the withdraw spl message and computes the hash +func (msg *MsgWithdrawSPL) Hash() [32]byte { + var message []byte + buff := make([]byte, 8) + + message = append(message, []byte("withdraw_spl_token")...) + + 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.tokenAccount.Bytes()...) + + message = append(message, msg.recipientAta.Bytes()...) + + return crypto.Keccak256Hash(message) +} + +// SetSignature attaches the signature to the message +func (msg *MsgWithdrawSPL) SetSignature(signature [65]byte) *MsgWithdrawSPL { + msg.signature = signature + return msg +} + +// SigRSV returns the full 65-byte [R+S+V] signature +func (msg *MsgWithdrawSPL) SigRSV() [65]byte { + return msg.signature +} + +// SigRS returns the 64-byte [R+S] core part of the signature +func (msg *MsgWithdrawSPL) 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 *MsgWithdrawSPL) SigV() uint8 { + return msg.signature[64] +} + +// Signer returns the signer of the message +func (msg *MsgWithdrawSPL) Signer() (common.Address, error) { + msgHash := msg.Hash() + msgSig := msg.SigRSV() + + return RecoverSigner(msgHash[:], msgSig[:]) +} + // MsgWhitelist is the message for the Solana gateway whitelist_spl_mint instruction type MsgWhitelist struct { // whitelistCandidate is the SPL token to be whitelisted in gateway program diff --git a/pkg/contracts/solana/instruction.go b/pkg/contracts/solana/instruction.go index 65b6e6e4c3..44aa80c16b 100644 --- a/pkg/contracts/solana/instruction.go +++ b/pkg/contracts/solana/instruction.go @@ -22,6 +22,12 @@ type InitializeParams struct { ChainID uint64 } +// InitializeRentPayerParams contains the parameters for a gateway initialize_rent_payer instruction +type InitializeRentPayerParams struct { + // Discriminator is the unique identifier for the initialize_rent_payer instruction + Discriminator [8]byte +} + // DepositInstructionParams contains the parameters for a gateway deposit instruction type DepositInstructionParams struct { // Discriminator is the unique identifier for the deposit instruction @@ -118,6 +124,65 @@ func ParseInstructionWithdraw(instruction solana.CompiledInstruction) (*Withdraw return inst, nil } +type WithdrawSPLInstructionParams struct { + // Discriminator is the unique identifier for the withdraw instruction + Discriminator [8]byte + + Decimals uint8 + + // 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 *WithdrawSPLInstructionParams) 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 *WithdrawSPLInstructionParams) GatewayNonce() uint64 { + return inst.Nonce +} + +// TokenAmount returns the amount of the instruction +func (inst *WithdrawSPLInstructionParams) 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 ParseInstructionWithdrawSPL(instruction solana.CompiledInstruction) (*WithdrawSPLInstructionParams, error) { + // try deserializing instruction as a 'withdraw' + inst := &WithdrawSPLInstructionParams{} + 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 != DiscriminatorWithdrawSPL { + 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 diff --git a/zetaclient/chains/solana/observer/observer.go b/zetaclient/chains/solana/observer/observer.go index 0548fcd6d3..6187ca2c39 100644 --- a/zetaclient/chains/solana/observer/observer.go +++ b/zetaclient/chains/solana/observer/observer.go @@ -67,7 +67,7 @@ func NewObserver( } // parse gateway ID and PDA - gatewayID, pda, err := contracts.ParseGatewayIDAndPda(chainParams.GatewayAddress) + gatewayID, pda, err := contracts.ParseGatewayWithPDA(chainParams.GatewayAddress) if err != nil { return nil, errors.Wrapf(err, "cannot parse gateway address %s", chainParams.GatewayAddress) } diff --git a/zetaclient/chains/solana/observer/outbound.go b/zetaclient/chains/solana/observer/outbound.go index 60bd70bec7..2ed98575c4 100644 --- a/zetaclient/chains/solana/observer/outbound.go +++ b/zetaclient/chains/solana/observer/outbound.go @@ -356,6 +356,8 @@ func ParseGatewayInstruction( return contracts.ParseInstructionWithdraw(instruction) case coin.CoinType_Cmd: return contracts.ParseInstructionWhitelist(instruction) + case coin.CoinType_ERC20: + return contracts.ParseInstructionWithdrawSPL(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 index 73af8da573..bdda96c451 100644 --- a/zetaclient/chains/solana/observer/outbound_test.go +++ b/zetaclient/chains/solana/observer/outbound_test.go @@ -234,7 +234,7 @@ func Test_ParseGatewayInstruction(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) + inst, err := observer.ParseGatewayInstruction(txResult, gatewayID, coin.CoinType_Zeta) require.ErrorContains(t, err, "unsupported outbound coin type") require.Nil(t, inst) }) diff --git a/zetaclient/chains/solana/signer/signer.go b/zetaclient/chains/solana/signer/signer.go index 9100f5c628..7405ddaf87 100644 --- a/zetaclient/chains/solana/signer/signer.go +++ b/zetaclient/chains/solana/signer/signer.go @@ -7,7 +7,9 @@ import ( "cosmossdk.io/errors" ethcommon "github.com/ethereum/go-ethereum/common" + bin "github.com/gagliardetto/binary" "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/programs/token" "github.com/gagliardetto/solana-go/rpc" "github.com/rs/zerolog" @@ -43,6 +45,9 @@ type Signer struct { // pda is the program derived address of the gateway program pda solana.PublicKey + + // rent payer pda is the program derived address of the gateway program to pay rent for creating atas + rentPayerPda solana.PublicKey } // NewSigner creates a new Solana signer @@ -59,17 +64,24 @@ func NewSigner( baseSigner := base.NewSigner(chain, tss, ts, logger) // parse gateway ID and PDA - gatewayID, pda, err := contracts.ParseGatewayIDAndPda(chainParams.GatewayAddress) + gatewayID, pda, err := contracts.ParseGatewayWithPDA(chainParams.GatewayAddress) + if err != nil { + return nil, errors.Wrapf(err, "cannot parse gateway address %s", chainParams.GatewayAddress) + } + + // parse rent payer PDA, used in case receiver ATA should be created in gateway + rentPayerPda, err := contracts.RentPayerPDA(gatewayID) if err != nil { return nil, errors.Wrapf(err, "cannot parse gateway address %s", chainParams.GatewayAddress) } // create Solana signer signer := &Signer{ - Signer: baseSigner, - client: solClient, - gatewayID: gatewayID, - pda: pda, + Signer: baseSigner, + client: solClient, + gatewayID: gatewayID, + pda: pda, + rentPayerPda: rentPayerPda, } // construct Solana private key if present @@ -151,6 +163,15 @@ func (signer *Signer) TryProcessOutbound( } tx = withdrawTx + + case coin.CoinType_ERC20: + withdrawSPLTx, err := signer.prepareWithdrawSPLTx(ctx, cctx, height, logger) + if err != nil { + logger.Error().Err(err).Msgf("TryProcessOutbound: Fail to sign withdraw spl outbound") + return + } + + tx = withdrawSPLTx default: logger.Error(). Msgf("TryProcessOutbound: can only send SOL to the Solana network") @@ -219,6 +240,56 @@ func (signer *Signer) prepareWithdrawTx( return tx, nil } +func (signer *Signer) prepareWithdrawSPLTx( + ctx context.Context, + cctx *types.CrossChainTx, + height uint64, + logger zerolog.Logger, +) (*solana.Transaction, error) { + params := cctx.GetCurrentOutboundParam() + // compliance check + cancelTx := compliance.IsCctxRestricted(cctx) + if cancelTx { + compliance.PrintComplianceLog( + logger, + signer.Logger().Compliance, + true, + signer.Chain().ChainId, + cctx.Index, + cctx.InboundParams.Sender, + params.Receiver, + "SPL", + ) + } + + // get mint details to get decimals + mint, err := signer.decodeMintAccountDetails(ctx, cctx.InboundParams.Asset) + if err != nil { + return nil, err + } + + // sign gateway withdraw spl message by TSS + msg, err := signer.createAndSignMsgWithdrawSPL( + ctx, + params, + height, + cctx.InboundParams.Asset, + mint.Decimals, + cancelTx, + ) + if err != nil { + return nil, err + } + + // sign the withdraw transaction by relayer key + tx, err := signer.signWithdrawSPLTx(ctx, *msg) + if err != nil { + return nil, err + } + + return tx, nil +} + func (signer *Signer) prepareWhitelistTx( ctx context.Context, cctx *types.CrossChainTx, @@ -256,6 +327,23 @@ func (signer *Signer) prepareWhitelistTx( return tx, nil } +func (signer *Signer) decodeMintAccountDetails(ctx context.Context, asset string) (token.Mint, error) { + info, err := signer.client.GetAccountInfo(ctx, solana.MustPublicKeyFromBase58(asset)) + if err != nil { + return token.Mint{}, err + } + + var mint token.Mint + // Account{}.Data.GetBinary() returns the *decoded* binary data + // regardless the original encoding (it can handle them all). + err = bin.NewBinDecoder(info.Value.Data.GetBinary()).Decode(&mint) + if err != nil { + return token.Mint{}, err + } + + return mint, nil +} + // SetGatewayAddress sets the gateway address func (signer *Signer) SetGatewayAddress(address string) { // noop @@ -264,9 +352,9 @@ func (signer *Signer) SetGatewayAddress(address string) { } // parse gateway ID and PDA - gatewayID, pda, err := contracts.ParseGatewayIDAndPda(address) + gatewayID, pda, err := contracts.ParseGatewayWithPDA(address) if err != nil { - signer.Logger().Std.Error().Err(err).Str("address", address).Msgf("Unable to parse gateway address") + signer.Logger().Std.Error().Err(err).Msgf("cannot parse gateway address: %s", address) return } diff --git a/zetaclient/chains/solana/signer/whitelist.go b/zetaclient/chains/solana/signer/whitelist.go index 73ee769039..6d9055adc7 100644 --- a/zetaclient/chains/solana/signer/whitelist.go +++ b/zetaclient/chains/solana/signer/whitelist.go @@ -35,7 +35,6 @@ func (signer *Signer) createAndSignMsgWhitelist( 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 @@ -44,9 +43,7 @@ func (signer *Signer) createAndSignMsgWhitelist( // signWhitelistTx wraps the whitelist 'msg' into a Solana transaction and signs it with the relayer key. func (signer *Signer) signWhitelistTx(ctx context.Context, msg *contracts.MsgWhitelist) (*solana.Transaction, error) { // create whitelist_spl_mint instruction with program call data - var err error - var inst solana.GenericInstruction - inst.DataBytes, err = borsh.Serialize(contracts.WhitelistInstructionParams{ + dataBytes, err := borsh.Serialize(contracts.WhitelistInstructionParams{ Discriminator: contracts.DiscriminatorWhitelistSplMint, Signature: msg.SigRS(), RecoveryID: msg.SigV(), @@ -57,16 +54,17 @@ func (signer *Signer) signWhitelistTx(ctx context.Context, msg *contracts.MsgWhi return nil, errors.Wrap(err, "cannot serialize whitelist_spl_mint instruction") } - // attach required accounts to the instruction - privkey := signer.relayerKey - attachWhitelistAccounts( - &inst, - privkey.PublicKey(), - signer.pda, - msg.WhitelistCandidate(), - msg.WhitelistEntry(), - signer.gatewayID, - ) + inst := solana.GenericInstruction{ + ProgID: signer.gatewayID, + DataBytes: dataBytes, + AccountValues: []*solana.AccountMeta{ + solana.Meta(msg.WhitelistEntry()).WRITE(), + solana.Meta(msg.WhitelistCandidate()), + solana.Meta(signer.pda).WRITE(), + solana.Meta(signer.relayerKey.PublicKey()).WRITE().SIGNER(), + solana.Meta(solana.SystemProgramID), + }, + } // get a recent blockhash recent, err := signer.client.GetLatestBlockhash(ctx, rpc.CommitmentFinalized) @@ -83,7 +81,7 @@ func (signer *Signer) signWhitelistTx(ctx context.Context, msg *contracts.MsgWhi // programs.ComputeBudgetSetComputeUnitPrice(computeUnitPrice), &inst}, recent.Value.Blockhash, - solana.TransactionPayer(privkey.PublicKey()), + solana.TransactionPayer(signer.relayerKey.PublicKey()), ) if err != nil { return nil, errors.Wrap(err, "NewTransaction error") @@ -91,8 +89,8 @@ func (signer *Signer) signWhitelistTx(ctx context.Context, msg *contracts.MsgWhi // relayer signs the transaction _, err = tx.Sign(func(key solana.PublicKey) *solana.PrivateKey { - if key.Equals(privkey.PublicKey()) { - return privkey + if key.Equals(signer.relayerKey.PublicKey()) { + return signer.relayerKey } return nil }) @@ -102,24 +100,3 @@ func (signer *Signer) signWhitelistTx(ctx context.Context, msg *contracts.MsgWhi return tx, nil } - -// attachWhitelistAccounts attaches the required accounts for the gateway whitelist instruction. -func attachWhitelistAccounts( - inst *solana.GenericInstruction, - signer solana.PublicKey, - pda solana.PublicKey, - whitelistCandidate solana.PublicKey, - whitelistEntry solana.PublicKey, - gatewayID solana.PublicKey, -) { - // attach required accounts to the instruction - var accountSlice []*solana.AccountMeta - accountSlice = append(accountSlice, solana.Meta(whitelistEntry).WRITE()) - accountSlice = append(accountSlice, solana.Meta(whitelistCandidate)) - accountSlice = append(accountSlice, solana.Meta(pda).WRITE()) - accountSlice = append(accountSlice, solana.Meta(signer).WRITE().SIGNER()) - accountSlice = append(accountSlice, solana.Meta(solana.SystemProgramID)) - inst.ProgID = gatewayID - - inst.AccountValues = accountSlice -} diff --git a/zetaclient/chains/solana/signer/withdraw.go b/zetaclient/chains/solana/signer/withdraw.go index 51f4cceeea..5a27095f6f 100644 --- a/zetaclient/chains/solana/signer/withdraw.go +++ b/zetaclient/chains/solana/signer/withdraw.go @@ -13,7 +13,7 @@ import ( "github.com/zeta-chain/node/x/crosschain/types" ) -// createAndSignMsgWithdraw creates and signs a withdraw message (for gateway withdraw/withdraw_spl instruction) with TSS. +// createAndSignMsgWithdraw creates and signs a withdraw message for gateway withdraw instruction with TSS. func (signer *Signer) createAndSignMsgWithdraw( ctx context.Context, params *types.OutboundParams, @@ -26,7 +26,7 @@ func (signer *Signer) createAndSignMsgWithdraw( nonce := params.TssNonce amount := params.Amount.Uint64() - // zero out the amount if cancelTx is set. It's legal to withdraw 0 lamports thru the gateway. + // zero out the amount if cancelTx is set. It's legal to withdraw 0 lamports through the gateway. if cancelTx { amount = 0 } @@ -47,7 +47,6 @@ func (signer *Signer) createAndSignMsgWithdraw( 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 @@ -56,9 +55,7 @@ func (signer *Signer) createAndSignMsgWithdraw( // signWithdrawTx wraps the withdraw 'msg' into a Solana transaction and signs it with the relayer 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{ + dataBytes, err := borsh.Serialize(contracts.WithdrawInstructionParams{ Discriminator: contracts.DiscriminatorWithdraw, Amount: msg.Amount(), Signature: msg.SigRS(), @@ -70,9 +67,15 @@ func (signer *Signer) signWithdrawTx(ctx context.Context, msg contracts.MsgWithd return nil, errors.Wrap(err, "cannot serialize withdraw instruction") } - // attach required accounts to the instruction - privkey := signer.relayerKey - attachWithdrawAccounts(&inst, privkey.PublicKey(), signer.pda, msg.To(), signer.gatewayID) + inst := solana.GenericInstruction{ + ProgID: signer.gatewayID, + DataBytes: dataBytes, + AccountValues: []*solana.AccountMeta{ + solana.Meta(signer.relayerKey.PublicKey()).WRITE().SIGNER(), + solana.Meta(signer.pda).WRITE(), + solana.Meta(msg.To()).WRITE(), + }, + } // get a recent blockhash recent, err := signer.client.GetLatestBlockhash(ctx, rpc.CommitmentFinalized) @@ -89,7 +92,7 @@ func (signer *Signer) signWithdrawTx(ctx context.Context, msg contracts.MsgWithd // programs.ComputeBudgetSetComputeUnitPrice(computeUnitPrice), &inst}, recent.Value.Blockhash, - solana.TransactionPayer(privkey.PublicKey()), + solana.TransactionPayer(signer.relayerKey.PublicKey()), ) if err != nil { return nil, errors.Wrap(err, "NewTransaction error") @@ -97,8 +100,8 @@ func (signer *Signer) signWithdrawTx(ctx context.Context, msg contracts.MsgWithd // relayer signs the transaction _, err = tx.Sign(func(key solana.PublicKey) *solana.PrivateKey { - if key.Equals(privkey.PublicKey()) { - return privkey + if key.Equals(signer.relayerKey.PublicKey()) { + return signer.relayerKey } return nil }) @@ -108,21 +111,3 @@ func (signer *Signer) signWithdrawTx(ctx context.Context, msg contracts.MsgWithd 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()) - inst.ProgID = gatewayID - - inst.AccountValues = accountSlice -} diff --git a/zetaclient/chains/solana/signer/withdraw_spl.go b/zetaclient/chains/solana/signer/withdraw_spl.go new file mode 100644 index 0000000000..bf03260eca --- /dev/null +++ b/zetaclient/chains/solana/signer/withdraw_spl.go @@ -0,0 +1,147 @@ +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/node/pkg/chains" + contracts "github.com/zeta-chain/node/pkg/contracts/solana" + "github.com/zeta-chain/node/x/crosschain/types" +) + +// createAndSignMsgWithdrawSPL creates and signs a withdraw spl message for gateway withdraw_spl instruction with TSS. +func (signer *Signer) createAndSignMsgWithdrawSPL( + ctx context.Context, + params *types.OutboundParams, + height uint64, + asset string, + decimals uint8, + cancelTx bool, +) (*contracts.MsgWithdrawSPL, error) { + chain := signer.Chain() + // #nosec G115 always positive + chainID := uint64(signer.Chain().ChainId) + nonce := params.TssNonce + amount := params.Amount.Uint64() + + // zero out the amount if cancelTx is set. It's legal to withdraw 0 spl through the gateway. + if cancelTx { + amount = 0 + } + + // check receiver address + to, err := chains.DecodeSolanaWalletAddress(params.Receiver) + if err != nil { + return nil, errors.Wrapf(err, "cannot decode receiver address %s", params.Receiver) + } + + // parse token account + tokenAccount, err := solana.PublicKeyFromBase58(asset) + if err != nil { + return nil, errors.Wrapf(err, "cannot parse asset public key %s", asset) + } + + // get recipient ata + recipientAta, _, err := solana.FindAssociatedTokenAddress(to, tokenAccount) + if err != nil { + return nil, errors.Wrapf(err, "cannot find ATA for %s and token account %s", to, tokenAccount) + } + + // prepare withdraw spl msg and compute hash + msg := contracts.NewMsgWithdrawSPL(chainID, nonce, amount, decimals, tokenAccount, to, recipientAta) + 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") + } + + // attach the signature and return + return msg.SetSignature(signature), nil +} + +// signWithdrawSPLTx wraps the withdraw spl 'msg' into a Solana transaction and signs it with the relayer key. +func (signer *Signer) signWithdrawSPLTx( + ctx context.Context, + msg contracts.MsgWithdrawSPL, +) (*solana.Transaction, error) { + // create withdraw spl instruction with program call data + dataBytes, err := borsh.Serialize(contracts.WithdrawSPLInstructionParams{ + Discriminator: contracts.DiscriminatorWithdrawSPL, + Decimals: msg.Decimals(), + 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") + } + + pdaAta, _, err := solana.FindAssociatedTokenAddress(signer.pda, msg.TokenAccount()) + if err != nil { + return nil, errors.Wrapf(err, "cannot find ATA for %s and token account %s", signer.pda, msg.TokenAccount()) + } + + recipientAta, _, err := solana.FindAssociatedTokenAddress(msg.To(), msg.TokenAccount()) + if err != nil { + return nil, errors.Wrapf(err, "cannot find ATA for %s and token account %s", msg.To(), msg.TokenAccount()) + } + + inst := solana.GenericInstruction{ + ProgID: signer.gatewayID, + DataBytes: dataBytes, + AccountValues: []*solana.AccountMeta{ + solana.Meta(signer.relayerKey.PublicKey()).WRITE().SIGNER(), + solana.Meta(signer.pda).WRITE(), + solana.Meta(pdaAta).WRITE(), + solana.Meta(msg.TokenAccount()), + solana.Meta(msg.To()), + solana.Meta(recipientAta).WRITE(), + solana.Meta(signer.rentPayerPda).WRITE(), + solana.Meta(solana.TokenProgramID), + solana.Meta(solana.SPLAssociatedTokenAccountProgramID), + solana.Meta(solana.SystemProgramID), + }, + } + // get a recent blockhash + recent, err := signer.client.GetLatestBlockhash(ctx, rpc.CommitmentFinalized) + if err != nil { + return nil, errors.Wrap(err, "GetLatestBlockhash 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(signer.relayerKey.PublicKey()), + ) + if err != nil { + return nil, errors.Wrap(err, "NewTransaction error") + } + + // relayer signs the transaction + _, err = tx.Sign(func(key solana.PublicKey) *solana.PrivateKey { + if key.Equals(signer.relayerKey.PublicKey()) { + return signer.relayerKey + } + return nil + }) + if err != nil { + return nil, errors.Wrap(err, "signer unable to sign transaction") + } + + return tx, nil +}