From 237f0bb169b5fdc518b5b1afa0feb91678193fc1 Mon Sep 17 00:00:00 2001 From: aman035 Date: Thu, 12 Feb 2026 18:11:12 +0530 Subject: [PATCH 1/2] add: fnto simulate tx --- universalClient/chains/svm/rpc_client.go | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/universalClient/chains/svm/rpc_client.go b/universalClient/chains/svm/rpc_client.go index baaba037..89777cd1 100644 --- a/universalClient/chains/svm/rpc_client.go +++ b/universalClient/chains/svm/rpc_client.go @@ -296,6 +296,30 @@ func (rc *RPCClient) BroadcastTransaction(ctx context.Context, tx *solana.Transa return txHash, err } +// SimulateTransaction runs a transaction against the current ledger state without broadcasting. +// Returns the simulation result (logs, error, compute units consumed). +// Skips signature verification so the TSS/relayer signatures don't need to be valid. +func (rc *RPCClient) SimulateTransaction(ctx context.Context, tx *solana.Transaction) (*rpc.SimulateTransactionResult, error) { + var result *rpc.SimulateTransactionResponse + err := rc.executeWithFailover(ctx, "simulate_transaction", func(client *rpc.Client) error { + resp, innerErr := client.SimulateTransactionWithOpts(ctx, tx, &rpc.SimulateTransactionOpts{ + SigVerify: false, + }) + if innerErr != nil { + return innerErr + } + result = resp + return nil + }) + if err != nil { + return nil, err + } + if result == nil || result.Value == nil { + return nil, fmt.Errorf("empty simulation result") + } + return result.Value, nil +} + // GetAccountData fetches account data for a given public key func (rc *RPCClient) GetAccountData(ctx context.Context, pubkey solana.PublicKey) ([]byte, error) { var accountData []byte From d64d25273b41e66315f74e68f5382d8d89b15a3b Mon Sep 17 00:00:00 2001 From: aman035 Date: Fri, 13 Feb 2026 16:35:25 +0530 Subject: [PATCH 2/2] fix: txBuilder --- universalClient/chains/svm/tx_builder.go | 1558 +++++++++++---- universalClient/chains/svm/tx_builder_test.go | 1669 +++++++++++++++++ 2 files changed, 2904 insertions(+), 323 deletions(-) create mode 100644 universalClient/chains/svm/tx_builder_test.go diff --git a/universalClient/chains/svm/tx_builder.go b/universalClient/chains/svm/tx_builder.go index dbc34fe5..5b5482b6 100644 --- a/universalClient/chains/svm/tx_builder.go +++ b/universalClient/chains/svm/tx_builder.go @@ -1,7 +1,65 @@ +// Package svm implements the Solana (SVM) transaction builder for Push Chain's +// cross-chain outbound transaction system. +// +// # How Cross-Chain Outbound Works (High-Level) +// +// When a user on Push Chain wants to send funds/execute something on Solana: +// +// 1. Push Chain emits an OutboundCreatedEvent with details (amount, recipient, etc.) +// 2. A coordinator node picks up the event +// 3. This TxBuilder constructs the message that needs to be signed (GetOutboundSigningRequest) +// 4. Push Chain validators collectively sign the message using TSS (Threshold Signature Scheme) +// - TSS uses secp256k1 (same curve as Ethereum) — the TSS group has an ETH-style address +// 5. This TxBuilder assembles the full Solana transaction with the TSS signature and broadcasts it +// (BroadcastOutboundSigningRequest) +// 6. The Solana gateway contract verifies the TSS signature on-chain using secp256k1_recover +// +// # Two-Signature Architecture +// +// Every Solana transaction requires TWO different signatures: +// +// - TSS Signature (secp256k1/ECDSA): Signs the message hash. Verified by the gateway contract +// on-chain via secp256k1_recover. This proves the Push Chain validators approved the operation. +// The TSS group's ETH address is stored in the TSS PDA on Solana. +// +// - Relayer Signature (Ed25519): Signs the Solana transaction itself. This is a standard +// Solana transaction signature from the relayer's keypair. The relayer pays for gas (SOL). +// +// # Gateway Contract (Anchor/Rust on Solana) +// +// The gateway is an Anchor program deployed on Solana with these main entry points: +// +// - withdraw_and_execute (instruction_id=1 for withdraw, 2 for execute): +// Unified function that handles both simple fund transfers and arbitrary program execution. +// For withdraw: transfers SOL/SPL from the vault to a recipient. +// For execute: calls an arbitrary Solana program via CPI with provided accounts and data. +// +// - revert_universal_tx (instruction_id=3): Reverts a failed cross-chain tx, returns native SOL. +// +// - revert_universal_tx_token (instruction_id=4): Same but for SPL tokens. +// +// # Key Concepts +// +// - PDA (Program Derived Address): Deterministic addresses derived from seeds + program ID. +// Like CREATE2 in EVM. The gateway uses PDAs for config, vault, TSS state, etc. +// +// - Anchor Discriminator: First 8 bytes of sha256("global:"). Tells the +// Anchor framework which function to call. Similar to EVM function selectors (4 bytes of keccak256). +// +// - Borsh Serialization: Solana's standard binary format. Little-endian integers, +// Vec = 4-byte LE length prefix + elements. Used for instruction data. +// +// - TSS PDA: Stores the TSS group's 20-byte ETH address, chain ID, and a nonce for replay protection. +// +// - CEA (Cross-chain Execution Account): Per-sender identity PDA derived from the EVM sender address. +// +// - ATA (Associated Token Account): Deterministic token account for a wallet + mint pair. +// Like mapping(address => mapping(token => balance)) in EVM, but accounts are explicit on Solana. package svm import ( "context" + "crypto/sha256" "encoding/binary" "encoding/hex" "encoding/json" @@ -21,11 +79,28 @@ import ( uetypes "github.com/pushchain/push-chain-node/x/uexecutor/types" ) -// DefaultComputeUnitLimit is used when gas limit is not provided in the outbound event data -// This is Solana's equivalent of EVM gas limit +// DefaultComputeUnitLimit is the fallback compute budget when the event doesn't specify a gas limit. +// Solana charges based on "compute units" (similar to EVM gas). 200k is a safe default for most +// gateway operations. The actual cost depends on how many accounts are touched, CPI depth, etc. const DefaultComputeUnitLimit = 200000 -// TxBuilder implements OutboundTxBuilder for Solana chains +// GatewayAccountMeta represents a single account that a target program needs when executing +// an arbitrary cross-chain call (instruction_id=2). The payload from Push Chain includes a list +// of these — each with the account's public key and whether it needs write access. +// This mirrors the Rust struct in the gateway contract (state.rs). +type GatewayAccountMeta struct { + Pubkey [32]byte // Solana public key (32 bytes, not base58-encoded) + IsWritable bool // Whether the target program needs to write to this account +} + +// TxBuilder constructs and broadcasts Solana transactions for cross-chain operations. +// It implements the common.OutboundTxBuilder interface shared with the EVM tx builder. +// +// The builder needs: +// - rpcClient: to talk to a Solana RPC node (fetch account data, send transactions) +// - chainID: identifies the Solana cluster (e.g., "solana:EtWTRABZ..." for devnet) +// - gatewayAddress: the deployed gateway program's public key on Solana +// - nodeHome: filesystem path where the relayer's Solana keypair is stored type TxBuilder struct { rpcClient *RPCClient chainID string @@ -34,7 +109,9 @@ type TxBuilder struct { logger zerolog.Logger } -// NewTxBuilder creates a new Solana transaction builder +// NewTxBuilder creates a new Solana transaction builder. +// gatewayAddress must be a valid base58-encoded Solana public key pointing to the +// deployed gateway program. func NewTxBuilder( rpcClient *RPCClient, chainID string, @@ -66,8 +143,22 @@ func NewTxBuilder( }, nil } -// GetOutboundSigningRequest creates a signing request from outbound event data -// The signing hash is the keccak256 hash of the TSS message constructed according to the gateway contract +// ============================================================================= +// STEP 1: GetOutboundSigningRequest +// +// Called when Push Chain detects an outbound event targeting Solana. +// This method: +// 1. Fetches the current TSS nonce from the on-chain TSS PDA (replay protection) +// 2. Determines the instruction type (withdraw/execute/revert) +// 3. Constructs the exact message that TSS validators will sign +// 4. Returns the 32-byte keccak256 hash for TSS signing +// +// After this, the coordinator broadcasts the hash to TSS nodes, they collectively +// sign it, and the 64-byte signature (r||s) is passed to BroadcastOutboundSigningRequest. +// ============================================================================= + +// GetOutboundSigningRequest creates a signing request from an outbound event. +// Returns a 32-byte keccak256 hash that the TSS nodes need to sign. func (tb *TxBuilder) GetOutboundSigningRequest( ctx context.Context, data *uetypes.OutboundCreatedEvent, @@ -90,112 +181,302 @@ func (tb *TxBuilder) GetOutboundSigningRequest( return nil, fmt.Errorf("signerAddress is required") } - // Parse amount + // --- Parse all fields from the outbound event --- + amount := new(big.Int) amount, ok := amount.SetString(data.Amount, 10) if !ok { return nil, fmt.Errorf("invalid amount: %s", data.Amount) } - // Parse asset address (token mint for SPL, empty for native SOL) + // Validate amount fits in u64 (Solana uses u64 for amounts, events use uint256) + if !amount.IsUint64() { + return nil, fmt.Errorf("amount exceeds u64 max: %s", data.Amount) + } + + // Determine if this is native SOL or an SPL token transfer. + // Empty or zero address = native SOL. Otherwise it's the SPL token mint address. assetAddr := data.AssetAddr isNative := assetAddr == "" || assetAddr == "0x0" || assetAddr == "0x0000000000000000000000000000000000000000" - // Parse TxType txType, err := parseTxType(data.TxType) if err != nil { return nil, fmt.Errorf("invalid tx type: %w", err) } - // Derive TSS PDA + // --- Fetch on-chain state from the TSS PDA --- + // The TSS PDA stores: ETH address of the TSS group, chain ID, and a nonce. + // The nonce increments with each operation to prevent replay attacks. tssPDA, err := tb.deriveTSSPDA() if err != nil { return nil, fmt.Errorf("failed to derive TSS PDA: %w", err) } - // Fetch nonce from TSS PDA nonce, chainID, err := tb.fetchTSSNonce(ctx, tssPDA) if err != nil { return nil, fmt.Errorf("failed to fetch TSS nonce: %w", err) } - // Determine instruction ID based on TxType and asset type - instructionID, err := tb.determineInstructionID(txType, isNative) + // --- Parse identifiers from hex strings to fixed-size byte arrays --- + + // txID: unique identifier for this cross-chain transaction (32 bytes). + // Must be deterministic and stable across retries (same tx_id for all retry attempts). + var txID [32]byte + txIDBytes, err := hex.DecodeString(removeHexPrefix(data.TxID)) + if err != nil { + return nil, fmt.Errorf("invalid txID: %s", data.TxID) + } + if len(txIDBytes) == 32 { + copy(txID[:], txIDBytes) + } else if len(txIDBytes) > 0 { + // Right-align shorter IDs (pad with leading zeros) + copy(txID[32-len(txIDBytes):], txIDBytes) + } + + // universalTxID: the original transaction ID from the source chain (32 bytes) + var universalTxID [32]byte + utxIDBytes, err := hex.DecodeString(removeHexPrefix(data.UniversalTxId)) if err != nil { - return nil, fmt.Errorf("failed to determine instruction ID: %w", err) + return nil, fmt.Errorf("invalid universalTxID: %s", data.UniversalTxId) + } + if len(utxIDBytes) == 32 { + copy(universalTxID[:], utxIDBytes) + } else if len(utxIDBytes) > 0 { + copy(universalTxID[32-len(utxIDBytes):], utxIDBytes) + } + + // sender: the 20-byte EVM address of the original sender on the source chain + var sender [20]byte + senderBytes, err := hex.DecodeString(removeHexPrefix(data.Sender)) + if err != nil { + return nil, fmt.Errorf("invalid sender: %s", data.Sender) + } + if len(senderBytes) == 20 { + copy(sender[:], senderBytes) + } else { + return nil, fmt.Errorf("invalid sender length: expected 20 bytes, got %d", len(senderBytes)) + } + + // token: 32-byte Solana pubkey of the SPL token mint. All zeros = native SOL (Pubkey::default()) + var token [32]byte + if !isNative { + mintPubkey, parseErr := solana.PublicKeyFromBase58(assetAddr) + if parseErr != nil { + hexBytes, hexErr := hex.DecodeString(removeHexPrefix(assetAddr)) + if hexErr != nil || len(hexBytes) != 32 { + return nil, fmt.Errorf("invalid asset address format: %s", assetAddr) + } + mintPubkey = solana.PublicKeyFromBytes(hexBytes) + } + copy(token[:], mintPubkey.Bytes()) } + // For native SOL, token stays all-zeros (Pubkey::default() in Rust) - // Parse recipient address (Solana Pubkey - 32 bytes) - // Try parsing as Solana base58 first, then as hex + // Gas fee from event. TODO: OutboundCreatedEvent doesn't include GasFee field yet. + // When the event pipeline threads gas_fee through, parse it here. + var gasFee uint64 + + // recipient/target: Solana pubkey of the destination. Used differently depending on instruction: + // - Withdraw (id=1): the wallet that receives the funds (target = recipient) + // - Execute (id=2): the target program to CPI into (target = destination_program) + // - Revert (id=3,4): the wallet that gets the refund var recipientPubkey solana.PublicKey - var recipientBytes []byte recipientPubkey, err = solana.PublicKeyFromBase58(data.Recipient) if err != nil { - // Try hex format hexBytes, hexErr := hex.DecodeString(removeHexPrefix(data.Recipient)) if hexErr != nil || len(hexBytes) != 32 { return nil, fmt.Errorf("invalid recipient address format (expected Solana Pubkey): %s", data.Recipient) } recipientPubkey = solana.PublicKeyFromBytes(hexBytes) } - recipientBytes = recipientPubkey.Bytes() - // Construct TSS message for signing + // --- Determine instruction ID and decode payload --- + // For non-revert flows: decode payload to get instruction_id (1=withdraw, 2=execute), + // accounts, ixData, and rentFee. The instruction_id in the payload is authoritative. + // For revert flows: instruction_id is determined from TxType + asset type. + var instructionID uint8 + var targetProgram [32]byte + var accounts []GatewayAccountMeta + var ixData []byte + var rentFee uint64 + var revertRecipient [32]byte + var revertMint [32]byte + + if txType == uetypes.TxType_INBOUND_REVERT { + // Revert flows: instruction_id from TxType (no payload-based instruction_id) + if isNative { + instructionID = 3 + } else { + instructionID = 4 + } + + switch instructionID { + case 3: // Revert SOL: recipient gets their SOL back + copy(revertRecipient[:], recipientPubkey.Bytes()) + case 4: // Revert SPL: recipient gets their SPL tokens back + copy(revertRecipient[:], recipientPubkey.Bytes()) + copy(revertMint[:], token[:]) + } + } else { + // Non-revert flows: decode payload to get instruction_id. + // Payload format: [accounts][ixData][rentFee][instruction_id] + // For simple withdraw, payload may be empty — fall back to TxType. + payloadHex := removeHexPrefix(data.Payload) + if payloadHex != "" { + payloadBytes, decErr := hex.DecodeString(payloadHex) + if decErr != nil { + return nil, fmt.Errorf("failed to decode payload hex: %w", decErr) + } + + if len(payloadBytes) > 0 { + var payloadInstructionID uint8 + accounts, ixData, rentFee, payloadInstructionID, err = decodePayload(payloadBytes) + if err != nil { + return nil, fmt.Errorf("failed to decode payload: %w", err) + } + instructionID = payloadInstructionID + } + } + + // If payload was empty/missing, fall back to TxType-derived instruction_id + if instructionID == 0 { + fallbackID, fbErr := tb.determineInstructionID(txType, isNative) + if fbErr != nil { + return nil, fmt.Errorf("failed to determine instruction ID: %w", fbErr) + } + instructionID = fallbackID + } + + // Validate instruction_id + if instructionID != 1 && instructionID != 2 { + return nil, fmt.Errorf("invalid instruction_id: %d (expected 1=withdraw or 2=execute)", instructionID) + } + + // Validate rent_fee <= gas_fee (contract requires this) + if rentFee > gasFee { + return nil, fmt.Errorf("rent_fee (%d) exceeds gas_fee (%d)", rentFee, gasFee) + } + + // Validate mode-specific constraints per integration guide + switch instructionID { + case 1: // Withdraw mode + if len(accounts) > 0 || len(ixData) > 0 || rentFee > 0 { + return nil, fmt.Errorf("withdraw mode: accounts, ixData, and rentFee must be empty/zero") + } + if amount.Uint64() == 0 { + return nil, fmt.Errorf("withdraw mode: amount must be > 0") + } + copy(targetProgram[:], recipientPubkey.Bytes()) + + case 2: // Execute mode + copy(targetProgram[:], recipientPubkey.Bytes()) + } + } + + // --- Construct the TSS message and hash it --- + // This message is what TSS validators sign. The gateway contract reconstructs + // the same message on-chain and verifies the signature matches. messageHash, err := tb.constructTSSMessage( - instructionID, - chainID, - nonce, - amount.Uint64(), - isNative, - assetAddr, - recipientBytes, - txType, + instructionID, chainID, nonce, amount.Uint64(), + txID, universalTxID, sender, token, gasFee, + targetProgram, accounts, ixData, rentFee, + revertRecipient, revertMint, ) if err != nil { return nil, fmt.Errorf("failed to construct TSS message: %w", err) } - // Convert gas price to lamports (Solana's native unit) prioritizationFee := gasPrice.Uint64() return &common.UnSignedOutboundTxReq{ - SigningHash: messageHash, // This is the keccak256 hash to be signed by TSS + SigningHash: messageHash, Signer: signerAddress, Nonce: nonce, GasPrice: big.NewInt(int64(prioritizationFee)), }, nil } -// BroadcastOutboundSigningRequest assembles and broadcasts a signed transaction from the signing request, event data, and signature -// NOTE: This method will internally use a relayer key to create and send the Solana transaction -// The signature provided is the TSS signature for the message hash, which will be included in the instruction data +// ============================================================================= +// STEP 2: BroadcastOutboundSigningRequest +// +// Called after TSS nodes have collectively signed the message hash. +// This method: +// 1. Re-parses all parameters from the event (same as GetOutboundSigningRequest) +// 2. Derives all PDAs needed for the gateway instruction +// 3. Determines the recovery ID for the ECDSA signature (v value, 0-3) +// 4. Builds the Borsh-serialized instruction data +// 5. Builds the ordered accounts list matching the Rust struct layout +// 6. Creates a Solana transaction with compute budget + gateway instruction +// 7. Signs with the relayer's Ed25519 key and broadcasts to the network +// +// The signature parameter is the 64-byte TSS signature (r||s, no v byte). +// The recovery ID is determined by trying all 4 possible values and checking +// which one recovers to the TSS ETH address stored on-chain. +// ============================================================================= + +// BroadcastOutboundSigningRequest assembles a complete Solana transaction with the +// TSS signature and broadcasts it to the Solana network. func (tb *TxBuilder) BroadcastOutboundSigningRequest( ctx context.Context, req *common.UnSignedOutboundTxReq, data *uetypes.OutboundCreatedEvent, signature []byte, ) (string, error) { + tx, instructionID, err := tb.BuildOutboundTransaction(ctx, req, data, signature) + if err != nil { + return "", err + } + + txHash, err := tb.rpcClient.BroadcastTransaction(ctx, tx) + if err != nil { + return "", fmt.Errorf("failed to broadcast transaction: %w", err) + } + + tb.logger.Info(). + Str("tx_hash", txHash). + Uint8("instruction_id", instructionID). + Msg("transaction broadcast successfully") + + return txHash, nil +} + +// BuildOutboundTransaction assembles a complete signed Solana transaction from the +// TSS signature and event data, without broadcasting. Returns the transaction and +// the instruction ID for logging. Use this for simulation or inspection. +func (tb *TxBuilder) BuildOutboundTransaction( + ctx context.Context, + req *common.UnSignedOutboundTxReq, + data *uetypes.OutboundCreatedEvent, + signature []byte, +) (*solana.Transaction, uint8, error) { if req == nil { - return "", fmt.Errorf("signing request is nil") + return nil, 0, fmt.Errorf("signing request is nil") } if data == nil { - return "", fmt.Errorf("outbound event data is nil") + return nil, 0, fmt.Errorf("outbound event data is nil") } if len(signature) != 64 { - return "", fmt.Errorf("signature must be 64 bytes, got %d", len(signature)) + return nil, 0, fmt.Errorf("signature must be 64 bytes, got %d", len(signature)) } - // Load relayer keypair + // Load the relayer's Solana keypair from disk. + // The relayer is the entity that pays for Solana transaction fees (gas). + // Its Ed25519 signature authorizes the Solana transaction itself. + // (This is separate from the TSS secp256k1 signature that authorizes the cross-chain operation.) relayerKeypair, err := tb.loadRelayerKeypair() if err != nil { - return "", fmt.Errorf("failed to load relayer keypair: %w", err) + return nil, 0, fmt.Errorf("failed to load relayer keypair: %w", err) } - // Reconstruct parameters from event data (same as GetOutboundSigningRequest) + // --- Re-parse event data (same parsing as GetOutboundSigningRequest) --- + amount := new(big.Int) amount, ok := amount.SetString(data.Amount, 10) if !ok { - return "", fmt.Errorf("invalid amount: %s", data.Amount) + return nil, 0, fmt.Errorf("invalid amount: %s", data.Amount) + } + if !amount.IsUint64() { + return nil, 0, fmt.Errorf("amount exceeds u64 max: %s", data.Amount) } assetAddr := data.AssetAddr @@ -203,162 +484,299 @@ func (tb *TxBuilder) BroadcastOutboundSigningRequest( txType, err := parseTxType(data.TxType) if err != nil { - return "", fmt.Errorf("invalid tx type: %w", err) + return nil, 0, fmt.Errorf("invalid tx type: %w", err) } - // Determine instruction ID - instructionID, err := tb.determineInstructionID(txType, isNative) + var txID [32]byte + txIDBytes, err := hex.DecodeString(removeHexPrefix(data.TxID)) if err != nil { - return "", fmt.Errorf("failed to determine instruction ID: %w", err) + return nil, 0, fmt.Errorf("invalid txID: %s", data.TxID) } - - // Parse recipient - recipientPubkey, err := solana.PublicKeyFromBase58(data.Recipient) - if err != nil { - hexBytes, hexErr := hex.DecodeString(removeHexPrefix(data.Recipient)) - if hexErr != nil || len(hexBytes) != 32 { - return "", fmt.Errorf("invalid recipient address format: %s", data.Recipient) - } - recipientPubkey = solana.PublicKeyFromBytes(hexBytes) + if len(txIDBytes) == 32 { + copy(txID[:], txIDBytes) + } else if len(txIDBytes) > 0 { + copy(txID[32-len(txIDBytes):], txIDBytes) } - // Derive all required PDAs - configPDA, _, err := solana.FindProgramAddress([][]byte{[]byte("config")}, tb.gatewayAddress) + var universalTxID [32]byte + utxIDBytes, err := hex.DecodeString(removeHexPrefix(data.UniversalTxId)) if err != nil { - return "", fmt.Errorf("failed to derive config PDA: %w", err) + return nil, 0, fmt.Errorf("invalid universalTxID: %s", data.UniversalTxId) } - - vaultPDA, _, err := solana.FindProgramAddress([][]byte{[]byte("vault")}, tb.gatewayAddress) - if err != nil { - return "", fmt.Errorf("failed to derive vault PDA: %w", err) + if len(utxIDBytes) == 32 { + copy(universalTxID[:], utxIDBytes) + } else if len(utxIDBytes) > 0 { + copy(universalTxID[32-len(utxIDBytes):], utxIDBytes) } - tssPDA, _, err := solana.FindProgramAddress([][]byte{[]byte("tss")}, tb.gatewayAddress) + var sender [20]byte + senderBytes, err := hex.DecodeString(removeHexPrefix(data.Sender)) if err != nil { - return "", fmt.Errorf("failed to derive TSS PDA: %w", err) + return nil, 0, fmt.Errorf("invalid sender: %s", data.Sender) } - - whitelistPDA, _, err := solana.FindProgramAddress([][]byte{[]byte("whitelist")}, tb.gatewayAddress) - if err != nil { - return "", fmt.Errorf("failed to derive whitelist PDA: %w", err) + if len(senderBytes) == 20 { + copy(sender[:], senderBytes) + } else { + return nil, 0, fmt.Errorf("invalid sender length: expected 20 bytes, got %d", len(senderBytes)) } - // Derive token vault PDA and recipient token account for SPL tokens - var tokenVaultPDA solana.PublicKey - var recipientTokenAccount solana.PublicKey + var token [32]byte + var mintPubkey solana.PublicKey if !isNative { - mintPubkey, err := solana.PublicKeyFromBase58(assetAddr) + mintPubkey, err = solana.PublicKeyFromBase58(assetAddr) if err != nil { hexBytes, hexErr := hex.DecodeString(removeHexPrefix(assetAddr)) if hexErr != nil || len(hexBytes) != 32 { - return "", fmt.Errorf("invalid asset address format: %s", assetAddr) + return nil, 0, fmt.Errorf("invalid asset address format: %s", assetAddr) } mintPubkey = solana.PublicKeyFromBytes(hexBytes) } + copy(token[:], mintPubkey.Bytes()) + } - tokenVaultPDA, _, err = solana.FindProgramAddress([][]byte{[]byte("token_vault"), mintPubkey.Bytes()}, tb.gatewayAddress) - if err != nil { - return "", fmt.Errorf("failed to derive token vault PDA: %w", err) + // Gas fee from event. TODO: OutboundCreatedEvent doesn't include GasFee field yet. + var gasFee uint64 + + recipientPubkey, err := solana.PublicKeyFromBase58(data.Recipient) + if err != nil { + hexBytes, hexErr := hex.DecodeString(removeHexPrefix(data.Recipient)) + if hexErr != nil || len(hexBytes) != 32 { + return nil, 0, fmt.Errorf("invalid recipient address format: %s", data.Recipient) } + recipientPubkey = solana.PublicKeyFromBytes(hexBytes) + } + + revertMsgBytes, err := hex.DecodeString(removeHexPrefix(data.RevertMsg)) + if err != nil { + revertMsgBytes = []byte{} + } - // Derive associated token account for recipient - // ATA derivation: PDA of [owner, token_program_id, mint] under AssociatedTokenProgram - // Note: This is a simplified derivation. In production, use the proper ATA derivation - // which uses the AssociatedTokenProgram (AToken9kL4eMniUP6vNrqG5nBR56fZc4f2TU3qH6w7) - ataSeeds := [][]byte{ - recipientPubkey.Bytes(), - solana.TokenProgramID.Bytes(), - mintPubkey.Bytes(), + // --- Determine instruction ID and decode payload --- + var instructionID uint8 + var execAccounts []GatewayAccountMeta + var ixData []byte + var rentFee uint64 + + if txType == uetypes.TxType_INBOUND_REVERT { + if isNative { + instructionID = 3 + } else { + instructionID = 4 } - // Associated Token Program ID (standard Solana ATA program) - ataProgramID := solana.MustPublicKeyFromBase58("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL") - recipientTokenAccount, _, err = solana.FindProgramAddress(ataSeeds, ataProgramID) - if err != nil { - return "", fmt.Errorf("failed to derive recipient token account: %w", err) + } else { + // Non-revert: decode payload to get instruction_id + payloadHex := removeHexPrefix(data.Payload) + if payloadHex != "" { + payloadBytes, decErr := hex.DecodeString(payloadHex) + if decErr != nil { + return nil, 0, fmt.Errorf("failed to decode payload hex: %w", decErr) + } + if len(payloadBytes) > 0 { + execAccounts, ixData, rentFee, instructionID, err = decodePayload(payloadBytes) + if err != nil { + return nil, 0, fmt.Errorf("failed to decode payload: %w", err) + } + } } + + // Fall back to TxType if payload was empty + if instructionID == 0 { + fallbackID, fbErr := tb.determineInstructionID(txType, isNative) + if fbErr != nil { + return nil, 0, fmt.Errorf("failed to determine instruction ID: %w", fbErr) + } + instructionID = fallbackID + } + + if instructionID != 1 && instructionID != 2 { + return nil, 0, fmt.Errorf("invalid instruction_id: %d", instructionID) + } + if rentFee > gasFee { + return nil, 0, fmt.Errorf("rent_fee (%d) exceeds gas_fee (%d)", rentFee, gasFee) + } + } + + // --- Derive PDAs --- + configPDA, _, err := solana.FindProgramAddress([][]byte{[]byte("config")}, tb.gatewayAddress) + if err != nil { + return nil, 0, fmt.Errorf("failed to derive config PDA: %w", err) + } + + vaultPDA, _, err := solana.FindProgramAddress([][]byte{[]byte("vault")}, tb.gatewayAddress) + if err != nil { + return nil, 0, fmt.Errorf("failed to derive vault PDA: %w", err) + } + + tssPDA, _, err := solana.FindProgramAddress([][]byte{[]byte("tsspda")}, tb.gatewayAddress) + if err != nil { + return nil, 0, fmt.Errorf("failed to derive TSS PDA: %w", err) } - // Determine recovery ID by verifying the signature against the message hash - // The message hash is req.SigningHash (keccak256 hash of the TSS message) - // We need to get the TSS Ethereum address from the TSS PDA account to verify the signature + executedTxPDA, _, err := solana.FindProgramAddress([][]byte{[]byte("executed_tx"), txID[:]}, tb.gatewayAddress) + if err != nil { + return nil, 0, fmt.Errorf("failed to derive executed_tx PDA: %w", err) + } + + // --- Determine recovery ID --- tssAccountData, err := tb.rpcClient.GetAccountData(ctx, tssPDA) if err != nil { - return "", fmt.Errorf("failed to fetch TSS PDA account for recovery ID: %w", err) + return nil, 0, fmt.Errorf("failed to fetch TSS PDA account for recovery ID: %w", err) } - // TSS Ethereum address is at offset 8 (after 8-byte discriminator), 20 bytes if len(tssAccountData) < 28 { - return "", fmt.Errorf("invalid TSS PDA account data for recovery ID") + return nil, 0, fmt.Errorf("invalid TSS PDA account data for recovery ID") } - tssEthAddress := tssAccountData[8:28] // 20-byte Ethereum address + tssEthAddress := tssAccountData[8:28] recoveryID, err := tb.determineRecoveryID(req.SigningHash, signature, hex.EncodeToString(tssEthAddress)) if err != nil { - return "", fmt.Errorf("failed to determine recovery ID: %w", err) + return nil, 0, fmt.Errorf("failed to determine recovery ID: %w", err) } - // Parse revert message from event data - revertMsgBytes, err := hex.DecodeString(removeHexPrefix(data.RevertMsg)) - if err != nil { - revertMsgBytes = []byte{} // Default to empty if decoding fails - } - - // Build instruction data - // Format: [8-byte Anchor discriminator] + [1-byte instruction_id] + [64-byte signature] + [1-byte recovery_id] + [additional data] - instructionData := tb.buildInstructionData(instructionID, signature, recoveryID, amount.Uint64(), recipientPubkey, isNative, assetAddr, revertMsgBytes) - - // Build accounts list - accounts := tb.buildAccountsList( - configPDA, - vaultPDA, - tssPDA, - whitelistPDA, - tokenVaultPDA, - recipientPubkey, - recipientTokenAccount, - relayerKeypair.PublicKey(), - isNative, - ) + // --- Build instruction data and accounts list --- + var instructionData []byte + var accounts []*solana.AccountMeta + + switch { + case instructionID == 1 || instructionID == 2: + // ---- withdraw_and_execute (unified function) ---- + var targetProgram solana.PublicKey + var writableFlags []byte + + if instructionID == 2 { + // Execute mode: writable flags from decoded accounts + writableFlags = accountsToWritableFlags(execAccounts) + targetProgram = recipientPubkey + } else { + // Withdraw mode: empty flags, system_program as sentinel + writableFlags = []byte{} + ixData = []byte{} + targetProgram = solana.SystemProgramID + } + + ceaAuthorityPDA, _, ceaErr := solana.FindProgramAddress([][]byte{[]byte("push_identity"), sender[:]}, tb.gatewayAddress) + if ceaErr != nil { + return nil, 0, fmt.Errorf("failed to derive cea_authority PDA: %w", ceaErr) + } + + instructionData = tb.buildWithdrawAndExecuteData( + instructionID, txID, universalTxID, amount.Uint64(), sender, + writableFlags, ixData, gasFee, rentFee, + signature, recoveryID, req.SigningHash, req.Nonce, + ) - // Create main instruction - instruction := solana.NewInstruction( + accounts = tb.buildWithdrawAndExecuteAccounts( + relayerKeypair.PublicKey(), + configPDA, vaultPDA, ceaAuthorityPDA, tssPDA, executedTxPDA, + targetProgram, + isNative, instructionID, + recipientPubkey, mintPubkey, + execAccounts, + ) + + case instructionID == 3: + // ---- revert_universal_tx (native SOL refund) ---- + instructionData = tb.buildRevertData( + instructionID, txID, universalTxID, amount.Uint64(), + recipientPubkey, revertMsgBytes, gasFee, + signature, recoveryID, req.SigningHash, req.Nonce, + ) + accounts = tb.buildRevertSOLAccounts( + configPDA, vaultPDA, tssPDA, recipientPubkey, + executedTxPDA, relayerKeypair.PublicKey(), + ) + + case instructionID == 4: + // ---- revert_universal_tx_token (SPL token refund) ---- + ataProgramID := solana.MustPublicKeyFromBase58("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL") + + tokenVaultATA, _, ataErr := solana.FindProgramAddress( + [][]byte{vaultPDA.Bytes(), solana.TokenProgramID.Bytes(), mintPubkey.Bytes()}, + ataProgramID, + ) + if ataErr != nil { + return nil, 0, fmt.Errorf("failed to derive token vault ATA: %w", ataErr) + } + + recipientATA, _, ataErr := solana.FindProgramAddress( + [][]byte{recipientPubkey.Bytes(), solana.TokenProgramID.Bytes(), mintPubkey.Bytes()}, + ataProgramID, + ) + if ataErr != nil { + return nil, 0, fmt.Errorf("failed to derive recipient ATA: %w", ataErr) + } + + instructionData = tb.buildRevertData( + instructionID, txID, universalTxID, amount.Uint64(), + recipientPubkey, revertMsgBytes, gasFee, + signature, recoveryID, req.SigningHash, req.Nonce, + ) + accounts = tb.buildRevertSPLAccounts( + configPDA, vaultPDA, tokenVaultATA, tssPDA, + recipientATA, mintPubkey, + executedTxPDA, relayerKeypair.PublicKey(), + ) + } + + // --- Assemble the Solana transaction --- + // Instructions in order: + // 1. SetComputeUnitLimit — tells the runtime how many compute units to allocate + // 2. (SPL only) CreateAssociatedTokenAccount — creates recipient ATA if it doesn't exist + // 3. The actual gateway instruction (withdraw/execute/revert) + + gatewayInstruction := solana.NewInstruction( tb.gatewayAddress, accounts, instructionData, ) - // Parse compute unit limit from gas limit, use default if not provided var computeUnitLimit uint32 if data.GasLimit == "" || data.GasLimit == "0" { computeUnitLimit = DefaultComputeUnitLimit } else { - parsedLimit, err := strconv.ParseUint(data.GasLimit, 10, 32) - if err != nil { + parsedLimit, parseErr := strconv.ParseUint(data.GasLimit, 10, 32) + if parseErr != nil { computeUnitLimit = DefaultComputeUnitLimit } else { computeUnitLimit = uint32(parsedLimit) } } - // Create compute budget instruction for setting compute unit limit computeBudgetInstruction := tb.buildSetComputeUnitLimitInstruction(computeUnitLimit) - // Get recent blockhash + // Build the instruction list. For SPL flows that need a recipient ATA + // (withdraw SPL or revert SPL), prepend a CreateIdempotent ATA instruction. + instructions := []solana.Instruction{computeBudgetInstruction} + + needsRecipientATA := (instructionID == 1 && !isNative) || instructionID == 4 + if needsRecipientATA { + createATAInstruction := tb.buildCreateATAIdempotentInstruction( + relayerKeypair.PublicKey(), + recipientPubkey, + mintPubkey, + ) + instructions = append(instructions, createATAInstruction) + } + + instructions = append(instructions, gatewayInstruction) + + // Get a recent blockhash — Solana uses this instead of nonces for transaction expiry. + // Transactions expire after ~60-90 seconds if not confirmed. recentBlockhash, err := tb.rpcClient.GetRecentBlockhash(ctx) if err != nil { - return "", fmt.Errorf("failed to get recent blockhash: %w", err) + return nil, 0, fmt.Errorf("failed to get recent blockhash: %w", err) } - // Create transaction with compute budget instruction first, then main instruction tx, err := solana.NewTransaction( - []solana.Instruction{computeBudgetInstruction, instruction}, + instructions, recentBlockhash, solana.TransactionPayer(relayerKeypair.PublicKey()), ) if err != nil { - return "", fmt.Errorf("failed to create transaction: %w", err) + return nil, 0, fmt.Errorf("failed to create transaction: %w", err) } - // Sign transaction with relayer keypair + // Sign the transaction with the relayer's Ed25519 key. + // This is the standard Solana transaction signature (NOT the TSS signature). _, err = tx.Sign(func(key solana.PublicKey) *solana.PrivateKey { if key.Equals(relayerKeypair.PublicKey()) { privKey := relayerKeypair @@ -367,25 +785,17 @@ func (tb *TxBuilder) BroadcastOutboundSigningRequest( return nil }) if err != nil { - return "", fmt.Errorf("failed to sign transaction: %w", err) - } - - // Broadcast transaction - txHash, err := tb.rpcClient.BroadcastTransaction(ctx, tx) - if err != nil { - return "", fmt.Errorf("failed to broadcast transaction: %w", err) + return nil, 0, fmt.Errorf("failed to sign transaction: %w", err) } - tb.logger.Info(). - Str("tx_hash", txHash). - Uint8("instruction_id", instructionID). - Msg("transaction broadcast successfully") - - return txHash, nil + return tx, instructionID, nil } -// Helper functions +// ============================================================================= +// Helper Functions +// ============================================================================= +// removeHexPrefix strips the "0x" prefix from hex strings if present. func removeHexPrefix(s string) string { if len(s) >= 2 && s[0:2] == "0x" { return s[2:] @@ -393,150 +803,234 @@ func removeHexPrefix(s string) string { return s } -// deriveTSSPDA derives the TSS PDA address using seeds [b"tss"] +// ============================================================================= +// PDA Derivation & On-Chain Data +// ============================================================================= + +// deriveTSSPDA derives the TSS PDA address. +// The TSS PDA is a singleton account (one per gateway) that stores: +// - tss_eth_address: the 20-byte Ethereum address of the TSS signing group +// - chain_id: identifies this Solana cluster (for cross-chain replay protection) +// - nonce: increments with each operation (per-chain replay protection) +// +// Seed: ["tsspda"] — must match the Rust constant TSS_SEED in state.rs func (tb *TxBuilder) deriveTSSPDA() (solana.PublicKey, error) { - seeds := [][]byte{[]byte("tss")} + seeds := [][]byte{[]byte("tsspda")} address, _, err := solana.FindProgramAddress(seeds, tb.gatewayAddress) return address, err } -// fetchTSSNonce fetches the nonce and chain ID from the TSS PDA account -// TSS PDA account structure (from state.rs): -// - discriminator: 8 bytes -// - tss_eth_address: 20 bytes -// - chain_id: 8 bytes (u64, little-endian) -// - nonce: 8 bytes (u64, little-endian) -// - authority: 32 bytes (Pubkey) -// - bump: 1 byte -func (tb *TxBuilder) fetchTSSNonce(ctx context.Context, tssPDA solana.PublicKey) (uint64, uint64, error) { +// fetchTSSNonce reads the TSS PDA account from on-chain and extracts the nonce and chain ID. +// +// On-chain layout (Borsh-serialized TssPda struct from state.rs): +// +// Offset Size Field +// 0 8 Anchor discriminator (account type identifier) +// 8 20 tss_eth_address [u8; 20] +// 28 4 chain_id length (u32, little-endian) — Borsh String prefix +// 32 N chain_id bytes (UTF-8, variable length) +// 32+N 8 nonce (u64, little-endian) +// 32+N+8 32 authority (Pubkey) +// ... 1 bump +func (tb *TxBuilder) fetchTSSNonce(ctx context.Context, tssPDA solana.PublicKey) (uint64, string, error) { accountData, err := tb.rpcClient.GetAccountData(ctx, tssPDA) if err != nil { - return 0, 0, fmt.Errorf("failed to fetch TSS PDA account: %w", err) + return 0, "", fmt.Errorf("failed to fetch TSS PDA account: %w", err) } - // Minimum account size check - // discriminator (8) + tss_eth_address (20) + chain_id (8) + nonce (8) = 44 bytes minimum - if len(accountData) < 44 { - return 0, 0, fmt.Errorf("invalid TSS PDA account data: too short (%d bytes)", len(accountData)) + // Need at least: discriminator(8) + tss_eth_address(20) + chain_id_len(4) = 32 bytes + if len(accountData) < 32 { + return 0, "", fmt.Errorf("invalid TSS PDA account data: too short (%d bytes)", len(accountData)) } - // Skip discriminator (8 bytes) and tss_eth_address (20 bytes) = offset 28 - // Read chain_id (8 bytes, little-endian) - chainID := binary.LittleEndian.Uint64(accountData[28:36]) + // chain_id is a Borsh String: 4-byte LE length prefix followed by UTF-8 bytes. + // This is NOT fixed-length — different clusters have different chain IDs. + chainIDLen := binary.LittleEndian.Uint32(accountData[28:32]) + + requiredLen := 32 + int(chainIDLen) + 8 + if len(accountData) < requiredLen { + return 0, "", fmt.Errorf("invalid TSS PDA account data: too short for chain_id length %d (%d bytes)", chainIDLen, len(accountData)) + } - // Read nonce (8 bytes, little-endian) - nonce := binary.LittleEndian.Uint64(accountData[36:44]) + chainID := string(accountData[32 : 32+chainIDLen]) + + // Nonce is right after the chain_id bytes + nonceOffset := 32 + int(chainIDLen) + nonce := binary.LittleEndian.Uint64(accountData[nonceOffset : nonceOffset+8]) return nonce, chainID, nil } -// determineInstructionID determines the instruction ID based on TxType and asset type -// instruction_id = 1 for SOL withdraw -// instruction_id = 2 for SPL withdraw -// instruction_id = 3 for SOL revert -// instruction_id = 4 for SPL revert +// ============================================================================= +// Instruction ID Mapping +// ============================================================================= + +// determineInstructionID maps the Push Chain TxType + asset type to the gateway's instruction ID. +// +// The gateway contract uses these IDs in the TSS message and the instruction data: +// +// ID Function When +// 1 withdraw_and_execute FUNDS (withdraw mode): send SOL or SPL tokens to a recipient +// 2 withdraw_and_execute FUNDS_AND_PAYLOAD or GAS_AND_PAYLOAD (execute mode): call a program +// 3 revert_universal_tx INBOUND_REVERT + native SOL: refund SOL for a failed cross-chain tx +// 4 revert_universal_tx_token INBOUND_REVERT + SPL token: refund SPL tokens for a failed tx func (tb *TxBuilder) determineInstructionID(txType uetypes.TxType, isNative bool) (uint8, error) { switch txType { case uetypes.TxType_FUNDS: - if isNative { - return 1, nil // withdraw_tss (SOL) - } - return 2, nil // withdraw_spl_token_tss (SPL) + return 1, nil + + case uetypes.TxType_FUNDS_AND_PAYLOAD, uetypes.TxType_GAS_AND_PAYLOAD: + return 2, nil case uetypes.TxType_INBOUND_REVERT: if isNative { - return 3, nil // revert_withdraw (SOL) + return 3, nil } - return 4, nil // revert_withdraw_spl_token (SPL) + return 4, nil default: return 0, fmt.Errorf("unsupported tx type for SVM: %s", txType.String()) } } -// constructTSSMessage constructs the TSS message according to the gateway contract -// Message format: "PUSH_CHAIN_SVM" + instruction_id + chain_id + nonce + amount + additional_data -// Then hash with keccak256 +// ============================================================================= +// TSS Message Construction +// ============================================================================= + +// constructTSSMessage builds the byte message that TSS validators sign. +// +// The gateway contract (tss.rs validate_message) reconstructs this exact same message +// on-chain to verify the signature. If even one byte differs, verification fails. +// +// Message format: +// +// "PUSH_CHAIN_SVM" — 14-byte ASCII prefix (prevents cross-protocol replay) +// instruction_id — 1 byte (1=withdraw, 2=execute, 3=revert SOL, 4=revert SPL) +// chain_id — N bytes UTF-8 (prevents cross-chain replay, e.g., "devnet") +// NOTE: raw UTF-8 bytes, NOT Borsh-encoded (no length prefix) +// nonce — 8 bytes big-endian (prevents same-chain replay) +// amount — 8 bytes big-endian +// additional_data — varies by instruction_id (see below) +// +// Additional data per instruction_id: +// +// 1 (withdraw): [tx_id(32), utx_id(32), sender(20), token(32), gas_fee(8 BE), target(32)] +// 2 (execute): [tx_id(32), utx_id(32), sender(20), token(32), gas_fee(8 BE), target(32), +// accounts_buf(4 BE count + [pubkey(32) + writable(1)] per account), +// ix_data_buf(4 BE len + data), rent_fee(8 BE)] +// 3 (revert SOL): [utx_id(32), tx_id(32), recipient(32), gas_fee(8 BE)] +// 4 (revert SPL): [utx_id(32), tx_id(32), mint(32), recipient(32), gas_fee(8 BE)] +// +// The final message is hashed with keccak256 (Solana's keccak::hash = Ethereum's keccak256). func (tb *TxBuilder) constructTSSMessage( instructionID uint8, - chainID uint64, + chainID string, nonce uint64, amount uint64, - isNative bool, - assetAddr string, - recipient []byte, - txType uetypes.TxType, + txID [32]byte, + universalTxID [32]byte, + sender [20]byte, + token [32]byte, + gasFee uint64, + targetProgram [32]byte, + execAccounts []GatewayAccountMeta, + ixData []byte, + rentFee uint64, + revertRecipient [32]byte, + revertMint [32]byte, ) ([]byte, error) { - // Start with prefix message := []byte("PUSH_CHAIN_SVM") - - // Add instruction_id (1 byte) message = append(message, instructionID) + message = append(message, []byte(chainID)...) - // Add chain_id (8 bytes, big-endian) - chainIDBytes := make([]byte, 8) - binary.BigEndian.PutUint64(chainIDBytes, chainID) - message = append(message, chainIDBytes...) - - // Add nonce (8 bytes, big-endian) nonceBytes := make([]byte, 8) binary.BigEndian.PutUint64(nonceBytes, nonce) message = append(message, nonceBytes...) - // Add amount (8 bytes, big-endian) amountBytes := make([]byte, 8) binary.BigEndian.PutUint64(amountBytes, amount) message = append(message, amountBytes...) - // Add additional_data based on instruction type - switch instructionID { - case 1, 3: // SOL withdraw or revert - // Additional data: recipient bytes (32 bytes for Solana Pubkey) - // From withdraw.rs: recipient.key().to_bytes() or revert_instruction.fund_recipient.to_bytes() - if len(recipient) != 32 { - return nil, fmt.Errorf("invalid recipient length: expected 32 bytes (Solana Pubkey), got %d", len(recipient)) - } - message = append(message, recipient...) + gasFeeBytes := make([]byte, 8) + binary.BigEndian.PutUint64(gasFeeBytes, gasFee) - case 2, 4: // SPL withdraw or revert - // Additional data: token mint (32 bytes) - if assetAddr == "" { - return nil, fmt.Errorf("asset address required for SPL token operations") - } - // Parse asset address (should be a Solana public key in base58 or hex) - mintPubkey, err := solana.PublicKeyFromBase58(assetAddr) - if err != nil { - // Try hex format - hexBytes, hexErr := hex.DecodeString(removeHexPrefix(assetAddr)) - if hexErr != nil || len(hexBytes) != 32 { - return nil, fmt.Errorf("invalid asset address format: %s", assetAddr) + switch instructionID { + case 1: // withdraw + message = append(message, txID[:]...) + message = append(message, universalTxID[:]...) + message = append(message, sender[:]...) + message = append(message, token[:]...) + message = append(message, gasFeeBytes...) + message = append(message, targetProgram[:]...) + + case 2: // execute + message = append(message, txID[:]...) + message = append(message, universalTxID[:]...) + message = append(message, sender[:]...) + message = append(message, token[:]...) + message = append(message, gasFeeBytes...) + message = append(message, targetProgram[:]...) + + // Encode the execute accounts into the message so the contract can verify + // that the accounts passed to the CPI match what was signed + accountsCount := make([]byte, 4) + binary.BigEndian.PutUint32(accountsCount, uint32(len(execAccounts))) + message = append(message, accountsCount...) + for _, acc := range execAccounts { + message = append(message, acc.Pubkey[:]...) + if acc.IsWritable { + message = append(message, 1) + } else { + message = append(message, 0) } - mintPubkey = solana.PublicKeyFromBytes(hexBytes) } - mintBytes := mintPubkey.Bytes() - message = append(message, mintBytes...) + + ixDataLen := make([]byte, 4) + binary.BigEndian.PutUint32(ixDataLen, uint32(len(ixData))) + message = append(message, ixDataLen...) + message = append(message, ixData...) + + rentFeeBytes := make([]byte, 8) + binary.BigEndian.PutUint64(rentFeeBytes, rentFee) + message = append(message, rentFeeBytes...) + + case 3: // revert SOL — note: utx_id comes before tx_id (matches Rust) + message = append(message, universalTxID[:]...) + message = append(message, txID[:]...) + message = append(message, revertRecipient[:]...) + message = append(message, gasFeeBytes...) + + case 4: // revert SPL — note: includes the mint address for token identification + message = append(message, universalTxID[:]...) + message = append(message, txID[:]...) + message = append(message, revertMint[:]...) + message = append(message, revertRecipient[:]...) + message = append(message, gasFeeBytes...) default: return nil, fmt.Errorf("unknown instruction ID: %d", instructionID) } - // Hash with keccak256 + // Hash with keccak256. Solana's keccak::hash is the same algorithm as Ethereum's keccak256. + // NOT sha256 — Anchor uses sha256 for discriminators, but TSS messages use keccak256. messageHash := crypto.Keccak256(message) return messageHash, nil } -// parseTxType parses the TxType string to uetypes.TxType enum +// ============================================================================= +// Parsing Helpers +// ============================================================================= + +// parseTxType converts a TxType string (e.g., "FUNDS", "INBOUND_REVERT") or +// numeric string (e.g., "3") to the protobuf enum value. func parseTxType(txTypeStr string) (uetypes.TxType, error) { - // Remove any whitespace and convert to uppercase txTypeStr = strings.TrimSpace(strings.ToUpper(txTypeStr)) - // Try to parse as enum name if val, ok := uetypes.TxType_value[txTypeStr]; ok { return uetypes.TxType(val), nil } - // Try to parse as number if num, err := strconv.ParseInt(txTypeStr, 10, 32); err == nil { return uetypes.TxType(num), nil } @@ -544,11 +1038,19 @@ func parseTxType(txTypeStr string) (uetypes.TxType, error) { return uetypes.TxType_UNSPECIFIED_TX, fmt.Errorf("unknown tx type: %s", txTypeStr) } -// loadRelayerKeypair loads the Solana relayer keypair from file -// The filename is derived from the first part of the chain ID (before the colon) -// e.g., "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" -> "solana.json" +// ============================================================================= +// Relayer Keypair +// ============================================================================= + +// loadRelayerKeypair loads the Solana relayer keypair from disk. +// +// The file is a JSON array of 64 bytes (Solana's standard keypair format): +// - First 32 bytes: Ed25519 private key seed +// - Last 32 bytes: Ed25519 public key +// +// Located at: /relayer/solana.json +// The relayer pays for Solana transaction fees and signs the transaction envelope. func (tb *TxBuilder) loadRelayerKeypair() (solana.PrivateKey, error) { - // Extract the first part of chain ID (namespace) for filename chainParts := strings.Split(tb.chainID, ":") if len(chainParts) == 0 { return nil, fmt.Errorf("invalid chain ID format: %s", tb.chainID) @@ -558,54 +1060,58 @@ func (tb *TxBuilder) loadRelayerKeypair() (solana.PrivateKey, error) { return nil, fmt.Errorf("empty namespace in chain ID: %s", tb.chainID) } - // Construct file path: /relayer/.json keyPath := filepath.Join(tb.nodeHome, constant.RelayerSubdir, namespace+".json") - // Read the key file (Solana keypairs are stored as JSON array of 64 bytes) keyData, err := os.ReadFile(keyPath) if err != nil { return nil, fmt.Errorf("failed to read relayer key file %s: %w", keyPath, err) } - // Parse JSON array var keyBytes []byte if err := json.Unmarshal(keyData, &keyBytes); err != nil { return nil, fmt.Errorf("failed to parse key file as JSON array: %w", err) } - // Solana keypair is 64 bytes: 32-byte private key + 32-byte public key if len(keyBytes) != 64 { return nil, fmt.Errorf("invalid key length: expected 64 bytes, got %d", len(keyBytes)) } - // Extract private key (first 32 bytes) - privateKey := solana.PrivateKey(keyBytes[:32]) + privateKey := solana.PrivateKey(keyBytes) return privateKey, nil } -// determineRecoveryID determines the recovery ID for the ECDSA signature -// It tries recovery IDs 0-3 and verifies which one recovers the correct public key +// ============================================================================= +// Recovery ID +// ============================================================================= + +// determineRecoveryID finds the ECDSA recovery ID (v value, 0-3) for a secp256k1 signature. +// +// Background: An ECDSA signature (r, s) can correspond to up to 4 different public keys. +// The recovery ID tells secp256k1_recover which one to use. In Ethereum, this is the "v" value. +// +// How it works: +// 1. Try each recovery ID (0, 1, 2, 3) +// 2. Recover the public key from (messageHash, signature, recoveryID) +// 3. Derive the ETH address from the recovered public key: keccak256(pubkey[1:])[-20:] +// 4. Compare with the expected TSS ETH address stored on-chain +// 5. Return the recovery ID that matches +// +// This is needed because the TSS signing protocol returns only (r, s) without v. func (tb *TxBuilder) determineRecoveryID(messageHash []byte, signature []byte, expectedAddress string) (byte, error) { - // For ECDSA signatures, recovery ID can be 0, 1, 2, or 3 - // Try each one and verify against the expected address for recoveryID := byte(0); recoveryID < 4; recoveryID++ { - // Construct full signature with recovery ID sigWithRecovery := make([]byte, 65) copy(sigWithRecovery[:64], signature) sigWithRecovery[64] = recoveryID - // Recover public key from signature pubKey, err := crypto.SigToPub(messageHash, sigWithRecovery) if err != nil { continue } - // Convert public key to address (last 20 bytes of keccak256 hash) pubKeyBytes := crypto.FromECDSAPub(pubKey) - addressBytes := crypto.Keccak256(pubKeyBytes[1:])[12:] // Skip 0x04 prefix, take last 20 bytes + addressBytes := crypto.Keccak256(pubKeyBytes[1:])[12:] - // Compare with expected address (convert to hex for comparison) expectedBytes, err := hex.DecodeString(removeHexPrefix(expectedAddress)) if err == nil && len(expectedBytes) == 20 { if hex.EncodeToString(addressBytes) == hex.EncodeToString(expectedBytes) { @@ -617,132 +1123,538 @@ func (tb *TxBuilder) determineRecoveryID(messageHash []byte, signature []byte, e return 0, fmt.Errorf("failed to determine recovery ID for signature") } -// buildInstructionData constructs the Anchor instruction data -// Format: [8-byte discriminator] + [1-byte instruction_id] + [64-byte signature] + [1-byte recovery_id] + [additional data] -func (tb *TxBuilder) buildInstructionData( +// ============================================================================= +// Payload Decoding (Execute Mode) +// ============================================================================= + +// decodePayload decodes the pre-encoded payload from a Push Chain event. +// +// The payload is built off-chain and encodes the operation type plus any +// target program data needed for execution: +// +// [u32 BE] accounts_count — how many accounts the target program needs +// [33 bytes] × N accounts — each is [pubkey(32) + is_writable(1)] +// [u32 BE] ix_data_len — length of the instruction data for the target program +// [N bytes] ix_data — the raw instruction data to pass to the target program +// [u64 BE] rent_fee — SOL to cover rent for any new accounts created +// [u8] instruction_id — 1=withdraw, 2=execute +// +// For withdraw (instruction_id=1): accounts_count=0, ix_data_len=0, rent_fee=0 +// For execute (instruction_id=2): accounts and ix_data contain CPI data +func decodePayload(payload []byte) ([]GatewayAccountMeta, []byte, uint64, uint8, error) { + // Minimum payload: accounts_count(4) + ix_data_len(4) + rent_fee(8) + instruction_id(1) = 17 + if len(payload) < 17 { + return nil, nil, 0, 0, fmt.Errorf("payload too short: %d bytes (minimum 17)", len(payload)) + } + + offset := 0 + + accountsCount := binary.BigEndian.Uint32(payload[offset : offset+4]) + offset += 4 + + accounts := make([]GatewayAccountMeta, accountsCount) + for i := uint32(0); i < accountsCount; i++ { + if offset+33 > len(payload) { + return nil, nil, 0, 0, fmt.Errorf("payload too short for account %d", i) + } + var pubkey [32]byte + copy(pubkey[:], payload[offset:offset+32]) + isWritable := payload[offset+32] == 1 + accounts[i] = GatewayAccountMeta{Pubkey: pubkey, IsWritable: isWritable} + offset += 33 + } + + if offset+4 > len(payload) { + return nil, nil, 0, 0, fmt.Errorf("payload too short for ix_data length") + } + ixDataLen := binary.BigEndian.Uint32(payload[offset : offset+4]) + offset += 4 + + if offset+int(ixDataLen) > len(payload) { + return nil, nil, 0, 0, fmt.Errorf("payload too short for ix_data") + } + ixData := make([]byte, ixDataLen) + copy(ixData, payload[offset:offset+int(ixDataLen)]) + offset += int(ixDataLen) + + if offset+8 > len(payload) { + return nil, nil, 0, 0, fmt.Errorf("payload too short for rent_fee") + } + rentFee := binary.BigEndian.Uint64(payload[offset : offset+8]) + offset += 8 + + if offset >= len(payload) { + return nil, nil, 0, 0, fmt.Errorf("payload too short for instruction_id") + } + instructionID := payload[offset] + + return accounts, ixData, rentFee, instructionID, nil +} + +// accountsToWritableFlags bitpacks the writable flags for execute accounts into a compact byte array. +// +// The gateway contract expects a compact representation where each bit indicates whether +// the corresponding account is writable. Packing is MSB-first: +// +// Account index: 0 1 2 3 4 5 6 7 | 8 9 ... +// Bit position: 7 6 5 4 3 2 1 0 | 7 6 ... +// Byte index: -------- 0 -------- | --- 1 --- +// +// Example: accounts [W, R, W, R, R, R, R, R] → bit pattern 10100000 = 0xA0 +func accountsToWritableFlags(accounts []GatewayAccountMeta) []byte { + if len(accounts) == 0 { + return []byte{} + } + flagsLen := (len(accounts) + 7) / 8 + flags := make([]byte, flagsLen) + for i, acc := range accounts { + if acc.IsWritable { + byteIdx := i / 8 + bitIdx := 7 - (i % 8) + flags[byteIdx] |= 1 << uint(bitIdx) + } + } + return flags +} + +// ============================================================================= +// Anchor Discriminator +// ============================================================================= + +// anchorDiscriminator computes the Anchor framework's instruction discriminator. +// +// Anchor uses the first 8 bytes of sha256("global:") to identify which +// instruction handler to call. This is similar to EVM function selectors (first 4 bytes +// of keccak256), but Anchor uses SHA256 and 8 bytes instead. +// +// NOTE: Discriminators use SHA256, NOT keccak256. The TSS message uses keccak256. +// These are two different hash functions used for different purposes. +func anchorDiscriminator(methodName string) []byte { + h := sha256.Sum256([]byte("global:" + methodName)) + return h[:8] +} + +// ============================================================================= +// Instruction Data Builders (Borsh Serialization) +// ============================================================================= + +// buildWithdrawAndExecuteData constructs the Borsh-serialized instruction data for +// the withdraw_and_execute gateway function. +// +// This is the exact byte layout that the Anchor deserializer expects on-chain. +// The field order MUST match the Rust function signature in withdraw_execute.rs: +// +// Offset Size Field Borsh Type +// 0 8 discriminator sha256("global:withdraw_and_execute")[:8] +// 8 1 instruction_id u8 +// 9 32 tx_id [u8; 32] +// 41 32 universal_tx_id [u8; 32] +// 73 8 amount u64 (little-endian) +// 81 20 sender [u8; 20] +// 101 4+N writable_flags Vec (4-byte LE length + data) +// ... 4+M ix_data Vec (4-byte LE length + data) +// ... 8 gas_fee u64 (little-endian) +// ... 8 rent_fee u64 (little-endian) +// ... 64 signature [u8; 64] (TSS secp256k1 r||s) +// ... 1 recovery_id u8 (ECDSA v value, 0-3) +// ... 32 message_hash [u8; 32] (keccak256 of TSS message) +// ... 8 nonce u64 (little-endian) +func (tb *TxBuilder) buildWithdrawAndExecuteData( instructionID uint8, + txID [32]byte, + universalTxID [32]byte, + amount uint64, + sender [20]byte, + writableFlags []byte, + ixData []byte, + gasFee uint64, + rentFee uint64, signature []byte, recoveryID byte, + messageHash []byte, + nonce uint64, +) []byte { + discriminator := anchorDiscriminator("withdraw_and_execute") + + data := make([]byte, 0, 256) + data = append(data, discriminator...) + data = append(data, instructionID) + data = append(data, txID[:]...) + data = append(data, universalTxID[:]...) + + amountBytes := make([]byte, 8) + binary.LittleEndian.PutUint64(amountBytes, amount) + data = append(data, amountBytes...) + + data = append(data, sender[:]...) + + // Vec in Borsh = 4-byte LE length prefix + raw bytes + wfLen := make([]byte, 4) + binary.LittleEndian.PutUint32(wfLen, uint32(len(writableFlags))) + data = append(data, wfLen...) + data = append(data, writableFlags...) + + ixLen := make([]byte, 4) + binary.LittleEndian.PutUint32(ixLen, uint32(len(ixData))) + data = append(data, ixLen...) + data = append(data, ixData...) + + gasFeeBytes := make([]byte, 8) + binary.LittleEndian.PutUint64(gasFeeBytes, gasFee) + data = append(data, gasFeeBytes...) + + rentFeeBytes := make([]byte, 8) + binary.LittleEndian.PutUint64(rentFeeBytes, rentFee) + data = append(data, rentFeeBytes...) + + data = append(data, signature...) + data = append(data, recoveryID) + data = append(data, messageHash...) + + nonceBytes := make([]byte, 8) + binary.LittleEndian.PutUint64(nonceBytes, nonce) + data = append(data, nonceBytes...) + + return data +} + +// buildRevertData constructs the Borsh-serialized instruction data for +// revert_universal_tx (id=3, native SOL) or revert_universal_tx_token (id=4, SPL). +// +// Both revert functions share the same parameter layout — only the discriminator differs. +// +// Offset Size Field +// 0 8 discriminator (different for SOL vs SPL) +// 8 32 tx_id [u8; 32] +// 40 32 universal_tx_id [u8; 32] +// 72 8 amount u64 (LE) +// 80 32 fund_recipient Pubkey — who gets the refund +// 112 4+N revert_msg Vec — human-readable revert reason +// ... 8 gas_fee u64 (LE) +// ... 64 signature [u8; 64] +// ... 1 recovery_id u8 +// ... 32 message_hash [u8; 32] +// ... 8 nonce u64 (LE) +func (tb *TxBuilder) buildRevertData( + instructionID uint8, + txID [32]byte, + universalTxID [32]byte, amount uint64, - recipient solana.PublicKey, - isNative bool, - assetAddr string, + fundRecipient solana.PublicKey, revertMsg []byte, + gasFee uint64, + signature []byte, + recoveryID byte, + messageHash []byte, + nonce uint64, ) []byte { - // Anchor discriminator is typically the first 8 bytes of sha256("global:method_name") - // For now, we'll use a placeholder - this should match the actual gateway contract - // Method names: withdraw_tss, withdraw_spl_token_tss, revert_withdraw, revert_withdraw_spl_token var discriminator []byte - switch instructionID { - case 1: // withdraw_tss - discriminator = crypto.Keccak256([]byte("global:withdraw_tss"))[:8] - case 2: // withdraw_spl_token_tss - discriminator = crypto.Keccak256([]byte("global:withdraw_spl_token_tss"))[:8] - case 3: // revert_withdraw - discriminator = crypto.Keccak256([]byte("global:revert_withdraw"))[:8] - case 4: // revert_withdraw_spl_token - discriminator = crypto.Keccak256([]byte("global:revert_withdraw_spl_token"))[:8] - default: - discriminator = make([]byte, 8) // Placeholder + if instructionID == 3 { + discriminator = anchorDiscriminator("revert_universal_tx") + } else { + discriminator = anchorDiscriminator("revert_universal_tx_token") } - data := make([]byte, 0, 8+1+64+1+8+32) // discriminator + id + sig + recovery + amount + recipient/mint + data := make([]byte, 0, 256) data = append(data, discriminator...) - data = append(data, instructionID) - data = append(data, signature...) - data = append(data, recoveryID) + data = append(data, txID[:]...) + data = append(data, universalTxID[:]...) - // Add amount (8 bytes, little-endian) amountBytes := make([]byte, 8) binary.LittleEndian.PutUint64(amountBytes, amount) data = append(data, amountBytes...) - // Add additional data based on instruction type - // For revert operations (3, 4), include RevertInstructions struct - if instructionID == 3 || instructionID == 4 { - // RevertInstructions struct (Borsh serialized): - // - fund_recipient: Pubkey (32 bytes) - // - revert_msg: Vec (4-byte length prefix + data) - data = append(data, recipient.Bytes()...) // fund_recipient = recipient - // Append revert_msg length (4 bytes, little-endian) - revertMsgLen := make([]byte, 4) - binary.LittleEndian.PutUint32(revertMsgLen, uint32(len(revertMsg))) - data = append(data, revertMsgLen...) - // Append revert_msg data - data = append(data, revertMsg...) - } - - // For SPL token operations, add mint address - if !isNative && (instructionID == 2 || instructionID == 4) { - mintPubkey, err := solana.PublicKeyFromBase58(assetAddr) - if err == nil { - data = append(data, mintPubkey.Bytes()...) - } else { - // Try hex format - hexBytes, _ := hex.DecodeString(removeHexPrefix(assetAddr)) - if len(hexBytes) == 32 { - data = append(data, hexBytes...) - } - } - } + // RevertInstructions struct: { fund_recipient: Pubkey, revert_msg: Vec } + data = append(data, fundRecipient.Bytes()...) + revertMsgLen := make([]byte, 4) + binary.LittleEndian.PutUint32(revertMsgLen, uint32(len(revertMsg))) + data = append(data, revertMsgLen...) + data = append(data, revertMsg...) + + gasFeeBytes := make([]byte, 8) + binary.LittleEndian.PutUint64(gasFeeBytes, gasFee) + data = append(data, gasFeeBytes...) + + data = append(data, signature...) + data = append(data, recoveryID) + data = append(data, messageHash...) + + nonceBytes := make([]byte, 8) + binary.LittleEndian.PutUint64(nonceBytes, nonce) + data = append(data, nonceBytes...) return data } -// buildAccountsList constructs the accounts list for the instruction -// Order and flags must match the gateway contract's account structure -func (tb *TxBuilder) buildAccountsList( +// ============================================================================= +// Accounts List Builders +// +// Solana instructions require an explicit list of every account they touch, +// in the exact order the on-chain program expects. Getting this wrong causes +// "invalid account" errors or silent data corruption. +// +// The order must match the Rust #[derive(Accounts)] struct in the gateway code. +// ============================================================================= + +// buildWithdrawAndExecuteAccounts builds the ordered accounts list for the +// withdraw_and_execute instruction. +// +// Must match the WithdrawAndExecute struct in withdraw_execute.rs: +// +// # Account Flags Notes +// 1 caller signer,mut Relayer who pays for the tx +// 2 config read-only Gateway configuration PDA +// 3 vault_sol mut SOL vault PDA (holds native SOL) +// 4 cea_authority mut Cross-chain identity PDA for this sender +// 5 tss_pda mut TSS state (nonce gets incremented) +// 6 executed_tx mut Replay protection PDA (gets created) +// 7 system_program read-only Solana system program +// 8 destination_program read-only Target program (system_program for withdraw) +// --- Optional SPL accounts (9-16) --- +// 9 recipient mut/None Withdraw: recipient wallet. Execute: None +// 10 vault_ata mut/None Vault's token account for the SPL mint +// 11 cea_ata mut/None CEA's token account for the SPL mint +// 12 mint read/None The SPL token mint +// 13 token_program read/None SPL Token program +// 14 rent read/None Rent sysvar +// 15 associated_token_prog read/None ATA program +// 16 recipient_ata mut/None Recipient's token account (withdraw SPL only) +// --- Execute-only remaining accounts --- +// 17+ remaining_accounts varies Accounts that the target program needs +// +// For Anchor Option fields: passing the gateway program's own ID = None. +// This is Anchor's convention for encoding "this optional account is not provided". +func (tb *TxBuilder) buildWithdrawAndExecuteAccounts( + caller solana.PublicKey, configPDA solana.PublicKey, vaultPDA solana.PublicKey, + ceaAuthorityPDA solana.PublicKey, tssPDA solana.PublicKey, - whitelistPDA solana.PublicKey, - tokenVaultPDA solana.PublicKey, - recipient solana.PublicKey, - recipientTokenAccount solana.PublicKey, - relayer solana.PublicKey, + executedTxPDA solana.PublicKey, + destinationProgram solana.PublicKey, isNative bool, + instructionID uint8, + recipientPubkey solana.PublicKey, + mintPubkey solana.PublicKey, + execAccounts []GatewayAccountMeta, ) []*solana.AccountMeta { + // First 8 required accounts (always present) accounts := []*solana.AccountMeta{ - {PublicKey: configPDA, IsWritable: true, IsSigner: false}, + {PublicKey: caller, IsWritable: true, IsSigner: true}, + {PublicKey: configPDA, IsWritable: false, IsSigner: false}, {PublicKey: vaultPDA, IsWritable: true, IsSigner: false}, + {PublicKey: ceaAuthorityPDA, IsWritable: true, IsSigner: false}, {PublicKey: tssPDA, IsWritable: true, IsSigner: false}, - {PublicKey: whitelistPDA, IsWritable: false, IsSigner: false}, - {PublicKey: recipient, IsWritable: true, IsSigner: false}, - {PublicKey: relayer, IsWritable: true, IsSigner: true}, // Relayer is fee payer and signer + {PublicKey: executedTxPDA, IsWritable: true, IsSigner: false}, + {PublicKey: solana.SystemProgramID, IsWritable: false, IsSigner: false}, + {PublicKey: destinationProgram, IsWritable: false, IsSigner: false}, } - if !isNative { - // For SPL tokens, add token vault and recipient token account - accounts = append(accounts, - &solana.AccountMeta{PublicKey: tokenVaultPDA, IsWritable: true, IsSigner: false}, - &solana.AccountMeta{PublicKey: recipientTokenAccount, IsWritable: true, IsSigner: false}, - &solana.AccountMeta{PublicKey: solana.TokenProgramID, IsWritable: false, IsSigner: false}, - ) + // Optional SPL accounts (#9-16) + // For native SOL: all optional accounts are set to the gateway program ID (= None sentinel) + // For SPL tokens: real ATAs and program addresses are provided + if isNative { + // Recipient: real for withdraw, None for execute + if instructionID == 1 { + accounts = append(accounts, &solana.AccountMeta{PublicKey: recipientPubkey, IsWritable: true, IsSigner: false}) + } else { + accounts = append(accounts, &solana.AccountMeta{PublicKey: tb.gatewayAddress, IsWritable: false, IsSigner: false}) + } + // All SPL-related accounts are None + for i := 0; i < 7; i++ { + accounts = append(accounts, &solana.AccountMeta{PublicKey: tb.gatewayAddress, IsWritable: false, IsSigner: false}) + } } else { - // For SOL, add system program - accounts = append(accounts, - &solana.AccountMeta{PublicKey: solana.SystemProgramID, IsWritable: false, IsSigner: false}, + // SPL token flow: derive and pass real ATAs + ataProgramID := solana.MustPublicKeyFromBase58("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL") + rentSysvar := solana.MustPublicKeyFromBase58("SysvarRent111111111111111111111111111111111") + + vaultATA, _, _ := solana.FindProgramAddress( + [][]byte{accounts[2].PublicKey.Bytes(), solana.TokenProgramID.Bytes(), mintPubkey.Bytes()}, + ataProgramID, ) + ceaATA, _, _ := solana.FindProgramAddress( + [][]byte{ceaAuthorityPDA.Bytes(), solana.TokenProgramID.Bytes(), mintPubkey.Bytes()}, + ataProgramID, + ) + + if instructionID == 1 { + accounts = append(accounts, &solana.AccountMeta{PublicKey: recipientPubkey, IsWritable: true, IsSigner: false}) + } else { + accounts = append(accounts, &solana.AccountMeta{PublicKey: tb.gatewayAddress, IsWritable: false, IsSigner: false}) + } + accounts = append(accounts, &solana.AccountMeta{PublicKey: vaultATA, IsWritable: true, IsSigner: false}) + accounts = append(accounts, &solana.AccountMeta{PublicKey: ceaATA, IsWritable: true, IsSigner: false}) + accounts = append(accounts, &solana.AccountMeta{PublicKey: mintPubkey, IsWritable: false, IsSigner: false}) + accounts = append(accounts, &solana.AccountMeta{PublicKey: solana.TokenProgramID, IsWritable: false, IsSigner: false}) + accounts = append(accounts, &solana.AccountMeta{PublicKey: rentSysvar, IsWritable: false, IsSigner: false}) + accounts = append(accounts, &solana.AccountMeta{PublicKey: ataProgramID, IsWritable: false, IsSigner: false}) + + if instructionID == 1 { + recipientATA, _, _ := solana.FindProgramAddress( + [][]byte{recipientPubkey.Bytes(), solana.TokenProgramID.Bytes(), mintPubkey.Bytes()}, + ataProgramID, + ) + accounts = append(accounts, &solana.AccountMeta{PublicKey: recipientATA, IsWritable: true, IsSigner: false}) + } else { + accounts = append(accounts, &solana.AccountMeta{PublicKey: tb.gatewayAddress, IsWritable: false, IsSigner: false}) + } + } + + // For execute mode: append the target program's accounts as "remaining_accounts". + // These are the accounts that the gateway will pass through via CPI to the target program. + if instructionID == 2 { + for _, acc := range execAccounts { + pubkey := solana.PublicKeyFromBytes(acc.Pubkey[:]) + accounts = append(accounts, &solana.AccountMeta{ + PublicKey: pubkey, + IsWritable: acc.IsWritable, + IsSigner: false, + }) + } } return accounts } -// buildSetComputeUnitLimitInstruction creates a SetComputeUnitLimit instruction for the Compute Budget program -// Instruction format: [1-byte instruction type (2 = SetComputeUnitLimit)] + [4-byte u32 units] +// buildRevertSOLAccounts builds the accounts list for revert_universal_tx (native SOL refund). +// +// Must match the RevertUniversalTx struct in revert.rs: +// +// # Account Flags +// 1 config read-only +// 2 vault mut SOL vault (source of refund) +// 3 tss_pda mut TSS state (nonce increment) +// 4 recipient mut Gets the SOL refund +// 5 executed_tx mut Replay protection (gets created) +// 6 caller signer,mut Relayer +// 7 system_program read-only +func (tb *TxBuilder) buildRevertSOLAccounts( + configPDA solana.PublicKey, + vaultPDA solana.PublicKey, + tssPDA solana.PublicKey, + recipient solana.PublicKey, + executedTxPDA solana.PublicKey, + caller solana.PublicKey, +) []*solana.AccountMeta { + return []*solana.AccountMeta{ + {PublicKey: configPDA, IsWritable: false, IsSigner: false}, + {PublicKey: vaultPDA, IsWritable: true, IsSigner: false}, + {PublicKey: tssPDA, IsWritable: true, IsSigner: false}, + {PublicKey: recipient, IsWritable: true, IsSigner: false}, + {PublicKey: executedTxPDA, IsWritable: true, IsSigner: false}, + {PublicKey: caller, IsWritable: true, IsSigner: true}, + {PublicKey: solana.SystemProgramID, IsWritable: false, IsSigner: false}, + } +} + +// buildRevertSPLAccounts builds the accounts list for revert_universal_tx_token (SPL token refund). +// +// Must match the RevertUniversalTxToken struct in revert.rs: +// +// # Account Flags +// 1 config read-only +// 2 vault mut SOL vault PDA (authority for token transfers) +// 3 token_vault mut Vault's ATA for the token (source of refund) +// 4 tss_pda mut TSS state (nonce increment) +// 5 recipient_token_account mut Recipient's ATA (destination of refund) +// 6 token_mint read-only The SPL token mint +// 7 executed_tx mut Replay protection +// 8 caller signer,mut Relayer +// 9 vault_sol mut Same as vault, needed for gas_fee transfer +// 10 token_program read-only SPL Token program +// 11 system_program read-only +func (tb *TxBuilder) buildRevertSPLAccounts( + configPDA solana.PublicKey, + vaultPDA solana.PublicKey, + tokenVaultATA solana.PublicKey, + tssPDA solana.PublicKey, + recipientATA solana.PublicKey, + tokenMint solana.PublicKey, + executedTxPDA solana.PublicKey, + caller solana.PublicKey, +) []*solana.AccountMeta { + return []*solana.AccountMeta{ + {PublicKey: configPDA, IsWritable: false, IsSigner: false}, + {PublicKey: vaultPDA, IsWritable: true, IsSigner: false}, + {PublicKey: tokenVaultATA, IsWritable: true, IsSigner: false}, + {PublicKey: tssPDA, IsWritable: true, IsSigner: false}, + {PublicKey: recipientATA, IsWritable: true, IsSigner: false}, + {PublicKey: tokenMint, IsWritable: false, IsSigner: false}, + {PublicKey: executedTxPDA, IsWritable: true, IsSigner: false}, + {PublicKey: caller, IsWritable: true, IsSigner: true}, + {PublicKey: vaultPDA, IsWritable: true, IsSigner: false}, // vault_sol = same PDA, needed for gas_fee + {PublicKey: solana.TokenProgramID, IsWritable: false, IsSigner: false}, + {PublicKey: solana.SystemProgramID, IsWritable: false, IsSigner: false}, + } +} + +// ============================================================================= +// Compute Budget +// ============================================================================= + +// buildSetComputeUnitLimitInstruction creates a Solana Compute Budget instruction +// that tells the runtime how many compute units to allocate for this transaction. +// +// This is Solana's equivalent of EVM gas limit. If the transaction uses more compute +// units than allocated, it fails. Setting it too high wastes priority fee. +// +// Instruction format: +// +// Byte 0: instruction type (2 = SetComputeUnitLimit) +// Bytes 1-4: units (u32, little-endian) func (tb *TxBuilder) buildSetComputeUnitLimitInstruction(units uint32) solana.Instruction { - // Compute Budget Program ID computeBudgetProgramID := solana.MustPublicKeyFromBase58("ComputeBudget111111111111111111111111111111") - // Instruction data: 1 byte for instruction type + 4 bytes for units (little-endian) - // Instruction type 2 = SetComputeUnitLimit data := make([]byte, 5) - data[0] = 2 // SetComputeUnitLimit instruction type + data[0] = 2 // SetComputeUnitLimit binary.LittleEndian.PutUint32(data[1:], units) return solana.NewInstruction( computeBudgetProgramID, - []*solana.AccountMeta{}, // No accounts required + []*solana.AccountMeta{}, data, ) } + +// ============================================================================= +// ATA Creation +// ============================================================================= + +// buildCreateATAIdempotentInstruction builds a CreateIdempotent instruction for the +// Associated Token Account (ATA) program. This creates the recipient's ATA if it +// doesn't exist, or succeeds as a no-op if it already exists. +// +// This is needed for SPL withdraw and SPL revert flows because the gateway contract +// validates that the recipient ATA exists but does NOT create it. The relayer pays +// the ATA rent (~0.002 SOL) which is reimbursed via the gas_fee. +// +// ATA program instruction indices: +// +// 0 = Create (fails if ATA exists) +// 1 = CreateIdempotent (no-op if ATA exists) ← we use this +func (tb *TxBuilder) buildCreateATAIdempotentInstruction( + payer solana.PublicKey, + owner solana.PublicKey, + mint solana.PublicKey, +) solana.Instruction { + ataProgramID := solana.MustPublicKeyFromBase58("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL") + + // Derive the ATA address deterministically from (owner, token_program, mint) + ata, _, _ := solana.FindProgramAddress( + [][]byte{owner.Bytes(), solana.TokenProgramID.Bytes(), mint.Bytes()}, + ataProgramID, + ) + + accounts := []*solana.AccountMeta{ + {PublicKey: payer, IsWritable: true, IsSigner: true}, + {PublicKey: ata, IsWritable: true, IsSigner: false}, + {PublicKey: owner, IsWritable: false, IsSigner: false}, + {PublicKey: mint, IsWritable: false, IsSigner: false}, + {PublicKey: solana.SystemProgramID, IsWritable: false, IsSigner: false}, + {PublicKey: solana.TokenProgramID, IsWritable: false, IsSigner: false}, + } + + // Instruction index 1 = CreateIdempotent + return solana.NewInstruction(ataProgramID, accounts, []byte{1}) +} diff --git a/universalClient/chains/svm/tx_builder_test.go b/universalClient/chains/svm/tx_builder_test.go new file mode 100644 index 00000000..92d970fb --- /dev/null +++ b/universalClient/chains/svm/tx_builder_test.go @@ -0,0 +1,1669 @@ +package svm + +import ( + "context" + "crypto/ecdsa" + crand "crypto/rand" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "fmt" + "math/big" + "os" + "path/filepath" + "testing" + "time" + + "github.com/ethereum/go-ethereum/crypto" + "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/rpc" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + uetypes "github.com/pushchain/push-chain-node/x/uexecutor/types" +) + +// ============================================================ +// Constants & helpers shared across tests +// ============================================================ + +// testGatewayAddress is a valid base58 Solana public key used for unit tests. +// It is NOT a real deployed gateway — only used for PDA derivations in offline tests. +const testGatewayAddress = "11111111111111111111111111111111" // system program, valid base58 + +func newTestBuilder(t *testing.T) *TxBuilder { + t.Helper() + logger := zerolog.Nop() + builder, err := NewTxBuilder(&RPCClient{}, "solana:devnet", testGatewayAddress, "/tmp", logger) + require.NoError(t, err) + return builder +} + +// makeTxID returns a deterministic 32-byte txID where every byte = fill +func makeTxID(fill byte) [32]byte { + var id [32]byte + for i := range id { + id[i] = fill + } + return id +} + +// makeSender returns a deterministic 20-byte sender address +func makeSender(fill byte) [20]byte { + var s [20]byte + for i := range s { + s[i] = fill + } + return s +} + +// makeToken returns a 32-byte token (Pubkey). Zero for native SOL. +func makeToken(fill byte) [32]byte { + var t [32]byte + for i := range t { + t[i] = fill + } + return t +} + +// buildMockTSSPDAData builds a raw byte slice simulating a TssPda account. +// Layout: discriminator(8) + tss_eth_address(20) + chain_id(Borsh String: 4 LE len + bytes) + nonce(u64 LE) + authority(32) + bump(1) +func buildMockTSSPDAData(tssAddr [20]byte, chainID string, nonce uint64, authority [32]byte, bump byte) []byte { + data := make([]byte, 0, 8+20+4+len(chainID)+8+32+1) + // discriminator (8 bytes of zeros) + data = append(data, make([]byte, 8)...) + // tss_eth_address (20 bytes) + data = append(data, tssAddr[:]...) + // chain_id Borsh String: 4-byte LE length + UTF-8 bytes + chainIDLenBytes := make([]byte, 4) + binary.LittleEndian.PutUint32(chainIDLenBytes, uint32(len(chainID))) + data = append(data, chainIDLenBytes...) + data = append(data, []byte(chainID)...) + // nonce (u64 LE) + nonceBytes := make([]byte, 8) + binary.LittleEndian.PutUint64(nonceBytes, nonce) + data = append(data, nonceBytes...) + // authority (32 bytes) + data = append(data, authority[:]...) + // bump (1 byte) + data = append(data, bump) + return data +} + +// generateTestEVMKey generates a fresh secp256k1 private key and returns +// the key, its 20-byte ETH address, and the hex-encoded address (no 0x prefix). +func generateTestEVMKey(t *testing.T) (*ecdsa.PrivateKey, [20]byte, string) { + t.Helper() + key, err := crypto.GenerateKey() + require.NoError(t, err) + pubBytes := crypto.FromECDSAPub(&key.PublicKey) + addrBytes := crypto.Keccak256(pubBytes[1:])[12:] + var addr [20]byte + copy(addr[:], addrBytes) + return key, addr, hex.EncodeToString(addrBytes) +} + +// signMessageHash signs a 32-byte hash with the given EVM private key +// and returns the 64-byte signature (r||s) and the recovery ID (v). +func signMessageHash(t *testing.T, key *ecdsa.PrivateKey, hash []byte) ([]byte, byte) { + t.Helper() + require.Len(t, hash, 32, "hash must be 32 bytes") + sig, err := crypto.Sign(hash, key) + require.NoError(t, err) + require.Len(t, sig, 65, "crypto.Sign must return 65 bytes (r||s||v)") + return sig[:64], sig[64] // signature (r||s), recovery_id (v) +} + +// buildMockExecutePayload builds a pre-encoded execute payload. +// Format: [u32 BE accounts_count][33 bytes per account (32 pubkey + 1 writable)][u32 BE ix_data_len][ix_data][u64 BE rent_fee] +func buildMockPayload(accounts []GatewayAccountMeta, ixData []byte, rentFee uint64, instructionID uint8) []byte { + payload := make([]byte, 0, 256) + // accounts count (u32 BE) + countBytes := make([]byte, 4) + binary.BigEndian.PutUint32(countBytes, uint32(len(accounts))) + payload = append(payload, countBytes...) + // each account: 32 pubkey + 1 writable + for _, acc := range accounts { + payload = append(payload, acc.Pubkey[:]...) + if acc.IsWritable { + payload = append(payload, 1) + } else { + payload = append(payload, 0) + } + } + // ix_data length (u32 BE) + ixLenBytes := make([]byte, 4) + binary.BigEndian.PutUint32(ixLenBytes, uint32(len(ixData))) + payload = append(payload, ixLenBytes...) + // ix_data + payload = append(payload, ixData...) + // rent_fee (u64 BE) + rentFeeBytes := make([]byte, 8) + binary.BigEndian.PutUint64(rentFeeBytes, rentFee) + payload = append(payload, rentFeeBytes...) + // instruction_id (u8) + payload = append(payload, instructionID) + return payload +} + +// buildMockExecutePayload is a convenience wrapper for execute payloads (instruction_id=2) +func buildMockExecutePayload(accounts []GatewayAccountMeta, ixData []byte, rentFee uint64) []byte { + return buildMockPayload(accounts, ixData, rentFee, 2) +} + +// buildMockWithdrawPayload builds a withdraw payload (instruction_id=1, no accounts/ixData) +func buildMockWithdrawPayload() []byte { + return buildMockPayload(nil, nil, 0, 1) +} + +// ============================================================ +// TestNewTxBuilder +// ============================================================ + +func TestNewTxBuilder(t *testing.T) { + logger := zerolog.Nop() + + tests := []struct { + name string + rpcClient *RPCClient + chainID string + gatewayAddress string + expectError bool + errorContains string + }{ + { + name: "valid inputs", + rpcClient: &RPCClient{}, + chainID: "solana:devnet", + gatewayAddress: testGatewayAddress, + expectError: false, + }, + { + name: "nil rpcClient", + rpcClient: nil, + chainID: "solana:devnet", + gatewayAddress: testGatewayAddress, + expectError: true, + errorContains: "rpcClient is required", + }, + { + name: "empty chainID", + rpcClient: &RPCClient{}, + chainID: "", + gatewayAddress: testGatewayAddress, + expectError: true, + errorContains: "chainID is required", + }, + { + name: "empty gatewayAddress", + rpcClient: &RPCClient{}, + chainID: "solana:devnet", + gatewayAddress: "", + expectError: true, + errorContains: "gatewayAddress is required", + }, + { + name: "invalid gatewayAddress", + rpcClient: &RPCClient{}, + chainID: "solana:devnet", + gatewayAddress: "not-a-valid-base58", + expectError: true, + errorContains: "invalid gateway address", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + builder, err := NewTxBuilder(tt.rpcClient, tt.chainID, tt.gatewayAddress, "/tmp", logger) + if tt.expectError { + assert.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + assert.Nil(t, builder) + } else { + assert.NoError(t, err) + assert.NotNil(t, builder) + assert.Equal(t, tt.chainID, builder.chainID) + } + }) + } +} + +// ============================================================ +// TestDefaultComputeUnitLimit +// ============================================================ + +func TestDefaultComputeUnitLimit(t *testing.T) { + assert.Equal(t, uint32(200000), uint32(DefaultComputeUnitLimit)) +} + +// ============================================================ +// TestDeriveTSSPDA — seed must be "tsspda" +// ============================================================ + +func TestDeriveTSSPDA(t *testing.T) { + builder := newTestBuilder(t) + + pda, err := builder.deriveTSSPDA() + require.NoError(t, err) + assert.False(t, pda.IsZero(), "TSS PDA should be non-zero") + + // Verify it matches FindProgramAddress with seed "tsspda" + expected, _, err := solana.FindProgramAddress([][]byte{[]byte("tsspda")}, builder.gatewayAddress) + require.NoError(t, err) + assert.Equal(t, expected, pda) + + // Verify it does NOT match the old buggy seed "tss" + oldPDA, _, err := solana.FindProgramAddress([][]byte{[]byte("tss")}, builder.gatewayAddress) + require.NoError(t, err) + assert.NotEqual(t, oldPDA, pda, "TSS PDA must NOT use old seed 'tss'") +} + +// ============================================================ +// TestFetchTSSNonce — Borsh String parsing +// ============================================================ + +func TestFetchTSSNonce(t *testing.T) { + t.Run("parses valid TssPda with short chain_id", func(t *testing.T) { + chainIDStr := "devnet" + data := buildMockTSSPDAData([20]byte{}, chainIDStr, 42, [32]byte{}, 255) + + nonce, chainID, err := parseTSSPDAData(data) + require.NoError(t, err) + assert.Equal(t, uint64(42), nonce) + assert.Equal(t, chainIDStr, chainID) + }) + + t.Run("parses valid TssPda with mainnet cluster pubkey", func(t *testing.T) { + chainIDStr := "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d" + data := buildMockTSSPDAData([20]byte{}, chainIDStr, 99, [32]byte{}, 1) + + nonce, chainID, err := parseTSSPDAData(data) + require.NoError(t, err) + assert.Equal(t, uint64(99), nonce) + assert.Equal(t, chainIDStr, chainID) + }) + + t.Run("rejects data too short for header", func(t *testing.T) { + _, _, err := parseTSSPDAData(make([]byte, 31)) // less than 32 + assert.Error(t, err) + assert.Contains(t, err.Error(), "too short") + }) + + t.Run("rejects data too short for chain_id + nonce", func(t *testing.T) { + // Build header with chain_id_len = 100, but only provide 40 total bytes + data := make([]byte, 40) + binary.LittleEndian.PutUint32(data[28:32], 100) // chain_id_len = 100 + _, _, err := parseTSSPDAData(data) + assert.Error(t, err) + assert.Contains(t, err.Error(), "too short") + }) + + t.Run("nonce at correct offset after variable-length chain_id", func(t *testing.T) { + // Two different chain_id lengths, same nonce — verify nonce offset is dynamic + for _, cid := range []string{"a", "abcdefghij"} { + data := buildMockTSSPDAData([20]byte{}, cid, 777, [32]byte{}, 0) + nonce, chainID, err := parseTSSPDAData(data) + require.NoError(t, err, "chain_id=%q", cid) + assert.Equal(t, uint64(777), nonce) + assert.Equal(t, cid, chainID) + } + }) +} + +// parseTSSPDAData is the extraction of fetchTSSNonce's parsing logic for unit testing +// without requiring an RPC call. This mirrors the parsing in fetchTSSNonce. +func parseTSSPDAData(accountData []byte) (uint64, string, error) { + if len(accountData) < 32 { + return 0, "", fmt.Errorf("invalid TSS PDA account data: too short (%d bytes)", len(accountData)) + } + chainIDLen := binary.LittleEndian.Uint32(accountData[28:32]) + requiredLen := 32 + int(chainIDLen) + 8 + if len(accountData) < requiredLen { + return 0, "", fmt.Errorf("invalid TSS PDA account data: too short for chain_id length %d (%d bytes)", chainIDLen, len(accountData)) + } + chainID := string(accountData[32 : 32+chainIDLen]) + nonceOffset := 32 + int(chainIDLen) + nonce := binary.LittleEndian.Uint64(accountData[nonceOffset : nonceOffset+8]) + return nonce, chainID, nil +} + +// ============================================================ +// TestDetermineInstructionID +// ============================================================ + +func TestDetermineInstructionID(t *testing.T) { + builder := newTestBuilder(t) + + tests := []struct { + name string + txType uetypes.TxType + isNative bool + expected uint8 + wantErr bool + }{ + {"FUNDS native → 1 (withdraw)", uetypes.TxType_FUNDS, true, 1, false}, + {"FUNDS SPL → 1 (withdraw)", uetypes.TxType_FUNDS, false, 1, false}, + {"FUNDS_AND_PAYLOAD → 2 (execute)", uetypes.TxType_FUNDS_AND_PAYLOAD, true, 2, false}, + {"GAS_AND_PAYLOAD → 2 (execute)", uetypes.TxType_GAS_AND_PAYLOAD, false, 2, false}, + {"INBOUND_REVERT native → 3", uetypes.TxType_INBOUND_REVERT, true, 3, false}, + {"INBOUND_REVERT SPL → 4", uetypes.TxType_INBOUND_REVERT, false, 4, false}, + {"UNSPECIFIED → error", uetypes.TxType_UNSPECIFIED_TX, true, 0, true}, + {"GAS → error", uetypes.TxType_GAS, true, 0, true}, + {"PAYLOAD → error", uetypes.TxType_PAYLOAD, true, 0, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + id, err := builder.determineInstructionID(tt.txType, tt.isNative) + if tt.wantErr { + assert.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, tt.expected, id) + } + }) + } +} + +// ============================================================ +// TestAnchorDiscriminator — SHA256, not Keccak +// ============================================================ + +func TestAnchorDiscriminator(t *testing.T) { + tests := []struct { + methodName string + }{ + {"withdraw_and_execute"}, + {"revert_universal_tx"}, + {"revert_universal_tx_token"}, + } + + for _, tt := range tests { + t.Run(tt.methodName, func(t *testing.T) { + disc := anchorDiscriminator(tt.methodName) + assert.Len(t, disc, 8, "discriminator must be 8 bytes") + + // Verify it matches sha256("global:")[:8] + h := sha256.Sum256([]byte("global:" + tt.methodName)) + assert.Equal(t, h[:8], disc) + + // Verify it does NOT match keccak256 (the old buggy approach) + keccak := crypto.Keccak256([]byte("global:" + tt.methodName))[:8] + assert.NotEqual(t, keccak, disc, "discriminator must use SHA256, not Keccak256") + }) + } +} + +// ============================================================ +// TestConstructTSSMessage — message format +// ============================================================ + +func TestConstructTSSMessage(t *testing.T) { + builder := newTestBuilder(t) + + txID := makeTxID(0xAA) + utxID := makeTxID(0xBB) + sender := makeSender(0xCC) + token := makeToken(0x00) // native SOL = zero + target := makeTxID(0xDD) + + t.Run("withdraw (id=1) message format", func(t *testing.T) { + hash, err := builder.constructTSSMessage( + 1, "devnet", 0, 1000000, + txID, utxID, sender, token, + 0, // gasFee + target, nil, nil, 0, + [32]byte{}, [32]byte{}, + ) + require.NoError(t, err) + assert.Len(t, hash, 32, "message hash must be 32 bytes (keccak256)") + + // Reconstruct expected message manually + msg := []byte("PUSH_CHAIN_SVM") + msg = append(msg, 1) // instruction_id + msg = append(msg, []byte("devnet")...) + nonceBE := make([]byte, 8) + binary.BigEndian.PutUint64(nonceBE, 0) + msg = append(msg, nonceBE...) + amountBE := make([]byte, 8) + binary.BigEndian.PutUint64(amountBE, 1000000) + msg = append(msg, amountBE...) + // additional: tx_id, utx_id, sender, token, gas_fee, target + msg = append(msg, txID[:]...) + msg = append(msg, utxID[:]...) + msg = append(msg, sender[:]...) + msg = append(msg, token[:]...) + gasBE := make([]byte, 8) + msg = append(msg, gasBE...) + msg = append(msg, target[:]...) + + expected := crypto.Keccak256(msg) + assert.Equal(t, expected, hash, "withdraw message hash mismatch") + }) + + t.Run("execute (id=2) message format", func(t *testing.T) { + accs := []GatewayAccountMeta{ + {Pubkey: makeTxID(0x11), IsWritable: true}, + {Pubkey: makeTxID(0x22), IsWritable: false}, + } + ixData := []byte{0xDE, 0xAD, 0xBE, 0xEF} + + hash, err := builder.constructTSSMessage( + 2, "devnet", 5, 2000000, + txID, utxID, sender, token, + 100, // gasFee + target, accs, ixData, 500, // rentFee + [32]byte{}, [32]byte{}, + ) + require.NoError(t, err) + assert.Len(t, hash, 32) + + // Rebuild expected + msg := []byte("PUSH_CHAIN_SVM") + msg = append(msg, 2) + msg = append(msg, []byte("devnet")...) + nonceBE := make([]byte, 8) + binary.BigEndian.PutUint64(nonceBE, 5) + msg = append(msg, nonceBE...) + amountBE := make([]byte, 8) + binary.BigEndian.PutUint64(amountBE, 2000000) + msg = append(msg, amountBE...) + msg = append(msg, txID[:]...) + msg = append(msg, utxID[:]...) + msg = append(msg, sender[:]...) + msg = append(msg, token[:]...) + gasBE := make([]byte, 8) + binary.BigEndian.PutUint64(gasBE, 100) + msg = append(msg, gasBE...) + msg = append(msg, target[:]...) + // accounts_buf + accCount := make([]byte, 4) + binary.BigEndian.PutUint32(accCount, 2) + msg = append(msg, accCount...) + acc1Key := makeTxID(0x11) + msg = append(msg, acc1Key[:]...) + msg = append(msg, 1) // writable + acc2Key := makeTxID(0x22) + msg = append(msg, acc2Key[:]...) + msg = append(msg, 0) // not writable + // ix_data_buf + ixLenBE := make([]byte, 4) + binary.BigEndian.PutUint32(ixLenBE, 4) + msg = append(msg, ixLenBE...) + msg = append(msg, 0xDE, 0xAD, 0xBE, 0xEF) + // rent_fee + rentBE := make([]byte, 8) + binary.BigEndian.PutUint64(rentBE, 500) + msg = append(msg, rentBE...) + + expected := crypto.Keccak256(msg) + assert.Equal(t, expected, hash, "execute message hash mismatch") + }) + + t.Run("revert SOL (id=3) message format", func(t *testing.T) { + revertRecipient := makeTxID(0xEE) + hash, err := builder.constructTSSMessage( + 3, "devnet", 10, 500000, + txID, utxID, sender, token, + 0, [32]byte{}, nil, nil, 0, + revertRecipient, [32]byte{}, + ) + require.NoError(t, err) + + msg := []byte("PUSH_CHAIN_SVM") + msg = append(msg, 3) + msg = append(msg, []byte("devnet")...) + nonceBE := make([]byte, 8) + binary.BigEndian.PutUint64(nonceBE, 10) + msg = append(msg, nonceBE...) + amountBE := make([]byte, 8) + binary.BigEndian.PutUint64(amountBE, 500000) + msg = append(msg, amountBE...) + // additional: utx_id, tx_id, recipient, gas_fee + msg = append(msg, utxID[:]...) + msg = append(msg, txID[:]...) + msg = append(msg, revertRecipient[:]...) + gasBE := make([]byte, 8) + msg = append(msg, gasBE...) + + expected := crypto.Keccak256(msg) + assert.Equal(t, expected, hash, "revert SOL message hash mismatch") + }) + + t.Run("revert SPL (id=4) message format", func(t *testing.T) { + revertRecipient := makeTxID(0xEE) + revertMint := makeTxID(0xFF) + hash, err := builder.constructTSSMessage( + 4, "devnet", 20, 750000, + txID, utxID, sender, token, + 0, [32]byte{}, nil, nil, 0, + revertRecipient, revertMint, + ) + require.NoError(t, err) + + msg := []byte("PUSH_CHAIN_SVM") + msg = append(msg, 4) + msg = append(msg, []byte("devnet")...) + nonceBE := make([]byte, 8) + binary.BigEndian.PutUint64(nonceBE, 20) + msg = append(msg, nonceBE...) + amountBE := make([]byte, 8) + binary.BigEndian.PutUint64(amountBE, 750000) + msg = append(msg, amountBE...) + // additional: utx_id, tx_id, mint, recipient, gas_fee + msg = append(msg, utxID[:]...) + msg = append(msg, txID[:]...) + msg = append(msg, revertMint[:]...) + msg = append(msg, revertRecipient[:]...) + gasBE := make([]byte, 8) + msg = append(msg, gasBE...) + + expected := crypto.Keccak256(msg) + assert.Equal(t, expected, hash, "revert SPL message hash mismatch") + }) + + t.Run("chain_id bytes go directly into message (no length prefix)", func(t *testing.T) { + // Verify that the chain_id in the message is raw UTF-8, not Borsh-encoded + chainID := "test_chain" + hash1, err := builder.constructTSSMessage( + 1, chainID, 0, 0, + [32]byte{}, [32]byte{}, [20]byte{}, [32]byte{}, + 0, [32]byte{}, nil, nil, 0, + [32]byte{}, [32]byte{}, + ) + require.NoError(t, err) + + // Build expected manually — chain_id as raw bytes + msg := []byte("PUSH_CHAIN_SVM") + msg = append(msg, 1) + msg = append(msg, []byte(chainID)...) // raw UTF-8, no 4-byte length prefix + msg = append(msg, make([]byte, 8)...) // nonce BE + msg = append(msg, make([]byte, 8)...) // amount BE + msg = append(msg, make([]byte, 32)...) // tx_id + msg = append(msg, make([]byte, 32)...) // utx_id + msg = append(msg, make([]byte, 20)...) // sender + msg = append(msg, make([]byte, 32)...) // token + msg = append(msg, make([]byte, 8)...) // gas_fee + msg = append(msg, make([]byte, 32)...) // target + + expected := crypto.Keccak256(msg) + assert.Equal(t, expected, hash1) + }) + + t.Run("unknown instruction ID returns error", func(t *testing.T) { + _, err := builder.constructTSSMessage( + 99, "devnet", 0, 0, + [32]byte{}, [32]byte{}, [20]byte{}, [32]byte{}, + 0, [32]byte{}, nil, nil, 0, + [32]byte{}, [32]byte{}, + ) + assert.Error(t, err) + assert.Contains(t, err.Error(), "unknown instruction ID") + }) +} + +// ============================================================ +// TestConstructTSSMessage_HashIsKeccak256 +// ============================================================ + +func TestConstructTSSMessage_HashIsKeccak256(t *testing.T) { + builder := newTestBuilder(t) + + // Construct a simple withdraw message and verify the hash algo + hash, err := builder.constructTSSMessage( + 1, "x", 0, 0, + [32]byte{}, [32]byte{}, [20]byte{}, [32]byte{}, + 0, [32]byte{}, nil, nil, 0, + [32]byte{}, [32]byte{}, + ) + require.NoError(t, err) + + // Build the raw message + msg := []byte("PUSH_CHAIN_SVM") + msg = append(msg, 1) + msg = append(msg, 'x') + msg = append(msg, make([]byte, 8+8+32+32+20+32+8+32)...) + + // Must be keccak256 (not sha256) + keccakHash := crypto.Keccak256(msg) + sha256Hash := sha256.Sum256(msg) + assert.Equal(t, keccakHash, hash, "TSS message must be hashed with keccak256") + assert.NotEqual(t, sha256Hash[:], hash, "TSS message must NOT be hashed with SHA256") +} + +// ============================================================ +// TestDecodePayload +// ============================================================ + +func TestDecodePayload(t *testing.T) { + t.Run("decodes valid execute payload with 2 accounts", func(t *testing.T) { + expectedAccounts := []GatewayAccountMeta{ + {Pubkey: makeTxID(0x11), IsWritable: true}, + {Pubkey: makeTxID(0x22), IsWritable: false}, + } + expectedIxData := []byte{0xAA, 0xBB, 0xCC} + expectedRentFee := uint64(12345) + + payload := buildMockPayload(expectedAccounts, expectedIxData, expectedRentFee, 2) + accounts, ixData, rentFee, instructionID, err := decodePayload(payload) + + require.NoError(t, err) + assert.Equal(t, uint8(2), instructionID) + assert.Len(t, accounts, 2) + assert.Equal(t, expectedAccounts[0].Pubkey, accounts[0].Pubkey) + assert.True(t, accounts[0].IsWritable) + assert.Equal(t, expectedAccounts[1].Pubkey, accounts[1].Pubkey) + assert.False(t, accounts[1].IsWritable) + assert.Equal(t, expectedIxData, ixData) + assert.Equal(t, expectedRentFee, rentFee) + }) + + t.Run("decodes withdraw payload (0 accounts)", func(t *testing.T) { + payload := buildMockWithdrawPayload() + accounts, ixData, rentFee, instructionID, err := decodePayload(payload) + require.NoError(t, err) + assert.Equal(t, uint8(1), instructionID) + assert.Len(t, accounts, 0) + assert.Len(t, ixData, 0) + assert.Equal(t, uint64(0), rentFee) + }) + + t.Run("decodes payload with empty ix_data", func(t *testing.T) { + accs := []GatewayAccountMeta{{Pubkey: makeTxID(0x33), IsWritable: true}} + payload := buildMockPayload(accs, nil, 999, 2) + accounts, ixData, rentFee, instructionID, err := decodePayload(payload) + require.NoError(t, err) + assert.Equal(t, uint8(2), instructionID) + assert.Len(t, accounts, 1) + assert.Len(t, ixData, 0) + assert.Equal(t, uint64(999), rentFee) + }) + + t.Run("rejects too-short payload", func(t *testing.T) { + _, _, _, _, err := decodePayload([]byte{0, 0}) + assert.Error(t, err) + }) + + t.Run("rejects truncated account data", func(t *testing.T) { + // Says 1 account but only provides 10 bytes (need 33) + payload := make([]byte, 4+10) + binary.BigEndian.PutUint32(payload[0:4], 1) + _, _, _, _, err := decodePayload(payload) + assert.Error(t, err) + }) + + t.Run("rejects truncated ix_data", func(t *testing.T) { + // 0 accounts, says ix_data len=100 but only 4 bytes remain + payload := make([]byte, 4+4+4) + binary.BigEndian.PutUint32(payload[0:4], 0) // 0 accounts + binary.BigEndian.PutUint32(payload[4:8], 100) // ix_data_len = 100 + _, _, _, _, err := decodePayload(payload) + assert.Error(t, err) + }) +} + +// ============================================================ +// TestAccountsToWritableFlags +// ============================================================ + +func TestAccountsToWritableFlags(t *testing.T) { + t.Run("empty accounts → empty flags", func(t *testing.T) { + flags := accountsToWritableFlags(nil) + assert.Empty(t, flags) + }) + + t.Run("single writable account → 0x80", func(t *testing.T) { + accs := []GatewayAccountMeta{{IsWritable: true}} + flags := accountsToWritableFlags(accs) + assert.Equal(t, []byte{0x80}, flags) // bit 7 set (MSB-first) + }) + + t.Run("single non-writable account → 0x00", func(t *testing.T) { + accs := []GatewayAccountMeta{{IsWritable: false}} + flags := accountsToWritableFlags(accs) + assert.Equal(t, []byte{0x00}, flags) + }) + + t.Run("8 accounts all writable → 0xFF", func(t *testing.T) { + accs := make([]GatewayAccountMeta, 8) + for i := range accs { + accs[i].IsWritable = true + } + flags := accountsToWritableFlags(accs) + assert.Equal(t, []byte{0xFF}, flags) + }) + + t.Run("9 accounts → 2 bytes", func(t *testing.T) { + accs := make([]GatewayAccountMeta, 9) + accs[0].IsWritable = true // byte 0, bit 7 → 0x80 + accs[8].IsWritable = true // byte 1, bit 7 → 0x80 + flags := accountsToWritableFlags(accs) + assert.Len(t, flags, 2) + assert.Equal(t, byte(0x80), flags[0]) + assert.Equal(t, byte(0x80), flags[1]) + }) + + t.Run("MSB-first bit ordering matches gateway contract", func(t *testing.T) { + // accounts: [W, R, W, R, R, R, R, R] → bit pattern: 10100000 = 0xA0 + accs := make([]GatewayAccountMeta, 8) + accs[0].IsWritable = true + accs[2].IsWritable = true + flags := accountsToWritableFlags(accs) + assert.Equal(t, []byte{0xA0}, flags) + }) +} + +// ============================================================ +// TestBuildWithdrawAndExecuteData — Borsh layout +// ============================================================ + +func TestBuildWithdrawAndExecuteData(t *testing.T) { + builder := newTestBuilder(t) + txID := makeTxID(0x01) + utxID := makeTxID(0x02) + sender := makeSender(0x03) + sig := make([]byte, 64) + for i := range sig { + sig[i] = byte(i) + } + msgHash := make([]byte, 32) + for i := range msgHash { + msgHash[i] = byte(0xFF - i) + } + + t.Run("withdraw (id=1) data layout", func(t *testing.T) { + data := builder.buildWithdrawAndExecuteData( + 1, txID, utxID, 1000000, sender, + []byte{}, []byte{}, // empty writable_flags and ix_data for withdraw + 0, 0, // gasFee, rentFee + sig, 2, msgHash, 42, + ) + + // Check discriminator + expectedDisc := anchorDiscriminator("withdraw_and_execute") + assert.Equal(t, expectedDisc, data[:8], "discriminator") + + // Check instruction_id + assert.Equal(t, byte(1), data[8], "instruction_id") + + // Check tx_id + assert.Equal(t, txID[:], data[9:41], "tx_id") + + // Check universal_tx_id + assert.Equal(t, utxID[:], data[41:73], "universal_tx_id") + + // Check amount (u64 LE) + assert.Equal(t, uint64(1000000), binary.LittleEndian.Uint64(data[73:81]), "amount") + + // Check sender + assert.Equal(t, sender[:], data[81:101], "sender") + + // Check writable_flags Vec: len=0 + assert.Equal(t, uint32(0), binary.LittleEndian.Uint32(data[101:105]), "writable_flags len") + + // Check ix_data Vec: len=0 + assert.Equal(t, uint32(0), binary.LittleEndian.Uint32(data[105:109]), "ix_data len") + + // Check gas_fee (u64 LE) + assert.Equal(t, uint64(0), binary.LittleEndian.Uint64(data[109:117]), "gas_fee") + + // Check rent_fee (u64 LE) + assert.Equal(t, uint64(0), binary.LittleEndian.Uint64(data[117:125]), "rent_fee") + + // Check signature + assert.Equal(t, sig, data[125:189], "signature") + + // Check recovery_id + assert.Equal(t, byte(2), data[189], "recovery_id") + + // Check message_hash + assert.Equal(t, msgHash, data[190:222], "message_hash") + + // Check nonce (u64 LE) + assert.Equal(t, uint64(42), binary.LittleEndian.Uint64(data[222:230]), "nonce") + + // Total length + assert.Len(t, data, 230) + }) + + t.Run("execute (id=2) with accounts and ix_data", func(t *testing.T) { + wf := []byte{0xA0} + ixData := []byte{0xDE, 0xAD} + + data := builder.buildWithdrawAndExecuteData( + 2, txID, utxID, 500, sender, + wf, ixData, + 100, 50, // gasFee, rentFee + sig, 0, msgHash, 7, + ) + + // Discriminator should be same function + expectedDisc := anchorDiscriminator("withdraw_and_execute") + assert.Equal(t, expectedDisc, data[:8]) + assert.Equal(t, byte(2), data[8]) + + // Verify writable_flags Vec has length=1 + offset := 101 + assert.Equal(t, uint32(1), binary.LittleEndian.Uint32(data[offset:offset+4])) + offset += 4 + assert.Equal(t, byte(0xA0), data[offset]) + offset += 1 + + // Verify ix_data Vec has length=2 + assert.Equal(t, uint32(2), binary.LittleEndian.Uint32(data[offset:offset+4])) + offset += 4 + assert.Equal(t, []byte{0xDE, 0xAD}, data[offset:offset+2]) + offset += 2 + + // gas_fee + assert.Equal(t, uint64(100), binary.LittleEndian.Uint64(data[offset:offset+8])) + offset += 8 + + // rent_fee + assert.Equal(t, uint64(50), binary.LittleEndian.Uint64(data[offset:offset+8])) + offset += 8 + + // signature + recovery_id + message_hash + nonce + assert.Equal(t, sig, data[offset:offset+64]) + offset += 64 + assert.Equal(t, byte(0), data[offset]) + offset += 1 + assert.Equal(t, msgHash, data[offset:offset+32]) + offset += 32 + assert.Equal(t, uint64(7), binary.LittleEndian.Uint64(data[offset:offset+8])) + }) +} + +// ============================================================ +// TestBuildRevertData +// ============================================================ + +func TestBuildRevertData(t *testing.T) { + builder := newTestBuilder(t) + txID := makeTxID(0x01) + utxID := makeTxID(0x02) + recipient := solana.MustPublicKeyFromBase58(testGatewayAddress) + revertMsg := []byte("revert me") + sig := make([]byte, 64) + msgHash := make([]byte, 32) + + t.Run("revert SOL (id=3) uses correct discriminator", func(t *testing.T) { + data := builder.buildRevertData(3, txID, utxID, 1000, recipient, revertMsg, 0, sig, 1, msgHash, 5) + + expectedDisc := anchorDiscriminator("revert_universal_tx") + assert.Equal(t, expectedDisc, data[:8]) + assert.Equal(t, txID[:], data[8:40]) + assert.Equal(t, utxID[:], data[40:72]) + assert.Equal(t, uint64(1000), binary.LittleEndian.Uint64(data[72:80])) + // revert_instruction: fund_recipient(32) + revert_msg Vec(4+N) + assert.Equal(t, recipient.Bytes(), data[80:112]) + assert.Equal(t, uint32(len(revertMsg)), binary.LittleEndian.Uint32(data[112:116])) + assert.Equal(t, revertMsg, data[116:116+len(revertMsg)]) + }) + + t.Run("revert SPL (id=4) uses correct discriminator", func(t *testing.T) { + data := builder.buildRevertData(4, txID, utxID, 2000, recipient, nil, 0, sig, 0, msgHash, 10) + + expectedDisc := anchorDiscriminator("revert_universal_tx_token") + assert.Equal(t, expectedDisc, data[:8]) + // revert_msg should be empty Vec (len=0) + offset := 112 // after tx_id(32) + utx_id(32) + amount(8) + fund_recipient(32) + assert.Equal(t, uint32(0), binary.LittleEndian.Uint32(data[offset:offset+4])) + }) +} + +// ============================================================ +// TestBuildWithdrawAndExecuteAccounts — accounts list +// ============================================================ + +func TestBuildWithdrawAndExecuteAccounts(t *testing.T) { + builder := newTestBuilder(t) + + caller := solana.NewWallet().PublicKey() + config := solana.NewWallet().PublicKey() + vault := solana.NewWallet().PublicKey() + cea := solana.NewWallet().PublicKey() + tss := solana.NewWallet().PublicKey() + executed := solana.NewWallet().PublicKey() + recipient := solana.NewWallet().PublicKey() + + t.Run("withdraw SOL (id=1) has correct account order", func(t *testing.T) { + accounts := builder.buildWithdrawAndExecuteAccounts( + caller, config, vault, cea, tss, executed, + solana.SystemProgramID, // destination_program = system for withdraw + true, 1, // isNative, instructionID + recipient, solana.PublicKey{}, // recipient, mint (unused for native) + nil, // no execute accounts + ) + + // First 8 required accounts + assert.Equal(t, caller, accounts[0].PublicKey, "caller") + assert.True(t, accounts[0].IsSigner) + assert.True(t, accounts[0].IsWritable) + + assert.Equal(t, config, accounts[1].PublicKey, "config") + assert.False(t, accounts[1].IsWritable, "config is read-only") + + assert.Equal(t, vault, accounts[2].PublicKey, "vault_sol") + assert.True(t, accounts[2].IsWritable) + + assert.Equal(t, cea, accounts[3].PublicKey, "cea_authority") + assert.True(t, accounts[3].IsWritable) + + assert.Equal(t, tss, accounts[4].PublicKey, "tss_pda") + assert.True(t, accounts[4].IsWritable) + + assert.Equal(t, executed, accounts[5].PublicKey, "executed_tx") + assert.True(t, accounts[5].IsWritable) + + assert.Equal(t, solana.SystemProgramID, accounts[6].PublicKey, "system_program") + assert.False(t, accounts[6].IsWritable) + + assert.Equal(t, solana.SystemProgramID, accounts[7].PublicKey, "destination_program") + assert.False(t, accounts[7].IsWritable) + + // For native SOL withdraw: recipient is real, rest are gateway sentinels + assert.Equal(t, recipient, accounts[8].PublicKey, "recipient (withdraw SOL)") + assert.True(t, accounts[8].IsWritable) + + // Remaining optional accounts should be gateway address (None sentinels) + for i := 9; i <= 15; i++ { + assert.Equal(t, builder.gatewayAddress, accounts[i].PublicKey, + "optional account %d should be gateway sentinel", i) + } + + assert.Len(t, accounts, 16, "total accounts for SOL withdraw") + }) + + t.Run("execute (id=2) appends remaining_accounts", func(t *testing.T) { + execAccounts := []GatewayAccountMeta{ + {Pubkey: makeTxID(0xAA), IsWritable: true}, + {Pubkey: makeTxID(0xBB), IsWritable: false}, + } + + accounts := builder.buildWithdrawAndExecuteAccounts( + caller, config, vault, cea, tss, executed, + recipient, // destination_program = target program + true, 2, // isNative, instructionID=execute + solana.PublicKey{}, solana.PublicKey{}, + execAccounts, + ) + + // For execute: recipient should be gateway sentinel (None) + assert.Equal(t, builder.gatewayAddress, accounts[8].PublicKey, "recipient should be None for execute") + + // remaining_accounts appended at the end + totalRequired := 16 // 8 required + 8 optional + assert.Len(t, accounts, totalRequired+2) + + // Check remaining_accounts + expectedPk1 := makeTxID(0xAA) + acc1 := accounts[totalRequired] + assert.Equal(t, solana.PublicKeyFromBytes(expectedPk1[:]), acc1.PublicKey) + assert.True(t, acc1.IsWritable) + assert.False(t, acc1.IsSigner) + + expectedPk2 := makeTxID(0xBB) + acc2 := accounts[totalRequired+1] + assert.Equal(t, solana.PublicKeyFromBytes(expectedPk2[:]), acc2.PublicKey) + assert.False(t, acc2.IsWritable) + }) +} + +// ============================================================ +// TestBuildRevertAccounts +// ============================================================ + +func TestBuildRevertSOLAccounts(t *testing.T) { + builder := newTestBuilder(t) + + config := solana.NewWallet().PublicKey() + vault := solana.NewWallet().PublicKey() + tss := solana.NewWallet().PublicKey() + recipient := solana.NewWallet().PublicKey() + executed := solana.NewWallet().PublicKey() + caller := solana.NewWallet().PublicKey() + + accounts := builder.buildRevertSOLAccounts(config, vault, tss, recipient, executed, caller) + + assert.Len(t, accounts, 7) + assert.Equal(t, config, accounts[0].PublicKey, "config") + assert.Equal(t, vault, accounts[1].PublicKey, "vault") + assert.True(t, accounts[1].IsWritable) + assert.Equal(t, tss, accounts[2].PublicKey, "tss_pda") + assert.True(t, accounts[2].IsWritable) + assert.Equal(t, recipient, accounts[3].PublicKey, "recipient") + assert.True(t, accounts[3].IsWritable) + assert.Equal(t, executed, accounts[4].PublicKey, "executed_tx") + assert.True(t, accounts[4].IsWritable) + assert.Equal(t, caller, accounts[5].PublicKey, "caller") + assert.True(t, accounts[5].IsSigner) + assert.Equal(t, solana.SystemProgramID, accounts[6].PublicKey, "system_program") +} + +func TestBuildRevertSPLAccounts(t *testing.T) { + builder := newTestBuilder(t) + + config := solana.NewWallet().PublicKey() + vault := solana.NewWallet().PublicKey() + tokenVault := solana.NewWallet().PublicKey() + tss := solana.NewWallet().PublicKey() + recipientATA := solana.NewWallet().PublicKey() + tokenMint := solana.NewWallet().PublicKey() + executed := solana.NewWallet().PublicKey() + caller := solana.NewWallet().PublicKey() + + accounts := builder.buildRevertSPLAccounts(config, vault, tokenVault, tss, recipientATA, tokenMint, executed, caller) + + assert.Len(t, accounts, 11) + assert.Equal(t, config, accounts[0].PublicKey, "config") + assert.Equal(t, vault, accounts[1].PublicKey, "vault") + assert.Equal(t, tokenVault, accounts[2].PublicKey, "token_vault") + assert.True(t, accounts[2].IsWritable) + assert.Equal(t, tss, accounts[3].PublicKey, "tss_pda") + assert.Equal(t, recipientATA, accounts[4].PublicKey, "recipient_token_account") + assert.Equal(t, tokenMint, accounts[5].PublicKey, "token_mint") + assert.Equal(t, executed, accounts[6].PublicKey, "executed_tx") + assert.Equal(t, caller, accounts[7].PublicKey, "caller") + assert.True(t, accounts[7].IsSigner) + assert.Equal(t, vault, accounts[8].PublicKey, "vault_sol (same as vault)") + assert.Equal(t, solana.TokenProgramID, accounts[9].PublicKey, "token_program") + assert.Equal(t, solana.SystemProgramID, accounts[10].PublicKey, "system_program") +} + +// ============================================================ +// TestRemoveHexPrefix +// ============================================================ + +func TestRemoveHexPrefix(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"0xabcdef", "abcdef"}, + {"0XABCDEF", "0XABCDEF"}, // only lowercase 0x + {"abcdef", "abcdef"}, + {"", ""}, + {"0x", ""}, + } + for _, tt := range tests { + assert.Equal(t, tt.expected, removeHexPrefix(tt.input)) + } +} + +// ============================================================ +// TestParseTxType +// ============================================================ + +func TestParseTxType(t *testing.T) { + tests := []struct { + input string + expected uetypes.TxType + wantErr bool + }{ + {"GAS", uetypes.TxType_GAS, false}, + {"FUNDS", uetypes.TxType_FUNDS, false}, + {"PAYLOAD", uetypes.TxType_PAYLOAD, false}, + {"FUNDS_AND_PAYLOAD", uetypes.TxType_FUNDS_AND_PAYLOAD, false}, + {"GAS_AND_PAYLOAD", uetypes.TxType_GAS_AND_PAYLOAD, false}, + {"INBOUND_REVERT", uetypes.TxType_INBOUND_REVERT, false}, + {"1", uetypes.TxType(1), false}, + {"3", uetypes.TxType(3), false}, + {"invalid", uetypes.TxType_UNSPECIFIED_TX, true}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result, err := parseTxType(tt.input) + if tt.wantErr { + assert.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, tt.expected, result) + } + }) + } +} + +// ============================================================ +// TestComputeUnitLimitInstruction +// ============================================================ + +func TestBuildSetComputeUnitLimitInstruction(t *testing.T) { + builder := newTestBuilder(t) + ix := builder.buildSetComputeUnitLimitInstruction(300000) + + // Verify program ID is Compute Budget + expectedProgramID := solana.MustPublicKeyFromBase58("ComputeBudget111111111111111111111111111111") + assert.Equal(t, expectedProgramID, ix.ProgramID()) + + // Verify instruction data + data, err := ix.Data() + require.NoError(t, err) + assert.Len(t, data, 5) + assert.Equal(t, byte(2), data[0], "instruction type = SetComputeUnitLimit") + assert.Equal(t, uint32(300000), binary.LittleEndian.Uint32(data[1:5])) +} + +// ============================================================ +// TestGatewayAccountMetaStruct +// ============================================================ + +func TestGatewayAccountMetaStruct(t *testing.T) { + var pk [32]byte + for i := range pk { + pk[i] = byte(i) + } + meta := GatewayAccountMeta{Pubkey: pk, IsWritable: true} + assert.Equal(t, pk, meta.Pubkey) + assert.True(t, meta.IsWritable) +} + +// ============================================================ +// TestGasLimitParsing +// ============================================================ + +func TestGasLimitParsing(t *testing.T) { + tests := []struct { + name string + gasLimit string + expected uint32 + }{ + {"empty → default", "", DefaultComputeUnitLimit}, + {"zero → default", "0", DefaultComputeUnitLimit}, + {"valid", "300000", 300000}, + {"invalid number → default", "abc", DefaultComputeUnitLimit}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var result uint32 + if tt.gasLimit == "" || tt.gasLimit == "0" { + result = DefaultComputeUnitLimit + } else { + parsed, err := parseUint32(tt.gasLimit) + if err != nil { + result = DefaultComputeUnitLimit + } else { + result = parsed + } + } + assert.Equal(t, tt.expected, result) + }) + } +} + +func parseUint32(s string) (uint32, error) { + val, err := parseUint64(s) + if err != nil { + return 0, err + } + return uint32(val), nil +} + +func parseUint64(s string) (uint64, error) { + var val uint64 + _, err := fmt.Sscanf(s, "%d", &val) + return val, err +} + +// ============================================================ +// TestEndToEndMessageAndDataConsistency +// ============================================================ + +func TestEndToEndWithdrawMessageAndData(t *testing.T) { + // Verifies that the TSS message hash (signed by TSS) ends up in the + // instruction data's message_hash field at the correct offset. + builder := newTestBuilder(t) + + txID := makeTxID(0xAA) + utxID := makeTxID(0xBB) + sender := makeSender(0xCC) + token := [32]byte{} // native SOL + target := makeTxID(0xDD) + + msgHash, err := builder.constructTSSMessage( + 1, "devnet", 42, 1000000, + txID, utxID, sender, token, + 0, target, nil, nil, 0, + [32]byte{}, [32]byte{}, + ) + require.NoError(t, err) + + sig := make([]byte, 64) + instrData := builder.buildWithdrawAndExecuteData( + 1, txID, utxID, 1000000, sender, + []byte{}, []byte{}, 0, 0, + sig, 0, msgHash, 42, + ) + + // Extract message_hash from instruction data + // Offset: 8(disc) + 1(id) + 32(txid) + 32(utxid) + 8(amount) + 20(sender) + // + 4(wf_len) + 0(wf) + 4(ix_len) + 0(ix) + 8(gas) + 8(rent) + 64(sig) + 1(recov) + // = 190 + msgHashFromData := instrData[190:222] + assert.Equal(t, msgHash, msgHashFromData, "message_hash in instruction data must match TSS message hash") + + // Extract nonce from instruction data + nonceFromData := binary.LittleEndian.Uint64(instrData[222:230]) + assert.Equal(t, uint64(42), nonceFromData, "nonce in instruction data must match") +} + +// ============================================================ +// TestAnchorDiscriminatorKnownValues +// ============================================================ + +func TestAnchorDiscriminatorKnownValues(t *testing.T) { + // Verify discriminator values are deterministic and can be independently computed + for _, method := range []string{"withdraw_and_execute", "revert_universal_tx", "revert_universal_tx_token"} { + disc := anchorDiscriminator(method) + h := sha256.Sum256([]byte("global:" + method)) + assert.Equal(t, h[:8], disc, "discriminator for %s", method) + } +} + +// ============================================================ +// TestDetermineRecoveryID — real EVM key signing +// ============================================================ + +func TestDetermineRecoveryID(t *testing.T) { + builder := newTestBuilder(t) + evmKey, _, ethAddrHex := generateTestEVMKey(t) + + t.Run("recovers correct ID from real signature", func(t *testing.T) { + // Construct a real TSS message hash + msgHash, err := builder.constructTSSMessage( + 1, "devnet", 0, 1000000, + makeTxID(0xAA), makeTxID(0xBB), makeSender(0xCC), [32]byte{}, + 0, makeTxID(0xDD), nil, nil, 0, + [32]byte{}, [32]byte{}, + ) + require.NoError(t, err) + + sig, expectedRecoveryID := signMessageHash(t, evmKey, msgHash) + + recoveryID, err := builder.determineRecoveryID(msgHash, sig, ethAddrHex) + require.NoError(t, err) + assert.Equal(t, expectedRecoveryID, recoveryID) + }) + + t.Run("fails with wrong address", func(t *testing.T) { + msgHash := crypto.Keccak256([]byte("test message")) + sig, _ := signMessageHash(t, evmKey, msgHash) + + _, err := builder.determineRecoveryID(msgHash, sig, "0000000000000000000000000000000000000000") + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to determine recovery ID") + }) + + t.Run("works with 0x-prefixed address", func(t *testing.T) { + msgHash := crypto.Keccak256([]byte("another test")) + sig, expectedRecoveryID := signMessageHash(t, evmKey, msgHash) + + recoveryID, err := builder.determineRecoveryID(msgHash, sig, "0x"+ethAddrHex) + require.NoError(t, err) + assert.Equal(t, expectedRecoveryID, recoveryID) + }) +} + +// ============================================================ +// TestEndToEndWithRealSignature +// Full offline end-to-end: construct TSS message → sign with +// real EVM key → build instruction data → verify recovery +// ============================================================ + +func TestEndToEndWithRealSignature(t *testing.T) { + builder := newTestBuilder(t) + evmKey, _, ethAddrHex := generateTestEVMKey(t) + + txID := makeTxID(0xAA) + utxID := makeTxID(0xBB) + sender := makeSender(0xCC) + token := [32]byte{} // native SOL + target := makeTxID(0xDD) + + t.Run("withdraw flow with real signature", func(t *testing.T) { + nonce := uint64(42) + amount := uint64(1000000) + + // 1. Construct TSS message hash (what TSS nodes would sign) + msgHash, err := builder.constructTSSMessage( + 1, "devnet", nonce, amount, + txID, utxID, sender, token, + 0, target, nil, nil, 0, + [32]byte{}, [32]byte{}, + ) + require.NoError(t, err) + + // 2. Sign with real EVM key (simulating TSS signing) + sig, _ := signMessageHash(t, evmKey, msgHash) + + // 3. Determine recovery ID (what the relayer does) + recoveryID, err := builder.determineRecoveryID(msgHash, sig, ethAddrHex) + require.NoError(t, err) + + // 4. Build instruction data with real signature + instrData := builder.buildWithdrawAndExecuteData( + 1, txID, utxID, amount, sender, + []byte{}, []byte{}, 0, 0, + sig, recoveryID, msgHash, nonce, + ) + + // 5. Verify the instruction data contains the real signature + assert.Equal(t, sig, instrData[125:189], "real signature in instruction data") + assert.Equal(t, recoveryID, instrData[189], "recovery ID in instruction data") + assert.Equal(t, msgHash, instrData[190:222], "message hash in instruction data") + }) + + t.Run("execute flow with real signature", func(t *testing.T) { + nonce := uint64(7) + amount := uint64(500) + accs := []GatewayAccountMeta{ + {Pubkey: makeTxID(0x11), IsWritable: true}, + } + ixData := []byte{0xDE, 0xAD} + rentFee := uint64(5000) + + msgHash, err := builder.constructTSSMessage( + 2, "devnet", nonce, amount, + txID, utxID, sender, token, + 0, target, accs, ixData, rentFee, + [32]byte{}, [32]byte{}, + ) + require.NoError(t, err) + + sig, _ := signMessageHash(t, evmKey, msgHash) + recoveryID, err := builder.determineRecoveryID(msgHash, sig, ethAddrHex) + require.NoError(t, err) + + wf := accountsToWritableFlags(accs) + instrData := builder.buildWithdrawAndExecuteData( + 2, txID, utxID, amount, sender, + wf, ixData, + 0, rentFee, + sig, recoveryID, msgHash, nonce, + ) + + // Verify instruction data length includes variable-length fields + // 8(disc) + 1(id) + 32(txid) + 32(utxid) + 8(amt) + 20(sender) + // + 4+1(wf) + 4+2(ix) + 8(gas) + 8(rent) + 64(sig) + 1(recov) + 32(hash) + 8(nonce) + expectedLen := 8 + 1 + 32 + 32 + 8 + 20 + 5 + 6 + 8 + 8 + 64 + 1 + 32 + 8 + assert.Len(t, instrData, expectedLen) + }) + + t.Run("revert SOL flow with real signature", func(t *testing.T) { + nonce := uint64(10) + amount := uint64(500000) + revertRecipient := makeTxID(0xEE) + + msgHash, err := builder.constructTSSMessage( + 3, "devnet", nonce, amount, + txID, utxID, sender, token, + 0, [32]byte{}, nil, nil, 0, + revertRecipient, [32]byte{}, + ) + require.NoError(t, err) + + sig, _ := signMessageHash(t, evmKey, msgHash) + recoveryID, err := builder.determineRecoveryID(msgHash, sig, ethAddrHex) + require.NoError(t, err) + + recipient := solana.PublicKeyFromBytes(revertRecipient[:]) + instrData := builder.buildRevertData( + 3, txID, utxID, amount, recipient, + []byte("revert msg"), 0, + sig, recoveryID, msgHash, nonce, + ) + + // Verify discriminator is for revert_universal_tx + expectedDisc := anchorDiscriminator("revert_universal_tx") + assert.Equal(t, expectedDisc, instrData[:8]) + }) +} + +// ============================================================ +// Simulation Tests — live devnet end-to-end +// +// Run: go test -run TestSimulate -v -count=1 -timeout 120s +// +// Each test does the full pipeline: +// 1. Connect to devnet RPC +// 2. Generate fresh Solana relayer keypair (written to temp dir) +// 3. Generate fresh EVM key for signing +// 4. GetOutboundSigningRequest (fetches TSS PDA nonce from chain) +// 5. Sign the message hash with the EVM key (secp256k1) +// 6. BroadcastOutboundSigningRequest (assembles & sends the Solana tx) +// +// Expected: Steps 1-5 always succeed. Step 6 fails with +// "failed to determine recovery ID" because the generated EVM key +// doesn't match the TSS ETH address stored on-chain. This validates +// the entire assembly pipeline up to the on-chain auth check. +// ============================================================ + +const ( + devnetGatewayAddress = "DJoFYDpgbTfxbXBv1QYhYGc9FK4J5FUKpYXAfSkHryXp" + devnetRPCURL = "https://api.devnet.solana.com" + devnetGenesisHash = "EtWTRABZaYq6iMfeYKouRu166VU2xqa1wcaWoxPkrZBG" + devnetSPLMint = "EiXDnrAg9ea2Q6vEPV7E5TpTU1vh41jcuZqKjU5Dc4ZF" + devnetMemoProgram = "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr" + + // Hardcoded EVM private key for simulation tests. + // ETH address: 0xc681e7bdacfe4dc7209a15ff052f897c3d87008f + // Set this address in the TSS PDA on the gateway contract for signatures to pass on-chain. + testEVMPrivKeyHex = "d54b0eb459b7c0b82e3c21ced25f52a0a7fae6ed1a8614df46dda86c8d5f1e59" + + // Hardcoded Solana relayer keypair for simulation tests. + // Pubkey: AdWDRaQfvWJqW4TaxTrXP5WogCWJMJBrtBfGjjHUDADM + testSolanaKeypairJSON = `[226,7,176,193,18,2,55,106,191,150,176,87,157,216,118,97,236,128,2,104,181,206,160,147,5,152,0,115,23,8,103,189,143,19,31,194,227,248,222,123,219,13,143,47,154,104,201,235,13,16,11,45,117,154,117,37,130,196,58,154,89,228,136,32]` +) + +// setupDevnetSimulation creates RPCClient and TxBuilder for devnet. +// Uses the hardcoded Solana relayer keypair (AdWDRaQfvWJqW4TaxTrXP5WogCWJMJBrtBfGjjHUDADM). +func setupDevnetSimulation(t *testing.T) (*RPCClient, *TxBuilder) { + + t.Skip("skipping simulation tests") // DELIBERATELY SKIPPING SIMULATION TESTS + t.Helper() + if testing.Short() { + t.Skip("skipping simulation test in short mode") + } + + logger := zerolog.New(zerolog.NewTestWriter(t)).Level(zerolog.DebugLevel) + rpcClient, err := NewRPCClient([]string{devnetRPCURL}, devnetGenesisHash, logger) + if err != nil { + t.Skipf("skipping: failed to connect to Devnet RPC: %v", err) + } + + // Write the hardcoded keypair JSON to the temp dir so loadRelayerKeypair can find it. + tmpDir := t.TempDir() + relayerDir := filepath.Join(tmpDir, "relayer") + require.NoError(t, os.MkdirAll(relayerDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(relayerDir, "solana.json"), []byte(testSolanaKeypairJSON), 0o600)) + + builder, err := NewTxBuilder(rpcClient, "solana:"+devnetGenesisHash, devnetGatewayAddress, tmpDir, logger) + require.NoError(t, err) + + t.Logf("relayer pubkey: AdWDRaQfvWJqW4TaxTrXP5WogCWJMJBrtBfGjjHUDADM") + return rpcClient, builder +} + +// loadTestEVMKey loads the hardcoded EVM private key and returns the key + ETH address hex. +func loadTestEVMKey(t *testing.T) (*ecdsa.PrivateKey, string) { + t.Helper() + privBytes, err := hex.DecodeString(testEVMPrivKeyHex) + require.NoError(t, err) + key, err := crypto.ToECDSA(privBytes) + require.NoError(t, err) + pubBytes := crypto.FromECDSAPub(&key.PublicKey) + addrBytes := crypto.Keccak256(pubBytes[1:])[12:] + return key, hex.EncodeToString(addrBytes) +} + +// newDevnetOutbound creates an OutboundCreatedEvent using the hardcoded EVM key as sender +// and a fresh Solana wallet as recipient. Uses random tx_id/utx_id for each call to avoid +// executed_tx PDA collisions between tests. +func newDevnetOutbound(t *testing.T, amount, assetAddr, payload, revertMsg, txType string) (*uetypes.OutboundCreatedEvent, *ecdsa.PrivateKey) { + t.Helper() + + evmKey, ethAddrHex := loadTestEVMKey(t) + recipientWallet := solana.NewWallet() + + txIDBytes := make([]byte, 32) + utxIDBytes := make([]byte, 32) + _, err := crand.Read(txIDBytes) + require.NoError(t, err) + _, err = crand.Read(utxIDBytes) + require.NoError(t, err) + return &uetypes.OutboundCreatedEvent{ + TxID: "0x" + hex.EncodeToString(txIDBytes), + UniversalTxId: "0x" + hex.EncodeToString(utxIDBytes), + DestinationChain: "solana:" + devnetGenesisHash, + Sender: "0x" + ethAddrHex, + Recipient: recipientWallet.PublicKey().String(), + Amount: amount, + AssetAddr: assetAddr, + Payload: payload, + GasLimit: "400000", + TxType: txType, + RevertMsg: revertMsg, + }, evmKey +} + +// buildAndSimulate runs the full pipeline: GetOutboundSigningRequest → sign → BuildOutboundTransaction → SimulateTransaction. +// Uses simulation (no broadcast), so no on-chain state is modified (nonce stays the same, no SOL spent). +// Returns the simulation result and any build errors. +func buildAndSimulate(t *testing.T, rpcClient *RPCClient, builder *TxBuilder, data *uetypes.OutboundCreatedEvent, evmKey *ecdsa.PrivateKey) (*rpc.SimulateTransactionResult, error) { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // Step 1: Build signing request (fetches TSS PDA nonce from on-chain) + req, err := builder.GetOutboundSigningRequest(ctx, data, big.NewInt(1000), "0x0000000000000000000000000000000000000000") + if err != nil { + return nil, fmt.Errorf("GetOutboundSigningRequest: %w", err) + } + require.NotNil(t, req) + require.Len(t, req.SigningHash, 32) + t.Logf(" signing_hash=0x%s nonce=%d", hex.EncodeToString(req.SigningHash), req.Nonce) + + // Step 2: Sign with EVM key (secp256k1) + sig, _ := signMessageHash(t, evmKey, req.SigningHash) + + // Step 3: Build the Solana transaction (derives PDAs, builds instruction data, signs with relayer key) + tx, _, err := builder.BuildOutboundTransaction(ctx, req, data, sig) + if err != nil { + return nil, fmt.Errorf("BuildOutboundTransaction: %w", err) + } + + // Step 4: Simulate against devnet (no broadcast, no state changes) + result, err := rpcClient.SimulateTransaction(ctx, tx) + if err != nil { + return nil, fmt.Errorf("SimulateTransaction: %w", err) + } + + return result, nil +} + +// requireSimulationSuccess asserts that a simulation completed without errors and logs the result. +func requireSimulationSuccess(t *testing.T, result *rpc.SimulateTransactionResult) { + t.Helper() + require.Nil(t, result.Err, "simulation failed: %v\nlogs: %v", result.Err, result.Logs) + t.Logf("simulation passed (%d compute units)", *result.UnitsConsumed) + for _, log := range result.Logs { + t.Logf(" %s", log) + } +} + +// ---- Withdraw ---- + +func TestSimulate_Withdraw_NativeSOL(t *testing.T) { + rpcClient, builder := setupDevnetSimulation(t) + defer rpcClient.Close() + + // Amount must be >= rent-exempt minimum (~890,880 lamports) since the recipient + // is a fresh account. Solana rejects transfers that leave accounts below rent-exempt. + // Payload includes instruction_id=1 (withdraw) per integration guide. + withdrawPayload := "0x" + hex.EncodeToString(buildMockWithdrawPayload()) + data, evmKey := newDevnetOutbound(t, "1000000", "", withdrawPayload, "", "FUNDS") + + result, err := buildAndSimulate(t, rpcClient, builder, data, evmKey) + require.NoError(t, err) + requireSimulationSuccess(t, result) +} + +func TestSimulate_Withdraw_SPLToken(t *testing.T) { + rpcClient, builder := setupDevnetSimulation(t) + defer rpcClient.Close() + + withdrawPayload := "0x" + hex.EncodeToString(buildMockWithdrawPayload()) + data, evmKey := newDevnetOutbound(t, "1000000", devnetSPLMint, withdrawPayload, "", "FUNDS") + + result, err := buildAndSimulate(t, rpcClient, builder, data, evmKey) + require.NoError(t, err) + requireSimulationSuccess(t, result) +} + +// ---- Execute ---- + +func TestSimulate_Execute_NativeSOL(t *testing.T) { + rpcClient, builder := setupDevnetSimulation(t) + defer rpcClient.Close() + + // Use the SPL Memo program as the execute destination. + // It accepts any UTF-8 data with no required accounts, making it ideal for + // CPI testing without needing initialized state on devnet. + ixData := []byte("hello from push chain") + payload := buildMockExecutePayload(nil, ixData, 0) + payloadHex := "0x" + hex.EncodeToString(payload) + + // Recipient = Memo program (becomes destination_program in execute mode) + data, evmKey := newDevnetOutbound(t, "10000000", "", payloadHex, "", "FUNDS_AND_PAYLOAD") + data.Recipient = devnetMemoProgram + + result, err := buildAndSimulate(t, rpcClient, builder, data, evmKey) + require.NoError(t, err) + requireSimulationSuccess(t, result) +} + +func TestSimulate_Execute_SPLToken(t *testing.T) { + rpcClient, builder := setupDevnetSimulation(t) + defer rpcClient.Close() + + // Use the SPL Memo program as the execute destination (same as SOL test above). + ixData := []byte("spl execute memo") + payload := buildMockExecutePayload(nil, ixData, 0) + payloadHex := "0x" + hex.EncodeToString(payload) + + // Recipient = Memo program (becomes destination_program in execute mode) + data, evmKey := newDevnetOutbound(t, "500000", devnetSPLMint, payloadHex, "", "FUNDS_AND_PAYLOAD") + data.Recipient = devnetMemoProgram + + result, err := buildAndSimulate(t, rpcClient, builder, data, evmKey) + require.NoError(t, err) + requireSimulationSuccess(t, result) +} + +// ---- Revert ---- + +func TestSimulate_Revert_NativeSOL(t *testing.T) { + rpcClient, builder := setupDevnetSimulation(t) + defer rpcClient.Close() + + data, evmKey := newDevnetOutbound(t, "10000000", "", "0x", hex.EncodeToString([]byte("revert native")), "INBOUND_REVERT") + + result, err := buildAndSimulate(t, rpcClient, builder, data, evmKey) + require.NoError(t, err) + requireSimulationSuccess(t, result) +} + +func TestSimulate_Revert_SPLToken(t *testing.T) { + rpcClient, builder := setupDevnetSimulation(t) + defer rpcClient.Close() + + data, evmKey := newDevnetOutbound(t, "500000", devnetSPLMint, "0x", hex.EncodeToString([]byte("revert spl")), "INBOUND_REVERT") + + result, err := buildAndSimulate(t, rpcClient, builder, data, evmKey) + require.NoError(t, err) + requireSimulationSuccess(t, result) +}