From 585099c622f73624f02a486aee3f4ce5fcf466af Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Fri, 13 Feb 2026 00:24:36 +0000 Subject: [PATCH 1/2] fix: add delegate to packed accounts in decompress instruction, version-aware proof chunking wip fixes fixes fix ci test fixes fix and lint upd fix ci rev fix order exporting fix regr wip fixes bump versions decompress mint at create fix ci bump versions rm md js(compressed-token): MAX_TOP_UP constant and optional maxTopUp override - Add MAX_TOP_UP (65535) in constants.ts; use in all instruction builders - mintTo action: default maxTopUp to MAX_TOP_UP when omitted - wrap/unwrap: optional maxTopUp on instruction and action - decompressMint: maxTopUp in DecompressMintParams and DecompressMintInstructionParams - createDecompressInterfaceInstruction, createMintInstruction, createMintToCompressedInstruction, update-mint, update-metadata: optional maxTopUp - Non-breaking: all new params optional, default no cap Co-authored-by: Cursor unskip tests changelog.md --- cli/package.json | 2 +- cli/src/commands/create-token-pool/index.ts | 4 +- ctoken_for_payments.md | 333 -------- external/photon | 2 +- js/compressed-token/CHANGELOG.md | 108 +++ js/compressed-token/docs/interface.md | 231 +++++ .../docs/payment-integration.md | 84 ++ js/compressed-token/package.json | 2 +- .../src/actions/create-token-pool.ts | 2 +- js/compressed-token/src/constants.ts | 7 + js/compressed-token/src/index.ts | 38 +- js/compressed-token/src/program.ts | 25 +- .../src/v3/actions/create-ata-interface.ts | 14 +- .../src/v3/actions/create-mint-interface.ts | 6 +- .../src/v3/actions/decompress-interface.ts | 4 +- .../src/v3/actions/decompress-mint.ts | 12 +- .../v3/actions/get-or-create-ata-interface.ts | 16 +- .../src/v3/actions/load-ata.ts | 771 ++++++++++++----- .../src/v3/actions/mint-to-compressed.ts | 7 +- js/compressed-token/src/v3/actions/mint-to.ts | 3 +- .../src/v3/actions/transfer-interface.ts | 666 ++++++++------- js/compressed-token/src/v3/actions/unwrap.ts | 225 ++++- .../src/v3/actions/update-metadata.ts | 8 +- .../src/v3/actions/update-mint.ts | 6 +- js/compressed-token/src/v3/actions/wrap.ts | 3 + js/compressed-token/src/v3/ata-utils.ts | 14 +- js/compressed-token/src/v3/derivation.ts | 14 +- .../src/v3/get-account-interface.ts | 247 ++++-- .../get-associated-token-address-interface.ts | 4 +- .../src/v3/get-mint-interface.ts | 29 +- .../instructions/create-associated-ctoken.ts | 59 +- .../v3/instructions/create-ata-interface.ts | 8 +- ...create-decompress-interface-instruction.ts | 19 +- .../create-load-accounts-params.ts | 2 +- .../src/v3/instructions/create-mint.ts | 38 +- .../src/v3/instructions/decompress-mint.ts | 16 +- .../src/v3/instructions/mint-to-compressed.ts | 59 +- .../src/v3/instructions/mint-to.ts | 4 +- .../src/v3/instructions/transfer-interface.ts | 73 +- .../src/v3/instructions/unwrap.ts | 9 +- .../src/v3/instructions/update-metadata.ts | 19 +- .../src/v3/instructions/update-mint.ts | 18 +- .../src/v3/instructions/wrap.ts | 11 +- js/compressed-token/src/v3/unified/index.ts | 166 +++- .../src/v3/utils/estimate-tx-size.ts | 89 ++ .../tests/e2e/approve-and-mint-to.test.ts | 6 +- .../tests/e2e/compressible-load.test.ts | 30 +- .../e2e/create-associated-ctoken.test.ts | 46 +- .../tests/e2e/create-ata-interface.test.ts | 46 +- .../tests/e2e/create-compressed-mint.test.ts | 8 +- .../tests/e2e/create-mint-interface.test.ts | 14 +- .../tests/e2e/create-token-pool.test.ts | 28 +- .../tests/e2e/get-account-interface.test.ts | 680 ++++++++++++++- .../tests/e2e/get-mint-interface.test.ts | 37 +- .../e2e/get-or-create-ata-interface.test.ts | 88 +- .../tests/e2e/input-selection.test.ts | 537 ++++++++++++ js/compressed-token/tests/e2e/layout.test.ts | 4 +- .../tests/e2e/load-ata-spl-t22.test.ts | 5 +- .../tests/e2e/load-ata-standard.test.ts | 14 +- .../tests/e2e/load-ata-unified.test.ts | 4 +- .../tests/e2e/mint-to-compressed.test.ts | 8 +- .../tests/e2e/mint-to-ctoken.test.ts | 11 +- .../tests/e2e/mint-to-interface.test.ts | 17 +- .../tests/e2e/mint-workflow.test.ts | 57 +- .../e2e/multi-cold-inputs-batching.test.ts | 796 ++++++++++++++++++ .../tests/e2e/multi-cold-inputs.test.ts | 781 ++++++----------- .../tests/e2e/multi-pool.test.ts | 4 +- .../tests/e2e/payment-flows.test.ts | 302 ++++++- .../tests/e2e/transfer-interface.test.ts | 250 +++++- js/compressed-token/tests/e2e/unwrap.test.ts | 219 ++++- .../tests/e2e/update-metadata.test.ts | 24 +- .../tests/e2e/update-mint.test.ts | 18 +- .../tests/e2e/v3-interface-migration.test.ts | 16 +- js/compressed-token/tests/e2e/wrap.test.ts | 296 ++++++- .../tests/unit/constants.test.ts | 10 + .../tests/unit/estimate-tx-size.test.ts | 398 +++++++++ ...associated-token-address-interface.test.ts | 30 +- .../unit/instructions-max-top-up.test.ts | 219 +++++ .../tests/unit/layout-mint-action.test.ts | 35 + .../tests/unit/layout-transfer2.test.ts | 26 + .../tests/unit/mint-action-layout.test.ts | 4 +- .../tests/unit/parse-account-fields.test.ts | 508 +++++++++++ .../tests/unit/select-inputs.test.ts | 165 ++++ .../tests/unit/unified-guards.test.ts | 4 +- js/stateless.js/package.json | 2 +- js/stateless.js/src/constants.ts | 3 + js/stateless.js/src/rpc.ts | 4 +- js/stateless.js/src/utils/validation.ts | 19 +- .../tests/unit/utils/validation.test.ts | 16 +- 89 files changed, 7164 insertions(+), 2102 deletions(-) delete mode 100644 ctoken_for_payments.md create mode 100644 js/compressed-token/docs/interface.md create mode 100644 js/compressed-token/docs/payment-integration.md create mode 100644 js/compressed-token/src/v3/utils/estimate-tx-size.ts create mode 100644 js/compressed-token/tests/e2e/input-selection.test.ts create mode 100644 js/compressed-token/tests/e2e/multi-cold-inputs-batching.test.ts create mode 100644 js/compressed-token/tests/unit/constants.test.ts create mode 100644 js/compressed-token/tests/unit/estimate-tx-size.test.ts create mode 100644 js/compressed-token/tests/unit/instructions-max-top-up.test.ts create mode 100644 js/compressed-token/tests/unit/parse-account-fields.test.ts create mode 100644 js/compressed-token/tests/unit/select-inputs.test.ts 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/external/photon b/external/photon index 0df2397c2c..84ddfc0f58 160000 --- a/external/photon +++ b/external/photon @@ -1 +1 @@ -Subproject commit 0df2397c2c7d8458f45df9279e999a730ba56482 +Subproject commit 84ddfc0f586806373567faf75f45158076a4f133 diff --git a/js/compressed-token/CHANGELOG.md b/js/compressed-token/CHANGELOG.md index 35beb46c54..07d43d5a2f 100644 --- a/js/compressed-token/CHANGELOG.md +++ b/js/compressed-token/CHANGELOG.md @@ -1,3 +1,111 @@ +## [0.23.0-beta.9] + +### Fixed + +- `maxTopUp` default changed from `0` (no top-ups allowed) to `MAX_TOP_UP` (65535, no cap) across all instruction builders (`wrap`, `unwrap`, `mintTo`, `createMint`, `decompressMint`, `updateMetadata`, `updateMintAuthority`). Previously rent top-ups were silently blocked, causing transaction failures on underfunded compressible accounts. +- `getSplOrToken2022AccountInterface` now fetches hot and cold accounts in parallel; individual fetch failures are handled gracefully instead of throwing immediately. +- `delegatedAmount` correctly parsed from CompressedOnly TLV extension instead of defaulting to 0. +- `parseCTokenHot` uses `unpackAccountSPL` for correct hot c-token account parsing. +- Frozen `ctoken-hot` sources now correctly excluded from load paths (was only filtering SPL/T22 frozen sources). +- SPL interface fetch errors in load paths are now rethrown when there is an SPL or T22 balance; previously all errors were silently swallowed. + +### Added + +- `MAX_TOP_UP` constant (65535) — exported from constants. +- `maxTopUp` optional parameter on `createWrapInstruction`, `createUnwrapInstruction`, `createMintToInstruction`, `createMintInstruction`, `decompressMintInstruction`, `createUpdateMetadataFieldInstruction`, `createUpdateMetadataAuthorityInstruction`, `createRemoveMetadataKeyInstruction`, `createUpdateMintAuthorityInstruction`, `createUpdateFreezeAuthorityInstruction` for explicit rent top-up capping. +- `maxTopUp` optional parameter on `mintToCompressed`, `unwrap`, `decompressMint` actions. **Note:** `mintToCompressed` inserts `maxTopUp` before the existing `confirmOptions` positional parameter — callers who were passing `confirmOptions` positionally must update their call sites (TypeScript will report a type error). +- `createUnwrapInstructions` — instruction builder for unwrapping, returns `TransactionInstruction[][]` with amount-aware input selection. +- `selectInputsForAmount` — greedy amount-aware compressed account selection for load/unwrap. +- `assertV2Only` guards on `loadAta` and decompress paths — V1 inputs are rejected early with a clear error. + +## [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/constants.ts b/js/compressed-token/src/constants.ts index 8978d49b96..129db39cbe 100644 --- a/js/compressed-token/src/constants.ts +++ b/js/compressed-token/src/constants.ts @@ -58,6 +58,13 @@ export const ADD_TOKEN_POOL_DISCRIMINATOR = Buffer.from([ export const DECOMPRESS_ACCOUNTS_IDEMPOTENT_DISCRIMINATOR = Buffer.from([107]); +/** + * Maximum lamports for rent top-up in a single instruction. + * u16::MAX = no limit; 0 = no top-ups allowed. + * Matches Rust SDK (e.g. token-sdk create_mints uses u16::MAX for "no limit"). + */ +export const MAX_TOP_UP = 65535; + /** * Rent configuration constants for compressible ctoken accounts. * These match the Rust SDK defaults in program-libs/compressible/src/rent/config.rs 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..ba2f8a0d9b 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'; @@ -26,6 +26,8 @@ export interface DecompressMintParams { configAccount?: PublicKey; /** Rent sponsor PDA (default: LIGHT_TOKEN_RENT_SPONSOR) */ rentSponsor?: PublicKey; + /** Cap on rent top-up for this instruction (units of 1k lamports; default no cap) */ + maxTopUp?: number; } /** @@ -60,16 +62,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( @@ -94,6 +97,7 @@ export async function decompressMint( writeTopUp: params?.writeTopUp, configAccount: params?.configAccount, rentSponsor: params?.rentSponsor, + maxTopUp: params?.maxTopUp, }); const additionalSigners: Signer[] = []; 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..c0cc00f5a9 100644 --- a/js/compressed-token/src/v3/actions/load-ata.ts +++ b/js/compressed-token/src/v3/actions/load-ata.ts @@ -1,6 +1,6 @@ import { Rpc, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, buildAndSignTx, sendAndConfirmTx, dedupeSigner, @@ -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,73 @@ function chunkArray(array: T[], chunkSize: number): T[][] { return chunks; } +/** + * 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); +} + +/** + * 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 +159,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), @@ -151,6 +223,7 @@ async function createChunkedDecompressInstructions( // Split accounts into non-overlapping chunks of MAX_INPUT_ACCOUNTS const chunks = chunkArray(compressedAccounts, MAX_INPUT_ACCOUNTS); + assertUniqueInputHashes(chunks); // Get separate proofs for each chunk const proofs = await Promise.all( @@ -201,6 +274,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 +337,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 +375,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 +412,26 @@ 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'); - const ctokenHotSource = sources.find(s => s.type === 'ctoken-hot'); - const ctokenColdSources = sources.filter(s => s.type === 'ctoken-cold'); + // 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' && !s.parsed.isFrozen, + ); + 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) && @@ -453,8 +467,10 @@ export async function createLoadAtaInstructionsFromInterface( ); decimals = mintInfo.decimals; } - } catch { - // No SPL interface exists + } catch (e) { + if (splBalance > BigInt(0) || t22Balance > BigInt(0)) { + throw e; + } } } @@ -469,7 +485,7 @@ export async function createLoadAtaInstructionsFromInterface( ctokenAtaAddress, owner, mint, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ), ); } @@ -509,7 +525,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 +545,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 +559,7 @@ export async function createLoadAtaInstructionsFromInterface( ctokenAtaAddress, owner, mint, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ), ); } @@ -613,16 +629,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 +644,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 +654,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 +799,159 @@ 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' && !s.parsed.isFrozen, + ); + 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 (e) { + if (splBalance > BigInt(0) || t22Balance > BigInt(0)) { + throw e; + } } } - // 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 +960,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 +978,156 @@ 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, + }, + ]; + } + + // V2-only: reject V1 inputs early + assertV2Only(accountsToLoad); + + // Chunk into non-overlapping groups of MAX_INPUT_ACCOUNTS and verify uniqueness + const chunks = chunkArray(accountsToLoad, MAX_INPUT_ACCOUNTS); + 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), + ); + + 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, + ), ); - batchInstructions.push(decompressIx); - batches.push(batchInstructions); + batches.push({ + instructions: batchIxs, + compressedAccounts: chunk, + wrapCount: batchWrapCount, + hasAtaCreation: batchHasAtaCreation, + }); } - return { batches, totalCompressedAccounts }; + return batches; } /** @@ -805,7 +1138,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 +1169,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..1e67c7652d 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, @@ -29,6 +29,7 @@ import { getMintInterface } from '../get-mint-interface'; * @param recipients Array of recipients with amounts * @param outputStateTreeInfo Optional output state tree info (auto-fetched if not provided) * @param tokenAccountVersion Token account version (default: 3) + * @param maxTopUp Optional: cap on rent top-up (units of 1k lamports; default no cap) * @param confirmOptions Optional confirm options */ export async function mintToCompressed( @@ -39,6 +40,7 @@ export async function mintToCompressed( recipients: Array<{ recipient: PublicKey; amount: number | bigint }>, outputStateTreeInfo?: TreeInfo, tokenAccountVersion: number = 3, + maxTopUp?: number, confirmOptions?: ConfirmOptions, ): Promise { assertBetaEnabled(); @@ -47,7 +49,7 @@ export async function mintToCompressed( rpc, mint, confirmOptions?.commitment, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); if (!mintInfo.merkleContext) { @@ -101,6 +103,7 @@ export async function mintToCompressed( recipients, outputStateTreeInfo, tokenAccountVersion, + maxTopUp, ); const additionalSigners = authority.publicKey.equals(payer.publicKey) diff --git a/js/compressed-token/src/v3/actions/mint-to.ts b/js/compressed-token/src/v3/actions/mint-to.ts index f92a6e2268..4ca02c812d 100644 --- a/js/compressed-token/src/v3/actions/mint-to.ts +++ b/js/compressed-token/src/v3/actions/mint-to.ts @@ -11,6 +11,7 @@ import { sendAndConfirmTx, assertBetaEnabled, } from '@lightprotocol/stateless.js'; +import { MAX_TOP_UP } from '../../constants'; import { createMintToInstruction } from '../instructions/mint-to'; /** @@ -51,7 +52,7 @@ export async function mintTo( destination, amount, authority: authority.publicKey, - maxTopUp, + maxTopUp: maxTopUp ?? MAX_TOP_UP, feePayer, }); 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..416c85853c 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,58 @@ 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 maxTopUp Optional: cap on rent top-up (units of 1k lamports; default no cap) + * @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 { + maxTopUp?: number, + 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 +88,7 @@ export async function unwrap( } } + // 2. Check destination exists const destAtaInfo = await rpc.getAccountInfo(destination); if (!destAtaInfo) { throw new Error( @@ -72,33 +97,66 @@ 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); + // 3. Derive c-token ATA and get account interface + const ctokenAta = getAssociatedTokenAddressInterface(mint, owner); - // Check c-token hot balance - const ctokenAccountInfo = await rpc.getAccountInfo(ctokenAta); - if (!ctokenAccountInfo) { - throw new Error('No c-token ATA found after loading'); + 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); + const totalBalance = accountInterface.parsed.amount; + const unfrozenBalance = (accountInterface._sources ?? []) + .filter(s => !s.parsed.isFrozen) + .reduce((sum, s) => sum + s.amount, BigInt(0)); - if (ctokenBalance === BigInt(0)) { + if (unfrozenBalance === BigInt(0)) { + if (totalBalance > BigInt(0)) { + throw new Error('All c-token balance is frozen'); + } throw new Error('No c-token balance to unwrap'); } - const unwrapAmount = amount ? BigInt(amount.toString()) : ctokenBalance; + const unwrapAmount = + amount != null ? BigInt(amount.toString()) : unfrozenBalance; - if (unwrapAmount > ctokenBalance) { + if (unwrapAmount > unfrozenBalance) { + const frozenNote = + totalBalance > unfrozenBalance + ? ` (${totalBalance - unfrozenBalance} frozen, not usable)` + : ''; throw new Error( - `Insufficient c-token balance. Requested: ${unwrapAmount}, Available: ${ctokenBalance}`, + `Insufficient c-token balance. Requested: ${unwrapAmount}, Available: ${unfrozenBalance}${frozenNote}`, ); } - // 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 +164,108 @@ 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, + maxTopUp, ); - const { blockhash } = await rpc.getLatestBlockhash(); - const additionalSigners = dedupeSigner(payer, [owner]); + const unwrapBatch: TransactionInstruction[] = [ + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 }), + ix, + ]; - const tx = buildAndSignTx( - [ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 }), ix], - payer, - blockhash, - additionalSigners, + // 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); + } + + 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 maxTopUp Optional: cap on rent top-up (units of 1k lamports; default no cap) + * @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, + maxTopUp?: number, + confirmOptions?: ConfirmOptions, +): Promise { + const batches = await createUnwrapInstructions( + rpc, + destination, + owner.publicKey, + mint, + amount, + payer.publicKey, + splInterfaceInfo, + maxTopUp, ); - 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/actions/wrap.ts b/js/compressed-token/src/v3/actions/wrap.ts index 5efeb6a467..b5d7cbc540 100644 --- a/js/compressed-token/src/v3/actions/wrap.ts +++ b/js/compressed-token/src/v3/actions/wrap.ts @@ -33,6 +33,7 @@ import { * @param mint Mint address * @param amount Amount to wrap * @param splInterfaceInfo Optional: SPL interface info (will be fetched if not provided) + * @param maxTopUp Optional: cap on rent top-up (units of 1k lamports; default no cap) * @param confirmOptions Optional: Confirm options * * @example @@ -60,6 +61,7 @@ export async function wrap( mint: PublicKey, amount: bigint, splInterfaceInfo?: SplInterfaceInfo, + maxTopUp?: number, confirmOptions?: ConfirmOptions, ): Promise { assertBetaEnabled(); @@ -98,6 +100,7 @@ export async function wrap( resolvedSplInterfaceInfo, mintInfo.decimals, payer.publicKey, + maxTopUp, ); // Build and send transaction 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..e9fce763b3 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, @@ -108,6 +108,87 @@ function parseTokenData(data: Buffer): { } } +/** + * Known extension data sizes by Borsh enum discriminator. + * undefined = variable-length (cannot skip without full parsing). + * @internal + */ +const EXTENSION_DATA_SIZES: Record = { + 0: 0, + 1: 0, + 2: 0, + 3: 0, + 4: 0, + 5: 0, + 6: 0, + 7: 0, + 8: 0, + 9: 0, + 10: 0, + 11: 0, + 12: 0, + 13: 0, + 14: 0, + 15: 0, + 16: 0, + 17: 0, + 18: 0, + 19: undefined, // TokenMetadata (variable) + 20: 0, + 21: 0, + 22: 0, + 23: 0, + 24: 0, + 25: 0, + 26: 0, + 27: 0, // PausableAccountExtension (unit struct) + 28: 0, // PermanentDelegateAccountExtension (unit struct) + 29: 8, // TransferFeeAccountExtension (u64) + 30: 1, // TransferHookAccountExtension (u8) + 31: 17, // CompressedOnlyExtension (u64 + u64 + u8) + 32: undefined, // CompressibleExtension (variable) +}; + +const COMPRESSED_ONLY_DISCRIMINATOR = 31; + +/** + * Extract delegated_amount from CompressedOnly extension in Borsh-serialized + * TLV data (Vec). + * @internal + */ +function extractDelegatedAmountFromTlv(tlv: Buffer | null): bigint | null { + if (!tlv || tlv.length < 5) return null; + + try { + let offset = 0; + const vecLen = tlv.readUInt32LE(offset); + offset += 4; + + for (let i = 0; i < vecLen; i++) { + if (offset >= tlv.length) return null; + + const discriminator = tlv[offset]; + offset += 1; + + if (discriminator === COMPRESSED_ONLY_DISCRIMINATOR) { + if (offset + 8 > tlv.length) return null; + // delegated_amount is the first u64 field + const lo = BigInt(tlv.readUInt32LE(offset)); + const hi = BigInt(tlv.readUInt32LE(offset + 4)); + return lo | (hi << BigInt(32)); + } + + const size = EXTENSION_DATA_SIZES[discriminator]; + if (size === undefined) return null; + offset += size; + } + } catch { + return null; + } + + return null; +} + /** @internal */ export function convertTokenDataToAccount( address: PublicKey, @@ -120,13 +201,28 @@ export function convertTokenDataToAccount( tlv: Buffer | null; }, ): Account { + // Determine delegatedAmount for compressed TokenData: + // 1. If CompressedOnly extension present in TLV, use its delegated_amount + // 2. If delegate is set (regular compressed approve), the entire compressed + // account's amount is the delegation (change goes to a separate account) + // 3. Otherwise, 0 + let delegatedAmount = BigInt(0); + const extensionDelegatedAmount = extractDelegatedAmountFromTlv( + tokenData.tlv, + ); + if (extensionDelegatedAmount !== null) { + delegatedAmount = extensionDelegatedAmount; + } else if (tokenData.delegate) { + delegatedAmount = BigInt(tokenData.amount.toString()); + } + return { address, mint: tokenData.mint, owner: tokenData.owner, amount: BigInt(tokenData.amount.toString()), delegate: tokenData.delegate, - delegatedAmount: BigInt(0), + delegatedAmount, isInitialized: tokenData.state !== AccountState.Uninitialized, isFrozen: tokenData.state === AccountState.Frozen, isNative: false, @@ -165,12 +261,18 @@ export function parseCTokenHot( parsed: Account; isCold: false; } { - const parsed = parseTokenData(accountInfo.data); - if (!parsed) throw new Error('Invalid token data'); + // Hot c-token accounts use SPL-compatible layout with 4-byte COption tags. + // unpackAccountSPL correctly parses all fields including delegatedAmount, + // isNative, and closeAuthority. + const parsed = unpackAccountSPL( + address, + accountInfo, + LIGHT_TOKEN_PROGRAM_ID, + ); return { accountInfo, loadContext: undefined, - parsed: convertTokenDataToAccount(address, parsed), + parsed, isCold: false, }; } @@ -348,7 +450,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 +478,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 +487,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 +506,7 @@ async function _tryFetchCTokenColdByAddress( const compressedAddress = deriveAddressV2( address.toBytes(), addressTree, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); // Fetch by derived compressed address @@ -420,7 +522,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 +563,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 +603,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 +691,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 +742,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 +767,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 +784,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 +832,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 eb35a94556..b0193c1bd7 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, @@ -18,7 +18,7 @@ import { COMPRESSION_MODE_DECOMPRESS, Compression, } from '../layout/layout-transfer2'; -import { TokenDataVersion } from '../../constants'; +import { MAX_TOP_UP, TokenDataVersion } from '../../constants'; import { SplInterfaceInfo } from '../../utils/get-token-pool-infos'; /** @@ -105,6 +105,7 @@ function buildInputTokenData( * @param validityProof Validity proof (contains compressedProof and rootIndices) * @param splInterfaceInfo Optional: SPL interface info for SPL destinations * @param decimals Mint decimals (required for SPL destinations) + * @param maxTopUp Optional cap on rent top-up (units of 1k lamports; default no cap) * @returns TransactionInstruction */ export function createDecompressInterfaceInstruction( @@ -115,6 +116,7 @@ export function createDecompressInterfaceInstruction( validityProof: ValidityProofWithContext, splInterfaceInfo: SplInterfaceInfo | undefined, decimals: number, + maxTopUp?: number, ): TransactionInstruction { if (inputCompressedTokenAccounts.length === 0) { throw new Error('No input compressed token accounts provided'); @@ -168,6 +170,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; @@ -258,7 +271,7 @@ export function createDecompressInterfaceInstruction( lamportsChangeAccountMerkleTreeIndex: 0, lamportsChangeAccountOwnerIndex: 0, outputQueue: firstQueueIndex, // First queue in packed accounts - maxTopUp: 65535, + maxTopUp: maxTopUp ?? MAX_TOP_UP, cpiContext: null, compressions, proof: validityProof.compressedProof 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 e6ac75c23d..f3d839a772 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,12 @@ import { MintActionCompressedInstructionData, TokenMetadataLayoutData as TokenMetadataBorshData, } from '../layout/layout-mint-action'; -import { TokenDataVersion } from '../../constants'; +import { + MAX_TOP_UP, + TokenDataVersion, + LIGHT_TOKEN_CONFIG, + LIGHT_TOKEN_RENT_SPONSOR, +} from '../../constants'; /** * Token metadata for creating a c-token mint. @@ -44,6 +49,7 @@ export interface EncodeCreateMintInstructionParams { rootIndex: number; proof: ValidityProof | null; metadata?: TokenMetadataInstructionData; + maxTopUp?: number; } export function createTokenMetadata( @@ -120,12 +126,19 @@ export function encodeCreateMintInstructionData( leafIndex: 0, proveByIndex: false, rootIndex: params.rootIndex, - maxTopUp: 65535, + maxTopUp: params.maxTopUp ?? MAX_TOP_UP, createMint: { 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: { @@ -172,6 +185,7 @@ export interface CreateMintInstructionParams { * @param addressTreeInfo Address tree info for the mint. * @param outputStateTreeInfo Output state tree info. * @param metadata Optional token metadata. + * @param maxTopUp Optional cap on rent top-up (units of 1k lamports; default no cap) */ export function createMintInstruction( mintSigner: PublicKey, @@ -183,6 +197,7 @@ export function createMintInstruction( addressTreeInfo: AddressTreeInfo, outputStateTreeInfo: TreeInfo, metadata?: TokenMetadataInstructionData, + maxTopUp?: number, ): TransactionInstruction { const data = encodeCreateMintInstructionData({ mintSigner, @@ -194,6 +209,7 @@ export function createMintInstruction( rootIndex: validityProof.rootIndices[0], proof: validityProof.compressedProof, metadata, + maxTopUp, }); return buildCreateMintIx( @@ -216,6 +232,7 @@ function buildCreateMintIx( data: Buffer, ): TransactionInstruction { const sys = defaultStaticAccountsStruct(); + const [splMintPda] = findMintAddress(mintSigner); const keys = [ { pubkey: LightSystemProgram.programId, @@ -224,6 +241,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 +287,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 2dfa69330a..a757c9562d 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, @@ -18,7 +18,11 @@ import { MintActionCompressedInstructionData, ExtensionInstructionData, } from '../layout/layout-mint-action'; -import { LIGHT_TOKEN_CONFIG, LIGHT_TOKEN_RENT_SPONSOR } from '../../constants'; +import { + LIGHT_TOKEN_CONFIG, + LIGHT_TOKEN_RENT_SPONSOR, + MAX_TOP_UP, +} from '../../constants'; interface EncodeDecompressMintInstructionParams { leafIndex: number; @@ -28,6 +32,7 @@ interface EncodeDecompressMintInstructionParams { mintInterface: MintInterface; rentPayment: number; writeTopUp: number; + maxTopUp?: number; } function encodeDecompressMintInstructionData( @@ -57,7 +62,7 @@ function encodeDecompressMintInstructionData( leafIndex: params.leafIndex, proveByIndex: params.proveByIndex, rootIndex: params.rootIndex, - maxTopUp: 65535, + maxTopUp: params.maxTopUp ?? MAX_TOP_UP, createMint: null, actions: [ { @@ -108,6 +113,8 @@ export interface DecompressMintInstructionParams { configAccount?: PublicKey; /** Rent sponsor PDA (default: LIGHT_TOKEN_RENT_SPONSOR) */ rentSponsor?: PublicKey; + /** Cap on rent top-up for this instruction (units of 1k lamports; default no cap) */ + maxTopUp?: number; } /** @@ -164,6 +171,7 @@ export function createDecompressMintInstruction( mintInterface, rentPayment, writeTopUp, + maxTopUp: params.maxTopUp, }); const sys = defaultStaticAccountsStruct(); @@ -232,7 +240,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 bcee8c7fc1..c49575514c 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, @@ -20,7 +20,7 @@ import { encodeMintActionInstructionData, MintActionCompressedInstructionData, } from '../layout/layout-mint-action'; -import { TokenDataVersion } from '../../constants'; +import { MAX_TOP_UP, TokenDataVersion } from '../../constants'; interface EncodeCompressedMintToInstructionParams { addressTree: PublicKey; @@ -30,6 +30,7 @@ interface EncodeCompressedMintToInstructionParams { mintData: MintInstructionData; recipients: Array<{ recipient: PublicKey; amount: number | bigint }>; tokenAccountVersion: number; + maxTopUp?: number; } function encodeCompressedMintToInstructionData( @@ -42,11 +43,15 @@ 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, rootIndex: params.rootIndex, - maxTopUp: 65535, + maxTopUp: params.maxTopUp ?? MAX_TOP_UP, createMint: null, actions: [ { @@ -59,22 +64,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); @@ -108,6 +115,7 @@ export interface CreateMintToCompressedInstructionParams { * context queue if not provided. * @param tokenAccountVersion Token account version (default: * TokenDataVersion.ShaFlat). + * @param maxTopUp Optional cap on rent top-up (units of 1k lamports; default no cap) */ export function createMintToCompressedInstruction( authority: PublicKey, @@ -118,7 +126,9 @@ export function createMintToCompressedInstruction( recipients: Array<{ recipient: PublicKey; amount: number | bigint }>, outputStateTreeInfo?: TreeInfo, tokenAccountVersion: TokenDataVersion = TokenDataVersion.ShaFlat, + maxTopUp?: number, ): TransactionInstruction { + const isDecompressed = mintData.cmintDecompressed; const addressTreeInfo = getDefaultAddressTreeInfo(); const data = encodeCompressedMintToInstructionData({ addressTree: addressTreeInfo.tree, @@ -128,6 +138,7 @@ export function createMintToCompressedInstruction( mintData, recipients, tokenAccountVersion, + maxTopUp, }); // Use outputStateTreeInfo.queue if provided, otherwise derive from merkleContext @@ -142,6 +153,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 +201,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 57b4df038b..76bdec252a 100644 --- a/js/compressed-token/src/v3/instructions/unwrap.ts +++ b/js/compressed-token/src/v3/instructions/unwrap.ts @@ -3,7 +3,8 @@ 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 { MAX_TOP_UP } from '../../constants'; import { CompressedTokenProgram } from '../../program'; import { SplInterfaceInfo } from '../../utils/get-token-pool-infos'; import { @@ -26,6 +27,7 @@ import { * @param splInterfaceInfo SPL interface info for the decompression * @param decimals Mint decimals (required for transfer_checked) * @param payer Fee payer (defaults to owner if not provided) + * @param maxTopUp Optional cap on rent top-up (units of 1k lamports; default no cap) * @returns TransactionInstruction to unwrap tokens */ export function createUnwrapInstruction( @@ -37,6 +39,7 @@ export function createUnwrapInstruction( splInterfaceInfo: SplInterfaceInfo, decimals: number, payer: PublicKey = owner, + maxTopUp?: number, ): TransactionInstruction { const MINT_INDEX = 0; const OWNER_INDEX = 1; @@ -72,7 +75,7 @@ export function createUnwrapInstruction( lamportsChangeAccountMerkleTreeIndex: 0, lamportsChangeAccountOwnerIndex: 0, outputQueue: 0, - maxTopUp: 65535, + maxTopUp: maxTopUp ?? MAX_TOP_UP, cpiContext: null, compressions, proof: null, @@ -109,7 +112,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 37cbdcf62a..ce9dcef3c0 100644 --- a/js/compressed-token/src/v3/instructions/update-metadata.ts +++ b/js/compressed-token/src/v3/instructions/update-metadata.ts @@ -6,12 +6,13 @@ import { import { Buffer } from 'buffer'; import { ValidityProofWithContext, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, LightSystemProgram, defaultStaticAccountsStruct, getDefaultAddressTreeInfo, getOutputQueue, } from '@lightprotocol/stateless.js'; +import { MAX_TOP_UP } from '../../constants'; import { CompressedTokenProgram } from '../../program'; import { MintInterface } from '../get-mint-interface'; import { @@ -48,6 +49,7 @@ interface EncodeUpdateMetadataInstructionParams { proof: { a: number[]; b: number[]; c: number[] } | null; mintInterface: MintInterface; action: UpdateMetadataAction; + maxTopUp?: number; } function convertActionToBorsh(action: UpdateMetadataAction): Action { @@ -98,7 +100,7 @@ function encodeUpdateMetadataInstructionData( leafIndex: params.leafIndex, proveByIndex: params.proof === null, rootIndex: params.rootIndex, - maxTopUp: 65535, + maxTopUp: params.maxTopUp ?? MAX_TOP_UP, createMint: null, actions: [convertActionToBorsh(params.action)], proof: params.proof, @@ -149,6 +151,7 @@ function createUpdateMetadataInstruction( payer: PublicKey, validityProof: ValidityProofWithContext | null, action: UpdateMetadataAction, + maxTopUp?: number, ): TransactionInstruction { if (!mintInterface.merkleContext) { throw new Error( @@ -179,6 +182,7 @@ function createUpdateMetadataInstruction( proof: isDecompressed ? null : (validityProof?.compressedProof ?? null), mintInterface, action, + maxTopUp, }); const sys = defaultStaticAccountsStruct(); @@ -235,7 +239,7 @@ function createUpdateMetadataInstruction( ]; return new TransactionInstruction({ - programId: CTOKEN_PROGRAM_ID, + programId: LIGHT_TOKEN_PROGRAM_ID, keys, data, }); @@ -255,6 +259,7 @@ function createUpdateMetadataInstruction( * @param value New value for the field * @param customKey Custom key name (required if fieldType is 'custom') * @param extensionIndex Extension index (default: 0) + * @param maxTopUp Optional cap on rent top-up (units of 1k lamports; default no cap) */ export function createUpdateMetadataFieldInstruction( mintInterface: MintInterface, @@ -265,6 +270,7 @@ export function createUpdateMetadataFieldInstruction( value: string, customKey?: string, extensionIndex: number = 0, + maxTopUp?: number, ): TransactionInstruction { const action: UpdateMetadataAction = { type: 'updateField', @@ -287,6 +293,7 @@ export function createUpdateMetadataFieldInstruction( payer, validityProof, action, + maxTopUp, ); } @@ -302,6 +309,7 @@ export function createUpdateMetadataFieldInstruction( * @param payer Fee payer public key * @param validityProof Validity proof for the compressed mint (null for decompressed mints) * @param extensionIndex Extension index (default: 0) + * @param maxTopUp Optional cap on rent top-up (units of 1k lamports; default no cap) */ export function createUpdateMetadataAuthorityInstruction( mintInterface: MintInterface, @@ -310,6 +318,7 @@ export function createUpdateMetadataAuthorityInstruction( payer: PublicKey, validityProof: ValidityProofWithContext | null, extensionIndex: number = 0, + maxTopUp?: number, ): TransactionInstruction { const action: UpdateMetadataAction = { type: 'updateAuthority', @@ -323,6 +332,7 @@ export function createUpdateMetadataAuthorityInstruction( payer, validityProof, action, + maxTopUp, ); } @@ -339,6 +349,7 @@ export function createUpdateMetadataAuthorityInstruction( * @param key Metadata key to remove * @param idempotent If true, don't error if key doesn't exist (default: false) * @param extensionIndex Extension index (default: 0) + * @param maxTopUp Optional cap on rent top-up (units of 1k lamports; default no cap) */ export function createRemoveMetadataKeyInstruction( mintInterface: MintInterface, @@ -348,6 +359,7 @@ export function createRemoveMetadataKeyInstruction( key: string, idempotent: boolean = false, extensionIndex: number = 0, + maxTopUp?: number, ): TransactionInstruction { const action: UpdateMetadataAction = { type: 'removeKey', @@ -362,5 +374,6 @@ export function createRemoveMetadataKeyInstruction( payer, validityProof, action, + maxTopUp, ); } diff --git a/js/compressed-token/src/v3/instructions/update-mint.ts b/js/compressed-token/src/v3/instructions/update-mint.ts index 7e81f24069..606383256f 100644 --- a/js/compressed-token/src/v3/instructions/update-mint.ts +++ b/js/compressed-token/src/v3/instructions/update-mint.ts @@ -6,12 +6,13 @@ import { import { Buffer } from 'buffer'; import { ValidityProofWithContext, - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, LightSystemProgram, defaultStaticAccountsStruct, getDefaultAddressTreeInfo, getOutputQueue, } from '@lightprotocol/stateless.js'; +import { MAX_TOP_UP } from '../../constants'; import { CompressedTokenProgram } from '../../program'; import { MintInterface } from '../get-mint-interface'; import { @@ -31,6 +32,7 @@ interface EncodeUpdateMintInstructionParams { mintInterface: MintInterface; newAuthority: PublicKey | null; actionType: 'mintAuthority' | 'freezeAuthority'; + maxTopUp?: number; } function encodeUpdateMintInstructionData( @@ -71,7 +73,11 @@ function encodeUpdateMintInstructionData( leafIndex: params.leafIndex, proveByIndex: params.proveByIndex, rootIndex: params.rootIndex, +<<<<<<< HEAD maxTopUp: 65535, +======= + maxTopUp: params.maxTopUp ?? MAX_TOP_UP, +>>>>>>> 03b831af0 (fix: add delegate to packed accounts in decompress instruction, version-aware proof chunking) createMint: null, actions: [action], proof: params.proof, @@ -109,6 +115,7 @@ function encodeUpdateMintInstructionData( * @param newMintAuthority New mint authority (or null to revoke) * @param payer Fee payer public key * @param validityProof Validity proof for the compressed mint (null for decompressed mints) + * @param maxTopUp Optional cap on rent top-up (units of 1k lamports; default no cap) */ export function createUpdateMintAuthorityInstruction( mintInterface: MintInterface, @@ -116,6 +123,7 @@ export function createUpdateMintAuthorityInstruction( newMintAuthority: PublicKey | null, payer: PublicKey, validityProof: ValidityProofWithContext | null, + maxTopUp?: number, ): TransactionInstruction { if (!mintInterface.merkleContext) { throw new Error( @@ -143,6 +151,7 @@ export function createUpdateMintAuthorityInstruction( mintInterface, newAuthority: newMintAuthority, actionType: 'mintAuthority', + maxTopUp, }); const sys = defaultStaticAccountsStruct(); @@ -199,7 +208,7 @@ export function createUpdateMintAuthorityInstruction( ]; return new TransactionInstruction({ - programId: CTOKEN_PROGRAM_ID, + programId: LIGHT_TOKEN_PROGRAM_ID, keys, data, }); @@ -217,6 +226,7 @@ export function createUpdateMintAuthorityInstruction( * @param newFreezeAuthority New freeze authority (or null to revoke) * @param payer Fee payer public key * @param validityProof Validity proof for the compressed mint (null for decompressed mints) + * @param maxTopUp Optional cap on rent top-up (units of 1k lamports; default no cap) */ export function createUpdateFreezeAuthorityInstruction( mintInterface: MintInterface, @@ -224,6 +234,7 @@ export function createUpdateFreezeAuthorityInstruction( newFreezeAuthority: PublicKey | null, payer: PublicKey, validityProof: ValidityProofWithContext | null, + maxTopUp?: number, ): TransactionInstruction { if (!mintInterface.merkleContext) { throw new Error( @@ -251,6 +262,7 @@ export function createUpdateFreezeAuthorityInstruction( mintInterface, newAuthority: newFreezeAuthority, actionType: 'freezeAuthority', + maxTopUp, }); const sys = defaultStaticAccountsStruct(); @@ -307,7 +319,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 34df811a26..cf0247d999 100644 --- a/js/compressed-token/src/v3/instructions/wrap.ts +++ b/js/compressed-token/src/v3/instructions/wrap.ts @@ -3,7 +3,8 @@ 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 { MAX_TOP_UP } from '../../constants'; import { CompressedTokenProgram } from '../../program'; import { SplInterfaceInfo } from '../../utils/get-token-pool-infos'; import { @@ -26,6 +27,7 @@ import { * @param splInterfaceInfo SPL interface info for the compression * @param decimals Mint decimals (required for transfer_checked) * @param payer Fee payer (defaults to owner) + * @param maxTopUp Optional cap on rent top-up (units of 1k lamports; default no cap) * @returns Instruction to wrap tokens */ export function createWrapInstruction( @@ -37,6 +39,7 @@ export function createWrapInstruction( splInterfaceInfo: SplInterfaceInfo, decimals: number, payer: PublicKey = owner, + maxTopUp?: number, ): TransactionInstruction { const MINT_INDEX = 0; const OWNER_INDEX = 1; @@ -71,7 +74,11 @@ export function createWrapInstruction( lamportsChangeAccountMerkleTreeIndex: 0, lamportsChangeAccountOwnerIndex: 0, outputQueue: 0, +<<<<<<< HEAD maxTopUp: 65535, +======= + maxTopUp: maxTopUp ?? MAX_TOP_UP, +>>>>>>> 03b831af0 (fix: add delegate to packed accounts in decompress instruction, version-aware proof chunking) cpiContext: null, compressions, proof: null, @@ -107,7 +114,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..2f6138b82c 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,129 @@ 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, + undefined, // maxTopUp - use default + 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, + undefined, // maxTopUp - use default + confirmOptions, + ); +} + +export type { _TransferOptions as TransferOptions }; + export { getAccountInterface, AccountInterface, @@ -307,7 +432,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 +463,7 @@ export { createWrapInstruction, createUnwrapInstruction, createDecompressInterfaceInstruction, - createTransferInterfaceInstruction, - createCTokenTransferInstruction, + createLightTokenTransferInstruction, // Types TokenMetadataInstructionData, CompressibleConfig, @@ -354,7 +478,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..5f95508428 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( @@ -285,6 +290,14 @@ describe('get-account-interface', () => { expect(result._sources?.[0].type).toBe( TokenAccountSourceType.CTokenHot, ); + + // Parsed field correctness (COption layout regression) + expect(result.parsed.isInitialized).toBe(true); + expect(result.parsed.isFrozen).toBe(false); + expect(result.parsed.delegatedAmount).toBe(0n); + expect(result.parsed.delegate).toBeNull(); + expect(result.parsed.isNative).toBe(false); + expect(result.parsed.closeAuthority).toBeNull(); }); }); @@ -320,7 +333,7 @@ describe('get-account-interface', () => { rpc, ctokenAta, 'confirmed', - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ), ).rejects.toThrow(); @@ -331,7 +344,7 @@ describe('get-account-interface', () => { owner.publicKey, ctokenMint, 'confirmed', - CTOKEN_PROGRAM_ID, + LIGHT_TOKEN_PROGRAM_ID, ); expect(result.parsed.mint.toBase58()).toBe( @@ -359,6 +372,11 @@ describe('get-account-interface', () => { payer, ctokenMint, owner.publicKey, + false, + undefined, + undefined, + undefined, + { compressibleConfig: null }, ); await mintTo( @@ -478,6 +496,11 @@ describe('get-account-interface', () => { payer, ctokenMint, owner.publicKey, + false, + undefined, + undefined, + undefined, + { compressibleConfig: null }, ); await mintTo( @@ -563,6 +586,14 @@ describe('get-account-interface', () => { expect(result._sources?.[0].type).toBe( TokenAccountSourceType.CTokenCold, ); + + // Parsed field correctness for cold accounts + expect(result.parsed.isInitialized).toBe(true); + expect(result.parsed.isFrozen).toBe(false); + expect(result.parsed.delegatedAmount).toBe(0n); + expect(result.parsed.delegate).toBeNull(); + expect(result.parsed.isNative).toBe(false); + expect(result.parsed.closeAuthority).toBeNull(); }); }); @@ -577,6 +608,11 @@ describe('get-account-interface', () => { payer, ctokenMint, owner.publicKey, + false, + undefined, + undefined, + undefined, + { compressibleConfig: null }, ); // Mint and decompress first batch (hot) @@ -944,6 +980,11 @@ describe('get-account-interface', () => { payer, ctokenMint, owner.publicKey, + false, + undefined, + undefined, + undefined, + { compressibleConfig: null }, ); // Mint and decompress to create hot balance @@ -1005,6 +1046,11 @@ describe('get-account-interface', () => { payer, ctokenMint, owner.publicKey, + false, + undefined, + undefined, + undefined, + { compressibleConfig: null }, ); // Create hot first @@ -1056,4 +1102,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..1776a57b4d 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) { @@ -36,10 +36,16 @@ import { selectTokenPoolInfosForDecompression, TokenPoolInfo, } from '../../src/utils/get-token-pool-infos'; +import { MAX_TOP_UP } from '../../src/constants'; import { createWrapInstruction } from '../../src/v3/instructions/wrap'; import { wrap } from '../../src/v3/actions/wrap'; import { getAssociatedTokenAddressInterface } from '../../src'; import { createAtaInterfaceIdempotent } from '../../src/v3/actions/create-ata-interface'; +import type { CompressibleConfig } from '../../src/v3/instructions/create-associated-ctoken'; +import { + LIGHT_TOKEN_CONFIG, + LIGHT_TOKEN_RENT_SPONSOR, +} from '../../src/constants'; // Force V2 for CToken tests featureFlags.version = VERSION.V2; @@ -107,6 +113,37 @@ describe('createWrapInstruction', () => { expect(ix.data.length).toBeGreaterThan(0); }); + it('should encode default MAX_TOP_UP (no cap) when maxTopUp not provided', async () => { + const owner = Keypair.generate(); + const source = getAssociatedTokenAddressSync( + mint, + owner.publicKey, + false, + TOKEN_PROGRAM_ID, + ); + const destination = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); + + const tokenPoolInfo = tokenPoolInfos.find(info => info.isInitialized); + expect(tokenPoolInfo).toBeDefined(); + + const ix = createWrapInstruction( + source, + destination, + owner.publicKey, + mint, + BigInt(1000), + tokenPoolInfo!, + TEST_TOKEN_DECIMALS, + ); + + const maxTopUpInData = ix.data.readUInt16LE(6); + expect(maxTopUpInData).toBe(MAX_TOP_UP); + expect(maxTopUpInData).toBe(65535); + }); + it('should create instruction with explicit payer', async () => { const owner = Keypair.generate(); const feePayer = Keypair.generate(); @@ -472,6 +509,261 @@ describe('wrap action', () => { const ctokenBalance = await getCTokenBalance(rpc, ctokenAta); expect(ctokenBalance).toBe(BigInt(150)); }, 60_000); + + /** + * Wrap with default maxTopUp (MAX_TOP_UP) must succeed so that rent top-up + * can occur when the ctoken ATA needs it. Regression test for default maxTopUp. + */ + it('should wrap successfully with default maxTopUp so rent top-up is allowed when needed', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + const splAta = await createAssociatedTokenAccount( + rpc, + payer, + mint, + owner.publicKey, + ); + + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(200), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + tokenPoolInfos = await getTokenPoolInfos(rpc, mint); + await decompress( + rpc, + payer, + mint, + bn(200), + owner, + splAta, + selectTokenPoolInfosForDecompression(tokenPoolInfos, bn(200)), + ); + + const ctokenAta = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); + await createAtaInterfaceIdempotent(rpc, payer, mint, owner.publicKey); + + tokenPoolInfos = await getTokenPoolInfos(rpc, mint); + const tokenPoolInfo = tokenPoolInfos.find(info => info.isInitialized); + expect(tokenPoolInfo).toBeDefined(); + + const ix = createWrapInstruction( + splAta, + ctokenAta, + owner.publicKey, + mint, + BigInt(50), + tokenPoolInfo!, + TEST_TOKEN_DECIMALS, + ); + const maxTopUpInData = ix.data.readUInt16LE(6); + expect(maxTopUpInData).toBe(MAX_TOP_UP); + + const result = await wrap( + rpc, + payer, + splAta, + ctokenAta, + owner, + mint, + BigInt(50), + tokenPoolInfo, + ); + expect(result).toBeDefined(); + const ctokenBalance = await getCTokenBalance(rpc, ctokenAta); + expect(ctokenBalance).toBe(BigInt(50)); + }, 60_000); + + /** + * CToken ATA created with minimal prepay (rentPayment: 2) so the first write (wrap) + * triggers rent top-up. Asserts payer is charged writeTopUp and the ctoken ATA + * receives that amount. + */ + it('should trigger rent top-up when ctoken ATA has minimal prepay and payer pays correct amount', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + const splAta = await createAssociatedTokenAccount( + rpc, + payer, + mint, + owner.publicKey, + ); + + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(1000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + tokenPoolInfos = await getTokenPoolInfos(rpc, mint); + await decompress( + rpc, + payer, + mint, + bn(1000), + owner, + splAta, + selectTokenPoolInfosForDecompression(tokenPoolInfos, bn(1000)), + ); + + const ctokenAta = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); + + const minimalPrepayConfig: CompressibleConfig = { + tokenAccountVersion: 3, + rentPayment: 2, + compressionOnly: 1, + writeTopUp: 766, + compressToAccountPubkey: null, + }; + await createAtaInterfaceIdempotent( + rpc, + payer, + mint, + owner.publicKey, + false, + undefined, + undefined, + undefined, + { + compressibleConfig: minimalPrepayConfig, + configAccount: LIGHT_TOKEN_CONFIG, + rentPayerPda: LIGHT_TOKEN_RENT_SPONSOR, + }, + ); + + const ctokenInfoBefore = await rpc.getAccountInfo(ctokenAta); + expect(ctokenInfoBefore).not.toBeNull(); + const ctokenLamportsBefore = ctokenInfoBefore!.lamports; + + const payerLamportsBefore = await rpc.getBalance(payer.publicKey); + + tokenPoolInfos = await getTokenPoolInfos(rpc, mint); + const tokenPoolInfo = tokenPoolInfos.find(info => info.isInitialized); + expect(tokenPoolInfo).toBeDefined(); + + await wrap( + rpc, + payer, + splAta, + ctokenAta, + owner, + mint, + BigInt(500), + tokenPoolInfo, + ); + + const payerLamportsAfter = await rpc.getBalance(payer.publicKey); + const ctokenInfoAfter = await rpc.getAccountInfo(ctokenAta); + expect(ctokenInfoAfter).not.toBeNull(); + const ctokenLamportsAfter = ctokenInfoAfter!.lamports; + + const payerSpent = payerLamportsBefore - payerLamportsAfter; + expect(payerSpent).toBeGreaterThanOrEqual(766); + expect(ctokenLamportsAfter - ctokenLamportsBefore).toBe(766); + + const ctokenBalance = await getCTokenBalance(rpc, ctokenAta); + expect(ctokenBalance).toBe(BigInt(500)); + }, 60_000); + + /** + * When maxTopUp is 0 and the ctoken ATA needs rent top-up, the wrap must fail + * with MaxTopUpExceeded (program error 18043 / 0x467b). + */ + it('should fail wrap with maxTopUp 0 when rent top-up is required', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + const splAta = await createAssociatedTokenAccount( + rpc, + payer, + mint, + owner.publicKey, + ); + + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(500), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + tokenPoolInfos = await getTokenPoolInfos(rpc, mint); + await decompress( + rpc, + payer, + mint, + bn(500), + owner, + splAta, + selectTokenPoolInfosForDecompression(tokenPoolInfos, bn(500)), + ); + + const ctokenAta = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); + + const minimalPrepayConfig: CompressibleConfig = { + tokenAccountVersion: 3, + rentPayment: 0, + compressionOnly: 1, + writeTopUp: 766, + compressToAccountPubkey: null, + }; + await createAtaInterfaceIdempotent( + rpc, + payer, + mint, + owner.publicKey, + false, + undefined, + undefined, + undefined, + { + compressibleConfig: minimalPrepayConfig, + configAccount: LIGHT_TOKEN_CONFIG, + rentPayerPda: LIGHT_TOKEN_RENT_SPONSOR, + }, + ); + + tokenPoolInfos = await getTokenPoolInfos(rpc, mint); + const tokenPoolInfo = tokenPoolInfos.find(info => info.isInitialized); + expect(tokenPoolInfo).toBeDefined(); + + await expect( + wrap( + rpc, + payer, + splAta, + ctokenAta, + owner, + mint, + BigInt(100), + tokenPoolInfo, + 0, + ), + ).rejects.toThrow(/18043|MaxTopUpExceeded|0x467b/i); + }, 60_000); }); describe('wrap with non-ATA accounts', () => { diff --git a/js/compressed-token/tests/unit/constants.test.ts b/js/compressed-token/tests/unit/constants.test.ts new file mode 100644 index 0000000000..6cc99556dd --- /dev/null +++ b/js/compressed-token/tests/unit/constants.test.ts @@ -0,0 +1,10 @@ +import { describe, it, expect } from 'vitest'; +import { MAX_TOP_UP } from '../../src/constants'; + +describe('constants', () => { + describe('MAX_TOP_UP', () => { + it('should equal 65535 (u16::MAX, no cap)', () => { + expect(MAX_TOP_UP).toBe(65535); + }); + }); +}); 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/instructions-max-top-up.test.ts b/js/compressed-token/tests/unit/instructions-max-top-up.test.ts new file mode 100644 index 0000000000..7dc5dc7ccb --- /dev/null +++ b/js/compressed-token/tests/unit/instructions-max-top-up.test.ts @@ -0,0 +1,219 @@ +import { describe, it, expect } from 'vitest'; +import { Keypair, PublicKey } from '@solana/web3.js'; +import BN from 'bn.js'; +import { TOKEN_PROGRAM_ID } from '@solana/spl-token'; +import { MAX_TOP_UP } from '../../src/constants'; +import { createWrapInstruction } from '../../src/v3/instructions/wrap'; +import { createUnwrapInstruction } from '../../src/v3/instructions/unwrap'; +import { createMintToInstruction } from '../../src/v3/instructions/mint-to'; +import type { SplInterfaceInfo } from '../../src/utils/get-token-pool-infos'; + +const TRANSFER2_BASE_MAX_TOP_UP_OFFSET = 6; + +function mockSplInterfaceInfo(mint: PublicKey): SplInterfaceInfo { + const splInterfacePda = Keypair.generate().publicKey; + return { + mint, + splInterfacePda, + tokenProgram: TOKEN_PROGRAM_ID, + isInitialized: true, + balance: new BN(0), + poolIndex: 0, + bump: 255, + }; +} + +function getTransfer2MaxTopUpFromInstructionData(data: Buffer): number { + return data.readUInt16LE(TRANSFER2_BASE_MAX_TOP_UP_OFFSET); +} + +describe('instructions maxTopUp encoding', () => { + describe('createWrapInstruction', () => { + it('should encode maxTopUp 65535 when maxTopUp is omitted', () => { + const source = Keypair.generate().publicKey; + const destination = Keypair.generate().publicKey; + const owner = Keypair.generate().publicKey; + const mint = Keypair.generate().publicKey; + const info = mockSplInterfaceInfo(mint); + + const ix = createWrapInstruction( + source, + destination, + owner, + mint, + 1000n, + info, + 9, + ); + + const maxTopUp = getTransfer2MaxTopUpFromInstructionData(ix.data); + expect(maxTopUp).toBe(65535); + }); + + it('should encode maxTopUp 0 when maxTopUp is 0', () => { + const source = Keypair.generate().publicKey; + const destination = Keypair.generate().publicKey; + const owner = Keypair.generate().publicKey; + const mint = Keypair.generate().publicKey; + const info = mockSplInterfaceInfo(mint); + + const ix = createWrapInstruction( + source, + destination, + owner, + mint, + 1000n, + info, + 9, + owner, + 0, + ); + + const maxTopUp = getTransfer2MaxTopUpFromInstructionData(ix.data); + expect(maxTopUp).toBe(0); + }); + + it('should encode custom maxTopUp when provided', () => { + const source = Keypair.generate().publicKey; + const destination = Keypair.generate().publicKey; + const owner = Keypair.generate().publicKey; + const mint = Keypair.generate().publicKey; + const info = mockSplInterfaceInfo(mint); + + const ix = createWrapInstruction( + source, + destination, + owner, + mint, + 1000n, + info, + 9, + owner, + 10, + ); + + const maxTopUp = getTransfer2MaxTopUpFromInstructionData(ix.data); + expect(maxTopUp).toBe(10); + }); + }); + + describe('createUnwrapInstruction', () => { + it('should encode maxTopUp 65535 when maxTopUp is omitted', () => { + const source = Keypair.generate().publicKey; + const destination = Keypair.generate().publicKey; + const owner = Keypair.generate().publicKey; + const mint = Keypair.generate().publicKey; + const info = mockSplInterfaceInfo(mint); + + const ix = createUnwrapInstruction( + source, + destination, + owner, + mint, + 1000n, + info, + 9, + ); + + const maxTopUp = getTransfer2MaxTopUpFromInstructionData(ix.data); + expect(maxTopUp).toBe(65535); + }); + + it('should encode maxTopUp 0 when maxTopUp is 0', () => { + const source = Keypair.generate().publicKey; + const destination = Keypair.generate().publicKey; + const owner = Keypair.generate().publicKey; + const mint = Keypair.generate().publicKey; + const info = mockSplInterfaceInfo(mint); + + const ix = createUnwrapInstruction( + source, + destination, + owner, + mint, + 1000n, + info, + 9, + owner, + 0, + ); + + const maxTopUp = getTransfer2MaxTopUpFromInstructionData(ix.data); + expect(maxTopUp).toBe(0); + }); + + it('should encode custom maxTopUp when provided', () => { + const source = Keypair.generate().publicKey; + const destination = Keypair.generate().publicKey; + const owner = Keypair.generate().publicKey; + const mint = Keypair.generate().publicKey; + const info = mockSplInterfaceInfo(mint); + + const ix = createUnwrapInstruction( + source, + destination, + owner, + mint, + 1000n, + info, + 9, + owner, + 100, + ); + + const maxTopUp = getTransfer2MaxTopUpFromInstructionData(ix.data); + expect(maxTopUp).toBe(100); + }); + }); + + describe('createMintToInstruction', () => { + it('should produce 9-byte data when maxTopUp is omitted', () => { + const mint = Keypair.generate().publicKey; + const destination = Keypair.generate().publicKey; + const authority = Keypair.generate().publicKey; + + const ix = createMintToInstruction({ + mint, + destination, + amount: 100n, + authority, + }); + + expect(ix.data.length).toBe(9); + }); + + it('should produce 11-byte data with maxTopUp 0 when maxTopUp is 0', () => { + const mint = Keypair.generate().publicKey; + const destination = Keypair.generate().publicKey; + const authority = Keypair.generate().publicKey; + + const ix = createMintToInstruction({ + mint, + destination, + amount: 100n, + authority, + maxTopUp: 0, + }); + + expect(ix.data.length).toBe(11); + expect(ix.data.readUInt16LE(9)).toBe(0); + }); + + it('should produce 11-byte data with maxTopUp 65535 when maxTopUp is MAX_TOP_UP', () => { + const mint = Keypair.generate().publicKey; + const destination = Keypair.generate().publicKey; + const authority = Keypair.generate().publicKey; + + const ix = createMintToInstruction({ + mint, + destination, + amount: 100n, + authority, + maxTopUp: MAX_TOP_UP, + }); + + expect(ix.data.length).toBe(11); + expect(ix.data.readUInt16LE(9)).toBe(65535); + }); + }); +}); diff --git a/js/compressed-token/tests/unit/layout-mint-action.test.ts b/js/compressed-token/tests/unit/layout-mint-action.test.ts index 23b444e074..df661dc840 100644 --- a/js/compressed-token/tests/unit/layout-mint-action.test.ts +++ b/js/compressed-token/tests/unit/layout-mint-action.test.ts @@ -7,6 +7,7 @@ import { Action, MINT_ACTION_DISCRIMINATOR, } from '../../src/v3/layout/layout-mint-action'; +import { MAX_TOP_UP } from '../../src/constants'; describe('layout-mint-action', () => { describe('encodeMintActionInstructionData / decodeMintActionInstructionData', () => { @@ -52,6 +53,40 @@ describe('layout-mint-action', () => { expect(decoded.mint.decimals).toBe(9); }); + it('should encode and decode maxTopUp MAX_TOP_UP (65535) round-trip', () => { + const mint = Keypair.generate().publicKey; + + const data: MintActionCompressedInstructionData = { + leafIndex: 0, + proveByIndex: false, + rootIndex: 0, + maxTopUp: MAX_TOP_UP, + createMint: null, + actions: [], + proof: null, + cpiContext: null, + mint: { + supply: 0n, + decimals: 9, + metadata: { + version: 1, + cmintDecompressed: false, + mint, + mintSigner: Array.from(new Uint8Array(32).fill(0)), + bump: 0, + }, + mintAuthority: mint, + freezeAuthority: null, + extensions: null, + }, + }; + + const encoded = encodeMintActionInstructionData(data); + const decoded = decodeMintActionInstructionData(encoded); + + expect(decoded.maxTopUp).toBe(65535); + }); + it('should encode and decode with mintToCompressed action', () => { const mint = Keypair.generate().publicKey; const recipient1 = Keypair.generate().publicKey; diff --git a/js/compressed-token/tests/unit/layout-transfer2.test.ts b/js/compressed-token/tests/unit/layout-transfer2.test.ts index 4cce2e10a9..25fe1a8346 100644 --- a/js/compressed-token/tests/unit/layout-transfer2.test.ts +++ b/js/compressed-token/tests/unit/layout-transfer2.test.ts @@ -10,6 +10,7 @@ import { COMPRESSION_MODE_COMPRESS, COMPRESSION_MODE_DECOMPRESS, } from '../../src/v3/layout/layout-transfer2'; +import { MAX_TOP_UP } from '../../src/constants'; describe('layout-transfer2', () => { describe('encodeTransfer2InstructionData', () => { @@ -39,6 +40,31 @@ describe('layout-transfer2', () => { expect(encoded.length).toBeGreaterThan(1); }); + it('should encode maxTopUp MAX_TOP_UP (65535) and round-trip at offset 6', () => { + const data: Transfer2InstructionData = { + withTransactionHash: false, + withLamportsChangeAccountMerkleTreeIndex: false, + lamportsChangeAccountMerkleTreeIndex: 0, + lamportsChangeAccountOwnerIndex: 0, + outputQueue: 0, + maxTopUp: MAX_TOP_UP, + cpiContext: null, + compressions: null, + proof: null, + inTokenData: [], + outTokenData: [], + inLamports: null, + outLamports: null, + inTlv: null, + outTlv: null, + }; + + const encoded = encodeTransfer2InstructionData(data); + + expect(encoded.subarray(0, 1)).toEqual(TRANSFER2_DISCRIMINATOR); + expect(encoded.readUInt16LE(6)).toBe(65535); + }); + it('should encode with compressions array', () => { const compressions: Compression[] = [ { 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/parse-account-fields.test.ts b/js/compressed-token/tests/unit/parse-account-fields.test.ts new file mode 100644 index 0000000000..199fc312b4 --- /dev/null +++ b/js/compressed-token/tests/unit/parse-account-fields.test.ts @@ -0,0 +1,508 @@ +import { describe, it, expect } from 'vitest'; +import { Keypair, PublicKey } from '@solana/web3.js'; +import { Buffer } from 'buffer'; +import BN from 'bn.js'; +import { LIGHT_TOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; +import { + convertTokenDataToAccount, + parseCTokenHot, +} from '../../src/v3/get-account-interface'; + +/** + * Build a 165-byte SPL-compatible token account buffer. + * + * Offsets (COption = 4-byte prefix): + * 0-31 mint (32) + * 32-63 owner (32) + * 64-71 amount (u64 LE) + * 72-75 delegateOption (u32 COption) + * 76-107 delegate (32) + * 108 state (u8: 0=Uninit, 1=Init, 2=Frozen) + * 109-112 isNativeOption (u32 COption) + * 113-120 isNative (u64 LE) + * 121-128 delegatedAmount (u64 LE) + * 129-132 closeAuthOption (u32 COption) + * 133-164 closeAuthority (32) + */ +function buildSplTokenBuffer(params: { + mint: PublicKey; + owner: PublicKey; + amount: number; + delegate?: PublicKey | null; + state: number; + isNative?: number | null; + delegatedAmount?: number; + closeAuthority?: PublicKey | null; +}): Buffer { + const buf = Buffer.alloc(165); + + Buffer.from(params.mint.toBytes()).copy(buf, 0); + Buffer.from(params.owner.toBytes()).copy(buf, 32); + + buf.writeUInt32LE(params.amount & 0xffffffff, 64); + buf.writeUInt32LE(Math.floor(params.amount / 0x100000000), 68); + + if (params.delegate) { + buf.writeUInt32LE(1, 72); + Buffer.from(params.delegate.toBytes()).copy(buf, 76); + } + + buf[108] = params.state; + + if (params.isNative != null) { + buf.writeUInt32LE(1, 109); + buf.writeUInt32LE(params.isNative & 0xffffffff, 113); + buf.writeUInt32LE(Math.floor(params.isNative / 0x100000000), 117); + } + + const da = params.delegatedAmount ?? 0; + buf.writeUInt32LE(da & 0xffffffff, 121); + buf.writeUInt32LE(Math.floor(da / 0x100000000), 125); + + if (params.closeAuthority) { + buf.writeUInt32LE(1, 129); + Buffer.from(params.closeAuthority.toBytes()).copy(buf, 133); + } + + return buf; +} + +/** + * Build Borsh-serialized TLV Vec containing a single + * CompressedOnly extension (discriminator 31). + * + * Format: [u32 vec_len] [u8 disc=31] [u64 delegated_amount] [u64 withheld_fee] [u8 is_ata] + */ +function buildCompressedOnlyTlv( + delegatedAmount: number, + withheldFee = 0, + isAta = 0, +): Buffer { + const buf = Buffer.alloc(4 + 1 + 17); + buf.writeUInt32LE(1, 0); + buf[4] = 31; + buf.writeUInt32LE(delegatedAmount & 0xffffffff, 5); + buf.writeUInt32LE(Math.floor(delegatedAmount / 0x100000000), 9); + buf.writeUInt32LE(withheldFee & 0xffffffff, 13); + buf.writeUInt32LE(Math.floor(withheldFee / 0x100000000), 17); + buf[21] = isAta; + return buf; +} + +/** + * Build TLV with multiple extensions before CompressedOnly. + * Prepends `prefixDiscs` (each 0-byte unit variant), then CompressedOnly. + */ +function buildTlvWithPrefixExtensions( + prefixDiscs: number[], + delegatedAmount: number, +): Buffer { + const vecLen = prefixDiscs.length + 1; + const totalSize = 4 + prefixDiscs.length + 1 + 17; + const buf = Buffer.alloc(totalSize); + let offset = 0; + + buf.writeUInt32LE(vecLen, offset); + offset += 4; + + for (const disc of prefixDiscs) { + buf[offset] = disc; + offset += 1; + } + + buf[offset] = 31; + offset += 1; + buf.writeUInt32LE(delegatedAmount & 0xffffffff, offset); + buf.writeUInt32LE(Math.floor(delegatedAmount / 0x100000000), offset + 4); + return buf; +} + +describe('parseCTokenHot - COption format correctness', () => { + it('should parse initialized state at offset 108 (regression: old parser read offset 105)', () => { + const mint = Keypair.generate().publicKey; + const owner = Keypair.generate().publicKey; + const address = Keypair.generate().publicKey; + + const data = buildSplTokenBuffer({ + mint, + owner, + amount: 1000, + state: 1, + }); + + const result = parseCTokenHot(address, { + executable: false, + owner: LIGHT_TOKEN_PROGRAM_ID, + lamports: 1_000_000, + data, + rentEpoch: undefined, + }); + + expect(result.parsed.isInitialized).toBe(true); + expect(result.parsed.isFrozen).toBe(false); + expect(result.parsed.amount).toBe(1000n); + expect(result.parsed.delegate).toBeNull(); + expect(result.parsed.delegatedAmount).toBe(0n); + expect(result.parsed.isNative).toBe(false); + expect(result.parsed.closeAuthority).toBeNull(); + expect(result.isCold).toBe(false); + }); + + it('should parse frozen state correctly', () => { + const data = buildSplTokenBuffer({ + mint: Keypair.generate().publicKey, + owner: Keypair.generate().publicKey, + amount: 500, + state: 2, + }); + + const result = parseCTokenHot(Keypair.generate().publicKey, { + executable: false, + owner: LIGHT_TOKEN_PROGRAM_ID, + lamports: 1_000_000, + data, + rentEpoch: undefined, + }); + + expect(result.parsed.isInitialized).toBe(true); + expect(result.parsed.isFrozen).toBe(true); + }); + + it('should parse delegate and delegatedAmount from COption layout', () => { + const delegate = Keypair.generate().publicKey; + const data = buildSplTokenBuffer({ + mint: Keypair.generate().publicKey, + owner: Keypair.generate().publicKey, + amount: 5000, + delegate, + state: 1, + delegatedAmount: 3000, + }); + + const result = parseCTokenHot(Keypair.generate().publicKey, { + executable: false, + owner: LIGHT_TOKEN_PROGRAM_ID, + lamports: 1_000_000, + data, + rentEpoch: undefined, + }); + + expect(result.parsed.delegate!.toBase58()).toBe(delegate.toBase58()); + expect(result.parsed.delegatedAmount).toBe(3000n); + expect(result.parsed.amount).toBe(5000n); + expect(result.parsed.isInitialized).toBe(true); + }); + + it('should parse closeAuthority from COption at offset 129', () => { + const closeAuth = Keypair.generate().publicKey; + const data = buildSplTokenBuffer({ + mint: Keypair.generate().publicKey, + owner: Keypair.generate().publicKey, + amount: 100, + state: 1, + closeAuthority: closeAuth, + }); + + const result = parseCTokenHot(Keypair.generate().publicKey, { + executable: false, + owner: LIGHT_TOKEN_PROGRAM_ID, + lamports: 1_000_000, + data, + rentEpoch: undefined, + }); + + expect(result.parsed.closeAuthority!.toBase58()).toBe( + closeAuth.toBase58(), + ); + }); + + it('should parse isNative correctly when set', () => { + const data = buildSplTokenBuffer({ + mint: Keypair.generate().publicKey, + owner: Keypair.generate().publicKey, + amount: 100, + state: 1, + isNative: 2039280, + }); + + const result = parseCTokenHot(Keypair.generate().publicKey, { + executable: false, + owner: LIGHT_TOKEN_PROGRAM_ID, + lamports: 1_000_000, + data, + rentEpoch: undefined, + }); + + expect(result.parsed.isNative).toBe(true); + expect(result.parsed.rentExemptReserve).toBe(2039280n); + }); + + it('should parse mint and owner pubkeys correctly', () => { + const mint = Keypair.generate().publicKey; + const owner = Keypair.generate().publicKey; + const address = Keypair.generate().publicKey; + const data = buildSplTokenBuffer({ mint, owner, amount: 0, state: 1 }); + + const result = parseCTokenHot(address, { + executable: false, + owner: LIGHT_TOKEN_PROGRAM_ID, + lamports: 1_000_000, + data, + rentEpoch: undefined, + }); + + expect(result.parsed.mint.toBase58()).toBe(mint.toBase58()); + expect(result.parsed.owner.toBase58()).toBe(owner.toBase58()); + expect(result.parsed.address.toBase58()).toBe(address.toBase58()); + }); +}); + +describe('convertTokenDataToAccount - delegatedAmount logic', () => { + it('should return 0 when no delegate and no TLV', () => { + const result = convertTokenDataToAccount(Keypair.generate().publicKey, { + mint: Keypair.generate().publicKey, + owner: Keypair.generate().publicKey, + amount: new BN(1000), + delegate: null, + state: 1, + tlv: null, + }); + expect(result.delegatedAmount).toBe(0n); + }); + + it('should equal amount when delegate is set and no TLV (compressed approve)', () => { + const result = convertTokenDataToAccount(Keypair.generate().publicKey, { + mint: Keypair.generate().publicKey, + owner: Keypair.generate().publicKey, + amount: new BN(5000), + delegate: Keypair.generate().publicKey, + state: 1, + tlv: null, + }); + expect(result.delegatedAmount).toBe(5000n); + }); + + it('should extract delegatedAmount from CompressedOnly extension in TLV', () => { + const tlv = buildCompressedOnlyTlv(3000); + + const result = convertTokenDataToAccount(Keypair.generate().publicKey, { + mint: Keypair.generate().publicKey, + owner: Keypair.generate().publicKey, + amount: new BN(10000), + delegate: Keypair.generate().publicKey, + state: 1, + tlv, + }); + expect(result.delegatedAmount).toBe(3000n); + }); + + it('should return 0 from CompressedOnly when delegated_amount is 0 (no delegate on source)', () => { + const tlv = buildCompressedOnlyTlv(0); + + const result = convertTokenDataToAccount(Keypair.generate().publicKey, { + mint: Keypair.generate().publicKey, + owner: Keypair.generate().publicKey, + amount: new BN(10000), + delegate: null, + state: 1, + tlv, + }); + expect(result.delegatedAmount).toBe(0n); + }); + + it('should find CompressedOnly after fixed-size extensions (PausableAccount + PermanentDelegate)', () => { + const tlv = buildTlvWithPrefixExtensions([27, 28], 7777); + + const result = convertTokenDataToAccount(Keypair.generate().publicKey, { + mint: Keypair.generate().publicKey, + owner: Keypair.generate().publicKey, + amount: new BN(10000), + delegate: Keypair.generate().publicKey, + state: 1, + tlv, + }); + expect(result.delegatedAmount).toBe(7777n); + }); + + it('should find CompressedOnly after TransferFeeAccount (8-byte extension)', () => { + // TLV: vec_len=2, [disc=29, 8 bytes fee data], [disc=31, 17 bytes CompressedOnly] + const buf = Buffer.alloc(4 + 1 + 8 + 1 + 17); + buf.writeUInt32LE(2, 0); + buf[4] = 29; // TransferFeeAccountExtension + buf.writeUInt32LE(999, 5); // withheld_amount (8 bytes, only lo) + buf[13] = 31; // CompressedOnly + buf.writeUInt32LE(4200, 14); // delegated_amount lo + + const result = convertTokenDataToAccount(Keypair.generate().publicKey, { + mint: Keypair.generate().publicKey, + owner: Keypair.generate().publicKey, + amount: new BN(10000), + delegate: Keypair.generate().publicKey, + state: 1, + tlv: buf, + }); + expect(result.delegatedAmount).toBe(4200n); + }); + + it('should find CompressedOnly after TransferHookAccount (1-byte extension)', () => { + // TLV: vec_len=2, [disc=30, 1 byte], [disc=31, 17 bytes] + const buf = Buffer.alloc(4 + 1 + 1 + 1 + 17); + buf.writeUInt32LE(2, 0); + buf[4] = 30; // TransferHookAccountExtension + buf[5] = 0; // transferring = false + buf[6] = 31; // CompressedOnly + buf.writeUInt32LE(1234, 7); + + const result = convertTokenDataToAccount(Keypair.generate().publicKey, { + mint: Keypair.generate().publicKey, + owner: Keypair.generate().publicKey, + amount: new BN(10000), + delegate: Keypair.generate().publicKey, + state: 1, + tlv: buf, + }); + expect(result.delegatedAmount).toBe(1234n); + }); + + it('should fall back to amount when variable-length extension blocks TLV parsing', () => { + // TokenMetadata (disc 19) is variable-length; can't skip past it + const buf = Buffer.alloc(4 + 1 + 50); + buf.writeUInt32LE(2, 0); + buf[4] = 19; // TokenMetadata (variable) + // CompressedOnly follows but is unreachable + + const result = convertTokenDataToAccount(Keypair.generate().publicKey, { + mint: Keypair.generate().publicKey, + owner: Keypair.generate().publicKey, + amount: new BN(8000), + delegate: Keypair.generate().publicKey, + state: 1, + tlv: buf, + }); + // Falls back: delegate set + TLV unparseable => delegatedAmount = amount + expect(result.delegatedAmount).toBe(8000n); + }); + + it('should return 0 when TLV has no CompressedOnly and no delegate', () => { + // TLV with only PausableAccount + const buf = Buffer.alloc(4 + 1); + buf.writeUInt32LE(1, 0); + buf[4] = 27; // PausableAccount (0 bytes) + + const result = convertTokenDataToAccount(Keypair.generate().publicKey, { + mint: Keypair.generate().publicKey, + owner: Keypair.generate().publicKey, + amount: new BN(5000), + delegate: null, + state: 1, + tlv: buf, + }); + expect(result.delegatedAmount).toBe(0n); + }); + + it('should handle empty TLV buffer gracefully', () => { + const result = convertTokenDataToAccount(Keypair.generate().publicKey, { + mint: Keypair.generate().publicKey, + owner: Keypair.generate().publicKey, + amount: new BN(1000), + delegate: Keypair.generate().publicKey, + state: 1, + tlv: Buffer.alloc(0), + }); + // Empty TLV can't be parsed => falls back to amount + expect(result.delegatedAmount).toBe(1000n); + }); + + it('should handle truncated TLV buffer gracefully', () => { + // 3 bytes: too short for even vec_len (4 bytes) + const result = convertTokenDataToAccount(Keypair.generate().publicKey, { + mint: Keypair.generate().publicKey, + owner: Keypair.generate().publicKey, + amount: new BN(2000), + delegate: Keypair.generate().publicKey, + state: 1, + tlv: Buffer.alloc(3), + }); + expect(result.delegatedAmount).toBe(2000n); + }); +}); + +describe('convertTokenDataToAccount - other parsed fields', () => { + it('should set isInitialized=true for state=1', () => { + const result = convertTokenDataToAccount(Keypair.generate().publicKey, { + mint: Keypair.generate().publicKey, + owner: Keypair.generate().publicKey, + amount: new BN(100), + delegate: null, + state: 1, + tlv: null, + }); + expect(result.isInitialized).toBe(true); + expect(result.isFrozen).toBe(false); + }); + + it('should set isFrozen=true for state=2', () => { + const result = convertTokenDataToAccount(Keypair.generate().publicKey, { + mint: Keypair.generate().publicKey, + owner: Keypair.generate().publicKey, + amount: new BN(100), + delegate: null, + state: 2, + tlv: null, + }); + expect(result.isInitialized).toBe(true); + expect(result.isFrozen).toBe(true); + }); + + it('should set isInitialized=false for state=0', () => { + const result = convertTokenDataToAccount(Keypair.generate().publicKey, { + mint: Keypair.generate().publicKey, + owner: Keypair.generate().publicKey, + amount: new BN(100), + delegate: null, + state: 0, + tlv: null, + }); + expect(result.isInitialized).toBe(false); + expect(result.isFrozen).toBe(false); + }); + + it('should hardcode isNative=false and closeAuthority=null for cold accounts', () => { + const result = convertTokenDataToAccount(Keypair.generate().publicKey, { + mint: Keypair.generate().publicKey, + owner: Keypair.generate().publicKey, + amount: new BN(100), + delegate: null, + state: 1, + tlv: null, + }); + expect(result.isNative).toBe(false); + expect(result.rentExemptReserve).toBeNull(); + expect(result.closeAuthority).toBeNull(); + }); + + it('should pass through TLV data as tlvData buffer', () => { + const tlv = Buffer.from([1, 2, 3, 4, 5]); + const result = convertTokenDataToAccount(Keypair.generate().publicKey, { + mint: Keypair.generate().publicKey, + owner: Keypair.generate().publicKey, + amount: new BN(100), + delegate: null, + state: 1, + tlv, + }); + expect(result.tlvData).toEqual(Buffer.from([1, 2, 3, 4, 5])); + }); + + it('should return empty tlvData when tlv is null', () => { + const result = convertTokenDataToAccount(Keypair.generate().publicKey, { + mint: Keypair.generate().publicKey, + owner: Keypair.generate().publicKey, + amount: new BN(100), + delegate: null, + state: 1, + tlv: null, + }); + expect(result.tlvData).toEqual(Buffer.alloc(0)); + }); +}); 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', ); }); }); From aa05dd695f0728a71340053fcfdb9e9e776a4596 Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Wed, 18 Feb 2026 16:23:17 +0000 Subject: [PATCH 2/2] fix mc --- js/compressed-token/src/v3/instructions/update-mint.ts | 4 ---- js/compressed-token/src/v3/instructions/wrap.ts | 4 ---- 2 files changed, 8 deletions(-) diff --git a/js/compressed-token/src/v3/instructions/update-mint.ts b/js/compressed-token/src/v3/instructions/update-mint.ts index 606383256f..e7fd6645d3 100644 --- a/js/compressed-token/src/v3/instructions/update-mint.ts +++ b/js/compressed-token/src/v3/instructions/update-mint.ts @@ -73,11 +73,7 @@ function encodeUpdateMintInstructionData( leafIndex: params.leafIndex, proveByIndex: params.proveByIndex, rootIndex: params.rootIndex, -<<<<<<< HEAD - maxTopUp: 65535, -======= maxTopUp: params.maxTopUp ?? MAX_TOP_UP, ->>>>>>> 03b831af0 (fix: add delegate to packed accounts in decompress instruction, version-aware proof chunking) createMint: null, actions: [action], proof: params.proof, diff --git a/js/compressed-token/src/v3/instructions/wrap.ts b/js/compressed-token/src/v3/instructions/wrap.ts index cf0247d999..ed9e969621 100644 --- a/js/compressed-token/src/v3/instructions/wrap.ts +++ b/js/compressed-token/src/v3/instructions/wrap.ts @@ -74,11 +74,7 @@ export function createWrapInstruction( lamportsChangeAccountMerkleTreeIndex: 0, lamportsChangeAccountOwnerIndex: 0, outputQueue: 0, -<<<<<<< HEAD - maxTopUp: 65535, -======= maxTopUp: maxTopUp ?? MAX_TOP_UP, ->>>>>>> 03b831af0 (fix: add delegate to packed accounts in decompress instruction, version-aware proof chunking) cpiContext: null, compressions, proof: null,