Skip to content

Commit 874f1c4

Browse files
committed
feat: add SPL token transfer support to build_consolidate
build_consolidate previously only emitted native SOL system transfers. Now it also handles SPL token recipients by emitting transfer_checked instructions, reusing the same pattern as build_payment. The source ATA is derived from the sender (child address) + mint; the destination ATA is passed in directly by the caller (already exists on the wallet root). Also updates the ConsolidateIntent TypeScript type to include tokenAddress, tokenProgramId, and decimalPlaces on recipients, matching PaymentIntent. Ticket: BTC-3188
1 parent aaff21f commit 874f1c4

2 files changed

Lines changed: 213 additions & 8 deletions

File tree

packages/wasm-solana/js/intentBuilder.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ export interface DurableNonce {
5151

5252
/** Parameters for building a transaction from intent */
5353
export interface BuildFromIntentParams {
54-
/** Fee payer address (wallet root) */
54+
/** Fee payer address — typically the wallet root, but for consolidation this is the child address being swept */
5555
feePayer: string;
5656
/** Nonce source - blockhash or durable nonce */
5757
nonce: NonceSource;
@@ -175,10 +175,16 @@ export interface ConsolidateIntent extends BaseIntent {
175175
intentType: "consolidate";
176176
/** The child address to consolidate from (sender) */
177177
receiveAddress: string;
178-
/** Recipients (root address for SOL, ATAs for tokens) */
178+
/** Recipients (root address for native SOL, wallet ATAs for tokens) */
179179
recipients?: Array<{
180180
address?: { address: string };
181-
amount?: { value: bigint };
181+
amount?: { value: bigint; symbol?: string };
182+
/** Mint address (base58) — if set, this is an SPL token transfer */
183+
tokenAddress?: string;
184+
/** Token program ID (defaults to SPL Token Program) */
185+
tokenProgramId?: string;
186+
/** Decimal places for the token (required for transfer_checked) */
187+
decimalPlaces?: number;
182188
}>;
183189
}
184190

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

Lines changed: 204 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1050,6 +1050,8 @@ fn build_consolidate(
10501050
.parse()
10511051
.map_err(|_| WasmSolanaError::new("Invalid receiveAddress (sender)"))?;
10521052

1053+
let default_token_program: Pubkey = SPL_TOKEN_PROGRAM_ID.parse().unwrap();
1054+
10531055
let mut instructions = Vec::new();
10541056

10551057
for recipient in intent.recipients {
@@ -1058,19 +1060,73 @@ fn build_consolidate(
10581060
.as_ref()
10591061
.map(|a| &a.address)
10601062
.ok_or_else(|| WasmSolanaError::new("Recipient missing address"))?;
1061-
let amount = recipient
1063+
let amount_wrapper = recipient
10621064
.amount
10631065
.as_ref()
1064-
.map(|a| &a.value)
10651066
.ok_or_else(|| WasmSolanaError::new("Recipient missing amount"))?;
10661067

10671068
let to_pubkey: Pubkey = address.parse().map_err(|_| {
10681069
WasmSolanaError::new(&format!("Invalid recipient address: {}", address))
10691070
})?;
1070-
let lamports: u64 = *amount;
10711071

1072-
// Transfer from sender (child address), not fee_payer
1073-
instructions.push(system_ix::transfer(&sender, &to_pubkey, lamports));
1072+
let mint_str = recipient.token_address.as_deref();
1073+
1074+
if let Some(mint_str) = mint_str {
1075+
// SPL token transfer: sender (child address) is the authority over its own ATA.
1076+
// The destination ATA is passed in directly by the caller — do NOT re-derive it.
1077+
let mint: Pubkey = mint_str
1078+
.parse()
1079+
.map_err(|_| WasmSolanaError::new(&format!("Invalid token mint: {}", mint_str)))?;
1080+
1081+
let token_program: Pubkey = recipient
1082+
.token_program_id
1083+
.as_deref()
1084+
.map(|p| {
1085+
p.parse()
1086+
.map_err(|_| WasmSolanaError::new("Invalid tokenProgramId"))
1087+
})
1088+
.transpose()?
1089+
.unwrap_or(default_token_program);
1090+
1091+
let decimals = recipient
1092+
.decimal_places
1093+
.ok_or_else(|| WasmSolanaError::new("Token transfer requires decimalPlaces"))?;
1094+
1095+
// Source ATA: derive from sender (child address) + mint
1096+
let sender_ata = derive_ata(&sender, &mint, &token_program);
1097+
1098+
// Destination ATA: passed in as-is (already exists on wallet root, caller provides it)
1099+
let dest_ata = to_pubkey;
1100+
1101+
use spl_token::instruction::TokenInstruction;
1102+
let data = TokenInstruction::TransferChecked {
1103+
amount: amount_wrapper.value,
1104+
decimals,
1105+
}
1106+
.pack();
1107+
1108+
// Accounts: source(w), mint(r), destination(w), authority(signer)
1109+
// sender is both signer and owner of the source ATA
1110+
let transfer_ix = Instruction::new_with_bytes(
1111+
token_program,
1112+
&data,
1113+
vec![
1114+
AccountMeta::new(sender_ata, false),
1115+
AccountMeta::new_readonly(mint, false),
1116+
AccountMeta::new(dest_ata, false),
1117+
AccountMeta::new_readonly(sender, true),
1118+
],
1119+
);
1120+
1121+
instructions.push(transfer_ix);
1122+
} else {
1123+
// Native SOL transfer from sender (child address), not fee_payer
1124+
instructions.push(system_ix::transfer(
1125+
&sender,
1126+
&to_pubkey,
1127+
amount_wrapper.value,
1128+
));
1129+
}
10741130
}
10751131

10761132
Ok((instructions, vec![]))
@@ -1424,4 +1480,147 @@ mod tests {
14241480
"Error should mention missing recipients"
14251481
);
14261482
}
1483+
1484+
#[test]
1485+
fn test_build_consolidate_native_sol() {
1486+
// Child address consolidates native SOL to root
1487+
let child_address = "FKjSjCqByQRwSzZoMXA7bKnDbJe41YgJTHFFzBeC42bH";
1488+
let root_address = "DgT9qyYwYKBRDyDw3EfR12LHQCQjtNrKu2qMsXHuosmB";
1489+
1490+
let intent = serde_json::json!({
1491+
"intentType": "consolidate",
1492+
"receiveAddress": child_address,
1493+
"recipients": [{
1494+
"address": { "address": root_address },
1495+
"amount": { "value": "5000000" }
1496+
}]
1497+
});
1498+
1499+
let result = build_from_intent(&intent, &test_params());
1500+
assert!(result.is_ok(), "Failed: {:?}", result);
1501+
let result = result.unwrap();
1502+
assert!(result.generated_keypairs.is_empty());
1503+
1504+
// Should have 1 system transfer instruction
1505+
let msg = result.transaction.message();
1506+
assert_eq!(
1507+
msg.instructions.len(),
1508+
1,
1509+
"Native SOL consolidate should have 1 instruction"
1510+
);
1511+
}
1512+
1513+
#[test]
1514+
fn test_build_consolidate_with_token_recipients() {
1515+
// Child address consolidates SPL tokens to root's ATA.
1516+
// The destination ATA is passed in directly by the caller — not re-derived.
1517+
let child_address = "FKjSjCqByQRwSzZoMXA7bKnDbJe41YgJTHFFzBeC42bH";
1518+
// USDC mint
1519+
let mint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
1520+
// Root wallet's USDC ATA (pre-existing, passed in by caller)
1521+
let root_ata = "5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1";
1522+
1523+
let intent = serde_json::json!({
1524+
"intentType": "consolidate",
1525+
"receiveAddress": child_address,
1526+
"recipients": [{
1527+
"address": { "address": root_ata },
1528+
"amount": { "value": "1000000" },
1529+
"tokenAddress": mint,
1530+
"decimalPlaces": 6
1531+
}]
1532+
});
1533+
1534+
let result = build_from_intent(&intent, &test_params());
1535+
assert!(result.is_ok(), "Failed: {:?}", result);
1536+
let result = result.unwrap();
1537+
assert!(result.generated_keypairs.is_empty());
1538+
1539+
// Should have 1 transfer_checked instruction (no create ATA — dest already exists)
1540+
let msg = result.transaction.message();
1541+
assert_eq!(
1542+
msg.instructions.len(),
1543+
1,
1544+
"Token consolidate should have 1 transfer_checked instruction"
1545+
);
1546+
1547+
// The instruction should use the SPL Token program
1548+
let token_program: Pubkey = SPL_TOKEN_PROGRAM_ID.parse().unwrap();
1549+
let ix = &msg.instructions[0];
1550+
let program_id = msg.account_keys[ix.program_id_index as usize];
1551+
assert_eq!(
1552+
program_id, token_program,
1553+
"Instruction should use SPL Token program"
1554+
);
1555+
1556+
// Verify account count: source ATA, mint, dest ATA, authority (sender/child) = 4
1557+
assert_eq!(
1558+
ix.accounts.len(),
1559+
4,
1560+
"transfer_checked should have 4 accounts"
1561+
);
1562+
}
1563+
1564+
#[test]
1565+
fn test_build_consolidate_token_missing_decimal_places() {
1566+
let child_address = "FKjSjCqByQRwSzZoMXA7bKnDbJe41YgJTHFFzBeC42bH";
1567+
let mint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
1568+
let root_ata = "5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1";
1569+
1570+
let intent = serde_json::json!({
1571+
"intentType": "consolidate",
1572+
"receiveAddress": child_address,
1573+
"recipients": [{
1574+
"address": { "address": root_ata },
1575+
"amount": { "value": "1000000" },
1576+
"tokenAddress": mint
1577+
// decimalPlaces intentionally omitted
1578+
}]
1579+
});
1580+
1581+
let result = build_from_intent(&intent, &test_params());
1582+
assert!(result.is_err(), "Should fail without decimalPlaces");
1583+
assert!(
1584+
result.unwrap_err().to_string().contains("decimalPlaces"),
1585+
"Error should mention decimalPlaces"
1586+
);
1587+
}
1588+
1589+
#[test]
1590+
fn test_build_consolidate_mixed_recipients() {
1591+
// One native SOL recipient and one token recipient
1592+
let child_address = "FKjSjCqByQRwSzZoMXA7bKnDbJe41YgJTHFFzBeC42bH";
1593+
let root_address = "DgT9qyYwYKBRDyDw3EfR12LHQCQjtNrKu2qMsXHuosmB";
1594+
let mint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
1595+
let root_ata = "5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1";
1596+
1597+
let intent = serde_json::json!({
1598+
"intentType": "consolidate",
1599+
"receiveAddress": child_address,
1600+
"recipients": [
1601+
{
1602+
"address": { "address": root_address },
1603+
"amount": { "value": "5000000" }
1604+
},
1605+
{
1606+
"address": { "address": root_ata },
1607+
"amount": { "value": "2000000" },
1608+
"tokenAddress": mint,
1609+
"decimalPlaces": 6
1610+
}
1611+
]
1612+
});
1613+
1614+
let result = build_from_intent(&intent, &test_params());
1615+
assert!(result.is_ok(), "Failed: {:?}", result);
1616+
let result = result.unwrap();
1617+
1618+
// Should have 2 instructions: 1 system transfer + 1 transfer_checked
1619+
let msg = result.transaction.message();
1620+
assert_eq!(
1621+
msg.instructions.len(),
1622+
2,
1623+
"Mixed consolidate should have 2 instructions"
1624+
);
1625+
}
14271626
}

0 commit comments

Comments
 (0)