From e64e4395bfbdd830b3b5431e124246bdd8880783 Mon Sep 17 00:00:00 2001 From: Mohammed Ryaan Date: Fri, 14 Aug 2026 15:28:06 +0530 Subject: [PATCH] feat(wasm-solana): add Create-ATA support for consolidateToken Add createAssociatedTokenAccount and ataOwnerAddress fields to the ConsolidateIntent so the WASM builder can emit idempotent CreateAssociatedTokenAccount instructions before token transfers. This removes the need for the wallet-platform needsCreateAta guard that forces consolidateToken to the legacy account-lib path. When createAssociatedTokenAccount is true, the builder derives the destination ATA from ataOwnerAddress + mint + tokenProgram, validates it matches the caller-provided recipient address, and prepends a CreateIdempotent ATA instruction before each TransferChecked. Native SOL recipients are unaffected. All fields are optional with serde defaults for full backwards compatibility. Ticket: CHALO-1260 --- packages/wasm-solana/js/intentBuilder.ts | 4 + packages/wasm-solana/src/intent/build.rs | 43 +++- packages/wasm-solana/src/intent/types.rs | 6 + packages/wasm-solana/test/intentBuilder.ts | 274 ++++++++++++++++++++- 4 files changed, 325 insertions(+), 2 deletions(-) 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 () {