diff --git a/packages/wasm-solana/js/intentBuilder.ts b/packages/wasm-solana/js/intentBuilder.ts index 0a109da98f1..5357855d262 100644 --- a/packages/wasm-solana/js/intentBuilder.ts +++ b/packages/wasm-solana/js/intentBuilder.ts @@ -180,6 +180,10 @@ export interface ConsolidateIntent extends BaseIntent { receiveAddress: string; /** Recipients (root address for native SOL, wallet ATAs for tokens) */ recipients?: Recipient[]; + /** When true, emit idempotent CreateAssociatedTokenAccount before each token transfer */ + createAssociatedTokenAccount?: boolean; + /** Owner of the destination ATA (wallet root, not sender) — required when createAssociatedTokenAccount is true */ + ataOwnerAddress?: string; } /** Authorize intent - pre-built transaction message */ diff --git a/packages/wasm-solana/src/intent/build.rs b/packages/wasm-solana/src/intent/build.rs index 420a0723d5a..d23664bd040 100644 --- a/packages/wasm-solana/src/intent/build.rs +++ b/packages/wasm-solana/src/intent/build.rs @@ -1039,7 +1039,7 @@ fn build_close_ata( fn build_consolidate( intent_json: &serde_json::Value, - _params: &BuildParams, + params: &BuildParams, ) -> Result<(Vec, Vec), WasmSolanaError> { let intent: ConsolidateIntent = serde_json::from_value(intent_json.clone()) .map_err(|e| WasmSolanaError::new(&format!("Failed to parse consolidate intent: {}", e)))?; @@ -1050,7 +1050,28 @@ fn build_consolidate( .parse() .map_err(|_| WasmSolanaError::new("Invalid receiveAddress (sender)"))?; + let fee_payer: Pubkey = params + .fee_payer + .parse() + .map_err(|_| WasmSolanaError::new("Invalid feePayer"))?; + let default_token_program: Pubkey = SPL_TOKEN_PROGRAM_ID.parse().unwrap(); + let system_program: Pubkey = SYSTEM_PROGRAM_ID.parse().unwrap(); + + let needs_create_ata = intent.create_associated_token_account.unwrap_or(false); + let ata_owner: Option = if needs_create_ata { + let addr = intent.ata_owner_address.as_ref().ok_or_else(|| { + WasmSolanaError::new( + "ataOwnerAddress is required when createAssociatedTokenAccount is true", + ) + })?; + Some( + addr.parse() + .map_err(|_| WasmSolanaError::new("Invalid ataOwnerAddress"))?, + ) + } else { + None + }; let mut instructions = Vec::new(); @@ -1098,6 +1119,26 @@ fn build_consolidate( // Destination ATA: passed in as-is (already exists on wallet root, caller provides it) let dest_ata = to_pubkey; + // Emit idempotent Create-ATA before the transfer when requested + if needs_create_ata { + let owner = ata_owner.unwrap(); // safe: validated above + let dest_ata_derived = derive_ata(&owner, &mint, &token_program); + if dest_ata_derived != dest_ata { + return Err(WasmSolanaError::new(&format!( + "Recipient ATA {} does not match derived ATA {} for owner {}", + dest_ata, dest_ata_derived, owner + ))); + } + instructions.push(create_ata_idempotent_ix( + &fee_payer, + &dest_ata_derived, + &owner, + &mint, + &system_program, + &token_program, + )); + } + use spl_token::instruction::TokenInstruction; let data = TokenInstruction::TransferChecked { amount: amount_wrapper.value, diff --git a/packages/wasm-solana/src/intent/types.rs b/packages/wasm-solana/src/intent/types.rs index ced31feb7f4..1ce571687fb 100644 --- a/packages/wasm-solana/src/intent/types.rs +++ b/packages/wasm-solana/src/intent/types.rs @@ -358,6 +358,12 @@ pub struct ConsolidateIntent { pub recipients: Vec, #[serde(default)] pub memo: Option, + /// When true, emit idempotent CreateAssociatedTokenAccount before each token transfer + #[serde(default)] + pub create_associated_token_account: Option, + /// Owner of the destination ATA (wallet root address, NOT the sender/child address) + #[serde(default)] + pub ata_owner_address: Option, } /// Authorize intent - pre-built transaction message diff --git a/packages/wasm-solana/test/intentBuilder.ts b/packages/wasm-solana/test/intentBuilder.ts index 0b0b3cfd844..c0e298cc6f2 100644 --- a/packages/wasm-solana/test/intentBuilder.ts +++ b/packages/wasm-solana/test/intentBuilder.ts @@ -8,12 +8,18 @@ /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument */ import assert from "assert"; -import { buildFromIntent, Transaction, parseTransaction } from "../dist/cjs/js/index.js"; +import { + buildFromIntent, + Transaction, + parseTransaction, + getAssociatedTokenAddress, +} from "../dist/cjs/js/index.js"; describe("buildFromIntent", function () { // Common test params const feePayer = "DgT9qyYwYKBRDyDw3EfR12LHQCQjtNrKu2qMsXHuosmB"; const blockhash = "GWaQEymC3Z9SHM2gkh8u12xL1zJPMHPCSVR3pSDpEXE4"; + const splTokenProgramId = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"; describe("payment intent", function () { it("should build a simple payment transaction", function () { @@ -385,6 +391,272 @@ describe("buildFromIntent", function () { const transfers = parsed.instructionsData.filter((i: any) => i.type === "Transfer"); assert.equal(transfers.length, 2, "Should have 2 transfer instructions"); }); + + it("should emit CreateAssociatedTokenAccount before TokenTransfer when createAssociatedTokenAccount is true", function () { + const childAddress = "5ZWgXcyqrrNpQHCme5SdC5hCeYb2o3fEJhF7Gok3bTVN"; + const usdcMint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"; + // Derive the correct ATA so it matches what build_consolidate derives + const destAta = getAssociatedTokenAddress(feePayer, usdcMint, splTokenProgramId); + const intent = { + intentType: "consolidate", + receiveAddress: childAddress, + createAssociatedTokenAccount: true, + ataOwnerAddress: feePayer, + recipients: [ + { + address: { address: destAta }, + amount: { value: 1000000n }, + tokenAddress: usdcMint, + decimalPlaces: 6, + }, + ], + }; + + const result = buildFromIntent(intent, { + feePayer, + nonce: { type: "blockhash", value: blockhash }, + }); + + 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"); + + // CreateAssociatedTokenAccount should come before TokenTransfer + const createAtaIndex = parsed.instructionsData.indexOf(createAta); + const tokenTransferIndex = parsed.instructionsData.indexOf(tokenTransfer); + assert( + createAtaIndex < tokenTransferIndex, + "CreateAssociatedTokenAccount should precede TokenTransfer", + ); + }); + + it("should NOT emit CreateAssociatedTokenAccount when flag is omitted (backwards compat)", function () { + const childAddress = "5ZWgXcyqrrNpQHCme5SdC5hCeYb2o3fEJhF7Gok3bTVN"; + const usdcMint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"; + const intent = { + intentType: "consolidate", + receiveAddress: childAddress, + recipients: [ + { + address: { address: "FKjSjCqByQRwSzZoMXA7bKnDbJe41YgJTHFFzBeC42bH" }, + amount: { value: 1000000n }, + tokenAddress: usdcMint, + decimalPlaces: 6, + }, + ], + }; + + const result = buildFromIntent(intent, { + feePayer, + nonce: { type: "blockhash", value: blockhash }, + }); + + const parsed = parseTransaction(result.transaction); + + const createAta = parsed.instructionsData.find( + (i: any) => i.type === "CreateAssociatedTokenAccount", + ); + assert(!createAta, "Should NOT have CreateAssociatedTokenAccount instruction"); + + const tokenTransfer = parsed.instructionsData.find((i: any) => i.type === "TokenTransfer"); + assert(tokenTransfer, "Should have TokenTransfer instruction"); + }); + + it("should throw when createAssociatedTokenAccount is true but ataOwnerAddress is missing", function () { + const childAddress = "5ZWgXcyqrrNpQHCme5SdC5hCeYb2o3fEJhF7Gok3bTVN"; + const usdcMint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"; + const intent = { + intentType: "consolidate", + receiveAddress: childAddress, + createAssociatedTokenAccount: true, + recipients: [ + { + address: { address: "FKjSjCqByQRwSzZoMXA7bKnDbJe41YgJTHFFzBeC42bH" }, + amount: { value: 1000000n }, + tokenAddress: usdcMint, + decimalPlaces: 6, + }, + ], + }; + + assert.throws( + () => + buildFromIntent(intent, { + feePayer, + nonce: { type: "blockhash", value: blockhash }, + }), + /ataOwnerAddress is required/, + ); + }); + + it("should throw when recipient ATA does not match derived ATA", function () { + const childAddress = "5ZWgXcyqrrNpQHCme5SdC5hCeYb2o3fEJhF7Gok3bTVN"; + const usdcMint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"; + const intent = { + intentType: "consolidate", + receiveAddress: childAddress, + createAssociatedTokenAccount: true, + ataOwnerAddress: feePayer, + recipients: [ + { + // Wrong address — does not match derived ATA for feePayer + usdcMint + address: { address: "FKjSjCqByQRwSzZoMXA7bKnDbJe41YgJTHFFzBeC42bH" }, + amount: { value: 1000000n }, + tokenAddress: usdcMint, + decimalPlaces: 6, + }, + ], + }; + + assert.throws( + () => + buildFromIntent(intent, { + feePayer, + nonce: { type: "blockhash", value: blockhash }, + }), + /does not match derived ATA/, + ); + }); + + it("should NOT emit CreateAssociatedTokenAccount when flag is explicitly false", function () { + const childAddress = "5ZWgXcyqrrNpQHCme5SdC5hCeYb2o3fEJhF7Gok3bTVN"; + const usdcMint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"; + const intent = { + intentType: "consolidate", + receiveAddress: childAddress, + createAssociatedTokenAccount: false, + recipients: [ + { + address: { address: "FKjSjCqByQRwSzZoMXA7bKnDbJe41YgJTHFFzBeC42bH" }, + amount: { value: 1000000n }, + tokenAddress: usdcMint, + decimalPlaces: 6, + }, + ], + }; + + const result = buildFromIntent(intent, { + feePayer, + nonce: { type: "blockhash", value: blockhash }, + }); + + const parsed = parseTransaction(result.transaction); + + const createAta = parsed.instructionsData.find( + (i: any) => i.type === "CreateAssociatedTokenAccount", + ); + assert(!createAta, "Should NOT have CreateAssociatedTokenAccount when flag is false"); + }); + + it("should emit CreateAssociatedTokenAccount for each token recipient in multi-token consolidate", function () { + const childAddress = "5ZWgXcyqrrNpQHCme5SdC5hCeYb2o3fEJhF7Gok3bTVN"; + const usdcMint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"; + const usdtMint = "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB"; + const usdcAta = getAssociatedTokenAddress(feePayer, usdcMint, splTokenProgramId); + const usdtAta = getAssociatedTokenAddress(feePayer, usdtMint, splTokenProgramId); + const intent = { + intentType: "consolidate", + receiveAddress: childAddress, + createAssociatedTokenAccount: true, + ataOwnerAddress: feePayer, + recipients: [ + { + address: { address: usdcAta }, + amount: { value: 1000000n }, + tokenAddress: usdcMint, + decimalPlaces: 6, + }, + { + address: { address: usdtAta }, + amount: { value: 2000000n }, + tokenAddress: usdtMint, + decimalPlaces: 6, + }, + ], + }; + + const result = buildFromIntent(intent, { + feePayer, + nonce: { type: "blockhash", value: blockhash }, + }); + + const parsed = parseTransaction(result.transaction); + + const createAtaInstructions = parsed.instructionsData.filter( + (i: any) => i.type === "CreateAssociatedTokenAccount", + ); + assert.equal( + createAtaInstructions.length, + 2, + "Should have 2 CreateAssociatedTokenAccount instructions", + ); + + const tokenTransfers = parsed.instructionsData.filter((i: any) => i.type === "TokenTransfer"); + assert.equal(tokenTransfers.length, 2, "Should have 2 TokenTransfer instructions"); + + // Each Create-ATA should precede its corresponding TokenTransfer + for (let i = 0; i < createAtaInstructions.length; i++) { + const createIdx = parsed.instructionsData.indexOf(createAtaInstructions[i]); + const transferIdx = parsed.instructionsData.indexOf(tokenTransfers[i]); + assert(createIdx < transferIdx, `CreateATA #${i} should precede TokenTransfer #${i}`); + } + }); + + it("should emit CreateAssociatedTokenAccount only for token recipients, not native SOL", function () { + const childAddress = "5ZWgXcyqrrNpQHCme5SdC5hCeYb2o3fEJhF7Gok3bTVN"; + const usdcMint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"; + const destAta = getAssociatedTokenAddress(feePayer, usdcMint, splTokenProgramId); + const intent = { + intentType: "consolidate", + receiveAddress: childAddress, + createAssociatedTokenAccount: true, + ataOwnerAddress: feePayer, + recipients: [ + { + // Native SOL recipient + address: { address: feePayer }, + amount: { value: 50000000n }, + }, + { + // Token recipient + address: { address: destAta }, + amount: { value: 1000000n }, + tokenAddress: usdcMint, + decimalPlaces: 6, + }, + ], + }; + + const result = buildFromIntent(intent, { + feePayer, + nonce: { type: "blockhash", value: blockhash }, + }); + + const parsed = parseTransaction(result.transaction); + + // Only one CreateAssociatedTokenAccount (for the token recipient) + const createAtaInstructions = parsed.instructionsData.filter( + (i: any) => i.type === "CreateAssociatedTokenAccount", + ); + assert.equal( + createAtaInstructions.length, + 1, + "Should have exactly 1 CreateAssociatedTokenAccount", + ); + + // Should have both Transfer (native) and TokenTransfer (SPL) + const nativeTransfer = parsed.instructionsData.find((i: any) => i.type === "Transfer"); + assert(nativeTransfer, "Should have native SOL Transfer instruction"); + + const tokenTransfer = parsed.instructionsData.find((i: any) => i.type === "TokenTransfer"); + assert(tokenTransfer, "Should have TokenTransfer instruction"); + }); }); describe("durable nonce", function () {