Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/wasm-solana/js/intentBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment on lines 91 to +99
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we probably could use a Recipient type here

}>;
}

Expand Down
111 changes: 107 additions & 4 deletions packages/wasm-solana/src/intent/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand All @@ -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![]))
Expand Down
9 changes: 9 additions & 0 deletions packages/wasm-solana/src/intent/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,15 @@ pub struct BaseIntent {
pub struct Recipient {
pub address: Option<AddressWrapper>,
pub amount: Option<AmountWrapper>,
/// Mint address (base58) — if set, this is an SPL token transfer
#[serde(default)]
pub token_address: Option<String>,
/// Token program ID (defaults to SPL Token Program)
#[serde(default)]
pub token_program_id: Option<String>,
/// Decimal places for the token (required for transfer_checked)
#[serde(default)]
pub decimal_places: Option<u8>,
}

#[derive(Debug, Clone, Deserialize)]
Expand Down
150 changes: 150 additions & 0 deletions packages/wasm-solana/test/intentBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Loading