From f124b6b7976f30016d8b03dd3636cf4d70383660 Mon Sep 17 00:00:00 2001 From: Luis Covarrubias Date: Thu, 12 Mar 2026 17:37:08 -0700 Subject: [PATCH] feat: add SPL token transfer support to wasm-solana payment intent The payment intent builder only supported native SOL transfers. This adds SPL token transfer support so bgms can route all payments through WASM without falling back to the legacy TypeScript path. - Added token_address, token_program_id, decimal_places fields to Recipient (all #[serde(default)], no breakage for existing callers) - Extracted derive_ata() and create_ata_idempotent_ix() helpers - build_payment() detects token recipients via token_address field or symbol containing ':' (bgms format: "USDC:EPjFWaYHrt...") - For token recipients: emits CreateIdempotent ATA + TransferChecked - For native SOL: unchanged system_ix::transfer path - Updated PaymentIntent TS type with tokenAddress, tokenProgramId, decimalPlaces optional fields - 6 new tests: explicit tokenAddress, symbol:mint extraction, SOL regression, missing decimalPlaces error, mixed recipients, round-trip BTC-3149 --- packages/wasm-solana/js/intentBuilder.ts | 6 + packages/wasm-solana/src/intent/build.rs | 111 ++++++++++++++- packages/wasm-solana/src/intent/types.rs | 9 ++ packages/wasm-solana/test/intentBuilder.ts | 150 +++++++++++++++++++++ 4 files changed, 272 insertions(+), 4 deletions(-) diff --git a/packages/wasm-solana/js/intentBuilder.ts b/packages/wasm-solana/js/intentBuilder.ts index e927c3d3..64e41194 100644 --- a/packages/wasm-solana/js/intentBuilder.ts +++ b/packages/wasm-solana/js/intentBuilder.ts @@ -91,6 +91,12 @@ export interface PaymentIntent extends BaseIntent { recipients?: Array<{ address?: { address: string }; amount?: { value: bigint; symbol?: string }; + /** Mint address (base58) — if set, this is an SPL token transfer */ + tokenAddress?: string; + /** Token program ID (defaults to SPL Token Program) */ + tokenProgramId?: string; + /** Decimal places for the token (required for transfer_checked) */ + decimalPlaces?: number; }>; } diff --git a/packages/wasm-solana/src/intent/build.rs b/packages/wasm-solana/src/intent/build.rs index 07541a13..389b0220 100644 --- a/packages/wasm-solana/src/intent/build.rs +++ b/packages/wasm-solana/src/intent/build.rs @@ -165,6 +165,39 @@ fn build_transaction_from_instructions( // Intent Builders // ============================================================================= +/// Derive the Associated Token Account address for `owner` + `mint` under `token_program`. +fn derive_ata(owner: &Pubkey, mint: &Pubkey, token_program: &Pubkey) -> Pubkey { + let ata_program: Pubkey = SPL_ATA_PROGRAM_ID.parse().unwrap(); + let seeds = &[owner.as_ref(), token_program.as_ref(), mint.as_ref()]; + let (ata, _bump) = Pubkey::find_program_address(seeds, &ata_program); + ata +} + +/// Build a `CreateIdempotent` ATA instruction (no-op if ATA already exists). +fn create_ata_idempotent_ix( + fee_payer: &Pubkey, + ata: &Pubkey, + owner: &Pubkey, + mint: &Pubkey, + system_program: &Pubkey, + token_program: &Pubkey, +) -> Instruction { + let ata_program: Pubkey = SPL_ATA_PROGRAM_ID.parse().unwrap(); + // Discriminator byte 1 = CreateIdempotent (0 = Create) + Instruction::new_with_bytes( + ata_program, + &[1], + vec![ + AccountMeta::new(*fee_payer, true), + AccountMeta::new(*ata, false), + AccountMeta::new_readonly(*owner, false), + AccountMeta::new_readonly(*mint, false), + AccountMeta::new_readonly(*system_program, false), + AccountMeta::new_readonly(*token_program, false), + ], + ) +} + fn build_payment( intent_json: &serde_json::Value, params: &BuildParams, @@ -177,6 +210,9 @@ fn build_payment( .parse() .map_err(|_| WasmSolanaError::new("Invalid feePayer"))?; + let system_program: Pubkey = SYSTEM_PROGRAM_ID.parse().unwrap(); + let default_token_program: Pubkey = SPL_TOKEN_PROGRAM_ID.parse().unwrap(); + let mut instructions = Vec::new(); for recipient in intent.recipients { @@ -185,18 +221,85 @@ fn build_payment( .as_ref() .map(|a| &a.address) .ok_or_else(|| WasmSolanaError::new("Recipient missing address"))?; - let amount = recipient + let amount_wrapper = recipient .amount .as_ref() - .map(|a| &a.value) .ok_or_else(|| WasmSolanaError::new("Recipient missing amount"))?; let to_pubkey: Pubkey = address.parse().map_err(|_| { WasmSolanaError::new(&format!("Invalid recipient address: {}", address)) })?; - let lamports: u64 = *amount; - instructions.push(system_ix::transfer(&fee_payer, &to_pubkey, lamports)); + // Detect token transfer: tokenAddress must be set explicitly by the caller. + // The caller (e.g. bgms) is responsible for resolving the token name to a mint + // address via @bitgo/statics before passing to buildFromIntent. + let mint_str = recipient.token_address.as_deref(); + + if let Some(mint_str) = mint_str { + // SPL token transfer + let mint: Pubkey = mint_str + .parse() + .map_err(|_| WasmSolanaError::new(&format!("Invalid token mint: {}", mint_str)))?; + + let token_program: Pubkey = recipient + .token_program_id + .as_deref() + .map(|p| { + p.parse() + .map_err(|_| WasmSolanaError::new("Invalid tokenProgramId")) + }) + .transpose()? + .unwrap_or(default_token_program); + + let decimals = recipient + .decimal_places + .ok_or_else(|| WasmSolanaError::new("Token transfer requires decimalPlaces"))?; + + // Derive ATAs for sender (fee_payer) and recipient + let sender_ata = derive_ata(&fee_payer, &mint, &token_program); + let recipient_ata = derive_ata(&to_pubkey, &mint, &token_program); + + // 1. CreateIdempotent ATA for the recipient (safe to always include) + instructions.push(create_ata_idempotent_ix( + &fee_payer, + &recipient_ata, + &to_pubkey, + &mint, + &system_program, + &token_program, + )); + + // 2. transfer_checked + // Pack the instruction data via spl_token types (avoids solana crate version mismatch) + // then build the Instruction manually with solana_sdk types. + use spl_token::instruction::TokenInstruction; + let data = TokenInstruction::TransferChecked { + amount: amount_wrapper.value, + decimals, + } + .pack(); + + // Accounts: source(w), mint(r), destination(w), authority(signer) + let transfer_ix = Instruction::new_with_bytes( + token_program, + &data, + vec![ + AccountMeta::new(sender_ata, false), + AccountMeta::new_readonly(mint, false), + AccountMeta::new(recipient_ata, false), + AccountMeta::new_readonly(fee_payer, true), + ], + ); + + instructions.push(transfer_ix); + } else { + // Native SOL transfer + instructions.push(system_ix::transfer( + &fee_payer, + &to_pubkey, + amount_wrapper.value, + )); + } } Ok((instructions, vec![])) diff --git a/packages/wasm-solana/src/intent/types.rs b/packages/wasm-solana/src/intent/types.rs index c8a36cc6..13c05819 100644 --- a/packages/wasm-solana/src/intent/types.rs +++ b/packages/wasm-solana/src/intent/types.rs @@ -112,6 +112,15 @@ pub struct BaseIntent { pub struct Recipient { pub address: Option, pub amount: Option, + /// Mint address (base58) — if set, this is an SPL token transfer + #[serde(default)] + pub token_address: Option, + /// Token program ID (defaults to SPL Token Program) + #[serde(default)] + pub token_program_id: Option, + /// Decimal places for the token (required for transfer_checked) + #[serde(default)] + pub decimal_places: Option, } #[derive(Debug, Clone, Deserialize)] diff --git a/packages/wasm-solana/test/intentBuilder.ts b/packages/wasm-solana/test/intentBuilder.ts index b0fd9967..0b0b3cfd 100644 --- a/packages/wasm-solana/test/intentBuilder.ts +++ b/packages/wasm-solana/test/intentBuilder.ts @@ -424,6 +424,156 @@ describe("buildFromIntent", function () { }); }); + describe("payment intent — SPL token transfer", function () { + // USDC mint address on mainnet + const usdcMint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"; + const recipient = "FKjSjCqByQRwSzZoMXA7bKnDbJe41YgJTHFFzBeC42bH"; + + it("should build an SPL token transfer with tokenAddress + decimalPlaces", function () { + const intent = { + intentType: "payment", + recipients: [ + { + address: { address: recipient }, + amount: { value: 1000000n, symbol: "sol:usdc" }, + tokenAddress: usdcMint, + decimalPlaces: 6, + }, + ], + }; + + const result = buildFromIntent(intent, { + feePayer, + nonce: { type: "blockhash", value: blockhash }, + }); + + assert(result.transaction instanceof Transaction, "Should return Transaction object"); + assert.equal(result.generatedKeypairs.length, 0, "Should not generate keypairs"); + + const parsed = parseTransaction(result.transaction); + + const createAta = parsed.instructionsData.find( + (i: any) => i.type === "CreateAssociatedTokenAccount", + ); + assert(createAta, "Should have CreateAssociatedTokenAccount instruction"); + + const tokenTransfer = parsed.instructionsData.find((i: any) => i.type === "TokenTransfer"); + assert(tokenTransfer, "Should have TokenTransfer instruction"); + assert.equal((tokenTransfer as any).tokenAddress, usdcMint, "Token mint should match"); + assert.equal((tokenTransfer as any).amount, BigInt(1000000), "Token amount should match"); + }); + + it("should build native SOL transfer (regression — no token fields)", function () { + const intent = { + intentType: "payment", + recipients: [ + { + address: { address: recipient }, + amount: { value: 1000000n }, + }, + ], + }; + + const result = buildFromIntent(intent, { + feePayer, + nonce: { type: "blockhash", value: blockhash }, + }); + + const parsed = parseTransaction(result.transaction); + + const transfer = parsed.instructionsData.find((i: any) => i.type === "Transfer"); + assert(transfer, "Should have native SOL Transfer instruction"); + + const tokenTransfer = parsed.instructionsData.find((i: any) => i.type === "TokenTransfer"); + assert(!tokenTransfer, "Should NOT have TokenTransfer instruction"); + }); + + it("should error when decimalPlaces is missing for a token transfer", function () { + const intent = { + intentType: "payment", + recipients: [ + { + address: { address: recipient }, + amount: { value: 1000000n }, + tokenAddress: usdcMint, + // decimalPlaces intentionally omitted + }, + ], + }; + + assert.throws(() => { + buildFromIntent(intent, { + feePayer, + nonce: { type: "blockhash", value: blockhash }, + }); + }, /Token transfer requires decimalPlaces/); + }); + + it("should build mixed payment (native SOL + SPL token recipients)", function () { + const intent = { + intentType: "payment", + recipients: [ + { + address: { address: recipient }, + amount: { value: 2000000n }, + }, + { + address: { address: "5ZWgXcyqrrNpQHCme5SdC5hCeYb2o3fEJhF7Gok3bTVN" }, + amount: { value: 100000n }, + tokenAddress: usdcMint, + decimalPlaces: 6, + }, + ], + }; + + const result = buildFromIntent(intent, { + feePayer, + nonce: { type: "blockhash", value: blockhash }, + }); + + const parsed = parseTransaction(result.transaction); + + const solTransfer = parsed.instructionsData.find((i: any) => i.type === "Transfer"); + assert(solTransfer, "Should have native SOL Transfer instruction"); + + const tokenTransfer = parsed.instructionsData.find((i: any) => i.type === "TokenTransfer"); + assert(tokenTransfer, "Should have SPL TokenTransfer instruction"); + }); + + it("parse round-trip: build token transfer then verify parsed output", function () { + const intent = { + intentType: "payment", + recipients: [ + { + address: { address: recipient }, + amount: { value: 1234567n }, + tokenAddress: usdcMint, + decimalPlaces: 6, + }, + ], + }; + + const { transaction } = buildFromIntent(intent, { + feePayer, + nonce: { type: "blockhash", value: blockhash }, + }); + + // Parse round-trip via bytes + const bytes = transaction.toBytes(); + const txFromBytes = Transaction.fromBytes(bytes); + const parsed = parseTransaction(txFromBytes); + + const tokenTransfer = parsed.instructionsData.find((i: any) => i.type === "TokenTransfer"); + assert(tokenTransfer, "Parsed output should have TokenTransfer"); + assert.equal((tokenTransfer as any).tokenAddress, usdcMint, "Mint should survive round-trip"); + assert.equal( + (tokenTransfer as any).amount, + BigInt(1234567), + "Amount should survive round-trip", + ); + }); + }); + describe("error handling", function () { it("should reject invalid intent type", function () { const intent = {