diff --git a/packages/wasm-solana/js/intentBuilder.ts b/packages/wasm-solana/js/intentBuilder.ts index e927c3d..64e4119 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 07541a1..389b022 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 c8a36cc..13c0581 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 b0fd996..0b0b3cf 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 = {