diff --git a/universalClient/chains/evm/tx_builder.go b/universalClient/chains/evm/tx_builder.go index 8f225e254..317d9caaa 100644 --- a/universalClient/chains/evm/tx_builder.go +++ b/universalClient/chains/evm/tx_builder.go @@ -445,6 +445,7 @@ func (tb *TxBuilder) IsAlreadyExecuted(ctx context.Context, txID string) (bool, return false, 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/client.go b/universalClient/chains/svm/client.go index 2593ce817..3afa0c656 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 000000000..ad0d87c06 --- /dev/null +++ b/universalClient/chains/svm/rent_reclaimer.go @@ -0,0 +1,256 @@ +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. +func (r *RentReclaimer) closeOrphan(ctx context.Context, o orphanPDA, relayer solana.PrivateKey) error { + 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) + 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 000000000..7fa8d28e6 --- /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/chains/svm/rpc_client.go b/universalClient/chains/svm/rpc_client.go index d29e16468..f63676a9d 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) } diff --git a/universalClient/chains/svm/tx_builder.go b/universalClient/chains/svm/tx_builder.go index bfde31f9f..1b218fafe 100644 --- a/universalClient/chains/svm/tx_builder.go +++ b/universalClient/chains/svm/tx_builder.go @@ -1,60 +1,27 @@ -// 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 ( @@ -81,31 +48,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 +324,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 +367,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 +411,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 +439,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 +461,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 +518,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 +536,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 +564,59 @@ func (tb *TxBuilder) BroadcastOutboundSigningRequest( return txHash, nil } +// 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 as a tick-based state +// machine — at most ONE action per broadcaster tick: +// +// - 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, + 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) + } + + 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("ref-finalize broadcast successfully") + return refHash, nil + } + + 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("store_tx_hash", storeHash). + Str("stored_pda", storedPDA.String()). + 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. // Always includes the protocol ALT (if configured). For SPL tokens, also includes // the token-specific ALT for the given mint (if configured). @@ -667,7 +785,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 +807,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 +816,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 +863,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 +881,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 +922,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 +973,298 @@ 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) + } + + // 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) + 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, storeRefundRecipient, // 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 +1288,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 +1332,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 +1404,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 +1879,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 +1905,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 +1936,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 +1954,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 +1972,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 +1982,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 +1990,22 @@ 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 { + // 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}) + } 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 +2022,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 +2070,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 +2124,170 @@ 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}, + } +} + +// buildCloseStoredIxDataAccounts builds the accounts list for close_stored_ix_data. +// All four metas required — Anchor's Option still demands a slot. +// +// 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}, + } +} + +// 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 +2299,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") } diff --git a/universalClient/chains/svm/tx_builder_test.go b/universalClient/chains/svm/tx_builder_test.go index 83d5715b4..2c145c5fd 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" @@ -20,6 +21,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 +294,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 +1089,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 +1133,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 +1152,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 @@ -1422,6 +1426,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) @@ -1548,6 +1575,548 @@ 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 TestBuildCloseStoredIxDataAccounts(t *testing.T) { + builder := newTestBuilder(t) + caller := solana.NewWallet().PublicKey() + storedPDA := solana.NewWallet().PublicKey() + executedSubTxPDA := solana.NewWallet().PublicKey() + + 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) + 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 (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) + + // 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) { + // 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.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") + 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") + }) +} + +// ============================================================================= +// 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 +2200,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 +2484,215 @@ func TestSimulate_Rescue_SPLToken(t *testing.T) { requireSimulationSuccess(t, result) } -func TestGetNextNonce(t *testing.T) { - builder := newTestBuilder(t) +// 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("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) - }) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() - 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) - }) -} + 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)) -func TestGetGasFeeUsed(t *testing.T) { - builder := newTestBuilder(t) + sig, recoveryID := signMessageHash(t, evmKey, req.SigningHash) + fullSig := append(sig, recoveryID) - 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) - }) + 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("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) - }) + // 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") + } + + storeSim, err = rpcClient.SimulateTransaction(ctx, storeTx) + if err != nil { + return nil, storedPDA, fmt.Errorf("simulate store: %w", err) + } + return storeSim, storedPDA, nil } -func TestNewTxBuilder_ChainConfig(t *testing.T) { - logger := zerolog.Nop() +// 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() - 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) - }) + relayerKey, err := builder.loadRelayerKeypair() + require.NoError(t, err) - 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") - }) + // 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)) - 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) - }) + storedPDA, err := builder.deriveStoredIxDataPDA(subTxID, ixDataHash) + require.NoError(t, err) + executedSubTxPDA, _, err := solana.FindProgramAddress( + [][]byte{executedSubTxSeed, subTxID[:]}, + builder.gatewayAddress, + ) + require.NoError(t, err) - 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) - }) + 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[:]) - t.Run("invalid tokenALT address is skipped", func(t *testing.T) { - cfg := &config.ChainSpecificConfig{ - TokenALTs: map[string]string{ - solana.NewWallet().PublicKey().String(): "bad-alt", - }, + 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 } - builder, err := NewTxBuilder(&RPCClient{}, "solana:devnet", testGatewayAddress, "/tmp", logger, cfg) - require.NoError(t, err) - assert.Len(t, builder.tokenALTs, 0) + return nil }) + require.NoError(t, err) - 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) - }) + 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)") } -func TestBuildCreateATAIdempotentInstruction(t *testing.T) { - builder := newTestBuilder(t) - payer := solana.NewWallet().PublicKey() - owner := solana.NewWallet().PublicKey() - mint := solana.NewWallet().PublicKey() +// 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() - ix := builder.buildCreateATAIdempotentInstruction(payer, owner, mint) + // 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 - t.Run("program ID is ATA program", func(t *testing.T) { - expected := solana.MustPublicKeyFromBase58("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL") - assert.Equal(t, expected, ix.ProgramID()) - }) + req, err := builder.GetOutboundSigningRequest(ctx, data, 0) + require.NoError(t, err) + sig, recoveryID := signMessageHash(t, evmKey, req.SigningHash) + fullSig := append(sig, recoveryID) - t.Run("has 6 accounts in correct order", func(t *testing.T) { - accounts := ix.Accounts() - require.Len(t, accounts, 6) + _, refTx, _, err := builder.BuildRefRouteTransactions(ctx, req, data, fullSig) + require.NoError(t, err) - // payer (signer, writable) - assert.Equal(t, payer, accounts[0].PublicKey) - assert.True(t, accounts[0].IsSigner) - assert.True(t, accounts[0].IsWritable) + 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") - // 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) + for _, log := range sim.Logs { + t.Log(log) + } - // owner - assert.Equal(t, owner, accounts[2].PublicKey) - assert.False(t, accounts[2].IsWritable) + 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)") +} - // 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) } diff --git a/universalClient/config/types.go b/universalClient/config/types.go index 0e00d1e9e..8a43a7091 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. diff --git a/universalClient/tss/coordinator/coordinator.go b/universalClient/tss/coordinator/coordinator.go index c68fb9a64..9518d04c6 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 ac81688bb..b26baca04 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) { diff --git a/universalClient/tss/txbroadcaster/broadcaster.go b/universalClient/tss/txbroadcaster/broadcaster.go index d4f3bd592..09457328e 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 6404119b5..dff2d811f 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 70ea667b0..976f6fe70 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") } diff --git a/universalClient/tss/txresolver/resolver_test.go b/universalClient/tss/txresolver/resolver_test.go index 8ac6c0990..a30d7d554 100644 --- a/universalClient/tss/txresolver/resolver_test.go +++ b/universalClient/tss/txresolver/resolver_test.go @@ -271,6 +271,8 @@ func TestSVM_PDAExists_MarksCompleted(t *testing.T) { func TestSVM_PDANotFound_VotesFailureAndReverts(t *testing.T) { // 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} @@ -281,16 +283,14 @@ func TestSVM_PDANotFound_VotesFailureAndReverts(t *testing.T) { builder.On("IsAlreadyExecuted", mock.Anything, "tx-123").Return(false, nil) - // 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) + builder.AssertExpectations(t) } func TestSVM_PDACheckFails_StaysBroadcasted(t *testing.T) {