Skip to content

Commit 8a654e6

Browse files
authored
Merge pull request #216 from BitGo/BTC-3188/consolidate-token-support
feat: add SPL token transfer support to build_consolidate
2 parents 3a2ad71 + 874f1c4 commit 8a654e6

File tree

2 files changed

+213
-8
lines changed

2 files changed

+213
-8
lines changed

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)