diff --git a/cli/package.json b/cli/package.json index 0f32eaa5e6..c29bcd265e 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@lightprotocol/zk-compression-cli", - "version": "0.28.0-beta.5", + "version": "0.28.0-beta.8", "description": "ZK Compression: Secure Scaling on Solana", "maintainers": [ { diff --git a/cli/src/commands/create-token-pool/index.ts b/cli/src/commands/create-token-pool/index.ts index 2949005f2b..66794c389a 100644 --- a/cli/src/commands/create-token-pool/index.ts +++ b/cli/src/commands/create-token-pool/index.ts @@ -7,7 +7,7 @@ import { } from "../../utils/utils"; import { PublicKey } from "@solana/web3.js"; -import { createTokenPool } from "@lightprotocol/compressed-token"; +import { createSplInterface } from "@lightprotocol/compressed-token"; class RegisterMintCommand extends Command { static summary = "Register an existing mint with the CompressedToken program"; @@ -31,7 +31,7 @@ class RegisterMintCommand extends Command { try { const payer = defaultSolanaWalletKeypair(); const mintAddress = new PublicKey(flags.mint); - const txId = await createTokenPool(rpc(), payer, mintAddress); + const txId = await createSplInterface(rpc(), payer, mintAddress); loader.stop(false); console.log("\x1b[1mMint public key:\x1b[0m ", mintAddress.toBase58()); console.log( diff --git a/ctoken_for_payments.md b/ctoken_for_payments.md deleted file mode 100644 index 2cf501e8b3..0000000000 --- a/ctoken_for_payments.md +++ /dev/null @@ -1,333 +0,0 @@ -# Using c-token for Payments - -**TL;DR**: Same API patterns, 1/200th ATA creation cost. Your users get the same USDC, just stored more efficiently. - ---- - -## Setup - -```typescript -import { createRpc } from "@lightprotocol/stateless.js"; - -import { - getOrCreateAtaInterface, - getAtaInterface, - getAssociatedTokenAddressInterface, - transferInterface, - unwrap, -} from "@lightprotocol/compressed-token/unified"; - -const rpc = createRpc(RPC_ENDPOINT); -``` - ---- - -## 1. Receive Payments - -**SPL Token:** - -```typescript -import { getOrCreateAssociatedTokenAccount } from "@solana/spl-token"; - -const ata = await getOrCreateAssociatedTokenAccount( - connection, - payer, - mint, - recipient -); -// Share ata.address with sender - -console.log(ata.amount); -``` - -**SPL Token (instruction-level):** - -```typescript -import { - getAssociatedTokenAddressSync, - createAssociatedTokenAccountIdempotentInstruction, -} from "@solana/spl-token"; - -const ata = getAssociatedTokenAddressSync(mint, recipient); - -const tx = new Transaction().add( - createAssociatedTokenAccountIdempotentInstruction( - payer.publicKey, - ata, - recipient, - mint - ) -); -``` - -**c-token:** - -```typescript -const ata = await getOrCreateAtaInterface(rpc, payer, mint, recipient); -// Share ata.parsed.address with sender - -console.log(ata.parsed.amount); -``` - -**c-token (instruction-level):** - -```typescript -import { - createAssociatedTokenAccountInterfaceIdempotentInstruction, - getAssociatedTokenAddressInterface, -} from "@lightprotocol/compressed-token/unified"; -import { LIGHT_TOKEN_PROGRAM_ID } from "@lightprotocol/stateless.js"; - -const ata = getAssociatedTokenAddressInterface(mint, recipient); - -const tx = new Transaction().add( - createAssociatedTokenAccountInterfaceIdempotentInstruction( - payer.publicKey, - ata, - recipient, - mint, - LIGHT_TOKEN_PROGRAM_ID - ) -); -``` - ---- - -## 2. Send Payments - -**SPL Token:** - -```typescript -import { transfer } from "@solana/spl-token"; -const sourceAta = getAssociatedTokenAddressSync(mint, owner.publicKey); -const destinationAta = getAssociatedTokenAddressSync(mint, recipient); - -await transfer( - connection, - payer, - sourceAta, - destinationAta, - owner, - amount, - decimals -); -``` - -**SPL Token (instruction-level):** - -```typescript -import { - getAssociatedTokenAddressSync, - createTransferInstruction, -} from "@solana/spl-token"; - -const sourceAta = getAssociatedTokenAddressSync(mint, owner.publicKey); -const destinationAta = getAssociatedTokenAddressSync(mint, recipient); - -const tx = new Transaction().add( - createTransferInstruction(sourceAta, destinationAta, owner.publicKey, amount) -); -``` - -**c-token:** - -```typescript -const sourceAta = getAssociatedTokenAddressInterface(mint, owner.publicKey); -const destinationAta = getAssociatedTokenAddressInterface(mint, recipient); - -await transferInterface( - rpc, - payer, - sourceAta, - mint, - destinationAta, - owner, - amount -); -``` - -**c-token (instruction-level):** - -```typescript -import { - createLoadAtaInstructions, - createTransferInterfaceInstruction, - getAssociatedTokenAddressInterface, -} from "@lightprotocol/compressed-token/unified"; - -const sourceAta = getAssociatedTokenAddressInterface(mint, owner.publicKey); -const destinationAta = getAssociatedTokenAddressInterface(mint, recipient); - -const tx = new Transaction().add( - ...(await createLoadAtaInstructions( - rpc, - sourceAta, - owner.publicKey, - mint, - payer.publicKey - )), - createTransferInterfaceInstruction( - sourceAta, - destinationAta, - owner.publicKey, - amount - ) -); -``` - -To ensure your recipient's ATA exists you can prepend an idempotent creation instruction in the same atomic transaction: - -**SPL Token:** - -```typescript -import { - getAssociatedTokenAddressSync, - createAssociatedTokenAccountIdempotentInstruction, -} from "@solana/spl-token"; - -const destinationAta = getAssociatedTokenAddressSync(mint, recipient); -const createAtaIx = createAssociatedTokenAccountIdempotentInstruction( - payer.publicKey, - destinationAta, - recipient, - mint -); - -new Transaction().add(createAtaIx, transferIx); -``` - -**c-token:** - -```typescript -import { - getAssociatedTokenAddressInterface, - createAssociatedTokenAccountInterfaceIdempotentInstruction, -} from "@lightprotocol/compressed-token/unified"; -import { LIGHT_TOKEN_PROGRAM_ID } from "@lightprotocol/stateless.js"; - -const destinationAta = getAssociatedTokenAddressInterface(mint, recipient); -const createAtaIx = createAssociatedTokenAccountInterfaceIdempotentInstruction( - payer.publicKey, - destinationAta, - recipient, - mint, - LIGHT_TOKEN_PROGRAM_ID -); - -new Transaction().add(createAtaIx, transferIx); -``` - ---- - -## 3. Show Balance - -**SPL Token:** - -```typescript -import { getAccount } from "@solana/spl-token"; - -const account = await getAccount(connection, ata); -console.log(account.amount); -``` - -**c-token:** - -```typescript -const ata = getAssociatedTokenAddressInterface(mint, owner); -const account = await getAtaInterface(rpc, ata, owner, mint); - -console.log(account.parsed.amount); -``` - ---- - -## 4. Transaction History - -**SPL Token:** - -```typescript -const signatures = await connection.getSignaturesForAddress(ata); -``` - -**c-token:** - -```typescript -// Unified: fetches both on-chain and compressed tx signatures -const result = await rpc.getSignaturesForOwnerInterface(owner); - -console.log(result.signatures); // Merged + deduplicated -console.log(result.solana); // On-chain txs only -console.log(result.compressed); // Compressed txs only -``` - -Use `getSignaturesForAddressInterface(address)` if you want address-specific rather than owner-wide history. - ---- - -## 5. Unwrap to SPL - -When users need vanilla SPL tokens (eg., for CEX off-ramp): - -**c-token -> SPL ATA:** - -```typescript -import { getAssociatedTokenAddressSync } from "@solana/spl-token"; - -// SPL ATA must exist -const splAta = getAssociatedTokenAddressSync(mint, owner.publicKey); - -await unwrap(rpc, payer, owner, mint, splAta, amount); -``` - -**c-token (instruction-level):** - -```typescript -import { getAssociatedTokenAddressSync } from "@solana/spl-token"; -import { - createLoadAtaInstructions, - createUnwrapInstruction, - getAssociatedTokenAddressInterface, -} from "@lightprotocol/compressed-token/unified"; -import { getSplInterfaceInfos } from "@lightprotocol/compressed-token"; - -const ctokenAta = getAssociatedTokenAddressInterface(mint, owner.publicKey); -const splAta = getAssociatedTokenAddressSync(mint, owner.publicKey); - -const splInterfaceInfos = await getSplInterfaceInfos(rpc, mint); -const splInterfaceInfo = splInterfaceInfos.find((i) => i.isInitialized); - -const tx = new Transaction().add( - ...(await createLoadAtaInstructions( - rpc, - ctokenAta, - owner.publicKey, - mint, - payer.publicKey - )), - createUnwrapInstruction( - ctokenAta, - splAta, - owner.publicKey, - mint, - amount, - splInterfaceInfo - ) -); -``` - ---- - -## Quick Reference - -| Operation | SPL Token | c-token (unified) | -| -------------- | ------------------------------------- | -------------------------------------- | -| Get/Create ATA | `getOrCreateAssociatedTokenAccount()` | `getOrCreateAtaInterface()` | -| Derive ATA | `getAssociatedTokenAddress()` | `getAssociatedTokenAddressInterface()` | -| Transfer | `transferChecked()` | `transferInterface()` | -| Get Balance | `getAccount()` | `getAtaInterface()` | -| Tx History | `getSignaturesForAddress()` | `rpc.getSignaturesForOwnerInterface()` | -| Exit to SPL | N/A | `unwrap()` | - ---- - -Need help with integration? Reach out: [support@lightprotocol.com](mailto:support@lightprotocol.com) diff --git a/js/compressed-token/CHANGELOG.md b/js/compressed-token/CHANGELOG.md index 35beb46c54..c0ca6a9c2d 100644 --- a/js/compressed-token/CHANGELOG.md +++ b/js/compressed-token/CHANGELOG.md @@ -1,3 +1,91 @@ +## [0.23.0-beta.7] - Transfer Interface Hardening + +### Breaking Changes + +#### Renames + +- **`CTOKEN_PROGRAM_ID`**: Deprecated. Use `LIGHT_TOKEN_PROGRAM_ID` (re-exported from `@lightprotocol/stateless.js`). + +- **`createCTokenTransferInstruction`**: Renamed to `createLightTokenTransferInstruction`. Instruction data layout changed (see below). + +- **`createTransferInterfaceInstruction`** (multi-program dispatcher): Deprecated. Use `createLightTokenTransferInstruction` for Light token transfers, or SPL's `createTransferCheckedInstruction` for SPL/T22 transfers. + +#### `transferInterface` (high-level action) + +- **`destination` parameter changed from ATA address to wallet public key.** The function now derives the recipient ATA internally and creates it idempotently (no extra RPC fetch). Callers that previously passed a pre-derived ATA address must now pass the recipient's wallet public key instead. + +- **`programId` default changed** from `CTOKEN_PROGRAM_ID` to `LIGHT_TOKEN_PROGRAM_ID`. Parameter order unchanged: `amount, programId?, confirmOptions?, options?, wrap?`. + +- **Multi-transaction support**: For >8 compressed inputs, the action now sends parallel load transactions before the final transfer transaction. Previously, all instructions were packed into a single transaction (which could exceed limits). + +#### `createTransferInterfaceInstructions` (instruction builder -- NEW) + +New function replacing the old monolithic `transferInterface` internals. Takes `recipient` as a wallet public key (not ATA). Returns `TransactionInstruction[][]` where each inner array is one transaction. The last element is always the transfer transaction; all preceding elements are load transactions that can be sent in parallel. + +```typescript +const batches = await createTransferInterfaceInstructions( + rpc, payer, mint, amount, sender, recipientWallet, options?, +); +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`. + +#### `createLoadAtaInstructions` + +- **Return type changed** from `TransactionInstruction[]` (flat) to `TransactionInstruction[][]` (batched). Each inner array is one transaction. For >8 compressed inputs, multiple transactions are needed because each decompress proof can handle at most 8 inputs. + + ```typescript + // Old + const ixs: TransactionInstruction[] = await createLoadAtaInstructions( + rpc, + ata, + owner, + mint, + ); + + // New + const batches: TransactionInstruction[][] = await createLoadAtaInstructions( + rpc, + ata, + owner, + mint, + ); + // Each element is one transaction's instructions + ``` + +#### `createLightTokenTransferInstruction` (instruction-level) + +- **Instruction data layout changed**: Old format was 10 bytes (discriminator + padding + u64 LE at offset 2). New format is 9 bytes (discriminator + u64 LE at offset 1, no padding). + +- **Account keys changed**: Now always includes `system_program` (index 3) and `fee_payer` (index 4) for compressible extension rent top-ups. Old format had 3 required accounts (source, destination, owner) with optional payer. New format has 5 required accounts. + +- **`owner` is now writable** (for rent top-ups via compressible extension). + +#### `createDecompressInterfaceInstruction` + +- **New required parameter**: `decimals: number` added after `splInterfaceInfo`. Required for SPL destination decompression. + +- **Delegate handling**: Now includes delegate pubkeys from input compressed accounts in the packed accounts list. + +#### Program instruction: createTokenPool → createSplInterface + +- **`CompressedTokenProgram.createTokenPool`**: Deprecated. Use `CompressedTokenProgram.createSplInterface` with the same call signature (`feePayer`, `mint`, `tokenProgramId?`). The high-level action `createSplInterface()` now calls the new instruction helper; the deprecated action alias `createTokenPool` still works but points to `createSplInterface`. `CompressedTokenProgram.createMint` now uses `createSplInterface` internally for the third instruction. + +### Added + +- **`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`. +- **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. +- **Frozen account handling**: `_buildLoadBatches` skips frozen sources. `createTransferInterfaceInstructions` throws early if hot account is frozen, reports frozen balance in insufficient-balance errors. +- **`loadAta` action**: Now sends all load batches in parallel (previously sequential single-tx). +- **`createUnwrapInstructions`**: New instruction builder for unwrapping c-tokens to SPL/T22. Returns `TransactionInstruction[][]` (load batches, if any, then one unwrap batch). Same loop pattern as `createLoadAtaInstructions` and `createTransferInterfaceInstructions`. The `unwrap` action now uses it internally. Use this when you need instruction-level control or to handle multi-batch load + unwrap in one go. +- **`LightTokenProgram`**: Export alias for `CompressedTokenProgram` for clearer naming in docs and examples. +- **Decompress mint as part of create mint**: `createMintInterface` and the create-mint instruction now decompress the mint in the same transaction. The mint is available on-chain (CMint account created) immediately after creation; a separate `decompressMint()` call is no longer required before creating ATAs or minting. `decompressMint()` remains supported and is idempotent: if the mint was already decompressed (e.g. via `createMintInterface`), it returns successfully without sending a transaction. + ## [0.22.0] - `CreateMint` action now allows passing a non-payer mint and freeze authority. diff --git a/js/compressed-token/docs/interface.md b/js/compressed-token/docs/interface.md new file mode 100644 index 0000000000..7da3ef330b --- /dev/null +++ b/js/compressed-token/docs/interface.md @@ -0,0 +1,231 @@ +# c-Token Interface Reference + +Concise reference for the v3 interface surface: reads (`getAtaInterface`), loads (`loadAta`, `createLoadAtaInstructions`), and transfers (`transferInterface`, `createTransferInterfaceInstructions`). + +## 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, creates recipient ATA | +| `createLightTokenTransferInstruction` | v3/instructions | Raw c-token transfer ix (no load/wrap) | + +Unified (`/unified`): `wrap=true` default, aggregates SPL/T22 into c-token ATA. 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 | `createLightTokenTransferInstruction` | dispatched by `programId` (Light or SPL transferChecked) | + +### 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` | `createLightTokenTransferInstruction` | +| `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, commit?, programId?, wrap?) + | + +- 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 -> getCTokenAccountInterface + | -> ctoken-hot + ctoken-cold + | + +- programId=SPL|T22 -> getSplOrToken2022AccountInterface + -> SPL/T22 hot (if exists) + compressed cold (as spl-cold/t22-cold) +``` + +### Load Path (\_buildLoadBatches) + +``` +_buildLoadBatches(senderInterface, wrap, targetAta) + | + +- Filter out frozen sources (SPL/T22/cold -- cannot wrap/decompress frozen) + +- spl/t22/cold unfrozen balance = 0 -> [] + | + +- wrap=true + | +- Create c-token ATA (idempotent, if needed) + | +- Wrap SPL (if unfrozen splBal>0) + | +- Wrap T22 (if unfrozen t22Bal>0) + | +- Chunk unfrozen cold by tree version (V1: {8,4,2,1}, V2: {8..1}) + | + +- wrap=false + | +- Create target ATA (ctoken/SPL/T22 per ataType, idempotent) + | +- Chunk unfrozen cold by tree version + | + +- For each chunk: fetch proof, build decompress ix + assertUniqueInputHashes(chunks) <- hash uniqueness enforced +``` + +### Transfer Flow (createTransferInterfaceInstructions) + +``` +createTransferInterfaceInstructions(rpc, payer, mint, amount, sender, recipient, options?) + | + +- amount <= 0 -> throw + +- derive ATAs using programId + +- getAtaInterface(sender, wrap, programId) + +- hot account frozen -> throw + +- unfrozen balance < amount -> throw (reports frozen balance separately) + | + +- _buildLoadBatches(...) -> internalBatches (frozen sources excluded) + | + +- programId = SPL|T22 && !wrap -> createTransferCheckedInstruction + +- else -> createLightTokenTransferInstruction + | + +- ensureRecipientAta (default: true) + | -> prepend idempotent recipient ATA creation ix (no RPC fetch) + | + +- Returns TransactionInstruction[][]: + +- batches.length = 0 (hot) -> [[CU, ?ataIx, transferIx]] + +- batches.length = 1 -> [[CU, ?ataIx, ...batch0, transferIx]] + +- batches.length > 1 + -> [[CU, load0], [CU, load1], ..., [CU, ?ataIx, ...lastBatch, transferIx]] + -> send [0..n-2] in parallel, then [n-1] after all confirm +``` + +### transferInterface (action) + +``` +transferInterface(rpc, payer, source, mint, destination, owner, amount, programId?, confirmOptions?, options?, wrap?) + | + +- Validate source == getAssociatedTokenAddressInterface(mint, owner, programId) + +- batches = createTransferInterfaceInstructions(..., ensureRecipientAta: true) + +- { rest: loads, last: transferIxs } = sliceLast(batches) + +- Send loads in parallel (if any) + +- Send transferIxs +``` + +## 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` | Skips frozen sources (cold/SPL/T22). Only decompresses unfrozen. | +| `createTransferInterfaceInstructions` | If hot account is frozen: throw. Otherwise: uses unfrozen balance only. Reports frozen amount in error if insufficient. | +| `transferInterface` | Same as above (delegates to `createTransferInterfaceInstructions`). | + +Why pre-filter instead of letting on-chain fail: our multi-batch architecture means a frozen account in batch 2 of 3 would fail on-chain while batches 1 and 3 succeed, creating a messy partial-load state. Pre-filtering avoids this. + +## 6. Delegate Handling + +Compressed `TokenData` has `delegate: Option` but no `delegated_amount` field. When a delegate exists, it can act on the full account amount. `convertTokenDataToAccount` sets `delegatedAmount: BigInt(0)` -- this is correct for the compressed token layout. + +`buildAccountInterfaceFromSources`: `_hasDelegate = sources.some(s => s.parsed.delegate !== null)`. The aggregated `parsed.delegate` comes from the primary source only (first by priority: ctoken-hot > ctoken-cold > SPL > T22). If a cold account has a delegate but the hot doesn't, `parsed.delegate` will be `null` while `_hasDelegate` is `true`. + +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`). + +## 7. Hash Uniqueness Guarantee + +Within a single call: compressed accounts fetched once globally, partitioned by tree version, each hash in exactly one batch. Enforced by `assertUniqueInputHashes`. + +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. + +## 8. Scenario Matrix (Unified, wrap=true) + +| Sender | Recipient | Status | +| ---------------- | ---------- | --------------------------------- | +| Hot only | ATA exists | Works | +| Hot only | No ATA | Works (transferInterface creates) | +| Cold <=8 | ATA exists | Works | +| Cold >8 | ATA exists | Works (parallel loads + transfer) | +| Cold | No ATA | Works (transferInterface creates) | +| 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 / insufficient | +| Partial frozen | Any | Works with unfrozen portion | +| amount=0 | Any | Throw: zero amount | +| Delegated cold | Any | Works | + +### Standard (wrap=false) with programId + +| 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 | + +## 9. Cases NOT Covered (Audit) + +### Test coverage gaps + +| 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 | + +### Design / out-of-scope + +| Case | Notes | +| -------------------------------------------------- | --------------------------------------------------- | +| Two independent calls, same sender (e.g. two tabs) | Requires app-level locking; SDK has no shared state | diff --git a/js/compressed-token/docs/payment-integration.md b/js/compressed-token/docs/payment-integration.md new file mode 100644 index 0000000000..681d79f17f --- /dev/null +++ b/js/compressed-token/docs/payment-integration.md @@ -0,0 +1,84 @@ +# Payment Integration: `createTransferInterfaceInstructions` + +Build transfer instructions for production payment flows. Returns +`TransactionInstruction[][]` with CU budgeting, recipient ATA creation +(idempotent, default), sender ATA creation, loading (decompression), and the +transfer instruction. + +## Import + +```typescript +// Standard (no SPL/T22 wrapping) +import { + createTransferInterfaceInstructions, + sliceLast, +} from '@lightprotocol/compressed-token'; + +// Unified (auto-wraps SPL/T22 to c-token ATA) +import { + createTransferInterfaceInstructions, + sliceLast, +} from '@lightprotocol/compressed-token/unified'; +``` + +## Usage + +```typescript +// 1. Build all instruction batches +const batches = await createTransferInterfaceInstructions( + rpc, + payer.publicKey, + mint, + amount, + sender.publicKey, + recipient.publicKey, +); + +// 2. Customize (optional) -- append memo, priority fee, etc. to the last batch +batches.at(-1)!.push(memoIx); + +// 3. Build all transactions +const { blockhash } = await rpc.getLatestBlockhash(); +const txns = batches.map(ixs => buildTx(ixs, blockhash, payer)); + +// 4. Sign all at once (one wallet prompt) +const signed = await wallet.signAllTransactions(txns); + +// 5. Send: loads in parallel, then transfer +const { rest, last } = sliceLast(signed); +await Promise.all(rest.map(tx => send(tx))); +await send(last); +``` + +## Return type + +`TransactionInstruction[][]` -- an array of transaction instruction arrays. + +- All batches except the last can be sent in parallel (load/decompress). +- The last batch is the transfer and must be sent after all others confirm. +- For a hot sender or <=8 cold inputs, the result is a single-element array. + +Use `sliceLast(batches)` to get `{ rest, last }` for clean send orchestration. + +## Options + +| Option | Default | Description | +| -------------------- | ------------------------ | -------------------------------------------------------- | +| `wrap` | `false` | Include SPL/T22 wrapping to c-token ATA (unified path) | +| `programId` | `LIGHT_TOKEN_PROGRAM_ID` | Token program ID (SPL/T22/Light) | +| `ensureRecipientAta` | `true` | Include idempotent recipient ATA creation (no extra RPC) | + +## What each transaction contains + +| Content | Load transaction | Transfer transaction | +| --------------------------- | :--------------: | :------------------: | +| `ComputeBudgetProgram` | yes | yes | +| Recipient ATA (idempotent) | -- | yes (by default) | +| Sender ATA creation | yes (idempotent) | yes (if needed) | +| Decompress instructions | yes | yes (if needed) | +| Wrap SPL/T22 (unified only) | first batch | -- | +| Transfer instruction | -- | yes | + +## Signers + +All transactions require the **payer** and the **sender** as signers. diff --git a/js/compressed-token/package.json b/js/compressed-token/package.json index 97d2df2e96..dc5bf8e646 100644 --- a/js/compressed-token/package.json +++ b/js/compressed-token/package.json @@ -1,6 +1,6 @@ { "name": "@lightprotocol/compressed-token", - "version": "0.23.0-beta.5", + "version": "0.23.0-beta.8", "description": "JS client to interact with the compressed-token program", "sideEffects": false, "main": "dist/cjs/node/index.cjs", diff --git a/js/compressed-token/src/actions/create-token-pool.ts b/js/compressed-token/src/actions/create-token-pool.ts index 03fd3cb0d9..3765935677 100644 --- a/js/compressed-token/src/actions/create-token-pool.ts +++ b/js/compressed-token/src/actions/create-token-pool.ts @@ -36,7 +36,7 @@ export async function createSplInterface( ? tokenProgramId : await CompressedTokenProgram.getMintProgramId(mint, rpc); - const ix = await CompressedTokenProgram.createTokenPool({ + const ix = await CompressedTokenProgram.createSplInterface({ feePayer: payer.publicKey, mint, tokenProgramId, diff --git a/js/compressed-token/src/index.ts b/js/compressed-token/src/index.ts index 48405d215b..ab4648087d 100644 --- a/js/compressed-token/src/index.ts +++ b/js/compressed-token/src/index.ts @@ -1,9 +1,9 @@ import type { Commitment, PublicKey, - TransactionInstruction, Signer, ConfirmOptions, + TransactionInstruction, TransactionSignature, } from '@solana/web3.js'; import type { Rpc } from '@lightprotocol/stateless.js'; @@ -19,6 +19,7 @@ export * from './constants'; export * from './idl'; export * from './layout'; export * from './program'; +export { CompressedTokenProgram as LightTokenProgram } from './program'; export * from './types'; import { createLoadAccountsParams, @@ -26,6 +27,7 @@ import { createLoadAtaInstructions as _createLoadAtaInstructions, loadAta as _loadAta, calculateCompressibleLoadComputeUnits, + selectInputsForAmount, CompressibleAccountInput, ParsedAccountInfoInterface, CompressibleLoadParams, @@ -37,6 +39,7 @@ export { createLoadAccountsParams, createLoadAtaInstructionsFromInterface, calculateCompressibleLoadComputeUnits, + selectInputsForAmount, CompressibleAccountInput, ParsedAccountInfoInterface, CompressibleLoadParams, @@ -44,6 +47,13 @@ export { LoadResult, }; +export { + estimateTransactionSize, + MAX_TRANSACTION_SIZE, + MAX_COMBINED_BATCH_BYTES, + MAX_LOAD_ONLY_BATCH_BYTES, +} from './v3/utils/estimate-tx-size'; + // Export mint module with explicit naming to avoid conflicts export { // Instructions @@ -63,9 +73,10 @@ export { createUpdateMetadataAuthorityInstruction, createRemoveMetadataKeyInstruction, createWrapInstruction, + createUnwrapInstruction, + createUnwrapInstructions, createDecompressInterfaceInstruction, - createTransferInterfaceInstruction, - createCTokenTransferInstruction, + createLightTokenTransferInstruction, // Types TokenMetadataInstructionData, CompressibleConfig, @@ -80,6 +91,8 @@ export { getAssociatedTokenAddressInterface, getOrCreateAtaInterface, transferInterface, + createTransferInterfaceInstructions, + sliceLast, decompressInterface, wrap, mintTo as mintToCToken, @@ -149,15 +162,16 @@ export async function getAtaInterface( } /** - * Create instructions to load token balances into a c-token ATA. + * Create instruction batches for loading token balances into an ATA. + * Returns batches of instructions, each batch is one transaction. * - * @param rpc RPC connection - * @param ata Associated token address - * @param owner Owner public key - * @param mint Mint public key - * @param payer Fee payer (defaults to owner) - * @param options Optional load options - * @returns Array of instructions (empty if nothing to load) + * @param rpc RPC connection + * @param ata Associated token address + * @param owner Owner public key + * @param mint Mint public key + * @param payer Fee payer (defaults to owner) + * @param options Optional load options + * @returns Instruction batches - each inner array is one transaction */ export async function createLoadAtaInstructions( rpc: Rpc, @@ -166,7 +180,7 @@ export async function createLoadAtaInstructions( mint: PublicKey, payer?: PublicKey, options?: InterfaceOptions, -): Promise { +): Promise { return _createLoadAtaInstructions( rpc, ata, diff --git a/js/compressed-token/src/program.ts b/js/compressed-token/src/program.ts index 468b49345b..e1eff825a4 100644 --- a/js/compressed-token/src/program.ts +++ b/js/compressed-token/src/program.ts @@ -786,9 +786,9 @@ export class CompressedTokenProgram { * @param mintSize Optional: mint size. Default: MINT_SIZE * * @returns [createMintAccountInstruction, initializeMintInstruction, - * createTokenPoolInstruction] + * createSplInterfaceInstruction] * - * Note that `createTokenPoolInstruction` must be executed after + * Note that `createSplInterfaceInstruction` must be executed after * `initializeMintInstruction`. */ static async createMint({ @@ -820,7 +820,7 @@ export class CompressedTokenProgram { tokenProgram, ); - const createTokenPoolInstruction = await this.createTokenPool({ + const createSplInterfaceInstruction = await this.createSplInterface({ feePayer, mint, tokenProgramId: tokenProgram, @@ -829,12 +829,12 @@ export class CompressedTokenProgram { return [ createMintAccountInstruction, initializeMintInstruction, - createTokenPoolInstruction, + createSplInterfaceInstruction, ]; } /** - * Enable compression for an existing SPL mint, creating an omnibus account. + * Create SPL interface (omnibus account) for an existing SPL mint. * For new mints, use `CompressedTokenProgram.createMint`. * * @param feePayer Fee payer. @@ -842,9 +842,9 @@ export class CompressedTokenProgram { * @param tokenProgramId Optional: Token program ID. Default: SPL * Token Program ID * - * @returns The createTokenPool instruction + * @returns The createSplInterface instruction */ - static async createTokenPool({ + static async createSplInterface({ feePayer, mint, tokenProgramId, @@ -869,9 +869,18 @@ export class CompressedTokenProgram { }); } + /** + * @deprecated Use {@link createSplInterface} instead. + */ + static async createTokenPool( + params: CreateSplInterfaceParams, + ): Promise { + return this.createSplInterface(params); + } + /** * Add a token pool to an existing SPL mint. For new mints, use - * {@link createTokenPool}. + * {@link createSplInterface}. * * @param feePayer Fee payer. * @param mint SPL Mint address. diff --git a/js/compressed-token/src/v3/actions/create-ata-interface.ts b/js/compressed-token/src/v3/actions/create-ata-interface.ts index 50618b7280..b55bae7d8e 100644 --- a/js/compressed-token/src/v3/actions/create-ata-interface.ts +++ b/js/compressed-token/src/v3/actions/create-ata-interface.ts @@ -8,7 +8,7 @@ import { } from '@solana/web3.js'; import { Rpc, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, buildAndSignTx, sendAndConfirmTx, assertBetaEnabled, @@ -35,7 +35,7 @@ export type { CTokenConfig }; * @param allowOwnerOffCurve Allow owner to be a PDA (default: false) * @param confirmOptions Options for confirming the transaction * @param programId Token program ID (default: - * CTOKEN_PROGRAM_ID) + * LIGHT_TOKEN_PROGRAM_ID) * @param associatedTokenProgramId ATA program ID (auto-derived if not * provided) * @param ctokenConfig Optional rent config @@ -48,7 +48,7 @@ export async function createAtaInterface( owner: PublicKey, allowOwnerOffCurve = false, confirmOptions?: ConfirmOptions, - programId: PublicKey = CTOKEN_PROGRAM_ID, + programId: PublicKey = LIGHT_TOKEN_PROGRAM_ID, associatedTokenProgramId?: PublicKey, ctokenConfig?: CTokenConfig, ): Promise { @@ -75,7 +75,7 @@ export async function createAtaInterface( ctokenConfig, ); - if (programId.equals(CTOKEN_PROGRAM_ID)) { + if (programId.equals(LIGHT_TOKEN_PROGRAM_ID)) { const { blockhash } = await rpc.getLatestBlockhash(); const tx = buildAndSignTx( [ComputeBudgetProgram.setComputeUnitLimit({ units: 30_000 }), ix], @@ -110,7 +110,7 @@ export async function createAtaInterface( * @param allowOwnerOffCurve Allow owner to be a PDA (default: false) * @param confirmOptions Options for confirming the transaction * @param programId Token program ID (default: - * CTOKEN_PROGRAM_ID) + * LIGHT_TOKEN_PROGRAM_ID) * @param associatedTokenProgramId ATA program ID (auto-derived if not * provided) * @param ctokenConfig Optional c-token-specific configuration @@ -124,7 +124,7 @@ export async function createAtaInterfaceIdempotent( owner: PublicKey, allowOwnerOffCurve = false, confirmOptions?: ConfirmOptions, - programId: PublicKey = CTOKEN_PROGRAM_ID, + programId: PublicKey = LIGHT_TOKEN_PROGRAM_ID, associatedTokenProgramId?: PublicKey, ctokenConfig?: CTokenConfig, ): Promise { @@ -151,7 +151,7 @@ export async function createAtaInterfaceIdempotent( ctokenConfig, ); - if (programId.equals(CTOKEN_PROGRAM_ID)) { + if (programId.equals(LIGHT_TOKEN_PROGRAM_ID)) { const { blockhash } = await rpc.getLatestBlockhash(); const tx = buildAndSignTx( [ComputeBudgetProgram.setComputeUnitLimit({ units: 30_000 }), ix], diff --git a/js/compressed-token/src/v3/actions/create-mint-interface.ts b/js/compressed-token/src/v3/actions/create-mint-interface.ts index 4b9d95b889..602de54695 100644 --- a/js/compressed-token/src/v3/actions/create-mint-interface.ts +++ b/js/compressed-token/src/v3/actions/create-mint-interface.ts @@ -16,7 +16,7 @@ import { selectStateTreeInfo, getBatchAddressTreeInfo, DerivationMode, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, getDefaultAddressTreeInfo, assertBetaEnabled, } from '@lightprotocol/stateless.js'; @@ -40,7 +40,7 @@ export { TokenMetadataInstructionData }; * @param decimals Location of the decimal place * @param keypair Mint keypair (defaults to a random keypair) * @param confirmOptions Confirm options - * @param programId Token program ID (defaults to CTOKEN_PROGRAM_ID) + * @param programId Token program ID (defaults to LIGHT_TOKEN_PROGRAM_ID) * @param tokenMetadata Optional token metadata (c-token mints only) * @param outputStateTreeInfo Optional output state tree info (c-token mints only) * @param addressTreeInfo Optional address tree info (c-token mints only) @@ -55,7 +55,7 @@ export async function createMintInterface( decimals: number, keypair: Keypair = Keypair.generate(), confirmOptions?: ConfirmOptions, - programId: PublicKey = CTOKEN_PROGRAM_ID, + programId: PublicKey = LIGHT_TOKEN_PROGRAM_ID, tokenMetadata?: TokenMetadataInstructionData, outputStateTreeInfo?: TreeInfo, addressTreeInfo?: AddressTreeInfo, diff --git a/js/compressed-token/src/v3/actions/decompress-interface.ts b/js/compressed-token/src/v3/actions/decompress-interface.ts index d28ee624c3..31b0a432c5 100644 --- a/js/compressed-token/src/v3/actions/decompress-interface.ts +++ b/js/compressed-token/src/v3/actions/decompress-interface.ts @@ -23,7 +23,7 @@ import BN from 'bn.js'; import { createDecompressInterfaceInstruction } from '../instructions/create-decompress-interface-instruction'; import { createAssociatedTokenAccountInterfaceIdempotentInstruction } from '../instructions/create-ata-interface'; import { getAssociatedTokenAddressInterface } from '../get-associated-token-address-interface'; -import { CTOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; +import { LIGHT_TOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; import { SplInterfaceInfo } from '../../utils/get-token-pool-infos'; /** @@ -150,7 +150,7 @@ export async function decompressInterface( destinationAtaAddress, ataOwner, mint, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ), ); } diff --git a/js/compressed-token/src/v3/actions/decompress-mint.ts b/js/compressed-token/src/v3/actions/decompress-mint.ts index a23f25afb9..6d223fa36d 100644 --- a/js/compressed-token/src/v3/actions/decompress-mint.ts +++ b/js/compressed-token/src/v3/actions/decompress-mint.ts @@ -11,7 +11,7 @@ import { sendAndConfirmTx, DerivationMode, bn, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, assertBetaEnabled, } from '@lightprotocol/stateless.js'; import { createDecompressMintInstruction } from '../instructions/decompress-mint'; @@ -60,16 +60,17 @@ export async function decompressMint( rpc, mint, confirmOptions?.commitment, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); if (!mintInterface.merkleContext) { throw new Error('Mint does not have MerkleContext'); } - // Check if already decompressed + // Already decompressed (e.g. createMintInterface now does it atomically). + // Return early instead of throwing so callers are idempotent. if (mintInterface.mintContext?.cmintDecompressed) { - throw new Error('Mint is already decompressed'); + return '' as TransactionSignature; } const validityProof = await rpc.getValidityProofV2( diff --git a/js/compressed-token/src/v3/actions/get-or-create-ata-interface.ts b/js/compressed-token/src/v3/actions/get-or-create-ata-interface.ts index 8c3f25479a..28a840ce16 100644 --- a/js/compressed-token/src/v3/actions/get-or-create-ata-interface.ts +++ b/js/compressed-token/src/v3/actions/get-or-create-ata-interface.ts @@ -1,6 +1,6 @@ import { Rpc, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, buildAndSignTx, sendAndConfirmTx, assertBetaEnabled, @@ -54,7 +54,7 @@ import { loadAta } from './load-ata'; * state. * @param confirmOptions Options for confirming the transaction * @param programId Token program ID (defaults to - * CTOKEN_PROGRAM_ID) + * LIGHT_TOKEN_PROGRAM_ID) * @param associatedTokenProgramId Associated token program ID (auto-derived if * not provided) * @@ -68,7 +68,7 @@ export async function getOrCreateAtaInterface( allowOwnerOffCurve = false, commitment?: Commitment, confirmOptions?: ConfirmOptions, - programId = CTOKEN_PROGRAM_ID, + programId = LIGHT_TOKEN_PROGRAM_ID, associatedTokenProgramId = getAtaProgramId(programId), ): Promise { assertBetaEnabled(); @@ -133,7 +133,7 @@ export async function _getOrCreateAtaInterface( // For c-token, use getAtaInterface which properly aggregates hot+cold balances // When wrap=true (unified path), also includes SPL/T22 balances - if (programId.equals(CTOKEN_PROGRAM_ID)) { + if (programId.equals(LIGHT_TOKEN_PROGRAM_ID)) { return getOrCreateCTokenAta( rpc, payer, @@ -200,7 +200,7 @@ async function getOrCreateCTokenAta( ownerPubkey, mint, commitment, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, wrap, allowOwnerOffCurve, ); @@ -232,7 +232,7 @@ async function getOrCreateCTokenAta( ownerPubkey, mint, commitment, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, wrap, allowOwnerOffCurve, ); @@ -305,7 +305,7 @@ async function getOrCreateCTokenAta( ownerPubkey, mint, commitment, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, wrap, allowOwnerOffCurve, ); @@ -338,7 +338,7 @@ async function createCTokenAtaIdempotent( associatedToken, owner, mint, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); const { blockhash } = await rpc.getLatestBlockhash(); diff --git a/js/compressed-token/src/v3/actions/load-ata.ts b/js/compressed-token/src/v3/actions/load-ata.ts index ff47c49cb4..f9d94fd60f 100644 --- a/js/compressed-token/src/v3/actions/load-ata.ts +++ b/js/compressed-token/src/v3/actions/load-ata.ts @@ -1,14 +1,14 @@ import { Rpc, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, buildAndSignTx, sendAndConfirmTx, dedupeSigner, ParsedTokenAccount, bn, assertBetaEnabled, + TreeType, } from '@lightprotocol/stateless.js'; -import { assertV2Only } from '../assert-v2-only'; import { PublicKey, TransactionInstruction, @@ -48,6 +48,13 @@ import { InterfaceOptions } from './transfer-interface'; */ export const MAX_INPUT_ACCOUNTS = 8; +/** All source types that represent compressed (cold) accounts. */ +const COLD_SOURCE_TYPES: ReadonlySet = new Set([ + TokenAccountSourceType.CTokenCold, + TokenAccountSourceType.SplCold, + TokenAccountSourceType.Token2022Cold, +]); + /** * Split an array into chunks of specified size */ @@ -59,6 +66,129 @@ function chunkArray(array: T[], chunkSize: number): T[][] { return chunks; } +/** + * Valid proof sizes per tree version (descending for greedy chunking). + * V1: {1, 2, 3, 4, 8} - matches prover keys for height-26 trees. + * V2: {1..8} - matches prover keys for height-32 trees. + */ +export const VALID_V1_PROOF_SIZES = [8, 4, 3, 2, 1] as const; +export const VALID_V2_PROOF_SIZES = [8, 7, 6, 5, 4, 3, 2, 1] as const; + +/** + * Select compressed inputs for a target amount. + * + * Sorts by amount descending (largest first), accumulates until the target + * is met, then pads to {@link MAX_INPUT_ACCOUNTS} if possible within a + * single batch. + * + * - If the amount is covered by N <= 8 inputs, returns min(8, total) inputs. + * - If more than 8 inputs are needed, returns exactly as many as required + * (no padding beyond the amount-needed count). + * - Returns [] when `neededAmount <= 0` or `accounts` is empty. + * + * @param accounts Cold compressed token accounts available for loading. + * @param neededAmount Amount that must be covered by selected inputs. + * @returns Subset of `accounts`, sorted largest-first. + */ +export function selectInputsForAmount( + accounts: ParsedTokenAccount[], + neededAmount: bigint, +): ParsedTokenAccount[] { + if (accounts.length === 0 || neededAmount <= BigInt(0)) return []; + + const sorted = [...accounts].sort((a, b) => { + const amtA = BigInt(a.parsed.amount.toString()); + const amtB = BigInt(b.parsed.amount.toString()); + if (amtB > amtA) return 1; + if (amtB < amtA) return -1; + return 0; + }); + + let accumulated = BigInt(0); + let countNeeded = 0; + for (const acc of sorted) { + countNeeded++; + accumulated += BigInt(acc.parsed.amount.toString()); + if (accumulated >= neededAmount) break; + } + + // Pad to MAX_INPUT_ACCOUNTS if within a single batch + const selectCount = Math.min( + Math.max(countNeeded, MAX_INPUT_ACCOUNTS), + sorted.length, + ); + + return sorted.slice(0, selectCount); +} + +/** + * Greedy-chunk an array into pieces whose sizes are in `validSizes`. + * `validSizes` must be sorted descending. + */ +function greedyChunk(array: T[], validSizes: readonly number[]): T[][] { + const chunks: T[][] = []; + let offset = 0; + let remaining = array.length; + for (const size of validSizes) { + while (remaining >= size) { + chunks.push(array.slice(offset, offset + size)); + offset += size; + remaining -= size; + } + } + return chunks; +} + +/** + * Chunk compressed accounts by tree version into prover-compatible groups. + * + * - Separates V1 (StateV1) and V2 (StateV2) inputs. + * - Chunks V1 accounts with sizes {1, 2, 4, 8}. + * - Chunks V2 accounts with sizes {1..8}. + * - A single proof request never mixes V1 and V2 inputs. + * + * V2 chunks are returned first, then V1 chunks. + */ +export function chunkAccountsByTreeVersion( + accounts: ParsedTokenAccount[], +): ParsedTokenAccount[][] { + const v1: ParsedTokenAccount[] = []; + const v2: ParsedTokenAccount[] = []; + + for (const acc of accounts) { + if (acc.compressedAccount.treeInfo.treeType === TreeType.StateV1) { + v1.push(acc); + } else { + v2.push(acc); + } + } + + return [ + ...greedyChunk(v2, VALID_V2_PROOF_SIZES), + ...greedyChunk(v1, VALID_V1_PROOF_SIZES), + ]; +} + +/** + * Verify no compressed account hash appears in more than one chunk. + * Prevents double-spending of inputs across parallel batches. + */ +function assertUniqueInputHashes(chunks: ParsedTokenAccount[][]): void { + const seen = new Set(); + for (const chunk of chunks) { + for (const acc of chunk) { + const hashStr = acc.compressedAccount.hash.toString(); + if (seen.has(hashStr)) { + throw new Error( + `Duplicate compressed account hash across chunks: ${hashStr}. ` + + `Each compressed account must appear in exactly one chunk.`, + ); + } + seen.add(hashStr); + } + } +} + /** * Create a single decompress instruction for compressed accounts. * Limited to MAX_INPUT_ACCOUNTS (8) accounts per call. @@ -85,12 +215,10 @@ async function createDecompressInstructionForAccounts( if (compressedAccounts.length > MAX_INPUT_ACCOUNTS) { throw new Error( `Too many compressed accounts: ${compressedAccounts.length} > ${MAX_INPUT_ACCOUNTS}. ` + - `Use createLoadAtaInstructionBatches for >8 accounts.`, + `Use createLoadAtaInstructions for >8 accounts.`, ); } - assertV2Only(compressedAccounts); - const amount = compressedAccounts.reduce( (sum, acc) => sum + BigInt(acc.parsed.amount.toString()), BigInt(0), @@ -145,12 +273,11 @@ async function createChunkedDecompressInstructions( return []; } - assertV2Only(compressedAccounts); - const instructions: TransactionInstruction[] = []; - // Split accounts into non-overlapping chunks of MAX_INPUT_ACCOUNTS - const chunks = chunkArray(compressedAccounts, MAX_INPUT_ACCOUNTS); + // Split accounts by tree version into non-overlapping, prover-compatible chunks + const chunks = chunkAccountsByTreeVersion(compressedAccounts); + assertUniqueInputHashes(chunks); // Get separate proofs for each chunk const proofs = await Promise.all( @@ -201,6 +328,7 @@ function getCompressedTokenAccountsFromAtaSources( return sources .filter(source => source.loadContext !== undefined) .filter(source => coldTypes.has(source.type)) + .filter(source => !source.parsed.isFrozen) .map(source => { const fullData = source.accountInfo.data; const discriminatorBytes = fullData.subarray( @@ -263,67 +391,6 @@ export { calculateCompressibleLoadComputeUnits, } from '../instructions/create-load-accounts-params'; -/** - * Create instructions to load token balances into an ATA. - * - * Behavior depends on `wrap` parameter: - * - wrap=false (standard): Decompress compressed tokens to the target ATA. - * ATA can be SPL (via pool), T22 (via pool), or c-token (direct). - * - wrap=true (unified): Wrap SPL/T22 + decompress all to c-token ATA. - * ATA must be a c-token ATA. - * - * @param rpc RPC connection - * @param ata Associated token address (SPL, T22, or c-token) - * @param owner Owner public key - * @param mint Mint public key - * @param payer Fee payer (defaults to owner) - * @param options Optional load options - * @param wrap Unified mode: wrap SPL/T22 to c-token (default: false) - * @returns Array of instructions (empty if nothing to load) - */ -export async function createLoadAtaInstructions( - rpc: Rpc, - ata: PublicKey, - owner: PublicKey, - mint: PublicKey, - payer?: PublicKey, - options?: InterfaceOptions, - wrap = false, -): Promise { - assertBetaEnabled(); - - payer ??= owner; - - // Validation happens inside getAtaInterface via checkAtaAddress helper: - // - Always validates ata matches mint+owner derivation - // - For wrap=true, additionally requires c-token ATA - try { - const ataInterface = await _getAtaInterface( - rpc, - ata, - owner, - mint, - undefined, - undefined, - wrap, - ); - return createLoadAtaInstructionsFromInterface( - rpc, - payer, - ataInterface, - options, - wrap, - ata, - ); - } catch (error) { - // If account doesn't exist, there's nothing to load - if (error instanceof TokenAccountNotFoundError) { - return []; - } - throw error; - } -} - // Re-export AtaType for backwards compatibility export { AtaType } from '../ata-utils'; @@ -362,12 +429,9 @@ export async function createLoadAtaInstructionsFromInterface( const mint = ata._mint; const sources = ata._sources ?? []; - // v3 interface only supports V2 trees - check cold sources early + // Precompute compressed accounts from cold sources const compressedAccountsToCheck = getCompressedTokenAccountsFromAtaSources(sources); - if (compressedAccountsToCheck.length > 0) { - assertV2Only(compressedAccountsToCheck); - } // Derive addresses const ctokenAtaAddress = getAssociatedTokenAddressInterface(mint, owner); @@ -402,22 +466,24 @@ export async function createLoadAtaInstructionsFromInterface( } } - // Check sources for balances - // Note: There can be multiple cold sources (one per compressed account) - const splSource = sources.find(s => s.type === 'spl'); - const t22Source = sources.find(s => s.type === 'token2022'); + // Check sources for balances (skip frozen -- cannot wrap/decompress frozen accounts) + const splSource = sources.find(s => s.type === 'spl' && !s.parsed.isFrozen); + const t22Source = sources.find( + s => s.type === 'token2022' && !s.parsed.isFrozen, + ); const ctokenHotSource = sources.find(s => s.type === 'ctoken-hot'); - const ctokenColdSources = sources.filter(s => s.type === 'ctoken-cold'); + const coldSources = sources.filter( + s => COLD_SOURCE_TYPES.has(s.type) && !s.parsed.isFrozen, + ); const splBalance = splSource?.amount ?? BigInt(0); const t22Balance = t22Source?.amount ?? BigInt(0); - // Sum ALL cold balances, not just the first - const coldBalance = ctokenColdSources.reduce( + const coldBalance = coldSources.reduce( (sum, s) => sum + s.amount, BigInt(0), ); - // Nothing to load + // Nothing to load (all balances are zero or frozen) if ( splBalance === BigInt(0) && t22Balance === BigInt(0) && @@ -469,7 +535,7 @@ export async function createLoadAtaInstructionsFromInterface( ctokenAtaAddress, owner, mint, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ), ); } @@ -509,7 +575,7 @@ export async function createLoadAtaInstructionsFromInterface( // 4. Decompress compressed tokens to c-token ATA // Note: v3 interface only supports V2 trees // Handles >8 accounts via chunking into multiple instructions - if (coldBalance > BigInt(0) && ctokenColdSources.length > 0) { + if (coldBalance > BigInt(0) && coldSources.length > 0) { const compressedAccounts = getCompressedTokenAccountsFromAtaSources(sources); @@ -529,7 +595,7 @@ export async function createLoadAtaInstructionsFromInterface( // STANDARD MODE: Decompress to target ATA type // Handles >8 accounts via chunking into multiple instructions - if (coldBalance > BigInt(0) && ctokenColdSources.length > 0) { + if (coldBalance > BigInt(0) && coldSources.length > 0) { const compressedAccounts = getCompressedTokenAccountsFromAtaSources(sources); @@ -543,7 +609,7 @@ export async function createLoadAtaInstructionsFromInterface( ctokenAtaAddress, owner, mint, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ), ); } @@ -613,16 +679,6 @@ export async function createLoadAtaInstructionsFromInterface( return instructions; } -/** - * Result type for createLoadAtaInstructionBatches - */ -export interface LoadAtaInstructionBatches { - /** Array of instruction batches - each batch is one transaction */ - batches: TransactionInstruction[][]; - /** Total number of compressed accounts being processed */ - totalCompressedAccounts: number; -} - /** * Create instruction batches for loading token balances into an ATA. * Handles >8 compressed accounts by returning multiple transaction batches. @@ -638,9 +694,9 @@ export interface LoadAtaInstructionBatches { * @param payer Fee payer public key (defaults to owner) * @param interfaceOptions Optional interface options * @param wrap Unified mode: wrap SPL/T22 to c-token (default: false) - * @returns Instruction batches and metadata + * @returns Instruction batches - each inner array is one transaction */ -export async function createLoadAtaInstructionBatches( +export async function createLoadAtaInstructions( rpc: Rpc, ata: PublicKey, owner: PublicKey, @@ -648,12 +704,133 @@ export async function createLoadAtaInstructionBatches( payer?: PublicKey, interfaceOptions?: InterfaceOptions, wrap = false, -): Promise { +): Promise { assertBetaEnabled(); payer ??= owner; - // Determine target ATA type - const { type: ataType } = checkAtaAddress(ata, mint, owner); + // Fetch account state (pass wrap so c-token ATA is validated before RPC) + let accountInterface: AccountInterface; + try { + accountInterface = await _getAtaInterface( + rpc, + ata, + owner, + mint, + undefined, + undefined, + wrap, + ); + } catch (e) { + if (e instanceof TokenAccountNotFoundError) { + return []; + } + throw e; + } + + // Delegate to _buildLoadBatches which handles wrapping, decompression, + // ATA creation, and parallel-safe batching. + const internalBatches = await _buildLoadBatches( + rpc, + payer, + accountInterface, + interfaceOptions, + wrap, + ata, + ); + + // Map InternalLoadBatch[] -> TransactionInstruction[][] + return internalBatches.map(batch => batch.instructions); +} + +/** + * Internal batch structure for loadAta parallel sending. + * @internal Exported for use by createTransferInterfaceInstructions. + */ +export interface InternalLoadBatch { + instructions: TransactionInstruction[]; + compressedAccounts: ParsedTokenAccount[]; + wrapCount: number; + hasAtaCreation: boolean; +} + +/** + * Calculate compute units for a load batch with 30% buffer. + * + * Heuristics: + * - ATA creation: ~30k CU + * - Wrap operation: ~50k CU each + * - Decompress base cost (CPI overhead, hash computation): ~50k CU + * - Full proof verification (when any input is NOT proveByIndex): ~100k CU + * - Per compressed account: ~10k (proveByIndex) or ~30k (full proof) CU + */ +/** @internal Exported for use by createTransferInterfaceInstructions. */ +export function calculateLoadBatchComputeUnits( + batch: InternalLoadBatch, +): number { + let cu = 0; + + if (batch.hasAtaCreation) { + cu += 30_000; + } + + cu += batch.wrapCount * 50_000; + + if (batch.compressedAccounts.length > 0) { + // Base cost for Transfer2 CPI chain (cToken -> system -> account-compression) + cu += 50_000; + + const needsFullProof = batch.compressedAccounts.some( + acc => !(acc.compressedAccount.proveByIndex ?? false), + ); + if (needsFullProof) { + cu += 100_000; + } + for (const acc of batch.compressedAccounts) { + const proveByIndex = acc.compressedAccount.proveByIndex ?? false; + cu += proveByIndex ? 10_000 : 30_000; + } + } + + // 30% buffer + cu = Math.ceil(cu * 1.3); + + return Math.max(50_000, Math.min(1_400_000, cu)); +} + +/** + * Build load instruction batches for parallel sending. + * + * Returns one or more batches: + * - Batch 0: setup (ATA creation, wraps) + first decompress chunk + * - Batch 1..N: idempotent ATA creation + decompress chunk 1..N + * + * Each batch is independent and can be sent in parallel. Idempotent ATA + * creation is included in every batch so they can land in any order. + * + * @internal + */ +/** @internal Exported for use by createTransferInterfaceInstructions. */ +export async function _buildLoadBatches( + rpc: Rpc, + payer: PublicKey, + ata: AccountInterface, + options: InterfaceOptions | undefined, + wrap: boolean, + targetAta: PublicKey, + targetAmount?: bigint, +): Promise { + if (!ata._isAta || !ata._owner || !ata._mint) { + throw new Error( + 'AccountInterface must be from getAtaInterface (requires _isAta, _owner, _mint)', + ); + } + + const owner = ata._owner; + const mint = ata._mint; + const sources = ata._sources ?? []; + + const allCompressedAccounts = + getCompressedTokenAccountsFromAtaSources(sources); // Derive addresses const ctokenAtaAddress = getAssociatedTokenAddressInterface(mint, owner); @@ -672,93 +849,155 @@ export async function createLoadAtaInstructionBatches( getAtaProgramId(TOKEN_2022_PROGRAM_ID), ); - // Fetch account state and sources - const accountInterface = await _getAtaInterface(rpc, ata, owner, mint); - const sources = accountInterface._sources ?? []; + // Validate target ATA type + let ataType: AtaType = 'ctoken'; + const validation = checkAtaAddress(targetAta, mint, owner); + ataType = validation.type; + if (wrap && ataType !== 'ctoken') { + throw new Error( + `For wrap=true, targetAta must be c-token ATA. Got ${ataType} ATA.`, + ); + } - // Get cold sources - const ctokenColdSources = sources.filter( - s => s.type === TokenAccountSourceType.CTokenCold, + // Check sources for balances (skip frozen for wrappable/decompressible sources) + const splSource = sources.find(s => s.type === 'spl' && !s.parsed.isFrozen); + const t22Source = sources.find( + s => s.type === 'token2022' && !s.parsed.isFrozen, + ); + const ctokenHotSource = sources.find(s => s.type === 'ctoken-hot'); + const coldSources = sources.filter( + s => COLD_SOURCE_TYPES.has(s.type) && !s.parsed.isFrozen, ); - const coldBalance = ctokenColdSources.reduce( + const splBalance = splSource?.amount ?? BigInt(0); + const t22Balance = t22Source?.amount ?? BigInt(0); + const coldBalance = coldSources.reduce( (sum, s) => sum + s.amount, BigInt(0), ); - // If no cold balance, return empty - if (coldBalance === BigInt(0) || ctokenColdSources.length === 0) { - return { batches: [], totalCompressedAccounts: 0 }; + if ( + splBalance === BigInt(0) && + t22Balance === BigInt(0) && + coldBalance === BigInt(0) + ) { + return []; } - // Get decimals - const mintInfo = await getMint(rpc, mint).catch(() => null); - const decimals = mintInfo?.decimals ?? 9; - - // Get all compressed accounts - const compressedAccounts = - getCompressedTokenAccountsFromAtaSources(sources); - const totalCompressedAccounts = compressedAccounts.length; - - // Determine target ATA and SPL interface info - let targetAta: PublicKey; + // Get SPL interface info if needed let splInterfaceInfo: SplInterfaceInfo | undefined; - - if (wrap) { - targetAta = ctokenAtaAddress; - splInterfaceInfo = undefined; - } else if (ataType === 'ctoken') { - targetAta = ctokenAtaAddress; - splInterfaceInfo = undefined; - } else { - // For SPL/T22, we need the interface info - const splInterfaceInfos = await getSplInterfaceInfos(rpc, mint); - if (ataType === 'spl') { - targetAta = splAta; - splInterfaceInfo = splInterfaceInfos.find(info => - info.tokenProgram.equals(TOKEN_PROGRAM_ID), - ); - } else { - targetAta = t22Ata; - splInterfaceInfo = splInterfaceInfos.find(info => - info.tokenProgram.equals(TOKEN_2022_PROGRAM_ID), + const needsSplInfo = + wrap || + ataType === 'spl' || + ataType === 'token2022' || + splBalance > BigInt(0) || + t22Balance > BigInt(0); + let decimals = 0; + if (needsSplInfo) { + try { + const splInterfaceInfos = + options?.splInterfaceInfos ?? + (await getSplInterfaceInfos(rpc, mint)); + splInterfaceInfo = splInterfaceInfos.find( + (info: SplInterfaceInfo) => info.isInitialized, ); + if (splInterfaceInfo) { + const mintInfo = await getMint( + rpc, + mint, + undefined, + splInterfaceInfo.tokenProgram, + ); + decimals = mintInfo.decimals; + } + } catch { + // No SPL interface. } } - // Split into chunks - const chunks = chunkArray(compressedAccounts, MAX_INPUT_ACCOUNTS); - const batches: TransactionInstruction[][] = []; + // Build setup instructions (ATA creation + wraps) + const setupInstructions: TransactionInstruction[] = []; + let wrapCount = 0; + let needsAtaCreation = false; - // Check if we need to create the ATA - const ctokenHotSource = sources.find( - s => s.type === TokenAccountSourceType.CTokenHot, - ); - const splSource = sources.find(s => s.type === TokenAccountSourceType.Spl); - const t22Source = sources.find( - s => s.type === TokenAccountSourceType.Token2022, - ); + // Determine decompress target based on mode + let decompressTarget: PublicKey = ctokenAtaAddress; + let decompressSplInfo: SplInterfaceInfo | undefined; + let canDecompress = false; - for (let i = 0; i < chunks.length; i++) { - const chunk = chunks[i]; - const batchInstructions: TransactionInstruction[] = []; + if (wrap) { + decompressTarget = ctokenAtaAddress; + decompressSplInfo = undefined; + canDecompress = true; - // First batch includes ATA creation if needed - if (i === 0) { - if (wrap || ataType === 'ctoken') { - if (!ctokenHotSource) { - batchInstructions.push( - createAssociatedTokenAccountInterfaceIdempotentInstruction( - payer, - ctokenAtaAddress, - owner, - mint, - CTOKEN_PROGRAM_ID, - ), - ); - } - } else if (ataType === 'spl' && !splSource) { - batchInstructions.push( + if (!ctokenHotSource) { + needsAtaCreation = true; + setupInstructions.push( + createAssociatedTokenAccountInterfaceIdempotentInstruction( + payer, + ctokenAtaAddress, + owner, + mint, + LIGHT_TOKEN_PROGRAM_ID, + ), + ); + } + + if (splBalance > BigInt(0) && splInterfaceInfo) { + setupInstructions.push( + createWrapInstruction( + splAta, + ctokenAtaAddress, + owner, + mint, + splBalance, + splInterfaceInfo, + decimals, + payer, + ), + ); + wrapCount++; + } + + if (t22Balance > BigInt(0) && splInterfaceInfo) { + setupInstructions.push( + createWrapInstruction( + t22Ata, + ctokenAtaAddress, + owner, + mint, + t22Balance, + splInterfaceInfo, + decimals, + payer, + ), + ); + wrapCount++; + } + } else { + if (ataType === 'ctoken') { + decompressTarget = ctokenAtaAddress; + decompressSplInfo = undefined; + canDecompress = true; + if (!ctokenHotSource) { + needsAtaCreation = true; + setupInstructions.push( + createAssociatedTokenAccountInterfaceIdempotentInstruction( + payer, + ctokenAtaAddress, + owner, + mint, + LIGHT_TOKEN_PROGRAM_ID, + ), + ); + } + } else if (ataType === 'spl' && splInterfaceInfo) { + decompressTarget = splAta; + decompressSplInfo = splInterfaceInfo; + canDecompress = true; + if (!splSource) { + needsAtaCreation = true; + setupInstructions.push( createAssociatedTokenAccountIdempotentInstruction( payer, splAta, @@ -767,8 +1006,14 @@ export async function createLoadAtaInstructionBatches( TOKEN_PROGRAM_ID, ), ); - } else if (ataType === 'token2022' && !t22Source) { - batchInstructions.push( + } + } else if (ataType === 'token2022' && splInterfaceInfo) { + decompressTarget = t22Ata; + decompressSplInfo = splInterfaceInfo; + canDecompress = true; + if (!t22Source) { + needsAtaCreation = true; + setupInstructions.push( createAssociatedTokenAccountIdempotentInstruction( payer, t22Ata, @@ -779,22 +1024,153 @@ export async function createLoadAtaInstructionBatches( ); } } + } - // Add decompress instruction for this chunk - const decompressIx = await createDecompressInstructionForAccounts( - rpc, - payer, - chunk, - targetAta, - splInterfaceInfo, - decimals, + // Amount-aware input selection: when targetAmount is provided, only + // load the cold inputs needed to cover the transfer/unwrap amount. + // When targetAmount is undefined (e.g. loadAta), load everything. + let accountsToLoad = allCompressedAccounts; + + if ( + targetAmount !== undefined && + canDecompress && + allCompressedAccounts.length > 0 + ) { + const hotBalance = ctokenHotSource?.amount ?? BigInt(0); + let effectiveHotAfterSetup: bigint; + + if (wrap) { + effectiveHotAfterSetup = hotBalance + splBalance + t22Balance; + } else if (ataType === 'ctoken') { + effectiveHotAfterSetup = hotBalance; + } else if (ataType === 'spl') { + effectiveHotAfterSetup = splBalance; + } else { + // token2022 + effectiveHotAfterSetup = t22Balance; + } + + const neededFromCold = + targetAmount > effectiveHotAfterSetup + ? targetAmount - effectiveHotAfterSetup + : BigInt(0); + + if (neededFromCold === BigInt(0)) { + accountsToLoad = []; + } else { + accountsToLoad = selectInputsForAmount( + allCompressedAccounts, + neededFromCold, + ); + } + } + + // If no cold accounts to decompress, return just the setup batch + if (!canDecompress || accountsToLoad.length === 0) { + if (setupInstructions.length === 0) return []; + return [ + { + instructions: setupInstructions, + compressedAccounts: [], + wrapCount, + hasAtaCreation: needsAtaCreation, + }, + ]; + } + + // Chunk by tree version into prover-compatible sizes and verify uniqueness + const chunks = chunkAccountsByTreeVersion(accountsToLoad); + assertUniqueInputHashes(chunks); + + // Get proofs for all chunks in parallel + const proofs = await Promise.all( + chunks.map(async chunk => { + const proofInputs = chunk.map(acc => ({ + hash: acc.compressedAccount.hash, + tree: acc.compressedAccount.treeInfo.tree, + queue: acc.compressedAccount.treeInfo.queue, + })); + return rpc.getValidityProofV0(proofInputs); + }), + ); + + // Build idempotent ATA creation instruction for subsequent batches + const idempotentAtaIx = (() => { + if (wrap || ataType === 'ctoken') { + return createAssociatedTokenAccountInterfaceIdempotentInstruction( + payer, + ctokenAtaAddress, + owner, + mint, + LIGHT_TOKEN_PROGRAM_ID, + ); + } else if (ataType === 'spl') { + return createAssociatedTokenAccountIdempotentInstruction( + payer, + splAta, + owner, + mint, + TOKEN_PROGRAM_ID, + ); + } else { + return createAssociatedTokenAccountIdempotentInstruction( + payer, + t22Ata, + owner, + mint, + TOKEN_2022_PROGRAM_ID, + ); + } + })(); + + // Build batches + const batches: InternalLoadBatch[] = []; + + for (let i = 0; i < chunks.length; i++) { + const chunk = chunks[i]; + const proof = proofs[i]; + const chunkAmount = chunk.reduce( + (sum, acc) => sum + BigInt(acc.parsed.amount.toString()), + BigInt(0), ); - batchInstructions.push(decompressIx); - batches.push(batchInstructions); + const batchIxs: TransactionInstruction[] = []; + let batchWrapCount = 0; + let batchHasAtaCreation = false; + + if (i === 0) { + // First batch includes all setup (ATA creation + wraps) + batchIxs.push(...setupInstructions); + batchWrapCount = wrapCount; + batchHasAtaCreation = needsAtaCreation; + } else { + // Subsequent batches: include idempotent ATA creation so + // batches can land in any order + batchIxs.push(idempotentAtaIx); + batchHasAtaCreation = true; + } + + batchIxs.push( + createDecompressInterfaceInstruction( + payer, + chunk, + decompressTarget, + chunkAmount, + proof, + decompressSplInfo, + decimals, + ), + ); + + batches.push({ + instructions: batchIxs, + compressedAccounts: chunk, + wrapCount: batchWrapCount, + hasAtaCreation: batchHasAtaCreation, + }); } - return { batches, totalCompressedAccounts }; + return batches; } /** @@ -805,7 +1181,11 @@ export async function createLoadAtaInstructionBatches( * ATA can be SPL (via pool), T22 (via pool), or c-token (direct). * - wrap=true (unified): Wrap SPL/T22 + decompress all to c-token ATA. * - * Handles >8 compressed accounts by sending multiple transactions sequentially. + * Handles any number of compressed accounts by building per-chunk batches + * (max 8 inputs per decompress instruction) and sending all batches in + * parallel. Each batch includes idempotent ATA creation so landing order + * does not matter. + * * Idempotent: returns null if nothing to load. * * @param rpc RPC connection @@ -832,41 +1212,61 @@ export async function loadAta( payer ??= owner; - const ixs = await createLoadAtaInstructions( + // Get account interface + let ataInterface: AccountInterface; + try { + ataInterface = await _getAtaInterface( + rpc, + ata, + owner.publicKey, + mint, + undefined, + undefined, + wrap, + ); + } catch (error) { + if (error instanceof TokenAccountNotFoundError) { + return null; + } + throw error; + } + + // Build batched instructions + const batches = await _buildLoadBatches( rpc, - ata, - owner.publicKey, - mint, payer.publicKey, + ataInterface, interfaceOptions, wrap, + ata, ); - if (ixs.length === 0) { + if (batches.length === 0) { return null; } - const { blockhash } = await rpc.getLatestBlockhash(); const additionalSigners = dedupeSigner(payer, [owner]); - // Scale CU based on number of decompress instructions - const decompressIxCount = ixs.filter( - ix => ix.programId.equals(CTOKEN_PROGRAM_ID) && ix.data.length > 50, - ).length; - const computeUnits = Math.min( - 1_400_000, - 500_000 + decompressIxCount * 100_000, - ); + // Send all batches in parallel + const txPromises = batches.map(async batch => { + const { blockhash } = await rpc.getLatestBlockhash(); + const computeUnits = calculateLoadBatchComputeUnits(batch); + + const tx = buildAndSignTx( + [ + ComputeBudgetProgram.setComputeUnitLimit({ + units: computeUnits, + }), + ...batch.instructions, + ], + payer!, + blockhash, + additionalSigners, + ); - const tx = buildAndSignTx( - [ - ComputeBudgetProgram.setComputeUnitLimit({ units: computeUnits }), - ...ixs, - ], - payer, - blockhash, - additionalSigners, - ); + return sendAndConfirmTx(rpc, tx, confirmOptions); + }); - return sendAndConfirmTx(rpc, tx, confirmOptions); + const results = await Promise.all(txPromises); + return results[results.length - 1]; } diff --git a/js/compressed-token/src/v3/actions/mint-to-compressed.ts b/js/compressed-token/src/v3/actions/mint-to-compressed.ts index e31a44464f..aaadd78bd4 100644 --- a/js/compressed-token/src/v3/actions/mint-to-compressed.ts +++ b/js/compressed-token/src/v3/actions/mint-to-compressed.ts @@ -11,7 +11,7 @@ import { sendAndConfirmTx, DerivationMode, bn, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, selectStateTreeInfo, TreeInfo, assertBetaEnabled, @@ -47,7 +47,7 @@ export async function mintToCompressed( rpc, mint, confirmOptions?.commitment, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); if (!mintInfo.merkleContext) { diff --git a/js/compressed-token/src/v3/actions/transfer-interface.ts b/js/compressed-token/src/v3/actions/transfer-interface.ts index 68d3485bb6..22204c890d 100644 --- a/js/compressed-token/src/v3/actions/transfer-interface.ts +++ b/js/compressed-token/src/v3/actions/transfer-interface.ts @@ -10,33 +10,37 @@ import { Rpc, buildAndSignTx, sendAndConfirmTx, - CTOKEN_PROGRAM_ID, dedupeSigner, - ParsedTokenAccount, assertBetaEnabled, + LIGHT_TOKEN_PROGRAM_ID, } from '@lightprotocol/stateless.js'; -import { assertV2Only } from '../assert-v2-only'; import { + TokenAccountNotFoundError, TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, - getAssociatedTokenAddressSync, + createTransferCheckedInstruction, getMint, } from '@solana/spl-token'; import BN from 'bn.js'; -import { getAtaProgramId } from '../ata-utils'; -import { - createTransferInterfaceInstruction, - createCTokenTransferInstruction, -} from '../instructions/transfer-interface'; +import { createLightTokenTransferInstruction } from '../instructions/transfer-interface'; import { createAssociatedTokenAccountInterfaceIdempotentInstruction } from '../instructions/create-ata-interface'; import { getAssociatedTokenAddressInterface } from '../get-associated-token-address-interface'; +import { type SplInterfaceInfo } from '../../utils/get-token-pool-infos'; +import { + _buildLoadBatches, + calculateLoadBatchComputeUnits, + type InternalLoadBatch, +} from './load-ata'; +import { + getAtaInterface as _getAtaInterface, + type AccountInterface, + TokenAccountSourceType, +} from '../get-account-interface'; +import { DEFAULT_COMPRESSIBLE_CONFIG } from '../instructions/create-associated-ctoken'; import { - getSplInterfaceInfos, - SplInterfaceInfo, -} from '../../utils/get-token-pool-infos'; -import { createWrapInstruction } from '../instructions/wrap'; -import { createDecompressInterfaceInstruction } from '../instructions/create-decompress-interface-instruction'; -import { loadAta } from './load-ata'; + estimateTransactionSize, + MAX_TRANSACTION_SIZE, +} from '../utils/estimate-tx-size'; /** * Options for interface operations (load, transfer) @@ -46,54 +50,26 @@ export interface InterfaceOptions { splInterfaceInfos?: SplInterfaceInfo[]; } -/** - * Calculate compute units needed for the operation - */ -function calculateComputeUnits( - compressedAccounts: ParsedTokenAccount[], - hasValidityProof: boolean, - splWrapCount: number, -): number { - // Base CU for hot c-token transfer - let cu = 5_000; - - // Compressed token decompression - if (compressedAccounts.length > 0) { - if (hasValidityProof) { - cu += 100_000; // Validity proof verification - } - // Per compressed account - for (const acc of compressedAccounts) { - const proveByIndex = acc.compressedAccount.proveByIndex ?? false; - cu += proveByIndex ? 10_000 : 30_000; - } - } - - // SPL/T22 wrap operations - cu += splWrapCount * 5_000; - - // TODO: dynamic - // return cu; - return 200_000; -} - /** * Transfer tokens using the c-token interface. * - * Matches SPL Token's transferChecked signature order. Destination must exist. + * High-level action: resolves balances, builds all instructions (load + + * transfer), signs, and sends. Creates the recipient ATA if it does not exist. + * + * For instruction-level control, use `createTransferInterfaceInstructions`. * * @param rpc RPC connection * @param payer Fee payer (signer) * @param source Source c-token ATA address * @param mint Mint address - * @param destination Destination c-token ATA address (must exist) + * @param destination Recipient wallet public key * @param owner Source owner (signer) * @param amount Amount to transfer - * @param programId Token program ID (default: CTOKEN_PROGRAM_ID) + * @param programId Token program ID (default: LIGHT_TOKEN_PROGRAM_ID) * @param confirmOptions Optional confirm options * @param options Optional interface options * @param wrap Include SPL/T22 wrapping (default: false) - * @returns Transaction signature + * @returns Transaction signature of the transfer transaction */ export async function transferInterface( rpc: Rpc, @@ -103,61 +79,19 @@ export async function transferInterface( destination: PublicKey, owner: Signer, amount: number | bigint | BN, - programId: PublicKey = CTOKEN_PROGRAM_ID, + programId: PublicKey = LIGHT_TOKEN_PROGRAM_ID, confirmOptions?: ConfirmOptions, options?: InterfaceOptions, wrap = false, ): Promise { assertBetaEnabled(); - const amountBigInt = BigInt(amount.toString()); - const { splInterfaceInfos: providedSplInterfaceInfos } = options ?? {}; - - const instructions: TransactionInstruction[] = []; - - // For non-c-token programs, use simple SPL transfer (no load) - if (!programId.equals(CTOKEN_PROGRAM_ID)) { - const expectedSource = getAssociatedTokenAddressSync( - mint, - owner.publicKey, - false, - programId, - getAtaProgramId(programId), - ); - if (!source.equals(expectedSource)) { - throw new Error( - `Source mismatch. Expected ${expectedSource.toBase58()}, got ${source.toBase58()}`, - ); - } - - instructions.push( - createTransferInterfaceInstruction( - source, - destination, - owner.publicKey, - amountBigInt, - [], - programId, - ), - ); - - const { blockhash } = await rpc.getLatestBlockhash(); - const tx = buildAndSignTx( - [ - ComputeBudgetProgram.setComputeUnitLimit({ units: 10_000 }), - ...instructions, - ], - payer, - blockhash, - [owner], - ); - return sendAndConfirmTx(rpc, tx, confirmOptions); - } - - // c-token transfer + // Validate source matches owner const expectedSource = getAssociatedTokenAddressInterface( mint, owner.publicKey, + false, + programId, ); if (!source.equals(expectedSource)) { throw new Error( @@ -165,244 +99,382 @@ export async function transferInterface( ); } - const ctokenAtaAddress = getAssociatedTokenAddressInterface( + const amountBigInt = BigInt(amount.toString()); + + // Build all instruction batches. ensureRecipientAta: true (default) + // includes idempotent ATA creation in the transfer tx -- no extra RPC + // fetch needed. + const batches = await createTransferInterfaceInstructions( + rpc, + payer.publicKey, mint, + amountBigInt, owner.publicKey, + destination, + { ...options, wrap, programId, ensureRecipientAta: true }, ); - // Derive SPL/T22 ATAs only if wrap is true - let splAta: PublicKey | undefined; - let t22Ata: PublicKey | undefined; - - if (wrap) { - splAta = getAssociatedTokenAddressSync( - mint, - owner.publicKey, - false, - TOKEN_PROGRAM_ID, - getAtaProgramId(TOKEN_PROGRAM_ID), - ); - t22Ata = getAssociatedTokenAddressSync( - mint, - owner.publicKey, - false, - TOKEN_2022_PROGRAM_ID, - getAtaProgramId(TOKEN_2022_PROGRAM_ID), + const additionalSigners = dedupeSigner(payer, [owner]); + const { rest: loads, last: transferIxs } = sliceLast(batches); + + // Send load transactions in parallel (if any) + if (loads.length > 0) { + await Promise.all( + loads.map(async ixs => { + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx( + ixs, + payer, + blockhash, + additionalSigners, + ); + return sendAndConfirmTx(rpc, tx, confirmOptions); + }), ); } - // Fetch sender's accounts in parallel (conditionally include SPL/T22) - const fetchPromises: Promise[] = [ - rpc.getAccountInfo(ctokenAtaAddress), - rpc.getCompressedTokenAccountsByOwner(owner.publicKey, { mint }), - ]; - if (wrap && splAta && t22Ata) { - fetchPromises.push(rpc.getAccountInfo(splAta)); - fetchPromises.push(rpc.getAccountInfo(t22Ata)); + // Send transfer transaction + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx(transferIxs, payer, blockhash, additionalSigners); + + return sendAndConfirmTx(rpc, tx, confirmOptions); +} + +/** + * Options for createTransferInterfaceInstructions. + */ +export interface TransferOptions extends InterfaceOptions { + /** Include SPL/T22 wrapping to c-token ATA (unified path). Default: false. */ + wrap?: boolean; + /** Token program ID. Default: LIGHT_TOKEN_PROGRAM_ID. */ + programId?: PublicKey; + /** + * Include an idempotent recipient ATA creation instruction in the + * transfer transaction. No extra RPC fetch -- uses + * createAssociatedTokenAccountInterfaceIdempotentInstruction which is + * a no-op on-chain if the ATA already exists (~200 CU overhead). + * Default: true. + */ + ensureRecipientAta?: boolean; +} + +/** + * Splits the last element from an array. + * + * Useful for separating load transactions (parallel) from the final transfer + * transaction (sequential) returned by `createTransferInterfaceInstructions`. + * + * @returns `{ rest, last }` where `rest` is everything before the last + * element and `last` is the last element. + * @throws if the input array is empty. + */ +export function sliceLast(items: T[]): { rest: T[]; last: T } { + if (items.length === 0) { + throw new Error('sliceLast: array must not be empty'); } + return { rest: items.slice(0, -1), last: items.at(-1)! }; +} - const results = await Promise.all(fetchPromises); - const ctokenAtaInfo = results[0] as Awaited< - ReturnType - >; - const compressedResult = results[1] as Awaited< - ReturnType - >; - const splAtaInfo = wrap - ? (results[2] as Awaited>) - : null; - const t22AtaInfo = wrap - ? (results[3] as Awaited>) - : null; - - const compressedAccounts = compressedResult.items; - - // Parse balances - const hotBalance = - ctokenAtaInfo && ctokenAtaInfo.data.length >= 72 - ? ctokenAtaInfo.data.readBigUInt64LE(64) - : BigInt(0); - const splBalance = - wrap && splAtaInfo && splAtaInfo.data.length >= 72 - ? splAtaInfo.data.readBigUInt64LE(64) - : BigInt(0); - const t22Balance = - wrap && t22AtaInfo && t22AtaInfo.data.length >= 72 - ? t22AtaInfo.data.readBigUInt64LE(64) - : BigInt(0); - const compressedBalance = compressedAccounts.reduce( - (sum, acc) => sum + BigInt(acc.parsed.amount.toString()), - BigInt(0), - ); +/** + * Compute units for the transfer transaction (load chunk + transfer). + */ +function calculateTransferCU(loadBatch: InternalLoadBatch | null): number { + let cu = 10_000; // c-token transfer base + + if (loadBatch) { + if (loadBatch.hasAtaCreation) cu += 30_000; + cu += loadBatch.wrapCount * 50_000; + + if (loadBatch.compressedAccounts.length > 0) { + // Base cost for Transfer2 CPI chain + cu += 50_000; + const needsFullProof = loadBatch.compressedAccounts.some( + acc => !(acc.compressedAccount.proveByIndex ?? false), + ); + if (needsFullProof) cu += 100_000; + for (const acc of loadBatch.compressedAccounts) { + cu += + (acc.compressedAccount.proveByIndex ?? false) + ? 10_000 + : 30_000; + } + } + } - const totalBalance = - hotBalance + splBalance + t22Balance + compressedBalance; + cu = Math.ceil(cu * 1.3); + return Math.max(50_000, Math.min(1_400_000, cu)); +} - if (totalBalance < amountBigInt) { +/** + * Assert that a batch of instructions fits within the max transaction size. + * Throws if the estimated size exceeds MAX_TRANSACTION_SIZE. + */ +function assertTxSize( + instructions: TransactionInstruction[], + numSigners: number, +): void { + const size = estimateTransactionSize(instructions, numSigners); + if (size > MAX_TRANSACTION_SIZE) { throw new Error( - `Insufficient balance. Required: ${amountBigInt}, Available: ${totalBalance}`, + `Batch exceeds max transaction size: ${size} > ${MAX_TRANSACTION_SIZE}. ` + + `This indicates a bug in batch assembly.`, ); } +} + +/** + * Create instructions for a c-token transfer. + * + * Returns `TransactionInstruction[][]` -- an array of transaction instruction + * arrays. Each inner array is one transaction to sign and send. + * + * - All transactions except the last can be sent in parallel (load/decompress). + * - The last transaction is the transfer and must be sent after all others + * confirm. + * - For a hot sender or <=8 cold inputs, the result is a single-element array. + * + * Use `sliceLast` to separate the parallel prefix from the final transfer: + * ``` + * const batches = await createTransferInterfaceInstructions(...); + * const { rest, last } = sliceLast(batches); + * ``` + * + * When `ensureRecipientAta` is true (the default), an idempotent ATA creation + * instruction is included in the transfer (last) transaction. No extra RPC + * fetch -- the instruction is a no-op on-chain if the ATA already exists. + * Set `ensureRecipientAta: false` if you manage recipient ATAs yourself. + * + * All transactions require payer + sender as signers. + * + * Hash uniqueness guarantee: all compressed accounts for the sender are + * fetched once, then partitioned into non-overlapping chunks by tree version. + * Each hash appears in exactly one batch. This is enforced at runtime by + * `assertUniqueInputHashes` inside `_buildLoadBatches`. + * + * @param rpc RPC connection + * @param payer Fee payer public key + * @param mint Mint address + * @param amount Amount to transfer + * @param sender Sender public key (must sign all transactions) + * @param recipient Recipient public key + * @param options Optional configuration + * @returns TransactionInstruction[][] -- send [0..n-2] in parallel, then [n-1] + */ +export async function createTransferInterfaceInstructions( + rpc: Rpc, + payer: PublicKey, + mint: PublicKey, + amount: number | bigint | BN, + sender: PublicKey, + recipient: PublicKey, + options?: TransferOptions, +): Promise { + assertBetaEnabled(); + + const amountBigInt = BigInt(amount.toString()); + + if (amountBigInt <= BigInt(0)) { + throw new Error('Transfer amount must be greater than zero.'); + } - // Track what we're doing for CU calculation - let splWrapCount = 0; - let hasValidityProof = false; - let compressedToLoad: ParsedTokenAccount[] = []; + const { + wrap = false, + programId = LIGHT_TOKEN_PROGRAM_ID, + ensureRecipientAta = true, + ...interfaceOptions + } = options ?? {}; - // Create sender's c-token ATA if needed (idempotent) - if (!ctokenAtaInfo) { - instructions.push( - createAssociatedTokenAccountInterfaceIdempotentInstruction( - payer.publicKey, - ctokenAtaAddress, - owner.publicKey, - mint, - CTOKEN_PROGRAM_ID, - ), + // Validate recipient is a wallet (on-curve), not an ATA or PDA. + // Passing an ATA here would derive an ATA-of-ATA and lose funds. + if (!PublicKey.isOnCurve(recipient.toBytes())) { + throw new Error( + `Recipient must be a wallet public key (on-curve), not a PDA or ATA. ` + + `Got: ${recipient.toBase58()}`, ); } - // Get SPL interface infos if we need to load - const needsLoad = - splBalance > BigInt(0) || - t22Balance > BigInt(0) || - compressedBalance > BigInt(0); - const splInterfaceInfos = needsLoad - ? (providedSplInterfaceInfos ?? (await getSplInterfaceInfos(rpc, mint))) - : []; - const splInterfaceInfo = splInterfaceInfos.find(info => info.isInitialized); - - // Fetch mint decimals if we need to wrap - let decimals = 0; - if ( - splInterfaceInfo && - (splBalance > BigInt(0) || t22Balance > BigInt(0)) - ) { - const mintInfo = await getMint( + const isSplOrT22 = + programId.equals(TOKEN_PROGRAM_ID) || + programId.equals(TOKEN_2022_PROGRAM_ID); + + // Derive ATAs + const senderAta = getAssociatedTokenAddressInterface( + mint, + sender, + false, + programId, + ); + const recipientAta = getAssociatedTokenAddressInterface( + mint, + recipient, + false, + programId, + ); + + // Get sender's account state + let senderInterface: AccountInterface; + try { + senderInterface = await _getAtaInterface( rpc, + senderAta, + sender, mint, undefined, - splInterfaceInfo.tokenProgram, + programId.equals(LIGHT_TOKEN_PROGRAM_ID) ? undefined : programId, + wrap, ); - decimals = mintInfo.decimals; + } catch (error) { + if (error instanceof TokenAccountNotFoundError) { + throw new Error('Sender has no token accounts for this mint.'); + } + throw error; } - // Wrap SPL tokens if balance exists (only when wrap=true) - if (wrap && splAta && splBalance > BigInt(0) && splInterfaceInfo) { - instructions.push( - createWrapInstruction( - splAta, - ctokenAtaAddress, - owner.publicKey, - mint, - splBalance, - splInterfaceInfo, - decimals, - payer.publicKey, - ), - ); - splWrapCount++; + // Frozen handling: match SPL semantics. Frozen accounts cannot be + // decompressed or wrapped, but unfrozen accounts can still be used. + // If the hot account itself is frozen, the on-chain transfer program + // will reject, so we fail early. + const senderSources = senderInterface._sources ?? []; + const hotSourceType = + isSplOrT22 && !wrap + ? programId.equals(TOKEN_PROGRAM_ID) + ? TokenAccountSourceType.Spl + : TokenAccountSourceType.Token2022 + : TokenAccountSourceType.CTokenHot; + const hotSource = senderSources.find(s => s.type === hotSourceType); + if (hotSource?.parsed.isFrozen) { + throw new Error('Cannot transfer: sender token account is frozen.'); } - // Wrap T22 tokens if balance exists (only when wrap=true) - if (wrap && t22Ata && t22Balance > BigInt(0) && splInterfaceInfo) { - instructions.push( - createWrapInstruction( - t22Ata, - ctokenAtaAddress, - owner.publicKey, - mint, - t22Balance, - splInterfaceInfo, - decimals, - payer.publicKey, - ), + // Calculate unfrozen balance (frozen accounts are excluded from load batches) + const unfrozenBalance = senderSources + .filter(s => !s.parsed.isFrozen) + .reduce((sum, s) => sum + s.amount, BigInt(0)); + + if (unfrozenBalance < amountBigInt) { + const frozenBalance = senderInterface.parsed.amount - unfrozenBalance; + const frozenNote = + frozenBalance > BigInt(0) + ? ` (${frozenBalance} frozen, not usable)` + : ''; + throw new Error( + `Insufficient balance. Required: ${amountBigInt}, ` + + `Available (unfrozen): ${unfrozenBalance}${frozenNote}`, ); - splWrapCount++; } - // Decompress compressed tokens if they exist - // Note: v3 interface only supports V2 trees - // For >8 compressed accounts, use loadAta to handle multi-tx batching - const MAX_INPUT_ACCOUNTS = 8; - - if (compressedBalance > BigInt(0) && compressedAccounts.length > 0) { - assertV2Only(compressedAccounts); + // Build load batches for sender (empty if sender is fully hot). + // Pass amountBigInt so only needed cold inputs are selected. + const internalBatches = await _buildLoadBatches( + rpc, + payer, + senderInterface, + interfaceOptions, + wrap, + senderAta, + amountBigInt, + ); - compressedToLoad = compressedAccounts; + // Transfer instruction: dispatch based on program + let transferIx: TransactionInstruction; + if (isSplOrT22 && !wrap) { + const mintInfo = await getMint(rpc, mint, undefined, programId); + transferIx = createTransferCheckedInstruction( + senderAta, + mint, + recipientAta, + sender, + amountBigInt, + mintInfo.decimals, + [], + programId, + ); + } else { + transferIx = createLightTokenTransferInstruction( + senderAta, + recipientAta, + sender, + amountBigInt, + ); + } - if (compressedAccounts.length > MAX_INPUT_ACCOUNTS) { - // >8 accounts: use loadAta which handles multi-tx internally - // This sends separate transactions for each batch of 8 accounts - await loadAta( - rpc, - ctokenAtaAddress, - owner, - mint, + // Create Recipient ATA idempotently. Optional. + const recipientAtaIxs: TransactionInstruction[] = []; + if (ensureRecipientAta) { + recipientAtaIxs.push( + createAssociatedTokenAccountInterfaceIdempotentInstruction( payer, - confirmOptions, - ); - // After loadAta, all compressed accounts are now in the hot ATA - // Don't add decompress instructions - they've already been executed - compressedToLoad = []; // Clear to skip CU calculation for decompression - } else { - // <=8 accounts: include decompress instruction in this transaction - const proof = await rpc.getValidityProofV0( - compressedAccounts.map(acc => ({ - hash: acc.compressedAccount.hash, - tree: acc.compressedAccount.treeInfo.tree, - queue: acc.compressedAccount.treeInfo.queue, - })), - ); - - if (proof.compressedProof !== null) { - hasValidityProof = true; - } - - instructions.push( - createDecompressInterfaceInstruction( - payer.publicKey, - compressedAccounts, - ctokenAtaAddress, - compressedBalance, - proof, - undefined, - decimals, - ), - ); - } + recipientAta, + recipient, + mint, + programId, + undefined, // associatedTokenProgramId (auto-derived) + programId.equals(LIGHT_TOKEN_PROGRAM_ID) + ? { compressibleConfig: DEFAULT_COMPRESSIBLE_CONFIG } + : undefined, + ), + ); } - // Transfer (destination must already exist - like SPL Token) - instructions.push( - createCTokenTransferInstruction( - source, - destination, - owner.publicKey, - amountBigInt, - ), - ); + // Number of signers for size estimation (payer + sender; may be same key) + const numSigners = payer.equals(sender) ? 1 : 2; + + // Assemble result: TransactionInstruction[][] + // Last element is always the transfer tx. Preceding elements are + // load txs that can be sent in parallel. + // Load txs include budgeting and ATA creation too. + if (internalBatches.length === 0) { + // Sender is hot: single transfer tx + const cu = calculateTransferCU(null); + const txIxs = [ + ComputeBudgetProgram.setComputeUnitLimit({ units: cu }), + ...recipientAtaIxs, + transferIx, + ]; + assertTxSize(txIxs, numSigners); + return [txIxs]; + } - // Calculate compute units - const computeUnits = calculateComputeUnits( - compressedToLoad, - hasValidityProof, - splWrapCount, - ); + if (internalBatches.length === 1) { + // Single load batch: combine with transfer in one tx + const batch = internalBatches[0]; + const cu = calculateTransferCU(batch); + const txIxs = [ + ComputeBudgetProgram.setComputeUnitLimit({ units: cu }), + ...recipientAtaIxs, + ...batch.instructions, + transferIx, + ]; + assertTxSize(txIxs, numSigners); + return [txIxs]; + } - // Build and send - const { blockhash } = await rpc.getLatestBlockhash(); - const additionalSigners = dedupeSigner(payer, [owner]); + // Multiple load batches (>8 compressed inputs): + // [0..n-2]: load-only (send in parallel) + // [n-1]: last load chunk + transfer (send after others confirm) + const result: TransactionInstruction[][] = []; + + for (let i = 0; i < internalBatches.length - 1; i++) { + const batch = internalBatches[i]; + const cu = calculateLoadBatchComputeUnits(batch); + const txIxs = [ + ComputeBudgetProgram.setComputeUnitLimit({ units: cu }), + ...batch.instructions, + ]; + assertTxSize(txIxs, numSigners); + result.push(txIxs); + } - const tx = buildAndSignTx( - [ - ComputeBudgetProgram.setComputeUnitLimit({ units: computeUnits }), - ...instructions, - ], - payer, - blockhash, - additionalSigners, - ); + const lastBatch = internalBatches[internalBatches.length - 1]; + const lastCu = calculateTransferCU(lastBatch); + const lastTxIxs = [ + ComputeBudgetProgram.setComputeUnitLimit({ units: lastCu }), + ...recipientAtaIxs, + ...lastBatch.instructions, + transferIx, + ]; + assertTxSize(lastTxIxs, numSigners); + result.push(lastTxIxs); - return sendAndConfirmTx(rpc, tx, confirmOptions); + return result; } diff --git a/js/compressed-token/src/v3/actions/unwrap.ts b/js/compressed-token/src/v3/actions/unwrap.ts index f0bf16ed87..a58106698e 100644 --- a/js/compressed-token/src/v3/actions/unwrap.ts +++ b/js/compressed-token/src/v3/actions/unwrap.ts @@ -3,6 +3,7 @@ import { ConfirmOptions, PublicKey, Signer, + TransactionInstruction, TransactionSignature, } from '@solana/web3.js'; import { @@ -12,7 +13,7 @@ import { dedupeSigner, assertBetaEnabled, } from '@lightprotocol/stateless.js'; -import { getMint } from '@solana/spl-token'; +import { getMint, TokenAccountNotFoundError } from '@solana/spl-token'; import BN from 'bn.js'; import { createUnwrapInstruction } from '../instructions/unwrap'; import { @@ -20,35 +21,56 @@ import { SplInterfaceInfo, } from '../../utils/get-token-pool-infos'; import { getAssociatedTokenAddressInterface } from '../get-associated-token-address-interface'; -import { loadAta as _loadAta } from './load-ata'; +import { + getAtaInterface as _getAtaInterface, + type AccountInterface, +} from '../get-account-interface'; +import { _buildLoadBatches, calculateLoadBatchComputeUnits } from './load-ata'; +import { InterfaceOptions } from './transfer-interface'; +import { + estimateTransactionSize, + MAX_TRANSACTION_SIZE, +} from '../utils/estimate-tx-size'; /** - * Unwrap c-tokens to SPL tokens. + * Build instruction batches for unwrapping c-tokens to SPL/T22 tokens. * - * @param rpc RPC connection - * @param payer Fee payer - * @param destination Destination SPL/T22 token account - * @param owner Owner of the c-token (signer) - * @param mint Mint address - * @param amount Amount to unwrap (defaults to all) - * @param splInterfaceInfo SPL interface info - * @param confirmOptions Confirm options + * Returns `TransactionInstruction[][]` with the same shape as + * `createLoadAtaInstructions` and `createTransferInterfaceInstructions`: + * each inner array is one transaction. Load batches (if any) come first, + * followed by one final unwrap transaction. + * + * Uses amount-aware input selection: only loads the cold inputs needed to + * cover the unwrap amount (plus padding to fill a single proof batch). * - * @returns Transaction signature + * @param rpc RPC connection + * @param destination Destination SPL/T22 token account (must exist) + * @param owner Owner of the c-token + * @param mint Mint address + * @param amount Amount to unwrap (defaults to full balance) + * @param payer Fee payer (defaults to owner) + * @param splInterfaceInfo Optional: SPL interface info + * @param interfaceOptions Optional: interface options for load + * @param wrap Whether to use unified (wrap) mode for loading. + * Default false. + * @returns Instruction batches - each inner array is one transaction */ -export async function unwrap( +export async function createUnwrapInstructions( rpc: Rpc, - payer: Signer, destination: PublicKey, - owner: Signer, + owner: PublicKey, mint: PublicKey, amount?: number | bigint | BN, + payer?: PublicKey, splInterfaceInfo?: SplInterfaceInfo, - confirmOptions?: ConfirmOptions, -): Promise { + interfaceOptions?: InterfaceOptions, + wrap = false, +): Promise { assertBetaEnabled(); - // 1. Get SPL interface info if not provided + payer ??= owner; + + // 1. Resolve SPL interface info let resolvedSplInterfaceInfo = splInterfaceInfo; if (!resolvedSplInterfaceInfo) { const splInterfaceInfos = await getSplInterfaceInfos(rpc, mint); @@ -64,6 +86,7 @@ export async function unwrap( } } + // 2. Check destination exists const destAtaInfo = await rpc.getAccountInfo(destination); if (!destAtaInfo) { throw new Error( @@ -72,33 +95,54 @@ export async function unwrap( ); } - // Load all tokens to c-token hot ATA - const ctokenAta = getAssociatedTokenAddressInterface(mint, owner.publicKey); - await _loadAta(rpc, ctokenAta, owner, mint, payer, confirmOptions); - - // Check c-token hot balance - const ctokenAccountInfo = await rpc.getAccountInfo(ctokenAta); - if (!ctokenAccountInfo) { - throw new Error('No c-token ATA found after loading'); + // 3. Derive c-token ATA and get account interface + const ctokenAta = getAssociatedTokenAddressInterface(mint, owner); + + let accountInterface: AccountInterface; + try { + accountInterface = await _getAtaInterface( + rpc, + ctokenAta, + owner, + mint, + undefined, + undefined, + wrap, + ); + } catch (error) { + if (error instanceof TokenAccountNotFoundError) { + throw new Error('No c-token balance to unwrap'); + } + throw error; } - // Parse c-token account balance - const data = ctokenAccountInfo.data; - const ctokenBalance = data.readBigUInt64LE(64); - - if (ctokenBalance === BigInt(0)) { + const totalBalance = accountInterface.parsed.amount; + if (totalBalance === BigInt(0)) { throw new Error('No c-token balance to unwrap'); } - const unwrapAmount = amount ? BigInt(amount.toString()) : ctokenBalance; + const unwrapAmount = amount ? BigInt(amount.toString()) : totalBalance; - if (unwrapAmount > ctokenBalance) { + if (unwrapAmount > totalBalance) { throw new Error( - `Insufficient c-token balance. Requested: ${unwrapAmount}, Available: ${ctokenBalance}`, + `Insufficient c-token balance. Requested: ${unwrapAmount}, Available: ${totalBalance}`, ); } - // Get mint info to get decimals + // 4. Build load batches with amount-aware selection. + // When amount is specified, pass it as targetAmount for selective loading. + // When amount is undefined (unwrap all), pass undefined to load everything. + const internalBatches = await _buildLoadBatches( + rpc, + payer, + accountInterface, + interfaceOptions, + wrap, + ctokenAta, + amount !== undefined ? unwrapAmount : undefined, + ); + + // 5. Get mint decimals const mintInfo = await getMint( rpc, mint, @@ -106,29 +150,104 @@ export async function unwrap( resolvedSplInterfaceInfo.tokenProgram, ); - // Build unwrap instruction + // 6. Build unwrap instruction const ix = createUnwrapInstruction( ctokenAta, destination, - owner.publicKey, + owner, mint, unwrapAmount, resolvedSplInterfaceInfo, mintInfo.decimals, - payer.publicKey, + payer, ); - const { blockhash } = await rpc.getLatestBlockhash(); - const additionalSigners = dedupeSigner(payer, [owner]); + const unwrapBatch: TransactionInstruction[] = [ + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 }), + ix, + ]; + + // 7. Assemble: load batches with CU budgets + unwrap batch + const numSigners = payer.equals(owner) ? 1 : 2; + const result: TransactionInstruction[][] = []; + + for (const batch of internalBatches) { + const cu = calculateLoadBatchComputeUnits(batch); + const txIxs = [ + ComputeBudgetProgram.setComputeUnitLimit({ units: cu }), + ...batch.instructions, + ]; + assertUnwrapTxSize(txIxs, numSigners); + result.push(txIxs); + } - const tx = buildAndSignTx( - [ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 }), ix], - payer, - blockhash, - additionalSigners, + assertUnwrapTxSize(unwrapBatch, numSigners); + result.push(unwrapBatch); + + return result; +} + +/** + * Assert that a batch of instructions fits within the max transaction size. + */ +function assertUnwrapTxSize( + instructions: TransactionInstruction[], + numSigners: number, +): void { + const size = estimateTransactionSize(instructions, numSigners); + if (size > MAX_TRANSACTION_SIZE) { + throw new Error( + `Unwrap batch exceeds max transaction size: ${size} > ${MAX_TRANSACTION_SIZE}. ` + + `This indicates a bug in batch assembly.`, + ); + } +} + +/** + * Unwrap c-tokens to SPL tokens. + * + * Loads cold state to the c-token ATA, then unwraps to the destination + * SPL/T22 token account. Uses `createUnwrapInstructions` internally. + * + * @param rpc RPC connection + * @param payer Fee payer + * @param destination Destination SPL/T22 token account + * @param owner Owner of the c-token (signer) + * @param mint Mint address + * @param amount Amount to unwrap (defaults to all) + * @param splInterfaceInfo SPL interface info + * @param confirmOptions Confirm options + * + * @returns Transaction signature of the unwrap transaction + */ +export async function unwrap( + rpc: Rpc, + payer: Signer, + destination: PublicKey, + owner: Signer, + mint: PublicKey, + amount?: number | bigint | BN, + splInterfaceInfo?: SplInterfaceInfo, + confirmOptions?: ConfirmOptions, +): Promise { + const batches = await createUnwrapInstructions( + rpc, + destination, + owner.publicKey, + mint, + amount, + payer.publicKey, + splInterfaceInfo, ); - const txId = await sendAndConfirmTx(rpc, tx, confirmOptions); + let txId: TransactionSignature = ''; + + for (const ixs of batches) { + const { blockhash } = await rpc.getLatestBlockhash(); + const additionalSigners = dedupeSigner(payer, [owner]); + const tx = buildAndSignTx(ixs, payer, blockhash, additionalSigners); + txId = await sendAndConfirmTx(rpc, tx, confirmOptions); + } return txId; } diff --git a/js/compressed-token/src/v3/actions/update-metadata.ts b/js/compressed-token/src/v3/actions/update-metadata.ts index 8c03686e76..0f4254eb52 100644 --- a/js/compressed-token/src/v3/actions/update-metadata.ts +++ b/js/compressed-token/src/v3/actions/update-metadata.ts @@ -11,7 +11,7 @@ import { sendAndConfirmTx, DerivationMode, bn, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, assertBetaEnabled, } from '@lightprotocol/stateless.js'; import { @@ -52,7 +52,7 @@ export async function updateMetadataField( rpc, mint, confirmOptions?.commitment, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); if (!mintInterface.tokenMetadata || !mintInterface.merkleContext) { @@ -130,7 +130,7 @@ export async function updateMetadataAuthority( rpc, mint, confirmOptions?.commitment, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); if (!mintInterface.tokenMetadata || !mintInterface.merkleContext) { @@ -208,7 +208,7 @@ export async function removeMetadataKey( rpc, mint, confirmOptions?.commitment, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); if (!mintInterface.tokenMetadata || !mintInterface.merkleContext) { diff --git a/js/compressed-token/src/v3/actions/update-mint.ts b/js/compressed-token/src/v3/actions/update-mint.ts index 075702a316..03b3f07f34 100644 --- a/js/compressed-token/src/v3/actions/update-mint.ts +++ b/js/compressed-token/src/v3/actions/update-mint.ts @@ -11,7 +11,7 @@ import { sendAndConfirmTx, DerivationMode, bn, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, assertBetaEnabled, } from '@lightprotocol/stateless.js'; import { @@ -45,7 +45,7 @@ export async function updateMintAuthority( rpc, mint, confirmOptions?.commitment, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); if (!mintInterface.merkleContext) { @@ -120,7 +120,7 @@ export async function updateFreezeAuthority( rpc, mint, confirmOptions?.commitment, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); if (!mintInterface.merkleContext) { diff --git a/js/compressed-token/src/v3/ata-utils.ts b/js/compressed-token/src/v3/ata-utils.ts index 1ffb3e758a..cb73fda281 100644 --- a/js/compressed-token/src/v3/ata-utils.ts +++ b/js/compressed-token/src/v3/ata-utils.ts @@ -4,7 +4,7 @@ import { TOKEN_2022_PROGRAM_ID, getAssociatedTokenAddressSync, } from '@solana/spl-token'; -import { CTOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; +import { LIGHT_TOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; import { PublicKey } from '@solana/web3.js'; /** @@ -13,8 +13,8 @@ import { PublicKey } from '@solana/web3.js'; * @returns ATA program ID */ export function getAtaProgramId(tokenProgramId: PublicKey): PublicKey { - if (tokenProgramId.equals(CTOKEN_PROGRAM_ID)) { - return CTOKEN_PROGRAM_ID; + if (tokenProgramId.equals(LIGHT_TOKEN_PROGRAM_ID)) { + return LIGHT_TOKEN_PROGRAM_ID; } return ASSOCIATED_TOKEN_PROGRAM_ID; } @@ -79,14 +79,14 @@ export function checkAtaAddress( mint, owner, allowOwnerOffCurve, - CTOKEN_PROGRAM_ID, - getAtaProgramId(CTOKEN_PROGRAM_ID), + LIGHT_TOKEN_PROGRAM_ID, + getAtaProgramId(LIGHT_TOKEN_PROGRAM_ID), ); if (ata.equals(ctokenExpected)) { return { valid: true, type: 'ctoken', - programId: CTOKEN_PROGRAM_ID, + programId: LIGHT_TOKEN_PROGRAM_ID, }; } @@ -132,7 +132,7 @@ export function checkAtaAddress( * Convert programId to AtaType */ function programIdToAtaType(programId: PublicKey): AtaType { - if (programId.equals(CTOKEN_PROGRAM_ID)) return 'ctoken'; + if (programId.equals(LIGHT_TOKEN_PROGRAM_ID)) return 'ctoken'; if (programId.equals(TOKEN_PROGRAM_ID)) return 'spl'; if (programId.equals(TOKEN_2022_PROGRAM_ID)) return 'token2022'; throw new Error(`Unknown program ID: ${programId.toBase58()}`); diff --git a/js/compressed-token/src/v3/derivation.ts b/js/compressed-token/src/v3/derivation.ts index 5010a537f6..bf4b276306 100644 --- a/js/compressed-token/src/v3/derivation.ts +++ b/js/compressed-token/src/v3/derivation.ts @@ -1,5 +1,5 @@ import { - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, deriveAddressV2, TreeInfo, } from '@lightprotocol/stateless.js'; @@ -18,7 +18,7 @@ export function deriveCMintAddress( const address = deriveAddressV2( findMintAddress(mintSeed)[0].toBytes(), addressTreeInfo.tree, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); return Array.from(address.toBytes()); } @@ -36,7 +36,7 @@ export const COMPRESSED_MINT_SEED: Buffer = Buffer.from([ export function findMintAddress(mintSigner: PublicKey): [PublicKey, number] { const [address, bump] = PublicKey.findProgramAddressSync( [COMPRESSED_MINT_SEED, mintSigner.toBuffer()], - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); return [address, bump]; } @@ -48,15 +48,15 @@ export function getAssociatedCTokenAddressAndBump( mint: PublicKey, ) { return PublicKey.findProgramAddressSync( - [owner.toBuffer(), CTOKEN_PROGRAM_ID.toBuffer(), mint.toBuffer()], - CTOKEN_PROGRAM_ID, + [owner.toBuffer(), LIGHT_TOKEN_PROGRAM_ID.toBuffer(), mint.toBuffer()], + LIGHT_TOKEN_PROGRAM_ID, ); } /// Same as "getAssociatedTokenAddress" but with c-token program ID. export function getAssociatedCTokenAddress(owner: PublicKey, mint: PublicKey) { return PublicKey.findProgramAddressSync( - [owner.toBuffer(), CTOKEN_PROGRAM_ID.toBuffer(), mint.toBuffer()], - CTOKEN_PROGRAM_ID, + [owner.toBuffer(), LIGHT_TOKEN_PROGRAM_ID.toBuffer(), mint.toBuffer()], + LIGHT_TOKEN_PROGRAM_ID, )[0]; } diff --git a/js/compressed-token/src/v3/get-account-interface.ts b/js/compressed-token/src/v3/get-account-interface.ts index 9cb5b4d5d1..8f2d53d4e2 100644 --- a/js/compressed-token/src/v3/get-account-interface.ts +++ b/js/compressed-token/src/v3/get-account-interface.ts @@ -10,7 +10,7 @@ import { } from '@solana/spl-token'; import { Rpc, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, MerkleContext, CompressedAccountWithMerkleContext, deriveAddressV2, @@ -348,7 +348,7 @@ async function _tryFetchCTokenHot( isCold: false; }> { const info = await rpc.getAccountInfo(address, commitment); - if (!info || !info.owner.equals(CTOKEN_PROGRAM_ID)) { + if (!info || !info.owner.equals(LIGHT_TOKEN_PROGRAM_ID)) { throw new Error('Not a CTOKEN onchain account'); } return parseCTokenHot(address, info); @@ -376,7 +376,7 @@ async function _tryFetchCTokenColdByOwner( if (!compressedAccount?.data?.data.length) { throw new Error('Not a compressed token account'); } - if (!compressedAccount.owner.equals(CTOKEN_PROGRAM_ID)) { + if (!compressedAccount.owner.equals(LIGHT_TOKEN_PROGRAM_ID)) { throw new Error('Invalid owner for compressed token'); } return parseCTokenCold(ataAddress, compressedAccount); @@ -385,7 +385,7 @@ async function _tryFetchCTokenColdByOwner( /** * @internal * Fetch compressed token account by deriving its compressed address from the on-chain address. - * Uses deriveAddressV2(address, addressTree, CTOKEN_PROGRAM_ID) to get the compressed address. + * Uses deriveAddressV2(address, addressTree, LIGHT_TOKEN_PROGRAM_ID) to get the compressed address. * * Note: This only works for accounts that were **compressed from on-chain** (via compress_accounts_idempotent). * For tokens minted compressed (via mintTo), use getAtaInterface with owner+mint instead. @@ -404,7 +404,7 @@ async function _tryFetchCTokenColdByAddress( const compressedAddress = deriveAddressV2( address.toBytes(), addressTree, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); // Fetch by derived compressed address @@ -420,7 +420,7 @@ async function _tryFetchCTokenColdByAddress( 'For tokens minted compressed (via mintTo), use getAtaInterface with owner+mint.', ); } - if (!compressedAccount.owner.equals(CTOKEN_PROGRAM_ID)) { + if (!compressedAccount.owner.equals(LIGHT_TOKEN_PROGRAM_ID)) { throw new Error('Invalid owner for compressed token'); } return parseCTokenCold(address, compressedAccount); @@ -461,7 +461,7 @@ async function _getAccountInterface( } // c-token-only mode - if (programId.equals(CTOKEN_PROGRAM_ID)) { + if (programId.equals(LIGHT_TOKEN_PROGRAM_ID)) { return getCTokenAccountInterface( rpc, address, @@ -501,8 +501,8 @@ async function getUnifiedAccountInterface( fetchByOwner!.mint, fetchByOwner!.owner, false, - CTOKEN_PROGRAM_ID, - getAtaProgramId(CTOKEN_PROGRAM_ID), + LIGHT_TOKEN_PROGRAM_ID, + getAtaProgramId(LIGHT_TOKEN_PROGRAM_ID), ); const fetchPromises: Promise<{ @@ -589,7 +589,7 @@ async function getUnifiedAccountInterface( compressedAccount && compressedAccount.data && compressedAccount.data.data.length > 0 && - compressedAccount.owner.equals(CTOKEN_PROGRAM_ID) + compressedAccount.owner.equals(LIGHT_TOKEN_PROGRAM_ID) ) { const parsed = parseCTokenCold(cTokenAta, compressedAccount); sources.push({ @@ -640,8 +640,8 @@ async function getCTokenAccountInterface( fetchByOwner.mint, fetchByOwner.owner, false, - CTOKEN_PROGRAM_ID, - getAtaProgramId(CTOKEN_PROGRAM_ID), + LIGHT_TOKEN_PROGRAM_ID, + getAtaProgramId(LIGHT_TOKEN_PROGRAM_ID), ); } @@ -665,7 +665,7 @@ async function getCTokenAccountInterface( const sources: TokenAccountSource[] = []; // Collect hot (decompressed) c-token account - if (onchainAccount && onchainAccount.owner.equals(CTOKEN_PROGRAM_ID)) { + if (onchainAccount && onchainAccount.owner.equals(LIGHT_TOKEN_PROGRAM_ID)) { const parsed = parseCTokenHot(address, onchainAccount); sources.push({ type: TokenAccountSourceType.CTokenHot, @@ -682,7 +682,7 @@ async function getCTokenAccountInterface( compressedAccount && compressedAccount.data && compressedAccount.data.data.length > 0 && - compressedAccount.owner.equals(CTOKEN_PROGRAM_ID) + compressedAccount.owner.equals(LIGHT_TOKEN_PROGRAM_ID) ) { const parsed = parseCTokenCold(address, compressedAccount); sources.push({ @@ -730,68 +730,75 @@ async function getSplOrToken2022AccountInterface( ); } - const info = await rpc.getAccountInfo(address, commitment); - if (!info) { - throw new TokenAccountNotFoundError(); - } - - const account = unpackAccountSPL(address, info, programId); - const hotType: TokenAccountSource['type'] = programId.equals( TOKEN_PROGRAM_ID, ) ? TokenAccountSourceType.Spl : TokenAccountSourceType.Token2022; - const sources: TokenAccountSource[] = [ - { - type: hotType, - address, - amount: account.amount, - accountInfo: info, - parsed: account, - }, - ]; + const coldType: TokenAccountSource['type'] = programId.equals( + TOKEN_PROGRAM_ID, + ) + ? TokenAccountSourceType.SplCold + : TokenAccountSourceType.Token2022Cold; + + // Fetch hot and cold in parallel (neither is required individually) + const hotPromise = rpc + .getAccountInfo(address, commitment) + .catch(() => null); + const coldPromise = fetchByOwner + ? rpc + .getCompressedTokenAccountsByOwner(fetchByOwner.owner, { + mint: fetchByOwner.mint, + }) + .catch(() => ({ items: [] as any[] })) + : Promise.resolve({ items: [] as any[] }); - // For ATA-based calls (fetchByOwner present), also include cold (compressed) balances - if (fetchByOwner) { - const compressedResult = await rpc.getCompressedTokenAccountsByOwner( - fetchByOwner.owner, - { - mint: fetchByOwner.mint, - }, - ); - const compressedAccounts = compressedResult.items.map( - item => item.compressedAccount, - ); + const [hotInfo, coldResult] = await Promise.all([hotPromise, coldPromise]); - const coldType: TokenAccountSource['type'] = programId.equals( - TOKEN_PROGRAM_ID, - ) - ? TokenAccountSourceType.SplCold - : TokenAccountSourceType.Token2022Cold; - - for (const compressedAccount of compressedAccounts) { - if ( - compressedAccount && - compressedAccount.data && - compressedAccount.data.data.length > 0 && - compressedAccount.owner.equals(CTOKEN_PROGRAM_ID) - ) { - // Represent cold supply as belonging to this SPL/T22 ATA - const parsedCold = parseCTokenCold(address, compressedAccount); - sources.push({ - type: coldType, - address, - amount: parsedCold.parsed.amount, - accountInfo: parsedCold.accountInfo, - loadContext: parsedCold.loadContext, - parsed: parsedCold.parsed, - }); - } + const sources: TokenAccountSource[] = []; + + // Hot SPL/T22 account (may not exist) + if (hotInfo) { + try { + const account = unpackAccountSPL(address, hotInfo, programId); + sources.push({ + type: hotType, + address, + amount: account.amount, + accountInfo: hotInfo, + parsed: account, + }); + } catch { + // Not a valid SPL/T22 account at this address, skip } } + // Cold (compressed) accounts + for (const item of coldResult.items) { + const compressedAccount = item.compressedAccount; + if ( + compressedAccount && + compressedAccount.data && + compressedAccount.data.data.length > 0 && + compressedAccount.owner.equals(LIGHT_TOKEN_PROGRAM_ID) + ) { + const parsedCold = parseCTokenCold(address, compressedAccount); + sources.push({ + type: coldType, + address, + amount: parsedCold.parsed.amount, + accountInfo: parsedCold.accountInfo, + loadContext: parsedCold.loadContext, + parsed: parsedCold.parsed, + }); + } + } + + if (sources.length === 0) { + throw new TokenAccountNotFoundError(); + } + return buildAccountInterfaceFromSources(sources, address); } diff --git a/js/compressed-token/src/v3/get-associated-token-address-interface.ts b/js/compressed-token/src/v3/get-associated-token-address-interface.ts index aa1b902f01..7d1238b0ff 100644 --- a/js/compressed-token/src/v3/get-associated-token-address-interface.ts +++ b/js/compressed-token/src/v3/get-associated-token-address-interface.ts @@ -1,6 +1,6 @@ import { PublicKey } from '@solana/web3.js'; import { getAssociatedTokenAddressSync } from '@solana/spl-token'; -import { CTOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; +import { LIGHT_TOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; import { getAtaProgramId } from './ata-utils'; /** @@ -20,7 +20,7 @@ export function getAssociatedTokenAddressInterface( mint: PublicKey, owner: PublicKey, allowOwnerOffCurve = false, - programId: PublicKey = CTOKEN_PROGRAM_ID, + programId: PublicKey = LIGHT_TOKEN_PROGRAM_ID, associatedTokenProgramId?: PublicKey, ): PublicKey { const effectiveAssociatedProgramId = diff --git a/js/compressed-token/src/v3/get-mint-interface.ts b/js/compressed-token/src/v3/get-mint-interface.ts index ee6cffc0e1..cd2b89f4c2 100644 --- a/js/compressed-token/src/v3/get-mint-interface.ts +++ b/js/compressed-token/src/v3/get-mint-interface.ts @@ -4,7 +4,7 @@ import { Rpc, bn, deriveAddressV2, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, getDefaultAddressTreeInfo, MerkleContext, assertBetaEnabled, @@ -67,7 +67,12 @@ export async function getMintInterface( commitment, TOKEN_2022_PROGRAM_ID, ), - getMintInterface(rpc, address, commitment, CTOKEN_PROGRAM_ID), + getMintInterface( + rpc, + address, + commitment, + LIGHT_TOKEN_PROGRAM_ID, + ), ]); if (tokenResult.status === 'fulfilled') { @@ -82,16 +87,16 @@ export async function getMintInterface( throw new Error( `Mint not found: ${address.toString()}. ` + - `Tried TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, and CTOKEN_PROGRAM_ID.`, + `Tried TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, and LIGHT_TOKEN_PROGRAM_ID.`, ); } - if (programId.equals(CTOKEN_PROGRAM_ID)) { + if (programId.equals(LIGHT_TOKEN_PROGRAM_ID)) { const addressTree = getDefaultAddressTreeInfo().tree; const compressedAddress = deriveAddressV2( address.toBytes(), addressTree, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); const compressedAccount = await rpc.getCompressedAccount( bn(compressedAddress.toBytes()), @@ -160,15 +165,15 @@ export async function getMintInterface( compression: compressedMintData.compression, }; - if (programId.equals(CTOKEN_PROGRAM_ID)) { + if (programId.equals(LIGHT_TOKEN_PROGRAM_ID)) { if (!result.merkleContext) { throw new Error( - `Invalid compressed mint: merkleContext is required for CTOKEN_PROGRAM_ID`, + `Invalid compressed mint: merkleContext is required for LIGHT_TOKEN_PROGRAM_ID`, ); } if (!result.mintContext) { throw new Error( - `Invalid compressed mint: mintContext is required for CTOKEN_PROGRAM_ID`, + `Invalid compressed mint: mintContext is required for LIGHT_TOKEN_PROGRAM_ID`, ); } } @@ -202,7 +207,7 @@ export function unpackMintInterface( : data.data; // If compressed token program, deserialize as compressed mint - if (programId.equals(CTOKEN_PROGRAM_ID)) { + if (programId.equals(LIGHT_TOKEN_PROGRAM_ID)) { const compressedMintData = deserializeMint(buffer); const mint: Mint = { @@ -229,11 +234,11 @@ export function unpackMintInterface( compression: compressedMintData.compression, }; - // Validate: CTOKEN_PROGRAM_ID requires mintContext - if (programId.equals(CTOKEN_PROGRAM_ID)) { + // Validate: LIGHT_TOKEN_PROGRAM_ID requires mintContext + if (programId.equals(LIGHT_TOKEN_PROGRAM_ID)) { if (!result.mintContext) { throw new Error( - `Invalid compressed mint: mintContext is required for CTOKEN_PROGRAM_ID`, + `Invalid compressed mint: mintContext is required for LIGHT_TOKEN_PROGRAM_ID`, ); } } diff --git a/js/compressed-token/src/v3/instructions/create-associated-ctoken.ts b/js/compressed-token/src/v3/instructions/create-associated-ctoken.ts index dff70fb33d..0bb8921fee 100644 --- a/js/compressed-token/src/v3/instructions/create-associated-ctoken.ts +++ b/js/compressed-token/src/v3/instructions/create-associated-ctoken.ts @@ -4,7 +4,7 @@ import { TransactionInstruction, } from '@solana/web3.js'; import { Buffer } from 'buffer'; -import { CTOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; +import { LIGHT_TOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; import { struct, u8, u32, option, vec, array } from '@coral-xyz/borsh'; import { LIGHT_TOKEN_CONFIG, LIGHT_TOKEN_RENT_SPONSOR } from '../../constants'; @@ -49,7 +49,7 @@ export interface CompressibleConfig { } export interface CreateAssociatedCTokenAccountParams { - compressibleConfig?: CompressibleConfig; + compressibleConfig?: CompressibleConfig | null; } /** @@ -96,8 +96,8 @@ function getAssociatedCTokenAddress( mint: PublicKey, ): PublicKey { return PublicKey.findProgramAddressSync( - [owner.toBuffer(), CTOKEN_PROGRAM_ID.toBuffer(), mint.toBuffer()], - CTOKEN_PROGRAM_ID, + [owner.toBuffer(), LIGHT_TOKEN_PROGRAM_ID.toBuffer(), mint.toBuffer()], + LIGHT_TOKEN_PROGRAM_ID, )[0]; } @@ -145,7 +145,7 @@ export function createAssociatedCTokenAccountInstruction( feePayer: PublicKey, owner: PublicKey, mint: PublicKey, - compressibleConfig: CompressibleConfig = DEFAULT_COMPRESSIBLE_CONFIG, + compressibleConfig: CompressibleConfig | null = DEFAULT_COMPRESSIBLE_CONFIG, configAccount: PublicKey = LIGHT_TOKEN_CONFIG, rentPayerPda: PublicKey = LIGHT_TOKEN_RENT_SPONSOR, ): TransactionInstruction { @@ -164,9 +164,14 @@ export function createAssociatedCTokenAccountInstruction( // 2. fee_payer (signer, mut) // 3. associated_token_account (mut) // 4. system_program + // Optional (only when compressibleConfig is non-null): // 5. config account // 6. rent_payer PDA - const keys = [ + const keys: { + pubkey: PublicKey; + isSigner: boolean; + isWritable: boolean; + }[] = [ { pubkey: owner, isSigner: false, isWritable: false }, { pubkey: mint, isSigner: false, isWritable: false }, { pubkey: feePayer, isSigner: true, isWritable: true }, @@ -175,13 +180,22 @@ export function createAssociatedCTokenAccountInstruction( isSigner: false, isWritable: true, }, - { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, - { pubkey: configAccount, isSigner: false, isWritable: false }, - { pubkey: rentPayerPda, isSigner: false, isWritable: true }, + { + pubkey: SystemProgram.programId, + isSigner: false, + isWritable: false, + }, ]; + if (compressibleConfig) { + keys.push( + { pubkey: configAccount, isSigner: false, isWritable: false }, + { pubkey: rentPayerPda, isSigner: false, isWritable: true }, + ); + } + return new TransactionInstruction({ - programId: CTOKEN_PROGRAM_ID, + programId: LIGHT_TOKEN_PROGRAM_ID, keys, data, }); @@ -202,7 +216,7 @@ export function createAssociatedCTokenAccountIdempotentInstruction( feePayer: PublicKey, owner: PublicKey, mint: PublicKey, - compressibleConfig: CompressibleConfig = DEFAULT_COMPRESSIBLE_CONFIG, + compressibleConfig: CompressibleConfig | null = DEFAULT_COMPRESSIBLE_CONFIG, configAccount: PublicKey = LIGHT_TOKEN_CONFIG, rentPayerPda: PublicKey = LIGHT_TOKEN_RENT_SPONSOR, ): TransactionInstruction { @@ -215,7 +229,11 @@ export function createAssociatedCTokenAccountIdempotentInstruction( true, ); - const keys = [ + const keys: { + pubkey: PublicKey; + isSigner: boolean; + isWritable: boolean; + }[] = [ { pubkey: owner, isSigner: false, isWritable: false }, { pubkey: mint, isSigner: false, isWritable: false }, { pubkey: feePayer, isSigner: true, isWritable: true }, @@ -224,13 +242,22 @@ export function createAssociatedCTokenAccountIdempotentInstruction( isSigner: false, isWritable: true, }, - { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, - { pubkey: configAccount, isSigner: false, isWritable: false }, - { pubkey: rentPayerPda, isSigner: false, isWritable: true }, + { + pubkey: SystemProgram.programId, + isSigner: false, + isWritable: false, + }, ]; + if (compressibleConfig) { + keys.push( + { pubkey: configAccount, isSigner: false, isWritable: false }, + { pubkey: rentPayerPda, isSigner: false, isWritable: true }, + ); + } + return new TransactionInstruction({ - programId: CTOKEN_PROGRAM_ID, + programId: LIGHT_TOKEN_PROGRAM_ID, keys, data, }); diff --git a/js/compressed-token/src/v3/instructions/create-ata-interface.ts b/js/compressed-token/src/v3/instructions/create-ata-interface.ts index c7bdb9e531..4141106391 100644 --- a/js/compressed-token/src/v3/instructions/create-ata-interface.ts +++ b/js/compressed-token/src/v3/instructions/create-ata-interface.ts @@ -4,7 +4,7 @@ import { createAssociatedTokenAccountInstruction as createSplAssociatedTokenAccountInstruction, createAssociatedTokenAccountIdempotentInstruction as createSplAssociatedTokenAccountIdempotentInstruction, } from '@solana/spl-token'; -import { CTOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; +import { LIGHT_TOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; import { getAtaProgramId } from '../ata-utils'; import { createAssociatedCTokenAccountInstruction, @@ -20,7 +20,7 @@ export { DEFAULT_COMPRESSIBLE_CONFIG }; * c-token-specific config for createAssociatedTokenAccountInterfaceInstruction */ export interface CTokenConfig { - compressibleConfig?: CompressibleConfig; + compressibleConfig?: CompressibleConfig | null; configAccount?: PublicKey; rentPayerPda?: PublicKey; } @@ -63,7 +63,7 @@ export function createAssociatedTokenAccountInterfaceInstruction( const effectiveAssociatedTokenProgramId = associatedTokenProgramId ?? getAtaProgramId(programId); - if (programId.equals(CTOKEN_PROGRAM_ID)) { + if (programId.equals(LIGHT_TOKEN_PROGRAM_ID)) { return createAssociatedCTokenAccountInstruction( payer, owner, @@ -109,7 +109,7 @@ export function createAssociatedTokenAccountInterfaceIdempotentInstruction( const effectiveAssociatedTokenProgramId = associatedTokenProgramId ?? getAtaProgramId(programId); - if (programId.equals(CTOKEN_PROGRAM_ID)) { + if (programId.equals(LIGHT_TOKEN_PROGRAM_ID)) { return createAssociatedCTokenAccountIdempotentInstruction( payer, owner, diff --git a/js/compressed-token/src/v3/instructions/create-decompress-interface-instruction.ts b/js/compressed-token/src/v3/instructions/create-decompress-interface-instruction.ts index e50e914634..41382e4304 100644 --- a/js/compressed-token/src/v3/instructions/create-decompress-interface-instruction.ts +++ b/js/compressed-token/src/v3/instructions/create-decompress-interface-instruction.ts @@ -4,7 +4,7 @@ import { SystemProgram, } from '@solana/web3.js'; import { - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, LightSystemProgram, defaultStaticAccountsStruct, ParsedTokenAccount, @@ -168,6 +168,17 @@ export function createDecompressInterfaceInstruction( packedAccountIndices.set(toAddress.toBase58(), destinationIndex); packedAccounts.push(toAddress); + // Add unique delegate pubkeys from input accounts + for (const acc of inputCompressedTokenAccounts) { + if (acc.parsed.delegate) { + const delegateKey = acc.parsed.delegate.toBase58(); + if (!packedAccountIndices.has(delegateKey)) { + packedAccountIndices.set(delegateKey, packedAccounts.length); + packedAccounts.push(acc.parsed.delegate); + } + } + } + // For SPL decompression, add pool account and token program let poolAccountIndex = 0; let poolIndex = 0; diff --git a/js/compressed-token/src/v3/instructions/create-load-accounts-params.ts b/js/compressed-token/src/v3/instructions/create-load-accounts-params.ts index 41e187dd7a..edd9dfd5da 100644 --- a/js/compressed-token/src/v3/instructions/create-load-accounts-params.ts +++ b/js/compressed-token/src/v3/instructions/create-load-accounts-params.ts @@ -106,7 +106,7 @@ export interface LoadResult { * ```typescript * const poolInfo = await myProgram.fetchPoolState(rpc, poolAddress); * const vault0Ata = getAssociatedTokenAddressInterface(token0Mint, poolAddress); - * const vault0Info = await getAtaInterface(rpc, vault0Ata, poolAddress, token0Mint, undefined, CTOKEN_PROGRAM_ID); + * const vault0Info = await getAtaInterface(rpc, vault0Ata, poolAddress, token0Mint, undefined, LIGHT_TOKEN_PROGRAM_ID); * const userAta = getAssociatedTokenAddressInterface(tokenMint, userWallet); * const userAtaInfo = await getAtaInterface(rpc, userAta, userWallet, tokenMint); * diff --git a/js/compressed-token/src/v3/instructions/create-mint.ts b/js/compressed-token/src/v3/instructions/create-mint.ts index b91de68252..bc2a2c9fe0 100644 --- a/js/compressed-token/src/v3/instructions/create-mint.ts +++ b/js/compressed-token/src/v3/instructions/create-mint.ts @@ -6,7 +6,7 @@ import { import { Buffer } from 'buffer'; import { ValidityProofWithContext, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, LightSystemProgram, defaultStaticAccountsStruct, TreeInfo, @@ -21,7 +21,11 @@ import { MintActionCompressedInstructionData, TokenMetadataLayoutData as TokenMetadataBorshData, } from '../layout/layout-mint-action'; -import { TokenDataVersion } from '../../constants'; +import { + TokenDataVersion, + LIGHT_TOKEN_CONFIG, + LIGHT_TOKEN_RENT_SPONSOR, +} from '../../constants'; /** * Token metadata for creating a c-token mint. @@ -125,7 +129,14 @@ export function encodeCreateMintInstructionData( readOnlyAddressTrees: [0, 0, 0, 0], readOnlyAddressTreeRootIndices: [0, 0, 0, 0], }, - actions: [], // No actions for create mint + actions: [ + { + decompressMint: { + rentPayment: 16, + writeTopUp: 766, + }, + }, + ], proof: validatedProof, cpiContext: null, mint: { @@ -216,6 +227,7 @@ function buildCreateMintIx( data: Buffer, ): TransactionInstruction { const sys = defaultStaticAccountsStruct(); + const [splMintPda] = findMintAddress(mintSigner); const keys = [ { pubkey: LightSystemProgram.programId, @@ -224,6 +236,17 @@ function buildCreateMintIx( }, { pubkey: mintSigner, isSigner: true, isWritable: false }, { pubkey: mintAuthority, isSigner: true, isWritable: false }, + { + pubkey: LIGHT_TOKEN_CONFIG, + isSigner: false, + isWritable: false, + }, + { pubkey: splMintPda, isSigner: false, isWritable: true }, + { + pubkey: LIGHT_TOKEN_RENT_SPONSOR, + isSigner: false, + isWritable: true, + }, { pubkey: payer, isSigner: true, isWritable: true }, { pubkey: CompressedTokenProgram.deriveCpiAuthorityPda, @@ -259,7 +282,7 @@ function buildCreateMintIx( ]; return new TransactionInstruction({ - programId: CTOKEN_PROGRAM_ID, + programId: LIGHT_TOKEN_PROGRAM_ID, keys, data, }); diff --git a/js/compressed-token/src/v3/instructions/decompress-mint.ts b/js/compressed-token/src/v3/instructions/decompress-mint.ts index df180c15b3..0d6129dbb1 100644 --- a/js/compressed-token/src/v3/instructions/decompress-mint.ts +++ b/js/compressed-token/src/v3/instructions/decompress-mint.ts @@ -6,7 +6,7 @@ import { import { Buffer } from 'buffer'; import { ValidityProofWithContext, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, LightSystemProgram, defaultStaticAccountsStruct, getOutputQueue, @@ -232,7 +232,7 @@ export function createDecompressMintInstruction( ]; return new TransactionInstruction({ - programId: CTOKEN_PROGRAM_ID, + programId: LIGHT_TOKEN_PROGRAM_ID, keys, data, }); diff --git a/js/compressed-token/src/v3/instructions/mint-to-compressed.ts b/js/compressed-token/src/v3/instructions/mint-to-compressed.ts index 109a1c27cf..660ad5c322 100644 --- a/js/compressed-token/src/v3/instructions/mint-to-compressed.ts +++ b/js/compressed-token/src/v3/instructions/mint-to-compressed.ts @@ -6,7 +6,7 @@ import { import { Buffer } from 'buffer'; import { ValidityProofWithContext, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, LightSystemProgram, defaultStaticAccountsStruct, getDefaultAddressTreeInfo, @@ -42,6 +42,10 @@ function encodeCompressedMintToInstructionData( ); } + // When mint is decompressed, the program reads mint data from the CMint + // Solana account. Setting mint to null signals this to the program. + const isDecompressed = params.mintData.cmintDecompressed; + const instructionData: MintActionCompressedInstructionData = { leafIndex: params.leafIndex, proveByIndex: true, @@ -59,22 +63,24 @@ function encodeCompressedMintToInstructionData( }, }, ], - proof: params.proof, + proof: isDecompressed ? null : params.proof, cpiContext: null, - mint: { - supply: params.mintData.supply, - decimals: params.mintData.decimals, - metadata: { - version: params.mintData.version, - cmintDecompressed: params.mintData.cmintDecompressed, - mint: params.mintData.splMint, - mintSigner: Array.from(params.mintData.mintSigner), - bump: params.mintData.bump, - }, - mintAuthority: params.mintData.mintAuthority, - freezeAuthority: params.mintData.freezeAuthority, - extensions: null, - }, + mint: isDecompressed + ? null + : { + supply: params.mintData.supply, + decimals: params.mintData.decimals, + metadata: { + version: params.mintData.version, + cmintDecompressed: params.mintData.cmintDecompressed, + mint: params.mintData.splMint, + mintSigner: Array.from(params.mintData.mintSigner), + bump: params.mintData.bump, + }, + mintAuthority: params.mintData.mintAuthority, + freezeAuthority: params.mintData.freezeAuthority, + extensions: null, + }, }; return encodeMintActionInstructionData(instructionData); @@ -119,6 +125,7 @@ export function createMintToCompressedInstruction( outputStateTreeInfo?: TreeInfo, tokenAccountVersion: TokenDataVersion = TokenDataVersion.ShaFlat, ): TransactionInstruction { + const isDecompressed = mintData.cmintDecompressed; const addressTreeInfo = getDefaultAddressTreeInfo(); const data = encodeCompressedMintToInstructionData({ addressTree: addressTreeInfo.tree, @@ -142,6 +149,16 @@ export function createMintToCompressedInstruction( isWritable: false, }, { pubkey: authority, isSigner: true, isWritable: false }, + // CMint account when decompressed (must come before payer for correct account ordering) + ...(isDecompressed + ? [ + { + pubkey: mintData.splMint, + isSigner: false, + isWritable: true, + }, + ] + : []), { pubkey: payer, isSigner: true, isWritable: true }, { pubkey: CompressedTokenProgram.deriveCpiAuthorityPda, @@ -180,7 +197,7 @@ export function createMintToCompressedInstruction( ]; return new TransactionInstruction({ - programId: CTOKEN_PROGRAM_ID, + programId: LIGHT_TOKEN_PROGRAM_ID, keys, data, }); diff --git a/js/compressed-token/src/v3/instructions/mint-to.ts b/js/compressed-token/src/v3/instructions/mint-to.ts index 1612905b3c..29e2fcd84c 100644 --- a/js/compressed-token/src/v3/instructions/mint-to.ts +++ b/js/compressed-token/src/v3/instructions/mint-to.ts @@ -4,7 +4,7 @@ import { TransactionInstruction, } from '@solana/web3.js'; import { Buffer } from 'buffer'; -import { CTOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; +import { LIGHT_TOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; /** * Parameters for creating a MintTo instruction. @@ -68,7 +68,7 @@ export function createMintToInstruction( } return new TransactionInstruction({ - programId: CTOKEN_PROGRAM_ID, + programId: LIGHT_TOKEN_PROGRAM_ID, keys, data, }); diff --git a/js/compressed-token/src/v3/instructions/transfer-interface.ts b/js/compressed-token/src/v3/instructions/transfer-interface.ts index 13df256476..9f05e3106f 100644 --- a/js/compressed-token/src/v3/instructions/transfer-interface.ts +++ b/js/compressed-token/src/v3/instructions/transfer-interface.ts @@ -1,23 +1,17 @@ import { PublicKey, - Signer, TransactionInstruction, SystemProgram, } from '@solana/web3.js'; -import { CTOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; -import { - TOKEN_2022_PROGRAM_ID, - TOKEN_PROGRAM_ID, - createTransferInstruction as createSplTransferInstruction, -} from '@solana/spl-token'; +import { LIGHT_TOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; /** - * c-token transfer instruction discriminator + * Light token transfer instruction discriminator */ -const CTOKEN_TRANSFER_DISCRIMINATOR = 3; +const LIGHT_TOKEN_TRANSFER_DISCRIMINATOR = 3; /** - * Create a c-token transfer instruction. + * Create a Light token transfer instruction. * * For c-token accounts with compressible extension, the program needs * system_program and fee_payer to handle rent top-ups. @@ -27,9 +21,9 @@ const CTOKEN_TRANSFER_DISCRIMINATOR = 3; * @param owner Owner of the source account (signer, also pays for compressible extension top-ups) * @param amount Amount to transfer * @param feePayer Optional fee payer for top-ups (defaults to owner) - * @returns Transaction instruction for c-token transfer + * @returns Transaction instruction for Light token transfer */ -export function createCTokenTransferInstruction( +export function createLightTokenTransferInstruction( source: PublicKey, destination: PublicKey, owner: PublicKey, @@ -40,7 +34,7 @@ export function createCTokenTransferInstruction( // byte 0: discriminator (3) // bytes 1-8: amount (u64 LE) const data = Buffer.alloc(9); - data.writeUInt8(CTOKEN_TRANSFER_DISCRIMINATOR, 0); + data.writeUInt8(LIGHT_TOKEN_TRANSFER_DISCRIMINATOR, 0); data.writeBigUInt64LE(BigInt(amount), 1); const effectiveFeePayer = feePayer ?? owner; @@ -64,59 +58,8 @@ export function createCTokenTransferInstruction( ]; return new TransactionInstruction({ - programId: CTOKEN_PROGRAM_ID, + programId: LIGHT_TOKEN_PROGRAM_ID, keys, data, }); } - -/** - * Construct a transfer instruction for SPL/T22/c-token. Defaults to c-token - * program. For cross-program transfers (SPL <> c-token), use `wrap`/`unwrap`. - * - * @param source Source token account - * @param destination Destination token account - * @param owner Owner of the source account (signer) - * @param amount Amount to transfer - * @returns instruction for c-token transfer - */ -export function createTransferInterfaceInstruction( - source: PublicKey, - destination: PublicKey, - owner: PublicKey, - amount: number | bigint, - multiSigners: (Signer | PublicKey)[] = [], - programId: PublicKey = CTOKEN_PROGRAM_ID, -): TransactionInstruction { - if (programId.equals(CTOKEN_PROGRAM_ID)) { - if (multiSigners.length > 0) { - throw new Error( - 'c-token transfer does not support multi-signers. Use a single owner.', - ); - } - return createCTokenTransferInstruction( - source, - destination, - owner, - amount, - ); - } - - if ( - programId.equals(TOKEN_PROGRAM_ID) || - programId.equals(TOKEN_2022_PROGRAM_ID) - ) { - return createSplTransferInstruction( - source, - destination, - owner, - amount, - multiSigners.map(pk => - pk instanceof PublicKey ? pk : pk.publicKey, - ), - programId, - ); - } - - throw new Error(`Unsupported program ID: ${programId.toBase58()}`); -} diff --git a/js/compressed-token/src/v3/instructions/unwrap.ts b/js/compressed-token/src/v3/instructions/unwrap.ts index d5a38d6186..d8ae930999 100644 --- a/js/compressed-token/src/v3/instructions/unwrap.ts +++ b/js/compressed-token/src/v3/instructions/unwrap.ts @@ -3,7 +3,7 @@ import { TransactionInstruction, SystemProgram, } from '@solana/web3.js'; -import { CTOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; +import { LIGHT_TOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; import { CompressedTokenProgram } from '../../program'; import { SplInterfaceInfo } from '../../utils/get-token-pool-infos'; import { @@ -109,7 +109,7 @@ export function createUnwrapInstruction( isWritable: false, }, { - pubkey: CTOKEN_PROGRAM_ID, + pubkey: LIGHT_TOKEN_PROGRAM_ID, isSigner: false, isWritable: false, }, diff --git a/js/compressed-token/src/v3/instructions/update-metadata.ts b/js/compressed-token/src/v3/instructions/update-metadata.ts index 62cdae007a..677e2bdc6f 100644 --- a/js/compressed-token/src/v3/instructions/update-metadata.ts +++ b/js/compressed-token/src/v3/instructions/update-metadata.ts @@ -6,7 +6,7 @@ import { import { Buffer } from 'buffer'; import { ValidityProofWithContext, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, LightSystemProgram, defaultStaticAccountsStruct, getDefaultAddressTreeInfo, @@ -235,7 +235,7 @@ function createUpdateMetadataInstruction( ]; return new TransactionInstruction({ - programId: CTOKEN_PROGRAM_ID, + programId: LIGHT_TOKEN_PROGRAM_ID, keys, data, }); diff --git a/js/compressed-token/src/v3/instructions/update-mint.ts b/js/compressed-token/src/v3/instructions/update-mint.ts index 6eb1841472..0b84b74e3e 100644 --- a/js/compressed-token/src/v3/instructions/update-mint.ts +++ b/js/compressed-token/src/v3/instructions/update-mint.ts @@ -6,7 +6,7 @@ import { import { Buffer } from 'buffer'; import { ValidityProofWithContext, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, LightSystemProgram, defaultStaticAccountsStruct, getDefaultAddressTreeInfo, @@ -199,7 +199,7 @@ export function createUpdateMintAuthorityInstruction( ]; return new TransactionInstruction({ - programId: CTOKEN_PROGRAM_ID, + programId: LIGHT_TOKEN_PROGRAM_ID, keys, data, }); @@ -307,7 +307,7 @@ export function createUpdateFreezeAuthorityInstruction( ]; return new TransactionInstruction({ - programId: CTOKEN_PROGRAM_ID, + programId: LIGHT_TOKEN_PROGRAM_ID, keys, data, }); diff --git a/js/compressed-token/src/v3/instructions/wrap.ts b/js/compressed-token/src/v3/instructions/wrap.ts index a5b491cb90..163e872877 100644 --- a/js/compressed-token/src/v3/instructions/wrap.ts +++ b/js/compressed-token/src/v3/instructions/wrap.ts @@ -3,7 +3,7 @@ import { TransactionInstruction, SystemProgram, } from '@solana/web3.js'; -import { CTOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; +import { LIGHT_TOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; import { CompressedTokenProgram } from '../../program'; import { SplInterfaceInfo } from '../../utils/get-token-pool-infos'; import { @@ -107,7 +107,7 @@ export function createWrapInstruction( isWritable: false, }, { - pubkey: CTOKEN_PROGRAM_ID, + pubkey: LIGHT_TOKEN_PROGRAM_ID, isSigner: false, isWritable: false, }, diff --git a/js/compressed-token/src/v3/unified/index.ts b/js/compressed-token/src/v3/unified/index.ts index 9b394950e8..f56518bc1c 100644 --- a/js/compressed-token/src/v3/unified/index.ts +++ b/js/compressed-token/src/v3/unified/index.ts @@ -10,10 +10,11 @@ import { ConfirmOptions, Commitment, ComputeBudgetProgram, + TransactionInstruction, } from '@solana/web3.js'; import { Rpc, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, buildAndSignTx, sendAndConfirmTx, } from '@lightprotocol/stateless.js'; @@ -29,8 +30,17 @@ import { loadAta as _loadAta, } from '../actions/load-ata'; import { createAssociatedTokenAccountInterfaceIdempotentInstruction } from '../instructions/create-ata-interface'; -import { transferInterface as _transferInterface } from '../actions/transfer-interface'; +import { + transferInterface as _transferInterface, + createTransferInterfaceInstructions as _createTransferInterfaceInstructions, +} 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, + unwrap as _unwrap, +} from '../actions/unwrap'; +import { SplInterfaceInfo } from '../../utils/get-token-pool-infos'; import { getAtaProgramId } from '../ata-utils'; import { InterfaceOptions } from '..'; @@ -59,7 +69,7 @@ export async function getAtaInterface( /** * Derive the canonical token ATA for SPL/T22/c-token in the unified path. * - * Enforces CTOKEN_PROGRAM_ID. + * Enforces LIGHT_TOKEN_PROGRAM_ID. * * @param mint Mint public key * @param owner Owner public key @@ -73,10 +83,10 @@ export function getAssociatedTokenAddressInterface( mint: PublicKey, owner: PublicKey, allowOwnerOffCurve = false, - programId: PublicKey = CTOKEN_PROGRAM_ID, + programId: PublicKey = LIGHT_TOKEN_PROGRAM_ID, associatedTokenProgramId?: PublicKey, ): PublicKey { - if (!programId.equals(CTOKEN_PROGRAM_ID)) { + if (!programId.equals(LIGHT_TOKEN_PROGRAM_ID)) { throw new Error( 'Please derive the unified ATA from the c-token program; balances across SPL, T22, and c-token are unified under the canonical c-token ATA.', ); @@ -92,7 +102,7 @@ export function getAssociatedTokenAddressInterface( } /** - * Create instructions to load ALL token balances into a c-token ATA. + * Create instruction batches for loading ALL token balances into a c-token ATA. * * @param rpc RPC connection * @param ata Associated token address @@ -100,7 +110,7 @@ export function getAssociatedTokenAddressInterface( * @param mint Mint public key * @param payer Fee payer (defaults to owner) * @param options Optional interface options - * @returns Array of instructions (empty if nothing to load) + * @returns Instruction batches - each inner array is one transaction */ export async function createLoadAtaInstructions( rpc: Rpc, @@ -109,7 +119,7 @@ export async function createLoadAtaInstructions( mint: PublicKey, payer?: PublicKey, options?: InterfaceOptions, -) { +): Promise { return _createLoadAtaInstructions( rpc, ata, @@ -169,7 +179,7 @@ export async function loadAta( ata, owner.publicKey, mint, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); const { blockhash } = await rpc.getLatestBlockhash(); const tx = buildAndSignTx( @@ -191,7 +201,7 @@ export async function loadAta( /** * Transfer tokens using the unified ata interface. * - * Matches SPL Token's transferChecked signature order. Destination must exist. + * Destination ATA must exist. Automatically wraps SPL/T22 to c-token ATA. * * @param rpc RPC connection * @param payer Fee payer (signer) @@ -200,7 +210,6 @@ export async function loadAta( * @param destination Destination c-token ATA address (must exist) * @param owner Source owner (signer) * @param amount Amount to transfer - * @param programId Token program ID (default: CTOKEN_PROGRAM_ID) * @param confirmOptions Optional confirm options * @param options Optional interface options * @returns Transaction signature @@ -213,7 +222,6 @@ export async function transferInterface( destination: PublicKey, owner: Signer, amount: number | bigint | BN, - programId: PublicKey = CTOKEN_PROGRAM_ID, confirmOptions?: ConfirmOptions, options?: InterfaceOptions, ) { @@ -225,17 +233,17 @@ export async function transferInterface( destination, owner, amount, - programId, + undefined, // programId: use default LIGHT_TOKEN_PROGRAM_ID confirmOptions, options, - true, + true, // wrap=true for unified ); } /** * Get or create c-token ATA with unified balance detection and auto-loading. * - * Enforces CTOKEN_PROGRAM_ID. Aggregates balances from: + * Enforces LIGHT_TOKEN_PROGRAM_ID. Aggregates balances from: * - c-token hot (on-chain) account * - c-token cold (compressed) accounts * - SPL token accounts (for unified wrapping) @@ -277,12 +285,127 @@ export async function getOrCreateAtaInterface( allowOwnerOffCurve, commitment, confirmOptions, - CTOKEN_PROGRAM_ID, - getAtaProgramId(CTOKEN_PROGRAM_ID), + LIGHT_TOKEN_PROGRAM_ID, + getAtaProgramId(LIGHT_TOKEN_PROGRAM_ID), true, // wrap=true for unified path ); } +/** + * Create transfer instructions for a unified token transfer. + * + * Unified variant: always wraps SPL/T22 to c-token ATA. + * + * Returns `TransactionInstruction[][]`. Send [0..n-2] in parallel, then [n-1]. + * Use `sliceLast` to separate the parallel prefix from the final transfer. + * + * @see createTransferInterfaceInstructions in v3/actions/transfer-interface.ts + */ +export async function createTransferInterfaceInstructions( + rpc: Rpc, + payer: PublicKey, + mint: PublicKey, + amount: number | bigint | BN, + sender: PublicKey, + recipient: PublicKey, + options?: Omit<_TransferOptions, 'wrap'>, +): Promise { + return _createTransferInterfaceInstructions( + rpc, + payer, + mint, + amount, + sender, + recipient, + { + ...options, + wrap: true, + }, + ); +} + +/** + * Build instruction batches for unwrapping c-tokens to SPL/T22. + * + * Unified variant: uses wrap=true for loading, so SPL/T22 balances are + * consolidated before unwrapping. + * + * Returns `TransactionInstruction[][]`. Load batches (if any) come first, + * followed by one final unwrap transaction. + * + * @param rpc RPC connection + * @param destination Destination SPL/T22 token account (must exist) + * @param owner Owner of the c-token + * @param mint Mint address + * @param amount Amount to unwrap (defaults to full balance) + * @param payer Fee payer (defaults to owner) + * @param splInterfaceInfo Optional: SPL interface info + * @param interfaceOptions Optional: interface options for load + * @returns Instruction batches - each inner array is one transaction + */ +export async function createUnwrapInstructions( + rpc: Rpc, + destination: PublicKey, + owner: PublicKey, + mint: PublicKey, + amount?: number | bigint | BN, + payer?: PublicKey, + splInterfaceInfo?: SplInterfaceInfo, + interfaceOptions?: InterfaceOptions, +): Promise { + return _createUnwrapInstructions( + rpc, + destination, + owner, + mint, + amount, + payer, + splInterfaceInfo, + interfaceOptions, + true, // wrap=true for unified + ); +} + +/** + * Unwrap c-tokens to SPL tokens. + * + * Unified variant: loads all cold + SPL/T22 balances to c-token ATA first, + * then unwraps to the destination SPL/T22 account. + * + * @param rpc RPC connection + * @param payer Fee payer + * @param destination Destination SPL/T22 token account + * @param owner Owner of the c-token (signer) + * @param mint Mint address + * @param amount Amount to unwrap (defaults to all) + * @param splInterfaceInfo SPL interface info + * @param confirmOptions Confirm options + * @returns Transaction signature of the unwrap transaction + */ +export async function unwrap( + rpc: Rpc, + payer: Signer, + destination: PublicKey, + owner: Signer, + mint: PublicKey, + amount?: number | bigint | BN, + splInterfaceInfo?: SplInterfaceInfo, + confirmOptions?: ConfirmOptions, +): Promise { + return _unwrap( + rpc, + payer, + destination, + owner, + mint, + amount, + splInterfaceInfo, + confirmOptions, + ); +} + +export type { _TransferOptions as TransferOptions }; + export { getAccountInterface, AccountInterface, @@ -307,7 +430,7 @@ export { LoadResult, } from '../actions/load-ata'; -export { InterfaceOptions } from '../actions/transfer-interface'; +export { InterfaceOptions, sliceLast } from '../actions/transfer-interface'; export * from '../../actions'; export * from '../../utils'; @@ -338,8 +461,7 @@ export { createWrapInstruction, createUnwrapInstruction, createDecompressInterfaceInstruction, - createTransferInterfaceInstruction, - createCTokenTransferInstruction, + createLightTokenTransferInstruction, // Types TokenMetadataInstructionData, CompressibleConfig, @@ -354,7 +476,7 @@ export { // getOrCreateAtaInterface is defined locally with unified behavior decompressInterface, wrap, - unwrap, + // unwrap and createUnwrapInstructions are defined locally with unified behavior mintTo as mintToCToken, mintToCompressed, mintToInterface, diff --git a/js/compressed-token/src/v3/utils/estimate-tx-size.ts b/js/compressed-token/src/v3/utils/estimate-tx-size.ts new file mode 100644 index 0000000000..e3af2d6338 --- /dev/null +++ b/js/compressed-token/src/v3/utils/estimate-tx-size.ts @@ -0,0 +1,89 @@ +import { TransactionInstruction } from '@solana/web3.js'; + +/** Solana maximum transaction size in bytes. */ +export const MAX_TRANSACTION_SIZE = 1232; + +/** + * Conservative size budget for a combined batch (load + transfer + ATA). + * Leaves headroom below MAX_TRANSACTION_SIZE for edge-case key counts. + */ +export const MAX_COMBINED_BATCH_BYTES = 900; + +/** + * Conservative size budget for a load-only or setup-only batch. + */ +export const MAX_LOAD_ONLY_BATCH_BYTES = 1000; + +/** + * Encode length as compact-u16 (Solana's variable-length encoding). + * Returns the number of bytes the encoded value occupies. + */ +function compactU16Size(value: number): number { + if (value < 0x80) return 1; + if (value < 0x4000) return 2; + return 3; +} + +/** + * Estimate the serialized byte size of a V0 VersionedTransaction built from + * the given instructions and signer count. + * + * The estimate accounts for Solana's account-key deduplication: all unique + * pubkeys across every instruction (keys + programIds) are collected into a + * single set, matching the behaviour of + * `TransactionMessage.compileToV0Message`. + * + * This intentionally does NOT use address lookup tables, so the result is an + * upper bound. If lookup tables are used at send time the actual size will be + * smaller. + * + * @param instructions The instructions that will be included in the tx. + * @param numSigners Number of signers (determines signature count). + * @returns Estimated byte size of the serialized transaction. + */ +export function estimateTransactionSize( + instructions: TransactionInstruction[], + numSigners: number, +): number { + // 1. Collect unique account keys (pubkeys + programIds) + const uniqueKeys = new Set(); + for (const ix of instructions) { + uniqueKeys.add(ix.programId.toBase58()); + for (const key of ix.keys) { + uniqueKeys.add(key.pubkey.toBase58()); + } + } + const numKeys = uniqueKeys.size; + + // 2. Signatures section + const signaturesSize = compactU16Size(numSigners) + 64 * numSigners; + + // 3. Message + const messagePrefix = 1; // V0 prefix byte (0x80) + const header = 3; // numRequiredSignatures, numReadonlySignedAccounts, numReadonlyUnsignedAccounts + const accountKeysSize = compactU16Size(numKeys) + 32 * numKeys; + const blockhashSize = 32; + + // 4. Instructions + let instructionsSize = compactU16Size(instructions.length); + for (const ix of instructions) { + instructionsSize += 1; // programIdIndex (u8) + instructionsSize += compactU16Size(ix.keys.length); // accounts array length + instructionsSize += ix.keys.length; // account indices (u8 each) + instructionsSize += compactU16Size(ix.data.length); // data length + instructionsSize += ix.data.length; // data bytes + } + + // 5. Address table lookups (empty) + const lookupTablesSize = compactU16Size(0); // empty array + + return ( + signaturesSize + + messagePrefix + + header + + accountKeysSize + + blockhashSize + + instructionsSize + + lookupTablesSize + ); +} diff --git a/js/compressed-token/tests/e2e/approve-and-mint-to.test.ts b/js/compressed-token/tests/e2e/approve-and-mint-to.test.ts index 7da24e7db2..ea3be763e4 100644 --- a/js/compressed-token/tests/e2e/approve-and-mint-to.test.ts +++ b/js/compressed-token/tests/e2e/approve-and-mint-to.test.ts @@ -6,7 +6,7 @@ import { TOKEN_PROGRAM_ID, createInitializeMint2Instruction, } from '@solana/spl-token'; -import { approveAndMintTo, createTokenPool } from '../../src/actions'; +import { approveAndMintTo, createSplInterface } from '../../src/actions'; import { Rpc, bn, @@ -85,7 +85,7 @@ describe('approveAndMintTo', () => { await createTestSplMint(rpc, payer, mintKeypair, mintAuthority); /// Register mint - await createTokenPool(rpc, payer, mint); + await createSplInterface(rpc, payer, mint); tokenPoolInfo = selectTokenPoolInfo(await getTokenPoolInfos(rpc, mint)); stateTreeInfo = selectStateTreeInfo(await rpc.getStateTreeInfos()); }); @@ -125,7 +125,7 @@ describe('approveAndMintTo', () => { const mintAccountInfo = await rpc.getAccountInfo(token22Mint); assert(mintAccountInfo!.owner.equals(TOKEN_2022_PROGRAM_ID)); /// Register mint - await createTokenPool(rpc, payer, token22Mint); + await createSplInterface(rpc, payer, token22Mint); assert(token22Mint.equals(token22MintKeypair.publicKey)); const tokenPoolInfoT22 = selectTokenPoolInfo( diff --git a/js/compressed-token/tests/e2e/compressible-load.test.ts b/js/compressed-token/tests/e2e/compressible-load.test.ts index cde591c3b6..93756f96e3 100644 --- a/js/compressed-token/tests/e2e/compressible-load.test.ts +++ b/js/compressed-token/tests/e2e/compressible-load.test.ts @@ -10,7 +10,7 @@ import { MerkleContext, VERSION, featureFlags, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, } from '@lightprotocol/stateless.js'; import { createMint, mintTo } from '../../src/actions'; import { @@ -67,7 +67,7 @@ describe('compressible-load', () => { const result = await createLoadAccountsParams( rpc, payer.publicKey, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, [], [], ); @@ -93,7 +93,7 @@ describe('compressible-load', () => { const result = await createLoadAccountsParams( rpc, payer.publicKey, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, accounts, [], ); @@ -120,7 +120,7 @@ describe('compressible-load', () => { owner.publicKey, mint, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); const hotInfo: ParsedAccountInfoInterface = { @@ -149,7 +149,7 @@ describe('compressible-load', () => { const result = await createLoadAccountsParams( rpc, payer.publicKey, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, accounts, [], ); @@ -182,7 +182,7 @@ describe('compressible-load', () => { owner.publicKey, mint, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); const accounts: CompressibleAccountInput[] = [ @@ -200,7 +200,7 @@ describe('compressible-load', () => { createLoadAccountsParams( rpc, payer.publicKey, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, accounts, [], ), @@ -227,7 +227,7 @@ describe('compressible-load', () => { owner.publicKey, mint, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); const accounts: CompressibleAccountInput[] = [ @@ -245,7 +245,7 @@ describe('compressible-load', () => { const result = await createLoadAccountsParams( rpc, payer.publicKey, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, accounts, [], ); @@ -282,13 +282,13 @@ describe('compressible-load', () => { owner.publicKey, mint, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); const result = await createLoadAccountsParams( rpc, payer.publicKey, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, [], [ata], { tokenPoolInfos }, @@ -318,7 +318,7 @@ describe('compressible-load', () => { owner.publicKey, mint, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); const loadIxs = await createLoadAtaInstructionsFromInterface( @@ -381,7 +381,7 @@ describe('compressible-load', () => { owner.publicKey, mint, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(ata._isAta).toBe(true); @@ -418,7 +418,7 @@ describe('compressible-load', () => { mint, owner.publicKey, ); - const ixs = await createLoadAtaInstructions( + const batches = await createLoadAtaInstructions( rpc, ata, owner.publicKey, @@ -427,7 +427,7 @@ describe('compressible-load', () => { { tokenPoolInfos }, ); - expect(ixs.length).toBeGreaterThan(0); + expect(batches.length).toBeGreaterThan(0); }); it('should return empty when nothing to load (hot ATA)', async () => { diff --git a/js/compressed-token/tests/e2e/create-associated-ctoken.test.ts b/js/compressed-token/tests/e2e/create-associated-ctoken.test.ts index 1948b9c3d3..312d43d4f4 100644 --- a/js/compressed-token/tests/e2e/create-associated-ctoken.test.ts +++ b/js/compressed-token/tests/e2e/create-associated-ctoken.test.ts @@ -8,7 +8,7 @@ import { featureFlags, getDefaultAddressTreeInfo, } from '@lightprotocol/stateless.js'; -import { createMintInterface, decompressMint } from '../../src/v3/actions'; +import { createMintInterface } from '../../src/v3/actions'; import { createAssociatedCTokenAccount, createAssociatedCTokenAccountIdempotent, @@ -47,10 +47,6 @@ describe('createAssociatedCTokenAccount', () => { ); await rpc.confirmTransaction(createMintSig, 'confirmed'); - // Decompress mint so it exists on-chain (required for ATA creation) - const decompressSig = await decompressMint(rpc, payer, mintPda); - await rpc.confirmTransaction(decompressSig, 'confirmed'); - const ataAddress = await createAssociatedCTokenAccount( rpc, payer, @@ -90,10 +86,6 @@ describe('createAssociatedCTokenAccount', () => { ); await rpc.confirmTransaction(createMintSig, 'confirmed'); - // Decompress mint so it exists on-chain (required for ATA creation) - const decompressSig = await decompressMint(rpc, payer, mintPda); - await rpc.confirmTransaction(decompressSig, 'confirmed'); - await createAssociatedCTokenAccount( rpc, payer, @@ -125,10 +117,6 @@ describe('createAssociatedCTokenAccount', () => { ); await rpc.confirmTransaction(createMintSig, 'confirmed'); - // Decompress mint so it exists on-chain (required for ATA creation) - const decompressSig = await decompressMint(rpc, payer, mintPda); - await rpc.confirmTransaction(decompressSig, 'confirmed'); - const ataAddress1 = await createAssociatedCTokenAccountIdempotent( rpc, payer, @@ -176,10 +164,6 @@ describe('createAssociatedCTokenAccount', () => { ); await rpc.confirmTransaction(createMintSig, 'confirmed'); - // Decompress mint so it exists on-chain (required for ATA creation) - const decompressSig = await decompressMint(rpc, payer, mintPda); - await rpc.confirmTransaction(decompressSig, 'confirmed'); - const ata1 = await createAssociatedCTokenAccount( rpc, payer, @@ -242,10 +226,6 @@ describe('createAssociatedCTokenAccount', () => { ); await rpc.confirmTransaction(createMintSig, 'confirmed'); - // Decompress mint so it exists on-chain (required for ATA creation) - const decompressSig = await decompressMint(rpc, payer, mintPda); - await rpc.confirmTransaction(decompressSig, 'confirmed'); - const createPromises = Array(3) .fill(null) .map(() => @@ -318,10 +298,6 @@ describe('createMint -> createAssociatedCTokenAccount flow', () => { expect(mint.toString()).toBe(mintPda.toString()); - // Decompress mint so it exists on-chain (required for ATA creation) - const decompressSig = await decompressMint(rpc, payer, mintPda); - await rpc.confirmTransaction(decompressSig, 'confirmed'); - const owner1 = Keypair.generate(); const owner2 = Keypair.generate(); @@ -378,10 +354,6 @@ describe('createMint -> createAssociatedCTokenAccount flow', () => { ); await rpc.confirmTransaction(createMintSig, 'confirmed'); - // Decompress mint so it exists on-chain (required for ATA creation) - const decompressSig = await decompressMint(rpc, payer, mintPda); - await rpc.confirmTransaction(decompressSig, 'confirmed'); - const ataAddress = await createAssociatedCTokenAccountIdempotent( rpc, payer, @@ -416,10 +388,6 @@ describe('createMint -> createAssociatedCTokenAccount flow', () => { ); await rpc.confirmTransaction(createMint1Sig, 'confirmed'); - // Decompress mint1 so it exists on-chain (required for ATA creation) - const decompressSig1 = await decompressMint(rpc, payer, mintPda1); - await rpc.confirmTransaction(decompressSig1, 'confirmed'); - const mintSigner2 = Keypair.generate(); const mintAuthority2 = Keypair.generate(); const [mintPda2] = findMintAddress(mintSigner2.publicKey); @@ -435,10 +403,6 @@ describe('createMint -> createAssociatedCTokenAccount flow', () => { ); await rpc.confirmTransaction(createMint2Sig, 'confirmed'); - // Decompress mint2 so it exists on-chain (required for ATA creation) - const decompressSig2 = await decompressMint(rpc, payer, mintPda2); - await rpc.confirmTransaction(decompressSig2, 'confirmed'); - const ata1 = await createAssociatedCTokenAccount( rpc, payer, @@ -486,10 +450,6 @@ describe('createMint -> createAssociatedCTokenAccount flow', () => { ); await rpc.confirmTransaction(createMintSig, 'confirmed'); - // Decompress mint so it exists on-chain (required for ATA creation) - const decompressSig = await decompressMint(rpc, payer, mintPda); - await rpc.confirmTransaction(decompressSig, 'confirmed'); - await new Promise(resolve => setTimeout(resolve, 1000)); const owner = Keypair.generate(); @@ -526,10 +486,6 @@ describe('createMint -> createAssociatedCTokenAccount flow', () => { ); await rpc.confirmTransaction(createMintSig, 'confirmed'); - // Decompress mint so it exists on-chain (required for ATA creation) - const decompressSig = await decompressMint(rpc, payer, mintPda); - await rpc.confirmTransaction(decompressSig, 'confirmed'); - const ataAddress1 = await createAssociatedCTokenAccountIdempotent( rpc, payer, diff --git a/js/compressed-token/tests/e2e/create-ata-interface.test.ts b/js/compressed-token/tests/e2e/create-ata-interface.test.ts index 199ca24892..10b2ab9da4 100644 --- a/js/compressed-token/tests/e2e/create-ata-interface.test.ts +++ b/js/compressed-token/tests/e2e/create-ata-interface.test.ts @@ -6,7 +6,7 @@ import { createRpc, VERSION, featureFlags, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, } from '@lightprotocol/stateless.js'; import { TOKEN_PROGRAM_ID, @@ -16,7 +16,7 @@ import { getAssociatedTokenAddressSync, ASSOCIATED_TOKEN_PROGRAM_ID, } from '@solana/spl-token'; -import { createMintInterface, decompressMint } from '../../src/v3/actions'; +import { createMintInterface } from '../../src/v3/actions'; import { createAtaInterface, createAtaInterfaceIdempotent, @@ -58,9 +58,6 @@ describe('createAtaInterface', () => { mintSigner, ); - // Decompress mint so it exists on-chain (required for ATA creation) - await decompressMint(rpc, payer, mintPda); - const address = await createAtaInterface( rpc, payer, @@ -77,11 +74,11 @@ describe('createAtaInterface', () => { const accountInfo = await rpc.getAccountInfo(address); expect(accountInfo).not.toBe(null); expect(accountInfo?.owner.toBase58()).toBe( - CTOKEN_PROGRAM_ID.toBase58(), + LIGHT_TOKEN_PROGRAM_ID.toBase58(), ); }); - it('should create CToken ATA with explicit CTOKEN_PROGRAM_ID', async () => { + it('should create CToken ATA with explicit LIGHT_TOKEN_PROGRAM_ID', async () => { const mintSigner = Keypair.generate(); const mintAuthority = Keypair.generate(); const owner = Keypair.generate(); @@ -96,9 +93,6 @@ describe('createAtaInterface', () => { mintSigner, ); - // Decompress mint so it exists on-chain (required for ATA creation) - await decompressMint(rpc, payer, mintPda); - const address = await createAtaInterface( rpc, payer, @@ -106,14 +100,14 @@ describe('createAtaInterface', () => { owner.publicKey, false, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); const expectedAddress = getAssociatedTokenAddressInterface( mintPda, owner.publicKey, false, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(address.toBase58()).toBe(expectedAddress.toBase58()); }); @@ -133,9 +127,6 @@ describe('createAtaInterface', () => { mintSigner, ); - // Decompress mint so it exists on-chain (required for ATA creation) - await decompressMint(rpc, payer, mintPda); - // Get rent sponsor balance before ATA creation const rentSponsorBalanceBefore = await rpc.getBalance( LIGHT_TOKEN_RENT_SPONSOR, @@ -190,9 +181,6 @@ describe('createAtaInterface', () => { mintSigner, ); - // Decompress mint so it exists on-chain (required for ATA creation) - await decompressMint(rpc, payer, mintPda); - // Get balances before const rentSponsorBalanceBefore = await rpc.getBalance( LIGHT_TOKEN_RENT_SPONSOR, @@ -268,9 +256,6 @@ describe('createAtaInterface', () => { mintSigner, ); - // Decompress mint so it exists on-chain (required for ATA creation) - await decompressMint(rpc, payer, mintPda); - await createAtaInterface(rpc, payer, mintPda, owner.publicKey); await expect( @@ -293,9 +278,6 @@ describe('createAtaInterface', () => { mintSigner, ); - // Decompress mint so it exists on-chain (required for ATA creation) - await decompressMint(rpc, payer, mintPda); - const addr1 = await createAtaInterfaceIdempotent( rpc, payer, @@ -337,9 +319,6 @@ describe('createAtaInterface', () => { mintSigner, ); - // Decompress mint so it exists on-chain (required for ATA creation) - await decompressMint(rpc, payer, mintPda); - const addr1 = await createAtaInterface( rpc, payer, @@ -579,7 +558,7 @@ describe('createAtaInterface', () => { // Create a PDA owner const [pdaOwner] = PublicKey.findProgramAddressSync( [Buffer.from('test-pda-owner')], - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); await createMintInterface( @@ -591,9 +570,6 @@ describe('createAtaInterface', () => { mintSigner, ); - // Decompress mint so it exists on-chain (required for ATA creation) - await decompressMint(rpc, payer, mintPda); - const address = await createAtaInterface( rpc, payer, @@ -680,9 +656,6 @@ describe('createAtaInterface', () => { mintSigner, ); - // Decompress CToken mint so it exists on-chain (required for ATA creation) - await decompressMint(rpc, payer, ctokenMint); - // Create ATAs for both const splAta = await createAtaInterfaceIdempotent( rpc, @@ -780,8 +753,6 @@ describe('createAtaInterface', () => { 9, mintSigner, ); - // Decompress CToken mint so it exists on-chain (required for ATA creation) - await decompressMint(rpc, payer, ctokenMint); const ctokenAta = await createAtaInterfaceIdempotent( rpc, payer, @@ -812,9 +783,6 @@ describe('createAtaInterface', () => { mintSigner, ); - // Decompress mint so it exists on-chain (required for ATA creation) - await decompressMint(rpc, payer, mintPda); - const promises = Array(3) .fill(null) .map(() => diff --git a/js/compressed-token/tests/e2e/create-compressed-mint.test.ts b/js/compressed-token/tests/e2e/create-compressed-mint.test.ts index fcb4cdfa24..3c0ce81f45 100644 --- a/js/compressed-token/tests/e2e/create-compressed-mint.test.ts +++ b/js/compressed-token/tests/e2e/create-compressed-mint.test.ts @@ -13,7 +13,7 @@ import { featureFlags, buildAndSignTx, sendAndConfirmTx, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, DerivationMode, selectStateTreeInfo, TreeType, @@ -60,7 +60,7 @@ describe('createMintInterface (compressed)', () => { rpc, mintPda, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(mint.address.toString()).toBe(mintPda.toString()); @@ -114,7 +114,7 @@ describe('createMintInterface (compressed)', () => { rpc, mintPda, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(mint.address.toString()).toBe(mintPda.toString()); @@ -192,7 +192,7 @@ describe('createMintInterface (compressed)', () => { rpc, mintPda, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(mint.isInitialized).toBe(true); diff --git a/js/compressed-token/tests/e2e/create-mint-interface.test.ts b/js/compressed-token/tests/e2e/create-mint-interface.test.ts index d55eedea8c..9b94b7da59 100644 --- a/js/compressed-token/tests/e2e/create-mint-interface.test.ts +++ b/js/compressed-token/tests/e2e/create-mint-interface.test.ts @@ -6,7 +6,7 @@ import { createRpc, VERSION, featureFlags, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, } from '@lightprotocol/stateless.js'; import { TOKEN_PROGRAM_ID, @@ -52,7 +52,7 @@ describe('createMintInterface', () => { rpc, mintPda, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(fetchedMint.mintAuthority?.toBase58()).toBe( mintAuthority.publicKey.toBase58(), @@ -60,7 +60,7 @@ describe('createMintInterface', () => { expect(fetchedMint.isInitialized).toBe(true); }); - it('should create compressed mint with explicit CTOKEN_PROGRAM_ID', async () => { + it('should create compressed mint with explicit LIGHT_TOKEN_PROGRAM_ID', async () => { const mintSigner = Keypair.generate(); const mintAuthority = Keypair.generate(); const [mintPda] = findMintAddress(mintSigner.publicKey); @@ -73,7 +73,7 @@ describe('createMintInterface', () => { 6, mintSigner, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); await rpc.confirmTransaction(transactionSignature, 'confirmed'); @@ -102,7 +102,7 @@ describe('createMintInterface', () => { rpc, mintPda, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(fetchedMint.freezeAuthority?.toBase58()).toBe( freezeAuthority.publicKey.toBase58(), @@ -129,7 +129,7 @@ describe('createMintInterface', () => { 9, mintSigner, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, metadata, ); @@ -329,7 +329,7 @@ describe('createMintInterface', () => { rpc, mintPda, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(fetchedMint.decimals).toBe(0); }); diff --git a/js/compressed-token/tests/e2e/create-token-pool.test.ts b/js/compressed-token/tests/e2e/create-token-pool.test.ts index 66f3658529..8f162f97f4 100644 --- a/js/compressed-token/tests/e2e/create-token-pool.test.ts +++ b/js/compressed-token/tests/e2e/create-token-pool.test.ts @@ -8,7 +8,11 @@ import { TOKEN_PROGRAM_ID, createInitializeMint2Instruction, } from '@solana/spl-token'; -import { addTokenPools, createMint, createTokenPool } from '../../src/actions'; +import { + addTokenPools, + createMint, + createSplInterface, +} from '../../src/actions'; import { Rpc, buildAndSignTx, @@ -21,7 +25,7 @@ import { TOKEN_2022_PROGRAM_ID } from '@solana/spl-token'; import { getTokenPoolInfos } from '../../src/utils'; /** - * Assert that createTokenPool() creates system-pool account for external mint, + * Assert that createSplInterface() creates system-pool account for external mint, * with external mintAuthority. */ async function assertRegisterMint( @@ -95,7 +99,7 @@ async function createTestSplMint( } const TEST_TOKEN_DECIMALS = 2; -describe('createTokenPool', () => { +describe('createSplInterface', () => { let rpc: Rpc; let payer: Signer; let mintKeypair: Keypair; @@ -129,7 +133,7 @@ describe('createTokenPool', () => { ), ).rejects.toThrow(); - await createTokenPool(rpc, payer, mint); + await createSplInterface(rpc, payer, mint); await assertRegisterMint( mint, @@ -140,7 +144,7 @@ describe('createTokenPool', () => { ); /// Mint already registered - await expect(createTokenPool(rpc, payer, mint)).rejects.toThrow(); + await expect(createSplInterface(rpc, payer, mint)).rejects.toThrow(); }); it('should register existing spl token22 mint', async () => { const token22MintKeypair = Keypair.generate(); @@ -174,7 +178,7 @@ describe('createTokenPool', () => { ), ).rejects.toThrow(); - await createTokenPool( + await createSplInterface( rpc, payer, token22Mint, @@ -193,7 +197,7 @@ describe('createTokenPool', () => { /// Mint already registered await expect( - createTokenPool( + createSplInterface( rpc, payer, token22Mint, @@ -208,7 +212,7 @@ describe('createTokenPool', () => { mintKeypair = Keypair.generate(); mint = mintKeypair.publicKey; await createTestSplMint(rpc, payer, mintKeypair, payer as Keypair); - await createTokenPool(rpc, payer, mint); + await createSplInterface(rpc, payer, mint); const poolAccount = CompressedTokenProgram.deriveTokenPoolPda(mint); await assertRegisterMint( @@ -229,8 +233,8 @@ describe('createTokenPool', () => { // Create external SPL mint await createTestSplMint(rpc, payer, newMintKeypair, newMintAuthority); - // First call to createTokenPool - await createTokenPool(rpc, payer, newMint, undefined); + // First call to createSplInterface + await createSplInterface(rpc, payer, newMint, undefined); // Verify first pool creation const poolAccount = CompressedTokenProgram.deriveTokenPoolPda(newMint); @@ -311,8 +315,8 @@ describe('createTokenPool', () => { true, // isToken22 ); - // First call to createTokenPool - await createTokenPool( + // First call to createSplInterface + await createSplInterface( rpc, payer, newMint, diff --git a/js/compressed-token/tests/e2e/get-account-interface.test.ts b/js/compressed-token/tests/e2e/get-account-interface.test.ts index 6a3961363c..fd56fc8a56 100644 --- a/js/compressed-token/tests/e2e/get-account-interface.test.ts +++ b/js/compressed-token/tests/e2e/get-account-interface.test.ts @@ -9,7 +9,7 @@ import { TreeInfo, VERSION, featureFlags, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, } from '@lightprotocol/stateless.js'; import { createMint as createSplMint, @@ -229,7 +229,7 @@ describe('get-account-interface', () => { }); }); - describe('c-token hot (CTOKEN_PROGRAM_ID)', () => { + describe('c-token hot (LIGHT_TOKEN_PROGRAM_ID)', () => { it('should fetch c-token hot account with explicit programId', async () => { const owner = await newAccountWithLamports(rpc, 1e9); const amount = bn(10000); @@ -240,6 +240,11 @@ describe('get-account-interface', () => { payer, ctokenMint, owner.publicKey, + false, + undefined, + undefined, + undefined, + { compressibleConfig: null }, ); await mintTo( @@ -265,7 +270,7 @@ describe('get-account-interface', () => { rpc, ctokenAta, 'confirmed', - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(result.parsed.address.toBase58()).toBe( @@ -320,7 +325,7 @@ describe('get-account-interface', () => { rpc, ctokenAta, 'confirmed', - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ), ).rejects.toThrow(); @@ -331,7 +336,7 @@ describe('get-account-interface', () => { owner.publicKey, ctokenMint, 'confirmed', - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(result.parsed.mint.toBase58()).toBe( @@ -359,6 +364,11 @@ describe('get-account-interface', () => { payer, ctokenMint, owner.publicKey, + false, + undefined, + undefined, + undefined, + { compressibleConfig: null }, ); await mintTo( @@ -478,6 +488,11 @@ describe('get-account-interface', () => { payer, ctokenMint, owner.publicKey, + false, + undefined, + undefined, + undefined, + { compressibleConfig: null }, ); await mintTo( @@ -577,6 +592,11 @@ describe('get-account-interface', () => { payer, ctokenMint, owner.publicKey, + false, + undefined, + undefined, + undefined, + { compressibleConfig: null }, ); // Mint and decompress first batch (hot) @@ -944,6 +964,11 @@ describe('get-account-interface', () => { payer, ctokenMint, owner.publicKey, + false, + undefined, + undefined, + undefined, + { compressibleConfig: null }, ); // Mint and decompress to create hot balance @@ -1005,6 +1030,11 @@ describe('get-account-interface', () => { payer, ctokenMint, owner.publicKey, + false, + undefined, + undefined, + undefined, + { compressibleConfig: null }, ); // Create hot first @@ -1056,4 +1086,628 @@ describe('get-account-interface', () => { expect(result.parsed.amount).toBe(expectedTotal); }); }); + + // ================================================================ + // FULL AGGREGATION COVERAGE + // ================================================================ + // Uses ctokenMint which is an SPL Token mint with a Light token pool, + // so both SPL ATAs and compressed accounts can coexist. + + const sortBigInt = (a: bigint, b: bigint) => (a < b ? -1 : a > b ? 1 : 0); + + describe('multi-cold aggregation', () => { + it('should aggregate 3 cold accounts with exact per-source amounts', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + const amounts = [1000n, 2000n, 3000n]; + + for (const amount of amounts) { + await mintTo( + rpc, + payer, + ctokenMint, + owner.publicKey, + mintAuthority, + bn(amount), + stateTreeInfo, + selectTokenPoolInfo(ctokenPoolInfos), + ); + } + + const ctokenAta = getAssociatedTokenAddressInterface( + ctokenMint, + owner.publicKey, + ); + const result = await getAtaInterface( + rpc, + ctokenAta, + owner.publicKey, + ctokenMint, + ); + + expect(result.parsed.amount).toBe(6000n); + expect(result._sources?.length).toBe(3); + expect(result.isCold).toBe(true); + expect(result._needsConsolidation).toBe(true); + + for (const source of result._sources!) { + expect(source.type).toBe(TokenAccountSourceType.CTokenCold); + } + + const sourceAmounts = result + ._sources!.map(s => s.amount) + .sort(sortBigInt); + expect(sourceAmounts).toEqual([1000n, 2000n, 3000n]); + }, 60_000); + + it('should aggregate ctoken-hot + 3 cold with exact per-source amounts', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + const hotAmount = 500n; + const coldAmounts = [1000n, 2000n, 3000n]; + + await createAtaInterfaceIdempotent( + rpc, + payer, + ctokenMint, + owner.publicKey, + false, + undefined, + undefined, + undefined, + { compressibleConfig: null }, + ); + await mintTo( + rpc, + payer, + ctokenMint, + owner.publicKey, + mintAuthority, + bn(hotAmount), + stateTreeInfo, + selectTokenPoolInfo(ctokenPoolInfos), + ); + await decompressInterface(rpc, payer, owner, ctokenMint); + + for (const amount of coldAmounts) { + await mintTo( + rpc, + payer, + ctokenMint, + owner.publicKey, + mintAuthority, + bn(amount), + stateTreeInfo, + selectTokenPoolInfo(ctokenPoolInfos), + ); + } + + const ctokenAta = getAssociatedTokenAddressInterface( + ctokenMint, + owner.publicKey, + ); + const result = await getAtaInterface( + rpc, + ctokenAta, + owner.publicKey, + ctokenMint, + ); + + expect(result.parsed.amount).toBe(6500n); + expect(result._sources?.length).toBe(4); + expect(result.isCold).toBe(false); + expect(result._needsConsolidation).toBe(true); + + // First source is hot (priority) + expect(result._sources![0].type).toBe( + TokenAccountSourceType.CTokenHot, + ); + expect(result._sources![0].amount).toBe(hotAmount); + + // Remaining are cold + const coldSources = result._sources!.slice(1); + for (const source of coldSources) { + expect(source.type).toBe(TokenAccountSourceType.CTokenCold); + } + const coldSourceAmounts = coldSources + .map(s => s.amount) + .sort(sortBigInt); + expect(coldSourceAmounts).toEqual([1000n, 2000n, 3000n]); + }, 120_000); + }); + + describe('SPL programId aggregation', () => { + it('should show SPL hot + spl-cold with exact amounts (programId=TOKEN_PROGRAM_ID)', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + const splHotAmount = 1500n; + const coldAmounts = [800n, 1200n]; + + // Create SPL ATA and mint SPL tokens directly + const splAta = await getOrCreateAssociatedTokenAccount( + rpc, + payer as Keypair, + ctokenMint, + owner.publicKey, + ); + await splMintTo( + rpc, + payer as Keypair, + ctokenMint, + splAta.address, + mintAuthority, + splHotAmount, + ); + + // Mint compressed tokens (will appear as spl-cold) + for (const amount of coldAmounts) { + await mintTo( + rpc, + payer, + ctokenMint, + owner.publicKey, + mintAuthority, + bn(amount), + stateTreeInfo, + selectTokenPoolInfo(ctokenPoolInfos), + ); + } + + const result = await getAtaInterface( + rpc, + splAta.address, + owner.publicKey, + ctokenMint, + undefined, + TOKEN_PROGRAM_ID, + ); + + expect(result.parsed.amount).toBe(3500n); + expect(result._sources?.length).toBe(3); + + // First source is SPL hot + expect(result._sources![0].type).toBe(TokenAccountSourceType.Spl); + expect(result._sources![0].amount).toBe(splHotAmount); + + // Cold sources are spl-cold + const coldSources = result._sources!.filter( + s => s.type === TokenAccountSourceType.SplCold, + ); + expect(coldSources.length).toBe(2); + const coldSourceAmounts = coldSources + .map(s => s.amount) + .sort(sortBigInt); + expect(coldSourceAmounts).toEqual([800n, 1200n]); + }, 60_000); + + it('should show spl-cold only when no SPL ATA exists (programId=TOKEN_PROGRAM_ID)', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + const coldAmounts = [700n, 1300n]; + + // Mint compressed tokens only (no SPL ATA created) + for (const amount of coldAmounts) { + await mintTo( + rpc, + payer, + ctokenMint, + owner.publicKey, + mintAuthority, + bn(amount), + stateTreeInfo, + selectTokenPoolInfo(ctokenPoolInfos), + ); + } + + // Derive SPL ATA address (not on-chain) + const splAta = getAssociatedTokenAddressSync( + ctokenMint, + owner.publicKey, + ); + + const result = await getAtaInterface( + rpc, + splAta, + owner.publicKey, + ctokenMint, + undefined, + TOKEN_PROGRAM_ID, + ); + + expect(result.parsed.amount).toBe(2000n); + expect(result._sources?.length).toBe(2); + expect(result.isCold).toBe(true); + + for (const source of result._sources!) { + expect(source.type).toBe(TokenAccountSourceType.SplCold); + } + const sourceAmounts = result + ._sources!.map(s => s.amount) + .sort(sortBigInt); + expect(sourceAmounts).toEqual([700n, 1300n]); + }, 60_000); + }); + + describe('cross-program unified aggregation (all modes from one setup)', () => { + // Shared state: ctoken-hot(3000) + 2 cold(1000,2000) + SPL hot(1500) + let unifiedOwner: Signer; + const uHotAmount = 3000n; + const uCold1 = 1000n; + const uCold2 = 2000n; + const uSplHot = 1500n; + + beforeAll(async () => { + unifiedOwner = await newAccountWithLamports(rpc, 2e9); + + // ctoken-hot: mint + decompress + await createAtaInterfaceIdempotent( + rpc, + payer, + ctokenMint, + unifiedOwner.publicKey, + false, + undefined, + undefined, + undefined, + { compressibleConfig: null }, + ); + await mintTo( + rpc, + payer, + ctokenMint, + unifiedOwner.publicKey, + mintAuthority, + bn(uHotAmount), + stateTreeInfo, + selectTokenPoolInfo(ctokenPoolInfos), + ); + await decompressInterface(rpc, payer, unifiedOwner, ctokenMint); + + // 2 cold accounts + await mintTo( + rpc, + payer, + ctokenMint, + unifiedOwner.publicKey, + mintAuthority, + bn(uCold1), + stateTreeInfo, + selectTokenPoolInfo(ctokenPoolInfos), + ); + await mintTo( + rpc, + payer, + ctokenMint, + unifiedOwner.publicKey, + mintAuthority, + bn(uCold2), + stateTreeInfo, + selectTokenPoolInfo(ctokenPoolInfos), + ); + + // SPL ATA with balance + const splAta = await getOrCreateAssociatedTokenAccount( + rpc, + payer as Keypair, + ctokenMint, + unifiedOwner.publicKey, + ); + await splMintTo( + rpc, + payer as Keypair, + ctokenMint, + splAta.address, + mintAuthority, + uSplHot, + ); + }, 120_000); + + it('wrap=true: aggregates ctoken-hot + ctoken-cold + SPL hot', async () => { + const ctokenAta = getAssociatedTokenAddressInterface( + ctokenMint, + unifiedOwner.publicKey, + ); + const result = await getAtaInterface( + rpc, + ctokenAta, + unifiedOwner.publicKey, + ctokenMint, + undefined, + undefined, + true, + ); + + expect(result.parsed.amount).toBe( + uHotAmount + uCold1 + uCold2 + uSplHot, + ); // 7500 + expect(result._needsConsolidation).toBe(true); + + const types = result._sources!.map(s => s.type); + expect(types).toContain(TokenAccountSourceType.CTokenHot); + expect(types).toContain(TokenAccountSourceType.CTokenCold); + expect(types).toContain(TokenAccountSourceType.Spl); + + // Priority: ctoken-hot first + expect(result._sources![0].type).toBe( + TokenAccountSourceType.CTokenHot, + ); + expect(result._sources![0].amount).toBe(uHotAmount); + + // SPL source amount + const splSource = result._sources!.find( + s => s.type === TokenAccountSourceType.Spl, + ); + expect(splSource!.amount).toBe(uSplHot); + + // Cold amounts + const coldSources = result._sources!.filter( + s => s.type === TokenAccountSourceType.CTokenCold, + ); + expect(coldSources.length).toBe(2); + expect(coldSources.map(s => s.amount).sort(sortBigInt)).toEqual([ + uCold1, + uCold2, + ]); + }); + + it('wrap=false: excludes SPL sources', async () => { + const ctokenAta = getAssociatedTokenAddressInterface( + ctokenMint, + unifiedOwner.publicKey, + ); + const result = await getAtaInterface( + rpc, + ctokenAta, + unifiedOwner.publicKey, + ctokenMint, + undefined, + undefined, + false, + ); + + expect(result.parsed.amount).toBe(uHotAmount + uCold1 + uCold2); // 6000 + + const types = result._sources!.map(s => s.type); + expect(types).not.toContain(TokenAccountSourceType.Spl); + expect(types).not.toContain(TokenAccountSourceType.Token2022); + expect(types).toContain(TokenAccountSourceType.CTokenHot); + expect(types).toContain(TokenAccountSourceType.CTokenCold); + }); + + it('programId=TOKEN_PROGRAM_ID: shows SPL hot + compressed as spl-cold', async () => { + const splAta = getAssociatedTokenAddressSync( + ctokenMint, + unifiedOwner.publicKey, + ); + const result = await getAtaInterface( + rpc, + splAta, + unifiedOwner.publicKey, + ctokenMint, + undefined, + TOKEN_PROGRAM_ID, + ); + + expect(result.parsed.amount).toBe(uSplHot + uCold1 + uCold2); // 4500 + + const types = result._sources!.map(s => s.type); + expect(types).not.toContain(TokenAccountSourceType.CTokenHot); + expect(types).toContain(TokenAccountSourceType.Spl); + expect(types).toContain(TokenAccountSourceType.SplCold); + + const splSource = result._sources!.find( + s => s.type === TokenAccountSourceType.Spl, + ); + expect(splSource!.amount).toBe(uSplHot); + + const coldSources = result._sources!.filter( + s => s.type === TokenAccountSourceType.SplCold, + ); + expect(coldSources.length).toBe(2); + expect(coldSources.map(s => s.amount).sort(sortBigInt)).toEqual([ + uCold1, + uCold2, + ]); + }); + + it('programId=LIGHT_TOKEN: shows ctoken-hot + ctoken-cold only', async () => { + const ctokenAta = getAssociatedTokenAddressInterface( + ctokenMint, + unifiedOwner.publicKey, + ); + const result = await getAtaInterface( + rpc, + ctokenAta, + unifiedOwner.publicKey, + ctokenMint, + undefined, + LIGHT_TOKEN_PROGRAM_ID, + ); + + expect(result.parsed.amount).toBe(uHotAmount + uCold1 + uCold2); // 6000 + + const types = result._sources!.map(s => s.type); + expect(types).not.toContain(TokenAccountSourceType.Spl); + expect(types).not.toContain(TokenAccountSourceType.SplCold); + expect(types).toContain(TokenAccountSourceType.CTokenHot); + expect(types).toContain(TokenAccountSourceType.CTokenCold); + + expect(result._sources![0].type).toBe( + TokenAccountSourceType.CTokenHot, + ); + expect(result._sources![0].amount).toBe(uHotAmount); + }); + }); + + describe('wrap=true edge cases', () => { + it('wrap=true with only SPL hot (no ctoken accounts)', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + const splHotAmount = 5000n; + + const splAta = await getOrCreateAssociatedTokenAccount( + rpc, + payer as Keypair, + ctokenMint, + owner.publicKey, + ); + await splMintTo( + rpc, + payer as Keypair, + ctokenMint, + splAta.address, + mintAuthority, + splHotAmount, + ); + + const ctokenAta = getAssociatedTokenAddressInterface( + ctokenMint, + owner.publicKey, + ); + const result = await getAtaInterface( + rpc, + ctokenAta, + owner.publicKey, + ctokenMint, + undefined, + undefined, + true, + ); + + expect(result.parsed.amount).toBe(splHotAmount); + expect(result._sources?.length).toBe(1); + expect(result._sources![0].type).toBe(TokenAccountSourceType.Spl); + expect(result._sources![0].amount).toBe(splHotAmount); + }, 60_000); + + it('wrap=true with ctoken-cold + SPL hot (no ctoken-hot)', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + const coldAmount = 2000n; + const splHotAmount = 3000n; + + // Cold only + await mintTo( + rpc, + payer, + ctokenMint, + owner.publicKey, + mintAuthority, + bn(coldAmount), + stateTreeInfo, + selectTokenPoolInfo(ctokenPoolInfos), + ); + + // SPL hot + const splAta = await getOrCreateAssociatedTokenAccount( + rpc, + payer as Keypair, + ctokenMint, + owner.publicKey, + ); + await splMintTo( + rpc, + payer as Keypair, + ctokenMint, + splAta.address, + mintAuthority, + splHotAmount, + ); + + const ctokenAta = getAssociatedTokenAddressInterface( + ctokenMint, + owner.publicKey, + ); + const result = await getAtaInterface( + rpc, + ctokenAta, + owner.publicKey, + ctokenMint, + undefined, + undefined, + true, + ); + + expect(result.parsed.amount).toBe(coldAmount + splHotAmount); // 5000 + expect(result._sources?.length).toBe(2); + + const coldSource = result._sources!.find( + s => s.type === TokenAccountSourceType.CTokenCold, + ); + expect(coldSource!.amount).toBe(coldAmount); + + const splSource = result._sources!.find( + s => s.type === TokenAccountSourceType.Spl, + ); + expect(splSource!.amount).toBe(splHotAmount); + }, 60_000); + + it('wrap=true with ctoken-hot + SPL hot (no cold)', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + const hotAmount = 4000n; + const splHotAmount = 2000n; + + // ctoken-hot + await createAtaInterfaceIdempotent( + rpc, + payer, + ctokenMint, + owner.publicKey, + false, + undefined, + undefined, + undefined, + { compressibleConfig: null }, + ); + await mintTo( + rpc, + payer, + ctokenMint, + owner.publicKey, + mintAuthority, + bn(hotAmount), + stateTreeInfo, + selectTokenPoolInfo(ctokenPoolInfos), + ); + await decompressInterface(rpc, payer, owner, ctokenMint); + + // SPL hot + const splAta = await getOrCreateAssociatedTokenAccount( + rpc, + payer as Keypair, + ctokenMint, + owner.publicKey, + ); + await splMintTo( + rpc, + payer as Keypair, + ctokenMint, + splAta.address, + mintAuthority, + splHotAmount, + ); + + const ctokenAta = getAssociatedTokenAddressInterface( + ctokenMint, + owner.publicKey, + ); + const result = await getAtaInterface( + rpc, + ctokenAta, + owner.publicKey, + ctokenMint, + undefined, + undefined, + true, + ); + + expect(result.parsed.amount).toBe(hotAmount + splHotAmount); // 6000 + expect(result._sources?.length).toBe(2); + expect(result._needsConsolidation).toBe(true); + + expect(result._sources![0].type).toBe( + TokenAccountSourceType.CTokenHot, + ); + expect(result._sources![0].amount).toBe(hotAmount); + + const splSource = result._sources!.find( + s => s.type === TokenAccountSourceType.Spl, + ); + expect(splSource!.amount).toBe(splHotAmount); + }, 120_000); + }); }); diff --git a/js/compressed-token/tests/e2e/get-mint-interface.test.ts b/js/compressed-token/tests/e2e/get-mint-interface.test.ts index 61771fa715..ff0f29db4c 100644 --- a/js/compressed-token/tests/e2e/get-mint-interface.test.ts +++ b/js/compressed-token/tests/e2e/get-mint-interface.test.ts @@ -6,7 +6,7 @@ import { createRpc, VERSION, featureFlags, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, } from '@lightprotocol/stateless.js'; import { TOKEN_PROGRAM_ID, @@ -47,7 +47,7 @@ describe('getMintInterface', () => { payer = await newAccountWithLamports(rpc, 10e9); }); - describe('CToken mint (CTOKEN_PROGRAM_ID)', () => { + describe('CToken mint (LIGHT_TOKEN_PROGRAM_ID)', () => { it('should fetch compressed mint with explicit programId', async () => { const mintSigner = Keypair.generate(); const mintAuthority = Keypair.generate(); @@ -68,7 +68,7 @@ describe('getMintInterface', () => { rpc, mintPda, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(result.mint.address.toBase58()).toBe(mintPda.toBase58()); @@ -80,7 +80,7 @@ describe('getMintInterface', () => { expect(result.mint.isInitialized).toBe(true); expect(result.mint.freezeAuthority).toBeNull(); expect(result.programId.toBase58()).toBe( - CTOKEN_PROGRAM_ID.toBase58(), + LIGHT_TOKEN_PROGRAM_ID.toBase58(), ); expect(result.merkleContext).toBeDefined(); expect(result.mintContext).toBeDefined(); @@ -107,7 +107,7 @@ describe('getMintInterface', () => { rpc, mintPda, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(result.mint.freezeAuthority?.toBase58()).toBe( @@ -136,7 +136,7 @@ describe('getMintInterface', () => { decimals, mintSigner, { skipPreflight: true }, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, metadata, ); @@ -144,7 +144,7 @@ describe('getMintInterface', () => { rpc, mintPda, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(result.tokenMetadata).toBeDefined(); @@ -161,7 +161,12 @@ describe('getMintInterface', () => { const fakeMint = Keypair.generate().publicKey; await expect( - getMintInterface(rpc, fakeMint, undefined, CTOKEN_PROGRAM_ID), + getMintInterface( + rpc, + fakeMint, + undefined, + LIGHT_TOKEN_PROGRAM_ID, + ), ).rejects.toThrow('Compressed mint not found'); }); }); @@ -338,7 +343,7 @@ describe('getMintInterface', () => { expect(result.mint.address.toBase58()).toBe(mintPda.toBase58()); expect(result.programId.toBase58()).toBe( - CTOKEN_PROGRAM_ID.toBase58(), + LIGHT_TOKEN_PROGRAM_ID.toBase58(), ); expect(result.merkleContext).toBeDefined(); expect(result.mintContext).toBeDefined(); @@ -373,7 +378,7 @@ describe('getMintInterface', () => { rpc, mintPda, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(result.mintContext).toBeDefined(); @@ -405,7 +410,7 @@ describe('getMintInterface', () => { rpc, mintPda, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(result.merkleContext).toBeDefined(); @@ -574,7 +579,7 @@ describe('unpackMintInterface', () => { const result = unpackMintInterface( mintAddress, buffer, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(result.mint.address.toBase58()).toBe(mintAddress.toBase58()); @@ -585,7 +590,7 @@ describe('unpackMintInterface', () => { expect(result.mint.decimals).toBe(9); expect(result.mint.isInitialized).toBe(true); expect(result.programId.toBase58()).toBe( - CTOKEN_PROGRAM_ID.toBase58(), + LIGHT_TOKEN_PROGRAM_ID.toBase58(), ); expect(result.mintContext).toBeDefined(); expect(result.mintContext!.version).toBe(1); @@ -638,7 +643,7 @@ describe('unpackMintInterface', () => { const result = unpackMintInterface( mintAddress, buffer, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(result.tokenMetadata).toBeDefined(); @@ -680,7 +685,7 @@ describe('unpackMintInterface', () => { const result = unpackMintInterface( mintAddress, buffer, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(result.mint.supply).toBe(100n); @@ -713,7 +718,7 @@ describe('unpackMintInterface', () => { const result = unpackMintInterface( mintAddress, uint8Array, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(result.mint.supply).toBe(200n); diff --git a/js/compressed-token/tests/e2e/get-or-create-ata-interface.test.ts b/js/compressed-token/tests/e2e/get-or-create-ata-interface.test.ts index a3c0895a67..735eedd5c2 100644 --- a/js/compressed-token/tests/e2e/get-or-create-ata-interface.test.ts +++ b/js/compressed-token/tests/e2e/get-or-create-ata-interface.test.ts @@ -6,7 +6,7 @@ import { createRpc, VERSION, featureFlags, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, } from '@lightprotocol/stateless.js'; import { TOKEN_PROGRAM_ID, @@ -22,7 +22,6 @@ import { import { createMintInterface } from '../../src/v3/actions/create-mint-interface'; import { createAtaInterfaceIdempotent } from '../../src/v3/actions/create-ata-interface'; import { getOrCreateAtaInterface } from '../../src/v3/actions/get-or-create-ata-interface'; -import { decompressMint } from '../../src/v3/actions/decompress-mint'; import { getAssociatedTokenAddressInterface } from '../../src/v3/get-associated-token-address-interface'; import { findMintAddress } from '../../src/v3/derivation'; import { getAtaProgramId } from '../../src/v3/ata-utils'; @@ -415,7 +414,7 @@ describe('getOrCreateAtaInterface', () => { }); }); - describe('c-token (CTOKEN_PROGRAM_ID)', () => { + describe('c-token (LIGHT_TOKEN_PROGRAM_ID)', () => { let ctokenMint: PublicKey; let mintAuthority: Keypair; @@ -433,9 +432,6 @@ describe('getOrCreateAtaInterface', () => { mintSigner, ); ctokenMint = mintPda; - - // Decompress mint so it exists on-chain (required for CToken ATA creation) - await decompressMint(rpc, payer, ctokenMint); }); it('should create c-token ATA when it does not exist (uninited)', async () => { @@ -445,7 +441,7 @@ describe('getOrCreateAtaInterface', () => { ctokenMint, owner.publicKey, false, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); // Verify ATA does not exist @@ -461,7 +457,7 @@ describe('getOrCreateAtaInterface', () => { false, undefined, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(account.parsed.address.toBase58()).toBe( @@ -477,7 +473,7 @@ describe('getOrCreateAtaInterface', () => { const afterInfo = await rpc.getAccountInfo(expectedAddress); expect(afterInfo).not.toBe(null); expect(afterInfo?.owner.toBase58()).toBe( - CTOKEN_PROGRAM_ID.toBase58(), + LIGHT_TOKEN_PROGRAM_ID.toBase58(), ); }); @@ -492,14 +488,14 @@ describe('getOrCreateAtaInterface', () => { owner.publicKey, false, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); const expectedAddress = getAssociatedTokenAddressInterface( ctokenMint, owner.publicKey, false, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); // Call getOrCreateAtaInterface on existing hot ATA @@ -511,7 +507,7 @@ describe('getOrCreateAtaInterface', () => { false, undefined, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(account.parsed.address.toBase58()).toBe( @@ -533,7 +529,7 @@ describe('getOrCreateAtaInterface', () => { ctokenMint, pdaOwner, true, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); const account = await getOrCreateAtaInterface( @@ -544,7 +540,7 @@ describe('getOrCreateAtaInterface', () => { true, undefined, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(account.parsed.address.toBase58()).toBe( @@ -569,9 +565,6 @@ describe('getOrCreateAtaInterface', () => { mintSigner, ); - // Decompress mint so it exists on-chain (required for CToken ATA creation) - await decompressMint(rpc, payer, testMint); - // Create ATA await createAtaInterfaceIdempotent( rpc, @@ -580,14 +573,14 @@ describe('getOrCreateAtaInterface', () => { owner.publicKey, false, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); const expectedAddress = getAssociatedTokenAddressInterface( testMint, owner.publicKey, false, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); // Note: Minting to c-token hot accounts uses mintToInterface which @@ -600,7 +593,7 @@ describe('getOrCreateAtaInterface', () => { false, undefined, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(account.parsed.address.toBase58()).toBe( @@ -626,20 +619,16 @@ describe('getOrCreateAtaInterface', () => { ); // Mint compressed tokens directly (creates cold balance, no hot ATA) - // Must happen BEFORE decompressMint since mintToCompressed needs compressed mint const mintAmount = 1000000n; await mintToCompressed(rpc, payer, testMint, testMintAuth, [ { recipient: owner.publicKey, amount: mintAmount }, ]); - // Decompress mint so it exists on-chain (required for CToken ATA creation) - await decompressMint(rpc, payer, testMint); - const expectedAddress = getAssociatedTokenAddressInterface( testMint, owner.publicKey, false, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); // Verify NO hot ATA exists before call @@ -662,7 +651,7 @@ describe('getOrCreateAtaInterface', () => { false, undefined, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); // Verify account has aggregated balance (from cold) @@ -699,20 +688,16 @@ describe('getOrCreateAtaInterface', () => { ); // Mint compressed tokens directly (creates cold balance, no hot ATA) - // Must happen BEFORE decompressMint since mintToCompressed needs compressed mint const mintAmount = 1000000n; await mintToCompressed(rpc, payer, testMint, testMintAuth, [ { recipient: owner.publicKey, amount: mintAmount }, ]); - // Decompress mint so it exists on-chain (required for CToken ATA creation) - await decompressMint(rpc, payer, testMint); - const expectedAddress = getAssociatedTokenAddressInterface( testMint, owner.publicKey, false, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); // Verify NO hot ATA exists before call @@ -735,7 +720,7 @@ describe('getOrCreateAtaInterface', () => { false, undefined, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); // Verify correct address @@ -750,7 +735,7 @@ describe('getOrCreateAtaInterface', () => { const afterInfo = await rpc.getAccountInfo(expectedAddress); expect(afterInfo).not.toBe(null); expect(afterInfo?.owner.toBase58()).toBe( - CTOKEN_PROGRAM_ID.toBase58(), + LIGHT_TOKEN_PROGRAM_ID.toBase58(), ); // Parse hot balance const hotBalance = afterInfo!.data.readBigUInt64LE(64); @@ -788,15 +773,11 @@ describe('getOrCreateAtaInterface', () => { ); // Mint compressed tokens first (creates cold balance) - // Must happen BEFORE decompressMint since mintToCompressed needs compressed mint const coldAmount = 500000n; await mintToCompressed(rpc, payer, testMint, testMintAuth, [ { recipient: owner.publicKey, amount: coldAmount }, ]); - // Decompress mint so it exists on-chain (required for CToken ATA creation) - await decompressMint(rpc, payer, testMint); - // Create hot ATA (after decompression) await createAtaInterfaceIdempotent( rpc, @@ -805,7 +786,7 @@ describe('getOrCreateAtaInterface', () => { owner.publicKey, false, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); // Call getOrCreateAtaInterface @@ -817,7 +798,7 @@ describe('getOrCreateAtaInterface', () => { false, undefined, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); // Verify aggregated balance (hot=0 + cold=coldAmount) @@ -825,7 +806,7 @@ describe('getOrCreateAtaInterface', () => { }); }); - describe('default programId (CTOKEN_PROGRAM_ID)', () => { + describe('default programId (LIGHT_TOKEN_PROGRAM_ID)', () => { let ctokenMint: PublicKey; beforeAll(async () => { @@ -838,20 +819,17 @@ describe('getOrCreateAtaInterface', () => { 9, ); ctokenMint = result.mint; - - // Decompress mint so it exists on-chain (required for CToken ATA creation) - await decompressMint(rpc, payer, ctokenMint); }); - it('should default to CTOKEN_PROGRAM_ID when programId not specified', async () => { + it('should default to LIGHT_TOKEN_PROGRAM_ID when programId not specified', async () => { const owner = Keypair.generate(); const expectedAddress = getAssociatedTokenAddressSync( ctokenMint, owner.publicKey, false, - CTOKEN_PROGRAM_ID, - CTOKEN_PROGRAM_ID, // c-token uses CTOKEN_PROGRAM_ID as ATA program + LIGHT_TOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, // c-token uses LIGHT_TOKEN_PROGRAM_ID as ATA program ); // Call without specifying programId @@ -866,9 +844,11 @@ describe('getOrCreateAtaInterface', () => { expectedAddress.toBase58(), ); - // Verify it's owned by CTOKEN_PROGRAM_ID + // Verify it's owned by LIGHT_TOKEN_PROGRAM_ID const info = await rpc.getAccountInfo(expectedAddress); - expect(info?.owner.toBase58()).toBe(CTOKEN_PROGRAM_ID.toBase58()); + expect(info?.owner.toBase58()).toBe( + LIGHT_TOKEN_PROGRAM_ID.toBase58(), + ); }); }); @@ -945,9 +925,6 @@ describe('getOrCreateAtaInterface', () => { mintSigner, ); - // Decompress mint so it exists on-chain (required for CToken ATA creation) - await decompressMint(rpc, payer, testMint); - const account1 = await getOrCreateAtaInterface( rpc, payer, @@ -956,7 +933,7 @@ describe('getOrCreateAtaInterface', () => { false, undefined, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); const account2 = await getOrCreateAtaInterface( @@ -967,7 +944,7 @@ describe('getOrCreateAtaInterface', () => { false, undefined, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(account1.parsed.address.toBase58()).toBe( @@ -1017,9 +994,6 @@ describe('getOrCreateAtaInterface', () => { mintSigner, ); - // Decompress mint so it exists on-chain (required for CToken ATA creation) - await decompressMint(rpc, payer, ctokenMint); - // Get/Create ATAs for all programs const splAccount = await getOrCreateAtaInterface( rpc, @@ -1051,7 +1025,7 @@ describe('getOrCreateAtaInterface', () => { false, undefined, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); // All addresses should be different (different mints) diff --git a/js/compressed-token/tests/e2e/input-selection.test.ts b/js/compressed-token/tests/e2e/input-selection.test.ts new file mode 100644 index 0000000000..76a69dea46 --- /dev/null +++ b/js/compressed-token/tests/e2e/input-selection.test.ts @@ -0,0 +1,537 @@ +/** + * Input Selection Test Suite + * + * Tests amount-aware greedy input selection in createTransferInterfaceInstructions. + * Verifies that only the cold inputs needed for the transfer amount are loaded, + * padded to MAX_INPUT_ACCOUNTS (8) when within a single batch. + * + * Key behavioral changes tested: + * - 20 cold inputs, small transfer: 1 tx (8 selected) instead of 3 txs (all 20) + * - 20 cold inputs, large transfer: 3 txs (all needed) -- unchanged + * - Hot balance sufficient: 0 loads + * - SPL wraps reduce cold inputs needed + * + * Every test asserts: + * 1. Batch count matches expected value + * 2. Each batch serializes within MAX_TRANSACTION_SIZE + * 3. estimateTransactionSize cross-checks against actual serialized size + * 4. Recipient receives the correct amount + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import { + Keypair, + Signer, + PublicKey, + ComputeBudgetProgram, +} from '@solana/web3.js'; +import { + Rpc, + bn, + newAccountWithLamports, + createRpc, + selectStateTreeInfo, + TreeInfo, + VERSION, + featureFlags, + buildAndSignTx, + sendAndConfirmTx, +} from '@lightprotocol/stateless.js'; +import { createMint, mintTo } from '../../src/actions'; +import { + getTokenPoolInfos, + selectTokenPoolInfo, + TokenPoolInfo, +} from '../../src/utils/get-token-pool-infos'; +import { getAssociatedTokenAddressInterface } from '../../src/v3/get-associated-token-address-interface'; +import { getOrCreateAtaInterface } from '../../src/v3/actions/get-or-create-ata-interface'; +import { + createTransferInterfaceInstructions, + sliceLast, +} from '../../src/v3/actions/transfer-interface'; +import { loadAta } from '../../src/v3/actions/load-ata'; +import { + estimateTransactionSize, + MAX_TRANSACTION_SIZE, +} from '../../src/v3/utils/estimate-tx-size'; + +featureFlags.version = VERSION.V2; + +const TEST_TOKEN_DECIMALS = 9; + +async function mintMultipleColdAccounts( + rpc: Rpc, + payer: Signer, + mint: PublicKey, + owner: PublicKey, + mintAuthority: Keypair, + count: number, + amountPerAccount: bigint, + stateTreeInfo: TreeInfo, + tokenPoolInfos: TokenPoolInfo[], +): Promise { + for (let i = 0; i < count; i++) { + await mintTo( + rpc, + payer, + mint, + owner, + mintAuthority, + bn(amountPerAccount.toString()), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + } +} + +/** + * Assert that every batch in the result fits within MAX_TRANSACTION_SIZE. + * Builds a real VersionedTransaction for each batch to get the actual + * serialized size, and cross-checks against estimateTransactionSize. + */ +async function assertAllBatchesFitInTx( + rpc: Rpc, + batches: any[][], + payer: Signer, + signers: Signer[], +): Promise { + for (let i = 0; i < batches.length; i++) { + const ixs = batches[i]; + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx(ixs, payer, blockhash, signers); + const serialized = tx.serialize().length; + + expect(serialized).toBeLessThanOrEqual(MAX_TRANSACTION_SIZE); + + // Cross-check estimate (payer + signers, deduplicated) + const allSignerKeys = new Set([ + payer.publicKey.toBase58(), + ...signers.map(s => s.publicKey.toBase58()), + ]); + const estimate = estimateTransactionSize(ixs, allSignerKeys.size); + expect(Math.abs(estimate - serialized)).toBeLessThanOrEqual(10); + } +} + +describe('Input Selection', () => { + let rpc: Rpc; + let payer: Signer; + let mint: PublicKey; + let mintAuthority: Keypair; + let stateTreeInfo: TreeInfo; + let tokenPoolInfos: TokenPoolInfo[]; + + beforeAll(async () => { + rpc = createRpc(); + payer = await newAccountWithLamports(rpc, 50e9); + mintAuthority = Keypair.generate(); + const mintKeypair = Keypair.generate(); + + mint = ( + await createMint( + rpc, + payer, + mintAuthority.publicKey, + TEST_TOKEN_DECIMALS, + mintKeypair, + ) + ).mint; + + stateTreeInfo = selectStateTreeInfo(await rpc.getStateTreeInfos()); + tokenPoolInfos = await getTokenPoolInfos(rpc, mint); + }, 120_000); + + describe('createTransferInterfaceInstructions with amount-aware selection', () => { + it('0 cold inputs (hot only): 1 batch, no loads', async () => { + const sender = await newAccountWithLamports(rpc, 1e9); + const recipient = Keypair.generate(); + + // Mint and load to make sender hot + await mintTo( + rpc, + payer, + mint, + sender.publicKey, + mintAuthority, + bn(5000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + const senderAta = getAssociatedTokenAddressInterface( + mint, + sender.publicKey, + ); + await loadAta(rpc, senderAta, sender, mint); + + await getOrCreateAtaInterface( + rpc, + payer, + mint, + recipient.publicKey, + ); + + const batches = await createTransferInterfaceInstructions( + rpc, + payer.publicKey, + mint, + BigInt(1000), + sender.publicKey, + recipient.publicKey, + ); + + // Hot sender: single transfer tx, no loads + expect(batches.length).toBe(1); + await assertAllBatchesFitInTx(rpc, batches, payer, [sender]); + + // Send and verify + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx(batches[0], payer, blockhash, [sender]); + await sendAndConfirmTx(rpc, tx); + + const recipientAta = getAssociatedTokenAddressInterface( + mint, + recipient.publicKey, + ); + const recipientBalance = (await rpc.getAccountInfo( + recipientAta, + ))!.data.readBigUInt64LE(64); + expect(recipientBalance).toBe(BigInt(1000)); + }, 120_000); + + it('1 cold input: 1 batch (load + transfer combined)', async () => { + const sender = await newAccountWithLamports(rpc, 1e9); + const recipient = Keypair.generate(); + + await mintTo( + rpc, + payer, + mint, + sender.publicKey, + mintAuthority, + bn(3000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + await getOrCreateAtaInterface( + rpc, + payer, + mint, + recipient.publicKey, + ); + + const batches = await createTransferInterfaceInstructions( + rpc, + payer.publicKey, + mint, + BigInt(2000), + sender.publicKey, + recipient.publicKey, + ); + + // 1 cold input fits in single batch with transfer + expect(batches.length).toBe(1); + await assertAllBatchesFitInTx(rpc, batches, payer, [sender]); + + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx(batches[0], payer, blockhash, [sender]); + await sendAndConfirmTx(rpc, tx); + + const recipientAta = getAssociatedTokenAddressInterface( + mint, + recipient.publicKey, + ); + const recipientBalance = (await rpc.getAccountInfo( + recipientAta, + ))!.data.readBigUInt64LE(64); + expect(recipientBalance).toBe(BigInt(2000)); + }, 120_000); + + it('8 cold inputs, small transfer: 1 batch (all 8 loaded, pads to fill)', async () => { + const sender = await newAccountWithLamports(rpc, 1e9); + const recipient = Keypair.generate(); + + // 8 inputs of 1000 each = 8000 total + await mintMultipleColdAccounts( + rpc, + payer, + mint, + sender.publicKey, + mintAuthority, + 8, + 1000n, + stateTreeInfo, + tokenPoolInfos, + ); + + await getOrCreateAtaInterface( + rpc, + payer, + mint, + recipient.publicKey, + ); + + // Transfer only 500 (1 input would suffice, but pads to 8) + const batches = await createTransferInterfaceInstructions( + rpc, + payer.publicKey, + mint, + BigInt(500), + sender.publicKey, + recipient.publicKey, + ); + + // All 8 loaded (padding fills to MAX_INPUT_ACCOUNTS), combined with transfer + expect(batches.length).toBe(1); + await assertAllBatchesFitInTx(rpc, batches, payer, [sender]); + + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx(batches[0], payer, blockhash, [sender]); + await sendAndConfirmTx(rpc, tx); + + const recipientAta = getAssociatedTokenAddressInterface( + mint, + recipient.publicKey, + ); + const recipientBalance = (await rpc.getAccountInfo( + recipientAta, + ))!.data.readBigUInt64LE(64); + expect(recipientBalance).toBe(BigInt(500)); + }, 120_000); + + it('20 cold inputs, small transfer (needs <=8): 1 batch instead of 3', async () => { + const sender = await newAccountWithLamports(rpc, 2e9); + const recipient = Keypair.generate(); + + // 20 inputs of 1000 each = 20000 total + await mintMultipleColdAccounts( + rpc, + payer, + mint, + sender.publicKey, + mintAuthority, + 20, + 1000n, + stateTreeInfo, + tokenPoolInfos, + ); + + await getOrCreateAtaInterface( + rpc, + payer, + mint, + recipient.publicKey, + ); + + // Transfer 500: only 1 input needed, pads to 8. + // _buildLoadBatches returns 1 internal batch (8 inputs). + // Assembly combines load + transfer = 1 tx. + const batches = await createTransferInterfaceInstructions( + rpc, + payer.publicKey, + mint, + BigInt(500), + sender.publicKey, + recipient.publicKey, + ); + + // KEY BEHAVIORAL CHANGE: 1 batch instead of 3 + expect(batches.length).toBe(1); + await assertAllBatchesFitInTx(rpc, batches, payer, [sender]); + + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx(batches[0], payer, blockhash, [sender]); + await sendAndConfirmTx(rpc, tx); + + const recipientAta = getAssociatedTokenAddressInterface( + mint, + recipient.publicKey, + ); + const recipientBalance = (await rpc.getAccountInfo( + recipientAta, + ))!.data.readBigUInt64LE(64); + expect(recipientBalance).toBe(BigInt(500)); + + // Sender should have loaded 8 * 1000 = 8000, sent 500, change = 7500 + const senderAta = getAssociatedTokenAddressInterface( + mint, + sender.publicKey, + ); + const senderBalance = (await rpc.getAccountInfo( + senderAta, + ))!.data.readBigUInt64LE(64); + expect(senderBalance).toBe(BigInt(7500)); + }, 240_000); + + it('20 cold inputs, large transfer (needs all): 3 batches (unchanged)', async () => { + const sender = await newAccountWithLamports(rpc, 2e9); + const recipient = Keypair.generate(); + + // 20 inputs of 50 each = 1000 total + await mintMultipleColdAccounts( + rpc, + payer, + mint, + sender.publicKey, + mintAuthority, + 20, + 50n, + stateTreeInfo, + tokenPoolInfos, + ); + + await getOrCreateAtaInterface( + rpc, + payer, + mint, + recipient.publicKey, + ); + + // Transfer 900: needs 18 inputs (18*50=900), selects all 20. + // 20 inputs -> 3 internal batches (8+8+4) -> 3 txs + const batches = await createTransferInterfaceInstructions( + rpc, + payer.publicKey, + mint, + BigInt(900), + sender.publicKey, + recipient.publicKey, + ); + + expect(batches.length).toBe(3); + await assertAllBatchesFitInTx(rpc, batches, payer, [sender]); + + // Send: loads in parallel, then transfer + 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, [sender]); + return sendAndConfirmTx(rpc, tx); + }), + ); + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx(transferIxs, payer, blockhash, [sender]); + await sendAndConfirmTx(rpc, tx); + + const recipientAta = getAssociatedTokenAddressInterface( + mint, + recipient.publicKey, + ); + const recipientBalance = (await rpc.getAccountInfo( + recipientAta, + ))!.data.readBigUInt64LE(64); + expect(recipientBalance).toBe(BigInt(900)); + }, 240_000); + + it('ATA creation mixed in: included in batch alongside selected inputs', async () => { + const sender = await newAccountWithLamports(rpc, 1e9); + const recipient = Keypair.generate(); + + // 3 cold inputs + await mintMultipleColdAccounts( + rpc, + payer, + mint, + sender.publicKey, + mintAuthority, + 3, + 2000n, + stateTreeInfo, + tokenPoolInfos, + ); + + // Do NOT create recipient ATA -- let transfer create it + const batches = await createTransferInterfaceInstructions( + rpc, + payer.publicKey, + mint, + BigInt(1000), + sender.publicKey, + recipient.publicKey, + // ensureRecipientAta defaults to true + ); + + // 3 cold inputs -> 1 internal batch, combined with transfer + ATA creation + expect(batches.length).toBe(1); + await assertAllBatchesFitInTx(rpc, batches, payer, [sender]); + + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx(batches[0], payer, blockhash, [sender]); + await sendAndConfirmTx(rpc, tx); + + // Verify recipient ATA was created and has correct balance + const recipientAta = getAssociatedTokenAddressInterface( + mint, + recipient.publicKey, + ); + const recipientBalance = (await rpc.getAccountInfo( + recipientAta, + ))!.data.readBigUInt64LE(64); + expect(recipientBalance).toBe(BigInt(1000)); + }, 120_000); + + it('selection sufficiency: exact amount covered by selected inputs', async () => { + const sender = await newAccountWithLamports(rpc, 1e9); + const recipient = Keypair.generate(); + + // 10 inputs with varying amounts (descending: 1000, 900, ..., 100) + for (let i = 0; i < 10; i++) { + await mintTo( + rpc, + payer, + mint, + sender.publicKey, + mintAuthority, + bn((1000 - i * 100).toString()), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + } + + await getOrCreateAtaInterface( + rpc, + payer, + mint, + recipient.publicKey, + ); + + // Transfer 2500: needs 1000+900+800 = 2700 >= 2500 (3 inputs). + // Pads to 8 since only 1 batch needed. + const batches = await createTransferInterfaceInstructions( + rpc, + payer.publicKey, + mint, + BigInt(2500), + sender.publicKey, + recipient.publicKey, + ); + + expect(batches.length).toBe(1); + await assertAllBatchesFitInTx(rpc, batches, payer, [sender]); + + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx(batches[0], payer, blockhash, [sender]); + await sendAndConfirmTx(rpc, tx); + + const recipientAta = getAssociatedTokenAddressInterface( + mint, + recipient.publicKey, + ); + const recipientBalance = (await rpc.getAccountInfo( + recipientAta, + ))!.data.readBigUInt64LE(64); + expect(recipientBalance).toBe(BigInt(2500)); + + // Sender loaded 8 inputs (top 8 by amount: 1000+900+...+300 = 5200), + // sent 2500, change = 2700 + const senderAta = getAssociatedTokenAddressInterface( + mint, + sender.publicKey, + ); + const senderBalance = (await rpc.getAccountInfo( + senderAta, + ))!.data.readBigUInt64LE(64); + expect(senderBalance).toBe(BigInt(2700)); + }, 180_000); + }); +}); diff --git a/js/compressed-token/tests/e2e/layout.test.ts b/js/compressed-token/tests/e2e/layout.test.ts index a2f6f14c27..bf029d09fa 100644 --- a/js/compressed-token/tests/e2e/layout.test.ts +++ b/js/compressed-token/tests/e2e/layout.test.ts @@ -12,7 +12,7 @@ import { InputTokenDataWithContext, PackedMerkleContextLegacy, ValidityProof, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, defaultStaticAccountsStruct, LightSystemProgram, } from '@lightprotocol/stateless.js'; @@ -55,7 +55,7 @@ const getTestProgram = (): Program => { }, ); setProvider(mockProvider); - return new Program(IDL, CTOKEN_PROGRAM_ID, mockProvider); + return new Program(IDL, LIGHT_TOKEN_PROGRAM_ID, mockProvider); }; function deepEqual(ref: any, val: any) { if (ref === null && val === null) return true; diff --git a/js/compressed-token/tests/e2e/load-ata-spl-t22.test.ts b/js/compressed-token/tests/e2e/load-ata-spl-t22.test.ts index 14198e3a5b..8d5a3f8e57 100644 --- a/js/compressed-token/tests/e2e/load-ata-spl-t22.test.ts +++ b/js/compressed-token/tests/e2e/load-ata-spl-t22.test.ts @@ -34,10 +34,7 @@ import { } from '../../src/utils/get-token-pool-infos'; import { getAtaProgramId } from '../../src/v3/ata-utils'; -import { - loadAta, - createLoadAtaInstructions, -} from '../../src/v3/actions/load-ata'; +import { loadAta } from '../../src/v3/actions/load-ata'; import { checkAtaAddress } from '../../src/v3/ata-utils'; import { getAssociatedTokenAddressInterface } from '../../src/v3/get-associated-token-address-interface'; diff --git a/js/compressed-token/tests/e2e/load-ata-standard.test.ts b/js/compressed-token/tests/e2e/load-ata-standard.test.ts index 87fe645aa0..8f490a8200 100644 --- a/js/compressed-token/tests/e2e/load-ata-standard.test.ts +++ b/js/compressed-token/tests/e2e/load-ata-standard.test.ts @@ -13,7 +13,7 @@ import { getTestRpc, selectStateTreeInfo, TreeInfo, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, VERSION, featureFlags, } from '@lightprotocol/stateless.js'; @@ -294,14 +294,14 @@ describe('loadAta - Standard Path (wrap=false)', () => { owner.publicKey, ); - const ixs = await createLoadAtaInstructions( + const batches = await createLoadAtaInstructions( rpc, ata, owner.publicKey, mint, payer.publicKey, ); - expect(ixs.length).toBe(0); + expect(batches.length).toBe(0); }); it('should return empty when hot exists but no cold', async () => { @@ -319,7 +319,7 @@ describe('loadAta - Standard Path (wrap=false)', () => { owner.publicKey, ); - const ixs = await createLoadAtaInstructions( + const batches = await createLoadAtaInstructions( rpc, ata, owner.publicKey, @@ -327,7 +327,7 @@ describe('loadAta - Standard Path (wrap=false)', () => { payer.publicKey, ); - expect(ixs.length).toBe(0); + expect(batches.length).toBe(0); }); it('should build instructions for cold balance', async () => { @@ -348,7 +348,7 @@ describe('loadAta - Standard Path (wrap=false)', () => { mint, owner.publicKey, ); - const ixs = await createLoadAtaInstructions( + const batches = await createLoadAtaInstructions( rpc, ata, owner.publicKey, @@ -356,7 +356,7 @@ describe('loadAta - Standard Path (wrap=false)', () => { payer.publicKey, ); - expect(ixs.length).toBeGreaterThan(0); + expect(batches.length).toBeGreaterThan(0); }); }); diff --git a/js/compressed-token/tests/e2e/load-ata-unified.test.ts b/js/compressed-token/tests/e2e/load-ata-unified.test.ts index aa2eab3bf7..08984b3a4f 100644 --- a/js/compressed-token/tests/e2e/load-ata-unified.test.ts +++ b/js/compressed-token/tests/e2e/load-ata-unified.test.ts @@ -463,7 +463,7 @@ describe('loadAta - Unified Path (wrap=true)', () => { owner.publicKey, ); - const ixs = await createLoadAtaInstructionsUnified( + const batches = await createLoadAtaInstructionsUnified( rpc, ctokenAta, owner.publicKey, @@ -471,7 +471,7 @@ describe('loadAta - Unified Path (wrap=true)', () => { payer.publicKey, ); - expect(ixs.length).toBeGreaterThan(1); + expect(batches.flat().length).toBeGreaterThan(1); }); }); }); diff --git a/js/compressed-token/tests/e2e/mint-to-compressed.test.ts b/js/compressed-token/tests/e2e/mint-to-compressed.test.ts index da0fcba554..0224d4351d 100644 --- a/js/compressed-token/tests/e2e/mint-to-compressed.test.ts +++ b/js/compressed-token/tests/e2e/mint-to-compressed.test.ts @@ -11,7 +11,7 @@ import { createRpc, VERSION, featureFlags, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, selectStateTreeInfo, } from '@lightprotocol/stateless.js'; import { createMintInterface } from '../../src/v3/actions/create-mint-interface'; @@ -80,7 +80,7 @@ describe('mintToCompressed', () => { rpc, mint, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(mintInfo.mint.supply).toBe(BigInt(amount)); }); @@ -117,7 +117,7 @@ describe('mintToCompressed', () => { rpc, mint, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(mintInfo.mint.supply).toBe(BigInt(1000 + amount1 + amount2)); }); @@ -145,7 +145,7 @@ describe('mintToCompressed', () => { rpc, mint, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(mintInfo.mint.supply).toBeGreaterThanOrEqual(amount); }); diff --git a/js/compressed-token/tests/e2e/mint-to-ctoken.test.ts b/js/compressed-token/tests/e2e/mint-to-ctoken.test.ts index 94ba4d4310..c4960ae98d 100644 --- a/js/compressed-token/tests/e2e/mint-to-ctoken.test.ts +++ b/js/compressed-token/tests/e2e/mint-to-ctoken.test.ts @@ -11,9 +11,9 @@ import { createRpc, VERSION, featureFlags, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, } from '@lightprotocol/stateless.js'; -import { createMintInterface, decompressMint } from '../../src/v3/actions'; +import { createMintInterface } from '../../src/v3/actions'; import { mintTo } from '../../src/v3/actions/mint-to'; import { getMintInterface } from '../../src/v3/get-mint-interface'; import { createAssociatedCTokenAccount } from '../../src/v3/actions/create-associated-ctoken'; @@ -52,9 +52,6 @@ describe('mintTo (MintToCToken)', () => { await rpc.confirmTransaction(result.transactionSignature, 'confirmed'); mint = result.mint; - // Decompress mint so it exists on-chain (required for ATA creation) - await decompressMint(rpc, payer, mint); - await createAssociatedCTokenAccount( rpc, payer, @@ -85,7 +82,7 @@ describe('mintTo (MintToCToken)', () => { rpc, mint, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(mintInfo.mint.supply).toBe(BigInt(amount)); }); @@ -116,7 +113,7 @@ describe('mintTo (MintToCToken)', () => { rpc, mint, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(mintInfo.mint.supply).toBeGreaterThanOrEqual(1000n + amount); }); diff --git a/js/compressed-token/tests/e2e/mint-to-interface.test.ts b/js/compressed-token/tests/e2e/mint-to-interface.test.ts index 9b786364a3..1c38bb6bce 100644 --- a/js/compressed-token/tests/e2e/mint-to-interface.test.ts +++ b/js/compressed-token/tests/e2e/mint-to-interface.test.ts @@ -6,7 +6,7 @@ import { createRpc, VERSION, featureFlags, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, } from '@lightprotocol/stateless.js'; import { getOrCreateAssociatedTokenAccount, @@ -14,7 +14,7 @@ import { TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, } from '@solana/spl-token'; -import { createMintInterface, decompressMint } from '../../src/v3/actions'; +import { createMintInterface } from '../../src/v3/actions'; import { mintToInterface } from '../../src/v3/actions/mint-to-interface'; import { createMint } from '../../src/actions/create-mint'; import { createAssociatedCTokenAccount } from '../../src/v3/actions/create-associated-ctoken'; @@ -193,9 +193,6 @@ describe('mintToInterface - Compressed Mints', () => { ); await rpc.confirmTransaction(result.transactionSignature, 'confirmed'); mint = result.mint; - - // Decompress mint so it exists on-chain (required for ATA creation) - await decompressMint(rpc, payer, mint); }); it('should mint compressed tokens to onchain ctoken account', async () => { @@ -232,7 +229,7 @@ describe('mintToInterface - Compressed Mints', () => { ); expect(accountInterface).toBeDefined(); expect(accountInterface.accountInfo.owner.toString()).toBe( - CTOKEN_PROGRAM_ID.toBase58(), + LIGHT_TOKEN_PROGRAM_ID.toBase58(), ); expect(accountInterface.parsed.amount).toBe(BigInt(amount)); }); @@ -298,7 +295,7 @@ describe('mintToInterface - Compressed Mints', () => { ).rejects.toThrow(); }); - it('should auto-detect CTOKEN_PROGRAM_ID when programId not provided', async () => { + it('should auto-detect LIGHT_TOKEN_PROGRAM_ID when programId not provided', async () => { const recipient = Keypair.generate(); await createAssociatedCTokenAccount( rpc, @@ -489,9 +486,6 @@ describe('mintToInterface - Edge Cases', () => { ); await rpc.confirmTransaction(result.transactionSignature, 'confirmed'); compressedMint = result.mint; - - // Decompress mint so it exists on-chain (required for ATA creation) - await decompressMint(rpc, payer, compressedMint); }); it('should handle zero amount minting', async () => { @@ -539,9 +533,6 @@ describe('mintToInterface - Edge Cases', () => { ); await rpc.confirmTransaction(result.transactionSignature, 'confirmed'); - // Decompress mint so it exists on-chain (required for ATA creation) - await decompressMint(rpc, payer, result.mint); - const recipient = Keypair.generate(); await createAssociatedCTokenAccount( rpc, diff --git a/js/compressed-token/tests/e2e/mint-workflow.test.ts b/js/compressed-token/tests/e2e/mint-workflow.test.ts index 89097ed951..860faf3447 100644 --- a/js/compressed-token/tests/e2e/mint-workflow.test.ts +++ b/js/compressed-token/tests/e2e/mint-workflow.test.ts @@ -7,9 +7,9 @@ import { VERSION, featureFlags, getDefaultAddressTreeInfo, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, } from '@lightprotocol/stateless.js'; -import { createMintInterface, decompressMint } from '../../src/v3/actions'; +import { createMintInterface } from '../../src/v3/actions'; import { createTokenMetadata } from '../../src/v3/instructions'; import { updateMintAuthority, @@ -70,7 +70,7 @@ describe('Complete Mint Workflow', () => { rpc, mintPda, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(mintInfo.mint.mintAuthority?.toString()).toBe( initialMintAuthority.publicKey.toString(), @@ -95,7 +95,7 @@ describe('Complete Mint Workflow', () => { rpc, mintPda, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(mintInfo.tokenMetadata?.name).toBe('Workflow Token V2'); @@ -113,7 +113,7 @@ describe('Complete Mint Workflow', () => { rpc, mintPda, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(mintInfo.tokenMetadata?.uri).toBe( 'https://workflow.com/updated', @@ -133,7 +133,7 @@ describe('Complete Mint Workflow', () => { rpc, mintPda, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(mintInfo.tokenMetadata?.updateAuthority?.toString()).toBe( newMetadataAuthority.publicKey.toString(), @@ -153,7 +153,7 @@ describe('Complete Mint Workflow', () => { rpc, mintPda, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(mintInfo.mint.mintAuthority?.toString()).toBe( newMintAuthority.publicKey.toString(), @@ -173,7 +173,7 @@ describe('Complete Mint Workflow', () => { rpc, mintPda, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(mintInfo.mint.freezeAuthority?.toString()).toBe( newFreezeAuthority.publicKey.toString(), @@ -183,9 +183,6 @@ describe('Complete Mint Workflow', () => { const owner2 = Keypair.generate(); const owner3 = Keypair.generate(); - // Decompress mint so it exists on-chain (required for CToken ATA creation) - await decompressMint(rpc, payer, mint); - const ata1 = await createAtaInterfaceIdempotent( rpc, payer, @@ -228,7 +225,7 @@ describe('Complete Mint Workflow', () => { rpc, mintPda, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(finalMintInfo.mint.mintAuthority?.toString()).toBe( newMintAuthority.publicKey.toString(), @@ -277,7 +274,7 @@ describe('Complete Mint Workflow', () => { rpc, mintPda, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(mintInfo.mint.freezeAuthority).not.toBe(null); @@ -294,16 +291,13 @@ describe('Complete Mint Workflow', () => { rpc, mintPda, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(mintInfo.mint.freezeAuthority).toBe(null); expect(mintInfo.mint.mintAuthority?.toString()).toBe( mintAuthority.publicKey.toString(), ); - // Decompress mint so it exists on-chain (required for CToken ATA creation) - await decompressMint(rpc, payer, mintPda); - const owner = Keypair.generate(); const ataAddress = await createAtaInterfaceIdempotent( rpc, @@ -344,13 +338,10 @@ describe('Complete Mint Workflow', () => { rpc, mintPda, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(mintInfo.tokenMetadata).toBeUndefined(); - // Decompress mint so it exists on-chain (required for CToken ATA creation) - await decompressMint(rpc, payer, mint); - const owners = [ Keypair.generate(), Keypair.generate(), @@ -403,9 +394,6 @@ describe('Complete Mint Workflow', () => { ); await rpc.confirmTransaction(createSig, 'confirmed'); - // Decompress mint so it exists on-chain (required for CToken ATA creation) - await decompressMint(rpc, payer, mintPda); - const owner = Keypair.generate(); const ataAddress = await createAtaInterfaceIdempotent( rpc, @@ -434,7 +422,7 @@ describe('Complete Mint Workflow', () => { rpc, mintPda, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(mintInfo.tokenMetadata?.name).toBe('After ATA'); @@ -475,7 +463,7 @@ describe('Complete Mint Workflow', () => { rpc, mintPda, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(mintInfo.mint.supply).toBe(0n); expect(mintInfo.mint.decimals).toBe(decimals); @@ -496,9 +484,6 @@ describe('Complete Mint Workflow', () => { expect(mintInfo.mintContext).toBeDefined(); expect(mintInfo.mintContext?.version).toBeGreaterThan(0); - // Decompress mint so it exists on-chain (required for CToken ATA creation) - await decompressMint(rpc, payer, mint); - const owner1 = Keypair.generate(); const owner2 = Keypair.generate(); @@ -535,7 +520,7 @@ describe('Complete Mint Workflow', () => { rpc, mintPda, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(mintInfo.mint.mintAuthority?.toString()).toBe( newMintAuthority.publicKey.toString(), @@ -555,7 +540,7 @@ describe('Complete Mint Workflow', () => { rpc, mintPda, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(finalMintInfo.tokenMetadata?.symbol).toBe('FULL2'); expect(finalMintInfo.mint.mintAuthority?.toString()).toBe( @@ -593,14 +578,11 @@ describe('Complete Mint Workflow', () => { rpc, mintPda, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(mintInfo.mint.freezeAuthority).toBe(null); expect(mintInfo.tokenMetadata).toBeUndefined(); - // Decompress mint so it exists on-chain (required for CToken ATA creation) - await decompressMint(rpc, payer, mint); - const owner = Keypair.generate(); const ataAddress = await createAtaInterfaceIdempotent( rpc, @@ -633,7 +615,7 @@ describe('Complete Mint Workflow', () => { rpc, mintPda, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(finalMintInfo.mint.mintAuthority?.toString()).toBe( newMintAuthority.publicKey.toString(), @@ -665,9 +647,6 @@ describe('Complete Mint Workflow', () => { owner.publicKey, ); - // Decompress mint so it exists on-chain (required for CToken ATA creation) - await decompressMint(rpc, payer, mint); - const ataAddress = await createAtaInterfaceIdempotent( rpc, payer, 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 new file mode 100644 index 0000000000..264683cf56 --- /dev/null +++ b/js/compressed-token/tests/e2e/multi-cold-inputs-batching.test.ts @@ -0,0 +1,796 @@ +/** + * Multi-Cold-Inputs Batching Test Suite + * + * Instruction-level building and parallel multi-tx batching tests. + * Separated from multi-cold-inputs.test.ts because these tests + * consume ~92 output queue entries, and the combined total (~175) + * exceeds the local test validator's 100-entry batch queue limit. + * + * Run with a fresh validator: `light test-validator` before this file. + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import { + Keypair, + Signer, + PublicKey, + ComputeBudgetProgram, +} from '@solana/web3.js'; +import { + Rpc, + bn, + newAccountWithLamports, + createRpc, + selectStateTreeInfo, + TreeInfo, + VERSION, + featureFlags, + buildAndSignTx, + sendAndConfirmTx, +} from '@lightprotocol/stateless.js'; +import { WasmFactory } from '@lightprotocol/hasher.rs'; +import { createMint, mintTo } from '../../src/actions'; +import { + getTokenPoolInfos, + selectTokenPoolInfo, + TokenPoolInfo, +} from '../../src/utils/get-token-pool-infos'; +import { getAssociatedTokenAddressInterface } from '../../src/v3/get-associated-token-address-interface'; +import { + getAtaInterface, + type AccountInterface, +} from '../../src/v3/get-account-interface'; +import { + loadAta, + createLoadAtaInstructions, + _buildLoadBatches, +} from '../../src/v3/actions/load-ata'; +import { getOrCreateAtaInterface } from '../../src/v3/actions/get-or-create-ata-interface'; +import { + transferInterface, + createTransferInterfaceInstructions, + sliceLast, +} from '../../src/v3/actions/transfer-interface'; + +featureFlags.version = VERSION.V2; + +const TEST_TOKEN_DECIMALS = 9; + +async function mintMultipleColdAccounts( + rpc: Rpc, + payer: Signer, + mint: PublicKey, + owner: PublicKey, + mintAuthority: Keypair, + count: number, + amountPerAccount: bigint, + stateTreeInfo: TreeInfo, + tokenPoolInfos: TokenPoolInfo[], +): Promise { + for (let i = 0; i < count; i++) { + await mintTo( + rpc, + payer, + mint, + owner, + mintAuthority, + bn(amountPerAccount.toString()), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + } +} + +async function getCompressedAccountCount( + rpc: Rpc, + owner: PublicKey, + mint: PublicKey, +): Promise { + const result = await rpc.getCompressedTokenAccountsByOwner(owner, { mint }); + return result.items.length; +} + +async function getCompressedBalance( + rpc: Rpc, + owner: PublicKey, + mint: PublicKey, +): Promise { + const result = await rpc.getCompressedTokenAccountsByOwner(owner, { mint }); + return result.items.reduce( + (sum, item) => sum + BigInt(item.parsed.amount.toString()), + BigInt(0), + ); +} + +describe('Multi-Cold-Inputs Batching', () => { + let rpc: Rpc; + let payer: Signer; + let mint: PublicKey; + let mintAuthority: Keypair; + let stateTreeInfo: TreeInfo; + let tokenPoolInfos: TokenPoolInfo[]; + + beforeAll(async () => { + const lightWasm = await WasmFactory.getInstance(); + rpc = createRpc(); + payer = await newAccountWithLamports(rpc, 50e9); + mintAuthority = Keypair.generate(); + const mintKeypair = Keypair.generate(); + + mint = ( + await createMint( + rpc, + payer, + mintAuthority.publicKey, + TEST_TOKEN_DECIMALS, + mintKeypair, + ) + ).mint; + + stateTreeInfo = selectStateTreeInfo(await rpc.getStateTreeInfos()); + tokenPoolInfos = await getTokenPoolInfos(rpc, mint); + }, 120_000); + + // --------------------------------------------------------------- + // instruction-level building (~28 output entries) + // --------------------------------------------------------------- + describe('instruction-level building with createLoadAtaInstructions', () => { + it('should build decompress instruction with 5 inputs', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + const coldCount = 5; + const amountPerAccount = BigInt(100); + + await mintMultipleColdAccounts( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + coldCount, + amountPerAccount, + stateTreeInfo, + tokenPoolInfos, + ); + + const ata = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); + + const batches = await createLoadAtaInstructions( + rpc, + ata, + owner.publicKey, + mint, + ); + + expect(batches.length).toBeGreaterThan(0); + const ixs = batches[0]; + + for (let i = 0; i < ixs.length; i++) { + const ix = ixs[i]; + console.log(`Instruction ${i}:`, { + programId: ix.programId.toBase58(), + numKeys: ix.keys.length, + dataLength: ix.data.length, + }); + } + + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx( + [ + ComputeBudgetProgram.setComputeUnitLimit({ + units: 500_000, + }), + ...ixs, + ], + payer, + blockhash, + [owner], + ); + + const signature = await sendAndConfirmTx(rpc, tx); + expect(signature).toBeDefined(); + + const countAfter = await getCompressedAccountCount( + rpc, + owner.publicKey, + mint, + ); + expect(countAfter).toBe(0); + }, 120_000); + + it('should measure CU usage for 8 cold inputs', async () => { + const owner = await newAccountWithLamports(rpc, 2e9); + const coldCount = 8; + const amountPerAccount = BigInt(100); + + await mintMultipleColdAccounts( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + coldCount, + amountPerAccount, + stateTreeInfo, + tokenPoolInfos, + ); + + const ata = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); + + const batches = await createLoadAtaInstructions( + rpc, + ata, + owner.publicKey, + mint, + ); + + const ixs = batches[0]; + let totalDataSize = 0; + let totalKeyCount = 0; + for (const ix of ixs) { + totalDataSize += ix.data.length; + totalKeyCount += ix.keys.length; + } + + console.log('8 cold inputs instruction stats:', { + instructionCount: ixs.length, + totalDataSize, + totalKeyCount, + }); + + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx( + [ + ComputeBudgetProgram.setComputeUnitLimit({ + units: 500_000, + }), + ...ixs, + ], + payer, + blockhash, + [owner], + ); + + const serialized = tx.serialize(); + console.log('Serialized transaction size:', serialized.length); + + const signature = await sendAndConfirmTx(rpc, tx); + expect(signature).toBeDefined(); + + const countAfter = await getCompressedAccountCount( + rpc, + owner.publicKey, + mint, + ); + expect(countAfter).toBe(0); + }, 180_000); + + it('should manually build and send 2 txs with 15 cold inputs using batches (8+7 for V2)', async () => { + const owner = await newAccountWithLamports(rpc, 4e9); + const coldCount = 15; + const amountPerAccount = BigInt(100); + + await mintMultipleColdAccounts( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + coldCount, + amountPerAccount, + stateTreeInfo, + tokenPoolInfos, + ); + + const countBefore = await getCompressedAccountCount( + rpc, + owner.publicKey, + mint, + ); + expect(countBefore).toBe(coldCount); + + const totalColdBalance = await getCompressedBalance( + rpc, + owner.publicKey, + mint, + ); + expect(totalColdBalance).toBe(BigInt(coldCount) * amountPerAccount); + + const ata = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); + + const batches = await createLoadAtaInstructions( + rpc, + ata, + owner.publicKey, + mint, + ); + + // 15 = 8 + 7 (V2 valid proof sizes) + expect(batches.length).toBe(2); + + for (let batchIdx = 0; batchIdx < batches.length; batchIdx++) { + const batch = batches[batchIdx]; + console.log( + `Batch ${batchIdx}: ${batch.length} instruction(s)`, + ); + for (let i = 0; i < batch.length; i++) { + const ix = batch[i]; + console.log(` Instruction ${i}:`, { + programId: ix.programId.toBase58(), + numKeys: ix.keys.length, + dataLength: ix.data.length, + }); + } + } + + expect(batches[0].length).toBe(2); // createATA + decompress 8 + expect(batches[1].length).toBe(1); // decompress 7 + + const signatures: string[] = []; + for (let batchIdx = 0; batchIdx < batches.length; batchIdx++) { + const batch = batches[batchIdx]; + const { blockhash } = await rpc.getLatestBlockhash(); + + const tx = buildAndSignTx( + [ + ComputeBudgetProgram.setComputeUnitLimit({ + units: 600_000, + }), + ...batch, + ], + payer, + blockhash, + [owner], + ); + + const serialized = tx.serialize(); + console.log( + `Batch ${batchIdx} serialized tx size:`, + serialized.length, + ); + + const signature = await sendAndConfirmTx(rpc, tx); + expect(signature).toBeDefined(); + signatures.push(signature); + console.log(`Batch ${batchIdx} succeeded:`, signature); + } + + // 15 = 8+7 = 2 batches for V2 + expect(signatures.length).toBe(2); + + const countAfter = await getCompressedAccountCount( + rpc, + owner.publicKey, + mint, + ); + expect(countAfter).toBe(0); + + const hotBalance = (await rpc.getAccountInfo( + ata, + ))!.data.readBigUInt64LE(64); + expect(hotBalance).toBe(totalColdBalance); + }, 240_000); + }); + + // --------------------------------------------------------------- + // hash uniqueness across batches (~10 output entries) + // --------------------------------------------------------------- + describe('hash uniqueness across batches', () => { + it('should partition 10 cold account hashes into non-overlapping batches', async () => { + const owner = await newAccountWithLamports(rpc, 3e9); + const coldCount = 10; + const amountPerAccount = BigInt(100); + + await mintMultipleColdAccounts( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + coldCount, + amountPerAccount, + stateTreeInfo, + tokenPoolInfos, + ); + + const countBefore = await getCompressedAccountCount( + rpc, + owner.publicKey, + mint, + ); + expect(countBefore).toBe(coldCount); + + const ata = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); + + // Get account interface (same call createTransferInterfaceInstructions makes) + const ataInterface: AccountInterface = await getAtaInterface( + rpc, + ata, + owner.publicKey, + mint, + ); + + // Build internal load batches directly to inspect compressedAccounts + const batches = await _buildLoadBatches( + rpc, + payer.publicKey, + ataInterface, + undefined, + false, + ata, + ); + + expect(batches.length).toBeGreaterThan(1); + + // Collect ALL hashes across ALL batches + const allHashes: string[] = []; + for (const batch of batches) { + for (const acc of batch.compressedAccounts) { + allHashes.push(acc.compressedAccount.hash.toString()); + } + } + + // Every hash must be unique + const uniqueHashes = new Set(allHashes); + expect(uniqueHashes.size).toBe(allHashes.length); + + // Total accounts across batches must equal input count + expect(allHashes.length).toBe(coldCount); + + console.log( + `10 cold inputs: ${batches.length} batches, ` + + `accounts per batch: [${batches.map(b => b.compressedAccounts.length)}], ` + + `all ${allHashes.length} hashes unique: true`, + ); + }, 120_000); + + it('should transfer with 10 cold inputs using unique hashes end-to-end', async () => { + const owner = await newAccountWithLamports(rpc, 3e9); + const recipient = Keypair.generate(); + const coldCount = 10; + const amountPerAccount = BigInt(100); + const totalAmount = BigInt(coldCount) * amountPerAccount; + + await mintMultipleColdAccounts( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + coldCount, + amountPerAccount, + stateTreeInfo, + tokenPoolInfos, + ); + + const sourceAta = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); + + // createTransferInterfaceInstructions should produce + // batches with non-overlapping hashes + const recipientAta = getAssociatedTokenAddressInterface( + mint, + recipient.publicKey, + ); + + const batches = await createTransferInterfaceInstructions( + rpc, + payer.publicKey, + mint, + totalAmount, + owner.publicKey, + recipient.publicKey, + ); + + // With 10 cold inputs: 2 batches (8+2 for V2). + expect(batches.length).toBe(2); + + const { rest: loads, last: transferIxs } = sliceLast(batches); + + // Send load batches in parallel + await Promise.all( + loads.map(async ixs => { + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx(ixs, payer, blockhash, [owner]); + return sendAndConfirmTx(rpc, tx); + }), + ); + + // Send transfer + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx(transferIxs, payer, blockhash, [owner]); + const signature = await sendAndConfirmTx(rpc, tx); + expect(signature).toBeDefined(); + + // Verify sender has no cold accounts left + const senderCount = await getCompressedAccountCount( + rpc, + owner.publicKey, + mint, + ); + expect(senderCount).toBe(0); + + // Verify recipient received tokens + const recipientBalance = (await rpc.getAccountInfo( + recipientAta, + ))!.data.readBigUInt64LE(64); + expect(recipientBalance).toBe(totalAmount); + }, 180_000); + }); + + // --------------------------------------------------------------- + // ensureRecipientAta (default true) -- no manual ATA creation + // --------------------------------------------------------------- + describe('ensureRecipientAta default', () => { + it('should create recipient ATA automatically via ensureRecipientAta (hot sender)', async () => { + const owner = await newAccountWithLamports(rpc, 3e9); + const recipient = Keypair.generate(); + + // Mint compressed tokens then load to make sender hot + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(500), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + const senderAta = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); + await loadAta(rpc, senderAta, owner, mint); + + const transferAmount = BigInt(200); + + // Build instructions -- do NOT manually create recipient ATA + const batches = await createTransferInterfaceInstructions( + rpc, + payer.publicKey, + mint, + transferAmount, + owner.publicKey, + recipient.publicKey, + // ensureRecipientAta defaults to true + ); + + // Hot sender: single batch with CU + recipient ATA + transfer ix + expect(batches.length).toBe(1); + expect(batches[0].length).toBe(3); + + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx(batches[0], payer, blockhash, [owner]); + const signature = await sendAndConfirmTx(rpc, tx); + expect(signature).toBeDefined(); + + // Verify recipient ATA was created and has correct balance + const recipientAta = getAssociatedTokenAddressInterface( + mint, + recipient.publicKey, + ); + const recipientInfo = await rpc.getAccountInfo(recipientAta); + expect(recipientInfo).not.toBeNull(); + const recipientBalance = recipientInfo!.data.readBigUInt64LE(64); + expect(recipientBalance).toBe(transferAmount); + }, 120_000); + + it('should create recipient ATA automatically with cold inputs (10 cold)', async () => { + const owner = await newAccountWithLamports(rpc, 3e9); + const recipient = Keypair.generate(); + const coldCount = 10; + const amountPerAccount = BigInt(100); + const totalAmount = BigInt(coldCount) * amountPerAccount; + + await mintMultipleColdAccounts( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + coldCount, + amountPerAccount, + stateTreeInfo, + tokenPoolInfos, + ); + + // Build instructions -- no manual recipient ATA creation + const batches = await createTransferInterfaceInstructions( + rpc, + payer.publicKey, + mint, + totalAmount, + owner.publicKey, + recipient.publicKey, + ); + + // 10 cold: 2 batches (load + transfer) + expect(batches.length).toBe(2); + + const { rest: loads, last: transferIxs } = sliceLast(batches); + + // Send loads in parallel + await Promise.all( + loads.map(async ixs => { + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx(ixs, payer, blockhash, [owner]); + return sendAndConfirmTx(rpc, tx); + }), + ); + + // Send transfer (recipient ATA creation is embedded) + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx(transferIxs, payer, blockhash, [owner]); + const signature = await sendAndConfirmTx(rpc, tx); + expect(signature).toBeDefined(); + + // Verify recipient got the tokens + const recipientAta = getAssociatedTokenAddressInterface( + mint, + recipient.publicKey, + ); + const recipientBalance = (await rpc.getAccountInfo( + recipientAta, + ))!.data.readBigUInt64LE(64); + expect(recipientBalance).toBe(totalAmount); + }, 180_000); + + it('should allow opt-out with ensureRecipientAta: false', async () => { + const owner = await newAccountWithLamports(rpc, 2e9); + const recipient = Keypair.generate(); + + // Mint compressed then load to make sender hot + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(300), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + const senderAta = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); + await loadAta(rpc, senderAta, owner, mint); + + const batches = await createTransferInterfaceInstructions( + rpc, + payer.publicKey, + mint, + BigInt(100), + owner.publicKey, + recipient.publicKey, + { ensureRecipientAta: false }, + ); + + // Single batch with CU budget + transfer ix only (no ATA ix) + expect(batches.length).toBe(1); + expect(batches[0].length).toBe(2); + }, 60_000); + }); + + // --------------------------------------------------------------- + // parallel multi-tx batching (~44 output entries) + // --------------------------------------------------------------- + describe('parallel multi-tx batching (>16 inputs)', () => { + it('should load 20 cold compressed accounts via parallel batches (3 batches: 8+8+4)', async () => { + const owner = await newAccountWithLamports(rpc, 5e9); + const coldCount = 20; + const amountPerAccount = BigInt(100); + + await mintMultipleColdAccounts( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + coldCount, + amountPerAccount, + stateTreeInfo, + tokenPoolInfos, + ); + + const countBefore = await getCompressedAccountCount( + rpc, + owner.publicKey, + mint, + ); + expect(countBefore).toBe(coldCount); + + const totalColdBalance = await getCompressedBalance( + rpc, + owner.publicKey, + mint, + ); + expect(totalColdBalance).toBe(BigInt(coldCount) * amountPerAccount); + + const ata = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); + + const signature = await loadAta(rpc, ata, owner, mint); + expect(signature).not.toBeNull(); + + const countAfter = await getCompressedAccountCount( + rpc, + owner.publicKey, + mint, + ); + expect(countAfter).toBe(0); + + const hotBalance = (await rpc.getAccountInfo( + ata, + ))!.data.readBigUInt64LE(64); + expect(hotBalance).toBe(totalColdBalance); + }, 300_000); + + it('should load 24 cold compressed accounts via parallel batches (3 batches: 8+8+8)', async () => { + const owner = await newAccountWithLamports(rpc, 6e9); + const coldCount = 24; + const amountPerAccount = BigInt(50); + + await mintMultipleColdAccounts( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + coldCount, + amountPerAccount, + stateTreeInfo, + tokenPoolInfos, + ); + + const countBefore = await getCompressedAccountCount( + rpc, + owner.publicKey, + mint, + ); + expect(countBefore).toBe(coldCount); + + const totalColdBalance = await getCompressedBalance( + rpc, + owner.publicKey, + mint, + ); + expect(totalColdBalance).toBe(BigInt(coldCount) * amountPerAccount); + + const ata = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); + + const signature = await loadAta(rpc, ata, owner, mint); + expect(signature).not.toBeNull(); + + const countAfter = await getCompressedAccountCount( + rpc, + owner.publicKey, + mint, + ); + expect(countAfter).toBe(0); + + const hotBalance = (await rpc.getAccountInfo( + ata, + ))!.data.readBigUInt64LE(64); + expect(hotBalance).toBe(totalColdBalance); + }, 360_000); + }); +}); 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 86cbeb5d4b..92bc3dad9c 100644 --- a/js/compressed-token/tests/e2e/multi-cold-inputs.test.ts +++ b/js/compressed-token/tests/e2e/multi-cold-inputs.test.ts @@ -5,16 +5,20 @@ * Validates behavior against program constraint: MAX_INPUT_ACCOUNTS = 8 * * Scenarios: - * - 5 cold inputs: should work (within limit) + * - 5 cold inputs: should work (single chunk for V2) * - 8 cold inputs: should work (at limit) * - 12 cold inputs: needs chunking (2 batches: 8+4) - * - 15 cold inputs: needs chunking (2 batches: 8+7) + * - 15 cold inputs: needs chunking (2 batches: 8+7 for V2) * * These tests verify: * 1. load loads ALL inputs for given owner+mint, not just amount-needed * 2. Fits into 1 validity proof and 1 instruction (up to 8) * 3. Transaction size and CU constraints * 4. Proper error handling when exceeding limits + * + * NOTE: The local test validator has a batched output queue of 100 entries. + * This file consumes ~83 entries. Instruction-level and parallel batching + * tests are in multi-cold-inputs-batching.test.ts (separate validator run). */ import { describe, it, expect, beforeAll } from 'vitest'; import { @@ -30,14 +34,14 @@ import { createRpc, selectStateTreeInfo, TreeInfo, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, VERSION, featureFlags, buildAndSignTx, sendAndConfirmTx, } from '@lightprotocol/stateless.js'; import { WasmFactory } from '@lightprotocol/hasher.rs'; -import { createMint, mintTo } from '../../src/actions'; +import { createMint, mintTo, approve } from '../../src/actions'; import { getTokenPoolInfos, selectTokenPoolInfo, @@ -49,10 +53,15 @@ import { transferInterface } from '../../src/v3/actions/transfer-interface'; import { loadAta, createLoadAtaInstructions, - createLoadAtaInstructionBatches, + calculateLoadBatchComputeUnits, + _buildLoadBatches, MAX_INPUT_ACCOUNTS, } from '../../src/v3/actions/load-ata'; import { getOrCreateAtaInterface } from '../../src/v3/actions/get-or-create-ata-interface'; +import { + estimateTransactionSize, + MAX_TRANSACTION_SIZE, +} from '../../src/v3/utils/estimate-tx-size'; featureFlags.version = VERSION.V2; @@ -143,13 +152,15 @@ describe('Multi-Cold-Inputs', () => { tokenPoolInfos = await getTokenPoolInfos(rpc, mint); }, 120_000); + // --------------------------------------------------------------- + // Section 1: loadAta with multiple cold inputs (~40 output entries) + // --------------------------------------------------------------- describe('loadAta with multiple cold inputs', () => { - it('should load 5 cold compressed accounts in single instruction', async () => { + it('should load 5 cold compressed accounts in 1 batch, under size limit', async () => { const owner = await newAccountWithLamports(rpc, 2e9); const coldCount = 5; const amountPerAccount = BigInt(1000); - // Mint 5 separate compressed accounts await mintMultipleColdAccounts( rpc, payer, @@ -162,7 +173,6 @@ describe('Multi-Cold-Inputs', () => { tokenPoolInfos, ); - // Verify we have 5 cold accounts const countBefore = await getCompressedAccountCount( rpc, owner.publicKey, @@ -177,31 +187,41 @@ describe('Multi-Cold-Inputs', () => { ); expect(totalColdBalance).toBe(BigInt(coldCount) * amountPerAccount); - // Load all cold accounts const ata = getAssociatedTokenAddressInterface( mint, owner.publicKey, ); - // Build instructions to inspect - const ixs = await createLoadAtaInstructions( + const batches = await createLoadAtaInstructions( rpc, ata, owner.publicKey, mint, ); - // Should have instructions (at least 1 decompress + possibly 1 create ATA) - expect(ixs.length).toBeGreaterThan(0); - console.log( - `5 cold inputs: ${ixs.length} instruction(s), data sizes: ${ixs.map(ix => ix.data.length)}`, - ); + // 5 inputs < 8: single batch + expect(batches.length).toBe(1); - // Execute load - const signature = await loadAta(rpc, ata, owner, mint); - expect(signature).not.toBeNull(); + // Build real tx and assert serialized size + const ixs = batches[0]; + const cuIx = ComputeBudgetProgram.setComputeUnitLimit({ + units: 500_000, + }); + const txIxs = [cuIx, ...ixs]; + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx(txIxs, payer, blockhash, [owner]); + const serializedSize = tx.serialize().length; + + expect(serializedSize).toBeLessThanOrEqual(MAX_TRANSACTION_SIZE); + + // Cross-check estimate + const estimate = estimateTransactionSize(txIxs, 2); + expect(Math.abs(estimate - serializedSize)).toBeLessThanOrEqual(10); + + // Send and verify + const sig = await sendAndConfirmTx(rpc, tx); + expect(sig).toBeDefined(); - // Verify ALL cold accounts were loaded (not just some) const countAfter = await getCompressedAccountCount( rpc, owner.publicKey, @@ -209,19 +229,17 @@ describe('Multi-Cold-Inputs', () => { ); expect(countAfter).toBe(0); - // Verify hot balance equals total cold balance const hotBalance = (await rpc.getAccountInfo( ata, ))!.data.readBigUInt64LE(64); expect(hotBalance).toBe(totalColdBalance); }, 120_000); - it('should load 8 cold compressed accounts in single instruction (at MAX_INPUT_ACCOUNTS limit)', async () => { + it('should load 8 cold compressed accounts in 1 batch at MAX_INPUT_ACCOUNTS, under size limit', async () => { const owner = await newAccountWithLamports(rpc, 2e9); const coldCount = 8; const amountPerAccount = BigInt(500); - // Mint 8 separate compressed accounts await mintMultipleColdAccounts( rpc, payer, @@ -234,7 +252,6 @@ describe('Multi-Cold-Inputs', () => { tokenPoolInfos, ); - // Verify we have 8 cold accounts const countBefore = await getCompressedAccountCount( rpc, owner.publicKey, @@ -242,35 +259,41 @@ describe('Multi-Cold-Inputs', () => { ); expect(countBefore).toBe(coldCount); - const totalColdBalance = await getCompressedBalance( - rpc, - owner.publicKey, - mint, - ); - expect(totalColdBalance).toBe(BigInt(coldCount) * amountPerAccount); - const ata = getAssociatedTokenAddressInterface( mint, owner.publicKey, ); - // Build instructions to inspect - const ixs = await createLoadAtaInstructions( + const batches = await createLoadAtaInstructions( rpc, ata, owner.publicKey, mint, ); - console.log( - `8 cold inputs: ${ixs.length} instruction(s), data sizes: ${ixs.map(ix => ix.data.length)}`, - ); + // 8 inputs = exactly MAX_INPUT_ACCOUNTS: single batch + expect(batches.length).toBe(1); - // Execute load - this is at the MAX_INPUT_ACCOUNTS=8 limit - const signature = await loadAta(rpc, ata, owner, mint); - expect(signature).not.toBeNull(); + // Build real tx and assert serialized size + const ixs = batches[0]; + const cuIx = ComputeBudgetProgram.setComputeUnitLimit({ + units: 500_000, + }); + const txIxs = [cuIx, ...ixs]; + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx(txIxs, payer, blockhash, [owner]); + const serializedSize = tx.serialize().length; + + expect(serializedSize).toBeLessThanOrEqual(MAX_TRANSACTION_SIZE); + + // Cross-check estimate + const estimate = estimateTransactionSize(txIxs, 2); + expect(Math.abs(estimate - serializedSize)).toBeLessThanOrEqual(10); + + // Send and verify + const sig = await sendAndConfirmTx(rpc, tx); + expect(sig).toBeDefined(); - // Verify ALL 8 cold accounts were loaded const countAfter = await getCompressedAccountCount( rpc, owner.publicKey, @@ -281,15 +304,14 @@ describe('Multi-Cold-Inputs', () => { const hotBalance = (await rpc.getAccountInfo( ata, ))!.data.readBigUInt64LE(64); - expect(hotBalance).toBe(totalColdBalance); - }, 180_000); + expect(hotBalance).toBe(BigInt(coldCount) * amountPerAccount); + }, 120_000); - it('should load 12 cold compressed accounts (2 decompress ixs in 1 tx)', async () => { + it('should load 12 cold accounts in 2 txs (8+4), each under size limit', async () => { const owner = await newAccountWithLamports(rpc, 3e9); const coldCount = 12; - const amountPerAccount = BigInt(300); + const amountPerAccount = BigInt(200); - // Mint 12 separate compressed accounts await mintMultipleColdAccounts( rpc, payer, @@ -302,7 +324,6 @@ describe('Multi-Cold-Inputs', () => { tokenPoolInfos, ); - // Verify we have 12 cold accounts const countBefore = await getCompressedAccountCount( rpc, owner.publicKey, @@ -322,25 +343,44 @@ describe('Multi-Cold-Inputs', () => { owner.publicKey, ); - // Build instructions - should return 3 instructions in 1 tx: - // 1. CreateAssociatedTokenAccountIdempotent - // 2. Decompress chunk 1 (8 accounts) - // 3. Decompress chunk 2 (4 accounts) - const ixs = await createLoadAtaInstructions( + // Use createLoadAtaInstructions for both measurement and execution + const batches = await createLoadAtaInstructions( rpc, ata, owner.publicKey, mint, ); - // Should have 3 instructions (createATA + 2 decompress chunks) - expect(ixs.length).toBe(3); + // 12 inputs = 8+4 for V2 = 2 batches + expect(batches.length).toBe(2); - // Execute load - const signature = await loadAta(rpc, ata, owner, mint); - expect(signature).not.toBeNull(); + // Build, measure, and send each batch + for (let i = 0; i < batches.length; i++) { + const batchIxs = batches[i]; + const cuIx = ComputeBudgetProgram.setComputeUnitLimit({ + units: 500_000, + }); + const txIxs = [cuIx, ...batchIxs]; + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx(txIxs, payer, blockhash, [owner]); + const serializedSize = tx.serialize().length; + + // Assert each batch fits within tx size limit + expect(serializedSize).toBeLessThanOrEqual( + MAX_TRANSACTION_SIZE, + ); - // Verify ALL 12 cold accounts were loaded + // Cross-check estimate is accurate + const estimate = estimateTransactionSize(txIxs, 2); + expect(Math.abs(estimate - serializedSize)).toBeLessThanOrEqual( + 10, + ); + + const sig = await sendAndConfirmTx(rpc, tx); + expect(sig).toBeDefined(); + } + + // Verify all cold accounts loaded const countAfter = await getCompressedAccountCount( rpc, owner.publicKey, @@ -354,12 +394,11 @@ describe('Multi-Cold-Inputs', () => { expect(hotBalance).toBe(totalColdBalance); }, 180_000); - it('should load 15 cold compressed accounts via batches (2 separate txs)', async () => { + it('should load 15 cold compressed accounts via batches (2 separate txs: 8+7 for V2)', async () => { const owner = await newAccountWithLamports(rpc, 4e9); const coldCount = 15; - const amountPerAccount = BigInt(200); + const amountPerAccount = BigInt(100); - // Mint 15 separate compressed accounts await mintMultipleColdAccounts( rpc, payer, @@ -372,7 +411,6 @@ describe('Multi-Cold-Inputs', () => { tokenPoolInfos, ); - // Verify we have 15 cold accounts const countBefore = await getCompressedAccountCount( rpc, owner.publicKey, @@ -392,39 +430,9 @@ describe('Multi-Cold-Inputs', () => { owner.publicKey, ); - // Build instruction batches - should return 2 batches: - // Batch 0: createATA + decompress 8 accounts (sent as tx 1) - // Batch 1: decompress 7 accounts (sent as tx 2) - const { batches, totalCompressedAccounts } = - await createLoadAtaInstructionBatches( - rpc, - ata, - owner.publicKey, - mint, - ); - - console.log( - `15 cold inputs: ${batches.length} batches, ixs per batch: ${batches.map(b => b.length)}`, - ); - - // Should have 2 batches - expect(batches.length).toBe(2); - expect(totalCompressedAccounts).toBe(15); - - // First batch: createATA + decompress (2 ixs) - expect(batches[0].length).toBe(2); - // Second batch: decompress only (1 ix) - expect(batches[1].length).toBe(1); - - // Execute load (loadAta sends each batch as separate tx) const signature = await loadAta(rpc, ata, owner, mint); expect(signature).not.toBeNull(); - console.log( - '15 cold inputs: loadAta succeeded with signature:', - signature, - ); - // Verify ALL 15 cold accounts were loaded const countAfter = await getCompressedAccountCount( rpc, owner.publicKey, @@ -436,27 +444,25 @@ describe('Multi-Cold-Inputs', () => { ata, ))!.data.readBigUInt64LE(64); expect(hotBalance).toBe(totalColdBalance); - - console.log( - `After load: ${countAfter} cold remaining, hot balance: ${hotBalance}`, - ); }, 240_000); }); - describe('transferInterface with multiple cold inputs', () => { - it('should auto-load 5 cold inputs when transferring', async () => { - const sender = await newAccountWithLamports(rpc, 2e9); - const recipient = Keypair.generate(); - const coldCount = 5; + // --------------------------------------------------------------- + // Section 2: edge cases (~7 output entries) + // --------------------------------------------------------------- + describe('edge cases', () => { + it('should handle partial load when only some accounts needed', async () => { + // Note: Current implementation loads ALL accounts, not just needed amount + // This test documents that behavior + const owner = await newAccountWithLamports(rpc, 1e9); + const coldCount = 4; const amountPerAccount = BigInt(1000); - const transferAmount = BigInt(2500); // Requires multiple inputs - // Mint 5 cold accounts to sender await mintMultipleColdAccounts( rpc, payer, mint, - sender.publicKey, + owner.publicKey, mintAuthority, coldCount, amountPerAccount, @@ -464,68 +470,38 @@ describe('Multi-Cold-Inputs', () => { tokenPoolInfos, ); - // Create recipient ATA - const recipientAta = await getOrCreateAtaInterface( - rpc, - payer, + const ata = getAssociatedTokenAddressInterface( mint, - recipient.publicKey, + owner.publicKey, ); - const senderAta = getAssociatedTokenAddressInterface( - mint, - sender.publicKey, - ); + // Load ATA - should load ALL 4 accounts + await loadAta(rpc, ata, owner, mint); - // Transfer - should auto-load all cold and then transfer - const signature = await transferInterface( + // Verify ALL accounts were loaded (not just partial) + const countAfter = await getCompressedAccountCount( rpc, - payer, - senderAta, + owner.publicKey, mint, - recipientAta.parsed.address, - sender, - transferAmount, ); + expect(countAfter).toBe(0); - expect(signature).toBeDefined(); - - // Verify recipient got the tokens - const recipientBalance = (await rpc.getAccountInfo( - recipientAta.parsed.address, - ))!.data.readBigUInt64LE(64); - expect(recipientBalance).toBe(transferAmount); - - // Verify sender has change in hot ATA - const senderHotBalance = (await rpc.getAccountInfo( - senderAta, + const hotBalance = (await rpc.getAccountInfo( + ata, ))!.data.readBigUInt64LE(64); - const expectedChange = - BigInt(coldCount) * amountPerAccount - transferAmount; - expect(senderHotBalance).toBe(expectedChange); - - // Verify all cold accounts were consumed - const coldRemaining = await getCompressedAccountCount( - rpc, - sender.publicKey, - mint, - ); - expect(coldRemaining).toBe(0); - }, 180_000); + expect(hotBalance).toBe(BigInt(coldCount) * amountPerAccount); + }, 120_000); - it('should auto-load 8 cold inputs when transferring (at limit)', async () => { - const sender = await newAccountWithLamports(rpc, 2e9); - const recipient = Keypair.generate(); - const coldCount = 8; + it('should be idempotent - second load returns null', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + const coldCount = 3; const amountPerAccount = BigInt(500); - const transferAmount = BigInt(2000); - // Mint 8 cold accounts to sender await mintMultipleColdAccounts( rpc, payer, mint, - sender.publicKey, + owner.publicKey, mintAuthority, coldCount, amountPerAccount, @@ -533,129 +509,105 @@ describe('Multi-Cold-Inputs', () => { tokenPoolInfos, ); - // Create recipient ATA - const recipientAta = await getOrCreateAtaInterface( - rpc, - payer, - mint, - recipient.publicKey, - ); - - const senderAta = getAssociatedTokenAddressInterface( - mint, - sender.publicKey, - ); - - // Transfer - should auto-load all 8 cold and then transfer - const signature = await transferInterface( - rpc, - payer, - senderAta, + const ata = getAssociatedTokenAddressInterface( mint, - recipientAta.parsed.address, - sender, - transferAmount, + owner.publicKey, ); - expect(signature).toBeDefined(); - - // Verify recipient got the tokens - const recipientBalance = (await rpc.getAccountInfo( - recipientAta.parsed.address, - ))!.data.readBigUInt64LE(64); - expect(recipientBalance).toBe(transferAmount); + // First load + const sig1 = await loadAta(rpc, ata, owner, mint); + expect(sig1).not.toBeNull(); - // All 8 cold accounts should be consumed - const coldRemaining = await getCompressedAccountCount( - rpc, - sender.publicKey, - mint, - ); - expect(coldRemaining).toBe(0); - }, 180_000); + // Second load - should return null (nothing to load) + const sig2 = await loadAta(rpc, ata, owner, mint); + expect(sig2).toBeNull(); + }, 120_000); + }); - it('should auto-load 12 cold inputs via chunking when transferring', async () => { - const sender = await newAccountWithLamports(rpc, 3e9); - const recipient = Keypair.generate(); - const coldCount = 12; - const amountPerAccount = BigInt(300); - const transferAmount = BigInt(2000); + // --------------------------------------------------------------- + // Section 3: delegated compressed accounts (~3 output entries) + // --------------------------------------------------------------- + describe('delegated compressed accounts', () => { + it('should load compressed accounts that have delegates', async () => { + const owner = await newAccountWithLamports(rpc, 2e9); + const delegate = Keypair.generate(); - // Mint 12 cold accounts to sender - await mintMultipleColdAccounts( + // Mint compressed tokens + await mintTo( rpc, payer, mint, - sender.publicKey, + owner.publicKey, mintAuthority, - coldCount, - amountPerAccount, + bn(2000), stateTreeInfo, - tokenPoolInfos, + selectTokenPoolInfo(tokenPoolInfos), ); - const totalColdBalance = BigInt(coldCount) * amountPerAccount; - - // Create recipient ATA - const recipientAta = await getOrCreateAtaInterface( + // Approve delegate (creates a compressed account with delegate set) + await approve( rpc, payer, mint, - recipient.publicKey, + bn(1000), + owner, + delegate.publicKey, + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), ); - const senderAta = getAssociatedTokenAddressInterface( - mint, - sender.publicKey, + // Verify compressed accounts exist with total balance preserved + const result = await rpc.getCompressedTokenAccountsByOwner( + owner.publicKey, + { mint }, ); - - // Transfer - should auto-load all 12 cold (via 2 chunks) and then transfer - const signature = await transferInterface( - rpc, - payer, - senderAta, - mint, - recipientAta.parsed.address, - sender, - transferAmount, + expect(result.items.length).toBeGreaterThan(0); + const totalCompressed = result.items.reduce( + (sum, item) => sum + BigInt(item.parsed.amount.toString()), + BigInt(0), ); + expect(totalCompressed).toBe(BigInt(2000)); - expect(signature).toBeDefined(); - console.log( - '12 cold inputs transfer: succeeded with signature:', - signature, + // Verify at least one account has a delegate + const hasDelegate = result.items.some( + item => item.parsed.delegate !== null, ); + expect(hasDelegate).toBe(true); - // Verify recipient got the tokens - const recipientBalance = (await rpc.getAccountInfo( - recipientAta.parsed.address, - ))!.data.readBigUInt64LE(64); - expect(recipientBalance).toBe(transferAmount); + // Load all - should handle delegated accounts + const ata = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); + const signature = await loadAta(rpc, ata, owner, mint); + expect(signature).not.toBeNull(); - // Sender should have change in hot ATA - const senderHotBalance = (await rpc.getAccountInfo( - senderAta, + // Verify all loaded + const hotBalance = (await rpc.getAccountInfo( + ata, ))!.data.readBigUInt64LE(64); - const expectedChange = totalColdBalance - transferAmount; - expect(senderHotBalance).toBe(expectedChange); + expect(hotBalance).toBe(BigInt(2000)); - // All 12 cold accounts should be consumed const coldRemaining = await getCompressedAccountCount( rpc, - sender.publicKey, + owner.publicKey, mint, ); expect(coldRemaining).toBe(0); - }, 240_000); + }, 120_000); }); - describe('getAtaInterface aggregation', () => { - it('should aggregate ALL cold balances in _sources', async () => { - const owner = await newAccountWithLamports(rpc, 1e9); - const coldCount = 6; - const amountPerAccount = BigInt(250); + // --------------------------------------------------------------- + // Section 4: transferInterface with cold inputs (~28 output entries) + // --------------------------------------------------------------- + describe('transferInterface with multiple cold inputs', () => { + it('should auto-load 5 cold inputs when transferring', async () => { + const owner = await newAccountWithLamports(rpc, 2e9); + const recipient = Keypair.generate(); + const coldCount = 5; + const amountPerAccount = BigInt(1000); + const totalAmount = BigInt(coldCount) * amountPerAccount; - // Mint 6 cold accounts await mintMultipleColdAccounts( rpc, payer, @@ -668,117 +620,46 @@ describe('Multi-Cold-Inputs', () => { tokenPoolInfos, ); - const ata = getAssociatedTokenAddressInterface( - mint, - owner.publicKey, - ); - - // Fetch ATA interface - const ataInterface = await getAtaInterface( - rpc, - ata, - owner.publicKey, - mint, - ); - - // Should have aggregated balance - const expectedTotal = BigInt(coldCount) * amountPerAccount; - expect(ataInterface.parsed.amount).toBe(expectedTotal); - - // _sources should contain ALL cold accounts - const coldSources = - ataInterface._sources?.filter(s => s.type === 'ctoken-cold') ?? - []; - expect(coldSources.length).toBe(coldCount); - - // Each source should have loadContext - for (const source of coldSources) { - expect(source.loadContext).toBeDefined(); - expect(source.loadContext?.hash).toBeDefined(); - expect(source.loadContext?.treeInfo).toBeDefined(); - } - - // isCold should be true (primary source is cold since no hot exists) - expect(ataInterface.isCold).toBe(true); - - // _needsConsolidation should be true (multiple sources) - expect(ataInterface._needsConsolidation).toBe(true); - }, 120_000); - }); - - describe('instruction-level building with createLoadAtaInstructions', () => { - it('should build decompress instruction with 5 inputs', async () => { - const owner = await newAccountWithLamports(rpc, 1e9); - const coldCount = 5; - const amountPerAccount = BigInt(100); - - await mintMultipleColdAccounts( + // Create recipient ATA first + await getOrCreateAtaInterface( rpc, payer, mint, - owner.publicKey, - mintAuthority, - coldCount, - amountPerAccount, - stateTreeInfo, - tokenPoolInfos, + recipient.publicKey, ); - const ata = getAssociatedTokenAddressInterface( + const sourceAta = getAssociatedTokenAddressInterface( mint, owner.publicKey, ); - const ixs = await createLoadAtaInstructions( + // Transfer should auto-load all cold accounts + const signature = await transferInterface( rpc, - ata, - owner.publicKey, - mint, - ); - - // Should have at least 1 instruction - expect(ixs.length).toBeGreaterThan(0); - - // Log instruction details for debugging - for (let i = 0; i < ixs.length; i++) { - const ix = ixs[i]; - console.log(`Instruction ${i}:`, { - programId: ix.programId.toBase58(), - numKeys: ix.keys.length, - dataLength: ix.data.length, - }); - } - - // Build and send manually to verify it works - const { blockhash } = await rpc.getLatestBlockhash(); - const tx = buildAndSignTx( - [ - ComputeBudgetProgram.setComputeUnitLimit({ - units: 500_000, - }), - ...ixs, - ], payer, - blockhash, - [owner], + sourceAta, + mint, + recipient.publicKey, + owner, + totalAmount, ); + expect(signature).not.toBeNull(); - const signature = await sendAndConfirmTx(rpc, tx); - expect(signature).toBeDefined(); - - // Verify all loaded - const countAfter = await getCompressedAccountCount( + // Sender should have nothing left + const senderCount = await getCompressedAccountCount( rpc, owner.publicKey, mint, ); - expect(countAfter).toBe(0); + expect(senderCount).toBe(0); }, 120_000); - it('should measure CU usage for 8 cold inputs', async () => { + it('should auto-load 8 cold inputs when transferring (at limit)', async () => { const owner = await newAccountWithLamports(rpc, 2e9); + const recipient = Keypair.generate(); const coldCount = 8; - const amountPerAccount = BigInt(100); + const amountPerAccount = BigInt(500); + const totalAmount = BigInt(coldCount) * amountPerAccount; await mintMultipleColdAccounts( rpc, @@ -792,67 +673,36 @@ describe('Multi-Cold-Inputs', () => { tokenPoolInfos, ); - const ata = getAssociatedTokenAddressInterface( + const sourceAta = getAssociatedTokenAddressInterface( mint, owner.publicKey, ); - const ixs = await createLoadAtaInstructions( + const signature = await transferInterface( rpc, - ata, - owner.publicKey, - mint, - ); - - // Calculate estimated data size - let totalDataSize = 0; - let totalKeyCount = 0; - for (const ix of ixs) { - totalDataSize += ix.data.length; - totalKeyCount += ix.keys.length; - } - - console.log('8 cold inputs instruction stats:', { - instructionCount: ixs.length, - totalDataSize, - totalKeyCount, - }); - - // Build transaction - const { blockhash } = await rpc.getLatestBlockhash(); - const tx = buildAndSignTx( - [ - ComputeBudgetProgram.setComputeUnitLimit({ - units: 500_000, - }), - ...ixs, - ], payer, - blockhash, - [owner], + sourceAta, + mint, + recipient.publicKey, + owner, + totalAmount, ); + expect(signature).not.toBeNull(); - // Log serialized tx size - const serialized = tx.serialize(); - console.log('Serialized transaction size:', serialized.length); - - // Execute - const signature = await sendAndConfirmTx(rpc, tx); - expect(signature).toBeDefined(); - - // Verify - const countAfter = await getCompressedAccountCount( + const senderCount = await getCompressedAccountCount( rpc, owner.publicKey, mint, ); - expect(countAfter).toBe(0); - }, 180_000); + expect(senderCount).toBe(0); + }, 120_000); - it('should manually build and send 2 txs with 15 cold inputs using batches', async () => { - const owner = await newAccountWithLamports(rpc, 4e9); - const coldCount = 15; - const amountPerAccount = BigInt(100); + it('should auto-load 12 cold inputs via chunking when transferring', async () => { + const owner = await newAccountWithLamports(rpc, 3e9); + const recipient = Keypair.generate(); + const coldCount = 12; + const amountPerAccount = BigInt(200); + const totalAmount = BigInt(coldCount) * amountPerAccount; await mintMultipleColdAccounts( rpc, @@ -866,116 +716,39 @@ describe('Multi-Cold-Inputs', () => { tokenPoolInfos, ); - // Verify setup - const countBefore = await getCompressedAccountCount( - rpc, - owner.publicKey, + const sourceAta = getAssociatedTokenAddressInterface( mint, - ); - expect(countBefore).toBe(coldCount); - - const totalColdBalance = await getCompressedBalance( - rpc, owner.publicKey, - mint, ); - expect(totalColdBalance).toBe(BigInt(coldCount) * amountPerAccount); - const ata = getAssociatedTokenAddressInterface( + const signature = await transferInterface( + rpc, + payer, + sourceAta, mint, - owner.publicKey, + recipient.publicKey, + owner, + totalAmount, ); + expect(signature).not.toBeNull(); - // Build instruction batches using createLoadAtaInstructionBatches - // NOTE: Multiple decompress ixs in one tx causes nullification race condition, - // so we must send each batch as a separate transaction - const { batches, totalCompressedAccounts } = - await createLoadAtaInstructionBatches( - rpc, - ata, - owner.publicKey, - mint, - ); - - // Must have exactly 2 batches (8+7 accounts) - expect(batches.length).toBe(2); - expect(totalCompressedAccounts).toBe(15); - - // Log batch details - for (let batchIdx = 0; batchIdx < batches.length; batchIdx++) { - const batch = batches[batchIdx]; - console.log( - `Batch ${batchIdx}: ${batch.length} instruction(s)`, - ); - for (let i = 0; i < batch.length; i++) { - const ix = batch[i]; - console.log(` Instruction ${i}:`, { - programId: ix.programId.toBase58(), - numKeys: ix.keys.length, - dataLength: ix.data.length, - }); - } - } - - // Verify batch structure - expect(batches[0].length).toBe(2); // createATA + decompress 8 - expect(batches[1].length).toBe(1); // decompress 7 - - // Manually build and send EACH batch as a separate transaction - const signatures: string[] = []; - for (let batchIdx = 0; batchIdx < batches.length; batchIdx++) { - const batch = batches[batchIdx]; - const { blockhash } = await rpc.getLatestBlockhash(); - - const tx = buildAndSignTx( - [ - ComputeBudgetProgram.setComputeUnitLimit({ - units: 600_000, - }), - ...batch, - ], - payer, - blockhash, - [owner], - ); - - const serialized = tx.serialize(); - console.log( - `Batch ${batchIdx} serialized tx size:`, - serialized.length, - ); - - const signature = await sendAndConfirmTx(rpc, tx); - expect(signature).toBeDefined(); - signatures.push(signature); - console.log(`Batch ${batchIdx} succeeded:`, signature); - } - - expect(signatures.length).toBe(2); - - // Verify ALL 15 cold accounts were loaded - const countAfter = await getCompressedAccountCount( + const senderCount = await getCompressedAccountCount( rpc, owner.publicKey, mint, ); - expect(countAfter).toBe(0); - - // Verify hot balance - const hotBalance = (await rpc.getAccountInfo( - ata, - ))!.data.readBigUInt64LE(64); - expect(hotBalance).toBe(totalColdBalance); - }, 240_000); + expect(senderCount).toBe(0); + }, 180_000); }); - describe('edge cases', () => { - it('should handle partial load when only some accounts needed', async () => { - // Note: Current implementation loads ALL accounts, not just needed amount - // This test documents that behavior + // --------------------------------------------------------------- + // Section 5: getAtaInterface aggregation (~5 output entries) + // --------------------------------------------------------------- + describe('getAtaInterface aggregation', () => { + it('should aggregate ALL cold balances in _sources', async () => { const owner = await newAccountWithLamports(rpc, 1e9); - const coldCount = 4; - const amountPerAccount = BigInt(1000); + const coldCount = 5; + const amountPerAccount = BigInt(300); await mintMultipleColdAccounts( rpc, @@ -989,57 +762,45 @@ describe('Multi-Cold-Inputs', () => { tokenPoolInfos, ); - const ata = getAssociatedTokenAddressInterface( + // Get account interface - should aggregate all cold accounts + const ataAddress = getAssociatedTokenAddressInterface( mint, owner.publicKey, ); - - // Load ATA - should load ALL 4 accounts - await loadAta(rpc, ata, owner, mint); - - // Verify ALL accounts were loaded (not just partial) - const countAfter = await getCompressedAccountCount( + const ataInterface = await getAtaInterface( rpc, + ataAddress, owner.publicKey, mint, ); - expect(countAfter).toBe(0); - - const hotBalance = (await rpc.getAccountInfo( - ata, - ))!.data.readBigUInt64LE(64); - expect(hotBalance).toBe(BigInt(coldCount) * amountPerAccount); - }, 120_000); - it('should be idempotent - second load returns null', async () => { - const owner = await newAccountWithLamports(rpc, 1e9); - const coldCount = 3; - const amountPerAccount = BigInt(500); - - await mintMultipleColdAccounts( - rpc, - payer, - mint, - owner.publicKey, - mintAuthority, - coldCount, - amountPerAccount, - stateTreeInfo, - tokenPoolInfos, + // Total aggregated amount should include all 5 cold accounts + expect(ataInterface.parsed.amount).toBe( + BigInt(coldCount) * amountPerAccount, ); - const ata = getAssociatedTokenAddressInterface( - mint, - owner.publicKey, + // Sources should contain 5 cold entries + const sources = ataInterface._sources ?? []; + const coldSources = sources.filter( + s => + s.type === 'ctoken-cold' || + s.type === 'spl-cold' || + s.type === 'token2022-cold', ); + expect(coldSources.length).toBe(coldCount); - // First load - const sig1 = await loadAta(rpc, ata, owner, mint); - expect(sig1).not.toBeNull(); + // Each source should have loadContext + for (const source of coldSources) { + expect(source.loadContext).toBeDefined(); + expect(source.loadContext!.hash).toBeDefined(); + expect(source.loadContext!.treeInfo).toBeDefined(); + } - // Second load - should return null (nothing to load) - const sig2 = await loadAta(rpc, ata, owner, mint); - expect(sig2).toBeNull(); + // isCold should be true (primary source is cold since no hot exists) + expect(ataInterface.isCold).toBe(true); + + // _needsConsolidation should be true (multiple sources) + expect(ataInterface._needsConsolidation).toBe(true); }, 120_000); }); }); diff --git a/js/compressed-token/tests/e2e/multi-pool.test.ts b/js/compressed-token/tests/e2e/multi-pool.test.ts index 1185de63e6..1e6dcdde63 100644 --- a/js/compressed-token/tests/e2e/multi-pool.test.ts +++ b/js/compressed-token/tests/e2e/multi-pool.test.ts @@ -12,7 +12,7 @@ import { addTokenPools, compress, createMint, - createTokenPool, + createSplInterface, decompress, } from '../../src/actions'; import { @@ -126,7 +126,7 @@ describe('multi-pool', () => { ), ).rejects.toThrow(); - await createTokenPool(rpc, payer, mint); + await createSplInterface(rpc, payer, mint); await addTokenPools(rpc, payer, mint, 3); const stateTreeInfos = await rpc.getStateTreeInfos(); diff --git a/js/compressed-token/tests/e2e/payment-flows.test.ts b/js/compressed-token/tests/e2e/payment-flows.test.ts index 3d3525b595..97d7f99f0b 100644 --- a/js/compressed-token/tests/e2e/payment-flows.test.ts +++ b/js/compressed-token/tests/e2e/payment-flows.test.ts @@ -20,7 +20,7 @@ import { TreeInfo, VERSION, featureFlags, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, buildAndSignTx, sendAndConfirmTx, } from '@lightprotocol/stateless.js'; @@ -33,12 +33,16 @@ import { import { getAtaInterface } from '../../src/v3/get-account-interface'; import { getAssociatedTokenAddressInterface } from '../../src/v3/get-associated-token-address-interface'; import { getOrCreateAtaInterface } from '../../src/v3/actions/get-or-create-ata-interface'; -import { transferInterface } from '../../src/v3/actions/transfer-interface'; +import { + transferInterface, + createTransferInterfaceInstructions, + sliceLast, +} from '../../src/v3/actions/transfer-interface'; import { createLoadAccountsParams, loadAta, } from '../../src/v3/actions/load-ata'; -import { createTransferInterfaceInstruction } from '../../src/v3/instructions/transfer-interface'; +import { createLightTokenTransferInstruction } from '../../src/v3/instructions/transfer-interface'; import { createAssociatedTokenAccountInterfaceIdempotentInstruction } from '../../src/v3/instructions/create-ata-interface'; featureFlags.version = VERSION.V2; @@ -103,7 +107,7 @@ describe('Payment Flows', () => { recipient.publicKey, ); - // STEP 2: transfer (auto-loads sender, destination must exist) + // STEP 2: transfer (auto-loads sender, auto-creates recipient ATA) const sourceAta = getAssociatedTokenAddressInterface( mint, sender.publicKey, @@ -113,10 +117,10 @@ describe('Payment Flows', () => { payer, sourceAta, mint, - recipientAta.parsed.address, + recipient.publicKey, sender, amount, - CTOKEN_PROGRAM_ID, + undefined, undefined, { splInterfaceInfos: tokenPoolInfos }, ); @@ -154,7 +158,7 @@ describe('Payment Flows', () => { recipient.publicKey, ); - // Transfer - auto-loads sender + // Transfer - auto-loads sender, auto-creates recipient ATA const sourceAta = getAssociatedTokenAddressInterface( mint, sender.publicKey, @@ -164,10 +168,10 @@ describe('Payment Flows', () => { payer, sourceAta, mint, - recipientAta.parsed.address, + recipient.publicKey, sender, BigInt(2000), - CTOKEN_PROGRAM_ID, + undefined, undefined, { splInterfaceInfos: tokenPoolInfos }, ); @@ -234,13 +238,13 @@ describe('Payment Flows', () => { destAta, ))!.data.readBigUInt64LE(64); - // Transfer - no loading needed + // Transfer - no loading needed, pass wallet pubkey await transferInterface( rpc, payer, sourceAta, mint, - destAta, + recipient.publicKey, sender, BigInt(500), ); @@ -290,7 +294,7 @@ describe('Payment Flows', () => { const result = await createLoadAccountsParams( rpc, payer.publicKey, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, [], [senderAta], { splInterfaceInfos: tokenPoolInfos }, @@ -312,10 +316,10 @@ describe('Payment Flows', () => { recipientAtaAddress, recipient.publicKey, mint, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ), // Transfer - createTransferInterfaceInstruction( + createLightTokenTransferInstruction( senderAtaAddress, recipientAtaAddress, sender.publicKey, @@ -368,7 +372,7 @@ describe('Payment Flows', () => { const result = await createLoadAccountsParams( rpc, payer.publicKey, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, [], [senderAta], ); @@ -386,9 +390,9 @@ describe('Payment Flows', () => { recipientAtaAddress, recipient.publicKey, mint, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ), - createTransferInterfaceInstruction( + createLightTokenTransferInstruction( senderAtaAddress, recipientAtaAddress, sender.publicKey, @@ -450,23 +454,23 @@ describe('Payment Flows', () => { r1AtaAddress, recipient1.publicKey, mint, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ), createAssociatedTokenAccountInterfaceIdempotentInstruction( payer.publicKey, r2AtaAddress, recipient2.publicKey, mint, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ), // Transfers - createTransferInterfaceInstruction( + createLightTokenTransferInstruction( senderAtaAddress, r1AtaAddress, sender.publicKey, BigInt(1000), ), - createTransferInterfaceInstruction( + createLightTokenTransferInstruction( senderAtaAddress, r2AtaAddress, sender.publicKey, @@ -490,6 +494,258 @@ describe('Payment Flows', () => { }); }); + // ================================================================ + // TRANSFER INSTRUCTIONS (Production Payment Pattern) + // ================================================================ + + describe('createTransferInterfaceInstructions', () => { + it('hot sender: single batch', async () => { + const sender = await newAccountWithLamports(rpc, 1e9); + const recipient = Keypair.generate(); + const amount = BigInt(500); + + // Setup: mint and load to make sender hot + await mintTo( + rpc, + payer, + mint, + sender.publicKey, + mintAuthority, + bn(2000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + const senderAta = getAssociatedTokenAddressInterface( + mint, + sender.publicKey, + ); + await loadAta(rpc, senderAta, sender, mint); + + // Ensure recipient ATA exists + await getOrCreateAtaInterface( + rpc, + payer, + mint, + recipient.publicKey, + ); + + // Get transfer instructions + const batches = await createTransferInterfaceInstructions( + rpc, + payer.publicKey, + mint, + amount, + sender.publicKey, + recipient.publicKey, + ); + + // Hot sender: single transaction (no loads) + expect(batches.length).toBe(1); + + // Production pattern: build tx, sign, send + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx(batches[0], payer, blockhash, [sender]); + const sig = await sendAndConfirmTx(rpc, tx); + expect(sig).toBeDefined(); + + // Verify + const recipientAta = getAssociatedTokenAddressInterface( + mint, + recipient.publicKey, + ); + const recipientBalance = (await rpc.getAccountInfo( + recipientAta, + ))!.data.readBigUInt64LE(64); + expect(recipientBalance).toBe(amount); + }); + + it('cold sender (<=8 inputs): single transaction', async () => { + const sender = await newAccountWithLamports(rpc, 1e9); + const recipient = Keypair.generate(); + + // Mint 3 compressed accounts + for (let i = 0; i < 3; i++) { + await mintTo( + rpc, + payer, + mint, + sender.publicKey, + mintAuthority, + bn(1000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + } + + // Ensure recipient ATA exists + await getOrCreateAtaInterface( + rpc, + payer, + mint, + recipient.publicKey, + ); + + const batches = await createTransferInterfaceInstructions( + rpc, + payer.publicKey, + mint, + BigInt(2500), + sender.publicKey, + recipient.publicKey, + ); + + // <=8 cold inputs: all fits in one transaction + expect(batches.length).toBe(1); + + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx(batches[0], payer, blockhash, [sender]); + const sig = await sendAndConfirmTx(rpc, tx); + expect(sig).toBeDefined(); + + const recipientAta = getAssociatedTokenAddressInterface( + mint, + recipient.publicKey, + ); + const recipientBalance = (await rpc.getAccountInfo( + recipientAta, + ))!.data.readBigUInt64LE(64); + expect(recipientBalance).toBe(BigInt(2500)); + }); + + it('cold sender (12 inputs): parallel load + sequential transfer', async () => { + const sender = await newAccountWithLamports(rpc, 1e9); + const recipient = Keypair.generate(); + + // Mint 12 compressed accounts (100 each = 1200 total) + for (let i = 0; i < 12; i++) { + await mintTo( + rpc, + payer, + mint, + sender.publicKey, + mintAuthority, + bn(100), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + } + + // Ensure recipient ATA exists + await getOrCreateAtaInterface( + rpc, + payer, + mint, + recipient.publicKey, + ); + + const batches = await createTransferInterfaceInstructions( + rpc, + payer.publicKey, + mint, + BigInt(1100), + sender.publicKey, + recipient.publicKey, + ); + + // >8 inputs: 2 batches (load + transfer) + expect(batches.length).toBe(2); + + // Send: loads in parallel, then transfer + const { rest: loads, last: transferIxs } = sliceLast(batches); + const loadSigs = await Promise.all( + loads.map(async ixs => { + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx(ixs, payer, blockhash, [sender]); + return sendAndConfirmTx(rpc, tx); + }), + ); + for (const sig of loadSigs) { + expect(sig).toBeDefined(); + } + + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx(transferIxs, payer, blockhash, [sender]); + const sig = await sendAndConfirmTx(rpc, tx); + expect(sig).toBeDefined(); + + // Verify + const recipientAta = getAssociatedTokenAddressInterface( + mint, + recipient.publicKey, + ); + const recipientBalance = (await rpc.getAccountInfo( + recipientAta, + ))!.data.readBigUInt64LE(64); + expect(recipientBalance).toBe(BigInt(1100)); + }, 120_000); + + it('cold sender (20 inputs): parallel loads + sequential transfer', async () => { + const sender = await newAccountWithLamports(rpc, 1e9); + const recipient = Keypair.generate(); + + // Mint 20 compressed accounts (50 each = 1000 total) + for (let i = 0; i < 20; i++) { + await mintTo( + rpc, + payer, + mint, + sender.publicKey, + mintAuthority, + bn(50), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + } + + // Ensure recipient ATA exists + await getOrCreateAtaInterface( + rpc, + payer, + mint, + recipient.publicKey, + ); + + const batches = await createTransferInterfaceInstructions( + rpc, + payer.publicKey, + mint, + BigInt(900), + sender.publicKey, + recipient.publicKey, + ); + + // 20 inputs: 3 batches (8+8 loads + last 4 + transfer) + expect(batches.length).toBe(3); + + // Send: loads in parallel, then transfer + const { rest: loads, last: transferIxs } = sliceLast(batches); + const loadSigs = await Promise.all( + loads.map(async ixs => { + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx(ixs, payer, blockhash, [sender]); + return sendAndConfirmTx(rpc, tx); + }), + ); + for (const sig of loadSigs) { + expect(sig).toBeDefined(); + } + + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx(transferIxs, payer, blockhash, [sender]); + const sig = await sendAndConfirmTx(rpc, tx); + expect(sig).toBeDefined(); + + const recipientAta = getAssociatedTokenAddressInterface( + mint, + recipient.publicKey, + ); + const recipientBalance = (await rpc.getAccountInfo( + recipientAta, + ))!.data.readBigUInt64LE(64); + expect(recipientBalance).toBe(BigInt(900)); + }, 180_000); + }); + // ================================================================ // IDEMPOTENCY // ================================================================ @@ -549,9 +805,9 @@ describe('Payment Flows', () => { recipientAtaAddress, recipient.publicKey, mint, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ), - createTransferInterfaceInstruction( + createLightTokenTransferInstruction( senderAtaAddress, recipientAtaAddress, sender.publicKey, diff --git a/js/compressed-token/tests/e2e/transfer-interface.test.ts b/js/compressed-token/tests/e2e/transfer-interface.test.ts index 8e21b6c0c6..5c163d71b0 100644 --- a/js/compressed-token/tests/e2e/transfer-interface.test.ts +++ b/js/compressed-token/tests/e2e/transfer-interface.test.ts @@ -7,10 +7,15 @@ import { createRpc, selectStateTreeInfo, TreeInfo, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, VERSION, featureFlags, } from '@lightprotocol/stateless.js'; +import { + TOKEN_PROGRAM_ID, + getAccount, + getAssociatedTokenAddressSync, +} from '@solana/spl-token'; import { createMint, mintTo } from '../../src/actions'; import { getTokenPoolInfos, @@ -19,20 +24,21 @@ import { } from '../../src/utils/get-token-pool-infos'; import { getAssociatedTokenAddressInterface } from '../../src/v3/get-associated-token-address-interface'; import { getOrCreateAtaInterface } from '../../src/v3/actions/get-or-create-ata-interface'; -import { transferInterface } from '../../src/v3/actions/transfer-interface'; +import { + transferInterface, + createTransferInterfaceInstructions, +} from '../../src/v3/actions/transfer-interface'; import { loadAta, createLoadAtaInstructions, } from '../../src/v3/actions/load-ata'; -import { - createTransferInterfaceInstruction, - createCTokenTransferInstruction, -} from '../../src/v3/instructions/transfer-interface'; +import { createLightTokenTransferInstruction } from '../../src/v3/instructions/transfer-interface'; import { LIGHT_TOKEN_RENT_SPONSOR, TOTAL_COMPRESSION_COST, DEFAULT_PREPAY_EPOCHS, } from '../../src/constants'; +import { getAtaProgramId } from '../../src/v3/ata-utils'; featureFlags.version = VERSION.V2; @@ -66,21 +72,21 @@ describe('transfer-interface', () => { tokenPoolInfos = await getTokenPoolInfos(rpc, mint); }, 60_000); - describe('createTransferInterfaceInstruction', () => { - it('should create CToken transfer instruction with correct accounts', () => { + describe('createLightTokenTransferInstruction', () => { + it('should create Light token transfer instruction with correct accounts', () => { const source = Keypair.generate().publicKey; const destination = Keypair.generate().publicKey; const owner = Keypair.generate().publicKey; const amount = BigInt(1000); - const ix = createTransferInterfaceInstruction( + const ix = createLightTokenTransferInstruction( source, destination, owner, amount, ); - expect(ix.programId.equals(CTOKEN_PROGRAM_ID)).toBe(true); + expect(ix.programId.equals(LIGHT_TOKEN_PROGRAM_ID)).toBe(true); // 5 accounts: source, destination, owner, system_program, fee_payer expect(ix.keys.length).toBe(5); expect(ix.keys[0].pubkey.equals(source)).toBe(true); @@ -94,7 +100,7 @@ describe('transfer-interface', () => { const owner = Keypair.generate().publicKey; const amount = BigInt(1000); - const ix = createCTokenTransferInstruction( + const ix = createLightTokenTransferInstruction( source, destination, owner, @@ -120,7 +126,7 @@ describe('transfer-interface', () => { owner.publicKey, ); - const ixs = await createLoadAtaInstructions( + const batches = await createLoadAtaInstructions( rpc, ata, owner.publicKey, @@ -128,7 +134,7 @@ describe('transfer-interface', () => { payer.publicKey, ); - expect(ixs.length).toBe(0); + expect(batches.length).toBe(0); }); it('should build load instructions for compressed balance', async () => { @@ -150,14 +156,14 @@ describe('transfer-interface', () => { mint, owner.publicKey, ); - const ixs = await createLoadAtaInstructions( + const batches = await createLoadAtaInstructions( rpc, ata, owner.publicKey, mint, ); - expect(ixs.length).toBeGreaterThan(0); + expect(batches.length).toBeGreaterThan(0); }); it('should load ALL compressed accounts', async () => { @@ -189,14 +195,14 @@ describe('transfer-interface', () => { mint, owner.publicKey, ); - const ixs = await createLoadAtaInstructions( + const batches = await createLoadAtaInstructions( rpc, ata, owner.publicKey, mint, ); - expect(ixs.length).toBeGreaterThan(0); + expect(batches.length).toBeGreaterThan(0); }); }); @@ -284,13 +290,13 @@ describe('transfer-interface', () => { sender.publicKey, ); - // Transfer - destination is ATA address + // Transfer - destination is recipient wallet public key const signature = await transferInterface( rpc, payer, sourceAta, mint, - recipientAta.parsed.address, + recipient.publicKey, sender, BigInt(1000), ); @@ -344,10 +350,10 @@ describe('transfer-interface', () => { payer, sourceAta, mint, - recipientAta.parsed.address, + recipient.publicKey, sender, BigInt(2000), - CTOKEN_PROGRAM_ID, + undefined, undefined, { splInterfaceInfos: tokenPoolInfos }, ); @@ -385,7 +391,7 @@ describe('transfer-interface', () => { payer, wrongSource, mint, - recipientAta.parsed.address, + recipient.publicKey, sender, BigInt(100), ), @@ -426,10 +432,10 @@ describe('transfer-interface', () => { payer, sourceAta, mint, - recipientAta.parsed.address, + recipient.publicKey, sender, BigInt(99999), - CTOKEN_PROGRAM_ID, + undefined, undefined, { splInterfaceInfos: tokenPoolInfos }, ), @@ -497,13 +503,13 @@ describe('transfer-interface', () => { destAta, ))!.data.readBigUInt64LE(64); - // Transfer + // Transfer - pass recipient wallet, not ATA await transferInterface( rpc, payer, sourceAta, mint, - destAta, + recipient.publicKey, sender, BigInt(500), ); @@ -594,4 +600,196 @@ describe('transfer-interface', () => { expect(recipientAtaBalance).toBe(expectedAtaBalance); }); }); + + // ================================================================ + // SPL/T22 NO-WRAP TRANSFER (programId=TOKEN_PROGRAM_ID, wrap=false) + // ================================================================ + describe('transferInterface 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); + + // Mint compressed tokens (cold) + await mintTo( + rpc, + payer, + mint, + sender.publicKey, + mintAuthority, + bn(5000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + // Derive SPL ATAs (not c-token ATAs) + const senderSplAta = getAssociatedTokenAddressSync( + mint, + sender.publicKey, + false, + TOKEN_PROGRAM_ID, + getAtaProgramId(TOKEN_PROGRAM_ID), + ); + const recipientSplAta = getAssociatedTokenAddressSync( + mint, + recipient.publicKey, + false, + TOKEN_PROGRAM_ID, + getAtaProgramId(TOKEN_PROGRAM_ID), + ); + + // Transfer using SPL program (no wrap) + // This should: 1) create sender SPL ATA, 2) decompress cold -> SPL ATA, + // 3) create recipient SPL ATA, 4) SPL transferChecked + const signature = await transferInterface( + rpc, + payer, + senderSplAta, + mint, + recipient.publicKey, + sender, + BigInt(2000), + TOKEN_PROGRAM_ID, + undefined, + { splInterfaceInfos: tokenPoolInfos }, + false, + ); + + expect(signature).toBeDefined(); + + // Verify recipient SPL ATA has tokens + const recipientAccount = await getAccount( + rpc, + recipientSplAta, + undefined, + TOKEN_PROGRAM_ID, + ); + expect(recipientAccount.amount).toBe(BigInt(2000)); + + // Verify sender SPL ATA has remaining tokens + const senderAccount = await getAccount( + rpc, + senderSplAta, + undefined, + TOKEN_PROGRAM_ID, + ); + expect(senderAccount.amount).toBe(BigInt(3000)); + }, 120_000); + + it('should build SPL transfer instructions via createTransferInterfaceInstructions', async () => { + const sender = await newAccountWithLamports(rpc, 2e9); + const recipient = Keypair.generate(); + + // Mint compressed tokens (cold) + await mintTo( + rpc, + payer, + mint, + sender.publicKey, + mintAuthority, + bn(3000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + const batches = await createTransferInterfaceInstructions( + rpc, + payer.publicKey, + mint, + BigInt(1000), + sender.publicKey, + recipient.publicKey, + { + programId: TOKEN_PROGRAM_ID, + splInterfaceInfos: tokenPoolInfos, + }, + ); + + // Should have at least one batch with the transfer + expect(batches.length).toBeGreaterThan(0); + + // The last batch (transfer tx) should contain a SPL transferChecked + // instruction as its last ix (programId = TOKEN_PROGRAM_ID) + const transferBatch = batches[batches.length - 1]; + const transferIx = transferBatch[transferBatch.length - 1]; + expect(transferIx.programId.equals(TOKEN_PROGRAM_ID)).toBe(true); + }, 120_000); + + it('should transfer hot-only SPL balance (no decompress needed)', async () => { + const sender = await newAccountWithLamports(rpc, 2e9); + const recipient = await newAccountWithLamports(rpc, 1e9); + + // First: mint compressed and decompress to SPL ATA to get hot SPL balance + await mintTo( + rpc, + payer, + mint, + sender.publicKey, + mintAuthority, + bn(4000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + const senderSplAta = getAssociatedTokenAddressSync( + mint, + sender.publicKey, + false, + TOKEN_PROGRAM_ID, + getAtaProgramId(TOKEN_PROGRAM_ID), + ); + + // Load to SPL ATA first (decompress) + await loadAta(rpc, senderSplAta, sender, mint, payer); + + // Verify sender has hot SPL balance + const senderBefore = await getAccount( + rpc, + senderSplAta, + undefined, + TOKEN_PROGRAM_ID, + ); + expect(senderBefore.amount).toBe(BigInt(4000)); + + // Now transfer using SPL programId -- should be hot-only (no decompress) + const signature = await transferInterface( + rpc, + payer, + senderSplAta, + mint, + recipient.publicKey, + sender, + BigInt(1500), + TOKEN_PROGRAM_ID, + undefined, + undefined, + false, + ); + + expect(signature).toBeDefined(); + + // Verify balances + const recipientSplAta = getAssociatedTokenAddressSync( + mint, + recipient.publicKey, + false, + TOKEN_PROGRAM_ID, + getAtaProgramId(TOKEN_PROGRAM_ID), + ); + const recipientAccount = await getAccount( + rpc, + recipientSplAta, + undefined, + TOKEN_PROGRAM_ID, + ); + expect(recipientAccount.amount).toBe(BigInt(1500)); + + const senderAfter = await getAccount( + rpc, + senderSplAta, + undefined, + TOKEN_PROGRAM_ID, + ); + expect(senderAfter.amount).toBe(BigInt(2500)); + }, 120_000); + }); }); diff --git a/js/compressed-token/tests/e2e/unwrap.test.ts b/js/compressed-token/tests/e2e/unwrap.test.ts index 806ac5f1a8..ef7a2ce6bb 100644 --- a/js/compressed-token/tests/e2e/unwrap.test.ts +++ b/js/compressed-token/tests/e2e/unwrap.test.ts @@ -9,6 +9,8 @@ import { TreeInfo, VERSION, featureFlags, + buildAndSignTx, + sendAndConfirmTx, } from '@lightprotocol/stateless.js'; import { createMint, mintTo } from '../../src/actions'; import { @@ -24,7 +26,7 @@ import { TokenPoolInfo, } from '../../src/utils/get-token-pool-infos'; import { createUnwrapInstruction } from '../../src/v3/instructions/unwrap'; -import { unwrap } from '../../src/v3/actions/unwrap'; +import { unwrap, createUnwrapInstructions } from '../../src/v3/actions/unwrap'; import { getAssociatedTokenAddressInterface } from '../../src'; import { createAtaInterfaceIdempotent } from '../../src/v3/actions/create-ata-interface'; import { getAtaProgramId } from '../../src/v3/ata-utils'; @@ -136,6 +138,221 @@ describe('createUnwrapInstruction', () => { }); }); +describe('createUnwrapInstructions', () => { + let rpc: Rpc; + let payer: Signer; + let mint: PublicKey; + let mintAuthority: Keypair; + let stateTreeInfo: TreeInfo; + let tokenPoolInfos: TokenPoolInfo[]; + + beforeAll(async () => { + rpc = createRpc(); + payer = await newAccountWithLamports(rpc, 10e9); + mintAuthority = Keypair.generate(); + const mintKeypair = Keypair.generate(); + + mint = ( + await createMint( + rpc, + payer, + mintAuthority.publicKey, + TEST_TOKEN_DECIMALS, + mintKeypair, + ) + ).mint; + + stateTreeInfo = selectStateTreeInfo(await rpc.getStateTreeInfos()); + tokenPoolInfos = await getTokenPoolInfos(rpc, mint); + }, 60_000); + + it('should return instruction batches including unwrap (from cold)', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + // Mint compressed tokens (cold) + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(1000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + // Create destination SPL ATA + const splAta = await createAssociatedTokenAccount( + rpc, + payer, + mint, + owner.publicKey, + undefined, + TOKEN_PROGRAM_ID, + ); + + const batches = await createUnwrapInstructions( + rpc, + splAta, + owner.publicKey, + mint, + BigInt(500), + payer.publicKey, + ); + + // Should have at least one batch (load + unwrap, or just unwrap) + expect(batches.length).toBeGreaterThanOrEqual(1); + // Each batch should be a non-empty array of instructions + for (const batch of batches) { + expect(batch.length).toBeGreaterThan(0); + } + + // Execute all batches + for (const ixs of batches) { + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx(ixs, payer, blockhash, [owner]); + await sendAndConfirmTx(rpc, tx); + } + + // Verify SPL balance + const splBalance = await getAccount(rpc, splAta); + expect(splBalance.amount).toBe(BigInt(500)); + + // Verify remaining c-token balance + const ctokenAta = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); + const ctokenBalance = await getCTokenBalance(rpc, ctokenAta); + expect(ctokenBalance).toBe(BigInt(500)); + }, 60_000); + + it('should return single batch when balance is already hot', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + // Create c-token ATA and mint to hot + const ctokenAta = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); + await createAtaInterfaceIdempotent(rpc, payer, mint, owner.publicKey); + + // Mint compressed tokens + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(800), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + // Load to hot first + const { loadAta } = await import('../../src/v3/actions/load-ata'); + await loadAta(rpc, ctokenAta, owner, mint, payer); + + // Create destination SPL ATA + const splAta = await createAssociatedTokenAccount( + rpc, + payer, + mint, + owner.publicKey, + undefined, + TOKEN_PROGRAM_ID, + ); + + const batches = await createUnwrapInstructions( + rpc, + splAta, + owner.publicKey, + mint, + BigInt(300), + payer.publicKey, + ); + + // Should be a single batch (no load needed, just unwrap) + expect(batches.length).toBe(1); + + // Execute + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx(batches[0], payer, blockhash, [owner]); + await sendAndConfirmTx(rpc, tx); + + const splBalance = await getAccount(rpc, splAta); + expect(splBalance.amount).toBe(BigInt(300)); + }, 60_000); + + it('should throw when destination does not exist', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(100), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + const splAta = getAssociatedTokenAddressSync( + mint, + owner.publicKey, + false, + TOKEN_PROGRAM_ID, + ); + + await expect( + createUnwrapInstructions( + rpc, + splAta, + owner.publicKey, + mint, + BigInt(50), + payer.publicKey, + ), + ).rejects.toThrow(/does not exist/); + }, 60_000); + + it('should throw on insufficient balance', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(100), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + const splAta = await createAssociatedTokenAccount( + rpc, + payer, + mint, + owner.publicKey, + undefined, + TOKEN_PROGRAM_ID, + ); + + await expect( + createUnwrapInstructions( + rpc, + splAta, + owner.publicKey, + mint, + BigInt(99999), + payer.publicKey, + ), + ).rejects.toThrow(/Insufficient/); + }, 60_000); +}); + describe('unwrap action', () => { let rpc: Rpc; let payer: Signer; diff --git a/js/compressed-token/tests/e2e/update-metadata.test.ts b/js/compressed-token/tests/e2e/update-metadata.test.ts index 53d7192000..b621c595bd 100644 --- a/js/compressed-token/tests/e2e/update-metadata.test.ts +++ b/js/compressed-token/tests/e2e/update-metadata.test.ts @@ -7,7 +7,7 @@ import { VERSION, featureFlags, getDefaultAddressTreeInfo, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, } from '@lightprotocol/stateless.js'; import { createMintInterface, updateMintAuthority } from '../../src/v3/actions'; import { createTokenMetadata } from '../../src/v3/instructions'; @@ -61,7 +61,7 @@ describe('updateMetadata', () => { rpc, mintPda, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(mintInfoBefore.tokenMetadata?.name).toBe('Initial Token'); @@ -79,7 +79,7 @@ describe('updateMetadata', () => { rpc, mintPda, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(mintInfoAfter.tokenMetadata?.name).toBe('Updated Token'); expect(mintInfoAfter.tokenMetadata?.symbol).toBe('INIT'); @@ -129,7 +129,7 @@ describe('updateMetadata', () => { rpc, mintPda, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(mintInfoAfter.tokenMetadata?.symbol).toBe('UPDATED'); expect(mintInfoAfter.tokenMetadata?.name).toBe('Test Token'); @@ -176,7 +176,7 @@ describe('updateMetadata', () => { rpc, mintPda, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(mintInfoAfter.tokenMetadata?.uri).toBe( 'https://new.com/metadata', @@ -216,7 +216,7 @@ describe('updateMetadata', () => { rpc, mintPda, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(mintInfoBefore.tokenMetadata?.updateAuthority?.toString()).toBe( initialMetadataAuthority.publicKey.toString(), @@ -235,7 +235,7 @@ describe('updateMetadata', () => { rpc, mintPda, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(mintInfoAfter.tokenMetadata?.updateAuthority?.toString()).toBe( newMetadataAuthority.publicKey.toString(), @@ -283,7 +283,7 @@ describe('updateMetadata', () => { rpc, mintPda, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(mintInfoAfterName.tokenMetadata?.name).toBe('New Name'); @@ -301,7 +301,7 @@ describe('updateMetadata', () => { rpc, mintPda, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(mintInfoAfterSymbol.tokenMetadata?.name).toBe('New Name'); expect(mintInfoAfterSymbol.tokenMetadata?.symbol).toBe('NEW'); @@ -320,7 +320,7 @@ describe('updateMetadata', () => { rpc, mintPda, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(mintInfoFinal.tokenMetadata?.name).toBe('New Name'); expect(mintInfoFinal.tokenMetadata?.symbol).toBe('NEW'); @@ -438,7 +438,7 @@ describe('updateMetadata', () => { rpc, mintPda, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(mintInfo.tokenMetadata).toBeDefined(); }); @@ -484,7 +484,7 @@ describe('updateMetadata', () => { rpc, mintPda, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(mintInfo.tokenMetadata?.name).toBe('Updated by Mint Authority'); expect(mintInfo.tokenMetadata?.updateAuthority?.toString()).toBe( diff --git a/js/compressed-token/tests/e2e/update-mint.test.ts b/js/compressed-token/tests/e2e/update-mint.test.ts index 27bc970a17..68a1477c6e 100644 --- a/js/compressed-token/tests/e2e/update-mint.test.ts +++ b/js/compressed-token/tests/e2e/update-mint.test.ts @@ -7,7 +7,7 @@ import { VERSION, featureFlags, getDefaultAddressTreeInfo, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, } from '@lightprotocol/stateless.js'; import { createMintInterface } from '../../src/v3/actions'; import { @@ -50,7 +50,7 @@ describe('updateMint', () => { rpc, mintPda, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(mintInfoBefore.mint.mintAuthority?.toString()).toBe( initialMintAuthority.publicKey.toString(), @@ -69,7 +69,7 @@ describe('updateMint', () => { rpc, mintPda, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(mintInfoAfter.mint.mintAuthority?.toString()).toBe( newMintAuthority.publicKey.toString(), @@ -108,7 +108,7 @@ describe('updateMint', () => { rpc, mintPda, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(mintInfoAfter.mint.mintAuthority).toBe(null); expect(mintInfoAfter.mint.supply).toBe(0n); @@ -137,7 +137,7 @@ describe('updateMint', () => { rpc, mintPda, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(mintInfoBefore.mint.freezeAuthority?.toString()).toBe( initialFreezeAuthority.publicKey.toString(), @@ -156,7 +156,7 @@ describe('updateMint', () => { rpc, mintPda, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(mintInfoAfter.mint.freezeAuthority?.toString()).toBe( newFreezeAuthority.publicKey.toString(), @@ -197,7 +197,7 @@ describe('updateMint', () => { rpc, mintPda, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(mintInfoAfter.mint.freezeAuthority).toBe(null); expect(mintInfoAfter.mint.mintAuthority?.toString()).toBe( @@ -238,7 +238,7 @@ describe('updateMint', () => { rpc, mintPda, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(mintInfoAfterMintAuth.mint.mintAuthority?.toString()).toBe( newMintAuthority.publicKey.toString(), @@ -257,7 +257,7 @@ describe('updateMint', () => { rpc, mintPda, undefined, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(mintInfoAfterBoth.mint.mintAuthority?.toString()).toBe( newMintAuthority.publicKey.toString(), 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 aaec2b48de..5afd0c2137 100644 --- a/js/compressed-token/tests/e2e/v3-interface-migration.test.ts +++ b/js/compressed-token/tests/e2e/v3-interface-migration.test.ts @@ -29,7 +29,6 @@ import { getAtaInterface, getAssociatedTokenAddressInterface, transferInterface, - createAtaInterfaceIdempotent, } from '../../src/v3'; import { createLoadAtaInstructions, loadAta } from '../../src/index'; @@ -326,26 +325,15 @@ describe('v3-interface-v1-rejection', () => { mint, owner.publicKey, ); - const destAta = getAssociatedTokenAddressInterface( - mint, - recipient.publicKey, - ); - - await createAtaInterfaceIdempotent( - rpc, - payer, - recipient.publicKey, - mint, - ); - // transferInterface(rpc, payer, source, mint, destination, owner, amount) + // transferInterface(rpc, payer, source, mint, recipientWallet, owner, amount) await expect( transferInterface( rpc, payer, sourceAta, mint, - destAta, + recipient.publicKey, owner, BigInt(500), ), diff --git a/js/compressed-token/tests/e2e/wrap.test.ts b/js/compressed-token/tests/e2e/wrap.test.ts index 7801594db4..8698c1aa3b 100644 --- a/js/compressed-token/tests/e2e/wrap.test.ts +++ b/js/compressed-token/tests/e2e/wrap.test.ts @@ -7,7 +7,7 @@ import { createRpc, selectStateTreeInfo, TreeInfo, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, VERSION, featureFlags, } from '@lightprotocol/stateless.js'; @@ -21,7 +21,7 @@ import { getAccount, } from '@solana/spl-token'; -// Helper to read CToken account balance (CToken accounts are owned by CTOKEN_PROGRAM_ID) +// Helper to read CToken account balance (CToken accounts are owned by LIGHT_TOKEN_PROGRAM_ID) async function getCTokenBalance(rpc: Rpc, address: PublicKey): Promise { const accountInfo = await rpc.getAccountInfo(address); if (!accountInfo) { diff --git a/js/compressed-token/tests/unit/estimate-tx-size.test.ts b/js/compressed-token/tests/unit/estimate-tx-size.test.ts new file mode 100644 index 0000000000..014562a758 --- /dev/null +++ b/js/compressed-token/tests/unit/estimate-tx-size.test.ts @@ -0,0 +1,398 @@ +import { describe, it, expect } from 'vitest'; +import { + Keypair, + PublicKey, + SystemProgram, + ComputeBudgetProgram, + TransactionInstruction, + TransactionMessage, + VersionedTransaction, +} from '@solana/web3.js'; +import { + estimateTransactionSize, + MAX_TRANSACTION_SIZE, +} from '../../src/v3/utils/estimate-tx-size'; + +/** + * Build an actual unsigned VersionedTransaction and return its serialized + * byte length. Used to cross-check the estimate. + */ +function actualTxSize( + instructions: TransactionInstruction[], + payer: PublicKey, + numSigners: number, +): number { + const dummyBlockhash = 'GWsqNcmNBbBigUdeFbMGjEiWRpWwR9bXZFaygD7RnPb8'; + const messageV0 = new TransactionMessage({ + payerKey: payer, + recentBlockhash: dummyBlockhash, + instructions, + }).compileToV0Message(); + + const tx = new VersionedTransaction(messageV0); + // Unsigned tx has placeholder signatures (all zeros) for each signer + // The serialized length includes those. + return tx.serialize().length; +} + +/** Helper: create a simple instruction with N keys and D bytes of data. */ +function makeIx( + programId: PublicKey, + keys: { pubkey: PublicKey; isSigner: boolean; isWritable: boolean }[], + dataLength: number, +): TransactionInstruction { + return new TransactionInstruction({ + programId, + keys, + data: Buffer.alloc(dataLength), + }); +} + +describe('estimateTransactionSize', () => { + const payer = Keypair.generate().publicKey; + + it('estimates base size for empty instructions', () => { + const estimate = estimateTransactionSize([], 1); + // Should be: signatures(1 + 64) + prefix(1) + header(3) + + // keys(1 + 0) + blockhash(32) + instructions(1) + lookups(1) = 104 + // But with the payer, there's at least 1 key... actually no: + // estimateTransactionSize doesn't inject payer, only counts keys from ixs. + // With 0 instructions and 0 keys: + // sigs: 1 + 64 = 65 + // msg: 1 + 3 + 1 + 32 + 1 + 1 = 39 + // total = 104 + expect(estimate).toBe(104); + }); + + it('estimates correctly for a single simple instruction', () => { + const programId = Keypair.generate().publicKey; + const ix = makeIx( + programId, + [{ pubkey: payer, isSigner: true, isWritable: true }], + 9, + ); + + const estimate = estimateTransactionSize([ix], 1); + // keys: programId + payer = 2 unique + // sigs: 1 + 64 = 65 + // msg: 1 + 3 + (1 + 64) + 32 + (1 + 1 + 1 + 1 + 1 + 9) + 1 = 181 + // Breakdown: + // prefix=1, header=3, keys=1+2*32=65, blockhash=32 + // ixs: count(1) + programIdIdx(1) + keysCount(1) + 1 key idx(1) + dataLen(1) + data(9) = 14 + // lookups=1 + // total: 65 + 1 + 3 + 65 + 32 + 14 + 1 = 181 + expect(estimate).toBe(181); + }); + + it('deduplicates shared keys across instructions', () => { + const programId = Keypair.generate().publicKey; + const sharedKey = Keypair.generate().publicKey; + + const ix1 = makeIx( + programId, + [{ pubkey: sharedKey, isSigner: false, isWritable: true }], + 4, + ); + const ix2 = makeIx( + programId, + [ + { pubkey: sharedKey, isSigner: false, isWritable: true }, + { pubkey: payer, isSigner: true, isWritable: true }, + ], + 8, + ); + + const estimate = estimateTransactionSize([ix1, ix2], 1); + // Unique keys: programId, sharedKey, payer = 3 + // sigs: 65 + // msg: 1 + 3 + (1+96) + 32 + (1 + 8 + 13) + 1 = 156 + // ix1: progIdx(1) + keysLen(1) + 1 idx(1) + dataLen(1) + data(4) = 8 + // ix2: progIdx(1) + keysLen(1) + 2 idx(2) + dataLen(1) + data(8) = 13 + // ixs total: 1(count) + 8 + 13 = 22 + // total: 65 + 1 + 3 + 97 + 32 + 22 + 1 = 221 + expect(estimate).toBe(221); + }); + + it('estimate matches actual serialized size for CU + transfer instruction', () => { + const owner = Keypair.generate().publicKey; + const source = Keypair.generate().publicKey; + const dest = Keypair.generate().publicKey; + const lightProgram = Keypair.generate().publicKey; + + const cuIx = ComputeBudgetProgram.setComputeUnitLimit({ + units: 200_000, + }); + const transferIx = makeIx( + lightProgram, + [ + { pubkey: source, isSigner: false, isWritable: true }, + { pubkey: dest, isSigner: false, isWritable: true }, + { pubkey: owner, isSigner: true, isWritable: true }, + { + pubkey: SystemProgram.programId, + isSigner: false, + isWritable: false, + }, + { pubkey: owner, isSigner: true, isWritable: true }, + ], + 9, + ); + + const instructions = [cuIx, transferIx]; + const estimate = estimateTransactionSize(instructions, 1); + const actual = actualTxSize(instructions, owner, 1); + + // Estimate should be very close to actual (within a few bytes) + expect(Math.abs(estimate - actual)).toBeLessThanOrEqual(5); + expect(estimate).toBeLessThan(MAX_TRANSACTION_SIZE); + }); + + it('estimate is deterministic for a complex multi-instruction batch', () => { + // Simulate a decompress-like instruction with many keys and data. + // We only test the estimate (not actualTxSize) because a tx this + // large exceeds Solana's serialization buffer in @solana/web3.js. + const owner = Keypair.generate().publicKey; + const programId = Keypair.generate().publicKey; + + const keys = Array.from({ length: 12 }, () => ({ + pubkey: Keypair.generate().publicKey, + isSigner: false, + isWritable: true, + })); + keys[1] = { pubkey: owner, isSigner: true, isWritable: true }; + + const decompressIx = makeIx(programId, keys, 360); + const cuIx = ComputeBudgetProgram.setComputeUnitLimit({ + units: 500_000, + }); + + const transferIx = makeIx( + Keypair.generate().publicKey, + [ + { + pubkey: Keypair.generate().publicKey, + isSigner: false, + isWritable: true, + }, + { + pubkey: Keypair.generate().publicKey, + isSigner: false, + isWritable: true, + }, + { pubkey: owner, isSigner: true, isWritable: true }, + { + pubkey: SystemProgram.programId, + isSigner: false, + isWritable: false, + }, + ], + 9, + ); + const ataIx = makeIx( + Keypair.generate().publicKey, + Array.from({ length: 7 }, () => ({ + pubkey: Keypair.generate().publicKey, + isSigner: false, + isWritable: false, + })), + 35, + ); + + const instructions = [cuIx, ataIx, decompressIx, transferIx]; + const est1 = estimateTransactionSize(instructions, 2); + const est2 = estimateTransactionSize(instructions, 2); + + // Deterministic + expect(est1).toBe(est2); + // Above MAX_TRANSACTION_SIZE (this combined batch is too big) + expect(est1).toBeGreaterThan(MAX_TRANSACTION_SIZE); + }); + + it('two decompress (8+4) + transfer + ATA + CU exceeds MAX_TRANSACTION_SIZE', () => { + const owner = Keypair.generate().publicKey; + + // First decompress (8 inputs): ~360 bytes data, 12 keys + const decompress1Keys = Array.from({ length: 12 }, () => ({ + pubkey: Keypair.generate().publicKey, + isSigner: false, + isWritable: true, + })); + const decompress1 = makeIx( + Keypair.generate().publicKey, + decompress1Keys, + 360, + ); + + // Second decompress (4 inputs): ~260 bytes data, same program but some new keys + const decompress2Keys = [ + ...decompress1Keys.slice(0, 5), // share some keys + ...Array.from({ length: 7 }, () => ({ + pubkey: Keypair.generate().publicKey, + isSigner: false, + isWritable: true, + })), + ]; + const decompress2 = makeIx(decompress1.programId, decompress2Keys, 260); + + const cuIx = ComputeBudgetProgram.setComputeUnitLimit({ + units: 600_000, + }); + const ataIx = makeIx( + Keypair.generate().publicKey, + Array.from({ length: 7 }, () => ({ + pubkey: Keypair.generate().publicKey, + isSigner: false, + isWritable: false, + })), + 35, + ); + const transferIx = makeIx( + Keypair.generate().publicKey, + [ + { + pubkey: Keypair.generate().publicKey, + isSigner: false, + isWritable: true, + }, + { + pubkey: Keypair.generate().publicKey, + isSigner: false, + isWritable: true, + }, + { pubkey: owner, isSigner: true, isWritable: true }, + ], + 9, + ); + + const instructions = [ + cuIx, + ataIx, + decompress1, + decompress2, + transferIx, + ]; + const estimate = estimateTransactionSize(instructions, 2); + + // Two decompress instructions should push this over the limit + expect(estimate).toBeGreaterThan(MAX_TRANSACTION_SIZE); + }); + + it('single decompress (8 inputs) + transfer + ATA + CU fits in MAX_TRANSACTION_SIZE', () => { + const owner = Keypair.generate().publicKey; + + // Decompress with 8 inputs, realistic key sharing + const mint = Keypair.generate().publicKey; + const tree = Keypair.generate().publicKey; + const queue = Keypair.generate().publicKey; + const decompressProgram = Keypair.generate().publicKey; + + const decompressKeys = [ + { + pubkey: Keypair.generate().publicKey, + isSigner: false, + isWritable: false, + }, // light_system_program + { pubkey: owner, isSigner: true, isWritable: true }, // fee_payer + { + pubkey: Keypair.generate().publicKey, + isSigner: false, + isWritable: false, + }, // cpi_authority_pda + { + pubkey: Keypair.generate().publicKey, + isSigner: false, + isWritable: false, + }, // registered_program_pda + { + pubkey: Keypair.generate().publicKey, + isSigner: false, + isWritable: false, + }, // account_compression_authority + { + pubkey: Keypair.generate().publicKey, + isSigner: false, + isWritable: false, + }, // account_compression_program + { + pubkey: SystemProgram.programId, + isSigner: false, + isWritable: false, + }, // system_program + { pubkey: tree, isSigner: false, isWritable: true }, // state tree + { pubkey: queue, isSigner: false, isWritable: true }, // output queue + { pubkey: mint, isSigner: false, isWritable: false }, // mint + { pubkey: owner, isSigner: true, isWritable: true }, // owner + { + pubkey: Keypair.generate().publicKey, + isSigner: false, + isWritable: true, + }, // destination ATA + ]; + const decompressIx = makeIx(decompressProgram, decompressKeys, 360); + + const cuIx = ComputeBudgetProgram.setComputeUnitLimit({ + units: 500_000, + }); + const transferProgram = Keypair.generate().publicKey; + const senderAta = Keypair.generate().publicKey; + const recipientAta = Keypair.generate().publicKey; + const transferIx = makeIx( + transferProgram, + [ + { pubkey: senderAta, isSigner: false, isWritable: true }, + { pubkey: recipientAta, isSigner: false, isWritable: true }, + { pubkey: owner, isSigner: true, isWritable: true }, + { + pubkey: SystemProgram.programId, + isSigner: false, + isWritable: false, + }, + { pubkey: owner, isSigner: true, isWritable: true }, + ], + 9, + ); + const ataProgram = Keypair.generate().publicKey; + const ataIx = makeIx( + ataProgram, + [ + { pubkey: owner, isSigner: false, isWritable: false }, + { pubkey: mint, isSigner: false, isWritable: false }, + { pubkey: owner, isSigner: true, isWritable: true }, + { pubkey: recipientAta, isSigner: false, isWritable: true }, + { + pubkey: SystemProgram.programId, + isSigner: false, + isWritable: false, + }, + { + pubkey: Keypair.generate().publicKey, + isSigner: false, + isWritable: false, + }, + { + pubkey: Keypair.generate().publicKey, + isSigner: false, + isWritable: true, + }, + ], + 35, + ); + + const instructions = [cuIx, ataIx, decompressIx, transferIx]; + const estimate = estimateTransactionSize(instructions, 2); + + expect(estimate).toBeLessThanOrEqual(MAX_TRANSACTION_SIZE); + }); + + it('handles 2 signers correctly', () => { + const ix = makeIx( + Keypair.generate().publicKey, + [{ pubkey: payer, isSigner: true, isWritable: true }], + 4, + ); + const est1 = estimateTransactionSize([ix], 1); + const est2 = estimateTransactionSize([ix], 2); + // 2 signers = 64 more bytes + expect(est2 - est1).toBe(64); + }); +}); diff --git a/js/compressed-token/tests/unit/get-associated-token-address-interface.test.ts b/js/compressed-token/tests/unit/get-associated-token-address-interface.test.ts index 56c8dc303d..a035cded93 100644 --- a/js/compressed-token/tests/unit/get-associated-token-address-interface.test.ts +++ b/js/compressed-token/tests/unit/get-associated-token-address-interface.test.ts @@ -6,7 +6,7 @@ import { ASSOCIATED_TOKEN_PROGRAM_ID, getAssociatedTokenAddressSync, } from '@solana/spl-token'; -import { CTOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; +import { LIGHT_TOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; import { getAssociatedTokenAddressInterface } from '../../src/v3/get-associated-token-address-interface'; import { getAtaProgramId } from '../../src/v3/ata-utils'; @@ -14,16 +14,16 @@ describe('getAssociatedTokenAddressInterface', () => { const mint = Keypair.generate().publicKey; const owner = Keypair.generate().publicKey; - describe('default behavior (CTOKEN_PROGRAM_ID)', () => { - it('should derive ATA using CTOKEN_PROGRAM_ID by default', () => { + describe('default behavior (LIGHT_TOKEN_PROGRAM_ID)', () => { + it('should derive ATA using LIGHT_TOKEN_PROGRAM_ID by default', () => { const result = getAssociatedTokenAddressInterface(mint, owner); const expected = getAssociatedTokenAddressSync( mint, owner, false, - CTOKEN_PROGRAM_ID, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(result.toBase58()).toBe(expected.toBase58()); @@ -133,7 +133,7 @@ describe('getAssociatedTokenAddressInterface', () => { mint, owner, false, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); const splAta = getAssociatedTokenAddressInterface( mint, @@ -168,7 +168,7 @@ describe('getAssociatedTokenAddressInterface', () => { // Create a PDA (off-curve point) const [pdaOwner] = PublicKey.findProgramAddressSync( [Buffer.from('test-seed')], - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); // Should not throw with allowOwnerOffCurve = true @@ -176,7 +176,7 @@ describe('getAssociatedTokenAddressInterface', () => { mint, pdaOwner, true, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(result).toBeInstanceOf(PublicKey); @@ -185,7 +185,7 @@ describe('getAssociatedTokenAddressInterface', () => { it('should throw for PDA owners when allowOwnerOffCurve is false', () => { const [pdaOwner] = PublicKey.findProgramAddressSync( [Buffer.from('test-seed')], - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(() => @@ -193,7 +193,7 @@ describe('getAssociatedTokenAddressInterface', () => { mint, pdaOwner, false, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ), ).toThrow(); }); @@ -253,13 +253,13 @@ describe('getAssociatedTokenAddressInterface', () => { }); it('should override auto-detected associatedTokenProgramId', () => { - // Force CTOKEN_PROGRAM_ID as associated program even for TOKEN_PROGRAM_ID + // Force LIGHT_TOKEN_PROGRAM_ID as associated program even for TOKEN_PROGRAM_ID const result = getAssociatedTokenAddressInterface( mint, owner, false, TOKEN_PROGRAM_ID, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); const autoDetected = getAssociatedTokenAddressInterface( @@ -275,9 +275,9 @@ describe('getAssociatedTokenAddressInterface', () => { }); describe('getAtaProgramId helper', () => { - it('should return CTOKEN_PROGRAM_ID for CTOKEN_PROGRAM_ID', () => { - expect(getAtaProgramId(CTOKEN_PROGRAM_ID).toBase58()).toBe( - CTOKEN_PROGRAM_ID.toBase58(), + it('should return LIGHT_TOKEN_PROGRAM_ID for LIGHT_TOKEN_PROGRAM_ID', () => { + expect(getAtaProgramId(LIGHT_TOKEN_PROGRAM_ID).toBase58()).toBe( + LIGHT_TOKEN_PROGRAM_ID.toBase58(), ); }); diff --git a/js/compressed-token/tests/unit/mint-action-layout.test.ts b/js/compressed-token/tests/unit/mint-action-layout.test.ts index 231a6a401d..9a25cb28ba 100644 --- a/js/compressed-token/tests/unit/mint-action-layout.test.ts +++ b/js/compressed-token/tests/unit/mint-action-layout.test.ts @@ -11,7 +11,7 @@ import { encodeCreateMintInstructionData } from '../../src/v3/instructions/creat import { TokenDataVersion } from '../../src/constants'; import { deriveAddressV2, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, getBatchAddressTreeInfo, } from '@lightprotocol/stateless.js'; import { findMintAddress } from '../../src/v3/derivation'; @@ -341,7 +341,7 @@ describe('MintActionCompressedInstructionData Layout', () => { const compressedAddress = deriveAddressV2( mintPda.toBytes(), addressTreeInfo.tree, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); // Verify it's a valid 32-byte address diff --git a/js/compressed-token/tests/unit/select-inputs.test.ts b/js/compressed-token/tests/unit/select-inputs.test.ts new file mode 100644 index 0000000000..9c39ecf91d --- /dev/null +++ b/js/compressed-token/tests/unit/select-inputs.test.ts @@ -0,0 +1,165 @@ +import { describe, it, expect } from 'vitest'; +import { PublicKey } from '@solana/web3.js'; +import { bn, TreeType } from '@lightprotocol/stateless.js'; +import { + selectInputsForAmount, + MAX_INPUT_ACCOUNTS, +} from '../../src/v3/actions/load-ata'; + +/** + * Build a minimal ParsedTokenAccount mock with only the fields that + * selectInputsForAmount accesses: `parsed.amount`. + */ +function mockAccount(amount: bigint): any { + return { + parsed: { + mint: PublicKey.default, + owner: PublicKey.default, + amount: bn(amount.toString()), + delegate: null, + state: 1, + tlv: null, + }, + compressedAccount: { + hash: new Uint8Array(32), + treeInfo: { + tree: PublicKey.default, + queue: PublicKey.default, + treeType: TreeType.StateV2, + }, + leafIndex: 0, + proveByIndex: false, + owner: PublicKey.default, + lamports: bn(0), + address: null, + data: null, + readOnly: false, + }, + }; +} + +function amounts(accounts: any[]): bigint[] { + return accounts.map((a: any) => BigInt(a.parsed.amount.toString())); +} + +describe('selectInputsForAmount', () => { + it('returns [] for empty accounts', () => { + expect(selectInputsForAmount([], BigInt(100))).toEqual([]); + }); + + it('returns [] when neededAmount is 0', () => { + const accs = [mockAccount(500n)]; + expect(selectInputsForAmount(accs, BigInt(0))).toEqual([]); + }); + + it('returns [] when neededAmount is negative', () => { + const accs = [mockAccount(500n)]; + expect(selectInputsForAmount(accs, BigInt(-1))).toEqual([]); + }); + + it('returns 1 account when only 1 exists and covers amount', () => { + const accs = [mockAccount(1000n)]; + const result = selectInputsForAmount(accs, BigInt(500)); + expect(result.length).toBe(1); + expect(amounts(result)).toEqual([1000n]); + }); + + it('pads to available count when fewer than MAX_INPUT_ACCOUNTS exist', () => { + // 5 accounts, only 3 needed for amount -> pads to 5 (< 8) + const accs = [ + mockAccount(100n), + mockAccount(200n), + mockAccount(300n), + mockAccount(50n), + mockAccount(150n), + ]; + const result = selectInputsForAmount(accs, BigInt(400)); + // 300 + 200 = 500 >= 400, so 2 needed. Pad to min(8, 5) = 5. + expect(result.length).toBe(5); + }); + + it('pads to MAX_INPUT_ACCOUNTS when 8+ accounts exist', () => { + // 10 accounts, only 2 needed for amount -> pads to 8 + const accs = Array.from({ length: 10 }, (_, i) => + mockAccount(BigInt((i + 1) * 100)), + ); + // Amounts: 100..1000. Need 500. 1000 alone covers it (1 needed). + // Pad to min(8, 10) = 8. + const result = selectInputsForAmount(accs, BigInt(500)); + expect(result.length).toBe(MAX_INPUT_ACCOUNTS); + }); + + it('returns exactly 8 accounts from 20 when amount is covered by 3', () => { + const accs = Array.from({ length: 20 }, (_, i) => + mockAccount(BigInt((i + 1) * 10)), + ); + // Amounts: 10..200. Need 500. + // Sorted desc: 200, 190, 180, ... 200+190+180 = 570 >= 500 (3 needed). + // Pad to min(8, 20) = 8. + const result = selectInputsForAmount(accs, BigInt(500)); + expect(result.length).toBe(MAX_INPUT_ACCOUNTS); + }); + + it('returns >8 when amount requires more than 8 inputs', () => { + // 20 accounts of 100 each. Need 1500 -> 15 inputs needed. + const accs = Array.from({ length: 20 }, () => mockAccount(100n)); + const result = selectInputsForAmount(accs, BigInt(1500)); + // 15 needed, > 8, so no padding -> 15 + expect(result.length).toBe(15); + }); + + it('returns all when amount requires all inputs', () => { + const accs = Array.from({ length: 12 }, () => mockAccount(100n)); + // Need 1200 = all + const result = selectInputsForAmount(accs, BigInt(1200)); + expect(result.length).toBe(12); + }); + + it('sorts output by amount descending (largest first)', () => { + const accs = [ + mockAccount(50n), + mockAccount(500n), + mockAccount(200n), + mockAccount(100n), + mockAccount(300n), + ]; + const result = selectInputsForAmount(accs, BigInt(100)); + const resultAmounts = amounts(result); + // Should be sorted descending + for (let i = 0; i < resultAmounts.length - 1; i++) { + expect(resultAmounts[i]).toBeGreaterThanOrEqual( + resultAmounts[i + 1], + ); + } + expect(resultAmounts[0]).toBe(500n); + }); + + it('handles all same-size accounts', () => { + const accs = Array.from({ length: 10 }, () => mockAccount(100n)); + // Need 300 -> 3 needed, pad to 8 + const result = selectInputsForAmount(accs, BigInt(300)); + expect(result.length).toBe(MAX_INPUT_ACCOUNTS); + expect(amounts(result).reduce((s, a) => s + a, 0n)).toBe(800n); + }); + + it('does not mutate the input array', () => { + const accs = [mockAccount(100n), mockAccount(300n), mockAccount(200n)]; + const originalOrder = amounts(accs); + selectInputsForAmount(accs, BigInt(150)); + expect(amounts(accs)).toEqual(originalOrder); + }); + + it('returns exactly needed count when all inputs are required (no padding beyond 8)', () => { + // 10 accounts, 100 each. Need 900 -> 9 inputs needed. > 8, no padding. + const accs = Array.from({ length: 10 }, () => mockAccount(100n)); + const result = selectInputsForAmount(accs, BigInt(900)); + expect(result.length).toBe(9); + }); + + it('returns 8 when exactly 8 inputs are needed', () => { + // 12 accounts, 100 each. Need 800 -> exactly 8 needed = MAX_INPUT_ACCOUNTS. + const accs = Array.from({ length: 12 }, () => mockAccount(100n)); + const result = selectInputsForAmount(accs, BigInt(800)); + expect(result.length).toBe(MAX_INPUT_ACCOUNTS); + }); +}); diff --git a/js/compressed-token/tests/unit/unified-guards.test.ts b/js/compressed-token/tests/unit/unified-guards.test.ts index 88b5909113..227159a5be 100644 --- a/js/compressed-token/tests/unit/unified-guards.test.ts +++ b/js/compressed-token/tests/unit/unified-guards.test.ts @@ -6,7 +6,7 @@ import { } from '@solana/spl-token'; import { Rpc, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, featureFlags, } from '@lightprotocol/stateless.js'; import { getAtaProgramId } from '../../src/v3/ata-utils'; @@ -42,7 +42,7 @@ describe('unified guards', () => { mint, owner, false, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ), ).not.toThrow(); }); diff --git a/js/stateless.js/package.json b/js/stateless.js/package.json index bf2aef7b81..e0d8e3bd11 100644 --- a/js/stateless.js/package.json +++ b/js/stateless.js/package.json @@ -1,6 +1,6 @@ { "name": "@lightprotocol/stateless.js", - "version": "0.23.0-beta.5", + "version": "0.23.0-beta.8", "description": "JavaScript API for Light & ZK Compression", "sideEffects": false, "main": "dist/cjs/node/index.cjs", diff --git a/js/stateless.js/src/constants.ts b/js/stateless.js/src/constants.ts index d447d0dbb4..648dee70a4 100644 --- a/js/stateless.js/src/constants.ts +++ b/js/stateless.js/src/constants.ts @@ -264,6 +264,9 @@ export const COMPRESSED_TOKEN_PROGRAM_ID = new PublicKey( 'cTokenmWW8bLPjZEBAUgYy3zKxQZW6VKi7bqNFEVv3m', ); +export const LIGHT_TOKEN_PROGRAM_ID = COMPRESSED_TOKEN_PROGRAM_ID; + +/** @deprecated Use {@link LIGHT_TOKEN_PROGRAM_ID} instead. */ export const CTOKEN_PROGRAM_ID = COMPRESSED_TOKEN_PROGRAM_ID; export const stateTreeLookupTableMainnet = '7i86eQs3GSqHjN47WdWLTCGMW6gde1q96G2EVnUyK2st'; diff --git a/js/stateless.js/src/rpc.ts b/js/stateless.js/src/rpc.ts index 9cfd913c55..a44ff3a8c1 100644 --- a/js/stateless.js/src/rpc.ts +++ b/js/stateless.js/src/rpc.ts @@ -89,7 +89,7 @@ import { versionedEndpoint, featureFlags, batchAddressTree, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, getDefaultAddressSpace, assertBetaEnabled, } from './constants'; @@ -1938,7 +1938,7 @@ export class Rpc extends Connection implements CompressionApiInterface { const publicKey = deriveAddressV2( Uint8Array.from(address.address), address.treeInfo.tree, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); derivedAddress = bn(publicKey.toBytes()); } else { diff --git a/js/stateless.js/src/utils/validation.ts b/js/stateless.js/src/utils/validation.ts index 39ea74e319..e8f9d2dfaf 100644 --- a/js/stateless.js/src/utils/validation.ts +++ b/js/stateless.js/src/utils/validation.ts @@ -27,15 +27,18 @@ export const validateSameOwner = ( } }; -/// for V1 circuits. +/// Client-side pre-flight validation for proof requests. +/// V1 inclusion: {1, 2, 3, 4, 8}, V2 inclusion: {1..8}. +/// Combined proofs (hashes + addresses): max 4 hashes for both V1 and V2. export const validateNumbersForProof = ( hashesLength: number, newAddressesLength: number, ) => { if (hashesLength > 0 && newAddressesLength > 0) { - if (hashesLength === 8) { + // Combined circuits (V1 and V2) support max 4 hashes. + if (hashesLength > 4) { throw new Error( - `Invalid number of compressed accounts for proof: ${hashesLength}. Allowed numbers: ${[1, 2, 3, 4].join(', ')}`, + `Invalid number of compressed accounts for combined proof: ${hashesLength}. Allowed: 1-4`, ); } validateNumbers(hashesLength, [1, 2, 3, 4], 'compressed accounts'); @@ -49,9 +52,15 @@ export const validateNumbersForProof = ( } }; -/// Ensure that the amount if compressed accounts is allowed. +/// Validate inclusion proof input count. +/// Accepts 1-8 (union of V1 {1,2,3,4,8} and V2 {1..8}). +/// Version-specific validation happens in the chunking layer. export const validateNumbersForInclusionProof = (hashesLength: number) => { - validateNumbers(hashesLength, [1, 2, 3, 4, 8], 'compressed accounts'); + validateNumbers( + hashesLength, + [1, 2, 3, 4, 5, 6, 7, 8], + 'compressed accounts', + ); }; /// Ensure that the amount if new addresses is allowed. diff --git a/js/stateless.js/tests/unit/utils/validation.test.ts b/js/stateless.js/tests/unit/utils/validation.test.ts index a6a09c6cb5..0d7e90ab04 100644 --- a/js/stateless.js/tests/unit/utils/validation.test.ts +++ b/js/stateless.js/tests/unit/utils/validation.test.ts @@ -32,7 +32,7 @@ describe('validateNumbersForProof', () => { }); it('should throw error for invalid hashesLength with zero newAddressesLength', () => { - expect(() => validateNumbersForProof(5, 0)).toThrow(); + expect(() => validateNumbersForProof(9, 0)).toThrow(); }); it('should throw error for invalid newAddressesLength with zero hashesLength', () => { @@ -49,8 +49,12 @@ describe('validateNumbersForInclusionProof', () => { expect(() => validateNumbersForInclusionProof(4)).not.toThrow(); }); + it('should not throw error for hashesLength 5 (allowed in V2)', () => { + expect(() => validateNumbersForInclusionProof(5)).not.toThrow(); + }); + it('should throw error for invalid hashesLength', () => { - expect(() => validateNumbersForInclusionProof(5)).toThrow(); + expect(() => validateNumbersForInclusionProof(9)).toThrow(); }); }); @@ -77,14 +81,14 @@ describe('validateNumbers', () => { }); }); -describe('validateNumbersForProof', () => { +describe('validateNumbersForProof error messages', () => { it('should not throw error for valid hashesLength and newAddressesLength', () => { expect(() => validateNumbersForProof(2, 1)).not.toThrow(); }); it('should throw error for invalid hashesLength with zero newAddressesLength', () => { - expect(() => validateNumbersForProof(5, 0)).toThrowError( - 'Invalid number of compressed accounts: 5. Allowed numbers: 1, 2, 3, 4, 8', + expect(() => validateNumbersForProof(9, 0)).toThrowError( + 'Invalid number of compressed accounts: 9. Allowed numbers: 1, 2, 3, 4, 5, 6, 7, 8', ); }); @@ -96,7 +100,7 @@ describe('validateNumbersForProof', () => { it('should throw error for invalid hashesLength with non-zero newAddressesLength', () => { expect(() => validateNumbersForProof(8, 1)).toThrowError( - 'Invalid number of compressed accounts for proof: 8. Allowed numbers: 1, 2, 3, 4', + 'Invalid number of compressed accounts for combined proof: 8. Allowed: 1-4', ); }); });