@@ -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