Skip to content

Commit f124b6b

Browse files
committed
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
1 parent 01cd75a commit f124b6b

4 files changed

Lines changed: 272 additions & 4 deletions

File tree

packages/wasm-solana/js/intentBuilder.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,12 @@ export interface PaymentIntent extends BaseIntent {
9191
recipients?: Array<{
9292
address?: { address: string };
9393
amount?: { value: bigint; symbol?: string };
94+
/** Mint address (base58) — if set, this is an SPL token transfer */
95+
tokenAddress?: string;
96+
/** Token program ID (defaults to SPL Token Program) */
97+
tokenProgramId?: string;
98+
/** Decimal places for the token (required for transfer_checked) */
99+
decimalPlaces?: number;
94100
}>;
95101
}
96102

packages/wasm-solana/src/intent/build.rs

Lines changed: 107 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,39 @@ fn build_transaction_from_instructions(
165165
// Intent Builders
166166
// =============================================================================
167167

168+
/// Derive the Associated Token Account address for `owner` + `mint` under `token_program`.
169+
fn derive_ata(owner: &Pubkey, mint: &Pubkey, token_program: &Pubkey) -> Pubkey {
170+
let ata_program: Pubkey = SPL_ATA_PROGRAM_ID.parse().unwrap();
171+
let seeds = &[owner.as_ref(), token_program.as_ref(), mint.as_ref()];
172+
let (ata, _bump) = Pubkey::find_program_address(seeds, &ata_program);
173+
ata
174+
}
175+
176+
/// Build a `CreateIdempotent` ATA instruction (no-op if ATA already exists).
177+
fn create_ata_idempotent_ix(
178+
fee_payer: &Pubkey,
179+
ata: &Pubkey,
180+
owner: &Pubkey,
181+
mint: &Pubkey,
182+
system_program: &Pubkey,
183+
token_program: &Pubkey,
184+
) -> Instruction {
185+
let ata_program: Pubkey = SPL_ATA_PROGRAM_ID.parse().unwrap();
186+
// Discriminator byte 1 = CreateIdempotent (0 = Create)
187+
Instruction::new_with_bytes(
188+
ata_program,
189+
&[1],
190+
vec![
191+
AccountMeta::new(*fee_payer, true),
192+
AccountMeta::new(*ata, false),
193+
AccountMeta::new_readonly(*owner, false),
194+
AccountMeta::new_readonly(*mint, false),
195+
AccountMeta::new_readonly(*system_program, false),
196+
AccountMeta::new_readonly(*token_program, false),
197+
],
198+
)
199+
}
200+
168201
fn build_payment(
169202
intent_json: &serde_json::Value,
170203
params: &BuildParams,
@@ -177,6 +210,9 @@ fn build_payment(
177210
.parse()
178211
.map_err(|_| WasmSolanaError::new("Invalid feePayer"))?;
179212

213+
let system_program: Pubkey = SYSTEM_PROGRAM_ID.parse().unwrap();
214+
let default_token_program: Pubkey = SPL_TOKEN_PROGRAM_ID.parse().unwrap();
215+
180216
let mut instructions = Vec::new();
181217

182218
for recipient in intent.recipients {
@@ -185,18 +221,85 @@ fn build_payment(
185221
.as_ref()
186222
.map(|a| &a.address)
187223
.ok_or_else(|| WasmSolanaError::new("Recipient missing address"))?;
188-
let amount = recipient
224+
let amount_wrapper = recipient
189225
.amount
190226
.as_ref()
191-
.map(|a| &a.value)
192227
.ok_or_else(|| WasmSolanaError::new("Recipient missing amount"))?;
193228

194229
let to_pubkey: Pubkey = address.parse().map_err(|_| {
195230
WasmSolanaError::new(&format!("Invalid recipient address: {}", address))
196231
})?;
197-
let lamports: u64 = *amount;
198232

199-
instructions.push(system_ix::transfer(&fee_payer, &to_pubkey, lamports));
233+
// Detect token transfer: tokenAddress must be set explicitly by the caller.
234+
// The caller (e.g. bgms) is responsible for resolving the token name to a mint
235+
// address via @bitgo/statics before passing to buildFromIntent.
236+
let mint_str = recipient.token_address.as_deref();
237+
238+
if let Some(mint_str) = mint_str {
239+
// SPL token transfer
240+
let mint: Pubkey = mint_str
241+
.parse()
242+
.map_err(|_| WasmSolanaError::new(&format!("Invalid token mint: {}", mint_str)))?;
243+
244+
let token_program: Pubkey = recipient
245+
.token_program_id
246+
.as_deref()
247+
.map(|p| {
248+
p.parse()
249+
.map_err(|_| WasmSolanaError::new("Invalid tokenProgramId"))
250+
})
251+
.transpose()?
252+
.unwrap_or(default_token_program);
253+
254+
let decimals = recipient
255+
.decimal_places
256+
.ok_or_else(|| WasmSolanaError::new("Token transfer requires decimalPlaces"))?;
257+
258+
// Derive ATAs for sender (fee_payer) and recipient
259+
let sender_ata = derive_ata(&fee_payer, &mint, &token_program);
260+
let recipient_ata = derive_ata(&to_pubkey, &mint, &token_program);
261+
262+
// 1. CreateIdempotent ATA for the recipient (safe to always include)
263+
instructions.push(create_ata_idempotent_ix(
264+
&fee_payer,
265+
&recipient_ata,
266+
&to_pubkey,
267+
&mint,
268+
&system_program,
269+
&token_program,
270+
));
271+
272+
// 2. transfer_checked
273+
// Pack the instruction data via spl_token types (avoids solana crate version mismatch)
274+
// then build the Instruction manually with solana_sdk types.
275+
use spl_token::instruction::TokenInstruction;
276+
let data = TokenInstruction::TransferChecked {
277+
amount: amount_wrapper.value,
278+
decimals,
279+
}
280+
.pack();
281+
282+
// Accounts: source(w), mint(r), destination(w), authority(signer)
283+
let transfer_ix = Instruction::new_with_bytes(
284+
token_program,
285+
&data,
286+
vec![
287+
AccountMeta::new(sender_ata, false),
288+
AccountMeta::new_readonly(mint, false),
289+
AccountMeta::new(recipient_ata, false),
290+
AccountMeta::new_readonly(fee_payer, true),
291+
],
292+
);
293+
294+
instructions.push(transfer_ix);
295+
} else {
296+
// Native SOL transfer
297+
instructions.push(system_ix::transfer(
298+
&fee_payer,
299+
&to_pubkey,
300+
amount_wrapper.value,
301+
));
302+
}
200303
}
201304

202305
Ok((instructions, vec![]))

packages/wasm-solana/src/intent/types.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,15 @@ pub struct BaseIntent {
112112
pub struct Recipient {
113113
pub address: Option<AddressWrapper>,
114114
pub amount: Option<AmountWrapper>,
115+
/// Mint address (base58) — if set, this is an SPL token transfer
116+
#[serde(default)]
117+
pub token_address: Option<String>,
118+
/// Token program ID (defaults to SPL Token Program)
119+
#[serde(default)]
120+
pub token_program_id: Option<String>,
121+
/// Decimal places for the token (required for transfer_checked)
122+
#[serde(default)]
123+
pub decimal_places: Option<u8>,
115124
}
116125

117126
#[derive(Debug, Clone, Deserialize)]

packages/wasm-solana/test/intentBuilder.ts

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,156 @@ describe("buildFromIntent", function () {
424424
});
425425
});
426426

427+
describe("payment intent — SPL token transfer", function () {
428+
// USDC mint address on mainnet
429+
const usdcMint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
430+
const recipient = "FKjSjCqByQRwSzZoMXA7bKnDbJe41YgJTHFFzBeC42bH";
431+
432+
it("should build an SPL token transfer with tokenAddress + decimalPlaces", function () {
433+
const intent = {
434+
intentType: "payment",
435+
recipients: [
436+
{
437+
address: { address: recipient },
438+
amount: { value: 1000000n, symbol: "sol:usdc" },
439+
tokenAddress: usdcMint,
440+
decimalPlaces: 6,
441+
},
442+
],
443+
};
444+
445+
const result = buildFromIntent(intent, {
446+
feePayer,
447+
nonce: { type: "blockhash", value: blockhash },
448+
});
449+
450+
assert(result.transaction instanceof Transaction, "Should return Transaction object");
451+
assert.equal(result.generatedKeypairs.length, 0, "Should not generate keypairs");
452+
453+
const parsed = parseTransaction(result.transaction);
454+
455+
const createAta = parsed.instructionsData.find(
456+
(i: any) => i.type === "CreateAssociatedTokenAccount",
457+
);
458+
assert(createAta, "Should have CreateAssociatedTokenAccount instruction");
459+
460+
const tokenTransfer = parsed.instructionsData.find((i: any) => i.type === "TokenTransfer");
461+
assert(tokenTransfer, "Should have TokenTransfer instruction");
462+
assert.equal((tokenTransfer as any).tokenAddress, usdcMint, "Token mint should match");
463+
assert.equal((tokenTransfer as any).amount, BigInt(1000000), "Token amount should match");
464+
});
465+
466+
it("should build native SOL transfer (regression — no token fields)", function () {
467+
const intent = {
468+
intentType: "payment",
469+
recipients: [
470+
{
471+
address: { address: recipient },
472+
amount: { value: 1000000n },
473+
},
474+
],
475+
};
476+
477+
const result = buildFromIntent(intent, {
478+
feePayer,
479+
nonce: { type: "blockhash", value: blockhash },
480+
});
481+
482+
const parsed = parseTransaction(result.transaction);
483+
484+
const transfer = parsed.instructionsData.find((i: any) => i.type === "Transfer");
485+
assert(transfer, "Should have native SOL Transfer instruction");
486+
487+
const tokenTransfer = parsed.instructionsData.find((i: any) => i.type === "TokenTransfer");
488+
assert(!tokenTransfer, "Should NOT have TokenTransfer instruction");
489+
});
490+
491+
it("should error when decimalPlaces is missing for a token transfer", function () {
492+
const intent = {
493+
intentType: "payment",
494+
recipients: [
495+
{
496+
address: { address: recipient },
497+
amount: { value: 1000000n },
498+
tokenAddress: usdcMint,
499+
// decimalPlaces intentionally omitted
500+
},
501+
],
502+
};
503+
504+
assert.throws(() => {
505+
buildFromIntent(intent, {
506+
feePayer,
507+
nonce: { type: "blockhash", value: blockhash },
508+
});
509+
}, /Token transfer requires decimalPlaces/);
510+
});
511+
512+
it("should build mixed payment (native SOL + SPL token recipients)", function () {
513+
const intent = {
514+
intentType: "payment",
515+
recipients: [
516+
{
517+
address: { address: recipient },
518+
amount: { value: 2000000n },
519+
},
520+
{
521+
address: { address: "5ZWgXcyqrrNpQHCme5SdC5hCeYb2o3fEJhF7Gok3bTVN" },
522+
amount: { value: 100000n },
523+
tokenAddress: usdcMint,
524+
decimalPlaces: 6,
525+
},
526+
],
527+
};
528+
529+
const result = buildFromIntent(intent, {
530+
feePayer,
531+
nonce: { type: "blockhash", value: blockhash },
532+
});
533+
534+
const parsed = parseTransaction(result.transaction);
535+
536+
const solTransfer = parsed.instructionsData.find((i: any) => i.type === "Transfer");
537+
assert(solTransfer, "Should have native SOL Transfer instruction");
538+
539+
const tokenTransfer = parsed.instructionsData.find((i: any) => i.type === "TokenTransfer");
540+
assert(tokenTransfer, "Should have SPL TokenTransfer instruction");
541+
});
542+
543+
it("parse round-trip: build token transfer then verify parsed output", function () {
544+
const intent = {
545+
intentType: "payment",
546+
recipients: [
547+
{
548+
address: { address: recipient },
549+
amount: { value: 1234567n },
550+
tokenAddress: usdcMint,
551+
decimalPlaces: 6,
552+
},
553+
],
554+
};
555+
556+
const { transaction } = buildFromIntent(intent, {
557+
feePayer,
558+
nonce: { type: "blockhash", value: blockhash },
559+
});
560+
561+
// Parse round-trip via bytes
562+
const bytes = transaction.toBytes();
563+
const txFromBytes = Transaction.fromBytes(bytes);
564+
const parsed = parseTransaction(txFromBytes);
565+
566+
const tokenTransfer = parsed.instructionsData.find((i: any) => i.type === "TokenTransfer");
567+
assert(tokenTransfer, "Parsed output should have TokenTransfer");
568+
assert.equal((tokenTransfer as any).tokenAddress, usdcMint, "Mint should survive round-trip");
569+
assert.equal(
570+
(tokenTransfer as any).amount,
571+
BigInt(1234567),
572+
"Amount should survive round-trip",
573+
);
574+
});
575+
});
576+
427577
describe("error handling", function () {
428578
it("should reject invalid intent type", function () {
429579
const intent = {

0 commit comments

Comments
 (0)