From c7ee92a278a2372460ac0798a41ec44e514aa607 Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Thu, 19 Mar 2026 01:57:42 +0000 Subject: [PATCH 1/7] fixes --- js/compressed-token/CHANGELOG.md | 9 +- js/compressed-token/src/index.ts | 2 + .../src/v3/actions/transfer-interface.ts | 181 +++++++++++++++--- .../src/v3/instructions/transfer-interface.ts | 2 +- js/compressed-token/src/v3/unified/index.ts | 83 +++++++- .../tests/e2e/input-selection.test.ts | 2 +- .../e2e/multi-cold-inputs-batching.test.ts | 4 +- .../tests/e2e/multi-cold-inputs.test.ts | 2 +- .../tests/e2e/payment-flows.test.ts | 4 +- .../tests/e2e/transfer-interface.test.ts | 99 +++++++++- .../tests/e2e/v3-interface-migration.test.ts | 2 +- 11 files changed, 338 insertions(+), 52 deletions(-) diff --git a/js/compressed-token/CHANGELOG.md b/js/compressed-token/CHANGELOG.md index 16e9723335..262b980cfa 100644 --- a/js/compressed-token/CHANGELOG.md +++ b/js/compressed-token/CHANGELOG.md @@ -2,10 +2,11 @@ ### Breaking Changes -- **`transferInterface` and `createTransferInterfaceInstructions`**: `destination` is now the token account address (SPL-style), not the recipient wallet. `ensureRecipientAta` removed; caller must create the destination ATA before transfer via `getOrCreateAtaInterface` or `createAssociatedTokenAccountInterfaceIdempotentInstruction`. - - **Action:** `transferInterface(rpc, payer, source, mint, destination, owner, amount, ...)` — `destination` is the token account (e.g. `getAssociatedTokenAddressInterface(mint, recipient.publicKey)`). - - **Instruction builder:** `createTransferInterfaceInstructions(rpc, payer, mint, amount, sender, destination, decimals, options?)` — same `destination` semantics. - - **`decimals` is now required** on `createTransferInterfaceInstructions` (instruction-level API). Fetch with `getMintInterface(rpc, mint).mint.decimals` if you were not already threading mint decimals. +- **`transferInterface` and `createTransferInterfaceInstructions`** now take a recipient wallet address and ensure recipient ATA internally. + - **Action:** `transferInterface(rpc, payer, source, mint, recipient, owner, amount, ...)` — `recipient` is the wallet public key. + - **Instruction builder:** `createTransferInterfaceInstructions(rpc, payer, mint, amount, sender, recipient, decimals, options?)` — derives destination ATA and inserts idempotent ATA-create internally. + - **Advanced explicit-account path:** use `transferToAccountInterface(...)` and `createTransferToAccountInterfaceInstructions(...)` for destination token-account routing (program-owned/custom accounts), preserving the previous destination-account behavior. + - **`decimals` is required** on v3 action-level instruction builders. Fetch with `getMintInterface(rpc, mint).mint.decimals` if not already threaded. - **Root export removed:** `createLoadAtaInstructionsFromInterface` is no longer exported from the package root. Use `createLoadAtaInstructions` (public API) and pass ATA/owner/mint directly. diff --git a/js/compressed-token/src/index.ts b/js/compressed-token/src/index.ts index 02770b2548..c8c8cd34ec 100644 --- a/js/compressed-token/src/index.ts +++ b/js/compressed-token/src/index.ts @@ -76,7 +76,9 @@ export { getAssociatedTokenAddressInterface, getOrCreateAtaInterface, transferInterface, + transferToAccountInterface, createTransferInterfaceInstructions, + createTransferToAccountInterfaceInstructions, sliceLast, wrap, mintTo as mintToLightToken, diff --git a/js/compressed-token/src/v3/actions/transfer-interface.ts b/js/compressed-token/src/v3/actions/transfer-interface.ts index 37fa49e8e1..d97d8cdd24 100644 --- a/js/compressed-token/src/v3/actions/transfer-interface.ts +++ b/js/compressed-token/src/v3/actions/transfer-interface.ts @@ -1,7 +1,9 @@ import { ConfirmOptions, + ComputeBudgetProgram, PublicKey, Signer, + TransactionInstruction, TransactionSignature, } from '@solana/web3.js'; import { @@ -13,11 +15,14 @@ import { LIGHT_TOKEN_PROGRAM_ID, } from '@lightprotocol/stateless.js'; import BN from 'bn.js'; -import { createTransferInterfaceInstructions } from '../instructions/transfer-interface'; +import { + createTransferToAccountInterfaceInstructions, +} from '../instructions/transfer-interface'; import { getAssociatedTokenAddressInterface } from '../get-associated-token-address-interface'; import { getMintInterface } from '../get-mint-interface'; import { type SplInterfaceInfo } from '../../utils/get-token-pool-infos'; import { sliceLast } from './slice-last'; +import { createAssociatedTokenAccountInterfaceIdempotentInstruction } from '../instructions/create-ata-interface'; export interface InterfaceOptions { splInterfaceInfos?: SplInterfaceInfo[]; @@ -28,7 +33,72 @@ export interface InterfaceOptions { owner?: PublicKey; } -export async function transferInterface( +function assertSourceMatchesExpectedAta( + source: PublicKey, + mint: PublicKey, + effectiveOwner: PublicKey, + programId: PublicKey, +) { + const expectedSource = getAssociatedTokenAddressInterface( + mint, + effectiveOwner, + false, + programId, + ); + if (!source.equals(expectedSource)) { + throw new Error( + `Source mismatch. Expected ${expectedSource.toBase58()}, got ${source.toBase58()}`, + ); + } +} + +async function executeTransferBatches( + rpc: Rpc, + payer: Signer, + owner: Signer, + batches: TransactionInstruction[][], + confirmOptions?: ConfirmOptions, +): Promise { + const additionalSigners = dedupeSigner(payer, [owner]); + const { rest: loads, last: transferIxs } = sliceLast(batches); + + await Promise.all( + loads.map(async ixs => { + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx(ixs, payer, blockhash, additionalSigners); + return sendAndConfirmTx(rpc, tx, confirmOptions); + }), + ); + + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx(transferIxs, payer, blockhash, additionalSigners); + return sendAndConfirmTx(rpc, tx, confirmOptions); +} + +function withFinalBatchPrepInstruction( + batches: TransactionInstruction[][], + prepIx: TransactionInstruction, +): TransactionInstruction[][] { + if (batches.length === 0) return [[prepIx]]; + + const finalBatch = batches[batches.length - 1]; + let insertionIdx = 0; + while ( + insertionIdx < finalBatch.length && + finalBatch[insertionIdx].programId.equals(ComputeBudgetProgram.programId) + ) { + insertionIdx += 1; + } + + const patchedFinalBatch = [ + ...finalBatch.slice(0, insertionIdx), + prepIx, + ...finalBatch.slice(insertionIdx), + ]; + return [...batches.slice(0, -1), patchedFinalBatch]; +} + +export async function transferToAccountInterface( rpc: Rpc, payer: Signer, source: PublicKey, @@ -45,23 +115,13 @@ export async function transferInterface( assertBetaEnabled(); const effectiveOwner = options?.owner ?? owner.publicKey; - const expectedSource = getAssociatedTokenAddressInterface( - mint, - effectiveOwner, - false, - programId, - ); - if (!source.equals(expectedSource)) { - throw new Error( - `Source mismatch. Expected ${expectedSource.toBase58()}, got ${source.toBase58()}`, - ); - } + assertSourceMatchesExpectedAta(source, mint, effectiveOwner, programId); const amountBigInt = BigInt(amount.toString()); const resolvedDecimals = decimals ?? (await getMintInterface(rpc, mint)).mint.decimals; - const batches = await createTransferInterfaceInstructions( + const batches = await createTransferToAccountInterfaceInstructions( rpc, payer.publicKey, mint, @@ -76,30 +136,97 @@ export async function transferInterface( }, ); - const additionalSigners = dedupeSigner(payer, [owner]); - const { rest: loads, last: transferIxs } = sliceLast(batches); + return executeTransferBatches(rpc, payer, owner, batches, confirmOptions); +} - await Promise.all( - loads.map(async ixs => { - const { blockhash } = await rpc.getLatestBlockhash(); - const tx = buildAndSignTx(ixs, payer, blockhash, additionalSigners); - return sendAndConfirmTx(rpc, tx, confirmOptions); - }), +export async function transferInterface( + rpc: Rpc, + payer: Signer, + source: PublicKey, + mint: PublicKey, + recipient: PublicKey, + owner: Signer, + amount: number | bigint | BN, + programId: PublicKey = LIGHT_TOKEN_PROGRAM_ID, + confirmOptions?: ConfirmOptions, + options?: InterfaceOptions, + wrap = false, + decimals?: number, +): Promise { + assertBetaEnabled(); + + const effectiveOwner = options?.owner ?? owner.publicKey; + assertSourceMatchesExpectedAta(source, mint, effectiveOwner, programId); + + const resolvedDecimals = + decimals ?? (await getMintInterface(rpc, mint)).mint.decimals; + const batches = await createTransferInterfaceInstructions( + rpc, + payer.publicKey, + mint, + amount, + owner.publicKey, + recipient, + resolvedDecimals, + { + ...options, + wrap, + programId, + }, ); - const { blockhash } = await rpc.getLatestBlockhash(); - const tx = buildAndSignTx(transferIxs, payer, blockhash, additionalSigners); - return sendAndConfirmTx(rpc, tx, confirmOptions); + return executeTransferBatches(rpc, payer, owner, batches, confirmOptions); } -export interface TransferOptions extends InterfaceOptions { +export interface TransferToAccountOptions extends InterfaceOptions { wrap?: boolean; programId?: PublicKey; } +export type TransferOptions = TransferToAccountOptions; export { sliceLast } from './slice-last'; +export async function createTransferInterfaceInstructions( + rpc: Rpc, + payer: PublicKey, + mint: PublicKey, + amount: number | bigint | BN, + sender: PublicKey, + recipient: PublicKey, + decimals: number, + options?: TransferToAccountOptions, +): Promise { + const programId = options?.programId ?? LIGHT_TOKEN_PROGRAM_ID; + const destination = getAssociatedTokenAddressInterface( + mint, + recipient, + false, + programId, + ); + const batches = await createTransferToAccountInterfaceInstructions( + rpc, + payer, + mint, + amount, + sender, + destination, + decimals, + options, + ); + + const ensureRecipientAtaIx = + createAssociatedTokenAccountInterfaceIdempotentInstruction( + payer, + destination, + recipient, + mint, + programId, + ); + + return withFinalBatchPrepInstruction(batches, ensureRecipientAtaIx); +} + export { - createTransferInterfaceInstructions, + createTransferToAccountInterfaceInstructions, calculateTransferCU, } from '../instructions/transfer-interface'; diff --git a/js/compressed-token/src/v3/instructions/transfer-interface.ts b/js/compressed-token/src/v3/instructions/transfer-interface.ts index cf48c9a100..f2e2161858 100644 --- a/js/compressed-token/src/v3/instructions/transfer-interface.ts +++ b/js/compressed-token/src/v3/instructions/transfer-interface.ts @@ -141,7 +141,7 @@ export function createLightTokenTransferCheckedInstruction( }); } -export async function createTransferInterfaceInstructions( +export async function createTransferToAccountInterfaceInstructions( rpc: Rpc, payer: PublicKey, mint: PublicKey, diff --git a/js/compressed-token/src/v3/unified/index.ts b/js/compressed-token/src/v3/unified/index.ts index ed66672165..4a8527a2f9 100644 --- a/js/compressed-token/src/v3/unified/index.ts +++ b/js/compressed-token/src/v3/unified/index.ts @@ -32,9 +32,14 @@ import { import { createAssociatedTokenAccountInterfaceIdempotentInstruction } from '../instructions/create-ata-interface'; import { transferInterface as _transferInterface, + transferToAccountInterface as _transferToAccountInterface, createTransferInterfaceInstructions as _createTransferInterfaceInstructions, + createTransferToAccountInterfaceInstructions as _createTransferToAccountInterfaceInstructions, +} from '../actions/transfer-interface'; +import type { + TransferOptions as _TransferOptions, + TransferToAccountOptions as _TransferToAccountOptions, } from '../actions/transfer-interface'; -import type { TransferOptions as _TransferOptions } from '../actions/transfer-interface'; import { _getOrCreateAtaInterface } from '../actions/get-or-create-ata-interface'; import { createUnwrapInstructions as _createUnwrapInstructions, @@ -206,13 +211,14 @@ export async function loadAta( /** * Transfer tokens using the unified ata interface. * - * Destination associated token account must exist. Automatically wraps SPL/T22 to light-token associated token account. + * Destination ATA is derived from `recipient` and created idempotently. + * Automatically wraps SPL/T22 to light-token associated token account. * * @param rpc RPC connection * @param payer Fee payer (signer) * @param source Source light-token associated token account address * @param mint Mint address - * @param destination Destination token account address (must exist; derive via getAssociatedTokenAddressInterface) + * @param recipient Destination owner wallet address * @param owner Source owner (signer) * @param amount Amount to transfer * @param confirmOptions Optional confirm options @@ -224,7 +230,7 @@ export async function transferInterface( payer: Signer, source: PublicKey, mint: PublicKey, - destination: PublicKey, + recipient: PublicKey, owner: Signer, amount: number | bigint | BN, confirmOptions?: ConfirmOptions, @@ -232,6 +238,39 @@ export async function transferInterface( decimals?: number, ) { return _transferInterface( + rpc, + payer, + source, + mint, + recipient, + owner, + amount, + undefined, // programId: use default LIGHT_TOKEN_PROGRAM_ID + confirmOptions, + options, + true, // wrap=true for unified + decimals, + ); +} + +/** + * Transfer tokens to an explicit destination token account. + * + * Use this for advanced routing to non-ATA destinations. + */ +export async function transferToAccountInterface( + rpc: Rpc, + payer: Signer, + source: PublicKey, + mint: PublicKey, + destination: PublicKey, + owner: Signer, + amount: number | bigint | BN, + confirmOptions?: ConfirmOptions, + options?: InterfaceOptions, + decimals?: number, +) { + return _transferToAccountInterface( rpc, payer, source, @@ -314,11 +353,40 @@ export async function createTransferInterfaceInstructions( mint: PublicKey, amount: number | bigint | BN, sender: PublicKey, - destination: PublicKey, + recipient: PublicKey, options?: Omit<_TransferOptions, 'wrap'>, ): Promise { const mintInterface = await getMintInterface(rpc, mint); return _createTransferInterfaceInstructions( + rpc, + payer, + mint, + amount, + sender, + recipient, + mintInterface.mint.decimals, + { + ...options, + wrap: true, + }, + ); +} + +/** + * Create transfer instructions that route to an explicit destination token + * account. + */ +export async function createTransferToAccountInterfaceInstructions( + rpc: Rpc, + payer: PublicKey, + mint: PublicKey, + amount: number | bigint | BN, + sender: PublicKey, + destination: PublicKey, + options?: Omit<_TransferToAccountOptions, 'wrap'>, +): Promise { + const mintInterface = await getMintInterface(rpc, mint); + return _createTransferToAccountInterfaceInstructions( rpc, payer, mint, @@ -421,7 +489,10 @@ export async function unwrap( ); } -export type { _TransferOptions as TransferOptions }; +export type { + _TransferOptions as TransferOptions, + _TransferToAccountOptions as TransferToAccountOptions, +}; export { getAccountInterface, diff --git a/js/compressed-token/tests/e2e/input-selection.test.ts b/js/compressed-token/tests/e2e/input-selection.test.ts index eb0cf14a67..f6f67be5e1 100644 --- a/js/compressed-token/tests/e2e/input-selection.test.ts +++ b/js/compressed-token/tests/e2e/input-selection.test.ts @@ -45,7 +45,7 @@ import { import { getAssociatedTokenAddressInterface } from '../../src/v3/get-associated-token-address-interface'; import { getOrCreateAtaInterface } from '../../src/v3/actions/get-or-create-ata-interface'; import { - createTransferInterfaceInstructions, + createTransferToAccountInterfaceInstructions as createTransferInterfaceInstructions, sliceLast, } from '../../src/v3/actions/transfer-interface'; import { loadAta } from '../../src/v3/actions/load-ata'; diff --git a/js/compressed-token/tests/e2e/multi-cold-inputs-batching.test.ts b/js/compressed-token/tests/e2e/multi-cold-inputs-batching.test.ts index a060166173..cd5b3fc360 100644 --- a/js/compressed-token/tests/e2e/multi-cold-inputs-batching.test.ts +++ b/js/compressed-token/tests/e2e/multi-cold-inputs-batching.test.ts @@ -43,8 +43,8 @@ import { } from '../../src/v3/actions/load-ata'; import { getOrCreateAtaInterface } from '../../src/v3/actions/get-or-create-ata-interface'; import { - transferInterface, - createTransferInterfaceInstructions, + transferToAccountInterface as transferInterface, + createTransferToAccountInterfaceInstructions as createTransferInterfaceInstructions, sliceLast, } from '../../src/v3/actions/transfer-interface'; diff --git a/js/compressed-token/tests/e2e/multi-cold-inputs.test.ts b/js/compressed-token/tests/e2e/multi-cold-inputs.test.ts index d51829f372..c95b621e1b 100644 --- a/js/compressed-token/tests/e2e/multi-cold-inputs.test.ts +++ b/js/compressed-token/tests/e2e/multi-cold-inputs.test.ts @@ -49,7 +49,7 @@ import { } from '../../src/utils/get-token-pool-infos'; import { getAssociatedTokenAddressInterface } from '../../src/v3/get-associated-token-address-interface'; import { getAtaInterface } from '../../src/v3/get-account-interface'; -import { transferInterface } from '../../src/v3/actions/transfer-interface'; +import { transferToAccountInterface as transferInterface } from '../../src/v3/actions/transfer-interface'; import { loadAta, createLoadAtaInstructions, diff --git a/js/compressed-token/tests/e2e/payment-flows.test.ts b/js/compressed-token/tests/e2e/payment-flows.test.ts index 319a2ace4c..61ff46606b 100644 --- a/js/compressed-token/tests/e2e/payment-flows.test.ts +++ b/js/compressed-token/tests/e2e/payment-flows.test.ts @@ -33,8 +33,8 @@ import { import { getAssociatedTokenAddressInterface } from '../../src/v3/get-associated-token-address-interface'; import { getOrCreateAtaInterface } from '../../src/v3/actions/get-or-create-ata-interface'; import { - transferInterface, - createTransferInterfaceInstructions, + transferToAccountInterface as transferInterface, + createTransferToAccountInterfaceInstructions as createTransferInterfaceInstructions, sliceLast, } from '../../src/v3/actions/transfer-interface'; import { diff --git a/js/compressed-token/tests/e2e/transfer-interface.test.ts b/js/compressed-token/tests/e2e/transfer-interface.test.ts index 8fe4cc3a61..e7ed969edf 100644 --- a/js/compressed-token/tests/e2e/transfer-interface.test.ts +++ b/js/compressed-token/tests/e2e/transfer-interface.test.ts @@ -37,8 +37,10 @@ import { import { getAssociatedTokenAddressInterface } from '../../src/v3/get-associated-token-address-interface'; import { getOrCreateAtaInterface } from '../../src/v3/actions/get-or-create-ata-interface'; import { - transferInterface, - createTransferInterfaceInstructions, + transferToAccountInterface as transferInterface, + createTransferToAccountInterfaceInstructions as createTransferInterfaceInstructions, + transferInterface as transferToRecipientInterface, + createTransferInterfaceInstructions as createTransferToRecipientInstructions, } from '../../src/v3/actions/transfer-interface'; import { loadAta, @@ -175,7 +177,7 @@ describe('transfer-interface', () => { }); }); - describe('transferInterface frozen sender', () => { + describe('transferToAccountInterface frozen sender', () => { let splMintWithFreeze: PublicKey; let freezeAuthority: Keypair; @@ -343,7 +345,7 @@ describe('transfer-interface', () => { }); }); - describe('transferInterface as delegate', () => { + describe('transferToAccountInterface as delegate', () => { it('should transfer from hot ATA when delegate is approved on ATA', async () => { const owner = await newAccountWithLamports(rpc, 2e9); const delegate = await newAccountWithLamports(rpc, 2e9); @@ -724,7 +726,7 @@ describe('transfer-interface', () => { }); }); - describe('transferInterface action', () => { + describe('transferToAccountInterface action', () => { it('should transfer from hot balance (destination exists)', async () => { const sender = await newAccountWithLamports(rpc, 1e9); const recipient = Keypair.generate(); @@ -1159,12 +1161,95 @@ describe('transfer-interface', () => { }); }); + describe('new transferInterface recipient-owner behavior', () => { + it('createTransferInterfaceInstructions derives recipient ATA and includes ATA create', async () => { + const sender = await newAccountWithLamports(rpc, 1e9); + const recipient = Keypair.generate(); + + await mintTo( + rpc, + payer, + mint, + sender.publicKey, + mintAuthority, + bn(1000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + const senderAta = getAssociatedTokenAddressInterface( + mint, + sender.publicKey, + ); + await loadAta(rpc, senderAta, sender, mint); + + const recipientAta = getAssociatedTokenAddressInterface( + mint, + recipient.publicKey, + ); + + const batches = await createTransferToRecipientInstructions( + rpc, + payer.publicKey, + mint, + BigInt(100), + sender.publicKey, + recipient.publicKey, + TEST_TOKEN_DECIMALS, + ); + const transferBatch = batches[batches.length - 1]; + const hasRecipientAtaCreate = transferBatch.some(ix => + ix.keys.some(k => k.pubkey.equals(recipientAta)), + ); + expect(hasRecipientAtaCreate).toBe(true); + }); + + it('transferInterface succeeds without pre-creating recipient ATA', async () => { + const sender = await newAccountWithLamports(rpc, 1e9); + const recipient = Keypair.generate(); + + await mintTo( + rpc, + payer, + mint, + sender.publicKey, + mintAuthority, + bn(1000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + const senderAta = getAssociatedTokenAddressInterface( + mint, + sender.publicKey, + ); + await loadAta(rpc, senderAta, sender, mint); + + const signature = await transferToRecipientInterface( + rpc, + payer, + senderAta, + mint, + recipient.publicKey, + sender, + BigInt(250), + ); + expect(signature).toBeDefined(); + + const recipientAta = getAssociatedTokenAddressInterface( + mint, + recipient.publicKey, + ); + const recipientInfo = await rpc.getAccountInfo(recipientAta); + expect(recipientInfo).not.toBeNull(); + expect(recipientInfo!.data.readBigUInt64LE(64)).toBe(BigInt(250)); + }); + }); + // ================================================================ // H7: wrap=true + programId=TOKEN_PROGRAM_ID // isSplOrT22 && !wrap is false → would route to light-token transfer path, // but _buildLoadBatches rejects a non-light-token targetAta when wrap=true. // ================================================================ - describe('H7: transferInterface wrap=true + programId=TOKEN_PROGRAM_ID', () => { + describe('H7: transferToAccountInterface wrap=true + programId=TOKEN_PROGRAM_ID', () => { it('should throw when wrap=true is combined with programId=TOKEN_PROGRAM_ID (targetAta is SPL)', async () => { const sender = await newAccountWithLamports(rpc, 2e9); const recipient = Keypair.generate(); @@ -1345,7 +1430,7 @@ describe('transfer-interface', () => { // ================================================================ // SPL/T22 NO-WRAP TRANSFER (programId=TOKEN_PROGRAM_ID, wrap=false) // ================================================================ - describe('transferInterface with SPL programId (no-wrap)', () => { + describe('transferToAccountInterface with SPL programId (no-wrap)', () => { it('should transfer cold-only via SPL (decompress + SPL transferChecked)', async () => { const sender = await newAccountWithLamports(rpc, 2e9); const recipient = await newAccountWithLamports(rpc, 1e9); diff --git a/js/compressed-token/tests/e2e/v3-interface-migration.test.ts b/js/compressed-token/tests/e2e/v3-interface-migration.test.ts index b50beeb2b4..3c248530e5 100644 --- a/js/compressed-token/tests/e2e/v3-interface-migration.test.ts +++ b/js/compressed-token/tests/e2e/v3-interface-migration.test.ts @@ -27,7 +27,7 @@ import { import { getAtaInterface, getAssociatedTokenAddressInterface, - transferInterface, + transferToAccountInterface as transferInterface, } from '../../src/v3'; import { createLoadAtaInstructions, loadAta } from '../../src/index'; From 9ac768e659becf3259bf4225ddfba39de399e019 Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Thu, 19 Mar 2026 02:02:19 +0000 Subject: [PATCH 2/7] use slicelast --- .../src/v3/actions/transfer-interface.ts | 133 +++++++++--------- 1 file changed, 63 insertions(+), 70 deletions(-) diff --git a/js/compressed-token/src/v3/actions/transfer-interface.ts b/js/compressed-token/src/v3/actions/transfer-interface.ts index d97d8cdd24..426622775d 100644 --- a/js/compressed-token/src/v3/actions/transfer-interface.ts +++ b/js/compressed-token/src/v3/actions/transfer-interface.ts @@ -33,71 +33,6 @@ export interface InterfaceOptions { owner?: PublicKey; } -function assertSourceMatchesExpectedAta( - source: PublicKey, - mint: PublicKey, - effectiveOwner: PublicKey, - programId: PublicKey, -) { - const expectedSource = getAssociatedTokenAddressInterface( - mint, - effectiveOwner, - false, - programId, - ); - if (!source.equals(expectedSource)) { - throw new Error( - `Source mismatch. Expected ${expectedSource.toBase58()}, got ${source.toBase58()}`, - ); - } -} - -async function executeTransferBatches( - rpc: Rpc, - payer: Signer, - owner: Signer, - batches: TransactionInstruction[][], - confirmOptions?: ConfirmOptions, -): Promise { - const additionalSigners = dedupeSigner(payer, [owner]); - const { rest: loads, last: transferIxs } = sliceLast(batches); - - await Promise.all( - loads.map(async ixs => { - const { blockhash } = await rpc.getLatestBlockhash(); - const tx = buildAndSignTx(ixs, payer, blockhash, additionalSigners); - return sendAndConfirmTx(rpc, tx, confirmOptions); - }), - ); - - const { blockhash } = await rpc.getLatestBlockhash(); - const tx = buildAndSignTx(transferIxs, payer, blockhash, additionalSigners); - return sendAndConfirmTx(rpc, tx, confirmOptions); -} - -function withFinalBatchPrepInstruction( - batches: TransactionInstruction[][], - prepIx: TransactionInstruction, -): TransactionInstruction[][] { - if (batches.length === 0) return [[prepIx]]; - - const finalBatch = batches[batches.length - 1]; - let insertionIdx = 0; - while ( - insertionIdx < finalBatch.length && - finalBatch[insertionIdx].programId.equals(ComputeBudgetProgram.programId) - ) { - insertionIdx += 1; - } - - const patchedFinalBatch = [ - ...finalBatch.slice(0, insertionIdx), - prepIx, - ...finalBatch.slice(insertionIdx), - ]; - return [...batches.slice(0, -1), patchedFinalBatch]; -} - export async function transferToAccountInterface( rpc: Rpc, payer: Signer, @@ -115,7 +50,17 @@ export async function transferToAccountInterface( assertBetaEnabled(); const effectiveOwner = options?.owner ?? owner.publicKey; - assertSourceMatchesExpectedAta(source, mint, effectiveOwner, programId); + const expectedSource = getAssociatedTokenAddressInterface( + mint, + effectiveOwner, + false, + programId, + ); + if (!source.equals(expectedSource)) { + throw new Error( + `Source mismatch. Expected ${expectedSource.toBase58()}, got ${source.toBase58()}`, + ); + } const amountBigInt = BigInt(amount.toString()); @@ -136,7 +81,18 @@ export async function transferToAccountInterface( }, ); - return executeTransferBatches(rpc, payer, owner, batches, confirmOptions); + const additionalSigners = dedupeSigner(payer, [owner]); + const { rest: loads, last: transferIxs } = sliceLast(batches); + await Promise.all( + loads.map(async ixs => { + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx(ixs, payer, blockhash, additionalSigners); + return sendAndConfirmTx(rpc, tx, confirmOptions); + }), + ); + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx(transferIxs, payer, blockhash, additionalSigners); + return sendAndConfirmTx(rpc, tx, confirmOptions); } export async function transferInterface( @@ -156,7 +112,17 @@ export async function transferInterface( assertBetaEnabled(); const effectiveOwner = options?.owner ?? owner.publicKey; - assertSourceMatchesExpectedAta(source, mint, effectiveOwner, programId); + const expectedSource = getAssociatedTokenAddressInterface( + mint, + effectiveOwner, + false, + programId, + ); + if (!source.equals(expectedSource)) { + throw new Error( + `Source mismatch. Expected ${expectedSource.toBase58()}, got ${source.toBase58()}`, + ); + } const resolvedDecimals = decimals ?? (await getMintInterface(rpc, mint)).mint.decimals; @@ -175,7 +141,18 @@ export async function transferInterface( }, ); - return executeTransferBatches(rpc, payer, owner, batches, confirmOptions); + const additionalSigners = dedupeSigner(payer, [owner]); + const { rest: loads, last: transferIxs } = sliceLast(batches); + await Promise.all( + loads.map(async ixs => { + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx(ixs, payer, blockhash, additionalSigners); + return sendAndConfirmTx(rpc, tx, confirmOptions); + }), + ); + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx(transferIxs, payer, blockhash, additionalSigners); + return sendAndConfirmTx(rpc, tx, confirmOptions); } export interface TransferToAccountOptions extends InterfaceOptions { @@ -223,7 +200,23 @@ export async function createTransferInterfaceInstructions( programId, ); - return withFinalBatchPrepInstruction(batches, ensureRecipientAtaIx); + if (batches.length === 0) return [[ensureRecipientAtaIx]]; + + const finalBatch = batches[batches.length - 1]; + let insertionIdx = 0; + while ( + insertionIdx < finalBatch.length && + finalBatch[insertionIdx].programId.equals(ComputeBudgetProgram.programId) + ) { + insertionIdx += 1; + } + + const patchedFinalBatch = [ + ...finalBatch.slice(0, insertionIdx), + ensureRecipientAtaIx, + ...finalBatch.slice(insertionIdx), + ]; + return [...batches.slice(0, -1), patchedFinalBatch]; } export { From b7956975fa3b2ce5db1c2f5c6544ee91b86ad6b1 Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Thu, 19 Mar 2026 02:43:36 +0000 Subject: [PATCH 3/7] upd docs --- js/compressed-token/CHANGELOG.md | 10 +- js/compressed-token/docs/interface.md | 312 ++++++------------ .../docs/payment-integration.md | 51 +-- .../src/v3/actions/transfer-interface.ts | 3 + js/compressed-token/src/v3/unified/index.ts | 2 + .../tests/e2e/transfer-interface.test.ts | 119 +++++++ 6 files changed, 271 insertions(+), 226 deletions(-) diff --git a/js/compressed-token/CHANGELOG.md b/js/compressed-token/CHANGELOG.md index 262b980cfa..b744dbfd81 100644 --- a/js/compressed-token/CHANGELOG.md +++ b/js/compressed-token/CHANGELOG.md @@ -81,7 +81,12 @@ const batches = await createTransferInterfaceInstructions( const { rest: loads, last: transferTx } = sliceLast(batches); ``` -Options include `ensureRecipientAta` (default: `true`) which prepends an idempotent ATA creation instruction to the transfer transaction, and `programId` which dispatches to SPL `transferChecked` for `TOKEN_PROGRAM_ID`/`TOKEN_2022_PROGRAM_ID`. +Options at this point included `ensureRecipientAta` (default: `true`) and +`programId`. `ensureRecipientAta` was removed again in `0.23.0-beta.10` when +the split was introduced: +`transferInterface/createTransferInterfaceInstructions` (wallet-recipient) and +`transferToAccountInterface/createTransferToAccountInterfaceInstructions` +(explicit destination-account). #### `createLoadAtaInstructions` @@ -128,7 +133,8 @@ Options include `ensureRecipientAta` (default: `true`) which prepends an idempot - **`createTransferInterfaceInstructions`**: Instruction builder for transfers with multi-transaction batching, frozen account pre-checks, zero-amount rejection, and `programId`-based dispatch (Light token vs SPL `transferChecked`). - **`sliceLast`** helper: Splits instruction batches into `{ rest, last }` for parallel-then-sequential sending. -- **`TransferOptions`** interface: `wrap`, `programId`, `ensureRecipientAta`, extends `InterfaceOptions`. +- **`TransferOptions`** at this point included: + `wrap`, `programId`, `ensureRecipientAta`, extends `InterfaceOptions`. - **Version-aware proof chunking**: V1 inputs chunked with sizes {8,4,2,1}, V2 with {8,7,6,5,4,3,2,1}. V1 and V2 never mixed in a single proof request. - **`assertUniqueInputHashes`**: Runtime enforcement that no compressed account hash appears in more than one parallel batch. - **`chunkAccountsByTreeVersion`**: Exported utility for splitting compressed accounts by tree version into prover-compatible groups. diff --git a/js/compressed-token/docs/interface.md b/js/compressed-token/docs/interface.md index 28d7903021..e7d8b605bb 100644 --- a/js/compressed-token/docs/interface.md +++ b/js/compressed-token/docs/interface.md @@ -1,230 +1,132 @@ # c-Token Interface Reference -Concise reference for the v3 interface surface: reads (`getAtaInterface`), loads (`loadAta`, `createLoadAtaInstructions`), and transfers (`transferInterface`, `createTransferInterfaceInstructions`). +Concise v3 interface reference for reads (`getAtaInterface`), loads +(`loadAta`, `createLoadAtaInstructions`), and transfers. ## 1. API Surface -| Method | Path | Purpose | -| -------------------------------------------- | --------------- | ---------------------------------------------------------- | -| `getAtaInterface` | v3, unified | Aggregate balance from hot/cold/SPL/T22 sources | -| `getOrCreateAtaInterface` | v3 | Create ATA if missing, return interface | -| `createLoadAtaInstructions` | v3 | Instruction batches for loading cold/wrap into ATA | -| `loadAta` | v3 | Action: execute load, return signature | -| `createTransferInterfaceInstructions` | v3 | Instruction builder for transfers | -| `transferInterface` | v3 | Action: load + transfer (destination must exist) | -| `createLightTokenTransferInstruction` | v3/instructions | Raw c-token transfer ix (no load/wrap, no decimals) | -| `createLightTokenTransferCheckedInstruction` | v3/instructions | Light-token transfer with decimals (used by transfer flow) | - -Unified (`/unified`): `wrap=true` forced (not configurable; unified export omits wrap from options). Standard (`v3`): `wrap=false` default. - -## 2. State Model (owner, mint) - -| Source | Count | Program | -| ----------------------------- | ------ | ---------------------- | -| Light Token ATA (hot) | 0 or 1 | LIGHT_TOKEN_PROGRAM_ID | -| Light Token compressed (cold) | 0..N | LIGHT_TOKEN_PROGRAM_ID | -| SPL Token ATA (hot) | 0 or 1 | TOKEN_PROGRAM_ID | -| Token-2022 ATA (hot) | 0 or 1 | TOKEN_2022_PROGRAM_ID | - -Constraints: mint owned by one of SPL/T22 (never both). All four source types can coexist for a given (owner, mint). - -## 3. Modes: Unified vs Standard - -| | Unified (`wrap=true`) | Standard (`wrap=false`, default) | -| ------------ | ----------------------------------------------------------------------------- | ------------------------------------ | -| Balance read | ctoken-hot + ctoken-cold + SPL + T22 | depends on `programId` | -| Load | Decompress cold + Wrap SPL/T22 | Decompress cold only | -| Target | c-token ATA | determined by `programId` / ATA type | -| Transfer ix | `createLightTokenTransferCheckedInstruction` (Light) or SPL `transferChecked` | dispatched by `programId` | - -### Standard mode `getAtaInterface` behavior by `programId` - -| `programId` | Sources aggregated | -| ------------------------ | --------------------------------------------- | -| `undefined` (default) | ctoken-hot + ALL ctoken-cold (no SPL/T22) | -| `LIGHT_TOKEN_PROGRAM_ID` | ctoken-hot + ALL ctoken-cold | -| `TOKEN_PROGRAM_ID` | SPL hot + compressed cold (tagged `spl-cold`) | -| `TOKEN_2022_PROGRAM_ID` | T22 hot + compressed cold (tagged `t22-cold`) | - -Note: compressed cold accounts always have `owner = LIGHT_TOKEN_PROGRAM_ID` regardless of the original mint's token program. The `spl-cold` / `t22-cold` tagging is a display convention for non-unified reads. - -### Standard mode load behavior by ATA type - -| ATA type | Target | Pool | -| ----------- | ------------------------ | ------- | -| `ctoken` | c-token ATA (direct) | No pool | -| `spl` | SPL ATA (via token pool) | Yes | -| `token2022` | T22 ATA (via token pool) | Yes | - -### Standard mode transfer dispatch - -`createTransferInterfaceInstructions` dispatches the transfer instruction based on `programId`: - -| `programId` | Transfer instruction | -| ------------------------ | -------------------------------------------- | -| `LIGHT_TOKEN_PROGRAM_ID` | `createLightTokenTransferCheckedInstruction` | -| `TOKEN_PROGRAM_ID` | `createTransferCheckedInstruction` (SPL) | -| `TOKEN_2022_PROGRAM_ID` | `createTransferCheckedInstruction` (T22) | - -For SPL/T22 with `wrap=false`: derives SPL/T22 ATAs, decompresses cold to SPL/T22 ATA via pool, then issues a standard SPL `transferChecked`. The flow is fully contained to SPL/T22 -- no Light token accounts involved. - -## 4. Flow Diagrams - -### getAtaInterface Dispatch - -``` -getAtaInterface(rpc, ata, owner, mint, commitment?, programId?, wrap?, allowOwnerOffCurve?) - | - +- programId=undefined (default) - | +- wrap=true -> getUnifiedAccountInterface - | | -> ctoken-hot + ctoken-cold + SPL hot + T22 hot - | +- wrap=false -> getUnifiedAccountInterface - | -> ctoken-hot + ctoken-cold only (SPL/T22 NOT fetched) - | - +- programId=LIGHT_TOKEN -> getLightTokenAccountInterface - | -> ctoken-hot + ctoken-cold - | - +- programId=SPL|T22 -> getSplOrToken2022AccountInterface - -> SPL/T22 hot (if exists) + compressed cold (as spl-cold/t22-cold) +| Method | Path | Purpose | +| ---------------------------------------------- | ----------- | ---------------------------------------------------------------- | +| `getAtaInterface` | v3, unified | Aggregate balance from hot/cold/SPL/T22 sources | +| `getOrCreateAtaInterface` | v3 | Create ATA if missing, return interface | +| `createLoadAtaInstructions` | v3 | Build load/decompress batches | +| `loadAta` | v3 | Execute load/decompress batches | +| `transferInterface` | v3 | Action: transfer to recipient wallet (ATA derived/ensured) | +| `createTransferInterfaceInstructions` | v3 | Builder: recipient wallet input, destination ATA derived/ensured | +| `transferToAccountInterface` | v3 | Action: explicit destination token account (legacy behavior) | +| `createTransferToAccountInterfaceInstructions` | v3 | Builder: explicit destination token account | +| `createLightTokenTransferInstruction` | instructions| Raw light-token transfer ix (no load/wrap) | + +Unified (`/unified`) always forces `wrap=true`. +Standard (`v3`) defaults to `wrap=false`. + +## 2. Transfer API Split + +### Wallet-recipient path (default payments UX) + +```ts +transferInterface( + rpc, + payer, + source, + mint, + recipientWallet, + owner, + amount, + programId?, + confirmOptions?, + options?, + wrap?, + decimals?, +) ``` -### Load Path (\_buildLoadBatches) - -``` -_buildLoadBatches(rpc, payer, ata, options?, wrap, targetAta, targetAmount?, authority?, decimals) - | - +- checkNotFrozen(ata) -> throw if any source frozen (no selective skip) - +- spl/t22/cold balance = 0 -> [] - | - +- wrap=true - | +- Create c-token ATA (idempotent, if needed) - | +- Wrap SPL (if splBal>0) - | +- Wrap T22 (if t22Bal>0) - | +- Chunk cold by tree version (V2 only; assertV2Only rejects V1) - | - +- wrap=false - | +- Create target ATA (ctoken/SPL/T22 per ataType, idempotent) - | +- Chunk cold by tree version - | - +- For each chunk: fetch proof, build decompress ix - assertUniqueInputHashes(chunks) <- hash uniqueness enforced +```ts +createTransferInterfaceInstructions( + rpc, + payer, + mint, + amount, + sender, + recipientWallet, + decimals, + options?, +) ``` -### Transfer Flow (createTransferInterfaceInstructions) - -``` -createTransferInterfaceInstructions(rpc, payer, mint, amount, sender, destination, decimals, options?) - | - +- amount <= 0 -> throw - +- destination: token account address (must exist; derive via getAssociatedTokenAddressInterface) - +- getAtaInterface(sender, wrap, programId) - +- checkNotFrozen(senderInterface) -> throw if any source frozen - +- balance < amount -> throw (Insufficient balance. Required: X, Available: Y) - | - +- _buildLoadBatches(..., decimals) -> internalBatches - | - +- programId = SPL|T22 && !wrap -> createTransferCheckedInstruction (SPL) - +- else -> createLightTokenTransferCheckedInstruction (Light) - | - +- Returns TransactionInstruction[][]: - +- batches.length = 0 (hot) -> [[CU, transferIx]] - +- batches.length = 1 -> [[CU, ...batch0, transferIx]] - +- batches.length > 1 - -> [[CU, load0], [CU, load1], ..., [CU, ...lastBatch, transferIx]] - -> send [0..n-2] in parallel, then [n-1] after all confirm +- Derives destination ATA from recipient wallet. +- Inserts idempotent ATA create into final transfer batch. +- Supports SPL/T22/light-token dispatch via `programId`. + +### Explicit destination-account path + +```ts +transferToAccountInterface( + rpc, + payer, + source, + mint, + destinationTokenAccount, + owner, + amount, + programId?, + confirmOptions?, + options?, + wrap?, + decimals?, +) ``` -### transferInterface (action) - -``` -transferInterface(rpc, payer, source, mint, destination, owner, amount, programId?, confirmOptions?, options?, wrap?, decimals?) - | - +- Validate source == getAssociatedTokenAddressInterface(mint, owner, programId) - +- destination: token account address (must exist; derive via getAssociatedTokenAddressInterface) - +- batches = createTransferInterfaceInstructions(..., destination, decimals, { ...options, wrap, programId }) - +- { rest: loads, last: transferIxs } = sliceLast(batches) - +- Send loads in parallel (if any) - +- Send transferIxs +```ts +createTransferToAccountInterfaceInstructions( + rpc, + payer, + mint, + amount, + sender, + destinationTokenAccount, + decimals, + options?, +) ``` -## 5. Frozen Account Handling - -SPL Token behavior: `getAccount()` returns full balance + `isFrozen=true`. The on-chain program rejects `transfer` for frozen accounts. There is no client-side pre-check in `@solana/spl-token`. - -Light Token interface behavior: - -| Method | Frozen accounts behavior | -| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | -| `getAtaInterface` | Shows full balance including frozen. `_anyFrozen=true`. | -| `_buildLoadBatches` | Throws via `checkNotFrozen(ata)` at entry if any source is frozen; no selective skip. | -| `createTransferInterfaceInstructions` | Throws via `checkNotFrozen(senderInterface)` if any source frozen. Insufficient balance error reports Required/Available only. | -| `transferInterface` | Same as above (delegates to `createTransferInterfaceInstructions`). | - -Why throw instead of letting on-chain fail: a frozen account in a later batch would fail on-chain while earlier batches succeed, creating partial-load state. Early assert avoids this. - -## 6. Delegate Handling - -Compressed `TokenData` has `delegate: Option` but no top-level `delegated_amount`; amount can come from TLV. `convertTokenDataToAccount` sets `delegatedAmount`: (1) from CompressedOnly extension TLV if present, (2) else if delegate set then full account amount, (3) else 0. - -`buildAccountInterfaceFromSources`: `parsed.delegate` is the canonical delegate: if any hot source has a delegate, that delegate wins and delegated amount is summed across all sources for that delegate; else if only cold sources have delegates, the first cold delegate is canonical and amount is summed over cold. Multi-source canonicalDelegate logic, not "primary source only". - -For load/transfer: `_buildLoadBatches` iterates `_sources` directly. Each cold account retains its own delegate info through the decompress instruction (`createDecompressInterfaceInstruction` includes delegate pubkeys in `packedAccountIndices`). +- Preserves previous destination-account semantics. +- Use for custom/program-owned destination accounts. -## 7. Hash Uniqueness Guarantee +## 3. Off-Curve/PDA Recipient Note -Within a single call: compressed accounts fetched once globally, partitioned by tree version, each hash in exactly one batch. Enforced by `assertUniqueInputHashes`. +- `createTransferInterfaceInstructions`/`transferInterface` convenience path + expects an on-curve wallet recipient for ATA derivation. +- For PDA/off-curve owners, derive destination ATA/account explicitly and use + `transferToAccountInterface` or + `createTransferToAccountInterfaceInstructions`. -Across concurrent calls for the same sender: not serialized. Both calls read the same hashes from `rpc.getCompressedTokenAccountsByOwner`. First tx nullifies them on-chain, second tx fails with stale hashes. This is inherent to the UTXO/nullifier model (same as Bitcoin double-spend protection). Application-level serialization required for concurrent same-sender transfers. +## 4. Flow (Action Level) -## 8. Scenario Matrix (Unified, wrap=true) +### `transferInterface` (wallet recipient) -| Sender | Recipient | Status | -| ---------------- | ---------- | --------------------------------- | -| Hot only | ATA exists | Works | -| Hot only | No ATA | Fails (destination must exist) | -| Cold <=8 | ATA exists | Works | -| Cold >8 | ATA exists | Works (parallel loads + transfer) | -| Cold | No ATA | Fails (destination must exist) | -| Hot + Cold | Any | Works | -| SPL hot only | Any | Works (wrap) | -| SPL + Cold | Any | Works | -| Hot + SPL + Cold | Any | Works | -| Nothing | Any | Throw: insufficient | -| All frozen | Any | Throw: frozen | -| Partial frozen | Any | Throw: frozen (any source frozen) | -| amount=0 | Any | Throw: zero amount | -| Delegated cold | Any | Works | +1. Validate source ATA matches sender authority + `programId`. +2. Build batched plan via `createTransferInterfaceInstructions`. +3. Split with `sliceLast` into load batches and final transfer batch. +4. Send load batches in parallel. +5. Send final transfer batch. -### Standard (wrap=false) with programId +### `transferToAccountInterface` (explicit destination) -| programId | Sender state | Result | -| --------- | ------------ | --------------------------------------------------- | -| Light | cold only | Decompress to c-token ATA + Light transfer | -| Light | hot only | Light transfer directly | -| Light | hot + cold | Decompress + Light transfer | -| SPL | cold only | Create SPL ATA + decompress via pool + SPL transfer | -| SPL | hot only | SPL transferChecked directly | -| SPL | hot + cold | Decompress to SPL ATA + SPL transferChecked | +Same execution model, but destination account is caller-provided and not derived. -## 9. Cases NOT Covered (Audit) +## 5. Dispatch Rules (Builder) -### Test coverage gaps +`createTransferToAccountInterfaceInstructions` transfer instruction dispatch: -| Case | Status | -| -------------------------------------------------- | -------------------------------------- | -| Frozen sender (partial and full) | No e2e test | -| Zero-amount transfer rejection | No e2e test | -| Unified transfer (wrap=true) SPL hot-only sender | No explicit e2e | -| Unified transfer SPL hot + cold | No explicit e2e | -| V1 tree in transfer path | No V1-specific test (V2 only in suite) | -| Self-transfer (sender == recipient) | No test (allowed, consolidation) | -| createTransferInterfaceInstructions with wrap=true | payment-flows uses wrap=false | -| programId=SPL, cold-only transfer | Tested in transfer-interface.test.ts | -| programId=SPL, hot-only transfer | Tested in transfer-interface.test.ts | -| programId=SPL, instruction builder | Tested in transfer-interface.test.ts | +| Condition | Transfer ix | +| --------------------------------------- | ------------------------------------------ | +| `programId` is SPL or T22 and `!wrap` | SPL `createTransferCheckedInstruction` | +| otherwise | `createLightTokenTransferCheckedInstruction` | -### Design / out-of-scope +## 6. Concurrency Model -| Case | Notes | -| -------------------------------------------------- | --------------------------------------------------- | -| Two independent calls, same sender (e.g. two tabs) | Requires app-level locking; SDK has no shared state | +- Return type is `TransactionInstruction[][]`. +- `[0..n-2]` are load batches and can be sent in parallel. +- `[n-1]` is the transfer batch and must be sent after loads confirm. +- Use `sliceLast(...)` to orchestrate this pattern. diff --git a/js/compressed-token/docs/payment-integration.md b/js/compressed-token/docs/payment-integration.md index 0bd3e7607e..f56c116937 100644 --- a/js/compressed-token/docs/payment-integration.md +++ b/js/compressed-token/docs/payment-integration.md @@ -1,10 +1,14 @@ # Payment Integration: `createTransferInterfaceInstructions` Build transfer instructions for production payment flows. Returns -`TransactionInstruction[][]` with CU budgeting, sender ATA creation, -loading (decompression), and the transfer instruction. Destination token -account must exist; create it via `getOrCreateAtaInterface` or -`createAssociatedTokenAccountInterfaceIdempotentInstruction` before transfer. +`TransactionInstruction[][]` with load/decompression batches followed by the +final transfer batch. + +`createTransferInterfaceInstructions` now takes a **recipient wallet** and +derives/ensures destination ATA internally. + +If you need an explicit destination token account (program-owned/custom), use +`createTransferToAccountInterfaceInstructions`. ## Import @@ -12,9 +16,7 @@ account must exist; create it via `getOrCreateAtaInterface` or // Standard (no SPL/T22 wrapping; decimals required) import { createTransferInterfaceInstructions, - getAssociatedTokenAddressInterface, - getOrCreateAtaInterface, - createAssociatedTokenAccountInterfaceIdempotentInstruction, + createTransferToAccountInterfaceInstructions, getMintInterface, sliceLast, } from '@lightprotocol/compressed-token'; @@ -22,8 +24,7 @@ import { // Unified (auto-wraps SPL/T22 to c-token ATA; decimals resolved internally) import { createTransferInterfaceInstructions, - getAssociatedTokenAddressInterface, - getOrCreateAtaInterface, + createTransferToAccountInterfaceInstructions, sliceLast, } from '@lightprotocol/compressed-token/unified'; ``` @@ -31,14 +32,8 @@ import { ## Usage ```typescript -// 1. Ensure destination exists, then build instruction batches -const destination = getAssociatedTokenAddressInterface( - mint, - recipient.publicKey, -); -await getOrCreateAtaInterface(rpc, payer, mint, recipient.publicKey); - -// Standard path: decimals is required (7th arg). Unified path: omit decimals (fetched internally). +// 1. Build instruction batches (wallet-recipient path) +// Standard path: decimals is required. Unified path resolves decimals internally. const decimals = (await getMintInterface(rpc, mint)).mint.decimals; const batches = await createTransferInterfaceInstructions( rpc, @@ -46,11 +41,11 @@ const batches = await createTransferInterfaceInstructions( mint, amount, sender.publicKey, - destination, + recipient.publicKey, decimals, // omit when using unified import ); -// 2. Customize (optional) -- append memo, priority fee, etc. to the last batch +// 2. Customize (optional) -- append memo/priority fee to the final batch batches.at(-1)!.push(memoIx); // 3. Build all transactions @@ -66,6 +61,23 @@ await Promise.all(rest.map(tx => send(tx))); await send(last); ``` +## Explicit destination-account variant + +```typescript +const destinationTokenAccount = /* PDA or program-owned token account */; +const decimals = (await getMintInterface(rpc, mint)).mint.decimals; + +const batches = await createTransferToAccountInterfaceInstructions( + rpc, + payer.publicKey, + mint, + amount, + sender.publicKey, + destinationTokenAccount, + decimals, +); +``` + ## Return type `TransactionInstruction[][]` -- an array of transaction instruction arrays. @@ -89,6 +101,7 @@ Use `sliceLast(batches)` to get `{ rest, last }` for clean send orchestration. | --------------------------- | :-------------------------------------------------------------------------------: | :------------------: | | `ComputeBudgetProgram` | yes | yes | | Sender (owner) ATA creation | yes (idempotent) | yes (if needed) | +| Recipient ATA creation | -- | yes | | Decompress instructions | yes | yes (if needed) | | Wrap SPL/T22 (unified only) | first load batch (when multiple batches); single batch = load + transfer together | -- | | Transfer instruction | -- | yes | diff --git a/js/compressed-token/src/v3/actions/transfer-interface.ts b/js/compressed-token/src/v3/actions/transfer-interface.ts index 426622775d..102e5ddcb6 100644 --- a/js/compressed-token/src/v3/actions/transfer-interface.ts +++ b/js/compressed-token/src/v3/actions/transfer-interface.ts @@ -173,6 +173,9 @@ export async function createTransferInterfaceInstructions( decimals: number, options?: TransferToAccountOptions, ): Promise { + // Convenience path intentionally derives ATA from a wallet recipient. + // PDA/off-curve recipients should use transferToAccountInterface with an + // explicitly derived destination token account. const programId = options?.programId ?? LIGHT_TOKEN_PROGRAM_ID; const destination = getAssociatedTokenAddressInterface( mint, diff --git a/js/compressed-token/src/v3/unified/index.ts b/js/compressed-token/src/v3/unified/index.ts index 4a8527a2f9..0839983dd6 100644 --- a/js/compressed-token/src/v3/unified/index.ts +++ b/js/compressed-token/src/v3/unified/index.ts @@ -341,6 +341,8 @@ export async function getOrCreateAtaInterface( * Create transfer instructions for a unified token transfer. * * Unified variant: always wraps SPL/T22 to light-token associated token account. + * Recipient must be an on-curve wallet address. For PDA/off-curve owners, + * use createTransferToAccountInterfaceInstructions with an explicit destination ATA. * * Returns `TransactionInstruction[][]`. Send [0..n-2] in parallel, then [n-1]. * Use `sliceLast` to separate the parallel prefix from the final transfer. diff --git a/js/compressed-token/tests/e2e/transfer-interface.test.ts b/js/compressed-token/tests/e2e/transfer-interface.test.ts index e7ed969edf..9327da9da3 100644 --- a/js/compressed-token/tests/e2e/transfer-interface.test.ts +++ b/js/compressed-token/tests/e2e/transfer-interface.test.ts @@ -21,6 +21,7 @@ import { } from '@lightprotocol/stateless.js'; import { TOKEN_PROGRAM_ID, + TOKEN_2022_PROGRAM_ID, getAccount, getAssociatedTokenAddressSync, createAssociatedTokenAccount, @@ -1645,4 +1646,122 @@ describe('transfer-interface', () => { expect(senderAfter.amount).toBe(BigInt(2500)); }, 120_000); }); + + describe('transferInterface recipient-wallet with Token-2022 programId (no-wrap)', () => { + let t22Mint: PublicKey; + let t22TokenPoolInfos: TokenPoolInfo[]; + + beforeAll(async () => { + const mintKeypair = Keypair.generate(); + const created = await createMint( + rpc, + payer, + mintAuthority.publicKey, + TEST_TOKEN_DECIMALS, + mintKeypair, + undefined, + TOKEN_2022_PROGRAM_ID, + ); + t22Mint = created.mint; + t22TokenPoolInfos = await getTokenPoolInfos(rpc, t22Mint); + }, 60_000); + + it('should build T22 transfer plan and include idempotent recipient ATA creation', async () => { + const sender = await newAccountWithLamports(rpc, 2e9); + const recipient = Keypair.generate(); + + await mintTo( + rpc, + payer, + t22Mint, + sender.publicKey, + mintAuthority, + bn(2200), + stateTreeInfo, + selectTokenPoolInfo(t22TokenPoolInfos), + ); + + const batches = await createTransferToRecipientInstructions( + rpc, + payer.publicKey, + t22Mint, + BigInt(700), + sender.publicKey, + recipient.publicKey, + TEST_TOKEN_DECIMALS, + { + programId: TOKEN_2022_PROGRAM_ID, + splInterfaceInfos: t22TokenPoolInfos, + }, + ); + expect(batches.length).toBeGreaterThan(0); + + const recipientT22Ata = getAssociatedTokenAddressSync( + t22Mint, + recipient.publicKey, + false, + TOKEN_2022_PROGRAM_ID, + getAtaProgramId(TOKEN_2022_PROGRAM_ID), + ); + const transferBatch = batches[batches.length - 1]; + const hasRecipientAtaCreate = transferBatch.some(ix => + ix.keys.some(k => k.pubkey.equals(recipientT22Ata)), + ); + expect(hasRecipientAtaCreate).toBe(true); + }, 120_000); + + it('should transfer to Token-2022 ATA without pre-creating recipient ATA', async () => { + const sender = await newAccountWithLamports(rpc, 2e9); + const recipient = await newAccountWithLamports(rpc, 1e9); + + await mintTo( + rpc, + payer, + t22Mint, + sender.publicKey, + mintAuthority, + bn(3000), + stateTreeInfo, + selectTokenPoolInfo(t22TokenPoolInfos), + ); + + const senderT22Ata = getAssociatedTokenAddressSync( + t22Mint, + sender.publicKey, + false, + TOKEN_2022_PROGRAM_ID, + getAtaProgramId(TOKEN_2022_PROGRAM_ID), + ); + const recipientT22Ata = getAssociatedTokenAddressSync( + t22Mint, + recipient.publicKey, + false, + TOKEN_2022_PROGRAM_ID, + getAtaProgramId(TOKEN_2022_PROGRAM_ID), + ); + + const signature = await transferToRecipientInterface( + rpc, + payer, + senderT22Ata, + t22Mint, + recipient.publicKey, + sender, + BigInt(900), + TOKEN_2022_PROGRAM_ID, + undefined, + { splInterfaceInfos: t22TokenPoolInfos }, + false, + ); + expect(signature).toBeDefined(); + + const recipientAccount = await getAccount( + rpc, + recipientT22Ata, + undefined, + TOKEN_2022_PROGRAM_ID, + ); + expect(recipientAccount.amount).toBe(BigInt(900)); + }, 120_000); + }); }); From a2936fc8d029bcb88ab45f1e91ae377f794d5e1c Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Thu, 19 Mar 2026 02:48:10 +0000 Subject: [PATCH 4/7] format --- js/compressed-token/docs/interface.md | 30 +++++++++---------- .../src/v3/actions/transfer-interface.ts | 8 ++--- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/js/compressed-token/docs/interface.md b/js/compressed-token/docs/interface.md index e7d8b605bb..f9b405683c 100644 --- a/js/compressed-token/docs/interface.md +++ b/js/compressed-token/docs/interface.md @@ -5,17 +5,17 @@ Concise v3 interface reference for reads (`getAtaInterface`), loads ## 1. API Surface -| Method | Path | Purpose | -| ---------------------------------------------- | ----------- | ---------------------------------------------------------------- | -| `getAtaInterface` | v3, unified | Aggregate balance from hot/cold/SPL/T22 sources | -| `getOrCreateAtaInterface` | v3 | Create ATA if missing, return interface | -| `createLoadAtaInstructions` | v3 | Build load/decompress batches | -| `loadAta` | v3 | Execute load/decompress batches | -| `transferInterface` | v3 | Action: transfer to recipient wallet (ATA derived/ensured) | -| `createTransferInterfaceInstructions` | v3 | Builder: recipient wallet input, destination ATA derived/ensured | -| `transferToAccountInterface` | v3 | Action: explicit destination token account (legacy behavior) | -| `createTransferToAccountInterfaceInstructions` | v3 | Builder: explicit destination token account | -| `createLightTokenTransferInstruction` | instructions| Raw light-token transfer ix (no load/wrap) | +| Method | Path | Purpose | +| ---------------------------------------------- | ------------ | ---------------------------------------------------------------- | +| `getAtaInterface` | v3, unified | Aggregate balance from hot/cold/SPL/T22 sources | +| `getOrCreateAtaInterface` | v3 | Create ATA if missing, return interface | +| `createLoadAtaInstructions` | v3 | Build load/decompress batches | +| `loadAta` | v3 | Execute load/decompress batches | +| `transferInterface` | v3 | Action: transfer to recipient wallet (ATA derived/ensured) | +| `createTransferInterfaceInstructions` | v3 | Builder: recipient wallet input, destination ATA derived/ensured | +| `transferToAccountInterface` | v3 | Action: explicit destination token account (legacy behavior) | +| `createTransferToAccountInterfaceInstructions` | v3 | Builder: explicit destination token account | +| `createLightTokenTransferInstruction` | instructions | Raw light-token transfer ix (no load/wrap) | Unified (`/unified`) always forces `wrap=true`. Standard (`v3`) defaults to `wrap=false`. @@ -119,10 +119,10 @@ Same execution model, but destination account is caller-provided and not derived `createTransferToAccountInterfaceInstructions` transfer instruction dispatch: -| Condition | Transfer ix | -| --------------------------------------- | ------------------------------------------ | -| `programId` is SPL or T22 and `!wrap` | SPL `createTransferCheckedInstruction` | -| otherwise | `createLightTokenTransferCheckedInstruction` | +| Condition | Transfer ix | +| ------------------------------------- | -------------------------------------------- | +| `programId` is SPL or T22 and `!wrap` | SPL `createTransferCheckedInstruction` | +| otherwise | `createLightTokenTransferCheckedInstruction` | ## 6. Concurrency Model diff --git a/js/compressed-token/src/v3/actions/transfer-interface.ts b/js/compressed-token/src/v3/actions/transfer-interface.ts index 102e5ddcb6..e30d35ecf2 100644 --- a/js/compressed-token/src/v3/actions/transfer-interface.ts +++ b/js/compressed-token/src/v3/actions/transfer-interface.ts @@ -15,9 +15,7 @@ import { LIGHT_TOKEN_PROGRAM_ID, } from '@lightprotocol/stateless.js'; import BN from 'bn.js'; -import { - createTransferToAccountInterfaceInstructions, -} from '../instructions/transfer-interface'; +import { createTransferToAccountInterfaceInstructions } from '../instructions/transfer-interface'; import { getAssociatedTokenAddressInterface } from '../get-associated-token-address-interface'; import { getMintInterface } from '../get-mint-interface'; import { type SplInterfaceInfo } from '../../utils/get-token-pool-infos'; @@ -209,7 +207,9 @@ export async function createTransferInterfaceInstructions( let insertionIdx = 0; while ( insertionIdx < finalBatch.length && - finalBatch[insertionIdx].programId.equals(ComputeBudgetProgram.programId) + finalBatch[insertionIdx].programId.equals( + ComputeBudgetProgram.programId, + ) ) { insertionIdx += 1; } From c1643d6d645386c61b2e9ff21533ac349dc67351 Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Thu, 19 Mar 2026 13:14:55 +0000 Subject: [PATCH 5/7] upd cu, transferoptions, --- .../src/v3/actions/transfer-interface.ts | 13 ++++++++++--- .../src/v3/instructions/transfer-interface.ts | 3 ++- js/compressed-token/src/v3/unified/index.ts | 5 ++--- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/js/compressed-token/src/v3/actions/transfer-interface.ts b/js/compressed-token/src/v3/actions/transfer-interface.ts index e30d35ecf2..cd4fd01adb 100644 --- a/js/compressed-token/src/v3/actions/transfer-interface.ts +++ b/js/compressed-token/src/v3/actions/transfer-interface.ts @@ -21,6 +21,7 @@ import { getMintInterface } from '../get-mint-interface'; import { type SplInterfaceInfo } from '../../utils/get-token-pool-infos'; import { sliceLast } from './slice-last'; import { createAssociatedTokenAccountInterfaceIdempotentInstruction } from '../instructions/create-ata-interface'; +import { assertTransactionSizeWithinLimit } from '../utils/estimate-tx-size'; export interface InterfaceOptions { splInterfaceInfos?: SplInterfaceInfo[]; @@ -153,11 +154,11 @@ export async function transferInterface( return sendAndConfirmTx(rpc, tx, confirmOptions); } -export interface TransferToAccountOptions extends InterfaceOptions { +export interface TransferOptions extends InterfaceOptions { wrap?: boolean; programId?: PublicKey; } -export type TransferOptions = TransferToAccountOptions; +export type TransferToAccountOptions = TransferOptions; export { sliceLast } from './slice-last'; @@ -169,7 +170,7 @@ export async function createTransferInterfaceInstructions( sender: PublicKey, recipient: PublicKey, decimals: number, - options?: TransferToAccountOptions, + options?: TransferOptions, ): Promise { // Convenience path intentionally derives ATA from a wallet recipient. // PDA/off-curve recipients should use transferToAccountInterface with an @@ -219,6 +220,12 @@ export async function createTransferInterfaceInstructions( ensureRecipientAtaIx, ...finalBatch.slice(insertionIdx), ]; + const numSigners = payer.equals(sender) ? 1 : 2; + assertTransactionSizeWithinLimit( + patchedFinalBatch, + numSigners, + 'Final transfer batch', + ); return [...batches.slice(0, -1), patchedFinalBatch]; } diff --git a/js/compressed-token/src/v3/instructions/transfer-interface.ts b/js/compressed-token/src/v3/instructions/transfer-interface.ts index f2e2161858..824c60a485 100644 --- a/js/compressed-token/src/v3/instructions/transfer-interface.ts +++ b/js/compressed-token/src/v3/instructions/transfer-interface.ts @@ -38,11 +38,12 @@ const LIGHT_TOKEN_TRANSFER_DISCRIMINATOR = 3; const LIGHT_TOKEN_TRANSFER_CHECKED_DISCRIMINATOR = 12; const TRANSFER_BASE_CU = 10_000; +const TRANSFER_EXTRA_BUFFER_CU = 10_000; export function calculateTransferCU( loadBatch: InternalLoadBatch | null, ): number { - return calculateCombinedCU(TRANSFER_BASE_CU, loadBatch); + return calculateCombinedCU(TRANSFER_BASE_CU + TRANSFER_EXTRA_BUFFER_CU, loadBatch); } /** diff --git a/js/compressed-token/src/v3/unified/index.ts b/js/compressed-token/src/v3/unified/index.ts index 0839983dd6..948ee5706d 100644 --- a/js/compressed-token/src/v3/unified/index.ts +++ b/js/compressed-token/src/v3/unified/index.ts @@ -38,7 +38,6 @@ import { } from '../actions/transfer-interface'; import type { TransferOptions as _TransferOptions, - TransferToAccountOptions as _TransferToAccountOptions, } from '../actions/transfer-interface'; import { _getOrCreateAtaInterface } from '../actions/get-or-create-ata-interface'; import { @@ -385,7 +384,7 @@ export async function createTransferToAccountInterfaceInstructions( amount: number | bigint | BN, sender: PublicKey, destination: PublicKey, - options?: Omit<_TransferToAccountOptions, 'wrap'>, + options?: Omit<_TransferOptions, 'wrap'>, ): Promise { const mintInterface = await getMintInterface(rpc, mint); return _createTransferToAccountInterfaceInstructions( @@ -493,7 +492,7 @@ export async function unwrap( export type { _TransferOptions as TransferOptions, - _TransferToAccountOptions as TransferToAccountOptions, + _TransferOptions as TransferToAccountOptions, }; export { From 57457ac597f5cd1fa35799f08236404c78f7d0d5 Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Thu, 19 Mar 2026 13:16:02 +0000 Subject: [PATCH 6/7] add check --- js/compressed-token/src/v3/actions/transfer-interface.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/js/compressed-token/src/v3/actions/transfer-interface.ts b/js/compressed-token/src/v3/actions/transfer-interface.ts index cd4fd01adb..5a723abc92 100644 --- a/js/compressed-token/src/v3/actions/transfer-interface.ts +++ b/js/compressed-token/src/v3/actions/transfer-interface.ts @@ -202,8 +202,6 @@ export async function createTransferInterfaceInstructions( programId, ); - if (batches.length === 0) return [[ensureRecipientAtaIx]]; - const finalBatch = batches[batches.length - 1]; let insertionIdx = 0; while ( From 6c1ff1b0e3f74d7c28890aee7d883dbc7166760d Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Thu, 19 Mar 2026 23:53:47 +0000 Subject: [PATCH 7/7] fmt ci --- .../src/v3/instructions/transfer-interface.ts | 5 ++- js/compressed-token/src/v3/unified/index.ts | 4 +-- .../tests/unit/load-transfer-cu.test.ts | 34 +++++++++---------- 3 files changed, 22 insertions(+), 21 deletions(-) diff --git a/js/compressed-token/src/v3/instructions/transfer-interface.ts b/js/compressed-token/src/v3/instructions/transfer-interface.ts index 824c60a485..1628c8e519 100644 --- a/js/compressed-token/src/v3/instructions/transfer-interface.ts +++ b/js/compressed-token/src/v3/instructions/transfer-interface.ts @@ -43,7 +43,10 @@ const TRANSFER_EXTRA_BUFFER_CU = 10_000; export function calculateTransferCU( loadBatch: InternalLoadBatch | null, ): number { - return calculateCombinedCU(TRANSFER_BASE_CU + TRANSFER_EXTRA_BUFFER_CU, loadBatch); + return calculateCombinedCU( + TRANSFER_BASE_CU + TRANSFER_EXTRA_BUFFER_CU, + loadBatch, + ); } /** diff --git a/js/compressed-token/src/v3/unified/index.ts b/js/compressed-token/src/v3/unified/index.ts index 948ee5706d..c61a60cf0a 100644 --- a/js/compressed-token/src/v3/unified/index.ts +++ b/js/compressed-token/src/v3/unified/index.ts @@ -36,9 +36,7 @@ import { createTransferInterfaceInstructions as _createTransferInterfaceInstructions, createTransferToAccountInterfaceInstructions as _createTransferToAccountInterfaceInstructions, } from '../actions/transfer-interface'; -import type { - TransferOptions as _TransferOptions, -} from '../actions/transfer-interface'; +import type { TransferOptions as _TransferOptions } from '../actions/transfer-interface'; import { _getOrCreateAtaInterface } from '../actions/get-or-create-ata-interface'; import { createUnwrapInstructions as _createUnwrapInstructions, diff --git a/js/compressed-token/tests/unit/load-transfer-cu.test.ts b/js/compressed-token/tests/unit/load-transfer-cu.test.ts index 4ef49550c5..1fa06e1e54 100644 --- a/js/compressed-token/tests/unit/load-transfer-cu.test.ts +++ b/js/compressed-token/tests/unit/load-transfer-cu.test.ts @@ -179,50 +179,50 @@ describe('calculateLoadBatchComputeUnits', () => { // --------------------------------------------------------------------------- describe('calculateTransferCU', () => { - it('hot sender (null batch): 10_000 base * 1.3 = 13_000 → clamped to 50_000', () => { + it('hot sender (null batch): (10_000 base + 10_000 transfer buffer) * 1.3 = 26_000 → clamped to 50_000', () => { const cu = calculateTransferCU(null); expect(cu).toBe(50_000); }); - it('empty load batch: 10_000 base * 1.3 = 13_000 → clamped to 50_000', () => { + it('empty load batch: (10_000 base + 10_000 transfer buffer) * 1.3 = 26_000 → clamped to 50_000', () => { const cu = calculateTransferCU(emptyBatch()); expect(cu).toBe(50_000); }); - it('ATA creation in batch: (10_000 + 30_000) * 1.3 = 52_000', () => { + it('ATA creation in batch: (10_000 + 10_000 + 30_000) * 1.3 = 65_000', () => { const cu = calculateTransferCU(emptyBatch({ hasAtaCreation: true })); - expect(cu).toBe(52_000); + expect(cu).toBe(65_000); }); - it('1 wrap in batch: (10_000 + 50_000) * 1.3 = 78_000', () => { + it('1 wrap in batch: (10_000 + 10_000 + 50_000) * 1.3 = 91_000', () => { const cu = calculateTransferCU(emptyBatch({ wrapCount: 1 })); - expect(cu).toBe(78_000); + expect(cu).toBe(91_000); }); - it('1 full-proof compressed account: (10_000 + 50_000 + 100_000 + 30_000) * 1.3 = 247_000', () => { + it('1 full-proof compressed account: (10_000 + 10_000 + 50_000 + 100_000 + 30_000) * 1.3 = 260_000', () => { const acc = mockParsedAccount(false); const cu = calculateTransferCU( emptyBatch({ compressedAccounts: [acc] }), ); - expect(cu).toBe(247_000); + expect(cu).toBe(260_000); }); - it('1 proveByIndex account: (10_000 + 50_000 + 10_000) * 1.3 = 91_000', () => { + it('1 proveByIndex account: (10_000 + 10_000 + 50_000 + 10_000) * 1.3 = 104_000', () => { const acc = mockParsedAccount(true); const cu = calculateTransferCU( emptyBatch({ compressedAccounts: [acc] }), ); - expect(cu).toBe(91_000); + expect(cu).toBe(104_000); }); - it('8 full-proof accounts: (10_000 + 50_000 + 100_000 + 8*30_000) * 1.3 = 520_000', () => { + it('8 full-proof accounts: (10_000 + 10_000 + 50_000 + 100_000 + 8*30_000) * 1.3 = 533_000', () => { const accounts = Array.from({ length: 8 }, () => mockParsedAccount(false), ); const cu = calculateTransferCU( emptyBatch({ compressedAccounts: accounts }), ); - expect(cu).toBe(520_000); + expect(cu).toBe(533_000); }); it('ATA + 1 wrap + 8 full-proof: combines all costs', () => { @@ -236,10 +236,10 @@ describe('calculateTransferCU', () => { compressedAccounts: accounts, }), ); - // (10_000 + 30_000 + 50_000 + 50_000 + 100_000 + 8*30_000) * 1.3 - // = (10_000+30_000+50_000+50_000+100_000+240_000) * 1.3 - // = 480_000 * 1.3 = 624_000 - expect(cu).toBe(624_000); + // (10_000 + 10_000 + 30_000 + 50_000 + 50_000 + 100_000 + 8*30_000) * 1.3 + // = (10_000+10_000+30_000+50_000+50_000+100_000+240_000) * 1.3 + // = 490_000 * 1.3 = 637_000 + expect(cu).toBe(637_000); }); it('caps at 1_400_000', () => { @@ -252,7 +252,7 @@ describe('calculateTransferCU', () => { expect(cu).toBe(1_400_000); }); - it('transfer CU exceeds load CU (transfer adds 10_000 base)', () => { + it('transfer CU exceeds load CU (transfer adds 10_000 base + 10_000 transfer buffer)', () => { const acc = mockParsedAccount(false); const loadCu = calculateLoadBatchComputeUnits( emptyBatch({ compressedAccounts: [acc] }),