diff --git a/js/compressed-token/CHANGELOG.md b/js/compressed-token/CHANGELOG.md index 16e9723335..b744dbfd81 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. @@ -80,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` @@ -127,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..f9b405683c 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/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..5a723abc92 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,13 @@ 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'; +import { assertTransactionSizeWithinLimit } from '../utils/estimate-tx-size'; export interface InterfaceOptions { splInterfaceInfos?: SplInterfaceInfo[]; @@ -28,7 +32,7 @@ export interface InterfaceOptions { owner?: PublicKey; } -export async function transferInterface( +export async function transferToAccountInterface( rpc: Rpc, payer: Signer, source: PublicKey, @@ -61,7 +65,7 @@ export async function transferInterface( const resolvedDecimals = decimals ?? (await getMintInterface(rpc, mint)).mint.decimals; - const batches = await createTransferInterfaceInstructions( + const batches = await createTransferToAccountInterfaceInstructions( rpc, payer.publicKey, mint, @@ -78,7 +82,6 @@ export async function transferInterface( const additionalSigners = dedupeSigner(payer, [owner]); const { rest: loads, last: transferIxs } = sliceLast(batches); - await Promise.all( loads.map(async ixs => { const { blockhash } = await rpc.getLatestBlockhash(); @@ -86,7 +89,66 @@ export async function transferInterface( 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( + 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; + 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; + const batches = await createTransferInterfaceInstructions( + rpc, + payer.publicKey, + mint, + amount, + owner.publicKey, + recipient, + resolvedDecimals, + { + ...options, + wrap, + programId, + }, + ); + + 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); @@ -96,10 +158,76 @@ export interface TransferOptions extends InterfaceOptions { wrap?: boolean; programId?: PublicKey; } +export type TransferToAccountOptions = TransferOptions; 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?: TransferOptions, +): 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, + recipient, + false, + programId, + ); + const batches = await createTransferToAccountInterfaceInstructions( + rpc, + payer, + mint, + amount, + sender, + destination, + decimals, + options, + ); + + const ensureRecipientAtaIx = + createAssociatedTokenAccountInterfaceIdempotentInstruction( + payer, + destination, + recipient, + mint, + programId, + ); + + 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), + ]; + const numSigners = payer.equals(sender) ? 1 : 2; + assertTransactionSizeWithinLimit( + patchedFinalBatch, + numSigners, + 'Final transfer batch', + ); + return [...batches.slice(0, -1), patchedFinalBatch]; +} + 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..1628c8e519 100644 --- a/js/compressed-token/src/v3/instructions/transfer-interface.ts +++ b/js/compressed-token/src/v3/instructions/transfer-interface.ts @@ -38,11 +38,15 @@ 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, + ); } /** @@ -141,7 +145,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..c61a60cf0a 100644 --- a/js/compressed-token/src/v3/unified/index.ts +++ b/js/compressed-token/src/v3/unified/index.ts @@ -32,7 +32,9 @@ 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 } from '../actions/transfer-interface'; import { _getOrCreateAtaInterface } from '../actions/get-or-create-ata-interface'; @@ -206,13 +208,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 +227,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 +235,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, @@ -302,6 +338,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. @@ -314,11 +352,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<_TransferOptions, 'wrap'>, +): Promise { + const mintInterface = await getMintInterface(rpc, mint); + return _createTransferToAccountInterfaceInstructions( rpc, payer, mint, @@ -421,7 +488,10 @@ export async function unwrap( ); } -export type { _TransferOptions as TransferOptions }; +export type { + _TransferOptions as TransferOptions, + _TransferOptions 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..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, @@ -37,8 +38,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 +178,7 @@ describe('transfer-interface', () => { }); }); - describe('transferInterface frozen sender', () => { + describe('transferToAccountInterface frozen sender', () => { let splMintWithFreeze: PublicKey; let freezeAuthority: Keypair; @@ -343,7 +346,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 +727,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 +1162,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 +1431,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); @@ -1560,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); + }); }); 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'; 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] }),