Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/wasm-solana/js/intentBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
43 changes: 42 additions & 1 deletion packages/wasm-solana/src/intent/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1039,7 +1039,7 @@ fn build_close_ata(

fn build_consolidate(
intent_json: &serde_json::Value,
_params: &BuildParams,
params: &BuildParams,
) -> Result<(Vec<Instruction>, Vec<GeneratedKeypair>), WasmSolanaError> {
let intent: ConsolidateIntent = serde_json::from_value(intent_json.clone())
.map_err(|e| WasmSolanaError::new(&format!("Failed to parse consolidate intent: {}", e)))?;
Expand All @@ -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<Pubkey> = 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();

Expand Down Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions packages/wasm-solana/src/intent/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,12 @@ pub struct ConsolidateIntent {
pub recipients: Vec<Recipient>,
#[serde(default)]
pub memo: Option<String>,
/// When true, emit idempotent CreateAssociatedTokenAccount before each token transfer
#[serde(default)]
pub create_associated_token_account: Option<bool>,
/// Owner of the destination ATA (wallet root address, NOT the sender/child address)
#[serde(default)]
pub ata_owner_address: Option<String>,
}

/// Authorize intent - pre-built transaction message
Expand Down
274 changes: 273 additions & 1 deletion packages/wasm-solana/test/intentBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () {
Expand Down Expand Up @@ -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 () {
Expand Down
Loading