Skip to content

Commit b0d1ced

Browse files
authored
Merge pull request #208 from BitGo/BTC-3149-sol-token-transfer-intent
feat: add SPL token transfer support to wasm-solana payment intent
2 parents 01cd75a + f124b6b commit b0d1ced

File tree

4 files changed

+272
-4
lines changed

4 files changed

+272
-4
lines changed

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)