From 831543f5f7e89d70926879bbe4f08dca9d6d3435 Mon Sep 17 00:00:00 2001 From: Aman Gupta Date: Wed, 20 May 2026 13:35:25 +0530 Subject: [PATCH 01/17] F-2026-16960 | [PUSHCHAIN-REPORTED] Issue 1: RPC failover retries same endpoint under concurrent load --- universalClient/chains/evm/rpc_client.go | 6 +- universalClient/chains/evm/rpc_client_test.go | 96 +++++++++++++++++++ universalClient/chains/svm/rpc_client.go | 6 +- universalClient/chains/svm/rpc_client_test.go | 88 +++++++++++++++++ 4 files changed, 192 insertions(+), 4 deletions(-) create mode 100644 universalClient/chains/evm/rpc_client_test.go diff --git a/universalClient/chains/evm/rpc_client.go b/universalClient/chains/evm/rpc_client.go index d2d45118..b8c83d04 100644 --- a/universalClient/chains/evm/rpc_client.go +++ b/universalClient/chains/evm/rpc_client.go @@ -93,6 +93,9 @@ func (rc *RPCClient) executeWithFailover(ctx context.Context, operation string, } maxAttempts := len(clients) + // Snapshot start index once per call so concurrent callers can't share + // counter advances and retry the same failing endpoint. + startIndex := atomic.AddUint64(&rc.index, 1) - 1 var lastErr error for attempt := 0; attempt < maxAttempts; attempt++ { if ctx != nil { @@ -103,8 +106,7 @@ func (rc *RPCClient) executeWithFailover(ctx context.Context, operation string, } } - index := atomic.AddUint64(&rc.index, 1) - 1 - client := clients[index%uint64(len(clients))] + client := clients[(startIndex+uint64(attempt))%uint64(len(clients))] if client == nil { continue diff --git a/universalClient/chains/evm/rpc_client_test.go b/universalClient/chains/evm/rpc_client_test.go new file mode 100644 index 00000000..0425a1a1 --- /dev/null +++ b/universalClient/chains/evm/rpc_client_test.go @@ -0,0 +1,96 @@ +package evm + +import ( + "context" + "errors" + "sync" + "testing" + + "github.com/ethereum/go-ethereum/ethclient" + "github.com/rs/zerolog" +) + +// TestExecuteWithFailover_ConcurrentRotation verifies F-2026-16960 is fixed: +// under concurrent load, every caller must visit every endpoint exactly once, +// even when all endpoints fail (forcing the loop to run to completion). +func TestExecuteWithFailover_ConcurrentRotation(t *testing.T) { + const numEndpoints = 3 + const numGoroutines = 200 + + clients := make([]*ethclient.Client, numEndpoints) + indexOf := make(map[*ethclient.Client]int, numEndpoints) + for i := range clients { + clients[i] = ðclient.Client{} + indexOf[clients[i]] = i + } + + rc := &RPCClient{clients: clients, logger: zerolog.Nop()} + + sequences := make([][]int, numGoroutines) + var wg sync.WaitGroup + wg.Add(numGoroutines) + for g := 0; g < numGoroutines; g++ { + go func(g int) { + defer wg.Done() + var visited []int + _ = rc.executeWithFailover(context.Background(), "test", func(c *ethclient.Client) error { + visited = append(visited, indexOf[c]) + return errors.New("force failover") + }) + sequences[g] = visited + }(g) + } + wg.Wait() + + for g, seq := range sequences { + if len(seq) != numEndpoints { + t.Errorf("goroutine %d visited %d endpoints, want %d (seq=%v)", g, len(seq), numEndpoints, seq) + continue + } + seen := make(map[int]bool, numEndpoints) + for _, idx := range seq { + if seen[idx] { + t.Errorf("goroutine %d hit endpoint %d twice (seq=%v)", g, idx, seq) + } + seen[idx] = true + } + } +} + +// TestExecuteWithFailover_SequentialRotation verifies that consecutive calls +// alternate their starting endpoint, distributing first-attempt traffic. +func TestExecuteWithFailover_SequentialRotation(t *testing.T) { + const numEndpoints = 3 + const numCalls = 12 + + clients := make([]*ethclient.Client, numEndpoints) + indexOf := make(map[*ethclient.Client]int, numEndpoints) + for i := range clients { + clients[i] = ðclient.Client{} + indexOf[clients[i]] = i + } + + rc := &RPCClient{clients: clients, logger: zerolog.Nop()} + + firstAttempts := make([]int, 0, numCalls) + for i := 0; i < numCalls; i++ { + _ = rc.executeWithFailover(context.Background(), "test", func(c *ethclient.Client) error { + if len(firstAttempts) < i+1 { + firstAttempts = append(firstAttempts, indexOf[c]) + } + return errors.New("force failover") + }) + } + + // Each endpoint should be the first attempt for exactly numCalls/numEndpoints calls. + counts := make(map[int]int, numEndpoints) + for _, idx := range firstAttempts { + counts[idx]++ + } + expected := numCalls / numEndpoints + for i := 0; i < numEndpoints; i++ { + if counts[i] != expected { + t.Errorf("endpoint %d was first-attempt %d times, want %d (firstAttempts=%v)", i, counts[i], expected, firstAttempts) + } + } +} diff --git a/universalClient/chains/svm/rpc_client.go b/universalClient/chains/svm/rpc_client.go index 7ce351ea..d29e1646 100644 --- a/universalClient/chains/svm/rpc_client.go +++ b/universalClient/chains/svm/rpc_client.go @@ -109,6 +109,9 @@ func (rc *RPCClient) executeWithFailover(ctx context.Context, operation string, } maxAttempts := len(clients) + // Snapshot start index once per call so concurrent callers can't share + // counter advances and retry the same failing endpoint. + startIndex := atomic.AddUint64(&rc.index, 1) - 1 for attempt := 0; attempt < maxAttempts; attempt++ { if ctx != nil { select { @@ -118,8 +121,7 @@ func (rc *RPCClient) executeWithFailover(ctx context.Context, operation string, } } - index := atomic.AddUint64(&rc.index, 1) - 1 - client := clients[index%uint64(len(clients))] + client := clients[(startIndex+uint64(attempt))%uint64(len(clients))] if client == nil { continue diff --git a/universalClient/chains/svm/rpc_client_test.go b/universalClient/chains/svm/rpc_client_test.go index 5bd4a708..e4ea5248 100644 --- a/universalClient/chains/svm/rpc_client_test.go +++ b/universalClient/chains/svm/rpc_client_test.go @@ -1,7 +1,10 @@ package svm import ( + "context" + "errors" "math" + "sync" "testing" "github.com/gagliardetto/solana-go/rpc" @@ -129,3 +132,88 @@ func TestClose_EmptyClients(t *testing.T) { t.Error("expected clients to be nil after Close") } } + +// TestExecuteWithFailover_ConcurrentRotation verifies F-2026-16960 is fixed: +// under concurrent load, every caller must visit every endpoint exactly once, +// even when all endpoints fail (forcing the loop to run to completion). +func TestExecuteWithFailover_ConcurrentRotation(t *testing.T) { + const numEndpoints = 3 + const numGoroutines = 200 + + clients := make([]*rpc.Client, numEndpoints) + indexOf := make(map[*rpc.Client]int, numEndpoints) + for i := range clients { + clients[i] = &rpc.Client{} + indexOf[clients[i]] = i + } + + rc := &RPCClient{clients: clients, logger: zerolog.Nop()} + + sequences := make([][]int, numGoroutines) + var wg sync.WaitGroup + wg.Add(numGoroutines) + for g := 0; g < numGoroutines; g++ { + go func(g int) { + defer wg.Done() + var visited []int + _ = rc.executeWithFailover(context.Background(), "test", func(c *rpc.Client) error { + visited = append(visited, indexOf[c]) + return errors.New("force failover") + }) + sequences[g] = visited + }(g) + } + wg.Wait() + + for g, seq := range sequences { + if len(seq) != numEndpoints { + t.Errorf("goroutine %d visited %d endpoints, want %d (seq=%v)", g, len(seq), numEndpoints, seq) + continue + } + seen := make(map[int]bool, numEndpoints) + for _, idx := range seq { + if seen[idx] { + t.Errorf("goroutine %d hit endpoint %d twice (seq=%v)", g, idx, seq) + } + seen[idx] = true + } + } +} + +// TestExecuteWithFailover_SequentialRotation verifies that consecutive calls +// alternate their starting endpoint, distributing first-attempt traffic. +func TestExecuteWithFailover_SequentialRotation(t *testing.T) { + const numEndpoints = 3 + const numCalls = 12 + + clients := make([]*rpc.Client, numEndpoints) + indexOf := make(map[*rpc.Client]int, numEndpoints) + for i := range clients { + clients[i] = &rpc.Client{} + indexOf[clients[i]] = i + } + + rc := &RPCClient{clients: clients, logger: zerolog.Nop()} + + firstAttempts := make([]int, 0, numCalls) + for i := 0; i < numCalls; i++ { + _ = rc.executeWithFailover(context.Background(), "test", func(c *rpc.Client) error { + if len(firstAttempts) < i+1 { + firstAttempts = append(firstAttempts, indexOf[c]) + } + return errors.New("force failover") + }) + } + + // Each endpoint should be the first attempt for exactly numCalls/numEndpoints calls. + counts := make(map[int]int, numEndpoints) + for _, idx := range firstAttempts { + counts[idx]++ + } + expected := numCalls / numEndpoints + for i := 0; i < numEndpoints; i++ { + if counts[i] != expected { + t.Errorf("endpoint %d was first-attempt %d times, want %d (firstAttempts=%v)", i, counts[i], expected, firstAttempts) + } + } +} From 3dfaba18fac7a5159204b92211b98adce86e947f Mon Sep 17 00:00:00 2001 From: aman035 Date: Thu, 21 May 2026 12:34:50 +0530 Subject: [PATCH 02/17] add: WaitForSignatureConfirmation in solana rpc with backoff --- universalClient/chains/svm/rpc_client.go | 70 ++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/universalClient/chains/svm/rpc_client.go b/universalClient/chains/svm/rpc_client.go index d29e1646..64f2cf5f 100644 --- a/universalClient/chains/svm/rpc_client.go +++ b/universalClient/chains/svm/rpc_client.go @@ -290,6 +290,76 @@ func (rc *RPCClient) GetTransaction(ctx context.Context, signature solana.Signat return tx, err } +// WaitForSignatureConfirmation polls the cluster until the given signature reaches +// the requested commitment level, the on-chain status is an error, or the timeout +// elapses. Returns nil on success. +// +// Used by the ref-finalize flow to gate the second tx (finalize-by-reference) on +// the first tx (store_execute_ix_data) landing on-chain. +func (rc *RPCClient) WaitForSignatureConfirmation( + ctx context.Context, + sig solana.Signature, + commitment rpc.ConfirmationStatusType, + timeout time.Duration, +) error { + deadline := time.Now().Add(timeout) + backoff := 200 * time.Millisecond + const maxBackoff = 1500 * time.Millisecond + + for { + var result *rpc.GetSignatureStatusesResult + execErr := rc.executeWithFailover(ctx, "get_signature_statuses", func(client *rpc.Client) error { + var innerErr error + result, innerErr = client.GetSignatureStatuses(ctx, false, sig) + return innerErr + }) + + if execErr == nil && result != nil && len(result.Value) > 0 && result.Value[0] != nil { + status := result.Value[0] + if status.Err != nil { + return fmt.Errorf("transaction %s failed on-chain: %v", sig.String(), status.Err) + } + if confirmationReached(status.ConfirmationStatus, commitment) { + return nil + } + } + + if time.Now().After(deadline) { + return fmt.Errorf("timeout waiting for signature %s to reach %s commitment", sig.String(), commitment) + } + + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(backoff): + } + if backoff < maxBackoff { + backoff *= 2 + if backoff > maxBackoff { + backoff = maxBackoff + } + } + } +} + +// confirmationReached reports whether `got` meets the bar set by `want`. +// Commitment ordering: processed < confirmed < finalized. +func confirmationReached(got, want rpc.ConfirmationStatusType) bool { + rank := func(c rpc.ConfirmationStatusType) int { + switch c { + case rpc.ConfirmationStatusFinalized: + return 3 + case rpc.ConfirmationStatusConfirmed: + return 2 + case rpc.ConfirmationStatusProcessed: + return 1 + default: + return 0 + } + } + return rank(got) >= rank(want) +} + // BroadcastTransaction broadcasts a signed transaction and returns the transaction signature (hash) func (rc *RPCClient) BroadcastTransaction(ctx context.Context, tx *solana.Transaction) (string, error) { if len(tx.Signatures) == 0 { From e70d71af5dee5ec2c1b17d245552a25a121b4357 Mon Sep 17 00:00:00 2001 From: aman035 Date: Thu, 21 May 2026 12:49:21 +0530 Subject: [PATCH 03/17] add: refinalize + ata closing in solana txbuilder --- universalClient/chains/common/types.go | 8 + universalClient/chains/evm/tx_builder.go | 6 + universalClient/chains/svm/tx_builder.go | 977 +++++++++++++++++++---- 3 files changed, 818 insertions(+), 173 deletions(-) diff --git a/universalClient/chains/common/types.go b/universalClient/chains/common/types.go index 24b9591e..20af8010 100644 --- a/universalClient/chains/common/types.go +++ b/universalClient/chains/common/types.go @@ -77,6 +77,14 @@ type TxBuilder interface { // BroadcastFundMigrationTx assembles and broadcasts a signed fund migration transaction. BroadcastFundMigrationTx(ctx context.Context, req *UnsignedSigningReq, data *FundMigrationData, signature []byte) (string, error) + + // CleanupOutboundArtifacts performs best-effort, idempotent cleanup of any + // chain-specific artifacts left behind by a terminally-failed outbound. + // - SVM: closes the StoredIxData PDA from the ref-finalize route if present, + // recovering ~0.002 SOL of rent to the relayer. + // - EVM: no-op (no off-chain artifacts). + // Safe to invoke for any terminal-failure outbound regardless of route. + CleanupOutboundArtifacts(ctx context.Context, data *uetypes.OutboundCreatedEvent) error } // UniversalTx Payload diff --git a/universalClient/chains/evm/tx_builder.go b/universalClient/chains/evm/tx_builder.go index 1e614c01..d54294fd 100644 --- a/universalClient/chains/evm/tx_builder.go +++ b/universalClient/chains/evm/tx_builder.go @@ -445,6 +445,12 @@ func (tb *TxBuilder) IsAlreadyExecuted(ctx context.Context, txID string) (bool, return false, nil } +// CleanupOutboundArtifacts is a no-op on EVM — there are no off-chain artifacts +// (no PDAs, no rent) tied to an outbound's lifetime. +func (tb *TxBuilder) CleanupOutboundArtifacts(ctx context.Context, data *uetypes.OutboundCreatedEvent) error { + return nil +} + // GetGasFeeUsed returns the gas fee used by a transaction on the EVM chain. // Fetches the receipt for gasUsed and the transaction for gasPrice, then returns // gasUsed * gasPrice as a decimal string. Returns "0" if not found. diff --git a/universalClient/chains/svm/tx_builder.go b/universalClient/chains/svm/tx_builder.go index bfde31f9..bd6f3b82 100644 --- a/universalClient/chains/svm/tx_builder.go +++ b/universalClient/chains/svm/tx_builder.go @@ -1,63 +1,31 @@ -// Package svm implements the Solana (SVM) transaction builder for Push Chain's -// cross-chain outbound transaction system. +// Package svm implements the Solana transaction builder for Push Chain +// cross-chain outbounds. // -// # How Cross-Chain Outbound Works (High-Level) +// # Two-signature model // -// When a user on Push Chain wants to send funds/execute something on Solana: +// Every gateway tx carries two signatures: +// - TSS (secp256k1/ECDSA): authorizes the cross-chain op. Signs the keccak256 +// of the canonical message; gateway recovers via secp256k1_recover and +// checks against the TSS PDA's stored ETH address. +// - Relayer (Ed25519): standard Solana tx signature. Relayer pays the SOL fee. // -// 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 +// # Gateway entry points // -// # Two-Signature Architecture +// - finalize_universal_tx — id=1 withdraw, id=2 execute (CPI). +// - finalize_universal_tx_with_ix_data_ref — same flow but ix_data is loaded +// from a stored PDA (large-payload path). See the Ref-Finalize Route section. +// - revert_universal_tx — id=3, refund-on-failure for SOL and SPL. +// - rescue_funds — id=4, emergency drain of locked vault funds. // -// 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: -// -// - finalize_universal_tx (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 and chain ID. Replay protection uses per-tx ExecutedTx PDAs. -// -// - 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. +// Gateway-internals shorthand used throughout this file: +// - PDA: deterministic address from seeds + program ID (Solana CREATE2 analog). +// - Anchor discriminator: sha256("global:")[:8] — 8-byte function selector. +// - Borsh: Solana's binary encoding — LE integers, Vec = 4-byte LE len + bytes. +// - CEA: per-sender identity PDA used as the CPI signer for execute mode. package svm import ( + "bytes" "context" "crypto/sha256" "encoding/binary" @@ -69,6 +37,7 @@ import ( "path/filepath" "strconv" "strings" + "time" "github.com/ethereum/go-ethereum/crypto" "github.com/gagliardetto/solana-go" @@ -81,31 +50,62 @@ import ( uetypes "github.com/pushchain/push-chain-node/x/uexecutor/types" ) -// 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). +// ============================================================================= +// Gateway Program Constants +// ============================================================================= + +// Gateway-protocol values — must match the on-chain Rust program. Changing any +// of these requires a coordinated gateway program upgrade. +var ( + // PDA seed prefixes. + configSeed = []byte("config") + vaultSeed = []byte("vault") + feeVaultSeed = []byte("fee_vault") + tssSeed = []byte("final_tss_pda") + executedSubTxSeed = []byte("executed_sub_tx") + ceaAuthoritySeed = []byte("push_identity") + rateLimitConfigSeed = []byte("rate_limit_config") + tokenRateLimitSeed = []byte("rate_limit") + storedIxDataSeed = []byte("stored_ix_data") + + // TSS message envelope — cross-protocol replay guard. + tssMessagePrefix = []byte("PUSH_CHAIN_SVM") + + // Anchor discriminators for ref-finalize. Copied verbatim from the IDL — + // anchorDiscriminator() typos would only fail at runtime as a decode error. + discStoreExecuteIxData = [8]byte{177, 199, 114, 191, 66, 93, 93, 110} + discFinalizeUniversalTxRef = [8]byte{143, 158, 113, 225, 174, 35, 57, 141} + discCloseStoredIxData = [8]byte{58, 81, 153, 208, 99, 218, 247, 14} +) + +// Local policy — universalClient-side routing thresholds and compute budget. +// Safe to tune in a universalClient release without coordinating with the gateway. +const ( + solanaTxMaxBytes = 1232 // Solana hard tx-size limit (legacy and v0) + maxDirectTxSize = 1180 // fall back to ref-route above this; margin absorbs blockhash-encoding variance + maxRefRouteIxData = 921 // ix_data ceiling — store tx itself must fit under solanaTxMaxBytes + defaultComputeUnitLimit = uint32(400_000) // CU budget per gateway tx; covers all flows including CEA execute +) + +// ============================================================================= +// Types +// ============================================================================= + +// GatewayAccountMeta describes one CPI account the target program needs for +// execute-mode (instruction_id=2) outbounds. Mirrors the Rust struct in 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 + Pubkey [32]byte // raw 32-byte pubkey, not base58 + IsWritable bool } -// TxBuilder constructs and broadcasts Solana transactions for cross-chain operations. -// It implements the common.TxBuilder 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 gatewayAddress solana.PublicKey nodeHome string logger zerolog.Logger - protocolALT solana.PublicKey // Protocol ALT pubkey (zero if not configured) - tokenALTs map[solana.PublicKey]solana.PublicKey // mint pubkey → token ALT pubkey + protocolALT solana.PublicKey // zero if not configured + tokenALTs map[solana.PublicKey]solana.PublicKey // mint → token ALT } // NewTxBuilder creates a new Solana transaction builder. @@ -326,7 +326,7 @@ func (tb *TxBuilder) GetOutboundSigningRequest( if txType == uetypes.TxType_INBOUND_REVERT || txType == uetypes.TxType_RESCUE_FUNDS { // Revert (id=3) and rescue (id=4): instruction_id determined by TxType, no payload decode - instructionID, err = tb.determineInstructionID(txType, isNative) + instructionID, err = tb.determineInstructionID(txType) if err != nil { return nil, fmt.Errorf("failed to determine instruction ID: %w", err) } @@ -369,7 +369,7 @@ func (tb *TxBuilder) GetOutboundSigningRequest( // If payload was empty/missing, fall back to TxType-derived instruction_id if instructionID == 0 { - fallbackID, fbErr := tb.determineInstructionID(txType, isNative) + fallbackID, fbErr := tb.determineInstructionID(txType) if fbErr != nil { return nil, fmt.Errorf("failed to determine instruction ID: %w", fbErr) } @@ -413,6 +413,14 @@ func (tb *TxBuilder) GetOutboundSigningRequest( }, nil } +// ============================================================================= +// Transaction Status & Lifecycle Queries +// +// Helpers that report the on-chain progress of an outbound. Used by the +// coordinator (nonce seeding), the broadcaster (replay-check), the resolver +// (terminal-state detection), and the event listener (status confirmation). +// ============================================================================= + // GetNextNonce returns 0 for SVM. The contract no longer uses a global nonce; // replay protection is handled by per-tx ExecutedTx PDAs. func (tb *TxBuilder) GetNextNonce(ctx context.Context, signerAddress string, useFinalized bool) (uint64, error) { @@ -433,7 +441,7 @@ func (tb *TxBuilder) IsAlreadyExecuted(ctx context.Context, txID string) (bool, var txIDArr [32]byte copy(txIDArr[:], txIDBytes) - executedTxPDA, _, err := solana.FindProgramAddress([][]byte{[]byte("executed_sub_tx"), txIDArr[:]}, tb.gatewayAddress) + executedTxPDA, _, err := solana.FindProgramAddress([][]byte{executedSubTxSeed, txIDArr[:]}, tb.gatewayAddress) if err != nil { return false, fmt.Errorf("failed to derive executed_tx PDA: %w", err) } @@ -455,6 +463,44 @@ func (tb *TxBuilder) GetGasFeeUsed(ctx context.Context, txHash string) (string, return "0", nil } +// VerifyBroadcastedTx checks the status of a broadcasted transaction on Solana. +// Returns (found, blockHeight, confirmations, status, error): +// - found=false: tx not found or not yet confirmed +// - found=true: tx exists on-chain +// - confirmations: number of slots since the tx was included (0 = just confirmed) +// - status: 0 = failed, 1 = success +func (tb *TxBuilder) VerifyBroadcastedTx(ctx context.Context, txHash string) (found bool, blockHeight uint64, confirmations uint64, status uint8, err error) { + sig, sigErr := solana.SignatureFromBase58(txHash) + if sigErr != nil { + return false, 0, 0, 0, nil + } + + tx, txErr := tb.rpcClient.GetTransaction(ctx, sig) + if txErr != nil { + return false, 0, 0, 0, nil + } + + if tx == nil { + return false, 0, 0, 0, nil + } + + // Calculate confirmations from current slot + var confs uint64 + if tx.Slot > 0 { + latestSlot, slotErr := tb.rpcClient.GetLatestSlot(ctx) + if slotErr == nil && latestSlot >= tx.Slot { + confs = latestSlot - tx.Slot + 1 + } + } + + // Check if transaction had an error + if tx.Meta != nil && tx.Meta.Err != nil { + return true, tx.Slot, confs, 0, nil + } + + return true, tx.Slot, confs, 1, nil +} + // ============================================================================= // STEP 2: BroadcastOutboundSigningRequest // @@ -474,7 +520,13 @@ func (tb *TxBuilder) GetGasFeeUsed(ctx context.Context, txHash string) (string, // ============================================================================= // BroadcastOutboundSigningRequest assembles a complete Solana transaction with the -// TSS signature and broadcasts it to the Solana network. +// TSS signature and broadcasts it to the Solana network. For execute-mode +// outbounds (instruction_id=2) whose direct tx exceeds maxDirectTxSize, falls +// back to the 2-tx ref-finalize route automatically. +// +// Returned tx hash is always the FINALIZE tx (direct or ref-finalize). The +// resolver / event listener only need to track this — the store tx is an +// implementation detail invisible to downstream consumers. func (tb *TxBuilder) BroadcastOutboundSigningRequest( ctx context.Context, req *common.UnsignedSigningReq, @@ -486,6 +538,21 @@ func (tb *TxBuilder) BroadcastOutboundSigningRequest( return "", err } + // Ref route is only viable for execute (id=2): + // - id=1 (withdraw) carries empty ix_data → can't overflow, and the + // store instruction would reject it with EmptyIxData anyway. + // - id=3/4 (revert/rescue) go through separate gateway entrypoints + // (revert_universal_tx / rescue_funds) with no ref-route counterpart. + if instructionID == 2 { + if txBytes, mErr := tx.MarshalBinary(); mErr == nil && len(txBytes) > maxDirectTxSize { + tb.logger.Info(). + Int("direct_tx_bytes", len(txBytes)). + Int("threshold", maxDirectTxSize). + Msg("direct finalize exceeds tx size threshold, switching to ref-finalize route") + return tb.broadcastRefRoute(ctx, req, data, signature) + } + } + txHash, err := tb.rpcClient.BroadcastTransaction(ctx, tx) if err != nil { return "", fmt.Errorf("failed to broadcast transaction: %w", err) @@ -499,6 +566,182 @@ func (tb *TxBuilder) BroadcastOutboundSigningRequest( return txHash, nil } +// broadcastRefRoute executes the 2-tx ref-finalize flow: +// 1. If the stored_ix_data PDA doesn't already exist on-chain, broadcast the +// store_execute_ix_data tx and wait for it to confirm. Existing PDA = retry +// after a partial earlier attempt; we skip directly to step 2. +// 2. Broadcast finalize_universal_tx_with_ix_data_ref using the same TSS +// signature payload as the direct route. +// +// Returns the ref-finalize tx hash. The store tx hash is logged but not +// propagated — only finalize lands the ExecutedSubTx PDA that the resolver +// keys on for success/failure. +func (tb *TxBuilder) broadcastRefRoute( + ctx context.Context, + req *common.UnsignedSigningReq, + data *uetypes.OutboundCreatedEvent, + signature []byte, +) (string, error) { + storeTx, refTx, storedPDA, err := tb.BuildRefRouteTransactions(ctx, req, data, signature) + if err != nil { + return "", fmt.Errorf("failed to build ref-route transactions: %w", err) + } + + // Skip the store broadcast if the canonical PDA already exists (retry after + // a previous partial attempt, or another relayer beat us to it — either way + // we can proceed directly to ref-finalize). + existingData, _ := tb.rpcClient.GetAccountData(ctx, storedPDA) + if len(existingData) == 0 { + storeHash, err := tb.rpcClient.BroadcastTransaction(ctx, storeTx) + if err != nil { + return "", fmt.Errorf("failed to broadcast store_execute_ix_data: %w", err) + } + tb.logger.Info(). + Str("store_tx_hash", storeHash). + Str("stored_pda", storedPDA.String()). + Msg("store_execute_ix_data broadcast, awaiting confirmation") + + sig, sigErr := solana.SignatureFromBase58(storeHash) + if sigErr != nil { + return "", fmt.Errorf("invalid store tx signature %s: %w", storeHash, sigErr) + } + if err := tb.rpcClient.WaitForSignatureConfirmation(ctx, sig, rpc.ConfirmationStatusConfirmed, 30*time.Second); err != nil { + return "", fmt.Errorf("store_execute_ix_data did not confirm: %w", err) + } + } else { + tb.logger.Info(). + Str("stored_pda", storedPDA.String()). + Msg("stored_ix_data PDA already present, skipping store tx") + } + + refHash, err := tb.rpcClient.BroadcastTransaction(ctx, refTx) + if err != nil { + return "", fmt.Errorf("failed to broadcast finalize_universal_tx_with_ix_data_ref: %w", err) + } + tb.logger.Info(). + Str("tx_hash", refHash). + Str("stored_pda", storedPDA.String()). + Uint8("instruction_id", 2). + Msg("ref-finalize broadcast successfully") + return refHash, nil +} + +// CleanupOutboundArtifacts best-effort closes the StoredIxData PDA for a +// terminally-failed ref-route outbound to recover the ~0.002 SOL of rent the +// relayer paid during store_execute_ix_data. Safe to call for non-ref-route +// outbounds: the PDA-presence probe returns nothing and we no-op. Safe to call +// after happy-path finalize: the program auto-closed the PDA, so the probe also +// returns nothing. +// +// Caller responsibility: only invoke after the outbound has reached a terminal +// failure state. Calling during a retry window would force a re-upload of the +// payload on the next attempt. +func (tb *TxBuilder) CleanupOutboundArtifacts(ctx context.Context, data *uetypes.OutboundCreatedEvent) error { + if data == nil { + return nil + } + + payloadHex := removeHexPrefix(data.Payload) + if payloadHex == "" { + return nil + } + payloadBytes, err := hex.DecodeString(payloadHex) + if err != nil || len(payloadBytes) == 0 { + return nil + } + _, ixData, instructionID, _, err := decodePayload(payloadBytes) + if err != nil || instructionID != 2 || len(ixData) == 0 { + return nil + } + + var subTxID [32]byte + txIDBytes, err := hex.DecodeString(removeHexPrefix(data.TxID)) + if err != nil { + return nil + } + if len(txIDBytes) == 32 { + copy(subTxID[:], txIDBytes) + } else if len(txIDBytes) > 0 { + copy(subTxID[32-len(txIDBytes):], txIDBytes) + } else { + return nil + } + + var ixDataHash [32]byte + copy(ixDataHash[:], crypto.Keccak256(ixData)) + + storedPDA, err := tb.deriveStoredIxDataPDA(subTxID, ixDataHash) + if err != nil { + return fmt.Errorf("failed to derive stored_ix_data PDA: %w", err) + } + + existing, _ := tb.rpcClient.GetAccountData(ctx, storedPDA) + if len(existing) == 0 { + return nil + } + + relayerKeypair, err := tb.loadRelayerKeypair() + if err != nil { + return fmt.Errorf("failed to load relayer keypair for cleanup: %w", err) + } + + // Only the original uploader can close on the failure path (contract enforces + // store_refund_recipient match + caller==store_refund_recipient when + // ExecutedSubTx is absent). Parse store_refund_recipient from the PDA data + // and bail out silently if we weren't the uploader — avoids wasting a tx + // the contract will reject with InvalidAccount. + // + // StoredIxData layout: 8 disc | 1 bump | 32 store_refund_recipient | 4 vec_len | N ix_data + const refundRecipientOffset = 8 + 1 + if len(existing) < refundRecipientOffset+32 { + return nil + } + if !bytes.Equal(existing[refundRecipientOffset:refundRecipientOffset+32], relayerKeypair.PublicKey().Bytes()) { + tb.logger.Debug(). + Str("stored_pda", storedPDA.String()). + Str("sub_tx_id", data.TxID). + Msg("stored_ix_data PDA owned by another relayer; skipping cleanup") + return nil + } + + ixData2 := tb.buildCloseStoredIxDataData(subTxID, ixDataHash) + accounts := tb.buildCloseStoredIxDataAccounts(relayerKeypair.PublicKey(), storedPDA) + closeIx := solana.NewInstruction(tb.gatewayAddress, accounts, ixData2) + + recentBlockhash, err := tb.rpcClient.GetRecentBlockhash(ctx) + if err != nil { + return fmt.Errorf("failed to get blockhash for cleanup: %w", err) + } + tx, err := solana.NewTransaction( + []solana.Instruction{closeIx}, + recentBlockhash, + solana.TransactionPayer(relayerKeypair.PublicKey()), + ) + if err != nil { + return fmt.Errorf("failed to build close_stored_ix_data tx: %w", err) + } + if _, err := tx.Sign(func(key solana.PublicKey) *solana.PrivateKey { + if key.Equals(relayerKeypair.PublicKey()) { + priv := relayerKeypair + return &priv + } + return nil + }); err != nil { + return fmt.Errorf("failed to sign close_stored_ix_data tx: %w", err) + } + + hash, err := tb.rpcClient.BroadcastTransaction(ctx, tx) + if err != nil { + return fmt.Errorf("failed to broadcast close_stored_ix_data: %w", err) + } + tb.logger.Info(). + Str("close_tx_hash", hash). + Str("stored_pda", storedPDA.String()). + Str("sub_tx_id", data.TxID). + Msg("close_stored_ix_data broadcast for failure-tail cleanup") + return nil +} + // fetchAddressTables fetches Address Lookup Table state for V0 transactions. // Always includes the protocol ALT (if configured). For SPL tokens, also includes // the token-specific ALT for the given mint (if configured). @@ -667,7 +910,7 @@ func (tb *TxBuilder) BuildOutboundTransaction( if txType == uetypes.TxType_INBOUND_REVERT || txType == uetypes.TxType_RESCUE_FUNDS { // Revert (id=3) and rescue (id=4): instruction_id determined by TxType, no payload decode var idErr error - instructionID, idErr = tb.determineInstructionID(txType, isNative) + instructionID, idErr = tb.determineInstructionID(txType) if idErr != nil { return nil, 0, fmt.Errorf("failed to determine instruction ID: %w", idErr) } @@ -689,7 +932,7 @@ func (tb *TxBuilder) BuildOutboundTransaction( // Fall back to TxType if payload was empty if instructionID == 0 { - fallbackID, fbErr := tb.determineInstructionID(txType, isNative) + fallbackID, fbErr := tb.determineInstructionID(txType) if fbErr != nil { return nil, 0, fmt.Errorf("failed to determine instruction ID: %w", fbErr) } @@ -698,28 +941,28 @@ func (tb *TxBuilder) BuildOutboundTransaction( } // --- Derive PDAs --- - configPDA, _, err := solana.FindProgramAddress([][]byte{[]byte("config")}, tb.gatewayAddress) + configPDA, _, err := solana.FindProgramAddress([][]byte{configSeed}, 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) + vaultPDA, _, err := solana.FindProgramAddress([][]byte{vaultSeed}, tb.gatewayAddress) if err != nil { return nil, 0, fmt.Errorf("failed to derive vault PDA: %w", err) } - tssPDA, _, err := solana.FindProgramAddress([][]byte{[]byte("final_tss_pda")}, tb.gatewayAddress) + tssPDA, _, err := solana.FindProgramAddress([][]byte{tssSeed}, tb.gatewayAddress) if err != nil { return nil, 0, fmt.Errorf("failed to derive TSS PDA: %w", err) } - executedTxPDA, _, err := solana.FindProgramAddress([][]byte{[]byte("executed_sub_tx"), txID[:]}, tb.gatewayAddress) + executedTxPDA, _, err := solana.FindProgramAddress([][]byte{executedSubTxSeed, txID[:]}, tb.gatewayAddress) if err != nil { return nil, 0, fmt.Errorf("failed to derive executed_tx PDA: %w", err) } // --- Derive fee_vault PDA (needed for revert and rescue) --- - feeVaultPDA, _, err := solana.FindProgramAddress([][]byte{[]byte("fee_vault")}, tb.gatewayAddress) + feeVaultPDA, _, err := solana.FindProgramAddress([][]byte{feeVaultSeed}, tb.gatewayAddress) if err != nil { return nil, 0, fmt.Errorf("failed to derive fee_vault PDA: %w", err) } @@ -745,7 +988,7 @@ func (tb *TxBuilder) BuildOutboundTransaction( targetProgram = solana.SystemProgramID } - ceaAuthorityPDA, _, ceaErr := solana.FindProgramAddress([][]byte{[]byte("push_identity"), sender[:]}, tb.gatewayAddress) + ceaAuthorityPDA, _, ceaErr := solana.FindProgramAddress([][]byte{ceaAuthoritySeed, sender[:]}, tb.gatewayAddress) if ceaErr != nil { return nil, 0, fmt.Errorf("failed to derive cea_authority PDA: %w", ceaErr) } @@ -763,6 +1006,7 @@ func (tb *TxBuilder) BuildOutboundTransaction( isNative, instructionID, recipientPubkey, mintPubkey, execAccounts, + solana.PublicKey{}, solana.PublicKey{}, // direct route: None sentinels for stored_ix_data + store_refund_recipient ) case instructionID == 3: @@ -803,11 +1047,9 @@ func (tb *TxBuilder) BuildOutboundTransaction( instructionData, ) - // Hardcoded compute budget for Solana transactions. The event's gasLimit is a fee - // parameter (used by core for gasFee = gasPrice × gasLimit), not actual compute units. - // 400,000 CU is sufficient for all gateway operations including CEA execute flows. - const svmComputeUnitLimit = uint32(400_000) - computeLimitIx := tb.buildSetComputeUnitLimitInstruction(svmComputeUnitLimit) + // Event's gasLimit is a fee parameter (gasFee = gasPrice × gasLimit), not + // actual compute units; we always allocate defaultComputeUnitLimit instead. + computeLimitIx := tb.buildSetComputeUnitLimitInstruction(defaultComputeUnitLimit) // Build the instruction list. instructions := []solana.Instruction{computeLimitIx} @@ -856,20 +1098,288 @@ func (tb *TxBuilder) BuildOutboundTransaction( return nil, 0, fmt.Errorf("failed to sign transaction: %w", err) } - // Warn if transaction exceeds Solana's 1232-byte raw limit. + // Warn if transaction exceeds Solana's raw tx limit. if txBytes, marshalErr := tx.MarshalBinary(); marshalErr == nil { - if len(txBytes) > 1232 { + if len(txBytes) > solanaTxMaxBytes { tb.logger.Warn(). Int("raw_bytes", len(txBytes)). + Int("limit", solanaTxMaxBytes). Int("ix_data_bytes", len(ixData)). Uint8("instruction_id", instructionID). - Msg("transaction exceeds 1232-byte Solana limit") + Msg("transaction exceeds Solana raw tx limit") } } return tx, instructionID, nil } +// ============================================================================= +// STEP 2b: BuildRefRouteTransactions +// +// For execute-mode outbounds whose direct finalize_universal_tx exceeds +// Solana's 1232-byte limit, the universal validator splits the work into +// two transactions: +// +// 1. store_execute_ix_data — relayer-signed only (no TSS involvement); +// uploads raw ix_data into a content-addressed PDA. +// 2. finalize_universal_tx_with_ix_data_ref — uses the SAME TSS signature +// as the direct route; gateway reconstructs the message from stored bytes. +// +// NOTE: parsing duplicates BuildOutboundTransaction. Future refactor should +// hoist the parse into a shared helper. For now the duplication is bounded +// to execute mode (id=2); revert/rescue (3/4) never use this path. +// ============================================================================= + +// BuildRefRouteTransactions builds the (storeTx, refFinalizeTx) pair for a +// large-payload execute outbound. Only valid for instructionID=2 with non-empty +// ix_data; callers should size-check the direct tx first and only invoke this +// when the direct route doesn't fit. +// +// Returns the storedIxData PDA alongside the txs so the broadcaster can probe +// for pre-existing PDAs (retry idempotency) before re-broadcasting the store tx. +func (tb *TxBuilder) BuildRefRouteTransactions( + ctx context.Context, + req *common.UnsignedSigningReq, + data *uetypes.OutboundCreatedEvent, + signature []byte, +) (*solana.Transaction, *solana.Transaction, solana.PublicKey, error) { + if req == nil { + return nil, nil, solana.PublicKey{}, fmt.Errorf("signing request is nil") + } + if data == nil { + return nil, nil, solana.PublicKey{}, fmt.Errorf("outbound event data is nil") + } + if len(signature) != 65 { + return nil, nil, solana.PublicKey{}, fmt.Errorf("signature must be 65 bytes, got %d", len(signature)) + } + + recoveryID := signature[64] + signature = signature[:64] + + relayerKeypair, err := tb.loadRelayerKeypair() + if err != nil { + return nil, nil, solana.PublicKey{}, fmt.Errorf("failed to load relayer keypair: %w", err) + } + + // --- Parse event (mirrors BuildOutboundTransaction; execute path only) --- + + amount := new(big.Int) + amount, ok := amount.SetString(data.Amount, 10) + if !ok { + return nil, nil, solana.PublicKey{}, fmt.Errorf("invalid amount: %s", data.Amount) + } + if !amount.IsUint64() { + return nil, nil, solana.PublicKey{}, fmt.Errorf("amount exceeds u64 max: %s", data.Amount) + } + + assetAddr := data.AssetAddr + isNative := assetAddr == "" || assetAddr == "0x0" || assetAddr == "0x0000000000000000000000000000000000000000" + + var txID [32]byte + txIDBytes, err := hex.DecodeString(removeHexPrefix(data.TxID)) + if err != nil { + return nil, nil, solana.PublicKey{}, fmt.Errorf("invalid txID: %s", data.TxID) + } + if len(txIDBytes) == 32 { + copy(txID[:], txIDBytes) + } else if len(txIDBytes) > 0 { + copy(txID[32-len(txIDBytes):], txIDBytes) + } + + var universalTxID [32]byte + utxIDBytes, err := hex.DecodeString(removeHexPrefix(data.UniversalTxId)) + if err != nil { + return nil, nil, solana.PublicKey{}, 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) + } + + var sender [20]byte + senderBytes, err := hex.DecodeString(removeHexPrefix(data.Sender)) + if err != nil { + return nil, nil, solana.PublicKey{}, fmt.Errorf("invalid sender: %s", data.Sender) + } + if len(senderBytes) == 20 { + copy(sender[:], senderBytes) + } else { + return nil, nil, solana.PublicKey{}, fmt.Errorf("invalid sender length: expected 20 bytes, got %d", len(senderBytes)) + } + + var mintPubkey solana.PublicKey + if !isNative { + mintPubkey, err = solana.PublicKeyFromBase58(assetAddr) + if err != nil { + hexBytes, hexErr := hex.DecodeString(removeHexPrefix(assetAddr)) + if hexErr != nil || len(hexBytes) != 32 { + return nil, nil, solana.PublicKey{}, fmt.Errorf("invalid asset address format: %s", assetAddr) + } + mintPubkey = solana.PublicKeyFromBytes(hexBytes) + } + } + + var gasFee uint64 + if data.GasFee != "" { + gasFee, _ = strconv.ParseUint(data.GasFee, 10, 64) + } + + recipientPubkey, err := solana.PublicKeyFromBase58(data.Recipient) + if err != nil { + hexBytes, hexErr := hex.DecodeString(removeHexPrefix(data.Recipient)) + if hexErr != nil || len(hexBytes) != 32 { + return nil, nil, solana.PublicKey{}, fmt.Errorf("invalid recipient address format: %s", data.Recipient) + } + recipientPubkey = solana.PublicKeyFromBytes(hexBytes) + } + + // Decode payload — ref route is execute-only, so we require an instruction_id of 2. + var execAccounts []GatewayAccountMeta + var ixData []byte + var instructionID uint8 + payloadHex := removeHexPrefix(data.Payload) + if payloadHex != "" { + payloadBytes, decErr := hex.DecodeString(payloadHex) + if decErr != nil { + return nil, nil, solana.PublicKey{}, fmt.Errorf("failed to decode payload hex: %w", decErr) + } + if len(payloadBytes) > 0 { + execAccounts, ixData, instructionID, _, err = decodePayload(payloadBytes) + if err != nil { + return nil, nil, solana.PublicKey{}, fmt.Errorf("failed to decode payload: %w", err) + } + } + } + if instructionID != 2 { + return nil, nil, solana.PublicKey{}, fmt.Errorf("ref route only valid for execute mode (instruction_id=2), got %d", instructionID) + } + if len(ixData) == 0 { + return nil, nil, solana.PublicKey{}, fmt.Errorf("ref route requires non-empty ix_data") + } + if len(ixData) > maxRefRouteIxData { + return nil, nil, solana.PublicKey{}, fmt.Errorf("ix_data size %d exceeds ref-route max %d (store tx would itself exceed %d-byte limit)", len(ixData), maxRefRouteIxData, solanaTxMaxBytes) + } + + // --- Derive PDAs --- + + configPDA, _, err := solana.FindProgramAddress([][]byte{configSeed}, tb.gatewayAddress) + if err != nil { + return nil, nil, solana.PublicKey{}, fmt.Errorf("failed to derive config PDA: %w", err) + } + vaultPDA, _, err := solana.FindProgramAddress([][]byte{vaultSeed}, tb.gatewayAddress) + if err != nil { + return nil, nil, solana.PublicKey{}, fmt.Errorf("failed to derive vault PDA: %w", err) + } + tssPDA, _, err := solana.FindProgramAddress([][]byte{tssSeed}, tb.gatewayAddress) + if err != nil { + return nil, nil, solana.PublicKey{}, fmt.Errorf("failed to derive TSS PDA: %w", err) + } + executedTxPDA, _, err := solana.FindProgramAddress([][]byte{executedSubTxSeed, txID[:]}, tb.gatewayAddress) + if err != nil { + return nil, nil, solana.PublicKey{}, fmt.Errorf("failed to derive executed_tx PDA: %w", err) + } + ceaAuthorityPDA, _, err := solana.FindProgramAddress([][]byte{ceaAuthoritySeed, sender[:]}, tb.gatewayAddress) + if err != nil { + return nil, nil, solana.PublicKey{}, fmt.Errorf("failed to derive cea_authority PDA: %w", err) + } + + // Content-addressed stored_ix_data PDA: ["stored_ix_data", sub_tx_id, keccak256(ix_data)] + ixDataHashSlice := crypto.Keccak256(ixData) + var ixDataHash [32]byte + copy(ixDataHash[:], ixDataHashSlice) + storedIxDataPDA, err := tb.deriveStoredIxDataPDA(txID, ixDataHash) + if err != nil { + return nil, nil, solana.PublicKey{}, fmt.Errorf("failed to derive stored_ix_data PDA: %w", err) + } + + // --- Build store_execute_ix_data tx (relayer-signed only, no TSS) --- + + recentBlockhash, err := tb.rpcClient.GetRecentBlockhash(ctx) + if err != nil { + return nil, nil, solana.PublicKey{}, fmt.Errorf("failed to get recent blockhash: %w", err) + } + + storeData := tb.buildStoreIxDataData(txID, ixDataHash, ixData) + storeAccounts := tb.buildStoreIxDataAccounts(relayerKeypair.PublicKey(), storedIxDataPDA) + storeInstruction := solana.NewInstruction(tb.gatewayAddress, storeAccounts, storeData) + + storeTx, err := solana.NewTransaction( + []solana.Instruction{storeInstruction}, + recentBlockhash, + solana.TransactionPayer(relayerKeypair.PublicKey()), + ) + if err != nil { + return nil, nil, solana.PublicKey{}, fmt.Errorf("failed to create store tx: %w", err) + } + if _, err := storeTx.Sign(func(key solana.PublicKey) *solana.PrivateKey { + if key.Equals(relayerKeypair.PublicKey()) { + priv := relayerKeypair + return &priv + } + return nil + }); err != nil { + return nil, nil, solana.PublicKey{}, fmt.Errorf("failed to sign store tx: %w", err) + } + + // --- Build finalize_universal_tx_with_ix_data_ref tx (TSS-signed) --- + + writableFlags := accountsToWritableFlags(execAccounts) + refInstructionData := tb.buildWithdrawAndExecuteRefData( + 2, // execute + txID, universalTxID, amount.Uint64(), sender, + ixDataHash, + writableFlags, + gasFee, + signature, recoveryID, req.SigningHash, + ) + + refAccounts := tb.buildWithdrawAndExecuteAccounts( + relayerKeypair.PublicKey(), + configPDA, vaultPDA, ceaAuthorityPDA, tssPDA, executedTxPDA, + recipientPubkey, // destination_program (target of CPI) + isNative, 2, // execute + recipientPubkey, mintPubkey, + execAccounts, + storedIxDataPDA, relayerKeypair.PublicKey(), // ref route: real values + ) + + refInstruction := solana.NewInstruction(tb.gatewayAddress, refAccounts, refInstructionData) + computeLimitIx := tb.buildSetComputeUnitLimitInstruction(defaultComputeUnitLimit) + + instructions := []solana.Instruction{computeLimitIx} + needsRecipientATA := !isNative && false // execute mode (id=2) doesn't create recipient ATA; gateway handles cea_ata internally + if needsRecipientATA { + instructions = append(instructions, tb.buildCreateATAIdempotentInstruction( + relayerKeypair.PublicKey(), recipientPubkey, mintPubkey, + )) + } + instructions = append(instructions, refInstruction) + + refOpts := []solana.TransactionOption{solana.TransactionPayer(relayerKeypair.PublicKey())} + addressTables, altErr := tb.fetchAddressTables(ctx, mintPubkey, isNative) + if altErr != nil { + tb.logger.Warn().Err(altErr).Msg("failed to fetch ALTs for ref-finalize, falling back to legacy tx") + } else if len(addressTables) > 0 { + refOpts = append(refOpts, solana.TransactionAddressTables(addressTables)) + } + refTx, err := solana.NewTransaction(instructions, recentBlockhash, refOpts...) + if err != nil { + return nil, nil, solana.PublicKey{}, fmt.Errorf("failed to create ref-finalize tx: %w", err) + } + if _, err := refTx.Sign(func(key solana.PublicKey) *solana.PrivateKey { + if key.Equals(relayerKeypair.PublicKey()) { + priv := relayerKeypair + return &priv + } + return nil + }); err != nil { + return nil, nil, solana.PublicKey{}, fmt.Errorf("failed to sign ref-finalize tx: %w", err) + } + + return storeTx, refTx, storedIxDataPDA, nil +} + // ============================================================================= // Helper Functions // ============================================================================= @@ -893,7 +1403,7 @@ func removeHexPrefix(s string) string { // // Seed: ["final_tss_pda"] — must match the Rust constant TSS_SEED in state.rs func (tb *TxBuilder) deriveTSSPDA() (solana.PublicKey, error) { - seeds := [][]byte{[]byte("final_tss_pda")} + seeds := [][]byte{tssSeed} address, _, err := solana.FindProgramAddress(seeds, tb.gatewayAddress) return address, err } @@ -937,16 +1447,14 @@ func (tb *TxBuilder) fetchTSSChainID(ctx context.Context, tssPDA solana.PublicKe // 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: +// determineInstructionID maps the Push Chain TxType to the gateway's instruction ID. // // ID Function When -// 1 finalize_universal_tx FUNDS (withdraw mode): send SOL or SPL tokens to a recipient -// 2 finalize_universal_tx FUNDS_AND_PAYLOAD or GAS_AND_PAYLOAD (execute mode): call a program -// 3 revert_universal_tx INBOUND_REVERT: unified revert for both SOL and SPL -// 4 rescue_funds RESCUE_FUNDS: emergency rescue of locked funds -func (tb *TxBuilder) determineInstructionID(txType uetypes.TxType, isNative bool) (uint8, error) { +// 1 finalize_universal_tx FUNDS (withdraw mode) +// 2 finalize_universal_tx FUNDS_AND_PAYLOAD or GAS_AND_PAYLOAD (execute mode) +// 3 revert_universal_tx INBOUND_REVERT (unified SOL + SPL) +// 4 rescue_funds RESCUE_FUNDS +func (tb *TxBuilder) determineInstructionID(txType uetypes.TxType) (uint8, error) { switch txType { case uetypes.TxType_FUNDS: return 1, nil @@ -1011,7 +1519,7 @@ func (tb *TxBuilder) constructTSSMessage( revertMint [32]byte, revertMsg []byte, ) ([]byte, error) { - message := []byte("PUSH_CHAIN_SVM") + message := append([]byte(nil), tssMessagePrefix...) message = append(message, instructionID) message = append(message, []byte(chainID)...) @@ -1486,11 +1994,19 @@ func (tb *TxBuilder) buildRescueData( // --- Optional rate limit accounts (17-18) --- // 17 rate_limit_config read/None Rate limit config PDA ["rate_limit_config"] (required when destination=gateway ie CEA path only)) // 18 token_rate_limit mut/None Token rate limit PDA ["rate_limit", mint] (required when destination=gateway ie CEA path only) +// --- Optional ref-finalize accounts (19-20) --- +// 19 stored_ix_data read/None StoredIxData PDA (only used by ref-finalize route) +// 20 store_refund_recipient mut/None Receives store-tx fee reimbursement (ref route only) // --- Execute-only remaining accounts --- -// 19+ remaining_accounts varies Accounts that the target program needs +// 21+ 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". +// +// storedIxDataPDA and storeRefundRecipient are zero-valued for the direct route +// (None sentinels emitted) and set to real values for the ref-finalize route. +// They must always occupy positions 19-20, otherwise remaining_accounts (CPI +// accounts for execute mode) shift up and Anchor misinterprets them. func (tb *TxBuilder) buildWithdrawAndExecuteAccounts( caller solana.PublicKey, configPDA solana.PublicKey, @@ -1504,6 +2020,8 @@ func (tb *TxBuilder) buildWithdrawAndExecuteAccounts( recipientPubkey solana.PublicKey, mintPubkey solana.PublicKey, execAccounts []GatewayAccountMeta, + storedIxDataPDA solana.PublicKey, + storeRefundRecipient solana.PublicKey, ) []*solana.AccountMeta { // First 8 required accounts (always present) accounts := []*solana.AccountMeta{ @@ -1533,16 +2051,13 @@ func (tb *TxBuilder) buildWithdrawAndExecuteAccounts( } } else { // 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, + solana.SPLAssociatedTokenAccountProgramID, ) ceaATA, _, _ := solana.FindProgramAddress( [][]byte{ceaAuthorityPDA.Bytes(), solana.TokenProgramID.Bytes(), mintPubkey.Bytes()}, - ataProgramID, + solana.SPLAssociatedTokenAccountProgramID, ) if instructionID == 1 { @@ -1554,13 +2069,13 @@ func (tb *TxBuilder) buildWithdrawAndExecuteAccounts( 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}) + accounts = append(accounts, &solana.AccountMeta{PublicKey: solana.SysVarRentPubkey, IsWritable: false, IsSigner: false}) + accounts = append(accounts, &solana.AccountMeta{PublicKey: solana.SPLAssociatedTokenAccountProgramID, IsWritable: false, IsSigner: false}) if instructionID == 1 { recipientATA, _, _ := solana.FindProgramAddress( [][]byte{recipientPubkey.Bytes(), solana.TokenProgramID.Bytes(), mintPubkey.Bytes()}, - ataProgramID, + solana.SPLAssociatedTokenAccountProgramID, ) accounts = append(accounts, &solana.AccountMeta{PublicKey: recipientATA, IsWritable: true, IsSigner: false}) } else { @@ -1572,7 +2087,7 @@ func (tb *TxBuilder) buildWithdrawAndExecuteAccounts( // When destination is the gateway itself (CEA→UEA), pass real rate limit PDAs. // Otherwise, pass None (gateway program ID sentinel). if destinationProgram.Equals(tb.gatewayAddress) { - rateLimitConfigPDA, _, _ := solana.FindProgramAddress([][]byte{[]byte("rate_limit_config")}, tb.gatewayAddress) + rateLimitConfigPDA, _, _ := solana.FindProgramAddress([][]byte{rateLimitConfigSeed}, tb.gatewayAddress) accounts = append(accounts, &solana.AccountMeta{PublicKey: rateLimitConfigPDA, IsWritable: false, IsSigner: false}) // token_rate_limit PDA: seeds = ["rate_limit", token_mint] @@ -1582,7 +2097,7 @@ func (tb *TxBuilder) buildWithdrawAndExecuteAccounts( rateLimitMint = mintPubkey } // rateLimitMint is zero-value (Pubkey::default()) for native SOL - tokenRateLimitPDA, _, _ := solana.FindProgramAddress([][]byte{[]byte("rate_limit"), rateLimitMint.Bytes()}, tb.gatewayAddress) + tokenRateLimitPDA, _, _ := solana.FindProgramAddress([][]byte{tokenRateLimitSeed, rateLimitMint.Bytes()}, tb.gatewayAddress) accounts = append(accounts, &solana.AccountMeta{PublicKey: tokenRateLimitPDA, IsWritable: true, IsSigner: false}) } else { // Not a CEA→UEA flow: rate limit accounts are None @@ -1590,6 +2105,20 @@ func (tb *TxBuilder) buildWithdrawAndExecuteAccounts( accounts = append(accounts, &solana.AccountMeta{PublicKey: tb.gatewayAddress, IsWritable: false, IsSigner: false}) } + // Ref-finalize optional accounts (#19-20): + // Always emit these slots so remaining_accounts (execute-mode CPI accounts) + // land at the correct position. Direct route passes zero pubkeys → None sentinel. + if storedIxDataPDA.IsZero() { + accounts = append(accounts, &solana.AccountMeta{PublicKey: tb.gatewayAddress, IsWritable: false, IsSigner: false}) + } else { + accounts = append(accounts, &solana.AccountMeta{PublicKey: storedIxDataPDA, IsWritable: false, IsSigner: false}) + } + if storeRefundRecipient.IsZero() { + accounts = append(accounts, &solana.AccountMeta{PublicKey: tb.gatewayAddress, IsWritable: false, IsSigner: false}) + } else { + accounts = append(accounts, &solana.AccountMeta{PublicKey: storeRefundRecipient, IsWritable: true, 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 { @@ -1606,46 +2135,6 @@ func (tb *TxBuilder) buildWithdrawAndExecuteAccounts( return accounts } -// VerifyBroadcastedTx checks the status of a broadcasted transaction on Solana. -// Returns (found, confirmations, status, error): -// - found=false: tx not found or not yet confirmed -// - found=true: tx exists on-chain -// - confirmations: number of slots since the tx was included (0 = just confirmed) -// - status: 0 = failed, 1 = success -func (tb *TxBuilder) VerifyBroadcastedTx(ctx context.Context, txHash string) (found bool, blockHeight uint64, confirmations uint64, status uint8, err error) { - sig, sigErr := solana.SignatureFromBase58(txHash) - if sigErr != nil { - return false, 0, 0, 0, nil - } - - tx, txErr := tb.rpcClient.GetTransaction(ctx, sig) - if txErr != nil { - return false, 0, 0, 0, nil - } - - if tx == nil { - return false, 0, 0, 0, nil - } - - // Calculate confirmations from current slot - var confs uint64 - if tx.Slot > 0 { - latestSlot, slotErr := tb.rpcClient.GetLatestSlot(ctx) - if slotErr == nil && latestSlot >= tx.Slot { - confs = latestSlot - tx.Slot + 1 - } - } - - // Check if transaction had an error - if tx.Meta != nil && tx.Meta.Err != nil { - return true, tx.Slot, confs, 0, nil - } - - return true, tx.Slot, confs, 1, nil -} - -// buildSetComputeUnitLimitInstruction creates a SetComputeUnitLimit instruction for the Compute Budget program -// Instruction format: [1-byte instruction type (2 = SetComputeUnitLimit)] + [4-byte u32 units] // buildRevertAccounts builds the unified accounts list for revert_universal_tx // (handles both SOL and SPL). // @@ -1694,14 +2183,13 @@ func (tb *TxBuilder) buildRevertAccounts( } } else { // SPL: derive and pass real ATAs - ataProgramID := solana.MustPublicKeyFromBase58("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL") tokenVaultATA, _, _ := solana.FindProgramAddress( [][]byte{vaultPDA.Bytes(), solana.TokenProgramID.Bytes(), mintPubkey.Bytes()}, - ataProgramID, + solana.SPLAssociatedTokenAccountProgramID, ) recipientATA, _, _ := solana.FindProgramAddress( [][]byte{recipient.Bytes(), solana.TokenProgramID.Bytes(), mintPubkey.Bytes()}, - ataProgramID, + solana.SPLAssociatedTokenAccountProgramID, ) accounts = append(accounts, &solana.AccountMeta{PublicKey: tokenVaultATA, IsWritable: true, IsSigner: false}, @@ -1749,46 +2237,186 @@ func (tb *TxBuilder) buildRescueAccounts( // Byte 0: instruction type (2 = SetComputeUnitLimit) // Bytes 1-4: units (u32, little-endian) func (tb *TxBuilder) buildSetComputeUnitLimitInstruction(units uint32) solana.Instruction { - computeBudgetProgramID := solana.MustPublicKeyFromBase58("ComputeBudget111111111111111111111111111111") - data := make([]byte, 5) data[0] = 2 // SetComputeUnitLimit binary.LittleEndian.PutUint32(data[1:], units) return solana.NewInstruction( - computeBudgetProgramID, + solana.ComputeBudget, []*solana.AccountMeta{}, data, ) } // ============================================================================= -// ATA Creation +// Ref-Finalize Route (Large-Payload 2-Tx Path) +// +// When the direct finalize_universal_tx exceeds Solana's 1232-byte raw tx +// limit (typically multi-hop CPI flows with a fat ix_data), we split the +// flow into two transactions: +// +// 1. store_execute_ix_data — relayer uploads raw ix_data into a content- +// addressed PDA. Permissionless, no TSS involvement. +// 2. finalize_universal_tx_with_ix_data_ref — same logical finalize as the +// direct route, but the program loads ix_data from the PDA instead of +// taking it inline. Uses the SAME TSS message envelope (raw ix_data), +// so constructTSSMessage does NOT branch on route. +// +// On success, finalize_universal_tx_with_ix_data_ref auto-closes the +// StoredIxData PDA and returns rent to store_refund_recipient in the same +// tx. close_stored_ix_data is only used for the failure / abort tail +// (store succeeded but finalize never did). // ============================================================================= -// 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. +// deriveStoredIxDataPDA returns the canonical StoredIxData PDA for a given +// (sub_tx_id, ix_data_hash). The PDA is content-addressed: any holder of the +// raw ix_data can compute its hash and derive the same address. +func (tb *TxBuilder) deriveStoredIxDataPDA(subTxID, ixDataHash [32]byte) (solana.PublicKey, error) { + addr, _, err := solana.FindProgramAddress( + [][]byte{storedIxDataSeed, subTxID[:], ixDataHash[:]}, + tb.gatewayAddress, + ) + return addr, err +} + +// buildStoreIxDataData constructs the Borsh-serialized instruction data for +// store_execute_ix_data. // -// 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. +// Offset Size Field +// 0 8 discriminator +// 8 32 sub_tx_id [u8; 32] +// 40 32 ix_data_hash [u8; 32] +// 72 4+N ix_data Vec (4-byte LE length + bytes) +func (tb *TxBuilder) buildStoreIxDataData(subTxID, ixDataHash [32]byte, ixData []byte) []byte { + data := make([]byte, 0, 8+32+32+4+len(ixData)) + data = append(data, discStoreExecuteIxData[:]...) + data = append(data, subTxID[:]...) + data = append(data, ixDataHash[:]...) + + lenBytes := make([]byte, 4) + binary.LittleEndian.PutUint32(lenBytes, uint32(len(ixData))) + data = append(data, lenBytes...) + data = append(data, ixData...) + return data +} + +// buildWithdrawAndExecuteRefData constructs the Borsh-serialized instruction +// data for finalize_universal_tx_with_ix_data_ref. // -// ATA program instruction indices: +// Field order DIFFERS from the direct route: ix_data_hash (fixed [u8; 32]) +// comes BEFORE writable_flags (Vec). This is intentional in the on-chain +// program — do not swap, or Anchor will fail to decode. // -// 0 = Create (fails if ATA exists) -// 1 = CreateIdempotent (no-op if ATA exists) ← we use this +// Offset Size Field +// 0 8 discriminator +// 8 1 instruction_id u8 +// 9 32 sub_tx_id [u8; 32] +// 41 32 universal_tx_id [u8; 32] +// 73 8 amount u64 (LE) +// 81 20 push_account [u8; 20] +// 101 32 ix_data_hash [u8; 32] ← swapped vs direct +// 133 4+N writable_flags Vec ← swapped vs direct +// ... 8 gas_fee u64 (LE) +// ... 64 signature [u8; 64] +// ... 1 recovery_id u8 +// ... 32 message_hash [u8; 32] +func (tb *TxBuilder) buildWithdrawAndExecuteRefData( + instructionID uint8, + subTxID [32]byte, + universalTxID [32]byte, + amount uint64, + pushAccount [20]byte, + ixDataHash [32]byte, + writableFlags []byte, + gasFee uint64, + signature []byte, + recoveryID byte, + messageHash []byte, +) []byte { + data := make([]byte, 0, 256) + data = append(data, discFinalizeUniversalTxRef[:]...) + data = append(data, instructionID) + data = append(data, subTxID[:]...) + data = append(data, universalTxID[:]...) + + amountBytes := make([]byte, 8) + binary.LittleEndian.PutUint64(amountBytes, amount) + data = append(data, amountBytes...) + + data = append(data, pushAccount[:]...) + data = append(data, ixDataHash[:]...) + + wfLen := make([]byte, 4) + binary.LittleEndian.PutUint32(wfLen, uint32(len(writableFlags))) + data = append(data, wfLen...) + data = append(data, writableFlags...) + + 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...) + return data +} + +// buildStoreIxDataAccounts builds the accounts list for store_execute_ix_data. +// +// # Account Flags +// 1 caller signer, mut Relayer paying for storage +// 2 stored_ix_data mut Canonical StoredIxData PDA (gets init'd) +// 3 system_program read-only +func (tb *TxBuilder) buildStoreIxDataAccounts(caller, storedIxDataPDA solana.PublicKey) []*solana.AccountMeta { + return []*solana.AccountMeta{ + {PublicKey: caller, IsWritable: true, IsSigner: true}, + {PublicKey: storedIxDataPDA, IsWritable: true, IsSigner: false}, + {PublicKey: solana.SystemProgramID, IsWritable: false, IsSigner: false}, + } +} + +// buildCloseStoredIxDataData constructs the Borsh-serialized instruction data +// for close_stored_ix_data. +// +// Offset Size Field +// 0 8 discriminator +// 8 32 sub_tx_id [u8; 32] +// 40 32 ix_data_hash [u8; 32] +func (tb *TxBuilder) buildCloseStoredIxDataData(subTxID, ixDataHash [32]byte) []byte { + data := make([]byte, 0, 8+32+32) + data = append(data, discCloseStoredIxData[:]...) + data = append(data, subTxID[:]...) + data = append(data, ixDataHash[:]...) + return data +} + +// buildCloseStoredIxDataAccounts builds the accounts list for close_stored_ix_data. +// +// # Account Flags Notes +// 1 caller signer, mut Relayer = stored.store_refund_recipient on failure path +// 2 stored_ix_data mut PDA being closed +// 3 store_refund_recipient mut Rent destination (= caller on failure path) +// 4 executed_sub_tx read-only Omitted on the failure path (PDA doesn't exist) +func (tb *TxBuilder) buildCloseStoredIxDataAccounts(caller, storedIxDataPDA solana.PublicKey) []*solana.AccountMeta { + return []*solana.AccountMeta{ + {PublicKey: caller, IsWritable: true, IsSigner: true}, + {PublicKey: storedIxDataPDA, IsWritable: true, IsSigner: false}, + {PublicKey: caller, IsWritable: true, IsSigner: false}, + } +} + +// buildCreateATAIdempotentInstruction creates the recipient's ATA if absent +// (no-op if present). Required for SPL withdraw/revert flows because the +// gateway validates the recipient ATA exists but does NOT create it. Relayer +// pays the ~0.002 SOL rent, reimbursed via gas_fee. 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, + solana.SPLAssociatedTokenAccountProgramID, ) accounts := []*solana.AccountMeta{ @@ -1800,16 +2428,19 @@ func (tb *TxBuilder) buildCreateATAIdempotentInstruction( {PublicKey: solana.TokenProgramID, IsWritable: false, IsSigner: false}, } - // Instruction index 1 = CreateIdempotent - return solana.NewInstruction(ataProgramID, accounts, []byte{1}) + // ATA program instruction discriminator: 0 = Create (fails if exists), 1 = CreateIdempotent. + return solana.NewInstruction(solana.SPLAssociatedTokenAccountProgramID, accounts, []byte{1}) } -// GetFundMigrationSigningRequest is not supported for SVM - funds are held by the program, not the TSS key. +// ============================================================================= +// Fund Migration (Unsupported on SVM) +// SVM funds are held by the gateway program in PDA-controlled vaults, not by TSS +// ============================================================================= + func (tb *TxBuilder) GetFundMigrationSigningRequest(ctx context.Context, data *common.FundMigrationData, nonce uint64) (*common.UnsignedSigningReq, error) { return nil, fmt.Errorf("fund migration not supported for SVM") } -// BroadcastFundMigrationTx is not supported for SVM - funds are held by the program, not the TSS key. func (tb *TxBuilder) BroadcastFundMigrationTx(ctx context.Context, req *common.UnsignedSigningReq, data *common.FundMigrationData, signature []byte) (string, error) { return "", fmt.Errorf("fund migration not supported for SVM") } From 95076a9df7a11063159e83d2d5a786aa25ce5b54 Mon Sep 17 00:00:00 2001 From: aman035 Date: Thu, 21 May 2026 12:56:52 +0530 Subject: [PATCH 04/17] add: tc --- universalClient/chains/svm/tx_builder_test.go | 848 ++++++++++++++---- 1 file changed, 690 insertions(+), 158 deletions(-) diff --git a/universalClient/chains/svm/tx_builder_test.go b/universalClient/chains/svm/tx_builder_test.go index 83d5715b..7d3424b3 100644 --- a/universalClient/chains/svm/tx_builder_test.go +++ b/universalClient/chains/svm/tx_builder_test.go @@ -20,6 +20,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/pushchain/push-chain-node/universalClient/chains/common" "github.com/pushchain/push-chain-node/universalClient/config" uetypes "github.com/pushchain/push-chain-node/x/uexecutor/types" ) @@ -292,26 +293,22 @@ func TestDetermineInstructionID(t *testing.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 → 3", uetypes.TxType_INBOUND_REVERT, false, 3, false}, - {"RESCUE_FUNDS native → 4", uetypes.TxType_RESCUE_FUNDS, true, 4, false}, - {"RESCUE_FUNDS SPL → 4", uetypes.TxType_RESCUE_FUNDS, 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}, + {"FUNDS → 1 (withdraw)", uetypes.TxType_FUNDS, 1, false}, + {"FUNDS_AND_PAYLOAD → 2 (execute)", uetypes.TxType_FUNDS_AND_PAYLOAD, 2, false}, + {"GAS_AND_PAYLOAD → 2 (execute)", uetypes.TxType_GAS_AND_PAYLOAD, 2, false}, + {"INBOUND_REVERT → 3", uetypes.TxType_INBOUND_REVERT, 3, false}, + {"RESCUE_FUNDS → 4", uetypes.TxType_RESCUE_FUNDS, 4, false}, + {"UNSPECIFIED → error", uetypes.TxType_UNSPECIFIED_TX, 0, true}, + {"GAS → error", uetypes.TxType_GAS, 0, true}, + {"PAYLOAD → error", uetypes.TxType_PAYLOAD, 0, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - id, err := builder.determineInstructionID(tt.txType, tt.isNative) + id, err := builder.determineInstructionID(tt.txType) if tt.wantErr { assert.Error(t, err) } else { @@ -1091,7 +1088,8 @@ func TestBuildWithdrawAndExecuteAccounts(t *testing.T) { solana.SystemProgramID, // destination_program = system for withdraw true, 1, // isNative, instructionID recipient, solana.PublicKey{}, // recipient, mint (unused for native) - nil, // no execute accounts + nil, // no execute accounts + solana.PublicKey{}, solana.PublicKey{}, // direct route: ref-finalize slots are None ) // First 8 required accounts @@ -1134,7 +1132,11 @@ func TestBuildWithdrawAndExecuteAccounts(t *testing.T) { assert.Equal(t, builder.gatewayAddress, accounts[16].PublicKey, "rateLimitConfig should be gateway sentinel") assert.Equal(t, builder.gatewayAddress, accounts[17].PublicKey, "tokenRateLimit should be gateway sentinel") - assert.Len(t, accounts, 18, "total accounts for SOL withdraw") + // Ref-finalize slots (19-20) should be gateway sentinels for direct route + assert.Equal(t, builder.gatewayAddress, accounts[18].PublicKey, "stored_ix_data should be gateway sentinel for direct route") + assert.Equal(t, builder.gatewayAddress, accounts[19].PublicKey, "store_refund_recipient should be gateway sentinel for direct route") + + assert.Len(t, accounts, 20, "total accounts for SOL withdraw (direct route)") }) t.Run("execute (id=2) appends remaining_accounts", func(t *testing.T) { @@ -1149,13 +1151,14 @@ func TestBuildWithdrawAndExecuteAccounts(t *testing.T) { true, 2, // isNative, instructionID=execute solana.PublicKey{}, solana.PublicKey{}, execAccounts, + solana.PublicKey{}, solana.PublicKey{}, // direct route: ref-finalize slots are None ) // 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 := 18 // 8 required + 8 optional + 2 rate limit + totalRequired := 20 // 8 required + 8 SPL optional + 2 rate limit + 2 ref-finalize optional assert.Len(t, accounts, totalRequired+2) // Check remaining_accounts @@ -1548,6 +1551,601 @@ func TestEndToEndWithRealSignature(t *testing.T) { }) } +func TestGetNextNonce(t *testing.T) { + builder := newTestBuilder(t) + + t.Run("returns 0 with arbitrary address and finalized=true", func(t *testing.T) { + nonce, err := builder.GetNextNonce(context.Background(), "SomeAddress123", true) + require.NoError(t, err) + assert.Equal(t, uint64(0), nonce) + }) + + t.Run("returns 0 with empty address and finalized=false", func(t *testing.T) { + nonce, err := builder.GetNextNonce(context.Background(), "", false) + require.NoError(t, err) + assert.Equal(t, uint64(0), nonce) + }) +} + +func TestGetGasFeeUsed(t *testing.T) { + builder := newTestBuilder(t) + + t.Run("returns string zero for any tx hash", func(t *testing.T) { + fee, err := builder.GetGasFeeUsed(context.Background(), "5xYz...someTxHash") + require.NoError(t, err) + assert.Equal(t, "0", fee) + }) + + t.Run("returns string zero for empty tx hash", func(t *testing.T) { + fee, err := builder.GetGasFeeUsed(context.Background(), "") + require.NoError(t, err) + assert.Equal(t, "0", fee) + }) +} + +func TestNewTxBuilder_ChainConfig(t *testing.T) { + logger := zerolog.Nop() + + t.Run("valid protocolALT is stored", func(t *testing.T) { + altKey := solana.NewWallet().PublicKey() + cfg := &config.ChainSpecificConfig{ + ProtocolALT: altKey.String(), + } + builder, err := NewTxBuilder(&RPCClient{}, "solana:devnet", testGatewayAddress, "/tmp", logger, cfg) + require.NoError(t, err) + assert.Equal(t, altKey, builder.protocolALT) + }) + + t.Run("invalid protocolALT is silently skipped", func(t *testing.T) { + cfg := &config.ChainSpecificConfig{ + ProtocolALT: "not-valid-base58!!!", + } + builder, err := NewTxBuilder(&RPCClient{}, "solana:devnet", testGatewayAddress, "/tmp", logger, cfg) + require.NoError(t, err) + assert.True(t, builder.protocolALT.IsZero(), "invalid ALT should result in zero pubkey") + }) + + t.Run("valid tokenALTs are stored", func(t *testing.T) { + mint := solana.NewWallet().PublicKey() + alt := solana.NewWallet().PublicKey() + cfg := &config.ChainSpecificConfig{ + TokenALTs: map[string]string{ + mint.String(): alt.String(), + }, + } + builder, err := NewTxBuilder(&RPCClient{}, "solana:devnet", testGatewayAddress, "/tmp", logger, cfg) + require.NoError(t, err) + got, ok := builder.tokenALTs[mint] + require.True(t, ok, "expected token ALT entry for mint") + assert.Equal(t, alt, got) + }) + + t.Run("invalid tokenALT mint is skipped", func(t *testing.T) { + cfg := &config.ChainSpecificConfig{ + TokenALTs: map[string]string{ + "bad-mint": solana.NewWallet().PublicKey().String(), + }, + } + builder, err := NewTxBuilder(&RPCClient{}, "solana:devnet", testGatewayAddress, "/tmp", logger, cfg) + require.NoError(t, err) + assert.Len(t, builder.tokenALTs, 0) + }) + + t.Run("invalid tokenALT address is skipped", func(t *testing.T) { + cfg := &config.ChainSpecificConfig{ + TokenALTs: map[string]string{ + solana.NewWallet().PublicKey().String(): "bad-alt", + }, + } + builder, err := NewTxBuilder(&RPCClient{}, "solana:devnet", testGatewayAddress, "/tmp", logger, cfg) + require.NoError(t, err) + assert.Len(t, builder.tokenALTs, 0) + }) + + t.Run("nil chainConfig is fine", func(t *testing.T) { + builder, err := NewTxBuilder(&RPCClient{}, "solana:devnet", testGatewayAddress, "/tmp", logger, nil) + require.NoError(t, err) + assert.True(t, builder.protocolALT.IsZero()) + assert.Len(t, builder.tokenALTs, 0) + }) +} + +func TestBuildCreateATAIdempotentInstruction(t *testing.T) { + builder := newTestBuilder(t) + payer := solana.NewWallet().PublicKey() + owner := solana.NewWallet().PublicKey() + mint := solana.NewWallet().PublicKey() + + ix := builder.buildCreateATAIdempotentInstruction(payer, owner, mint) + + t.Run("program ID is ATA program", func(t *testing.T) { + expected := solana.MustPublicKeyFromBase58("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL") + assert.Equal(t, expected, ix.ProgramID()) + }) + + t.Run("has 6 accounts in correct order", func(t *testing.T) { + accounts := ix.Accounts() + require.Len(t, accounts, 6) + + // payer (signer, writable) + assert.Equal(t, payer, accounts[0].PublicKey) + assert.True(t, accounts[0].IsSigner) + assert.True(t, accounts[0].IsWritable) + + // ATA (writable, derived deterministically) + ataProgramID := solana.MustPublicKeyFromBase58("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL") + expectedATA, _, _ := solana.FindProgramAddress( + [][]byte{owner.Bytes(), solana.TokenProgramID.Bytes(), mint.Bytes()}, + ataProgramID, + ) + assert.Equal(t, expectedATA, accounts[1].PublicKey) + assert.True(t, accounts[1].IsWritable) + assert.False(t, accounts[1].IsSigner) + + // owner + assert.Equal(t, owner, accounts[2].PublicKey) + assert.False(t, accounts[2].IsWritable) + + // mint + assert.Equal(t, mint, accounts[3].PublicKey) + assert.False(t, accounts[3].IsWritable) + + // system program + assert.Equal(t, solana.SystemProgramID, accounts[4].PublicKey) + + // token program + assert.Equal(t, solana.TokenProgramID, accounts[5].PublicKey) + }) + + t.Run("instruction data is [1] for CreateIdempotent", func(t *testing.T) { + data, err := ix.Data() + require.NoError(t, err) + assert.Equal(t, []byte{1}, data) + }) +} + +// ============================================================================= +// Ref-Finalize Route Tests +// ============================================================================= + +func TestDeriveStoredIxDataPDA(t *testing.T) { + builder := newTestBuilder(t) + subTxID := makeTxID(0xAB) + ixDataHash := makeTxID(0xCD) + + pda1, err := builder.deriveStoredIxDataPDA(subTxID, ixDataHash) + require.NoError(t, err) + require.False(t, pda1.IsZero(), "PDA must be non-zero") + + // Determinism: same inputs → same PDA + pda2, err := builder.deriveStoredIxDataPDA(subTxID, ixDataHash) + require.NoError(t, err) + assert.Equal(t, pda1, pda2, "same seeds must derive same PDA") + + // Sensitivity: different sub_tx_id → different PDA + pdaDifferentSubTx, err := builder.deriveStoredIxDataPDA(makeTxID(0xAC), ixDataHash) + require.NoError(t, err) + assert.NotEqual(t, pda1, pdaDifferentSubTx, "different sub_tx_id must change PDA") + + // Sensitivity: different ix_data_hash → different PDA + pdaDifferentHash, err := builder.deriveStoredIxDataPDA(subTxID, makeTxID(0xCE)) + require.NoError(t, err) + assert.NotEqual(t, pda1, pdaDifferentHash, "different ix_data_hash must change PDA") +} + +func TestBuildStoreIxDataData(t *testing.T) { + builder := newTestBuilder(t) + subTxID := makeTxID(0x11) + ixDataHash := makeTxID(0x22) + ixData := []byte{0xDE, 0xAD, 0xBE, 0xEF} + + data := builder.buildStoreIxDataData(subTxID, ixDataHash, ixData) + + // Discriminator + assert.Equal(t, discStoreExecuteIxData[:], data[0:8], "discriminator") + // sub_tx_id + assert.Equal(t, subTxID[:], data[8:40], "sub_tx_id") + // ix_data_hash + assert.Equal(t, ixDataHash[:], data[40:72], "ix_data_hash") + // ix_data Vec: 4-byte LE length + bytes + assert.Equal(t, uint32(len(ixData)), binary.LittleEndian.Uint32(data[72:76]), "vec length prefix") + assert.Equal(t, ixData, data[76:80], "ix_data bytes") + assert.Equal(t, 8+32+32+4+len(ixData), len(data), "total length") +} + +func TestBuildStoreIxDataData_EmptyIxData(t *testing.T) { + // Building with empty ix_data is technically allowed at the universalClient + // layer; the on-chain program rejects with EmptyIxData. Test that we still + // produce a well-formed payload (zero-length Vec). + builder := newTestBuilder(t) + data := builder.buildStoreIxDataData(makeTxID(0x11), makeTxID(0x22), []byte{}) + assert.Equal(t, uint32(0), binary.LittleEndian.Uint32(data[72:76])) + assert.Equal(t, 8+32+32+4, len(data)) +} + +func TestBuildWithdrawAndExecuteRefData_ArgOrder(t *testing.T) { + // Critical: ix_data_hash (fixed 32) comes BEFORE writable_flags (Vec). + // Direct route has writable_flags before ix_data — swap is intentional. + builder := newTestBuilder(t) + + subTxID := makeTxID(0x01) + utxID := makeTxID(0x02) + pushAccount := makeSender(0x03) + ixDataHash := makeTxID(0x04) + writableFlags := []byte{1, 0, 1} + signature := make([]byte, 64) + for i := range signature { + signature[i] = byte(i) + } + msgHash := make([]byte, 32) + for i := range msgHash { + msgHash[i] = 0xFF + } + + data := builder.buildWithdrawAndExecuteRefData( + 2, // instruction_id + subTxID, // sub_tx_id + utxID, // universal_tx_id + 1_000_000_000, // amount + pushAccount, + ixDataHash, + writableFlags, + 500_000, // gas_fee + signature, + 1, // recovery_id + msgHash, + ) + + // Layout: + // 0..8 discriminator + // 8 instruction_id + // 9..41 sub_tx_id + // 41..73 universal_tx_id + // 73..81 amount (LE) + // 81..101 push_account + // 101..133 ix_data_hash ← BEFORE writable_flags + // 133..137 writable_flags len + // 137..140 writable_flags + // 140..148 gas_fee (LE) + // 148..212 signature + // 212 recovery_id + // 213..245 message_hash + + assert.Equal(t, discFinalizeUniversalTxRef[:], data[0:8]) + assert.Equal(t, uint8(2), data[8]) + assert.Equal(t, subTxID[:], data[9:41]) + assert.Equal(t, utxID[:], data[41:73]) + assert.Equal(t, uint64(1_000_000_000), binary.LittleEndian.Uint64(data[73:81])) + assert.Equal(t, pushAccount[:], data[81:101]) + assert.Equal(t, ixDataHash[:], data[101:133], "ix_data_hash must precede writable_flags") + assert.Equal(t, uint32(3), binary.LittleEndian.Uint32(data[133:137]), "writable_flags length") + assert.Equal(t, writableFlags, data[137:140], "writable_flags bytes") + assert.Equal(t, uint64(500_000), binary.LittleEndian.Uint64(data[140:148])) + assert.Equal(t, signature, data[148:212]) + assert.Equal(t, uint8(1), data[212]) + assert.Equal(t, msgHash, data[213:245]) + assert.Equal(t, 245, len(data)) +} + +func TestBuildStoreIxDataAccounts(t *testing.T) { + builder := newTestBuilder(t) + caller := solana.NewWallet().PublicKey() + storedPDA := solana.NewWallet().PublicKey() + + accounts := builder.buildStoreIxDataAccounts(caller, storedPDA) + require.Len(t, accounts, 3) + + assert.Equal(t, caller, accounts[0].PublicKey) + assert.True(t, accounts[0].IsSigner) + assert.True(t, accounts[0].IsWritable) + + assert.Equal(t, storedPDA, accounts[1].PublicKey) + assert.True(t, accounts[1].IsWritable) + assert.False(t, accounts[1].IsSigner) + + assert.Equal(t, solana.SystemProgramID, accounts[2].PublicKey) + assert.False(t, accounts[2].IsWritable) + assert.False(t, accounts[2].IsSigner) +} + +func TestBuildCloseStoredIxDataData(t *testing.T) { + builder := newTestBuilder(t) + subTxID := makeTxID(0x33) + ixDataHash := makeTxID(0x44) + + data := builder.buildCloseStoredIxDataData(subTxID, ixDataHash) + + require.Len(t, data, 8+32+32) + assert.Equal(t, discCloseStoredIxData[:], data[0:8], "discriminator") + assert.Equal(t, subTxID[:], data[8:40], "sub_tx_id") + assert.Equal(t, ixDataHash[:], data[40:72], "ix_data_hash") +} + +func TestBuildCloseStoredIxDataAccounts(t *testing.T) { + builder := newTestBuilder(t) + caller := solana.NewWallet().PublicKey() + storedPDA := solana.NewWallet().PublicKey() + + accounts := builder.buildCloseStoredIxDataAccounts(caller, storedPDA) + require.Len(t, accounts, 3) + + // caller: signer, mut + assert.Equal(t, caller, accounts[0].PublicKey) + assert.True(t, accounts[0].IsSigner) + assert.True(t, accounts[0].IsWritable) + + // stored_ix_data: mut, not signer + assert.Equal(t, storedPDA, accounts[1].PublicKey) + assert.True(t, accounts[1].IsWritable) + assert.False(t, accounts[1].IsSigner) + + // store_refund_recipient: mut, not signer; must equal caller on the failure path + assert.Equal(t, caller, accounts[2].PublicKey, "refund recipient must equal caller on failure path") + assert.True(t, accounts[2].IsWritable) + assert.False(t, accounts[2].IsSigner) +} + +func TestBuildWithdrawAndExecuteAccounts_RefRouteSlots(t *testing.T) { + // Verify the ref-finalize slots (#19-20) carry real values when populated, + // and that remaining_accounts still land at position 21+ in execute mode. + builder := newTestBuilder(t) + caller := solana.NewWallet().PublicKey() + configPDA := solana.NewWallet().PublicKey() + vaultPDA := solana.NewWallet().PublicKey() + ceaPDA := solana.NewWallet().PublicKey() + tssPDA := solana.NewWallet().PublicKey() + executedPDA := solana.NewWallet().PublicKey() + recipientPDA := solana.NewWallet().PublicKey() + storedPDA := solana.NewWallet().PublicKey() + storeRefund := solana.NewWallet().PublicKey() + + execAccounts := []GatewayAccountMeta{ + {Pubkey: makeTxID(0xAA), IsWritable: true}, + } + + accounts := builder.buildWithdrawAndExecuteAccounts( + caller, configPDA, vaultPDA, ceaPDA, tssPDA, executedPDA, + recipientPDA, // destination_program + true, 2, // isNative, execute + solana.PublicKey{}, solana.PublicKey{}, // recipient/mint unused + execAccounts, + storedPDA, storeRefund, // ref route: real values + ) + + // Position 18 (0-indexed): stored_ix_data + assert.Equal(t, storedPDA, accounts[18].PublicKey, "stored_ix_data slot") + assert.False(t, accounts[18].IsWritable, "stored_ix_data is read-only on-chain") + + // Position 19: store_refund_recipient + assert.Equal(t, storeRefund, accounts[19].PublicKey, "store_refund_recipient slot") + assert.True(t, accounts[19].IsWritable, "store_refund_recipient must be writable for reimbursement") + + // Position 20+: remaining_accounts (the execute CPI accounts) + require.Len(t, accounts, 21, "8 required + 8 SPL + 2 rate-limit + 2 ref + 1 remaining") + expectedRemaining := makeTxID(0xAA) + assert.Equal(t, solana.PublicKeyFromBytes(expectedRemaining[:]), accounts[20].PublicKey, "remaining account 0") + assert.True(t, accounts[20].IsWritable) +} + +// newTestBuilderWithKeypair returns a TxBuilder whose nodeHome contains a +// valid relayer keypair on disk, so loadRelayerKeypair() succeeds in unit +// tests that never touch the network. The embedded RPCClient is still a +// zero-value stub — any code path that actually calls RPC will panic, which +// is intentional: it forces validation tests to fail *before* they reach RPC. +func newTestBuilderWithKeypair(t *testing.T) *TxBuilder { + t.Helper() + 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, + )) + + logger := zerolog.Nop() + builder, err := NewTxBuilder(&RPCClient{}, "solana:devnet", testGatewayAddress, tmpDir, logger, nil) + require.NoError(t, err) + return builder +} + +// buildExecutePayloadForTest assembles a payload in the format decodePayload +// expects: [accountsCount(4 BE) | accounts(N×33) | ixDataLen(4 BE) | ixData | instructionID(1) | targetProgram(32)]. +func buildExecutePayloadForTest(t *testing.T, accounts []GatewayAccountMeta, ixData []byte, instructionID uint8, targetProgram [32]byte) string { + t.Helper() + buf := make([]byte, 0, 4+len(accounts)*33+4+len(ixData)+1+32) + + countBytes := make([]byte, 4) + binary.BigEndian.PutUint32(countBytes, uint32(len(accounts))) + buf = append(buf, countBytes...) + for _, a := range accounts { + buf = append(buf, a.Pubkey[:]...) + if a.IsWritable { + buf = append(buf, 1) + } else { + buf = append(buf, 0) + } + } + + lenBytes := make([]byte, 4) + binary.BigEndian.PutUint32(lenBytes, uint32(len(ixData))) + buf = append(buf, lenBytes...) + buf = append(buf, ixData...) + buf = append(buf, instructionID) + buf = append(buf, targetProgram[:]...) + return "0x" + hex.EncodeToString(buf) +} + +// newBaseRefRouteEvent constructs a minimal OutboundCreatedEvent suitable as +// the "happy template" for ref-route validation tests. Each test mutates a +// single field to exercise a specific error path. +// +// Validation tests don't exercise the signing path, so the sender is a +// hardcoded 20-byte hex string rather than a real EVM key. +func newBaseRefRouteEvent(t *testing.T, payload string) *uetypes.OutboundCreatedEvent { + t.Helper() + recipient := solana.NewWallet().PublicKey() + 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:devnet", + Sender: "0xc681e7bdacfe4dc7209a15ff052f897c3d87008f", + Recipient: recipient.String(), + Amount: "0", + Payload: payload, + GasFee: "1000000", + TxType: "GAS_AND_PAYLOAD", + } +} + +func TestBuildRefRouteTransactions_Validation(t *testing.T) { + builder := newTestBuilderWithKeypair(t) + ctx := context.Background() + + validSig := make([]byte, 65) + req := &common.UnsignedSigningReq{SigningHash: make([]byte, 32)} + + // Default "good" payload: execute mode with a small valid ix_data so we + // hit validation paths cleanly without tripping decodePayload. + target := makeTxID(0x99) + smallIxData := []byte{0xDE, 0xAD, 0xBE, 0xEF} + validPayload := buildExecutePayloadForTest(t, []GatewayAccountMeta{}, smallIxData, 2, target) + + t.Run("nil signing request", func(t *testing.T) { + _, _, _, err := builder.BuildRefRouteTransactions(ctx, nil, newBaseRefRouteEvent(t, validPayload), validSig) + require.Error(t, err) + require.Contains(t, err.Error(), "signing request is nil") + }) + + t.Run("nil event data", func(t *testing.T) { + _, _, _, err := builder.BuildRefRouteTransactions(ctx, req, nil, validSig) + require.Error(t, err) + require.Contains(t, err.Error(), "outbound event data is nil") + }) + + t.Run("wrong signature length", func(t *testing.T) { + _, _, _, err := builder.BuildRefRouteTransactions(ctx, req, newBaseRefRouteEvent(t, validPayload), make([]byte, 64)) + require.Error(t, err) + require.Contains(t, err.Error(), "signature must be 65 bytes") + }) + + t.Run("withdraw payload (id=1) rejected — ref route is execute-only", func(t *testing.T) { + withdrawPayload := buildExecutePayloadForTest(t, []GatewayAccountMeta{}, nil, 1, target) + _, _, _, err := builder.BuildRefRouteTransactions(ctx, req, newBaseRefRouteEvent(t, withdrawPayload), validSig) + require.Error(t, err) + require.Contains(t, err.Error(), "ref route only valid for execute mode") + }) + + t.Run("empty payload (instructionID=0) rejected", func(t *testing.T) { + ev := newBaseRefRouteEvent(t, "") + _, _, _, err := builder.BuildRefRouteTransactions(ctx, req, ev, validSig) + require.Error(t, err) + require.Contains(t, err.Error(), "ref route only valid for execute mode") + }) + + t.Run("oversized ix_data rejected", func(t *testing.T) { + bigIxData := make([]byte, maxRefRouteIxData+1) + bigPayload := buildExecutePayloadForTest(t, []GatewayAccountMeta{}, bigIxData, 2, target) + _, _, _, err := builder.BuildRefRouteTransactions(ctx, req, newBaseRefRouteEvent(t, bigPayload), validSig) + require.Error(t, err) + require.Contains(t, err.Error(), "exceeds ref-route max") + }) + + t.Run("invalid txID hex", func(t *testing.T) { + ev := newBaseRefRouteEvent(t, validPayload) + ev.TxID = "0xnothex" + _, _, _, err := builder.BuildRefRouteTransactions(ctx, req, ev, validSig) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid txID") + }) + + t.Run("invalid sender length", func(t *testing.T) { + ev := newBaseRefRouteEvent(t, validPayload) + ev.Sender = "0xdeadbeef" // 4 bytes, not 20 + _, _, _, err := builder.BuildRefRouteTransactions(ctx, req, ev, validSig) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid sender length") + }) + + t.Run("amount overflows u64", func(t *testing.T) { + ev := newBaseRefRouteEvent(t, validPayload) + ev.Amount = "99999999999999999999999999" // > u64 max + _, _, _, err := builder.BuildRefRouteTransactions(ctx, req, ev, validSig) + require.Error(t, err) + require.Contains(t, err.Error(), "amount exceeds u64 max") + }) +} + +// TestCleanupOutboundArtifacts_NoOps covers the early-return paths in +// CleanupOutboundArtifacts that don't touch RPC. The test builder's RPCClient +// is a zero-value stub — any path that would reach GetAccountData would panic, +// which is exactly what we want here: it proves these cases short-circuit +// before any network call. +func TestCleanupOutboundArtifacts_NoOps(t *testing.T) { + builder := newTestBuilderWithKeypair(t) + ctx := context.Background() + + target := makeTxID(0x77) + + t.Run("nil data", func(t *testing.T) { + require.NoError(t, builder.CleanupOutboundArtifacts(ctx, nil)) + }) + + t.Run("empty payload", func(t *testing.T) { + ev := newBaseRefRouteEvent(t, "") + require.NoError(t, builder.CleanupOutboundArtifacts(ctx, ev)) + }) + + t.Run("invalid payload hex", func(t *testing.T) { + ev := newBaseRefRouteEvent(t, "0xnothex") + require.NoError(t, builder.CleanupOutboundArtifacts(ctx, ev)) + }) + + t.Run("direct route (instruction_id=1)", func(t *testing.T) { + // Withdraw payload — no StoredIxData PDA was ever created for it, so + // cleanup must short-circuit before any RPC. + withdrawPayload := buildExecutePayloadForTest(t, []GatewayAccountMeta{}, nil, 1, target) + ev := newBaseRefRouteEvent(t, withdrawPayload) + require.NoError(t, builder.CleanupOutboundArtifacts(ctx, ev)) + }) + + t.Run("empty ix_data on execute mode", func(t *testing.T) { + // Execute mode but no ix_data → ref route never engages, no PDA to clean. + emptyPayload := buildExecutePayloadForTest(t, []GatewayAccountMeta{}, []byte{}, 2, target) + ev := newBaseRefRouteEvent(t, emptyPayload) + require.NoError(t, builder.CleanupOutboundArtifacts(ctx, ev)) + }) + + t.Run("invalid txID hex", func(t *testing.T) { + ixData := []byte{0xAA, 0xBB} + payload := buildExecutePayloadForTest(t, []GatewayAccountMeta{}, ixData, 2, target) + ev := newBaseRefRouteEvent(t, payload) + ev.TxID = "0xnothex" + require.NoError(t, builder.CleanupOutboundArtifacts(ctx, ev)) + }) +} + +// ============================================================================= +// Devnet Simulation Tests +// +// Below this line: integration tests that build and simulate transactions +// against a real Solana devnet RPC + the deployed dummy gateway program. +// All are gated by t.Skip("skipping simulation tests") in setupDevnetSimulation +// — a developer un-skips locally to manually verify wire-level correctness +// against an actual cluster. CI never runs them. +// +// Constants (devnetRPCURL, testEVMPrivKeyHex, testSolanaKeypairJSON, etc.) +// and helpers (loadTestEVMKey, buildAndSimulate, …) live here because they're +// only meaningful in the devnet path. Unit tests above this line use the +// in-package mocks defined at the top of the file. +// ============================================================================= + const ( devnetGatewayAddress = "DJoFYDpgbTfxbXBv1QYhYGc9FK4J5FUKpYXAfSkHryXp" devnetRPCURL = "https://api.devnet.solana.com" @@ -1631,8 +2229,12 @@ func newDevnetOutbound(t *testing.T, amount, assetAddr, payload, revertMsg, txTy AssetAddr: assetAddr, Payload: payload, GasLimit: "400000", - TxType: txType, - RevertMsg: revertMsg, + // Gateway enforces gas_fee ≥ on-chain gas_used. Native SOL paths need + // ~952k (signature fee + executed_sub_tx rent). SPL paths additionally + // create the CEA ATA (~2.04M rent). 3M covers both with headroom. + GasFee: "3000000", + TxType: txType, + RevertMsg: revertMsg, }, evmKey } @@ -1911,155 +2513,85 @@ func TestSimulate_Rescue_SPLToken(t *testing.T) { requireSimulationSuccess(t, result) } -func TestGetNextNonce(t *testing.T) { - builder := newTestBuilder(t) - - t.Run("returns 0 with arbitrary address and finalized=true", func(t *testing.T) { - nonce, err := builder.GetNextNonce(context.Background(), "SomeAddress123", true) - require.NoError(t, err) - assert.Equal(t, uint64(0), nonce) - }) - - t.Run("returns 0 with empty address and finalized=false", func(t *testing.T) { - nonce, err := builder.GetNextNonce(context.Background(), "", false) - require.NoError(t, err) - assert.Equal(t, uint64(0), nonce) - }) -} - -func TestGetGasFeeUsed(t *testing.T) { - builder := newTestBuilder(t) - - t.Run("returns string zero for any tx hash", func(t *testing.T) { - fee, err := builder.GetGasFeeUsed(context.Background(), "5xYz...someTxHash") - require.NoError(t, err) - assert.Equal(t, "0", fee) - }) - - t.Run("returns string zero for empty tx hash", func(t *testing.T) { - fee, err := builder.GetGasFeeUsed(context.Background(), "") - require.NoError(t, err) - assert.Equal(t, "0", fee) - }) -} - -func TestNewTxBuilder_ChainConfig(t *testing.T) { - logger := zerolog.Nop() +// buildAndSimulateRefRoute drives the ref-finalize pipeline through +// simulation only — no broadcasts, no state changes, no SOL spent. +// +// Pipeline: GetOutboundSigningRequest → sign → BuildRefRouteTransactions → +// +// verify both txs fit the 1232-byte limit → simulate(storeTx). +// +// Limitation: the ref-finalize tx is built and size-checked, but NOT simulated. +// Its simulation would always fail because the StoredIxData PDA only exists +// after the store tx actually lands on-chain, and SimulateTransaction is not +// stateful. Asserting "ref-finalize is well-formed" is therefore left to the +// unit tests (TestBuildWithdrawAndExecuteRefData_ArgOrder, +// TestBuildWithdrawAndExecuteAccounts_RefRouteSlots). +func buildAndSimulateRefRoute( + t *testing.T, + rpcClient *RPCClient, + builder *TxBuilder, + data *uetypes.OutboundCreatedEvent, + evmKey *ecdsa.PrivateKey, +) (storeSim *rpc.SimulateTransactionResult, storedPDA solana.PublicKey, err error) { + t.Helper() - t.Run("valid protocolALT is stored", func(t *testing.T) { - altKey := solana.NewWallet().PublicKey() - cfg := &config.ChainSpecificConfig{ - ProtocolALT: altKey.String(), - } - builder, err := NewTxBuilder(&RPCClient{}, "solana:devnet", testGatewayAddress, "/tmp", logger, cfg) - require.NoError(t, err) - assert.Equal(t, altKey, builder.protocolALT) - }) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() - t.Run("invalid protocolALT is silently skipped", func(t *testing.T) { - cfg := &config.ChainSpecificConfig{ - ProtocolALT: "not-valid-base58!!!", - } - builder, err := NewTxBuilder(&RPCClient{}, "solana:devnet", testGatewayAddress, "/tmp", logger, cfg) - require.NoError(t, err) - assert.True(t, builder.protocolALT.IsZero(), "invalid ALT should result in zero pubkey") - }) + req, err := builder.GetOutboundSigningRequest(ctx, data, 0) + if err != nil { + return nil, solana.PublicKey{}, fmt.Errorf("GetOutboundSigningRequest: %w", err) + } + t.Logf(" signing_hash=0x%s", hex.EncodeToString(req.SigningHash)) - t.Run("valid tokenALTs are stored", func(t *testing.T) { - mint := solana.NewWallet().PublicKey() - alt := solana.NewWallet().PublicKey() - cfg := &config.ChainSpecificConfig{ - TokenALTs: map[string]string{ - mint.String(): alt.String(), - }, - } - builder, err := NewTxBuilder(&RPCClient{}, "solana:devnet", testGatewayAddress, "/tmp", logger, cfg) - require.NoError(t, err) - got, ok := builder.tokenALTs[mint] - require.True(t, ok, "expected token ALT entry for mint") - assert.Equal(t, alt, got) - }) + sig, recoveryID := signMessageHash(t, evmKey, req.SigningHash) + fullSig := append(sig, recoveryID) - t.Run("invalid tokenALT mint is skipped", func(t *testing.T) { - cfg := &config.ChainSpecificConfig{ - TokenALTs: map[string]string{ - "bad-mint": solana.NewWallet().PublicKey().String(), - }, - } - builder, err := NewTxBuilder(&RPCClient{}, "solana:devnet", testGatewayAddress, "/tmp", logger, cfg) - require.NoError(t, err) - assert.Len(t, builder.tokenALTs, 0) - }) + storeTx, refTx, storedPDA, err := builder.BuildRefRouteTransactions(ctx, req, data, fullSig) + if err != nil { + return nil, solana.PublicKey{}, fmt.Errorf("BuildRefRouteTransactions: %w", err) + } + t.Logf(" stored_ix_data PDA=%s", storedPDA.String()) - t.Run("invalid tokenALT address is skipped", func(t *testing.T) { - cfg := &config.ChainSpecificConfig{ - TokenALTs: map[string]string{ - solana.NewWallet().PublicKey().String(): "bad-alt", - }, - } - builder, err := NewTxBuilder(&RPCClient{}, "solana:devnet", testGatewayAddress, "/tmp", logger, cfg) - require.NoError(t, err) - assert.Len(t, builder.tokenALTs, 0) - }) + // Size sanity for both txs (the ref-finalize won't be simulated, so this + // is our only check that it's wire-correct on the size axis). + if storeBytes, mErr := storeTx.MarshalBinary(); mErr == nil { + t.Logf(" store_tx_bytes=%d", len(storeBytes)) + require.LessOrEqual(t, len(storeBytes), solanaTxMaxBytes, "store tx exceeds 1232-byte limit") + } + if refBytes, mErr := refTx.MarshalBinary(); mErr == nil { + t.Logf(" ref_finalize_tx_bytes=%d", len(refBytes)) + require.LessOrEqual(t, len(refBytes), solanaTxMaxBytes, "ref-finalize tx exceeds 1232-byte limit") + } - t.Run("nil chainConfig is fine", func(t *testing.T) { - builder, err := NewTxBuilder(&RPCClient{}, "solana:devnet", testGatewayAddress, "/tmp", logger, nil) - require.NoError(t, err) - assert.True(t, builder.protocolALT.IsZero()) - assert.Len(t, builder.tokenALTs, 0) - }) + storeSim, err = rpcClient.SimulateTransaction(ctx, storeTx) + if err != nil { + return nil, storedPDA, fmt.Errorf("simulate store: %w", err) + } + return storeSim, storedPDA, nil } -func TestBuildCreateATAIdempotentInstruction(t *testing.T) { - builder := newTestBuilder(t) - payer := solana.NewWallet().PublicKey() - owner := solana.NewWallet().PublicKey() - mint := solana.NewWallet().PublicKey() - - ix := builder.buildCreateATAIdempotentInstruction(payer, owner, mint) - - t.Run("program ID is ATA program", func(t *testing.T) { - expected := solana.MustPublicKeyFromBase58("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL") - assert.Equal(t, expected, ix.ProgramID()) - }) - - t.Run("has 6 accounts in correct order", func(t *testing.T) { - accounts := ix.Accounts() - require.Len(t, accounts, 6) - - // payer (signer, writable) - assert.Equal(t, payer, accounts[0].PublicKey) - assert.True(t, accounts[0].IsSigner) - assert.True(t, accounts[0].IsWritable) - - // ATA (writable, derived deterministically) - ataProgramID := solana.MustPublicKeyFromBase58("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL") - expectedATA, _, _ := solana.FindProgramAddress( - [][]byte{owner.Bytes(), solana.TokenProgramID.Bytes(), mint.Bytes()}, - ataProgramID, - ) - assert.Equal(t, expectedATA, accounts[1].PublicKey) - assert.True(t, accounts[1].IsWritable) - assert.False(t, accounts[1].IsSigner) - - // owner - assert.Equal(t, owner, accounts[2].PublicKey) - assert.False(t, accounts[2].IsWritable) - - // mint - assert.Equal(t, mint, accounts[3].PublicKey) - assert.False(t, accounts[3].IsWritable) +// TestSimulate_RefRoute_Execute simulates the store half of the ref-finalize +// pipeline against devnet. The ref-finalize half can't be simulated standalone +// because it depends on the store tx having actually landed on-chain first; +// see buildAndSimulateRefRoute for the full rationale. +func TestSimulate_RefRoute_Execute(t *testing.T) { + rpcClient, builder := setupDevnetSimulation(t) + defer rpcClient.Close() - // system program - assert.Equal(t, solana.SystemProgramID, accounts[4].PublicKey) + // 600 bytes of ix_data — large enough to force the ref route, well under + // the 921-byte cap on the store tx itself. + ixData := make([]byte, 600) + for i := range ixData { + ixData[i] = byte('A' + (i % 26)) + } + payload := buildMockExecutePayload(nil, ixData) + payloadHex := "0x" + hex.EncodeToString(payload) - // token program - assert.Equal(t, solana.TokenProgramID, accounts[5].PublicKey) - }) + data, evmKey := newDevnetOutbound(t, "10000000", "", payloadHex, "", "FUNDS_AND_PAYLOAD") + data.Recipient = devnetMemoProgram - t.Run("instruction data is [1] for CreateIdempotent", func(t *testing.T) { - data, err := ix.Data() - require.NoError(t, err) - assert.Equal(t, []byte{1}, data) - }) + storeSim, _, err := buildAndSimulateRefRoute(t, rpcClient, builder, data, evmKey) + require.NoError(t, err) + requireSimulationSuccess(t, storeSim) } From de51911729c4d14bb3390ec8d2cfa7d27b54eaa3 Mon Sep 17 00:00:00 2001 From: aman035 Date: Thu, 21 May 2026 13:55:06 +0530 Subject: [PATCH 05/17] add: closing artifact on failure --- .../tss/coordinator/coordinator_test.go | 5 +++ .../tss/txbroadcaster/broadcaster_test.go | 5 +++ .../tss/txresolver/resolver_test.go | 41 ++++++++++++++++++- universalClient/tss/txresolver/svm.go | 15 +++++++ 4 files changed, 65 insertions(+), 1 deletion(-) diff --git a/universalClient/tss/coordinator/coordinator_test.go b/universalClient/tss/coordinator/coordinator_test.go index 68481a85..f84f9741 100644 --- a/universalClient/tss/coordinator/coordinator_test.go +++ b/universalClient/tss/coordinator/coordinator_test.go @@ -75,6 +75,11 @@ func (m *coordMockTxBuilder) BroadcastFundMigrationTx(ctx context.Context, req * return args.String(0), args.Error(1) } +func (m *coordMockTxBuilder) CleanupOutboundArtifacts(ctx context.Context, data *uexecutortypes.OutboundCreatedEvent) error { + args := m.Called(ctx, data) + return args.Error(0) +} + type coordMockChainClient struct { builder *coordMockTxBuilder builderErr error diff --git a/universalClient/tss/txbroadcaster/broadcaster_test.go b/universalClient/tss/txbroadcaster/broadcaster_test.go index 6725f154..8e828232 100644 --- a/universalClient/tss/txbroadcaster/broadcaster_test.go +++ b/universalClient/tss/txbroadcaster/broadcaster_test.go @@ -75,6 +75,11 @@ func (m *mockTxBuilder) BroadcastFundMigrationTx(ctx context.Context, req *commo return args.String(0), args.Error(1) } +func (m *mockTxBuilder) CleanupOutboundArtifacts(ctx context.Context, data *uexecutortypes.OutboundCreatedEvent) error { + args := m.Called(ctx, data) + return args.Error(0) +} + type mockChainClient struct{ builder *mockTxBuilder } func (m *mockChainClient) Start(context.Context) error { return nil } diff --git a/universalClient/tss/txresolver/resolver_test.go b/universalClient/tss/txresolver/resolver_test.go index 8ac6c099..1ed3cd25 100644 --- a/universalClient/tss/txresolver/resolver_test.go +++ b/universalClient/tss/txresolver/resolver_test.go @@ -74,6 +74,11 @@ func (m *mockTxBuilder) BroadcastFundMigrationTx(ctx context.Context, req *commo return args.String(0), args.Error(1) } +func (m *mockTxBuilder) CleanupOutboundArtifacts(ctx context.Context, data *uexecutortypes.OutboundCreatedEvent) error { + args := m.Called(ctx, data) + return args.Error(0) +} + type mockChainClient struct{ builder *mockTxBuilder } func (m *mockChainClient) Start(context.Context) error { return nil } @@ -270,7 +275,8 @@ func TestSVM_PDAExists_MarksCompleted(t *testing.T) { } func TestSVM_PDANotFound_VotesFailureAndReverts(t *testing.T) { - // PDA not found → vote failure → REVERTED. + // PDA not found → vote failure → REVERTED, plus cleanup of any orphaned + // StoredIxData PDA from the ref-finalize route. evtStore, db := setupTestDB(t) builder := &mockTxBuilder{} client := &mockChainClient{builder: builder} @@ -280,6 +286,7 @@ func TestSVM_PDANotFound_VotesFailureAndReverts(t *testing.T) { insertBroadcastedEvent(t, db, "ev-1", "solana:mainnet", "solana:mainnet:", eventData) builder.On("IsAlreadyExecuted", mock.Anything, "tx-123").Return(false, nil) + builder.On("CleanupOutboundArtifacts", mock.Anything, mock.Anything).Return(nil).Once() // No PushSigner — voteFailure will log warning and return nil, but won't mark REVERTED // (because pushSigner is nil, it returns early). This validates the code path. @@ -291,6 +298,38 @@ func TestSVM_PDANotFound_VotesFailureAndReverts(t *testing.T) { // The event stays BROADCASTED because the vote+revert is skipped. updated := getEvent(t, db, "ev-1") require.Equal(t, store.StatusBroadcasted, updated.Status) + // Explicit assertion: cleanup must have been invoked once for the failure path. + builder.AssertExpectations(t) +} + +func TestSVM_PDANotFound_CleanupErrorIsBestEffort(t *testing.T) { + // A failure from CleanupOutboundArtifacts must NOT crash the resolver or + // roll back the failure-vote transition. Cleanup is opex-only. + evtStore, db := setupTestDB(t) + builder := &mockTxBuilder{} + client := &mockChainClient{builder: builder} + ch := newTestChains(t, "solana:mainnet", uregistrytypes.VmType_SVM, client) + + eventData := makeOutboundEventData("tx-123", "utx-456", "solana:mainnet") + insertBroadcastedEvent(t, db, "ev-1", "solana:mainnet", "solana:mainnet:", eventData) + + builder.On("IsAlreadyExecuted", mock.Anything, "tx-123").Return(false, nil) + builder.On("CleanupOutboundArtifacts", mock.Anything, mock.Anything). + Return(assert.AnError).Once() + + resolver := newResolver(evtStore, ch) + ev := getEvent(t, db, "ev-1") + + // Should not panic even though cleanup returned an error. + require.NotPanics(t, func() { + resolver.resolveSVM(context.Background(), &ev, "solana:mainnet") + }) + + // State transition behaves identically to the success-cleanup case + // (BROADCASTED here because no push signer is configured). + updated := getEvent(t, db, "ev-1") + require.Equal(t, store.StatusBroadcasted, updated.Status) + builder.AssertExpectations(t) } func TestSVM_PDACheckFails_StaysBroadcasted(t *testing.T) { diff --git a/universalClient/tss/txresolver/svm.go b/universalClient/tss/txresolver/svm.go index 6243afa5..0405b8b6 100644 --- a/universalClient/tss/txresolver/svm.go +++ b/universalClient/tss/txresolver/svm.go @@ -2,8 +2,10 @@ package txresolver import ( "context" + "encoding/json" "github.com/pushchain/push-chain-node/universalClient/store" + uexecutortypes "github.com/pushchain/push-chain-node/x/uexecutor/types" ) // resolveSVM checks the on-chain ExecutedTx PDA and moves the event to COMPLETED or REVERTED. @@ -55,4 +57,17 @@ func (r *Resolver) resolveSVM(ctx context.Context, event *store.Event, chainID s // PDA not found — tx was not executed on destination chain, no gas consumed _ = r.voteOutboundFailureAndMarkReverted(ctx, event, txID, utxID, "", 0, "0", "tx not executed on destination chain") + + // Best-effort: close any orphaned StoredIxData PDA from the ref-finalize + // route so the relayer recovers the ~0.002 SOL of rent. No-op for direct- + // route outbounds and for ref-route outbounds whose PDA was already auto- + // closed by a successful finalize. + var data uexecutortypes.OutboundCreatedEvent + if err := json.Unmarshal(event.EventData, &data); err != nil { + return + } + if err := builder.CleanupOutboundArtifacts(ctx, &data); err != nil { + r.logger.Warn().Err(err).Str("event_id", event.EventID).Str("tx_id", txID). + Msg("SVM cleanup of stored_ix_data PDA failed; rent recovery deferred") + } } From 5388157c14f2ceb5360c169193a8f989ec76a68f Mon Sep 17 00:00:00 2001 From: aman035 Date: Fri, 22 May 2026 16:46:45 +0530 Subject: [PATCH 06/17] refactor: return last error --- universalClient/chains/svm/rpc_client.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/universalClient/chains/svm/rpc_client.go b/universalClient/chains/svm/rpc_client.go index 64f2cf5f..ab6dcfe8 100644 --- a/universalClient/chains/svm/rpc_client.go +++ b/universalClient/chains/svm/rpc_client.go @@ -112,6 +112,7 @@ func (rc *RPCClient) executeWithFailover(ctx context.Context, operation string, // Snapshot start index once per call so concurrent callers can't share // counter advances and retry the same failing endpoint. startIndex := atomic.AddUint64(&rc.index, 1) - 1 + var lastErr error for attempt := 0; attempt < maxAttempts; attempt++ { if ctx != nil { select { @@ -131,6 +132,7 @@ func (rc *RPCClient) executeWithFailover(ctx context.Context, operation string, if err == nil { return nil } + lastErr = err rc.logger.Warn(). Str("operation", operation). @@ -139,6 +141,9 @@ func (rc *RPCClient) executeWithFailover(ctx context.Context, operation string, Msg("operation failed, trying next endpoint") } + if lastErr != nil { + return fmt.Errorf("operation %s failed after trying %d endpoints: %w", operation, maxAttempts, lastErr) + } return fmt.Errorf("operation %s failed after trying %d endpoints", operation, maxAttempts) } From 7ba075a7f570f6415e33c11442939949ca66a205 Mon Sep 17 00:00:00 2001 From: aman035 Date: Fri, 22 May 2026 16:48:01 +0530 Subject: [PATCH 07/17] remove: best effor approach --- universalClient/chains/common/types.go | 8 ---- universalClient/chains/evm/tx_builder.go | 5 -- .../tss/coordinator/coordinator_test.go | 5 -- .../tss/txbroadcaster/broadcaster_test.go | 5 -- .../tss/txresolver/resolver_test.go | 47 ++----------------- universalClient/tss/txresolver/svm.go | 15 ------ 6 files changed, 4 insertions(+), 81 deletions(-) diff --git a/universalClient/chains/common/types.go b/universalClient/chains/common/types.go index c3d87b9c..d0526d59 100644 --- a/universalClient/chains/common/types.go +++ b/universalClient/chains/common/types.go @@ -83,14 +83,6 @@ type TxBuilder interface { // BroadcastFundMigrationTx assembles and broadcasts a signed fund migration transaction. BroadcastFundMigrationTx(ctx context.Context, req *UnsignedSigningReq, data *FundMigrationData, signature []byte) (string, error) - - // CleanupOutboundArtifacts performs best-effort, idempotent cleanup of any - // chain-specific artifacts left behind by a terminally-failed outbound. - // - SVM: closes the StoredIxData PDA from the ref-finalize route if present, - // recovering ~0.002 SOL of rent to the relayer. - // - EVM: no-op (no off-chain artifacts). - // Safe to invoke for any terminal-failure outbound regardless of route. - CleanupOutboundArtifacts(ctx context.Context, data *uetypes.OutboundCreatedEvent) error } // UniversalTx Payload diff --git a/universalClient/chains/evm/tx_builder.go b/universalClient/chains/evm/tx_builder.go index c64f40e6..317d9caa 100644 --- a/universalClient/chains/evm/tx_builder.go +++ b/universalClient/chains/evm/tx_builder.go @@ -445,11 +445,6 @@ func (tb *TxBuilder) IsAlreadyExecuted(ctx context.Context, txID string) (bool, return false, nil } -// CleanupOutboundArtifacts is a no-op on EVM — there are no off-chain artifacts -// (no PDAs, no rent) tied to an outbound's lifetime. -func (tb *TxBuilder) CleanupOutboundArtifacts(ctx context.Context, data *uetypes.OutboundCreatedEvent) error { - return nil -} // GetGasFeeUsed returns the gas fee used by a transaction on the EVM chain. // Fetches the receipt for gasUsed and the transaction for gasPrice, then returns diff --git a/universalClient/tss/coordinator/coordinator_test.go b/universalClient/tss/coordinator/coordinator_test.go index efd7f667..ac81688b 100644 --- a/universalClient/tss/coordinator/coordinator_test.go +++ b/universalClient/tss/coordinator/coordinator_test.go @@ -76,11 +76,6 @@ func (m *coordMockTxBuilder) BroadcastFundMigrationTx(ctx context.Context, req * return args.String(0), args.Error(1) } -func (m *coordMockTxBuilder) CleanupOutboundArtifacts(ctx context.Context, data *uexecutortypes.OutboundCreatedEvent) error { - args := m.Called(ctx, data) - return args.Error(0) -} - type coordMockChainClient struct { builder *coordMockTxBuilder builderErr error diff --git a/universalClient/tss/txbroadcaster/broadcaster_test.go b/universalClient/tss/txbroadcaster/broadcaster_test.go index f4c56244..6404119b 100644 --- a/universalClient/tss/txbroadcaster/broadcaster_test.go +++ b/universalClient/tss/txbroadcaster/broadcaster_test.go @@ -76,11 +76,6 @@ func (m *mockTxBuilder) BroadcastFundMigrationTx(ctx context.Context, req *commo return args.String(0), args.Error(1) } -func (m *mockTxBuilder) CleanupOutboundArtifacts(ctx context.Context, data *uexecutortypes.OutboundCreatedEvent) error { - args := m.Called(ctx, data) - return args.Error(0) -} - type mockChainClient struct{ builder *mockTxBuilder } func (m *mockChainClient) Start(context.Context) error { return nil } diff --git a/universalClient/tss/txresolver/resolver_test.go b/universalClient/tss/txresolver/resolver_test.go index 1ed3cd25..a30d7d55 100644 --- a/universalClient/tss/txresolver/resolver_test.go +++ b/universalClient/tss/txresolver/resolver_test.go @@ -74,11 +74,6 @@ func (m *mockTxBuilder) BroadcastFundMigrationTx(ctx context.Context, req *commo return args.String(0), args.Error(1) } -func (m *mockTxBuilder) CleanupOutboundArtifacts(ctx context.Context, data *uexecutortypes.OutboundCreatedEvent) error { - args := m.Called(ctx, data) - return args.Error(0) -} - type mockChainClient struct{ builder *mockTxBuilder } func (m *mockChainClient) Start(context.Context) error { return nil } @@ -275,8 +270,9 @@ func TestSVM_PDAExists_MarksCompleted(t *testing.T) { } func TestSVM_PDANotFound_VotesFailureAndReverts(t *testing.T) { - // PDA not found → vote failure → REVERTED, plus cleanup of any orphaned - // StoredIxData PDA from the ref-finalize route. + // PDA not found → vote failure → REVERTED. + // Note: orphaned StoredIxData PDAs are reclaimed by the periodic + // RentReclaimer in svm/rent_reclaimer.go, not from this hot path. evtStore, db := setupTestDB(t) builder := &mockTxBuilder{} client := &mockChainClient{builder: builder} @@ -286,47 +282,12 @@ func TestSVM_PDANotFound_VotesFailureAndReverts(t *testing.T) { insertBroadcastedEvent(t, db, "ev-1", "solana:mainnet", "solana:mainnet:", eventData) builder.On("IsAlreadyExecuted", mock.Anything, "tx-123").Return(false, nil) - builder.On("CleanupOutboundArtifacts", mock.Anything, mock.Anything).Return(nil).Once() - // No PushSigner — voteFailure will log warning and return nil, but won't mark REVERTED - // (because pushSigner is nil, it returns early). This validates the code path. + // No PushSigner — voteFailure logs a warning and returns nil without marking REVERTED. resolver := newResolver(evtStore, ch) ev := getEvent(t, db, "ev-1") resolver.resolveSVM(context.Background(), &ev, "solana:mainnet") - // With no push signer, voteOutboundFailureAndMarkReverted returns nil early (logs warning). - // The event stays BROADCASTED because the vote+revert is skipped. - updated := getEvent(t, db, "ev-1") - require.Equal(t, store.StatusBroadcasted, updated.Status) - // Explicit assertion: cleanup must have been invoked once for the failure path. - builder.AssertExpectations(t) -} - -func TestSVM_PDANotFound_CleanupErrorIsBestEffort(t *testing.T) { - // A failure from CleanupOutboundArtifacts must NOT crash the resolver or - // roll back the failure-vote transition. Cleanup is opex-only. - evtStore, db := setupTestDB(t) - builder := &mockTxBuilder{} - client := &mockChainClient{builder: builder} - ch := newTestChains(t, "solana:mainnet", uregistrytypes.VmType_SVM, client) - - eventData := makeOutboundEventData("tx-123", "utx-456", "solana:mainnet") - insertBroadcastedEvent(t, db, "ev-1", "solana:mainnet", "solana:mainnet:", eventData) - - builder.On("IsAlreadyExecuted", mock.Anything, "tx-123").Return(false, nil) - builder.On("CleanupOutboundArtifacts", mock.Anything, mock.Anything). - Return(assert.AnError).Once() - - resolver := newResolver(evtStore, ch) - ev := getEvent(t, db, "ev-1") - - // Should not panic even though cleanup returned an error. - require.NotPanics(t, func() { - resolver.resolveSVM(context.Background(), &ev, "solana:mainnet") - }) - - // State transition behaves identically to the success-cleanup case - // (BROADCASTED here because no push signer is configured). updated := getEvent(t, db, "ev-1") require.Equal(t, store.StatusBroadcasted, updated.Status) builder.AssertExpectations(t) diff --git a/universalClient/tss/txresolver/svm.go b/universalClient/tss/txresolver/svm.go index 0405b8b6..6243afa5 100644 --- a/universalClient/tss/txresolver/svm.go +++ b/universalClient/tss/txresolver/svm.go @@ -2,10 +2,8 @@ package txresolver import ( "context" - "encoding/json" "github.com/pushchain/push-chain-node/universalClient/store" - uexecutortypes "github.com/pushchain/push-chain-node/x/uexecutor/types" ) // resolveSVM checks the on-chain ExecutedTx PDA and moves the event to COMPLETED or REVERTED. @@ -57,17 +55,4 @@ func (r *Resolver) resolveSVM(ctx context.Context, event *store.Event, chainID s // PDA not found — tx was not executed on destination chain, no gas consumed _ = r.voteOutboundFailureAndMarkReverted(ctx, event, txID, utxID, "", 0, "0", "tx not executed on destination chain") - - // Best-effort: close any orphaned StoredIxData PDA from the ref-finalize - // route so the relayer recovers the ~0.002 SOL of rent. No-op for direct- - // route outbounds and for ref-route outbounds whose PDA was already auto- - // closed by a successful finalize. - var data uexecutortypes.OutboundCreatedEvent - if err := json.Unmarshal(event.EventData, &data); err != nil { - return - } - if err := builder.CleanupOutboundArtifacts(ctx, &data); err != nil { - r.logger.Warn().Err(err).Str("event_id", event.EventID).Str("tx_id", txID). - Msg("SVM cleanup of stored_ix_data PDA failed; rent recovery deferred") - } } From 7089109a771c2bc5f1e0f01a62e0d99bc68e3226 Mon Sep 17 00:00:00 2001 From: aman035 Date: Fri, 22 May 2026 17:52:00 +0530 Subject: [PATCH 08/17] fix: tx builder --- universalClient/chains/svm/tx_builder.go | 294 +++++++++--------- universalClient/chains/svm/tx_builder_test.go | 145 +++++---- 2 files changed, 224 insertions(+), 215 deletions(-) diff --git a/universalClient/chains/svm/tx_builder.go b/universalClient/chains/svm/tx_builder.go index bd6f3b82..a503ce48 100644 --- a/universalClient/chains/svm/tx_builder.go +++ b/universalClient/chains/svm/tx_builder.go @@ -25,12 +25,12 @@ package svm import ( - "bytes" "context" "crypto/sha256" "encoding/binary" "encoding/hex" "encoding/json" + "errors" "fmt" "math/big" "os" @@ -43,6 +43,7 @@ import ( "github.com/gagliardetto/solana-go" addresslookuptable "github.com/gagliardetto/solana-go/programs/address-lookup-table" "github.com/gagliardetto/solana-go/rpc" + "github.com/gagliardetto/solana-go/rpc/jsonrpc" "github.com/rs/zerolog" "github.com/pushchain/push-chain-node/universalClient/chains/common" @@ -566,16 +567,19 @@ func (tb *TxBuilder) BroadcastOutboundSigningRequest( return txHash, nil } -// broadcastRefRoute executes the 2-tx ref-finalize flow: -// 1. If the stored_ix_data PDA doesn't already exist on-chain, broadcast the -// store_execute_ix_data tx and wait for it to confirm. Existing PDA = retry -// after a partial earlier attempt; we skip directly to step 2. -// 2. Broadcast finalize_universal_tx_with_ix_data_ref using the same TSS -// signature payload as the direct route. +// storedPDAExists is the race-recovery probe — if the PDA is on-chain we can +// proceed to finalize regardless of whose store_execute_ix_data put it there. +func (tb *TxBuilder) storedPDAExists(ctx context.Context, storedPDA solana.PublicKey) bool { + data, _ := tb.rpcClient.GetAccountData(ctx, storedPDA) + return len(data) > 0 +} + +// broadcastRefRoute drives the 2-tx ref-finalize flow: +// 1. store_execute_ix_data (skipped if PDA already exists) +// 2. finalize_universal_tx_with_ix_data_ref (same TSS payload as direct route) // -// Returns the ref-finalize tx hash. The store tx hash is logged but not -// propagated — only finalize lands the ExecutedSubTx PDA that the resolver -// keys on for success/failure. +// Returns the finalize tx hash — only finalize lands the ExecutedSubTx PDA +// the resolver keys on. func (tb *TxBuilder) broadcastRefRoute( ctx context.Context, req *common.UnsignedSigningReq, @@ -587,34 +591,50 @@ func (tb *TxBuilder) broadcastRefRoute( return "", fmt.Errorf("failed to build ref-route transactions: %w", err) } - // Skip the store broadcast if the canonical PDA already exists (retry after - // a previous partial attempt, or another relayer beat us to it — either way - // we can proceed directly to ref-finalize). - existingData, _ := tb.rpcClient.GetAccountData(ctx, storedPDA) - if len(existingData) == 0 { - storeHash, err := tb.rpcClient.BroadcastTransaction(ctx, storeTx) - if err != nil { - return "", fmt.Errorf("failed to broadcast store_execute_ix_data: %w", err) - } + // Ensure StoredIxData PDA is on-chain before finalize. + // + // PDA is content-addressed by (sub_tx_id, keccak256(ix_data)) — every + // validator derives the same address with the same payload. Only one + // store_execute_ix_data wins on-chain (Anchor `init` dedups); losers see + // AccountAlreadyInUse. Either way finalize proceeds against the winner's PDA. + if tb.storedPDAExists(ctx, storedPDA) { tb.logger.Info(). - Str("store_tx_hash", storeHash). Str("stored_pda", storedPDA.String()). - Msg("store_execute_ix_data broadcast, awaiting confirmation") + Msg("stored_ix_data PDA already present, skipping store tx") + } else { + storeHash, broadcastErr := tb.rpcClient.BroadcastTransaction(ctx, storeTx) + if broadcastErr != nil { + if !tb.storedPDAExists(ctx, storedPDA) { + return "", fmt.Errorf("failed to broadcast store_execute_ix_data: %w", broadcastErr) + } + tb.logger.Info(). + Str("stored_pda", storedPDA.String()). + Msg("store_execute_ix_data broadcast lost race; PDA already exists, proceeding to finalize") + } else { + tb.logger.Info(). + Str("store_tx_hash", storeHash). + Str("stored_pda", storedPDA.String()). + Msg("store_execute_ix_data broadcast, awaiting confirmation") - sig, sigErr := solana.SignatureFromBase58(storeHash) - if sigErr != nil { - return "", fmt.Errorf("invalid store tx signature %s: %w", storeHash, sigErr) - } - if err := tb.rpcClient.WaitForSignatureConfirmation(ctx, sig, rpc.ConfirmationStatusConfirmed, 30*time.Second); err != nil { - return "", fmt.Errorf("store_execute_ix_data did not confirm: %w", err) + sig, sigErr := solana.SignatureFromBase58(storeHash) + if sigErr != nil { + return "", fmt.Errorf("invalid store tx signature %s: %w", storeHash, sigErr) + } + if confirmErr := tb.rpcClient.WaitForSignatureConfirmation(ctx, sig, rpc.ConfirmationStatusConfirmed, 30*time.Second); confirmErr != nil { + if !tb.storedPDAExists(ctx, storedPDA) { + return "", fmt.Errorf("store_execute_ix_data did not confirm: %w", confirmErr) + } + tb.logger.Info(). + Str("stored_pda", storedPDA.String()). + Msg("our store_execute_ix_data failed confirmation but PDA exists (race-lost); proceeding to finalize") + } } - } else { - tb.logger.Info(). - Str("stored_pda", storedPDA.String()). - Msg("stored_ix_data PDA already present, skipping store tx") } - refHash, err := tb.rpcClient.BroadcastTransaction(ctx, refTx) + // Bounded retry on preflight lag: a laggy RPC may not yet see the just- + // confirmed store and reject finalize with Anchor 3012. Retry through other + // RPCs in the pool until one is synced. + refHash, err := tb.broadcastFinalizeWithRetry(ctx, refTx) if err != nil { return "", fmt.Errorf("failed to broadcast finalize_universal_tx_with_ix_data_ref: %w", err) } @@ -626,120 +646,107 @@ func (tb *TxBuilder) broadcastRefRoute( return refHash, nil } -// CleanupOutboundArtifacts best-effort closes the StoredIxData PDA for a -// terminally-failed ref-route outbound to recover the ~0.002 SOL of rent the -// relayer paid during store_execute_ix_data. Safe to call for non-ref-route -// outbounds: the PDA-presence probe returns nothing and we no-op. Safe to call -// after happy-path finalize: the program auto-closed the PDA, so the probe also -// returns nothing. -// -// Caller responsibility: only invoke after the outbound has reached a terminal -// failure state. Calling during a retry window would force a re-upload of the -// payload on the next attempt. -func (tb *TxBuilder) CleanupOutboundArtifacts(ctx context.Context, data *uetypes.OutboundCreatedEvent) error { - if data == nil { - return nil - } - - payloadHex := removeHexPrefix(data.Payload) - if payloadHex == "" { - return nil - } - payloadBytes, err := hex.DecodeString(payloadHex) - if err != nil || len(payloadBytes) == 0 { - return nil - } - _, ixData, instructionID, _, err := decodePayload(payloadBytes) - if err != nil || instructionID != 2 || len(ixData) == 0 { - return nil - } +// Retry budget: 5 attempts × exponential backoff (500ms → 1s → 2s → 4s) ≈ 8s +// total, well inside the ~60-90s blockhash validity window — no need to rebuild. +const ( + finalizeRetryAttempts = 5 + finalizeRetryInitialDelay = 500 * time.Millisecond + finalizeRetryMaxDelay = 4 * time.Second - var subTxID [32]byte - txIDBytes, err := hex.DecodeString(removeHexPrefix(data.TxID)) - if err != nil { - return nil - } - if len(txIDBytes) == 32 { - copy(subTxID[:], txIDBytes) - } else if len(txIDBytes) > 0 { - copy(subTxID[32-len(txIDBytes):], txIDBytes) - } else { - return nil - } + // Anchor: AccountNotInitialized. In ref-finalize this can only fire on + // stored_ix_data — other deserialized accounts are program-init time. + anchorErrAccountNotInitialized = 3012 - var ixDataHash [32]byte - copy(ixDataHash[:], crypto.Keccak256(ixData)) + // Solana JSON-RPC: preflight simulation failed. Scoped to this code so + // non-preflight errors don't retry. + rpcSimulationFailedCode = -32002 +) - storedPDA, err := tb.deriveStoredIxDataPDA(subTxID, ixDataHash) - if err != nil { - return fmt.Errorf("failed to derive stored_ix_data PDA: %w", err) +// broadcastFinalizeWithRetry retries finalize_universal_tx_with_ix_data_ref on +// Anchor's AccountNotInitialized (3012); other errors return immediately. +func (tb *TxBuilder) broadcastFinalizeWithRetry(ctx context.Context, refTx *solana.Transaction) (string, error) { + delay := finalizeRetryInitialDelay + var lastErr error + for attempt := range finalizeRetryAttempts { + hash, err := tb.rpcClient.BroadcastTransaction(ctx, refTx) + if err == nil { + if attempt > 0 { + tb.logger.Info().Int("attempts", attempt+1). + Msg("ref-finalize broadcast succeeded after preflight-lag retries") + } + return hash, nil + } + if !isAccountNotInitializedError(err) { + return "", err + } + lastErr = err + tb.logger.Warn().Int("attempt", attempt+1).Dur("backoff", delay). + Msg("ref-finalize preflight reported AccountNotInitialized, retrying after RPC sync delay") + + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-time.After(delay): + } + if delay < finalizeRetryMaxDelay { + delay *= 2 + if delay > finalizeRetryMaxDelay { + delay = finalizeRetryMaxDelay + } + } } + return "", fmt.Errorf("ref-finalize preflight still lagging after %d retries: %w", finalizeRetryAttempts, lastErr) +} - existing, _ := tb.rpcClient.GetAccountData(ctx, storedPDA) - if len(existing) == 0 { - return nil +// isAccountNotInitializedError matches a preflight rejection with Anchor 3012. +// Matches on code only — preflight errors don't carry the account name in +// structured form (only in program logs). +func isAccountNotInitializedError(err error) bool { + var rpcErr *jsonrpc.RPCError + if !errors.As(err, &rpcErr) { + return false } - - relayerKeypair, err := tb.loadRelayerKeypair() - if err != nil { - return fmt.Errorf("failed to load relayer keypair for cleanup: %w", err) + if rpcErr.Code != rpcSimulationFailedCode { + return false } - - // Only the original uploader can close on the failure path (contract enforces - // store_refund_recipient match + caller==store_refund_recipient when - // ExecutedSubTx is absent). Parse store_refund_recipient from the PDA data - // and bail out silently if we weren't the uploader — avoids wasting a tx - // the contract will reject with InvalidAccount. - // - // StoredIxData layout: 8 disc | 1 bump | 32 store_refund_recipient | 4 vec_len | N ix_data - const refundRecipientOffset = 8 + 1 - if len(existing) < refundRecipientOffset+32 { - return nil + data, ok := rpcErr.Data.(map[string]any) + if !ok { + return false } - if !bytes.Equal(existing[refundRecipientOffset:refundRecipientOffset+32], relayerKeypair.PublicKey().Bytes()) { - tb.logger.Debug(). - Str("stored_pda", storedPDA.String()). - Str("sub_tx_id", data.TxID). - Msg("stored_ix_data PDA owned by another relayer; skipping cleanup") - return nil + inner, ok := data["err"].(map[string]any) + if !ok { + return false } - - ixData2 := tb.buildCloseStoredIxDataData(subTxID, ixDataHash) - accounts := tb.buildCloseStoredIxDataAccounts(relayerKeypair.PublicKey(), storedPDA) - closeIx := solana.NewInstruction(tb.gatewayAddress, accounts, ixData2) - - recentBlockhash, err := tb.rpcClient.GetRecentBlockhash(ctx) - if err != nil { - return fmt.Errorf("failed to get blockhash for cleanup: %w", err) + instErr, ok := inner["InstructionError"].([]any) + if !ok || len(instErr) != 2 { + return false } - tx, err := solana.NewTransaction( - []solana.Instruction{closeIx}, - recentBlockhash, - solana.TransactionPayer(relayerKeypair.PublicKey()), - ) - if err != nil { - return fmt.Errorf("failed to build close_stored_ix_data tx: %w", err) + detail, ok := instErr[1].(map[string]any) + if !ok { + return false } - if _, err := tx.Sign(func(key solana.PublicKey) *solana.PrivateKey { - if key.Equals(relayerKeypair.PublicKey()) { - priv := relayerKeypair - return &priv - } - return nil - }); err != nil { - return fmt.Errorf("failed to sign close_stored_ix_data tx: %w", err) + custom, ok := detail["Custom"] + if !ok { + return false } + return toUint64(custom) == anchorErrAccountNotInitialized +} - hash, err := tb.rpcClient.BroadcastTransaction(ctx, tx) - if err != nil { - return fmt.Errorf("failed to broadcast close_stored_ix_data: %w", err) - } - tb.logger.Info(). - Str("close_tx_hash", hash). - Str("stored_pda", storedPDA.String()). - Str("sub_tx_id", data.TxID). - Msg("close_stored_ix_data broadcast for failure-tail cleanup") - return nil +func toUint64(v any) uint64 { + switch n := v.(type) { + case float64: + return uint64(n) + case json.Number: + i, _ := n.Int64() + return uint64(i) + case int: + return uint64(n) + case int64: + return uint64(n) + case uint64: + return n + } + return 0 } // fetchAddressTables fetches Address Lookup Table state for V0 transactions. @@ -2375,28 +2382,13 @@ func (tb *TxBuilder) buildStoreIxDataAccounts(caller, storedIxDataPDA solana.Pub } } -// buildCloseStoredIxDataData constructs the Borsh-serialized instruction data -// for close_stored_ix_data. -// -// Offset Size Field -// 0 8 discriminator -// 8 32 sub_tx_id [u8; 32] -// 40 32 ix_data_hash [u8; 32] -func (tb *TxBuilder) buildCloseStoredIxDataData(subTxID, ixDataHash [32]byte) []byte { - data := make([]byte, 0, 8+32+32) - data = append(data, discCloseStoredIxData[:]...) - data = append(data, subTxID[:]...) - data = append(data, ixDataHash[:]...) - return data -} - // buildCloseStoredIxDataAccounts builds the accounts list for close_stored_ix_data. // // # Account Flags Notes -// 1 caller signer, mut Relayer = stored.store_refund_recipient on failure path +// 1 caller signer, mut The relayer // 2 stored_ix_data mut PDA being closed -// 3 store_refund_recipient mut Rent destination (= caller on failure path) -// 4 executed_sub_tx read-only Omitted on the failure path (PDA doesn't exist) +// 3 store_refund_recipient mut Rent destination (= caller for the cron path) +// 4 executed_sub_tx optional Omitted; widens permission to "anyone" when present func (tb *TxBuilder) buildCloseStoredIxDataAccounts(caller, storedIxDataPDA solana.PublicKey) []*solana.AccountMeta { return []*solana.AccountMeta{ {PublicKey: caller, IsWritable: true, IsSigner: true}, diff --git a/universalClient/chains/svm/tx_builder_test.go b/universalClient/chains/svm/tx_builder_test.go index 7d3424b3..7eaaad98 100644 --- a/universalClient/chains/svm/tx_builder_test.go +++ b/universalClient/chains/svm/tx_builder_test.go @@ -7,6 +7,7 @@ import ( "crypto/sha256" "encoding/binary" "encoding/hex" + "encoding/json" "fmt" "os" "path/filepath" @@ -16,6 +17,7 @@ import ( "github.com/ethereum/go-ethereum/crypto" "github.com/gagliardetto/solana-go" "github.com/gagliardetto/solana-go/rpc" + "github.com/gagliardetto/solana-go/rpc/jsonrpc" "github.com/rs/zerolog" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -1268,6 +1270,59 @@ func TestBuildRescueAccounts(t *testing.T) { }) } +func TestIsAccountNotInitializedError(t *testing.T) { + // Builds an RPCError that matches the wire shape Solana returns for a + // preflight failure with the given Anchor custom error code. + preflightErr := func(custom uint64) error { + return &jsonrpc.RPCError{ + Code: -32002, + Message: "Transaction simulation failed", + Data: map[string]any{ + "err": map[string]any{ + "InstructionError": []any{ + float64(1), + map[string]any{"Custom": float64(custom)}, + }, + }, + }, + } + } + + tests := []struct { + name string + err error + want bool + }{ + {"3012 unwrapped", preflightErr(3012), true}, + {"3012 wrapped via fmt.Errorf %w", fmt.Errorf("broadcast failed: %w", preflightErr(3012)), true}, + {"3012 with json.Number", &jsonrpc.RPCError{ + Code: -32002, + Data: map[string]any{ + "err": map[string]any{ + "InstructionError": []any{ + float64(1), + map[string]any{"Custom": json.Number("3012")}, + }, + }, + }, + }, true}, + {"6005 not 3012", preflightErr(6005), false}, + {"different RPC code", &jsonrpc.RPCError{Code: -32000, Data: map[string]any{}}, false}, + {"missing InstructionError", &jsonrpc.RPCError{Code: -32002, Data: map[string]any{"err": map[string]any{}}}, false}, + {"plain error", fmt.Errorf("transport failure"), false}, + {"nil error", nil, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isAccountNotInitializedError(tt.err) + if got != tt.want { + t.Errorf("isAccountNotInitializedError = %v, want %v", got, tt.want) + } + }) + } +} + func TestRemoveHexPrefix(t *testing.T) { tests := []struct { input string @@ -1425,6 +1480,29 @@ func TestAnchorDiscriminatorKnownValues(t *testing.T) { } } +// TestRefRouteDiscriminatorConstants pins the hardcoded discriminator byte +// arrays for the ref-route instructions to their canonical Anchor derivation. +// These are protocol-critical: a single-byte typo would silently make every +// store / finalize-ref / close call fail against the on-chain gateway with a +// "fallback function not found" error, with no signal at compile time. +func TestRefRouteDiscriminatorConstants(t *testing.T) { + cases := []struct { + method string + got [8]byte + }{ + {"store_execute_ix_data", discStoreExecuteIxData}, + {"finalize_universal_tx_with_ix_data_ref", discFinalizeUniversalTxRef}, + {"close_stored_ix_data", discCloseStoredIxData}, + } + for _, c := range cases { + t.Run(c.method, func(t *testing.T) { + h := sha256.Sum256([]byte("global:" + c.method)) + assert.Equal(t, h[:8], c.got[:], + "discriminator constant for %s does not match sha256(\"global:%s\")[:8]", c.method, c.method) + }) + } +} + func TestEndToEndWithRealSignature(t *testing.T) { builder := newTestBuilder(t) evmKey, _, _ := generateTestEVMKey(t) @@ -1848,19 +1926,6 @@ func TestBuildStoreIxDataAccounts(t *testing.T) { assert.False(t, accounts[2].IsSigner) } -func TestBuildCloseStoredIxDataData(t *testing.T) { - builder := newTestBuilder(t) - subTxID := makeTxID(0x33) - ixDataHash := makeTxID(0x44) - - data := builder.buildCloseStoredIxDataData(subTxID, ixDataHash) - - require.Len(t, data, 8+32+32) - assert.Equal(t, discCloseStoredIxData[:], data[0:8], "discriminator") - assert.Equal(t, subTxID[:], data[8:40], "sub_tx_id") - assert.Equal(t, ixDataHash[:], data[40:72], "ix_data_hash") -} - func TestBuildCloseStoredIxDataAccounts(t *testing.T) { builder := newTestBuilder(t) caller := solana.NewWallet().PublicKey() @@ -1879,8 +1944,9 @@ func TestBuildCloseStoredIxDataAccounts(t *testing.T) { assert.True(t, accounts[1].IsWritable) assert.False(t, accounts[1].IsSigner) - // store_refund_recipient: mut, not signer; must equal caller on the failure path - assert.Equal(t, caller, accounts[2].PublicKey, "refund recipient must equal caller on failure path") + // store_refund_recipient: mut, not signer; must equal caller (the relayer + // reclaiming its own rent via the RentReclaimer cron). + assert.Equal(t, caller, accounts[2].PublicKey, "refund recipient must equal caller") assert.True(t, accounts[2].IsWritable) assert.False(t, accounts[2].IsSigner) } @@ -2082,55 +2148,6 @@ func TestBuildRefRouteTransactions_Validation(t *testing.T) { }) } -// TestCleanupOutboundArtifacts_NoOps covers the early-return paths in -// CleanupOutboundArtifacts that don't touch RPC. The test builder's RPCClient -// is a zero-value stub — any path that would reach GetAccountData would panic, -// which is exactly what we want here: it proves these cases short-circuit -// before any network call. -func TestCleanupOutboundArtifacts_NoOps(t *testing.T) { - builder := newTestBuilderWithKeypair(t) - ctx := context.Background() - - target := makeTxID(0x77) - - t.Run("nil data", func(t *testing.T) { - require.NoError(t, builder.CleanupOutboundArtifacts(ctx, nil)) - }) - - t.Run("empty payload", func(t *testing.T) { - ev := newBaseRefRouteEvent(t, "") - require.NoError(t, builder.CleanupOutboundArtifacts(ctx, ev)) - }) - - t.Run("invalid payload hex", func(t *testing.T) { - ev := newBaseRefRouteEvent(t, "0xnothex") - require.NoError(t, builder.CleanupOutboundArtifacts(ctx, ev)) - }) - - t.Run("direct route (instruction_id=1)", func(t *testing.T) { - // Withdraw payload — no StoredIxData PDA was ever created for it, so - // cleanup must short-circuit before any RPC. - withdrawPayload := buildExecutePayloadForTest(t, []GatewayAccountMeta{}, nil, 1, target) - ev := newBaseRefRouteEvent(t, withdrawPayload) - require.NoError(t, builder.CleanupOutboundArtifacts(ctx, ev)) - }) - - t.Run("empty ix_data on execute mode", func(t *testing.T) { - // Execute mode but no ix_data → ref route never engages, no PDA to clean. - emptyPayload := buildExecutePayloadForTest(t, []GatewayAccountMeta{}, []byte{}, 2, target) - ev := newBaseRefRouteEvent(t, emptyPayload) - require.NoError(t, builder.CleanupOutboundArtifacts(ctx, ev)) - }) - - t.Run("invalid txID hex", func(t *testing.T) { - ixData := []byte{0xAA, 0xBB} - payload := buildExecutePayloadForTest(t, []GatewayAccountMeta{}, ixData, 2, target) - ev := newBaseRefRouteEvent(t, payload) - ev.TxID = "0xnothex" - require.NoError(t, builder.CleanupOutboundArtifacts(ctx, ev)) - }) -} - // ============================================================================= // Devnet Simulation Tests // From 050a3b0bbf4149c5a2629e838d86b226a26602c9 Mon Sep 17 00:00:00 2001 From: aman035 Date: Fri, 22 May 2026 17:56:00 +0530 Subject: [PATCH 09/17] feat: add rent reclaimer for orphan pdas --- universalClient/chains/svm/client.go | 50 +++- universalClient/chains/svm/rent_reclaimer.go | 249 ++++++++++++++++++ .../chains/svm/rent_reclaimer_test.go | 53 ++++ universalClient/config/types.go | 4 + 4 files changed, 347 insertions(+), 9 deletions(-) create mode 100644 universalClient/chains/svm/rent_reclaimer.go create mode 100644 universalClient/chains/svm/rent_reclaimer_test.go diff --git a/universalClient/chains/svm/client.go b/universalClient/chains/svm/client.go index 2593ce81..3afa0c65 100644 --- a/universalClient/chains/svm/client.go +++ b/universalClient/chains/svm/client.go @@ -36,6 +36,7 @@ type Client struct { eventConfirmer *EventConfirmer chainMetaOracle *ChainMetaOracle txBuilder *TxBuilder + rentReclaimer *RentReclaimer // Dependencies pushSigner *pushsigner.Signer @@ -266,6 +267,13 @@ func (c *Client) initializeComponents() error { return fmt.Errorf("failed to create txBuilder: %w", err) } c.txBuilder = txBuilder + + c.rentReclaimer = NewRentReclaimer( + c.txBuilder, + config.rentReclaimSweepInterval, + config.rentReclaimMinPDAAge, + c.logger, + ) } return nil @@ -297,6 +305,10 @@ func (c *Client) startComponents() error { } } + if c.rentReclaimer != nil { + c.rentReclaimer.Start(c.ctx) + } + return nil } @@ -320,20 +332,24 @@ func (c *Client) createRPCClient() error { // componentConfig holds configuration values for components with defaults applied type componentConfig struct { - eventPollingInterval int - gasPriceInterval int - gasPriceMarkupPercent int - fastConfirmations uint64 - standardConfirmations uint64 + eventPollingInterval int + gasPriceInterval int + gasPriceMarkupPercent int + fastConfirmations uint64 + standardConfirmations uint64 + rentReclaimSweepInterval time.Duration + rentReclaimMinPDAAge time.Duration } // applyDefaults applies default values to all component configuration func (c *Client) applyDefaults() componentConfig { config := componentConfig{ - eventPollingInterval: 5, // default - gasPriceInterval: 30, // default - fastConfirmations: 5, // Solana fast confirmations - standardConfirmations: 12, // Solana standard confirmations + eventPollingInterval: 5, // default + gasPriceInterval: 30, // default + fastConfirmations: 5, // Solana fast confirmations + standardConfirmations: 12, // Solana standard confirmations + rentReclaimSweepInterval: rentReclaimSweepInterval, + rentReclaimMinPDAAge: rentReclaimMinPDAAge, } // Apply event polling interval @@ -351,6 +367,22 @@ func (c *Client) applyDefaults() componentConfig { config.gasPriceMarkupPercent = *c.chainConfig.GasPriceMarkupPercent } + // Apply rent-reclaimer overrides + if c.chainConfig != nil && c.chainConfig.RentReclaimSweepIntervalSeconds != nil && *c.chainConfig.RentReclaimSweepIntervalSeconds > 0 { + config.rentReclaimSweepInterval = time.Duration(*c.chainConfig.RentReclaimSweepIntervalSeconds) * time.Second + } + if c.chainConfig != nil && c.chainConfig.RentReclaimMinPDAAgeSeconds != nil && *c.chainConfig.RentReclaimMinPDAAgeSeconds > 0 { + requested := time.Duration(*c.chainConfig.RentReclaimMinPDAAgeSeconds) * time.Second + if requested < rentReclaimMinPDAAgeFloor { + c.logger.Warn(). + Dur("requested", requested). + Dur("floor", rentReclaimMinPDAAgeFloor). + Msg("rent_reclaim_min_pda_age_seconds below safe floor; clamping to avoid racing in-flight finalize") + requested = rentReclaimMinPDAAgeFloor + } + config.rentReclaimMinPDAAge = requested + } + // Apply confirmation requirements if c.registryConfig != nil && c.registryConfig.BlockConfirmation != nil { config.fastConfirmations = uint64(c.registryConfig.BlockConfirmation.FastInbound) diff --git a/universalClient/chains/svm/rent_reclaimer.go b/universalClient/chains/svm/rent_reclaimer.go new file mode 100644 index 00000000..2ffe62cc --- /dev/null +++ b/universalClient/chains/svm/rent_reclaimer.go @@ -0,0 +1,249 @@ +package svm + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "time" + + "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/rpc" + "github.com/rs/zerolog" +) + +// RentReclaimer closes orphaned StoredIxData PDAs to recover rent (~0.002 SOL each). +// +// - Orphan = PDA created by store_execute_ix_data whose finalize never succeeded +// (so the program's auto-close path never ran). +// - Skips PDAs younger than minAge to avoid racing in-flight finalize broadcasts. +type RentReclaimer struct { + builder *TxBuilder + interval time.Duration + minAge time.Duration + logger zerolog.Logger +} + +// Protocol byte widths (Solana / Anchor / Borsh). +const ( + anchorDiscriminatorSize = 8 // Anchor account prefix + anchorBumpSize = 1 // PDA bump + pubkeyByteLen = 32 // Ed25519 public key + subTxIDByteLen = 32 // sub_tx_id (content hash) + borshVecLenPrefix = 4 // Borsh Vec length, u32 LE +) + +// StoredIxData layout — must match the Rust struct in execute.rs: +// +// disc(8) | bump(1) | sub_tx_id(32) | store_refund_recipient(32) | ix_data: Vec(4+N) +const ( + storedIxDataSubTxIDOffset = anchorDiscriminatorSize + anchorBumpSize + storedIxDataRefundRecipientOffset = storedIxDataSubTxIDOffset + subTxIDByteLen + storedIxDataMinLen = storedIxDataRefundRecipientOffset + pubkeyByteLen + borshVecLenPrefix +) + +var storedIxDataAccountDiscriminator = func() []byte { + h := sha256.Sum256([]byte("account:StoredIxData")) + out := make([]byte, anchorDiscriminatorSize) + copy(out, h[:anchorDiscriminatorSize]) + return out +}() + +func NewRentReclaimer(builder *TxBuilder, interval, minAge time.Duration, logger zerolog.Logger) *RentReclaimer { + return &RentReclaimer{ + builder: builder, + interval: interval, + minAge: minAge, + logger: logger.With().Str("component", "svm_rent_reclaimer").Logger(), + } +} + +func (r *RentReclaimer) Start(ctx context.Context) { + go r.run(ctx) +} + +func (r *RentReclaimer) run(ctx context.Context) { + r.runOnce(ctx) + + ticker := time.NewTicker(r.interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + r.runOnce(ctx) + } + } +} + +func (r *RentReclaimer) runOnce(ctx context.Context) { + relayer, err := r.builder.loadRelayerKeypair() + if err != nil { + r.logger.Warn().Err(err).Msg("failed to load relayer keypair; skipping sweep") + return + } + + candidates, err := r.discoverOrphans(ctx, relayer.PublicKey()) + if err != nil { + r.logger.Warn().Err(err).Msg("failed to discover orphan PDAs") + return + } + if len(candidates) == 0 { + r.logger.Debug().Msg("no orphan StoredIxData PDAs found") + return + } + + var closed, skipped, failed int + for _, c := range candidates { + if ctx.Err() != nil { + return + } + old, err := r.isOldEnough(ctx, c.address) + if err != nil || !old { + skipped++ + continue + } + if err := r.closeOrphan(ctx, c, relayer); err != nil { + r.logger.Warn().Err(err).Str("pda", c.address.String()). + Msg("failed to close orphan PDA") + failed++ + continue + } + closed++ + } + r.logger.Info(). + Int("discovered", len(candidates)). + Int("closed", closed). + Int("skipped_young", skipped). + Int("failed", failed). + Msg("rent reclaim sweep complete") +} + +type orphanPDA struct { + address solana.PublicKey + subTxID [subTxIDByteLen]byte +} + +// discoverOrphans scans StoredIxData accounts owned by the gateway program +// where store_refund_recipient == our relayer. Finalized commitment naturally +// excludes very-recently-created PDAs. +func (r *RentReclaimer) discoverOrphans(ctx context.Context, relayer solana.PublicKey) ([]orphanPDA, error) { + var result rpc.GetProgramAccountsResult + err := r.builder.rpcClient.executeWithFailover(ctx, "get_program_accounts", func(client *rpc.Client) error { + opts := &rpc.GetProgramAccountsOpts{ + Commitment: rpc.CommitmentFinalized, + Filters: []rpc.RPCFilter{ + // match account type + {Memcmp: &rpc.RPCFilterMemcmp{Offset: 0, Bytes: solana.Base58(storedIxDataAccountDiscriminator)}}, + // match refund recipient = us + {Memcmp: &rpc.RPCFilterMemcmp{Offset: storedIxDataRefundRecipientOffset, Bytes: solana.Base58(relayer.Bytes())}}, + }, + } + resp, innerErr := client.GetProgramAccountsWithOpts(ctx, r.builder.gatewayAddress, opts) + if innerErr != nil { + return innerErr + } + result = resp + return nil + }) + if err != nil { + return nil, err + } + + orphans := make([]orphanPDA, 0, len(result)) + for _, ka := range result { + if ka == nil || ka.Account == nil { + continue + } + data := ka.Account.Data.GetBinary() + if len(data) < storedIxDataMinLen { + continue + } + var subTxID [subTxIDByteLen]byte + copy(subTxID[:], data[storedIxDataSubTxIDOffset:storedIxDataSubTxIDOffset+subTxIDByteLen]) + orphans = append(orphans, orphanPDA{address: ka.Pubkey, subTxID: subTxID}) + } + return orphans, nil +} + +// getSignaturesForAddress page size when probing PDA age — we only need the +// most recent signature to bound age from below. +const signatureAgeProbeLimit = 1 + +// Default lifecycle params, well above the broadcaster's retry window. +const ( + rentReclaimSweepInterval = 30 * time.Minute + rentReclaimMinPDAAge = 10 * time.Minute + + // Floor for the configured minPDAAge. Anything shorter risks racing an + // in-flight finalize that hasn't landed yet. + rentReclaimMinPDAAgeFloor = 1 * time.Minute +) + +// isOldEnough reports whether the most recent tx touching addr is at least +// minAge old. For StoredIxData PDAs, that's effectively the PDA's age (they +// only ever see one tx — their creating store_execute_ix_data). +func (r *RentReclaimer) isOldEnough(ctx context.Context, addr solana.PublicKey) (bool, error) { + limit := signatureAgeProbeLimit + var sigs []*rpc.TransactionSignature + err := r.builder.rpcClient.executeWithFailover(ctx, "get_signatures_for_address", func(client *rpc.Client) error { + resp, innerErr := client.GetSignaturesForAddressWithOpts(ctx, addr, &rpc.GetSignaturesForAddressOpts{ + Limit: &limit, + }) + if innerErr != nil { + return innerErr + } + sigs = resp + return nil + }) + if err != nil || len(sigs) == 0 { + return false, err + } + if sigs[0].BlockTime == nil { + return false, nil + } + age := time.Since(time.Unix(int64(*sigs[0].BlockTime), 0)) + return age >= r.minAge, nil +} + +// closeOrphan builds and broadcasts an arg-free close_stored_ix_data tx. +// The contract reads sub_tx_id and ix_data from the PDA itself. +func (r *RentReclaimer) closeOrphan(ctx context.Context, o orphanPDA, relayer solana.PrivateKey) error { + accounts := r.builder.buildCloseStoredIxDataAccounts(relayer.PublicKey(), o.address) + closeIx := solana.NewInstruction(r.builder.gatewayAddress, accounts, discCloseStoredIxData[:]) + + blockhash, err := r.builder.rpcClient.GetRecentBlockhash(ctx) + if err != nil { + return fmt.Errorf("get blockhash: %w", err) + } + + tx, err := solana.NewTransaction( + []solana.Instruction{closeIx}, + blockhash, + solana.TransactionPayer(relayer.PublicKey()), + ) + if err != nil { + return fmt.Errorf("build close tx: %w", err) + } + if _, err := tx.Sign(func(key solana.PublicKey) *solana.PrivateKey { + if key.Equals(relayer.PublicKey()) { + priv := relayer + return &priv + } + return nil + }); err != nil { + return fmt.Errorf("sign close tx: %w", err) + } + + hash, err := r.builder.rpcClient.BroadcastTransaction(ctx, tx) + if err != nil { + return fmt.Errorf("broadcast close tx: %w", err) + } + r.logger.Info(). + Str("pda", o.address.String()). + Str("close_tx_hash", hash). + Str("sub_tx_id", hex.EncodeToString(o.subTxID[:])). + Msg("orphan StoredIxData PDA closed, rent reclaimed") + return nil +} diff --git a/universalClient/chains/svm/rent_reclaimer_test.go b/universalClient/chains/svm/rent_reclaimer_test.go new file mode 100644 index 00000000..7fa8d28e --- /dev/null +++ b/universalClient/chains/svm/rent_reclaimer_test.go @@ -0,0 +1,53 @@ +package svm + +import ( + "crypto/sha256" + "encoding/hex" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestStoredIxDataAccountDiscriminator pins the Anchor account discriminator +// computation. The discriminator is sha256("account:")[:8] and MUST +// match what the gateway program emits when it serializes the account — a +// mismatch here makes the entire reclaimer's getProgramAccounts filter return +// no matches, silently breaking rent recovery. +func TestStoredIxDataAccountDiscriminator(t *testing.T) { + expected := sha256.Sum256([]byte("account:StoredIxData")) + require.Len(t, storedIxDataAccountDiscriminator, 8) + assert.Equal(t, expected[:8], storedIxDataAccountDiscriminator, + "discriminator must equal sha256(\"account:StoredIxData\")[:8]") +} + +// TestStoredIxDataLayoutOffsets pins the on-chain byte offsets the reclaimer +// uses to parse sub_tx_id and filter on store_refund_recipient. These mirror +// the Rust struct exactly: +// +// #[account] +// pub struct StoredIxData { +// pub bump: u8, // 1 +// pub sub_tx_id: [u8; 32], // 32 +// pub store_refund_recipient: Pubkey, // 32 +// pub ix_data: Vec, // 4-byte len + bytes +// } +// +// preceded by Anchor's 8-byte account discriminator. +func TestStoredIxDataLayoutOffsets(t *testing.T) { + assert.Equal(t, 9, storedIxDataSubTxIDOffset, "disc(8) + bump(1) = 9") + assert.Equal(t, 41, storedIxDataRefundRecipientOffset, "disc(8) + bump(1) + sub_tx_id(32) = 41") + assert.Equal(t, 77, storedIxDataMinLen, "disc + bump + sub_tx_id + refund_recipient + vec_len = 77") +} + +// TestStoredIxDataDiscriminatorHexPin double-locks the discriminator by +// committing its hex form to the test. If anyone ever swaps the formula or +// the type name, this test catches it without needing to recompute sha256. +func TestStoredIxDataDiscriminatorHexPin(t *testing.T) { + got := hex.EncodeToString(storedIxDataAccountDiscriminator) + expected := func() string { + h := sha256.Sum256([]byte("account:StoredIxData")) + return hex.EncodeToString(h[:8]) + }() + assert.Equal(t, expected, got) +} diff --git a/universalClient/config/types.go b/universalClient/config/types.go index 0e00d1e9..8a43a709 100644 --- a/universalClient/config/types.go +++ b/universalClient/config/types.go @@ -55,6 +55,10 @@ type ChainSpecificConfig struct { GasPriceMarkupPercent *int `json:"gas_price_markup_percent,omitempty"` // % markup on fetched gas price to handle spikes ProtocolALT string `json:"protocol_alt,omitempty"` // Protocol ALT address (base58) for V0 transactions TokenALTs map[string]string `json:"token_alts,omitempty"` // mint address → token ALT address (base58) + + // SVM rent reclaimer (orphaned StoredIxData PDA cleanup). Both default if unset. + RentReclaimSweepIntervalSeconds *int `json:"rent_reclaim_sweep_interval_seconds,omitempty"` // how often to sweep + RentReclaimMinPDAAgeSeconds *int `json:"rent_reclaim_min_pda_age_seconds,omitempty"` // skip PDAs younger than this } // GetChainCleanupSettings returns cleanup settings for a specific chain. From ded7d7f62cf0b5e9d77f701a6e258ad85e0f7b25 Mon Sep 17 00:00:00 2001 From: aman035 Date: Fri, 22 May 2026 21:14:55 +0530 Subject: [PATCH 10/17] revert: rpc fn --- universalClient/chains/svm/rpc_client.go | 70 ------------------------ 1 file changed, 70 deletions(-) diff --git a/universalClient/chains/svm/rpc_client.go b/universalClient/chains/svm/rpc_client.go index ab6dcfe8..f63676a9 100644 --- a/universalClient/chains/svm/rpc_client.go +++ b/universalClient/chains/svm/rpc_client.go @@ -295,76 +295,6 @@ func (rc *RPCClient) GetTransaction(ctx context.Context, signature solana.Signat return tx, err } -// WaitForSignatureConfirmation polls the cluster until the given signature reaches -// the requested commitment level, the on-chain status is an error, or the timeout -// elapses. Returns nil on success. -// -// Used by the ref-finalize flow to gate the second tx (finalize-by-reference) on -// the first tx (store_execute_ix_data) landing on-chain. -func (rc *RPCClient) WaitForSignatureConfirmation( - ctx context.Context, - sig solana.Signature, - commitment rpc.ConfirmationStatusType, - timeout time.Duration, -) error { - deadline := time.Now().Add(timeout) - backoff := 200 * time.Millisecond - const maxBackoff = 1500 * time.Millisecond - - for { - var result *rpc.GetSignatureStatusesResult - execErr := rc.executeWithFailover(ctx, "get_signature_statuses", func(client *rpc.Client) error { - var innerErr error - result, innerErr = client.GetSignatureStatuses(ctx, false, sig) - return innerErr - }) - - if execErr == nil && result != nil && len(result.Value) > 0 && result.Value[0] != nil { - status := result.Value[0] - if status.Err != nil { - return fmt.Errorf("transaction %s failed on-chain: %v", sig.String(), status.Err) - } - if confirmationReached(status.ConfirmationStatus, commitment) { - return nil - } - } - - if time.Now().After(deadline) { - return fmt.Errorf("timeout waiting for signature %s to reach %s commitment", sig.String(), commitment) - } - - select { - case <-ctx.Done(): - return ctx.Err() - case <-time.After(backoff): - } - if backoff < maxBackoff { - backoff *= 2 - if backoff > maxBackoff { - backoff = maxBackoff - } - } - } -} - -// confirmationReached reports whether `got` meets the bar set by `want`. -// Commitment ordering: processed < confirmed < finalized. -func confirmationReached(got, want rpc.ConfirmationStatusType) bool { - rank := func(c rpc.ConfirmationStatusType) int { - switch c { - case rpc.ConfirmationStatusFinalized: - return 3 - case rpc.ConfirmationStatusConfirmed: - return 2 - case rpc.ConfirmationStatusProcessed: - return 1 - default: - return 0 - } - } - return rank(got) >= rank(want) -} - // BroadcastTransaction broadcasts a signed transaction and returns the transaction signature (hash) func (rc *RPCClient) BroadcastTransaction(ctx context.Context, tx *solana.Transaction) (string, error) { if len(tx.Signatures) == 0 { From 6c89e11c67687ba467c4cb0d0492b3f06fa3a638 Mon Sep 17 00:00:00 2001 From: aman035 Date: Fri, 22 May 2026 21:15:16 +0530 Subject: [PATCH 11/17] fix: lazy handling in tx builder --- universalClient/chains/svm/tx_builder.go | 180 +++--------------- universalClient/chains/svm/tx_builder_test.go | 55 ------ 2 files changed, 24 insertions(+), 211 deletions(-) diff --git a/universalClient/chains/svm/tx_builder.go b/universalClient/chains/svm/tx_builder.go index a503ce48..7e6b8dea 100644 --- a/universalClient/chains/svm/tx_builder.go +++ b/universalClient/chains/svm/tx_builder.go @@ -30,20 +30,17 @@ import ( "encoding/binary" "encoding/hex" "encoding/json" - "errors" "fmt" "math/big" "os" "path/filepath" "strconv" "strings" - "time" "github.com/ethereum/go-ethereum/crypto" "github.com/gagliardetto/solana-go" addresslookuptable "github.com/gagliardetto/solana-go/programs/address-lookup-table" "github.com/gagliardetto/solana-go/rpc" - "github.com/gagliardetto/solana-go/rpc/jsonrpc" "github.com/rs/zerolog" "github.com/pushchain/push-chain-node/universalClient/chains/common" @@ -574,12 +571,18 @@ func (tb *TxBuilder) storedPDAExists(ctx context.Context, storedPDA solana.Publi return len(data) > 0 } -// broadcastRefRoute drives the 2-tx ref-finalize flow: -// 1. store_execute_ix_data (skipped if PDA already exists) -// 2. finalize_universal_tx_with_ix_data_ref (same TSS payload as direct route) +// broadcastRefRoute drives the 2-tx ref-finalize flow as a tick-based state +// machine — at most ONE action per broadcaster tick: // -// Returns the finalize tx hash — only finalize lands the ExecutedSubTx PDA -// the resolver keys on. +// - PDA exists on-chain → broadcast finalize, return tx hash. +// - PDA absent → broadcast store, return non-nil error so the +// broadcaster counts it as a failed attempt and retries next tick. The +// happy path: tick N broadcasts store, tick N+1 (15s later, after ~13s +// Finalized) sees the PDA and broadcasts finalize. +// +// PDA is content-addressed by (sub_tx_id, keccak256(ix_data)); every validator +// derives the same address. Only one store wins on-chain (Anchor `init` dedups); +// losers see AccountAlreadyInUse — the broadcaster's retry handles it. func (tb *TxBuilder) broadcastRefRoute( ctx context.Context, req *common.UnsignedSigningReq, @@ -591,162 +594,27 @@ func (tb *TxBuilder) broadcastRefRoute( return "", fmt.Errorf("failed to build ref-route transactions: %w", err) } - // Ensure StoredIxData PDA is on-chain before finalize. - // - // PDA is content-addressed by (sub_tx_id, keccak256(ix_data)) — every - // validator derives the same address with the same payload. Only one - // store_execute_ix_data wins on-chain (Anchor `init` dedups); losers see - // AccountAlreadyInUse. Either way finalize proceeds against the winner's PDA. if tb.storedPDAExists(ctx, storedPDA) { + refHash, err := tb.rpcClient.BroadcastTransaction(ctx, refTx) + if err != nil { + return "", fmt.Errorf("failed to broadcast finalize_universal_tx_with_ix_data_ref: %w", err) + } tb.logger.Info(). + Str("tx_hash", refHash). Str("stored_pda", storedPDA.String()). - Msg("stored_ix_data PDA already present, skipping store tx") - } else { - storeHash, broadcastErr := tb.rpcClient.BroadcastTransaction(ctx, storeTx) - if broadcastErr != nil { - if !tb.storedPDAExists(ctx, storedPDA) { - return "", fmt.Errorf("failed to broadcast store_execute_ix_data: %w", broadcastErr) - } - tb.logger.Info(). - Str("stored_pda", storedPDA.String()). - Msg("store_execute_ix_data broadcast lost race; PDA already exists, proceeding to finalize") - } else { - tb.logger.Info(). - Str("store_tx_hash", storeHash). - Str("stored_pda", storedPDA.String()). - Msg("store_execute_ix_data broadcast, awaiting confirmation") - - sig, sigErr := solana.SignatureFromBase58(storeHash) - if sigErr != nil { - return "", fmt.Errorf("invalid store tx signature %s: %w", storeHash, sigErr) - } - if confirmErr := tb.rpcClient.WaitForSignatureConfirmation(ctx, sig, rpc.ConfirmationStatusConfirmed, 30*time.Second); confirmErr != nil { - if !tb.storedPDAExists(ctx, storedPDA) { - return "", fmt.Errorf("store_execute_ix_data did not confirm: %w", confirmErr) - } - tb.logger.Info(). - Str("stored_pda", storedPDA.String()). - Msg("our store_execute_ix_data failed confirmation but PDA exists (race-lost); proceeding to finalize") - } - } + Msg("ref-finalize broadcast successfully") + return refHash, nil } - // Bounded retry on preflight lag: a laggy RPC may not yet see the just- - // confirmed store and reject finalize with Anchor 3012. Retry through other - // RPCs in the pool until one is synced. - refHash, err := tb.broadcastFinalizeWithRetry(ctx, refTx) - if err != nil { - return "", fmt.Errorf("failed to broadcast finalize_universal_tx_with_ix_data_ref: %w", err) + storeHash, broadcastErr := tb.rpcClient.BroadcastTransaction(ctx, storeTx) + if broadcastErr != nil { + return "", fmt.Errorf("failed to broadcast store_execute_ix_data: %w", broadcastErr) } tb.logger.Info(). - Str("tx_hash", refHash). + Str("store_tx_hash", storeHash). Str("stored_pda", storedPDA.String()). - Uint8("instruction_id", 2). - Msg("ref-finalize broadcast successfully") - return refHash, nil -} - -// Retry budget: 5 attempts × exponential backoff (500ms → 1s → 2s → 4s) ≈ 8s -// total, well inside the ~60-90s blockhash validity window — no need to rebuild. -const ( - finalizeRetryAttempts = 5 - finalizeRetryInitialDelay = 500 * time.Millisecond - finalizeRetryMaxDelay = 4 * time.Second - - // Anchor: AccountNotInitialized. In ref-finalize this can only fire on - // stored_ix_data — other deserialized accounts are program-init time. - anchorErrAccountNotInitialized = 3012 - - // Solana JSON-RPC: preflight simulation failed. Scoped to this code so - // non-preflight errors don't retry. - rpcSimulationFailedCode = -32002 -) - -// broadcastFinalizeWithRetry retries finalize_universal_tx_with_ix_data_ref on -// Anchor's AccountNotInitialized (3012); other errors return immediately. -func (tb *TxBuilder) broadcastFinalizeWithRetry(ctx context.Context, refTx *solana.Transaction) (string, error) { - delay := finalizeRetryInitialDelay - var lastErr error - for attempt := range finalizeRetryAttempts { - hash, err := tb.rpcClient.BroadcastTransaction(ctx, refTx) - if err == nil { - if attempt > 0 { - tb.logger.Info().Int("attempts", attempt+1). - Msg("ref-finalize broadcast succeeded after preflight-lag retries") - } - return hash, nil - } - if !isAccountNotInitializedError(err) { - return "", err - } - lastErr = err - tb.logger.Warn().Int("attempt", attempt+1).Dur("backoff", delay). - Msg("ref-finalize preflight reported AccountNotInitialized, retrying after RPC sync delay") - - select { - case <-ctx.Done(): - return "", ctx.Err() - case <-time.After(delay): - } - if delay < finalizeRetryMaxDelay { - delay *= 2 - if delay > finalizeRetryMaxDelay { - delay = finalizeRetryMaxDelay - } - } - } - return "", fmt.Errorf("ref-finalize preflight still lagging after %d retries: %w", finalizeRetryAttempts, lastErr) -} - -// isAccountNotInitializedError matches a preflight rejection with Anchor 3012. -// Matches on code only — preflight errors don't carry the account name in -// structured form (only in program logs). -func isAccountNotInitializedError(err error) bool { - var rpcErr *jsonrpc.RPCError - if !errors.As(err, &rpcErr) { - return false - } - if rpcErr.Code != rpcSimulationFailedCode { - return false - } - data, ok := rpcErr.Data.(map[string]any) - if !ok { - return false - } - inner, ok := data["err"].(map[string]any) - if !ok { - return false - } - instErr, ok := inner["InstructionError"].([]any) - if !ok || len(instErr) != 2 { - return false - } - detail, ok := instErr[1].(map[string]any) - if !ok { - return false - } - custom, ok := detail["Custom"] - if !ok { - return false - } - return toUint64(custom) == anchorErrAccountNotInitialized -} - -func toUint64(v any) uint64 { - switch n := v.(type) { - case float64: - return uint64(n) - case json.Number: - i, _ := n.Int64() - return uint64(i) - case int: - return uint64(n) - case int64: - return uint64(n) - case uint64: - return n - } - return 0 + Msg("store_execute_ix_data broadcast; finalize deferred to next tick") + return "", fmt.Errorf("store_execute_ix_data broadcast; finalize will be attempted on next broadcaster tick") } // fetchAddressTables fetches Address Lookup Table state for V0 transactions. diff --git a/universalClient/chains/svm/tx_builder_test.go b/universalClient/chains/svm/tx_builder_test.go index 7eaaad98..0022551b 100644 --- a/universalClient/chains/svm/tx_builder_test.go +++ b/universalClient/chains/svm/tx_builder_test.go @@ -7,7 +7,6 @@ import ( "crypto/sha256" "encoding/binary" "encoding/hex" - "encoding/json" "fmt" "os" "path/filepath" @@ -17,7 +16,6 @@ import ( "github.com/ethereum/go-ethereum/crypto" "github.com/gagliardetto/solana-go" "github.com/gagliardetto/solana-go/rpc" - "github.com/gagliardetto/solana-go/rpc/jsonrpc" "github.com/rs/zerolog" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -1270,59 +1268,6 @@ func TestBuildRescueAccounts(t *testing.T) { }) } -func TestIsAccountNotInitializedError(t *testing.T) { - // Builds an RPCError that matches the wire shape Solana returns for a - // preflight failure with the given Anchor custom error code. - preflightErr := func(custom uint64) error { - return &jsonrpc.RPCError{ - Code: -32002, - Message: "Transaction simulation failed", - Data: map[string]any{ - "err": map[string]any{ - "InstructionError": []any{ - float64(1), - map[string]any{"Custom": float64(custom)}, - }, - }, - }, - } - } - - tests := []struct { - name string - err error - want bool - }{ - {"3012 unwrapped", preflightErr(3012), true}, - {"3012 wrapped via fmt.Errorf %w", fmt.Errorf("broadcast failed: %w", preflightErr(3012)), true}, - {"3012 with json.Number", &jsonrpc.RPCError{ - Code: -32002, - Data: map[string]any{ - "err": map[string]any{ - "InstructionError": []any{ - float64(1), - map[string]any{"Custom": json.Number("3012")}, - }, - }, - }, - }, true}, - {"6005 not 3012", preflightErr(6005), false}, - {"different RPC code", &jsonrpc.RPCError{Code: -32000, Data: map[string]any{}}, false}, - {"missing InstructionError", &jsonrpc.RPCError{Code: -32002, Data: map[string]any{"err": map[string]any{}}}, false}, - {"plain error", fmt.Errorf("transport failure"), false}, - {"nil error", nil, false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := isAccountNotInitializedError(tt.err) - if got != tt.want { - t.Errorf("isAccountNotInitializedError = %v, want %v", got, tt.want) - } - }) - } -} - func TestRemoveHexPrefix(t *testing.T) { tests := []struct { input string From 87fa9e19926223711d4587bb6f197105e6285255 Mon Sep 17 00:00:00 2001 From: aman035 Date: Fri, 22 May 2026 21:15:36 +0530 Subject: [PATCH 12/17] fix: add temp retires approach in svm --- .../tss/txbroadcaster/broadcaster.go | 18 ++++-- .../tss/txbroadcaster/broadcaster_test.go | 35 ++++++++++- universalClient/tss/txbroadcaster/svm.go | 63 +++++++++++-------- 3 files changed, 81 insertions(+), 35 deletions(-) diff --git a/universalClient/tss/txbroadcaster/broadcaster.go b/universalClient/tss/txbroadcaster/broadcaster.go index d4f3bd59..09457328 100644 --- a/universalClient/tss/txbroadcaster/broadcaster.go +++ b/universalClient/tss/txbroadcaster/broadcaster.go @@ -63,6 +63,13 @@ type Broadcaster struct { checkInterval time.Duration logger zerolog.Logger getTSSAddress func(ctx context.Context) (string, error) + + // svmBroadcastAttempts is an in-memory failure counter per event_id used to + // cap SVM retries before escalating to REVERT. Lost on process restart by + // design — restart resets all counters, giving the operator a fresh budget. + // Temporary mechanism; the signature-deadline system will supersede it. + // Safe without a mutex: processSigned drains events serially. + svmBroadcastAttempts map[string]uint32 } // NewBroadcaster creates a new tx broadcaster. @@ -72,11 +79,12 @@ func NewBroadcaster(cfg Config) *Broadcaster { interval = 15 * time.Second } return &Broadcaster{ - eventStore: cfg.EventStore, - chains: cfg.Chains, - checkInterval: interval, - logger: cfg.Logger.With().Str("component", "txbroadcaster").Logger(), - getTSSAddress: cfg.GetTSSAddress, + eventStore: cfg.EventStore, + chains: cfg.Chains, + checkInterval: interval, + logger: cfg.Logger.With().Str("component", "txbroadcaster").Logger(), + getTSSAddress: cfg.GetTSSAddress, + svmBroadcastAttempts: make(map[string]uint32), } } diff --git a/universalClient/tss/txbroadcaster/broadcaster_test.go b/universalClient/tss/txbroadcaster/broadcaster_test.go index 6404119b..dff2d811 100644 --- a/universalClient/tss/txbroadcaster/broadcaster_test.go +++ b/universalClient/tss/txbroadcaster/broadcaster_test.go @@ -340,8 +340,10 @@ func TestSVM_BroadcastFails_PDAExists_MarksBroadcasted(t *testing.T) { require.Equal(t, "solana:mainnet:", ev.BroadcastedTxHash) // empty tx hash } -func TestSVM_BroadcastFails_PDANotFound_MarksBroadcasted(t *testing.T) { - // Broadcast fails, PDA not found → permanent failure (bad payload) → BROADCASTED for resolver to REVERT. +func TestSVM_BroadcastFails_FirstAttempt_StaysSignedAndCountsAttempt(t *testing.T) { + // Broadcast fails, tx not on chain → stay SIGNED, increment in-memory + // attempt counter. Retry happens next tick; only after maxSVMBroadcastAttempts + // do we escalate. evtStore, db := setupTestDB(t) builder := &mockTxBuilder{} client := &mockChainClient{builder: builder} @@ -356,9 +358,36 @@ func TestSVM_BroadcastFails_PDANotFound_MarksBroadcasted(t *testing.T) { b := newBroadcaster(evtStore, ch, "") b.processSigned(context.Background()) + ev := getEvent(t, db, "ev-1") + require.Equal(t, store.StatusSigned, ev.Status) + require.Equal(t, uint32(1), b.svmBroadcastAttempts["ev-1"]) +} + +func TestSVM_BroadcastFails_ExhaustsAttempts_MarksBroadcasted(t *testing.T) { + // After maxSVMBroadcastAttempts failures, escalate to BROADCASTED-empty so + // the resolver can REVERT, and clear the in-memory counter. + evtStore, db := setupTestDB(t) + builder := &mockTxBuilder{} + client := &mockChainClient{builder: builder} + ch := newTestChains(t, "solana:mainnet", uregistrytypes.VmType_SVM, client) + + insertSignedEvent(t, db, "ev-1", "solana:mainnet", 0) + + builder.On("BroadcastOutboundSigningRequest", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return("", fmt.Errorf("persistent failure")) + builder.On("IsAlreadyExecuted", mock.Anything, "tx-123").Return(false, nil) + + b := newBroadcaster(evtStore, ch, "") + // Seed the counter one attempt away from the cap, then the next call hits + // the cap and escalates. + b.svmBroadcastAttempts["ev-1"] = maxSVMBroadcastAttempts - 1 + b.processSigned(context.Background()) + ev := getEvent(t, db, "ev-1") require.Equal(t, store.StatusBroadcasted, ev.Status) - require.Equal(t, "solana:mainnet:", ev.BroadcastedTxHash) // empty tx hash + require.Equal(t, "solana:mainnet:", ev.BroadcastedTxHash, "empty tx hash signals REVERT to resolver") + _, present := b.svmBroadcastAttempts["ev-1"] + require.False(t, present, "counter should be cleared after escalation") } func TestSVM_BroadcastFails_PDACheckFails_StaysSigned(t *testing.T) { diff --git a/universalClient/tss/txbroadcaster/svm.go b/universalClient/tss/txbroadcaster/svm.go index 70ea667b..976f6fe7 100644 --- a/universalClient/tss/txbroadcaster/svm.go +++ b/universalClient/tss/txbroadcaster/svm.go @@ -6,21 +6,27 @@ import ( "github.com/pushchain/push-chain-node/universalClient/store" ) +// maxSVMBroadcastAttempts caps the number of failed broadcast attempts before +// the broadcaster gives up and marks the event for REVERT. +// +// TEMPORARY: a future signature-deadline mechanism will supersede this +// attempt-based cap with time-based finality. Until then, this prevents events +// from looping indefinitely on persistent failures (bad payload, downstream +// program upgrade, etc.). +const maxSVMBroadcastAttempts = 10 + // broadcastSVM broadcasts a signed Solana transaction. // -// With the V2 gateway contract, Solana transactions either land atomically or -// fully revert — there is no partial state. Unlike EVM (where reverted txs still -// consume nonce and land on-chain), a failed Solana CPI means nothing is created -// on-chain (no ExecutedTx PDA, no event emitted). +// Three branches: +// - Success → BROADCASTED with tx hash. +// - Tx already executed by another relayer → BROADCASTED with empty hash. +// - Anything else → increment attempt counter; stay SIGNED until the cap is +// hit, then mark BROADCASTED with empty hash so the resolver can REVERT. // -// Flow: -// 1. Broadcast the signed tx -// 2. Success → BROADCASTED with tx hash -// 3. Error → check if ExecutedTx PDA exists on-chain: -// - PDA exists (another relayer already processed it) → BROADCASTED -// - PDA not found (permanent failure: bad payload, simulation error) → BROADCASTED -// with empty tx hash, resolver will verify and REVERT -// - PDA check fails (RPC truly down) → stay SIGNED, retry next tick +// Branch 3 deliberately absorbs every other failure mode (RPC lag, race-lost, +// CPI failure, blockhash stale, transport error, etc.) — we don't classify +// them, we just retry. The attempt cap is the safety valve for genuinely +// stuck events. func (b *Broadcaster) broadcastSVM(ctx context.Context, event *store.Event, data *SignedOutboundData, chainID string) { client, err := b.chains.GetClient(chainID) if err != nil { @@ -43,30 +49,33 @@ func (b *Broadcaster) broadcastSVM(ctx context.Context, event *store.Event, data txHash, broadcastErr := builder.BroadcastOutboundSigningRequest(ctx, signingReq, &outboundData, signature) if broadcastErr == nil { + delete(b.svmBroadcastAttempts, event.EventID) b.markBroadcasted(event, chainID, txHash) return } - // Broadcast failed — check PDA to distinguish permanent vs transient failure. - executed, execErr := builder.IsAlreadyExecuted(ctx, outboundData.TxID) - if execErr != nil { - // RPC truly down (both broadcast and PDA check failed) — stay SIGNED, retry next tick. - b.logger.Debug().Err(broadcastErr).Str("event_id", event.EventID).Str("chain", chainID). - Msg("SVM broadcast failed and PDA check unreachable, will retry next tick") + // Tx may have landed via another relayer — that ends the event cleanly. + if executed, execErr := builder.IsAlreadyExecuted(ctx, outboundData.TxID); execErr == nil && executed { + delete(b.svmBroadcastAttempts, event.EventID) + b.logger.Info().Err(broadcastErr).Str("event_id", event.EventID).Str("chain", chainID). + Msg("broadcast failed but tx already executed on-chain, marking BROADCASTED") + b.markBroadcasted(event, chainID, "") return } - if executed { - // Another relayer already executed this tx. - b.logger.Info().Err(broadcastErr).Str("event_id", event.EventID).Str("chain", chainID). - Msg("broadcast failed but tx already executed on-chain, marking BROADCASTED") + // Broadcast failed and tx isn't on chain. Count the attempt; cap or retry. + attempts := b.svmBroadcastAttempts[event.EventID] + 1 + if attempts >= maxSVMBroadcastAttempts { + delete(b.svmBroadcastAttempts, event.EventID) + b.logger.Warn().Err(broadcastErr).Uint32("attempts", attempts). + Str("event_id", event.EventID).Str("chain", chainID). + Msg("SVM broadcast exhausted retry budget, marking BROADCASTED for resolver to REVERT") b.markBroadcasted(event, chainID, "") return } - // RPC is reachable but PDA not found — permanent failure (bad payload, simulation error). - // Mark BROADCASTED with empty hash so resolver can verify and REVERT. - b.logger.Warn().Err(broadcastErr).Str("event_id", event.EventID).Str("chain", chainID). - Msg("SVM broadcast failed and PDA not found, marking BROADCASTED for resolver to REVERT") - b.markBroadcasted(event, chainID, "") + b.svmBroadcastAttempts[event.EventID] = attempts + b.logger.Info().Err(broadcastErr).Uint32("attempts", attempts). + Str("event_id", event.EventID).Str("chain", chainID). + Msg("SVM broadcast failed, staying SIGNED for next-tick retry") } From a60a1f49fff2d1477d2ae960b33b581902d133c0 Mon Sep 17 00:00:00 2001 From: aman035 Date: Fri, 22 May 2026 21:16:06 +0530 Subject: [PATCH 13/17] skip svm chains in coordinator to prevent slowness from svm retires --- .../tss/coordinator/coordinator.go | 22 +++++-- .../tss/coordinator/coordinator_test.go | 59 +++++++++++++++++++ 2 files changed, 76 insertions(+), 5 deletions(-) diff --git a/universalClient/tss/coordinator/coordinator.go b/universalClient/tss/coordinator/coordinator.go index c68fb9a6..9518d04c 100644 --- a/universalClient/tss/coordinator/coordinator.go +++ b/universalClient/tss/coordinator/coordinator.go @@ -37,10 +37,15 @@ type PushCoreClient interface { } const ( - // PerChainCap is the max in-flight SIGN events per destination chain (default 16; below EVM mempool accountqueue 64). + // PerChainCap is the max in-flight SIGN events per destination chain + // (default 16; below EVM mempool accountqueue 64). + // EVM-only: bypassed for non-EVM chains (e.g. SVM has no nonce queueing, + // so in-flight events don't block each other). PerChainCap = 16 - // ConsecutiveWaitThreshold: after this many consecutive polls where a chain has in-flight events, - // use finalized nonce to recover from stuck nonces (~200s at 10s poll). + // ConsecutiveWaitThreshold: after this many consecutive polls where a chain + // has in-flight events, use finalized nonce to recover from stuck nonces + // (~200s at 10s poll). + // EVM-only: SVM doesn't use a nonce, so stuck-nonce recovery is meaningless. ConsecutiveWaitThreshold = 20 // staleValidatorsHaltMultiplier: if the cached validator set is older than // this many pollInterval ticks, it is cleared @@ -1061,9 +1066,16 @@ func (c *Coordinator) assignSignNonce( return 0, false } + // Non-EVM chains (SVM today) have no nonce semantics — every tx carries + // its own blockhash and replay protection (ExecutedSubTx PDA on SVM). + // In-flight events don't block each other, so PerChainCap and the + // wait-then-recover dance are EVM-only optimizations. For non-EVM chains + // skip straight to nonce fetch (which returns 0 for SVM). + isEVM := c.chains != nil && c.chains.IsEVMChain(chain) + // ── Subsequent event for this chain (nonce already fetched this poll) ── if _, exists := nonceByChain[chain]; exists { - if inFlightPerChain[chain] >= PerChainCap { + if isEVM && inFlightPerChain[chain] >= PerChainCap { return 0, false } nonceByChain[chain]++ @@ -1075,7 +1087,7 @@ func (c *Coordinator) assignSignNonce( // Decide: process normally, wait (skip), or recover with finalized nonce. useFinalized := false - if inFlightPerChain[chain] > 0 { + if isEVM && inFlightPerChain[chain] > 0 { c.chainWaitMu.Lock() consecutiveWait := c.consecutiveWaitPerChain[chain] if consecutiveWait < ConsecutiveWaitThreshold { diff --git a/universalClient/tss/coordinator/coordinator_test.go b/universalClient/tss/coordinator/coordinator_test.go index ac81688b..b26baca0 100644 --- a/universalClient/tss/coordinator/coordinator_test.go +++ b/universalClient/tss/coordinator/coordinator_test.go @@ -672,6 +672,7 @@ func TestAssignSignNonce_SubsequentEventUsesCache(t *testing.T) { func TestAssignSignNonce_SubsequentEventCapReached(t *testing.T) { coord, _, _ := setupTestCoordinator(t) + coord.chains = newTestChainsForCoordinator(t, "eip155:1", uregistrytypes.VmType_EVM, &coordMockChainClient{builder: &coordMockTxBuilder{}}) nonceByChain := map[string]uint64{"eip155:1": 10} inFlightPerChain := map[string]int{"eip155:1": PerChainCap} @@ -690,6 +691,7 @@ func TestAssignSignNonce_SubsequentEventCapReached(t *testing.T) { func TestAssignSignNonce_FirstEventWithInFlight_SkipsUntilThreshold(t *testing.T) { coord, _, _ := setupTestCoordinator(t) + coord.chains = newTestChainsForCoordinator(t, "eip155:1", uregistrytypes.VmType_EVM, &coordMockChainClient{builder: &coordMockTxBuilder{}}) inFlightPerChain := map[string]int{"eip155:1": 1} nonceByChain := map[string]uint64{} @@ -712,6 +714,63 @@ func TestAssignSignNonce_FirstEventWithInFlight_SkipsUntilThreshold(t *testing.T coord.chainWaitMu.Unlock() } +// TestAssignSignNonce_SVM_BypassesPerChainCap verifies that SVM chains aren't +// subject to the EVM-only PerChainCap. Solana has no nonce-based ordering, so +// in-flight count creates no operational pressure. +func TestAssignSignNonce_SVM_BypassesPerChainCap(t *testing.T) { + coord, _, _ := setupTestCoordinator(t) + coord.chains = newTestChainsForCoordinator(t, "solana:mainnet", uregistrytypes.VmType_SVM, &coordMockChainClient{builder: &coordMockTxBuilder{}}) + + // Subsequent-event branch with in-flight at the cap. On EVM this would + // return (0, false); on SVM we should pass through and assign. + nonceByChain := map[string]uint64{"solana:mainnet": 0} + inFlightPerChain := map[string]int{"solana:mainnet": PerChainCap} + + nonce, ok := coord.assignSignNonce( + context.Background(), + store.Event{EventID: "e1"}, + "solana:mainnet", + inFlightPerChain, + nonceByChain, + map[string]bool{}, + ) + assert.True(t, ok, "SVM should bypass PerChainCap") + assert.Equal(t, uint64(1), nonce) + assert.Equal(t, PerChainCap+1, inFlightPerChain["solana:mainnet"]) +} + +// TestAssignSignNonce_SVM_BypassesInFlightSkip verifies that SVM chains +// bypass the EVM-only wait-counter/skippedChains machinery. Even with +// in-flight events, the chain must NOT be marked as skipped and the +// consecutive-wait counter must NOT increment. +// +// The downstream getNextNonceForChain call still tries to fetch a TSS +// address (which fails in the test fixture, so ok=false here). That's +// orthogonal to what we're testing — the gate is observed via the absence +// of side-effects on skippedChains / consecutiveWaitPerChain. +func TestAssignSignNonce_SVM_BypassesInFlightSkip(t *testing.T) { + coord, _, _ := setupTestCoordinator(t) + coord.chains = newTestChainsForCoordinator(t, "solana:mainnet", uregistrytypes.VmType_SVM, &coordMockChainClient{builder: &coordMockTxBuilder{}}) + + inFlightPerChain := map[string]int{"solana:mainnet": 5} + nonceByChain := map[string]uint64{} + skippedChains := map[string]bool{} + + _, _ = coord.assignSignNonce( + context.Background(), + store.Event{EventID: "e1"}, + "solana:mainnet", + inFlightPerChain, + nonceByChain, + skippedChains, + ) + + assert.False(t, skippedChains["solana:mainnet"], "SVM chain must not be marked as skipped on in-flight events") + coord.chainWaitMu.Lock() + assert.Equal(t, 0, coord.consecutiveWaitPerChain["solana:mainnet"], "SVM chain must not advance the consecutive-wait counter") + coord.chainWaitMu.Unlock() +} + // --- Lifecycle --- func TestCoordinator_StartStop(t *testing.T) { From 0c574b13f297941649cfed39bd084fc618bdc391 Mon Sep 17 00:00:00 2001 From: aman035 Date: Fri, 22 May 2026 21:27:57 +0530 Subject: [PATCH 14/17] fix: orphan pda closure --- universalClient/chains/svm/rent_reclaimer.go | 11 ++- universalClient/chains/svm/tx_builder.go | 11 +-- universalClient/chains/svm/tx_builder_test.go | 91 ++++++++++++++++++- 3 files changed, 103 insertions(+), 10 deletions(-) diff --git a/universalClient/chains/svm/rent_reclaimer.go b/universalClient/chains/svm/rent_reclaimer.go index 2ffe62cc..ad0d87c0 100644 --- a/universalClient/chains/svm/rent_reclaimer.go +++ b/universalClient/chains/svm/rent_reclaimer.go @@ -208,9 +208,16 @@ func (r *RentReclaimer) isOldEnough(ctx context.Context, addr solana.PublicKey) } // closeOrphan builds and broadcasts an arg-free close_stored_ix_data tx. -// The contract reads sub_tx_id and ix_data from the PDA itself. func (r *RentReclaimer) closeOrphan(ctx context.Context, o orphanPDA, relayer solana.PrivateKey) error { - accounts := r.builder.buildCloseStoredIxDataAccounts(relayer.PublicKey(), o.address) + executedSubTxPDA, _, err := solana.FindProgramAddress( + [][]byte{executedSubTxSeed, o.subTxID[:]}, + r.builder.gatewayAddress, + ) + if err != nil { + return fmt.Errorf("derive executed_sub_tx PDA: %w", err) + } + + accounts := r.builder.buildCloseStoredIxDataAccounts(relayer.PublicKey(), o.address, executedSubTxPDA) closeIx := solana.NewInstruction(r.builder.gatewayAddress, accounts, discCloseStoredIxData[:]) blockhash, err := r.builder.rpcClient.GetRecentBlockhash(ctx) diff --git a/universalClient/chains/svm/tx_builder.go b/universalClient/chains/svm/tx_builder.go index 7e6b8dea..77d6ff9f 100644 --- a/universalClient/chains/svm/tx_builder.go +++ b/universalClient/chains/svm/tx_builder.go @@ -2251,17 +2251,16 @@ func (tb *TxBuilder) buildStoreIxDataAccounts(caller, storedIxDataPDA solana.Pub } // buildCloseStoredIxDataAccounts builds the accounts list for close_stored_ix_data. +// All four metas required — Anchor's Option still demands a slot. // -// # Account Flags Notes -// 1 caller signer, mut The relayer -// 2 stored_ix_data mut PDA being closed -// 3 store_refund_recipient mut Rent destination (= caller for the cron path) -// 4 executed_sub_tx optional Omitted; widens permission to "anyone" when present -func (tb *TxBuilder) buildCloseStoredIxDataAccounts(caller, storedIxDataPDA solana.PublicKey) []*solana.AccountMeta { +// 1 caller (signer, mut) 2 stored_ix_data (mut) +// 3 store_refund_recipient (mut) 4 executed_sub_tx (canonical PDA) +func (tb *TxBuilder) buildCloseStoredIxDataAccounts(caller, storedIxDataPDA, executedSubTxPDA solana.PublicKey) []*solana.AccountMeta { return []*solana.AccountMeta{ {PublicKey: caller, IsWritable: true, IsSigner: true}, {PublicKey: storedIxDataPDA, IsWritable: true, IsSigner: false}, {PublicKey: caller, IsWritable: true, IsSigner: false}, + {PublicKey: executedSubTxPDA, IsWritable: false, IsSigner: false}, } } diff --git a/universalClient/chains/svm/tx_builder_test.go b/universalClient/chains/svm/tx_builder_test.go index 0022551b..8133793d 100644 --- a/universalClient/chains/svm/tx_builder_test.go +++ b/universalClient/chains/svm/tx_builder_test.go @@ -10,6 +10,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "testing" "time" @@ -1875,9 +1876,10 @@ func TestBuildCloseStoredIxDataAccounts(t *testing.T) { builder := newTestBuilder(t) caller := solana.NewWallet().PublicKey() storedPDA := solana.NewWallet().PublicKey() + executedSubTxPDA := solana.NewWallet().PublicKey() - accounts := builder.buildCloseStoredIxDataAccounts(caller, storedPDA) - require.Len(t, accounts, 3) + accounts := builder.buildCloseStoredIxDataAccounts(caller, storedPDA, executedSubTxPDA) + require.Len(t, accounts, 4, "Anchor's Option still requires a meta slot") // caller: signer, mut assert.Equal(t, caller, accounts[0].PublicKey) @@ -1894,6 +1896,13 @@ func TestBuildCloseStoredIxDataAccounts(t *testing.T) { assert.Equal(t, caller, accounts[2].PublicKey, "refund recipient must equal caller") assert.True(t, accounts[2].IsWritable) assert.False(t, accounts[2].IsSigner) + + // executed_sub_tx: canonical PDA address, read-only. Contract loads the + // account and inspects its data — even if the on-chain account doesn't + // exist (finalize hasn't succeeded), the meta slot must still be populated. + assert.Equal(t, executedSubTxPDA, accounts[3].PublicKey) + assert.False(t, accounts[3].IsWritable, "executed_sub_tx is read-only") + assert.False(t, accounts[3].IsSigner) } func TestBuildWithdrawAndExecuteAccounts_RefRouteSlots(t *testing.T) { @@ -2533,6 +2542,84 @@ func buildAndSimulateRefRoute( return storeSim, storedPDA, nil } +// TestSimulate_CloseStoredIxData_MetaShape verifies the close_stored_ix_data +// account-meta list shape against devnet. We target a deliberately +// non-existent PDA so the contract advances past meta-count validation and +// fails at stored_ix_data deserialization. +// +// - PASS: simulation errors with Anchor 3012 (AccountNotInitialized) on +// stored_ix_data — meta count was correct, contract reached step 2. +// - FAIL: simulation errors with Anchor 3005 (AccountNotEnoughKeys) on +// executed_sub_tx — meta count is wrong (the production bug). +// +// No on-chain state needed; no fees spent. +func TestSimulate_CloseStoredIxData_MetaShape(t *testing.T) { + rpcClient, builder := setupDevnetSimulation(t) + defer rpcClient.Close() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + relayerKey, err := builder.loadRelayerKeypair() + require.NoError(t, err) + + // Deterministic fake sub_tx_id + ix_data so the derived PDAs don't exist. + var subTxID [32]byte + for i := range subTxID { + subTxID[i] = 0xAB + } + fakeIxData := []byte("never-actually-stored") + var ixDataHash [32]byte + copy(ixDataHash[:], crypto.Keccak256(fakeIxData)) + + storedPDA, err := builder.deriveStoredIxDataPDA(subTxID, ixDataHash) + require.NoError(t, err) + executedSubTxPDA, _, err := solana.FindProgramAddress( + [][]byte{executedSubTxSeed, subTxID[:]}, + builder.gatewayAddress, + ) + require.NoError(t, err) + + accounts := builder.buildCloseStoredIxDataAccounts(relayerKey.PublicKey(), storedPDA, executedSubTxPDA) + require.Len(t, accounts, 4, "must include the executed_sub_tx meta to satisfy Anchor Option") + closeIx := solana.NewInstruction(builder.gatewayAddress, accounts, discCloseStoredIxData[:]) + + blockhash, err := rpcClient.GetRecentBlockhash(ctx) + require.NoError(t, err) + + tx, err := solana.NewTransaction( + []solana.Instruction{closeIx}, + blockhash, + solana.TransactionPayer(relayerKey.PublicKey()), + ) + require.NoError(t, err) + _, err = tx.Sign(func(key solana.PublicKey) *solana.PrivateKey { + if key.Equals(relayerKey.PublicKey()) { + priv := relayerKey + return &priv + } + return nil + }) + require.NoError(t, err) + + sim, err := rpcClient.SimulateTransaction(ctx, tx) + require.NoError(t, err) + + // The simulation MUST fail (PDA doesn't exist) but the failure must be on + // stored_ix_data (Anchor 3012) — NOT on executed_sub_tx (Anchor 3005, + // AccountNotEnoughKeys, which is the production-incident bug). + require.NotNil(t, sim.Err, "expected simulation to fail against a non-existent PDA") + + for _, log := range sim.Logs { + t.Log(log) + } + + joined := strings.Join(sim.Logs, "\n") + require.NotContains(t, joined, "AccountNotEnoughKeys", + "meta-count is wrong — Anchor rejected before reaching stored_ix_data deserialization") + require.Contains(t, joined, "AccountNotInitialized", + "expected stored_ix_data AccountNotInitialized (proves meta-count is correct)") +} + // TestSimulate_RefRoute_Execute simulates the store half of the ref-finalize // pipeline against devnet. The ref-finalize half can't be simulated standalone // because it depends on the store tx having actually landed on-chain first; From a5fc3fa9ddd23cfa59a986edc6c27a283fccc9af Mon Sep 17 00:00:00 2001 From: aman035 Date: Fri, 22 May 2026 22:14:45 +0530 Subject: [PATCH 15/17] fix: txBuilder ref finalize account write status --- universalClient/chains/svm/tx_builder.go | 4 +++- universalClient/chains/svm/tx_builder_test.go | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/universalClient/chains/svm/tx_builder.go b/universalClient/chains/svm/tx_builder.go index 77d6ff9f..25ad2f2f 100644 --- a/universalClient/chains/svm/tx_builder.go +++ b/universalClient/chains/svm/tx_builder.go @@ -1986,7 +1986,9 @@ func (tb *TxBuilder) buildWithdrawAndExecuteAccounts( if storedIxDataPDA.IsZero() { accounts = append(accounts, &solana.AccountMeta{PublicKey: tb.gatewayAddress, IsWritable: false, IsSigner: false}) } else { - accounts = append(accounts, &solana.AccountMeta{PublicKey: storedIxDataPDA, IsWritable: false, IsSigner: false}) + // Must be writable — finalize_universal_tx_with_ix_data_ref auto-closes + // the PDA on success (the contract declares it `#[account(mut)]`). + accounts = append(accounts, &solana.AccountMeta{PublicKey: storedIxDataPDA, IsWritable: true, IsSigner: false}) } if storeRefundRecipient.IsZero() { accounts = append(accounts, &solana.AccountMeta{PublicKey: tb.gatewayAddress, IsWritable: false, IsSigner: false}) diff --git a/universalClient/chains/svm/tx_builder_test.go b/universalClient/chains/svm/tx_builder_test.go index 8133793d..c05f5edd 100644 --- a/universalClient/chains/svm/tx_builder_test.go +++ b/universalClient/chains/svm/tx_builder_test.go @@ -1934,7 +1934,7 @@ func TestBuildWithdrawAndExecuteAccounts_RefRouteSlots(t *testing.T) { // Position 18 (0-indexed): stored_ix_data assert.Equal(t, storedPDA, accounts[18].PublicKey, "stored_ix_data slot") - assert.False(t, accounts[18].IsWritable, "stored_ix_data is read-only on-chain") + assert.True(t, accounts[18].IsWritable, "stored_ix_data must be writable; finalize auto-closes it on success") // Position 19: store_refund_recipient assert.Equal(t, storeRefund, accounts[19].PublicKey, "store_refund_recipient slot") From 481d95e7dc8ad7761935322ad0df016289c3b881 Mon Sep 17 00:00:00 2001 From: aman035 Date: Fri, 22 May 2026 22:19:02 +0530 Subject: [PATCH 16/17] fix: tc --- universalClient/chains/svm/tx_builder_test.go | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/universalClient/chains/svm/tx_builder_test.go b/universalClient/chains/svm/tx_builder_test.go index c05f5edd..2c145c5f 100644 --- a/universalClient/chains/svm/tx_builder_test.go +++ b/universalClient/chains/svm/tx_builder_test.go @@ -2620,6 +2620,58 @@ func TestSimulate_CloseStoredIxData_MetaShape(t *testing.T) { "expected stored_ix_data AccountNotInitialized (proves meta-count is correct)") } +// TestSimulate_FinalizeRef_MetaShape simulates a finalize_universal_tx_with_ix_data_ref +// against a non-existent StoredIxData PDA and asserts the failure is at +// data-deserialization (Anchor 3012 / AccountNotInitialized), not at an +// earlier shape-level check (3005 AccountNotEnoughKeys / 2003 ConstraintSeeds / +// etc). +// +// Caveat: this does NOT catch ConstraintMut (2000). For `Option`, +// Anchor short-circuits at "account empty → 3012" before checking mut — so a +// missing writable flag only surfaces against a *real* on-chain PDA. Covering +// that needs a broadcast-then-simulate flow with real lamports. +func TestSimulate_FinalizeRef_MetaShape(t *testing.T) { + rpcClient, builder := setupDevnetSimulation(t) + defer rpcClient.Close() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // Build a real ref-route outbound; we sim only the finalize tx against + // a PDA that hasn't been stored. The store-then-finalize ordering doesn't + // matter — Anchor's meta-level checks (mut, signer, seeds, etc.) fire + // before any account is deserialized. + ixData := make([]byte, 600) + for i := range ixData { + ixData[i] = byte('A' + (i % 26)) + } + payload := buildMockExecutePayload(nil, ixData) + payloadHex := "0x" + hex.EncodeToString(payload) + data, evmKey := newDevnetOutbound(t, "10000000", "", payloadHex, "", "FUNDS_AND_PAYLOAD") + data.Recipient = devnetMemoProgram + + req, err := builder.GetOutboundSigningRequest(ctx, data, 0) + require.NoError(t, err) + sig, recoveryID := signMessageHash(t, evmKey, req.SigningHash) + fullSig := append(sig, recoveryID) + + _, refTx, _, err := builder.BuildRefRouteTransactions(ctx, req, data, fullSig) + require.NoError(t, err) + + sim, err := rpcClient.SimulateTransaction(ctx, refTx) + require.NoError(t, err) + require.NotNil(t, sim.Err, "expected sim to fail against a non-existent stored_ix_data PDA") + + for _, log := range sim.Logs { + t.Log(log) + } + + joined := strings.Join(sim.Logs, "\n") + require.NotContains(t, joined, "AccountNotEnoughKeys", + "finalize_ref meta-count too low (Anchor 3005)") + require.Contains(t, joined, "AccountNotInitialized", + "expected to reach stored_ix_data deserialization (proves meta-count + shape constraints passed)") +} + // TestSimulate_RefRoute_Execute simulates the store half of the ref-finalize // pipeline against devnet. The ref-finalize half can't be simulated standalone // because it depends on the store tx having actually landed on-chain first; From 24ef042546fa69a7f4f773ca59f7612a690b2dea Mon Sep 17 00:00:00 2001 From: aman035 Date: Fri, 22 May 2026 22:44:23 +0530 Subject: [PATCH 17/17] fix: storeRefundRecipient --- universalClient/chains/svm/tx_builder.go | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/universalClient/chains/svm/tx_builder.go b/universalClient/chains/svm/tx_builder.go index 25ad2f2f..1b218faf 100644 --- a/universalClient/chains/svm/tx_builder.go +++ b/universalClient/chains/svm/tx_builder.go @@ -1168,6 +1168,16 @@ func (tb *TxBuilder) BuildRefRouteTransactions( return nil, nil, solana.PublicKey{}, fmt.Errorf("failed to derive stored_ix_data PDA: %w", err) } + // Resolve store_refund_recipient: + // - If the PDA already exists on-chain (another validator won the store + // race), the contract enforces store_refund_recipient.key() == stored + // value, so we must echo whatever's already stored — not our own key. + // - Otherwise we'll be the one creating the PDA, so our relayer is right. + storeRefundRecipient := relayerKeypair.PublicKey() + if existing, _ := tb.rpcClient.GetAccountData(ctx, storedIxDataPDA); len(existing) >= storedIxDataRefundRecipientOffset+32 { + copy(storeRefundRecipient[:], existing[storedIxDataRefundRecipientOffset:storedIxDataRefundRecipientOffset+32]) + } + // --- Build store_execute_ix_data tx (relayer-signed only, no TSS) --- recentBlockhash, err := tb.rpcClient.GetRecentBlockhash(ctx) @@ -1216,7 +1226,7 @@ func (tb *TxBuilder) BuildRefRouteTransactions( isNative, 2, // execute recipientPubkey, mintPubkey, execAccounts, - storedIxDataPDA, relayerKeypair.PublicKey(), // ref route: real values + storedIxDataPDA, storeRefundRecipient, // ref route: real values ) refInstruction := solana.NewInstruction(tb.gatewayAddress, refAccounts, refInstructionData)