From 3c22fbbb6070b0aa6d963237f22e6415fdd74951 Mon Sep 17 00:00:00 2001 From: skosito Date: Fri, 15 Nov 2024 09:06:33 +0000 Subject: [PATCH] test: extend solana unit tests (#3158) * improve inbound tests * fix terminology * add outbound tests for withdraw spl * PR comments * PR comment --- e2e/runner/setup_solana.go | 4 +- e2e/runner/solana.go | 36 ++--- pkg/contracts/solana/gateway.go | 2 +- pkg/contracts/solana/gateway_message.go | 14 +- pkg/contracts/solana/gateway_message_test.go | 49 ++++-- pkg/contracts/solana/inbound.go | 43 +++-- pkg/contracts/solana/inbound_test.go | 146 ++++++++++++++++- pkg/contracts/solana/instruction.go | 1 + .../chains/solana/observer/outbound_test.go | 149 ++++++++++++++++++ .../chains/solana/signer/withdraw_spl.go | 20 +-- ...DMZndhZn7hQ1i4RhTyHXRWxtR5ZNVHmmjAUSF.json | 78 +++++++++ 11 files changed, 478 insertions(+), 64 deletions(-) create mode 100644 zetaclient/testdata/solana/chain_901_outbound_tx_result_3NgoR4K9FJq7UunorPRGW9wpqMV8oNvZERejutd7bKmqh3CKEV5DMZndhZn7hQ1i4RhTyHXRWxtR5ZNVHmmjAUSF.json diff --git a/e2e/runner/setup_solana.go b/e2e/runner/setup_solana.go index 28a4fb65fa..46155c8386 100644 --- a/e2e/runner/setup_solana.go +++ b/e2e/runner/setup_solana.go @@ -113,8 +113,8 @@ func (r *E2ERunner) SetupSolana(gatewayID, deployerPrivateKey string) { require.NoError(r, err) // deploy test spl - tokenAccount := r.DeploySPL(&privkey, true) - r.SPLAddr = tokenAccount.PublicKey() + mintAccount := r.DeploySPL(&privkey, true) + r.SPLAddr = mintAccount.PublicKey() } func (r *E2ERunner) ensureSolanaChainParams() error { diff --git a/e2e/runner/solana.go b/e2e/runner/solana.go index 380219dee4..37407c67e1 100644 --- a/e2e/runner/solana.go +++ b/e2e/runner/solana.go @@ -161,9 +161,9 @@ func (r *E2ERunner) CreateSignedTransaction( func (r *E2ERunner) ResolveSolanaATA( payer solana.PrivateKey, owner solana.PublicKey, - tokenAccount solana.PublicKey, + mintAccount solana.PublicKey, ) solana.PublicKey { - pdaAta, _, err := solana.FindAssociatedTokenAddress(owner, tokenAccount) + pdaAta, _, err := solana.FindAssociatedTokenAddress(owner, mintAccount) require.NoError(r, err) info, _ := r.SolanaClient.GetAccountInfo(r.Ctx, pdaAta) @@ -172,7 +172,7 @@ func (r *E2ERunner) ResolveSolanaATA( return pdaAta } // doesn't exist, create it - ataInstruction := associatedtokenaccount.NewCreateInstruction(payer.PublicKey(), owner, tokenAccount).Build() + ataInstruction := associatedtokenaccount.NewCreateInstruction(payer.PublicKey(), owner, mintAccount).Build() signedTx := r.CreateSignedTransaction( []solana.Instruction{ataInstruction}, payer, @@ -188,19 +188,19 @@ func (r *E2ERunner) ResolveSolanaATA( func (r *E2ERunner) SPLDepositAndCall( privateKey *solana.PrivateKey, amount uint64, - tokenAccount solana.PublicKey, + mintAccount solana.PublicKey, receiver ethcommon.Address, data []byte, ) solana.Signature { // ata for pda pda := r.ComputePdaAddress() - pdaAta := r.ResolveSolanaATA(*privateKey, pda, tokenAccount) + pdaAta := r.ResolveSolanaATA(*privateKey, pda, mintAccount) // deployer ata - ata := r.ResolveSolanaATA(*privateKey, privateKey.PublicKey(), tokenAccount) + ata := r.ResolveSolanaATA(*privateKey, privateKey.PublicKey(), mintAccount) // deposit spl - seed := [][]byte{[]byte("whitelist"), tokenAccount.Bytes()} + seed := [][]byte{[]byte("whitelist"), mintAccount.Bytes()} whitelistEntryPDA, _, err := solana.FindProgramAddress(seed, r.GatewayProgram) require.NoError(r, err) @@ -208,7 +208,7 @@ func (r *E2ERunner) SPLDepositAndCall( amount, privateKey.PublicKey(), whitelistEntryPDA, - tokenAccount, + mintAccount, ata, pdaAta, receiver, @@ -231,26 +231,26 @@ func (r *E2ERunner) DeploySPL(privateKey *solana.PrivateKey, whitelist bool) *so require.NoError(r, err) // to deploy new spl token, create account instruction and initialize mint instruction have to be in the same transaction - tokenAccount := solana.NewWallet() + mintAccount := solana.NewWallet() createAccountInstruction := system.NewCreateAccountInstruction( lamport, token.MINT_SIZE, solana.TokenProgramID, privateKey.PublicKey(), - tokenAccount.PublicKey(), + mintAccount.PublicKey(), ).Build() initializeMintInstruction := token.NewInitializeMint2Instruction( 6, privateKey.PublicKey(), privateKey.PublicKey(), - tokenAccount.PublicKey(), + mintAccount.PublicKey(), ).Build() signedTx := r.CreateSignedTransaction( []solana.Instruction{createAccountInstruction, initializeMintInstruction}, *privateKey, - []solana.PrivateKey{tokenAccount.PrivateKey}, + []solana.PrivateKey{mintAccount.PrivateKey}, ) // broadcast the transaction and wait for finalization @@ -258,9 +258,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.ResolveSolanaATA(*privateKey, privateKey.PublicKey(), tokenAccount.PublicKey()) + ata := r.ResolveSolanaATA(*privateKey, privateKey.PublicKey(), mintAccount.PublicKey()) - mintToInstruction := token.NewMintToInstruction(uint64(1_000_000_000), tokenAccount.PublicKey(), ata, privateKey.PublicKey(), []solana.PublicKey{}). + mintToInstruction := token.NewMintToInstruction(uint64(1_000_000_000), mintAccount.PublicKey(), ata, privateKey.PublicKey(), []solana.PublicKey{}). Build() signedTx = r.CreateSignedTransaction( []solana.Instruction{mintToInstruction}, @@ -274,7 +274,7 @@ func (r *E2ERunner) DeploySPL(privateKey *solana.PrivateKey, whitelist bool) *so // optionally whitelist spl token in gateway if whitelist { - seed := [][]byte{[]byte("whitelist"), tokenAccount.PublicKey().Bytes()} + seed := [][]byte{[]byte("whitelist"), mintAccount.PublicKey().Bytes()} whitelistEntryPDA, _, err := solana.FindProgramAddress(seed, r.GatewayProgram) require.NoError(r, err) @@ -283,14 +283,14 @@ func (r *E2ERunner) DeploySPL(privateKey *solana.PrivateKey, whitelist bool) *so // already whitelisted if whitelistEntryInfo != nil { - return tokenAccount + return mintAccount } // create 'whitelist_spl_mint' instruction instruction := r.CreateWhitelistSPLMintInstruction( privateKey.PublicKey(), whitelistEntryPDA, - tokenAccount.PublicKey(), + mintAccount.PublicKey(), ) // create and sign the transaction signedTx := r.CreateSignedTransaction([]solana.Instruction{instruction}, *privateKey, []solana.PrivateKey{}) @@ -304,7 +304,7 @@ func (r *E2ERunner) DeploySPL(privateKey *solana.PrivateKey, whitelist bool) *so require.NotNil(r, whitelistEntryInfo) } - return tokenAccount + return mintAccount } // BroadcastTxSync broadcasts a transaction and waits for it to be finalized diff --git a/pkg/contracts/solana/gateway.go b/pkg/contracts/solana/gateway.go index 7465893149..9ff1d5361f 100644 --- a/pkg/contracts/solana/gateway.go +++ b/pkg/contracts/solana/gateway.go @@ -63,7 +63,7 @@ func ParseGatewayWithPDA(gatewayAddress string) (solana.PublicKey, solana.Public return gatewayID, pda, err } -// ParseRentPayerPda parses the rent payer program derived address from the given string +// 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) diff --git a/pkg/contracts/solana/gateway_message.go b/pkg/contracts/solana/gateway_message.go index bd8cbb51af..1ab7e64ae4 100644 --- a/pkg/contracts/solana/gateway_message.go +++ b/pkg/contracts/solana/gateway_message.go @@ -119,8 +119,8 @@ type MsgWithdrawSPL struct { // amount is the lamports amount for the withdraw_spl amount uint64 - // tokenAccount is the address for the spl token - tokenAccount solana.PublicKey + // mintAccount is the address for the spl token + mintAccount solana.PublicKey // decimals of spl token decimals uint8 @@ -139,7 +139,7 @@ type MsgWithdrawSPL struct { func NewMsgWithdrawSPL( chainID, nonce, amount uint64, decimals uint8, - tokenAccount, to, toAta solana.PublicKey, + mintAccount, to, toAta solana.PublicKey, ) *MsgWithdrawSPL { return &MsgWithdrawSPL{ chainID: chainID, @@ -147,7 +147,7 @@ func NewMsgWithdrawSPL( amount: amount, to: to, recipientAta: toAta, - tokenAccount: tokenAccount, + mintAccount: mintAccount, decimals: decimals, } } @@ -176,8 +176,8 @@ func (msg *MsgWithdrawSPL) RecipientAta() solana.PublicKey { return msg.recipientAta } -func (msg *MsgWithdrawSPL) TokenAccount() solana.PublicKey { - return msg.tokenAccount +func (msg *MsgWithdrawSPL) MintAccount() solana.PublicKey { + return msg.mintAccount } func (msg *MsgWithdrawSPL) Decimals() uint8 { @@ -200,7 +200,7 @@ func (msg *MsgWithdrawSPL) Hash() [32]byte { binary.BigEndian.PutUint64(buff, msg.amount) message = append(message, buff...) - message = append(message, msg.tokenAccount.Bytes()...) + message = append(message, msg.mintAccount.Bytes()...) message = append(message, msg.recipientAta.Bytes()...) diff --git a/pkg/contracts/solana/gateway_message_test.go b/pkg/contracts/solana/gateway_message_test.go index 68af93e859..f291a408cf 100644 --- a/pkg/contracts/solana/gateway_message_test.go +++ b/pkg/contracts/solana/gateway_message_test.go @@ -1,8 +1,6 @@ package solana_test import ( - "bytes" - "encoding/hex" "testing" "github.com/stretchr/testify/require" @@ -10,10 +8,12 @@ import ( "github.com/gagliardetto/solana-go" "github.com/zeta-chain/node/pkg/chains" contracts "github.com/zeta-chain/node/pkg/contracts/solana" + "github.com/zeta-chain/node/testutil" ) func Test_MsgWithdrawHash(t *testing.T) { - t.Run("should pass for archived inbound, receipt and cctx", func(t *testing.T) { + t.Run("should calculate expected hash", func(t *testing.T) { + // ARRANGE // #nosec G115 always positive chainID := uint64(chains.SolanaLocalnet.ChainId) nonce := uint64(0) @@ -21,17 +21,20 @@ func Test_MsgWithdrawHash(t *testing.T) { to := solana.MustPublicKeyFromBase58("37yGiHAnLvWZUNVwu9esp74YQFqxU1qHCbABkDvRddUQ") wantHash := "aa609ef9480303e8d743f6e36fe1bea0cc56b8d27dcbd8220846125c1181b681" - wantHashBytes, err := hex.DecodeString(wantHash) - require.NoError(t, err) + wantHashBytes := testutil.HexToBytes(t, wantHash) + // ACT // create new withdraw message hash := contracts.NewMsgWithdraw(chainID, nonce, amount, to).Hash() - require.True(t, bytes.Equal(hash[:], wantHashBytes)) + + // ASSERT + require.EqualValues(t, hash[:], wantHashBytes) }) } func Test_MsgWhitelistHash(t *testing.T) { - t.Run("should pass for archived inbound, receipt and cctx", func(t *testing.T) { + t.Run("should calculate expected hash", func(t *testing.T) { + // ARRANGE // #nosec G115 always positive chainID := uint64(chains.SolanaLocalnet.ChainId) nonce := uint64(0) @@ -39,11 +42,37 @@ func Test_MsgWhitelistHash(t *testing.T) { whitelistEntry := solana.MustPublicKeyFromBase58("2kJndCL9NBR36ySiQ4bmArs4YgWQu67LmCDfLzk5Gb7s") wantHash := "cde8fa3ab24b50320db1c47f30492e789177d28e76208176f0a52b8ed54ce2dd" - wantHashBytes, err := hex.DecodeString(wantHash) - require.NoError(t, err) + wantHashBytes := testutil.HexToBytes(t, wantHash) + // ACT // create new withdraw message hash := contracts.NewMsgWhitelist(whitelistCandidate, whitelistEntry, chainID, nonce).Hash() - require.True(t, bytes.Equal(hash[:], wantHashBytes)) + + // ASSERT + require.EqualValues(t, hash[:], wantHashBytes) + }) +} + +func Test_MsgWithdrawSPLHash(t *testing.T) { + t.Run("should calculate expected hash", func(t *testing.T) { + // ARRANGE + // #nosec G115 always positive + chainID := uint64(chains.SolanaLocalnet.ChainId) + nonce := uint64(0) + amount := uint64(1336000) + mintAccount := solana.MustPublicKeyFromBase58("AS48jKNQsDGkEdDvfwu1QpqjtqbCadrAq9nGXjFmdX3Z") + to := solana.MustPublicKeyFromBase58("37yGiHAnLvWZUNVwu9esp74YQFqxU1qHCbABkDvRddUQ") + toAta, _, err := solana.FindAssociatedTokenAddress(to, mintAccount) + require.NoError(t, err) + + wantHash := "87fa5c0ed757c6e1ea9d8976537eaf7868bc1f1bbf55ab198a01645d664fe0ae" + wantHashBytes := testutil.HexToBytes(t, wantHash) + + // ACT + // create new withdraw message + hash := contracts.NewMsgWithdrawSPL(chainID, nonce, amount, 8, mintAccount, to, toAta).Hash() + + // ASSERT + require.EqualValues(t, hash[:], wantHashBytes) }) } diff --git a/pkg/contracts/solana/inbound.go b/pkg/contracts/solana/inbound.go index 766f84aa58..3b2e153606 100644 --- a/pkg/contracts/solana/inbound.go +++ b/pkg/contracts/solana/inbound.go @@ -94,34 +94,53 @@ func ParseInboundAsDepositSPL( }, nil } -// GetSignerDeposit returns the signer address of the deposit instruction -// Note: solana-go is not able to parse the AccountMeta 'is_signer' ATM. This is a workaround. +// getSignerDeposit returns the signer address of the deposit instruction func getSignerDeposit(tx *solana.Transaction, inst *solana.CompiledInstruction) (string, error) { + instructionAccounts, err := inst.ResolveInstructionAccounts(&tx.Message) + if err != nil { + return "", err + } + // there should be 3 accounts for a deposit instruction - if len(inst.Accounts) != accountsNumDeposit { - return "", fmt.Errorf("want %d accounts, got %d", accountsNumDeposit, len(inst.Accounts)) + if len(instructionAccounts) != accountsNumDeposit { + return "", fmt.Errorf("want %d accounts, got %d", accountsNumDeposit, len(instructionAccounts)) } - // sender is the signer account - return tx.Message.AccountKeys[0].String(), nil + // the accounts are [signer, pda, system_program] + // check if first account is signer + if !instructionAccounts[0].IsSigner { + return "", fmt.Errorf("not signer %s", instructionAccounts[0].PublicKey.String()) + } + + return instructionAccounts[0].PublicKey.String(), nil } +// getSignerAndSPLFromDepositSPLAccounts returns the signer and spl address of the deposit_spl instruction func getSignerAndSPLFromDepositSPLAccounts( tx *solana.Transaction, inst *solana.CompiledInstruction, ) (string, string, error) { + instructionAccounts, err := inst.ResolveInstructionAccounts(&tx.Message) + if err != nil { + return "", "", err + } + // there should be 7 accounts for a deposit spl instruction - if len(inst.Accounts) != accountsNumberDepositSPL { + if len(instructionAccounts) != accountsNumberDepositSPL { return "", "", fmt.Errorf( "want %d accounts, got %d", accountsNumberDepositSPL, - len(inst.Accounts), + len(instructionAccounts), ) } - // the accounts are [signer, pda, whitelist_entry, mint_account, token_program, from, to] - signer := tx.Message.AccountKeys[0] - spl := tx.Message.AccountKeys[inst.Accounts[3]] + // check if first account is signer + if !instructionAccounts[0].IsSigner { + return "", "", fmt.Errorf("not signer %s", instructionAccounts[0].PublicKey.String()) + } + + signer := instructionAccounts[0].PublicKey.String() + spl := instructionAccounts[3].PublicKey.String() - return signer.String(), spl.String(), nil + return signer, spl, nil } diff --git a/pkg/contracts/solana/inbound_test.go b/pkg/contracts/solana/inbound_test.go index 7b19badf7a..b6550d99fd 100644 --- a/pkg/contracts/solana/inbound_test.go +++ b/pkg/contracts/solana/inbound_test.go @@ -8,7 +8,9 @@ import ( "path/filepath" "testing" + "github.com/gagliardetto/solana-go" "github.com/gagliardetto/solana-go/rpc" + "github.com/near/borsh-go" "github.com/stretchr/testify/require" "github.com/zeta-chain/node/pkg/chains" "github.com/zeta-chain/node/testutil/sample" @@ -36,6 +38,7 @@ func LoadSolanaInboundTxResult( } func Test_ParseInboundAsDeposit(t *testing.T) { + // ARRANGE txHash := "MS3MPLN7hkbyCZFwKqXcg8fmEvQMD74fN6Ps2LSWXJoRxPW5ehaxBorK9q1JFVbqnAvu9jXm6ertj7kT7HpYw1j" chain := chains.SolanaDevnet @@ -43,8 +46,6 @@ func Test_ParseInboundAsDeposit(t *testing.T) { tx, err := txResult.Transaction.GetTransaction() require.NoError(t, err) - require.NoError(t, err) - // create observer chainParams := sample.ChainParams(chain.ChainId) chainParams.GatewayAddress = testutils.OldSolanaGatewayAddressDevnet @@ -61,15 +62,84 @@ func Test_ParseInboundAsDeposit(t *testing.T) { } t.Run("should parse inbound event deposit SOL", func(t *testing.T) { + // ACT deposit, err := ParseInboundAsDeposit(tx, 0, txResult.Slot) require.NoError(t, err) - // check result + // ASSERT require.EqualValues(t, expectedDeposit, deposit) }) + + t.Run("should skip parsing if wrong discriminator", func(t *testing.T) { + // ARRANGE + txResult := LoadSolanaInboundTxResult(t, txHash) + tx, err := txResult.Transaction.GetTransaction() + require.NoError(t, err) + + instruction := tx.Message.Instructions[0] + + // try deserializing instruction as a 'deposit' + var inst DepositInstructionParams + err = borsh.Deserialize(&inst, instruction.Data) + require.NoError(t, err) + + // serialize it back with wrong discriminator + data, err := borsh.Serialize(DepositInstructionParams{ + Amount: inst.Amount, + Discriminator: DiscriminatorDepositSPL, + Memo: inst.Memo, + }) + require.NoError(t, err) + + tx.Message.Instructions[0].Data = data + + // ACT + deposit, err := ParseInboundAsDeposit(tx, 0, txResult.Slot) + + // ASSERT + require.NoError(t, err) + require.Nil(t, deposit) + }) + + t.Run("should fail if wrong accounts count", func(t *testing.T) { + // ARRANGE + txResult := LoadSolanaInboundTxResult(t, txHash) + tx, err := txResult.Transaction.GetTransaction() + require.NoError(t, err) + + // append one more account to instruction + tx.Message.AccountKeys = append(tx.Message.AccountKeys, solana.MustPublicKeyFromBase58(sample.SolanaAddress(t))) + tx.Message.Instructions[0].Accounts = append(tx.Message.Instructions[0].Accounts, 4) + + // ACT + deposit, err := ParseInboundAsDeposit(tx, 0, txResult.Slot) + + // ASSERT + require.Error(t, err) + require.Nil(t, deposit) + }) + + t.Run("should fail if first account is not signer", func(t *testing.T) { + // ARRANGE + txResult := LoadSolanaInboundTxResult(t, txHash) + tx, err := txResult.Transaction.GetTransaction() + require.NoError(t, err) + + // switch account places + tx.Message.Instructions[0].Accounts[0] = 1 + tx.Message.Instructions[0].Accounts[1] = 0 + + // ACT + deposit, err := ParseInboundAsDeposit(tx, 0, txResult.Slot) + + // ASSERT + require.Error(t, err) + require.Nil(t, deposit) + }) } func Test_ParseInboundAsDepositSPL(t *testing.T) { + // ARRANGE txHash := "aY8yLDze6nHSRi7L5REozKAZY1aAyPJ6TfibiqQL5JGwgSBkYux5z5JfXs5ed8LZqpXUy4VijoU3x15mBd66ZGE" chain := chains.SolanaDevnet @@ -96,10 +166,78 @@ func Test_ParseInboundAsDepositSPL(t *testing.T) { } t.Run("should parse inbound event deposit SPL", func(t *testing.T) { + // ACT deposit, err := ParseInboundAsDepositSPL(tx, 0, txResult.Slot) require.NoError(t, err) - // check result + // ASSERT require.EqualValues(t, expectedDeposit, deposit) }) + + t.Run("should skip parsing if wrong discriminator", func(t *testing.T) { + // ARRANGE + txResult := LoadSolanaInboundTxResult(t, txHash) + tx, err := txResult.Transaction.GetTransaction() + require.NoError(t, err) + + instruction := tx.Message.Instructions[0] + + // try deserializing instruction as a 'deposit_spl' + var inst DepositSPLInstructionParams + err = borsh.Deserialize(&inst, instruction.Data) + require.NoError(t, err) + + // serialize it back with wrong discriminator + data, err := borsh.Serialize(DepositInstructionParams{ + Amount: inst.Amount, + Discriminator: DiscriminatorDeposit, + Memo: inst.Memo, + }) + require.NoError(t, err) + + tx.Message.Instructions[0].Data = data + + // ACT + deposit, err := ParseInboundAsDepositSPL(tx, 0, txResult.Slot) + + // ASSERT + require.NoError(t, err) + require.Nil(t, deposit) + }) + + t.Run("should fail if wrong accounts count", func(t *testing.T) { + // ARRANGE + txResult := LoadSolanaInboundTxResult(t, txHash) + tx, err := txResult.Transaction.GetTransaction() + require.NoError(t, err) + + // append one more account to instruction + tx.Message.AccountKeys = append(tx.Message.AccountKeys, solana.MustPublicKeyFromBase58(sample.SolanaAddress(t))) + tx.Message.Instructions[0].Accounts = append(tx.Message.Instructions[0].Accounts, 4) + + // ACT + deposit, err := ParseInboundAsDepositSPL(tx, 0, txResult.Slot) + + // ASSERT + require.Error(t, err) + require.Nil(t, deposit) + }) + + t.Run("should fail if first account is not signer", func(t *testing.T) { + // ARRANGE + txResult := LoadSolanaInboundTxResult(t, txHash) + tx, err := txResult.Transaction.GetTransaction() + require.NoError(t, err) + + // switch account places + tx.Message.Instructions[0].Accounts[0] = 1 + tx.Message.Instructions[0].Accounts[1] = 0 + + // ACT + deposit, err := ParseInboundAsDepositSPL(tx, 0, txResult.Slot) + + // ASSERT + require.Error(t, err) + require.Nil(t, deposit) + }) } diff --git a/pkg/contracts/solana/instruction.go b/pkg/contracts/solana/instruction.go index 44aa80c16b..9bd216d338 100644 --- a/pkg/contracts/solana/instruction.go +++ b/pkg/contracts/solana/instruction.go @@ -128,6 +128,7 @@ type WithdrawSPLInstructionParams struct { // Discriminator is the unique identifier for the withdraw instruction Discriminator [8]byte + // Decimals is decimals for spl token Decimals uint8 // Amount is the lamports amount for the withdraw diff --git a/zetaclient/chains/solana/observer/outbound_test.go b/zetaclient/chains/solana/observer/outbound_test.go index bdda96c451..020c781b3a 100644 --- a/zetaclient/chains/solana/observer/outbound_test.go +++ b/zetaclient/chains/solana/observer/outbound_test.go @@ -38,6 +38,9 @@ const ( // whitelistTxTest is local devnet tx result for testing whitelistTxTest = "phM9bESbiqojmpkkUxgjed8EABkxvPGNau9q31B8Yk1sXUtsxJvd6G9VbZZQPsEyn6RiTH4YBtqJ89omqfbbNNY" + + // withdrawSPLTxTest is local devnet tx result for testing + withdrawSPLTxTest = "3NgoR4K9FJq7UunorPRGW9wpqMV8oNvZERejutd7bKmqh3CKEV5DMZndhZn7hQ1i4RhTyHXRWxtR5ZNVHmmjAUSF" ) // createTestObserver creates a test observer for testing @@ -60,6 +63,7 @@ func createTestObserver( } func Test_CheckFinalizedTx(t *testing.T) { + // ARRANGE // the test chain and transaction hash chain := chains.SolanaDevnet txHash := withdrawTxTest @@ -83,18 +87,25 @@ func Test_CheckFinalizedTx(t *testing.T) { ctx := context.Background() t.Run("should successfully check finalized tx", func(t *testing.T) { + // ACT tx, finalized := ob.CheckFinalizedTx(ctx, txHash, nonce, coinType) + + // ASSERT require.True(t, finalized) require.NotNil(t, tx) }) t.Run("should return error on invalid tx hash", func(t *testing.T) { + // ACT tx, finalized := ob.CheckFinalizedTx(ctx, "invalid_hash_1234", nonce, coinType) + + // ASSERT require.False(t, finalized) require.Nil(t, tx) }) t.Run("should return error on GetTransaction error", func(t *testing.T) { + // ARRANGE // mock GetTransaction error client := mocks.NewSolanaRPCClient(t) client.On("GetTransaction", mock.Anything, mock.Anything, mock.Anything).Return(nil, errors.New("error")) @@ -102,12 +113,16 @@ func Test_CheckFinalizedTx(t *testing.T) { // create observer ob := createTestObserver(t, chain, client, tss) + // ACT tx, finalized := ob.CheckFinalizedTx(ctx, txHash, nonce, coinType) + + // ASSERT require.False(t, finalized) require.Nil(t, tx) }) t.Run("should return error on if transaction is failed", func(t *testing.T) { + // ARRANGE // load archived outbound tx result which is failed due to nonce mismatch failedResult := testutils.LoadSolanaOutboundTxResult(t, TestDataDir, chain.ChainId, txHashFailed) @@ -118,31 +133,44 @@ func Test_CheckFinalizedTx(t *testing.T) { // create observer ob := createTestObserver(t, chain, client, tss) + // ACT tx, finalized := ob.CheckFinalizedTx(ctx, txHash, nonce, coinType) + + // ASSERT require.False(t, finalized) require.Nil(t, tx) }) t.Run("should return error on ParseGatewayInstruction error", func(t *testing.T) { + // ACT // use CoinType_Zeta to cause ParseGatewayInstruction error tx, finalized := ob.CheckFinalizedTx(ctx, txHash, nonce, coin.CoinType_Zeta) + + // ASSERT require.False(t, finalized) require.Nil(t, tx) }) t.Run("should return error on ECDSA signer mismatch", func(t *testing.T) { + // ARRANGE // create observer with other TSS address tssOther := mocks.NewMockTSS(chain, sample.EthAddress().String(), "") ob := createTestObserver(t, chain, solClient, tssOther) + // ACT tx, finalized := ob.CheckFinalizedTx(ctx, txHash, nonce, coinType) + + // ASSERT require.False(t, finalized) require.Nil(t, tx) }) t.Run("should return error on nonce mismatch", func(t *testing.T) { + // ACT // use different nonce tx, finalized := ob.CheckFinalizedTx(ctx, txHash, nonce+1, coinType) + + // ASSERT require.False(t, finalized) require.Nil(t, tx) }) @@ -159,13 +187,16 @@ func Test_ParseGatewayInstruction(t *testing.T) { require.NoError(t, err) t.Run("should parse gateway instruction", func(t *testing.T) { + // ARRANGE // load archived outbound tx result txResult := testutils.LoadSolanaOutboundTxResult(t, TestDataDir, chain.ChainId, txHash) + // ACT // parse gateway instruction inst, err := observer.ParseGatewayInstruction(txResult, gatewayID, coin.CoinType_Gas) require.NoError(t, err) + // ASSERT // check sender, nonce and amount sender, err := inst.Signer() require.NoError(t, err) @@ -175,6 +206,7 @@ func Test_ParseGatewayInstruction(t *testing.T) { }) t.Run("should return error on invalid number of instructions", func(t *testing.T) { + // ARRANGE // load and unmarshal archived transaction txResult := testutils.LoadSolanaOutboundTxResult(t, TestDataDir, chain.ChainId, txHash) tx, err := txResult.Transaction.GetTransaction() @@ -183,12 +215,16 @@ func Test_ParseGatewayInstruction(t *testing.T) { // remove all instructions tx.Message.Instructions = nil + // ACT inst, err := observer.ParseGatewayInstruction(txResult, gatewayID, coin.CoinType_Gas) + + // ASSERT 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) { + // ARRANGE // load and unmarshal archived transaction txResult := testutils.LoadSolanaOutboundTxResult(t, TestDataDir, chain.ChainId, txHash) tx, err := txResult.Transaction.GetTransaction() @@ -197,12 +233,16 @@ func Test_ParseGatewayInstruction(t *testing.T) { // set invalid program id index (out of range) tx.Message.Instructions[0].ProgramIDIndex = 4 + // ACT inst, err := observer.ParseGatewayInstruction(txResult, gatewayID, coin.CoinType_Gas) + + // ASSERT 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) { + // ARRANGE // load and unmarshal archived transaction txResult := testutils.LoadSolanaOutboundTxResult(t, TestDataDir, chain.ChainId, txHash) tx, err := txResult.Transaction.GetTransaction() @@ -211,12 +251,16 @@ func Test_ParseGatewayInstruction(t *testing.T) { // set invalid program id index (pda account index) tx.Message.Instructions[0].ProgramIDIndex = 1 + // ACT inst, err := observer.ParseGatewayInstruction(txResult, gatewayID, coin.CoinType_Gas) + + // ASSERT require.ErrorContains(t, err, "not matching gatewayID") require.Nil(t, inst) }) t.Run("should return error when instruction parsing fails", func(t *testing.T) { + // ARRANGE // load and unmarshal archived transaction txResult := testutils.LoadSolanaOutboundTxResult(t, TestDataDir, chain.ChainId, txHash) tx, err := txResult.Transaction.GetTransaction() @@ -225,16 +269,23 @@ func Test_ParseGatewayInstruction(t *testing.T) { // set invalid instruction data to cause parsing error tx.Message.Instructions[0].Data = []byte("invalid instruction data") + // ACT inst, err := observer.ParseGatewayInstruction(txResult, gatewayID, coin.CoinType_Gas) + + // ASSERT require.Error(t, err) require.Nil(t, inst) }) t.Run("should return error on unsupported coin type", func(t *testing.T) { + // ARRANGE // load and unmarshal archived transaction txResult := testutils.LoadSolanaOutboundTxResult(t, TestDataDir, chain.ChainId, txHash) + // ACT inst, err := observer.ParseGatewayInstruction(txResult, gatewayID, coin.CoinType_Zeta) + + // ASSERT require.ErrorContains(t, err, "unsupported outbound coin type") require.Nil(t, inst) }) @@ -247,15 +298,19 @@ func Test_ParseInstructionWithdraw(t *testing.T) { txAmount := uint64(890880) t.Run("should parse instruction withdraw", func(t *testing.T) { + // ARRANGE // 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] + + // ACT inst, err := contracts.ParseInstructionWithdraw(instruction) require.NoError(t, err) + // ASSERT // check sender, nonce and amount sender, err := inst.Signer() require.NoError(t, err) @@ -265,6 +320,7 @@ func Test_ParseInstructionWithdraw(t *testing.T) { }) t.Run("should return error on invalid instruction data", func(t *testing.T) { + // ARRANGE // load and unmarshal archived transaction txResult := testutils.LoadSolanaOutboundTxResult(t, TestDataDir, chain.ChainId, txHash) txFake, err := txResult.Transaction.GetTransaction() @@ -274,12 +330,16 @@ func Test_ParseInstructionWithdraw(t *testing.T) { instruction := txFake.Message.Instructions[0] instruction.Data = []byte("invalid instruction data") + // ACT inst, err := contracts.ParseInstructionWithdraw(instruction) + + // ASSERT require.ErrorContains(t, err, "error deserializing instruction") require.Nil(t, inst) }) t.Run("should return error on discriminator mismatch", func(t *testing.T) { + // ARRANGE // load and unmarshal archived transaction txResult := testutils.LoadSolanaOutboundTxResult(t, TestDataDir, chain.ChainId, txHash) txFake, err := txResult.Transaction.GetTransaction() @@ -292,7 +352,10 @@ func Test_ParseInstructionWithdraw(t *testing.T) { require.NoError(t, err) copy(instruction.Data, fakeDiscriminatorBytes) + // ACT inst, err := contracts.ParseInstructionWithdraw(instruction) + + // ASSERT require.ErrorContains(t, err, "not a withdraw instruction") require.Nil(t, inst) }) @@ -305,6 +368,7 @@ func Test_ParseInstructionWhitelist(t *testing.T) { txAmount := uint64(0) t.Run("should parse instruction whitelist", func(t *testing.T) { + // ARRANGE // tss address used in local devnet tssAddress := "0x7E8c7bAcd3c6220DDC35A4EA1141BE14F2e1dFEB" // load and unmarshal archived transaction @@ -313,9 +377,12 @@ func Test_ParseInstructionWhitelist(t *testing.T) { require.NoError(t, err) instruction := tx.Message.Instructions[0] + + // ACT inst, err := contracts.ParseInstructionWhitelist(instruction) require.NoError(t, err) + // ASSERT // check sender, nonce and amount sender, err := inst.Signer() require.NoError(t, err) @@ -325,6 +392,7 @@ func Test_ParseInstructionWhitelist(t *testing.T) { }) t.Run("should return error on invalid instruction data", func(t *testing.T) { + // ARRANGE // load and unmarshal archived transaction txResult := testutils.LoadSolanaOutboundTxResult(t, TestDataDir, chain.ChainId, txHash) txFake, err := txResult.Transaction.GetTransaction() @@ -334,12 +402,16 @@ func Test_ParseInstructionWhitelist(t *testing.T) { instruction := txFake.Message.Instructions[0] instruction.Data = []byte("invalid instruction data") + // ACT inst, err := contracts.ParseInstructionWhitelist(instruction) + + // ASSERT require.ErrorContains(t, err, "error deserializing instruction") require.Nil(t, inst) }) t.Run("should return error on discriminator mismatch", func(t *testing.T) { + // ARRANGE // load and unmarshal archived transaction txResult := testutils.LoadSolanaOutboundTxResult(t, TestDataDir, chain.ChainId, txHash) txFake, err := txResult.Transaction.GetTransaction() @@ -352,8 +424,85 @@ func Test_ParseInstructionWhitelist(t *testing.T) { require.NoError(t, err) copy(instruction.Data, fakeDiscriminatorBytes) + // ACT inst, err := contracts.ParseInstructionWhitelist(instruction) + + // ASSERT require.ErrorContains(t, err, "not a whitelist_spl_mint instruction") require.Nil(t, inst) }) } + +func Test_ParseInstructionWithdrawSPL(t *testing.T) { + // the test chain and transaction hash + chain := chains.SolanaDevnet + txHash := withdrawSPLTxTest + txAmount := uint64(1000000) + + t.Run("should parse instruction withdraw spl", func(t *testing.T) { + // ARRANGE + // tss address used in local devnet + tssAddress := "0x9c427Bc95cC11dE0D3Fb7603A99833e8f781Cfba" + // 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] + + // ACT + inst, err := contracts.ParseInstructionWithdrawSPL(instruction) + require.NoError(t, err) + + // ASSERT + // check sender, nonce and amount + sender, err := inst.Signer() + require.NoError(t, err) + require.Equal(t, tssAddress, sender.String()) + require.EqualValues(t, 3, inst.GatewayNonce()) + require.EqualValues(t, txAmount, inst.TokenAmount()) + require.EqualValues(t, 6, inst.Decimals) + require.EqualValues(t, contracts.DiscriminatorWithdrawSPL, inst.Discriminator) + }) + + t.Run("should return error on invalid instruction data", func(t *testing.T) { + // ARRANGE + // 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") + + // ACT + inst, err := contracts.ParseInstructionWithdrawSPL(instruction) + + // ASSERT + require.ErrorContains(t, err, "error deserializing instruction") + require.Nil(t, inst) + }) + + t.Run("should return error on discriminator mismatch", func(t *testing.T) { + // ARRANGE + // 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) + + // ACT + inst, err := contracts.ParseInstructionWithdrawSPL(instruction) + + // ASSERT + require.ErrorContains(t, err, "not a withdraw instruction") + require.Nil(t, inst) + }) +} diff --git a/zetaclient/chains/solana/signer/withdraw_spl.go b/zetaclient/chains/solana/signer/withdraw_spl.go index bf03260eca..aaeae17f38 100644 --- a/zetaclient/chains/solana/signer/withdraw_spl.go +++ b/zetaclient/chains/solana/signer/withdraw_spl.go @@ -39,20 +39,20 @@ func (signer *Signer) createAndSignMsgWithdrawSPL( return nil, errors.Wrapf(err, "cannot decode receiver address %s", params.Receiver) } - // parse token account - tokenAccount, err := solana.PublicKeyFromBase58(asset) + // parse mint account + mintAccount, 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) + recipientAta, _, err := solana.FindAssociatedTokenAddress(to, mintAccount) if err != nil { - return nil, errors.Wrapf(err, "cannot find ATA for %s and token account %s", to, tokenAccount) + return nil, errors.Wrapf(err, "cannot find ATA for %s and mint account %s", to, mintAccount) } // prepare withdraw spl msg and compute hash - msg := contracts.NewMsgWithdrawSPL(chainID, nonce, amount, decimals, tokenAccount, to, recipientAta) + msg := contracts.NewMsgWithdrawSPL(chainID, nonce, amount, decimals, mintAccount, to, recipientAta) msgHash := msg.Hash() // sign the message with TSS to get an ECDSA signature. @@ -85,14 +85,14 @@ func (signer *Signer) signWithdrawSPLTx( return nil, errors.Wrap(err, "cannot serialize withdraw instruction") } - pdaAta, _, err := solana.FindAssociatedTokenAddress(signer.pda, msg.TokenAccount()) + pdaAta, _, err := solana.FindAssociatedTokenAddress(signer.pda, msg.MintAccount()) if err != nil { - return nil, errors.Wrapf(err, "cannot find ATA for %s and token account %s", signer.pda, msg.TokenAccount()) + return nil, errors.Wrapf(err, "cannot find ATA for %s and mint account %s", signer.pda, msg.MintAccount()) } - recipientAta, _, err := solana.FindAssociatedTokenAddress(msg.To(), msg.TokenAccount()) + recipientAta, _, err := solana.FindAssociatedTokenAddress(msg.To(), msg.MintAccount()) if err != nil { - return nil, errors.Wrapf(err, "cannot find ATA for %s and token account %s", msg.To(), msg.TokenAccount()) + return nil, errors.Wrapf(err, "cannot find ATA for %s and mint account %s", msg.To(), msg.MintAccount()) } inst := solana.GenericInstruction{ @@ -102,7 +102,7 @@ func (signer *Signer) signWithdrawSPLTx( solana.Meta(signer.relayerKey.PublicKey()).WRITE().SIGNER(), solana.Meta(signer.pda).WRITE(), solana.Meta(pdaAta).WRITE(), - solana.Meta(msg.TokenAccount()), + solana.Meta(msg.MintAccount()), solana.Meta(msg.To()), solana.Meta(recipientAta).WRITE(), solana.Meta(signer.rentPayerPda).WRITE(), diff --git a/zetaclient/testdata/solana/chain_901_outbound_tx_result_3NgoR4K9FJq7UunorPRGW9wpqMV8oNvZERejutd7bKmqh3CKEV5DMZndhZn7hQ1i4RhTyHXRWxtR5ZNVHmmjAUSF.json b/zetaclient/testdata/solana/chain_901_outbound_tx_result_3NgoR4K9FJq7UunorPRGW9wpqMV8oNvZERejutd7bKmqh3CKEV5DMZndhZn7hQ1i4RhTyHXRWxtR5ZNVHmmjAUSF.json new file mode 100644 index 0000000000..d45b665270 --- /dev/null +++ b/zetaclient/testdata/solana/chain_901_outbound_tx_result_3NgoR4K9FJq7UunorPRGW9wpqMV8oNvZERejutd7bKmqh3CKEV5DMZndhZn7hQ1i4RhTyHXRWxtR5ZNVHmmjAUSF.json @@ -0,0 +1,78 @@ +{ + "slot": 1124, + "blockTime": 1731595541, + "transaction": { + "signatures": [ + "3NgoR4K9FJq7UunorPRGW9wpqMV8oNvZERejutd7bKmqh3CKEV5DMZndhZn7hQ1i4RhTyHXRWxtR5ZNVHmmjAUSF" + ], + "message": { + "accountKeys": [ + "4kkCV8H38xirwQTkE5kL6FHNtYGHnMQQ7SkCjAxibHFK", + "9dcAyYG4bawApZocwZSyJBi9Mynf5EuKAJfifXdfkqik", + "E4hYMd283QGuJu6Gr427Ef6w2Vx538YjesKRM9pENn6a", + "A3aMngDG1mxpRcvjhY1L91x5XU11EmJEbecm7PaVojd9", + "C6KPvGDYfNusoE4yfRP21F8wK35bxCBMT69xk4xo3X79", + "EZtz4FdHsiTZN9ApRzFrCZMDKniD4RFGf5tLkWyxb6vr", + "37yGiHAnLvWZUNVwu9esp74YQFqxU1qHCbABkDvRddUQ", + "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL", + "11111111111111111111111111111111", + "94U5AHQMKkV5txNJ17QPXWoh474PheGou6cNP2FEuL1d" + ], + "header": { + "numRequiredSignatures": 1, + "numReadonlySignedAccounts": 0, + "numReadonlyUnsignedAccounts": 6 + }, + "recentBlockhash": "7ArdgHXwbb2KmpkpYguJt4wUW17gtWjB2RA4Zx26Y3k6", + "instructions": [ + { + "programIdIndex": 10, + "accounts": [ + 0, + 1, + 2, + 5, + 6, + 3, + 4, + 7, + 8, + 9 + ], + "data": "BE2bNcnGtjP96vc9ByEzi6BT8v8rm5qYr8bBZ7GutCZ3RyDXzqTJumgvBxtGVZV4BMXEF4ZSD9YnwQBfTGS7dSAZG6EwBs58tVJp44Mo4TQAQUmZXytiKREBpE8zpT948qaciT1ispFPNuyxqXQNzxN5qZi7533zm5VYP2P" + } + ] + } + }, + "meta": { + "err": null, + "fee": 5000, + "preBalances": [99999985000, 14947680, 2039280, 2039280, 100000000000, 1461600, 99978495600, 929020800, 731913600, 1, 1141440], + "postBalances": [99999980000, 14947680, 2039280, 2039280, 100000000000, 1461600, 99978495600, 929020800, 731913600, 1, 1141440], + "innerInstructions": [], + "preTokenBalances": [], + "postTokenBalances": [], + "logMessages": [ + "Program 94U5AHQMKkV5txNJ17QPXWoh474PheGou6cNP2FEuL1d invoke [1]", + "Program log: Instruction: WithdrawSplToken Program log: recovered address [156, 66, 123, 201, 92, 193, 29, 224, 211, 251, 118, 3, 169, 152, 51, 232, 247, 129, 207, 186]", + "Program log: recovered address [156, 66, 123, 201, 92, 193, 29, 224, 211, 251, 118, 3, 169, 152, 51, 232, 247, 129, 207, 186]", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]", + "Program log: Instruction: TransferChecked Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 6201 of 141039 compute units", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success", + "Program log: withdraw spl token successfully", + "Program 94U5AHQMKkV5txNJ17QPXWoh474PheGou6cNP2FEuL1d consumed 66232 of 200000 compute units", + "Program 94U5AHQMKkV5txNJ17QPXWoh474PheGou6cNP2FEuL1d success" + ], + "status": { + "Ok": null + }, + "rewards": [], + "loadedAddresses": { + "readonly": [], + "writable": [] + }, + "computeUnitsConsumed": 274892589760 + }, + "version": 0 +} \ No newline at end of file