diff --git a/e2e/e2etests/e2etests.go b/e2e/e2etests/e2etests.go index a2a666f3fd..d2b7d29e97 100644 --- a/e2e/e2etests/e2etests.go +++ b/e2e/e2etests/e2etests.go @@ -68,6 +68,7 @@ const ( TestSolanaWithdrawName = "solana_withdraw" TestSolanaWithdrawRevertExecutableReceiverName = "solana_withdraw_revert_executable_receiver" TestSolanaWithdrawAndCallName = "solana_withdraw_and_call" + TestSolanaWithdrawAndCallInvalidTxSizeName = "solana_withdraw_and_call_invalid_tx_size" TestSolanaWithdrawAndCallInvalidMsgEncodingName = "solana_withdraw_and_call_invalid_msg_encoding" TestZEVMToSolanaCallName = "zevm_to_solana_call" TestSolanaWithdrawAndCallRevertWithCallName = "solana_withdraw_and_call_revert_with_call" @@ -722,6 +723,14 @@ var AllE2ETests = []runner.E2ETest{ TestZEVMToSolanaCall, runner.WithMinimumVersion("v29.0.0"), ), + runner.NewE2ETest( + TestSolanaWithdrawAndCallInvalidTxSizeName, + "withdraw SOL from ZEVM and call solana program with invalid tx size", + []runner.ArgDefinition{ + {Description: "amount in lamport", DefaultValue: "1000000"}, + }, + TestSolanaWithdrawAndCallInvalidTxSize, + ), runner.NewE2ETest( TestSolanaWithdrawAndCallInvalidMsgEncodingName, "withdraw SOL from ZEVM and call solana program with invalid msg encoding", diff --git a/e2e/e2etests/test_solana_withdraw_and_call_invalid_tx_size.go b/e2e/e2etests/test_solana_withdraw_and_call_invalid_tx_size.go new file mode 100644 index 0000000000..9c17823dba --- /dev/null +++ b/e2e/e2etests/test_solana_withdraw_and_call_invalid_tx_size.go @@ -0,0 +1,114 @@ +package e2etests + +import ( + "crypto/rand" + "errors" + "math/big" + "strings" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/gagliardetto/solana-go" + "github.com/stretchr/testify/require" + "github.com/zeta-chain/protocol-contracts/pkg/gatewayzevm.sol" + + "github.com/zeta-chain/node/e2e/runner" + "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" +) + +// TestSolanaWithdrawAndCallInvalidTxSize executes withdrawAndCall, but with invalid tx size +// in that case, cctx is reverted due to "transaction is too large" error +func TestSolanaWithdrawAndCallInvalidTxSize(r *runner.E2ERunner, args []string) { + require.Len(r, args, 1) + + // ARRANGE + // Given withdraw amount + withdrawAmount := utils.ParseBigInt(r, args[0]) + + // ensure runner has enough balance + balanceBefore, err := r.SOLZRC20.BalanceOf(&bind.CallOpts{}, r.EVMAddress()) + require.NoError(r, err) + r.Logger.Info("runner balance of SOL before withdraw: %d", balanceBefore) + + require.Equal(r, 1, balanceBefore.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 approved amount: %v", + approvedAmount, + ) + + // get connected program pda + connectedPda, err := solanacontract.ComputeConnectedPdaAddress(r.ConnectedProgram) + require.NoError(r, err) + + // generate a big enough data that exceeds Solana max tx size (encoded/raw 1644/1232) + data := make([]byte, 2048) + _, err = rand.Read(data) + require.NoError(r, err) + + // encode msg + msg := solanacontract.ExecuteMsg{ + Accounts: []solanacontract.AccountMeta{ + {PublicKey: [32]byte(connectedPda.Bytes()), IsWritable: true}, + {PublicKey: [32]byte(r.ComputePdaAddress().Bytes()), IsWritable: false}, + {PublicKey: [32]byte(r.GetSolanaPrivKey().PublicKey().Bytes()), IsWritable: true}, + {PublicKey: [32]byte(solana.SystemProgramID.Bytes()), IsWritable: false}, + {PublicKey: [32]byte(solana.SysVarInstructionsPubkey.Bytes()), IsWritable: false}, + }, + Data: data, + } + + msgEncoded, err := msg.Encode() + require.NoError(r, err) + + // ACT + // withdraw and call + tx := r.WithdrawAndCallSOLZRC20( + withdrawAmount, + approvedAmount, + msgEncoded, + gatewayzevm.RevertOptions{OnRevertGasLimit: big.NewInt(0)}, + ) + + // refresh ERC20 SOL balance immediately after withdraw + // the refund is now on the way, we need to remember the balance and wait for update + balanceBefore, err = r.SOLZRC20.BalanceOf(&bind.CallOpts{}, r.EVMAddress()) + require.NoError(r, err) + r.Logger.Info("runner balance of SOL right after withdraw: %d", balanceBefore) + + // ASSERT + // wait for the cctx to be reverted + cctx := utils.WaitCctxMinedByInboundHash(r.Ctx, tx.Hash().Hex(), r.CctxClient, r.Logger, r.CctxTimeout) + utils.RequireCCTXStatus(r, cctx, crosschaintypes.CctxStatus_Reverted) + + // get ERC20 SOL balance after withdraw + balanceAfter, err := r.SOLZRC20.BalanceOf(&bind.CallOpts{}, r.EVMAddress()) + require.NoError(r, err) + r.Logger.Info("runner balance of SOL after CCTX is reverted: %d", balanceAfter) + + // check if the balance is increased correctly + balanceExpected := new(big.Int).Add(balanceBefore, withdrawAmount) + require.True(r, balanceExpected.Cmp(balanceAfter) == 0, "balance is not refunded correctly") + + // check that failure log is attached to increment nonce instruction + txIncNonce, err := r.SolanaClient.GetTransaction( + r.Ctx, + solana.MustSignatureFromBase58(cctx.OutboundParams[0].Hash), + nil, + ) + require.NoError(r, err) + + expectedLog := "Program log: Failure reason: transaction is too large" + for _, log := range txIncNonce.Meta.LogMessages { + if strings.Contains(log, expectedLog) { + return + } + } + require.NoError(r, errors.New("expected log not found")) +} diff --git a/zetaclient/chains/solana/signer/fallback_tx.go b/zetaclient/chains/solana/signer/fallback_tx.go index 536e95aeeb..51bff0ae8f 100644 --- a/zetaclient/chains/solana/signer/fallback_tx.go +++ b/zetaclient/chains/solana/signer/fallback_tx.go @@ -6,6 +6,14 @@ import ( "github.com/gagliardetto/solana-go/rpc/jsonrpc" ) +const ( + transactionSizeError = "transaction is too large" + + // Solana uses JSON-RPC error code -32602 for invalid params + // see: https://github.com/paritytech/jsonrpc/blob/dc9550b4b0d8bf409d025eba7e9b229b67af9401/core/src/types/error.rs#L32 + errorCodeJSONRPCInvalidParams = -32602 +) + // parseRPCErrorForFallback parse error as RPCError and verifies if fallback tx should be used // and which failure reason to attach to fallback tx func parseRPCErrorForFallback(err error, program string) (useFallback bool, failureReason string) { @@ -14,6 +22,11 @@ func parseRPCErrorForFallback(err error, program string) (useFallback bool, fail return false, "" } + // Special handling for transaction size errors + if isTransactionSizeError(rpcErr) { + return true, transactionSizeError + } + if !strings.Contains(rpcErr.Message, "Error processing Instruction") { return false, "" } @@ -102,3 +115,21 @@ func containsNonceMismatch(logs []string) bool { } return false } + +// isTransactionSizeError checks if the error matches transaction size error patterns +// +// the invalid transaction size error message will be like: +// "base64 encoded solana_transaction::versioned::VersionedTransaction too large: 2012 bytes (max: encoded/raw 1644/1232)" +// +// see: +// +// https://github.com/solana-labs/solana/blob/bfacaf616fa4a1c57e2a337fcc864c92c25815a0/rpc/src/rpc.rs#L4506 +// https://github.com/solana-labs/solana/blob/bfacaf616fa4a1c57e2a337fcc864c92c25815a0/rpc/src/rpc.rs#L4521 +func isTransactionSizeError(rpcErr *jsonrpc.RPCError) bool { + if rpcErr.Code == errorCodeJSONRPCInvalidParams && strings.Contains(rpcErr.Message, "too large") && + strings.Contains(rpcErr.Message, "bytes (max: encoded/raw") { + return true + } + + return false +} diff --git a/zetaclient/chains/solana/signer/fallback_tx_test.go b/zetaclient/chains/solana/signer/fallback_tx_test.go index b583cfae54..9933705040 100644 --- a/zetaclient/chains/solana/signer/fallback_tx_test.go +++ b/zetaclient/chains/solana/signer/fallback_tx_test.go @@ -111,4 +111,30 @@ func Test_ParseRPCErrorForFallback(t *testing.T) { require.False(t, shouldUseFallbackTx) require.Empty(t, failureReason) }) + + t.Run("use fallback, transaction size error", func(t *testing.T) { + err := &jsonrpc.RPCError{ + Code: errorCodeJSONRPCInvalidParams, + Message: "base64 encoded solana_transaction::versioned::VersionedTransaction too large: 2012 bytes (max: encoded/raw 1644/1232)", + Data: nil, + } + + shouldUseFallbackTx, failureReason := parseRPCErrorForFallback(err, gateway) + + require.True(t, shouldUseFallbackTx) + require.Equal(t, transactionSizeError, failureReason) + }) + + t.Run("don't use fallback, invalid params error but not transaction size", func(t *testing.T) { + err := &jsonrpc.RPCError{ + Code: errorCodeJSONRPCInvalidParams, + Message: "some other invalid parameter error", + Data: nil, + } + + shouldUseFallbackTx, failureReason := parseRPCErrorForFallback(err, gateway) + + require.False(t, shouldUseFallbackTx) + require.Empty(t, failureReason) + }) }