diff --git a/.github/workflows/token-kit.yml b/.github/workflows/token-kit.yml
new file mode 100644
index 0000000000..b81ca509c4
--- /dev/null
+++ b/.github/workflows/token-kit.yml
@@ -0,0 +1,61 @@
+on:
+ push:
+ branches:
+ - main
+ paths:
+ - "js/token-kit/**"
+ - "pnpm-lock.yaml"
+ pull_request:
+ branches:
+ - "*"
+ paths:
+ - "js/token-kit/**"
+ - "pnpm-lock.yaml"
+ types:
+ - opened
+ - synchronize
+ - reopened
+ - ready_for_review
+
+name: token-kit
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ token-kit-tests:
+ name: token-kit-tests
+ if: github.event.pull_request.draft == false
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+
+ steps:
+ - name: Checkout sources
+ uses: actions/checkout@v6
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: "22"
+
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v4
+ with:
+ run_install: false
+
+ - name: Install just
+ uses: extractions/setup-just@v2
+
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile
+
+ - name: Build token-kit
+ run: cd js/token-kit && pnpm build
+
+ - name: Run token-kit unit tests
+ run: just js test-token-kit
+
+ - name: Lint token-kit
+ run: just js lint-token-kit
diff --git a/js/compressed-token/src/index.ts b/js/compressed-token/src/index.ts
index 02770b2548..332499fd1f 100644
--- a/js/compressed-token/src/index.ts
+++ b/js/compressed-token/src/index.ts
@@ -76,6 +76,8 @@ export {
getAssociatedTokenAddressInterface,
getOrCreateAtaInterface,
transferInterface,
+ decompressInterface,
+ decompressMint,
createTransferInterfaceInstructions,
sliceLast,
wrap,
@@ -87,6 +89,8 @@ export {
updateMetadataField,
updateMetadataAuthority,
removeMetadataKey,
+ createAssociatedCTokenAccount,
+ createAssociatedCTokenAccountIdempotent,
// Action types
InterfaceOptions,
// Helpers
@@ -117,6 +121,10 @@ export {
encodeTokenMetadata,
extractTokenMetadata,
ExtensionType,
+ // Derivation
+ getAssociatedCTokenAddress,
+ getAssociatedCTokenAddressAndBump,
+ findMintAddress,
// Metadata formatting (for use with any uploader)
toOffChainMetadataJson,
OffChainTokenMetadata,
diff --git a/js/justfile b/js/justfile
index 892573cd7c..e5aeaf06fe 100644
--- a/js/justfile
+++ b/js/justfile
@@ -7,6 +7,9 @@ build:
cd stateless.js && pnpm build
cd compressed-token && pnpm build
+build-token-kit:
+ cd token-kit && pnpm build
+
test: test-stateless test-compressed-token
test-stateless:
@@ -18,10 +21,22 @@ test-compressed-token:
test-compressed-token-unit-v2:
cd compressed-token && pnpm test:unit:all:v2
+test-token-kit:
+ cd token-kit && pnpm test
+
+start-validator:
+ ./../cli/test_bin/run test-validator
+
+test-token-kit-e2e: start-validator
+ cd token-kit && LIGHT_PROTOCOL_VERSION=V2 LIGHT_PROTOCOL_BETA=true pnpm test:e2e
+
lint:
cd stateless.js && pnpm lint
cd compressed-token && pnpm lint
+lint-token-kit:
+ cd token-kit && pnpm lint
+
format:
cd stateless.js && pnpm format
cd compressed-token && pnpm format
diff --git a/js/token-kit/CHANGELOG.md b/js/token-kit/CHANGELOG.md
new file mode 100644
index 0000000000..3a8a0fe438
--- /dev/null
+++ b/js/token-kit/CHANGELOG.md
@@ -0,0 +1,22 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [0.1.0] - Unreleased
+
+Initial release. API may change before 1.0.
+
+### Added
+
+- Instruction builders for all CToken operations (transfer, mint-to, burn, approve, revoke, freeze, thaw, close)
+- Transfer2 instruction builder for compressed account transfers with validity proofs
+- MintAction instruction builder for compressed mint management
+- Borsh-compatible codecs for all instruction data types
+- `PhotonIndexer` client for fetching compressed accounts and validity proofs
+- Account loading and selection utilities (`loadTokenAccountsForTransfer`, `selectAccountsForAmount`)
+- `buildCompressedTransfer` high-level action builder
+- PDA derivation utilities for ATAs, mints, and pools
+- Compressible extension codecs for rent-free account creation
diff --git a/js/token-kit/README.md b/js/token-kit/README.md
new file mode 100644
index 0000000000..9d0f1b11fb
--- /dev/null
+++ b/js/token-kit/README.md
@@ -0,0 +1,265 @@
+
+
+
+
+@lightprotocol/token-kit
+
+
+ TypeScript SDK for Light Protocol compressed tokens, built on Solana Kit (web3.js v2).
+
+
+### Installation
+
+```bash
+pnpm add @lightprotocol/token-kit @solana/kit
+```
+
+Requires `@solana/kit ^2.1.0` as a peer dependency.
+
+### Usage
+
+```typescript
+import {
+ createLightIndexer,
+ buildCompressedTransfer,
+} from '@lightprotocol/token-kit';
+
+// Connect to Photon indexer
+const indexer = createLightIndexer('https://photon.helius.dev');
+
+// Build a compressed token transfer
+const result = await buildCompressedTransfer(indexer, {
+ owner: ownerAddress,
+ mint: mintAddress,
+ amount: 1000n,
+ recipientOwner: recipientAddress,
+ feePayer: payerAddress,
+});
+
+// result.instruction — Transfer2 instruction ready for a transaction
+```
+
+### Compress and decompress
+
+```typescript
+import { buildCompress, buildDecompress } from '@lightprotocol/token-kit';
+
+// Compress: SPL token account → compressed token accounts
+const compressIx = await buildCompress({
+ rpc,
+ source: splAta,
+ owner: ownerAddress,
+ mint: mintAddress,
+ amount: 1000n,
+ decimals: 9,
+ tokenProgram: SPL_TOKEN_PROGRAM_ID,
+ outputQueue: queueAddress,
+});
+
+// Decompress: compressed → SPL token account
+const { instruction: decompressIx } = await buildDecompress({
+ rpc,
+ indexer,
+ owner: ownerAddress,
+ mint: mintAddress,
+ amount: 1000n,
+ destination: splAta,
+ decimals: 9,
+ tokenProgram: SPL_TOKEN_PROGRAM_ID,
+});
+```
+
+### Wrap and unwrap (SPL ↔ Light Token)
+
+```typescript
+import { buildWrap, buildUnwrap } from '@lightprotocol/token-kit';
+
+// Wrap: SPL associated token account → Light Token associated token account
+const wrapIx = await buildWrap({
+ rpc,
+ source: splAta,
+ destination: lightTokenAta,
+ owner: ownerAddress,
+ mint: mintAddress,
+ amount: 1000n,
+ decimals: 9,
+ tokenProgram: SPL_TOKEN_PROGRAM_ID,
+});
+
+// Unwrap: Light Token → SPL
+const unwrapIx = await buildUnwrap({
+ rpc,
+ source: lightTokenAta,
+ destination: splAta,
+ owner: ownerAddress,
+ mint: mintAddress,
+ amount: 1000n,
+ decimals: 9,
+ tokenProgram: SPL_TOKEN_PROGRAM_ID,
+});
+```
+
+### Mint management
+
+```typescript
+import {
+ buildCreateMint,
+ buildDecompressMint,
+ buildMintToCompressed,
+ buildUpdateMetadataField,
+} from '@lightprotocol/token-kit';
+
+// Create a compressed mint with metadata
+const createMintIx = await buildCreateMint({
+ mintSigner,
+ authority: authorityAddress,
+ feePayer: payerAddress,
+ outOutputQueue: queueAddress,
+ merkleTree: treeAddress,
+ decimals: 9,
+ mintAuthorityBytes: authorityBytes,
+ extensions: [{ type: 'TokenMetadata', data: metadata }],
+ proof: addressProof,
+});
+
+// Mint to compressed accounts
+const mintIx = buildMintToCompressed({
+ authority: authorityAddress,
+ feePayer: payerAddress,
+ mintSigner,
+ outOutputQueue: queueAddress,
+ merkleTree: treeAddress,
+ leafIndex: 0,
+ rootIndex: 0,
+ recipients: [{ recipient: recipientBytes, amount: 1000n }],
+});
+```
+
+### Query functions
+
+```typescript
+import { getAtaInterface, getMintInterface } from '@lightprotocol/token-kit';
+
+// Unified balance view (hot + cold + SPL)
+const account = await getAtaInterface(rpc, indexer, owner, mint, hotAta, splAta);
+console.log(account.totalBalance); // hot + cold + SPL
+
+// Mint info
+const mintInfo = await getMintInterface(rpc, mintAddress);
+console.log(mintInfo.decimals, mintInfo.supply);
+```
+
+### What's included
+
+**Instruction builders** (low-level)
+
+| Builder | Description |
+|---------|-------------|
+| `createTransferInstruction` | Transfer between Light Token accounts |
+| `createTransfer2Instruction` | Batch transfer with compress/decompress |
+| `createMintToInstruction` | Mint tokens to Light Token account |
+| `createBurnInstruction` | Burn tokens |
+| `createApproveInstruction` | Approve delegate |
+| `createFreezeInstruction` / `createThawInstruction` | Freeze/thaw accounts |
+| `createAssociatedTokenAccountInstruction` | Create Light Token associated token account |
+| `createTokenAccountInstruction` | Create token account with extensions |
+| `createCloseAccountInstruction` | Close zero-balance account |
+| `createWrapInstruction` / `createUnwrapInstruction` | SPL ↔ Light Token |
+| `createMintActionInstruction` | Batch mint operations |
+| `createClaimInstruction` | Claim rent from compressible accounts |
+| `createWithdrawFundingPoolInstruction` | Withdraw from funding pool |
+| `createSplInterfaceInstruction` | Register SPL interface PDA |
+| `addSplInterfacesInstruction` | Add additional pool PDAs |
+
+**High-level builders** (load + select + proof + instruction)
+
+| Builder | Description |
+|---------|-------------|
+| `buildCompressedTransfer` | Compressed-to-compressed transfer |
+| `buildTransferDelegated` | Transfer via delegate authority |
+| `buildTransferInterface` | Auto-routing transfer |
+| `buildCompress` | SPL → compressed accounts |
+| `buildDecompress` | Compressed → SPL account |
+| `buildCompressSplTokenAccount` | Compress SPL token account |
+| `buildWrap` / `buildUnwrap` | SPL ↔ Light Token |
+| `buildCreateMint` | Create compressed mint |
+| `buildDecompressMint` | Decompress mint to on-chain |
+| `buildUpdateMintAuthority` | Update mint authority |
+| `buildUpdateFreezeAuthority` | Update freeze authority |
+| `buildUpdateMetadataField` | Update metadata name/symbol/uri |
+| `buildUpdateMetadataAuthority` | Update metadata authority |
+| `buildRemoveMetadataKey` | Remove metadata key |
+| `buildMintToCompressed` | Mint to compressed accounts |
+| `buildMintToInterface` | Mint to Light Token account |
+| `buildApproveAndMintTo` | Approve delegate + mint |
+| `buildLoadAta` | Load cold balance to hot |
+
+**Query functions**
+
+| Function | Description |
+|----------|-------------|
+| `getAtaInterface` | Unified balance view (hot + cold + SPL) |
+| `getMintInterface` | On-chain mint info |
+
+**Indexer client**
+
+| Method | Description |
+|--------|-------------|
+| `getCompressedTokenAccountsByOwner` | Fetch compressed accounts |
+| `getValidityProof` | Fetch validity proof |
+| `getCompressedTokenBalancesByOwner` | Balances grouped by mint |
+| `getCompressedMintTokenHolders` | Token holders for a mint |
+| `getCompressedTokenAccountBalance` | Single account balance |
+| `getSignaturesForTokenOwner` | Transaction signatures |
+
+**Utilities**
+
+- PDA derivation: `deriveAssociatedTokenAddress`, `deriveMintAddress`, `derivePoolAddress`
+- SPL interface: `getSplInterfaceInfo`, `getSplInterfaceInfos`, `selectSplInterfaceInfo`, `deriveSplInterfaceInfo`
+- Account loading: `loadTokenAccountsForTransfer`, `selectAccountsForAmount`, `loadAllTokenAccounts`
+- Validation: `isLightTokenAccount`, `determineTransferType`, `validateAtaDerivation`
+- Codecs: Borsh-compatible encoders/decoders for all instruction data types
+
+### Feature parity with @lightprotocol/compressed-token
+
+| Feature | compressed-token | token-kit |
+|---------|-----------------|-----------|
+| Compressed transfer | `transfer` | `buildCompressedTransfer` |
+| Delegated transfer | `transferDelegated` | `buildTransferDelegated` |
+| Transfer interface | `transferInterface` | `buildTransferInterface` |
+| Compress SPL→compressed | `compress` | `buildCompress` |
+| Decompress compressed→SPL | `decompress` | `buildDecompress` |
+| Compress SPL account | `compressSplTokenAccount` | `buildCompressSplTokenAccount` |
+| Wrap SPL→Light Token | — | `buildWrap` |
+| Unwrap Light Token→SPL | — | `buildUnwrap` |
+| Create mint | `createMintInterface` | `buildCreateMint` |
+| Decompress mint | `decompressMint` | `buildDecompressMint` |
+| Update mint authority | `updateMintAuthority` | `buildUpdateMintAuthority` |
+| Update freeze authority | `updateFreezeAuthority` | `buildUpdateFreezeAuthority` |
+| Update metadata | `updateMetadataField` | `buildUpdateMetadataField` |
+| Update metadata authority | `updateMetadataAuthority` | `buildUpdateMetadataAuthority` |
+| Remove metadata key | `removeMetadataKey` | `buildRemoveMetadataKey` |
+| Mint to compressed | `mintToCompressed` | `buildMintToCompressed` |
+| Mint to interface | `mintToInterface` | `buildMintToInterface` |
+| Approve + mint | `approveAndMintTo` | `buildApproveAndMintTo` |
+| Load ATA | `loadAta` | `buildLoadAta` |
+| Create SPL interface | `createSplInterface` | `createSplInterfaceInstruction` |
+| Add SPL interfaces | `addSplInterfaces` | `addSplInterfacesInstruction` |
+| Account interface | `getAtaInterface` | `getAtaInterface` |
+| Mint interface | `getMintInterface` | `getMintInterface` |
+| Token balances by owner | `getCompressedTokenBalancesByOwner` | `getCompressedTokenBalancesByOwner` |
+| Mint token holders | `getCompressedMintTokenHolders` | `getCompressedMintTokenHolders` |
+
+### Documentation and examples
+
+- [ZK Compression docs](https://www.zkcompression.com)
+- [Compressed Token guides](https://www.zkcompression.com/compressed-tokens/guides)
+- [Source code](https://github.com/lightprotocol/light-protocol/tree/main/js/token-kit)
+
+### Getting help
+
+Check out the [Light](https://discord.gg/CYvjBgzRFP) and [Helius](https://discord.gg/Uzzf6a7zKr) Developer Discord servers.
+
+### License
+
+Apache-2.0
diff --git a/js/token-kit/package.json b/js/token-kit/package.json
new file mode 100644
index 0000000000..f6ce99222d
--- /dev/null
+++ b/js/token-kit/package.json
@@ -0,0 +1,81 @@
+{
+ "name": "@lightprotocol/token-kit",
+ "version": "0.1.0",
+ "description": "Light Protocol Token Kit for Solana Kit (web3.js v2)",
+ "type": "module",
+ "main": "./dist/index.js",
+ "module": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "import": "./dist/index.js",
+ "types": "./dist/index.d.ts"
+ },
+ "./codecs": {
+ "import": "./dist/codecs/index.js",
+ "types": "./dist/codecs/index.d.ts"
+ },
+ "./instructions": {
+ "import": "./dist/instructions/index.js",
+ "types": "./dist/instructions/index.d.ts"
+ }
+ },
+ "files": [
+ "dist"
+ ],
+ "scripts": {
+ "generate": "tsx scripts/generate-clients.ts && tsx scripts/fix-generated-extensions.ts",
+ "build": "pnpm run generate && tsc",
+ "clean": "rm -rf dist",
+ "test": "vitest run tests/unit/",
+ "test:e2e": "vitest run --config vitest.e2e.config.ts",
+ "test:watch": "vitest",
+ "lint": "eslint .",
+ "format": "prettier --write .",
+ "prepublishOnly": "pnpm run build"
+ },
+ "peerDependencies": {
+ "@solana/kit": "^2.1.0"
+ },
+ "dependencies": {
+ "@noble/hashes": "^1.4.0",
+ "@solana/addresses": "^2.1.0",
+ "@solana/codecs": "^2.1.0",
+ "@solana/instructions": "^2.1.0"
+ },
+ "devDependencies": {
+ "@codama/nodes": "^1.4.1",
+ "@codama/renderers-js": "^1.2.8",
+ "@codama/visitors": "^1.4.1",
+ "@codama/visitors-core": "^1.4.1",
+ "@eslint/js": "9.36.0",
+ "@lightprotocol/compressed-token": "workspace:*",
+ "@lightprotocol/stateless.js": "workspace:*",
+ "@solana/kit": "^2.1.0",
+ "@typescript-eslint/eslint-plugin": "^8.44.0",
+ "@typescript-eslint/parser": "^8.44.0",
+ "codama": "^1.4.1",
+ "eslint": "^9.36.0",
+ "prettier": "^3.3.3",
+ "tsx": "^4.19.2",
+ "typescript": "^5.7.3",
+ "vitest": "^2.1.8"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "keywords": [
+ "solana",
+ "light-protocol",
+ "compressed-token",
+ "zk-compression",
+ "web3",
+ "indexer"
+ ],
+ "license": "Apache-2.0",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/Lightprotocol/light-protocol.git",
+ "directory": "js/token-kit"
+ }
+}
diff --git a/js/token-kit/scripts/fix-generated-extensions.ts b/js/token-kit/scripts/fix-generated-extensions.ts
new file mode 100644
index 0000000000..e0e20d7149
--- /dev/null
+++ b/js/token-kit/scripts/fix-generated-extensions.ts
@@ -0,0 +1,64 @@
+/**
+ * Fix Codama-generated imports: add .js extensions for NodeNext module resolution.
+ * Codama's renderVisitor generates imports without .js extensions which fail
+ * with TypeScript's NodeNext moduleResolution.
+ *
+ * Handles two cases:
+ * - Directory imports (e.g., '../pdas') → '../pdas/index.js'
+ * - File imports (e.g., './ctokenApprove') → './ctokenApprove.js'
+ */
+
+import fs from 'fs';
+import path from 'path';
+import { fileURLToPath } from 'url';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+const generatedDir = path.resolve(__dirname, '../src/generated');
+
+function fixImportExtensions(dir: string): number {
+ let fixedCount = 0;
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+ const fullPath = path.join(dir, entry.name);
+ if (entry.isDirectory()) {
+ fixedCount += fixImportExtensions(fullPath);
+ } else if (entry.name.endsWith('.ts')) {
+ const content = fs.readFileSync(fullPath, 'utf8');
+ const fixed = content.replace(
+ /(from\s+['"])(\.\.?\/[^'"]+?)(? {
+ // Resolve the import relative to the current file
+ const fileDir = path.dirname(fullPath);
+ const resolved = path.resolve(fileDir, importPath);
+
+ // Check if it's a directory import (has index.ts inside)
+ if (
+ fs.existsSync(resolved) &&
+ fs.statSync(resolved).isDirectory()
+ ) {
+ return `${prefix}${importPath}/index.js${suffix}`;
+ }
+ // Otherwise it's a file import
+ return `${prefix}${importPath}.js${suffix}`;
+ },
+ );
+ if (fixed !== content) {
+ fs.writeFileSync(fullPath, fixed);
+ fixedCount++;
+ }
+ }
+ }
+ return fixedCount;
+}
+
+console.log(`Fixing import extensions in ${generatedDir}...`);
+const count = fixImportExtensions(generatedDir);
+console.log(`Fixed ${count} files.`);
+
+// Restore .gitignore (renderVisitor deletes the output directory)
+const gitignorePath = path.join(generatedDir, '.gitignore');
+fs.writeFileSync(
+ gitignorePath,
+ `# Generated by Codama — rebuild with: cd js/token-kit && pnpm run generate\n*\n!.gitignore\n!.gitkeep\n`,
+);
+console.log('Restored .gitignore in generated/.');
diff --git a/js/token-kit/scripts/generate-clients.ts b/js/token-kit/scripts/generate-clients.ts
new file mode 100644
index 0000000000..1ef77f6737
--- /dev/null
+++ b/js/token-kit/scripts/generate-clients.ts
@@ -0,0 +1,57 @@
+/**
+ * Generate TypeScript clients from the Light Token IDL using Codama.
+ */
+
+import { createFromRoot } from 'codama';
+import { renderVisitor } from '@codama/renderers-js';
+import { setInstructionAccountDefaultValuesVisitor } from '@codama/visitors';
+import { publicKeyValueNode } from 'codama';
+import path from 'path';
+import { fileURLToPath } from 'url';
+
+import {
+ lightTokenIdl,
+ LIGHT_TOKEN_PROGRAM_ID,
+ SYSTEM_PROGRAM,
+} from '../src/idl.js';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+// Output directory for generated TypeScript
+const typescriptOutputDir = path.resolve(
+ __dirname,
+ '../src/generated',
+);
+
+console.log('Creating Codama instance from Light Token IDL...');
+const codama = createFromRoot(lightTokenIdl);
+
+// Apply default account values for common accounts
+console.log('Applying default account values...');
+codama.update(
+ setInstructionAccountDefaultValuesVisitor([
+ {
+ account: 'systemProgram',
+ defaultValue: publicKeyValueNode(SYSTEM_PROGRAM),
+ },
+ {
+ account: 'selfProgram',
+ defaultValue: publicKeyValueNode(LIGHT_TOKEN_PROGRAM_ID),
+ },
+ ]),
+);
+
+// Generate TypeScript client
+console.log(`Generating TypeScript client to ${typescriptOutputDir}...`);
+codama.accept(
+ renderVisitor(typescriptOutputDir, {
+ formatCode: true,
+ dependencyMap: {
+ // Map codama codecs to @solana/codecs
+ generatedPackage: '@lightprotocol/token-kit',
+ },
+ }),
+);
+
+console.log('Generation complete!');
diff --git a/js/token-kit/src/actions.ts b/js/token-kit/src/actions.ts
new file mode 100644
index 0000000000..a005b2dbb1
--- /dev/null
+++ b/js/token-kit/src/actions.ts
@@ -0,0 +1,1726 @@
+/**
+ * High-level transaction builders that wire load → select → proof → instruction.
+ *
+ * These bridge the gap between token-client (data loading) and token-sdk (instruction building).
+ */
+
+import { type Address, getAddressCodec } from '@solana/addresses';
+import { AccountRole, type Instruction, type AccountMeta } from '@solana/instructions';
+
+import type { LightIndexer } from './indexer.js';
+import {
+ loadTokenAccountsForTransfer,
+ loadAllTokenAccounts,
+ loadMintContext,
+ getOutputTreeInfo,
+ type InputTokenAccount,
+ type LoadTokenAccountsOptions,
+ type MintContext,
+} from './load.js';
+
+import {
+ IndexerError,
+ IndexerErrorCode,
+ type ValidityProofWithContext,
+} from './client/index.js';
+import {
+ createTransfer2Instruction,
+ createWrapInstruction,
+ createUnwrapInstruction,
+ createCompressSpl,
+ createDecompressSpl,
+ createMintActionInstruction,
+ createApproveInstruction,
+ createMintToInstruction,
+ createAssociatedTokenAccountInstruction,
+ createAssociatedTokenAccountIdempotentInstruction,
+} from './instructions/index.js';
+import {
+ TOKEN_ACCOUNT_VERSION_V2,
+ LIGHT_TOKEN_CONFIG,
+ LIGHT_TOKEN_RENT_SPONSOR,
+ SPL_TOKEN_PROGRAM_ID,
+} from './constants.js';
+import {
+ type SplInterfaceInfo,
+ getSplInterfaceInfo,
+ deriveMintAddress,
+ deriveAssociatedTokenAddress,
+} from './utils/index.js';
+import { getMintDecimals, type QueryRpc } from './queries.js';
+import type {
+ MintAction,
+ MintActionInstructionData,
+ MintRecipient,
+ CompressedProof,
+ ExtensionInstructionData,
+} from './codecs/index.js';
+
+// ============================================================================
+// SHARED TYPES
+// ============================================================================
+
+/**
+ * Result of building a compressed transfer instruction with loaded account data.
+ */
+export interface BuildTransferResult {
+ /** The transfer instruction to include in the transaction */
+ instruction: Instruction;
+ /** The input token accounts used */
+ inputs: InputTokenAccount[];
+ /** The validity proof for the inputs */
+ proof: ValidityProofWithContext;
+ /** Total amount available (may exceed requested amount; change goes back to sender) */
+ totalInputAmount: bigint;
+}
+
+/**
+ * Minimal RPC interface for builder operations.
+ */
+export interface BuilderRpc {
+ getAccountInfo(
+ address: Address,
+ config?: { encoding: string },
+ ): Promise<{ value: { owner: Address; data: unknown } | null }>;
+}
+
+/**
+ * User-friendly metadata field type names.
+ */
+export type MetadataFieldType = 'name' | 'symbol' | 'uri' | 'custom';
+
+/** Maps string field type to the on-chain numeric enum value. */
+const FIELD_TYPE_MAP: Record = {
+ name: 0,
+ symbol: 1,
+ uri: 2,
+ custom: 3,
+};
+
+/**
+ * User-friendly recipient param using Address instead of raw bytes.
+ */
+export interface MintRecipientParam {
+ /** Recipient address */
+ recipient: Address;
+ /** Amount to mint */
+ amount: bigint;
+}
+
+// ============================================================================
+// INTERNAL HELPERS
+// ============================================================================
+
+/** Convert an Address to a 32-byte Uint8Array. */
+function addressToBytes(addr: Address): Uint8Array {
+ return new Uint8Array(getAddressCodec().encode(addr));
+}
+
+/** Convert MintRecipientParam[] to codec-level MintRecipient[]. */
+function toCodecRecipients(params: MintRecipientParam[]): MintRecipient[] {
+ return params.map((p) => ({
+ recipient: addressToBytes(p.recipient),
+ amount: p.amount,
+ }));
+}
+
+function bytesToHexKey(hash: Uint8Array): string {
+ return Array.from(hash, (b) => b.toString(16).padStart(2, '0')).join('');
+}
+
+// ============================================================================
+// COMPRESSED TRANSFER
+// ============================================================================
+
+/**
+ * Builds a compressed token transfer (Transfer2) instruction by loading accounts,
+ * selecting inputs, fetching a validity proof, and creating the instruction.
+ *
+ * @param params - Transfer parameters
+ * @returns The instruction, inputs, and proof
+ */
+export async function buildCompressedTransfer(params: {
+ /** Light indexer client */
+ indexer: LightIndexer;
+ /** Token account owner (sender) */
+ owner: Address;
+ /** Token mint */
+ mint: Address;
+ /** Amount to transfer */
+ amount: bigint;
+ /** Recipient owner address */
+ recipientOwner: Address;
+ /** Fee payer address (signer, writable) */
+ feePayer: Address;
+ /** Maximum top-up amount for rent (optional) */
+ maxTopUp?: number;
+ /** Maximum number of input accounts (default: 4) */
+ maxInputs?: number;
+}): Promise {
+ const options: LoadTokenAccountsOptions = {
+ mint: params.mint,
+ maxInputs: params.maxInputs,
+ };
+
+ // Load and select accounts, fetch proof
+ const loaded = await loadTokenAccountsForTransfer(
+ params.indexer,
+ params.owner,
+ params.amount,
+ options,
+ );
+ if (loaded.inputs.length === 0) {
+ throw new IndexerError(
+ IndexerErrorCode.InvalidResponse,
+ 'No inputs were selected for transfer',
+ );
+ }
+
+ const proofRootIndexByHash = new Map();
+ for (const proofInput of loaded.proof.accounts) {
+ if (!(proofInput.hash instanceof Uint8Array) || proofInput.hash.length !== 32) {
+ throw new IndexerError(
+ IndexerErrorCode.InvalidResponse,
+ `Invalid proof account hash: expected 32-byte Uint8Array, got ${proofInput.hash?.length ?? 'null'} bytes`,
+ );
+ }
+ const key = bytesToHexKey(proofInput.hash);
+ if (proofRootIndexByHash.has(key)) {
+ throw new IndexerError(
+ IndexerErrorCode.InvalidResponse,
+ `Duplicate proof entry for input hash ${key}`,
+ );
+ }
+ const rootIndex = proofInput.rootIndex.rootIndex;
+ if (!Number.isInteger(rootIndex) || rootIndex < 0 || rootIndex > 65535) {
+ throw new IndexerError(
+ IndexerErrorCode.InvalidResponse,
+ `Invalid rootIndex ${rootIndex} for input hash ${key}`,
+ );
+ }
+ proofRootIndexByHash.set(key, rootIndex);
+ }
+
+ const packedAddressMap = new Map();
+ const packedAccounts: AccountMeta[] = [];
+
+ function getOrAddPacked(addr: Address, role: AccountRole): number {
+ const existing = packedAddressMap.get(addr as string);
+ if (existing !== undefined) return existing;
+ const idx = packedAccounts.length;
+ packedAddressMap.set(addr as string, idx);
+ packedAccounts.push({ address: addr, role });
+ return idx;
+ }
+
+ // 1. Add merkle tree/queue pairs first
+ for (const input of loaded.inputs) {
+ getOrAddPacked(input.merkleContext.tree, AccountRole.WRITABLE);
+ getOrAddPacked(input.merkleContext.queue, AccountRole.WRITABLE);
+ }
+
+ // 2. Output queue (rollover-aware)
+ const outputTreeInfo = getOutputTreeInfo(
+ loaded.inputs[0].tokenAccount.account.treeInfo,
+ );
+ const outputQueueIdx = getOrAddPacked(outputTreeInfo.queue, AccountRole.WRITABLE);
+
+ // 3. Mint (readonly)
+ const mintIdx = getOrAddPacked(params.mint, AccountRole.READONLY);
+
+ // 4. Owner (readonly)
+ const ownerIdx = getOrAddPacked(params.owner, AccountRole.READONLY);
+
+ // 5. Recipient (readonly)
+ const recipientIdx = getOrAddPacked(params.recipientOwner, AccountRole.READONLY);
+
+ // Build input token data
+ const inTokenData = loaded.inputs.map((input) => {
+ const treeIdx = getOrAddPacked(input.merkleContext.tree, AccountRole.WRITABLE);
+ const queueIdx = getOrAddPacked(input.merkleContext.queue, AccountRole.WRITABLE);
+
+ const inputHashKey = bytesToHexKey(input.tokenAccount.account.hash);
+ const rootIndex = proofRootIndexByHash.get(inputHashKey);
+ if (rootIndex === undefined) {
+ throw new IndexerError(
+ IndexerErrorCode.InvalidResponse,
+ `Missing proof account for selected input hash ${inputHashKey}`,
+ );
+ }
+
+ const delegateAddress = input.tokenAccount.token.delegate;
+ const hasDelegate = delegateAddress !== null;
+ const delegateIdx = hasDelegate
+ ? getOrAddPacked(delegateAddress, AccountRole.READONLY)
+ : 0;
+
+ return {
+ owner: ownerIdx,
+ amount: input.tokenAccount.token.amount,
+ hasDelegate,
+ delegate: delegateIdx,
+ mint: mintIdx,
+ version: TOKEN_ACCOUNT_VERSION_V2,
+ merkleContext: {
+ merkleTreePubkeyIndex: treeIdx,
+ queuePubkeyIndex: queueIdx,
+ leafIndex: input.merkleContext.leafIndex,
+ proveByIndex: input.merkleContext.proveByIndex,
+ },
+ rootIndex,
+ };
+ });
+
+ // Output token data
+ const outTokenData = [
+ {
+ owner: recipientIdx,
+ amount: params.amount,
+ hasDelegate: false,
+ delegate: 0,
+ mint: mintIdx,
+ version: TOKEN_ACCOUNT_VERSION_V2,
+ },
+ ];
+
+ if (loaded.totalAmount > params.amount) {
+ outTokenData.push({
+ owner: ownerIdx,
+ amount: loaded.totalAmount - params.amount,
+ hasDelegate: false,
+ delegate: 0,
+ mint: mintIdx,
+ version: TOKEN_ACCOUNT_VERSION_V2,
+ });
+ }
+
+ const instruction = createTransfer2Instruction({
+ feePayer: params.feePayer,
+ packedAccounts,
+ data: {
+ withTransactionHash: false,
+ withLamportsChangeAccountMerkleTreeIndex: false,
+ lamportsChangeAccountMerkleTreeIndex: 0,
+ lamportsChangeAccountOwnerIndex: ownerIdx,
+ outputQueue: outputQueueIdx,
+ maxTopUp: params.maxTopUp ?? 65535,
+ cpiContext: null,
+ compressions: null,
+ proof: loaded.proof.proof,
+ inTokenData,
+ outTokenData,
+ inLamports: null,
+ outLamports: null,
+ inTlv: null,
+ outTlv: null,
+ },
+ });
+
+ return {
+ instruction,
+ inputs: loaded.inputs,
+ proof: loaded.proof,
+ totalInputAmount: loaded.totalAmount,
+ };
+}
+
+// ============================================================================
+// DELEGATED TRANSFER
+// ============================================================================
+
+/**
+ * Builds a Transfer2 instruction that sends from a delegated account.
+ *
+ * @param params - Transfer parameters with delegate authority
+ * @returns The instruction, inputs, and proof
+ */
+export async function buildTransferDelegated(params: {
+ /** Light indexer client */
+ indexer: LightIndexer;
+ /** Delegate authority (signer) */
+ delegate: Address;
+ /** Token account owner */
+ owner: Address;
+ /** Token mint */
+ mint: Address;
+ /** Amount to transfer */
+ amount: bigint;
+ /** Recipient owner address */
+ recipientOwner: Address;
+ /** Fee payer (signer, writable) */
+ feePayer: Address;
+ /** Maximum top-up */
+ maxTopUp?: number;
+ /** Maximum number of input accounts */
+ maxInputs?: number;
+}): Promise {
+ const options: LoadTokenAccountsOptions = {
+ mint: params.mint,
+ maxInputs: params.maxInputs,
+ };
+
+ const loaded = await loadTokenAccountsForTransfer(
+ params.indexer,
+ params.owner,
+ params.amount,
+ options,
+ );
+ if (loaded.inputs.length === 0) {
+ throw new IndexerError(
+ IndexerErrorCode.InvalidResponse,
+ 'No inputs were selected for delegated transfer',
+ );
+ }
+
+ const proofRootIndexByHash = new Map();
+ for (const proofInput of loaded.proof.accounts) {
+ const key = bytesToHexKey(proofInput.hash);
+ proofRootIndexByHash.set(key, proofInput.rootIndex.rootIndex);
+ }
+
+ const packedAccounts: AccountMeta[] = [];
+ const packedMap = new Map();
+
+ function getOrAdd(addr: Address, role: AccountRole): number {
+ const existing = packedMap.get(addr as string);
+ if (existing !== undefined) return existing;
+ const idx = packedAccounts.length;
+ packedMap.set(addr as string, idx);
+ packedAccounts.push({ address: addr, role });
+ return idx;
+ }
+
+ for (const input of loaded.inputs) {
+ getOrAdd(input.merkleContext.tree, AccountRole.WRITABLE);
+ getOrAdd(input.merkleContext.queue, AccountRole.WRITABLE);
+ }
+
+ const outputTreeInfo = getOutputTreeInfo(
+ loaded.inputs[0].tokenAccount.account.treeInfo,
+ );
+ const outputQueueIdx = getOrAdd(outputTreeInfo.queue, AccountRole.WRITABLE);
+ const mintIdx = getOrAdd(params.mint, AccountRole.READONLY);
+ const ownerIdx = getOrAdd(params.owner, AccountRole.READONLY);
+ const delegateIdx = getOrAdd(params.delegate, AccountRole.READONLY);
+ const recipientIdx = getOrAdd(params.recipientOwner, AccountRole.READONLY);
+
+ const inTokenData = loaded.inputs.map((input) => {
+ const treeIdx = getOrAdd(input.merkleContext.tree, AccountRole.WRITABLE);
+ const queueIdx = getOrAdd(input.merkleContext.queue, AccountRole.WRITABLE);
+ const inputHashKey = bytesToHexKey(input.tokenAccount.account.hash);
+ const rootIndex = proofRootIndexByHash.get(inputHashKey) ?? 0;
+
+ return {
+ owner: ownerIdx,
+ amount: input.tokenAccount.token.amount,
+ hasDelegate: true,
+ delegate: delegateIdx,
+ mint: mintIdx,
+ version: TOKEN_ACCOUNT_VERSION_V2,
+ merkleContext: {
+ merkleTreePubkeyIndex: treeIdx,
+ queuePubkeyIndex: queueIdx,
+ leafIndex: input.merkleContext.leafIndex,
+ proveByIndex: input.merkleContext.proveByIndex,
+ },
+ rootIndex,
+ };
+ });
+
+ const outTokenData = [
+ {
+ owner: recipientIdx,
+ amount: params.amount,
+ hasDelegate: false,
+ delegate: 0,
+ mint: mintIdx,
+ version: TOKEN_ACCOUNT_VERSION_V2,
+ },
+ ];
+
+ if (loaded.totalAmount > params.amount) {
+ outTokenData.push({
+ owner: ownerIdx,
+ amount: loaded.totalAmount - params.amount,
+ hasDelegate: false,
+ delegate: 0,
+ mint: mintIdx,
+ version: TOKEN_ACCOUNT_VERSION_V2,
+ });
+ }
+
+ const instruction = createTransfer2Instruction({
+ feePayer: params.feePayer,
+ packedAccounts,
+ data: {
+ withTransactionHash: false,
+ withLamportsChangeAccountMerkleTreeIndex: false,
+ lamportsChangeAccountMerkleTreeIndex: 0,
+ lamportsChangeAccountOwnerIndex: ownerIdx,
+ outputQueue: outputQueueIdx,
+ maxTopUp: params.maxTopUp ?? 65535,
+ cpiContext: null,
+ compressions: null,
+ proof: loaded.proof.proof,
+ inTokenData,
+ outTokenData,
+ inLamports: null,
+ outLamports: null,
+ inTlv: null,
+ outTlv: null,
+ },
+ });
+
+ return {
+ instruction,
+ inputs: loaded.inputs,
+ proof: loaded.proof,
+ totalInputAmount: loaded.totalAmount,
+ };
+}
+
+// ============================================================================
+// WRAP / UNWRAP BUILDERS
+// ============================================================================
+
+/**
+ * Builds a wrap instruction (SPL → Light Token).
+ *
+ * @param params - Wrap parameters
+ * @returns The wrap instruction
+ */
+export async function buildWrap(params: {
+ rpc: BuilderRpc;
+ source: Address;
+ destination: Address;
+ owner: Address;
+ mint: Address;
+ amount: bigint;
+ decimals?: number;
+ tokenProgram?: Address;
+ feePayer?: Address;
+ splInterfaceInfo?: SplInterfaceInfo;
+}): Promise {
+ const tokenProgram = params.tokenProgram ?? SPL_TOKEN_PROGRAM_ID;
+ const decimals =
+ params.decimals ??
+ (await getMintDecimals(params.rpc as unknown as QueryRpc, params.mint));
+
+ const splInterfaceInfo =
+ params.splInterfaceInfo ??
+ (await getSplInterfaceInfo(params.rpc, params.mint, tokenProgram));
+
+ return createWrapInstruction({
+ source: params.source,
+ destination: params.destination,
+ owner: params.owner,
+ mint: params.mint,
+ amount: params.amount,
+ splInterfaceInfo,
+ decimals,
+ feePayer: params.feePayer,
+ });
+}
+
+/**
+ * Builds an unwrap instruction (Light Token → SPL).
+ *
+ * @param params - Unwrap parameters
+ * @returns The unwrap instruction
+ */
+export async function buildUnwrap(params: {
+ rpc: BuilderRpc;
+ source: Address;
+ destination: Address;
+ owner: Address;
+ mint: Address;
+ amount: bigint;
+ decimals?: number;
+ tokenProgram?: Address;
+ feePayer?: Address;
+ splInterfaceInfo?: SplInterfaceInfo;
+}): Promise {
+ const tokenProgram = params.tokenProgram ?? SPL_TOKEN_PROGRAM_ID;
+ const decimals =
+ params.decimals ??
+ (await getMintDecimals(params.rpc as unknown as QueryRpc, params.mint));
+
+ const splInterfaceInfo =
+ params.splInterfaceInfo ??
+ (await getSplInterfaceInfo(params.rpc, params.mint, tokenProgram));
+
+ return createUnwrapInstruction({
+ source: params.source,
+ destination: params.destination,
+ owner: params.owner,
+ mint: params.mint,
+ amount: params.amount,
+ splInterfaceInfo,
+ decimals,
+ feePayer: params.feePayer,
+ });
+}
+
+// ============================================================================
+// COMPRESS / DECOMPRESS BUILDERS
+// ============================================================================
+
+/**
+ * Builds a compress instruction (SPL → compressed token accounts).
+ *
+ * @param params - Compress parameters
+ * @returns The Transfer2 instruction
+ */
+export async function buildCompress(params: {
+ rpc: BuilderRpc;
+ source: Address;
+ owner: Address;
+ mint: Address;
+ amount: bigint;
+ recipientOwner?: Address;
+ decimals?: number;
+ tokenProgram?: Address;
+ outputQueue: Address;
+ feePayer?: Address;
+ splInterfaceInfo?: SplInterfaceInfo;
+ maxTopUp?: number;
+}): Promise {
+ const payer = params.feePayer ?? params.owner;
+ const recipientOwner = params.recipientOwner ?? params.owner;
+ const tokenProgram = params.tokenProgram ?? SPL_TOKEN_PROGRAM_ID;
+ const decimals =
+ params.decimals ??
+ (await getMintDecimals(params.rpc as unknown as QueryRpc, params.mint));
+
+ const splInterfaceInfo =
+ params.splInterfaceInfo ??
+ (await getSplInterfaceInfo(params.rpc, params.mint, tokenProgram));
+
+ const packedAccounts: AccountMeta[] = [];
+ const packedMap = new Map();
+
+ function getOrAdd(addr: Address, role: AccountRole): number {
+ const existing = packedMap.get(addr as string);
+ if (existing !== undefined) return existing;
+ const idx = packedAccounts.length;
+ packedMap.set(addr as string, idx);
+ packedAccounts.push({ address: addr, role });
+ return idx;
+ }
+
+ const outputQueueIdx = getOrAdd(params.outputQueue, AccountRole.WRITABLE);
+ const mintIdx = getOrAdd(params.mint, AccountRole.READONLY);
+ const ownerIdx = getOrAdd(params.owner, AccountRole.READONLY_SIGNER);
+ const sourceIdx = getOrAdd(params.source, AccountRole.WRITABLE);
+ const poolIdx = getOrAdd(splInterfaceInfo.poolAddress, AccountRole.WRITABLE);
+ getOrAdd(tokenProgram, AccountRole.READONLY);
+ const recipientIdx =
+ recipientOwner === params.owner
+ ? ownerIdx
+ : getOrAdd(recipientOwner, AccountRole.READONLY);
+
+ const compressions = [
+ createCompressSpl({
+ amount: params.amount,
+ mintIndex: mintIdx,
+ sourceIndex: sourceIdx,
+ authorityIndex: ownerIdx,
+ poolAccountIndex: poolIdx,
+ poolIndex: splInterfaceInfo.poolIndex,
+ bump: splInterfaceInfo.bump,
+ decimals,
+ }),
+ ];
+
+ const outTokenData = [
+ {
+ owner: recipientIdx,
+ amount: params.amount,
+ hasDelegate: false,
+ delegate: 0,
+ mint: mintIdx,
+ version: TOKEN_ACCOUNT_VERSION_V2,
+ },
+ ];
+
+ return createTransfer2Instruction({
+ feePayer: payer,
+ packedAccounts,
+ data: {
+ withTransactionHash: false,
+ withLamportsChangeAccountMerkleTreeIndex: false,
+ lamportsChangeAccountMerkleTreeIndex: 0,
+ lamportsChangeAccountOwnerIndex: ownerIdx,
+ outputQueue: outputQueueIdx,
+ maxTopUp: params.maxTopUp ?? 65535,
+ cpiContext: null,
+ compressions,
+ proof: null,
+ inTokenData: [],
+ outTokenData,
+ inLamports: null,
+ outLamports: null,
+ inTlv: null,
+ outTlv: null,
+ },
+ });
+}
+
+/**
+ * Builds a decompress instruction (compressed → SPL token account).
+ *
+ * @param params - Decompress parameters
+ * @returns The Transfer2 instruction and loaded account info
+ */
+export async function buildDecompress(params: {
+ rpc: BuilderRpc;
+ indexer: LightIndexer;
+ owner: Address;
+ mint: Address;
+ amount: bigint;
+ destination: Address;
+ decimals?: number;
+ tokenProgram?: Address;
+ feePayer?: Address;
+ splInterfaceInfo?: SplInterfaceInfo;
+ maxInputs?: number;
+ maxTopUp?: number;
+}): Promise {
+ const payer = params.feePayer ?? params.owner;
+ const tokenProgram = params.tokenProgram ?? SPL_TOKEN_PROGRAM_ID;
+ const decimals =
+ params.decimals ??
+ (await getMintDecimals(params.rpc as unknown as QueryRpc, params.mint));
+
+ const splInterfaceInfo =
+ params.splInterfaceInfo ??
+ (await getSplInterfaceInfo(params.rpc, params.mint, tokenProgram));
+
+ // Load compressed token accounts
+ const loaded = await loadTokenAccountsForTransfer(
+ params.indexer,
+ params.owner,
+ params.amount,
+ { mint: params.mint, maxInputs: params.maxInputs },
+ );
+ if (loaded.inputs.length === 0) {
+ throw new IndexerError(
+ IndexerErrorCode.InvalidResponse,
+ 'No compressed accounts found for decompress',
+ );
+ }
+
+ const proofRootIndexByHash = new Map();
+ for (const proofInput of loaded.proof.accounts) {
+ const key = bytesToHexKey(proofInput.hash);
+ proofRootIndexByHash.set(key, proofInput.rootIndex.rootIndex);
+ }
+
+ const packedAccounts: AccountMeta[] = [];
+ const packedMap = new Map();
+
+ function getOrAdd(addr: Address, role: AccountRole): number {
+ const existing = packedMap.get(addr as string);
+ if (existing !== undefined) return existing;
+ const idx = packedAccounts.length;
+ packedMap.set(addr as string, idx);
+ packedAccounts.push({ address: addr, role });
+ return idx;
+ }
+
+ // Merkle tree/queue pairs first
+ for (const input of loaded.inputs) {
+ getOrAdd(input.merkleContext.tree, AccountRole.WRITABLE);
+ getOrAdd(input.merkleContext.queue, AccountRole.WRITABLE);
+ }
+
+ const outputTreeInfo = getOutputTreeInfo(
+ loaded.inputs[0].tokenAccount.account.treeInfo,
+ );
+ const outputQueueIdx = getOrAdd(outputTreeInfo.queue, AccountRole.WRITABLE);
+ const mintIdx = getOrAdd(params.mint, AccountRole.READONLY);
+ const ownerIdx = getOrAdd(params.owner, AccountRole.READONLY);
+ const destIdx = getOrAdd(params.destination, AccountRole.WRITABLE);
+ const poolIdx = getOrAdd(splInterfaceInfo.poolAddress, AccountRole.WRITABLE);
+ getOrAdd(tokenProgram, AccountRole.READONLY);
+
+ const inTokenData = loaded.inputs.map((input) => {
+ const treeIdx = getOrAdd(input.merkleContext.tree, AccountRole.WRITABLE);
+ const queueIdx = getOrAdd(input.merkleContext.queue, AccountRole.WRITABLE);
+ const inputHashKey = bytesToHexKey(input.tokenAccount.account.hash);
+ const rootIndex = proofRootIndexByHash.get(inputHashKey) ?? 0;
+ const delegateAddress = input.tokenAccount.token.delegate;
+ const hasDelegate = delegateAddress !== null;
+ const delegateIdx = hasDelegate
+ ? getOrAdd(delegateAddress, AccountRole.READONLY)
+ : 0;
+
+ return {
+ owner: ownerIdx,
+ amount: input.tokenAccount.token.amount,
+ hasDelegate,
+ delegate: delegateIdx,
+ mint: mintIdx,
+ version: TOKEN_ACCOUNT_VERSION_V2,
+ merkleContext: {
+ merkleTreePubkeyIndex: treeIdx,
+ queuePubkeyIndex: queueIdx,
+ leafIndex: input.merkleContext.leafIndex,
+ proveByIndex: input.merkleContext.proveByIndex,
+ },
+ rootIndex,
+ };
+ });
+
+ const compressions = [
+ createDecompressSpl({
+ amount: params.amount,
+ mintIndex: mintIdx,
+ recipientIndex: destIdx,
+ poolAccountIndex: poolIdx,
+ poolIndex: splInterfaceInfo.poolIndex,
+ bump: splInterfaceInfo.bump,
+ decimals,
+ }),
+ ];
+
+ const outTokenData =
+ loaded.totalAmount > params.amount
+ ? [
+ {
+ owner: ownerIdx,
+ amount: loaded.totalAmount - params.amount,
+ hasDelegate: false,
+ delegate: 0,
+ mint: mintIdx,
+ version: TOKEN_ACCOUNT_VERSION_V2,
+ },
+ ]
+ : [];
+
+ const instruction = createTransfer2Instruction({
+ feePayer: payer,
+ packedAccounts,
+ data: {
+ withTransactionHash: false,
+ withLamportsChangeAccountMerkleTreeIndex: false,
+ lamportsChangeAccountMerkleTreeIndex: 0,
+ lamportsChangeAccountOwnerIndex: ownerIdx,
+ outputQueue: outputQueueIdx,
+ maxTopUp: params.maxTopUp ?? 65535,
+ cpiContext: null,
+ compressions,
+ proof: loaded.proof.proof,
+ inTokenData,
+ outTokenData,
+ inLamports: null,
+ outLamports: null,
+ inTlv: null,
+ outTlv: null,
+ },
+ });
+
+ return {
+ instruction,
+ inputs: loaded.inputs,
+ proof: loaded.proof,
+ totalInputAmount: loaded.totalAmount,
+ };
+}
+
+// ============================================================================
+// MINT MANAGEMENT BUILDERS (Auto-resolving)
+// ============================================================================
+
+/**
+ * Internal: Common params for mint actions that operate on existing mints.
+ */
+interface ExistingMintActionParams {
+ authority: Address;
+ feePayer: Address;
+ mintSigner: Address;
+ outOutputQueue: Address;
+ merkleTree: Address;
+ leafIndex: number;
+ rootIndex: number;
+ proveByIndex?: boolean;
+ proof?: CompressedProof | null;
+ maxTopUp?: number;
+}
+
+/**
+ * Internal: Build MintActionInstructionData for existing mint operations.
+ */
+function buildExistingMintData(
+ params: ExistingMintActionParams,
+ actions: MintAction[],
+): MintActionInstructionData {
+ return {
+ leafIndex: params.leafIndex,
+ proveByIndex: params.proveByIndex ?? false,
+ rootIndex: params.rootIndex,
+ maxTopUp: params.maxTopUp ?? 65535,
+ createMint: null,
+ actions,
+ proof: params.proof ?? null,
+ cpiContext: null,
+ mint: null,
+ };
+}
+
+/**
+ * Internal: Resolve mint context from either provided context or auto-fetch.
+ */
+async function resolveMintContext(
+ indexer: LightIndexer | undefined,
+ mintSigner: Address | undefined,
+ mintContext?: MintContext,
+): Promise {
+ if (mintContext) return mintContext;
+ if (!indexer || !mintSigner) {
+ throw new Error(
+ 'Either mintContext or both indexer and mintSigner must be provided',
+ );
+ }
+ return loadMintContext(indexer, mintSigner);
+}
+
+/**
+ * Internal: Convert MintContext to ExistingMintActionParams.
+ */
+function mintContextToParams(
+ ctx: MintContext,
+ authority: Address,
+ feePayer: Address,
+ maxTopUp?: number,
+): ExistingMintActionParams {
+ return {
+ authority,
+ feePayer,
+ mintSigner: ctx.mintSigner,
+ outOutputQueue: ctx.outOutputQueue,
+ merkleTree: ctx.merkleTree,
+ leafIndex: ctx.leafIndex,
+ rootIndex: ctx.rootIndex,
+ proveByIndex: ctx.proveByIndex,
+ proof: ctx.proof,
+ maxTopUp,
+ };
+}
+
+/**
+ * Builds a CreateMint instruction via MintAction.
+ *
+ * @param params - Create mint parameters
+ * @returns The MintAction instruction
+ */
+export async function buildCreateMint(params: {
+ mintSigner: Address;
+ authority: Address;
+ feePayer: Address;
+ outOutputQueue: Address;
+ merkleTree: Address;
+ decimals: number;
+ supply?: bigint;
+ mintAuthority: Address;
+ freezeAuthority?: Address | null;
+ extensions?: ExtensionInstructionData[] | null;
+ addressTree?: Address;
+ rootIndex?: number;
+ proof?: CompressedProof | null;
+ maxTopUp?: number;
+ actions?: MintAction[];
+}): Promise {
+ const { address: mintAddress, bump } = await deriveMintAddress(params.mintSigner);
+ const mintSignerBytes = addressToBytes(params.mintSigner);
+ const mintAddressBytes = addressToBytes(mintAddress);
+ const mintAuthorityBytes = addressToBytes(params.mintAuthority);
+ const freezeAuthorityBytes =
+ params.freezeAuthority != null
+ ? addressToBytes(params.freezeAuthority)
+ : null;
+
+ const data: MintActionInstructionData = {
+ leafIndex: 0,
+ proveByIndex: false,
+ rootIndex: params.rootIndex ?? 0,
+ maxTopUp: params.maxTopUp ?? 65535,
+ createMint: {
+ readOnlyAddressTrees: new Uint8Array(4),
+ readOnlyAddressTreeRootIndices: [0, 0, 0, 0],
+ },
+ actions: params.actions ?? [],
+ proof: params.proof ?? null,
+ cpiContext: null,
+ mint: {
+ supply: params.supply ?? 0n,
+ decimals: params.decimals,
+ metadata: {
+ version: 0,
+ mintDecompressed: false,
+ mint: mintAddressBytes,
+ mintSigner: mintSignerBytes,
+ bump,
+ },
+ mintAuthority: mintAuthorityBytes,
+ freezeAuthority: freezeAuthorityBytes,
+ extensions: params.extensions ?? null,
+ },
+ };
+
+ return createMintActionInstruction({
+ mintSigner: params.mintSigner,
+ authority: params.authority,
+ feePayer: params.feePayer,
+ outOutputQueue: params.outOutputQueue,
+ merkleTree: params.merkleTree,
+ data,
+ });
+}
+
+/**
+ * Builds an UpdateMintAuthority instruction via MintAction.
+ * Auto-resolves merkle context when indexer + mint are provided.
+ *
+ * @param params - Parameters including the new authority
+ * @returns The MintAction instruction
+ */
+export async function buildUpdateMintAuthority(params: {
+ indexer: LightIndexer;
+ mint: Address;
+ authority: Address;
+ feePayer: Address;
+ newAuthority: Address | null;
+ mintContext?: MintContext;
+ maxTopUp?: number;
+}): Promise {
+ const ctx = await resolveMintContext(params.indexer, params.mint, params.mintContext);
+ const action: MintAction = {
+ type: 'UpdateMintAuthority',
+ newAuthority: params.newAuthority ? addressToBytes(params.newAuthority) : null,
+ };
+ const resolved = mintContextToParams(ctx, params.authority, params.feePayer, params.maxTopUp);
+ return createMintActionInstruction({
+ mintSigner: resolved.mintSigner,
+ authority: resolved.authority,
+ feePayer: resolved.feePayer,
+ outOutputQueue: resolved.outOutputQueue,
+ merkleTree: resolved.merkleTree,
+ data: buildExistingMintData(resolved, [action]),
+ });
+}
+
+/**
+ * Builds an UpdateFreezeAuthority instruction via MintAction.
+ * Auto-resolves merkle context when indexer + mint are provided.
+ *
+ * @param params - Parameters including the new freeze authority
+ * @returns The MintAction instruction
+ */
+export async function buildUpdateFreezeAuthority(params: {
+ indexer: LightIndexer;
+ mint: Address;
+ authority: Address;
+ feePayer: Address;
+ newAuthority: Address | null;
+ mintContext?: MintContext;
+ maxTopUp?: number;
+}): Promise {
+ const ctx = await resolveMintContext(params.indexer, params.mint, params.mintContext);
+ const action: MintAction = {
+ type: 'UpdateFreezeAuthority',
+ newAuthority: params.newAuthority ? addressToBytes(params.newAuthority) : null,
+ };
+ const resolved = mintContextToParams(ctx, params.authority, params.feePayer, params.maxTopUp);
+ return createMintActionInstruction({
+ mintSigner: resolved.mintSigner,
+ authority: resolved.authority,
+ feePayer: resolved.feePayer,
+ outOutputQueue: resolved.outOutputQueue,
+ merkleTree: resolved.merkleTree,
+ data: buildExistingMintData(resolved, [action]),
+ });
+}
+
+/**
+ * Builds an UpdateMetadataField instruction via MintAction.
+ * Auto-resolves merkle context and extensionIndex.
+ *
+ * @param params - Parameters including field type and value
+ * @returns The MintAction instruction
+ */
+export async function buildUpdateMetadataField(params: {
+ indexer: LightIndexer;
+ mint: Address;
+ authority: Address;
+ feePayer: Address;
+ fieldType: MetadataFieldType;
+ value: string;
+ customKey?: string;
+ extensionIndex?: number;
+ mintContext?: MintContext;
+ maxTopUp?: number;
+}): Promise {
+ const ctx = await resolveMintContext(params.indexer, params.mint, params.mintContext);
+ const extensionIndex =
+ params.extensionIndex ?? Math.max(0, ctx.metadataExtensionIndex);
+ const fieldTypeNum = FIELD_TYPE_MAP[params.fieldType];
+ const encoder = new TextEncoder();
+
+ const action: MintAction = {
+ type: 'UpdateMetadataField',
+ extensionIndex,
+ fieldType: fieldTypeNum,
+ key:
+ params.fieldType === 'custom' && params.customKey
+ ? encoder.encode(params.customKey)
+ : new Uint8Array(0),
+ value: encoder.encode(params.value),
+ };
+
+ const resolved = mintContextToParams(ctx, params.authority, params.feePayer, params.maxTopUp);
+ return createMintActionInstruction({
+ mintSigner: resolved.mintSigner,
+ authority: resolved.authority,
+ feePayer: resolved.feePayer,
+ outOutputQueue: resolved.outOutputQueue,
+ merkleTree: resolved.merkleTree,
+ data: buildExistingMintData(resolved, [action]),
+ });
+}
+
+/**
+ * Builds an UpdateMetadataAuthority instruction via MintAction.
+ * Auto-resolves merkle context and extensionIndex.
+ *
+ * @param params - Parameters including the new metadata authority
+ * @returns The MintAction instruction
+ */
+export async function buildUpdateMetadataAuthority(params: {
+ indexer: LightIndexer;
+ mint: Address;
+ authority: Address;
+ feePayer: Address;
+ newAuthority: Address;
+ extensionIndex?: number;
+ mintContext?: MintContext;
+ maxTopUp?: number;
+}): Promise {
+ const ctx = await resolveMintContext(params.indexer, params.mint, params.mintContext);
+ const extensionIndex =
+ params.extensionIndex ?? Math.max(0, ctx.metadataExtensionIndex);
+
+ const action: MintAction = {
+ type: 'UpdateMetadataAuthority',
+ extensionIndex,
+ newAuthority: addressToBytes(params.newAuthority),
+ };
+
+ const resolved = mintContextToParams(ctx, params.authority, params.feePayer, params.maxTopUp);
+ return createMintActionInstruction({
+ mintSigner: resolved.mintSigner,
+ authority: resolved.authority,
+ feePayer: resolved.feePayer,
+ outOutputQueue: resolved.outOutputQueue,
+ merkleTree: resolved.merkleTree,
+ data: buildExistingMintData(resolved, [action]),
+ });
+}
+
+/**
+ * Builds a RemoveMetadataKey instruction via MintAction.
+ * Auto-resolves merkle context and extensionIndex.
+ *
+ * @param params - Parameters including the key to remove
+ * @returns The MintAction instruction
+ */
+export async function buildRemoveMetadataKey(params: {
+ indexer: LightIndexer;
+ mint: Address;
+ authority: Address;
+ feePayer: Address;
+ key: string;
+ idempotent?: boolean;
+ extensionIndex?: number;
+ mintContext?: MintContext;
+ maxTopUp?: number;
+}): Promise {
+ const ctx = await resolveMintContext(params.indexer, params.mint, params.mintContext);
+ const extensionIndex =
+ params.extensionIndex ?? Math.max(0, ctx.metadataExtensionIndex);
+
+ const action: MintAction = {
+ type: 'RemoveMetadataKey',
+ extensionIndex,
+ key: new TextEncoder().encode(params.key),
+ idempotent: params.idempotent ? 1 : 0,
+ };
+
+ const resolved = mintContextToParams(ctx, params.authority, params.feePayer, params.maxTopUp);
+ return createMintActionInstruction({
+ mintSigner: resolved.mintSigner,
+ authority: resolved.authority,
+ feePayer: resolved.feePayer,
+ outOutputQueue: resolved.outOutputQueue,
+ merkleTree: resolved.merkleTree,
+ data: buildExistingMintData(resolved, [action]),
+ });
+}
+
+// ============================================================================
+// MINT TO BUILDERS
+// ============================================================================
+
+/**
+ * Builds a MintToCompressed instruction via MintAction.
+ * Auto-resolves merkle context. Uses Address-based recipients.
+ *
+ * @param params - Parameters including recipients
+ * @returns The MintAction instruction
+ */
+export async function buildMintToCompressed(params: {
+ indexer: LightIndexer;
+ mint: Address;
+ authority: Address;
+ feePayer: Address;
+ recipients: MintRecipientParam[];
+ tokenAccountVersion?: number;
+ mintContext?: MintContext;
+ maxTopUp?: number;
+}): Promise {
+ const ctx = await resolveMintContext(params.indexer, params.mint, params.mintContext);
+ const action: MintAction = {
+ type: 'MintToCompressed',
+ tokenAccountVersion: params.tokenAccountVersion ?? TOKEN_ACCOUNT_VERSION_V2,
+ recipients: toCodecRecipients(params.recipients),
+ };
+
+ const resolved = mintContextToParams(ctx, params.authority, params.feePayer, params.maxTopUp);
+ return createMintActionInstruction({
+ mintSigner: resolved.mintSigner,
+ authority: resolved.authority,
+ feePayer: resolved.feePayer,
+ outOutputQueue: resolved.outOutputQueue,
+ merkleTree: resolved.merkleTree,
+ data: buildExistingMintData(resolved, [action]),
+ });
+}
+
+/**
+ * Builds a MintTo instruction via MintAction (to an on-chain token account).
+ * Auto-resolves merkle context. The user provides tokenAccount Address.
+ *
+ * @param params - Parameters including destination token account
+ * @returns The MintAction instruction
+ */
+export async function buildMintToInterface(params: {
+ indexer: LightIndexer;
+ mint: Address;
+ authority: Address;
+ feePayer: Address;
+ tokenAccount: Address;
+ amount: bigint;
+ mintContext?: MintContext;
+ maxTopUp?: number;
+}): Promise {
+ const ctx = await resolveMintContext(params.indexer, params.mint, params.mintContext);
+ const packedAccounts: AccountMeta[] = [
+ { address: params.tokenAccount, role: AccountRole.WRITABLE },
+ ];
+
+ const action: MintAction = {
+ type: 'MintTo',
+ accountIndex: 0,
+ amount: params.amount,
+ };
+
+ const resolved = mintContextToParams(ctx, params.authority, params.feePayer, params.maxTopUp);
+ return createMintActionInstruction({
+ mintSigner: resolved.mintSigner,
+ authority: resolved.authority,
+ feePayer: resolved.feePayer,
+ outOutputQueue: resolved.outOutputQueue,
+ merkleTree: resolved.merkleTree,
+ data: buildExistingMintData(resolved, [action]),
+ packedAccounts,
+ });
+}
+
+/**
+ * Builds a DecompressMint instruction via MintAction.
+ * Auto-resolves merkle context.
+ *
+ * @param params - Parameters for decompress mint
+ * @returns The MintAction instruction
+ */
+export async function buildDecompressMint(params: {
+ indexer: LightIndexer;
+ mint: Address;
+ authority: Address;
+ feePayer: Address;
+ rentPayment?: number;
+ writeTopUp?: number;
+ compressibleConfig?: Address;
+ cmint?: Address;
+ rentSponsor?: Address;
+ mintContext?: MintContext;
+ maxTopUp?: number;
+}): Promise {
+ const ctx = await resolveMintContext(params.indexer, params.mint, params.mintContext);
+ const action: MintAction = {
+ type: 'DecompressMint',
+ rentPayment: params.rentPayment ?? 2,
+ writeTopUp: params.writeTopUp ?? 0,
+ };
+
+ const resolved = mintContextToParams(ctx, params.authority, params.feePayer, params.maxTopUp);
+ return createMintActionInstruction({
+ mintSigner: resolved.mintSigner,
+ authority: resolved.authority,
+ feePayer: resolved.feePayer,
+ outOutputQueue: resolved.outOutputQueue,
+ merkleTree: resolved.merkleTree,
+ data: buildExistingMintData(resolved, [action]),
+ compressibleConfig: params.compressibleConfig ?? LIGHT_TOKEN_CONFIG,
+ cmint: params.cmint,
+ rentSponsor: params.rentSponsor ?? LIGHT_TOKEN_RENT_SPONSOR,
+ });
+}
+
+// ============================================================================
+// APPROVE AND MINT TO
+// ============================================================================
+
+/**
+ * Builds approve + mint-to instructions for a single transaction.
+ *
+ * @param params - Approve and mint parameters
+ * @returns Array of two instructions [approve, mintTo]
+ */
+export function buildApproveAndMintTo(params: {
+ tokenAccount: Address;
+ mint: Address;
+ delegate: Address;
+ owner: Address;
+ mintAuthority: Address;
+ approveAmount: bigint;
+ mintAmount: bigint;
+ feePayer?: Address;
+ maxTopUp?: number;
+}): Instruction[] {
+ const approveIx = createApproveInstruction({
+ tokenAccount: params.tokenAccount,
+ delegate: params.delegate,
+ owner: params.owner,
+ amount: params.approveAmount,
+ maxTopUp: params.maxTopUp,
+ });
+
+ const mintToIx = createMintToInstruction({
+ mint: params.mint,
+ tokenAccount: params.tokenAccount,
+ mintAuthority: params.mintAuthority,
+ amount: params.mintAmount,
+ maxTopUp: params.maxTopUp,
+ feePayer: params.feePayer,
+ });
+
+ return [approveIx, mintToIx];
+}
+
+// ============================================================================
+// COMPRESS SPL TOKEN ACCOUNT
+// ============================================================================
+
+/**
+ * Builds a Transfer2 instruction to compress an SPL token account.
+ *
+ * @param params - Compress SPL token account parameters
+ * @returns The Transfer2 instruction
+ */
+export async function buildCompressSplTokenAccount(params: {
+ rpc: BuilderRpc;
+ source: Address;
+ owner: Address;
+ mint: Address;
+ amount: bigint;
+ decimals?: number;
+ tokenProgram?: Address;
+ outputQueue: Address;
+ feePayer?: Address;
+ splInterfaceInfo?: SplInterfaceInfo;
+ maxTopUp?: number;
+}): Promise {
+ return buildCompress({
+ rpc: params.rpc,
+ source: params.source,
+ owner: params.owner,
+ mint: params.mint,
+ amount: params.amount,
+ decimals: params.decimals,
+ tokenProgram: params.tokenProgram,
+ outputQueue: params.outputQueue,
+ feePayer: params.feePayer,
+ splInterfaceInfo: params.splInterfaceInfo,
+ maxTopUp: params.maxTopUp,
+ });
+}
+
+// ============================================================================
+// TRANSFER INTERFACE
+// ============================================================================
+
+/**
+ * Builds a transfer via the unified interface.
+ *
+ * @param params - Transfer interface parameters
+ * @returns Array of instructions to execute
+ */
+export async function buildTransferInterface(params: {
+ indexer: LightIndexer;
+ owner: Address;
+ mint: Address;
+ amount: bigint;
+ recipientOwner: Address;
+ feePayer: Address;
+ maxTopUp?: number;
+ maxInputs?: number;
+}): Promise<{ instructions: Instruction[]; transferResult: BuildTransferResult }> {
+ const result = await buildCompressedTransfer({
+ indexer: params.indexer,
+ owner: params.owner,
+ mint: params.mint,
+ amount: params.amount,
+ recipientOwner: params.recipientOwner,
+ feePayer: params.feePayer,
+ maxTopUp: params.maxTopUp,
+ maxInputs: params.maxInputs,
+ });
+
+ return {
+ instructions: [result.instruction],
+ transferResult: result,
+ };
+}
+
+// ============================================================================
+// LOAD ATA
+// ============================================================================
+
+/**
+ * Builds instructions to load a Light Token ATA from compressed (cold) balances.
+ *
+ * @param params - Load ATA parameters
+ * @returns Array of decompress instructions (may be empty)
+ */
+export async function buildLoadAta(params: {
+ rpc: BuilderRpc;
+ indexer: LightIndexer;
+ owner: Address;
+ mint: Address;
+ destination: Address;
+ decimals?: number;
+ tokenProgram?: Address;
+ feePayer?: Address;
+ splInterfaceInfo?: SplInterfaceInfo;
+ maxInputsPerInstruction?: number;
+}): Promise {
+ const maxInputs = params.maxInputsPerInstruction ?? 4;
+ const payer = params.feePayer ?? params.owner;
+
+ const allAccounts = await loadAllTokenAccounts(
+ params.indexer,
+ params.owner,
+ { mint: params.mint },
+ );
+
+ if (allAccounts.length === 0) {
+ return [];
+ }
+
+ const totalColdBalance = allAccounts.reduce(
+ (sum, acc) => sum + acc.token.amount,
+ 0n,
+ );
+ if (totalColdBalance === 0n) {
+ return [];
+ }
+
+ const result = await buildDecompress({
+ rpc: params.rpc,
+ indexer: params.indexer,
+ owner: params.owner,
+ mint: params.mint,
+ amount: totalColdBalance,
+ destination: params.destination,
+ decimals: params.decimals,
+ tokenProgram: params.tokenProgram,
+ feePayer: payer,
+ splInterfaceInfo: params.splInterfaceInfo,
+ maxInputs,
+ });
+
+ return [result.instruction];
+}
+
+// ============================================================================
+// CREATE ATA BUILDERS (NEW)
+// ============================================================================
+
+/**
+ * Builds a createAssociatedTokenAccount instruction.
+ * Derives the ATA address automatically.
+ *
+ * @param params - Owner, mint, feePayer
+ * @returns Instruction, derived ATA address, and bump
+ */
+export async function buildCreateAta(params: {
+ owner: Address;
+ mint: Address;
+ feePayer: Address;
+}): Promise<{ instruction: Instruction; ata: Address; bump: number }> {
+ const { address: ata, bump, instruction } =
+ await createAssociatedTokenAccountInstruction({
+ payer: params.feePayer,
+ owner: params.owner,
+ mint: params.mint,
+ });
+ return { instruction, ata: ata, bump };
+}
+
+/**
+ * Builds an idempotent createAssociatedTokenAccount instruction.
+ * Derives the ATA address automatically.
+ *
+ * @param params - Owner, mint, feePayer
+ * @returns Instruction, derived ATA address, and bump
+ */
+export async function buildCreateAtaIdempotent(params: {
+ owner: Address;
+ mint: Address;
+ feePayer: Address;
+}): Promise<{ instruction: Instruction; ata: Address; bump: number }> {
+ const { address: ata, bump, instruction } =
+ await createAssociatedTokenAccountIdempotentInstruction({
+ payer: params.feePayer,
+ owner: params.owner,
+ mint: params.mint,
+ });
+ return { instruction, ata: ata, bump };
+}
+
+// ============================================================================
+// GET OR CREATE ATA (NEW)
+// ============================================================================
+
+/**
+ * Builds instructions to ensure an ATA exists and load cold balances.
+ *
+ * Returns instructions to:
+ * 1. Create the ATA if it doesn't exist on-chain (idempotent)
+ * 2. Decompress cold compressed token balances into the ATA
+ *
+ * @param params - Get or create ATA parameters
+ * @returns Instructions, ATA address, and balance info
+ */
+export async function buildGetOrCreateAta(params: {
+ rpc: BuilderRpc;
+ indexer: LightIndexer;
+ owner: Address;
+ mint: Address;
+ feePayer: Address;
+ tokenProgram?: Address;
+ decimals?: number;
+ splInterfaceInfo?: SplInterfaceInfo;
+}): Promise<{
+ instructions: Instruction[];
+ ata: Address;
+ hotBalance: bigint;
+ coldBalance: bigint;
+ totalBalance: bigint;
+}> {
+ const instructions: Instruction[] = [];
+
+ // 1. Derive ATA address
+ const { address: ata } = await deriveAssociatedTokenAddress(
+ params.owner,
+ params.mint,
+ );
+
+ // 2. Check if ATA exists on-chain
+ let hotBalance = 0n;
+ try {
+ const info = await params.rpc.getAccountInfo(ata, { encoding: 'base64' });
+ if (!info.value) {
+ // ATA doesn't exist — add idempotent create instruction
+ const { instruction } = await buildCreateAtaIdempotent({
+ owner: params.owner,
+ mint: params.mint,
+ feePayer: params.feePayer,
+ });
+ instructions.push(instruction);
+ } else {
+ // Parse hot balance from on-chain data
+ const data = info.value.data;
+ if (data && typeof data === 'object' && Array.isArray(data)) {
+ const bytes = Uint8Array.from(
+ atob(data[0] as string),
+ (c) => c.charCodeAt(0),
+ );
+ if (bytes.length >= 72) {
+ const view = new DataView(
+ bytes.buffer,
+ bytes.byteOffset,
+ bytes.byteLength,
+ );
+ hotBalance = view.getBigUint64(64, true);
+ }
+ }
+ }
+ } catch {
+ // If getAccountInfo fails, assume ATA doesn't exist
+ const { instruction } = await buildCreateAtaIdempotent({
+ owner: params.owner,
+ mint: params.mint,
+ feePayer: params.feePayer,
+ });
+ instructions.push(instruction);
+ }
+
+ // 3. Load compressed accounts
+ const coldAccounts = await loadAllTokenAccounts(
+ params.indexer,
+ params.owner,
+ { mint: params.mint },
+ );
+ const coldBalance = coldAccounts.reduce(
+ (sum, acc) => sum + acc.token.amount,
+ 0n,
+ );
+
+ // 4. If cold balance exists, add decompress instructions
+ if (coldBalance > 0n) {
+ const decompressResult = await buildDecompress({
+ rpc: params.rpc,
+ indexer: params.indexer,
+ owner: params.owner,
+ mint: params.mint,
+ amount: coldBalance,
+ destination: ata,
+ decimals: params.decimals,
+ tokenProgram: params.tokenProgram,
+ feePayer: params.feePayer,
+ splInterfaceInfo: params.splInterfaceInfo,
+ });
+ instructions.push(decompressResult.instruction);
+ }
+
+ return {
+ instructions,
+ ata,
+ hotBalance,
+ coldBalance,
+ totalBalance: hotBalance + coldBalance,
+ };
+}
+
+// ============================================================================
+// DECOMPRESS INTERFACE (NEW)
+// ============================================================================
+
+/**
+ * Builds decompress instructions with auto-derived ATA creation.
+ *
+ * When destination is omitted, derives the Light Token ATA for owner+mint
+ * and creates it idempotently if needed.
+ *
+ * @param params - Decompress interface parameters
+ * @returns Instructions array and destination address
+ */
+export async function buildDecompressInterface(params: {
+ rpc: BuilderRpc;
+ indexer: LightIndexer;
+ owner: Address;
+ mint: Address;
+ amount?: bigint;
+ destination?: Address;
+ destinationOwner?: Address;
+ feePayer?: Address;
+ tokenProgram?: Address;
+ decimals?: number;
+ splInterfaceInfo?: SplInterfaceInfo;
+}): Promise<{ instructions: Instruction[]; destination: Address }> {
+ const instructions: Instruction[] = [];
+ const payer = params.feePayer ?? params.owner;
+ const destOwner = params.destinationOwner ?? params.owner;
+
+ // Resolve destination
+ let destination: Address;
+ if (params.destination) {
+ destination = params.destination;
+ } else {
+ // Derive ATA and create idempotently
+ const { address: ata } = await deriveAssociatedTokenAddress(
+ destOwner,
+ params.mint,
+ );
+ destination = ata;
+
+ // Check if it exists
+ try {
+ const info = await params.rpc.getAccountInfo(ata, {
+ encoding: 'base64',
+ });
+ if (!info.value) {
+ const { instruction } = await buildCreateAtaIdempotent({
+ owner: destOwner,
+ mint: params.mint,
+ feePayer: payer,
+ });
+ instructions.push(instruction);
+ }
+ } catch {
+ const { instruction } = await buildCreateAtaIdempotent({
+ owner: destOwner,
+ mint: params.mint,
+ feePayer: payer,
+ });
+ instructions.push(instruction);
+ }
+ }
+
+ // Determine amount
+ let decompressAmount: bigint;
+ if (params.amount !== undefined) {
+ decompressAmount = params.amount;
+ } else {
+ // Load all compressed accounts to decompress entire balance
+ const accounts = await loadAllTokenAccounts(
+ params.indexer,
+ params.owner,
+ { mint: params.mint },
+ );
+ decompressAmount = accounts.reduce(
+ (sum, acc) => sum + acc.token.amount,
+ 0n,
+ );
+ }
+
+ if (decompressAmount > 0n) {
+ const result = await buildDecompress({
+ rpc: params.rpc,
+ indexer: params.indexer,
+ owner: params.owner,
+ mint: params.mint,
+ amount: decompressAmount,
+ destination,
+ decimals: params.decimals,
+ tokenProgram: params.tokenProgram,
+ feePayer: payer,
+ splInterfaceInfo: params.splInterfaceInfo,
+ });
+ instructions.push(result.instruction);
+ }
+
+ return { instructions, destination };
+}
diff --git a/js/token-kit/src/client/index.ts b/js/token-kit/src/client/index.ts
new file mode 100644
index 0000000000..f7dbe10f10
--- /dev/null
+++ b/js/token-kit/src/client/index.ts
@@ -0,0 +1,45 @@
+/**
+ * Light Token SDK Client Types
+ *
+ * Types for interacting with the Light Protocol indexer (Photon).
+ */
+
+export {
+ // Tree types
+ TreeType,
+ type TreeInfo,
+
+ // Account types
+ AccountState,
+ type CompressedAccountData,
+ type CompressedAccount,
+ type TokenData,
+ type CompressedTokenAccount,
+
+ // Proof types
+ type ValidityProof,
+ type RootIndex,
+ type AccountProofInputs,
+ type AddressProofInputs,
+ type ValidityProofWithContext,
+
+ // Request/response types
+ type AddressWithTree,
+ type GetCompressedTokenAccountsOptions,
+ type ResponseContext,
+ type IndexerResponse,
+ type ItemsWithCursor,
+
+ // Error types
+ IndexerErrorCode,
+ IndexerError,
+
+ // Balance/holder types
+ type TokenBalance,
+ type TokenHolder,
+ type SignatureInfo,
+
+ // Validation
+ assertValidTreeType,
+ assertV2Tree,
+} from './types.js';
diff --git a/js/token-kit/src/client/types.ts b/js/token-kit/src/client/types.ts
new file mode 100644
index 0000000000..63d42d9245
--- /dev/null
+++ b/js/token-kit/src/client/types.ts
@@ -0,0 +1,343 @@
+/**
+ * Light Token SDK Client Types
+ *
+ * Core types for interacting with the Light Protocol indexer (Photon).
+ * These types align with the Rust sdk-libs/client types.
+ */
+
+import type { Address } from '@solana/addresses';
+
+// ============================================================================
+// TREE TYPES
+// ============================================================================
+
+/**
+ * Tree type enum matching Rust TreeType.
+ */
+export enum TreeType {
+ /** V1 state merkle tree */
+ StateV1 = 1,
+ /** V1 address merkle tree */
+ AddressV1 = 2,
+ /** V2 state merkle tree */
+ StateV2 = 3,
+ /** V2 address merkle tree */
+ AddressV2 = 4,
+}
+
+/**
+ * Tree info for a merkle tree context.
+ */
+export interface TreeInfo {
+ /** Merkle tree pubkey */
+ tree: Address;
+ /** Queue pubkey */
+ queue: Address;
+ /** Tree type */
+ treeType: TreeType;
+ /** CPI context (optional) */
+ cpiContext?: Address;
+ /** Next tree info (when current tree is full) */
+ nextTreeInfo?: TreeInfo;
+}
+
+// ============================================================================
+// ACCOUNT TYPES
+// ============================================================================
+
+/**
+ * Account state for token accounts.
+ */
+export enum AccountState {
+ Initialized = 1,
+ Frozen = 2,
+}
+
+/**
+ * Compressed account data.
+ */
+export interface CompressedAccountData {
+ /** 8-byte discriminator */
+ discriminator: Uint8Array;
+ /** Account data bytes */
+ data: Uint8Array;
+ /** 32-byte data hash */
+ dataHash: Uint8Array;
+}
+
+/**
+ * Compressed account matching Rust CompressedAccount.
+ */
+export interface CompressedAccount {
+ /** 32-byte account hash */
+ hash: Uint8Array;
+ /** 32-byte address (optional) */
+ address: Uint8Array | null;
+ /** Owner program pubkey */
+ owner: Address;
+ /** Lamports */
+ lamports: bigint;
+ /** Account data (optional) */
+ data: CompressedAccountData | null;
+ /** Leaf index in the merkle tree */
+ leafIndex: number;
+ /** Tree info */
+ treeInfo: TreeInfo;
+ /** Whether to prove by index */
+ proveByIndex: boolean;
+ /** Sequence number (optional) */
+ seq: bigint | null;
+ /** Slot when account was created */
+ slotCreated: bigint;
+}
+
+/**
+ * Token-specific data.
+ */
+export interface TokenData {
+ /** Token mint */
+ mint: Address;
+ /** Token owner */
+ owner: Address;
+ /** Token amount */
+ amount: bigint;
+ /** Delegate (optional) */
+ delegate: Address | null;
+ /** Account state */
+ state: AccountState;
+ /** TLV extension data (optional) */
+ tlv: Uint8Array | null;
+}
+
+/**
+ * Compressed token account combining account and token data.
+ */
+export interface CompressedTokenAccount {
+ /** Token-specific data */
+ token: TokenData;
+ /** General account information */
+ account: CompressedAccount;
+}
+
+// ============================================================================
+// PROOF TYPES
+// ============================================================================
+
+/**
+ * Groth16 validity proof.
+ */
+export interface ValidityProof {
+ /** 32 bytes - G1 point */
+ a: Uint8Array;
+ /** 64 bytes - G2 point */
+ b: Uint8Array;
+ /** 32 bytes - G1 point */
+ c: Uint8Array;
+}
+
+/**
+ * Root index for proof context.
+ */
+export interface RootIndex {
+ /** The root index value */
+ rootIndex: number;
+ /** Whether to prove by index rather than validity proof */
+ proveByIndex: boolean;
+}
+
+/**
+ * Account proof inputs for validity proof context.
+ */
+export interface AccountProofInputs {
+ /** 32-byte account hash */
+ hash: Uint8Array;
+ /** 32-byte merkle root */
+ root: Uint8Array;
+ /** Root index info */
+ rootIndex: RootIndex;
+ /** Leaf index */
+ leafIndex: number;
+ /** Tree info */
+ treeInfo: TreeInfo;
+}
+
+/**
+ * Address proof inputs for validity proof context.
+ */
+export interface AddressProofInputs {
+ /** 32-byte address */
+ address: Uint8Array;
+ /** 32-byte merkle root */
+ root: Uint8Array;
+ /** Root index */
+ rootIndex: number;
+ /** Tree info */
+ treeInfo: TreeInfo;
+}
+
+/**
+ * Validity proof with full context.
+ */
+export interface ValidityProofWithContext {
+ /** The validity proof (null if proving by index) */
+ proof: ValidityProof | null;
+ /** Account proof inputs */
+ accounts: AccountProofInputs[];
+ /** Address proof inputs */
+ addresses: AddressProofInputs[];
+}
+
+// ============================================================================
+// REQUEST/RESPONSE TYPES
+// ============================================================================
+
+/**
+ * Address with tree for new address proofs.
+ */
+export interface AddressWithTree {
+ /** 32-byte address */
+ address: Uint8Array;
+ /** Address tree pubkey */
+ tree: Address;
+}
+
+/**
+ * Options for fetching compressed token accounts.
+ */
+export interface GetCompressedTokenAccountsOptions {
+ /** Filter by mint */
+ mint?: Address;
+ /** Pagination cursor */
+ cursor?: string;
+ /** Maximum results to return */
+ limit?: number;
+}
+
+/**
+ * Response context with slot.
+ */
+export interface ResponseContext {
+ /** Slot of the response */
+ slot: bigint;
+}
+
+/**
+ * Response wrapper with context.
+ */
+export interface IndexerResponse {
+ /** Response context */
+ context: ResponseContext;
+ /** Response value */
+ value: T;
+}
+
+/**
+ * Paginated items with cursor.
+ */
+export interface ItemsWithCursor {
+ /** Items in this page */
+ items: T[];
+ /** Cursor for next page (null if no more pages) */
+ cursor: string | null;
+}
+
+// ============================================================================
+// ERROR TYPES
+// ============================================================================
+
+/**
+ * Indexer error codes.
+ */
+export enum IndexerErrorCode {
+ /** Network/fetch error */
+ NetworkError = 'NETWORK_ERROR',
+ /** Invalid response format */
+ InvalidResponse = 'INVALID_RESPONSE',
+ /** RPC error response */
+ RpcError = 'RPC_ERROR',
+ /** Account not found */
+ NotFound = 'NOT_FOUND',
+ /** Insufficient balance for operation */
+ InsufficientBalance = 'INSUFFICIENT_BALANCE',
+}
+
+/**
+ * Error from indexer operations.
+ */
+export class IndexerError extends Error {
+ constructor(
+ public readonly code: IndexerErrorCode,
+ message: string,
+ public readonly cause?: unknown,
+ ) {
+ super(message);
+ this.name = 'IndexerError';
+ }
+}
+
+// ============================================================================
+// VALIDATION
+// ============================================================================
+
+/**
+ * Validate that a tree type is recognized.
+ *
+ * Both V1 and V2 trees are supported. Only unknown/invalid tree types
+ * are rejected.
+ *
+ * @param treeType - The tree type to validate
+ * @throws IndexerError if tree type is unknown
+ */
+export function assertValidTreeType(treeType: TreeType): void {
+ const known = [
+ TreeType.StateV1,
+ TreeType.AddressV1,
+ TreeType.StateV2,
+ TreeType.AddressV2,
+ ];
+ if (!known.includes(treeType)) {
+ throw new IndexerError(
+ IndexerErrorCode.InvalidResponse,
+ `Unknown tree type: ${treeType}`,
+ );
+ }
+}
+
+/** @deprecated Use assertValidTreeType instead. */
+export const assertV2Tree = assertValidTreeType;
+
+// ============================================================================
+// BALANCE / HOLDER TYPES
+// ============================================================================
+
+/**
+ * Token balance for a specific mint.
+ */
+export interface TokenBalance {
+ /** Token mint address */
+ mint: Address;
+ /** Total balance across all compressed accounts */
+ balance: bigint;
+}
+
+/**
+ * Token holder with balance.
+ */
+export interface TokenHolder {
+ /** Owner address */
+ owner: Address;
+ /** Balance */
+ balance: bigint;
+}
+
+/**
+ * Signature info for token operations.
+ */
+export interface SignatureInfo {
+ /** Transaction signature */
+ signature: string;
+ /** Slot the transaction was confirmed in */
+ slot: bigint;
+ /** Block time (optional) */
+ blockTime: bigint | null;
+}
diff --git a/js/token-kit/src/codecs/borsh-helpers.ts b/js/token-kit/src/codecs/borsh-helpers.ts
new file mode 100644
index 0000000000..381d558ab7
--- /dev/null
+++ b/js/token-kit/src/codecs/borsh-helpers.ts
@@ -0,0 +1,80 @@
+/**
+ * Shared manual Borsh encoding helpers.
+ *
+ * Used by transfer2.ts and mint-action.ts for complex nested structures
+ * that are too dynamic for Solana Kit's static struct codecs.
+ */
+
+import type { ReadonlyUint8Array, Encoder, Decoder } from '@solana/codecs';
+import { getArrayEncoder, getArrayDecoder } from '@solana/codecs';
+
+export function writeU8(value: number): Uint8Array {
+ return new Uint8Array([value & 0xff]);
+}
+
+export function writeU16(value: number): Uint8Array {
+ const buf = new Uint8Array(2);
+ new DataView(buf.buffer).setUint16(0, value, true);
+ return buf;
+}
+
+export function writeU32(value: number): Uint8Array {
+ const buf = new Uint8Array(4);
+ new DataView(buf.buffer).setUint32(0, value, true);
+ return buf;
+}
+
+export function writeU64(value: bigint): Uint8Array {
+ const buf = new Uint8Array(8);
+ new DataView(buf.buffer).setBigUint64(0, value, true);
+ return buf;
+}
+
+export function writeBool(value: boolean): Uint8Array {
+ return new Uint8Array([value ? 1 : 0]);
+}
+
+/** Borsh Vec encoding: u32 length + bytes. */
+export function writeVecBytes(bytes: ReadonlyUint8Array): Uint8Array {
+ return concatBytes([writeU32(bytes.length), new Uint8Array(bytes)]);
+}
+
+/** Borsh Option encoding: 0x00 for None, 0x01 + data for Some. */
+export function writeOption(
+ value: T | null | undefined,
+ encoder: (v: T) => Uint8Array,
+): Uint8Array {
+ if (value === null || value === undefined) {
+ return new Uint8Array([0]);
+ }
+ return concatBytes([new Uint8Array([1]), encoder(value)]);
+}
+
+export function concatBytes(arrays: Uint8Array[]): Uint8Array {
+ const totalLen = arrays.reduce((sum, a) => sum + a.length, 0);
+ const result = new Uint8Array(totalLen);
+ let offset = 0;
+ for (const arr of arrays) {
+ result.set(arr, offset);
+ offset += arr.length;
+ }
+ return result;
+}
+
+/**
+ * Creates an encoder for a Vec type (Borsh style: u32 element count prefix).
+ *
+ * Note: getArrayEncoder defaults to { size: getU32Encoder() } which is the
+ * Borsh Vec format (u32 count + items). Do NOT wrap with addEncoderSizePrefix
+ * which would add a byte-count prefix on top.
+ */
+export function getVecEncoder(itemEncoder: Encoder): Encoder {
+ return getArrayEncoder(itemEncoder) as Encoder;
+}
+
+/**
+ * Creates a decoder for a Vec type (Borsh style: u32 element count prefix).
+ */
+export function getVecDecoder(itemDecoder: Decoder): Decoder {
+ return getArrayDecoder(itemDecoder) as Decoder;
+}
diff --git a/js/token-kit/src/codecs/compressible.ts b/js/token-kit/src/codecs/compressible.ts
new file mode 100644
index 0000000000..222c0aa3e5
--- /dev/null
+++ b/js/token-kit/src/codecs/compressible.ts
@@ -0,0 +1,233 @@
+/**
+ * Compressible extension codecs using Solana Kit patterns.
+ */
+
+import {
+ type Codec,
+ type Decoder,
+ type Encoder,
+ combineCodec,
+ getStructDecoder,
+ getStructEncoder,
+ getU8Decoder,
+ getU8Encoder,
+ getU32Decoder,
+ getU32Encoder,
+ getBytesDecoder,
+ getBytesEncoder,
+ addDecoderSizePrefix,
+ addEncoderSizePrefix,
+ getOptionEncoder,
+ getOptionDecoder,
+ fixEncoderSize,
+ fixDecoderSize,
+} from '@solana/codecs';
+import { getAddressCodec, type Address } from '@solana/addresses';
+
+import { getVecEncoder, getVecDecoder } from './borsh-helpers.js';
+
+import type {
+ CompressToPubkey,
+ CompressibleExtensionInstructionData,
+ CreateAtaInstructionData,
+ CreateTokenAccountInstructionData,
+} from './types.js';
+
+import { DISCRIMINATOR } from '../constants.js';
+
+// ============================================================================
+// COMPRESS TO PUBKEY CODEC
+// ============================================================================
+
+// Seeds are Vec> which we encode as Vec using u32 length-prefixed bytes.
+// This correctly maps ReadonlyUint8Array[] ↔ Borsh Vec>.
+const getSeedEncoder = () =>
+ addEncoderSizePrefix(getBytesEncoder(), getU32Encoder());
+const getSeedDecoder = () =>
+ addDecoderSizePrefix(getBytesDecoder(), getU32Decoder());
+
+export const getCompressToPubkeyEncoder = (): Encoder =>
+ getStructEncoder([
+ ['bump', getU8Encoder()],
+ ['programId', fixEncoderSize(getBytesEncoder(), 32)],
+ ['seeds', getVecEncoder(getSeedEncoder())],
+ ]);
+
+export const getCompressToPubkeyDecoder = (): Decoder =>
+ getStructDecoder([
+ ['bump', getU8Decoder()],
+ ['programId', fixDecoderSize(getBytesDecoder(), 32)],
+ ['seeds', getVecDecoder(getSeedDecoder())],
+ ]);
+
+export const getCompressToPubkeyCodec = (): Codec =>
+ combineCodec(getCompressToPubkeyEncoder(), getCompressToPubkeyDecoder());
+
+// ============================================================================
+// COMPRESSIBLE EXTENSION INSTRUCTION DATA CODEC
+// ============================================================================
+
+export const getCompressibleExtensionDataEncoder =
+ (): Encoder =>
+ getStructEncoder([
+ ['tokenAccountVersion', getU8Encoder()],
+ ['rentPayment', getU8Encoder()],
+ ['compressionOnly', getU8Encoder()],
+ ['writeTopUp', getU32Encoder()],
+ [
+ 'compressToPubkey',
+ getOptionEncoder(getCompressToPubkeyEncoder()),
+ ],
+ ]);
+
+// Cast needed: getOptionDecoder returns Option but interface uses T | null.
+export const getCompressibleExtensionDataDecoder =
+ (): Decoder =>
+ getStructDecoder([
+ ['tokenAccountVersion', getU8Decoder()],
+ ['rentPayment', getU8Decoder()],
+ ['compressionOnly', getU8Decoder()],
+ ['writeTopUp', getU32Decoder()],
+ [
+ 'compressToPubkey',
+ getOptionDecoder(getCompressToPubkeyDecoder()),
+ ],
+ ]) as unknown as Decoder;
+
+export const getCompressibleExtensionDataCodec =
+ (): Codec =>
+ combineCodec(
+ getCompressibleExtensionDataEncoder(),
+ getCompressibleExtensionDataDecoder(),
+ );
+
+// ============================================================================
+// CREATE ATA INSTRUCTION DATA CODEC
+// ============================================================================
+
+export const getCreateAtaDataEncoder = (): Encoder =>
+ getStructEncoder([
+ [
+ 'compressibleConfig',
+ getOptionEncoder(getCompressibleExtensionDataEncoder()),
+ ],
+ ]);
+
+// Cast needed: getOptionDecoder returns Option but interface uses T | null.
+export const getCreateAtaDataDecoder = (): Decoder =>
+ getStructDecoder([
+ [
+ 'compressibleConfig',
+ getOptionDecoder(getCompressibleExtensionDataDecoder()),
+ ],
+ ]) as unknown as Decoder;
+
+export const getCreateAtaDataCodec = (): Codec =>
+ combineCodec(getCreateAtaDataEncoder(), getCreateAtaDataDecoder());
+
+// ============================================================================
+// CREATE TOKEN ACCOUNT INSTRUCTION DATA CODEC
+// ============================================================================
+
+const getOwnerEncoder = (): Encoder =>
+ getAddressCodec() as unknown as Encoder;
+
+const getOwnerDecoder = (): Decoder =>
+ getAddressCodec() as unknown as Decoder;
+
+export const getCreateTokenAccountDataEncoder =
+ (): Encoder =>
+ getStructEncoder([
+ ['owner', getOwnerEncoder()],
+ [
+ 'compressibleConfig',
+ getOptionEncoder(getCompressibleExtensionDataEncoder()),
+ ],
+ ]);
+
+// Cast needed: getOptionDecoder returns Option but interface uses T | null.
+export const getCreateTokenAccountDataDecoder =
+ (): Decoder =>
+ getStructDecoder([
+ ['owner', getOwnerDecoder()],
+ [
+ 'compressibleConfig',
+ getOptionDecoder(getCompressibleExtensionDataDecoder()),
+ ],
+ ]) as unknown as Decoder;
+
+export const getCreateTokenAccountDataCodec =
+ (): Codec =>
+ combineCodec(
+ getCreateTokenAccountDataEncoder(),
+ getCreateTokenAccountDataDecoder(),
+ );
+
+// ============================================================================
+// FULL INSTRUCTION ENCODERS
+// ============================================================================
+
+/**
+ * Encodes the CreateAssociatedTokenAccount instruction data.
+ */
+export function encodeCreateAtaInstructionData(
+ data: CreateAtaInstructionData,
+ idempotent = false,
+): Uint8Array {
+ const discriminator = idempotent
+ ? DISCRIMINATOR.CREATE_ATA_IDEMPOTENT
+ : DISCRIMINATOR.CREATE_ATA;
+
+ const dataEncoder = getCreateAtaDataEncoder();
+ const dataBytes = dataEncoder.encode(data);
+
+ const result = new Uint8Array(1 + dataBytes.length);
+ result[0] = discriminator;
+ result.set(new Uint8Array(dataBytes), 1);
+
+ return result;
+}
+
+/**
+ * Encodes the CreateTokenAccount instruction data.
+ *
+ * When `splCompatibleOwnerOnlyData` is true, this emits the SPL-compatible
+ * owner-only payload (`[owner:32]`) instead of the full Borsh struct.
+ */
+export function encodeCreateTokenAccountInstructionData(
+ data: CreateTokenAccountInstructionData,
+ splCompatibleOwnerOnlyData = false,
+): Uint8Array {
+ let payload: Uint8Array;
+ if (splCompatibleOwnerOnlyData) {
+ payload = new Uint8Array(getAddressCodec().encode(data.owner));
+ } else {
+ const dataEncoder = getCreateTokenAccountDataEncoder();
+ payload = new Uint8Array(dataEncoder.encode(data));
+ }
+
+ const result = new Uint8Array(1 + payload.length);
+ result[0] = DISCRIMINATOR.CREATE_TOKEN_ACCOUNT;
+ result.set(payload, 1);
+ return result;
+}
+
+/**
+ * Default compressible extension params for rent-free ATAs.
+ *
+ * Matches the Rust SDK defaults:
+ * - tokenAccountVersion: 3 (ShaFlat hashing)
+ * - rentPayment: 16 (16 epochs, ~24 hours)
+ * - compressionOnly: 1 (required for ATAs)
+ * - writeTopUp: 766 (per-write top-up, ~2 epochs rent)
+ * - compressToPubkey: null (required null for ATAs)
+ */
+export function defaultCompressibleParams(): CompressibleExtensionInstructionData {
+ return {
+ tokenAccountVersion: 3,
+ rentPayment: 16,
+ compressionOnly: 1,
+ writeTopUp: 766,
+ compressToPubkey: null,
+ };
+}
diff --git a/js/token-kit/src/codecs/index.ts b/js/token-kit/src/codecs/index.ts
new file mode 100644
index 0000000000..6ca9428b9b
--- /dev/null
+++ b/js/token-kit/src/codecs/index.ts
@@ -0,0 +1,113 @@
+/**
+ * Light Token SDK Codecs
+ *
+ * Serialization codecs for Light Token instruction data using Solana Kit patterns.
+ */
+
+// Types
+export * from './types.js';
+
+// Borsh helpers
+export {
+ writeU8,
+ writeU16,
+ writeU32,
+ writeU64,
+ writeBool,
+ writeOption,
+ writeVecBytes,
+ concatBytes,
+ getVecEncoder,
+ getVecDecoder,
+} from './borsh-helpers.js';
+
+// Transfer2 codecs
+export {
+ getCompressionEncoder,
+ getCompressionDecoder,
+ getCompressionCodec,
+ getPackedMerkleContextEncoder,
+ getPackedMerkleContextDecoder,
+ getPackedMerkleContextCodec,
+ getMultiInputTokenDataEncoder,
+ getMultiInputTokenDataDecoder,
+ getMultiInputTokenDataCodec,
+ getMultiTokenOutputDataEncoder,
+ getMultiTokenOutputDataDecoder,
+ getMultiTokenOutputDataCodec,
+ getCpiContextEncoder,
+ getCpiContextDecoder,
+ getCpiContextCodec,
+ getCompressedProofEncoder,
+ getCompressedProofDecoder,
+ getCompressedProofCodec,
+ getTransfer2BaseEncoder,
+ getTransfer2BaseDecoder,
+ encodeTransfer2InstructionData,
+ type Transfer2BaseInstructionData,
+} from './transfer2.js';
+
+// Compressible codecs
+export {
+ getCompressToPubkeyEncoder,
+ getCompressToPubkeyDecoder,
+ getCompressToPubkeyCodec,
+ getCompressibleExtensionDataEncoder,
+ getCompressibleExtensionDataDecoder,
+ getCompressibleExtensionDataCodec,
+ getCreateAtaDataEncoder,
+ getCreateAtaDataDecoder,
+ getCreateAtaDataCodec,
+ getCreateTokenAccountDataEncoder,
+ getCreateTokenAccountDataDecoder,
+ getCreateTokenAccountDataCodec,
+ encodeCreateAtaInstructionData,
+ encodeCreateTokenAccountInstructionData,
+ defaultCompressibleParams,
+} from './compressible.js';
+
+// Simple instruction codecs
+export {
+ getAmountInstructionEncoder,
+ getAmountInstructionDecoder,
+ getAmountInstructionCodec,
+ getCheckedInstructionEncoder,
+ getCheckedInstructionDecoder,
+ getCheckedInstructionCodec,
+ getDiscriminatorOnlyEncoder,
+ getDiscriminatorOnlyDecoder,
+ getDiscriminatorOnlyCodec,
+ encodeMaxTopUp,
+ decodeMaxTopUp,
+ type AmountInstructionData,
+ type CheckedInstructionData,
+ type DiscriminatorOnlyData,
+} from './instructions.js';
+
+// Mint deserializer
+export {
+ deserializeCompressedMint,
+ type BaseMint,
+ type DeserializedMintContext,
+ type DeserializedCompressedMint,
+} from './mint-deserialize.js';
+
+// MintAction codecs
+export {
+ encodeMintActionInstructionData,
+ type MintRecipient,
+ type MintToCompressedAction,
+ type MintToAction,
+ type UpdateAuthorityAction,
+ type UpdateMetadataFieldAction,
+ type UpdateMetadataAuthorityAction,
+ type RemoveMetadataKeyAction,
+ type DecompressMintAction,
+ type CompressAndCloseMintAction,
+ type MintAction,
+ type CreateMint,
+ type MintMetadata,
+ type MintInstructionData,
+ type MintActionCpiContext,
+ type MintActionInstructionData,
+} from './mint-action.js';
diff --git a/js/token-kit/src/codecs/instructions.ts b/js/token-kit/src/codecs/instructions.ts
new file mode 100644
index 0000000000..1fc4251e21
--- /dev/null
+++ b/js/token-kit/src/codecs/instructions.ts
@@ -0,0 +1,131 @@
+/**
+ * Codecs for simple CToken instructions (transfer, burn, mint-to, approve, etc.).
+ *
+ * Each instruction follows the pattern: discriminator (u8) + fields.
+ * Having codecs gives us decoders for free, enabling roundtrip tests.
+ */
+
+import {
+ type Codec,
+ type Decoder,
+ type Encoder,
+ combineCodec,
+ getStructDecoder,
+ getStructEncoder,
+ getU8Decoder,
+ getU8Encoder,
+ getU16Decoder,
+ getU16Encoder,
+ getU64Decoder,
+ getU64Encoder,
+} from '@solana/codecs';
+
+// ============================================================================
+// AMOUNT-ONLY INSTRUCTIONS (transfer, mint-to, burn, approve)
+// ============================================================================
+
+export interface AmountInstructionData {
+ discriminator: number;
+ amount: bigint;
+}
+
+export const getAmountInstructionEncoder =
+ (): Encoder =>
+ getStructEncoder([
+ ['discriminator', getU8Encoder()],
+ ['amount', getU64Encoder()],
+ ]);
+
+export const getAmountInstructionDecoder =
+ (): Decoder =>
+ getStructDecoder([
+ ['discriminator', getU8Decoder()],
+ ['amount', getU64Decoder()],
+ ]);
+
+export const getAmountInstructionCodec = (): Codec =>
+ combineCodec(getAmountInstructionEncoder(), getAmountInstructionDecoder());
+
+// ============================================================================
+// CHECKED INSTRUCTIONS (transfer-checked, mint-to-checked, burn-checked)
+// ============================================================================
+
+export interface CheckedInstructionData {
+ discriminator: number;
+ amount: bigint;
+ decimals: number;
+}
+
+export const getCheckedInstructionEncoder =
+ (): Encoder =>
+ getStructEncoder([
+ ['discriminator', getU8Encoder()],
+ ['amount', getU64Encoder()],
+ ['decimals', getU8Encoder()],
+ ]);
+
+export const getCheckedInstructionDecoder =
+ (): Decoder =>
+ getStructDecoder([
+ ['discriminator', getU8Decoder()],
+ ['amount', getU64Decoder()],
+ ['decimals', getU8Decoder()],
+ ]);
+
+export const getCheckedInstructionCodec =
+ (): Codec =>
+ combineCodec(
+ getCheckedInstructionEncoder(),
+ getCheckedInstructionDecoder(),
+ );
+
+// ============================================================================
+// DISCRIMINATOR-ONLY INSTRUCTIONS (revoke, freeze, thaw, close)
+// ============================================================================
+
+export interface DiscriminatorOnlyData {
+ discriminator: number;
+}
+
+export const getDiscriminatorOnlyEncoder = (): Encoder =>
+ getStructEncoder([['discriminator', getU8Encoder()]]);
+
+export const getDiscriminatorOnlyDecoder = (): Decoder =>
+ getStructDecoder([['discriminator', getU8Decoder()]]);
+
+export const getDiscriminatorOnlyCodec = (): Codec =>
+ combineCodec(getDiscriminatorOnlyEncoder(), getDiscriminatorOnlyDecoder());
+
+// ============================================================================
+// MAX TOP-UP ENCODING HELPER
+// ============================================================================
+
+/**
+ * Encodes optional maxTopUp as a variable-length suffix.
+ *
+ * The on-chain program detects the format by instruction data length:
+ * - 9 bytes (disc + u64 amount) = legacy format, no maxTopUp
+ * - 11 bytes (disc + u64 amount + u16 maxTopUp) = extended format
+ *
+ * This matches the Rust program's length-based format detection.
+ */
+export function encodeMaxTopUp(maxTopUp: number | undefined): Uint8Array {
+ if (maxTopUp === undefined) {
+ return new Uint8Array(0);
+ }
+ return new Uint8Array(getU16Encoder().encode(maxTopUp));
+}
+
+/**
+ * Attempts to decode a maxTopUp u16 from instruction data at the given offset.
+ * Returns undefined if there are not enough bytes remaining.
+ */
+export function decodeMaxTopUp(
+ data: Uint8Array,
+ offset: number,
+): number | undefined {
+ if (data.length <= offset) {
+ return undefined;
+ }
+ return getU16Decoder().read(data, offset)[0];
+}
diff --git a/js/token-kit/src/codecs/mint-action.ts b/js/token-kit/src/codecs/mint-action.ts
new file mode 100644
index 0000000000..4b78866031
--- /dev/null
+++ b/js/token-kit/src/codecs/mint-action.ts
@@ -0,0 +1,492 @@
+/**
+ * MintAction instruction codecs using Solana Kit patterns.
+ *
+ * Handles encoding of MintAction instruction data (discriminator 103).
+ * Uses manual Borsh encoding via DataView/Uint8Array for complex nested
+ * structures, following the same approach as transfer2.ts.
+ */
+
+import type { ReadonlyUint8Array } from '@solana/codecs';
+
+import {
+ writeU8,
+ writeU16,
+ writeU32,
+ writeU64,
+ writeBool,
+ writeOption,
+ writeVecBytes,
+ concatBytes,
+} from './borsh-helpers.js';
+
+import type {
+ CompressedProof,
+ ExtensionInstructionData,
+} from './types.js';
+
+import { encodeExtensionInstructionData } from './transfer2.js';
+import { DISCRIMINATOR } from '../constants.js';
+
+// ============================================================================
+// MINT ACTION TYPES
+// ============================================================================
+
+/** Recipient for MintToCompressed action. */
+export interface MintRecipient {
+ /** Recipient pubkey (32 bytes). */
+ recipient: ReadonlyUint8Array;
+ /** Amount to mint. */
+ amount: bigint;
+}
+
+/** Mint compressed tokens to compressed accounts. */
+export interface MintToCompressedAction {
+ type: 'MintToCompressed';
+ /** Token account version. */
+ tokenAccountVersion: number;
+ /** Recipients to mint to. */
+ recipients: MintRecipient[];
+}
+
+/** Mint tokens from a compressed mint to a token Solana account. */
+export interface MintToAction {
+ type: 'MintTo';
+ /** Index into remaining accounts for the recipient token account. */
+ accountIndex: number;
+ /** Amount to mint. */
+ amount: bigint;
+}
+
+/** Update mint authority or freeze authority of a compressed mint. */
+export interface UpdateAuthorityAction {
+ type: 'UpdateMintAuthority' | 'UpdateFreezeAuthority';
+ /** New authority (32 bytes), or null to revoke. */
+ newAuthority: ReadonlyUint8Array | null;
+}
+
+/** Update a metadata field on a compressed mint. */
+export interface UpdateMetadataFieldAction {
+ type: 'UpdateMetadataField';
+ /** Index of the TokenMetadata extension in the extensions array. */
+ extensionIndex: number;
+ /** Field type: 0=Name, 1=Symbol, 2=Uri, 3=Custom key. */
+ fieldType: number;
+ /** Empty for Name/Symbol/Uri, key string bytes for custom fields. */
+ key: ReadonlyUint8Array;
+ /** UTF-8 encoded value. */
+ value: ReadonlyUint8Array;
+}
+
+/** Update metadata authority on a compressed mint. */
+export interface UpdateMetadataAuthorityAction {
+ type: 'UpdateMetadataAuthority';
+ /** Index of the TokenMetadata extension in the extensions array. */
+ extensionIndex: number;
+ /** New authority (use zero bytes to set to None). */
+ newAuthority: ReadonlyUint8Array;
+}
+
+/** Remove a metadata key from a compressed mint. */
+export interface RemoveMetadataKeyAction {
+ type: 'RemoveMetadataKey';
+ /** Index of the TokenMetadata extension in the extensions array. */
+ extensionIndex: number;
+ /** UTF-8 encoded key to remove. */
+ key: ReadonlyUint8Array;
+ /** 0=false, 1=true - don't error if key doesn't exist. */
+ idempotent: number;
+}
+
+/** Decompress a compressed mint to a Mint Solana account. */
+export interface DecompressMintAction {
+ type: 'DecompressMint';
+ /** Rent payment in epochs (prepaid, must be >= 2). */
+ rentPayment: number;
+ /** Lamports allocated for future write operations. */
+ writeTopUp: number;
+}
+
+/** Compress and close a Mint Solana account. */
+export interface CompressAndCloseMintAction {
+ type: 'CompressAndCloseMint';
+ /** If non-zero, succeed silently when Mint doesn't exist or cannot be compressed. */
+ idempotent: number;
+}
+
+/** Union of all MintAction variants. */
+export type MintAction =
+ | MintToCompressedAction
+ | UpdateAuthorityAction
+ | MintToAction
+ | UpdateMetadataFieldAction
+ | UpdateMetadataAuthorityAction
+ | RemoveMetadataKeyAction
+ | DecompressMintAction
+ | CompressAndCloseMintAction;
+
+// ============================================================================
+// CREATE MINT & MINT METADATA
+// ============================================================================
+
+/** CreateMint data for new mint creation. */
+export interface CreateMint {
+ /** Placeholder for address trees (4 bytes, currently all zeros). */
+ readOnlyAddressTrees: Uint8Array;
+ /** Placeholder for root indices (4 x u16, currently all zeros). */
+ readOnlyAddressTreeRootIndices: number[];
+}
+
+/** Light Protocol-specific metadata for compressed mints. */
+export interface MintMetadata {
+ /** Version for upgradability. */
+ version: number;
+ /** Whether the mint has been decompressed. */
+ mintDecompressed: boolean;
+ /** PDA derived from mintSigner (32 bytes). */
+ mint: ReadonlyUint8Array;
+ /** Signer pubkey used to derive the mint PDA (32 bytes). */
+ mintSigner: ReadonlyUint8Array;
+ /** Bump seed from mint PDA derivation. */
+ bump: number;
+}
+
+/** Mint instruction data for creating or updating a mint. */
+export interface MintInstructionData {
+ /** Total supply of tokens. */
+ supply: bigint;
+ /** Number of base 10 digits to the right of the decimal place. */
+ decimals: number;
+ /** Light Protocol-specific metadata. */
+ metadata: MintMetadata;
+ /** Optional mint authority (32 bytes). */
+ mintAuthority: ReadonlyUint8Array | null;
+ /** Optional freeze authority (32 bytes). */
+ freezeAuthority: ReadonlyUint8Array | null;
+ /** Optional extensions for additional functionality. */
+ extensions: ExtensionInstructionData[] | null;
+}
+
+// ============================================================================
+// CPI CONTEXT
+// ============================================================================
+
+/** CPI context for mint action operations. */
+export interface MintActionCpiContext {
+ /** Whether to set the CPI context. */
+ setContext: boolean;
+ /** Whether this is the first set context call. */
+ firstSetContext: boolean;
+ /** Address tree index if create mint. */
+ inTreeIndex: number;
+ /** Input queue index. */
+ inQueueIndex: number;
+ /** Output queue index. */
+ outQueueIndex: number;
+ /** Token output queue index. */
+ tokenOutQueueIndex: number;
+ /** Index of the compressed account that should receive the new address. */
+ assignedAccountIndex: number;
+ /** Placeholder for multiple address trees (4 bytes). */
+ readOnlyAddressTrees: Uint8Array;
+ /** Address tree pubkey (32 bytes). */
+ addressTreePubkey: ReadonlyUint8Array;
+}
+
+// ============================================================================
+// TOP-LEVEL INSTRUCTION DATA
+// ============================================================================
+
+/** Full MintAction instruction data (discriminator 103). */
+export interface MintActionInstructionData {
+ /** Leaf index in the merkle tree (only set if mint already exists). */
+ leafIndex: number;
+ /** Whether to prove by index (only set if mint already exists). */
+ proveByIndex: boolean;
+ /** Root index for address or validity proof. */
+ rootIndex: number;
+ /** Maximum lamports for rent and top-up (u16::MAX = no limit, 0 = no top-ups). */
+ maxTopUp: number;
+ /** Only set when creating a new mint. */
+ createMint: CreateMint | null;
+ /** Actions to perform on the mint. */
+ actions: MintAction[];
+ /** Validity proof (optional). */
+ proof: CompressedProof | null;
+ /** CPI context (optional). */
+ cpiContext: MintActionCpiContext | null;
+ /** Mint data (optional, for create or full state). */
+ mint: MintInstructionData | null;
+}
+
+// ============================================================================
+// ACTION ENCODERS
+// ============================================================================
+
+/**
+ * Borsh enum variant indices for the Action enum, matching the Rust definition.
+ */
+const ACTION_VARIANT = {
+ MintToCompressed: 0,
+ UpdateMintAuthority: 1,
+ UpdateFreezeAuthority: 2,
+ MintTo: 3,
+ UpdateMetadataField: 4,
+ UpdateMetadataAuthority: 5,
+ RemoveMetadataKey: 6,
+ DecompressMint: 7,
+ CompressAndCloseMint: 8,
+} as const;
+
+function encodeMintRecipient(r: MintRecipient): Uint8Array {
+ return concatBytes([
+ new Uint8Array(r.recipient),
+ writeU64(r.amount),
+ ]);
+}
+
+function encodeMintToCompressedAction(
+ action: MintToCompressedAction,
+): Uint8Array {
+ const parts: Uint8Array[] = [
+ writeU8(action.tokenAccountVersion),
+ writeU32(action.recipients.length),
+ ];
+ for (const r of action.recipients) {
+ parts.push(encodeMintRecipient(r));
+ }
+ return concatBytes(parts);
+}
+
+function encodeUpdateAuthority(newAuthority: ReadonlyUint8Array | null): Uint8Array {
+ return writeOption(newAuthority, (v: ReadonlyUint8Array) =>
+ new Uint8Array(v),
+ );
+}
+
+function encodeMintToAction(action: MintToAction): Uint8Array {
+ return concatBytes([
+ writeU8(action.accountIndex),
+ writeU64(action.amount),
+ ]);
+}
+
+function encodeUpdateMetadataFieldAction(
+ action: UpdateMetadataFieldAction,
+): Uint8Array {
+ return concatBytes([
+ writeU8(action.extensionIndex),
+ writeU8(action.fieldType),
+ writeVecBytes(action.key),
+ writeVecBytes(action.value),
+ ]);
+}
+
+function encodeUpdateMetadataAuthorityAction(
+ action: UpdateMetadataAuthorityAction,
+): Uint8Array {
+ return concatBytes([
+ writeU8(action.extensionIndex),
+ new Uint8Array(action.newAuthority),
+ ]);
+}
+
+function encodeRemoveMetadataKeyAction(
+ action: RemoveMetadataKeyAction,
+): Uint8Array {
+ return concatBytes([
+ writeU8(action.extensionIndex),
+ writeVecBytes(action.key),
+ writeU8(action.idempotent),
+ ]);
+}
+
+function encodeDecompressMintAction(
+ action: DecompressMintAction,
+): Uint8Array {
+ return concatBytes([
+ writeU8(action.rentPayment),
+ writeU32(action.writeTopUp),
+ ]);
+}
+
+function encodeCompressAndCloseMintAction(
+ action: CompressAndCloseMintAction,
+): Uint8Array {
+ return writeU8(action.idempotent);
+}
+
+function encodeAction(action: MintAction): Uint8Array {
+ switch (action.type) {
+ case 'MintToCompressed':
+ return concatBytes([
+ writeU8(ACTION_VARIANT.MintToCompressed),
+ encodeMintToCompressedAction(action),
+ ]);
+ case 'UpdateMintAuthority':
+ return concatBytes([
+ writeU8(ACTION_VARIANT.UpdateMintAuthority),
+ encodeUpdateAuthority(action.newAuthority),
+ ]);
+ case 'UpdateFreezeAuthority':
+ return concatBytes([
+ writeU8(ACTION_VARIANT.UpdateFreezeAuthority),
+ encodeUpdateAuthority(action.newAuthority),
+ ]);
+ case 'MintTo':
+ return concatBytes([
+ writeU8(ACTION_VARIANT.MintTo),
+ encodeMintToAction(action),
+ ]);
+ case 'UpdateMetadataField':
+ return concatBytes([
+ writeU8(ACTION_VARIANT.UpdateMetadataField),
+ encodeUpdateMetadataFieldAction(action),
+ ]);
+ case 'UpdateMetadataAuthority':
+ return concatBytes([
+ writeU8(ACTION_VARIANT.UpdateMetadataAuthority),
+ encodeUpdateMetadataAuthorityAction(action),
+ ]);
+ case 'RemoveMetadataKey':
+ return concatBytes([
+ writeU8(ACTION_VARIANT.RemoveMetadataKey),
+ encodeRemoveMetadataKeyAction(action),
+ ]);
+ case 'DecompressMint':
+ return concatBytes([
+ writeU8(ACTION_VARIANT.DecompressMint),
+ encodeDecompressMintAction(action),
+ ]);
+ case 'CompressAndCloseMint':
+ return concatBytes([
+ writeU8(ACTION_VARIANT.CompressAndCloseMint),
+ encodeCompressAndCloseMintAction(action),
+ ]);
+ }
+}
+
+// ============================================================================
+// STRUCT ENCODERS
+// ============================================================================
+
+function encodeCreateMint(data: CreateMint): Uint8Array {
+ const parts: Uint8Array[] = [
+ new Uint8Array(data.readOnlyAddressTrees),
+ ];
+ for (const idx of data.readOnlyAddressTreeRootIndices) {
+ parts.push(writeU16(idx));
+ }
+ return concatBytes(parts);
+}
+
+function encodeMintMetadata(data: MintMetadata): Uint8Array {
+ return concatBytes([
+ writeU8(data.version),
+ writeBool(data.mintDecompressed),
+ new Uint8Array(data.mint),
+ new Uint8Array(data.mintSigner),
+ writeU8(data.bump),
+ ]);
+}
+
+function encodeMintInstructionData(data: MintInstructionData): Uint8Array {
+ const parts: Uint8Array[] = [
+ writeU64(data.supply),
+ writeU8(data.decimals),
+ encodeMintMetadata(data.metadata),
+ writeOption(data.mintAuthority, (v: ReadonlyUint8Array) =>
+ new Uint8Array(v),
+ ),
+ writeOption(data.freezeAuthority, (v: ReadonlyUint8Array) =>
+ new Uint8Array(v),
+ ),
+ ];
+
+ // Option>
+ if (data.extensions === null) {
+ parts.push(new Uint8Array([0]));
+ } else {
+ parts.push(new Uint8Array([1]));
+ parts.push(writeU32(data.extensions.length));
+ for (const ext of data.extensions) {
+ parts.push(encodeExtensionInstructionData(ext));
+ }
+ }
+
+ return concatBytes(parts);
+}
+
+function encodeCompressedProof(proof: CompressedProof): Uint8Array {
+ return concatBytes([
+ new Uint8Array(proof.a),
+ new Uint8Array(proof.b),
+ new Uint8Array(proof.c),
+ ]);
+}
+
+function encodeMintActionCpiContext(
+ ctx: MintActionCpiContext,
+): Uint8Array {
+ return concatBytes([
+ writeBool(ctx.setContext),
+ writeBool(ctx.firstSetContext),
+ writeU8(ctx.inTreeIndex),
+ writeU8(ctx.inQueueIndex),
+ writeU8(ctx.outQueueIndex),
+ writeU8(ctx.tokenOutQueueIndex),
+ writeU8(ctx.assignedAccountIndex),
+ new Uint8Array(ctx.readOnlyAddressTrees),
+ new Uint8Array(ctx.addressTreePubkey),
+ ]);
+}
+
+// ============================================================================
+// TOP-LEVEL ENCODER
+// ============================================================================
+
+/**
+ * Encodes the full MintAction instruction data including discriminator (103).
+ *
+ * Borsh layout:
+ * - discriminator: u8 (103)
+ * - leaf_index: u32
+ * - prove_by_index: bool
+ * - root_index: u16
+ * - max_top_up: u16
+ * - create_mint: Option
+ * - actions: Vec
+ * - proof: Option
+ * - cpi_context: Option
+ * - mint: Option
+ */
+export function encodeMintActionInstructionData(
+ data: MintActionInstructionData,
+): Uint8Array {
+ const parts: Uint8Array[] = [
+ // Discriminator
+ writeU8(DISCRIMINATOR.MINT_ACTION),
+
+ // Base fields
+ writeU32(data.leafIndex),
+ writeBool(data.proveByIndex),
+ writeU16(data.rootIndex),
+ writeU16(data.maxTopUp),
+
+ // Option
+ writeOption(data.createMint, encodeCreateMint),
+
+ // Vec
+ writeU32(data.actions.length),
+ ];
+
+ for (const action of data.actions) {
+ parts.push(encodeAction(action));
+ }
+
+ // Option
+ parts.push(writeOption(data.proof, encodeCompressedProof));
+ parts.push(writeOption(data.cpiContext, encodeMintActionCpiContext));
+ parts.push(writeOption(data.mint, encodeMintInstructionData));
+
+ return concatBytes(parts);
+}
diff --git a/js/token-kit/src/codecs/mint-deserialize.ts b/js/token-kit/src/codecs/mint-deserialize.ts
new file mode 100644
index 0000000000..297c12c9a0
--- /dev/null
+++ b/js/token-kit/src/codecs/mint-deserialize.ts
@@ -0,0 +1,130 @@
+/**
+ * Lightweight compressed mint account deserializer.
+ *
+ * Parses raw bytes from a compressed mint account using DataView.
+ * No external dependencies needed — follows the same manual Borsh
+ * pattern as queries.ts getMintInterface.
+ */
+
+import { EXTENSION_DISCRIMINANT } from '../constants.js';
+
+// ============================================================================
+// TYPES
+// ============================================================================
+
+/** Base SPL mint fields (82 bytes). */
+export interface BaseMint {
+ mintAuthorityOption: number;
+ mintAuthority: Uint8Array;
+ supply: bigint;
+ decimals: number;
+ isInitialized: boolean;
+ freezeAuthorityOption: number;
+ freezeAuthority: Uint8Array;
+}
+
+/** Light Protocol-specific mint context following the base mint. */
+export interface DeserializedMintContext {
+ version: number;
+ cmintDecompressed: boolean;
+ splMint: Uint8Array;
+ mintSigner: Uint8Array;
+ bump: number;
+}
+
+/** Full deserialized compressed mint. */
+export interface DeserializedCompressedMint {
+ base: BaseMint;
+ mintContext: DeserializedMintContext;
+ /** Index of the TokenMetadata extension in extensions array, or -1. */
+ metadataExtensionIndex: number;
+}
+
+// ============================================================================
+// DESERIALIZER
+// ============================================================================
+
+/**
+ * Deserializes a compressed mint account from raw bytes.
+ *
+ * Layout:
+ * BaseMint (82 bytes):
+ * 0-3: mintAuthorityOption (u32 LE)
+ * 4-35: mintAuthority (32 bytes)
+ * 36-43: supply (u64 LE)
+ * 44: decimals (u8)
+ * 45: isInitialized (bool)
+ * 46-49: freezeAuthorityOption (u32 LE)
+ * 50-81: freezeAuthority (32 bytes)
+ *
+ * MintContext (67 bytes):
+ * 82: version (u8)
+ * 83: cmintDecompressed (bool)
+ * 84-115: splMint (32 bytes)
+ * 116-147: mintSigner (32 bytes)
+ * 148: bump (u8)
+ *
+ * Extensions (variable, starting at offset 149):
+ * Scanned for TokenMetadata (discriminant 19).
+ *
+ * @param data - Raw account data bytes
+ * @returns Deserialized compressed mint
+ * @throws Error if data is too short
+ */
+export function deserializeCompressedMint(
+ data: Uint8Array,
+): DeserializedCompressedMint {
+ if (data.length < 149) {
+ throw new Error(
+ `Compressed mint data too short: ${data.length} bytes, need at least 149`,
+ );
+ }
+
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
+
+ // BaseMint (82 bytes)
+ const base: BaseMint = {
+ mintAuthorityOption: view.getUint32(0, true),
+ mintAuthority: data.slice(4, 36),
+ supply: view.getBigUint64(36, true),
+ decimals: data[44],
+ isInitialized: data[45] !== 0,
+ freezeAuthorityOption: view.getUint32(46, true),
+ freezeAuthority: data.slice(50, 82),
+ };
+
+ // MintContext (67 bytes starting at offset 82)
+ const mintContext: DeserializedMintContext = {
+ version: data[82],
+ cmintDecompressed: data[83] !== 0,
+ splMint: data.slice(84, 116),
+ mintSigner: data.slice(116, 148),
+ bump: data[148],
+ };
+
+ // Scan extensions for TokenMetadata (discriminant 19)
+ let metadataExtensionIndex = -1;
+ if (data.length > 149) {
+ // Extensions are a Vec: first 4 bytes = length (u32 LE)
+ const extOffset = 149;
+ if (data.length >= extOffset + 4) {
+ const extCount = view.getUint32(extOffset, true);
+ let pos = extOffset + 4;
+ for (let i = 0; i < extCount && pos < data.length; i++) {
+ // Each extension starts with a discriminant (u16 LE)
+ if (pos + 2 > data.length) break;
+ const disc = view.getUint16(pos, true);
+ if (disc === EXTENSION_DISCRIMINANT.TOKEN_METADATA) {
+ metadataExtensionIndex = i;
+ break;
+ }
+ // Skip this extension — we don't know the exact size of every
+ // variant, so we stop scanning after finding metadata or not.
+ // For builders that need extensionIndex, -1 means "not found".
+ break;
+ }
+ }
+ }
+
+ return { base, mintContext, metadataExtensionIndex };
+}
diff --git a/js/token-kit/src/codecs/transfer2.ts b/js/token-kit/src/codecs/transfer2.ts
new file mode 100644
index 0000000000..05a8ea3030
--- /dev/null
+++ b/js/token-kit/src/codecs/transfer2.ts
@@ -0,0 +1,490 @@
+/**
+ * Transfer2 instruction codecs using Solana Kit patterns.
+ */
+
+import {
+ type Codec,
+ type Decoder,
+ type Encoder,
+ combineCodec,
+ getStructDecoder,
+ getStructEncoder,
+ getU8Decoder,
+ getU8Encoder,
+ getU16Decoder,
+ getU16Encoder,
+ getU32Decoder,
+ getU32Encoder,
+ getU64Decoder,
+ getU64Encoder,
+ getBooleanDecoder,
+ getBooleanEncoder,
+ getBytesDecoder,
+ getBytesEncoder,
+ getOptionEncoder,
+ getOptionDecoder,
+ fixEncoderSize,
+ fixDecoderSize,
+} from '@solana/codecs';
+
+import type { Address } from '@solana/addresses';
+import { getAddressCodec } from '@solana/addresses';
+import type { ReadonlyUint8Array } from '@solana/codecs';
+
+import {
+ writeU16,
+ writeU32,
+ writeU64,
+ writeBool,
+ writeOption,
+ writeVecBytes,
+ concatBytes,
+ getVecEncoder,
+ getVecDecoder,
+} from './borsh-helpers.js';
+
+import type {
+ Compression,
+ PackedMerkleContext,
+ MultiInputTokenDataWithContext,
+ MultiTokenTransferOutputData,
+ CompressedCpiContext,
+ CompressedProof,
+ Transfer2InstructionData,
+ ExtensionInstructionData,
+ TokenMetadataExtension,
+ CompressedOnlyExtension,
+ CompressionInfo,
+ RentConfig,
+} from './types.js';
+
+import { DISCRIMINATOR, EXTENSION_DISCRIMINANT } from '../constants.js';
+
+// ============================================================================
+// COMPRESSION CODEC
+// ============================================================================
+
+export const getCompressionEncoder = (): Encoder =>
+ getStructEncoder([
+ ['mode', getU8Encoder()],
+ ['amount', getU64Encoder()],
+ ['mint', getU8Encoder()],
+ ['sourceOrRecipient', getU8Encoder()],
+ ['authority', getU8Encoder()],
+ ['poolAccountIndex', getU8Encoder()],
+ ['poolIndex', getU8Encoder()],
+ ['bump', getU8Encoder()],
+ ['decimals', getU8Encoder()],
+ ]);
+
+export const getCompressionDecoder = (): Decoder =>
+ getStructDecoder([
+ ['mode', getU8Decoder()],
+ ['amount', getU64Decoder()],
+ ['mint', getU8Decoder()],
+ ['sourceOrRecipient', getU8Decoder()],
+ ['authority', getU8Decoder()],
+ ['poolAccountIndex', getU8Decoder()],
+ ['poolIndex', getU8Decoder()],
+ ['bump', getU8Decoder()],
+ ['decimals', getU8Decoder()],
+ ]);
+
+export const getCompressionCodec = (): Codec =>
+ combineCodec(getCompressionEncoder(), getCompressionDecoder());
+
+// ============================================================================
+// PACKED MERKLE CONTEXT CODEC
+// ============================================================================
+
+export const getPackedMerkleContextEncoder = (): Encoder =>
+ getStructEncoder([
+ ['merkleTreePubkeyIndex', getU8Encoder()],
+ ['queuePubkeyIndex', getU8Encoder()],
+ ['leafIndex', getU32Encoder()],
+ ['proveByIndex', getBooleanEncoder()],
+ ]);
+
+export const getPackedMerkleContextDecoder = (): Decoder =>
+ getStructDecoder([
+ ['merkleTreePubkeyIndex', getU8Decoder()],
+ ['queuePubkeyIndex', getU8Decoder()],
+ ['leafIndex', getU32Decoder()],
+ ['proveByIndex', getBooleanDecoder()],
+ ]);
+
+export const getPackedMerkleContextCodec = (): Codec =>
+ combineCodec(
+ getPackedMerkleContextEncoder(),
+ getPackedMerkleContextDecoder(),
+ );
+
+// ============================================================================
+// INPUT TOKEN DATA CODEC
+// ============================================================================
+
+export const getMultiInputTokenDataEncoder =
+ (): Encoder =>
+ getStructEncoder([
+ ['owner', getU8Encoder()],
+ ['amount', getU64Encoder()],
+ ['hasDelegate', getBooleanEncoder()],
+ ['delegate', getU8Encoder()],
+ ['mint', getU8Encoder()],
+ ['version', getU8Encoder()],
+ ['merkleContext', getPackedMerkleContextEncoder()],
+ ['rootIndex', getU16Encoder()],
+ ]);
+
+export const getMultiInputTokenDataDecoder =
+ (): Decoder =>
+ getStructDecoder([
+ ['owner', getU8Decoder()],
+ ['amount', getU64Decoder()],
+ ['hasDelegate', getBooleanDecoder()],
+ ['delegate', getU8Decoder()],
+ ['mint', getU8Decoder()],
+ ['version', getU8Decoder()],
+ ['merkleContext', getPackedMerkleContextDecoder()],
+ ['rootIndex', getU16Decoder()],
+ ]);
+
+export const getMultiInputTokenDataCodec =
+ (): Codec =>
+ combineCodec(
+ getMultiInputTokenDataEncoder(),
+ getMultiInputTokenDataDecoder(),
+ );
+
+// ============================================================================
+// OUTPUT TOKEN DATA CODEC
+// ============================================================================
+
+export const getMultiTokenOutputDataEncoder =
+ (): Encoder =>
+ getStructEncoder([
+ ['owner', getU8Encoder()],
+ ['amount', getU64Encoder()],
+ ['hasDelegate', getBooleanEncoder()],
+ ['delegate', getU8Encoder()],
+ ['mint', getU8Encoder()],
+ ['version', getU8Encoder()],
+ ]);
+
+export const getMultiTokenOutputDataDecoder =
+ (): Decoder =>
+ getStructDecoder([
+ ['owner', getU8Decoder()],
+ ['amount', getU64Decoder()],
+ ['hasDelegate', getBooleanDecoder()],
+ ['delegate', getU8Decoder()],
+ ['mint', getU8Decoder()],
+ ['version', getU8Decoder()],
+ ]);
+
+export const getMultiTokenOutputDataCodec =
+ (): Codec =>
+ combineCodec(
+ getMultiTokenOutputDataEncoder(),
+ getMultiTokenOutputDataDecoder(),
+ );
+
+// ============================================================================
+// CPI CONTEXT CODEC
+// ============================================================================
+
+export const getCpiContextEncoder = (): Encoder =>
+ getStructEncoder([
+ ['setContext', getBooleanEncoder()],
+ ['firstSetContext', getBooleanEncoder()],
+ ]);
+
+export const getCpiContextDecoder = (): Decoder =>
+ getStructDecoder([
+ ['setContext', getBooleanDecoder()],
+ ['firstSetContext', getBooleanDecoder()],
+ ]);
+
+export const getCpiContextCodec = (): Codec =>
+ combineCodec(getCpiContextEncoder(), getCpiContextDecoder());
+
+// ============================================================================
+// PROOF CODEC
+// ============================================================================
+
+export const getCompressedProofEncoder = (): Encoder =>
+ getStructEncoder([
+ ['a', fixEncoderSize(getBytesEncoder(), 32)],
+ ['b', fixEncoderSize(getBytesEncoder(), 64)],
+ ['c', fixEncoderSize(getBytesEncoder(), 32)],
+ ]);
+
+export const getCompressedProofDecoder = (): Decoder =>
+ getStructDecoder([
+ ['a', fixDecoderSize(getBytesDecoder(), 32)],
+ ['b', fixDecoderSize(getBytesDecoder(), 64)],
+ ['c', fixDecoderSize(getBytesDecoder(), 32)],
+ ]);
+
+export const getCompressedProofCodec = (): Codec =>
+ combineCodec(getCompressedProofEncoder(), getCompressedProofDecoder());
+
+// ============================================================================
+// TRANSFER2 INSTRUCTION DATA CODEC (Base fields only)
+// Note: TLV fields require manual serialization due to complex nested structures
+// ============================================================================
+
+/**
+ * Base Transfer2 instruction data (without TLV fields).
+ */
+export interface Transfer2BaseInstructionData {
+ withTransactionHash: boolean;
+ withLamportsChangeAccountMerkleTreeIndex: boolean;
+ lamportsChangeAccountMerkleTreeIndex: number;
+ lamportsChangeAccountOwnerIndex: number;
+ outputQueue: number;
+ maxTopUp: number;
+ cpiContext: CompressedCpiContext | null;
+ compressions: readonly Compression[] | null;
+ proof: CompressedProof | null;
+ inTokenData: readonly MultiInputTokenDataWithContext[];
+ outTokenData: readonly MultiTokenTransferOutputData[];
+ inLamports: readonly bigint[] | null;
+ outLamports: readonly bigint[] | null;
+}
+
+// The encoder/decoder use `as unknown` casts because Kit's getOptionEncoder
+// accepts OptionOrNullable (broader than T | null) and getOptionDecoder
+// returns Option (narrower than T | null). The binary format is correct;
+// the casts bridge the Rust Option ↔ TypeScript T | null mismatch.
+export const getTransfer2BaseEncoder =
+ (): Encoder =>
+ getStructEncoder([
+ ['withTransactionHash', getBooleanEncoder()],
+ ['withLamportsChangeAccountMerkleTreeIndex', getBooleanEncoder()],
+ ['lamportsChangeAccountMerkleTreeIndex', getU8Encoder()],
+ ['lamportsChangeAccountOwnerIndex', getU8Encoder()],
+ ['outputQueue', getU8Encoder()],
+ ['maxTopUp', getU16Encoder()],
+ ['cpiContext', getOptionEncoder(getCpiContextEncoder())],
+ [
+ 'compressions',
+ getOptionEncoder(getVecEncoder(getCompressionEncoder())),
+ ],
+ ['proof', getOptionEncoder(getCompressedProofEncoder())],
+ ['inTokenData', getVecEncoder(getMultiInputTokenDataEncoder())],
+ ['outTokenData', getVecEncoder(getMultiTokenOutputDataEncoder())],
+ ['inLamports', getOptionEncoder(getVecEncoder(getU64Encoder()))],
+ ['outLamports', getOptionEncoder(getVecEncoder(getU64Encoder()))],
+ ]) as unknown as Encoder;
+
+export const getTransfer2BaseDecoder =
+ (): Decoder =>
+ getStructDecoder([
+ ['withTransactionHash', getBooleanDecoder()],
+ ['withLamportsChangeAccountMerkleTreeIndex', getBooleanDecoder()],
+ ['lamportsChangeAccountMerkleTreeIndex', getU8Decoder()],
+ ['lamportsChangeAccountOwnerIndex', getU8Decoder()],
+ ['outputQueue', getU8Decoder()],
+ ['maxTopUp', getU16Decoder()],
+ ['cpiContext', getOptionDecoder(getCpiContextDecoder())],
+ [
+ 'compressions',
+ getOptionDecoder(getVecDecoder(getCompressionDecoder())),
+ ],
+ ['proof', getOptionDecoder(getCompressedProofDecoder())],
+ ['inTokenData', getVecDecoder(getMultiInputTokenDataDecoder())],
+ ['outTokenData', getVecDecoder(getMultiTokenOutputDataDecoder())],
+ ['inLamports', getOptionDecoder(getVecDecoder(getU64Decoder()))],
+ ['outLamports', getOptionDecoder(getVecDecoder(getU64Decoder()))],
+ ]) as unknown as Decoder;
+
+// ============================================================================
+// TRANSFER2 FULL ENCODER (with discriminator and TLV fields)
+// ============================================================================
+
+/**
+ * Encodes the full Transfer2 instruction data including discriminator and TLV.
+ */
+export function encodeTransfer2InstructionData(
+ data: Transfer2InstructionData,
+): Uint8Array {
+ const baseEncoder = getTransfer2BaseEncoder();
+
+ // Encode base data
+ const baseData: Transfer2BaseInstructionData = {
+ withTransactionHash: data.withTransactionHash,
+ withLamportsChangeAccountMerkleTreeIndex:
+ data.withLamportsChangeAccountMerkleTreeIndex,
+ lamportsChangeAccountMerkleTreeIndex:
+ data.lamportsChangeAccountMerkleTreeIndex,
+ lamportsChangeAccountOwnerIndex: data.lamportsChangeAccountOwnerIndex,
+ outputQueue: data.outputQueue,
+ maxTopUp: data.maxTopUp,
+ cpiContext: data.cpiContext,
+ compressions: data.compressions,
+ proof: data.proof,
+ inTokenData: data.inTokenData,
+ outTokenData: data.outTokenData,
+ inLamports: data.inLamports,
+ outLamports: data.outLamports,
+ };
+
+ const baseBytes = baseEncoder.encode(baseData);
+
+ // Encode TLV fields (Option>>)
+ const inTlvBytes = encodeTlv(data.inTlv);
+ const outTlvBytes = encodeTlv(data.outTlv);
+
+ // Combine: discriminator + base + inTlv + outTlv
+ const result = new Uint8Array(
+ 1 + baseBytes.length + inTlvBytes.length + outTlvBytes.length,
+ );
+ result[0] = DISCRIMINATOR.TRANSFER2;
+ result.set(baseBytes, 1);
+ result.set(inTlvBytes, 1 + baseBytes.length);
+ result.set(outTlvBytes, 1 + baseBytes.length + inTlvBytes.length);
+
+ return result;
+}
+
+/**
+ * Encodes TLV data as Option>>.
+ *
+ * Borsh format:
+ * - None: [0x00]
+ * - Some: [0x01] [outer_len: u32] [inner_vec_0] [inner_vec_1] ...
+ * where each inner_vec = [len: u32] [ext_0] [ext_1] ...
+ * and each ext = [discriminant: u8] [data...]
+ *
+ * Extension discriminants match Rust enum variant indices:
+ * - 19: TokenMetadata
+ * - 31: CompressedOnly
+ * - 32: Compressible
+ */
+function encodeTlv(
+ tlv: ExtensionInstructionData[][] | null,
+): Uint8Array {
+ if (tlv === null) {
+ return new Uint8Array([0]);
+ }
+
+ const chunks: Uint8Array[] = [];
+
+ // Option::Some
+ chunks.push(new Uint8Array([1]));
+
+ // Outer vec length (u32)
+ chunks.push(writeU32(tlv.length));
+
+ for (const innerVec of tlv) {
+ // Inner vec length (u32)
+ chunks.push(writeU32(innerVec.length));
+
+ for (const ext of innerVec) {
+ chunks.push(encodeExtensionInstructionData(ext));
+ }
+ }
+
+ return concatBytes(chunks);
+}
+
+/**
+ * Encodes a single ExtensionInstructionData with its Borsh enum discriminant.
+ */
+export function encodeExtensionInstructionData(
+ ext: ExtensionInstructionData,
+): Uint8Array {
+ switch (ext.type) {
+ case 'TokenMetadata':
+ return concatBytes([
+ new Uint8Array([EXTENSION_DISCRIMINANT.TOKEN_METADATA]),
+ encodeTokenMetadata(ext.data),
+ ]);
+ case 'PausableAccount':
+ return new Uint8Array([EXTENSION_DISCRIMINANT.PAUSABLE_ACCOUNT]);
+ case 'PermanentDelegateAccount':
+ return new Uint8Array([EXTENSION_DISCRIMINANT.PERMANENT_DELEGATE_ACCOUNT]);
+ case 'TransferFeeAccount':
+ return new Uint8Array([EXTENSION_DISCRIMINANT.TRANSFER_FEE_ACCOUNT]);
+ case 'TransferHookAccount':
+ return new Uint8Array([EXTENSION_DISCRIMINANT.TRANSFER_HOOK_ACCOUNT]);
+ case 'CompressedOnly':
+ return concatBytes([
+ new Uint8Array([EXTENSION_DISCRIMINANT.COMPRESSED_ONLY]),
+ encodeCompressedOnly(ext.data),
+ ]);
+ case 'Compressible':
+ return concatBytes([
+ new Uint8Array([EXTENSION_DISCRIMINANT.COMPRESSIBLE]),
+ encodeCompressionInfo(ext.data),
+ ]);
+ }
+}
+
+function encodeTokenMetadata(data: TokenMetadataExtension): Uint8Array {
+ const chunks: Uint8Array[] = [];
+
+ // Option - update_authority
+ chunks.push(
+ writeOption(data.updateAuthority, (v: Address) =>
+ new Uint8Array(getAddressCodec().encode(v)),
+ ),
+ );
+
+ // Vec fields
+ chunks.push(writeVecBytes(data.name));
+ chunks.push(writeVecBytes(data.symbol));
+ chunks.push(writeVecBytes(data.uri));
+
+ // Option>
+ chunks.push(
+ writeOption(data.additionalMetadata, (items: Array<{ key: ReadonlyUint8Array; value: ReadonlyUint8Array }>) => {
+ const parts: Uint8Array[] = [writeU32(items.length)];
+ for (const item of items) {
+ parts.push(writeVecBytes(item.key));
+ parts.push(writeVecBytes(item.value));
+ }
+ return concatBytes(parts);
+ }),
+ );
+
+ return concatBytes(chunks);
+}
+
+function encodeCompressedOnly(data: CompressedOnlyExtension): Uint8Array {
+ return concatBytes([
+ writeU64(data.delegatedAmount),
+ writeU64(data.withheldTransferFee),
+ writeBool(data.isFrozen),
+ new Uint8Array([data.compressionIndex]),
+ writeBool(data.isAta),
+ new Uint8Array([data.bump]),
+ new Uint8Array([data.ownerIndex]),
+ ]);
+}
+
+function encodeCompressionInfo(data: CompressionInfo): Uint8Array {
+ return concatBytes([
+ writeU16(data.configAccountVersion),
+ new Uint8Array([data.compressToPubkey]),
+ new Uint8Array([data.accountVersion]),
+ writeU32(data.lamportsPerWrite),
+ new Uint8Array(data.compressionAuthority),
+ new Uint8Array(data.rentSponsor),
+ writeU64(data.lastClaimedSlot),
+ writeU32(data.rentExemptionPaid),
+ writeU32(data.reserved),
+ encodeRentConfig(data.rentConfig),
+ ]);
+}
+
+function encodeRentConfig(data: RentConfig): Uint8Array {
+ return concatBytes([
+ writeU16(data.baseRent),
+ writeU16(data.compressionCost),
+ new Uint8Array([data.lamportsPerBytePerEpoch]),
+ new Uint8Array([data.maxFundedEpochs]),
+ writeU16(data.maxTopUp),
+ ]);
+}
diff --git a/js/token-kit/src/codecs/types.ts b/js/token-kit/src/codecs/types.ts
new file mode 100644
index 0000000000..0e93c6e6d4
--- /dev/null
+++ b/js/token-kit/src/codecs/types.ts
@@ -0,0 +1,320 @@
+/**
+ * Type definitions for Light Token codecs
+ */
+
+import type { Address } from '@solana/addresses';
+import type { ReadonlyUint8Array } from '@solana/codecs';
+
+// ============================================================================
+// COMPRESSION TYPES
+// ============================================================================
+
+/**
+ * Compression operation for Transfer2 instruction.
+ * Describes how to compress/decompress tokens.
+ */
+export interface Compression {
+ /** Compression mode: 0=compress, 1=decompress, 2=compress_and_close */
+ mode: number;
+ /** Amount to compress/decompress */
+ amount: bigint;
+ /** Index of mint in packed accounts */
+ mint: number;
+ /** Index of source (compress) or recipient (decompress) in packed accounts */
+ sourceOrRecipient: number;
+ /** Index of authority in packed accounts */
+ authority: number;
+ /** Index of pool account in packed accounts */
+ poolAccountIndex: number;
+ /** Pool index (for multi-pool mints) */
+ poolIndex: number;
+ /** PDA bump for pool derivation */
+ bump: number;
+ /** Token decimals (or rent_sponsor_is_signer flag for CompressAndClose) */
+ decimals: number;
+}
+
+// ============================================================================
+// MERKLE CONTEXT TYPES
+// ============================================================================
+
+/**
+ * Packed merkle context for compressed accounts.
+ */
+export interface PackedMerkleContext {
+ /** Index of merkle tree pubkey in packed accounts */
+ merkleTreePubkeyIndex: number;
+ /** Index of queue pubkey in packed accounts */
+ queuePubkeyIndex: number;
+ /** Leaf index in the merkle tree */
+ leafIndex: number;
+ /** Whether to prove by index (vs by hash) */
+ proveByIndex: boolean;
+}
+
+// ============================================================================
+// TOKEN DATA TYPES
+// ============================================================================
+
+/**
+ * Input token data with merkle context for Transfer2.
+ */
+export interface MultiInputTokenDataWithContext {
+ /** Index of owner in packed accounts */
+ owner: number;
+ /** Token amount */
+ amount: bigint;
+ /** Whether token has a delegate */
+ hasDelegate: boolean;
+ /** Index of delegate in packed accounts (if hasDelegate) */
+ delegate: number;
+ /** Index of mint in packed accounts */
+ mint: number;
+ /** Token account version */
+ version: number;
+ /** Merkle context for the compressed account */
+ merkleContext: PackedMerkleContext;
+ /** Root index for validity proof */
+ rootIndex: number;
+}
+
+/**
+ * Output token data for Transfer2.
+ */
+export interface MultiTokenTransferOutputData {
+ /** Index of owner in packed accounts */
+ owner: number;
+ /** Token amount */
+ amount: bigint;
+ /** Whether token has a delegate */
+ hasDelegate: boolean;
+ /** Index of delegate in packed accounts (if hasDelegate) */
+ delegate: number;
+ /** Index of mint in packed accounts */
+ mint: number;
+ /** Token account version */
+ version: number;
+}
+
+// ============================================================================
+// CPI CONTEXT
+// ============================================================================
+
+/**
+ * CPI context for compressed account operations.
+ */
+export interface CompressedCpiContext {
+ /** Whether to set the CPI context */
+ setContext: boolean;
+ /** Whether this is the first set context call */
+ firstSetContext: boolean;
+}
+
+// ============================================================================
+// PROOF TYPES
+// ============================================================================
+
+/**
+ * Groth16 proof for compressed account validity.
+ */
+export interface CompressedProof {
+ /** Proof element A (32 bytes) */
+ a: ReadonlyUint8Array;
+ /** Proof element B (64 bytes) */
+ b: ReadonlyUint8Array;
+ /** Proof element C (32 bytes) */
+ c: ReadonlyUint8Array;
+}
+
+// ============================================================================
+// EXTENSION TYPES
+// ============================================================================
+
+/**
+ * Token metadata extension data.
+ */
+export interface TokenMetadataExtension {
+ /** Update authority (optional) */
+ updateAuthority: Address | null;
+ /** Token name */
+ name: ReadonlyUint8Array;
+ /** Token symbol */
+ symbol: ReadonlyUint8Array;
+ /** Token URI */
+ uri: ReadonlyUint8Array;
+ /** Additional metadata key-value pairs */
+ additionalMetadata: Array<{
+ key: ReadonlyUint8Array;
+ value: ReadonlyUint8Array;
+ }> | null;
+}
+
+/**
+ * CompressedOnly extension data.
+ */
+export interface CompressedOnlyExtension {
+ /** Delegated amount */
+ delegatedAmount: bigint;
+ /** Withheld transfer fee */
+ withheldTransferFee: bigint;
+ /** Whether account is frozen */
+ isFrozen: boolean;
+ /** Compression index */
+ compressionIndex: number;
+ /** Whether this is an ATA */
+ isAta: boolean;
+ /** PDA bump */
+ bump: number;
+ /** Owner index in packed accounts */
+ ownerIndex: number;
+}
+
+/**
+ * Rent configuration for compressible accounts.
+ */
+export interface RentConfig {
+ /** Base rent in lamports */
+ baseRent: number;
+ /** Compression cost in lamports */
+ compressionCost: number;
+ /** Lamports per byte per epoch */
+ lamportsPerBytePerEpoch: number;
+ /** Maximum funded epochs */
+ maxFundedEpochs: number;
+ /** Maximum top-up amount */
+ maxTopUp: number;
+}
+
+/**
+ * Compression info for compressible accounts.
+ */
+export interface CompressionInfo {
+ /** Config account version */
+ configAccountVersion: number;
+ /** Compress-to pubkey type: 0=none, 1=owner, 2=custom */
+ compressToPubkey: number;
+ /** Account version */
+ accountVersion: number;
+ /** Lamports per write operation */
+ lamportsPerWrite: number;
+ /** Compression authority (32 bytes) */
+ compressionAuthority: Uint8Array;
+ /** Rent sponsor (32 bytes) */
+ rentSponsor: Uint8Array;
+ /** Last claimed slot */
+ lastClaimedSlot: bigint;
+ /** Rent exemption paid */
+ rentExemptionPaid: number;
+ /** Reserved bytes */
+ reserved: number;
+ /** Rent configuration */
+ rentConfig: RentConfig;
+}
+
+/**
+ * Extension instruction data (union type).
+ */
+export type ExtensionInstructionData =
+ | { type: 'TokenMetadata'; data: TokenMetadataExtension }
+ | { type: 'PausableAccount' }
+ | { type: 'PermanentDelegateAccount' }
+ | { type: 'TransferFeeAccount' }
+ | { type: 'TransferHookAccount' }
+ | { type: 'CompressedOnly'; data: CompressedOnlyExtension }
+ | { type: 'Compressible'; data: CompressionInfo };
+
+// ============================================================================
+// TRANSFER2 INSTRUCTION DATA
+// ============================================================================
+
+/**
+ * Full Transfer2 instruction data.
+ */
+export interface Transfer2InstructionData {
+ /** Whether to include transaction hash in hashing */
+ withTransactionHash: boolean;
+ /** Whether to include lamports change account merkle tree index */
+ withLamportsChangeAccountMerkleTreeIndex: boolean;
+ /** Merkle tree index for lamports change account */
+ lamportsChangeAccountMerkleTreeIndex: number;
+ /** Owner index for lamports change account */
+ lamportsChangeAccountOwnerIndex: number;
+ /** Output queue index */
+ outputQueue: number;
+ /** Maximum top-up for rent */
+ maxTopUp: number;
+ /** CPI context (optional) */
+ cpiContext: CompressedCpiContext | null;
+ /** Compression operations (optional) */
+ compressions: Compression[] | null;
+ /** Validity proof (optional) */
+ proof: CompressedProof | null;
+ /** Input token data */
+ inTokenData: MultiInputTokenDataWithContext[];
+ /** Output token data */
+ outTokenData: MultiTokenTransferOutputData[];
+ /** Input lamports (optional) */
+ inLamports: bigint[] | null;
+ /** Output lamports (optional) */
+ outLamports: bigint[] | null;
+ /** Input TLV extensions (optional) */
+ inTlv: ExtensionInstructionData[][] | null;
+ /** Output TLV extensions (optional) */
+ outTlv: ExtensionInstructionData[][] | null;
+}
+
+// ============================================================================
+// COMPRESSIBLE CONFIG TYPES
+// ============================================================================
+
+/**
+ * Compress-to pubkey configuration.
+ */
+export interface CompressToPubkey {
+ /** PDA bump */
+ bump: number;
+ /** Program ID for the PDA */
+ programId: ReadonlyUint8Array;
+ /** Seeds for the PDA */
+ seeds: ReadonlyUint8Array[];
+}
+
+/**
+ * Compressible extension instruction data for create instructions.
+ */
+export interface CompressibleExtensionInstructionData {
+ /** Token account version */
+ tokenAccountVersion: number;
+ /** Number of epochs to pre-pay rent */
+ rentPayment: number;
+ /** Compression only mode: 0=false, 1=true */
+ compressionOnly: number;
+ /** Lamports per write for top-up */
+ writeTopUp: number;
+ /** Compress-to pubkey configuration (optional) */
+ compressToPubkey: CompressToPubkey | null;
+}
+
+// ============================================================================
+// CREATE ACCOUNT TYPES
+// ============================================================================
+
+/**
+ * Create Associated Token Account instruction data.
+ * Note: bump is NOT included in instruction data — the on-chain program
+ * derives it via validate_ata_derivation.
+ */
+export interface CreateAtaInstructionData {
+ /** Compressible config (optional) */
+ compressibleConfig: CompressibleExtensionInstructionData | null;
+}
+
+/**
+ * Create Token Account instruction data.
+ */
+export interface CreateTokenAccountInstructionData {
+ /** Owner of the token account */
+ owner: Address;
+ /** Compressible config (optional) */
+ compressibleConfig: CompressibleExtensionInstructionData | null;
+}
diff --git a/js/token-kit/src/constants.ts b/js/token-kit/src/constants.ts
new file mode 100644
index 0000000000..e726b27718
--- /dev/null
+++ b/js/token-kit/src/constants.ts
@@ -0,0 +1,216 @@
+/**
+ * Light Protocol Token SDK Constants
+ */
+
+import { address, type Address } from '@solana/addresses';
+
+// ============================================================================
+// PROGRAM IDS
+// ============================================================================
+
+/** Light Token Program ID */
+export const LIGHT_TOKEN_PROGRAM_ID: Address = address(
+ 'cTokenmWW8bLPjZEBAUgYy3zKxQZW6VKi7bqNFEVv3m',
+);
+
+/** Light System Program ID */
+export const LIGHT_SYSTEM_PROGRAM_ID: Address = address(
+ 'SySTEM1eSU2p4BGQfQpimFEWWSC1XDFeun3Nqzz3rT7',
+);
+
+/** Account Compression Program ID */
+export const ACCOUNT_COMPRESSION_PROGRAM_ID: Address = address(
+ 'compr6CUsB5m2jS4Y3831ztGSTnDpnKJTKS95d64XVq',
+);
+
+/** SPL Token Program ID */
+export const SPL_TOKEN_PROGRAM_ID: Address = address(
+ 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA',
+);
+
+/** SPL Token 2022 Program ID */
+export const SPL_TOKEN_2022_PROGRAM_ID: Address = address(
+ 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb',
+);
+
+/** System Program ID */
+export const SYSTEM_PROGRAM_ID: Address = address(
+ '11111111111111111111111111111111',
+);
+
+// ============================================================================
+// KNOWN ACCOUNTS
+// ============================================================================
+
+/** CPI Authority - used for cross-program invocations */
+export const CPI_AUTHORITY: Address = address(
+ 'GXtd2izAiMJPwMEjfgTRH3d7k9mjn4Jq3JrWFv9gySYy',
+);
+
+/** Registered Program PDA - expected by Light system account parsing */
+export const REGISTERED_PROGRAM_PDA: Address = address(
+ '35hkDgaAKwMCaxRz2ocSZ6NaUrtKkyNqU6c4RV3tYJRh',
+);
+
+/** Account Compression Authority PDA - expected by Light system account parsing */
+export const ACCOUNT_COMPRESSION_AUTHORITY_PDA: Address = address(
+ 'HwXnGK3tPkkVY6P439H2p68AxpeuWXd5PcrAxFpbmfbA',
+);
+
+/** Mint Address Tree - default tree for compressed mint addresses */
+export const MINT_ADDRESS_TREE: Address = address(
+ 'amt2kaJA14v3urZbZvnc5v2np8jqvc4Z8zDep5wbtzx',
+);
+
+/** Native Mint (wrapped SOL) */
+export const NATIVE_MINT: Address = address(
+ 'So11111111111111111111111111111111111111112',
+);
+
+/** Default compressible config PDA (V1) */
+export const LIGHT_TOKEN_CONFIG: Address = address(
+ 'ACXg8a7VaqecBWrSbdu73W4Pg9gsqXJ3EXAqkHyhvVXg',
+);
+
+/** Default rent sponsor PDA (V1) */
+export const LIGHT_TOKEN_RENT_SPONSOR: Address = address(
+ 'r18WwUxfG8kQ69bQPAB2jV6zGNKy3GosFGctjQoV4ti',
+);
+
+/** Noop program (used for logging in Light Protocol) */
+export const NOOP_PROGRAM: Address = address(
+ 'noopb9bkMVfRPU8AsbpTUg8AQkHtKwMYZiFUjNRtMmV',
+);
+
+// ============================================================================
+// INSTRUCTION DISCRIMINATORS
+// ============================================================================
+
+/**
+ * Instruction discriminators for the Light Token program.
+ * Uses SPL-compatible values (3-18) plus custom values (100+).
+ */
+export const DISCRIMINATOR = {
+ /** CToken transfer between decompressed accounts */
+ TRANSFER: 3,
+ /** Approve delegate on CToken account */
+ APPROVE: 4,
+ /** Revoke delegate on CToken account */
+ REVOKE: 5,
+ /** Mint tokens to CToken account */
+ MINT_TO: 7,
+ /** Burn tokens from CToken account */
+ BURN: 8,
+ /** Close CToken account */
+ CLOSE: 9,
+ /** Freeze CToken account */
+ FREEZE: 10,
+ /** Thaw frozen CToken account */
+ THAW: 11,
+ /** Transfer with decimals validation */
+ TRANSFER_CHECKED: 12,
+ /** Mint with decimals validation */
+ MINT_TO_CHECKED: 14,
+ /** Burn with decimals validation */
+ BURN_CHECKED: 15,
+ /** Create CToken account */
+ CREATE_TOKEN_ACCOUNT: 18,
+ /** Create associated CToken account */
+ CREATE_ATA: 100,
+ /** Batch transfer instruction (compressed/decompressed) */
+ TRANSFER2: 101,
+ /** Create associated CToken account (idempotent) */
+ CREATE_ATA_IDEMPOTENT: 102,
+ /** Batch mint action instruction */
+ MINT_ACTION: 103,
+ /** Claim rent from compressible accounts */
+ CLAIM: 104,
+ /** Withdraw from funding pool */
+ WITHDRAW_FUNDING_POOL: 105,
+} as const;
+
+export type Discriminator = (typeof DISCRIMINATOR)[keyof typeof DISCRIMINATOR];
+
+// ============================================================================
+// COMPRESSION MODES
+// ============================================================================
+
+/**
+ * Compression mode for Transfer2 instruction.
+ */
+export const COMPRESSION_MODE = {
+ /** Compress: SPL/CToken -> compressed token */
+ COMPRESS: 0,
+ /** Decompress: compressed token -> SPL/CToken */
+ DECOMPRESS: 1,
+ /** Compress and close the source account */
+ COMPRESS_AND_CLOSE: 2,
+} as const;
+
+export type CompressionMode =
+ (typeof COMPRESSION_MODE)[keyof typeof COMPRESSION_MODE];
+
+// ============================================================================
+// EXTENSION DISCRIMINANTS
+// ============================================================================
+
+/**
+ * Extension discriminant values for TLV data.
+ */
+export const EXTENSION_DISCRIMINANT = {
+ /** Token metadata extension */
+ TOKEN_METADATA: 19,
+ /** Pausable account marker extension (zero-size) */
+ PAUSABLE_ACCOUNT: 27,
+ /** Permanent delegate account marker extension (zero-size) */
+ PERMANENT_DELEGATE_ACCOUNT: 28,
+ /** Transfer fee account extension (u64 withheld_amount) */
+ TRANSFER_FEE_ACCOUNT: 29,
+ /** Transfer hook account extension (u8 transferring flag) */
+ TRANSFER_HOOK_ACCOUNT: 30,
+ /** CompressedOnly extension */
+ COMPRESSED_ONLY: 31,
+ /** Compressible extension */
+ COMPRESSIBLE: 32,
+} as const;
+
+export type ExtensionDiscriminant =
+ (typeof EXTENSION_DISCRIMINANT)[keyof typeof EXTENSION_DISCRIMINANT];
+
+// ============================================================================
+// SEEDS
+// ============================================================================
+
+/** Compressed mint PDA seed */
+export const COMPRESSED_MINT_SEED = 'compressed_mint';
+
+/** Pool PDA seed for SPL interface */
+export const POOL_SEED = 'pool';
+
+/** Restricted pool PDA seed */
+export const RESTRICTED_POOL_SEED = 'restricted';
+
+// ============================================================================
+// ACCOUNT SIZES
+// ============================================================================
+
+/** Size of a compressed mint account */
+export const MINT_ACCOUNT_SIZE = 82;
+
+/** Base size of a CToken account (without extensions) */
+export const BASE_TOKEN_ACCOUNT_SIZE = 266;
+
+/** Extension metadata overhead (Vec length) */
+export const EXTENSION_METADATA_SIZE = 4;
+
+/** CompressedOnly extension size */
+export const COMPRESSED_ONLY_EXTENSION_SIZE = 17;
+
+/** Transfer fee account extension size */
+export const TRANSFER_FEE_ACCOUNT_EXTENSION_SIZE = 9;
+
+/** Transfer hook account extension size */
+export const TRANSFER_HOOK_ACCOUNT_EXTENSION_SIZE = 2;
+
+/** V2 token account version (ShaFlat hashing) */
+export const TOKEN_ACCOUNT_VERSION_V2 = 3;
diff --git a/js/token-kit/src/generated/.gitignore b/js/token-kit/src/generated/.gitignore
new file mode 100644
index 0000000000..a393998e96
--- /dev/null
+++ b/js/token-kit/src/generated/.gitignore
@@ -0,0 +1,4 @@
+# Generated by Codama — rebuild with: cd js/token-kit && pnpm run generate
+*
+!.gitignore
+!.gitkeep
diff --git a/js/token-kit/src/idl.ts b/js/token-kit/src/idl.ts
new file mode 100644
index 0000000000..95029d8f91
--- /dev/null
+++ b/js/token-kit/src/idl.ts
@@ -0,0 +1,1173 @@
+/**
+ * Light Protocol Token IDL
+ *
+ * Programmatic IDL definition for the Light Token program using Codama.
+ * The program uses single-byte SPL-compatible discriminators (3-18) and
+ * custom discriminators (100+) with Pinocchio-based instruction dispatch.
+ */
+
+import {
+ rootNode,
+ programNode,
+ instructionNode,
+ instructionAccountNode,
+ instructionArgumentNode,
+ pdaNode,
+ pdaValueNode,
+ pdaLinkNode,
+ constantDiscriminatorNode,
+ constantValueNode,
+ constantPdaSeedNodeFromString,
+ variablePdaSeedNode,
+ numberTypeNode,
+ numberValueNode,
+ publicKeyTypeNode,
+ publicKeyValueNode,
+ booleanTypeNode,
+ optionTypeNode,
+ bytesTypeNode,
+ structTypeNode,
+ structFieldTypeNode,
+ arrayTypeNode,
+ fixedSizeTypeNode,
+ prefixedCountNode,
+} from 'codama';
+
+// ============================================================================
+// CONSTANTS
+// ============================================================================
+
+export const LIGHT_TOKEN_PROGRAM_ID =
+ 'cTokenmWW8bLPjZEBAUgYy3zKxQZW6VKi7bqNFEVv3m';
+export const CPI_AUTHORITY = 'GXtd2izAiMJPwMEjfgTRH3d7k9mjn4Jq3JrWFv9gySYy';
+export const MINT_ADDRESS_TREE = 'amt2kaJA14v3urZbZvnc5v2np8jqvc4Z8zDep5wbtzx';
+export const SYSTEM_PROGRAM = '11111111111111111111111111111111';
+
+// ============================================================================
+// INSTRUCTION DISCRIMINATORS
+// ============================================================================
+
+/** SPL-compatible discriminators */
+export const DISCRIMINATOR = {
+ TRANSFER: 3,
+ APPROVE: 4,
+ REVOKE: 5,
+ MINT_TO: 7,
+ BURN: 8,
+ CLOSE: 9,
+ FREEZE: 10,
+ THAW: 11,
+ TRANSFER_CHECKED: 12,
+ MINT_TO_CHECKED: 14,
+ BURN_CHECKED: 15,
+ CREATE_TOKEN_ACCOUNT: 18,
+ CREATE_ATA: 100,
+ TRANSFER2: 101,
+ CREATE_ATA_IDEMPOTENT: 102,
+ MINT_ACTION: 103,
+ CLAIM: 104,
+ WITHDRAW_FUNDING_POOL: 105,
+} as const;
+
+// ============================================================================
+// TYPE DEFINITIONS
+// ============================================================================
+
+/** Compression struct for Transfer2 */
+const compressionStructType = structTypeNode([
+ structFieldTypeNode({ name: 'mode', type: numberTypeNode('u8') }),
+ structFieldTypeNode({ name: 'amount', type: numberTypeNode('u64') }),
+ structFieldTypeNode({ name: 'mint', type: numberTypeNode('u8') }),
+ structFieldTypeNode({
+ name: 'sourceOrRecipient',
+ type: numberTypeNode('u8'),
+ }),
+ structFieldTypeNode({ name: 'authority', type: numberTypeNode('u8') }),
+ structFieldTypeNode({
+ name: 'poolAccountIndex',
+ type: numberTypeNode('u8'),
+ }),
+ structFieldTypeNode({ name: 'poolIndex', type: numberTypeNode('u8') }),
+ structFieldTypeNode({ name: 'bump', type: numberTypeNode('u8') }),
+ structFieldTypeNode({ name: 'decimals', type: numberTypeNode('u8') }),
+]);
+
+/** Packed merkle context */
+const packedMerkleContextType = structTypeNode([
+ structFieldTypeNode({
+ name: 'merkleTreePubkeyIndex',
+ type: numberTypeNode('u8'),
+ }),
+ structFieldTypeNode({
+ name: 'queuePubkeyIndex',
+ type: numberTypeNode('u8'),
+ }),
+ structFieldTypeNode({ name: 'leafIndex', type: numberTypeNode('u32') }),
+ structFieldTypeNode({ name: 'proveByIndex', type: booleanTypeNode() }),
+]);
+
+/** CPI context */
+const cpiContextType = structTypeNode([
+ structFieldTypeNode({ name: 'setContext', type: booleanTypeNode() }),
+ structFieldTypeNode({ name: 'firstSetContext', type: booleanTypeNode() }),
+ structFieldTypeNode({
+ name: 'cpiContextAccountIndex',
+ type: numberTypeNode('u8'),
+ }),
+]);
+
+/** Compressible extension instruction data */
+const compressibleExtensionDataType = structTypeNode([
+ structFieldTypeNode({
+ name: 'tokenAccountVersion',
+ type: numberTypeNode('u8'),
+ }),
+ structFieldTypeNode({ name: 'rentPayment', type: numberTypeNode('u8') }),
+ structFieldTypeNode({
+ name: 'compressionOnly',
+ type: numberTypeNode('u8'),
+ }),
+ structFieldTypeNode({ name: 'writeTopUp', type: numberTypeNode('u32') }),
+ structFieldTypeNode({
+ name: 'compressToPubkey',
+ type: optionTypeNode(
+ structTypeNode([
+ structFieldTypeNode({
+ name: 'bump',
+ type: numberTypeNode('u8'),
+ }),
+ structFieldTypeNode({
+ name: 'programId',
+ type: fixedSizeTypeNode(bytesTypeNode(), 32),
+ }),
+ structFieldTypeNode({
+ name: 'seeds',
+ type: arrayTypeNode(bytesTypeNode(), prefixedCountNode(numberTypeNode('u32'))),
+ }),
+ ]),
+ ),
+ }),
+]);
+
+// ============================================================================
+// IDL ROOT
+// ============================================================================
+
+export const lightTokenIdl = rootNode(
+ programNode({
+ name: 'lightToken',
+ publicKey: LIGHT_TOKEN_PROGRAM_ID,
+ version: '1.0.0',
+ docs: ['Light Protocol compressed token program'],
+
+ // ========================================================================
+ // PDAs
+ // ========================================================================
+ pdas: [
+ pdaNode({
+ name: 'associatedTokenAccount',
+ seeds: [
+ variablePdaSeedNode('owner', publicKeyTypeNode()),
+ constantPdaSeedNodeFromString(
+ 'utf8',
+ LIGHT_TOKEN_PROGRAM_ID,
+ ),
+ variablePdaSeedNode('mint', publicKeyTypeNode()),
+ ],
+ docs: [
+ 'Associated token account PDA: [owner, LIGHT_TOKEN_PROGRAM_ID, mint]',
+ ],
+ }),
+ pdaNode({
+ name: 'lightMint',
+ seeds: [
+ constantPdaSeedNodeFromString('utf8', 'compressed_mint'),
+ variablePdaSeedNode('mintSigner', publicKeyTypeNode()),
+ ],
+ docs: ['Light mint PDA: ["compressed_mint", mintSigner]'],
+ }),
+ pdaNode({
+ name: 'splInterfacePool',
+ seeds: [
+ constantPdaSeedNodeFromString('utf8', 'pool'),
+ variablePdaSeedNode('mint', publicKeyTypeNode()),
+ ],
+ docs: ['SPL interface pool PDA: ["pool", mint]'],
+ }),
+ ],
+
+ // ========================================================================
+ // ACCOUNTS (for generated types)
+ // ========================================================================
+ accounts: [],
+
+ // ========================================================================
+ // INSTRUCTIONS
+ // ========================================================================
+ instructions: [
+ // ----------------------------------------------------------------------
+ // CToken Transfer (discriminator: 3)
+ // ----------------------------------------------------------------------
+ instructionNode({
+ name: 'ctokenTransfer',
+ discriminators: [
+ constantDiscriminatorNode(
+ constantValueNode(
+ numberTypeNode('u8'),
+ numberValueNode(DISCRIMINATOR.TRANSFER),
+ ),
+ ),
+ ],
+ docs: ['Transfer CToken between decompressed accounts'],
+ accounts: [
+ instructionAccountNode({
+ name: 'source',
+ isSigner: false,
+ isWritable: true,
+ docs: ['Source CToken account'],
+ }),
+ instructionAccountNode({
+ name: 'destination',
+ isSigner: false,
+ isWritable: true,
+ docs: ['Destination CToken account'],
+ }),
+ instructionAccountNode({
+ name: 'authority',
+ isSigner: true,
+ isWritable: false,
+ docs: ['Authority (owner or delegate)'],
+ }),
+ instructionAccountNode({
+ name: 'systemProgram',
+ isSigner: false,
+ isWritable: false,
+ defaultValue: publicKeyValueNode(SYSTEM_PROGRAM),
+ docs: ['System program for rent top-up'],
+ }),
+ instructionAccountNode({
+ name: 'feePayer',
+ isSigner: true,
+ isWritable: true,
+ isOptional: true,
+ docs: ['Optional fee payer for rent top-ups'],
+ }),
+ ],
+ arguments: [
+ instructionArgumentNode({
+ name: 'discriminator',
+ type: numberTypeNode('u8'),
+ defaultValue: numberValueNode(DISCRIMINATOR.TRANSFER),
+ defaultValueStrategy: 'omitted',
+ }),
+ instructionArgumentNode({
+ name: 'amount',
+ type: numberTypeNode('u64'),
+ docs: ['Amount to transfer'],
+ }),
+ instructionArgumentNode({
+ name: 'maxTopUp',
+ type: optionTypeNode(numberTypeNode('u16')),
+ docs: [
+ 'Maximum lamports for rent top-up (0 = no limit)',
+ ],
+ }),
+ ],
+ }),
+
+ // ----------------------------------------------------------------------
+ // CToken TransferChecked (discriminator: 12)
+ // ----------------------------------------------------------------------
+ instructionNode({
+ name: 'ctokenTransferChecked',
+ discriminators: [
+ constantDiscriminatorNode(
+ constantValueNode(
+ numberTypeNode('u8'),
+ numberValueNode(DISCRIMINATOR.TRANSFER_CHECKED),
+ ),
+ ),
+ ],
+ docs: ['Transfer CToken with decimals validation'],
+ accounts: [
+ instructionAccountNode({
+ name: 'source',
+ isSigner: false,
+ isWritable: true,
+ }),
+ instructionAccountNode({
+ name: 'mint',
+ isSigner: false,
+ isWritable: false,
+ }),
+ instructionAccountNode({
+ name: 'destination',
+ isSigner: false,
+ isWritable: true,
+ }),
+ instructionAccountNode({
+ name: 'authority',
+ isSigner: true,
+ isWritable: false,
+ docs: ['Authority (owner or delegate)'],
+ }),
+ instructionAccountNode({
+ name: 'systemProgram',
+ isSigner: false,
+ isWritable: false,
+ defaultValue: publicKeyValueNode(SYSTEM_PROGRAM),
+ docs: ['System program for rent top-up'],
+ }),
+ instructionAccountNode({
+ name: 'feePayer',
+ isSigner: true,
+ isWritable: true,
+ isOptional: true,
+ docs: ['Optional fee payer for rent top-ups'],
+ }),
+ ],
+ arguments: [
+ instructionArgumentNode({
+ name: 'discriminator',
+ type: numberTypeNode('u8'),
+ defaultValue: numberValueNode(
+ DISCRIMINATOR.TRANSFER_CHECKED,
+ ),
+ defaultValueStrategy: 'omitted',
+ }),
+ instructionArgumentNode({
+ name: 'amount',
+ type: numberTypeNode('u64'),
+ }),
+ instructionArgumentNode({
+ name: 'decimals',
+ type: numberTypeNode('u8'),
+ }),
+ instructionArgumentNode({
+ name: 'maxTopUp',
+ type: optionTypeNode(numberTypeNode('u16')),
+ }),
+ ],
+ }),
+
+ // ----------------------------------------------------------------------
+ // CToken Approve (discriminator: 4)
+ // ----------------------------------------------------------------------
+ instructionNode({
+ name: 'ctokenApprove',
+ discriminators: [
+ constantDiscriminatorNode(
+ constantValueNode(
+ numberTypeNode('u8'),
+ numberValueNode(DISCRIMINATOR.APPROVE),
+ ),
+ ),
+ ],
+ docs: ['Approve delegate on decompressed CToken account'],
+ accounts: [
+ instructionAccountNode({
+ name: 'tokenAccount',
+ isSigner: false,
+ isWritable: true,
+ }),
+ instructionAccountNode({
+ name: 'delegate',
+ isSigner: false,
+ isWritable: false,
+ }),
+ instructionAccountNode({
+ name: 'owner',
+ isSigner: true,
+ isWritable: false,
+ }),
+ ],
+ arguments: [
+ instructionArgumentNode({
+ name: 'discriminator',
+ type: numberTypeNode('u8'),
+ defaultValue: numberValueNode(DISCRIMINATOR.APPROVE),
+ defaultValueStrategy: 'omitted',
+ }),
+ instructionArgumentNode({
+ name: 'amount',
+ type: numberTypeNode('u64'),
+ }),
+ ],
+ }),
+
+ // ----------------------------------------------------------------------
+ // CToken Revoke (discriminator: 5)
+ // ----------------------------------------------------------------------
+ instructionNode({
+ name: 'ctokenRevoke',
+ discriminators: [
+ constantDiscriminatorNode(
+ constantValueNode(
+ numberTypeNode('u8'),
+ numberValueNode(DISCRIMINATOR.REVOKE),
+ ),
+ ),
+ ],
+ docs: ['Revoke delegate on decompressed CToken account'],
+ accounts: [
+ instructionAccountNode({
+ name: 'tokenAccount',
+ isSigner: false,
+ isWritable: true,
+ }),
+ instructionAccountNode({
+ name: 'owner',
+ isSigner: true,
+ isWritable: false,
+ }),
+ ],
+ arguments: [
+ instructionArgumentNode({
+ name: 'discriminator',
+ type: numberTypeNode('u8'),
+ defaultValue: numberValueNode(DISCRIMINATOR.REVOKE),
+ defaultValueStrategy: 'omitted',
+ }),
+ ],
+ }),
+
+ // ----------------------------------------------------------------------
+ // CToken MintTo (discriminator: 7)
+ // ----------------------------------------------------------------------
+ instructionNode({
+ name: 'ctokenMintTo',
+ discriminators: [
+ constantDiscriminatorNode(
+ constantValueNode(
+ numberTypeNode('u8'),
+ numberValueNode(DISCRIMINATOR.MINT_TO),
+ ),
+ ),
+ ],
+ docs: ['Mint tokens to decompressed CToken account'],
+ accounts: [
+ instructionAccountNode({
+ name: 'mint',
+ isSigner: false,
+ isWritable: true,
+ }),
+ instructionAccountNode({
+ name: 'tokenAccount',
+ isSigner: false,
+ isWritable: true,
+ }),
+ instructionAccountNode({
+ name: 'mintAuthority',
+ isSigner: true,
+ isWritable: false,
+ }),
+ ],
+ arguments: [
+ instructionArgumentNode({
+ name: 'discriminator',
+ type: numberTypeNode('u8'),
+ defaultValue: numberValueNode(DISCRIMINATOR.MINT_TO),
+ defaultValueStrategy: 'omitted',
+ }),
+ instructionArgumentNode({
+ name: 'amount',
+ type: numberTypeNode('u64'),
+ }),
+ ],
+ }),
+
+ // ----------------------------------------------------------------------
+ // CToken MintToChecked (discriminator: 14)
+ // ----------------------------------------------------------------------
+ instructionNode({
+ name: 'ctokenMintToChecked',
+ discriminators: [
+ constantDiscriminatorNode(
+ constantValueNode(
+ numberTypeNode('u8'),
+ numberValueNode(DISCRIMINATOR.MINT_TO_CHECKED),
+ ),
+ ),
+ ],
+ docs: ['Mint tokens with decimals validation'],
+ accounts: [
+ instructionAccountNode({
+ name: 'mint',
+ isSigner: false,
+ isWritable: true,
+ }),
+ instructionAccountNode({
+ name: 'tokenAccount',
+ isSigner: false,
+ isWritable: true,
+ }),
+ instructionAccountNode({
+ name: 'mintAuthority',
+ isSigner: true,
+ isWritable: false,
+ }),
+ ],
+ arguments: [
+ instructionArgumentNode({
+ name: 'discriminator',
+ type: numberTypeNode('u8'),
+ defaultValue: numberValueNode(
+ DISCRIMINATOR.MINT_TO_CHECKED,
+ ),
+ defaultValueStrategy: 'omitted',
+ }),
+ instructionArgumentNode({
+ name: 'amount',
+ type: numberTypeNode('u64'),
+ }),
+ instructionArgumentNode({
+ name: 'decimals',
+ type: numberTypeNode('u8'),
+ }),
+ ],
+ }),
+
+ // ----------------------------------------------------------------------
+ // CToken Burn (discriminator: 8)
+ // ----------------------------------------------------------------------
+ instructionNode({
+ name: 'ctokenBurn',
+ discriminators: [
+ constantDiscriminatorNode(
+ constantValueNode(
+ numberTypeNode('u8'),
+ numberValueNode(DISCRIMINATOR.BURN),
+ ),
+ ),
+ ],
+ docs: ['Burn tokens from decompressed CToken account'],
+ accounts: [
+ instructionAccountNode({
+ name: 'tokenAccount',
+ isSigner: false,
+ isWritable: true,
+ }),
+ instructionAccountNode({
+ name: 'mint',
+ isSigner: false,
+ isWritable: true,
+ }),
+ instructionAccountNode({
+ name: 'authority',
+ isSigner: true,
+ isWritable: false,
+ }),
+ ],
+ arguments: [
+ instructionArgumentNode({
+ name: 'discriminator',
+ type: numberTypeNode('u8'),
+ defaultValue: numberValueNode(DISCRIMINATOR.BURN),
+ defaultValueStrategy: 'omitted',
+ }),
+ instructionArgumentNode({
+ name: 'amount',
+ type: numberTypeNode('u64'),
+ }),
+ ],
+ }),
+
+ // ----------------------------------------------------------------------
+ // CToken BurnChecked (discriminator: 15)
+ // ----------------------------------------------------------------------
+ instructionNode({
+ name: 'ctokenBurnChecked',
+ discriminators: [
+ constantDiscriminatorNode(
+ constantValueNode(
+ numberTypeNode('u8'),
+ numberValueNode(DISCRIMINATOR.BURN_CHECKED),
+ ),
+ ),
+ ],
+ docs: ['Burn tokens with decimals validation'],
+ accounts: [
+ instructionAccountNode({
+ name: 'tokenAccount',
+ isSigner: false,
+ isWritable: true,
+ }),
+ instructionAccountNode({
+ name: 'mint',
+ isSigner: false,
+ isWritable: true,
+ }),
+ instructionAccountNode({
+ name: 'authority',
+ isSigner: true,
+ isWritable: false,
+ }),
+ ],
+ arguments: [
+ instructionArgumentNode({
+ name: 'discriminator',
+ type: numberTypeNode('u8'),
+ defaultValue: numberValueNode(
+ DISCRIMINATOR.BURN_CHECKED,
+ ),
+ defaultValueStrategy: 'omitted',
+ }),
+ instructionArgumentNode({
+ name: 'amount',
+ type: numberTypeNode('u64'),
+ }),
+ instructionArgumentNode({
+ name: 'decimals',
+ type: numberTypeNode('u8'),
+ }),
+ ],
+ }),
+
+ // ----------------------------------------------------------------------
+ // CToken Close (discriminator: 9)
+ // ----------------------------------------------------------------------
+ instructionNode({
+ name: 'ctokenClose',
+ discriminators: [
+ constantDiscriminatorNode(
+ constantValueNode(
+ numberTypeNode('u8'),
+ numberValueNode(DISCRIMINATOR.CLOSE),
+ ),
+ ),
+ ],
+ docs: ['Close decompressed CToken account'],
+ accounts: [
+ instructionAccountNode({
+ name: 'tokenAccount',
+ isSigner: false,
+ isWritable: true,
+ }),
+ instructionAccountNode({
+ name: 'destination',
+ isSigner: false,
+ isWritable: true,
+ }),
+ instructionAccountNode({
+ name: 'owner',
+ isSigner: true,
+ isWritable: false,
+ }),
+ ],
+ arguments: [
+ instructionArgumentNode({
+ name: 'discriminator',
+ type: numberTypeNode('u8'),
+ defaultValue: numberValueNode(DISCRIMINATOR.CLOSE),
+ defaultValueStrategy: 'omitted',
+ }),
+ ],
+ }),
+
+ // ----------------------------------------------------------------------
+ // CToken Freeze (discriminator: 10)
+ // ----------------------------------------------------------------------
+ instructionNode({
+ name: 'ctokenFreeze',
+ discriminators: [
+ constantDiscriminatorNode(
+ constantValueNode(
+ numberTypeNode('u8'),
+ numberValueNode(DISCRIMINATOR.FREEZE),
+ ),
+ ),
+ ],
+ docs: ['Freeze decompressed CToken account'],
+ accounts: [
+ instructionAccountNode({
+ name: 'tokenAccount',
+ isSigner: false,
+ isWritable: true,
+ }),
+ instructionAccountNode({
+ name: 'mint',
+ isSigner: false,
+ isWritable: false,
+ }),
+ instructionAccountNode({
+ name: 'freezeAuthority',
+ isSigner: true,
+ isWritable: false,
+ }),
+ ],
+ arguments: [
+ instructionArgumentNode({
+ name: 'discriminator',
+ type: numberTypeNode('u8'),
+ defaultValue: numberValueNode(DISCRIMINATOR.FREEZE),
+ defaultValueStrategy: 'omitted',
+ }),
+ ],
+ }),
+
+ // ----------------------------------------------------------------------
+ // CToken Thaw (discriminator: 11)
+ // ----------------------------------------------------------------------
+ instructionNode({
+ name: 'ctokenThaw',
+ discriminators: [
+ constantDiscriminatorNode(
+ constantValueNode(
+ numberTypeNode('u8'),
+ numberValueNode(DISCRIMINATOR.THAW),
+ ),
+ ),
+ ],
+ docs: ['Thaw frozen decompressed CToken account'],
+ accounts: [
+ instructionAccountNode({
+ name: 'tokenAccount',
+ isSigner: false,
+ isWritable: true,
+ }),
+ instructionAccountNode({
+ name: 'mint',
+ isSigner: false,
+ isWritable: false,
+ }),
+ instructionAccountNode({
+ name: 'freezeAuthority',
+ isSigner: true,
+ isWritable: false,
+ }),
+ ],
+ arguments: [
+ instructionArgumentNode({
+ name: 'discriminator',
+ type: numberTypeNode('u8'),
+ defaultValue: numberValueNode(DISCRIMINATOR.THAW),
+ defaultValueStrategy: 'omitted',
+ }),
+ ],
+ }),
+
+ // ----------------------------------------------------------------------
+ // Create Token Account (discriminator: 18)
+ // ----------------------------------------------------------------------
+ instructionNode({
+ name: 'createTokenAccount',
+ discriminators: [
+ constantDiscriminatorNode(
+ constantValueNode(
+ numberTypeNode('u8'),
+ numberValueNode(DISCRIMINATOR.CREATE_TOKEN_ACCOUNT),
+ ),
+ ),
+ ],
+ docs: [
+ 'Create CToken account (equivalent to SPL InitializeAccount3)',
+ ],
+ accounts: [
+ instructionAccountNode({
+ name: 'owner',
+ isSigner: false,
+ isWritable: false,
+ }),
+ instructionAccountNode({
+ name: 'mint',
+ isSigner: false,
+ isWritable: false,
+ }),
+ instructionAccountNode({
+ name: 'payer',
+ isSigner: true,
+ isWritable: true,
+ }),
+ instructionAccountNode({
+ name: 'tokenAccount',
+ isSigner: false,
+ isWritable: true,
+ }),
+ instructionAccountNode({
+ name: 'systemProgram',
+ isSigner: false,
+ isWritable: false,
+ defaultValue: publicKeyValueNode(SYSTEM_PROGRAM),
+ }),
+ instructionAccountNode({
+ name: 'compressibleConfig',
+ isSigner: false,
+ isWritable: false,
+ isOptional: true,
+ }),
+ instructionAccountNode({
+ name: 'rentSponsor',
+ isSigner: false,
+ isWritable: true,
+ isOptional: true,
+ }),
+ ],
+ arguments: [
+ instructionArgumentNode({
+ name: 'discriminator',
+ type: numberTypeNode('u8'),
+ defaultValue: numberValueNode(
+ DISCRIMINATOR.CREATE_TOKEN_ACCOUNT,
+ ),
+ defaultValueStrategy: 'omitted',
+ }),
+ instructionArgumentNode({
+ name: 'compressibleConfig',
+ type: optionTypeNode(compressibleExtensionDataType),
+ }),
+ ],
+ }),
+
+ // ----------------------------------------------------------------------
+ // Create Associated Token Account (discriminator: 100)
+ // ----------------------------------------------------------------------
+ instructionNode({
+ name: 'createAssociatedTokenAccount',
+ discriminators: [
+ constantDiscriminatorNode(
+ constantValueNode(
+ numberTypeNode('u8'),
+ numberValueNode(DISCRIMINATOR.CREATE_ATA),
+ ),
+ ),
+ ],
+ docs: ['Create associated CToken account'],
+ accounts: [
+ instructionAccountNode({
+ name: 'owner',
+ isSigner: false,
+ isWritable: false,
+ }),
+ instructionAccountNode({
+ name: 'mint',
+ isSigner: false,
+ isWritable: false,
+ }),
+ instructionAccountNode({
+ name: 'payer',
+ isSigner: true,
+ isWritable: true,
+ }),
+ instructionAccountNode({
+ name: 'associatedTokenAccount',
+ isSigner: false,
+ isWritable: true,
+ defaultValue: pdaValueNode(
+ pdaLinkNode('associatedTokenAccount'),
+ ),
+ }),
+ instructionAccountNode({
+ name: 'systemProgram',
+ isSigner: false,
+ isWritable: false,
+ defaultValue: publicKeyValueNode(SYSTEM_PROGRAM),
+ }),
+ instructionAccountNode({
+ name: 'compressibleConfig',
+ isSigner: false,
+ isWritable: false,
+ }),
+ instructionAccountNode({
+ name: 'rentSponsor',
+ isSigner: false,
+ isWritable: true,
+ }),
+ ],
+ arguments: [
+ instructionArgumentNode({
+ name: 'discriminator',
+ type: numberTypeNode('u8'),
+ defaultValue: numberValueNode(DISCRIMINATOR.CREATE_ATA),
+ defaultValueStrategy: 'omitted',
+ }),
+ instructionArgumentNode({
+ name: 'bump',
+ type: numberTypeNode('u8'),
+ }),
+ instructionArgumentNode({
+ name: 'compressibleConfig',
+ type: optionTypeNode(compressibleExtensionDataType),
+ }),
+ ],
+ }),
+
+ // ----------------------------------------------------------------------
+ // Create Associated Token Account Idempotent (discriminator: 102)
+ // ----------------------------------------------------------------------
+ instructionNode({
+ name: 'createAssociatedTokenAccountIdempotent',
+ discriminators: [
+ constantDiscriminatorNode(
+ constantValueNode(
+ numberTypeNode('u8'),
+ numberValueNode(
+ DISCRIMINATOR.CREATE_ATA_IDEMPOTENT,
+ ),
+ ),
+ ),
+ ],
+ docs: [
+ 'Create associated CToken account (idempotent - no-op if exists)',
+ ],
+ accounts: [
+ instructionAccountNode({
+ name: 'owner',
+ isSigner: false,
+ isWritable: false,
+ }),
+ instructionAccountNode({
+ name: 'mint',
+ isSigner: false,
+ isWritable: false,
+ }),
+ instructionAccountNode({
+ name: 'payer',
+ isSigner: true,
+ isWritable: true,
+ }),
+ instructionAccountNode({
+ name: 'associatedTokenAccount',
+ isSigner: false,
+ isWritable: true,
+ defaultValue: pdaValueNode(
+ pdaLinkNode('associatedTokenAccount'),
+ ),
+ }),
+ instructionAccountNode({
+ name: 'systemProgram',
+ isSigner: false,
+ isWritable: false,
+ defaultValue: publicKeyValueNode(SYSTEM_PROGRAM),
+ }),
+ instructionAccountNode({
+ name: 'compressibleConfig',
+ isSigner: false,
+ isWritable: false,
+ }),
+ instructionAccountNode({
+ name: 'rentSponsor',
+ isSigner: false,
+ isWritable: true,
+ }),
+ ],
+ arguments: [
+ instructionArgumentNode({
+ name: 'discriminator',
+ type: numberTypeNode('u8'),
+ defaultValue: numberValueNode(
+ DISCRIMINATOR.CREATE_ATA_IDEMPOTENT,
+ ),
+ defaultValueStrategy: 'omitted',
+ }),
+ instructionArgumentNode({
+ name: 'bump',
+ type: numberTypeNode('u8'),
+ }),
+ instructionArgumentNode({
+ name: 'compressibleConfig',
+ type: optionTypeNode(compressibleExtensionDataType),
+ }),
+ ],
+ }),
+
+ // ----------------------------------------------------------------------
+ // Transfer2 (discriminator: 101) - Batch transfer instruction
+ // ----------------------------------------------------------------------
+ instructionNode({
+ name: 'transfer2',
+ discriminators: [
+ constantDiscriminatorNode(
+ constantValueNode(
+ numberTypeNode('u8'),
+ numberValueNode(DISCRIMINATOR.TRANSFER2),
+ ),
+ ),
+ ],
+ docs: [
+ 'Batch transfer instruction for compressed/decompressed operations.',
+ 'Supports: transfer, compress, decompress, compress-and-close.',
+ ],
+ accounts: [
+ instructionAccountNode({
+ name: 'feePayer',
+ isSigner: true,
+ isWritable: true,
+ }),
+ instructionAccountNode({
+ name: 'authority',
+ isSigner: true,
+ isWritable: false,
+ }),
+ instructionAccountNode({
+ name: 'lightSystemProgram',
+ isSigner: false,
+ isWritable: false,
+ }),
+ instructionAccountNode({
+ name: 'registeredProgramPda',
+ isSigner: false,
+ isWritable: false,
+ }),
+ instructionAccountNode({
+ name: 'accountCompressionAuthority',
+ isSigner: false,
+ isWritable: false,
+ }),
+ instructionAccountNode({
+ name: 'accountCompressionProgram',
+ isSigner: false,
+ isWritable: false,
+ }),
+ instructionAccountNode({
+ name: 'selfProgram',
+ isSigner: false,
+ isWritable: false,
+ defaultValue: publicKeyValueNode(
+ LIGHT_TOKEN_PROGRAM_ID,
+ ),
+ }),
+ instructionAccountNode({
+ name: 'systemProgram',
+ isSigner: false,
+ isWritable: false,
+ defaultValue: publicKeyValueNode(SYSTEM_PROGRAM),
+ }),
+ // Remaining accounts are dynamic based on the transfer
+ ],
+ arguments: [
+ instructionArgumentNode({
+ name: 'discriminator',
+ type: numberTypeNode('u8'),
+ defaultValue: numberValueNode(DISCRIMINATOR.TRANSFER2),
+ defaultValueStrategy: 'omitted',
+ }),
+ instructionArgumentNode({
+ name: 'withTransactionHash',
+ type: booleanTypeNode(),
+ }),
+ instructionArgumentNode({
+ name: 'withLamportsChangeAccountMerkleTreeIndex',
+ type: booleanTypeNode(),
+ }),
+ instructionArgumentNode({
+ name: 'lamportsChangeAccountMerkleTreeIndex',
+ type: numberTypeNode('u8'),
+ }),
+ instructionArgumentNode({
+ name: 'lamportsChangeAccountOwnerIndex',
+ type: numberTypeNode('u8'),
+ }),
+ instructionArgumentNode({
+ name: 'outputQueue',
+ type: numberTypeNode('u8'),
+ }),
+ instructionArgumentNode({
+ name: 'maxTopUp',
+ type: numberTypeNode('u16'),
+ }),
+ instructionArgumentNode({
+ name: 'cpiContext',
+ type: optionTypeNode(cpiContextType),
+ }),
+ instructionArgumentNode({
+ name: 'compressions',
+ type: optionTypeNode(
+ arrayTypeNode(compressionStructType, prefixedCountNode(numberTypeNode('u32'))),
+ ),
+ }),
+ // Note: proof, inTokenData, outTokenData, inLamports, outLamports, inTlv, outTlv
+ // are complex nested structures that will be handled by manual codecs
+ ],
+ }),
+
+ // ----------------------------------------------------------------------
+ // MintAction (discriminator: 103) - Batch mint operations
+ // ----------------------------------------------------------------------
+ instructionNode({
+ name: 'mintAction',
+ discriminators: [
+ constantDiscriminatorNode(
+ constantValueNode(
+ numberTypeNode('u8'),
+ numberValueNode(DISCRIMINATOR.MINT_ACTION),
+ ),
+ ),
+ ],
+ docs: [
+ 'Batch instruction for compressed mint management.',
+ 'Supports: CreateMint, MintTo, UpdateAuthorities, DecompressMint, etc.',
+ ],
+ accounts: [
+ instructionAccountNode({
+ name: 'feePayer',
+ isSigner: true,
+ isWritable: true,
+ }),
+ instructionAccountNode({
+ name: 'authority',
+ isSigner: true,
+ isWritable: false,
+ }),
+ instructionAccountNode({
+ name: 'lightSystemProgram',
+ isSigner: false,
+ isWritable: false,
+ }),
+ instructionAccountNode({
+ name: 'registeredProgramPda',
+ isSigner: false,
+ isWritable: false,
+ }),
+ instructionAccountNode({
+ name: 'accountCompressionAuthority',
+ isSigner: false,
+ isWritable: false,
+ }),
+ instructionAccountNode({
+ name: 'accountCompressionProgram',
+ isSigner: false,
+ isWritable: false,
+ }),
+ instructionAccountNode({
+ name: 'selfProgram',
+ isSigner: false,
+ isWritable: false,
+ defaultValue: publicKeyValueNode(
+ LIGHT_TOKEN_PROGRAM_ID,
+ ),
+ }),
+ instructionAccountNode({
+ name: 'systemProgram',
+ isSigner: false,
+ isWritable: false,
+ defaultValue: publicKeyValueNode(SYSTEM_PROGRAM),
+ }),
+ // Remaining accounts are dynamic based on the mint action
+ ],
+ arguments: [
+ instructionArgumentNode({
+ name: 'discriminator',
+ type: numberTypeNode('u8'),
+ defaultValue: numberValueNode(
+ DISCRIMINATOR.MINT_ACTION,
+ ),
+ defaultValueStrategy: 'omitted',
+ }),
+ // MintAction has complex nested data handled by manual codecs
+ ],
+ }),
+ ],
+
+ // ========================================================================
+ // DEFINED TYPES
+ // ========================================================================
+ definedTypes: [],
+
+ // ========================================================================
+ // ERRORS
+ // ========================================================================
+ errors: [],
+ }),
+);
+
+export default lightTokenIdl;
diff --git a/js/token-kit/src/index.ts b/js/token-kit/src/index.ts
new file mode 100644
index 0000000000..a04eaf94b1
--- /dev/null
+++ b/js/token-kit/src/index.ts
@@ -0,0 +1,416 @@
+/**
+ * Light Protocol Token Kit
+ *
+ * Unified TypeScript SDK for Light Protocol compressed tokens using Solana Kit (web3.js v2).
+ * Includes instructions, codecs, indexer client, account loading, and high-level actions.
+ *
+ * @example
+ * ```typescript
+ * import {
+ * createTransferInstruction,
+ * createAssociatedTokenAccountInstruction,
+ * deriveAssociatedTokenAddress,
+ * LIGHT_TOKEN_PROGRAM_ID,
+ * createLightIndexer,
+ * loadTokenAccountsForTransfer,
+ * buildCompressedTransfer,
+ * } from '@lightprotocol/token-kit';
+ *
+ * // Derive ATA address
+ * const { address: ata, bump } = await deriveAssociatedTokenAddress(owner, mint);
+ *
+ * // Create transfer instruction
+ * const transferIx = createTransferInstruction({
+ * source: sourceAta,
+ * destination: destAta,
+ * amount: 1000n,
+ * authority: owner,
+ * });
+ *
+ * // Or use the high-level builder
+ * const indexer = createLightIndexer('https://photon.helius.dev');
+ * const result = await buildCompressedTransfer(indexer, {
+ * owner, mint, amount: 1000n, recipientOwner, feePayer,
+ * });
+ * ```
+ *
+ * @packageDocumentation
+ */
+
+// ============================================================================
+// CONSTANTS
+// ============================================================================
+
+export {
+ // Program IDs
+ LIGHT_TOKEN_PROGRAM_ID,
+ LIGHT_SYSTEM_PROGRAM_ID,
+ ACCOUNT_COMPRESSION_PROGRAM_ID,
+ SPL_TOKEN_PROGRAM_ID,
+ SPL_TOKEN_2022_PROGRAM_ID,
+ SYSTEM_PROGRAM_ID,
+
+ // Known accounts
+ CPI_AUTHORITY,
+ REGISTERED_PROGRAM_PDA,
+ ACCOUNT_COMPRESSION_AUTHORITY_PDA,
+ MINT_ADDRESS_TREE,
+ NATIVE_MINT,
+ LIGHT_TOKEN_CONFIG,
+ LIGHT_TOKEN_RENT_SPONSOR,
+ NOOP_PROGRAM,
+
+ // Instruction discriminators
+ DISCRIMINATOR,
+ type Discriminator,
+
+ // Compression modes
+ COMPRESSION_MODE,
+ type CompressionMode,
+
+ // Extension discriminants
+ EXTENSION_DISCRIMINANT,
+ type ExtensionDiscriminant,
+
+ // Seeds
+ COMPRESSED_MINT_SEED,
+ POOL_SEED,
+ RESTRICTED_POOL_SEED,
+
+ // Account sizes
+ MINT_ACCOUNT_SIZE,
+ BASE_TOKEN_ACCOUNT_SIZE,
+ EXTENSION_METADATA_SIZE,
+ COMPRESSED_ONLY_EXTENSION_SIZE,
+ TRANSFER_FEE_ACCOUNT_EXTENSION_SIZE,
+ TRANSFER_HOOK_ACCOUNT_EXTENSION_SIZE,
+ TOKEN_ACCOUNT_VERSION_V2,
+} from './constants.js';
+
+// ============================================================================
+// UTILITIES
+// ============================================================================
+
+export {
+ // PDA derivation
+ deriveAssociatedTokenAddress,
+ getAssociatedTokenAddressWithBump,
+ deriveMintAddress,
+ derivePoolAddress,
+ deriveCompressedAddress,
+ deriveCompressedMintAddress,
+
+ // Validation
+ isLightTokenAccount,
+ determineTransferType,
+ type TransferType,
+ validateAtaDerivation,
+ validatePositiveAmount,
+ validateDecimals,
+
+ // SPL interface
+ type SplInterfaceInfo,
+ getSplInterfaceInfo,
+ getSplInterfaceInfos,
+ selectSplInterfaceInfo,
+ selectSplInterfaceInfosForDecompression,
+ deriveSplInterfaceInfo,
+} from './utils/index.js';
+
+// ============================================================================
+// CODECS
+// ============================================================================
+
+export {
+ // Types
+ type Compression,
+ type PackedMerkleContext,
+ type MultiInputTokenDataWithContext,
+ type MultiTokenTransferOutputData,
+ type CompressedCpiContext,
+ type CompressedProof,
+ type TokenMetadataExtension,
+ type CompressedOnlyExtension,
+ type RentConfig,
+ type CompressionInfo,
+ type ExtensionInstructionData,
+ type Transfer2InstructionData,
+ type CompressToPubkey,
+ type CompressibleExtensionInstructionData,
+ type CreateAtaInstructionData,
+ type CreateTokenAccountInstructionData,
+
+ // Mint deserializer
+ deserializeCompressedMint,
+ type BaseMint,
+ type DeserializedMintContext,
+ type DeserializedCompressedMint,
+
+ // Transfer2 codecs
+ getCompressionCodec,
+ getPackedMerkleContextCodec,
+ getMultiInputTokenDataCodec,
+ getMultiTokenOutputDataCodec,
+ getCpiContextCodec,
+ getCompressedProofCodec,
+ encodeTransfer2InstructionData,
+ type Transfer2BaseInstructionData,
+
+ // Compressible codecs
+ getCompressibleExtensionDataCodec,
+ getCreateAtaDataCodec,
+ getCreateTokenAccountDataCodec,
+ encodeCreateAtaInstructionData,
+ encodeCreateTokenAccountInstructionData,
+ defaultCompressibleParams,
+
+ // Simple instruction codecs
+ getAmountInstructionCodec,
+ getCheckedInstructionCodec,
+ getDiscriminatorOnlyCodec,
+ encodeMaxTopUp,
+ decodeMaxTopUp,
+ type AmountInstructionData,
+ type CheckedInstructionData,
+ type DiscriminatorOnlyData,
+
+ // MintAction codecs
+ encodeMintActionInstructionData,
+ type MintRecipient,
+ type MintToCompressedAction,
+ type MintToAction,
+ type UpdateAuthorityAction,
+ type UpdateMetadataFieldAction,
+ type UpdateMetadataAuthorityAction,
+ type RemoveMetadataKeyAction,
+ type DecompressMintAction,
+ type CompressAndCloseMintAction,
+ type MintAction,
+ type CreateMint,
+ type MintMetadata,
+ type MintInstructionData,
+ type MintActionCpiContext,
+ type MintActionInstructionData,
+} from './codecs/index.js';
+
+// ============================================================================
+// INSTRUCTIONS
+// ============================================================================
+
+export {
+ // Transfer
+ createTransferInstruction,
+ createTransferCheckedInstruction,
+ createTransferInterfaceInstruction,
+ requiresCompression,
+ type TransferParams,
+ type TransferCheckedParams,
+ type TransferInterfaceParams,
+ type TransferInterfaceResult,
+
+ // Account
+ createAssociatedTokenAccountInstruction,
+ createAssociatedTokenAccountIdempotentInstruction,
+ createTokenAccountInstruction,
+ createCloseAccountInstruction,
+ type CreateAtaParams,
+ type CreateAtaResult,
+ type CreateTokenAccountParams,
+ type CloseAccountParams,
+
+ // Token operations
+ createApproveInstruction,
+ createRevokeInstruction,
+ createBurnInstruction,
+ createBurnCheckedInstruction,
+ createFreezeInstruction,
+ createThawInstruction,
+ type ApproveParams,
+ type RevokeParams,
+ type BurnParams,
+ type BurnCheckedParams,
+ type FreezeParams,
+ type ThawParams,
+ type FreezeThawParams,
+
+ // Mint
+ createMintToInstruction,
+ createMintToCheckedInstruction,
+ type MintToParams,
+ type MintToCheckedParams,
+
+ // Transfer2 (compressed account operations)
+ createTransfer2Instruction,
+ type Transfer2Params,
+
+ // Compression factory functions (for Transfer2)
+ createCompress,
+ createCompressSpl,
+ createDecompress,
+ createDecompressSpl,
+ createCompressAndClose,
+
+ // MintAction (compressed mint management)
+ createMintActionInstruction,
+ type MintActionParams,
+ type MintActionCpiContextAccounts,
+
+ // Rent management
+ createClaimInstruction,
+ type ClaimParams,
+ createWithdrawFundingPoolInstruction,
+ type WithdrawFundingPoolParams,
+
+ // Wrap/Unwrap (SPL ↔ Light Token)
+ createWrapInstruction,
+ createUnwrapInstruction,
+ type WrapParams,
+ type UnwrapParams,
+
+ // SPL interface PDA
+ createSplInterfaceInstruction,
+ addSplInterfacesInstruction,
+ type CreateSplInterfaceParams,
+ type CreateSplInterfaceResult,
+ type AddSplInterfacesParams,
+} from './instructions/index.js';
+
+// ============================================================================
+// CLIENT TYPES
+// ============================================================================
+
+export {
+ // Validation
+ assertValidTreeType,
+ assertV2Tree,
+
+ // Types
+ TreeType,
+ AccountState,
+ IndexerErrorCode,
+ IndexerError,
+ type TreeInfo,
+ type CompressedAccountData,
+ type CompressedAccount,
+ type TokenData,
+ type CompressedTokenAccount,
+ type ValidityProof,
+ type RootIndex,
+ type AccountProofInputs,
+ type AddressProofInputs,
+ type ValidityProofWithContext,
+ type AddressWithTree,
+ type GetCompressedTokenAccountsOptions,
+ type ResponseContext,
+ type IndexerResponse,
+ type ItemsWithCursor,
+ type TokenBalance,
+ type TokenHolder,
+ type SignatureInfo,
+} from './client/index.js';
+
+// ============================================================================
+// INDEXER
+// ============================================================================
+
+export {
+ type LightIndexer,
+ PhotonIndexer,
+ createLightIndexer,
+ isLightIndexerAvailable,
+} from './indexer.js';
+
+// ============================================================================
+// LOAD FUNCTIONS
+// ============================================================================
+
+export {
+ // Types
+ type InputTokenAccount,
+ type MerkleContext,
+ type LoadedTokenAccounts,
+ type LoadTokenAccountsOptions,
+ type SelectedAccounts,
+ type MintContext,
+
+ // Load functions
+ loadTokenAccountsForTransfer,
+ loadTokenAccount,
+ loadAllTokenAccounts,
+ loadCompressedAccount,
+ loadCompressedAccountByHash,
+ loadMintContext,
+
+ // Account selection
+ selectAccountsForAmount,
+ DEFAULT_MAX_INPUTS,
+
+ // Proof helpers
+ getValidityProofForAccounts,
+ needsValidityProof,
+ getTreeInfo,
+ getOutputTreeInfo,
+} from './load.js';
+
+// ============================================================================
+// ACTIONS (high-level builders)
+// ============================================================================
+
+export {
+ // Transfer
+ buildCompressedTransfer,
+ buildTransferDelegated,
+ buildTransferInterface,
+
+ // Compress / Decompress
+ buildCompress,
+ buildDecompress,
+ buildCompressSplTokenAccount,
+ buildDecompressInterface,
+
+ // Wrap / Unwrap
+ buildWrap,
+ buildUnwrap,
+
+ // Mint management
+ buildCreateMint,
+ buildUpdateMintAuthority,
+ buildUpdateFreezeAuthority,
+ buildUpdateMetadataField,
+ buildUpdateMetadataAuthority,
+ buildRemoveMetadataKey,
+ buildDecompressMint,
+
+ // Mint to
+ buildMintToCompressed,
+ buildMintToInterface,
+ buildApproveAndMintTo,
+
+ // ATA
+ buildCreateAta,
+ buildCreateAtaIdempotent,
+ buildGetOrCreateAta,
+
+ // Load
+ buildLoadAta,
+
+ // Types
+ type BuildTransferResult,
+ type BuilderRpc,
+ type MetadataFieldType,
+ type MintRecipientParam,
+} from './actions.js';
+
+// ============================================================================
+// QUERIES
+// ============================================================================
+
+export {
+ getAtaInterface,
+ getMintInterface,
+ getMintDecimals,
+ type QueryRpc,
+ type AtaInterface,
+ type MintInterface,
+ type TokenAccountSource,
+} from './queries.js';
diff --git a/js/token-kit/src/indexer.ts b/js/token-kit/src/indexer.ts
new file mode 100644
index 0000000000..d46ed4748b
--- /dev/null
+++ b/js/token-kit/src/indexer.ts
@@ -0,0 +1,784 @@
+/**
+ * Light Token Client Indexer
+ *
+ * Minimal indexer client for fetching compressed accounts and validity proofs.
+ * Implements the core methods needed for the AccountInterface pattern.
+ */
+
+import { address as createAddress, type Address } from '@solana/addresses';
+import { getBase58Decoder, getBase58Encoder } from '@solana/codecs';
+
+import {
+ type CompressedAccount,
+ type CompressedTokenAccount,
+ type ValidityProofWithContext,
+ type GetCompressedTokenAccountsOptions,
+ type IndexerResponse,
+ type ItemsWithCursor,
+ type AddressWithTree,
+ type TreeInfo,
+ type TokenData,
+ type TokenBalance,
+ type TokenHolder,
+ type SignatureInfo,
+ type CompressedAccountData,
+ type AccountProofInputs,
+ type AddressProofInputs,
+ type RootIndex,
+ TreeType,
+ AccountState,
+ IndexerError,
+ IndexerErrorCode,
+ assertValidTreeType,
+} from './client/index.js';
+
+// ============================================================================
+// INTERFACES
+// ============================================================================
+
+/**
+ * Light indexer interface.
+ *
+ * Provides the minimum methods required for fetching compressed accounts
+ * and validity proofs needed for token operations.
+ */
+export interface LightIndexer {
+ /**
+ * Fetch a compressed account by its address.
+ *
+ * @param address - 32-byte compressed account address
+ * @returns The compressed account or null if not found
+ */
+ getCompressedAccount(
+ address: Uint8Array,
+ ): Promise>;
+
+ /**
+ * Fetch a compressed account by its hash.
+ *
+ * @param hash - 32-byte account hash
+ * @returns The compressed account or null if not found
+ */
+ getCompressedAccountByHash(
+ hash: Uint8Array,
+ ): Promise>;
+
+ /**
+ * Fetch compressed token accounts by owner.
+ *
+ * @param owner - Owner address
+ * @param options - Optional filters and pagination
+ * @returns Paginated list of token accounts
+ */
+ getCompressedTokenAccountsByOwner(
+ owner: Address,
+ options?: GetCompressedTokenAccountsOptions,
+ ): Promise>>;
+
+ /**
+ * Fetch multiple compressed accounts by their addresses.
+ *
+ * @param addresses - Array of 32-byte addresses
+ * @returns Array of compressed accounts (null for not found)
+ */
+ getMultipleCompressedAccounts(
+ addresses: Uint8Array[],
+ ): Promise>;
+
+ /**
+ * Fetch a validity proof for the given account hashes and new addresses.
+ *
+ * @param hashes - Account hashes to prove existence
+ * @param newAddresses - New addresses to prove uniqueness (optional)
+ * @returns Validity proof with context
+ */
+ getValidityProof(
+ hashes: Uint8Array[],
+ newAddresses?: AddressWithTree[],
+ ): Promise>;
+
+ /**
+ * Fetch compressed token balances grouped by mint for an owner.
+ *
+ * @param owner - Owner address
+ * @param options - Optional filters
+ * @returns Paginated list of token balances
+ */
+ getCompressedTokenBalancesByOwner(
+ owner: Address,
+ options?: GetCompressedTokenAccountsOptions,
+ ): Promise>>;
+
+ /**
+ * Fetch token holders for a given mint.
+ *
+ * @param mint - Token mint address
+ * @param options - Optional pagination
+ * @returns Paginated list of token holders
+ */
+ getCompressedMintTokenHolders(
+ mint: Address,
+ options?: { cursor?: string; limit?: number },
+ ): Promise>>;
+
+ /**
+ * Fetch balance of a single compressed token account by hash.
+ *
+ * @param hash - 32-byte account hash
+ * @returns Token balance info or null
+ */
+ getCompressedTokenAccountBalance(
+ hash: Uint8Array,
+ ): Promise>;
+
+ /**
+ * Fetch transaction signatures for a token owner.
+ *
+ * @param owner - Owner address
+ * @param options - Optional pagination
+ * @returns Paginated list of signatures
+ */
+ getSignaturesForTokenOwner(
+ owner: Address,
+ options?: { cursor?: string; limit?: number },
+ ): Promise>>;
+}
+
+// ============================================================================
+// PHOTON INDEXER IMPLEMENTATION
+// ============================================================================
+
+/**
+ * JSON-RPC request structure.
+ */
+interface JsonRpcRequest {
+ jsonrpc: '2.0';
+ id: string;
+ method: string;
+ params: unknown;
+}
+
+/**
+ * JSON-RPC response structure.
+ */
+interface JsonRpcResponse {
+ jsonrpc: '2.0';
+ id: string;
+ result?: {
+ context: { slot: number };
+ value: T;
+ };
+ error?: {
+ code: number;
+ message: string;
+ data?: unknown;
+ };
+}
+
+/**
+ * Photon indexer client.
+ *
+ * Implements the LightIndexer interface using the Photon API.
+ */
+export class PhotonIndexer implements LightIndexer {
+ private requestId = 0;
+ private readonly base58ToBytes_ = getBase58Encoder();
+ private readonly bytesToBase58_ = getBase58Decoder();
+
+ /**
+ * Create a new PhotonIndexer.
+ *
+ * @param endpoint - Photon API endpoint URL
+ */
+ constructor(private readonly endpoint: string) {}
+
+ async getCompressedAccount(
+ address: Uint8Array,
+ ): Promise> {
+ const addressB58 = this.bytesToBase58(address);
+ const response = await this.rpcCall(
+ 'getCompressedAccountV2',
+ { address: addressB58 },
+ );
+
+ return {
+ context: { slot: BigInt(response.context.slot) },
+ value: response.value
+ ? this.parseAccountV2(response.value)
+ : null,
+ };
+ }
+
+ async getCompressedAccountByHash(
+ hash: Uint8Array,
+ ): Promise> {
+ const hashB58 = this.bytesToBase58(hash);
+ const response = await this.rpcCall(
+ 'getCompressedAccountByHashV2',
+ { hash: hashB58 },
+ );
+
+ return {
+ context: { slot: BigInt(response.context.slot) },
+ value: response.value
+ ? this.parseAccountV2(response.value)
+ : null,
+ };
+ }
+
+ async getCompressedTokenAccountsByOwner(
+ owner: Address,
+ options?: GetCompressedTokenAccountsOptions,
+ ): Promise>> {
+ const params: Record = { owner: owner.toString() };
+ if (options?.mint) {
+ params.mint = options.mint.toString();
+ }
+ if (options?.cursor) {
+ params.cursor = options.cursor;
+ }
+ if (options?.limit !== undefined) {
+ params.limit = options.limit;
+ }
+
+ const response = await this.rpcCall(
+ 'getCompressedTokenAccountsByOwnerV2',
+ params,
+ );
+
+ return {
+ context: { slot: BigInt(response.context.slot) },
+ value: {
+ items: response.value.items.map((item) =>
+ this.parseTokenAccountV2(item),
+ ),
+ cursor: response.value.cursor,
+ },
+ };
+ }
+
+ async getMultipleCompressedAccounts(
+ addresses: Uint8Array[],
+ ): Promise> {
+ const addressesB58 = addresses.map((a) => this.bytesToBase58(a));
+ const response = await this.rpcCall(
+ 'getMultipleCompressedAccountsV2',
+ { addresses: addressesB58 },
+ );
+
+ return {
+ context: { slot: BigInt(response.context.slot) },
+ value: response.value.items.map((item) =>
+ item ? this.parseAccountV2(item) : null,
+ ),
+ };
+ }
+
+ async getValidityProof(
+ hashes: Uint8Array[],
+ newAddresses?: AddressWithTree[],
+ ): Promise> {
+ const hashesB58 = hashes.map((h) => this.bytesToBase58(h));
+ const addressesParam = newAddresses?.map((a) => ({
+ address: this.bytesToBase58(a.address),
+ tree: a.tree.toString(),
+ }));
+
+ const response = await this.rpcCall(
+ 'getValidityProofV2',
+ {
+ hashes: hashesB58,
+ newAddressesWithTrees: addressesParam ?? [],
+ },
+ );
+
+ return {
+ context: { slot: BigInt(response.context.slot) },
+ value: this.parseValidityProofV2(response.value),
+ };
+ }
+
+ async getCompressedTokenBalancesByOwner(
+ owner: Address,
+ options?: GetCompressedTokenAccountsOptions,
+ ): Promise>> {
+ const params: Record = { owner: owner.toString() };
+ if (options?.mint) params.mint = options.mint.toString();
+ if (options?.cursor) params.cursor = options.cursor;
+ if (options?.limit !== undefined) params.limit = options.limit;
+
+ const response = await this.rpcCall(
+ 'getCompressedTokenBalancesByOwnerV2',
+ params,
+ );
+
+ return {
+ context: { slot: BigInt(response.context.slot) },
+ value: {
+ items: response.value.items.map((item) => ({
+ mint: createAddress(item.mint),
+ balance: BigInt(item.balance),
+ })),
+ cursor: response.value.cursor,
+ },
+ };
+ }
+
+ async getCompressedMintTokenHolders(
+ mint: Address,
+ options?: { cursor?: string; limit?: number },
+ ): Promise>> {
+ const params: Record = { mint: mint.toString() };
+ if (options?.cursor) params.cursor = options.cursor;
+ if (options?.limit !== undefined) params.limit = options.limit;
+
+ const response = await this.rpcCall(
+ 'getCompressedMintTokenHoldersV2',
+ params,
+ );
+
+ return {
+ context: { slot: BigInt(response.context.slot) },
+ value: {
+ items: response.value.items.map((item) => ({
+ owner: createAddress(item.owner),
+ balance: BigInt(item.balance),
+ })),
+ cursor: response.value.cursor,
+ },
+ };
+ }
+
+ async getCompressedTokenAccountBalance(
+ hash: Uint8Array,
+ ): Promise> {
+ const hashB58 = this.bytesToBase58(hash);
+ const response = await this.rpcCall(
+ 'getCompressedTokenAccountBalanceV2',
+ { hash: hashB58 },
+ );
+
+ return {
+ context: { slot: BigInt(response.context.slot) },
+ value: response.value
+ ? {
+ mint: createAddress(response.value.mint),
+ balance: BigInt(response.value.balance),
+ }
+ : null,
+ };
+ }
+
+ async getSignaturesForTokenOwner(
+ owner: Address,
+ options?: { cursor?: string; limit?: number },
+ ): Promise>> {
+ const params: Record = { owner: owner.toString() };
+ if (options?.cursor) params.cursor = options.cursor;
+ if (options?.limit !== undefined) params.limit = options.limit;
+
+ const response = await this.rpcCall(
+ 'getSignaturesForTokenOwnerV2',
+ params,
+ );
+
+ return {
+ context: { slot: BigInt(response.context.slot) },
+ value: {
+ items: response.value.items.map((item) => ({
+ signature: item.signature,
+ slot: BigInt(item.slot),
+ blockTime: item.blockTime !== null
+ ? BigInt(item.blockTime)
+ : null,
+ })),
+ cursor: response.value.cursor,
+ },
+ };
+ }
+
+ // ========================================================================
+ // PRIVATE HELPERS
+ // ========================================================================
+
+ private async rpcCall(
+ method: string,
+ params: unknown,
+ ): Promise<{ context: { slot: number }; value: T }> {
+ const request: JsonRpcRequest = {
+ jsonrpc: '2.0',
+ id: String(++this.requestId),
+ method,
+ params,
+ };
+
+ let response: Response;
+ try {
+ response = await fetch(this.endpoint, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(request),
+ });
+ } catch (e) {
+ throw new IndexerError(
+ IndexerErrorCode.NetworkError,
+ `Failed to fetch from ${this.endpoint}: ${e}`,
+ e,
+ );
+ }
+
+ if (!response.ok) {
+ throw new IndexerError(
+ IndexerErrorCode.NetworkError,
+ `HTTP error ${response.status}: ${response.statusText}`,
+ );
+ }
+
+ let json: JsonRpcResponse;
+ try {
+ // Parse JSON text manually to preserve big integer precision.
+ // JSON.parse() silently truncates integers > 2^53.
+ // Wrap large numbers as strings before parsing so BigInt()
+ // conversion in parse methods receives the full value.
+ const text = await response.text();
+ const safeText = text.replace(
+ /:\s*(\d{16,})\s*([,}\]])/g,
+ ': "$1"$2',
+ );
+ json = JSON.parse(safeText) as JsonRpcResponse;
+ } catch (e) {
+ throw new IndexerError(
+ IndexerErrorCode.InvalidResponse,
+ `Invalid JSON response: ${e}`,
+ e,
+ );
+ }
+
+ if (json.error) {
+ throw new IndexerError(
+ IndexerErrorCode.RpcError,
+ `RPC error ${json.error.code}: ${json.error.message}`,
+ json.error,
+ );
+ }
+
+ if (!json.result) {
+ throw new IndexerError(
+ IndexerErrorCode.InvalidResponse,
+ 'Missing result in response',
+ );
+ }
+
+ return json.result;
+ }
+
+ private parseTreeInfo(ctx: PhotonMerkleContextV2): TreeInfo {
+ // Validate V2-only tree types
+ assertValidTreeType(ctx.treeType as TreeType);
+
+ const info: TreeInfo = {
+ tree: createAddress(ctx.tree),
+ queue: createAddress(ctx.queue),
+ treeType: ctx.treeType as TreeType,
+ };
+ if (ctx.cpiContext) {
+ info.cpiContext = createAddress(ctx.cpiContext);
+ }
+ if (ctx.nextTreeContext) {
+ info.nextTreeInfo = this.parseTreeInfo(ctx.nextTreeContext);
+ }
+ return info;
+ }
+
+ private parseAccountData(
+ data: PhotonAccountData,
+ ): CompressedAccountData {
+ return {
+ discriminator: this.bigintToBytes8(BigInt(data.discriminator)),
+ data: this.base64Decode(data.data),
+ dataHash: this.base58ToBytes(data.dataHash),
+ };
+ }
+
+ private parseAccountV2(account: PhotonAccountV2): CompressedAccount {
+ return {
+ hash: this.base58ToBytes(account.hash),
+ address: account.address
+ ? this.base58ToBytes(account.address)
+ : null,
+ owner: createAddress(account.owner),
+ lamports: BigInt(account.lamports),
+ data: account.data ? this.parseAccountData(account.data) : null,
+ leafIndex: account.leafIndex,
+ treeInfo: this.parseTreeInfo(account.merkleContext),
+ proveByIndex: Boolean(account.proveByIndex),
+ seq: account.seq !== null ? BigInt(account.seq) : null,
+ slotCreated: BigInt(account.slotCreated),
+ };
+ }
+
+ private parseTokenData(data: PhotonTokenData): TokenData {
+ return {
+ mint: createAddress(data.mint),
+ owner: createAddress(data.owner),
+ amount: BigInt(data.amount),
+ delegate: data.delegate ? createAddress(data.delegate) : null,
+ state:
+ data.state === 'frozen'
+ ? AccountState.Frozen
+ : AccountState.Initialized,
+ tlv: data.tlv ? this.base64Decode(data.tlv) : null,
+ };
+ }
+
+ private parseTokenAccountV2(
+ tokenAccount: PhotonTokenAccountV2,
+ ): CompressedTokenAccount {
+ return {
+ token: this.parseTokenData(tokenAccount.tokenData),
+ account: this.parseAccountV2(tokenAccount.account),
+ };
+ }
+
+ private parseRootIndex(ri: PhotonRootIndex): RootIndex {
+ return {
+ rootIndex: ri.rootIndex,
+ proveByIndex: Boolean(ri.proveByIndex),
+ };
+ }
+
+ private parseAccountProofInputs(
+ input: PhotonAccountProofInputs,
+ ): AccountProofInputs {
+ return {
+ hash: this.base58ToBytes(input.hash),
+ root: this.base58ToBytes(input.root),
+ rootIndex: this.parseRootIndex(input.rootIndex),
+ leafIndex: input.leafIndex,
+ treeInfo: this.parseTreeInfo(input.merkleContext),
+ };
+ }
+
+ private parseAddressProofInputs(
+ input: PhotonAddressProofInputs,
+ ): AddressProofInputs {
+ return {
+ address: this.base58ToBytes(input.address),
+ root: this.base58ToBytes(input.root),
+ rootIndex: input.rootIndex,
+ treeInfo: this.parseTreeInfo(input.merkleContext),
+ };
+ }
+
+ private parseValidityProofV2(
+ proof: PhotonValidityProofV2,
+ ): ValidityProofWithContext {
+ return {
+ proof: proof.compressedProof
+ ? {
+ a: Uint8Array.from(proof.compressedProof.a),
+ b: Uint8Array.from(proof.compressedProof.b),
+ c: Uint8Array.from(proof.compressedProof.c),
+ }
+ : null,
+ accounts: proof.accounts.map((a) => this.parseAccountProofInputs(a)),
+ addresses: proof.addresses.map((a) =>
+ this.parseAddressProofInputs(a),
+ ),
+ };
+ }
+
+ private bytesToBase58(bytes: Uint8Array): string {
+ return this.bytesToBase58_.decode(bytes);
+ }
+
+ private base58ToBytes(str: string): Uint8Array {
+ return Uint8Array.from(this.base58ToBytes_.encode(str));
+ }
+
+ private base64Decode(str: string): Uint8Array {
+ // Use atob for browser/node compatibility
+ const binary = atob(str);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) {
+ bytes[i] = binary.charCodeAt(i);
+ }
+ return bytes;
+ }
+
+ private bigintToBytes8(value: bigint): Uint8Array {
+ const bytes = new Uint8Array(8);
+ let remaining = value;
+ for (let i = 0; i < 8; i++) {
+ bytes[i] = Number(remaining & 0xffn);
+ remaining >>= 8n;
+ }
+ return bytes;
+ }
+}
+
+// ============================================================================
+// PHOTON API RESPONSE TYPES (Internal)
+// ============================================================================
+
+interface PhotonMerkleContextV2 {
+ tree: string;
+ queue: string;
+ treeType: number;
+ cpiContext?: string | null;
+ nextTreeContext?: PhotonMerkleContextV2 | null;
+}
+
+interface PhotonAccountData {
+ discriminator: string | number;
+ data: string;
+ dataHash: string;
+}
+
+interface PhotonAccountV2 {
+ address: string | null;
+ hash: string;
+ data: PhotonAccountData | null;
+ lamports: string | number;
+ owner: string;
+ leafIndex: number;
+ seq: number | null;
+ slotCreated: string | number;
+ merkleContext: PhotonMerkleContextV2;
+ proveByIndex: boolean | number;
+}
+
+interface PhotonTokenData {
+ mint: string;
+ owner: string;
+ amount: string | number;
+ delegate: string | null;
+ state: string;
+ tlv: string | null;
+}
+
+interface PhotonTokenAccountV2 {
+ tokenData: PhotonTokenData;
+ account: PhotonAccountV2;
+}
+
+interface PhotonTokenAccountListV2 {
+ items: PhotonTokenAccountV2[];
+ cursor: string | null;
+}
+
+interface PhotonMultipleAccountsV2 {
+ items: (PhotonAccountV2 | null)[];
+}
+
+interface PhotonRootIndex {
+ rootIndex: number;
+ proveByIndex: boolean | number;
+}
+
+interface PhotonAccountProofInputs {
+ hash: string;
+ root: string;
+ rootIndex: PhotonRootIndex;
+ merkleContext: PhotonMerkleContextV2;
+ leafIndex: number;
+}
+
+interface PhotonAddressProofInputs {
+ address: string;
+ root: string;
+ rootIndex: number;
+ merkleContext: PhotonMerkleContextV2;
+}
+
+interface PhotonTokenBalanceV2 {
+ mint: string;
+ balance: string | number;
+}
+
+interface PhotonTokenBalanceListV2 {
+ items: PhotonTokenBalanceV2[];
+ cursor: string | null;
+}
+
+interface PhotonTokenHolderV2 {
+ owner: string;
+ balance: string | number;
+}
+
+interface PhotonTokenHolderListV2 {
+ items: PhotonTokenHolderV2[];
+ cursor: string | null;
+}
+
+interface PhotonSignatureV2 {
+ signature: string;
+ slot: string | number;
+ blockTime: string | number | null;
+}
+
+interface PhotonSignatureListV2 {
+ items: PhotonSignatureV2[];
+ cursor: string | null;
+}
+
+interface PhotonCompressedProof {
+ a: number[];
+ b: number[];
+ c: number[];
+}
+
+interface PhotonValidityProofV2 {
+ compressedProof: PhotonCompressedProof | null;
+ accounts: PhotonAccountProofInputs[];
+ addresses: PhotonAddressProofInputs[];
+}
+
+// ============================================================================
+// FACTORY FUNCTION
+// ============================================================================
+
+/**
+ * Create a Light indexer client.
+ *
+ * @param endpoint - Photon API endpoint URL
+ * @returns LightIndexer instance
+ *
+ * @example
+ * ```typescript
+ * const indexer = createLightIndexer('https://photon.helius.dev');
+ * const accounts = await indexer.getCompressedTokenAccountsByOwner(owner);
+ * const proof = await indexer.getValidityProof(hashes);
+ * ```
+ */
+export function createLightIndexer(endpoint: string): LightIndexer {
+ return new PhotonIndexer(endpoint);
+}
+
+/**
+ * Check if Light indexer services are available.
+ *
+ * @param endpoint - Photon API endpoint URL
+ * @returns True if the indexer is healthy
+ */
+export async function isLightIndexerAvailable(
+ endpoint: string,
+): Promise {
+ try {
+ const response = await fetch(endpoint, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ jsonrpc: '2.0',
+ id: '1',
+ method: 'getIndexerHealth',
+ params: {},
+ }),
+ });
+ if (!response.ok) return false;
+ const json = await response.json();
+ return !json.error;
+ } catch {
+ return false;
+ }
+}
diff --git a/js/token-kit/src/instructions/approve.ts b/js/token-kit/src/instructions/approve.ts
new file mode 100644
index 0000000000..a6b0ba6fe7
--- /dev/null
+++ b/js/token-kit/src/instructions/approve.ts
@@ -0,0 +1,119 @@
+/**
+ * Approve and revoke delegate instructions.
+ */
+
+import type { Address } from '@solana/addresses';
+import {
+ AccountRole,
+ type Instruction,
+ type AccountMeta,
+} from '@solana/instructions';
+
+import { DISCRIMINATOR, LIGHT_TOKEN_PROGRAM_ID } from '../constants.js';
+import { validatePositiveAmount } from '../utils/validation.js';
+import {
+ getAmountInstructionEncoder,
+ getDiscriminatorOnlyEncoder,
+} from '../codecs/instructions.js';
+import { buildInstructionDataWithMaxTopUp } from './helpers.js';
+
+/**
+ * Parameters for approving a delegate.
+ */
+export interface ApproveParams {
+ /** Token account to approve delegate on */
+ tokenAccount: Address;
+ /** Delegate to approve */
+ delegate: Address;
+ /** Owner of the token account - must be signer and payer */
+ owner: Address;
+ /** Amount to delegate */
+ amount: bigint;
+ /** Maximum lamports for rent top-up (optional, 0 = no limit) */
+ maxTopUp?: number;
+}
+
+/**
+ * Creates an approve instruction (discriminator: 4).
+ *
+ * Approves a delegate to transfer up to the specified amount.
+ *
+ * Account layout:
+ * 0: token account (writable)
+ * 1: delegate (readonly)
+ * 2: owner (signer, writable) - always the payer (APPROVE_PAYER_IDX=2 in Rust)
+ *
+ * Note: Unlike transfer/burn/mint-to, approve does NOT support a separate fee payer.
+ * The owner is always the payer for compressible rent top-ups.
+ *
+ * @param params - Approve parameters
+ * @returns The approve instruction
+ */
+export function createApproveInstruction(params: ApproveParams): Instruction {
+ const { tokenAccount, delegate, owner, amount, maxTopUp } = params;
+
+ validatePositiveAmount(amount);
+
+ const accounts: AccountMeta[] = [
+ { address: tokenAccount, role: AccountRole.WRITABLE },
+ { address: delegate, role: AccountRole.READONLY },
+ { address: owner, role: AccountRole.WRITABLE_SIGNER },
+ ];
+
+ const baseBytes = getAmountInstructionEncoder().encode({
+ discriminator: DISCRIMINATOR.APPROVE,
+ amount,
+ });
+
+ return {
+ programAddress: LIGHT_TOKEN_PROGRAM_ID,
+ accounts,
+ data: buildInstructionDataWithMaxTopUp(baseBytes, maxTopUp),
+ };
+}
+
+/**
+ * Parameters for revoking a delegate.
+ */
+export interface RevokeParams {
+ /** Token account to revoke delegate from */
+ tokenAccount: Address;
+ /** Owner of the token account - must be signer and payer */
+ owner: Address;
+ /** Maximum lamports for rent top-up (optional, 0 = no limit) */
+ maxTopUp?: number;
+}
+
+/**
+ * Creates a revoke instruction (discriminator: 5).
+ *
+ * Revokes the delegate authority from the token account.
+ *
+ * Account layout:
+ * 0: token account (writable)
+ * 1: owner (signer, writable) - always the payer (REVOKE_PAYER_IDX=1 in Rust)
+ *
+ * Note: Unlike transfer/burn/mint-to, revoke does NOT support a separate fee payer.
+ * The owner is always the payer for compressible rent top-ups.
+ *
+ * @param params - Revoke parameters
+ * @returns The revoke instruction
+ */
+export function createRevokeInstruction(params: RevokeParams): Instruction {
+ const { tokenAccount, owner, maxTopUp } = params;
+
+ const accounts: AccountMeta[] = [
+ { address: tokenAccount, role: AccountRole.WRITABLE },
+ { address: owner, role: AccountRole.WRITABLE_SIGNER },
+ ];
+
+ const baseBytes = getDiscriminatorOnlyEncoder().encode({
+ discriminator: DISCRIMINATOR.REVOKE,
+ });
+
+ return {
+ programAddress: LIGHT_TOKEN_PROGRAM_ID,
+ accounts,
+ data: buildInstructionDataWithMaxTopUp(baseBytes, maxTopUp),
+ };
+}
diff --git a/js/token-kit/src/instructions/burn.ts b/js/token-kit/src/instructions/burn.ts
new file mode 100644
index 0000000000..b5e28d1a89
--- /dev/null
+++ b/js/token-kit/src/instructions/burn.ts
@@ -0,0 +1,148 @@
+/**
+ * Burn token instructions.
+ */
+
+import type { Address } from '@solana/addresses';
+import {
+ AccountRole,
+ type Instruction,
+ type AccountMeta,
+} from '@solana/instructions';
+
+import {
+ DISCRIMINATOR,
+ LIGHT_TOKEN_PROGRAM_ID,
+ SYSTEM_PROGRAM_ID,
+} from '../constants.js';
+import { validatePositiveAmount, validateDecimals } from '../utils/validation.js';
+import {
+ getAmountInstructionEncoder,
+ getCheckedInstructionEncoder,
+} from '../codecs/instructions.js';
+import { buildInstructionDataWithMaxTopUp } from './helpers.js';
+
+/**
+ * Parameters for burning tokens.
+ */
+export interface BurnParams {
+ /** Token account to burn from */
+ tokenAccount: Address;
+ /** Mint address (CMint) */
+ mint: Address;
+ /** Authority (owner or delegate) - must be signer */
+ authority: Address;
+ /** Amount to burn */
+ amount: bigint;
+ /** Maximum lamports for rent top-up (optional, 0 = no limit) */
+ maxTopUp?: number;
+ /** Fee payer for rent top-ups (optional, defaults to authority) */
+ feePayer?: Address;
+}
+
+/**
+ * Creates a burn instruction (discriminator: 8).
+ *
+ * Burns tokens from the token account and updates mint supply.
+ *
+ * Account layout:
+ * 0: source CToken account (writable)
+ * 1: CMint account (writable)
+ * 2: authority (signer, writable unless feePayer provided)
+ * 3: system_program (readonly)
+ * 4: fee_payer (optional, signer, writable)
+ *
+ * @param params - Burn parameters
+ * @returns The burn instruction
+ */
+export function createBurnInstruction(params: BurnParams): Instruction {
+ const { tokenAccount, mint, authority, amount, maxTopUp, feePayer } =
+ params;
+
+ validatePositiveAmount(amount);
+
+ const accounts: AccountMeta[] = [
+ { address: tokenAccount, role: AccountRole.WRITABLE },
+ { address: mint, role: AccountRole.WRITABLE },
+ {
+ address: authority,
+ role: feePayer
+ ? AccountRole.READONLY_SIGNER
+ : AccountRole.WRITABLE_SIGNER,
+ },
+ { address: SYSTEM_PROGRAM_ID, role: AccountRole.READONLY },
+ ];
+ if (feePayer) {
+ accounts.push({ address: feePayer, role: AccountRole.WRITABLE_SIGNER });
+ }
+
+ const baseBytes = getAmountInstructionEncoder().encode({
+ discriminator: DISCRIMINATOR.BURN,
+ amount,
+ });
+
+ return {
+ programAddress: LIGHT_TOKEN_PROGRAM_ID,
+ accounts,
+ data: buildInstructionDataWithMaxTopUp(baseBytes, maxTopUp),
+ };
+}
+
+/**
+ * Parameters for burn checked.
+ */
+export interface BurnCheckedParams extends BurnParams {
+ /** Expected decimals */
+ decimals: number;
+}
+
+/**
+ * Creates a burn checked instruction (discriminator: 15).
+ *
+ * Burns tokens with decimals validation.
+ *
+ * @param params - Burn checked parameters
+ * @returns The burn checked instruction
+ */
+export function createBurnCheckedInstruction(
+ params: BurnCheckedParams,
+): Instruction {
+ const {
+ tokenAccount,
+ mint,
+ authority,
+ amount,
+ decimals,
+ maxTopUp,
+ feePayer,
+ } = params;
+
+ validatePositiveAmount(amount);
+ validateDecimals(decimals);
+
+ const accounts: AccountMeta[] = [
+ { address: tokenAccount, role: AccountRole.WRITABLE },
+ { address: mint, role: AccountRole.WRITABLE },
+ {
+ address: authority,
+ role: feePayer
+ ? AccountRole.READONLY_SIGNER
+ : AccountRole.WRITABLE_SIGNER,
+ },
+ { address: SYSTEM_PROGRAM_ID, role: AccountRole.READONLY },
+ ];
+ if (feePayer) {
+ accounts.push({ address: feePayer, role: AccountRole.WRITABLE_SIGNER });
+ }
+
+ const baseBytes = getCheckedInstructionEncoder().encode({
+ discriminator: DISCRIMINATOR.BURN_CHECKED,
+ amount,
+ decimals,
+ });
+
+ return {
+ programAddress: LIGHT_TOKEN_PROGRAM_ID,
+ accounts,
+ data: buildInstructionDataWithMaxTopUp(baseBytes, maxTopUp),
+ };
+}
diff --git a/js/token-kit/src/instructions/claim.ts b/js/token-kit/src/instructions/claim.ts
new file mode 100644
index 0000000000..208a058552
--- /dev/null
+++ b/js/token-kit/src/instructions/claim.ts
@@ -0,0 +1,72 @@
+/**
+ * Claim rent instruction.
+ */
+
+import type { Address } from '@solana/addresses';
+import {
+ AccountRole,
+ type Instruction,
+ type AccountMeta,
+} from '@solana/instructions';
+
+import { DISCRIMINATOR, LIGHT_TOKEN_PROGRAM_ID } from '../constants.js';
+import { getDiscriminatorOnlyEncoder } from '../codecs/instructions.js';
+
+/**
+ * Parameters for claiming rent from compressible accounts.
+ */
+export interface ClaimParams {
+ /** Rent sponsor PDA receiving claimed rent (writable) */
+ rentSponsor: Address;
+ /** Compression authority (signer) */
+ compressionAuthority: Address;
+ /** Compressible config account (readonly) */
+ compressibleConfig: Address;
+ /** Token accounts to claim rent from (writable, variable count) */
+ tokenAccounts: Address[];
+}
+
+/**
+ * Creates a claim instruction (discriminator: 104).
+ *
+ * Claims rent from compressible token accounts and returns it to the
+ * rent sponsor PDA.
+ *
+ * Account layout:
+ * 0: rent_sponsor (writable) - PDA receiving claimed rent
+ * 1: compression_authority (signer)
+ * 2: compressible_config (readonly) - CompressibleConfig
+ * 3+: token_accounts... (writable, variable count)
+ *
+ * @param params - Claim parameters
+ * @returns The claim instruction
+ */
+export function createClaimInstruction(params: ClaimParams): Instruction {
+ const { rentSponsor, compressionAuthority, compressibleConfig, tokenAccounts } =
+ params;
+
+ // Build accounts
+ const accounts: AccountMeta[] = [
+ { address: rentSponsor, role: AccountRole.WRITABLE },
+ { address: compressionAuthority, role: AccountRole.READONLY_SIGNER },
+ { address: compressibleConfig, role: AccountRole.READONLY },
+ ];
+
+ // Add variable-count token accounts
+ for (const tokenAccount of tokenAccounts) {
+ accounts.push({ address: tokenAccount, role: AccountRole.WRITABLE });
+ }
+
+ // Build instruction data (just discriminator, no additional data)
+ const data = new Uint8Array(
+ getDiscriminatorOnlyEncoder().encode({
+ discriminator: DISCRIMINATOR.CLAIM,
+ }),
+ );
+
+ return {
+ programAddress: LIGHT_TOKEN_PROGRAM_ID,
+ accounts,
+ data,
+ };
+}
diff --git a/js/token-kit/src/instructions/close.ts b/js/token-kit/src/instructions/close.ts
new file mode 100644
index 0000000000..54097a7418
--- /dev/null
+++ b/js/token-kit/src/instructions/close.ts
@@ -0,0 +1,73 @@
+/**
+ * Close token account instruction.
+ */
+
+import type { Address } from '@solana/addresses';
+import {
+ AccountRole,
+ type Instruction,
+ type AccountMeta,
+} from '@solana/instructions';
+
+import { DISCRIMINATOR, LIGHT_TOKEN_PROGRAM_ID } from '../constants.js';
+import { getDiscriminatorOnlyEncoder } from '../codecs/instructions.js';
+
+/**
+ * Parameters for closing a token account.
+ */
+export interface CloseAccountParams {
+ /** Token account to close */
+ tokenAccount: Address;
+ /** Destination for remaining lamports */
+ destination: Address;
+ /** Owner of the token account - must be signer */
+ owner: Address;
+ /** Rent sponsor for compressible accounts (optional, writable) */
+ rentSponsor?: Address;
+}
+
+/**
+ * Creates a close token account instruction (discriminator: 9).
+ *
+ * Closes a decompressed CToken account and returns rent to the destination.
+ * For compressible accounts, rent goes to the rent sponsor.
+ *
+ * Account layout:
+ * 0: token account (writable)
+ * 1: destination (writable)
+ * 2: authority/owner (signer)
+ * 3: rent_sponsor (optional, writable) - required for compressible accounts
+ *
+ * @param params - Close account parameters
+ * @returns The close instruction
+ */
+export function createCloseAccountInstruction(
+ params: CloseAccountParams,
+): Instruction {
+ const { tokenAccount, destination, owner, rentSponsor } = params;
+
+ // Build accounts
+ const accounts: AccountMeta[] = [
+ { address: tokenAccount, role: AccountRole.WRITABLE },
+ { address: destination, role: AccountRole.WRITABLE },
+ { address: owner, role: AccountRole.READONLY_SIGNER },
+ ];
+
+ // Add rent sponsor if provided (required for compressible accounts)
+ if (rentSponsor) {
+ accounts.push({ address: rentSponsor, role: AccountRole.WRITABLE });
+ }
+
+ // Build instruction data (just discriminator)
+ const data = new Uint8Array(
+ getDiscriminatorOnlyEncoder().encode({
+ discriminator: DISCRIMINATOR.CLOSE,
+ }),
+ );
+
+ return {
+ programAddress: LIGHT_TOKEN_PROGRAM_ID,
+ accounts,
+ data,
+ };
+}
diff --git a/js/token-kit/src/instructions/create-ata.ts b/js/token-kit/src/instructions/create-ata.ts
new file mode 100644
index 0000000000..03dd8af228
--- /dev/null
+++ b/js/token-kit/src/instructions/create-ata.ts
@@ -0,0 +1,123 @@
+/**
+ * Create Associated Token Account instruction.
+ */
+
+import type { Address } from '@solana/addresses';
+import {
+ AccountRole,
+ type Instruction,
+ type AccountMeta,
+} from '@solana/instructions';
+
+import {
+ LIGHT_TOKEN_PROGRAM_ID,
+ SYSTEM_PROGRAM_ID,
+ LIGHT_TOKEN_CONFIG,
+ LIGHT_TOKEN_RENT_SPONSOR,
+} from '../constants.js';
+import { deriveAssociatedTokenAddress } from '../utils/derivation.js';
+import {
+ encodeCreateAtaInstructionData,
+ defaultCompressibleParams,
+} from '../codecs/compressible.js';
+import type { CompressibleExtensionInstructionData } from '../codecs/types.js';
+
+/**
+ * Parameters for creating an associated token account.
+ */
+export interface CreateAtaParams {
+ /** Payer for the account creation */
+ payer: Address;
+ /** Owner of the token account */
+ owner: Address;
+ /** Mint address */
+ mint: Address;
+ /** Compressible config account (defaults to LIGHT_TOKEN_CONFIG) */
+ compressibleConfig?: Address;
+ /** Rent sponsor PDA (defaults to LIGHT_TOKEN_RENT_SPONSOR) */
+ rentSponsor?: Address;
+ /** Compressible extension params (optional, uses production defaults) */
+ compressibleParams?: CompressibleExtensionInstructionData;
+ /** Whether to use idempotent variant (no-op if exists) */
+ idempotent?: boolean;
+}
+
+/**
+ * Result of ATA creation.
+ */
+export interface CreateAtaResult {
+ /** The derived ATA address */
+ address: Address;
+ /** The PDA bump */
+ bump: number;
+ /** The instruction to create the ATA */
+ instruction: Instruction;
+}
+
+/**
+ * Creates an associated token account instruction.
+ *
+ * @param params - ATA creation parameters
+ * @returns The ATA address, bump, and instruction
+ */
+export async function createAssociatedTokenAccountInstruction(
+ params: CreateAtaParams,
+): Promise {
+ const {
+ payer,
+ owner,
+ mint,
+ compressibleConfig = LIGHT_TOKEN_CONFIG,
+ rentSponsor = LIGHT_TOKEN_RENT_SPONSOR,
+ compressibleParams = defaultCompressibleParams(),
+ idempotent = false,
+ } = params;
+
+ // Derive the ATA address
+ const { address: ata, bump } = await deriveAssociatedTokenAddress(
+ owner,
+ mint,
+ );
+
+ // Build accounts
+ const accounts: AccountMeta[] = [
+ { address: owner, role: AccountRole.READONLY },
+ { address: mint, role: AccountRole.READONLY },
+ { address: payer, role: AccountRole.WRITABLE_SIGNER },
+ { address: ata, role: AccountRole.WRITABLE },
+ { address: SYSTEM_PROGRAM_ID, role: AccountRole.READONLY },
+ { address: compressibleConfig, role: AccountRole.READONLY },
+ { address: rentSponsor, role: AccountRole.WRITABLE },
+ ];
+
+ // Build instruction data
+ const data = encodeCreateAtaInstructionData(
+ {
+ compressibleConfig: compressibleParams,
+ },
+ idempotent,
+ );
+
+ const instruction: Instruction = {
+ programAddress: LIGHT_TOKEN_PROGRAM_ID,
+ accounts,
+ data,
+ };
+
+ return { address: ata, bump, instruction };
+}
+
+/**
+ * Creates an idempotent ATA instruction (no-op if account exists).
+ *
+ * @param params - ATA creation parameters (idempotent flag ignored)
+ * @returns The ATA address, bump, and instruction
+ */
+export async function createAssociatedTokenAccountIdempotentInstruction(
+ params: Omit,
+): Promise {
+ return createAssociatedTokenAccountInstruction({
+ ...params,
+ idempotent: true,
+ });
+}
diff --git a/js/token-kit/src/instructions/create-token-account.ts b/js/token-kit/src/instructions/create-token-account.ts
new file mode 100644
index 0000000000..a2a0dd0a2c
--- /dev/null
+++ b/js/token-kit/src/instructions/create-token-account.ts
@@ -0,0 +1,129 @@
+/**
+ * Create token account instruction.
+ */
+
+import type { Address } from '@solana/addresses';
+import {
+ AccountRole,
+ type Instruction,
+ type AccountMeta,
+} from '@solana/instructions';
+
+import {
+ LIGHT_TOKEN_PROGRAM_ID,
+ SYSTEM_PROGRAM_ID,
+ LIGHT_TOKEN_CONFIG,
+ LIGHT_TOKEN_RENT_SPONSOR,
+} from '../constants.js';
+import { encodeCreateTokenAccountInstructionData } from '../codecs/compressible.js';
+import type { CompressibleExtensionInstructionData } from '../codecs/types.js';
+
+/**
+ * Parameters for creating a token account.
+ */
+export interface CreateTokenAccountParams {
+ /** Token account address */
+ tokenAccount: Address;
+ /** Mint address */
+ mint: Address;
+ /** Owner of the token account */
+ owner: Address;
+ /** Compressible extension params (optional, enables compressible mode) */
+ compressibleParams?: CompressibleExtensionInstructionData;
+ /** Payer for account creation (required for compressible accounts) */
+ payer?: Address;
+ /** Compressible config account (defaults to LIGHT_TOKEN_CONFIG) */
+ compressibleConfig?: Address;
+ /** Rent sponsor PDA (defaults to LIGHT_TOKEN_RENT_SPONSOR) */
+ rentSponsor?: Address;
+ /** When true, emits SPL-compatible owner-only payload (non-compressible only) */
+ splCompatibleOwnerOnlyData?: boolean;
+}
+
+/**
+ * Creates a create token account instruction (discriminator: 18).
+ *
+ * Creates a CToken account for the given owner and mint.
+ *
+ * Account layout (non-compressible, owner-only data):
+ * 0: token_account (writable) - SPL compatible, non-signer
+ * 1: mint (readonly)
+ *
+ * Account layout (compressible):
+ * 0: token_account (signer, writable) - created via CPI
+ * 1: mint (readonly)
+ * 2: payer (signer, writable)
+ * 3: config_account (readonly) - CompressibleConfig
+ * 4: system_program (readonly)
+ * 5: rent_sponsor (writable)
+ *
+ * @param params - Create token account parameters
+ * @returns The create token account instruction
+ */
+export function createTokenAccountInstruction(
+ params: CreateTokenAccountParams,
+): Instruction {
+ const {
+ tokenAccount,
+ mint,
+ owner,
+ compressibleParams,
+ payer,
+ compressibleConfig,
+ rentSponsor,
+ splCompatibleOwnerOnlyData,
+ } = params;
+
+ const isCompressible = compressibleParams !== undefined;
+
+ // Validate: payer/compressibleConfig/rentSponsor require compressibleParams
+ if (!isCompressible && (payer !== undefined || compressibleConfig !== undefined || rentSponsor !== undefined)) {
+ throw new Error('payer/compressibleConfig/rentSponsor require compressibleParams');
+ }
+
+ // Validate: splCompatibleOwnerOnlyData is only valid for non-compressible
+ if (splCompatibleOwnerOnlyData && isCompressible) {
+ throw new Error('splCompatibleOwnerOnlyData is only valid for non-compressible token account creation');
+ }
+
+ // Validate: compressibleParams requires payer
+ if (isCompressible && !payer) {
+ throw new Error('payer is required when compressibleParams is provided');
+ }
+
+ // Build accounts
+ const accounts: AccountMeta[] = [
+ {
+ address: tokenAccount,
+ role: isCompressible
+ ? AccountRole.WRITABLE_SIGNER
+ : AccountRole.WRITABLE,
+ },
+ { address: mint, role: AccountRole.READONLY },
+ ];
+
+ if (isCompressible) {
+ accounts.push(
+ { address: payer!, role: AccountRole.WRITABLE_SIGNER },
+ { address: compressibleConfig ?? LIGHT_TOKEN_CONFIG, role: AccountRole.READONLY },
+ { address: SYSTEM_PROGRAM_ID, role: AccountRole.READONLY },
+ { address: rentSponsor ?? LIGHT_TOKEN_RENT_SPONSOR, role: AccountRole.WRITABLE },
+ );
+ }
+
+ // Build instruction data
+ const useSplOwnerOnly = splCompatibleOwnerOnlyData === true;
+ const data = encodeCreateTokenAccountInstructionData(
+ {
+ owner,
+ compressibleConfig: compressibleParams ?? null,
+ },
+ useSplOwnerOnly,
+ );
+
+ return {
+ programAddress: LIGHT_TOKEN_PROGRAM_ID,
+ accounts,
+ data,
+ };
+}
diff --git a/js/token-kit/src/instructions/freeze-thaw.ts b/js/token-kit/src/instructions/freeze-thaw.ts
new file mode 100644
index 0000000000..f17195ff69
--- /dev/null
+++ b/js/token-kit/src/instructions/freeze-thaw.ts
@@ -0,0 +1,71 @@
+/**
+ * Freeze and thaw token account instructions.
+ */
+
+import type { Address } from '@solana/addresses';
+import {
+ AccountRole,
+ type Instruction,
+ type AccountMeta,
+} from '@solana/instructions';
+
+import { DISCRIMINATOR, LIGHT_TOKEN_PROGRAM_ID } from '../constants.js';
+import { getDiscriminatorOnlyEncoder } from '../codecs/instructions.js';
+
+/**
+ * Parameters for freezing or thawing a token account.
+ */
+export interface FreezeThawParams {
+ /** Token account to freeze/thaw */
+ tokenAccount: Address;
+ /** Mint address */
+ mint: Address;
+ /** Freeze authority - must be signer */
+ freezeAuthority: Address;
+}
+
+/** @deprecated Use FreezeThawParams instead. */
+export type FreezeParams = FreezeThawParams;
+/** @deprecated Use FreezeThawParams instead. */
+export type ThawParams = FreezeThawParams;
+
+function createFreezeThawInstruction(
+ params: FreezeThawParams,
+ discriminator: number,
+): Instruction {
+ const { tokenAccount, mint, freezeAuthority } = params;
+
+ const accounts: AccountMeta[] = [
+ { address: tokenAccount, role: AccountRole.WRITABLE },
+ { address: mint, role: AccountRole.READONLY },
+ { address: freezeAuthority, role: AccountRole.READONLY_SIGNER },
+ ];
+
+ const data = new Uint8Array(
+ getDiscriminatorOnlyEncoder().encode({ discriminator }),
+ );
+
+ return {
+ programAddress: LIGHT_TOKEN_PROGRAM_ID,
+ accounts,
+ data,
+ };
+}
+
+/**
+ * Creates a freeze instruction (discriminator: 10).
+ *
+ * Freezes a token account, preventing transfers.
+ */
+export function createFreezeInstruction(params: FreezeThawParams): Instruction {
+ return createFreezeThawInstruction(params, DISCRIMINATOR.FREEZE);
+}
+
+/**
+ * Creates a thaw instruction (discriminator: 11).
+ *
+ * Thaws a frozen token account, allowing transfers again.
+ */
+export function createThawInstruction(params: FreezeThawParams): Instruction {
+ return createFreezeThawInstruction(params, DISCRIMINATOR.THAW);
+}
diff --git a/js/token-kit/src/instructions/helpers.ts b/js/token-kit/src/instructions/helpers.ts
new file mode 100644
index 0000000000..38b6d77d20
--- /dev/null
+++ b/js/token-kit/src/instructions/helpers.ts
@@ -0,0 +1,23 @@
+/**
+ * Shared helpers for instruction builders.
+ */
+
+import type { ReadonlyUint8Array } from '@solana/codecs';
+
+import { encodeMaxTopUp } from '../codecs/instructions.js';
+
+/**
+ * Builds instruction data by concatenating base bytes with an optional maxTopUp suffix.
+ */
+export function buildInstructionDataWithMaxTopUp(
+ baseBytes: ReadonlyUint8Array,
+ maxTopUp?: number,
+): Uint8Array {
+ const maxTopUpBytes = encodeMaxTopUp(maxTopUp);
+ const data = new Uint8Array(baseBytes.length + maxTopUpBytes.length);
+ data.set(baseBytes, 0);
+ if (maxTopUpBytes.length > 0) {
+ data.set(maxTopUpBytes, baseBytes.length);
+ }
+ return data;
+}
diff --git a/js/token-kit/src/instructions/index.ts b/js/token-kit/src/instructions/index.ts
new file mode 100644
index 0000000000..2bf6968432
--- /dev/null
+++ b/js/token-kit/src/instructions/index.ts
@@ -0,0 +1,20 @@
+/**
+ * Light Token instruction builders.
+ */
+
+export * from './create-ata.js';
+export * from './create-token-account.js';
+export * from './close.js';
+export * from './mint-to.js';
+export * from './approve.js';
+export * from './burn.js';
+export * from './freeze-thaw.js';
+export * from './transfer.js';
+export * from './transfer2.js';
+export * from './transfer-interface.js';
+export * from './mint-action.js';
+export * from './claim.js';
+export * from './withdraw-funding-pool.js';
+export { buildInstructionDataWithMaxTopUp } from './helpers.js';
+export * from './wrap-unwrap.js';
+export * from './spl-interface.js';
diff --git a/js/token-kit/src/instructions/mint-action.ts b/js/token-kit/src/instructions/mint-action.ts
new file mode 100644
index 0000000000..fceb3cb546
--- /dev/null
+++ b/js/token-kit/src/instructions/mint-action.ts
@@ -0,0 +1,190 @@
+/**
+ * MintAction instruction builder.
+ */
+
+import type { Address } from '@solana/addresses';
+import {
+ AccountRole,
+ type Instruction,
+ type AccountMeta,
+} from '@solana/instructions';
+
+import {
+ LIGHT_TOKEN_PROGRAM_ID,
+ LIGHT_SYSTEM_PROGRAM_ID,
+ CPI_AUTHORITY,
+ REGISTERED_PROGRAM_PDA,
+ ACCOUNT_COMPRESSION_AUTHORITY_PDA,
+ ACCOUNT_COMPRESSION_PROGRAM_ID,
+ SYSTEM_PROGRAM_ID,
+} from '../constants.js';
+
+import { encodeMintActionInstructionData } from '../codecs/mint-action.js';
+import type { MintActionInstructionData } from '../codecs/mint-action.js';
+
+// ============================================================================
+// MINT ACTION INSTRUCTION
+// ============================================================================
+
+/**
+ * CPI context accounts for mint action.
+ */
+export interface MintActionCpiContextAccounts {
+ /** Fee payer (writable signer) */
+ feePayer: Address;
+ /** CPI authority PDA (readonly) */
+ cpiAuthorityPda: Address;
+ /** CPI context account (writable) */
+ cpiContext: Address;
+}
+
+/**
+ * Parameters for MintAction instruction.
+ */
+export interface MintActionParams {
+ /** Mint signer (optional, role depends on whether createMint is set) */
+ mintSigner?: Address;
+ /** Authority (signer) - mint authority for the token */
+ authority: Address;
+ /** Fee payer (signer, writable) */
+ feePayer: Address;
+ /** Output queue (writable) */
+ outOutputQueue: Address;
+ /** Merkle tree (writable) */
+ merkleTree: Address;
+ /** Structured instruction data (encoded via codec) */
+ data: MintActionInstructionData;
+ /** Packed remaining accounts (optional) */
+ packedAccounts?: AccountMeta[];
+ /** Compressible config account (optional, readonly) */
+ compressibleConfig?: Address;
+ /** Compressed mint account (optional, writable) */
+ cmint?: Address;
+ /** Rent sponsor (optional, writable) */
+ rentSponsor?: Address;
+ /** CPI context accounts (optional, triggers CPI context path) */
+ cpiContextAccounts?: MintActionCpiContextAccounts;
+}
+
+/**
+ * Creates a MintAction instruction (discriminator: 103).
+ *
+ * MintAction supports batch minting operations for compressed tokens.
+ *
+ * Normal path account layout (matches on-chain program parsing order):
+ * 0: light_system_program (readonly)
+ * 1: authority (readonly signer)
+ * [optional: mintSigner — only when createMint is set]
+ * [optional: compressibleConfig — for DecompressMint/CompressAndCloseMint]
+ * [optional: cmint — for decompressed mints]
+ * [optional: rentSponsor — for DecompressMint/CompressAndCloseMint]
+ * N: fee_payer (writable signer) \
+ * N+1: cpi_authority_pda (readonly) |
+ * N+2: registered_program_pda (readonly) | LightSystemAccounts (6)
+ * N+3: account_compression_authority |
+ * N+4: account_compression_program |
+ * N+5: system_program (readonly) /
+ * N+6: out_output_queue (writable)
+ * N+7: merkle_tree (writable)
+ * [...packed_accounts]
+ *
+ * CPI context path account layout:
+ * 0: light_system_program (readonly)
+ * 1: authority (readonly signer)
+ * 2: fee_payer (writable signer)
+ * 3: cpi_authority_pda (readonly)
+ * 4: cpi_context (writable)
+ *
+ * @param params - MintAction parameters
+ * @returns The MintAction instruction
+ */
+export function createMintActionInstruction(
+ params: MintActionParams,
+): Instruction {
+ const {
+ mintSigner,
+ authority,
+ feePayer,
+ outOutputQueue,
+ merkleTree,
+ data: mintActionData,
+ packedAccounts,
+ compressibleConfig,
+ cmint,
+ rentSponsor,
+ cpiContextAccounts,
+ } = params;
+
+ const accounts: AccountMeta[] = [];
+
+ if (cpiContextAccounts) {
+ // CPI context path
+ accounts.push(
+ { address: LIGHT_SYSTEM_PROGRAM_ID, role: AccountRole.READONLY },
+ { address: authority, role: AccountRole.READONLY_SIGNER },
+ { address: cpiContextAccounts.feePayer, role: AccountRole.WRITABLE_SIGNER },
+ { address: cpiContextAccounts.cpiAuthorityPda, role: AccountRole.READONLY },
+ { address: cpiContextAccounts.cpiContext, role: AccountRole.WRITABLE },
+ );
+ } else {
+ // Normal path: program parses optional accounts between authority
+ // and Light system accounts (fee_payer, cpi_authority, etc.)
+ accounts.push(
+ { address: LIGHT_SYSTEM_PROGRAM_ID, role: AccountRole.READONLY },
+ { address: authority, role: AccountRole.READONLY_SIGNER },
+ );
+
+ // Optional accounts — order must match on-chain parsing:
+ // mint_signer → compressible_config → cmint → rent_sponsor
+ if (mintSigner) {
+ const hasCreateMint = mintActionData.createMint !== null;
+ accounts.push({
+ address: mintSigner,
+ role: hasCreateMint
+ ? AccountRole.READONLY_SIGNER
+ : AccountRole.READONLY,
+ });
+ }
+ if (compressibleConfig) {
+ accounts.push({ address: compressibleConfig, role: AccountRole.READONLY });
+ }
+ if (cmint) {
+ accounts.push({ address: cmint, role: AccountRole.WRITABLE });
+ }
+ if (rentSponsor) {
+ accounts.push({ address: rentSponsor, role: AccountRole.WRITABLE });
+ }
+
+ // Light system accounts
+ accounts.push(
+ { address: feePayer, role: AccountRole.WRITABLE_SIGNER },
+ { address: CPI_AUTHORITY, role: AccountRole.READONLY },
+ { address: REGISTERED_PROGRAM_PDA, role: AccountRole.READONLY },
+ {
+ address: ACCOUNT_COMPRESSION_AUTHORITY_PDA,
+ role: AccountRole.READONLY,
+ },
+ {
+ address: ACCOUNT_COMPRESSION_PROGRAM_ID,
+ role: AccountRole.READONLY,
+ },
+ { address: SYSTEM_PROGRAM_ID, role: AccountRole.READONLY },
+ { address: outOutputQueue, role: AccountRole.WRITABLE },
+ { address: merkleTree, role: AccountRole.WRITABLE },
+ );
+ }
+
+ // Add packed remaining accounts
+ if (packedAccounts) {
+ accounts.push(...packedAccounts);
+ }
+
+ // Encode instruction data via codec (includes discriminator)
+ const data = encodeMintActionInstructionData(mintActionData);
+
+ return {
+ programAddress: LIGHT_TOKEN_PROGRAM_ID,
+ accounts,
+ data,
+ };
+}
diff --git a/js/token-kit/src/instructions/mint-to.ts b/js/token-kit/src/instructions/mint-to.ts
new file mode 100644
index 0000000000..4db9ec24d1
--- /dev/null
+++ b/js/token-kit/src/instructions/mint-to.ts
@@ -0,0 +1,148 @@
+/**
+ * Mint-to token instructions.
+ */
+
+import type { Address } from '@solana/addresses';
+import {
+ AccountRole,
+ type Instruction,
+ type AccountMeta,
+} from '@solana/instructions';
+
+import {
+ DISCRIMINATOR,
+ LIGHT_TOKEN_PROGRAM_ID,
+ SYSTEM_PROGRAM_ID,
+} from '../constants.js';
+import { validatePositiveAmount, validateDecimals } from '../utils/validation.js';
+import {
+ getAmountInstructionEncoder,
+ getCheckedInstructionEncoder,
+} from '../codecs/instructions.js';
+import { buildInstructionDataWithMaxTopUp } from './helpers.js';
+
+/**
+ * Parameters for minting tokens.
+ */
+export interface MintToParams {
+ /** Mint address (CMint) */
+ mint: Address;
+ /** Token account to mint to */
+ tokenAccount: Address;
+ /** Mint authority - must be signer */
+ mintAuthority: Address;
+ /** Amount to mint */
+ amount: bigint;
+ /** Maximum lamports for rent top-up (optional, 0 = no limit) */
+ maxTopUp?: number;
+ /** Fee payer for rent top-ups (optional, defaults to authority) */
+ feePayer?: Address;
+}
+
+/**
+ * Creates a mint-to instruction (discriminator: 7).
+ *
+ * Mints tokens to a decompressed CToken account.
+ *
+ * Account layout:
+ * 0: CMint account (writable)
+ * 1: destination CToken account (writable)
+ * 2: authority (signer, writable unless feePayer provided)
+ * 3: system_program (readonly)
+ * 4: fee_payer (optional, signer, writable)
+ *
+ * @param params - Mint-to parameters
+ * @returns The mint-to instruction
+ */
+export function createMintToInstruction(params: MintToParams): Instruction {
+ const { mint, tokenAccount, mintAuthority, amount, maxTopUp, feePayer } =
+ params;
+
+ validatePositiveAmount(amount);
+
+ const accounts: AccountMeta[] = [
+ { address: mint, role: AccountRole.WRITABLE },
+ { address: tokenAccount, role: AccountRole.WRITABLE },
+ {
+ address: mintAuthority,
+ role: feePayer
+ ? AccountRole.READONLY_SIGNER
+ : AccountRole.WRITABLE_SIGNER,
+ },
+ { address: SYSTEM_PROGRAM_ID, role: AccountRole.READONLY },
+ ];
+ if (feePayer) {
+ accounts.push({ address: feePayer, role: AccountRole.WRITABLE_SIGNER });
+ }
+
+ const baseBytes = getAmountInstructionEncoder().encode({
+ discriminator: DISCRIMINATOR.MINT_TO,
+ amount,
+ });
+
+ return {
+ programAddress: LIGHT_TOKEN_PROGRAM_ID,
+ accounts,
+ data: buildInstructionDataWithMaxTopUp(baseBytes, maxTopUp),
+ };
+}
+
+/**
+ * Parameters for mint-to checked.
+ */
+export interface MintToCheckedParams extends MintToParams {
+ /** Expected decimals */
+ decimals: number;
+}
+
+/**
+ * Creates a mint-to checked instruction (discriminator: 14).
+ *
+ * Mints tokens with decimals validation.
+ *
+ * @param params - Mint-to checked parameters
+ * @returns The mint-to checked instruction
+ */
+export function createMintToCheckedInstruction(
+ params: MintToCheckedParams,
+): Instruction {
+ const {
+ mint,
+ tokenAccount,
+ mintAuthority,
+ amount,
+ decimals,
+ maxTopUp,
+ feePayer,
+ } = params;
+
+ validatePositiveAmount(amount);
+ validateDecimals(decimals);
+
+ const accounts: AccountMeta[] = [
+ { address: mint, role: AccountRole.WRITABLE },
+ { address: tokenAccount, role: AccountRole.WRITABLE },
+ {
+ address: mintAuthority,
+ role: feePayer
+ ? AccountRole.READONLY_SIGNER
+ : AccountRole.WRITABLE_SIGNER,
+ },
+ { address: SYSTEM_PROGRAM_ID, role: AccountRole.READONLY },
+ ];
+ if (feePayer) {
+ accounts.push({ address: feePayer, role: AccountRole.WRITABLE_SIGNER });
+ }
+
+ const baseBytes = getCheckedInstructionEncoder().encode({
+ discriminator: DISCRIMINATOR.MINT_TO_CHECKED,
+ amount,
+ decimals,
+ });
+
+ return {
+ programAddress: LIGHT_TOKEN_PROGRAM_ID,
+ accounts,
+ data: buildInstructionDataWithMaxTopUp(baseBytes, maxTopUp),
+ };
+}
diff --git a/js/token-kit/src/instructions/spl-interface.ts b/js/token-kit/src/instructions/spl-interface.ts
new file mode 100644
index 0000000000..91ea496817
--- /dev/null
+++ b/js/token-kit/src/instructions/spl-interface.ts
@@ -0,0 +1,178 @@
+/**
+ * SPL interface PDA instruction builders.
+ *
+ * Creates and manages SPL interface PDAs that register mints with
+ * the Light Token Program, enabling compress/decompress operations.
+ */
+
+import type { Address } from '@solana/addresses';
+import { AccountRole, type Instruction } from '@solana/instructions';
+
+import {
+ LIGHT_TOKEN_PROGRAM_ID,
+ SYSTEM_PROGRAM_ID,
+ SPL_TOKEN_PROGRAM_ID,
+} from '../constants.js';
+import { derivePoolAddress } from '../utils/derivation.js';
+
+// ============================================================================
+// CREATE SPL INTERFACE
+// ============================================================================
+
+/**
+ * Parameters for creating an SPL interface PDA instruction.
+ */
+export interface CreateSplInterfaceParams {
+ /** Fee payer (signer, writable) */
+ feePayer: Address;
+ /** Token mint address */
+ mint: Address;
+ /** Token program (SPL Token or Token 2022) */
+ tokenProgram?: Address;
+}
+
+/**
+ * Result of creating an SPL interface instruction.
+ */
+export interface CreateSplInterfaceResult {
+ /** The instruction to create the SPL interface PDA */
+ instruction: Instruction;
+ /** The derived pool PDA address */
+ poolAddress: Address;
+ /** The PDA bump */
+ bump: number;
+}
+
+/**
+ * Creates an instruction to register an SPL interface PDA for a mint.
+ *
+ * This registers the mint with the Light Token Program, enabling
+ * compress and decompress operations for the mint's tokens.
+ *
+ * Account layout (matches CompressedTokenProgram.createTokenPool):
+ * 0: feePayer (writable signer)
+ * 1: tokenPoolPda (writable)
+ * 2: systemProgram (readonly)
+ * 3: mint (readonly)
+ * 4: tokenProgram (readonly)
+ * 5: cTokenProgram (readonly)
+ *
+ * @param params - Create SPL interface parameters
+ * @returns The instruction and derived pool info
+ */
+export async function createSplInterfaceInstruction(
+ params: CreateSplInterfaceParams,
+): Promise {
+ const tokenProgram = params.tokenProgram ?? SPL_TOKEN_PROGRAM_ID;
+ const { address: poolAddress, bump } = await derivePoolAddress(
+ params.mint,
+ 0,
+ );
+
+ // Discriminator for create_token_pool (8-byte Anchor discriminator)
+ // Matches CompressedTokenProgram.createTokenPool
+ const discriminator = new Uint8Array([
+ 0x3c, 0xb4, 0x0e, 0x78, 0x03, 0x0a, 0xd3, 0x04,
+ ]);
+
+ const instruction: Instruction = {
+ programAddress: LIGHT_TOKEN_PROGRAM_ID,
+ accounts: [
+ {
+ address: params.feePayer,
+ role: AccountRole.WRITABLE_SIGNER,
+ },
+ { address: poolAddress, role: AccountRole.WRITABLE },
+ { address: SYSTEM_PROGRAM_ID, role: AccountRole.READONLY },
+ { address: params.mint, role: AccountRole.READONLY },
+ { address: tokenProgram, role: AccountRole.READONLY },
+ {
+ address: LIGHT_TOKEN_PROGRAM_ID,
+ role: AccountRole.READONLY,
+ },
+ ],
+ data: discriminator,
+ };
+
+ return { instruction, poolAddress, bump };
+}
+
+// ============================================================================
+// ADD SPL INTERFACES (MULTI-POOL)
+// ============================================================================
+
+/**
+ * Parameters for adding additional SPL interface PDAs.
+ */
+export interface AddSplInterfacesParams {
+ /** Fee payer (signer, writable) */
+ feePayer: Address;
+ /** Token mint address */
+ mint: Address;
+ /** Token program (SPL Token or Token 2022) */
+ tokenProgram?: Address;
+ /** Number of additional pools to create (up to 4 total, indices 1-4) */
+ count?: number;
+ /** Existing pool indices to skip (already initialized) */
+ existingIndices?: number[];
+}
+
+/**
+ * Creates instructions to add additional SPL interface PDAs for a mint.
+ *
+ * Mints can have up to 5 pool PDAs (indices 0-4). Index 0 is created by
+ * createSplInterfaceInstruction. This function creates PDAs for indices
+ * 1 through count, skipping any already-initialized indices.
+ *
+ * @param params - Add SPL interfaces parameters
+ * @returns Array of instructions, one per new pool PDA
+ */
+export async function addSplInterfacesInstruction(
+ params: AddSplInterfacesParams,
+): Promise {
+ const tokenProgram = params.tokenProgram ?? SPL_TOKEN_PROGRAM_ID;
+ const count = params.count ?? 4;
+ const existingSet = new Set(params.existingIndices ?? [0]);
+
+ // Discriminator for add_token_pool (8-byte Anchor discriminator)
+ const discriminator = new Uint8Array([
+ 0xf2, 0x39, 0xc1, 0x2b, 0x97, 0x96, 0xbe, 0x55,
+ ]);
+
+ const results: CreateSplInterfaceResult[] = [];
+
+ for (let i = 1; i <= count; i++) {
+ if (existingSet.has(i)) continue;
+
+ const { address: poolAddress, bump } = await derivePoolAddress(
+ params.mint,
+ i,
+ );
+
+ const instruction: Instruction = {
+ programAddress: LIGHT_TOKEN_PROGRAM_ID,
+ accounts: [
+ {
+ address: params.feePayer,
+ role: AccountRole.WRITABLE_SIGNER,
+ },
+ { address: poolAddress, role: AccountRole.WRITABLE },
+ {
+ address: SYSTEM_PROGRAM_ID,
+ role: AccountRole.READONLY,
+ },
+ { address: params.mint, role: AccountRole.READONLY },
+ { address: tokenProgram, role: AccountRole.READONLY },
+ {
+ address: LIGHT_TOKEN_PROGRAM_ID,
+ role: AccountRole.READONLY,
+ },
+ ],
+ data: discriminator,
+ };
+
+ results.push({ instruction, poolAddress, bump });
+ }
+
+ return results;
+}
diff --git a/js/token-kit/src/instructions/transfer-interface.ts b/js/token-kit/src/instructions/transfer-interface.ts
new file mode 100644
index 0000000000..c41f6d2458
--- /dev/null
+++ b/js/token-kit/src/instructions/transfer-interface.ts
@@ -0,0 +1,112 @@
+/**
+ * Transfer interface - auto-routing between light-to-light, light-to-SPL, and SPL-to-light.
+ */
+
+import type { Address } from '@solana/addresses';
+import type { Instruction } from '@solana/instructions';
+
+import { determineTransferType, type TransferType } from '../utils/validation.js';
+import { createTransferInstruction } from './transfer.js';
+
+/**
+ * Parameters for transfer interface.
+ */
+export interface TransferInterfaceParams {
+ /** Source account owner (to determine if Light or SPL) */
+ sourceOwner: Address;
+ /** Destination account owner (to determine if Light or SPL) */
+ destOwner: Address;
+ /** Source token account */
+ source: Address;
+ /** Destination token account */
+ destination: Address;
+ /** Amount to transfer */
+ amount: bigint;
+ /** Authority for the transfer */
+ authority: Address;
+ /** Mint address (for routing and pools) */
+ mint: Address;
+ /** Maximum top-up for rent (optional) */
+ maxTopUp?: number;
+}
+
+/**
+ * Result of transfer interface routing.
+ */
+export interface TransferInterfaceResult {
+ /** The determined transfer type */
+ transferType: TransferType;
+ /** The instruction(s) to execute */
+ instructions: Instruction[];
+}
+
+/**
+ * Creates transfer instruction(s) with automatic routing.
+ *
+ * Routes transfers based on account ownership:
+ * - Light-to-Light: Direct CToken transfer
+ * - Light-to-SPL: Decompress to SPL (requires Transfer2)
+ * - SPL-to-Light: Compress from SPL (requires Transfer2)
+ * - SPL-to-SPL: Falls through to SPL Token program
+ *
+ * @param params - Transfer interface parameters
+ * @returns The transfer type and instruction(s)
+ */
+export function createTransferInterfaceInstruction(
+ params: TransferInterfaceParams,
+): TransferInterfaceResult {
+ const transferType = determineTransferType(
+ params.sourceOwner,
+ params.destOwner,
+ );
+
+ switch (transferType) {
+ case 'light-to-light':
+ return {
+ transferType,
+ instructions: [
+ createTransferInstruction({
+ source: params.source,
+ destination: params.destination,
+ amount: params.amount,
+ authority: params.authority,
+ maxTopUp: params.maxTopUp,
+ }),
+ ],
+ };
+
+ case 'light-to-spl':
+ throw new Error(
+ 'Light-to-SPL transfer requires Transfer2 with DECOMPRESS mode. ' +
+ 'Use createTransfer2Instruction() with createDecompress() or ' +
+ 'createDecompressSpl() to build the Compression struct.',
+ );
+
+ case 'spl-to-light':
+ throw new Error(
+ 'SPL-to-Light transfer requires Transfer2 with COMPRESS mode. ' +
+ 'Use createTransfer2Instruction() with createCompress() or ' +
+ 'createCompressSpl() to build the Compression struct.',
+ );
+
+ case 'spl-to-spl':
+ throw new Error(
+ 'SPL-to-SPL transfers should use the SPL Token program directly.',
+ );
+ }
+}
+
+/**
+ * Helper to determine if a transfer requires compression operations.
+ *
+ * @param sourceOwner - Source account owner
+ * @param destOwner - Destination account owner
+ * @returns True if the transfer crosses the Light/SPL boundary
+ */
+export function requiresCompression(
+ sourceOwner: Address,
+ destOwner: Address,
+): boolean {
+ const transferType = determineTransferType(sourceOwner, destOwner);
+ return transferType === 'light-to-spl' || transferType === 'spl-to-light';
+}
diff --git a/js/token-kit/src/instructions/transfer.ts b/js/token-kit/src/instructions/transfer.ts
new file mode 100644
index 0000000000..27cc59cb00
--- /dev/null
+++ b/js/token-kit/src/instructions/transfer.ts
@@ -0,0 +1,153 @@
+/**
+ * CToken transfer instructions.
+ */
+
+import type { Address } from '@solana/addresses';
+import {
+ AccountRole,
+ type Instruction,
+ type AccountMeta,
+} from '@solana/instructions';
+
+import {
+ DISCRIMINATOR,
+ LIGHT_TOKEN_PROGRAM_ID,
+ SYSTEM_PROGRAM_ID,
+} from '../constants.js';
+import { validatePositiveAmount, validateDecimals } from '../utils/validation.js';
+import {
+ getAmountInstructionEncoder,
+ getCheckedInstructionEncoder,
+} from '../codecs/instructions.js';
+import { buildInstructionDataWithMaxTopUp } from './helpers.js';
+
+/**
+ * Parameters for CToken transfer.
+ */
+export interface TransferParams {
+ /** Source CToken account */
+ source: Address;
+ /** Destination CToken account */
+ destination: Address;
+ /** Amount to transfer */
+ amount: bigint;
+ /** Authority (owner or delegate) - must be signer */
+ authority: Address;
+ /** Maximum lamports for rent top-up (optional, 0 = no limit) */
+ maxTopUp?: number;
+ /** Fee payer for rent top-ups (optional, defaults to authority) */
+ feePayer?: Address;
+}
+
+/**
+ * Creates a CToken transfer instruction (discriminator: 3).
+ *
+ * Transfers tokens between decompressed CToken accounts.
+ *
+ * @param params - Transfer parameters
+ * @returns The transfer instruction
+ */
+export function createTransferInstruction(
+ params: TransferParams,
+): Instruction {
+ const { source, destination, amount, authority, maxTopUp, feePayer } =
+ params;
+
+ validatePositiveAmount(amount);
+ if (source === destination) {
+ throw new Error('Source and destination must be different accounts');
+ }
+
+ const accounts: AccountMeta[] = [
+ { address: source, role: AccountRole.WRITABLE },
+ { address: destination, role: AccountRole.WRITABLE },
+ {
+ address: authority,
+ role: feePayer
+ ? AccountRole.READONLY_SIGNER
+ : AccountRole.WRITABLE_SIGNER,
+ },
+ { address: SYSTEM_PROGRAM_ID, role: AccountRole.READONLY },
+ ];
+ if (feePayer) {
+ accounts.push({ address: feePayer, role: AccountRole.WRITABLE_SIGNER });
+ }
+
+ const baseBytes = getAmountInstructionEncoder().encode({
+ discriminator: DISCRIMINATOR.TRANSFER,
+ amount,
+ });
+
+ return {
+ programAddress: LIGHT_TOKEN_PROGRAM_ID,
+ accounts,
+ data: buildInstructionDataWithMaxTopUp(baseBytes, maxTopUp),
+ };
+}
+
+/**
+ * Parameters for CToken transfer checked.
+ */
+export interface TransferCheckedParams extends TransferParams {
+ /** Mint address for validation */
+ mint: Address;
+ /** Expected decimals */
+ decimals: number;
+}
+
+/**
+ * Creates a CToken transfer checked instruction (discriminator: 12).
+ *
+ * Transfers tokens with decimals validation.
+ *
+ * @param params - Transfer checked parameters
+ * @returns The transfer checked instruction
+ */
+export function createTransferCheckedInstruction(
+ params: TransferCheckedParams,
+): Instruction {
+ const {
+ source,
+ mint,
+ destination,
+ amount,
+ authority,
+ decimals,
+ maxTopUp,
+ feePayer,
+ } = params;
+
+ validatePositiveAmount(amount);
+ validateDecimals(decimals);
+ if (source === destination) {
+ throw new Error('Source and destination must be different accounts');
+ }
+
+ const accounts: AccountMeta[] = [
+ { address: source, role: AccountRole.WRITABLE },
+ { address: mint, role: AccountRole.READONLY },
+ { address: destination, role: AccountRole.WRITABLE },
+ {
+ address: authority,
+ role: feePayer
+ ? AccountRole.READONLY_SIGNER
+ : AccountRole.WRITABLE_SIGNER,
+ },
+ { address: SYSTEM_PROGRAM_ID, role: AccountRole.READONLY },
+ ];
+ if (feePayer) {
+ accounts.push({ address: feePayer, role: AccountRole.WRITABLE_SIGNER });
+ }
+
+ const baseBytes = getCheckedInstructionEncoder().encode({
+ discriminator: DISCRIMINATOR.TRANSFER_CHECKED,
+ amount,
+ decimals,
+ });
+
+ return {
+ programAddress: LIGHT_TOKEN_PROGRAM_ID,
+ accounts,
+ data: buildInstructionDataWithMaxTopUp(baseBytes, maxTopUp),
+ };
+}
diff --git a/js/token-kit/src/instructions/transfer2.ts b/js/token-kit/src/instructions/transfer2.ts
new file mode 100644
index 0000000000..c6b05c3955
--- /dev/null
+++ b/js/token-kit/src/instructions/transfer2.ts
@@ -0,0 +1,310 @@
+/**
+ * Transfer2 instruction builder and compression factory helpers.
+ */
+
+import type { Address } from '@solana/addresses';
+import {
+ AccountRole,
+ type Instruction,
+ type AccountMeta,
+} from '@solana/instructions';
+
+import {
+ LIGHT_TOKEN_PROGRAM_ID,
+ LIGHT_SYSTEM_PROGRAM_ID,
+ CPI_AUTHORITY,
+ REGISTERED_PROGRAM_PDA,
+ ACCOUNT_COMPRESSION_AUTHORITY_PDA,
+ ACCOUNT_COMPRESSION_PROGRAM_ID,
+ SYSTEM_PROGRAM_ID,
+ COMPRESSION_MODE,
+} from '../constants.js';
+import { encodeTransfer2InstructionData } from '../codecs/transfer2.js';
+import type {
+ Compression,
+ Transfer2InstructionData,
+} from '../codecs/types.js';
+
+// ============================================================================
+// TRANSFER2 INSTRUCTION
+// ============================================================================
+
+/**
+ * Parameters for Transfer2 instruction.
+ */
+export interface Transfer2Params {
+ /** Fee payer (signer, writable) */
+ feePayer: Address;
+ /** Full Transfer2 instruction data */
+ data: Transfer2InstructionData;
+ /** SOL pool PDA (optional, writable) */
+ solPoolPda?: Address;
+ /** SOL decompression recipient (optional, writable) */
+ solDecompressionRecipient?: Address;
+ /** CPI context account (optional, writable) — triggers Path C */
+ cpiContextAccount?: Address;
+ /** Packed remaining accounts (mints, owners, delegates, trees, queues) */
+ packedAccounts: AccountMeta[];
+}
+
+/**
+ * Creates a Transfer2 instruction (discriminator: 101).
+ *
+ * Transfer2 supports batch transfers between compressed and decompressed
+ * token accounts, including compress and decompress operations.
+ *
+ * Path A (compression-only): compressions set, no inTokenData/outTokenData
+ * 0: cpiAuthorityPda (readonly)
+ * 1: feePayer (writable signer)
+ * [...packed_accounts]
+ *
+ * Path B (full transfer): inTokenData or outTokenData present, no cpiContextAccount
+ * 0: lightSystemProgram (readonly)
+ * 1: feePayer (writable signer)
+ * 2: cpiAuthorityPda (readonly)
+ * 3: registeredProgramPda (readonly)
+ * 4: accountCompressionAuthority (readonly)
+ * 5: accountCompressionProgram (readonly)
+ * 6: systemProgram (readonly)
+ * [...packed_accounts]
+ *
+ * Path C (CPI context write): cpiContextAccount provided
+ * 0: lightSystemProgram (readonly)
+ * 1: feePayer (writable signer)
+ * 2: cpiAuthorityPda (readonly)
+ * 3: cpiContextAccount (writable)
+ * [...packed_accounts]
+ *
+ * @param params - Transfer2 parameters
+ * @returns The Transfer2 instruction
+ */
+export function createTransfer2Instruction(
+ params: Transfer2Params,
+): Instruction {
+ const {
+ feePayer,
+ data: transferData,
+ solPoolPda,
+ solDecompressionRecipient,
+ cpiContextAccount,
+ packedAccounts,
+ } = params;
+
+ const hasInOrOut =
+ (transferData.inTokenData && transferData.inTokenData.length > 0) ||
+ (transferData.outTokenData && transferData.outTokenData.length > 0);
+
+ const accounts: AccountMeta[] = [];
+
+ if (cpiContextAccount) {
+ // Path C: CPI context write
+ accounts.push(
+ { address: LIGHT_SYSTEM_PROGRAM_ID, role: AccountRole.READONLY },
+ { address: feePayer, role: AccountRole.WRITABLE_SIGNER },
+ { address: CPI_AUTHORITY, role: AccountRole.READONLY },
+ { address: cpiContextAccount, role: AccountRole.WRITABLE },
+ );
+ } else if (hasInOrOut) {
+ // Path B: full transfer with Light system accounts
+ accounts.push(
+ { address: LIGHT_SYSTEM_PROGRAM_ID, role: AccountRole.READONLY },
+ { address: feePayer, role: AccountRole.WRITABLE_SIGNER },
+ { address: CPI_AUTHORITY, role: AccountRole.READONLY },
+ { address: REGISTERED_PROGRAM_PDA, role: AccountRole.READONLY },
+ {
+ address: ACCOUNT_COMPRESSION_AUTHORITY_PDA,
+ role: AccountRole.READONLY,
+ },
+ {
+ address: ACCOUNT_COMPRESSION_PROGRAM_ID,
+ role: AccountRole.READONLY,
+ },
+ { address: SYSTEM_PROGRAM_ID, role: AccountRole.READONLY },
+ );
+ } else {
+ // Path A: compression-only (no system CPI needed)
+ accounts.push(
+ { address: CPI_AUTHORITY, role: AccountRole.READONLY },
+ { address: feePayer, role: AccountRole.WRITABLE_SIGNER },
+ );
+ }
+
+ // Add optional accounts (only for Path B)
+ if (!cpiContextAccount && hasInOrOut) {
+ if (solPoolPda) {
+ accounts.push({ address: solPoolPda, role: AccountRole.WRITABLE });
+ }
+ if (solDecompressionRecipient) {
+ accounts.push({
+ address: solDecompressionRecipient,
+ role: AccountRole.WRITABLE,
+ });
+ }
+ }
+
+ // Add packed remaining accounts
+ accounts.push(...packedAccounts);
+
+ // Encode instruction data
+ const encodedData = encodeTransfer2InstructionData(transferData);
+
+ return {
+ programAddress: LIGHT_TOKEN_PROGRAM_ID,
+ accounts,
+ data: encodedData,
+ };
+}
+
+// ============================================================================
+// COMPRESSION FACTORY HELPERS
+// ============================================================================
+
+/**
+ * Parameters for creating a CToken compression struct.
+ */
+interface CompressParams {
+ amount: bigint;
+ mintIndex: number;
+ sourceIndex: number;
+ authorityIndex: number;
+ /** Token program index in packed accounts — for CToken compress via CPI */
+ tokenProgramIndex?: number;
+}
+
+/**
+ * Parameters for creating an SPL compression struct.
+ */
+interface CompressSplParams extends CompressParams {
+ poolAccountIndex: number;
+ poolIndex: number;
+ bump: number;
+ decimals: number;
+}
+
+/**
+ * Parameters for creating a CToken decompression struct.
+ */
+interface DecompressParams {
+ amount: bigint;
+ mintIndex: number;
+ recipientIndex: number;
+ /** Token program index in packed accounts — for CToken decompress via CPI */
+ tokenProgramIndex?: number;
+}
+
+/**
+ * Parameters for creating an SPL decompression struct.
+ */
+interface DecompressSplParams extends DecompressParams {
+ poolAccountIndex: number;
+ poolIndex: number;
+ bump: number;
+ decimals: number;
+}
+
+/**
+ * Parameters for creating a compress-and-close struct.
+ */
+interface CompressAndCloseParams {
+ amount: bigint;
+ mintIndex: number;
+ sourceIndex: number;
+ authorityIndex: number;
+ rentSponsorIndex: number;
+ compressedAccountIndex: number;
+ destinationIndex: number;
+}
+
+/**
+ * Creates a Compression struct for compressing CTokens.
+ */
+export function createCompress(params: CompressParams): Compression {
+ return {
+ mode: COMPRESSION_MODE.COMPRESS,
+ amount: params.amount,
+ mint: params.mintIndex,
+ sourceOrRecipient: params.sourceIndex,
+ authority: params.authorityIndex,
+ poolAccountIndex: params.tokenProgramIndex ?? 0,
+ poolIndex: 0,
+ bump: 0,
+ decimals: 0,
+ };
+}
+
+/**
+ * Creates a Compression struct for compressing SPL tokens.
+ */
+export function createCompressSpl(params: CompressSplParams): Compression {
+ return {
+ mode: COMPRESSION_MODE.COMPRESS,
+ amount: params.amount,
+ mint: params.mintIndex,
+ sourceOrRecipient: params.sourceIndex,
+ authority: params.authorityIndex,
+ poolAccountIndex: params.poolAccountIndex,
+ poolIndex: params.poolIndex,
+ bump: params.bump,
+ decimals: params.decimals,
+ };
+}
+
+/**
+ * Creates a Compression struct for decompressing CTokens.
+ */
+export function createDecompress(params: DecompressParams): Compression {
+ return {
+ mode: COMPRESSION_MODE.DECOMPRESS,
+ amount: params.amount,
+ mint: params.mintIndex,
+ sourceOrRecipient: params.recipientIndex,
+ authority: 0,
+ poolAccountIndex: params.tokenProgramIndex ?? 0,
+ poolIndex: 0,
+ bump: 0,
+ decimals: 0,
+ };
+}
+
+/**
+ * Creates a Compression struct for decompressing SPL tokens.
+ */
+export function createDecompressSpl(
+ params: DecompressSplParams,
+): Compression {
+ return {
+ mode: COMPRESSION_MODE.DECOMPRESS,
+ amount: params.amount,
+ mint: params.mintIndex,
+ sourceOrRecipient: params.recipientIndex,
+ authority: 0,
+ poolAccountIndex: params.poolAccountIndex,
+ poolIndex: params.poolIndex,
+ bump: params.bump,
+ decimals: params.decimals,
+ };
+}
+
+/**
+ * Creates a Compression struct for compressing and closing an account.
+ *
+ * Repurposed fields:
+ * - poolAccountIndex = rentSponsorIndex
+ * - poolIndex = compressedAccountIndex
+ * - bump = destinationIndex
+ */
+export function createCompressAndClose(
+ params: CompressAndCloseParams,
+): Compression {
+ return {
+ mode: COMPRESSION_MODE.COMPRESS_AND_CLOSE,
+ amount: params.amount,
+ mint: params.mintIndex,
+ sourceOrRecipient: params.sourceIndex,
+ authority: params.authorityIndex,
+ poolAccountIndex: params.rentSponsorIndex,
+ poolIndex: params.compressedAccountIndex,
+ bump: params.destinationIndex,
+ decimals: 0,
+ };
+}
diff --git a/js/token-kit/src/instructions/withdraw-funding-pool.ts b/js/token-kit/src/instructions/withdraw-funding-pool.ts
new file mode 100644
index 0000000000..2122fd794c
--- /dev/null
+++ b/js/token-kit/src/instructions/withdraw-funding-pool.ts
@@ -0,0 +1,82 @@
+/**
+ * Withdraw from funding pool instruction.
+ */
+
+import type { Address } from '@solana/addresses';
+import {
+ AccountRole,
+ type Instruction,
+ type AccountMeta,
+} from '@solana/instructions';
+
+import { getU64Encoder } from '@solana/codecs';
+
+import {
+ DISCRIMINATOR,
+ LIGHT_TOKEN_PROGRAM_ID,
+ SYSTEM_PROGRAM_ID,
+} from '../constants.js';
+
+/**
+ * Parameters for withdrawing from a funding pool.
+ */
+export interface WithdrawFundingPoolParams {
+ /** Rent sponsor pool PDA (writable) */
+ rentSponsor: Address;
+ /** Compression authority (signer) */
+ compressionAuthority: Address;
+ /** Destination account receiving withdrawn lamports (writable) */
+ destination: Address;
+ /** Compressible config account (readonly) */
+ compressibleConfig: Address;
+ /** Amount of lamports to withdraw */
+ amount: bigint;
+}
+
+/**
+ * Creates a withdraw funding pool instruction (discriminator: 105).
+ *
+ * Withdraws lamports from the rent sponsor funding pool.
+ *
+ * Account layout:
+ * 0: rent_sponsor (writable) - Pool PDA
+ * 1: compression_authority (signer)
+ * 2: destination (writable) - Receives withdrawn lamports
+ * 3: system_program (readonly)
+ * 4: compressible_config (readonly)
+ *
+ * @param params - Withdraw funding pool parameters
+ * @returns The withdraw funding pool instruction
+ */
+export function createWithdrawFundingPoolInstruction(
+ params: WithdrawFundingPoolParams,
+): Instruction {
+ const {
+ rentSponsor,
+ compressionAuthority,
+ destination,
+ compressibleConfig,
+ amount,
+ } = params;
+
+ // Build accounts
+ const accounts: AccountMeta[] = [
+ { address: rentSponsor, role: AccountRole.WRITABLE },
+ { address: compressionAuthority, role: AccountRole.READONLY_SIGNER },
+ { address: destination, role: AccountRole.WRITABLE },
+ { address: SYSTEM_PROGRAM_ID, role: AccountRole.READONLY },
+ { address: compressibleConfig, role: AccountRole.READONLY },
+ ];
+
+ // Build instruction data: discriminator (u8) + amount (u64)
+ const amountBytes = getU64Encoder().encode(amount);
+ const data = new Uint8Array(1 + amountBytes.length);
+ data[0] = DISCRIMINATOR.WITHDRAW_FUNDING_POOL;
+ data.set(new Uint8Array(amountBytes), 1);
+
+ return {
+ programAddress: LIGHT_TOKEN_PROGRAM_ID,
+ accounts,
+ data,
+ };
+}
diff --git a/js/token-kit/src/instructions/wrap-unwrap.ts b/js/token-kit/src/instructions/wrap-unwrap.ts
new file mode 100644
index 0000000000..9bdc3f1889
--- /dev/null
+++ b/js/token-kit/src/instructions/wrap-unwrap.ts
@@ -0,0 +1,267 @@
+/**
+ * Wrap (SPL → Light Token) and Unwrap (Light Token → SPL) instruction builders.
+ *
+ * Both use Transfer2 Path A (compression-only) with two compression structs.
+ */
+
+import type { Address } from '@solana/addresses';
+import { AccountRole, type Instruction } from '@solana/instructions';
+
+import type { SplInterfaceInfo } from '../utils/spl-interface.js';
+import {
+ LIGHT_TOKEN_PROGRAM_ID,
+ CPI_AUTHORITY,
+ SYSTEM_PROGRAM_ID,
+} from '../constants.js';
+import { encodeTransfer2InstructionData } from '../codecs/transfer2.js';
+import {
+ createCompressSpl,
+ createDecompress,
+ createCompress,
+ createDecompressSpl,
+} from './transfer2.js';
+
+// Packed account indices (relative to the packed accounts array after Path A prefix)
+const MINT_INDEX = 0;
+const OWNER_INDEX = 1;
+const SOURCE_INDEX = 2;
+const DESTINATION_INDEX = 3;
+const POOL_INDEX = 4;
+// SPL_TOKEN_PROGRAM_INDEX = 5 (unused in compression structs but present in accounts)
+const CTOKEN_PROGRAM_INDEX = 6;
+
+// ============================================================================
+// WRAP INSTRUCTION
+// ============================================================================
+
+/**
+ * Parameters for creating a wrap instruction (SPL → Light Token).
+ */
+export interface WrapParams {
+ /** Source SPL token account (writable) */
+ source: Address;
+ /** Destination Light Token account (writable) */
+ destination: Address;
+ /** Owner of the source account (signer) */
+ owner: Address;
+ /** Token mint address */
+ mint: Address;
+ /** Amount to wrap */
+ amount: bigint;
+ /** SPL interface pool info */
+ splInterfaceInfo: SplInterfaceInfo;
+ /** Mint decimals */
+ decimals: number;
+ /** Fee payer (defaults to owner) */
+ feePayer?: Address;
+}
+
+/**
+ * Creates a wrap instruction that moves tokens from an SPL/Token 2022 account
+ * to a Light Token account.
+ *
+ * Uses Transfer2 Path A (compression-only) with two compressions:
+ * 1. compressSpl: burns from SPL associated token account into the pool
+ * 2. decompressCtoken: mints from pool into Light Token associated token account
+ *
+ * Account layout:
+ * 0: CPI_AUTHORITY (readonly)
+ * 1: feePayer (writable signer)
+ * 2: mint (readonly) — packed index 0
+ * 3: owner (signer) — packed index 1
+ * 4: source (writable) — packed index 2
+ * 5: destination (writable) — packed index 3
+ * 6: poolPda (writable) — packed index 4
+ * 7: tokenProgram (readonly) — packed index 5
+ * 8: LIGHT_TOKEN_PROGRAM_ID — packed index 6
+ * 9: SYSTEM_PROGRAM_ID — packed index 7
+ */
+export function createWrapInstruction(params: WrapParams): Instruction {
+ const {
+ source,
+ destination,
+ owner,
+ mint,
+ amount,
+ splInterfaceInfo,
+ decimals,
+ feePayer,
+ } = params;
+
+ const payer = feePayer ?? owner;
+
+ const compressions = [
+ createCompressSpl({
+ amount,
+ mintIndex: MINT_INDEX,
+ sourceIndex: SOURCE_INDEX,
+ authorityIndex: OWNER_INDEX,
+ poolAccountIndex: POOL_INDEX,
+ poolIndex: splInterfaceInfo.poolIndex,
+ bump: splInterfaceInfo.bump,
+ decimals,
+ }),
+ createDecompress({
+ amount,
+ mintIndex: MINT_INDEX,
+ recipientIndex: DESTINATION_INDEX,
+ tokenProgramIndex: CTOKEN_PROGRAM_INDEX,
+ }),
+ ];
+
+ const data = encodeTransfer2InstructionData({
+ withTransactionHash: false,
+ withLamportsChangeAccountMerkleTreeIndex: false,
+ lamportsChangeAccountMerkleTreeIndex: 0,
+ lamportsChangeAccountOwnerIndex: 0,
+ outputQueue: 0,
+ maxTopUp: 0,
+ cpiContext: null,
+ compressions,
+ proof: null,
+ inTokenData: [],
+ outTokenData: [],
+ inLamports: null,
+ outLamports: null,
+ inTlv: null,
+ outTlv: null,
+ });
+
+ return {
+ programAddress: LIGHT_TOKEN_PROGRAM_ID,
+ accounts: [
+ // Path A prefix
+ { address: CPI_AUTHORITY, role: AccountRole.READONLY },
+ { address: payer, role: AccountRole.WRITABLE_SIGNER },
+ // Packed accounts
+ { address: mint, role: AccountRole.READONLY },
+ { address: owner, role: AccountRole.READONLY_SIGNER },
+ { address: source, role: AccountRole.WRITABLE },
+ { address: destination, role: AccountRole.WRITABLE },
+ {
+ address: splInterfaceInfo.poolAddress,
+ role: AccountRole.WRITABLE,
+ },
+ {
+ address: splInterfaceInfo.tokenProgram,
+ role: AccountRole.READONLY,
+ },
+ { address: LIGHT_TOKEN_PROGRAM_ID, role: AccountRole.READONLY },
+ { address: SYSTEM_PROGRAM_ID, role: AccountRole.READONLY },
+ ],
+ data,
+ };
+}
+
+// ============================================================================
+// UNWRAP INSTRUCTION
+// ============================================================================
+
+/**
+ * Parameters for creating an unwrap instruction (Light Token → SPL).
+ */
+export interface UnwrapParams {
+ /** Source Light Token account (writable) */
+ source: Address;
+ /** Destination SPL token account (writable) */
+ destination: Address;
+ /** Owner of the source account (signer) */
+ owner: Address;
+ /** Token mint address */
+ mint: Address;
+ /** Amount to unwrap */
+ amount: bigint;
+ /** SPL interface pool info */
+ splInterfaceInfo: SplInterfaceInfo;
+ /** Mint decimals */
+ decimals: number;
+ /** Fee payer (defaults to owner) */
+ feePayer?: Address;
+}
+
+/**
+ * Creates an unwrap instruction that moves tokens from a Light Token account
+ * to an SPL/Token 2022 account.
+ *
+ * Uses Transfer2 Path A (compression-only) with two compressions:
+ * 1. compressCtoken: burns from Light Token associated token account into the pool
+ * 2. decompressSpl: mints from pool into SPL associated token account
+ *
+ * Account layout matches wrap for consistency.
+ */
+export function createUnwrapInstruction(params: UnwrapParams): Instruction {
+ const {
+ source,
+ destination,
+ owner,
+ mint,
+ amount,
+ splInterfaceInfo,
+ decimals,
+ feePayer,
+ } = params;
+
+ const payer = feePayer ?? owner;
+
+ const compressions = [
+ createCompress({
+ amount,
+ mintIndex: MINT_INDEX,
+ sourceIndex: SOURCE_INDEX,
+ authorityIndex: OWNER_INDEX,
+ tokenProgramIndex: CTOKEN_PROGRAM_INDEX,
+ }),
+ createDecompressSpl({
+ amount,
+ mintIndex: MINT_INDEX,
+ recipientIndex: DESTINATION_INDEX,
+ poolAccountIndex: POOL_INDEX,
+ poolIndex: splInterfaceInfo.poolIndex,
+ bump: splInterfaceInfo.bump,
+ decimals,
+ }),
+ ];
+
+ const data = encodeTransfer2InstructionData({
+ withTransactionHash: false,
+ withLamportsChangeAccountMerkleTreeIndex: false,
+ lamportsChangeAccountMerkleTreeIndex: 0,
+ lamportsChangeAccountOwnerIndex: 0,
+ outputQueue: 0,
+ maxTopUp: 0,
+ cpiContext: null,
+ compressions,
+ proof: null,
+ inTokenData: [],
+ outTokenData: [],
+ inLamports: null,
+ outLamports: null,
+ inTlv: null,
+ outTlv: null,
+ });
+
+ return {
+ programAddress: LIGHT_TOKEN_PROGRAM_ID,
+ accounts: [
+ // Path A prefix
+ { address: CPI_AUTHORITY, role: AccountRole.READONLY },
+ { address: payer, role: AccountRole.WRITABLE_SIGNER },
+ // Packed accounts
+ { address: mint, role: AccountRole.READONLY },
+ { address: owner, role: AccountRole.READONLY_SIGNER },
+ { address: source, role: AccountRole.WRITABLE },
+ { address: destination, role: AccountRole.WRITABLE },
+ {
+ address: splInterfaceInfo.poolAddress,
+ role: AccountRole.WRITABLE,
+ },
+ {
+ address: splInterfaceInfo.tokenProgram,
+ role: AccountRole.READONLY,
+ },
+ { address: LIGHT_TOKEN_PROGRAM_ID, role: AccountRole.READONLY },
+ { address: SYSTEM_PROGRAM_ID, role: AccountRole.READONLY },
+ ],
+ data,
+ };
+}
diff --git a/js/token-kit/src/load.ts b/js/token-kit/src/load.ts
new file mode 100644
index 0000000000..00a6602604
--- /dev/null
+++ b/js/token-kit/src/load.ts
@@ -0,0 +1,488 @@
+/**
+ * Light Token Client Load Functions
+ *
+ * Functions for loading compressed account data for use in transactions.
+ * Implements the AccountInterface pattern from sdk-libs/client.
+ */
+
+import type { Address } from '@solana/addresses';
+
+import type { LightIndexer } from './indexer.js';
+import {
+ IndexerError,
+ IndexerErrorCode,
+ type CompressedAccount,
+ type CompressedTokenAccount,
+ type ValidityProofWithContext,
+ type GetCompressedTokenAccountsOptions,
+ type TreeInfo,
+} from './client/index.js';
+import { deriveCompressedMintAddress } from './utils/derivation.js';
+import { deserializeCompressedMint, type DeserializedCompressedMint } from './codecs/mint-deserialize.js';
+import type { CompressedProof } from './codecs/types.js';
+
+// ============================================================================
+// ACCOUNT INTERFACE TYPES
+// ============================================================================
+
+/**
+ * Input account for building transfer instructions.
+ *
+ * Contains the token account data and proof context needed for the transaction.
+ */
+export interface InputTokenAccount {
+ /** The compressed token account */
+ tokenAccount: CompressedTokenAccount;
+ /** Merkle context for the account */
+ merkleContext: MerkleContext;
+}
+
+/**
+ * Merkle context for a compressed account.
+ */
+export interface MerkleContext {
+ /** Merkle tree pubkey */
+ tree: Address;
+ /** Queue pubkey */
+ queue: Address;
+ /** Leaf index in the tree */
+ leafIndex: number;
+ /** Whether to prove by index */
+ proveByIndex: boolean;
+}
+
+/**
+ * Loaded token accounts with validity proof.
+ *
+ * This is the result of loading token accounts for a transaction.
+ * Contains all the data needed to build transfer instructions.
+ */
+export interface LoadedTokenAccounts {
+ /** Input token accounts with their merkle contexts */
+ inputs: InputTokenAccount[];
+ /** Validity proof for all inputs */
+ proof: ValidityProofWithContext;
+ /** Total amount available across all inputs */
+ totalAmount: bigint;
+}
+
+/**
+ * Options for loading token accounts.
+ */
+export interface LoadTokenAccountsOptions {
+ /** Filter by mint */
+ mint?: Address;
+ /** Maximum number of accounts to load */
+ limit?: number;
+ /** Maximum number of selected input accounts (default: 4) */
+ maxInputs?: number;
+}
+
+// ============================================================================
+// LOAD FUNCTIONS
+// ============================================================================
+
+/**
+ * Load token accounts for a transfer.
+ *
+ * Fetches token accounts for the given owner, selects enough accounts
+ * to meet the required amount, and fetches a validity proof.
+ *
+ * @param indexer - Light indexer client
+ * @param owner - Token account owner
+ * @param amount - Amount to transfer
+ * @param options - Optional filters
+ * @returns Loaded token accounts with proof
+ * @throws Error if insufficient balance
+ *
+ * @example
+ * ```typescript
+ * const indexer = createLightIndexer('https://photon.helius.dev');
+ * const loaded = await loadTokenAccountsForTransfer(
+ * indexer,
+ * owner,
+ * 1000n,
+ * { mint: tokenMint }
+ * );
+ * // Use loaded.inputs and loaded.proof to build transfer instruction
+ * ```
+ */
+export async function loadTokenAccountsForTransfer(
+ indexer: LightIndexer,
+ owner: Address,
+ amount: bigint,
+ options?: LoadTokenAccountsOptions,
+): Promise {
+ // Fetch token accounts
+ const fetchOptions: GetCompressedTokenAccountsOptions = {};
+ if (options?.mint) {
+ fetchOptions.mint = options.mint;
+ }
+ if (options?.limit !== undefined) {
+ fetchOptions.limit = options.limit;
+ }
+
+ const response = await indexer.getCompressedTokenAccountsByOwner(
+ owner,
+ fetchOptions,
+ );
+
+ const tokenAccounts = response.value.items;
+
+ if (tokenAccounts.length === 0) {
+ throw new IndexerError(
+ IndexerErrorCode.NotFound,
+ `No token accounts found for owner ${owner}`,
+ );
+ }
+
+ // Select accounts to meet the required amount
+ const selectedAccounts = selectAccountsForAmount(
+ tokenAccounts,
+ amount,
+ options?.maxInputs ?? DEFAULT_MAX_INPUTS,
+ );
+
+ if (selectedAccounts.totalAmount < amount) {
+ throw new IndexerError(
+ IndexerErrorCode.InsufficientBalance,
+ `Insufficient balance: have ${selectedAccounts.totalAmount}, need ${amount}`,
+ );
+ }
+
+ // Get validity proof for selected accounts
+ const hashes = selectedAccounts.accounts.map((a) => a.account.hash);
+ const proofResponse = await indexer.getValidityProof(hashes);
+
+ // Build input accounts with merkle contexts
+ const inputs: InputTokenAccount[] = selectedAccounts.accounts.map(
+ (tokenAccount) => ({
+ tokenAccount,
+ merkleContext: {
+ tree: tokenAccount.account.treeInfo.tree,
+ queue: tokenAccount.account.treeInfo.queue,
+ leafIndex: tokenAccount.account.leafIndex,
+ proveByIndex: tokenAccount.account.proveByIndex,
+ },
+ }),
+ );
+
+ return {
+ inputs,
+ proof: proofResponse.value,
+ totalAmount: selectedAccounts.totalAmount,
+ };
+}
+
+/**
+ * Load a single token account by owner and mint (ATA pattern).
+ *
+ * @param indexer - Light indexer client
+ * @param owner - Token account owner
+ * @param mint - Token mint
+ * @returns The token account or null if not found
+ */
+export async function loadTokenAccount(
+ indexer: LightIndexer,
+ owner: Address,
+ mint: Address,
+): Promise {
+ const response = await indexer.getCompressedTokenAccountsByOwner(owner, {
+ mint,
+ limit: 1,
+ });
+
+ return response.value.items[0] ?? null;
+}
+
+/**
+ * Load all token accounts for an owner.
+ *
+ * @param indexer - Light indexer client
+ * @param owner - Token account owner
+ * @param options - Optional filters
+ * @returns Array of token accounts
+ */
+/** Maximum number of pages to fetch to prevent infinite pagination loops. */
+const MAX_PAGES = 100;
+
+export async function loadAllTokenAccounts(
+ indexer: LightIndexer,
+ owner: Address,
+ options?: GetCompressedTokenAccountsOptions,
+): Promise {
+ const allAccounts: CompressedTokenAccount[] = [];
+ let cursor: string | undefined = options?.cursor;
+ let pages = 0;
+
+ do {
+ if (++pages > MAX_PAGES) {
+ throw new IndexerError(
+ IndexerErrorCode.InvalidResponse,
+ `Pagination exceeded maximum of ${MAX_PAGES} pages`,
+ );
+ }
+
+ const response = await indexer.getCompressedTokenAccountsByOwner(
+ owner,
+ { ...options, cursor },
+ );
+
+ allAccounts.push(...response.value.items);
+ cursor = response.value.cursor ?? undefined;
+ } while (cursor);
+
+ return allAccounts;
+}
+
+/**
+ * Load a compressed account by address.
+ *
+ * @param indexer - Light indexer client
+ * @param address - 32-byte account address
+ * @returns The compressed account or null if not found
+ */
+export async function loadCompressedAccount(
+ indexer: LightIndexer,
+ address: Uint8Array,
+): Promise {
+ const response = await indexer.getCompressedAccount(address);
+ return response.value;
+}
+
+/**
+ * Load a compressed account by hash.
+ *
+ * @param indexer - Light indexer client
+ * @param hash - 32-byte account hash
+ * @returns The compressed account or null if not found
+ */
+export async function loadCompressedAccountByHash(
+ indexer: LightIndexer,
+ hash: Uint8Array,
+): Promise {
+ const response = await indexer.getCompressedAccountByHash(hash);
+ return response.value;
+}
+
+// ============================================================================
+// ACCOUNT SELECTION
+// ============================================================================
+
+/**
+ * Result of account selection.
+ */
+export interface SelectedAccounts {
+ /** Selected accounts */
+ accounts: CompressedTokenAccount[];
+ /** Total amount across selected accounts */
+ totalAmount: bigint;
+}
+
+/**
+ * Default maximum number of input accounts per transaction.
+ * Limits transaction size and compute budget usage.
+ */
+export const DEFAULT_MAX_INPUTS = 4;
+
+/**
+ * Select token accounts to meet the required amount.
+ *
+ * Uses a greedy algorithm that prefers larger accounts first
+ * to minimize the number of inputs. Skips zero-balance accounts
+ * and enforces a maximum input count to keep transactions within
+ * Solana's size and compute budget limits.
+ *
+ * @param accounts - Available token accounts
+ * @param requiredAmount - Amount needed
+ * @param maxInputs - Maximum number of input accounts (default: 4)
+ * @returns Selected accounts and their total amount
+ */
+export function selectAccountsForAmount(
+ accounts: CompressedTokenAccount[],
+ requiredAmount: bigint,
+ maxInputs: number = DEFAULT_MAX_INPUTS,
+): SelectedAccounts {
+ // Sort by amount descending (prefer larger accounts)
+ const sorted = [...accounts].sort((a, b) => {
+ const diff = b.token.amount - a.token.amount;
+ return diff > 0n ? 1 : diff < 0n ? -1 : 0;
+ });
+
+ const selected: CompressedTokenAccount[] = [];
+ let total = 0n;
+
+ for (const account of sorted) {
+ if (total >= requiredAmount || selected.length >= maxInputs) {
+ break;
+ }
+ // Skip zero-balance accounts
+ if (account.token.amount === 0n) {
+ continue;
+ }
+ selected.push(account);
+ total += account.token.amount;
+ }
+
+ return {
+ accounts: selected,
+ totalAmount: total,
+ };
+}
+
+// ============================================================================
+// PROOF HELPERS
+// ============================================================================
+
+/**
+ * Get a validity proof for multiple token accounts.
+ *
+ * @param indexer - Light indexer client
+ * @param accounts - Token accounts to prove
+ * @returns Validity proof with context
+ */
+export async function getValidityProofForAccounts(
+ indexer: LightIndexer,
+ accounts: CompressedTokenAccount[],
+): Promise {
+ const hashes = accounts.map((a) => a.account.hash);
+ const response = await indexer.getValidityProof(hashes);
+ return response.value;
+}
+
+/**
+ * Check if an account needs a validity proof or can prove by index.
+ *
+ * @param account - The compressed account
+ * @returns True if validity proof is needed
+ */
+export function needsValidityProof(account: CompressedAccount): boolean {
+ return !account.proveByIndex;
+}
+
+/**
+ * Extract tree info from a compressed account.
+ *
+ * @param account - The compressed account
+ * @returns Tree info
+ */
+export function getTreeInfo(account: CompressedAccount): TreeInfo {
+ return account.treeInfo;
+}
+
+/**
+ * Get the output tree for new state.
+ *
+ * If the tree has a next tree (tree is full), use that.
+ * Otherwise use the current tree.
+ *
+ * @param treeInfo - Current tree info
+ * @returns Tree info for output state
+ */
+export function getOutputTreeInfo(treeInfo: TreeInfo): TreeInfo {
+ return treeInfo.nextTreeInfo ?? treeInfo;
+}
+
+// ============================================================================
+// MINT CONTEXT
+// ============================================================================
+
+/**
+ * Fully-resolved mint context for use in high-level builders.
+ *
+ * Contains all the merkle internals that builders need so the user
+ * doesn't have to fetch them manually.
+ */
+export interface MintContext {
+ /** The compressed mint account */
+ account: CompressedAccount;
+ /** Deserialized mint data */
+ mint: DeserializedCompressedMint;
+ /** Mint signer address (from mintContext.mintSigner) */
+ mintSigner: Address;
+ /** Leaf index in the merkle tree */
+ leafIndex: number;
+ /** Root index from validity proof */
+ rootIndex: number;
+ /** Whether to prove by index */
+ proveByIndex: boolean;
+ /** Merkle tree address */
+ merkleTree: Address;
+ /** Output queue address */
+ outOutputQueue: Address;
+ /** Validity proof (null if prove-by-index) */
+ proof: CompressedProof | null;
+ /** Index of TokenMetadata extension, or -1 */
+ metadataExtensionIndex: number;
+}
+
+/**
+ * Load and resolve all mint context needed for MintAction builders.
+ *
+ * 1. Derives the compressed mint address from the mint signer address
+ * 2. Fetches the compressed account via indexer
+ * 3. Deserializes the mint data
+ * 4. Fetches a validity proof (unless prove-by-index)
+ * 5. Returns all fields builders need
+ *
+ * @param indexer - Light indexer client
+ * @param mintSigner - The mint signer address
+ * @returns Fully resolved MintContext
+ * @throws IndexerError if the mint is not found
+ */
+export async function loadMintContext(
+ indexer: LightIndexer,
+ mintSigner: Address,
+): Promise {
+ // 1. Derive compressed address
+ const compressedAddress = deriveCompressedMintAddress(mintSigner);
+
+ // 2. Fetch the compressed account
+ const response = await indexer.getCompressedAccount(compressedAddress);
+ const account = response.value;
+ if (!account) {
+ throw new IndexerError(
+ IndexerErrorCode.NotFound,
+ `Compressed mint not found for signer ${mintSigner}`,
+ );
+ }
+
+ // 3. Deserialize
+ if (!account.data?.data) {
+ throw new IndexerError(
+ IndexerErrorCode.InvalidResponse,
+ 'Compressed mint account has no data',
+ );
+ }
+ const deserialized = deserializeCompressedMint(account.data.data);
+
+ // 4. Get output tree info (rollover-aware)
+ const outputTreeInfo = getOutputTreeInfo(account.treeInfo);
+
+ // 5. Fetch validity proof
+ let proof: CompressedProof | null = null;
+ let rootIndex = 0;
+ const proveByIndex = account.proveByIndex;
+
+ if (!proveByIndex) {
+ const proofResponse = await indexer.getValidityProof([account.hash]);
+ proof = proofResponse.value.proof;
+ if (proofResponse.value.accounts.length > 0) {
+ rootIndex =
+ proofResponse.value.accounts[0].rootIndex.rootIndex;
+ }
+ }
+
+ return {
+ account,
+ mint: deserialized,
+ mintSigner,
+ leafIndex: account.leafIndex,
+ rootIndex,
+ proveByIndex,
+ merkleTree: account.treeInfo.tree,
+ outOutputQueue: outputTreeInfo.queue,
+ proof,
+ metadataExtensionIndex: deserialized.metadataExtensionIndex,
+ };
+}
diff --git a/js/token-kit/src/queries.ts b/js/token-kit/src/queries.ts
new file mode 100644
index 0000000000..5482ecb895
--- /dev/null
+++ b/js/token-kit/src/queries.ts
@@ -0,0 +1,325 @@
+/**
+ * Query functions for unified account and mint views.
+ *
+ * These provide aggregated views across hot (on-chain), cold (compressed),
+ * and SPL token account balances.
+ */
+
+import type { Address } from '@solana/addresses';
+
+import type { LightIndexer } from './indexer.js';
+import { loadAllTokenAccounts } from './load.js';
+import type {
+ GetCompressedTokenAccountsOptions,
+ CompressedTokenAccount,
+} from './client/index.js';
+
+// ============================================================================
+// TYPES
+// ============================================================================
+
+/**
+ * Unified view of a token account across all sources.
+ */
+export interface AtaInterface {
+ /** On-chain Light Token associated token account balance (hot) */
+ hotBalance: bigint;
+ /** Compressed token account balance (cold) */
+ coldBalance: bigint;
+ /** SPL token account balance */
+ splBalance: bigint;
+ /** Total balance across all sources */
+ totalBalance: bigint;
+ /** Source breakdown */
+ sources: TokenAccountSource[];
+ /** Number of compressed accounts */
+ coldAccountCount: number;
+ /** Compressed token accounts (cold) */
+ coldAccounts: CompressedTokenAccount[];
+}
+
+/**
+ * Individual token account source with balance.
+ */
+export interface TokenAccountSource {
+ /** Source type */
+ type: 'hot' | 'cold' | 'spl';
+ /** Account address or identifier */
+ address: Address;
+ /** Balance from this source */
+ balance: bigint;
+}
+
+/**
+ * Unified view of a mint.
+ */
+export interface MintInterface {
+ /** Mint address */
+ mint: Address;
+ /** Whether the mint exists on-chain */
+ exists: boolean;
+ /** Mint decimals (from on-chain data, 0 if not found) */
+ decimals: number;
+ /** Total supply (from on-chain data, 0n if not found) */
+ supply: bigint;
+ /** Whether the mint has a freeze authority */
+ hasFreezeAuthority: boolean;
+}
+
+// ============================================================================
+// QUERY FUNCTIONS
+// ============================================================================
+
+/**
+ * Fetches a unified view of token balances for an owner and mint.
+ *
+ * Aggregates balances from:
+ * - On-chain Light Token associated token account (hot)
+ * - Compressed token accounts (cold)
+ * - SPL associated token account (if exists)
+ *
+ * @param rpc - RPC client
+ * @param indexer - Light indexer
+ * @param owner - Account owner
+ * @param mint - Token mint
+ * @param hotAccount - On-chain Light Token ATA address (optional)
+ * @param splAccount - SPL ATA address (optional)
+ * @returns Unified account interface
+ */
+export async function getAtaInterface(
+ rpc: QueryRpc,
+ indexer: LightIndexer,
+ owner: Address,
+ mint: Address,
+ hotAccount?: Address,
+ splAccount?: Address,
+): Promise {
+ const sources: TokenAccountSource[] = [];
+ let hotBalance = 0n;
+ let coldBalance = 0n;
+ let splBalance = 0n;
+
+ // Fetch hot balance (on-chain Light Token ATA)
+ if (hotAccount) {
+ try {
+ const info = await rpc.getAccountInfo(hotAccount, {
+ encoding: 'base64',
+ });
+ if (info.value) {
+ const data = info.value.data;
+ if (data && typeof data === 'object' && Array.isArray(data)) {
+ const bytes = Uint8Array.from(
+ atob(data[0] as string),
+ (c) => c.charCodeAt(0),
+ );
+ if (bytes.length >= 72) {
+ const view = new DataView(
+ bytes.buffer,
+ bytes.byteOffset,
+ bytes.byteLength,
+ );
+ hotBalance = view.getBigUint64(64, true);
+ }
+ }
+ sources.push({
+ type: 'hot',
+ address: hotAccount,
+ balance: hotBalance,
+ });
+ }
+ } catch {
+ // Account may not exist
+ }
+ }
+
+ // Fetch cold balance (compressed token accounts)
+ const coldAccounts = await loadAllTokenAccounts(indexer, owner, {
+ mint,
+ } as GetCompressedTokenAccountsOptions);
+ coldBalance = coldAccounts.reduce(
+ (sum, acc) => sum + acc.token.amount,
+ 0n,
+ );
+ if (coldAccounts.length > 0) {
+ sources.push({
+ type: 'cold',
+ address: owner,
+ balance: coldBalance,
+ });
+ }
+
+ // Fetch SPL balance
+ if (splAccount) {
+ try {
+ const info = await rpc.getAccountInfo(splAccount, {
+ encoding: 'base64',
+ });
+ if (info.value) {
+ const data = info.value.data;
+ if (data && typeof data === 'object' && Array.isArray(data)) {
+ const bytes = Uint8Array.from(
+ atob(data[0] as string),
+ (c) => c.charCodeAt(0),
+ );
+ if (bytes.length >= 72) {
+ const view = new DataView(
+ bytes.buffer,
+ bytes.byteOffset,
+ bytes.byteLength,
+ );
+ splBalance = view.getBigUint64(64, true);
+ }
+ }
+ sources.push({
+ type: 'spl',
+ address: splAccount,
+ balance: splBalance,
+ });
+ }
+ } catch {
+ // Account may not exist
+ }
+ }
+
+ return {
+ hotBalance,
+ coldBalance,
+ splBalance,
+ totalBalance: hotBalance + coldBalance + splBalance,
+ sources,
+ coldAccountCount: coldAccounts.length,
+ coldAccounts,
+ };
+}
+
+/**
+ * Minimal RPC interface for query operations.
+ */
+export interface QueryRpc {
+ getAccountInfo(
+ address: Address,
+ config?: { encoding: string },
+ ): Promise<{
+ value: { owner: Address; data: unknown; lamports?: number } | null;
+ }>;
+}
+
+/**
+ * Fetches the decimals for an on-chain mint account.
+ *
+ * Reads byte 44 from the SPL mint layout.
+ *
+ * @param rpc - RPC client
+ * @param mint - Mint address
+ * @returns Mint decimals
+ * @throws Error if the mint does not exist or data is too short
+ */
+export async function getMintDecimals(
+ rpc: QueryRpc,
+ mint: Address,
+): Promise {
+ const info = await rpc.getAccountInfo(mint, { encoding: 'base64' });
+ if (!info.value) {
+ throw new Error(`Mint account not found: ${mint}`);
+ }
+ const data = info.value.data;
+ if (!data || typeof data !== 'object' || !Array.isArray(data)) {
+ throw new Error(`Invalid mint account data for ${mint}`);
+ }
+ const bytes = Uint8Array.from(
+ atob(data[0] as string),
+ (c) => c.charCodeAt(0),
+ );
+ if (bytes.length < 45) {
+ throw new Error(`Mint data too short: ${bytes.length} bytes`);
+ }
+ return bytes[44];
+}
+
+/**
+ * Fetches a unified view of a mint.
+ *
+ * Reads the on-chain mint account to extract decimals, supply,
+ * and freeze authority status.
+ *
+ * @param rpc - RPC client
+ * @param mint - Mint address
+ * @returns Mint interface
+ */
+export async function getMintInterface(
+ rpc: QueryRpc,
+ mint: Address,
+): Promise {
+ try {
+ const info = await rpc.getAccountInfo(mint, { encoding: 'base64' });
+ if (!info.value) {
+ return {
+ mint,
+ exists: false,
+ decimals: 0,
+ supply: 0n,
+ hasFreezeAuthority: false,
+ };
+ }
+
+ const data = info.value.data;
+ let bytes: Uint8Array;
+ if (data && typeof data === 'object' && Array.isArray(data)) {
+ bytes = Uint8Array.from(
+ atob(data[0] as string),
+ (c) => c.charCodeAt(0),
+ );
+ } else {
+ return {
+ mint,
+ exists: true,
+ decimals: 0,
+ supply: 0n,
+ hasFreezeAuthority: false,
+ };
+ }
+
+ if (bytes.length < 82) {
+ return {
+ mint,
+ exists: true,
+ decimals: 0,
+ supply: 0n,
+ hasFreezeAuthority: false,
+ };
+ }
+
+ // SPL Mint layout:
+ // 0-3: mintAuthorityOption (u32)
+ // 4-35: mintAuthority (32 bytes)
+ // 36-43: supply (u64 LE)
+ // 44: decimals (u8)
+ // 45: isInitialized (bool)
+ // 46-49: freezeAuthorityOption (u32)
+ // 50-81: freezeAuthority (32 bytes)
+ const view = new DataView(
+ bytes.buffer,
+ bytes.byteOffset,
+ bytes.byteLength,
+ );
+ const supply = view.getBigUint64(36, true);
+ const decimals = bytes[44];
+ const freezeAuthorityOption = view.getUint32(46, true);
+
+ return {
+ mint,
+ exists: true,
+ decimals,
+ supply,
+ hasFreezeAuthority: freezeAuthorityOption === 1,
+ };
+ } catch {
+ return {
+ mint,
+ exists: false,
+ decimals: 0,
+ supply: 0n,
+ hasFreezeAuthority: false,
+ };
+ }
+}
diff --git a/js/token-kit/src/utils/derivation.ts b/js/token-kit/src/utils/derivation.ts
new file mode 100644
index 0000000000..c646a5f8d3
--- /dev/null
+++ b/js/token-kit/src/utils/derivation.ts
@@ -0,0 +1,205 @@
+/**
+ * PDA derivation utilities for Light Token accounts.
+ */
+
+import {
+ type Address,
+ getAddressCodec,
+ getProgramDerivedAddress,
+} from '@solana/addresses';
+import { keccak_256 } from '@noble/hashes/sha3';
+
+import {
+ LIGHT_TOKEN_PROGRAM_ID,
+ COMPRESSED_MINT_SEED,
+ POOL_SEED,
+ RESTRICTED_POOL_SEED,
+ MINT_ADDRESS_TREE,
+} from '../constants.js';
+
+// ============================================================================
+// ASSOCIATED TOKEN ACCOUNT
+// ============================================================================
+
+/**
+ * Derives the associated token account address for a given owner and mint.
+ *
+ * Seeds: [owner, LIGHT_TOKEN_PROGRAM_ID, mint]
+ *
+ * @param owner - The token account owner
+ * @param mint - The token mint address
+ * @returns The derived ATA address and bump
+ */
+export async function deriveAssociatedTokenAddress(
+ owner: Address,
+ mint: Address,
+): Promise<{ address: Address; bump: number }> {
+ const programIdBytes = getAddressCodec().encode(LIGHT_TOKEN_PROGRAM_ID);
+
+ const [derivedAddress, bump] = await getProgramDerivedAddress({
+ programAddress: LIGHT_TOKEN_PROGRAM_ID,
+ seeds: [
+ getAddressCodec().encode(owner),
+ programIdBytes,
+ getAddressCodec().encode(mint),
+ ],
+ });
+
+ return { address: derivedAddress, bump };
+}
+
+/**
+ * Derives the ATA address and verifies the provided bump matches.
+ *
+ * @param owner - The token account owner
+ * @param mint - The token mint address
+ * @param bump - The expected PDA bump seed
+ * @returns The derived ATA address
+ * @throws Error if the provided bump does not match the derived bump
+ */
+export async function getAssociatedTokenAddressWithBump(
+ owner: Address,
+ mint: Address,
+ bump: number,
+): Promise {
+ const { address: derivedAddress, bump: derivedBump } =
+ await deriveAssociatedTokenAddress(owner, mint);
+
+ if (derivedBump !== bump) {
+ throw new Error(`Bump mismatch: expected ${bump}, got ${derivedBump}`);
+ }
+
+ return derivedAddress;
+}
+
+// ============================================================================
+// LIGHT MINT
+// ============================================================================
+
+/**
+ * Derives the Light mint PDA address from a mint signer.
+ *
+ * Seeds: ["compressed_mint", mintSigner]
+ *
+ * @param mintSigner - The mint signer/authority pubkey
+ * @returns The derived mint address and bump
+ */
+export async function deriveMintAddress(
+ mintSigner: Address,
+): Promise<{ address: Address; bump: number }> {
+ const [derivedAddress, bump] = await getProgramDerivedAddress({
+ programAddress: LIGHT_TOKEN_PROGRAM_ID,
+ seeds: [
+ new TextEncoder().encode(COMPRESSED_MINT_SEED),
+ getAddressCodec().encode(mintSigner),
+ ],
+ });
+
+ return { address: derivedAddress, bump };
+}
+
+// ============================================================================
+// SPL INTERFACE POOL
+// ============================================================================
+
+/**
+ * Derives the SPL interface pool PDA address.
+ *
+ * Seed format:
+ * - Regular index 0: ["pool", mint]
+ * - Regular index 1-4: ["pool", mint, index]
+ * - Restricted index 0: ["pool", mint, "restricted"]
+ * - Restricted index 1-4: ["pool", mint, "restricted", index]
+ *
+ * Restricted pools are required for mints with extensions:
+ * Pausable, PermanentDelegate, TransferFeeConfig, TransferHook,
+ * DefaultAccountState, MintCloseAuthority.
+ *
+ * @param mint - The token mint address
+ * @param index - Pool index (0-4, default 0)
+ * @param restricted - Whether to use restricted derivation path
+ * @returns The derived pool address and bump
+ */
+export async function derivePoolAddress(
+ mint: Address,
+ index = 0,
+ restricted = false,
+): Promise<{ address: Address; bump: number }> {
+ if (!Number.isInteger(index) || index < 0 || index > 4) {
+ throw new Error(
+ `Pool index must be an integer between 0 and 4, got ${index}`,
+ );
+ }
+
+ const mintBytes = getAddressCodec().encode(mint);
+ const seeds: Uint8Array[] = [
+ new TextEncoder().encode(POOL_SEED),
+ new Uint8Array(mintBytes),
+ ];
+
+ if (restricted) {
+ seeds.push(new TextEncoder().encode(RESTRICTED_POOL_SEED));
+ }
+
+ if (index > 0) {
+ // Index as single u8 byte (matches Rust: let index_bytes = [index])
+ seeds.push(new Uint8Array([index]));
+ }
+
+ const [derivedAddress, bump] = await getProgramDerivedAddress({
+ programAddress: LIGHT_TOKEN_PROGRAM_ID,
+ seeds,
+ });
+
+ return { address: derivedAddress, bump };
+}
+
+// ============================================================================
+// COMPRESSED ADDRESS DERIVATION
+// ============================================================================
+
+/**
+ * Derives a compressed account address using keccak256.
+ *
+ * Hash: keccak256(seed || addressTree || programId || 0xff), then clears the
+ * high bit to ensure the result is a valid BN254 field element.
+ *
+ * @param seed - The derivation seed bytes
+ * @param addressTree - The address tree pubkey
+ * @param programId - The owning program ID
+ * @returns 32-byte compressed address
+ */
+export function deriveCompressedAddress(
+ seed: Uint8Array,
+ addressTree: Address,
+ programId: Address,
+): Uint8Array {
+ const codec = getAddressCodec();
+ const hasher = keccak_256.create();
+ hasher.update(seed);
+ hasher.update(new Uint8Array(codec.encode(addressTree)));
+ hasher.update(new Uint8Array(codec.encode(programId)));
+ hasher.update(Uint8Array.from([255]));
+ const hash = hasher.digest();
+ // Clear the high bit so the result fits in the BN254 field
+ hash[0] = 0;
+ return hash;
+}
+
+/**
+ * Derives the compressed mint address for a given mint signer.
+ *
+ * Uses MINT_ADDRESS_TREE and LIGHT_TOKEN_PROGRAM_ID as defaults.
+ *
+ * @param mintSigner - The mint signer address
+ * @param addressTree - The address tree (defaults to MINT_ADDRESS_TREE)
+ * @returns 32-byte compressed mint address
+ */
+export function deriveCompressedMintAddress(
+ mintSigner: Address,
+ addressTree: Address = MINT_ADDRESS_TREE,
+): Uint8Array {
+ const codec = getAddressCodec();
+ const seed = new Uint8Array(codec.encode(mintSigner));
+ return deriveCompressedAddress(seed, addressTree, LIGHT_TOKEN_PROGRAM_ID);
+}
diff --git a/js/token-kit/src/utils/index.ts b/js/token-kit/src/utils/index.ts
new file mode 100644
index 0000000000..370a62e18e
--- /dev/null
+++ b/js/token-kit/src/utils/index.ts
@@ -0,0 +1,30 @@
+/**
+ * Light Token SDK Utilities
+ */
+
+export {
+ deriveAssociatedTokenAddress,
+ getAssociatedTokenAddressWithBump,
+ deriveMintAddress,
+ derivePoolAddress,
+ deriveCompressedAddress,
+ deriveCompressedMintAddress,
+} from './derivation.js';
+
+export {
+ type TransferType,
+ isLightTokenAccount,
+ determineTransferType,
+ validateAtaDerivation,
+ validatePositiveAmount,
+ validateDecimals,
+} from './validation.js';
+
+export {
+ type SplInterfaceInfo,
+ getSplInterfaceInfo,
+ getSplInterfaceInfos,
+ selectSplInterfaceInfo,
+ selectSplInterfaceInfosForDecompression,
+ deriveSplInterfaceInfo,
+} from './spl-interface.js';
diff --git a/js/token-kit/src/utils/spl-interface.ts b/js/token-kit/src/utils/spl-interface.ts
new file mode 100644
index 0000000000..7098e62e12
--- /dev/null
+++ b/js/token-kit/src/utils/spl-interface.ts
@@ -0,0 +1,179 @@
+/**
+ * SPL interface pool info for wrap/unwrap operations.
+ */
+
+import type { Address } from '@solana/addresses';
+
+import { derivePoolAddress } from './derivation.js';
+
+/**
+ * Information about an initialized SPL interface pool PDA.
+ */
+export interface SplInterfaceInfo {
+ /** Pool PDA address */
+ poolAddress: Address;
+ /** Token program (SPL Token or Token-2022) */
+ tokenProgram: Address;
+ /** Pool index (0-4) */
+ poolIndex: number;
+ /** PDA bump */
+ bump: number;
+ /** Whether the pool account is initialized */
+ isInitialized: boolean;
+}
+
+/**
+ * Minimal RPC interface for fetching account info.
+ */
+interface RpcLike {
+ getAccountInfo(
+ address: Address,
+ config?: { encoding: string },
+ ): Promise<{ value: { owner: Address; data: unknown } | null }>;
+}
+
+/**
+ * Fetches SPL interface pool info for a mint.
+ *
+ * Derives all 5 possible pool PDAs (indices 0-4), queries each,
+ * and returns the first initialized one.
+ *
+ * @param rpc - RPC client with getAccountInfo
+ * @param mint - The token mint address
+ * @param tokenProgram - The SPL token program that owns the pool accounts
+ * @returns The first initialized SplInterfaceInfo
+ * @throws If no initialized pool is found
+ */
+export async function getSplInterfaceInfo(
+ rpc: RpcLike,
+ mint: Address,
+ tokenProgram: Address,
+): Promise {
+ // Derive all 5 pool PDAs
+ const poolDerivations = await Promise.all(
+ [0, 1, 2, 3, 4].map((index) => derivePoolAddress(mint, index)),
+ );
+
+ // Fetch all pool accounts
+ const accountResults = await Promise.all(
+ poolDerivations.map((derivation) =>
+ rpc.getAccountInfo(derivation.address, { encoding: 'base64' }),
+ ),
+ );
+
+ // Find the first initialized pool
+ for (let i = 0; i < accountResults.length; i++) {
+ const result = accountResults[i];
+ if (result.value !== null) {
+ return {
+ poolAddress: poolDerivations[i].address,
+ tokenProgram,
+ poolIndex: i,
+ bump: poolDerivations[i].bump,
+ isInitialized: true,
+ };
+ }
+ }
+
+ throw new Error(
+ `No initialized SPL interface pool found for mint ${mint}`,
+ );
+}
+
+/**
+ * Fetches all 5 SPL interface pool PDAs for a mint.
+ *
+ * Returns info for all 5 pool slots (indices 0-4), whether initialized or not.
+ * Use this when you need visibility into all pool slots.
+ *
+ * @param rpc - RPC client with getAccountInfo
+ * @param mint - The token mint address
+ * @param tokenProgram - The SPL token program that owns the pool accounts
+ * @returns Array of 5 SplInterfaceInfo entries
+ */
+export async function getSplInterfaceInfos(
+ rpc: RpcLike,
+ mint: Address,
+ tokenProgram: Address,
+): Promise {
+ const poolDerivations = await Promise.all(
+ [0, 1, 2, 3, 4].map((index) => derivePoolAddress(mint, index)),
+ );
+
+ const accountResults = await Promise.all(
+ poolDerivations.map((derivation) =>
+ rpc.getAccountInfo(derivation.address, { encoding: 'base64' }),
+ ),
+ );
+
+ return poolDerivations.map((derivation, i) => ({
+ poolAddress: derivation.address,
+ tokenProgram,
+ poolIndex: i,
+ bump: derivation.bump,
+ isInitialized: accountResults[i].value !== null,
+ }));
+}
+
+/**
+ * Selects an SPL interface pool for a compress or mint-to operation.
+ *
+ * Picks a random initialized pool from the available slots.
+ *
+ * @param infos - Array of pool infos (from getSplInterfaceInfos)
+ * @returns A randomly selected initialized pool
+ * @throws If no initialized pools exist
+ */
+export function selectSplInterfaceInfo(
+ infos: SplInterfaceInfo[],
+): SplInterfaceInfo {
+ const initialized = infos.filter((info) => info.isInitialized);
+ if (initialized.length === 0) {
+ throw new Error('No initialized SPL interface pools available');
+ }
+ return initialized[Math.floor(Math.random() * initialized.length)];
+}
+
+/**
+ * Selects SPL interface pools for decompression with sufficient balance.
+ *
+ * Returns all initialized pools. Consumers can further filter by balance
+ * if needed (requires fetching token account data for each pool).
+ *
+ * @param infos - Array of pool infos (from getSplInterfaceInfos)
+ * @returns Array of initialized pool infos
+ */
+export function selectSplInterfaceInfosForDecompression(
+ infos: SplInterfaceInfo[],
+): SplInterfaceInfo[] {
+ return infos.filter((info) => info.isInitialized);
+}
+
+/**
+ * Derives SPL interface info without fetching on-chain state.
+ *
+ * Useful when creating a pool in the same transaction (you know
+ * it will be initialized by the time you need it).
+ *
+ * @param mint - The token mint address
+ * @param tokenProgram - The SPL token program
+ * @param poolIndex - Pool index (0-4, default 0)
+ * @returns Pre-derived SplInterfaceInfo
+ */
+export async function deriveSplInterfaceInfo(
+ mint: Address,
+ tokenProgram: Address,
+ poolIndex = 0,
+): Promise {
+ const { address: poolAddress, bump } = await derivePoolAddress(
+ mint,
+ poolIndex,
+ );
+ return {
+ poolAddress,
+ tokenProgram,
+ poolIndex,
+ bump,
+ isInitialized: true,
+ };
+}
diff --git a/js/token-kit/src/utils/validation.ts b/js/token-kit/src/utils/validation.ts
new file mode 100644
index 0000000000..68d936f805
--- /dev/null
+++ b/js/token-kit/src/utils/validation.ts
@@ -0,0 +1,108 @@
+/**
+ * Validation utilities for Light Token accounts.
+ */
+
+import type { Address } from '@solana/addresses';
+import { LIGHT_TOKEN_PROGRAM_ID } from '../constants.js';
+import { deriveAssociatedTokenAddress } from './derivation.js';
+
+/**
+ * Transfer type for routing between Light and SPL accounts.
+ */
+export type TransferType =
+ | 'light-to-light'
+ | 'light-to-spl'
+ | 'spl-to-light'
+ | 'spl-to-spl';
+
+// ============================================================================
+// ACCOUNT TYPE DETECTION
+// ============================================================================
+
+/**
+ * Checks if an account owner indicates a Light Token account.
+ *
+ * @param owner - The account owner address
+ * @returns True if the owner is the Light Token program
+ */
+export function isLightTokenAccount(owner: Address): boolean {
+ return owner === LIGHT_TOKEN_PROGRAM_ID;
+}
+
+/**
+ * Determines the transfer type based on source and destination owners.
+ *
+ * @param sourceOwner - Owner of the source account
+ * @param destOwner - Owner of the destination account
+ * @returns The transfer type
+ */
+export function determineTransferType(
+ sourceOwner: Address,
+ destOwner: Address,
+): TransferType {
+ const sourceIsLight = isLightTokenAccount(sourceOwner);
+ const destIsLight = isLightTokenAccount(destOwner);
+
+ if (sourceIsLight && destIsLight) {
+ return 'light-to-light';
+ }
+ if (sourceIsLight && !destIsLight) {
+ return 'light-to-spl';
+ }
+ if (!sourceIsLight && destIsLight) {
+ return 'spl-to-light';
+ }
+ return 'spl-to-spl';
+}
+
+// ============================================================================
+// ATA VALIDATION
+// ============================================================================
+
+/**
+ * Validates that an ATA address matches the expected derivation.
+ *
+ * @param ata - The ATA address to validate
+ * @param owner - The expected owner
+ * @param mint - The expected mint
+ * @returns True if the ATA matches the derivation
+ */
+export async function validateAtaDerivation(
+ ata: Address,
+ owner: Address,
+ mint: Address,
+): Promise {
+ const { address: derivedAta } = await deriveAssociatedTokenAddress(
+ owner,
+ mint,
+ );
+ return ata === derivedAta;
+}
+
+// ============================================================================
+// AMOUNT VALIDATION
+// ============================================================================
+
+/**
+ * Validates that a transfer amount is positive.
+ *
+ * @param amount - The amount to validate
+ * @throws Error if amount is not positive
+ */
+export function validatePositiveAmount(amount: bigint): void {
+ if (amount <= 0n) {
+ throw new Error('Amount must be positive');
+ }
+}
+
+/**
+ * Validates decimal places for checked operations.
+ *
+ * @param decimals - The decimals value (0-255)
+ * @throws Error if decimals is out of range
+ */
+export function validateDecimals(decimals: number): void {
+ if (decimals < 0 || decimals > 255 || !Number.isInteger(decimals)) {
+ throw new Error('Decimals must be an integer between 0 and 255');
+ }
+}
diff --git a/js/token-kit/tests/e2e/actions.test.ts b/js/token-kit/tests/e2e/actions.test.ts
new file mode 100644
index 0000000000..d8f482c677
--- /dev/null
+++ b/js/token-kit/tests/e2e/actions.test.ts
@@ -0,0 +1,84 @@
+/**
+ * E2E tests for buildCompressedTransfer.
+ *
+ * Requires a running local validator + indexer + prover.
+ */
+
+import { describe, it, expect, beforeAll } from 'vitest';
+import {
+ getTestRpc,
+ fundAccount,
+ createCompressedMint,
+ mintCompressedTokens,
+ toKitAddress,
+ ensureValidatorRunning,
+ type Signer,
+ type Rpc,
+} from './helpers/setup.js';
+
+import { PhotonIndexer, buildCompressedTransfer } from '../../src/index.js';
+import { DISCRIMINATOR } from '../../src/index.js';
+
+const COMPRESSION_RPC = 'http://127.0.0.1:8784';
+const DECIMALS = 2;
+const MINT_AMOUNT = 10_000n;
+
+describe('buildCompressedTransfer e2e', () => {
+ let rpc: Rpc;
+ let payer: Signer;
+ let mint: any;
+ let mintAuthority: Signer;
+ let indexer: PhotonIndexer;
+
+ beforeAll(async () => {
+ await ensureValidatorRunning();
+ rpc = getTestRpc();
+ payer = await fundAccount(rpc);
+
+ const created = await createCompressedMint(rpc, payer, DECIMALS);
+ mint = created.mint;
+ mintAuthority = created.mintAuthority;
+
+ await mintCompressedTokens(
+ rpc, payer, mint, payer.publicKey, mintAuthority, MINT_AMOUNT,
+ );
+
+ indexer = new PhotonIndexer(COMPRESSION_RPC);
+ });
+
+ it('builds Transfer2 instruction with loaded accounts and proof', async () => {
+ const recipient = await fundAccount(rpc);
+ const ownerAddr = toKitAddress(payer.publicKey);
+ const recipientAddr = toKitAddress(recipient.publicKey);
+ const mintAddr = toKitAddress(mint);
+ const feePayerAddr = toKitAddress(payer.publicKey);
+
+ const transferAmount = 3_000n;
+
+ const result = await buildCompressedTransfer({
+ indexer,
+ owner: ownerAddr,
+ mint: mintAddr,
+ amount: transferAmount,
+ recipientOwner: recipientAddr,
+ feePayer: feePayerAddr,
+ });
+
+ // Verify the result structure
+ expect(result.instruction).toBeDefined();
+ expect(result.inputs.length).toBeGreaterThan(0);
+ expect(result.proof).toBeDefined();
+ expect(result.totalInputAmount).toBeGreaterThanOrEqual(transferAmount);
+
+ // Verify the Transfer2 instruction
+ const ix = result.instruction;
+ expect(ix.data[0]).toBe(DISCRIMINATOR.TRANSFER2);
+ expect(ix.accounts.length).toBeGreaterThanOrEqual(4);
+
+ // Verify loaded account data
+ const input = result.inputs[0];
+ expect(input.tokenAccount.token.amount).toBeGreaterThanOrEqual(0n);
+ expect(input.merkleContext.tree).toBeDefined();
+ expect(input.merkleContext.queue).toBeDefined();
+ });
+});
diff --git a/js/token-kit/tests/e2e/approve.test.ts b/js/token-kit/tests/e2e/approve.test.ts
new file mode 100644
index 0000000000..9954354eeb
--- /dev/null
+++ b/js/token-kit/tests/e2e/approve.test.ts
@@ -0,0 +1,104 @@
+/**
+ * E2E tests for Kit v2 approve and revoke instructions against CToken accounts.
+ */
+
+import { describe, it, expect, beforeAll } from 'vitest';
+import {
+ getTestRpc,
+ fundAccount,
+ createTestMint,
+ createCTokenWithBalance,
+ sendKitInstructions,
+ getCTokenAccountData,
+ toKitAddress,
+ ensureValidatorRunning,
+ type Signer,
+ type Rpc,
+} from './helpers/setup.js';
+
+import {
+ createApproveInstruction,
+ createRevokeInstruction,
+} from '../../src/index.js';
+
+const DECIMALS = 2;
+const MINT_AMOUNT = 10_000n;
+
+describe('approve/revoke e2e (CToken)', () => {
+ let rpc: Rpc;
+ let payer: Signer;
+ let mint: any;
+ let mintAuthority: Signer;
+
+ beforeAll(async () => {
+ await ensureValidatorRunning();
+ rpc = getTestRpc();
+ payer = await fundAccount(rpc);
+
+ const created = await createTestMint(rpc, payer, DECIMALS);
+ mint = created.mint;
+ mintAuthority = created.mintAuthority;
+ });
+
+ it('approve delegate', async () => {
+ const owner = await fundAccount(rpc);
+ const delegate = await fundAccount(rpc);
+
+ const { ctokenPubkey, ctokenAddress } = await createCTokenWithBalance(
+ rpc, payer, mint, owner, mintAuthority, MINT_AMOUNT,
+ );
+
+ const ownerAddr = toKitAddress(owner.publicKey);
+ const delegateAddr = toKitAddress(delegate.publicKey);
+
+ const ix = createApproveInstruction({
+ tokenAccount: ctokenAddress,
+ delegate: delegateAddr,
+ owner: ownerAddr,
+ amount: 5_000n,
+ });
+
+ await sendKitInstructions(rpc, [ix], owner);
+
+ // Verify on-chain: delegate is set
+ const data = await getCTokenAccountData(rpc, ctokenPubkey);
+ expect(data).not.toBeNull();
+ expect(data!.hasDelegate).toBe(true);
+ expect(data!.delegate).toBe(delegate.publicKey.toBase58());
+ expect(data!.delegatedAmount).toBe(5_000n);
+ });
+
+ it('revoke delegate', async () => {
+ const owner = await fundAccount(rpc);
+ const delegate = await fundAccount(rpc);
+
+ const { ctokenPubkey, ctokenAddress } = await createCTokenWithBalance(
+ rpc, payer, mint, owner, mintAuthority, MINT_AMOUNT,
+ );
+
+ const ownerAddr = toKitAddress(owner.publicKey);
+ const delegateAddr = toKitAddress(delegate.publicKey);
+
+ // Approve first
+ const approveIx = createApproveInstruction({
+ tokenAccount: ctokenAddress,
+ delegate: delegateAddr,
+ owner: ownerAddr,
+ amount: 5_000n,
+ });
+ await sendKitInstructions(rpc, [approveIx], owner);
+
+ // Then revoke
+ const revokeIx = createRevokeInstruction({
+ tokenAccount: ctokenAddress,
+ owner: ownerAddr,
+ });
+ await sendKitInstructions(rpc, [revokeIx], owner);
+
+ // Verify on-chain: delegate is cleared
+ const data = await getCTokenAccountData(rpc, ctokenPubkey);
+ expect(data).not.toBeNull();
+ expect(data!.hasDelegate).toBe(false);
+ expect(data!.delegate).toBeNull();
+ });
+});
diff --git a/js/token-kit/tests/e2e/ata.test.ts b/js/token-kit/tests/e2e/ata.test.ts
new file mode 100644
index 0000000000..34daa34fba
--- /dev/null
+++ b/js/token-kit/tests/e2e/ata.test.ts
@@ -0,0 +1,78 @@
+/**
+ * E2E tests for Kit v2 create associated token account instruction.
+ */
+
+import { describe, it, expect, beforeAll } from 'vitest';
+import {
+ getTestRpc,
+ fundAccount,
+ createTestMint,
+ sendKitInstructions,
+ getCTokenAccountData,
+ toKitAddress,
+ ensureValidatorRunning,
+ type Signer,
+ type Rpc,
+} from './helpers/setup.js';
+
+import {
+ createAssociatedTokenAccountIdempotentInstruction,
+ deriveAssociatedTokenAddress,
+ LIGHT_TOKEN_PROGRAM_ID,
+} from '../../src/index.js';
+
+const DECIMALS = 2;
+
+describe('create ATA e2e (CToken)', () => {
+ let rpc: Rpc;
+ let payer: Signer;
+ let mint: any;
+ let mintAuthority: Signer;
+ let mintAddress: string;
+
+ beforeAll(async () => {
+ await ensureValidatorRunning();
+ rpc = getTestRpc();
+ payer = await fundAccount(rpc);
+
+ const created = await createTestMint(rpc, payer, DECIMALS);
+ mint = created.mint;
+ mintAuthority = created.mintAuthority;
+ mintAddress = created.mintAddress;
+ });
+
+ it('derive ATA address: deterministic and valid', async () => {
+ const owner = await fundAccount(rpc);
+ const ownerAddr = toKitAddress(owner.publicKey);
+
+ const { address: expectedAta, bump } =
+ await deriveAssociatedTokenAddress(ownerAddr, mintAddress);
+
+ expect(expectedAta).toBeDefined();
+ expect(bump).toBeGreaterThanOrEqual(0);
+ expect(bump).toBeLessThanOrEqual(255);
+
+ // Same inputs produce same output
+ const { address: ata2 } =
+ await deriveAssociatedTokenAddress(ownerAddr, mintAddress);
+ expect(ata2).toBe(expectedAta);
+ });
+
+ it('create ATA idempotent: builds valid instruction', async () => {
+ const owner = await fundAccount(rpc);
+ const ownerAddr = toKitAddress(owner.publicKey);
+ const payerAddr = toKitAddress(payer.publicKey);
+
+ const result = await createAssociatedTokenAccountIdempotentInstruction({
+ payer: payerAddr,
+ owner: ownerAddr,
+ mint: mintAddress,
+ });
+
+ expect(result.address).toBeDefined();
+ expect(result.bump).toBeGreaterThanOrEqual(0);
+ expect(result.instruction.programAddress).toBe(LIGHT_TOKEN_PROGRAM_ID);
+ expect(result.instruction.accounts).toBeDefined();
+ expect(result.instruction.data).toBeInstanceOf(Uint8Array);
+ });
+});
diff --git a/js/token-kit/tests/e2e/close.test.ts b/js/token-kit/tests/e2e/close.test.ts
new file mode 100644
index 0000000000..fa79b512c1
--- /dev/null
+++ b/js/token-kit/tests/e2e/close.test.ts
@@ -0,0 +1,77 @@
+/**
+ * E2E tests for Kit v2 close account instruction against CToken accounts.
+ */
+
+import { describe, it, expect, beforeAll } from 'vitest';
+import {
+ getTestRpc,
+ fundAccount,
+ createTestMint,
+ createCTokenWithBalance,
+ sendKitInstructions,
+ getCTokenAccountData,
+ toKitAddress,
+ ensureValidatorRunning,
+ type Signer,
+ type Rpc,
+} from './helpers/setup.js';
+
+import {
+ createCloseAccountInstruction,
+ createBurnInstruction,
+ LIGHT_TOKEN_RENT_SPONSOR,
+} from '../../src/index.js';
+
+const DECIMALS = 2;
+const MINT_AMOUNT = 10_000n;
+
+describe('close account e2e (CToken)', () => {
+ let rpc: Rpc;
+ let payer: Signer;
+ let mint: any;
+ let mintAuthority: Signer;
+ let mintAddress: string;
+
+ beforeAll(async () => {
+ await ensureValidatorRunning();
+ rpc = getTestRpc();
+ payer = await fundAccount(rpc);
+
+ const created = await createTestMint(rpc, payer, DECIMALS);
+ mint = created.mint;
+ mintAuthority = created.mintAuthority;
+ mintAddress = created.mintAddress;
+ });
+
+ it('close zero-balance CToken account', async () => {
+ const holder = await fundAccount(rpc);
+ const { ctokenPubkey, ctokenAddress } = await createCTokenWithBalance(
+ rpc, payer, mint, holder, mintAuthority, MINT_AMOUNT,
+ );
+
+ const holderAddr = toKitAddress(holder.publicKey);
+ const payerAddr = toKitAddress(payer.publicKey);
+
+ // Burn all tokens to get zero balance
+ const burnIx = createBurnInstruction({
+ tokenAccount: ctokenAddress,
+ mint: mintAddress,
+ authority: holderAddr,
+ amount: MINT_AMOUNT,
+ });
+ await sendKitInstructions(rpc, [burnIx], holder);
+
+ // Close the zero-balance account (rentSponsor required for compressible CToken accounts)
+ const closeIx = createCloseAccountInstruction({
+ tokenAccount: ctokenAddress,
+ destination: payerAddr,
+ owner: holderAddr,
+ rentSponsor: LIGHT_TOKEN_RENT_SPONSOR,
+ });
+ await sendKitInstructions(rpc, [closeIx], holder);
+
+ // Account should no longer exist (or be zeroed)
+ const data = await getCTokenAccountData(rpc, ctokenPubkey);
+ expect(data).toBeNull();
+ });
+});
diff --git a/js/token-kit/tests/e2e/create-token-account.test.ts b/js/token-kit/tests/e2e/create-token-account.test.ts
new file mode 100644
index 0000000000..1289b9d80f
--- /dev/null
+++ b/js/token-kit/tests/e2e/create-token-account.test.ts
@@ -0,0 +1,69 @@
+/**
+ * E2E tests for createTokenAccountInstruction.
+ *
+ * Verifies the instruction builder produces valid instructions with the
+ * correct discriminator and account layout.
+ *
+ * Requires a running local validator + indexer + prover.
+ */
+
+import { describe, it, expect, beforeAll } from 'vitest';
+import {
+ getTestRpc,
+ fundAccount,
+ createTestMint,
+ toKitAddress,
+ ensureValidatorRunning,
+ type Signer,
+ type Rpc,
+} from './helpers/setup.js';
+
+import {
+ createTokenAccountInstruction,
+ deriveAssociatedTokenAddress,
+ LIGHT_TOKEN_PROGRAM_ID,
+ DISCRIMINATOR,
+} from '../../src/index.js';
+
+const DECIMALS = 2;
+
+describe('createTokenAccount e2e', () => {
+ let rpc: Rpc;
+ let payer: Signer;
+ let mint: any;
+ let mintAddress: string;
+
+ beforeAll(async () => {
+ await ensureValidatorRunning();
+ rpc = getTestRpc();
+ payer = await fundAccount(rpc);
+
+ const created = await createTestMint(rpc, payer, DECIMALS);
+ mint = created.mint;
+ mintAddress = created.mintAddress;
+ });
+
+ it('builds valid non-compressible token account instruction', async () => {
+ const owner = await fundAccount(rpc);
+ const ownerAddr = toKitAddress(owner.publicKey);
+
+ // Derive ATA address as the token account address
+ const { address: tokenAccountAddress } =
+ await deriveAssociatedTokenAddress(ownerAddr, mintAddress);
+
+ const ix = createTokenAccountInstruction({
+ tokenAccount: tokenAccountAddress,
+ mint: mintAddress,
+ owner: ownerAddr,
+ });
+
+ // Verify instruction structure
+ expect(ix.programAddress).toBe(LIGHT_TOKEN_PROGRAM_ID);
+ expect(ix.accounts).toHaveLength(2); // token_account (writable), mint (readonly)
+ expect(ix.data[0]).toBe(DISCRIMINATOR.CREATE_TOKEN_ACCOUNT);
+
+ // Verify account roles
+ expect(ix.accounts[0].address).toBe(tokenAccountAddress);
+ expect(ix.accounts[1].address).toBe(mintAddress);
+ });
+});
diff --git a/js/token-kit/tests/e2e/freeze-thaw.test.ts b/js/token-kit/tests/e2e/freeze-thaw.test.ts
new file mode 100644
index 0000000000..4a5164b0c2
--- /dev/null
+++ b/js/token-kit/tests/e2e/freeze-thaw.test.ts
@@ -0,0 +1,148 @@
+/**
+ * E2E tests for Kit v2 freeze and thaw instructions against CToken accounts.
+ */
+
+import { describe, it, expect, beforeAll } from 'vitest';
+import {
+ getTestRpc,
+ fundAccount,
+ createTestMint,
+ createCTokenWithBalance,
+ createCTokenAccount,
+ sendKitInstructions,
+ getCTokenAccountData,
+ getCTokenBalance,
+ toKitAddress,
+ ensureValidatorRunning,
+ type Signer,
+ type Rpc,
+} from './helpers/setup.js';
+
+import {
+ createFreezeInstruction,
+ createThawInstruction,
+ createTransferInstruction,
+} from '../../src/index.js';
+
+const DECIMALS = 2;
+const MINT_AMOUNT = 10_000n;
+
+describe('freeze/thaw e2e (CToken)', () => {
+ let rpc: Rpc;
+ let payer: Signer;
+ let mint: any;
+ let mintAuthority: Signer;
+ let mintAddress: string;
+ let freezeAuthority: Signer;
+
+ beforeAll(async () => {
+ await ensureValidatorRunning();
+ rpc = getTestRpc();
+ payer = await fundAccount(rpc);
+ freezeAuthority = await fundAccount(rpc, 1e9);
+
+ const created = await createTestMint(
+ rpc,
+ payer,
+ DECIMALS,
+ freezeAuthority,
+ );
+ mint = created.mint;
+ mintAuthority = created.mintAuthority;
+ mintAddress = created.mintAddress;
+ });
+
+ it('freeze account', async () => {
+ const holder = await fundAccount(rpc);
+ const { ctokenPubkey, ctokenAddress } = await createCTokenWithBalance(
+ rpc, payer, mint, holder, mintAuthority, MINT_AMOUNT,
+ );
+
+ const freezeAddr = toKitAddress(freezeAuthority.publicKey);
+
+ const ix = createFreezeInstruction({
+ tokenAccount: ctokenAddress,
+ mint: mintAddress,
+ freezeAuthority: freezeAddr,
+ });
+
+ await sendKitInstructions(rpc, [ix], payer, [freezeAuthority]);
+
+ // Verify on-chain: state = 2 (frozen)
+ const data = await getCTokenAccountData(rpc, ctokenPubkey);
+ expect(data).not.toBeNull();
+ expect(data!.state).toBe(2);
+ });
+
+ it('thaw account', async () => {
+ const holder = await fundAccount(rpc);
+ const { ctokenPubkey, ctokenAddress } = await createCTokenWithBalance(
+ rpc, payer, mint, holder, mintAuthority, MINT_AMOUNT,
+ );
+
+ const freezeAddr = toKitAddress(freezeAuthority.publicKey);
+
+ // Freeze first
+ const freezeIx = createFreezeInstruction({
+ tokenAccount: ctokenAddress,
+ mint: mintAddress,
+ freezeAuthority: freezeAddr,
+ });
+ await sendKitInstructions(rpc, [freezeIx], payer, [freezeAuthority]);
+
+ // Then thaw
+ const thawIx = createThawInstruction({
+ tokenAccount: ctokenAddress,
+ mint: mintAddress,
+ freezeAuthority: freezeAddr,
+ });
+ await sendKitInstructions(rpc, [thawIx], payer, [freezeAuthority]);
+
+ // Verify on-chain: state = 1 (initialized, not frozen)
+ const data = await getCTokenAccountData(rpc, ctokenPubkey);
+ expect(data).not.toBeNull();
+ expect(data!.state).toBe(1);
+ });
+
+ it('transfer after thaw succeeds', async () => {
+ const holder = await fundAccount(rpc);
+ const receiver = await fundAccount(rpc);
+
+ const { ctokenPubkey: holderCtoken, ctokenAddress: holderCtokenAddr } =
+ await createCTokenWithBalance(rpc, payer, mint, holder, mintAuthority, MINT_AMOUNT);
+
+ const { ctokenPubkey: receiverCtoken, ctokenAddress: receiverCtokenAddr } =
+ await createCTokenAccount(rpc, payer, receiver, mint);
+
+ const freezeAddr = toKitAddress(freezeAuthority.publicKey);
+ const holderAddr = toKitAddress(holder.publicKey);
+
+ // Freeze
+ const freezeIx = createFreezeInstruction({
+ tokenAccount: holderCtokenAddr,
+ mint: mintAddress,
+ freezeAuthority: freezeAddr,
+ });
+ await sendKitInstructions(rpc, [freezeIx], payer, [freezeAuthority]);
+
+ // Thaw
+ const thawIx = createThawInstruction({
+ tokenAccount: holderCtokenAddr,
+ mint: mintAddress,
+ freezeAuthority: freezeAddr,
+ });
+ await sendKitInstructions(rpc, [thawIx], payer, [freezeAuthority]);
+
+ // Transfer should succeed after thaw
+ const transferIx = createTransferInstruction({
+ source: holderCtokenAddr,
+ destination: receiverCtokenAddr,
+ amount: 5_000n,
+ authority: holderAddr,
+ });
+ await sendKitInstructions(rpc, [transferIx], holder);
+
+ const receiverBalance = await getCTokenBalance(rpc, receiverCtoken);
+ expect(receiverBalance).toBe(5_000n);
+ });
+});
diff --git a/js/token-kit/tests/e2e/helpers/setup.ts b/js/token-kit/tests/e2e/helpers/setup.ts
new file mode 100644
index 0000000000..d89bac66fe
--- /dev/null
+++ b/js/token-kit/tests/e2e/helpers/setup.ts
@@ -0,0 +1,572 @@
+/**
+ * E2E test setup helpers for token-kit tests.
+ *
+ * Combines helpers from both token-sdk and token-client:
+ * - CToken fixtures: decompressed mints, on-chain CToken accounts with balances
+ * - Compressed token fixtures: compressed mints, minting to compressed accounts
+ * - Bridge to send Kit v2 instructions via web3.js v1 transactions
+ *
+ * NOTE: No direct @solana/web3.js import — the PublicKey constructor is
+ * extracted at runtime from objects returned by stateless.js.
+ */
+
+import {
+ Rpc,
+ createRpc,
+ newAccountWithLamports,
+ buildAndSignTx,
+ sendAndConfirmTx,
+ dedupeSigner,
+ VERSION,
+ featureFlags,
+} from '@lightprotocol/stateless.js';
+import {
+ createMintInterface,
+ decompressMint,
+ createAssociatedCTokenAccount,
+ getAssociatedCTokenAddress,
+ mintToCToken,
+ createMint,
+ mintTo,
+} from '@lightprotocol/compressed-token';
+
+import { AccountRole, type Instruction } from '@solana/instructions';
+import { type Address, address } from '@solana/addresses';
+
+// Enable V2 + beta features for CToken operations
+featureFlags.version = VERSION.V2;
+featureFlags.enableBeta();
+
+// ============================================================================
+// LEGACY INTEROP — runtime-extracted from stateless.js's web3.js
+// ============================================================================
+
+let PubKey: any = null;
+
+function pk(value: string): any {
+ if (!PubKey) throw new Error('call fundAccount() before using pk()');
+ return new PubKey(value);
+}
+
+// ============================================================================
+// TEST RPC
+// ============================================================================
+
+const SOLANA_RPC = 'http://127.0.0.1:8899';
+const COMPRESSION_RPC = 'http://127.0.0.1:8784';
+const PROVER_RPC = 'http://127.0.0.1:3001';
+
+export function getTestRpc(): Rpc {
+ return createRpc(SOLANA_RPC, COMPRESSION_RPC, PROVER_RPC);
+}
+
+// ============================================================================
+// VALIDATOR HEALTH CHECK
+// ============================================================================
+
+/**
+ * Check if the local test validator is reachable.
+ * Call this in beforeAll to skip tests when the validator is down.
+ */
+export async function ensureValidatorRunning(): Promise {
+ try {
+ const response = await fetch(SOLANA_RPC, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ jsonrpc: '2.0',
+ id: 1,
+ method: 'getHealth',
+ }),
+ signal: AbortSignal.timeout(3000),
+ });
+ const json = (await response.json()) as { result?: string };
+ if (json.result !== 'ok') {
+ throw new Error(`Validator unhealthy: ${JSON.stringify(json)}`);
+ }
+ } catch {
+ throw new Error(
+ 'Local test validator is not running. ' +
+ 'Start it with: ./cli/test_bin/run test-validator',
+ );
+ }
+}
+
+// ============================================================================
+// TYPE ALIASES
+// ============================================================================
+
+/** web3.js v1 Signer shape (publicKey + secretKey). */
+export type Signer = { publicKey: any; secretKey: Uint8Array };
+
+// ============================================================================
+// ACCOUNT HELPERS
+// ============================================================================
+
+export async function fundAccount(
+ rpc: Rpc,
+ lamports = 10e9,
+): Promise {
+ const signer: any = await newAccountWithLamports(rpc, lamports);
+ if (!PubKey) PubKey = signer.publicKey.constructor;
+ return signer;
+}
+
+// ============================================================================
+// CTOKEN MINT HELPERS (decompressed mints — on-chain CMint accounts)
+// ============================================================================
+
+/**
+ * Create a CToken mint: creates a compressed mint then decompresses it
+ * so it exists as a CMint on-chain account.
+ */
+export async function createTestMint(
+ rpc: Rpc,
+ payer: Signer,
+ decimals = 2,
+ freezeAuthority?: Signer | null,
+): Promise<{
+ mint: any;
+ mintAuthority: Signer;
+ mintAddress: Address;
+}> {
+ const mintAuthority = await fundAccount(rpc, 1e9);
+
+ // Step 1: Create compressed mint
+ const result = await createMintInterface(
+ rpc,
+ payer as any,
+ mintAuthority as any,
+ freezeAuthority ? (freezeAuthority as any).publicKey : null,
+ decimals,
+ );
+ const mint = result.mint;
+
+ // Step 2: Decompress mint to create on-chain CMint account
+ await decompressMint(rpc, payer as any, mint);
+
+ return {
+ mint,
+ mintAuthority,
+ mintAddress: toKitAddress(mint),
+ };
+}
+
+/**
+ * Create a CToken mint WITH metadata: creates a compressed mint with
+ * tokenMetadata then decompresses it so it exists as a CMint on-chain account.
+ */
+export async function createTestMintWithMetadata(
+ rpc: Rpc,
+ payer: Signer,
+ decimals = 2,
+ metadata: { name: string; symbol: string; uri: string } = {
+ name: 'Test Token',
+ symbol: 'TEST',
+ uri: 'https://example.com/token.json',
+ },
+): Promise<{
+ mint: any;
+ mintAuthority: Signer;
+ mintAddress: Address;
+}> {
+ const mintAuthority = await fundAccount(rpc, 1e9);
+
+ // Step 1: Create compressed mint with metadata
+ const result = await createMintInterface(
+ rpc,
+ payer as any,
+ mintAuthority as any,
+ null,
+ decimals,
+ undefined, // keypair
+ undefined, // confirmOptions
+ undefined, // programId
+ {
+ name: metadata.name,
+ symbol: metadata.symbol,
+ uri: metadata.uri,
+ additionalMetadata: null,
+ },
+ );
+ const mint = result.mint;
+
+ // Step 2: Decompress mint to create on-chain CMint account
+ await decompressMint(rpc, payer as any, mint);
+
+ return {
+ mint,
+ mintAuthority,
+ mintAddress: toKitAddress(mint),
+ };
+}
+
+// ============================================================================
+// COMPRESSED TOKEN MINT HELPERS (V1-style compressed mints)
+// ============================================================================
+
+/**
+ * Create a compressed token mint (V1-style, stays compressed).
+ */
+export async function createCompressedMint(
+ rpc: Rpc,
+ payer: Signer,
+ decimals = 2,
+ freezeAuthority?: Signer | null,
+): Promise<{
+ mint: any;
+ mintAuthority: Signer;
+ mintAddress: Address;
+}> {
+ const mintAuthority = await fundAccount(rpc, 1e9);
+
+ const { mint } = await createMint(
+ rpc,
+ payer as any,
+ (mintAuthority as any).publicKey,
+ decimals,
+ undefined,
+ undefined,
+ undefined,
+ freezeAuthority ? (freezeAuthority as any).publicKey : null,
+ );
+ return {
+ mint,
+ mintAuthority,
+ mintAddress: toKitAddress(mint),
+ };
+}
+
+/**
+ * Mint compressed tokens to a recipient.
+ */
+export async function mintCompressedTokens(
+ rpc: Rpc,
+ payer: Signer,
+ mint: any,
+ to: any,
+ authority: Signer,
+ amount: number | bigint,
+): Promise {
+ return mintTo(
+ rpc,
+ payer as any,
+ mint,
+ to,
+ authority as any,
+ Number(amount),
+ );
+}
+
+// ============================================================================
+// CTOKEN ACCOUNT HELPERS
+// ============================================================================
+
+/**
+ * Create a CToken associated token account for the given owner.
+ * Returns the on-chain CToken account address (web3.js PublicKey + Kit Address).
+ */
+export async function createCTokenAccount(
+ rpc: Rpc,
+ payer: Signer,
+ owner: Signer,
+ mint: any,
+): Promise<{ ctokenPubkey: any; ctokenAddress: Address }> {
+ await createAssociatedCTokenAccount(
+ rpc,
+ payer as any,
+ (owner as any).publicKey,
+ mint,
+ );
+ const ctokenPubkey = getAssociatedCTokenAddress(
+ (owner as any).publicKey,
+ mint,
+ );
+ return {
+ ctokenPubkey,
+ ctokenAddress: toKitAddress(ctokenPubkey),
+ };
+}
+
+/**
+ * Create a CToken account and mint tokens to it.
+ */
+export async function createCTokenWithBalance(
+ rpc: Rpc,
+ payer: Signer,
+ mint: any,
+ owner: Signer,
+ mintAuthority: Signer,
+ amount: number | bigint,
+): Promise<{ ctokenPubkey: any; ctokenAddress: Address }> {
+ const { ctokenPubkey, ctokenAddress } = await createCTokenAccount(
+ rpc,
+ payer,
+ owner,
+ mint,
+ );
+
+ // Mint tokens to the CToken account
+ await mintToCToken(
+ rpc,
+ payer as any,
+ mint,
+ ctokenPubkey,
+ mintAuthority as any,
+ amount,
+ );
+
+ return { ctokenPubkey, ctokenAddress };
+}
+
+// ============================================================================
+// CTOKEN STATE READERS
+// ============================================================================
+
+/**
+ * Parsed CToken account info from on-chain data.
+ * Follows SPL Token Account layout (first 165 bytes).
+ */
+export interface CTokenAccountData {
+ mint: string;
+ owner: string;
+ amount: bigint;
+ hasDelegate: boolean;
+ delegate: string | null;
+ /** 1 = initialized, 2 = frozen */
+ state: number;
+ delegatedAmount: bigint;
+ hasCloseAuthority: boolean;
+ closeAuthority: string | null;
+}
+
+function pubkeyToBase58(bytes: Uint8Array): string {
+ // Use the PubKey constructor to convert bytes → base58
+ return new PubKey(bytes).toBase58();
+}
+
+/**
+ * Read and parse a CToken account from on-chain.
+ */
+export async function getCTokenAccountData(
+ rpc: Rpc,
+ ctokenPubkey: any,
+): Promise {
+ const info = await rpc.getAccountInfo(ctokenPubkey);
+ if (!info || !info.data || info.data.length < 165) return null;
+
+ const data = info.data;
+ const view = new DataView(
+ data.buffer,
+ data.byteOffset,
+ data.byteLength,
+ );
+
+ const mint = pubkeyToBase58(data.slice(0, 32));
+ const owner = pubkeyToBase58(data.slice(32, 64));
+ const amount = view.getBigUint64(64, true);
+
+ const delegateOption = view.getUint32(72, true);
+ const hasDelegate = delegateOption === 1;
+ const delegate = hasDelegate
+ ? pubkeyToBase58(data.slice(76, 108))
+ : null;
+
+ const state = data[108];
+
+ const delegatedAmount = view.getBigUint64(121, true);
+
+ const closeAuthorityOption = view.getUint32(129, true);
+ const hasCloseAuthority = closeAuthorityOption === 1;
+ const closeAuthority = hasCloseAuthority
+ ? pubkeyToBase58(data.slice(133, 165))
+ : null;
+
+ return {
+ mint,
+ owner,
+ amount,
+ hasDelegate,
+ delegate,
+ state,
+ delegatedAmount,
+ hasCloseAuthority,
+ closeAuthority,
+ };
+}
+
+/**
+ * Get the balance of a CToken account.
+ */
+export async function getCTokenBalance(
+ rpc: Rpc,
+ ctokenPubkey: any,
+): Promise {
+ const data = await getCTokenAccountData(rpc, ctokenPubkey);
+ if (!data) throw new Error('CToken account not found');
+ return data.amount;
+}
+
+// ============================================================================
+// QUERY HELPERS (for compressed token accounts)
+// ============================================================================
+
+export async function getCompressedBalance(
+ rpc: Rpc,
+ owner: any,
+ mint: any,
+): Promise {
+ const accounts = await rpc.getCompressedTokenAccountsByOwner(owner, {
+ mint,
+ });
+ return accounts.items.reduce(
+ (sum: bigint, acc: any) => sum + BigInt(acc.parsed.amount.toString()),
+ 0n,
+ );
+}
+
+export async function getCompressedAccountCount(
+ rpc: Rpc,
+ owner: any,
+ mint: any,
+): Promise {
+ const accounts = await rpc.getCompressedTokenAccountsByOwner(owner, {
+ mint,
+ });
+ return accounts.items.length;
+}
+
+// ============================================================================
+// SPL ASSOCIATED TOKEN ACCOUNT HELPERS
+// ============================================================================
+
+const ASSOCIATED_TOKEN_PROGRAM_ID = 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL';
+const SPL_TOKEN_PROGRAM = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA';
+const SYS_PROGRAM = '11111111111111111111111111111111';
+
+/**
+ * Create an SPL associated token account for a given owner and mint.
+ * Returns the associated token account public key.
+ */
+export async function createSplAssociatedTokenAccount(
+ rpc: Rpc,
+ payer: Signer,
+ mint: any,
+ owner: any,
+ tokenProgramId?: string,
+): Promise {
+ const tokenProg = pk(tokenProgramId ?? SPL_TOKEN_PROGRAM);
+
+ // Derive the associated token account address
+ const [ataAddress] = await (async () => {
+ // Use PublicKey.findProgramAddress
+ const associatedProg = pk(ASSOCIATED_TOKEN_PROGRAM_ID);
+ return PubKey.findProgramAddress(
+ [owner.toBuffer(), tokenProg.toBuffer(), mint.toBuffer()],
+ associatedProg,
+ );
+ })();
+
+ // Build the create ATA instruction (SPL Associated Token Account program)
+ const ix = {
+ programId: pk(ASSOCIATED_TOKEN_PROGRAM_ID),
+ keys: [
+ { pubkey: (payer as any).publicKey, isSigner: true, isWritable: true },
+ { pubkey: ataAddress, isSigner: false, isWritable: true },
+ { pubkey: owner, isSigner: false, isWritable: false },
+ { pubkey: mint, isSigner: false, isWritable: false },
+ { pubkey: pk(SYS_PROGRAM), isSigner: false, isWritable: false },
+ { pubkey: tokenProg, isSigner: false, isWritable: false },
+ ],
+ data: Buffer.alloc(0),
+ };
+
+ const { blockhash } = await rpc.getLatestBlockhash();
+ const tx = buildAndSignTx([ix as any], payer as any, blockhash);
+ await sendAndConfirmTx(rpc, tx);
+
+ return ataAddress;
+}
+
+/**
+ * Read the balance of an SPL token account (amount at offset 64 in the account data).
+ */
+export async function getSplTokenBalance(
+ rpc: Rpc,
+ tokenAccount: any,
+): Promise {
+ const info = await rpc.getAccountInfo(tokenAccount);
+ if (!info || !info.data || info.data.length < 72) {
+ throw new Error('SPL token account not found or too small');
+ }
+ return info.data.readBigUInt64LE(64);
+}
+
+// ============================================================================
+// INSTRUCTION CONVERSION
+// ============================================================================
+
+/**
+ * Convert a Kit v2 Instruction to a web3.js v1 TransactionInstruction-
+ * compatible plain object.
+ */
+export function toWeb3Instruction(ix: Instruction): any {
+ return {
+ programId: pk(ix.programAddress as string),
+ keys: (ix.accounts ?? []).map((acc) => ({
+ pubkey: pk(acc.address as string),
+ isSigner:
+ acc.role === AccountRole.READONLY_SIGNER ||
+ acc.role === AccountRole.WRITABLE_SIGNER,
+ isWritable:
+ acc.role === AccountRole.WRITABLE ||
+ acc.role === AccountRole.WRITABLE_SIGNER,
+ })),
+ data: Buffer.from(ix.data ?? new Uint8Array()),
+ };
+}
+
+/** Convert a web3.js v1 PublicKey to a Kit v2 Address. */
+export function toKitAddress(pubkey: any): Address {
+ return address(pubkey.toBase58());
+}
+
+// ============================================================================
+// TRANSACTION HELPERS
+// ============================================================================
+
+/** ComputeBudget SetComputeUnitLimit (variant 2, u32 LE units). */
+function setComputeUnitLimit(units: number): any {
+ const data = Buffer.alloc(5);
+ data.writeUInt8(2, 0);
+ data.writeUInt32LE(units, 1);
+ return {
+ programId: pk('ComputeBudget111111111111111111111111111111'),
+ keys: [] as any[],
+ data,
+ };
+}
+
+export async function sendKitInstructions(
+ rpc: Rpc,
+ ixs: Instruction[],
+ payer: Signer,
+ signers: Signer[] = [],
+): Promise {
+ const web3Ixs = [
+ setComputeUnitLimit(1_000_000),
+ ...ixs.map(toWeb3Instruction),
+ ];
+
+ const { blockhash } = await rpc.getLatestBlockhash();
+ const additionalSigners = dedupeSigner(payer as any, signers as any[]);
+ const tx = buildAndSignTx(
+ web3Ixs as any[],
+ payer as any,
+ blockhash,
+ additionalSigners,
+ );
+ return sendAndConfirmTx(rpc, tx);
+}
+
+export type { Rpc };
diff --git a/js/token-kit/tests/e2e/indexer-e2e.test.ts b/js/token-kit/tests/e2e/indexer-e2e.test.ts
new file mode 100644
index 0000000000..c2e3eb0f0b
--- /dev/null
+++ b/js/token-kit/tests/e2e/indexer-e2e.test.ts
@@ -0,0 +1,107 @@
+/**
+ * E2E tests for PhotonIndexer against a real endpoint.
+ */
+
+import { describe, it, expect, beforeAll } from 'vitest';
+import {
+ getTestRpc,
+ fundAccount,
+ createCompressedMint,
+ mintCompressedTokens,
+ toKitAddress,
+ ensureValidatorRunning,
+ type Signer,
+ type Rpc,
+} from './helpers/setup.js';
+
+import {
+ PhotonIndexer,
+ createLightIndexer,
+ isLightIndexerAvailable,
+} from '../../src/index.js';
+
+const COMPRESSION_RPC = 'http://127.0.0.1:8784';
+const DECIMALS = 2;
+const MINT_AMOUNT = 10_000n;
+
+describe('PhotonIndexer e2e', () => {
+ let rpc: Rpc;
+ let payer: Signer;
+ let mint: any;
+ let mintAuthority: Signer;
+ let indexer: PhotonIndexer;
+
+ beforeAll(async () => {
+ await ensureValidatorRunning();
+ rpc = getTestRpc();
+ payer = await fundAccount(rpc);
+
+ const created = await createCompressedMint(rpc, payer, DECIMALS);
+ mint = created.mint;
+ mintAuthority = created.mintAuthority;
+
+ // Mint tokens so there's something to query
+ await mintCompressedTokens(
+ rpc, payer, mint, payer.publicKey, mintAuthority, MINT_AMOUNT,
+ );
+
+ indexer = new PhotonIndexer(COMPRESSION_RPC);
+ });
+
+ it('isLightIndexerAvailable returns true for running endpoint', async () => {
+ const available = await isLightIndexerAvailable(COMPRESSION_RPC);
+ expect(available).toBe(true);
+ });
+
+ it('isLightIndexerAvailable returns false for invalid endpoint', async () => {
+ const available = await isLightIndexerAvailable(
+ 'http://127.0.0.1:9999',
+ );
+ expect(available).toBe(false);
+ });
+
+ it('getCompressedTokenAccountsByOwner returns token accounts', async () => {
+ const ownerAddr = toKitAddress(payer.publicKey);
+ const mintAddr = toKitAddress(mint);
+
+ const response = await indexer.getCompressedTokenAccountsByOwner(
+ ownerAddr,
+ { mint: mintAddr },
+ );
+
+ expect(response.value.items.length).toBeGreaterThan(0);
+ const account = response.value.items[0];
+ expect(account.token.mint).toBe(mintAddr);
+ expect(account.token.owner).toBe(ownerAddr);
+ expect(account.token.amount).toBe(MINT_AMOUNT);
+ expect(account.account.hash).toBeInstanceOf(Uint8Array);
+ });
+
+ it('getValidityProof returns valid proof', async () => {
+ const ownerAddr = toKitAddress(payer.publicKey);
+ const mintAddr = toKitAddress(mint);
+
+ // First get an account to prove
+ const accountsResponse =
+ await indexer.getCompressedTokenAccountsByOwner(ownerAddr, {
+ mint: mintAddr,
+ });
+ const account = accountsResponse.value.items[0];
+
+ const proofResponse = await indexer.getValidityProof([
+ account.account.hash,
+ ]);
+
+ expect(proofResponse.value).toBeDefined();
+ expect(proofResponse.value.accounts.length).toBeGreaterThan(0);
+ });
+
+ it('createLightIndexer factory works', () => {
+ const client = createLightIndexer(COMPRESSION_RPC);
+ expect(client).toBeDefined();
+ expect(typeof client.getCompressedTokenAccountsByOwner).toBe(
+ 'function',
+ );
+ expect(typeof client.getValidityProof).toBe('function');
+ });
+});
diff --git a/js/token-kit/tests/e2e/load-e2e.test.ts b/js/token-kit/tests/e2e/load-e2e.test.ts
new file mode 100644
index 0000000000..0fdfea8463
--- /dev/null
+++ b/js/token-kit/tests/e2e/load-e2e.test.ts
@@ -0,0 +1,132 @@
+/**
+ * E2E tests for load functions with a real indexer.
+ */
+
+import { describe, it, expect, beforeAll } from 'vitest';
+import {
+ getTestRpc,
+ fundAccount,
+ createCompressedMint,
+ mintCompressedTokens,
+ toKitAddress,
+ ensureValidatorRunning,
+ type Signer,
+ type Rpc,
+} from './helpers/setup.js';
+
+import {
+ PhotonIndexer,
+ loadTokenAccountsForTransfer,
+ loadAllTokenAccounts,
+ loadTokenAccount,
+ needsValidityProof,
+ getOutputTreeInfo,
+ getTreeInfo,
+} from '../../src/index.js';
+
+const COMPRESSION_RPC = 'http://127.0.0.1:8784';
+const DECIMALS = 2;
+const MINT_AMOUNT = 10_000n;
+
+describe('load functions e2e', () => {
+ let rpc: Rpc;
+ let payer: Signer;
+ let mint: any;
+ let mintAuthority: Signer;
+ let indexer: PhotonIndexer;
+
+ beforeAll(async () => {
+ await ensureValidatorRunning();
+ rpc = getTestRpc();
+ payer = await fundAccount(rpc);
+
+ const created = await createCompressedMint(rpc, payer, DECIMALS);
+ mint = created.mint;
+ mintAuthority = created.mintAuthority;
+
+ await mintCompressedTokens(
+ rpc, payer, mint, payer.publicKey, mintAuthority, MINT_AMOUNT,
+ );
+
+ indexer = new PhotonIndexer(COMPRESSION_RPC);
+ });
+
+ it('loadTokenAccountsForTransfer returns accounts + proof', async () => {
+ const ownerAddr = toKitAddress(payer.publicKey);
+ const mintAddr = toKitAddress(mint);
+
+ const loaded = await loadTokenAccountsForTransfer(
+ indexer,
+ ownerAddr,
+ 5_000n,
+ { mint: mintAddr },
+ );
+
+ expect(loaded.inputs.length).toBeGreaterThan(0);
+ expect(loaded.totalAmount).toBeGreaterThanOrEqual(5_000n);
+ expect(loaded.proof).toBeDefined();
+
+ // Verify input structure
+ const input = loaded.inputs[0];
+ expect(input.tokenAccount).toBeDefined();
+ expect(input.merkleContext.tree).toBeDefined();
+ expect(input.merkleContext.queue).toBeDefined();
+ expect(typeof input.merkleContext.leafIndex).toBe('number');
+ });
+
+ it('loadAllTokenAccounts returns all accounts', async () => {
+ const ownerAddr = toKitAddress(payer.publicKey);
+ const mintAddr = toKitAddress(mint);
+
+ const accounts = await loadAllTokenAccounts(indexer, ownerAddr, {
+ mint: mintAddr,
+ });
+
+ expect(accounts.length).toBeGreaterThan(0);
+ expect(accounts[0].token.mint).toBe(mintAddr);
+ });
+
+ it('loadTokenAccount returns single account', async () => {
+ const ownerAddr = toKitAddress(payer.publicKey);
+ const mintAddr = toKitAddress(mint);
+
+ const account = await loadTokenAccount(indexer, ownerAddr, mintAddr);
+
+ expect(account).not.toBeNull();
+ expect(account!.token.mint).toBe(mintAddr);
+ expect(account!.token.owner).toBe(ownerAddr);
+ });
+
+ it('loadTokenAccount returns null for unknown mint', async () => {
+ const ownerAddr = toKitAddress(payer.publicKey);
+ const { address } = await import('@solana/addresses');
+ const fakeMint = address('FakeMint111111111111111111111111111111111111');
+
+ const account = await loadTokenAccount(indexer, ownerAddr, fakeMint);
+ expect(account).toBeNull();
+ });
+
+ it('needsValidityProof / getTreeInfo / getOutputTreeInfo with real data', async () => {
+ const ownerAddr = toKitAddress(payer.publicKey);
+ const mintAddr = toKitAddress(mint);
+
+ const accounts = await loadAllTokenAccounts(indexer, ownerAddr, {
+ mint: mintAddr,
+ });
+ const account = accounts[0];
+
+ // needsValidityProof
+ const needsProof = needsValidityProof(account.account);
+ expect(typeof needsProof).toBe('boolean');
+
+ // getTreeInfo
+ const treeInfo = getTreeInfo(account.account);
+ expect(treeInfo.tree).toBeDefined();
+ expect(treeInfo.queue).toBeDefined();
+
+ // getOutputTreeInfo - should return current or next tree
+ const outputTree = getOutputTreeInfo(treeInfo);
+ expect(outputTree.tree).toBeDefined();
+ expect(outputTree.queue).toBeDefined();
+ });
+});
diff --git a/js/token-kit/tests/e2e/mint-action.test.ts b/js/token-kit/tests/e2e/mint-action.test.ts
new file mode 100644
index 0000000000..a8897829de
--- /dev/null
+++ b/js/token-kit/tests/e2e/mint-action.test.ts
@@ -0,0 +1,241 @@
+/**
+ * E2E tests for MintAction instruction (compressed mint management).
+ *
+ * Uses V3/stateless.js for setup (mints, mint interface, proofs).
+ * Uses token-kit's createMintActionInstruction to build instructions.
+ * Verifies results on-chain.
+ *
+ * Requires a running local validator + indexer + prover.
+ */
+
+import { describe, it, expect, beforeAll } from 'vitest';
+import type { Address } from '@solana/addresses';
+import { AccountRole } from '@solana/instructions';
+
+import {
+ getTestRpc,
+ fundAccount,
+ createTestMint,
+ createTestMintWithMetadata,
+ sendKitInstructions,
+ toKitAddress,
+ ensureValidatorRunning,
+ type Signer,
+ type Rpc,
+} from './helpers/setup.js';
+
+import {
+ getMintInterface,
+ updateMintAuthority,
+ updateMetadataField,
+} from '@lightprotocol/compressed-token';
+
+import {
+ getOutputQueue,
+} from '@lightprotocol/stateless.js';
+
+import {
+ createMintActionInstruction,
+ DISCRIMINATOR,
+ type MintActionInstructionData,
+} from '../../src/index.js';
+
+const DECIMALS = 2;
+
+describe('MintAction e2e', () => {
+ let rpc: Rpc;
+ let payer: Signer;
+
+ beforeAll(async () => {
+ await ensureValidatorRunning();
+ rpc = getTestRpc();
+ payer = await fundAccount(rpc);
+ });
+
+ it('update mint authority on decompressed mint', async () => {
+ // Setup: create decompressed CToken mint
+ const { mint, mintAuthority, mintAddress } = await createTestMint(
+ rpc, payer, DECIMALS,
+ );
+
+ // Get mint interface from V3 for merkle context
+ const mintInterface = await getMintInterface(
+ rpc, mint, undefined, undefined,
+ );
+ expect(mintInterface.merkleContext).toBeDefined();
+ expect(mintInterface.mintContext?.cmintDecompressed).toBe(true);
+
+ const merkleContext = mintInterface.merkleContext!;
+ const outputQueue = getOutputQueue(merkleContext);
+
+ // Build MintAction instruction via token-kit
+ const newAuthority = await fundAccount(rpc);
+ const newAuthorityBytes = (newAuthority as any).publicKey.toBytes();
+
+ const data: MintActionInstructionData = {
+ leafIndex: merkleContext.leafIndex,
+ proveByIndex: true,
+ rootIndex: 0, // No proof needed for decompressed mints
+ maxTopUp: 0,
+ createMint: null,
+ actions: [{
+ type: 'UpdateMintAuthority',
+ newAuthority: new Uint8Array(newAuthorityBytes),
+ }],
+ proof: null, // No proof for decompressed mints
+ cpiContext: null,
+ mint: null, // Program reads from CMint account
+ };
+
+ const ix = createMintActionInstruction({
+ authority: toKitAddress((mintAuthority as any).publicKey),
+ feePayer: toKitAddress((payer as any).publicKey),
+ outOutputQueue: toKitAddress(outputQueue),
+ merkleTree: toKitAddress(merkleContext.treeInfo.tree),
+ cmint: mintAddress,
+ data,
+ packedAccounts: [
+ // in_output_queue (required when createMint is null)
+ {
+ address: toKitAddress(merkleContext.treeInfo.queue),
+ role: AccountRole.WRITABLE,
+ },
+ ],
+ });
+
+ expect(ix.data[0]).toBe(DISCRIMINATOR.MINT_ACTION);
+
+ // Send on-chain
+ await sendKitInstructions(rpc, [ix], payer, [mintAuthority]);
+
+ // Verify: re-read mint interface, check authority changed
+ const updatedMint = await getMintInterface(
+ rpc, mint, undefined, undefined,
+ );
+ const newAuth = (newAuthority as any).publicKey;
+ expect(updatedMint.mint.mintAuthority?.equals(newAuth)).toBe(true);
+ });
+
+ it('update metadata field on decompressed mint', async () => {
+ const { mint, mintAuthority } = await createTestMintWithMetadata(
+ rpc, payer, DECIMALS,
+ );
+
+ // First verify mint has metadata
+ const mintInterface = await getMintInterface(
+ rpc, mint, undefined, undefined,
+ );
+ expect(mintInterface.tokenMetadata).toBeDefined();
+ const originalName = mintInterface.tokenMetadata!.name;
+
+ // Use V3's updateMetadataField to update the name
+ await updateMetadataField(
+ rpc,
+ payer as any,
+ mint,
+ mintAuthority as any,
+ 'name',
+ 'Updated Name',
+ );
+
+ // Verify: re-read mint, check metadata field changed
+ const updatedMint = await getMintInterface(
+ rpc, mint, undefined, undefined,
+ );
+ expect(updatedMint.tokenMetadata!.name).toBe('Updated Name');
+ expect(updatedMint.tokenMetadata!.name).not.toBe(originalName);
+ });
+
+ it('update mint authority via V3 action (reference test)', async () => {
+ // This test verifies the V3 action works end-to-end,
+ // establishing the baseline for token-kit instruction tests.
+ const { mint, mintAuthority } = await createTestMint(
+ rpc, payer, DECIMALS,
+ );
+ const newAuthority = await fundAccount(rpc);
+
+ await updateMintAuthority(
+ rpc,
+ payer as any,
+ mint,
+ mintAuthority as any,
+ (newAuthority as any).publicKey,
+ );
+
+ const updatedMint = await getMintInterface(
+ rpc, mint, undefined, undefined,
+ );
+ const newAuth = (newAuthority as any).publicKey;
+ expect(updatedMint.mint.mintAuthority?.equals(newAuth)).toBe(true);
+ });
+
+ it('revoke mint authority (set to null)', async () => {
+ const { mint, mintAuthority, mintAddress } = await createTestMint(
+ rpc, payer, DECIMALS,
+ );
+
+ const mintInterface = await getMintInterface(
+ rpc, mint, undefined, undefined,
+ );
+ const merkleContext = mintInterface.merkleContext!;
+ const outputQueue = getOutputQueue(merkleContext);
+
+ // Build instruction to revoke (set authority to null)
+ const data: MintActionInstructionData = {
+ leafIndex: merkleContext.leafIndex,
+ proveByIndex: true,
+ rootIndex: 0,
+ maxTopUp: 0,
+ createMint: null,
+ actions: [{
+ type: 'UpdateMintAuthority',
+ newAuthority: null,
+ }],
+ proof: null,
+ cpiContext: null,
+ mint: null,
+ };
+
+ const ix = createMintActionInstruction({
+ authority: toKitAddress((mintAuthority as any).publicKey),
+ feePayer: toKitAddress((payer as any).publicKey),
+ outOutputQueue: toKitAddress(outputQueue),
+ merkleTree: toKitAddress(merkleContext.treeInfo.tree),
+ cmint: mintAddress,
+ data,
+ packedAccounts: [{
+ address: toKitAddress(merkleContext.treeInfo.queue),
+ role: AccountRole.WRITABLE,
+ }],
+ });
+
+ await sendKitInstructions(rpc, [ix], payer, [mintAuthority]);
+
+ // Verify: mint authority is now null
+ const updatedMint = await getMintInterface(
+ rpc, mint, undefined, undefined,
+ );
+ expect(updatedMint.mint.mintAuthority).toBeNull();
+ });
+
+ it('update metadata symbol field', async () => {
+ const { mint, mintAuthority } = await createTestMintWithMetadata(
+ rpc, payer, DECIMALS,
+ );
+
+ // Use V3 action for symbol update
+ await updateMetadataField(
+ rpc,
+ payer as any,
+ mint,
+ mintAuthority as any,
+ 'symbol',
+ 'NEWSYM',
+ );
+
+ const updatedMint = await getMintInterface(
+ rpc, mint, undefined, undefined,
+ );
+ expect(updatedMint.tokenMetadata!.symbol).toBe('NEWSYM');
+ });
+});
diff --git a/js/token-kit/tests/e2e/mint-burn.test.ts b/js/token-kit/tests/e2e/mint-burn.test.ts
new file mode 100644
index 0000000000..873b13d5d4
--- /dev/null
+++ b/js/token-kit/tests/e2e/mint-burn.test.ts
@@ -0,0 +1,174 @@
+/**
+ * E2E tests for Kit v2 mint-to and burn instructions against CToken accounts.
+ */
+
+import { describe, it, expect, beforeAll } from 'vitest';
+import {
+ getTestRpc,
+ fundAccount,
+ createTestMint,
+ createCTokenWithBalance,
+ createCTokenAccount,
+ sendKitInstructions,
+ getCTokenBalance,
+ toKitAddress,
+ ensureValidatorRunning,
+ type Signer,
+ type Rpc,
+} from './helpers/setup.js';
+
+import {
+ createMintToInstruction,
+ createMintToCheckedInstruction,
+ createBurnInstruction,
+ createBurnCheckedInstruction,
+} from '../../src/index.js';
+
+const DECIMALS = 2;
+const MINT_AMOUNT = 10_000n;
+
+describe('mint-to e2e (CToken)', () => {
+ let rpc: Rpc;
+ let payer: Signer;
+ let mint: any;
+ let mintAuthority: Signer;
+ let mintAddress: string;
+
+ beforeAll(async () => {
+ await ensureValidatorRunning();
+ rpc = getTestRpc();
+ payer = await fundAccount(rpc);
+
+ const created = await createTestMint(rpc, payer, DECIMALS);
+ mint = created.mint;
+ mintAuthority = created.mintAuthority;
+ mintAddress = created.mintAddress;
+ });
+
+ it('mintTo: mint tokens to CToken account and verify balance', async () => {
+ const recipient = await fundAccount(rpc);
+ const { ctokenPubkey, ctokenAddress } = await createCTokenAccount(
+ rpc, payer, recipient, mint,
+ );
+
+ const authorityAddr = toKitAddress(mintAuthority.publicKey);
+
+ const ix = createMintToInstruction({
+ mint: mintAddress,
+ tokenAccount: ctokenAddress,
+ mintAuthority: authorityAddr,
+ amount: MINT_AMOUNT,
+ });
+
+ await sendKitInstructions(rpc, [ix], payer, [mintAuthority]);
+
+ const balance = await getCTokenBalance(rpc, ctokenPubkey);
+ expect(balance).toBe(MINT_AMOUNT);
+ });
+
+ it('mintTo checked: with decimals', async () => {
+ const recipient = await fundAccount(rpc);
+ const { ctokenPubkey, ctokenAddress } = await createCTokenAccount(
+ rpc, payer, recipient, mint,
+ );
+
+ const authorityAddr = toKitAddress(mintAuthority.publicKey);
+
+ const ix = createMintToCheckedInstruction({
+ mint: mintAddress,
+ tokenAccount: ctokenAddress,
+ mintAuthority: authorityAddr,
+ amount: 5_000n,
+ decimals: DECIMALS,
+ });
+
+ await sendKitInstructions(rpc, [ix], payer, [mintAuthority]);
+
+ const balance = await getCTokenBalance(rpc, ctokenPubkey);
+ expect(balance).toBe(5_000n);
+ });
+});
+
+describe('burn e2e (CToken)', () => {
+ let rpc: Rpc;
+ let payer: Signer;
+ let mint: any;
+ let mintAuthority: Signer;
+ let mintAddress: string;
+
+ beforeAll(async () => {
+ await ensureValidatorRunning();
+ rpc = getTestRpc();
+ payer = await fundAccount(rpc);
+
+ const created = await createTestMint(rpc, payer, DECIMALS);
+ mint = created.mint;
+ mintAuthority = created.mintAuthority;
+ mintAddress = created.mintAddress;
+ });
+
+ it('burn: reduce balance', async () => {
+ const holder = await fundAccount(rpc);
+ const { ctokenPubkey, ctokenAddress } = await createCTokenWithBalance(
+ rpc, payer, mint, holder, mintAuthority, MINT_AMOUNT,
+ );
+
+ const holderAddr = toKitAddress(holder.publicKey);
+ const burnAmount = 3_000n;
+
+ const ix = createBurnInstruction({
+ tokenAccount: ctokenAddress,
+ mint: mintAddress,
+ authority: holderAddr,
+ amount: burnAmount,
+ });
+
+ await sendKitInstructions(rpc, [ix], holder);
+
+ const balance = await getCTokenBalance(rpc, ctokenPubkey);
+ expect(balance).toBe(MINT_AMOUNT - burnAmount);
+ });
+
+ it('burn checked: with decimals', async () => {
+ const holder = await fundAccount(rpc);
+ const { ctokenPubkey, ctokenAddress } = await createCTokenWithBalance(
+ rpc, payer, mint, holder, mintAuthority, MINT_AMOUNT,
+ );
+
+ const holderAddr = toKitAddress(holder.publicKey);
+
+ const ix = createBurnCheckedInstruction({
+ tokenAccount: ctokenAddress,
+ mint: mintAddress,
+ authority: holderAddr,
+ amount: 2_000n,
+ decimals: DECIMALS,
+ });
+
+ await sendKitInstructions(rpc, [ix], holder);
+
+ const balance = await getCTokenBalance(rpc, ctokenPubkey);
+ expect(balance).toBe(MINT_AMOUNT - 2_000n);
+ });
+
+ it('burn full amount', async () => {
+ const holder = await fundAccount(rpc);
+ const { ctokenPubkey, ctokenAddress } = await createCTokenWithBalance(
+ rpc, payer, mint, holder, mintAuthority, MINT_AMOUNT,
+ );
+
+ const holderAddr = toKitAddress(holder.publicKey);
+
+ const ix = createBurnInstruction({
+ tokenAccount: ctokenAddress,
+ mint: mintAddress,
+ authority: holderAddr,
+ amount: MINT_AMOUNT,
+ });
+
+ await sendKitInstructions(rpc, [ix], holder);
+
+ const balance = await getCTokenBalance(rpc, ctokenPubkey);
+ expect(balance).toBe(0n);
+ });
+});
diff --git a/js/token-kit/tests/e2e/smoke.test.ts b/js/token-kit/tests/e2e/smoke.test.ts
new file mode 100644
index 0000000000..b0bba97f92
--- /dev/null
+++ b/js/token-kit/tests/e2e/smoke.test.ts
@@ -0,0 +1,93 @@
+/**
+ * Smoke test: proves the full Kit v2 instruction → on-chain CToken pipeline works.
+ *
+ * 1. Create decompressed CToken mint (legacy SDK)
+ * 2. Create CToken accounts and mint tokens (legacy SDK)
+ * 3. Build transfer instruction (Kit v2 createTransferInstruction)
+ * 4. Convert to web3.js v1 instruction, build tx, send & confirm
+ * 5. Verify recipient CToken balance on-chain
+ */
+
+import { describe, it, expect, beforeAll } from 'vitest';
+import {
+ getTestRpc,
+ fundAccount,
+ createTestMint,
+ createCTokenWithBalance,
+ createCTokenAccount,
+ sendKitInstructions,
+ getCTokenBalance,
+ toKitAddress,
+ ensureValidatorRunning,
+ type Signer,
+ type Rpc,
+} from './helpers/setup.js';
+
+import { createTransferInstruction } from '../../src/index.js';
+
+const DECIMALS = 2;
+const MINT_AMOUNT = 10_000n;
+const TRANSFER_AMOUNT = 3_000n;
+
+describe('Smoke test: Kit v2 transfer on-chain CToken', () => {
+ let rpc: Rpc;
+ let payer: Signer;
+ let recipient: Signer;
+ let mint: any;
+ let mintAuthority: Signer;
+ let payerCtoken: any;
+ let payerCtokenAddress: string;
+ let recipientCtoken: any;
+ let recipientCtokenAddress: string;
+
+ beforeAll(async () => {
+ await ensureValidatorRunning();
+ rpc = getTestRpc();
+ payer = await fundAccount(rpc);
+ recipient = await fundAccount(rpc);
+
+ // Create decompressed CToken mint
+ const created = await createTestMint(rpc, payer, DECIMALS);
+ mint = created.mint;
+ mintAuthority = created.mintAuthority;
+
+ // Create CToken accounts and mint to payer
+ const payerResult = await createCTokenWithBalance(
+ rpc, payer, mint, payer, mintAuthority, MINT_AMOUNT,
+ );
+ payerCtoken = payerResult.ctokenPubkey;
+ payerCtokenAddress = payerResult.ctokenAddress;
+
+ // Create empty CToken account for recipient
+ const recipientResult = await createCTokenAccount(
+ rpc, payer, recipient, mint,
+ );
+ recipientCtoken = recipientResult.ctokenPubkey;
+ recipientCtokenAddress = recipientResult.ctokenAddress;
+ });
+
+ it('should transfer CTokens using Kit v2 instruction builder', async () => {
+ // Verify sender has tokens
+ const senderBalancePre = await getCTokenBalance(rpc, payerCtoken);
+ expect(senderBalancePre).toBe(MINT_AMOUNT);
+
+ // Build Kit v2 transfer instruction
+ const payerAddr = toKitAddress(payer.publicKey);
+ const ix = createTransferInstruction({
+ source: payerCtokenAddress,
+ destination: recipientCtokenAddress,
+ amount: TRANSFER_AMOUNT,
+ authority: payerAddr,
+ });
+
+ // Send through legacy pipeline
+ await sendKitInstructions(rpc, [ix], payer);
+
+ // Verify balances on-chain
+ const senderBalancePost = await getCTokenBalance(rpc, payerCtoken);
+ const recipientBalance = await getCTokenBalance(rpc, recipientCtoken);
+
+ expect(senderBalancePost).toBe(MINT_AMOUNT - TRANSFER_AMOUNT);
+ expect(recipientBalance).toBe(TRANSFER_AMOUNT);
+ });
+});
diff --git a/js/token-kit/tests/e2e/transfer-interface.test.ts b/js/token-kit/tests/e2e/transfer-interface.test.ts
new file mode 100644
index 0000000000..82f48c6e7a
--- /dev/null
+++ b/js/token-kit/tests/e2e/transfer-interface.test.ts
@@ -0,0 +1,120 @@
+/**
+ * E2E tests for TransferInterface (auto-routing) and requiresCompression.
+ *
+ * Tests light-to-light routing and cross-boundary detection.
+ *
+ * Requires a running local validator + indexer + prover.
+ */
+
+import { describe, it, expect, beforeAll } from 'vitest';
+import {
+ getTestRpc,
+ fundAccount,
+ createTestMint,
+ createCTokenWithBalance,
+ createCTokenAccount,
+ sendKitInstructions,
+ getCTokenBalance,
+ toKitAddress,
+ ensureValidatorRunning,
+ type Signer,
+ type Rpc,
+} from './helpers/setup.js';
+
+import {
+ createTransferInterfaceInstruction,
+ requiresCompression,
+ LIGHT_TOKEN_PROGRAM_ID,
+ SPL_TOKEN_PROGRAM_ID,
+} from '../../src/index.js';
+
+const DECIMALS = 2;
+const MINT_AMOUNT = 10_000n;
+
+describe('TransferInterface e2e', () => {
+ let rpc: Rpc;
+ let payer: Signer;
+ let recipient: Signer;
+ let mint: any;
+ let mintAuthority: Signer;
+ let mintAddress: string;
+ let payerCtoken: any;
+ let payerCtokenAddress: string;
+ let recipientCtoken: any;
+ let recipientCtokenAddress: string;
+
+ beforeAll(async () => {
+ await ensureValidatorRunning();
+ rpc = getTestRpc();
+ payer = await fundAccount(rpc);
+ recipient = await fundAccount(rpc);
+
+ const created = await createTestMint(rpc, payer, DECIMALS);
+ mint = created.mint;
+ mintAuthority = created.mintAuthority;
+ mintAddress = created.mintAddress;
+
+ // Create CToken accounts with balance
+ const payerResult = await createCTokenWithBalance(
+ rpc, payer, mint, payer, mintAuthority, MINT_AMOUNT,
+ );
+ payerCtoken = payerResult.ctokenPubkey;
+ payerCtokenAddress = payerResult.ctokenAddress;
+
+ const recipientResult = await createCTokenAccount(
+ rpc, payer, recipient, mint,
+ );
+ recipientCtoken = recipientResult.ctokenPubkey;
+ recipientCtokenAddress = recipientResult.ctokenAddress;
+ });
+
+ it('light-to-light transfer via interface', async () => {
+ const transferAmount = 1_500n;
+ const payerAddr = toKitAddress(payer.publicKey);
+
+ const result = createTransferInterfaceInstruction({
+ sourceOwner: LIGHT_TOKEN_PROGRAM_ID,
+ destOwner: LIGHT_TOKEN_PROGRAM_ID,
+ source: payerCtokenAddress,
+ destination: recipientCtokenAddress,
+ amount: transferAmount,
+ authority: payerAddr,
+ mint: mintAddress,
+ });
+
+ expect(result.transferType).toBe('light-to-light');
+ expect(result.instructions).toHaveLength(1);
+
+ // Send on-chain
+ await sendKitInstructions(rpc, result.instructions, payer);
+
+ // Verify balances
+ const senderBalance = await getCTokenBalance(rpc, payerCtoken);
+ const recipientBalance = await getCTokenBalance(rpc, recipientCtoken);
+
+ expect(senderBalance).toBe(MINT_AMOUNT - transferAmount);
+ expect(recipientBalance).toBe(transferAmount);
+ });
+
+ it('requiresCompression detection', () => {
+ // Light-to-light: no compression needed
+ expect(
+ requiresCompression(LIGHT_TOKEN_PROGRAM_ID, LIGHT_TOKEN_PROGRAM_ID),
+ ).toBe(false);
+
+ // Light-to-SPL: needs compression
+ expect(
+ requiresCompression(LIGHT_TOKEN_PROGRAM_ID, SPL_TOKEN_PROGRAM_ID),
+ ).toBe(true);
+
+ // SPL-to-Light: needs compression
+ expect(
+ requiresCompression(SPL_TOKEN_PROGRAM_ID, LIGHT_TOKEN_PROGRAM_ID),
+ ).toBe(true);
+
+ // SPL-to-SPL: no compression needed
+ expect(
+ requiresCompression(SPL_TOKEN_PROGRAM_ID, SPL_TOKEN_PROGRAM_ID),
+ ).toBe(false);
+ });
+});
diff --git a/js/token-kit/tests/e2e/transfer.test.ts b/js/token-kit/tests/e2e/transfer.test.ts
new file mode 100644
index 0000000000..f6d2b580b6
--- /dev/null
+++ b/js/token-kit/tests/e2e/transfer.test.ts
@@ -0,0 +1,184 @@
+/**
+ * E2E tests for Kit v2 transfer instructions against CToken accounts.
+ */
+
+import { describe, it, expect, beforeAll } from 'vitest';
+import {
+ getTestRpc,
+ fundAccount,
+ createTestMint,
+ createCTokenWithBalance,
+ createCTokenAccount,
+ sendKitInstructions,
+ getCTokenBalance,
+ toKitAddress,
+ ensureValidatorRunning,
+ type Signer,
+ type Rpc,
+} from './helpers/setup.js';
+
+import {
+ createTransferInstruction,
+ createTransferCheckedInstruction,
+} from '../../src/index.js';
+
+const DECIMALS = 2;
+const MINT_AMOUNT = 10_000n;
+
+describe('transfer e2e (CToken)', () => {
+ let rpc: Rpc;
+ let payer: Signer;
+ let mint: any;
+ let mintAuthority: Signer;
+ let mintAddress: string;
+
+ beforeAll(async () => {
+ await ensureValidatorRunning();
+ rpc = getTestRpc();
+ payer = await fundAccount(rpc);
+
+ const created = await createTestMint(rpc, payer, DECIMALS);
+ mint = created.mint;
+ mintAuthority = created.mintAuthority;
+ mintAddress = created.mintAddress;
+ });
+
+ it('partial transfer creates change in source account', async () => {
+ const bob = await fundAccount(rpc);
+ const { ctokenPubkey: bobCtoken, ctokenAddress: bobCtokenAddr } =
+ await createCTokenWithBalance(rpc, payer, mint, bob, mintAuthority, MINT_AMOUNT);
+
+ const { ctokenPubkey: payerCtoken, ctokenAddress: payerCtokenAddr } =
+ await createCTokenAccount(rpc, payer, payer, mint);
+
+ const transferAmount = 3_000n;
+ const bobAddr = toKitAddress(bob.publicKey);
+
+ const ix = createTransferInstruction({
+ source: bobCtokenAddr,
+ destination: payerCtokenAddr,
+ amount: transferAmount,
+ authority: bobAddr,
+ });
+
+ await sendKitInstructions(rpc, [ix], bob);
+
+ const bobBalance = await getCTokenBalance(rpc, bobCtoken);
+ const payerBalance = await getCTokenBalance(rpc, payerCtoken);
+
+ expect(bobBalance).toBe(MINT_AMOUNT - transferAmount);
+ expect(payerBalance).toBe(transferAmount);
+ });
+
+ it('full-amount transfer', async () => {
+ const alice = await fundAccount(rpc);
+ const charlie = await fundAccount(rpc);
+
+ const { ctokenPubkey: aliceCtoken, ctokenAddress: aliceCtokenAddr } =
+ await createCTokenWithBalance(rpc, payer, mint, alice, mintAuthority, MINT_AMOUNT);
+
+ const { ctokenPubkey: charlieCtoken, ctokenAddress: charlieCtokenAddr } =
+ await createCTokenAccount(rpc, payer, charlie, mint);
+
+ const aliceAddr = toKitAddress(alice.publicKey);
+
+ const ix = createTransferInstruction({
+ source: aliceCtokenAddr,
+ destination: charlieCtokenAddr,
+ amount: MINT_AMOUNT,
+ authority: aliceAddr,
+ });
+
+ await sendKitInstructions(rpc, [ix], alice);
+
+ const aliceBalance = await getCTokenBalance(rpc, aliceCtoken);
+ const charlieBalance = await getCTokenBalance(rpc, charlieCtoken);
+
+ expect(aliceBalance).toBe(0n);
+ expect(charlieBalance).toBe(MINT_AMOUNT);
+ });
+
+ it('transfer checked with decimals', async () => {
+ const sender = await fundAccount(rpc);
+ const receiver = await fundAccount(rpc);
+
+ const { ctokenPubkey: senderCtoken, ctokenAddress: senderCtokenAddr } =
+ await createCTokenWithBalance(rpc, payer, mint, sender, mintAuthority, MINT_AMOUNT);
+
+ const { ctokenPubkey: receiverCtoken, ctokenAddress: receiverCtokenAddr } =
+ await createCTokenAccount(rpc, payer, receiver, mint);
+
+ const senderAddr = toKitAddress(sender.publicKey);
+
+ const ix = createTransferCheckedInstruction({
+ source: senderCtokenAddr,
+ destination: receiverCtokenAddr,
+ mint: mintAddress,
+ amount: 5_000n,
+ authority: senderAddr,
+ decimals: DECIMALS,
+ });
+
+ await sendKitInstructions(rpc, [ix], sender);
+
+ const receiverBalance = await getCTokenBalance(rpc, receiverCtoken);
+ expect(receiverBalance).toBe(5_000n);
+ });
+
+ it('transfer to self', async () => {
+ const user = await fundAccount(rpc);
+ const { ctokenPubkey: userCtoken, ctokenAddress: userCtokenAddr } =
+ await createCTokenWithBalance(rpc, payer, mint, user, mintAuthority, MINT_AMOUNT);
+
+ const userAddr = toKitAddress(user.publicKey);
+
+ const ix = createTransferInstruction({
+ source: userCtokenAddr,
+ destination: userCtokenAddr,
+ amount: 1_000n,
+ authority: userAddr,
+ });
+
+ await sendKitInstructions(rpc, [ix], user);
+
+ const balance = await getCTokenBalance(rpc, userCtoken);
+ expect(balance).toBe(MINT_AMOUNT);
+ });
+
+ it('multiple sequential transfers', async () => {
+ const sender = await fundAccount(rpc);
+ const receiver = await fundAccount(rpc);
+
+ const { ctokenPubkey: senderCtoken, ctokenAddress: senderCtokenAddr } =
+ await createCTokenWithBalance(rpc, payer, mint, sender, mintAuthority, MINT_AMOUNT);
+
+ const { ctokenPubkey: receiverCtoken, ctokenAddress: receiverCtokenAddr } =
+ await createCTokenAccount(rpc, payer, receiver, mint);
+
+ const senderAddr = toKitAddress(sender.publicKey);
+
+ // First transfer
+ const ix1 = createTransferInstruction({
+ source: senderCtokenAddr,
+ destination: receiverCtokenAddr,
+ amount: 2_000n,
+ authority: senderAddr,
+ });
+ await sendKitInstructions(rpc, [ix1], sender);
+
+ // Second transfer
+ const ix2 = createTransferInstruction({
+ source: senderCtokenAddr,
+ destination: receiverCtokenAddr,
+ amount: 3_000n,
+ authority: senderAddr,
+ });
+ await sendKitInstructions(rpc, [ix2], sender);
+
+ const senderBalance = await getCTokenBalance(rpc, senderCtoken);
+ const receiverBalance = await getCTokenBalance(rpc, receiverCtoken);
+
+ expect(senderBalance).toBe(MINT_AMOUNT - 5_000n);
+ expect(receiverBalance).toBe(5_000n);
+ });
+});
diff --git a/js/token-kit/tests/e2e/transfer2.test.ts b/js/token-kit/tests/e2e/transfer2.test.ts
new file mode 100644
index 0000000000..b6ade67c5a
--- /dev/null
+++ b/js/token-kit/tests/e2e/transfer2.test.ts
@@ -0,0 +1,204 @@
+/**
+ * E2E tests for Transfer2 instruction (compressed token transfers).
+ *
+ * Uses V3/stateless.js for setup (mints, compressed token accounts).
+ * Uses token-kit's buildCompressedTransfer + createTransfer2Instruction for operations.
+ * Verifies results via indexer.
+ *
+ * Requires a running local validator + indexer + prover.
+ */
+
+import { describe, it, expect, beforeAll } from 'vitest';
+import {
+ getTestRpc,
+ fundAccount,
+ createCompressedMint,
+ mintCompressedTokens,
+ toKitAddress,
+ sendKitInstructions,
+ getCompressedBalance,
+ ensureValidatorRunning,
+ type Signer,
+ type Rpc,
+} from './helpers/setup.js';
+
+import {
+ PhotonIndexer,
+ buildCompressedTransfer,
+ DISCRIMINATOR,
+} from '../../src/index.js';
+
+const COMPRESSION_RPC = 'http://127.0.0.1:8784';
+const DECIMALS = 2;
+
+describe('Transfer2 e2e (compressed)', () => {
+ let rpc: Rpc;
+ let payer: Signer;
+ let mint: any;
+ let mintAuthority: Signer;
+ let indexer: PhotonIndexer;
+
+ beforeAll(async () => {
+ await ensureValidatorRunning();
+ rpc = getTestRpc();
+ payer = await fundAccount(rpc);
+
+ const created = await createCompressedMint(rpc, payer, DECIMALS);
+ mint = created.mint;
+ mintAuthority = created.mintAuthority;
+
+ // Mint initial tokens
+ await mintCompressedTokens(
+ rpc, payer, mint, payer.publicKey, mintAuthority, 10_000,
+ );
+
+ indexer = new PhotonIndexer(COMPRESSION_RPC);
+ });
+
+ it('compressed transfer: send on-chain and verify via indexer', async () => {
+ const recipient = await fundAccount(rpc);
+ const ownerAddr = toKitAddress(payer.publicKey);
+ const recipientAddr = toKitAddress(recipient.publicKey);
+ const mintAddr = toKitAddress(mint);
+
+ const transferAmount = 2_000n;
+
+ const balanceBefore = await getCompressedBalance(
+ rpc, payer.publicKey, mint,
+ );
+
+ const result = await buildCompressedTransfer({ indexer,
+ owner: ownerAddr,
+ mint: mintAddr,
+ amount: transferAmount,
+ recipientOwner: recipientAddr,
+ feePayer: ownerAddr,
+ });
+
+ // Send on-chain
+ await sendKitInstructions(rpc, [result.instruction], payer);
+
+ // Verify balances via indexer
+ const senderBalance = await getCompressedBalance(
+ rpc, payer.publicKey, mint,
+ );
+ const recipientBalance = await getCompressedBalance(
+ rpc, recipient.publicKey, mint,
+ );
+
+ expect(recipientBalance).toBe(transferAmount);
+ expect(senderBalance).toBe(balanceBefore - transferAmount);
+ });
+
+ it('transfer with change: sender gets remainder back', async () => {
+ const recipient = await fundAccount(rpc);
+ const ownerAddr = toKitAddress(payer.publicKey);
+ const recipientAddr = toKitAddress(recipient.publicKey);
+ const mintAddr = toKitAddress(mint);
+
+ const balanceBefore = await getCompressedBalance(
+ rpc, payer.publicKey, mint,
+ );
+ // Transfer less than total to force change output
+ const transferAmount = 300n;
+
+ const result = await buildCompressedTransfer({ indexer,
+ owner: ownerAddr,
+ mint: mintAddr,
+ amount: transferAmount,
+ recipientOwner: recipientAddr,
+ feePayer: ownerAddr,
+ });
+
+ expect(result.instruction.data[0]).toBe(DISCRIMINATOR.TRANSFER2);
+ expect(result.totalInputAmount).toBeGreaterThan(transferAmount);
+
+ await sendKitInstructions(rpc, [result.instruction], payer);
+
+ const senderBalance = await getCompressedBalance(
+ rpc, payer.publicKey, mint,
+ );
+ const recipientBalance = await getCompressedBalance(
+ rpc, recipient.publicKey, mint,
+ );
+
+ expect(recipientBalance).toBe(transferAmount);
+ expect(senderBalance).toBe(balanceBefore - transferAmount);
+ });
+
+ it('multi-input transfer: consumes multiple compressed accounts', async () => {
+ // Create a new mint for isolation
+ const multiPayer = await fundAccount(rpc);
+ const multiCreated = await createCompressedMint(
+ rpc, multiPayer, DECIMALS,
+ );
+ const multiMint = multiCreated.mint;
+ const multiAuthority = multiCreated.mintAuthority;
+
+ // Mint 100 tokens 5 times → 5 separate compressed accounts
+ for (let i = 0; i < 5; i++) {
+ await mintCompressedTokens(
+ rpc,
+ multiPayer,
+ multiMint,
+ multiPayer.publicKey,
+ multiAuthority,
+ 100,
+ );
+ }
+
+ const ownerAddr = toKitAddress(multiPayer.publicKey);
+ const recipient = await fundAccount(rpc);
+ const recipientAddr = toKitAddress(recipient.publicKey);
+ const mintAddr = toKitAddress(multiMint);
+
+ // Transfer 400 → needs at least 4 inputs
+ const transferAmount = 400n;
+
+ const result = await buildCompressedTransfer({ indexer,
+ owner: ownerAddr,
+ mint: mintAddr,
+ amount: transferAmount,
+ recipientOwner: recipientAddr,
+ feePayer: ownerAddr,
+ maxInputs: 5,
+ });
+
+ expect(result.inputs.length).toBeGreaterThanOrEqual(4);
+
+ await sendKitInstructions(rpc, [result.instruction], multiPayer);
+
+ const recipientBalance = await getCompressedBalance(
+ rpc, recipient.publicKey, multiMint,
+ );
+ const senderBalance = await getCompressedBalance(
+ rpc, multiPayer.publicKey, multiMint,
+ );
+
+ expect(recipientBalance).toBe(transferAmount);
+ expect(senderBalance).toBe(500n - transferAmount);
+ });
+
+ it('Transfer2 instruction has correct discriminator and account structure', async () => {
+ const recipient = await fundAccount(rpc);
+ const ownerAddr = toKitAddress(payer.publicKey);
+ const recipientAddr = toKitAddress(recipient.publicKey);
+ const mintAddr = toKitAddress(mint);
+
+ const result = await buildCompressedTransfer({ indexer,
+ owner: ownerAddr,
+ mint: mintAddr,
+ amount: 100n,
+ recipientOwner: recipientAddr,
+ feePayer: ownerAddr,
+ });
+
+ // Verify Transfer2 instruction structure
+ const ix = result.instruction;
+ expect(ix.data[0]).toBe(DISCRIMINATOR.TRANSFER2);
+ // Path B: at least 7 system accounts + packed accounts
+ expect(ix.accounts.length).toBeGreaterThanOrEqual(7);
+ expect(result.proof).toBeDefined();
+ expect(result.proof.proof).toBeDefined();
+ });
+});
diff --git a/js/token-kit/tests/e2e/wrap-unwrap.test.ts b/js/token-kit/tests/e2e/wrap-unwrap.test.ts
new file mode 100644
index 0000000000..8ae48bbeea
--- /dev/null
+++ b/js/token-kit/tests/e2e/wrap-unwrap.test.ts
@@ -0,0 +1,300 @@
+/**
+ * E2E tests for wrap (SPL → Light Token) and unwrap (Light Token → SPL).
+ *
+ * Setup uses V1 createMint (creates SPL mint + SPL interface PDA) with
+ * V1 mintTo + decompress to bootstrap SPL tokens.
+ *
+ * Uses token-kit's createWrapInstruction / createUnwrapInstruction for the
+ * actual wrap/unwrap operations, sent via the sendKitInstructions bridge.
+ *
+ * Requires a running local validator + indexer + prover.
+ */
+
+import { describe, it, expect, beforeAll } from 'vitest';
+import {
+ getTestRpc,
+ fundAccount,
+ toKitAddress,
+ sendKitInstructions,
+ createSplAssociatedTokenAccount,
+ getSplTokenBalance,
+ getCTokenBalance,
+ ensureValidatorRunning,
+ type Signer,
+ type Rpc,
+} from './helpers/setup.js';
+
+import {
+ createMint,
+ mintTo,
+ decompress,
+ getAssociatedTokenAddressInterface,
+ createAtaInterfaceIdempotent,
+ getTokenPoolInfos,
+ selectTokenPoolInfo,
+ selectTokenPoolInfosForDecompression,
+ type TokenPoolInfo,
+ type SplInterfaceInfo as CompressedTokenSplInterfaceInfo,
+} from '@lightprotocol/compressed-token';
+
+import {
+ selectStateTreeInfo,
+ bn,
+ type TreeInfo,
+} from '@lightprotocol/stateless.js';
+
+import {
+ createWrapInstruction,
+ createUnwrapInstruction,
+ type SplInterfaceInfo,
+} from '../../src/index.js';
+
+const DECIMALS = 9;
+
+describe('Wrap / Unwrap e2e', () => {
+ let rpc: Rpc;
+ let payer: Signer;
+ let mint: any;
+ let mintAuthority: Signer;
+ let mintAddress: ReturnType;
+ let stateTreeInfo: TreeInfo;
+ let tokenPoolInfos: TokenPoolInfo[];
+
+ beforeAll(async () => {
+ await ensureValidatorRunning();
+ rpc = getTestRpc();
+ payer = await fundAccount(rpc);
+ mintAuthority = await fundAccount(rpc, 1e9);
+
+ // V1 createMint: creates SPL mint (owned by SPL Token Program) + SPL interface PDA
+ const result = await createMint(
+ rpc,
+ payer as any,
+ (mintAuthority as any).publicKey,
+ DECIMALS,
+ );
+ mint = result.mint;
+ mintAddress = toKitAddress(mint);
+
+ stateTreeInfo = selectStateTreeInfo(await rpc.getStateTreeInfos());
+ tokenPoolInfos = await getTokenPoolInfos(rpc, mint);
+ }, 120_000);
+
+ /** Convert compressed-token SplInterfaceInfo to token-kit SplInterfaceInfo. */
+ function toKitSplInterfaceInfo(
+ info: CompressedTokenSplInterfaceInfo,
+ ): SplInterfaceInfo {
+ return {
+ poolAddress: toKitAddress(info.splInterfacePda),
+ tokenProgram: toKitAddress(info.tokenProgram),
+ poolIndex: info.poolIndex,
+ bump: info.bump,
+ isInitialized: info.isInitialized,
+ };
+ }
+
+ /**
+ * Helper: create an owner with SPL tokens.
+ *
+ * 1. Mint compressed tokens to owner
+ * 2. Create SPL associated token account (standard SPL Token)
+ * 3. Decompress to SPL associated token account
+ *
+ * Returns the owner, SPL associated token account, and SPL interface info.
+ */
+ async function setupOwnerWithSplTokens(amount: number): Promise<{
+ owner: Signer;
+ splAta: any;
+ splInterfaceInfo: SplInterfaceInfo;
+ }> {
+ const owner = await fundAccount(rpc, 2e9);
+
+ // Mint compressed tokens
+ await mintTo(
+ rpc,
+ payer as any,
+ mint,
+ (owner as any).publicKey,
+ mintAuthority as any,
+ bn(amount),
+ stateTreeInfo,
+ selectTokenPoolInfo(tokenPoolInfos),
+ );
+
+ // Create SPL associated token account (standard SPL Token — not Token 2022)
+ const splAta = await createSplAssociatedTokenAccount(
+ rpc,
+ payer,
+ mint,
+ (owner as any).publicKey,
+ );
+
+ // Decompress to SPL associated token account
+ tokenPoolInfos = await getTokenPoolInfos(rpc, mint);
+ await decompress(
+ rpc,
+ payer as any,
+ mint,
+ bn(amount),
+ owner as any,
+ splAta,
+ selectTokenPoolInfosForDecompression(tokenPoolInfos, bn(amount)),
+ );
+
+ // Get SPL interface info
+ tokenPoolInfos = await getTokenPoolInfos(rpc, mint);
+ const compressedTokenSplInfo = tokenPoolInfos.find(
+ (info) => info.isInitialized,
+ );
+ if (!compressedTokenSplInfo) {
+ throw new Error('No initialized SPL interface PDA found');
+ }
+
+ return {
+ owner,
+ splAta,
+ splInterfaceInfo: toKitSplInterfaceInfo(compressedTokenSplInfo),
+ };
+ }
+
+ it('wrap: SPL → Light Token associated token account', async () => {
+ const { owner, splAta, splInterfaceInfo } =
+ await setupOwnerWithSplTokens(1000);
+
+ // Create Light Token associated token account
+ await createAtaInterfaceIdempotent(
+ rpc,
+ payer as any,
+ mint,
+ (owner as any).publicKey,
+ );
+ const lightTokenAta = getAssociatedTokenAddressInterface(
+ mint,
+ (owner as any).publicKey,
+ );
+
+ // Verify SPL balance before wrap
+ expect(await getSplTokenBalance(rpc, splAta)).toBe(1000n);
+
+ // Wrap 500 SPL tokens → Light Token associated token account
+ const wrapIx = createWrapInstruction({
+ source: toKitAddress(splAta),
+ destination: toKitAddress(lightTokenAta),
+ owner: toKitAddress((owner as any).publicKey),
+ mint: mintAddress,
+ amount: 500n,
+ splInterfaceInfo,
+ decimals: DECIMALS,
+ feePayer: toKitAddress((payer as any).publicKey),
+ });
+
+ await sendKitInstructions(rpc, [wrapIx], payer, [owner]);
+
+ // Verify: SPL has 500 remaining, Light Token account has 500
+ expect(await getSplTokenBalance(rpc, splAta)).toBe(500n);
+ expect(await getCTokenBalance(rpc, lightTokenAta)).toBe(500n);
+ }, 120_000);
+
+ it('unwrap: Light Token associated token account → SPL', async () => {
+ const { owner, splAta, splInterfaceInfo } =
+ await setupOwnerWithSplTokens(1000);
+
+ // Create Light Token associated token account
+ await createAtaInterfaceIdempotent(
+ rpc,
+ payer as any,
+ mint,
+ (owner as any).publicKey,
+ );
+ const lightTokenAta = getAssociatedTokenAddressInterface(
+ mint,
+ (owner as any).publicKey,
+ );
+
+ // Wrap all 1000 → Light Token first
+ const wrapIx = createWrapInstruction({
+ source: toKitAddress(splAta),
+ destination: toKitAddress(lightTokenAta),
+ owner: toKitAddress((owner as any).publicKey),
+ mint: mintAddress,
+ amount: 1000n,
+ splInterfaceInfo,
+ decimals: DECIMALS,
+ feePayer: toKitAddress((payer as any).publicKey),
+ });
+ await sendKitInstructions(rpc, [wrapIx], payer, [owner]);
+
+ expect(await getSplTokenBalance(rpc, splAta)).toBe(0n);
+ expect(await getCTokenBalance(rpc, lightTokenAta)).toBe(1000n);
+
+ // Unwrap 700 Light Token → SPL
+ const unwrapIx = createUnwrapInstruction({
+ source: toKitAddress(lightTokenAta),
+ destination: toKitAddress(splAta),
+ owner: toKitAddress((owner as any).publicKey),
+ mint: mintAddress,
+ amount: 700n,
+ splInterfaceInfo,
+ decimals: DECIMALS,
+ feePayer: toKitAddress((payer as any).publicKey),
+ });
+ await sendKitInstructions(rpc, [unwrapIx], payer, [owner]);
+
+ expect(await getSplTokenBalance(rpc, splAta)).toBe(700n);
+ expect(await getCTokenBalance(rpc, lightTokenAta)).toBe(300n);
+ }, 120_000);
+
+ it('round-trip: wrap then unwrap preserves total supply', async () => {
+ const { owner, splAta, splInterfaceInfo } =
+ await setupOwnerWithSplTokens(2000);
+
+ // Create Light Token associated token account
+ await createAtaInterfaceIdempotent(
+ rpc,
+ payer as any,
+ mint,
+ (owner as any).publicKey,
+ );
+ const lightTokenAta = getAssociatedTokenAddressInterface(
+ mint,
+ (owner as any).publicKey,
+ );
+
+ const ownerAddr = toKitAddress((owner as any).publicKey);
+ const payerAddr = toKitAddress((payer as any).publicKey);
+ const splAtaAddr = toKitAddress(splAta);
+ const lightTokenAtaAddr = toKitAddress(lightTokenAta);
+
+ // Wrap all 2000 SPL → Light Token
+ const wrapIx = createWrapInstruction({
+ source: splAtaAddr,
+ destination: lightTokenAtaAddr,
+ owner: ownerAddr,
+ mint: mintAddress,
+ amount: 2000n,
+ splInterfaceInfo,
+ decimals: DECIMALS,
+ feePayer: payerAddr,
+ });
+ await sendKitInstructions(rpc, [wrapIx], payer, [owner]);
+
+ expect(await getSplTokenBalance(rpc, splAta)).toBe(0n);
+ expect(await getCTokenBalance(rpc, lightTokenAta)).toBe(2000n);
+
+ // Unwrap all 2000 Light Token → SPL
+ const unwrapIx = createUnwrapInstruction({
+ source: lightTokenAtaAddr,
+ destination: splAtaAddr,
+ owner: ownerAddr,
+ mint: mintAddress,
+ amount: 2000n,
+ splInterfaceInfo,
+ decimals: DECIMALS,
+ feePayer: payerAddr,
+ });
+ await sendKitInstructions(rpc, [unwrapIx], payer, [owner]);
+
+ expect(await getSplTokenBalance(rpc, splAta)).toBe(2000n);
+ expect(await getCTokenBalance(rpc, lightTokenAta)).toBe(0n);
+ }, 120_000);
+});
diff --git a/js/token-kit/tests/unit/actions.test.ts b/js/token-kit/tests/unit/actions.test.ts
new file mode 100644
index 0000000000..fc70fa7450
--- /dev/null
+++ b/js/token-kit/tests/unit/actions.test.ts
@@ -0,0 +1,1013 @@
+/**
+ * Unit tests for all action builders in actions.ts.
+ *
+ * Tests cover: transfer, wrap/unwrap, compress/decompress, mint management,
+ * ATA creation, and interface builders.
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { address } from '@solana/addresses';
+
+import {
+ // Transfer
+ buildTransferDelegated,
+ buildTransferInterface,
+
+ // Wrap / Unwrap
+ buildWrap,
+ buildUnwrap,
+
+ // Compress / Decompress
+ buildCompress,
+ buildDecompress,
+ buildCompressSplTokenAccount,
+ buildDecompressInterface,
+ buildLoadAta,
+
+ // Mint management
+ buildCreateMint,
+ buildUpdateMintAuthority,
+ buildUpdateFreezeAuthority,
+ buildUpdateMetadataField,
+ buildUpdateMetadataAuthority,
+ buildRemoveMetadataKey,
+ buildDecompressMint,
+
+ // Mint to
+ buildMintToCompressed,
+ buildMintToInterface,
+ buildApproveAndMintTo,
+
+ // ATA
+ buildCreateAta,
+ buildCreateAtaIdempotent,
+ buildGetOrCreateAta,
+
+ // Constants
+ DISCRIMINATOR,
+ SPL_TOKEN_PROGRAM_ID,
+ LIGHT_TOKEN_PROGRAM_ID,
+ LIGHT_TOKEN_CONFIG,
+ LIGHT_TOKEN_RENT_SPONSOR,
+
+ IndexerError,
+ IndexerErrorCode,
+} from '../../src/index.js';
+
+import {
+ createMockTokenAccount,
+ createMockIndexer,
+ createMockRpc,
+ createMockRpcWithMint,
+ createMockMintContext,
+ createMockSplInterfaceInfo,
+ createBase64MintData,
+ createMockAccountWithHash,
+ createTransferMockIndexer,
+ MOCK_OWNER,
+ MOCK_MINT,
+ MOCK_POOL,
+ MOCK_TREE,
+ MOCK_QUEUE,
+ MOCK_MINT_SIGNER,
+} from './helpers.js';
+
+const FEE_PAYER = address('BPFLoaderUpgradeab1e11111111111111111111111');
+const RECIPIENT = address('GXtd2izAiMJPwMEjfgTRH3d7k9mjn4Jq3JrWFv9gySYy');
+const DELEGATE = address('Sysvar1111111111111111111111111111111111111');
+
+// ============================================================================
+// TRANSFER BUILDERS
+// ============================================================================
+
+describe('buildTransferDelegated', () => {
+ it('builds instruction with delegate in packed accounts', async () => {
+ const accounts = [createMockAccountWithHash(1000n, 0xab, 5, DELEGATE)];
+ const indexer = createTransferMockIndexer(accounts, [
+ { hashByte: 0xab, rootIndex: 10 },
+ ]);
+
+ const result = await buildTransferDelegated({
+ indexer,
+ delegate: DELEGATE,
+ owner: MOCK_OWNER,
+ mint: MOCK_MINT,
+ amount: 500n,
+ recipientOwner: RECIPIENT,
+ feePayer: FEE_PAYER,
+ });
+
+ expect(result.instruction.data[0]).toBe(DISCRIMINATOR.TRANSFER2);
+ expect(result.totalInputAmount).toBe(1000n);
+ // Delegate should appear in packed accounts
+ expect(
+ result.instruction.accounts.some(
+ (acc) => acc.address === DELEGATE,
+ ),
+ ).toBe(true);
+ });
+
+ it('throws when no accounts found', async () => {
+ const indexer = createMockIndexer({
+ getCompressedTokenAccountsByOwner: vi.fn().mockResolvedValue({
+ context: { slot: 100n },
+ value: { items: [], cursor: null },
+ }),
+ });
+
+ await expect(
+ buildTransferDelegated({
+ indexer,
+ delegate: DELEGATE,
+ owner: MOCK_OWNER,
+ mint: MOCK_MINT,
+ amount: 100n,
+ recipientOwner: RECIPIENT,
+ feePayer: FEE_PAYER,
+ }),
+ ).rejects.toThrow(IndexerError);
+ });
+});
+
+describe('buildTransferInterface', () => {
+ it('returns instructions array wrapping transfer result', async () => {
+ const accounts = [createMockAccountWithHash(1000n, 0xab, 5)];
+ const indexer = createTransferMockIndexer(accounts, [
+ { hashByte: 0xab, rootIndex: 10 },
+ ]);
+
+ const result = await buildTransferInterface({
+ indexer,
+ owner: MOCK_OWNER,
+ mint: MOCK_MINT,
+ amount: 500n,
+ recipientOwner: RECIPIENT,
+ feePayer: FEE_PAYER,
+ });
+
+ expect(result.instructions).toHaveLength(1);
+ expect(result.instructions[0].data[0]).toBe(DISCRIMINATOR.TRANSFER2);
+ expect(result.transferResult.totalInputAmount).toBe(1000n);
+ });
+});
+
+// ============================================================================
+// WRAP / UNWRAP BUILDERS
+// ============================================================================
+
+describe('buildWrap', () => {
+ it('builds wrap instruction with explicit decimals', async () => {
+ const rpc = createMockRpc();
+ const splInfo = createMockSplInterfaceInfo();
+
+ const ix = await buildWrap({
+ rpc,
+ source: MOCK_OWNER,
+ destination: RECIPIENT,
+ owner: MOCK_OWNER,
+ mint: MOCK_MINT,
+ amount: 1000n,
+ decimals: 9,
+ tokenProgram: SPL_TOKEN_PROGRAM_ID,
+ splInterfaceInfo: splInfo,
+ });
+
+ expect(ix.programAddress).toBe(LIGHT_TOKEN_PROGRAM_ID);
+ expect(ix.accounts.length).toBeGreaterThan(0);
+ });
+
+ it('auto-fetches decimals when omitted', async () => {
+ const rpc = createMockRpcWithMint(6);
+ const splInfo = createMockSplInterfaceInfo();
+
+ const ix = await buildWrap({
+ rpc,
+ source: MOCK_OWNER,
+ destination: RECIPIENT,
+ owner: MOCK_OWNER,
+ mint: MOCK_MINT,
+ amount: 1000n,
+ splInterfaceInfo: splInfo,
+ });
+
+ expect(ix.programAddress).toBe(LIGHT_TOKEN_PROGRAM_ID);
+ });
+
+ it('defaults tokenProgram to SPL_TOKEN_PROGRAM_ID', async () => {
+ const rpc = createMockRpcWithMint(9);
+ const splInfo = createMockSplInterfaceInfo();
+
+ const ix = await buildWrap({
+ rpc,
+ source: MOCK_OWNER,
+ destination: RECIPIENT,
+ owner: MOCK_OWNER,
+ mint: MOCK_MINT,
+ amount: 1000n,
+ splInterfaceInfo: splInfo,
+ });
+
+ expect(ix.programAddress).toBe(LIGHT_TOKEN_PROGRAM_ID);
+ });
+});
+
+describe('buildUnwrap', () => {
+ it('builds unwrap instruction with explicit decimals', async () => {
+ const rpc = createMockRpc();
+ const splInfo = createMockSplInterfaceInfo();
+
+ const ix = await buildUnwrap({
+ rpc,
+ source: MOCK_OWNER,
+ destination: RECIPIENT,
+ owner: MOCK_OWNER,
+ mint: MOCK_MINT,
+ amount: 1000n,
+ decimals: 9,
+ tokenProgram: SPL_TOKEN_PROGRAM_ID,
+ splInterfaceInfo: splInfo,
+ });
+
+ expect(ix.programAddress).toBe(LIGHT_TOKEN_PROGRAM_ID);
+ });
+
+ it('auto-fetches decimals when omitted', async () => {
+ const rpc = createMockRpcWithMint(6);
+ const splInfo = createMockSplInterfaceInfo();
+
+ const ix = await buildUnwrap({
+ rpc,
+ source: MOCK_OWNER,
+ destination: RECIPIENT,
+ owner: MOCK_OWNER,
+ mint: MOCK_MINT,
+ amount: 1000n,
+ splInterfaceInfo: splInfo,
+ });
+
+ expect(ix.programAddress).toBe(LIGHT_TOKEN_PROGRAM_ID);
+ });
+});
+
+// ============================================================================
+// COMPRESS / DECOMPRESS BUILDERS
+// ============================================================================
+
+describe('buildCompress', () => {
+ it('builds Transfer2 instruction with compression struct', async () => {
+ const rpc = createMockRpc();
+ const splInfo = createMockSplInterfaceInfo();
+
+ const ix = await buildCompress({
+ rpc,
+ source: MOCK_OWNER,
+ owner: MOCK_OWNER,
+ mint: MOCK_MINT,
+ amount: 1000n,
+ decimals: 9,
+ tokenProgram: SPL_TOKEN_PROGRAM_ID,
+ outputQueue: MOCK_QUEUE,
+ splInterfaceInfo: splInfo,
+ });
+
+ expect(ix.data[0]).toBe(DISCRIMINATOR.TRANSFER2);
+ expect(ix.accounts.length).toBeGreaterThan(0);
+ });
+
+ it('auto-fetches decimals when omitted', async () => {
+ const rpc = createMockRpcWithMint(6);
+ const splInfo = createMockSplInterfaceInfo();
+
+ const ix = await buildCompress({
+ rpc,
+ source: MOCK_OWNER,
+ owner: MOCK_OWNER,
+ mint: MOCK_MINT,
+ amount: 1000n,
+ outputQueue: MOCK_QUEUE,
+ splInterfaceInfo: splInfo,
+ });
+
+ expect(ix.data[0]).toBe(DISCRIMINATOR.TRANSFER2);
+ });
+});
+
+describe('buildDecompress', () => {
+ it('builds Transfer2 instruction with decompress compression', async () => {
+ const accounts = [createMockAccountWithHash(1000n, 0xab, 5)];
+ const indexer = createTransferMockIndexer(accounts, [
+ { hashByte: 0xab, rootIndex: 10 },
+ ]);
+ const rpc = createMockRpc();
+ const splInfo = createMockSplInterfaceInfo();
+
+ const result = await buildDecompress({
+ rpc,
+ indexer,
+ owner: MOCK_OWNER,
+ mint: MOCK_MINT,
+ amount: 500n,
+ destination: RECIPIENT,
+ decimals: 9,
+ tokenProgram: SPL_TOKEN_PROGRAM_ID,
+ splInterfaceInfo: splInfo,
+ });
+
+ expect(result.instruction.data[0]).toBe(DISCRIMINATOR.TRANSFER2);
+ expect(result.totalInputAmount).toBe(1000n);
+ });
+
+ it('creates change output when input > amount', async () => {
+ const accounts = [createMockAccountWithHash(1000n, 0xab, 5)];
+ const indexer = createTransferMockIndexer(accounts, [
+ { hashByte: 0xab, rootIndex: 10 },
+ ]);
+ const rpc = createMockRpc();
+ const splInfo = createMockSplInterfaceInfo();
+
+ const result = await buildDecompress({
+ rpc,
+ indexer,
+ owner: MOCK_OWNER,
+ mint: MOCK_MINT,
+ amount: 300n,
+ destination: RECIPIENT,
+ decimals: 9,
+ tokenProgram: SPL_TOKEN_PROGRAM_ID,
+ splInterfaceInfo: splInfo,
+ });
+
+ expect(result.totalInputAmount).toBeGreaterThan(300n);
+ });
+});
+
+describe('buildCompressSplTokenAccount', () => {
+ it('delegates to buildCompress', async () => {
+ const rpc = createMockRpc();
+ const splInfo = createMockSplInterfaceInfo();
+
+ const ix = await buildCompressSplTokenAccount({
+ rpc,
+ source: MOCK_OWNER,
+ owner: MOCK_OWNER,
+ mint: MOCK_MINT,
+ amount: 1000n,
+ decimals: 9,
+ tokenProgram: SPL_TOKEN_PROGRAM_ID,
+ outputQueue: MOCK_QUEUE,
+ splInterfaceInfo: splInfo,
+ });
+
+ expect(ix.data[0]).toBe(DISCRIMINATOR.TRANSFER2);
+ });
+});
+
+describe('buildDecompressInterface', () => {
+ it('uses explicit destination without creating ATA', async () => {
+ const accounts = [createMockAccountWithHash(1000n, 0xab, 5)];
+ const indexer = createTransferMockIndexer(accounts, [
+ { hashByte: 0xab, rootIndex: 10 },
+ ]);
+ const rpc = createMockRpc();
+ const splInfo = createMockSplInterfaceInfo();
+
+ const result = await buildDecompressInterface({
+ rpc,
+ indexer,
+ owner: MOCK_OWNER,
+ mint: MOCK_MINT,
+ amount: 500n,
+ destination: RECIPIENT,
+ decimals: 9,
+ tokenProgram: SPL_TOKEN_PROGRAM_ID,
+ splInterfaceInfo: splInfo,
+ });
+
+ expect(result.destination).toBe(RECIPIENT);
+ // Only decompress instruction, no createAta
+ expect(result.instructions).toHaveLength(1);
+ expect(result.instructions[0].data[0]).toBe(DISCRIMINATOR.TRANSFER2);
+ });
+
+ it('returns empty instructions when amount is 0', async () => {
+ const indexer = createMockIndexer({
+ getCompressedTokenAccountsByOwner: vi.fn().mockResolvedValue({
+ context: { slot: 100n },
+ value: { items: [], cursor: null },
+ }),
+ });
+ const rpc = createMockRpc();
+
+ const result = await buildDecompressInterface({
+ rpc,
+ indexer,
+ owner: MOCK_OWNER,
+ mint: MOCK_MINT,
+ destination: RECIPIENT,
+ });
+
+ // No cold balance = no decompress instruction
+ expect(result.instructions).toHaveLength(0);
+ });
+});
+
+describe('buildLoadAta', () => {
+ it('returns empty array when no cold accounts', async () => {
+ const indexer = createMockIndexer({
+ getCompressedTokenAccountsByOwner: vi.fn().mockResolvedValue({
+ context: { slot: 100n },
+ value: { items: [], cursor: null },
+ }),
+ });
+ const rpc = createMockRpc();
+
+ const result = await buildLoadAta({
+ rpc,
+ indexer,
+ owner: MOCK_OWNER,
+ mint: MOCK_MINT,
+ destination: RECIPIENT,
+ decimals: 9,
+ tokenProgram: SPL_TOKEN_PROGRAM_ID,
+ });
+
+ expect(result).toEqual([]);
+ });
+
+ it('returns decompress instruction when cold balance exists', async () => {
+ const accounts = [createMockAccountWithHash(500n, 0xab, 5)];
+ const indexer = createMockIndexer({
+ getCompressedTokenAccountsByOwner: vi.fn().mockResolvedValue({
+ context: { slot: 100n },
+ value: { items: accounts, cursor: null },
+ }),
+ getValidityProof: vi.fn().mockResolvedValue({
+ context: { slot: 100n },
+ value: {
+ proof: { a: new Uint8Array(32), b: new Uint8Array(64), c: new Uint8Array(32) },
+ accounts: [{ hash: new Uint8Array(32).fill(0xab), root: new Uint8Array(32), rootIndex: { rootIndex: 1, proveByIndex: false }, leafIndex: 5, treeInfo: { tree: MOCK_TREE, queue: MOCK_QUEUE, treeType: 2 } }],
+ addresses: [],
+ },
+ }),
+ });
+ const rpc = createMockRpc();
+ const splInfo = createMockSplInterfaceInfo();
+
+ const result = await buildLoadAta({
+ rpc,
+ indexer,
+ owner: MOCK_OWNER,
+ mint: MOCK_MINT,
+ destination: RECIPIENT,
+ decimals: 9,
+ tokenProgram: SPL_TOKEN_PROGRAM_ID,
+ splInterfaceInfo: splInfo,
+ });
+
+ expect(result).toHaveLength(1);
+ expect(result[0].data[0]).toBe(DISCRIMINATOR.TRANSFER2);
+ });
+});
+
+// ============================================================================
+// MINT MANAGEMENT BUILDERS
+// ============================================================================
+
+describe('buildCreateMint', () => {
+ it('builds MintAction instruction with discriminator 103', async () => {
+ const ix = await buildCreateMint({
+ mintSigner: MOCK_MINT_SIGNER,
+ authority: MOCK_OWNER,
+ feePayer: FEE_PAYER,
+ outOutputQueue: MOCK_QUEUE,
+ merkleTree: MOCK_TREE,
+ decimals: 9,
+ mintAuthority: MOCK_OWNER,
+ });
+
+ expect(ix.data[0]).toBe(DISCRIMINATOR.MINT_ACTION);
+ expect(ix.programAddress).toBe(LIGHT_TOKEN_PROGRAM_ID);
+ });
+
+ it('converts Address types to bytes for authorities', async () => {
+ const ix = await buildCreateMint({
+ mintSigner: MOCK_MINT_SIGNER,
+ authority: MOCK_OWNER,
+ feePayer: FEE_PAYER,
+ outOutputQueue: MOCK_QUEUE,
+ merkleTree: MOCK_TREE,
+ decimals: 6,
+ mintAuthority: MOCK_OWNER,
+ freezeAuthority: RECIPIENT,
+ });
+
+ expect(ix.data[0]).toBe(DISCRIMINATOR.MINT_ACTION);
+ });
+
+ it('handles null freezeAuthority', async () => {
+ const ix = await buildCreateMint({
+ mintSigner: MOCK_MINT_SIGNER,
+ authority: MOCK_OWNER,
+ feePayer: FEE_PAYER,
+ outOutputQueue: MOCK_QUEUE,
+ merkleTree: MOCK_TREE,
+ decimals: 9,
+ mintAuthority: MOCK_OWNER,
+ freezeAuthority: null,
+ });
+
+ expect(ix.data[0]).toBe(DISCRIMINATOR.MINT_ACTION);
+ });
+});
+
+describe('buildUpdateMintAuthority', () => {
+ it('builds instruction with mintContext override', async () => {
+ const ctx = createMockMintContext();
+ const indexer = createMockIndexer();
+
+ const ix = await buildUpdateMintAuthority({
+ indexer,
+ mint: MOCK_MINT,
+ authority: MOCK_OWNER,
+ feePayer: FEE_PAYER,
+ newAuthority: RECIPIENT,
+ mintContext: ctx,
+ });
+
+ expect(ix.data[0]).toBe(DISCRIMINATOR.MINT_ACTION);
+ });
+
+ it('handles null newAuthority (revoke)', async () => {
+ const ctx = createMockMintContext();
+ const indexer = createMockIndexer();
+
+ const ix = await buildUpdateMintAuthority({
+ indexer,
+ mint: MOCK_MINT,
+ authority: MOCK_OWNER,
+ feePayer: FEE_PAYER,
+ newAuthority: null,
+ mintContext: ctx,
+ });
+
+ expect(ix.data[0]).toBe(DISCRIMINATOR.MINT_ACTION);
+ });
+});
+
+describe('buildUpdateFreezeAuthority', () => {
+ it('builds instruction with mintContext', async () => {
+ const ctx = createMockMintContext();
+ const indexer = createMockIndexer();
+
+ const ix = await buildUpdateFreezeAuthority({
+ indexer,
+ mint: MOCK_MINT,
+ authority: MOCK_OWNER,
+ feePayer: FEE_PAYER,
+ newAuthority: RECIPIENT,
+ mintContext: ctx,
+ });
+
+ expect(ix.data[0]).toBe(DISCRIMINATOR.MINT_ACTION);
+ });
+
+ it('handles null newAuthority (revoke)', async () => {
+ const ctx = createMockMintContext();
+ const indexer = createMockIndexer();
+
+ const ix = await buildUpdateFreezeAuthority({
+ indexer,
+ mint: MOCK_MINT,
+ authority: MOCK_OWNER,
+ feePayer: FEE_PAYER,
+ newAuthority: null,
+ mintContext: ctx,
+ });
+
+ expect(ix.data[0]).toBe(DISCRIMINATOR.MINT_ACTION);
+ });
+});
+
+describe('buildUpdateMetadataField', () => {
+ const ctx = createMockMintContext();
+
+ it.each([
+ ['name', 'TestToken'],
+ ['symbol', 'TT'],
+ ['uri', 'https://example.com'],
+ ] as const)('builds instruction for fieldType=%s', async (fieldType, value) => {
+ const indexer = createMockIndexer();
+
+ const ix = await buildUpdateMetadataField({
+ indexer,
+ mint: MOCK_MINT,
+ authority: MOCK_OWNER,
+ feePayer: FEE_PAYER,
+ fieldType,
+ value,
+ mintContext: ctx,
+ });
+
+ expect(ix.data[0]).toBe(DISCRIMINATOR.MINT_ACTION);
+ });
+
+ it('encodes custom key for fieldType=custom', async () => {
+ const indexer = createMockIndexer();
+
+ const ix = await buildUpdateMetadataField({
+ indexer,
+ mint: MOCK_MINT,
+ authority: MOCK_OWNER,
+ feePayer: FEE_PAYER,
+ fieldType: 'custom',
+ value: 'myValue',
+ customKey: 'myKey',
+ mintContext: ctx,
+ });
+
+ expect(ix.data[0]).toBe(DISCRIMINATOR.MINT_ACTION);
+ });
+});
+
+describe('buildUpdateMetadataAuthority', () => {
+ it('builds instruction with default extensionIndex', async () => {
+ const ctx = createMockMintContext({ metadataExtensionIndex: 2 });
+ const indexer = createMockIndexer();
+
+ const ix = await buildUpdateMetadataAuthority({
+ indexer,
+ mint: MOCK_MINT,
+ authority: MOCK_OWNER,
+ feePayer: FEE_PAYER,
+ newAuthority: RECIPIENT,
+ mintContext: ctx,
+ });
+
+ expect(ix.data[0]).toBe(DISCRIMINATOR.MINT_ACTION);
+ });
+
+ it('uses explicit extensionIndex when provided', async () => {
+ const ctx = createMockMintContext();
+ const indexer = createMockIndexer();
+
+ const ix = await buildUpdateMetadataAuthority({
+ indexer,
+ mint: MOCK_MINT,
+ authority: MOCK_OWNER,
+ feePayer: FEE_PAYER,
+ newAuthority: RECIPIENT,
+ extensionIndex: 5,
+ mintContext: ctx,
+ });
+
+ expect(ix.data[0]).toBe(DISCRIMINATOR.MINT_ACTION);
+ });
+});
+
+describe('buildRemoveMetadataKey', () => {
+ it('builds instruction with idempotent=false', async () => {
+ const ctx = createMockMintContext();
+ const indexer = createMockIndexer();
+
+ const ix = await buildRemoveMetadataKey({
+ indexer,
+ mint: MOCK_MINT,
+ authority: MOCK_OWNER,
+ feePayer: FEE_PAYER,
+ key: 'website',
+ mintContext: ctx,
+ });
+
+ expect(ix.data[0]).toBe(DISCRIMINATOR.MINT_ACTION);
+ });
+
+ it('builds instruction with idempotent=true', async () => {
+ const ctx = createMockMintContext();
+ const indexer = createMockIndexer();
+
+ const ix = await buildRemoveMetadataKey({
+ indexer,
+ mint: MOCK_MINT,
+ authority: MOCK_OWNER,
+ feePayer: FEE_PAYER,
+ key: 'website',
+ idempotent: true,
+ mintContext: ctx,
+ });
+
+ expect(ix.data[0]).toBe(DISCRIMINATOR.MINT_ACTION);
+ });
+});
+
+// ============================================================================
+// MINT TO BUILDERS
+// ============================================================================
+
+describe('buildMintToCompressed', () => {
+ it('builds instruction with multiple recipients', async () => {
+ const ctx = createMockMintContext();
+ const indexer = createMockIndexer();
+
+ const ix = await buildMintToCompressed({
+ indexer,
+ mint: MOCK_MINT,
+ authority: MOCK_OWNER,
+ feePayer: FEE_PAYER,
+ recipients: [
+ { recipient: RECIPIENT, amount: 1000n },
+ { recipient: MOCK_OWNER, amount: 2000n },
+ ],
+ mintContext: ctx,
+ });
+
+ expect(ix.data[0]).toBe(DISCRIMINATOR.MINT_ACTION);
+ });
+
+ it('builds instruction with single recipient', async () => {
+ const ctx = createMockMintContext();
+ const indexer = createMockIndexer();
+
+ const ix = await buildMintToCompressed({
+ indexer,
+ mint: MOCK_MINT,
+ authority: MOCK_OWNER,
+ feePayer: FEE_PAYER,
+ recipients: [{ recipient: RECIPIENT, amount: 500n }],
+ mintContext: ctx,
+ });
+
+ expect(ix.data[0]).toBe(DISCRIMINATOR.MINT_ACTION);
+ });
+});
+
+describe('buildMintToInterface', () => {
+ it('includes tokenAccount in packed accounts as writable', async () => {
+ const ctx = createMockMintContext();
+ const indexer = createMockIndexer();
+ const tokenAccount = address('Vote111111111111111111111111111111111111111');
+
+ const ix = await buildMintToInterface({
+ indexer,
+ mint: MOCK_MINT,
+ authority: MOCK_OWNER,
+ feePayer: FEE_PAYER,
+ tokenAccount,
+ amount: 1000n,
+ mintContext: ctx,
+ });
+
+ expect(ix.data[0]).toBe(DISCRIMINATOR.MINT_ACTION);
+ // tokenAccount should be in the remaining accounts
+ expect(
+ ix.accounts.some((acc) => acc.address === tokenAccount),
+ ).toBe(true);
+ });
+});
+
+describe('buildDecompressMint', () => {
+ it('uses default rentPayment and writeTopUp', async () => {
+ const ctx = createMockMintContext();
+ const indexer = createMockIndexer();
+
+ const ix = await buildDecompressMint({
+ indexer,
+ mint: MOCK_MINT,
+ authority: MOCK_OWNER,
+ feePayer: FEE_PAYER,
+ mintContext: ctx,
+ });
+
+ expect(ix.data[0]).toBe(DISCRIMINATOR.MINT_ACTION);
+ // Verify config accounts are present
+ expect(
+ ix.accounts.some((acc) => acc.address === LIGHT_TOKEN_CONFIG),
+ ).toBe(true);
+ expect(
+ ix.accounts.some((acc) => acc.address === LIGHT_TOKEN_RENT_SPONSOR),
+ ).toBe(true);
+ });
+
+ it('accepts custom config addresses', async () => {
+ const ctx = createMockMintContext();
+ const indexer = createMockIndexer();
+ const customConfig = address('Vote111111111111111111111111111111111111111');
+ const customSponsor = address('11111111111111111111111111111111');
+
+ const ix = await buildDecompressMint({
+ indexer,
+ mint: MOCK_MINT,
+ authority: MOCK_OWNER,
+ feePayer: FEE_PAYER,
+ compressibleConfig: customConfig,
+ rentSponsor: customSponsor,
+ mintContext: ctx,
+ });
+
+ expect(ix.data[0]).toBe(DISCRIMINATOR.MINT_ACTION);
+ expect(
+ ix.accounts.some((acc) => acc.address === customConfig),
+ ).toBe(true);
+ expect(
+ ix.accounts.some((acc) => acc.address === customSponsor),
+ ).toBe(true);
+ });
+});
+
+// ============================================================================
+// APPROVE AND MINT TO
+// ============================================================================
+
+describe('buildApproveAndMintTo', () => {
+ it('returns two instructions [approve, mintTo]', () => {
+ const result = buildApproveAndMintTo({
+ tokenAccount: RECIPIENT,
+ mint: MOCK_MINT,
+ delegate: DELEGATE,
+ owner: MOCK_OWNER,
+ mintAuthority: MOCK_OWNER,
+ approveAmount: 1000n,
+ mintAmount: 500n,
+ });
+
+ expect(result).toHaveLength(2);
+ expect(result[0].data[0]).toBe(DISCRIMINATOR.APPROVE);
+ expect(result[1].data[0]).toBe(DISCRIMINATOR.MINT_TO);
+ });
+
+ it('passes maxTopUp to both instructions', () => {
+ const result = buildApproveAndMintTo({
+ tokenAccount: RECIPIENT,
+ mint: MOCK_MINT,
+ delegate: DELEGATE,
+ owner: MOCK_OWNER,
+ mintAuthority: MOCK_OWNER,
+ approveAmount: 1000n,
+ mintAmount: 500n,
+ maxTopUp: 100,
+ });
+
+ expect(result).toHaveLength(2);
+ });
+});
+
+// ============================================================================
+// ATA BUILDERS
+// ============================================================================
+
+describe('buildCreateAta', () => {
+ it('derives ATA and returns instruction + address + bump', async () => {
+ const result = await buildCreateAta({
+ owner: MOCK_OWNER,
+ mint: MOCK_MINT,
+ feePayer: FEE_PAYER,
+ });
+
+ expect(result.instruction).toBeDefined();
+ expect(result.ata).toBeDefined();
+ expect(typeof result.bump).toBe('number');
+ expect(result.bump).toBeGreaterThanOrEqual(0);
+ expect(result.bump).toBeLessThanOrEqual(255);
+ expect(result.instruction.data[0]).toBe(DISCRIMINATOR.CREATE_ATA);
+ });
+
+ it('produces consistent ATA address', async () => {
+ const result1 = await buildCreateAta({
+ owner: MOCK_OWNER,
+ mint: MOCK_MINT,
+ feePayer: FEE_PAYER,
+ });
+ const result2 = await buildCreateAta({
+ owner: MOCK_OWNER,
+ mint: MOCK_MINT,
+ feePayer: FEE_PAYER,
+ });
+
+ expect(result1.ata).toBe(result2.ata);
+ expect(result1.bump).toBe(result2.bump);
+ });
+});
+
+describe('buildCreateAtaIdempotent', () => {
+ it('uses idempotent discriminator', async () => {
+ const result = await buildCreateAtaIdempotent({
+ owner: MOCK_OWNER,
+ mint: MOCK_MINT,
+ feePayer: FEE_PAYER,
+ });
+
+ expect(result.instruction).toBeDefined();
+ expect(result.ata).toBeDefined();
+ expect(result.instruction.data[0]).toBe(
+ DISCRIMINATOR.CREATE_ATA_IDEMPOTENT,
+ );
+ });
+});
+
+describe('buildGetOrCreateAta', () => {
+ it('returns create + decompress instructions when ATA missing and cold balance exists', async () => {
+ const accounts = [createMockAccountWithHash(500n, 0xab, 5)];
+ const indexer = createMockIndexer({
+ getCompressedTokenAccountsByOwner: vi.fn().mockResolvedValue({
+ context: { slot: 100n },
+ value: { items: accounts, cursor: null },
+ }),
+ getValidityProof: vi.fn().mockResolvedValue({
+ context: { slot: 100n },
+ value: {
+ proof: { a: new Uint8Array(32), b: new Uint8Array(64), c: new Uint8Array(32) },
+ accounts: [{
+ hash: new Uint8Array(32).fill(0xab),
+ root: new Uint8Array(32),
+ rootIndex: { rootIndex: 1, proveByIndex: false },
+ leafIndex: 5,
+ treeInfo: { tree: MOCK_TREE, queue: MOCK_QUEUE, treeType: 2 },
+ }],
+ addresses: [],
+ },
+ }),
+ });
+
+ // RPC returns null = ATA doesn't exist
+ const rpc = createMockRpc();
+ const splInfo = createMockSplInterfaceInfo();
+
+ const result = await buildGetOrCreateAta({
+ rpc,
+ indexer,
+ owner: MOCK_OWNER,
+ mint: MOCK_MINT,
+ feePayer: FEE_PAYER,
+ decimals: 9,
+ tokenProgram: SPL_TOKEN_PROGRAM_ID,
+ splInterfaceInfo: splInfo,
+ });
+
+ expect(result.ata).toBeDefined();
+ // Should have create ATA + decompress
+ expect(result.instructions.length).toBeGreaterThanOrEqual(2);
+ expect(result.coldBalance).toBe(500n);
+ expect(result.hotBalance).toBe(0n);
+ });
+
+ it('skips create instruction when ATA exists', async () => {
+ // Build a 72-byte account with balance=1000 at offset 64
+ const accountBytes = new Uint8Array(72);
+ const view = new DataView(accountBytes.buffer);
+ view.setBigUint64(64, 1000n, true);
+ const base64 = btoa(String.fromCharCode(...accountBytes));
+
+ const rpc = createMockRpc({
+ getAccountInfo: vi.fn().mockResolvedValue({
+ value: {
+ owner: LIGHT_TOKEN_PROGRAM_ID,
+ data: [base64, 'base64'],
+ },
+ }),
+ });
+
+ const indexer = createMockIndexer({
+ getCompressedTokenAccountsByOwner: vi.fn().mockResolvedValue({
+ context: { slot: 100n },
+ value: { items: [], cursor: null },
+ }),
+ });
+
+ const result = await buildGetOrCreateAta({
+ rpc,
+ indexer,
+ owner: MOCK_OWNER,
+ mint: MOCK_MINT,
+ feePayer: FEE_PAYER,
+ });
+
+ // ATA exists with no cold balance = no instructions
+ expect(result.instructions).toHaveLength(0);
+ expect(result.hotBalance).toBe(1000n);
+ expect(result.coldBalance).toBe(0n);
+ expect(result.totalBalance).toBe(1000n);
+ });
+
+ it('returns only create instruction when ATA missing and no cold balance', async () => {
+ const rpc = createMockRpc(); // returns null = no ATA
+
+ const indexer = createMockIndexer({
+ getCompressedTokenAccountsByOwner: vi.fn().mockResolvedValue({
+ context: { slot: 100n },
+ value: { items: [], cursor: null },
+ }),
+ });
+
+ const result = await buildGetOrCreateAta({
+ rpc,
+ indexer,
+ owner: MOCK_OWNER,
+ mint: MOCK_MINT,
+ feePayer: FEE_PAYER,
+ });
+
+ // Just create ATA, no decompress
+ expect(result.instructions).toHaveLength(1);
+ expect(result.coldBalance).toBe(0n);
+ expect(result.hotBalance).toBe(0n);
+ });
+});
diff --git a/js/token-kit/tests/unit/client.test.ts b/js/token-kit/tests/unit/client.test.ts
new file mode 100644
index 0000000000..f4c7a6e1a7
--- /dev/null
+++ b/js/token-kit/tests/unit/client.test.ts
@@ -0,0 +1,57 @@
+/**
+ * Unit tests for client-level shared error and validation types.
+ *
+ * Selection and load helper behavior is covered in selection.test.ts and load.test.ts.
+ */
+
+import { describe, it, expect } from 'vitest';
+
+import {
+ assertValidTreeType,
+ TreeType,
+ IndexerError,
+ IndexerErrorCode,
+} from '../../src/index.js';
+
+describe('IndexerError', () => {
+ it('constructs with code, message, and cause', () => {
+ const cause = new Error('Original error');
+ const error = new IndexerError(
+ IndexerErrorCode.NetworkError,
+ 'Connection failed',
+ cause,
+ );
+
+ expect(error.code).toBe(IndexerErrorCode.NetworkError);
+ expect(error.message).toBe('Connection failed');
+ expect(error.cause).toBe(cause);
+ expect(error.name).toBe('IndexerError');
+ expect(error instanceof Error).toBe(true);
+ });
+
+ it('supports construction without cause', () => {
+ const error = new IndexerError(
+ IndexerErrorCode.InvalidResponse,
+ 'Bad response',
+ );
+
+ expect(error.code).toBe(IndexerErrorCode.InvalidResponse);
+ expect(error.message).toBe('Bad response');
+ expect(error.cause).toBeUndefined();
+ });
+});
+
+describe('assertValidTreeType', () => {
+ it('accepts all known tree types', () => {
+ expect(() => assertValidTreeType(TreeType.StateV1)).not.toThrow();
+ expect(() => assertValidTreeType(TreeType.AddressV1)).not.toThrow();
+ expect(() => assertValidTreeType(TreeType.StateV2)).not.toThrow();
+ expect(() => assertValidTreeType(TreeType.AddressV2)).not.toThrow();
+ });
+
+ it('throws for unknown tree types', () => {
+ expect(() => assertValidTreeType(99 as TreeType)).toThrow(IndexerError);
+ expect(() => assertValidTreeType(99 as TreeType)).toThrow('Unknown tree type');
+ });
+});
+
diff --git a/js/token-kit/tests/unit/codecs.test.ts b/js/token-kit/tests/unit/codecs.test.ts
new file mode 100644
index 0000000000..92324e80c0
--- /dev/null
+++ b/js/token-kit/tests/unit/codecs.test.ts
@@ -0,0 +1,2120 @@
+/**
+ * Comprehensive codec roundtrip tests for Light Token SDK.
+ *
+ * Verifies that encoding then decoding produces the original data for all codecs.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { address, getAddressCodec } from '@solana/addresses';
+
+import {
+ getCompressionCodec,
+ getPackedMerkleContextCodec,
+ getMultiInputTokenDataCodec,
+ getMultiTokenOutputDataCodec,
+ getCpiContextCodec,
+ getCompressedProofCodec,
+ getCompressibleExtensionDataCodec,
+ getCreateAtaDataCodec,
+ getCreateTokenAccountDataCodec,
+ encodeCreateTokenAccountInstructionData,
+ getAmountInstructionCodec,
+ getCheckedInstructionCodec,
+ getDiscriminatorOnlyCodec,
+ encodeMaxTopUp,
+ decodeMaxTopUp,
+} from '../../src/codecs/index.js';
+
+import {
+ encodeTransfer2InstructionData,
+ encodeExtensionInstructionData,
+ getTransfer2BaseEncoder,
+ getTransfer2BaseDecoder,
+} from '../../src/codecs/transfer2.js';
+
+import {
+ encodeMintActionInstructionData,
+} from '../../src/codecs/mint-action.js';
+
+import type {
+ Compression,
+ PackedMerkleContext,
+ MultiInputTokenDataWithContext,
+ MultiTokenTransferOutputData,
+ CompressedCpiContext,
+ CompressedProof,
+ CompressibleExtensionInstructionData,
+ CreateAtaInstructionData,
+ CreateTokenAccountInstructionData,
+ Transfer2InstructionData,
+ ExtensionInstructionData,
+ CompressionInfo,
+ RentConfig,
+ CompressedOnlyExtension,
+ TokenMetadataExtension,
+} from '../../src/codecs/types.js';
+
+import type {
+ MintActionInstructionData,
+ MintMetadata,
+ MintInstructionData,
+ MintActionCpiContext,
+ CreateMint,
+} from '../../src/codecs/mint-action.js';
+
+import type {
+ AmountInstructionData,
+ CheckedInstructionData,
+ DiscriminatorOnlyData,
+} from '../../src/codecs/instructions.js';
+
+import { DISCRIMINATOR, EXTENSION_DISCRIMINANT } from '../../src/constants.js';
+
+// ============================================================================
+// 1. Compression codec roundtrip
+// ============================================================================
+
+describe('Compression codec', () => {
+ it('roundtrip encodes and decodes all fields', () => {
+ const codec = getCompressionCodec();
+ const original: Compression = {
+ mode: 2,
+ amount: 1_000_000n,
+ mint: 3,
+ sourceOrRecipient: 5,
+ authority: 7,
+ poolAccountIndex: 9,
+ poolIndex: 1,
+ bump: 254,
+ decimals: 9,
+ };
+ const encoded = codec.encode(original);
+ const decoded = codec.decode(encoded);
+ expect(decoded).toEqual(original);
+ });
+
+ it('handles zero amount', () => {
+ const codec = getCompressionCodec();
+ const original: Compression = {
+ mode: 0,
+ amount: 0n,
+ mint: 0,
+ sourceOrRecipient: 0,
+ authority: 0,
+ poolAccountIndex: 0,
+ poolIndex: 0,
+ bump: 0,
+ decimals: 0,
+ };
+ const encoded = codec.encode(original);
+ const decoded = codec.decode(encoded);
+ expect(decoded).toEqual(original);
+ });
+
+ it('handles max u64 amount', () => {
+ const codec = getCompressionCodec();
+ const original: Compression = {
+ mode: 1,
+ amount: 18446744073709551615n,
+ mint: 255,
+ sourceOrRecipient: 255,
+ authority: 255,
+ poolAccountIndex: 255,
+ poolIndex: 255,
+ bump: 255,
+ decimals: 255,
+ };
+ const encoded = codec.encode(original);
+ const decoded = codec.decode(encoded);
+ expect(decoded).toEqual(original);
+ });
+});
+
+// ============================================================================
+// 2. PackedMerkleContext codec roundtrip
+// ============================================================================
+
+describe('PackedMerkleContext codec', () => {
+ it('roundtrip with proveByIndex true', () => {
+ const codec = getPackedMerkleContextCodec();
+ const original: PackedMerkleContext = {
+ merkleTreePubkeyIndex: 1,
+ queuePubkeyIndex: 2,
+ leafIndex: 12345,
+ proveByIndex: true,
+ };
+ const encoded = codec.encode(original);
+ const decoded = codec.decode(encoded);
+ expect(decoded).toEqual(original);
+ });
+
+ it('roundtrip with proveByIndex false', () => {
+ const codec = getPackedMerkleContextCodec();
+ const original: PackedMerkleContext = {
+ merkleTreePubkeyIndex: 0,
+ queuePubkeyIndex: 0,
+ leafIndex: 0,
+ proveByIndex: false,
+ };
+ const encoded = codec.encode(original);
+ const decoded = codec.decode(encoded);
+ expect(decoded).toEqual(original);
+ });
+
+ it('handles max u32 leafIndex', () => {
+ const codec = getPackedMerkleContextCodec();
+ const original: PackedMerkleContext = {
+ merkleTreePubkeyIndex: 255,
+ queuePubkeyIndex: 255,
+ leafIndex: 4294967295,
+ proveByIndex: true,
+ };
+ const encoded = codec.encode(original);
+ const decoded = codec.decode(encoded);
+ expect(decoded).toEqual(original);
+ });
+});
+
+// ============================================================================
+// 3. MultiInputTokenData codec roundtrip
+// ============================================================================
+
+describe('MultiInputTokenData codec', () => {
+ it('roundtrip with delegate', () => {
+ const codec = getMultiInputTokenDataCodec();
+ const original: MultiInputTokenDataWithContext = {
+ owner: 1,
+ amount: 500_000n,
+ hasDelegate: true,
+ delegate: 3,
+ mint: 2,
+ version: 0,
+ merkleContext: {
+ merkleTreePubkeyIndex: 4,
+ queuePubkeyIndex: 5,
+ leafIndex: 999,
+ proveByIndex: false,
+ },
+ rootIndex: 42,
+ };
+ const encoded = codec.encode(original);
+ const decoded = codec.decode(encoded);
+ expect(decoded).toEqual(original);
+ });
+
+ it('roundtrip without delegate', () => {
+ const codec = getMultiInputTokenDataCodec();
+ const original: MultiInputTokenDataWithContext = {
+ owner: 0,
+ amount: 0n,
+ hasDelegate: false,
+ delegate: 0,
+ mint: 0,
+ version: 0,
+ merkleContext: {
+ merkleTreePubkeyIndex: 0,
+ queuePubkeyIndex: 0,
+ leafIndex: 0,
+ proveByIndex: false,
+ },
+ rootIndex: 0,
+ };
+ const encoded = codec.encode(original);
+ const decoded = codec.decode(encoded);
+ expect(decoded).toEqual(original);
+ });
+
+ it('handles max u16 rootIndex', () => {
+ const codec = getMultiInputTokenDataCodec();
+ const original: MultiInputTokenDataWithContext = {
+ owner: 10,
+ amount: 18446744073709551615n,
+ hasDelegate: true,
+ delegate: 20,
+ mint: 30,
+ version: 1,
+ merkleContext: {
+ merkleTreePubkeyIndex: 100,
+ queuePubkeyIndex: 200,
+ leafIndex: 4294967295,
+ proveByIndex: true,
+ },
+ rootIndex: 65535,
+ };
+ const encoded = codec.encode(original);
+ const decoded = codec.decode(encoded);
+ expect(decoded).toEqual(original);
+ });
+});
+
+// ============================================================================
+// 4. MultiTokenOutputData codec roundtrip
+// ============================================================================
+
+describe('MultiTokenOutputData codec', () => {
+ it('roundtrip with standard values', () => {
+ const codec = getMultiTokenOutputDataCodec();
+ const original: MultiTokenTransferOutputData = {
+ owner: 1,
+ amount: 750_000n,
+ hasDelegate: true,
+ delegate: 2,
+ mint: 3,
+ version: 0,
+ };
+ const encoded = codec.encode(original);
+ const decoded = codec.decode(encoded);
+ expect(decoded).toEqual(original);
+ });
+
+ it('roundtrip without delegate', () => {
+ const codec = getMultiTokenOutputDataCodec();
+ const original: MultiTokenTransferOutputData = {
+ owner: 5,
+ amount: 100n,
+ hasDelegate: false,
+ delegate: 0,
+ mint: 7,
+ version: 1,
+ };
+ const encoded = codec.encode(original);
+ const decoded = codec.decode(encoded);
+ expect(decoded).toEqual(original);
+ });
+});
+
+// ============================================================================
+// 5. CpiContext codec roundtrip
+// ============================================================================
+
+describe('CpiContext codec', () => {
+ it('roundtrip with setContext true', () => {
+ const codec = getCpiContextCodec();
+ const original: CompressedCpiContext = {
+ setContext: true,
+ firstSetContext: true,
+ };
+ const encoded = codec.encode(original);
+ const decoded = codec.decode(encoded);
+ expect(decoded).toEqual(original);
+ });
+
+ it('roundtrip with setContext false', () => {
+ const codec = getCpiContextCodec();
+ const original: CompressedCpiContext = {
+ setContext: false,
+ firstSetContext: false,
+ };
+ const encoded = codec.encode(original);
+ const decoded = codec.decode(encoded);
+ expect(decoded).toEqual(original);
+ });
+});
+
+// ============================================================================
+// 6. CompressedProof codec roundtrip
+// ============================================================================
+
+describe('CompressedProof codec', () => {
+ it('roundtrip with populated proof data', () => {
+ const codec = getCompressedProofCodec();
+ const aBytes = new Uint8Array(32);
+ aBytes.fill(0xaa);
+ const bBytes = new Uint8Array(64);
+ bBytes.fill(0xbb);
+ const cBytes = new Uint8Array(32);
+ cBytes.fill(0xcc);
+
+ const original: CompressedProof = {
+ a: aBytes,
+ b: bBytes,
+ c: cBytes,
+ };
+ const encoded = codec.encode(original);
+ const decoded = codec.decode(encoded);
+
+ expect(new Uint8Array(decoded.a)).toEqual(new Uint8Array(original.a));
+ expect(new Uint8Array(decoded.b)).toEqual(new Uint8Array(original.b));
+ expect(new Uint8Array(decoded.c)).toEqual(new Uint8Array(original.c));
+ });
+
+ it('verifies 32+64+32 byte sizes', () => {
+ const codec = getCompressedProofCodec();
+ const original: CompressedProof = {
+ a: new Uint8Array(32).fill(1),
+ b: new Uint8Array(64).fill(2),
+ c: new Uint8Array(32).fill(3),
+ };
+ const encoded = codec.encode(original);
+
+ // Total encoded size should be 32 + 64 + 32 = 128 bytes
+ expect(encoded.length).toBe(128);
+ });
+
+ it('roundtrip with all-zero proof', () => {
+ const codec = getCompressedProofCodec();
+ const original: CompressedProof = {
+ a: new Uint8Array(32),
+ b: new Uint8Array(64),
+ c: new Uint8Array(32),
+ };
+ const encoded = codec.encode(original);
+ const decoded = codec.decode(encoded);
+
+ expect(new Uint8Array(decoded.a)).toEqual(new Uint8Array(32));
+ expect(new Uint8Array(decoded.b)).toEqual(new Uint8Array(64));
+ expect(new Uint8Array(decoded.c)).toEqual(new Uint8Array(32));
+ });
+
+ it('roundtrip with random-like proof data', () => {
+ const codec = getCompressedProofCodec();
+ const a = new Uint8Array(32);
+ const b = new Uint8Array(64);
+ const c = new Uint8Array(32);
+ for (let i = 0; i < 32; i++) a[i] = i;
+ for (let i = 0; i < 64; i++) b[i] = i % 256;
+ for (let i = 0; i < 32; i++) c[i] = 255 - i;
+
+ const original: CompressedProof = { a, b, c };
+ const encoded = codec.encode(original);
+ const decoded = codec.decode(encoded);
+
+ expect(new Uint8Array(decoded.a)).toEqual(new Uint8Array(original.a));
+ expect(new Uint8Array(decoded.b)).toEqual(new Uint8Array(original.b));
+ expect(new Uint8Array(decoded.c)).toEqual(new Uint8Array(original.c));
+ });
+});
+
+// ============================================================================
+// 7. CompressibleExtensionData codec roundtrip
+// ============================================================================
+
+describe('CompressibleExtensionData codec', () => {
+ // Note: getOptionDecoder returns Option ({ __option: 'Some'/'None' })
+ // at runtime, while the types use T | null via `as unknown` casts.
+ // For roundtrip tests, we verify that encode -> decode preserves semantics.
+
+ it('roundtrip without compressToPubkey (null)', () => {
+ const codec = getCompressibleExtensionDataCodec();
+ const original: CompressibleExtensionInstructionData = {
+ tokenAccountVersion: 0,
+ rentPayment: 5,
+ compressionOnly: 1,
+ writeTopUp: 1000,
+ compressToPubkey: null,
+ };
+ const encoded = codec.encode(original);
+ const decoded = codec.decode(encoded);
+
+ expect(decoded.tokenAccountVersion).toBe(
+ original.tokenAccountVersion,
+ );
+ expect(decoded.rentPayment).toBe(original.rentPayment);
+ expect(decoded.compressionOnly).toBe(original.compressionOnly);
+ expect(decoded.writeTopUp).toBe(original.writeTopUp);
+
+ // Decoded option field uses { __option: 'None' } at runtime
+ const decodedPubkey = decoded.compressToPubkey as unknown;
+ expect(decodedPubkey).toEqual({ __option: 'None' });
+ });
+
+ it('roundtrip with compressToPubkey', () => {
+ const codec = getCompressibleExtensionDataCodec();
+ const programId = new Uint8Array(32);
+ programId.fill(0x11);
+ const seed1 = new Uint8Array([1, 2, 3]);
+ const seed2 = new Uint8Array([4, 5, 6, 7]);
+
+ const original: CompressibleExtensionInstructionData = {
+ tokenAccountVersion: 1,
+ rentPayment: 10,
+ compressionOnly: 0,
+ writeTopUp: 50000,
+ compressToPubkey: {
+ bump: 254,
+ programId: programId,
+ seeds: [seed1, seed2],
+ },
+ };
+ const encoded = codec.encode(original);
+ const decoded = codec.decode(encoded);
+
+ expect(decoded.tokenAccountVersion).toBe(
+ original.tokenAccountVersion,
+ );
+ expect(decoded.rentPayment).toBe(original.rentPayment);
+ expect(decoded.compressionOnly).toBe(original.compressionOnly);
+ expect(decoded.writeTopUp).toBe(original.writeTopUp);
+
+ // Decoded option field uses { __option: 'Some', value: ... } at runtime
+ const decodedPubkey = decoded.compressToPubkey as unknown as {
+ __option: 'Some';
+ value: {
+ bump: number;
+ programId: Uint8Array;
+ seeds: Uint8Array[];
+ };
+ };
+ expect(decodedPubkey.__option).toBe('Some');
+ expect(decodedPubkey.value.bump).toBe(254);
+ expect(new Uint8Array(decodedPubkey.value.programId)).toEqual(
+ programId,
+ );
+ expect(decodedPubkey.value.seeds.length).toBe(2);
+ expect(new Uint8Array(decodedPubkey.value.seeds[0])).toEqual(seed1);
+ expect(new Uint8Array(decodedPubkey.value.seeds[1])).toEqual(seed2);
+ });
+});
+
+// ============================================================================
+// 8. CreateAtaData codec roundtrip
+// ============================================================================
+
+describe('CreateAtaData codec', () => {
+ it('roundtrip without compressible config (null)', () => {
+ const codec = getCreateAtaDataCodec();
+ const original: CreateAtaInstructionData = {
+ compressibleConfig: null,
+ };
+ const encoded = codec.encode(original);
+ const decoded = codec.decode(encoded);
+
+ // Decoded option field uses { __option: 'None' } at runtime
+ const decodedConfig = decoded.compressibleConfig as unknown;
+ expect(decodedConfig).toEqual({ __option: 'None' });
+ });
+
+ it('roundtrip with compressible config', () => {
+ const codec = getCreateAtaDataCodec();
+ const original: CreateAtaInstructionData = {
+ compressibleConfig: {
+ tokenAccountVersion: 0,
+ rentPayment: 3,
+ compressionOnly: 0,
+ writeTopUp: 0,
+ compressToPubkey: null,
+ },
+ };
+ const encoded = codec.encode(original);
+ const decoded = codec.decode(encoded);
+
+ // Outer option: { __option: 'Some', value: { ..., compressToPubkey: { __option: 'None' } } }
+ const decodedConfig = decoded.compressibleConfig as unknown as {
+ __option: 'Some';
+ value: {
+ tokenAccountVersion: number;
+ rentPayment: number;
+ compressionOnly: number;
+ writeTopUp: number;
+ compressToPubkey: { __option: 'None' };
+ };
+ };
+ expect(decodedConfig.__option).toBe('Some');
+ expect(decodedConfig.value.tokenAccountVersion).toBe(0);
+ expect(decodedConfig.value.rentPayment).toBe(3);
+ expect(decodedConfig.value.compressionOnly).toBe(0);
+ expect(decodedConfig.value.writeTopUp).toBe(0);
+ expect(decodedConfig.value.compressToPubkey).toEqual({
+ __option: 'None',
+ });
+ });
+
+ it('roundtrip with compressible config and compressToPubkey', () => {
+ const codec = getCreateAtaDataCodec();
+ const programId = new Uint8Array(32);
+ programId.fill(0x42);
+
+ const original: CreateAtaInstructionData = {
+ compressibleConfig: {
+ tokenAccountVersion: 1,
+ rentPayment: 12,
+ compressionOnly: 1,
+ writeTopUp: 99999,
+ compressToPubkey: {
+ bump: 253,
+ programId: programId,
+ seeds: [new Uint8Array([0xde, 0xad])],
+ },
+ },
+ };
+ const encoded = codec.encode(original);
+ const decoded = codec.decode(encoded);
+
+ const decodedConfig = decoded.compressibleConfig as unknown as {
+ __option: 'Some';
+ value: {
+ tokenAccountVersion: number;
+ rentPayment: number;
+ compressionOnly: number;
+ writeTopUp: number;
+ compressToPubkey: {
+ __option: 'Some';
+ value: {
+ bump: number;
+ programId: Uint8Array;
+ seeds: Uint8Array[];
+ };
+ };
+ };
+ };
+ expect(decodedConfig.__option).toBe('Some');
+ expect(decodedConfig.value.tokenAccountVersion).toBe(1);
+ expect(decodedConfig.value.rentPayment).toBe(12);
+ expect(decodedConfig.value.compressionOnly).toBe(1);
+ expect(decodedConfig.value.writeTopUp).toBe(99999);
+ expect(decodedConfig.value.compressToPubkey.__option).toBe('Some');
+ expect(decodedConfig.value.compressToPubkey.value.bump).toBe(253);
+ expect(
+ new Uint8Array(decodedConfig.value.compressToPubkey.value.programId),
+ ).toEqual(programId);
+ expect(decodedConfig.value.compressToPubkey.value.seeds.length).toBe(1);
+ expect(
+ new Uint8Array(
+ decodedConfig.value.compressToPubkey.value.seeds[0],
+ ),
+ ).toEqual(new Uint8Array([0xde, 0xad]));
+ });
+});
+
+// ============================================================================
+// 9. CreateTokenAccountData codec roundtrip
+// ============================================================================
+
+describe('CreateTokenAccountData codec', () => {
+ const TEST_OWNER = address('11111111111111111111111111111111');
+
+ it('roundtrip without compressible config (null)', () => {
+ const codec = getCreateTokenAccountDataCodec();
+ const original: CreateTokenAccountInstructionData = {
+ owner: TEST_OWNER,
+ compressibleConfig: null,
+ };
+ const encoded = codec.encode(original);
+ const decoded = codec.decode(encoded);
+ expect(decoded.owner).toBe(TEST_OWNER);
+ expect(decoded.compressibleConfig).toEqual({ __option: 'None' });
+ });
+
+ it('roundtrip with compressible config', () => {
+ const codec = getCreateTokenAccountDataCodec();
+ const original: CreateTokenAccountInstructionData = {
+ owner: TEST_OWNER,
+ compressibleConfig: {
+ tokenAccountVersion: 3,
+ rentPayment: 16,
+ compressionOnly: 0,
+ writeTopUp: 766,
+ compressToPubkey: null,
+ },
+ };
+ const encoded = codec.encode(original);
+ const decoded = codec.decode(encoded) as unknown as {
+ owner: string;
+ compressibleConfig: {
+ __option: 'Some';
+ value: {
+ tokenAccountVersion: number;
+ rentPayment: number;
+ compressionOnly: number;
+ writeTopUp: number;
+ };
+ };
+ };
+ expect(decoded.owner).toBe(TEST_OWNER);
+ expect(decoded.compressibleConfig.__option).toBe('Some');
+ expect(decoded.compressibleConfig.value.tokenAccountVersion).toBe(3);
+ expect(decoded.compressibleConfig.value.rentPayment).toBe(16);
+ expect(decoded.compressibleConfig.value.compressionOnly).toBe(0);
+ expect(decoded.compressibleConfig.value.writeTopUp).toBe(766);
+ });
+
+ it('encodeCreateTokenAccountInstructionData supports full and owner-only payloads', () => {
+ const data: CreateTokenAccountInstructionData = {
+ owner: TEST_OWNER,
+ compressibleConfig: null,
+ };
+ const full = encodeCreateTokenAccountInstructionData(data);
+ const ownerOnly = encodeCreateTokenAccountInstructionData(data, true);
+
+ expect(full[0]).toBe(DISCRIMINATOR.CREATE_TOKEN_ACCOUNT);
+ expect(ownerOnly[0]).toBe(DISCRIMINATOR.CREATE_TOKEN_ACCOUNT);
+ expect(ownerOnly).toHaveLength(33);
+ expect(ownerOnly.slice(1)).toEqual(
+ new Uint8Array(getAddressCodec().encode(TEST_OWNER)),
+ );
+ expect(full.length).toBeGreaterThan(ownerOnly.length);
+ });
+});
+
+// ============================================================================
+// 10. AmountInstructionData codec roundtrip
+// ============================================================================
+
+describe('AmountInstructionData codec', () => {
+ it('roundtrip for transfer', () => {
+ const codec = getAmountInstructionCodec();
+ const original: AmountInstructionData = {
+ discriminator: 3,
+ amount: 1_000_000n,
+ };
+ const encoded = codec.encode(original);
+ const decoded = codec.decode(encoded);
+ expect(decoded).toEqual(original);
+ });
+
+ it('roundtrip for mint-to', () => {
+ const codec = getAmountInstructionCodec();
+ const original: AmountInstructionData = {
+ discriminator: 7,
+ amount: 5_000_000_000n,
+ };
+ const encoded = codec.encode(original);
+ const decoded = codec.decode(encoded);
+ expect(decoded).toEqual(original);
+ });
+
+ it('roundtrip for burn', () => {
+ const codec = getAmountInstructionCodec();
+ const original: AmountInstructionData = {
+ discriminator: 8,
+ amount: 250n,
+ };
+ const encoded = codec.encode(original);
+ const decoded = codec.decode(encoded);
+ expect(decoded).toEqual(original);
+ });
+
+ it('roundtrip for approve', () => {
+ const codec = getAmountInstructionCodec();
+ const original: AmountInstructionData = {
+ discriminator: 4,
+ amount: 999_999n,
+ };
+ const encoded = codec.encode(original);
+ const decoded = codec.decode(encoded);
+ expect(decoded).toEqual(original);
+ });
+
+ it('encoded size is 9 bytes (1 disc + 8 amount)', () => {
+ const codec = getAmountInstructionCodec();
+ const original: AmountInstructionData = {
+ discriminator: 3,
+ amount: 100n,
+ };
+ const encoded = codec.encode(original);
+ expect(encoded.length).toBe(9);
+ });
+});
+
+// ============================================================================
+// 10. CheckedInstructionData codec roundtrip
+// ============================================================================
+
+describe('CheckedInstructionData codec', () => {
+ it('roundtrip for transfer-checked', () => {
+ const codec = getCheckedInstructionCodec();
+ const original: CheckedInstructionData = {
+ discriminator: 12,
+ amount: 1_000_000n,
+ decimals: 9,
+ };
+ const encoded = codec.encode(original);
+ const decoded = codec.decode(encoded);
+ expect(decoded).toEqual(original);
+ });
+
+ it('roundtrip for mint-to-checked', () => {
+ const codec = getCheckedInstructionCodec();
+ const original: CheckedInstructionData = {
+ discriminator: 14,
+ amount: 50_000n,
+ decimals: 6,
+ };
+ const encoded = codec.encode(original);
+ const decoded = codec.decode(encoded);
+ expect(decoded).toEqual(original);
+ });
+
+ it('roundtrip for burn-checked', () => {
+ const codec = getCheckedInstructionCodec();
+ const original: CheckedInstructionData = {
+ discriminator: 15,
+ amount: 1n,
+ decimals: 0,
+ };
+ const encoded = codec.encode(original);
+ const decoded = codec.decode(encoded);
+ expect(decoded).toEqual(original);
+ });
+
+ it('encoded size is 10 bytes (1 disc + 8 amount + 1 decimals)', () => {
+ const codec = getCheckedInstructionCodec();
+ const original: CheckedInstructionData = {
+ discriminator: 12,
+ amount: 0n,
+ decimals: 0,
+ };
+ const encoded = codec.encode(original);
+ expect(encoded.length).toBe(10);
+ });
+});
+
+// ============================================================================
+// 11. DiscriminatorOnlyData codec roundtrip
+// ============================================================================
+
+describe('DiscriminatorOnlyData codec', () => {
+ it('roundtrip for revoke', () => {
+ const codec = getDiscriminatorOnlyCodec();
+ const original: DiscriminatorOnlyData = { discriminator: 5 };
+ const encoded = codec.encode(original);
+ const decoded = codec.decode(encoded);
+ expect(decoded).toEqual(original);
+ });
+
+ it('roundtrip for freeze', () => {
+ const codec = getDiscriminatorOnlyCodec();
+ const original: DiscriminatorOnlyData = { discriminator: 10 };
+ const encoded = codec.encode(original);
+ const decoded = codec.decode(encoded);
+ expect(decoded).toEqual(original);
+ });
+
+ it('roundtrip for thaw', () => {
+ const codec = getDiscriminatorOnlyCodec();
+ const original: DiscriminatorOnlyData = { discriminator: 11 };
+ const encoded = codec.encode(original);
+ const decoded = codec.decode(encoded);
+ expect(decoded).toEqual(original);
+ });
+
+ it('roundtrip for close', () => {
+ const codec = getDiscriminatorOnlyCodec();
+ const original: DiscriminatorOnlyData = { discriminator: 9 };
+ const encoded = codec.encode(original);
+ const decoded = codec.decode(encoded);
+ expect(decoded).toEqual(original);
+ });
+
+ it('encoded size is 1 byte', () => {
+ const codec = getDiscriminatorOnlyCodec();
+ const original: DiscriminatorOnlyData = { discriminator: 5 };
+ const encoded = codec.encode(original);
+ expect(encoded.length).toBe(1);
+ });
+});
+
+// ============================================================================
+// 12. MaxTopUp encode/decode
+// ============================================================================
+
+describe('MaxTopUp encode/decode', () => {
+ it('encodes undefined as empty bytes', () => {
+ const encoded = encodeMaxTopUp(undefined);
+ expect(encoded.length).toBe(0);
+ });
+
+ it('decodes undefined when no bytes remain', () => {
+ const data = new Uint8Array([0x03, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]);
+ // offset at 9 = data.length, so no bytes remain
+ const result = decodeMaxTopUp(data, 9);
+ expect(result).toBeUndefined();
+ });
+
+ it('roundtrip with a value', () => {
+ const value = 1234;
+ const encoded = encodeMaxTopUp(value);
+ expect(encoded.length).toBe(2);
+
+ // Place the encoded bytes into a buffer and decode at offset 0
+ const decoded = decodeMaxTopUp(encoded, 0);
+ expect(decoded).toBe(value);
+ });
+
+ it('roundtrip with zero', () => {
+ const value = 0;
+ const encoded = encodeMaxTopUp(value);
+ expect(encoded.length).toBe(2);
+ const decoded = decodeMaxTopUp(encoded, 0);
+ expect(decoded).toBe(0);
+ });
+
+ it('roundtrip with max u16 value', () => {
+ const value = 65535;
+ const encoded = encodeMaxTopUp(value);
+ expect(encoded.length).toBe(2);
+ const decoded = decodeMaxTopUp(encoded, 0);
+ expect(decoded).toBe(65535);
+ });
+
+ it('decodes from a specific offset within larger buffer', () => {
+ // Build a buffer: [disc(1 byte), amount(8 bytes), maxTopUp(2 bytes)]
+ const disc = new Uint8Array([3]);
+ const amount = new Uint8Array(8);
+ const topUpBytes = encodeMaxTopUp(500);
+ const buffer = new Uint8Array(1 + 8 + 2);
+ buffer.set(disc, 0);
+ buffer.set(amount, 1);
+ buffer.set(topUpBytes, 9);
+
+ const decoded = decodeMaxTopUp(buffer, 9);
+ expect(decoded).toBe(500);
+ });
+});
+
+// ============================================================================
+// 13. Edge cases
+// ============================================================================
+
+describe('Edge cases', () => {
+ it('max u64 amount in Compression', () => {
+ const codec = getCompressionCodec();
+ const original: Compression = {
+ mode: 0,
+ amount: 18446744073709551615n,
+ mint: 0,
+ sourceOrRecipient: 0,
+ authority: 0,
+ poolAccountIndex: 0,
+ poolIndex: 0,
+ bump: 0,
+ decimals: 0,
+ };
+ const encoded = codec.encode(original);
+ const decoded = codec.decode(encoded);
+ expect(decoded.amount).toBe(18446744073709551615n);
+ });
+
+ it('max u64 amount in AmountInstructionData', () => {
+ const codec = getAmountInstructionCodec();
+ const original: AmountInstructionData = {
+ discriminator: 3,
+ amount: 18446744073709551615n,
+ };
+ const encoded = codec.encode(original);
+ const decoded = codec.decode(encoded);
+ expect(decoded.amount).toBe(18446744073709551615n);
+ });
+
+ it('zero amount in all amount-bearing codecs', () => {
+ const amountCodec = getAmountInstructionCodec();
+ const amountData: AmountInstructionData = {
+ discriminator: 3,
+ amount: 0n,
+ };
+ expect(amountCodec.decode(amountCodec.encode(amountData)).amount).toBe(
+ 0n,
+ );
+
+ const checkedCodec = getCheckedInstructionCodec();
+ const checkedData: CheckedInstructionData = {
+ discriminator: 12,
+ amount: 0n,
+ decimals: 0,
+ };
+ expect(
+ checkedCodec.decode(checkedCodec.encode(checkedData)).amount,
+ ).toBe(0n);
+
+ const compressionCodec = getCompressionCodec();
+ const compressionData: Compression = {
+ mode: 0,
+ amount: 0n,
+ mint: 0,
+ sourceOrRecipient: 0,
+ authority: 0,
+ poolAccountIndex: 0,
+ poolIndex: 0,
+ bump: 0,
+ decimals: 0,
+ };
+ expect(
+ compressionCodec.decode(compressionCodec.encode(compressionData))
+ .amount,
+ ).toBe(0n);
+ });
+
+ it('all-zero CompressedProof', () => {
+ const codec = getCompressedProofCodec();
+ const original: CompressedProof = {
+ a: new Uint8Array(32),
+ b: new Uint8Array(64),
+ c: new Uint8Array(32),
+ };
+ const encoded = codec.encode(original);
+ const decoded = codec.decode(encoded);
+
+ // All bytes should be zero
+ expect(new Uint8Array(decoded.a).every((b) => b === 0)).toBe(true);
+ expect(new Uint8Array(decoded.b).every((b) => b === 0)).toBe(true);
+ expect(new Uint8Array(decoded.c).every((b) => b === 0)).toBe(true);
+ });
+
+ it('max u16 values in rootIndex and maxTopUp', () => {
+ const inputCodec = getMultiInputTokenDataCodec();
+ const inputData: MultiInputTokenDataWithContext = {
+ owner: 0,
+ amount: 0n,
+ hasDelegate: false,
+ delegate: 0,
+ mint: 0,
+ version: 0,
+ merkleContext: {
+ merkleTreePubkeyIndex: 0,
+ queuePubkeyIndex: 0,
+ leafIndex: 0,
+ proveByIndex: false,
+ },
+ rootIndex: 65535,
+ };
+ const decoded = inputCodec.decode(inputCodec.encode(inputData));
+ expect(decoded.rootIndex).toBe(65535);
+
+ const topUpEncoded = encodeMaxTopUp(65535);
+ const topUpDecoded = decodeMaxTopUp(topUpEncoded, 0);
+ expect(topUpDecoded).toBe(65535);
+ });
+
+ it('max u8 values in all u8 fields', () => {
+ const codec = getCompressionCodec();
+ const original: Compression = {
+ mode: 255,
+ amount: 0n,
+ mint: 255,
+ sourceOrRecipient: 255,
+ authority: 255,
+ poolAccountIndex: 255,
+ poolIndex: 255,
+ bump: 255,
+ decimals: 255,
+ };
+ const encoded = codec.encode(original);
+ const decoded = codec.decode(encoded);
+ expect(decoded).toEqual(original);
+ });
+});
+
+// ============================================================================
+// 14. TLV encoding in encodeTransfer2InstructionData
+// ============================================================================
+
+describe('TLV encoding via encodeTransfer2InstructionData', () => {
+ function makeMinimalTransfer2(
+ overrides?: Partial,
+ ): Transfer2InstructionData {
+ return {
+ withTransactionHash: false,
+ withLamportsChangeAccountMerkleTreeIndex: false,
+ lamportsChangeAccountMerkleTreeIndex: 0,
+ lamportsChangeAccountOwnerIndex: 0,
+ outputQueue: 0,
+ maxTopUp: 0,
+ cpiContext: null,
+ compressions: null,
+ proof: null,
+ inTokenData: [],
+ outTokenData: [],
+ inLamports: null,
+ outLamports: null,
+ inTlv: null,
+ outTlv: null,
+ ...overrides,
+ };
+ }
+
+ it('null TLV produces [0] (Option::None) byte', () => {
+ const data = makeMinimalTransfer2({
+ inTlv: null,
+ outTlv: null,
+ });
+ const encoded = encodeTransfer2InstructionData(data);
+
+ // The last 2 bytes should be [0] [0] for inTlv=None, outTlv=None
+ const lastTwo = encoded.slice(-2);
+ expect(lastTwo[0]).toBe(0); // inTlv: None
+ expect(lastTwo[1]).toBe(0); // outTlv: None
+ });
+
+ it('empty vec TLV produces [1, 0,0,0,0] (Option::Some(Vec[]))', () => {
+ const data = makeMinimalTransfer2({
+ inTlv: [],
+ outTlv: null,
+ });
+ const encoded = encodeTransfer2InstructionData(data);
+
+ // outTlv = None: last byte is 0
+ expect(encoded[encoded.length - 1]).toBe(0);
+
+ // inTlv = Some(Vec<>[]) = [1, 0,0,0,0]: 5 bytes before the last 1 byte
+ const inTlvStart = encoded.length - 1 - 5;
+ expect(encoded[inTlvStart]).toBe(1); // Option::Some
+ expect(encoded[inTlvStart + 1]).toBe(0); // u32 length = 0
+ expect(encoded[inTlvStart + 2]).toBe(0);
+ expect(encoded[inTlvStart + 3]).toBe(0);
+ expect(encoded[inTlvStart + 4]).toBe(0);
+ });
+
+ it('empty inner vec TLV produces correct bytes', () => {
+ // inTlv = Some(Vec[[]]) = [1, 1,0,0,0, 0,0,0,0]
+ // This is 1 (Some) + 4 (outer len=1) + 4 (inner len=0) = 9 bytes
+ const data = makeMinimalTransfer2({
+ inTlv: [[]],
+ outTlv: null,
+ });
+ const encoded = encodeTransfer2InstructionData(data);
+
+ // outTlv = None: last byte is 0
+ expect(encoded[encoded.length - 1]).toBe(0);
+
+ // inTlv = Some(Vec[ Vec[] ]) = [1, 1,0,0,0, 0,0,0,0]: 9 bytes before last 1 byte
+ const inTlvStart = encoded.length - 1 - 9;
+ expect(encoded[inTlvStart]).toBe(1); // Option::Some
+ // outer len = 1 (little-endian u32)
+ expect(encoded[inTlvStart + 1]).toBe(1);
+ expect(encoded[inTlvStart + 2]).toBe(0);
+ expect(encoded[inTlvStart + 3]).toBe(0);
+ expect(encoded[inTlvStart + 4]).toBe(0);
+ // inner len = 0 (little-endian u32)
+ expect(encoded[inTlvStart + 5]).toBe(0);
+ expect(encoded[inTlvStart + 6]).toBe(0);
+ expect(encoded[inTlvStart + 7]).toBe(0);
+ expect(encoded[inTlvStart + 8]).toBe(0);
+ });
+
+ it('encodes CompressedOnly extension in TLV', () => {
+ const data = makeMinimalTransfer2({
+ inTlv: [[{
+ type: 'CompressedOnly' as const,
+ data: {
+ delegatedAmount: 0n,
+ withheldTransferFee: 0n,
+ isFrozen: false,
+ compressionIndex: 0,
+ isAta: true,
+ bump: 255,
+ ownerIndex: 1,
+ },
+ }]],
+ outTlv: null,
+ });
+ const encoded = encodeTransfer2InstructionData(data);
+ // Should not throw - TLV serialization is now implemented
+ expect(encoded.length).toBeGreaterThan(0);
+ });
+
+ it('both TLV fields null', () => {
+ const data = makeMinimalTransfer2();
+ const encoded = encodeTransfer2InstructionData(data);
+
+ // Verify first byte is the discriminator (101 = TRANSFER2)
+ expect(encoded[0]).toBe(101);
+
+ // Last two bytes are both None (0)
+ expect(encoded[encoded.length - 2]).toBe(0);
+ expect(encoded[encoded.length - 1]).toBe(0);
+ });
+
+ it('encodes discriminator as first byte', () => {
+ const data = makeMinimalTransfer2();
+ const encoded = encodeTransfer2InstructionData(data);
+ expect(encoded[0]).toBe(101);
+ });
+});
+
+// ============================================================================
+// 15. Transfer2 base data roundtrip via encoder/decoder
+// ============================================================================
+
+describe('Transfer2 base data roundtrip', () => {
+ it('roundtrip with minimal data', () => {
+ const encoder = getTransfer2BaseEncoder();
+ const decoder = getTransfer2BaseDecoder();
+
+ const original = {
+ withTransactionHash: false,
+ withLamportsChangeAccountMerkleTreeIndex: false,
+ lamportsChangeAccountMerkleTreeIndex: 0,
+ lamportsChangeAccountOwnerIndex: 0,
+ outputQueue: 0,
+ maxTopUp: 0,
+ cpiContext: null,
+ compressions: null,
+ proof: null,
+ inTokenData: [],
+ outTokenData: [],
+ inLamports: null,
+ outLamports: null,
+ };
+ const encoded = encoder.encode(original);
+ const decoded = decoder.decode(encoded);
+
+ expect(decoded.withTransactionHash).toBe(false);
+ expect(decoded.outputQueue).toBe(0);
+ expect(decoded.maxTopUp).toBe(0);
+ expect(decoded.inTokenData).toHaveLength(0);
+ expect(decoded.outTokenData).toHaveLength(0);
+ });
+
+ it('roundtrip with populated fields', () => {
+ const encoder = getTransfer2BaseEncoder();
+ const decoder = getTransfer2BaseDecoder();
+
+ const original = {
+ withTransactionHash: true,
+ withLamportsChangeAccountMerkleTreeIndex: true,
+ lamportsChangeAccountMerkleTreeIndex: 5,
+ lamportsChangeAccountOwnerIndex: 3,
+ outputQueue: 2,
+ maxTopUp: 1000,
+ cpiContext: { setContext: true, firstSetContext: false },
+ compressions: null,
+ proof: null,
+ inTokenData: [
+ {
+ owner: 1,
+ amount: 5000n,
+ hasDelegate: false,
+ delegate: 0,
+ mint: 2,
+ version: 3,
+ merkleContext: {
+ merkleTreePubkeyIndex: 4,
+ queuePubkeyIndex: 5,
+ leafIndex: 100,
+ proveByIndex: true,
+ },
+ rootIndex: 42,
+ },
+ ],
+ outTokenData: [
+ {
+ owner: 6,
+ amount: 3000n,
+ hasDelegate: false,
+ delegate: 0,
+ mint: 2,
+ version: 3,
+ },
+ {
+ owner: 1,
+ amount: 2000n,
+ hasDelegate: false,
+ delegate: 0,
+ mint: 2,
+ version: 3,
+ },
+ ],
+ inLamports: null,
+ outLamports: null,
+ };
+ const encoded = encoder.encode(original);
+ const decoded = decoder.decode(encoded);
+
+ expect(decoded.withTransactionHash).toBe(true);
+ expect(decoded.lamportsChangeAccountMerkleTreeIndex).toBe(5);
+ expect(decoded.outputQueue).toBe(2);
+ expect(decoded.maxTopUp).toBe(1000);
+ expect(decoded.inTokenData).toHaveLength(1);
+ expect(decoded.inTokenData[0].amount).toBe(5000n);
+ expect(decoded.inTokenData[0].rootIndex).toBe(42);
+ expect(decoded.outTokenData).toHaveLength(2);
+ expect(decoded.outTokenData[0].amount).toBe(3000n);
+ expect(decoded.outTokenData[1].amount).toBe(2000n);
+ });
+
+ it('roundtrip with lamports fields', () => {
+ const encoder = getTransfer2BaseEncoder();
+ const decoder = getTransfer2BaseDecoder();
+
+ const original = {
+ withTransactionHash: false,
+ withLamportsChangeAccountMerkleTreeIndex: false,
+ lamportsChangeAccountMerkleTreeIndex: 0,
+ lamportsChangeAccountOwnerIndex: 0,
+ outputQueue: 0,
+ maxTopUp: 0,
+ cpiContext: null,
+ compressions: null,
+ proof: null,
+ inTokenData: [],
+ outTokenData: [],
+ inLamports: [1000000n, 2000000n],
+ outLamports: [3000000n],
+ };
+ const encoded = encoder.encode(original);
+ const decoded = decoder.decode(encoded);
+
+ // Option> fields
+ const inLamports = decoded.inLamports as unknown as {
+ __option: string;
+ value?: bigint[];
+ };
+ expect(inLamports.__option).toBe('Some');
+ expect(inLamports.value).toHaveLength(2);
+ expect(inLamports.value![0]).toBe(1000000n);
+ expect(inLamports.value![1]).toBe(2000000n);
+ });
+
+ it('roundtrip with compression operations', () => {
+ const encoder = getTransfer2BaseEncoder();
+ const decoder = getTransfer2BaseDecoder();
+
+ const original = {
+ withTransactionHash: false,
+ withLamportsChangeAccountMerkleTreeIndex: false,
+ lamportsChangeAccountMerkleTreeIndex: 0,
+ lamportsChangeAccountOwnerIndex: 0,
+ outputQueue: 0,
+ maxTopUp: 0,
+ cpiContext: null,
+ compressions: [
+ {
+ mode: 0,
+ amount: 1000000n,
+ mint: 1,
+ sourceOrRecipient: 2,
+ authority: 3,
+ poolAccountIndex: 4,
+ poolIndex: 0,
+ bump: 255,
+ decimals: 9,
+ },
+ ],
+ proof: null,
+ inTokenData: [],
+ outTokenData: [],
+ inLamports: null,
+ outLamports: null,
+ };
+ const encoded = encoder.encode(original);
+ const decoded = decoder.decode(encoded);
+
+ const compressions = decoded.compressions as unknown as {
+ __option: string;
+ value?: Compression[];
+ };
+ expect(compressions.__option).toBe('Some');
+ expect(compressions.value).toHaveLength(1);
+ expect(compressions.value![0].amount).toBe(1000000n);
+ expect(compressions.value![0].bump).toBe(255);
+ });
+});
+
+// ============================================================================
+// 16. Extension encoding byte-level tests
+// ============================================================================
+
+describe('Extension encoding byte-level', () => {
+ it('PausableAccount encodes as single discriminant byte [27]', () => {
+ const ext: ExtensionInstructionData = { type: 'PausableAccount' };
+ const encoded = encodeExtensionInstructionData(ext);
+ expect(encoded).toEqual(new Uint8Array([EXTENSION_DISCRIMINANT.PAUSABLE_ACCOUNT]));
+ expect(encoded.length).toBe(1);
+ });
+
+ it('PermanentDelegateAccount encodes as single discriminant byte [28]', () => {
+ const ext: ExtensionInstructionData = { type: 'PermanentDelegateAccount' };
+ const encoded = encodeExtensionInstructionData(ext);
+ expect(encoded).toEqual(new Uint8Array([EXTENSION_DISCRIMINANT.PERMANENT_DELEGATE_ACCOUNT]));
+ expect(encoded.length).toBe(1);
+ });
+
+ it('TransferFeeAccount encodes as single discriminant byte [29]', () => {
+ const ext: ExtensionInstructionData = { type: 'TransferFeeAccount' };
+ const encoded = encodeExtensionInstructionData(ext);
+ expect(encoded).toEqual(new Uint8Array([EXTENSION_DISCRIMINANT.TRANSFER_FEE_ACCOUNT]));
+ expect(encoded.length).toBe(1);
+ });
+
+ it('TransferHookAccount encodes as single discriminant byte [30]', () => {
+ const ext: ExtensionInstructionData = { type: 'TransferHookAccount' };
+ const encoded = encodeExtensionInstructionData(ext);
+ expect(encoded).toEqual(new Uint8Array([EXTENSION_DISCRIMINANT.TRANSFER_HOOK_ACCOUNT]));
+ expect(encoded.length).toBe(1);
+ });
+
+ it('CompressedOnly encodes discriminant [31] + 20 bytes of data', () => {
+ const ext: ExtensionInstructionData = {
+ type: 'CompressedOnly',
+ data: {
+ delegatedAmount: 1000n,
+ withheldTransferFee: 500n,
+ isFrozen: true,
+ compressionIndex: 42,
+ isAta: false,
+ bump: 253,
+ ownerIndex: 7,
+ },
+ };
+ const encoded = encodeExtensionInstructionData(ext);
+ expect(encoded[0]).toBe(EXTENSION_DISCRIMINANT.COMPRESSED_ONLY);
+ // CompressedOnly: u64(8) + u64(8) + bool(1) + u8(1) + bool(1) + u8(1) + u8(1) = 21 bytes + 1 disc
+ expect(encoded.length).toBe(22);
+
+ // Verify delegatedAmount (LE u64 at offset 1)
+ const view = new DataView(encoded.buffer, encoded.byteOffset);
+ expect(view.getBigUint64(1, true)).toBe(1000n);
+ // Verify withheldTransferFee (LE u64 at offset 9)
+ expect(view.getBigUint64(9, true)).toBe(500n);
+ // isFrozen (bool at offset 17)
+ expect(encoded[17]).toBe(1);
+ // compressionIndex (u8 at offset 18)
+ expect(encoded[18]).toBe(42);
+ // isAta (bool at offset 19)
+ expect(encoded[19]).toBe(0);
+ // bump (u8 at offset 20)
+ expect(encoded[20]).toBe(253);
+ // ownerIndex (u8 at offset 21)
+ expect(encoded[21]).toBe(7);
+ });
+
+ it('Compressible encodes discriminant [32] + CompressionInfo bytes', () => {
+ const compressionAuthority = new Uint8Array(32).fill(0xaa);
+ const rentSponsor = new Uint8Array(32).fill(0xbb);
+
+ const ext: ExtensionInstructionData = {
+ type: 'Compressible',
+ data: {
+ configAccountVersion: 1,
+ compressToPubkey: 2,
+ accountVersion: 0,
+ lamportsPerWrite: 5000,
+ compressionAuthority,
+ rentSponsor,
+ lastClaimedSlot: 42n,
+ rentExemptionPaid: 1000,
+ reserved: 0,
+ rentConfig: {
+ baseRent: 100,
+ compressionCost: 200,
+ lamportsPerBytePerEpoch: 3,
+ maxFundedEpochs: 10,
+ maxTopUp: 500,
+ },
+ },
+ };
+ const encoded = encodeExtensionInstructionData(ext);
+ expect(encoded[0]).toBe(EXTENSION_DISCRIMINANT.COMPRESSIBLE);
+ // CompressionInfo: u16(2) + u8(1) + u8(1) + u32(4) + pubkey(32) + pubkey(32)
+ // + u64(8) + u32(4) + u32(4) + RentConfig(2+2+1+1+2=8) = 96 bytes + 1 disc
+ expect(encoded.length).toBe(97);
+
+ const view = new DataView(encoded.buffer, encoded.byteOffset);
+ // configAccountVersion (u16 at offset 1)
+ expect(view.getUint16(1, true)).toBe(1);
+ // compressToPubkey (u8 at offset 3)
+ expect(encoded[3]).toBe(2);
+ // accountVersion (u8 at offset 4)
+ expect(encoded[4]).toBe(0);
+ // lamportsPerWrite (u32 at offset 5)
+ expect(view.getUint32(5, true)).toBe(5000);
+ // compressionAuthority (32 bytes at offset 9)
+ expect(encoded.slice(9, 41).every((b) => b === 0xaa)).toBe(true);
+ // rentSponsor (32 bytes at offset 41)
+ expect(encoded.slice(41, 73).every((b) => b === 0xbb)).toBe(true);
+ // lastClaimedSlot (u64 at offset 73)
+ expect(view.getBigUint64(73, true)).toBe(42n);
+ // rentExemptionPaid (u32 at offset 81)
+ expect(view.getUint32(81, true)).toBe(1000);
+ // reserved (u32 at offset 85)
+ expect(view.getUint32(85, true)).toBe(0);
+ // RentConfig.baseRent (u16 at offset 89)
+ expect(view.getUint16(89, true)).toBe(100);
+ // RentConfig.compressionCost (u16 at offset 91)
+ expect(view.getUint16(91, true)).toBe(200);
+ // RentConfig.lamportsPerBytePerEpoch (u8 at offset 93)
+ expect(encoded[93]).toBe(3);
+ // RentConfig.maxFundedEpochs (u8 at offset 94)
+ expect(encoded[94]).toBe(10);
+ // RentConfig.maxTopUp (u16 at offset 95)
+ expect(view.getUint16(95, true)).toBe(500);
+ });
+
+ it('TokenMetadata encodes discriminant [19] + metadata fields', () => {
+ const name = new TextEncoder().encode('TestToken');
+ const symbol = new TextEncoder().encode('TT');
+ const uri = new TextEncoder().encode('https://example.com');
+
+ const ext: ExtensionInstructionData = {
+ type: 'TokenMetadata',
+ data: {
+ updateAuthority: null,
+ name,
+ symbol,
+ uri,
+ additionalMetadata: null,
+ },
+ };
+ const encoded = encodeExtensionInstructionData(ext);
+ expect(encoded[0]).toBe(EXTENSION_DISCRIMINANT.TOKEN_METADATA);
+
+ // After disc: Option=None(1) + Vec name (4+9)
+ // + Vec symbol (4+2) + Vec uri (4+19) + Option=None(1)
+ // = 1 + 1 + 13 + 6 + 23 + 1 = 45
+ expect(encoded.length).toBe(45);
+
+ // updateAuthority = None
+ expect(encoded[1]).toBe(0);
+ // name Vec len
+ const view = new DataView(encoded.buffer, encoded.byteOffset);
+ expect(view.getUint32(2, true)).toBe(9);
+ // name content
+ const decodedName = new TextDecoder().decode(encoded.slice(6, 15));
+ expect(decodedName).toBe('TestToken');
+ });
+
+ it('TokenMetadata with updateAuthority and additionalMetadata', () => {
+ const name = new TextEncoder().encode('A');
+ const symbol = new TextEncoder().encode('B');
+ const uri = new TextEncoder().encode('C');
+ // Use a valid base58 address for updateAuthority
+ const updateAuthority = '11111111111111111111111111111111';
+
+ const ext: ExtensionInstructionData = {
+ type: 'TokenMetadata',
+ data: {
+ updateAuthority: updateAuthority as any,
+ name,
+ symbol,
+ uri,
+ additionalMetadata: [
+ {
+ key: new TextEncoder().encode('key1'),
+ value: new TextEncoder().encode('val1'),
+ },
+ ],
+ },
+ };
+ const encoded = encodeExtensionInstructionData(ext);
+ expect(encoded[0]).toBe(EXTENSION_DISCRIMINANT.TOKEN_METADATA);
+
+ // updateAuthority = Some (offset 1)
+ expect(encoded[1]).toBe(1);
+ // After updateAuthority (32 bytes) at offset 2..34
+ // name Vec: 4+1 at offset 34
+ const view = new DataView(encoded.buffer, encoded.byteOffset);
+ expect(view.getUint32(34, true)).toBe(1); // name len
+ // additionalMetadata = Some
+ // Find additionalMetadata option byte - it's after disc(1) + option(1) + pubkey(32)
+ // + name(4+1) + symbol(4+1) + uri(4+1) = 49
+ expect(encoded[49]).toBe(1); // Some
+ // Vec len = 1 (4 bytes)
+ expect(view.getUint32(50, true)).toBe(1);
+ });
+});
+
+// ============================================================================
+// 17. MintAction codec byte-level tests
+// ============================================================================
+
+describe('MintAction codec encoding', () => {
+ function makeMinimalMintAction(
+ overrides?: Partial,
+ ): MintActionInstructionData {
+ return {
+ leafIndex: 0,
+ proveByIndex: false,
+ rootIndex: 0,
+ maxTopUp: 0,
+ createMint: null,
+ actions: [],
+ proof: null,
+ cpiContext: null,
+ mint: null,
+ ...overrides,
+ };
+ }
+
+ it('starts with MINT_ACTION discriminator (103)', () => {
+ const data = makeMinimalMintAction();
+ const encoded = encodeMintActionInstructionData(data);
+ expect(encoded[0]).toBe(DISCRIMINATOR.MINT_ACTION);
+ expect(encoded[0]).toBe(103);
+ });
+
+ it('encodes fixed header fields correctly', () => {
+ const data = makeMinimalMintAction({
+ leafIndex: 12345,
+ proveByIndex: true,
+ rootIndex: 42,
+ maxTopUp: 1000,
+ });
+ const encoded = encodeMintActionInstructionData(data);
+ const view = new DataView(encoded.buffer, encoded.byteOffset);
+
+ // disc (1) + leafIndex (u32 at offset 1)
+ expect(view.getUint32(1, true)).toBe(12345);
+ // proveByIndex (bool at offset 5)
+ expect(encoded[5]).toBe(1);
+ // rootIndex (u16 at offset 6)
+ expect(view.getUint16(6, true)).toBe(42);
+ // maxTopUp (u16 at offset 8)
+ expect(view.getUint16(8, true)).toBe(1000);
+ });
+
+ it('encodes null createMint as Option::None [0]', () => {
+ const data = makeMinimalMintAction();
+ const encoded = encodeMintActionInstructionData(data);
+ // After fixed header: disc(1) + u32(4) + bool(1) + u16(2) + u16(2) = 10
+ expect(encoded[10]).toBe(0); // createMint = None
+ });
+
+ it('encodes createMint as Option::Some with tree and root indices', () => {
+ const addressTrees = new Uint8Array([1, 2, 3, 4]);
+ const data = makeMinimalMintAction({
+ createMint: {
+ readOnlyAddressTrees: addressTrees,
+ readOnlyAddressTreeRootIndices: [100, 200, 300, 400],
+ },
+ });
+ const encoded = encodeMintActionInstructionData(data);
+ // createMint = Some at offset 10
+ expect(encoded[10]).toBe(1);
+ // readOnlyAddressTrees (4 bytes at offset 11)
+ expect(encoded[11]).toBe(1);
+ expect(encoded[12]).toBe(2);
+ expect(encoded[13]).toBe(3);
+ expect(encoded[14]).toBe(4);
+ // 4 x u16 root indices at offset 15
+ const view = new DataView(encoded.buffer, encoded.byteOffset);
+ expect(view.getUint16(15, true)).toBe(100);
+ expect(view.getUint16(17, true)).toBe(200);
+ expect(view.getUint16(19, true)).toBe(300);
+ expect(view.getUint16(21, true)).toBe(400);
+ });
+
+ it('encodes empty actions vec as [0,0,0,0]', () => {
+ const data = makeMinimalMintAction();
+ const encoded = encodeMintActionInstructionData(data);
+ // After None createMint: offset 11 = actions vec length (u32)
+ const view = new DataView(encoded.buffer, encoded.byteOffset);
+ expect(view.getUint32(11, true)).toBe(0);
+ });
+
+ it('encodes MintToCompressed action (discriminant 0)', () => {
+ const recipient = new Uint8Array(32).fill(0xab);
+ const data = makeMinimalMintAction({
+ actions: [
+ {
+ type: 'MintToCompressed',
+ tokenAccountVersion: 3,
+ recipients: [{ recipient, amount: 1000000n }],
+ },
+ ],
+ });
+ const encoded = encodeMintActionInstructionData(data);
+ const view = new DataView(encoded.buffer, encoded.byteOffset);
+
+ // actions vec len = 1 at offset 11
+ expect(view.getUint32(11, true)).toBe(1);
+ // action disc = 0 at offset 15
+ expect(encoded[15]).toBe(0);
+ // tokenAccountVersion = 3 at offset 16
+ expect(encoded[16]).toBe(3);
+ // recipients vec len = 1 at offset 17
+ expect(view.getUint32(17, true)).toBe(1);
+ // recipient pubkey (32 bytes at offset 21)
+ expect(encoded[21]).toBe(0xab);
+ expect(encoded[52]).toBe(0xab);
+ // amount (u64 at offset 53)
+ expect(view.getBigUint64(53, true)).toBe(1000000n);
+ });
+
+ it('encodes MintTo action (discriminant 3)', () => {
+ const data = makeMinimalMintAction({
+ actions: [
+ {
+ type: 'MintTo',
+ accountIndex: 5,
+ amount: 999n,
+ },
+ ],
+ });
+ const encoded = encodeMintActionInstructionData(data);
+ const view = new DataView(encoded.buffer, encoded.byteOffset);
+
+ expect(view.getUint32(11, true)).toBe(1);
+ expect(encoded[15]).toBe(3); // MintTo disc
+ expect(encoded[16]).toBe(5); // accountIndex
+ expect(view.getBigUint64(17, true)).toBe(999n);
+ });
+
+ it('encodes UpdateMintAuthority action (discriminant 1)', () => {
+ const newAuth = new Uint8Array(32).fill(0xcc);
+ const data = makeMinimalMintAction({
+ actions: [
+ {
+ type: 'UpdateMintAuthority',
+ newAuthority: newAuth,
+ },
+ ],
+ });
+ const encoded = encodeMintActionInstructionData(data);
+
+ expect(encoded[15]).toBe(1); // UpdateMintAuthority disc
+ expect(encoded[16]).toBe(1); // Option::Some
+ expect(encoded[17]).toBe(0xcc); // first byte of authority
+ });
+
+ it('encodes UpdateMintAuthority with null (revoke)', () => {
+ const data = makeMinimalMintAction({
+ actions: [
+ {
+ type: 'UpdateMintAuthority',
+ newAuthority: null,
+ },
+ ],
+ });
+ const encoded = encodeMintActionInstructionData(data);
+
+ expect(encoded[15]).toBe(1); // UpdateMintAuthority disc
+ expect(encoded[16]).toBe(0); // Option::None
+ });
+
+ it('encodes UpdateFreezeAuthority action (discriminant 2)', () => {
+ const data = makeMinimalMintAction({
+ actions: [
+ {
+ type: 'UpdateFreezeAuthority',
+ newAuthority: null,
+ },
+ ],
+ });
+ const encoded = encodeMintActionInstructionData(data);
+
+ expect(encoded[15]).toBe(2); // UpdateFreezeAuthority disc
+ expect(encoded[16]).toBe(0); // None
+ });
+
+ it('encodes UpdateMetadataField action (discriminant 4)', () => {
+ const key = new TextEncoder().encode('name');
+ const value = new TextEncoder().encode('NewName');
+ const data = makeMinimalMintAction({
+ actions: [
+ {
+ type: 'UpdateMetadataField',
+ extensionIndex: 0,
+ fieldType: 0, // Name
+ key,
+ value,
+ },
+ ],
+ });
+ const encoded = encodeMintActionInstructionData(data);
+ const view = new DataView(encoded.buffer, encoded.byteOffset);
+
+ expect(encoded[15]).toBe(4); // UpdateMetadataField disc
+ expect(encoded[16]).toBe(0); // extensionIndex
+ expect(encoded[17]).toBe(0); // fieldType (Name)
+ // key Vec: len=4 at offset 18
+ expect(view.getUint32(18, true)).toBe(4);
+ // key content at offset 22
+ expect(new TextDecoder().decode(encoded.slice(22, 26))).toBe('name');
+ // value Vec: len=7 at offset 26
+ expect(view.getUint32(26, true)).toBe(7);
+ expect(new TextDecoder().decode(encoded.slice(30, 37))).toBe('NewName');
+ });
+
+ it('encodes UpdateMetadataAuthority action (discriminant 5)', () => {
+ const newAuth = new Uint8Array(32).fill(0xdd);
+ const data = makeMinimalMintAction({
+ actions: [
+ {
+ type: 'UpdateMetadataAuthority',
+ extensionIndex: 2,
+ newAuthority: newAuth,
+ },
+ ],
+ });
+ const encoded = encodeMintActionInstructionData(data);
+
+ expect(encoded[15]).toBe(5); // disc
+ expect(encoded[16]).toBe(2); // extensionIndex
+ expect(encoded[17]).toBe(0xdd); // first byte of authority
+ });
+
+ it('encodes RemoveMetadataKey action (discriminant 6)', () => {
+ const key = new TextEncoder().encode('key1');
+ const data = makeMinimalMintAction({
+ actions: [
+ {
+ type: 'RemoveMetadataKey',
+ extensionIndex: 1,
+ key,
+ idempotent: 1,
+ },
+ ],
+ });
+ const encoded = encodeMintActionInstructionData(data);
+ const view = new DataView(encoded.buffer, encoded.byteOffset);
+
+ expect(encoded[15]).toBe(6); // disc
+ expect(encoded[16]).toBe(1); // extensionIndex
+ expect(view.getUint32(17, true)).toBe(4); // key Vec len
+ expect(new TextDecoder().decode(encoded.slice(21, 25))).toBe('key1');
+ expect(encoded[25]).toBe(1); // idempotent
+ });
+
+ it('encodes DecompressMint action (discriminant 7)', () => {
+ const data = makeMinimalMintAction({
+ actions: [
+ {
+ type: 'DecompressMint',
+ rentPayment: 5,
+ writeTopUp: 10000,
+ },
+ ],
+ });
+ const encoded = encodeMintActionInstructionData(data);
+ const view = new DataView(encoded.buffer, encoded.byteOffset);
+
+ expect(encoded[15]).toBe(7); // disc
+ expect(encoded[16]).toBe(5); // rentPayment (u8)
+ expect(view.getUint32(17, true)).toBe(10000); // writeTopUp (u32)
+ });
+
+ it('encodes CompressAndCloseMint action (discriminant 8)', () => {
+ const data = makeMinimalMintAction({
+ actions: [
+ {
+ type: 'CompressAndCloseMint',
+ idempotent: 1,
+ },
+ ],
+ });
+ const encoded = encodeMintActionInstructionData(data);
+
+ expect(encoded[15]).toBe(8); // disc
+ expect(encoded[16]).toBe(1); // idempotent
+ });
+
+ it('encodes multiple actions sequentially', () => {
+ const data = makeMinimalMintAction({
+ actions: [
+ { type: 'CompressAndCloseMint', idempotent: 0 },
+ { type: 'MintTo', accountIndex: 1, amount: 100n },
+ ],
+ });
+ const encoded = encodeMintActionInstructionData(data);
+ const view = new DataView(encoded.buffer, encoded.byteOffset);
+
+ // actions vec len = 2
+ expect(view.getUint32(11, true)).toBe(2);
+ // First action: CompressAndCloseMint
+ expect(encoded[15]).toBe(8);
+ expect(encoded[16]).toBe(0);
+ // Second action: MintTo at offset 17
+ expect(encoded[17]).toBe(3);
+ });
+
+ it('encodes MintMetadata as fixed 67 bytes', () => {
+ const mint = new Uint8Array(32).fill(0x11);
+ const mintSigner = new Uint8Array(32).fill(0x22);
+
+ const metadata: MintMetadata = {
+ version: 1,
+ mintDecompressed: true,
+ mint,
+ mintSigner,
+ bump: 254,
+ };
+
+ const mintData: MintInstructionData = {
+ supply: 1000000n,
+ decimals: 9,
+ metadata,
+ mintAuthority: null,
+ freezeAuthority: null,
+ extensions: null,
+ };
+
+ const data = makeMinimalMintAction({ mint: mintData });
+ const encoded = encodeMintActionInstructionData(data);
+
+ // Find the mint data section. After:
+ // disc(1) + header(9) + createMint None(1) + actions Vec(4) + proof None(1) + cpiContext None(1)
+ // = 17 bytes, then mint = Some(1) = offset 17
+ // But wait: actions is empty so no action bytes. Let me calculate:
+ // disc(1) + leafIndex(4) + proveByIndex(1) + rootIndex(2) + maxTopUp(2) = 10
+ // + createMint None(1) = 11
+ // + actions vec len(4) + 0 action bytes = 15
+ // + proof None(1) = 16
+ // + cpiContext None(1) = 17
+ // + mint Some(1) = 18
+ // + supply(8) = offset 18..26
+ // + decimals(1) = offset 26
+ // + MintMetadata starts at offset 27
+
+ const view = new DataView(encoded.buffer, encoded.byteOffset);
+
+ // mint option = Some at offset 17
+ expect(encoded[17]).toBe(1);
+ // supply (u64)
+ expect(view.getBigUint64(18, true)).toBe(1000000n);
+ // decimals
+ expect(encoded[26]).toBe(9);
+
+ // MintMetadata at offset 27:
+ // version (u8)
+ expect(encoded[27]).toBe(1);
+ // mintDecompressed (bool)
+ expect(encoded[28]).toBe(1);
+ // mint pubkey (32 bytes)
+ expect(encoded[29]).toBe(0x11);
+ expect(encoded[60]).toBe(0x11);
+ // mintSigner (32 bytes starting at offset 61)
+ expect(encoded[61]).toBe(0x22);
+ expect(encoded[92]).toBe(0x22);
+ // bump (u8 at offset 93)
+ expect(encoded[93]).toBe(254);
+
+ // Total MintMetadata = 1 + 1 + 32 + 32 + 1 = 67 bytes
+ const metadataSlice = encoded.slice(27, 94);
+ expect(metadataSlice.length).toBe(67);
+ });
+
+ it('encodes MintInstructionData with authorities and extensions', () => {
+ const mint = new Uint8Array(32).fill(0);
+ const mintSigner = new Uint8Array(32).fill(0);
+ const mintAuth = new Uint8Array(32).fill(0xaa);
+ const freezeAuth = new Uint8Array(32).fill(0xbb);
+
+ const mintData: MintInstructionData = {
+ supply: 0n,
+ decimals: 6,
+ metadata: {
+ version: 0,
+ mintDecompressed: false,
+ mint,
+ mintSigner,
+ bump: 0,
+ },
+ mintAuthority: mintAuth,
+ freezeAuthority: freezeAuth,
+ extensions: [{ type: 'PausableAccount' }],
+ };
+
+ const data = makeMinimalMintAction({ mint: mintData });
+ const encoded = encodeMintActionInstructionData(data);
+
+ // After MintMetadata (67 bytes starting at offset 27, ends at offset 94):
+ // mintAuthority = Some(1) + 32 bytes at offset 94
+ expect(encoded[94]).toBe(1); // Some
+ expect(encoded[95]).toBe(0xaa); // first byte
+ // freezeAuthority = Some(1) + 32 bytes at offset 127
+ expect(encoded[127]).toBe(1); // Some
+ expect(encoded[128]).toBe(0xbb); // first byte
+ // extensions = Some(1) + Vec len(4) + PausableAccount disc(1) at offset 160
+ expect(encoded[160]).toBe(1); // Some
+ const view = new DataView(encoded.buffer, encoded.byteOffset);
+ expect(view.getUint32(161, true)).toBe(1); // Vec len
+ expect(encoded[165]).toBe(27); // PausableAccount discriminant
+ });
+
+ it('encodes MintActionCpiContext with all fields', () => {
+ const addressTreePubkey = new Uint8Array(32).fill(0xee);
+ const readOnlyAddressTrees = new Uint8Array([10, 20, 30, 40]);
+
+ const cpiCtx: MintActionCpiContext = {
+ setContext: true,
+ firstSetContext: false,
+ inTreeIndex: 1,
+ inQueueIndex: 2,
+ outQueueIndex: 3,
+ tokenOutQueueIndex: 4,
+ assignedAccountIndex: 5,
+ readOnlyAddressTrees,
+ addressTreePubkey,
+ };
+
+ const data = makeMinimalMintAction({ cpiContext: cpiCtx });
+ const encoded = encodeMintActionInstructionData(data);
+
+ // After disc(1) + header(9) + createMint None(1) + actions(4) + proof None(1) = 16
+ // cpiContext Some(1) at offset 16
+ expect(encoded[16]).toBe(1);
+ // setContext (bool at offset 17)
+ expect(encoded[17]).toBe(1);
+ // firstSetContext (bool at offset 18)
+ expect(encoded[18]).toBe(0);
+ // inTreeIndex (u8 at 19)
+ expect(encoded[19]).toBe(1);
+ // inQueueIndex (u8 at 20)
+ expect(encoded[20]).toBe(2);
+ // outQueueIndex (u8 at 21)
+ expect(encoded[21]).toBe(3);
+ // tokenOutQueueIndex (u8 at 22)
+ expect(encoded[22]).toBe(4);
+ // assignedAccountIndex (u8 at 23)
+ expect(encoded[23]).toBe(5);
+ // readOnlyAddressTrees (4 bytes at 24)
+ expect(encoded[24]).toBe(10);
+ expect(encoded[25]).toBe(20);
+ expect(encoded[26]).toBe(30);
+ expect(encoded[27]).toBe(40);
+ // addressTreePubkey (32 bytes at 28)
+ expect(encoded[28]).toBe(0xee);
+ });
+
+ it('encodes proof via CompressedProof encoder', () => {
+ const proof = {
+ a: new Uint8Array(32).fill(0x11),
+ b: new Uint8Array(64).fill(0x22),
+ c: new Uint8Array(32).fill(0x33),
+ };
+
+ const data = makeMinimalMintAction({ proof });
+ const encoded = encodeMintActionInstructionData(data);
+
+ // proof at offset 15 (after disc(1) + header(9) + None(1) + actionsVec(4))
+ expect(encoded[15]).toBe(1); // Some
+ // proof.a (32 bytes at offset 16)
+ expect(encoded[16]).toBe(0x11);
+ // proof.b (64 bytes at offset 48)
+ expect(encoded[48]).toBe(0x22);
+ // proof.c (32 bytes at offset 112)
+ expect(encoded[112]).toBe(0x33);
+ // Total proof = 128 bytes, offset 16..144
+ });
+});
+
+// ============================================================================
+// 18. TLV content verification (byte-level extension data in Transfer2)
+// ============================================================================
+
+describe('TLV content verification', () => {
+ function makeMinimalTransfer2WithTlv(
+ inTlv: ExtensionInstructionData[][] | null,
+ outTlv: ExtensionInstructionData[][] | null,
+ ): Transfer2InstructionData {
+ return {
+ withTransactionHash: false,
+ withLamportsChangeAccountMerkleTreeIndex: false,
+ lamportsChangeAccountMerkleTreeIndex: 0,
+ lamportsChangeAccountOwnerIndex: 0,
+ outputQueue: 0,
+ maxTopUp: 0,
+ cpiContext: null,
+ compressions: null,
+ proof: null,
+ inTokenData: [],
+ outTokenData: [],
+ inLamports: null,
+ outLamports: null,
+ inTlv,
+ outTlv,
+ };
+ }
+
+ it('multiple extensions per account are encoded sequentially', () => {
+ const data = makeMinimalTransfer2WithTlv(
+ [[
+ { type: 'PausableAccount' },
+ { type: 'PermanentDelegateAccount' },
+ { type: 'TransferFeeAccount' },
+ ]],
+ null,
+ );
+ const encoded = encodeTransfer2InstructionData(data);
+
+ // outTlv = None: last byte is 0
+ expect(encoded[encoded.length - 1]).toBe(0);
+
+ // inTlv structure: Some(1) + outer_len=1(4) + inner_len=3(4) + ext1(1) + ext2(1) + ext3(1)
+ // = 12 bytes before the last None byte
+ const inTlvStart = encoded.length - 1 - 12;
+ expect(encoded[inTlvStart]).toBe(1); // Some
+ // outer len = 1
+ const view = new DataView(encoded.buffer, encoded.byteOffset);
+ expect(view.getUint32(inTlvStart + 1, true)).toBe(1);
+ // inner len = 3
+ expect(view.getUint32(inTlvStart + 5, true)).toBe(3);
+ // extensions
+ expect(encoded[inTlvStart + 9]).toBe(27); // PausableAccount
+ expect(encoded[inTlvStart + 10]).toBe(28); // PermanentDelegateAccount
+ expect(encoded[inTlvStart + 11]).toBe(29); // TransferFeeAccount
+ });
+
+ it('multiple accounts with different extensions', () => {
+ const data = makeMinimalTransfer2WithTlv(
+ [
+ [{ type: 'PausableAccount' }],
+ [{ type: 'TransferHookAccount' }],
+ ],
+ null,
+ );
+ const encoded = encodeTransfer2InstructionData(data);
+
+ expect(encoded[encoded.length - 1]).toBe(0); // outTlv None
+
+ // inTlv: Some(1) + outer_len=2(4) + inner1_len=1(4) + ext1(1) + inner2_len=1(4) + ext2(1)
+ // = 15 bytes before last None byte
+ const inTlvStart = encoded.length - 1 - 15;
+ expect(encoded[inTlvStart]).toBe(1); // Some
+ const view = new DataView(encoded.buffer, encoded.byteOffset);
+ expect(view.getUint32(inTlvStart + 1, true)).toBe(2); // 2 accounts
+ // First inner vec
+ expect(view.getUint32(inTlvStart + 5, true)).toBe(1);
+ expect(encoded[inTlvStart + 9]).toBe(27); // PausableAccount
+ // Second inner vec
+ expect(view.getUint32(inTlvStart + 10, true)).toBe(1);
+ expect(encoded[inTlvStart + 14]).toBe(30); // TransferHookAccount
+ });
+
+ it('both inTlv and outTlv populated', () => {
+ const data = makeMinimalTransfer2WithTlv(
+ [[{ type: 'PausableAccount' }]],
+ [[{ type: 'TransferFeeAccount' }]],
+ );
+ const encoded = encodeTransfer2InstructionData(data);
+
+ // outTlv at the end: Some(1) + outer_len=1(4) + inner_len=1(4) + ext(1) = 10 bytes
+ const outTlvStart = encoded.length - 10;
+ expect(encoded[outTlvStart]).toBe(1); // Some
+ const view = new DataView(encoded.buffer, encoded.byteOffset);
+ expect(view.getUint32(outTlvStart + 1, true)).toBe(1);
+ expect(view.getUint32(outTlvStart + 5, true)).toBe(1);
+ expect(encoded[outTlvStart + 9]).toBe(29); // TransferFeeAccount
+
+ // inTlv before outTlv: also 10 bytes
+ const inTlvStart = outTlvStart - 10;
+ expect(encoded[inTlvStart]).toBe(1); // Some
+ expect(view.getUint32(inTlvStart + 1, true)).toBe(1);
+ expect(view.getUint32(inTlvStart + 5, true)).toBe(1);
+ expect(encoded[inTlvStart + 9]).toBe(27); // PausableAccount
+ });
+
+ it('CompressedOnly extension data bytes are correct in TLV', () => {
+ const data = makeMinimalTransfer2WithTlv(
+ [[{
+ type: 'CompressedOnly',
+ data: {
+ delegatedAmount: 42n,
+ withheldTransferFee: 0n,
+ isFrozen: false,
+ compressionIndex: 1,
+ isAta: true,
+ bump: 200,
+ ownerIndex: 3,
+ },
+ }]],
+ null,
+ );
+ const encoded = encodeTransfer2InstructionData(data);
+ expect(encoded[encoded.length - 1]).toBe(0); // outTlv None
+
+ // inTlv: Some(1) + outer(4) + inner(4) + disc(1) + CompressedOnly(21) = 31 before outTlv
+ const inTlvStart = encoded.length - 1 - 31;
+ expect(encoded[inTlvStart]).toBe(1); // Some
+ const extStart = inTlvStart + 9; // after Some + outerLen + innerLen
+ expect(encoded[extStart]).toBe(31); // CompressedOnly disc
+ const view = new DataView(encoded.buffer, encoded.byteOffset);
+ expect(view.getBigUint64(extStart + 1, true)).toBe(42n); // delegatedAmount
+ expect(view.getBigUint64(extStart + 9, true)).toBe(0n); // withheldTransferFee
+ expect(encoded[extStart + 17]).toBe(0); // isFrozen
+ expect(encoded[extStart + 18]).toBe(1); // compressionIndex
+ expect(encoded[extStart + 19]).toBe(1); // isAta
+ expect(encoded[extStart + 20]).toBe(200); // bump
+ expect(encoded[extStart + 21]).toBe(3); // ownerIndex
+ });
+});
diff --git a/js/token-kit/tests/unit/helpers.ts b/js/token-kit/tests/unit/helpers.ts
new file mode 100644
index 0000000000..fd9a358c4b
--- /dev/null
+++ b/js/token-kit/tests/unit/helpers.ts
@@ -0,0 +1,343 @@
+/**
+ * Shared test helpers for unit tests.
+ */
+
+import { address } from '@solana/addresses';
+import { vi } from 'vitest';
+
+import {
+ type CompressedTokenAccount,
+ type CompressedAccount,
+ type TreeInfo,
+ type LightIndexer,
+ type MintContext,
+ type SplInterfaceInfo,
+ type BuilderRpc,
+ TreeType,
+ AccountState,
+ SPL_TOKEN_PROGRAM_ID,
+} from '../../src/index.js';
+import type { DeserializedCompressedMint } from '../../src/codecs/mint-deserialize.js';
+
+// ============================================================================
+// CONSTANTS
+// ============================================================================
+
+export const MOCK_TREE = address('amt2kaJA14v3urZbZvnc5v2np8jqvc4Z8zDep5wbtzx');
+export const MOCK_QUEUE = address('SySTEM1eSU2p4BGQfQpimFEWWSC1XDFeun3Nqzz3rT7');
+export const MOCK_MINT = address('So11111111111111111111111111111111111111112');
+export const MOCK_OWNER = address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA');
+export const MOCK_CTOKEN_PROGRAM = address('cTokenmWW8bLPjZEBAUgYy3zKxQZW6VKi7bqNFEVv3m');
+export const MOCK_POOL = address('GXtd2izAiMJPwMEjfgTRH3d7k9mjn4Jq3JrWFv9gySYy');
+export const MOCK_MINT_SIGNER = address('BPFLoaderUpgradeab1e11111111111111111111111');
+
+// ============================================================================
+// EXISTING HELPERS
+// ============================================================================
+
+export function createMockTokenAccount(amount: bigint): CompressedTokenAccount {
+ const mockTreeInfo: TreeInfo = {
+ tree: MOCK_TREE,
+ queue: MOCK_QUEUE,
+ treeType: TreeType.StateV2,
+ };
+ const mockAccount: CompressedAccount = {
+ hash: new Uint8Array(32),
+ address: null,
+ owner: MOCK_CTOKEN_PROGRAM,
+ lamports: 0n,
+ data: null,
+ leafIndex: 0,
+ treeInfo: mockTreeInfo,
+ proveByIndex: false,
+ seq: null,
+ slotCreated: 0n,
+ };
+ return {
+ token: {
+ mint: MOCK_MINT,
+ owner: MOCK_OWNER,
+ amount,
+ delegate: null,
+ state: AccountState.Initialized,
+ tlv: null,
+ },
+ account: mockAccount,
+ };
+}
+
+export function createMockTreeInfo(
+ treeType: TreeType,
+ nextTree?: TreeInfo,
+): TreeInfo {
+ return {
+ tree: MOCK_TREE,
+ queue: MOCK_QUEUE,
+ treeType,
+ nextTreeInfo: nextTree,
+ };
+}
+
+// ============================================================================
+// MOCK INDEXER
+// ============================================================================
+
+/**
+ * Creates a mock LightIndexer with all methods stubbed via vi.fn().
+ * Pass overrides to mock specific return values.
+ */
+export function createMockIndexer(
+ overrides?: Partial,
+): LightIndexer {
+ return {
+ getCompressedAccount: vi.fn(),
+ getCompressedAccountByHash: vi.fn(),
+ getCompressedTokenAccountsByOwner: vi.fn(),
+ getMultipleCompressedAccounts: vi.fn(),
+ getValidityProof: vi.fn(),
+ getCompressedTokenBalancesByOwner: vi.fn(),
+ getCompressedMintTokenHolders: vi.fn(),
+ getCompressedTokenAccountBalance: vi.fn(),
+ getSignaturesForTokenOwner: vi.fn(),
+ ...overrides,
+ };
+}
+
+// ============================================================================
+// MOCK RPC
+// ============================================================================
+
+/**
+ * Creates a mock BuilderRpc with getAccountInfo stubbed.
+ * Default: returns null (account not found).
+ */
+export function createMockRpc(
+ overrides?: Partial,
+): BuilderRpc {
+ return {
+ getAccountInfo: vi.fn().mockResolvedValue({ value: null }),
+ ...overrides,
+ };
+}
+
+/**
+ * Creates base64-encoded SPL mint account data (82 bytes) with the given decimals.
+ * Returns [base64String, 'base64'] tuple matching the RPC response shape.
+ */
+export function createBase64MintData(
+ decimals: number,
+ supply: bigint = 1000000n,
+ hasFreezeAuthority = false,
+): [string, string] {
+ const data = new Uint8Array(82);
+ const view = new DataView(data.buffer);
+ // mintAuthorityOption = 1
+ view.setUint32(0, 1, true);
+ // supply at offset 36
+ view.setBigUint64(36, supply, true);
+ // decimals at offset 44
+ data[44] = decimals;
+ // isInitialized at offset 45
+ data[45] = 1;
+ // freezeAuthorityOption at offset 46
+ view.setUint32(46, hasFreezeAuthority ? 1 : 0, true);
+
+ const base64 = btoa(String.fromCharCode(...data));
+ return [base64, 'base64'];
+}
+
+/**
+ * Creates a mock RPC that returns valid mint data for getAccountInfo calls.
+ */
+export function createMockRpcWithMint(
+ decimals: number,
+ supply: bigint = 1000000n,
+): BuilderRpc {
+ const mintData = createBase64MintData(decimals, supply);
+ return createMockRpc({
+ getAccountInfo: vi.fn().mockResolvedValue({
+ value: {
+ owner: SPL_TOKEN_PROGRAM_ID,
+ data: mintData,
+ },
+ }),
+ });
+}
+
+// ============================================================================
+// MOCK MINT CONTEXT
+// ============================================================================
+
+/**
+ * Creates a 149-byte compressed mint data Uint8Array.
+ * Layout: BaseMint(0-81) + MintContext(82-148).
+ */
+export function createMockCompressedMintData(
+ decimals = 9,
+ supply = 1000000n,
+): Uint8Array {
+ const data = new Uint8Array(149);
+ const view = new DataView(data.buffer);
+ // BaseMint
+ view.setUint32(0, 1, true); // mintAuthorityOption = 1
+ data.set(new Uint8Array(32).fill(0x11), 4); // mintAuthority
+ view.setBigUint64(36, supply, true); // supply
+ data[44] = decimals;
+ data[45] = 1; // isInitialized
+ view.setUint32(46, 0, true); // freezeAuthorityOption = 0
+ // MintContext
+ data[82] = 0; // version
+ data[83] = 0; // cmintDecompressed = false
+ data.set(new Uint8Array(32).fill(0x22), 84); // splMint
+ data.set(new Uint8Array(32).fill(0x33), 116); // mintSigner
+ data[148] = 254; // bump
+ return data;
+}
+
+/**
+ * Creates a mock MintContext for builders that accept mintContext override.
+ * All fields populated with consistent test values.
+ */
+export function createMockMintContext(
+ overrides?: Partial,
+): MintContext {
+ const mintData = createMockCompressedMintData();
+ const mockDeserializedMint: DeserializedCompressedMint = {
+ base: {
+ mintAuthorityOption: 1,
+ mintAuthority: new Uint8Array(32).fill(0x11),
+ supply: 1000000n,
+ decimals: 9,
+ isInitialized: true,
+ freezeAuthorityOption: 0,
+ freezeAuthority: new Uint8Array(32),
+ },
+ mintContext: {
+ version: 0,
+ cmintDecompressed: false,
+ splMint: new Uint8Array(32).fill(0x22),
+ mintSigner: new Uint8Array(32).fill(0x33),
+ bump: 254,
+ },
+ metadataExtensionIndex: 0,
+ };
+
+ const mockAccount: CompressedAccount = {
+ hash: new Uint8Array(32).fill(0xaa),
+ address: new Uint8Array(32).fill(0xbb),
+ owner: MOCK_CTOKEN_PROGRAM,
+ lamports: 0n,
+ data: {
+ discriminator: new Uint8Array(8),
+ data: mintData,
+ dataHash: new Uint8Array(32),
+ },
+ leafIndex: 42,
+ treeInfo: createMockTreeInfo(TreeType.StateV2),
+ proveByIndex: true,
+ seq: 5n,
+ slotCreated: 100n,
+ };
+
+ return {
+ account: mockAccount,
+ mint: mockDeserializedMint,
+ mintSigner: MOCK_MINT_SIGNER,
+ leafIndex: 42,
+ rootIndex: 10,
+ proveByIndex: true,
+ merkleTree: MOCK_TREE,
+ outOutputQueue: MOCK_QUEUE,
+ proof: null,
+ metadataExtensionIndex: 0,
+ ...overrides,
+ };
+}
+
+// ============================================================================
+// MOCK SPL INTERFACE INFO
+// ============================================================================
+
+/**
+ * Creates a mock SplInterfaceInfo with consistent test values.
+ */
+export function createMockSplInterfaceInfo(): SplInterfaceInfo {
+ return {
+ poolAddress: MOCK_POOL,
+ tokenProgram: SPL_TOKEN_PROGRAM_ID,
+ poolIndex: 0,
+ bump: 255,
+ isInitialized: true,
+ };
+}
+
+// ============================================================================
+// PROOF HELPERS
+// ============================================================================
+
+/**
+ * Creates a mock proof input for validity proof responses.
+ */
+export function createProofInput(hashByte: number, rootIndex: number) {
+ return {
+ hash: new Uint8Array(32).fill(hashByte),
+ root: new Uint8Array(32),
+ rootIndex: { rootIndex, proveByIndex: false },
+ leafIndex: 0,
+ treeInfo: createMockTreeInfo(TreeType.StateV2),
+ };
+}
+
+/**
+ * Creates a mock validity proof response.
+ */
+export function createMockProof(
+ accountInputs: Array<{ hashByte: number; rootIndex: number }> = [],
+) {
+ return {
+ proof: {
+ a: new Uint8Array(32),
+ b: new Uint8Array(64),
+ c: new Uint8Array(32),
+ },
+ accounts: accountInputs.map((a) =>
+ createProofInput(a.hashByte, a.rootIndex),
+ ),
+ addresses: [],
+ };
+}
+
+/**
+ * Creates a mock token account with a specific hash byte and leaf index.
+ */
+export function createMockAccountWithHash(
+ amount: bigint,
+ hashByte: number,
+ leafIndex: number,
+ delegate: ReturnType | null = null,
+): CompressedTokenAccount {
+ const account = createMockTokenAccount(amount);
+ account.account.hash = new Uint8Array(32).fill(hashByte);
+ account.account.leafIndex = leafIndex;
+ account.token.delegate = delegate;
+ return account;
+}
+
+/**
+ * Creates a mock indexer that returns accounts and proof for transfer builders.
+ */
+export function createTransferMockIndexer(
+ accounts: CompressedTokenAccount[],
+ proofInputs: Array<{ hashByte: number; rootIndex: number }>,
+): LightIndexer {
+ return createMockIndexer({
+ getCompressedTokenAccountsByOwner: vi.fn().mockResolvedValue({
+ context: { slot: 100n },
+ value: { items: accounts, cursor: null },
+ }),
+ getValidityProof: vi.fn().mockResolvedValue({
+ context: { slot: 100n },
+ value: createMockProof(proofInputs),
+ }),
+ });
+}
diff --git a/js/token-kit/tests/unit/indexer.test.ts b/js/token-kit/tests/unit/indexer.test.ts
new file mode 100644
index 0000000000..6a594b7736
--- /dev/null
+++ b/js/token-kit/tests/unit/indexer.test.ts
@@ -0,0 +1,410 @@
+/**
+ * Unit tests for PhotonIndexer and isLightIndexerAvailable.
+ *
+ * Tests error handling paths in the RPC client by mocking globalThis.fetch.
+ */
+
+import { describe, it, expect, vi, afterEach } from 'vitest';
+
+import {
+ PhotonIndexer,
+ isLightIndexerAvailable,
+ IndexerError,
+ IndexerErrorCode,
+ TreeType,
+} from '../../src/index.js';
+
+// ============================================================================
+// SETUP
+// ============================================================================
+
+const ENDPOINT = 'https://test.photon.endpoint';
+const originalFetch = globalThis.fetch;
+
+afterEach(() => {
+ globalThis.fetch = originalFetch;
+});
+
+/**
+ * Helper to create a mock Response that provides both .text() and .json().
+ * The indexer now uses response.text() for big-number-safe parsing.
+ */
+function mockResponse(body: unknown, ok = true, status = 200, statusText = 'OK') {
+ const text = JSON.stringify(body);
+ return {
+ ok,
+ status,
+ statusText,
+ text: vi.fn().mockResolvedValue(text),
+ json: vi.fn().mockResolvedValue(body),
+ };
+}
+
+// ============================================================================
+// TESTS: PhotonIndexer error handling
+// ============================================================================
+
+describe('PhotonIndexer', () => {
+ it('throws IndexerError with NetworkError on network failure', async () => {
+ globalThis.fetch = vi.fn().mockRejectedValue(new Error('ECONNREFUSED'));
+
+ const indexer = new PhotonIndexer(ENDPOINT);
+
+ await expect(
+ indexer.getCompressedAccount(new Uint8Array(32)),
+ ).rejects.toThrow(IndexerError);
+
+ try {
+ await indexer.getCompressedAccount(new Uint8Array(32));
+ } catch (e) {
+ expect(e).toBeInstanceOf(IndexerError);
+ expect((e as IndexerError).code).toBe(IndexerErrorCode.NetworkError);
+ }
+ });
+
+ it('throws IndexerError with NetworkError on HTTP error status', async () => {
+ globalThis.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 500,
+ statusText: 'Internal Server Error',
+ });
+
+ const indexer = new PhotonIndexer(ENDPOINT);
+
+ await expect(
+ indexer.getCompressedAccount(new Uint8Array(32)),
+ ).rejects.toThrow(IndexerError);
+
+ try {
+ await indexer.getCompressedAccount(new Uint8Array(32));
+ } catch (e) {
+ expect(e).toBeInstanceOf(IndexerError);
+ expect((e as IndexerError).code).toBe(IndexerErrorCode.NetworkError);
+ expect((e as IndexerError).message).toContain('500');
+ }
+ });
+
+ it('throws IndexerError with InvalidResponse on invalid JSON', async () => {
+ globalThis.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ text: vi.fn().mockResolvedValue('not valid json {{{'),
+ });
+
+ const indexer = new PhotonIndexer(ENDPOINT);
+
+ await expect(
+ indexer.getCompressedAccount(new Uint8Array(32)),
+ ).rejects.toThrow(IndexerError);
+
+ try {
+ await indexer.getCompressedAccount(new Uint8Array(32));
+ } catch (e) {
+ expect(e).toBeInstanceOf(IndexerError);
+ expect((e as IndexerError).code).toBe(
+ IndexerErrorCode.InvalidResponse,
+ );
+ }
+ });
+
+ it('throws IndexerError with RpcError on JSON-RPC error response', async () => {
+ globalThis.fetch = vi.fn().mockResolvedValue(
+ mockResponse({
+ jsonrpc: '2.0',
+ id: '1',
+ error: { code: -32600, message: 'Invalid' },
+ }),
+ );
+
+ const indexer = new PhotonIndexer(ENDPOINT);
+
+ await expect(
+ indexer.getCompressedAccount(new Uint8Array(32)),
+ ).rejects.toThrow(IndexerError);
+
+ try {
+ await indexer.getCompressedAccount(new Uint8Array(32));
+ } catch (e) {
+ expect(e).toBeInstanceOf(IndexerError);
+ expect((e as IndexerError).code).toBe(IndexerErrorCode.RpcError);
+ expect((e as IndexerError).message).toContain('-32600');
+ }
+ });
+
+ it('throws IndexerError with InvalidResponse when result is missing', async () => {
+ globalThis.fetch = vi.fn().mockResolvedValue(
+ mockResponse({
+ jsonrpc: '2.0',
+ id: '1',
+ }),
+ );
+
+ const indexer = new PhotonIndexer(ENDPOINT);
+
+ await expect(
+ indexer.getCompressedAccount(new Uint8Array(32)),
+ ).rejects.toThrow(IndexerError);
+
+ try {
+ await indexer.getCompressedAccount(new Uint8Array(32));
+ } catch (e) {
+ expect(e).toBeInstanceOf(IndexerError);
+ expect((e as IndexerError).code).toBe(
+ IndexerErrorCode.InvalidResponse,
+ );
+ expect((e as IndexerError).message).toContain('Missing result');
+ }
+ });
+
+ it('accepts V1 tree type in account response', async () => {
+ globalThis.fetch = vi.fn().mockResolvedValue(
+ mockResponse({
+ jsonrpc: '2.0',
+ id: '1',
+ result: {
+ context: { slot: 100 },
+ value: {
+ hash: '11111111111111111111111111111111',
+ address: null,
+ data: null,
+ lamports: '0',
+ owner: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA',
+ leafIndex: 0,
+ seq: null,
+ slotCreated: '0',
+ merkleContext: {
+ tree: 'amt2kaJA14v3urZbZvnc5v2np8jqvc4Z8zDep5wbtzx',
+ queue: 'SySTEM1eSU2p4BGQfQpimFEWWSC1XDFeun3Nqzz3rT7',
+ treeType: TreeType.StateV1,
+ },
+ proveByIndex: false,
+ },
+ },
+ }),
+ );
+
+ const indexer = new PhotonIndexer(ENDPOINT);
+ // V1 trees should not throw — they are accepted
+ await expect(
+ indexer.getCompressedAccount(new Uint8Array(32)),
+ ).resolves.not.toThrow();
+ });
+
+ it('successfully parses a valid compressed account response', async () => {
+ globalThis.fetch = vi.fn().mockResolvedValue(
+ mockResponse({
+ jsonrpc: '2.0',
+ id: '1',
+ result: {
+ context: { slot: 42 },
+ value: {
+ hash: '11111111111111111111111111111111',
+ address: 'So11111111111111111111111111111111111111112',
+ data: null,
+ lamports: '1000000',
+ owner: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA',
+ leafIndex: 7,
+ seq: 99,
+ slotCreated: '123',
+ merkleContext: {
+ tree: 'amt2kaJA14v3urZbZvnc5v2np8jqvc4Z8zDep5wbtzx',
+ queue: 'SySTEM1eSU2p4BGQfQpimFEWWSC1XDFeun3Nqzz3rT7',
+ treeType: TreeType.StateV2,
+ },
+ proveByIndex: true,
+ },
+ },
+ }),
+ );
+
+ const indexer = new PhotonIndexer(ENDPOINT);
+ const result = await indexer.getCompressedAccount(new Uint8Array(32));
+
+ expect(result.context.slot).toBe(42n);
+ expect(result.value).not.toBeNull();
+ expect(result.value!.lamports).toBe(1000000n);
+ expect(result.value!.leafIndex).toBe(7);
+ expect(result.value!.seq).toBe(99n);
+ expect(result.value!.slotCreated).toBe(123n);
+ expect(result.value!.proveByIndex).toBe(true);
+ expect(result.value!.address).not.toBeNull();
+ expect(result.value!.data).toBeNull();
+ });
+
+ it('successfully parses a null account response', async () => {
+ globalThis.fetch = vi.fn().mockResolvedValue(
+ mockResponse({
+ jsonrpc: '2.0',
+ id: '1',
+ result: {
+ context: { slot: 10 },
+ value: null,
+ },
+ }),
+ );
+
+ const indexer = new PhotonIndexer(ENDPOINT);
+ const result = await indexer.getCompressedAccount(new Uint8Array(32));
+ expect(result.value).toBeNull();
+ });
+
+ it('successfully parses token accounts response', async () => {
+ globalThis.fetch = vi.fn().mockResolvedValue(
+ mockResponse({
+ jsonrpc: '2.0',
+ id: '1',
+ result: {
+ context: { slot: 50 },
+ value: {
+ items: [
+ {
+ tokenData: {
+ mint: 'So11111111111111111111111111111111111111112',
+ owner: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA',
+ amount: '5000',
+ delegate: null,
+ state: 'initialized',
+ tlv: null,
+ },
+ account: {
+ hash: '11111111111111111111111111111111',
+ address: null,
+ data: null,
+ lamports: '0',
+ owner: 'cTokenmWW8bLPjZEBAUgYy3zKxQZW6VKi7bqNFEVv3m',
+ leafIndex: 3,
+ seq: null,
+ slotCreated: '100',
+ merkleContext: {
+ tree: 'amt2kaJA14v3urZbZvnc5v2np8jqvc4Z8zDep5wbtzx',
+ queue: 'SySTEM1eSU2p4BGQfQpimFEWWSC1XDFeun3Nqzz3rT7',
+ treeType: TreeType.StateV2,
+ },
+ proveByIndex: false,
+ },
+ },
+ ],
+ cursor: null,
+ },
+ },
+ }),
+ );
+
+ const indexer = new PhotonIndexer(ENDPOINT);
+ const result = await indexer.getCompressedTokenAccountsByOwner(
+ 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA' as any,
+ );
+
+ expect(result.value.items).toHaveLength(1);
+ expect(result.value.items[0].token.amount).toBe(5000n);
+ expect(result.value.items[0].token.state).toBe(1); // AccountState.Initialized
+ expect(result.value.cursor).toBeNull();
+ });
+
+ it('parses frozen token state correctly', async () => {
+ globalThis.fetch = vi.fn().mockResolvedValue(
+ mockResponse({
+ jsonrpc: '2.0',
+ id: '1',
+ result: {
+ context: { slot: 50 },
+ value: {
+ items: [
+ {
+ tokenData: {
+ mint: 'So11111111111111111111111111111111111111112',
+ owner: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA',
+ amount: '0',
+ delegate: 'SySTEM1eSU2p4BGQfQpimFEWWSC1XDFeun3Nqzz3rT7',
+ state: 'frozen',
+ tlv: null,
+ },
+ account: {
+ hash: '11111111111111111111111111111111',
+ address: null,
+ data: null,
+ lamports: '0',
+ owner: 'cTokenmWW8bLPjZEBAUgYy3zKxQZW6VKi7bqNFEVv3m',
+ leafIndex: 0,
+ seq: null,
+ slotCreated: '50',
+ merkleContext: {
+ tree: 'amt2kaJA14v3urZbZvnc5v2np8jqvc4Z8zDep5wbtzx',
+ queue: 'SySTEM1eSU2p4BGQfQpimFEWWSC1XDFeun3Nqzz3rT7',
+ treeType: TreeType.StateV2,
+ },
+ proveByIndex: false,
+ },
+ },
+ ],
+ cursor: null,
+ },
+ },
+ }),
+ );
+
+ const indexer = new PhotonIndexer(ENDPOINT);
+ const result = await indexer.getCompressedTokenAccountsByOwner(
+ 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA' as any,
+ );
+
+ expect(result.value.items[0].token.state).toBe(2); // AccountState.Frozen
+ expect(result.value.items[0].token.delegate).not.toBeNull();
+ });
+});
+
+// ============================================================================
+// TESTS: isLightIndexerAvailable
+// ============================================================================
+
+describe('isLightIndexerAvailable', () => {
+ it('returns true when endpoint is healthy', async () => {
+ globalThis.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: vi.fn().mockResolvedValue({
+ jsonrpc: '2.0',
+ id: '1',
+ result: 'ok',
+ }),
+ });
+
+ const result = await isLightIndexerAvailable(ENDPOINT);
+
+ expect(result).toBe(true);
+ });
+
+ it('returns false when endpoint returns HTTP error', async () => {
+ globalThis.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 503,
+ });
+
+ const result = await isLightIndexerAvailable(ENDPOINT);
+
+ expect(result).toBe(false);
+ });
+
+ it('returns false when endpoint returns RPC error', async () => {
+ globalThis.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: vi.fn().mockResolvedValue({
+ jsonrpc: '2.0',
+ id: '1',
+ error: { code: -32000, message: 'Unhealthy' },
+ }),
+ });
+
+ const result = await isLightIndexerAvailable(ENDPOINT);
+
+ expect(result).toBe(false);
+ });
+
+ it('returns false when fetch throws', async () => {
+ globalThis.fetch = vi
+ .fn()
+ .mockRejectedValue(new Error('Network unreachable'));
+
+ const result = await isLightIndexerAvailable(ENDPOINT);
+
+ expect(result).toBe(false);
+ });
+});
diff --git a/js/token-kit/tests/unit/instructions.test.ts b/js/token-kit/tests/unit/instructions.test.ts
new file mode 100644
index 0000000000..76d3508888
--- /dev/null
+++ b/js/token-kit/tests/unit/instructions.test.ts
@@ -0,0 +1,2741 @@
+/**
+ * Comprehensive unit tests for Light Token SDK instruction builders.
+ *
+ * Tests for every instruction builder exported from the SDK, verifying:
+ * - Correct program address
+ * - Correct number of accounts
+ * - Correct account addresses in correct order
+ * - Correct account roles (AccountRole enum)
+ * - Correct discriminator byte (first byte of data)
+ * - Correct data encoding via codec round-trip
+ * - Optional fields (maxTopUp, feePayer, etc.)
+ * - Validation (zero amount, invalid decimals, etc.)
+ */
+
+import { describe, it, expect } from 'vitest';
+import { address, getAddressCodec } from '@solana/addresses';
+import { AccountRole } from '@solana/instructions';
+
+import {
+ // Instruction builders
+ createTransferInstruction,
+ createTransferCheckedInstruction,
+ createTransferInterfaceInstruction,
+ createCloseAccountInstruction,
+ createMintToInstruction,
+ createMintToCheckedInstruction,
+ createBurnInstruction,
+ createBurnCheckedInstruction,
+ createFreezeInstruction,
+ createThawInstruction,
+ createApproveInstruction,
+ createRevokeInstruction,
+ createTokenAccountInstruction,
+ createAssociatedTokenAccountInstruction,
+ createAssociatedTokenAccountIdempotentInstruction,
+ createTransfer2Instruction,
+ createClaimInstruction,
+ createWithdrawFundingPoolInstruction,
+ createMintActionInstruction,
+ createWrapInstruction,
+ createUnwrapInstruction,
+
+ // Compression factory functions
+ createCompress,
+ createCompressSpl,
+ createDecompress,
+ createDecompressSpl,
+ createCompressAndClose,
+
+ // Constants
+ LIGHT_TOKEN_PROGRAM_ID,
+ LIGHT_SYSTEM_PROGRAM_ID,
+ CPI_AUTHORITY,
+ REGISTERED_PROGRAM_PDA,
+ ACCOUNT_COMPRESSION_AUTHORITY_PDA,
+ DISCRIMINATOR,
+ SYSTEM_PROGRAM_ID,
+ ACCOUNT_COMPRESSION_PROGRAM_ID,
+ SPL_TOKEN_PROGRAM_ID,
+ COMPRESSION_MODE,
+
+ // Codecs
+ getAmountInstructionCodec,
+ getCheckedInstructionCodec,
+ getDiscriminatorOnlyCodec,
+ decodeMaxTopUp,
+
+ // Types
+ type SplInterfaceInfo,
+} from '../../src/index.js';
+
+// ============================================================================
+// TEST ADDRESSES
+// ============================================================================
+
+const TEST_PAYER = address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA');
+const TEST_OWNER = address('11111111111111111111111111111111');
+const TEST_MINT = address('So11111111111111111111111111111111111111112');
+const TEST_SOURCE = address('amt2kaJA14v3urZbZvnc5v2np8jqvc4Z8zDep5wbtzx');
+const TEST_DEST = address('GXtd2izAiMJPwMEjfgTRH3d7k9mjn4Jq3JrWFv9gySYy');
+const TEST_DELEGATE = address('SySTEM1eSU2p4BGQfQpimFEWWSC1XDFeun3Nqzz3rT7');
+const TEST_AUTHORITY = address('compr6CUsB5m2jS4Y3831ztGSTnDpnKJTKS95d64XVq');
+const TEST_FREEZE_AUTH = address('cTokenmWW8bLPjZEBAUgYy3zKxQZW6VKi7bqNFEVv3m');
+const TEST_CONFIG = address('TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb');
+const TEST_SPONSOR = address('BPFLoaderUpgradeab1e11111111111111111111111');
+
+// ============================================================================
+// TEST: createTransferInstruction
+// ============================================================================
+
+describe('createTransferInstruction', () => {
+ it('has correct program address', () => {
+ const ix = createTransferInstruction({
+ source: TEST_SOURCE,
+ destination: TEST_DEST,
+ amount: 1000n,
+ authority: TEST_AUTHORITY,
+ });
+ expect(ix.programAddress).toBe(LIGHT_TOKEN_PROGRAM_ID);
+ });
+
+ it('has correct number of accounts (4 without feePayer)', () => {
+ const ix = createTransferInstruction({
+ source: TEST_SOURCE,
+ destination: TEST_DEST,
+ amount: 1000n,
+ authority: TEST_AUTHORITY,
+ });
+ expect(ix.accounts).toHaveLength(4);
+ });
+
+ it('has correct account addresses in correct order', () => {
+ const ix = createTransferInstruction({
+ source: TEST_SOURCE,
+ destination: TEST_DEST,
+ amount: 1000n,
+ authority: TEST_AUTHORITY,
+ });
+ expect(ix.accounts[0].address).toBe(TEST_SOURCE);
+ expect(ix.accounts[1].address).toBe(TEST_DEST);
+ expect(ix.accounts[2].address).toBe(TEST_AUTHORITY);
+ expect(ix.accounts[3].address).toBe(SYSTEM_PROGRAM_ID);
+ });
+
+ it('has correct account roles', () => {
+ const ix = createTransferInstruction({
+ source: TEST_SOURCE,
+ destination: TEST_DEST,
+ amount: 1000n,
+ authority: TEST_AUTHORITY,
+ });
+ expect(ix.accounts[0].role).toBe(AccountRole.WRITABLE);
+ expect(ix.accounts[1].role).toBe(AccountRole.WRITABLE);
+ expect(ix.accounts[2].role).toBe(AccountRole.WRITABLE_SIGNER);
+ expect(ix.accounts[3].role).toBe(AccountRole.READONLY);
+ });
+
+ it('has correct discriminator byte', () => {
+ const ix = createTransferInstruction({
+ source: TEST_SOURCE,
+ destination: TEST_DEST,
+ amount: 1000n,
+ authority: TEST_AUTHORITY,
+ });
+ expect(ix.data[0]).toBe(DISCRIMINATOR.TRANSFER);
+ expect(ix.data[0]).toBe(3);
+ });
+
+ it('has correct data encoding via codec round-trip', () => {
+ const ix = createTransferInstruction({
+ source: TEST_SOURCE,
+ destination: TEST_DEST,
+ amount: 1000n,
+ authority: TEST_AUTHORITY,
+ });
+ const codec = getAmountInstructionCodec();
+ const decoded = codec.decode(ix.data);
+ expect(decoded.discriminator).toBe(DISCRIMINATOR.TRANSFER);
+ expect(decoded.amount).toBe(1000n);
+ });
+
+ it('has 9-byte data without maxTopUp', () => {
+ const ix = createTransferInstruction({
+ source: TEST_SOURCE,
+ destination: TEST_DEST,
+ amount: 1000n,
+ authority: TEST_AUTHORITY,
+ });
+ // 1 (disc) + 8 (amount) = 9 bytes
+ expect(ix.data.length).toBe(9);
+ });
+
+ it('with maxTopUp has 11-byte data and authority is WRITABLE_SIGNER', () => {
+ const ix = createTransferInstruction({
+ source: TEST_SOURCE,
+ destination: TEST_DEST,
+ amount: 1000n,
+ authority: TEST_AUTHORITY,
+ maxTopUp: 5000,
+ });
+ // 1 (disc) + 8 (amount) + 2 (maxTopUp u16) = 11 bytes
+ expect(ix.data.length).toBe(11);
+ // authority is WRITABLE_SIGNER (default when no feePayer)
+ expect(ix.accounts[2].role).toBe(AccountRole.WRITABLE_SIGNER);
+
+ // Verify maxTopUp decoding
+ const maxTopUp = decodeMaxTopUp(ix.data, 9);
+ expect(maxTopUp).toBe(5000);
+ });
+
+ it('with feePayer has 5 accounts and authority stays READONLY_SIGNER', () => {
+ const feePayer = address('Vote111111111111111111111111111111111111111');
+ const ix = createTransferInstruction({
+ source: TEST_SOURCE,
+ destination: TEST_DEST,
+ amount: 1000n,
+ authority: TEST_AUTHORITY,
+ maxTopUp: 5000,
+ feePayer,
+ });
+ expect(ix.accounts).toHaveLength(5);
+ // authority stays READONLY_SIGNER when feePayer is provided
+ expect(ix.accounts[2].role).toBe(AccountRole.READONLY_SIGNER);
+ // feePayer is WRITABLE_SIGNER
+ expect(ix.accounts[4].address).toBe(feePayer);
+ expect(ix.accounts[4].role).toBe(AccountRole.WRITABLE_SIGNER);
+ });
+
+ it('with feePayer but no maxTopUp still adds feePayer account', () => {
+ const feePayer = address('Vote111111111111111111111111111111111111111');
+ const ix = createTransferInstruction({
+ source: TEST_SOURCE,
+ destination: TEST_DEST,
+ amount: 1000n,
+ authority: TEST_AUTHORITY,
+ feePayer,
+ });
+ expect(ix.accounts).toHaveLength(5);
+ expect(ix.accounts[2].role).toBe(AccountRole.READONLY_SIGNER);
+ expect(ix.accounts[4].address).toBe(feePayer);
+ expect(ix.accounts[4].role).toBe(AccountRole.WRITABLE_SIGNER);
+ });
+
+ it('validation: zero amount throws "Amount must be positive"', () => {
+ expect(() =>
+ createTransferInstruction({
+ source: TEST_SOURCE,
+ destination: TEST_DEST,
+ amount: 0n,
+ authority: TEST_AUTHORITY,
+ }),
+ ).toThrow('Amount must be positive');
+ });
+
+ it('validation: negative amount throws "Amount must be positive"', () => {
+ expect(() =>
+ createTransferInstruction({
+ source: TEST_SOURCE,
+ destination: TEST_DEST,
+ amount: -1n,
+ authority: TEST_AUTHORITY,
+ }),
+ ).toThrow('Amount must be positive');
+ });
+
+ it('encodes large amounts correctly', () => {
+ const largeAmount = 18_446_744_073_709_551_615n; // u64::MAX
+ const ix = createTransferInstruction({
+ source: TEST_SOURCE,
+ destination: TEST_DEST,
+ amount: largeAmount,
+ authority: TEST_AUTHORITY,
+ });
+ const codec = getAmountInstructionCodec();
+ const decoded = codec.decode(ix.data);
+ expect(decoded.amount).toBe(largeAmount);
+ });
+
+ it('throws when source equals destination', () => {
+ expect(() =>
+ createTransferInstruction({
+ source: TEST_SOURCE,
+ destination: TEST_SOURCE,
+ amount: 1000n,
+ authority: TEST_AUTHORITY,
+ }),
+ ).toThrow('Source and destination must be different accounts');
+ });
+});
+
+// ============================================================================
+// TEST: createTransferCheckedInstruction
+// ============================================================================
+
+describe('createTransferCheckedInstruction', () => {
+ it('has correct program address', () => {
+ const ix = createTransferCheckedInstruction({
+ source: TEST_SOURCE,
+ destination: TEST_DEST,
+ mint: TEST_MINT,
+ amount: 1000n,
+ authority: TEST_AUTHORITY,
+ decimals: 9,
+ });
+ expect(ix.programAddress).toBe(LIGHT_TOKEN_PROGRAM_ID);
+ });
+
+ it('has correct number of accounts (5 without feePayer)', () => {
+ const ix = createTransferCheckedInstruction({
+ source: TEST_SOURCE,
+ destination: TEST_DEST,
+ mint: TEST_MINT,
+ amount: 1000n,
+ authority: TEST_AUTHORITY,
+ decimals: 9,
+ });
+ expect(ix.accounts).toHaveLength(5);
+ });
+
+ it('has correct account addresses in correct order', () => {
+ const ix = createTransferCheckedInstruction({
+ source: TEST_SOURCE,
+ destination: TEST_DEST,
+ mint: TEST_MINT,
+ amount: 1000n,
+ authority: TEST_AUTHORITY,
+ decimals: 9,
+ });
+ expect(ix.accounts[0].address).toBe(TEST_SOURCE);
+ expect(ix.accounts[1].address).toBe(TEST_MINT);
+ expect(ix.accounts[2].address).toBe(TEST_DEST);
+ expect(ix.accounts[3].address).toBe(TEST_AUTHORITY);
+ expect(ix.accounts[4].address).toBe(SYSTEM_PROGRAM_ID);
+ });
+
+ it('has correct account roles', () => {
+ const ix = createTransferCheckedInstruction({
+ source: TEST_SOURCE,
+ destination: TEST_DEST,
+ mint: TEST_MINT,
+ amount: 1000n,
+ authority: TEST_AUTHORITY,
+ decimals: 9,
+ });
+ expect(ix.accounts[0].role).toBe(AccountRole.WRITABLE);
+ expect(ix.accounts[1].role).toBe(AccountRole.READONLY);
+ expect(ix.accounts[2].role).toBe(AccountRole.WRITABLE);
+ expect(ix.accounts[3].role).toBe(AccountRole.WRITABLE_SIGNER);
+ expect(ix.accounts[4].role).toBe(AccountRole.READONLY);
+ });
+
+ it('has correct discriminator byte', () => {
+ const ix = createTransferCheckedInstruction({
+ source: TEST_SOURCE,
+ destination: TEST_DEST,
+ mint: TEST_MINT,
+ amount: 1000n,
+ authority: TEST_AUTHORITY,
+ decimals: 9,
+ });
+ expect(ix.data[0]).toBe(DISCRIMINATOR.TRANSFER_CHECKED);
+ expect(ix.data[0]).toBe(12);
+ });
+
+ it('has correct data encoding via codec round-trip', () => {
+ const ix = createTransferCheckedInstruction({
+ source: TEST_SOURCE,
+ destination: TEST_DEST,
+ mint: TEST_MINT,
+ amount: 1000n,
+ authority: TEST_AUTHORITY,
+ decimals: 9,
+ });
+ const codec = getCheckedInstructionCodec();
+ const decoded = codec.decode(ix.data);
+ expect(decoded.discriminator).toBe(DISCRIMINATOR.TRANSFER_CHECKED);
+ expect(decoded.amount).toBe(1000n);
+ expect(decoded.decimals).toBe(9);
+ });
+
+ it('with maxTopUp: authority becomes WRITABLE_SIGNER', () => {
+ const ix = createTransferCheckedInstruction({
+ source: TEST_SOURCE,
+ destination: TEST_DEST,
+ mint: TEST_MINT,
+ amount: 1000n,
+ authority: TEST_AUTHORITY,
+ decimals: 9,
+ maxTopUp: 3000,
+ });
+ expect(ix.accounts[3].role).toBe(AccountRole.WRITABLE_SIGNER);
+
+ // Verify maxTopUp in data: disc(1) + amount(8) + decimals(1) = offset 10
+ const maxTopUp = decodeMaxTopUp(ix.data, 10);
+ expect(maxTopUp).toBe(3000);
+ });
+
+ it('with feePayer: 6 accounts, authority stays READONLY_SIGNER', () => {
+ const feePayer = address('Vote111111111111111111111111111111111111111');
+ const ix = createTransferCheckedInstruction({
+ source: TEST_SOURCE,
+ destination: TEST_DEST,
+ mint: TEST_MINT,
+ amount: 1000n,
+ authority: TEST_AUTHORITY,
+ decimals: 9,
+ maxTopUp: 3000,
+ feePayer,
+ });
+ expect(ix.accounts).toHaveLength(6);
+ expect(ix.accounts[3].role).toBe(AccountRole.READONLY_SIGNER);
+ expect(ix.accounts[5].address).toBe(feePayer);
+ expect(ix.accounts[5].role).toBe(AccountRole.WRITABLE_SIGNER);
+ });
+
+ it('validation: zero amount throws "Amount must be positive"', () => {
+ expect(() =>
+ createTransferCheckedInstruction({
+ source: TEST_SOURCE,
+ destination: TEST_DEST,
+ mint: TEST_MINT,
+ amount: 0n,
+ authority: TEST_AUTHORITY,
+ decimals: 9,
+ }),
+ ).toThrow('Amount must be positive');
+ });
+
+ it('validation: invalid decimals throws', () => {
+ expect(() =>
+ createTransferCheckedInstruction({
+ source: TEST_SOURCE,
+ destination: TEST_DEST,
+ mint: TEST_MINT,
+ amount: 1000n,
+ authority: TEST_AUTHORITY,
+ decimals: 256,
+ }),
+ ).toThrow('Decimals must be an integer between 0 and 255');
+ });
+
+ it('validation: non-integer decimals throws', () => {
+ expect(() =>
+ createTransferCheckedInstruction({
+ source: TEST_SOURCE,
+ destination: TEST_DEST,
+ mint: TEST_MINT,
+ amount: 1000n,
+ authority: TEST_AUTHORITY,
+ decimals: 6.5,
+ }),
+ ).toThrow('Decimals must be an integer between 0 and 255');
+ });
+
+ it('validation: negative decimals throws', () => {
+ expect(() =>
+ createTransferCheckedInstruction({
+ source: TEST_SOURCE,
+ destination: TEST_DEST,
+ mint: TEST_MINT,
+ amount: 1000n,
+ authority: TEST_AUTHORITY,
+ decimals: -1,
+ }),
+ ).toThrow('Decimals must be an integer between 0 and 255');
+ });
+
+ it('throws when source equals destination', () => {
+ expect(() =>
+ createTransferCheckedInstruction({
+ source: TEST_SOURCE,
+ destination: TEST_SOURCE,
+ mint: TEST_MINT,
+ amount: 1000n,
+ authority: TEST_AUTHORITY,
+ decimals: 6,
+ }),
+ ).toThrow('Source and destination must be different accounts');
+ });
+});
+
+// ============================================================================
+// TEST: createMintToInstruction
+// ============================================================================
+
+describe('createMintToInstruction', () => {
+ it('has correct program address', () => {
+ const ix = createMintToInstruction({
+ mint: TEST_MINT,
+ tokenAccount: TEST_DEST,
+ mintAuthority: TEST_AUTHORITY,
+ amount: 1_000_000n,
+ });
+ expect(ix.programAddress).toBe(LIGHT_TOKEN_PROGRAM_ID);
+ });
+
+ it('has correct number of accounts (4)', () => {
+ const ix = createMintToInstruction({
+ mint: TEST_MINT,
+ tokenAccount: TEST_DEST,
+ mintAuthority: TEST_AUTHORITY,
+ amount: 1_000_000n,
+ });
+ expect(ix.accounts).toHaveLength(4);
+ });
+
+ it('has correct account addresses in correct order', () => {
+ const ix = createMintToInstruction({
+ mint: TEST_MINT,
+ tokenAccount: TEST_DEST,
+ mintAuthority: TEST_AUTHORITY,
+ amount: 1_000_000n,
+ });
+ expect(ix.accounts[0].address).toBe(TEST_MINT);
+ expect(ix.accounts[1].address).toBe(TEST_DEST);
+ expect(ix.accounts[2].address).toBe(TEST_AUTHORITY);
+ expect(ix.accounts[3].address).toBe(SYSTEM_PROGRAM_ID);
+ });
+
+ it('has correct account roles', () => {
+ const ix = createMintToInstruction({
+ mint: TEST_MINT,
+ tokenAccount: TEST_DEST,
+ mintAuthority: TEST_AUTHORITY,
+ amount: 1_000_000n,
+ });
+ expect(ix.accounts[0].role).toBe(AccountRole.WRITABLE);
+ expect(ix.accounts[1].role).toBe(AccountRole.WRITABLE);
+ expect(ix.accounts[2].role).toBe(AccountRole.WRITABLE_SIGNER);
+ expect(ix.accounts[3].role).toBe(AccountRole.READONLY);
+ });
+
+ it('has correct discriminator byte', () => {
+ const ix = createMintToInstruction({
+ mint: TEST_MINT,
+ tokenAccount: TEST_DEST,
+ mintAuthority: TEST_AUTHORITY,
+ amount: 1_000_000n,
+ });
+ expect(ix.data[0]).toBe(DISCRIMINATOR.MINT_TO);
+ expect(ix.data[0]).toBe(7);
+ });
+
+ it('has correct data encoding via codec round-trip', () => {
+ const ix = createMintToInstruction({
+ mint: TEST_MINT,
+ tokenAccount: TEST_DEST,
+ mintAuthority: TEST_AUTHORITY,
+ amount: 1_000_000n,
+ });
+ const codec = getAmountInstructionCodec();
+ const decoded = codec.decode(ix.data);
+ expect(decoded.discriminator).toBe(DISCRIMINATOR.MINT_TO);
+ expect(decoded.amount).toBe(1_000_000n);
+ });
+
+ it('validation: zero amount throws "Amount must be positive"', () => {
+ expect(() =>
+ createMintToInstruction({
+ mint: TEST_MINT,
+ tokenAccount: TEST_DEST,
+ mintAuthority: TEST_AUTHORITY,
+ amount: 0n,
+ }),
+ ).toThrow('Amount must be positive');
+ });
+});
+
+// ============================================================================
+// TEST: createMintToCheckedInstruction
+// ============================================================================
+
+describe('createMintToCheckedInstruction', () => {
+ it('has correct program address', () => {
+ const ix = createMintToCheckedInstruction({
+ mint: TEST_MINT,
+ tokenAccount: TEST_DEST,
+ mintAuthority: TEST_AUTHORITY,
+ amount: 1_000_000n,
+ decimals: 6,
+ });
+ expect(ix.programAddress).toBe(LIGHT_TOKEN_PROGRAM_ID);
+ });
+
+ it('has correct number of accounts (4)', () => {
+ const ix = createMintToCheckedInstruction({
+ mint: TEST_MINT,
+ tokenAccount: TEST_DEST,
+ mintAuthority: TEST_AUTHORITY,
+ amount: 1_000_000n,
+ decimals: 6,
+ });
+ expect(ix.accounts).toHaveLength(4);
+ });
+
+ it('has correct account addresses in correct order', () => {
+ const ix = createMintToCheckedInstruction({
+ mint: TEST_MINT,
+ tokenAccount: TEST_DEST,
+ mintAuthority: TEST_AUTHORITY,
+ amount: 1_000_000n,
+ decimals: 6,
+ });
+ expect(ix.accounts[0].address).toBe(TEST_MINT);
+ expect(ix.accounts[1].address).toBe(TEST_DEST);
+ expect(ix.accounts[2].address).toBe(TEST_AUTHORITY);
+ expect(ix.accounts[3].address).toBe(SYSTEM_PROGRAM_ID);
+ });
+
+ it('has correct account roles', () => {
+ const ix = createMintToCheckedInstruction({
+ mint: TEST_MINT,
+ tokenAccount: TEST_DEST,
+ mintAuthority: TEST_AUTHORITY,
+ amount: 1_000_000n,
+ decimals: 6,
+ });
+ expect(ix.accounts[0].role).toBe(AccountRole.WRITABLE);
+ expect(ix.accounts[1].role).toBe(AccountRole.WRITABLE);
+ expect(ix.accounts[2].role).toBe(AccountRole.WRITABLE_SIGNER);
+ expect(ix.accounts[3].role).toBe(AccountRole.READONLY);
+ });
+
+ it('has correct discriminator byte', () => {
+ const ix = createMintToCheckedInstruction({
+ mint: TEST_MINT,
+ tokenAccount: TEST_DEST,
+ mintAuthority: TEST_AUTHORITY,
+ amount: 1_000_000n,
+ decimals: 6,
+ });
+ expect(ix.data[0]).toBe(DISCRIMINATOR.MINT_TO_CHECKED);
+ expect(ix.data[0]).toBe(14);
+ });
+
+ it('has correct data encoding via codec round-trip', () => {
+ const ix = createMintToCheckedInstruction({
+ mint: TEST_MINT,
+ tokenAccount: TEST_DEST,
+ mintAuthority: TEST_AUTHORITY,
+ amount: 1_000_000n,
+ decimals: 6,
+ });
+ const codec = getCheckedInstructionCodec();
+ const decoded = codec.decode(ix.data);
+ expect(decoded.discriminator).toBe(DISCRIMINATOR.MINT_TO_CHECKED);
+ expect(decoded.amount).toBe(1_000_000n);
+ expect(decoded.decimals).toBe(6);
+ });
+
+ it('validation: zero amount throws', () => {
+ expect(() =>
+ createMintToCheckedInstruction({
+ mint: TEST_MINT,
+ tokenAccount: TEST_DEST,
+ mintAuthority: TEST_AUTHORITY,
+ amount: 0n,
+ decimals: 6,
+ }),
+ ).toThrow('Amount must be positive');
+ });
+
+ it('validation: invalid decimals throws', () => {
+ expect(() =>
+ createMintToCheckedInstruction({
+ mint: TEST_MINT,
+ tokenAccount: TEST_DEST,
+ mintAuthority: TEST_AUTHORITY,
+ amount: 1000n,
+ decimals: 256,
+ }),
+ ).toThrow('Decimals must be an integer between 0 and 255');
+ });
+});
+
+// ============================================================================
+// TEST: createBurnInstruction
+// ============================================================================
+
+describe('createBurnInstruction', () => {
+ it('has correct program address', () => {
+ const ix = createBurnInstruction({
+ tokenAccount: TEST_SOURCE,
+ mint: TEST_MINT,
+ authority: TEST_AUTHORITY,
+ amount: 500n,
+ });
+ expect(ix.programAddress).toBe(LIGHT_TOKEN_PROGRAM_ID);
+ });
+
+ it('has correct number of accounts (4)', () => {
+ const ix = createBurnInstruction({
+ tokenAccount: TEST_SOURCE,
+ mint: TEST_MINT,
+ authority: TEST_AUTHORITY,
+ amount: 500n,
+ });
+ expect(ix.accounts).toHaveLength(4);
+ });
+
+ it('has correct account addresses in correct order', () => {
+ const ix = createBurnInstruction({
+ tokenAccount: TEST_SOURCE,
+ mint: TEST_MINT,
+ authority: TEST_AUTHORITY,
+ amount: 500n,
+ });
+ expect(ix.accounts[0].address).toBe(TEST_SOURCE);
+ expect(ix.accounts[1].address).toBe(TEST_MINT);
+ expect(ix.accounts[2].address).toBe(TEST_AUTHORITY);
+ expect(ix.accounts[3].address).toBe(SYSTEM_PROGRAM_ID);
+ });
+
+ it('has correct account roles', () => {
+ const ix = createBurnInstruction({
+ tokenAccount: TEST_SOURCE,
+ mint: TEST_MINT,
+ authority: TEST_AUTHORITY,
+ amount: 500n,
+ });
+ expect(ix.accounts[0].role).toBe(AccountRole.WRITABLE);
+ expect(ix.accounts[1].role).toBe(AccountRole.WRITABLE);
+ expect(ix.accounts[2].role).toBe(AccountRole.WRITABLE_SIGNER);
+ expect(ix.accounts[3].role).toBe(AccountRole.READONLY);
+ });
+
+ it('has correct discriminator byte', () => {
+ const ix = createBurnInstruction({
+ tokenAccount: TEST_SOURCE,
+ mint: TEST_MINT,
+ authority: TEST_AUTHORITY,
+ amount: 500n,
+ });
+ expect(ix.data[0]).toBe(DISCRIMINATOR.BURN);
+ expect(ix.data[0]).toBe(8);
+ });
+
+ it('has correct data encoding via codec round-trip', () => {
+ const ix = createBurnInstruction({
+ tokenAccount: TEST_SOURCE,
+ mint: TEST_MINT,
+ authority: TEST_AUTHORITY,
+ amount: 500n,
+ });
+ const codec = getAmountInstructionCodec();
+ const decoded = codec.decode(ix.data);
+ expect(decoded.discriminator).toBe(DISCRIMINATOR.BURN);
+ expect(decoded.amount).toBe(500n);
+ });
+
+ it('validation: zero amount throws "Amount must be positive"', () => {
+ expect(() =>
+ createBurnInstruction({
+ tokenAccount: TEST_SOURCE,
+ mint: TEST_MINT,
+ authority: TEST_AUTHORITY,
+ amount: 0n,
+ }),
+ ).toThrow('Amount must be positive');
+ });
+});
+
+// ============================================================================
+// TEST: createBurnCheckedInstruction
+// ============================================================================
+
+describe('createBurnCheckedInstruction', () => {
+ it('has correct program address', () => {
+ const ix = createBurnCheckedInstruction({
+ tokenAccount: TEST_SOURCE,
+ mint: TEST_MINT,
+ authority: TEST_AUTHORITY,
+ amount: 500n,
+ decimals: 9,
+ });
+ expect(ix.programAddress).toBe(LIGHT_TOKEN_PROGRAM_ID);
+ });
+
+ it('has correct number of accounts (4)', () => {
+ const ix = createBurnCheckedInstruction({
+ tokenAccount: TEST_SOURCE,
+ mint: TEST_MINT,
+ authority: TEST_AUTHORITY,
+ amount: 500n,
+ decimals: 9,
+ });
+ expect(ix.accounts).toHaveLength(4);
+ });
+
+ it('has correct account addresses in correct order', () => {
+ const ix = createBurnCheckedInstruction({
+ tokenAccount: TEST_SOURCE,
+ mint: TEST_MINT,
+ authority: TEST_AUTHORITY,
+ amount: 500n,
+ decimals: 9,
+ });
+ expect(ix.accounts[0].address).toBe(TEST_SOURCE);
+ expect(ix.accounts[1].address).toBe(TEST_MINT);
+ expect(ix.accounts[2].address).toBe(TEST_AUTHORITY);
+ expect(ix.accounts[3].address).toBe(SYSTEM_PROGRAM_ID);
+ });
+
+ it('has correct account roles', () => {
+ const ix = createBurnCheckedInstruction({
+ tokenAccount: TEST_SOURCE,
+ mint: TEST_MINT,
+ authority: TEST_AUTHORITY,
+ amount: 500n,
+ decimals: 9,
+ });
+ expect(ix.accounts[0].role).toBe(AccountRole.WRITABLE);
+ expect(ix.accounts[1].role).toBe(AccountRole.WRITABLE);
+ expect(ix.accounts[2].role).toBe(AccountRole.WRITABLE_SIGNER);
+ expect(ix.accounts[3].role).toBe(AccountRole.READONLY);
+ });
+
+ it('has correct discriminator byte', () => {
+ const ix = createBurnCheckedInstruction({
+ tokenAccount: TEST_SOURCE,
+ mint: TEST_MINT,
+ authority: TEST_AUTHORITY,
+ amount: 500n,
+ decimals: 9,
+ });
+ expect(ix.data[0]).toBe(DISCRIMINATOR.BURN_CHECKED);
+ expect(ix.data[0]).toBe(15);
+ });
+
+ it('has correct data encoding via codec round-trip', () => {
+ const ix = createBurnCheckedInstruction({
+ tokenAccount: TEST_SOURCE,
+ mint: TEST_MINT,
+ authority: TEST_AUTHORITY,
+ amount: 500n,
+ decimals: 9,
+ });
+ const codec = getCheckedInstructionCodec();
+ const decoded = codec.decode(ix.data);
+ expect(decoded.discriminator).toBe(DISCRIMINATOR.BURN_CHECKED);
+ expect(decoded.amount).toBe(500n);
+ expect(decoded.decimals).toBe(9);
+ });
+
+ it('validation: zero amount throws', () => {
+ expect(() =>
+ createBurnCheckedInstruction({
+ tokenAccount: TEST_SOURCE,
+ mint: TEST_MINT,
+ authority: TEST_AUTHORITY,
+ amount: 0n,
+ decimals: 9,
+ }),
+ ).toThrow('Amount must be positive');
+ });
+
+ it('validation: invalid decimals throws', () => {
+ expect(() =>
+ createBurnCheckedInstruction({
+ tokenAccount: TEST_SOURCE,
+ mint: TEST_MINT,
+ authority: TEST_AUTHORITY,
+ amount: 500n,
+ decimals: 256,
+ }),
+ ).toThrow('Decimals must be an integer between 0 and 255');
+ });
+});
+
+// ============================================================================
+// TEST: createApproveInstruction
+// ============================================================================
+
+describe('createApproveInstruction', () => {
+ it('has correct program address', () => {
+ const ix = createApproveInstruction({
+ tokenAccount: TEST_SOURCE,
+ delegate: TEST_DELEGATE,
+ owner: TEST_OWNER,
+ amount: 10_000n,
+ });
+ expect(ix.programAddress).toBe(LIGHT_TOKEN_PROGRAM_ID);
+ });
+
+ it('has correct number of accounts (3)', () => {
+ const ix = createApproveInstruction({
+ tokenAccount: TEST_SOURCE,
+ delegate: TEST_DELEGATE,
+ owner: TEST_OWNER,
+ amount: 10_000n,
+ });
+ expect(ix.accounts).toHaveLength(3);
+ });
+
+ it('has correct account addresses in correct order', () => {
+ const ix = createApproveInstruction({
+ tokenAccount: TEST_SOURCE,
+ delegate: TEST_DELEGATE,
+ owner: TEST_OWNER,
+ amount: 10_000n,
+ });
+ expect(ix.accounts[0].address).toBe(TEST_SOURCE);
+ expect(ix.accounts[1].address).toBe(TEST_DELEGATE);
+ expect(ix.accounts[2].address).toBe(TEST_OWNER);
+ });
+
+ it('has correct account roles', () => {
+ const ix = createApproveInstruction({
+ tokenAccount: TEST_SOURCE,
+ delegate: TEST_DELEGATE,
+ owner: TEST_OWNER,
+ amount: 10_000n,
+ });
+ expect(ix.accounts[0].role).toBe(AccountRole.WRITABLE);
+ expect(ix.accounts[1].role).toBe(AccountRole.READONLY);
+ expect(ix.accounts[2].role).toBe(AccountRole.WRITABLE_SIGNER);
+ });
+
+ it('has correct discriminator byte', () => {
+ const ix = createApproveInstruction({
+ tokenAccount: TEST_SOURCE,
+ delegate: TEST_DELEGATE,
+ owner: TEST_OWNER,
+ amount: 10_000n,
+ });
+ expect(ix.data[0]).toBe(DISCRIMINATOR.APPROVE);
+ expect(ix.data[0]).toBe(4);
+ });
+
+ it('has correct data encoding via codec round-trip', () => {
+ const ix = createApproveInstruction({
+ tokenAccount: TEST_SOURCE,
+ delegate: TEST_DELEGATE,
+ owner: TEST_OWNER,
+ amount: 10_000n,
+ });
+ const codec = getAmountInstructionCodec();
+ const decoded = codec.decode(ix.data);
+ expect(decoded.discriminator).toBe(DISCRIMINATOR.APPROVE);
+ expect(decoded.amount).toBe(10_000n);
+ });
+
+ it('validation: zero amount throws "Amount must be positive"', () => {
+ expect(() =>
+ createApproveInstruction({
+ tokenAccount: TEST_SOURCE,
+ delegate: TEST_DELEGATE,
+ owner: TEST_OWNER,
+ amount: 0n,
+ }),
+ ).toThrow('Amount must be positive');
+ });
+});
+
+// ============================================================================
+// TEST: createRevokeInstruction
+// ============================================================================
+
+describe('createRevokeInstruction', () => {
+ it('has correct program address', () => {
+ const ix = createRevokeInstruction({
+ tokenAccount: TEST_SOURCE,
+ owner: TEST_OWNER,
+ });
+ expect(ix.programAddress).toBe(LIGHT_TOKEN_PROGRAM_ID);
+ });
+
+ it('has correct number of accounts (2)', () => {
+ const ix = createRevokeInstruction({
+ tokenAccount: TEST_SOURCE,
+ owner: TEST_OWNER,
+ });
+ expect(ix.accounts).toHaveLength(2);
+ });
+
+ it('has correct account addresses in correct order', () => {
+ const ix = createRevokeInstruction({
+ tokenAccount: TEST_SOURCE,
+ owner: TEST_OWNER,
+ });
+ expect(ix.accounts[0].address).toBe(TEST_SOURCE);
+ expect(ix.accounts[1].address).toBe(TEST_OWNER);
+ });
+
+ it('has correct account roles', () => {
+ const ix = createRevokeInstruction({
+ tokenAccount: TEST_SOURCE,
+ owner: TEST_OWNER,
+ });
+ expect(ix.accounts[0].role).toBe(AccountRole.WRITABLE);
+ expect(ix.accounts[1].role).toBe(AccountRole.WRITABLE_SIGNER);
+ });
+
+ it('has correct discriminator byte', () => {
+ const ix = createRevokeInstruction({
+ tokenAccount: TEST_SOURCE,
+ owner: TEST_OWNER,
+ });
+ expect(ix.data[0]).toBe(DISCRIMINATOR.REVOKE);
+ expect(ix.data[0]).toBe(5);
+ });
+
+ it('has discriminator-only data (1 byte)', () => {
+ const ix = createRevokeInstruction({
+ tokenAccount: TEST_SOURCE,
+ owner: TEST_OWNER,
+ });
+ expect(ix.data.length).toBe(1);
+ });
+
+ it('has correct data encoding via codec round-trip', () => {
+ const ix = createRevokeInstruction({
+ tokenAccount: TEST_SOURCE,
+ owner: TEST_OWNER,
+ });
+ const codec = getDiscriminatorOnlyCodec();
+ const decoded = codec.decode(ix.data);
+ expect(decoded.discriminator).toBe(DISCRIMINATOR.REVOKE);
+ });
+});
+
+// ============================================================================
+// TEST: createFreezeInstruction
+// ============================================================================
+
+describe('createFreezeInstruction', () => {
+ it('has correct program address', () => {
+ const ix = createFreezeInstruction({
+ tokenAccount: TEST_SOURCE,
+ mint: TEST_MINT,
+ freezeAuthority: TEST_FREEZE_AUTH,
+ });
+ expect(ix.programAddress).toBe(LIGHT_TOKEN_PROGRAM_ID);
+ });
+
+ it('has correct number of accounts (3)', () => {
+ const ix = createFreezeInstruction({
+ tokenAccount: TEST_SOURCE,
+ mint: TEST_MINT,
+ freezeAuthority: TEST_FREEZE_AUTH,
+ });
+ expect(ix.accounts).toHaveLength(3);
+ });
+
+ it('has correct account addresses in correct order', () => {
+ const ix = createFreezeInstruction({
+ tokenAccount: TEST_SOURCE,
+ mint: TEST_MINT,
+ freezeAuthority: TEST_FREEZE_AUTH,
+ });
+ expect(ix.accounts[0].address).toBe(TEST_SOURCE);
+ expect(ix.accounts[1].address).toBe(TEST_MINT);
+ expect(ix.accounts[2].address).toBe(TEST_FREEZE_AUTH);
+ });
+
+ it('has correct account roles', () => {
+ const ix = createFreezeInstruction({
+ tokenAccount: TEST_SOURCE,
+ mint: TEST_MINT,
+ freezeAuthority: TEST_FREEZE_AUTH,
+ });
+ expect(ix.accounts[0].role).toBe(AccountRole.WRITABLE);
+ expect(ix.accounts[1].role).toBe(AccountRole.READONLY);
+ expect(ix.accounts[2].role).toBe(AccountRole.READONLY_SIGNER);
+ });
+
+ it('has correct discriminator byte', () => {
+ const ix = createFreezeInstruction({
+ tokenAccount: TEST_SOURCE,
+ mint: TEST_MINT,
+ freezeAuthority: TEST_FREEZE_AUTH,
+ });
+ expect(ix.data[0]).toBe(DISCRIMINATOR.FREEZE);
+ expect(ix.data[0]).toBe(10);
+ });
+
+ it('has discriminator-only data (1 byte)', () => {
+ const ix = createFreezeInstruction({
+ tokenAccount: TEST_SOURCE,
+ mint: TEST_MINT,
+ freezeAuthority: TEST_FREEZE_AUTH,
+ });
+ expect(ix.data.length).toBe(1);
+ });
+
+ it('has correct data encoding via codec round-trip', () => {
+ const ix = createFreezeInstruction({
+ tokenAccount: TEST_SOURCE,
+ mint: TEST_MINT,
+ freezeAuthority: TEST_FREEZE_AUTH,
+ });
+ const codec = getDiscriminatorOnlyCodec();
+ const decoded = codec.decode(ix.data);
+ expect(decoded.discriminator).toBe(DISCRIMINATOR.FREEZE);
+ });
+});
+
+// ============================================================================
+// TEST: createThawInstruction
+// ============================================================================
+
+describe('createThawInstruction', () => {
+ it('has correct program address', () => {
+ const ix = createThawInstruction({
+ tokenAccount: TEST_SOURCE,
+ mint: TEST_MINT,
+ freezeAuthority: TEST_FREEZE_AUTH,
+ });
+ expect(ix.programAddress).toBe(LIGHT_TOKEN_PROGRAM_ID);
+ });
+
+ it('has correct number of accounts (3)', () => {
+ const ix = createThawInstruction({
+ tokenAccount: TEST_SOURCE,
+ mint: TEST_MINT,
+ freezeAuthority: TEST_FREEZE_AUTH,
+ });
+ expect(ix.accounts).toHaveLength(3);
+ });
+
+ it('has correct account addresses in correct order', () => {
+ const ix = createThawInstruction({
+ tokenAccount: TEST_SOURCE,
+ mint: TEST_MINT,
+ freezeAuthority: TEST_FREEZE_AUTH,
+ });
+ expect(ix.accounts[0].address).toBe(TEST_SOURCE);
+ expect(ix.accounts[1].address).toBe(TEST_MINT);
+ expect(ix.accounts[2].address).toBe(TEST_FREEZE_AUTH);
+ });
+
+ it('has correct account roles', () => {
+ const ix = createThawInstruction({
+ tokenAccount: TEST_SOURCE,
+ mint: TEST_MINT,
+ freezeAuthority: TEST_FREEZE_AUTH,
+ });
+ expect(ix.accounts[0].role).toBe(AccountRole.WRITABLE);
+ expect(ix.accounts[1].role).toBe(AccountRole.READONLY);
+ expect(ix.accounts[2].role).toBe(AccountRole.READONLY_SIGNER);
+ });
+
+ it('has correct discriminator byte', () => {
+ const ix = createThawInstruction({
+ tokenAccount: TEST_SOURCE,
+ mint: TEST_MINT,
+ freezeAuthority: TEST_FREEZE_AUTH,
+ });
+ expect(ix.data[0]).toBe(DISCRIMINATOR.THAW);
+ expect(ix.data[0]).toBe(11);
+ });
+
+ it('has discriminator-only data (1 byte)', () => {
+ const ix = createThawInstruction({
+ tokenAccount: TEST_SOURCE,
+ mint: TEST_MINT,
+ freezeAuthority: TEST_FREEZE_AUTH,
+ });
+ expect(ix.data.length).toBe(1);
+ });
+
+ it('has correct data encoding via codec round-trip', () => {
+ const ix = createThawInstruction({
+ tokenAccount: TEST_SOURCE,
+ mint: TEST_MINT,
+ freezeAuthority: TEST_FREEZE_AUTH,
+ });
+ const codec = getDiscriminatorOnlyCodec();
+ const decoded = codec.decode(ix.data);
+ expect(decoded.discriminator).toBe(DISCRIMINATOR.THAW);
+ });
+});
+
+// ============================================================================
+// TEST: createCloseAccountInstruction
+// ============================================================================
+
+describe('createCloseAccountInstruction', () => {
+ it('has correct program address', () => {
+ const ix = createCloseAccountInstruction({
+ tokenAccount: TEST_SOURCE,
+ destination: TEST_DEST,
+ owner: TEST_OWNER,
+ });
+ expect(ix.programAddress).toBe(LIGHT_TOKEN_PROGRAM_ID);
+ });
+
+ it('has correct number of accounts (3)', () => {
+ const ix = createCloseAccountInstruction({
+ tokenAccount: TEST_SOURCE,
+ destination: TEST_DEST,
+ owner: TEST_OWNER,
+ });
+ expect(ix.accounts).toHaveLength(3);
+ });
+
+ it('has correct account addresses in correct order', () => {
+ const ix = createCloseAccountInstruction({
+ tokenAccount: TEST_SOURCE,
+ destination: TEST_DEST,
+ owner: TEST_OWNER,
+ });
+ expect(ix.accounts[0].address).toBe(TEST_SOURCE);
+ expect(ix.accounts[1].address).toBe(TEST_DEST);
+ expect(ix.accounts[2].address).toBe(TEST_OWNER);
+ });
+
+ it('has correct account roles', () => {
+ const ix = createCloseAccountInstruction({
+ tokenAccount: TEST_SOURCE,
+ destination: TEST_DEST,
+ owner: TEST_OWNER,
+ });
+ expect(ix.accounts[0].role).toBe(AccountRole.WRITABLE);
+ expect(ix.accounts[1].role).toBe(AccountRole.WRITABLE);
+ expect(ix.accounts[2].role).toBe(AccountRole.READONLY_SIGNER);
+ });
+
+ it('has correct discriminator byte', () => {
+ const ix = createCloseAccountInstruction({
+ tokenAccount: TEST_SOURCE,
+ destination: TEST_DEST,
+ owner: TEST_OWNER,
+ });
+ expect(ix.data[0]).toBe(DISCRIMINATOR.CLOSE);
+ expect(ix.data[0]).toBe(9);
+ });
+
+ it('has discriminator-only data (1 byte)', () => {
+ const ix = createCloseAccountInstruction({
+ tokenAccount: TEST_SOURCE,
+ destination: TEST_DEST,
+ owner: TEST_OWNER,
+ });
+ expect(ix.data.length).toBe(1);
+ });
+
+ it('has correct data encoding via codec round-trip', () => {
+ const ix = createCloseAccountInstruction({
+ tokenAccount: TEST_SOURCE,
+ destination: TEST_DEST,
+ owner: TEST_OWNER,
+ });
+ const codec = getDiscriminatorOnlyCodec();
+ const decoded = codec.decode(ix.data);
+ expect(decoded.discriminator).toBe(DISCRIMINATOR.CLOSE);
+ });
+});
+
+// ============================================================================
+// TEST: createTransferInterfaceInstruction
+// ============================================================================
+
+describe('createTransferInterfaceInstruction', () => {
+ it('light-to-light: returns transferType "light-to-light" with 1 instruction', () => {
+ const result = createTransferInterfaceInstruction({
+ sourceOwner: LIGHT_TOKEN_PROGRAM_ID,
+ destOwner: LIGHT_TOKEN_PROGRAM_ID,
+ source: TEST_SOURCE,
+ destination: TEST_DEST,
+ amount: 1000n,
+ authority: TEST_AUTHORITY,
+ mint: TEST_MINT,
+ });
+ expect(result.transferType).toBe('light-to-light');
+ expect(result.instructions).toHaveLength(1);
+ expect(result.instructions[0].programAddress).toBe(
+ LIGHT_TOKEN_PROGRAM_ID,
+ );
+ });
+
+ it('light-to-light: instruction has correct discriminator and amount', () => {
+ const result = createTransferInterfaceInstruction({
+ sourceOwner: LIGHT_TOKEN_PROGRAM_ID,
+ destOwner: LIGHT_TOKEN_PROGRAM_ID,
+ source: TEST_SOURCE,
+ destination: TEST_DEST,
+ amount: 2000n,
+ authority: TEST_AUTHORITY,
+ mint: TEST_MINT,
+ });
+ const ix = result.instructions[0];
+ const codec = getAmountInstructionCodec();
+ const decoded = codec.decode(ix.data);
+ expect(decoded.discriminator).toBe(DISCRIMINATOR.TRANSFER);
+ expect(decoded.amount).toBe(2000n);
+ });
+
+ it('light-to-light: passes maxTopUp through', () => {
+ const result = createTransferInterfaceInstruction({
+ sourceOwner: LIGHT_TOKEN_PROGRAM_ID,
+ destOwner: LIGHT_TOKEN_PROGRAM_ID,
+ source: TEST_SOURCE,
+ destination: TEST_DEST,
+ amount: 1000n,
+ authority: TEST_AUTHORITY,
+ mint: TEST_MINT,
+ maxTopUp: 7000,
+ });
+ const ix = result.instructions[0];
+ // Data should include maxTopUp suffix: 1 + 8 + 2 = 11
+ expect(ix.data.length).toBe(11);
+ const maxTopUp = decodeMaxTopUp(ix.data, 9);
+ expect(maxTopUp).toBe(7000);
+ });
+
+ it('light-to-spl: throws', () => {
+ const splProgram = address(
+ 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA',
+ );
+ expect(() =>
+ createTransferInterfaceInstruction({
+ sourceOwner: LIGHT_TOKEN_PROGRAM_ID,
+ destOwner: splProgram,
+ source: TEST_SOURCE,
+ destination: TEST_DEST,
+ amount: 1000n,
+ authority: TEST_AUTHORITY,
+ mint: TEST_MINT,
+ }),
+ ).toThrow('Light-to-SPL transfer requires Transfer2');
+ });
+
+ it('spl-to-light: throws', () => {
+ const splProgram = address(
+ 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA',
+ );
+ expect(() =>
+ createTransferInterfaceInstruction({
+ sourceOwner: splProgram,
+ destOwner: LIGHT_TOKEN_PROGRAM_ID,
+ source: TEST_SOURCE,
+ destination: TEST_DEST,
+ amount: 1000n,
+ authority: TEST_AUTHORITY,
+ mint: TEST_MINT,
+ }),
+ ).toThrow('SPL-to-Light transfer requires Transfer2');
+ });
+
+ it('spl-to-spl: throws', () => {
+ const splProgram = address(
+ 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA',
+ );
+ expect(() =>
+ createTransferInterfaceInstruction({
+ sourceOwner: splProgram,
+ destOwner: splProgram,
+ source: TEST_SOURCE,
+ destination: TEST_DEST,
+ amount: 1000n,
+ authority: TEST_AUTHORITY,
+ mint: TEST_MINT,
+ }),
+ ).toThrow('SPL-to-SPL transfers should use the SPL Token program');
+ });
+});
+
+// ============================================================================
+// TEST: createTokenAccountInstruction
+// ============================================================================
+
+describe('createTokenAccountInstruction', () => {
+ it('non-compressible path has 2 accounts and discriminator 18', () => {
+ const ix = createTokenAccountInstruction({
+ tokenAccount: TEST_SOURCE,
+ mint: TEST_MINT,
+ owner: TEST_OWNER,
+ });
+ expect(ix.programAddress).toBe(LIGHT_TOKEN_PROGRAM_ID);
+ expect(ix.accounts).toHaveLength(2);
+ expect(ix.accounts[0].address).toBe(TEST_SOURCE);
+ expect(ix.accounts[0].role).toBe(AccountRole.WRITABLE);
+ expect(ix.accounts[1].address).toBe(TEST_MINT);
+ expect(ix.accounts[1].role).toBe(AccountRole.READONLY);
+ expect(ix.data[0]).toBe(DISCRIMINATOR.CREATE_TOKEN_ACCOUNT);
+ });
+
+ it('compressible path includes payer/config/system/rent accounts', () => {
+ const ix = createTokenAccountInstruction({
+ tokenAccount: TEST_SOURCE,
+ mint: TEST_MINT,
+ owner: TEST_OWNER,
+ payer: TEST_PAYER,
+ compressibleConfig: TEST_CONFIG,
+ rentSponsor: TEST_SPONSOR,
+ compressibleParams: {
+ tokenAccountVersion: 3,
+ rentPayment: 16,
+ compressionOnly: 0,
+ writeTopUp: 766,
+ compressToPubkey: null,
+ },
+ });
+ expect(ix.accounts).toHaveLength(6);
+ expect(ix.accounts[0].role).toBe(AccountRole.WRITABLE_SIGNER);
+ expect(ix.accounts[2].address).toBe(TEST_PAYER);
+ expect(ix.accounts[2].role).toBe(AccountRole.WRITABLE_SIGNER);
+ expect(ix.accounts[3].address).toBe(TEST_CONFIG);
+ expect(ix.accounts[4].address).toBe(SYSTEM_PROGRAM_ID);
+ expect(ix.accounts[5].address).toBe(TEST_SPONSOR);
+ expect(ix.data[0]).toBe(DISCRIMINATOR.CREATE_TOKEN_ACCOUNT);
+ expect(ix.data.length).toBeGreaterThan(33);
+ });
+
+ it('throws when compressibleParams is set without payer', () => {
+ expect(() =>
+ createTokenAccountInstruction({
+ tokenAccount: TEST_SOURCE,
+ mint: TEST_MINT,
+ owner: TEST_OWNER,
+ compressibleParams: {
+ tokenAccountVersion: 3,
+ rentPayment: 16,
+ compressionOnly: 0,
+ writeTopUp: 766,
+ compressToPubkey: null,
+ },
+ }),
+ ).toThrow('payer is required when compressibleParams is provided');
+ });
+
+ it('throws when compressible-only accounts are provided without compressibleParams', () => {
+ expect(() =>
+ createTokenAccountInstruction({
+ tokenAccount: TEST_SOURCE,
+ mint: TEST_MINT,
+ owner: TEST_OWNER,
+ payer: TEST_PAYER,
+ }),
+ ).toThrow('payer/compressibleConfig/rentSponsor require compressibleParams');
+ });
+
+ it('supports SPL-compatible owner-only payload mode', () => {
+ const ix = createTokenAccountInstruction({
+ tokenAccount: TEST_SOURCE,
+ mint: TEST_MINT,
+ owner: TEST_OWNER,
+ splCompatibleOwnerOnlyData: true,
+ });
+ expect(ix.data[0]).toBe(DISCRIMINATOR.CREATE_TOKEN_ACCOUNT);
+ expect(ix.data).toHaveLength(33);
+ expect(ix.data.slice(1)).toEqual(
+ new Uint8Array(getAddressCodec().encode(TEST_OWNER)),
+ );
+ });
+
+ it('throws when SPL-compatible owner-only mode is used with compressible params', () => {
+ expect(() =>
+ createTokenAccountInstruction({
+ tokenAccount: TEST_SOURCE,
+ mint: TEST_MINT,
+ owner: TEST_OWNER,
+ payer: TEST_PAYER,
+ splCompatibleOwnerOnlyData: true,
+ compressibleParams: {
+ tokenAccountVersion: 3,
+ rentPayment: 16,
+ compressionOnly: 0,
+ writeTopUp: 766,
+ compressToPubkey: null,
+ },
+ }),
+ ).toThrow(
+ 'splCompatibleOwnerOnlyData is only valid for non-compressible token account creation',
+ );
+ });
+});
+
+// ============================================================================
+// TEST: createAssociatedTokenAccountInstruction
+// ============================================================================
+
+describe('createAssociatedTokenAccountInstruction', () => {
+ it('has correct program address', async () => {
+ const result = await createAssociatedTokenAccountInstruction({
+ payer: TEST_PAYER,
+ owner: TEST_OWNER,
+ mint: TEST_MINT,
+ compressibleConfig: TEST_CONFIG,
+ rentSponsor: TEST_SPONSOR,
+ });
+ expect(result.instruction.programAddress).toBe(
+ LIGHT_TOKEN_PROGRAM_ID,
+ );
+ });
+
+ it('has correct number of accounts (7)', async () => {
+ const result = await createAssociatedTokenAccountInstruction({
+ payer: TEST_PAYER,
+ owner: TEST_OWNER,
+ mint: TEST_MINT,
+ compressibleConfig: TEST_CONFIG,
+ rentSponsor: TEST_SPONSOR,
+ });
+ expect(result.instruction.accounts).toHaveLength(7);
+ });
+
+ it('has correct account addresses in correct order', async () => {
+ const result = await createAssociatedTokenAccountInstruction({
+ payer: TEST_PAYER,
+ owner: TEST_OWNER,
+ mint: TEST_MINT,
+ compressibleConfig: TEST_CONFIG,
+ rentSponsor: TEST_SPONSOR,
+ });
+ const accounts = result.instruction.accounts;
+ expect(accounts[0].address).toBe(TEST_OWNER);
+ expect(accounts[1].address).toBe(TEST_MINT);
+ expect(accounts[2].address).toBe(TEST_PAYER);
+ expect(accounts[3].address).toBe(result.address); // derived ATA
+ expect(accounts[4].address).toBe(SYSTEM_PROGRAM_ID);
+ expect(accounts[5].address).toBe(TEST_CONFIG);
+ expect(accounts[6].address).toBe(TEST_SPONSOR);
+ });
+
+ it('has correct account roles', async () => {
+ const result = await createAssociatedTokenAccountInstruction({
+ payer: TEST_PAYER,
+ owner: TEST_OWNER,
+ mint: TEST_MINT,
+ compressibleConfig: TEST_CONFIG,
+ rentSponsor: TEST_SPONSOR,
+ });
+ const accounts = result.instruction.accounts;
+ expect(accounts[0].role).toBe(AccountRole.READONLY); // owner
+ expect(accounts[1].role).toBe(AccountRole.READONLY); // mint
+ expect(accounts[2].role).toBe(AccountRole.WRITABLE_SIGNER); // payer
+ expect(accounts[3].role).toBe(AccountRole.WRITABLE); // ata
+ expect(accounts[4].role).toBe(AccountRole.READONLY); // systemProgram
+ expect(accounts[5].role).toBe(AccountRole.READONLY); // compressibleConfig
+ expect(accounts[6].role).toBe(AccountRole.WRITABLE); // rentSponsor
+ });
+
+ it('data starts with CREATE_ATA discriminator (100)', async () => {
+ const result = await createAssociatedTokenAccountInstruction({
+ payer: TEST_PAYER,
+ owner: TEST_OWNER,
+ mint: TEST_MINT,
+ compressibleConfig: TEST_CONFIG,
+ rentSponsor: TEST_SPONSOR,
+ });
+ expect(result.instruction.data[0]).toBe(DISCRIMINATOR.CREATE_ATA);
+ expect(result.instruction.data[0]).toBe(100);
+ });
+
+ it('returns valid address and bump', async () => {
+ const result = await createAssociatedTokenAccountInstruction({
+ payer: TEST_PAYER,
+ owner: TEST_OWNER,
+ mint: TEST_MINT,
+ compressibleConfig: TEST_CONFIG,
+ rentSponsor: TEST_SPONSOR,
+ });
+ expect(result.address).toBeDefined();
+ expect(typeof result.bump).toBe('number');
+ expect(result.bump).toBeGreaterThanOrEqual(0);
+ expect(result.bump).toBeLessThanOrEqual(255);
+ });
+
+ it('consistent PDA derivation across calls', async () => {
+ const result1 = await createAssociatedTokenAccountInstruction({
+ payer: TEST_PAYER,
+ owner: TEST_OWNER,
+ mint: TEST_MINT,
+ compressibleConfig: TEST_CONFIG,
+ rentSponsor: TEST_SPONSOR,
+ });
+ const result2 = await createAssociatedTokenAccountInstruction({
+ payer: TEST_PAYER,
+ owner: TEST_OWNER,
+ mint: TEST_MINT,
+ compressibleConfig: TEST_CONFIG,
+ rentSponsor: TEST_SPONSOR,
+ });
+ expect(result1.address).toBe(result2.address);
+ expect(result1.bump).toBe(result2.bump);
+ });
+
+ it('data length is greater than 1 (discriminator + encoded payload)', async () => {
+ const result = await createAssociatedTokenAccountInstruction({
+ payer: TEST_PAYER,
+ owner: TEST_OWNER,
+ mint: TEST_MINT,
+ compressibleConfig: TEST_CONFIG,
+ rentSponsor: TEST_SPONSOR,
+ });
+ // discriminator (1) + compressibleConfig option prefix (1) + data
+ expect(result.instruction.data.length).toBeGreaterThan(1);
+ });
+});
+
+// ============================================================================
+// TEST: createAssociatedTokenAccountIdempotentInstruction
+// ============================================================================
+
+describe('createAssociatedTokenAccountIdempotentInstruction', () => {
+ it('has correct program address', async () => {
+ const result =
+ await createAssociatedTokenAccountIdempotentInstruction({
+ payer: TEST_PAYER,
+ owner: TEST_OWNER,
+ mint: TEST_MINT,
+ compressibleConfig: TEST_CONFIG,
+ rentSponsor: TEST_SPONSOR,
+ });
+ expect(result.instruction.programAddress).toBe(
+ LIGHT_TOKEN_PROGRAM_ID,
+ );
+ });
+
+ it('has correct number of accounts (7)', async () => {
+ const result =
+ await createAssociatedTokenAccountIdempotentInstruction({
+ payer: TEST_PAYER,
+ owner: TEST_OWNER,
+ mint: TEST_MINT,
+ compressibleConfig: TEST_CONFIG,
+ rentSponsor: TEST_SPONSOR,
+ });
+ expect(result.instruction.accounts).toHaveLength(7);
+ });
+
+ it('data starts with CREATE_ATA_IDEMPOTENT discriminator (102)', async () => {
+ const result =
+ await createAssociatedTokenAccountIdempotentInstruction({
+ payer: TEST_PAYER,
+ owner: TEST_OWNER,
+ mint: TEST_MINT,
+ compressibleConfig: TEST_CONFIG,
+ rentSponsor: TEST_SPONSOR,
+ });
+ expect(result.instruction.data[0]).toBe(
+ DISCRIMINATOR.CREATE_ATA_IDEMPOTENT,
+ );
+ expect(result.instruction.data[0]).toBe(102);
+ });
+
+ it('consistent PDA derivation matches non-idempotent variant', async () => {
+ const normalResult =
+ await createAssociatedTokenAccountInstruction({
+ payer: TEST_PAYER,
+ owner: TEST_OWNER,
+ mint: TEST_MINT,
+ compressibleConfig: TEST_CONFIG,
+ rentSponsor: TEST_SPONSOR,
+ });
+ const idempotentResult =
+ await createAssociatedTokenAccountIdempotentInstruction({
+ payer: TEST_PAYER,
+ owner: TEST_OWNER,
+ mint: TEST_MINT,
+ compressibleConfig: TEST_CONFIG,
+ rentSponsor: TEST_SPONSOR,
+ });
+ expect(normalResult.address).toBe(idempotentResult.address);
+ expect(normalResult.bump).toBe(idempotentResult.bump);
+ });
+
+ it('has same account structure as non-idempotent variant', async () => {
+ const normalResult =
+ await createAssociatedTokenAccountInstruction({
+ payer: TEST_PAYER,
+ owner: TEST_OWNER,
+ mint: TEST_MINT,
+ compressibleConfig: TEST_CONFIG,
+ rentSponsor: TEST_SPONSOR,
+ });
+ const idempotentResult =
+ await createAssociatedTokenAccountIdempotentInstruction({
+ payer: TEST_PAYER,
+ owner: TEST_OWNER,
+ mint: TEST_MINT,
+ compressibleConfig: TEST_CONFIG,
+ rentSponsor: TEST_SPONSOR,
+ });
+
+ // Same number of accounts
+ expect(idempotentResult.instruction.accounts).toHaveLength(
+ normalResult.instruction.accounts.length,
+ );
+
+ // Same account addresses and roles
+ for (let i = 0; i < normalResult.instruction.accounts.length; i++) {
+ expect(idempotentResult.instruction.accounts[i].address).toBe(
+ normalResult.instruction.accounts[i].address,
+ );
+ expect(idempotentResult.instruction.accounts[i].role).toBe(
+ normalResult.instruction.accounts[i].role,
+ );
+ }
+ });
+
+ it('only differs from non-idempotent in discriminator byte', async () => {
+ const normalResult =
+ await createAssociatedTokenAccountInstruction({
+ payer: TEST_PAYER,
+ owner: TEST_OWNER,
+ mint: TEST_MINT,
+ compressibleConfig: TEST_CONFIG,
+ rentSponsor: TEST_SPONSOR,
+ });
+ const idempotentResult =
+ await createAssociatedTokenAccountIdempotentInstruction({
+ payer: TEST_PAYER,
+ owner: TEST_OWNER,
+ mint: TEST_MINT,
+ compressibleConfig: TEST_CONFIG,
+ rentSponsor: TEST_SPONSOR,
+ });
+
+ // Discriminators differ
+ expect(normalResult.instruction.data[0]).toBe(100);
+ expect(idempotentResult.instruction.data[0]).toBe(102);
+
+ // Rest of data is identical
+ const normalPayload = normalResult.instruction.data.slice(1);
+ const idempotentPayload = idempotentResult.instruction.data.slice(1);
+ expect(normalPayload).toEqual(idempotentPayload);
+ });
+});
+
+// ============================================================================
+// TEST: AccountRole enum values
+// ============================================================================
+
+describe('AccountRole enum values', () => {
+ it('READONLY = 0', () => {
+ expect(AccountRole.READONLY).toBe(0);
+ });
+
+ it('WRITABLE = 1', () => {
+ expect(AccountRole.WRITABLE).toBe(1);
+ });
+
+ it('READONLY_SIGNER = 2', () => {
+ expect(AccountRole.READONLY_SIGNER).toBe(2);
+ });
+
+ it('WRITABLE_SIGNER = 3', () => {
+ expect(AccountRole.WRITABLE_SIGNER).toBe(3);
+ });
+});
+
+// ============================================================================
+// TEST: DISCRIMINATOR constant values
+// ============================================================================
+
+describe('DISCRIMINATOR constant values', () => {
+ it('TRANSFER = 3', () => {
+ expect(DISCRIMINATOR.TRANSFER).toBe(3);
+ });
+
+ it('APPROVE = 4', () => {
+ expect(DISCRIMINATOR.APPROVE).toBe(4);
+ });
+
+ it('REVOKE = 5', () => {
+ expect(DISCRIMINATOR.REVOKE).toBe(5);
+ });
+
+ it('MINT_TO = 7', () => {
+ expect(DISCRIMINATOR.MINT_TO).toBe(7);
+ });
+
+ it('BURN = 8', () => {
+ expect(DISCRIMINATOR.BURN).toBe(8);
+ });
+
+ it('CLOSE = 9', () => {
+ expect(DISCRIMINATOR.CLOSE).toBe(9);
+ });
+
+ it('FREEZE = 10', () => {
+ expect(DISCRIMINATOR.FREEZE).toBe(10);
+ });
+
+ it('THAW = 11', () => {
+ expect(DISCRIMINATOR.THAW).toBe(11);
+ });
+
+ it('TRANSFER_CHECKED = 12', () => {
+ expect(DISCRIMINATOR.TRANSFER_CHECKED).toBe(12);
+ });
+
+ it('MINT_TO_CHECKED = 14', () => {
+ expect(DISCRIMINATOR.MINT_TO_CHECKED).toBe(14);
+ });
+
+ it('BURN_CHECKED = 15', () => {
+ expect(DISCRIMINATOR.BURN_CHECKED).toBe(15);
+ });
+
+ it('CREATE_TOKEN_ACCOUNT = 18', () => {
+ expect(DISCRIMINATOR.CREATE_TOKEN_ACCOUNT).toBe(18);
+ });
+
+ it('CREATE_ATA = 100', () => {
+ expect(DISCRIMINATOR.CREATE_ATA).toBe(100);
+ });
+
+ it('CREATE_ATA_IDEMPOTENT = 102', () => {
+ expect(DISCRIMINATOR.CREATE_ATA_IDEMPOTENT).toBe(102);
+ });
+
+ it('TRANSFER2 = 101', () => {
+ expect(DISCRIMINATOR.TRANSFER2).toBe(101);
+ });
+
+ it('MINT_ACTION = 103', () => {
+ expect(DISCRIMINATOR.MINT_ACTION).toBe(103);
+ });
+});
+
+// ============================================================================
+// TEST: createApproveInstruction with maxTopUp (no feePayer - Rust doesn't support it)
+// ============================================================================
+
+describe('createApproveInstruction (maxTopUp)', () => {
+ it('includes maxTopUp in data when provided', () => {
+ const ix = createApproveInstruction({
+ tokenAccount: TEST_SOURCE,
+ delegate: TEST_DELEGATE,
+ owner: TEST_OWNER,
+ amount: 10_000n,
+ maxTopUp: 500,
+ });
+ // disc(1) + amount(8) + maxTopUp(2) = 11
+ expect(ix.data.length).toBe(11);
+ const maxTopUp = decodeMaxTopUp(ix.data, 9);
+ expect(maxTopUp).toBe(500);
+ });
+
+ it('owner is always WRITABLE_SIGNER (payer at APPROVE_PAYER_IDX=2)', () => {
+ const ix = createApproveInstruction({
+ tokenAccount: TEST_SOURCE,
+ delegate: TEST_DELEGATE,
+ owner: TEST_OWNER,
+ amount: 10_000n,
+ maxTopUp: 500,
+ });
+ // Always 3 accounts, no separate feePayer
+ expect(ix.accounts).toHaveLength(3);
+ expect(ix.accounts[2].address).toBe(TEST_OWNER);
+ expect(ix.accounts[2].role).toBe(AccountRole.WRITABLE_SIGNER);
+ });
+});
+
+// ============================================================================
+// TEST: createRevokeInstruction with maxTopUp (no feePayer - Rust doesn't support it)
+// ============================================================================
+
+describe('createRevokeInstruction (maxTopUp)', () => {
+ it('includes maxTopUp in data when provided', () => {
+ const ix = createRevokeInstruction({
+ tokenAccount: TEST_SOURCE,
+ owner: TEST_OWNER,
+ maxTopUp: 1000,
+ });
+ // disc(1) + maxTopUp(2) = 3
+ expect(ix.data.length).toBe(3);
+ const maxTopUp = decodeMaxTopUp(ix.data, 1);
+ expect(maxTopUp).toBe(1000);
+ });
+
+ it('owner is always WRITABLE_SIGNER (payer at REVOKE_PAYER_IDX=1)', () => {
+ const ix = createRevokeInstruction({
+ tokenAccount: TEST_SOURCE,
+ owner: TEST_OWNER,
+ maxTopUp: 1000,
+ });
+ // Always 2 accounts, no separate feePayer
+ expect(ix.accounts).toHaveLength(2);
+ expect(ix.accounts[1].address).toBe(TEST_OWNER);
+ expect(ix.accounts[1].role).toBe(AccountRole.WRITABLE_SIGNER);
+ });
+});
+
+// ============================================================================
+// TEST: createTransfer2Instruction
+// ============================================================================
+
+describe('createTransfer2Instruction', () => {
+ it('Path A: compression-only has cpiAuthority + feePayer + packed accounts', () => {
+ const ix = createTransfer2Instruction({
+ feePayer: TEST_PAYER,
+ packedAccounts: [
+ { address: TEST_MINT, role: AccountRole.READONLY },
+ { address: TEST_SOURCE, role: AccountRole.WRITABLE },
+ ],
+ data: {
+ withTransactionHash: false,
+ withLamportsChangeAccountMerkleTreeIndex: false,
+ lamportsChangeAccountMerkleTreeIndex: 0,
+ lamportsChangeAccountOwnerIndex: 0,
+ outputQueue: 0,
+ maxTopUp: 65535,
+ cpiContext: null,
+ compressions: [{
+ mode: 0, amount: 1000n, mint: 0, sourceOrRecipient: 1,
+ authority: 0, poolAccountIndex: 0, poolIndex: 0, bump: 0, decimals: 2,
+ }],
+ proof: null,
+ inTokenData: [],
+ outTokenData: [],
+ inLamports: null,
+ outLamports: null,
+ inTlv: null,
+ outTlv: null,
+ },
+ });
+ // Path A: 2 fixed + 2 packed = 4
+ expect(ix.accounts).toHaveLength(4);
+ expect(ix.programAddress).toBe(LIGHT_TOKEN_PROGRAM_ID);
+ expect(ix.data[0]).toBe(DISCRIMINATOR.TRANSFER2);
+ });
+
+ it('Path A: packed accounts preserve their roles', () => {
+ const ix = createTransfer2Instruction({
+ feePayer: TEST_PAYER,
+ packedAccounts: [
+ { address: TEST_MINT, role: AccountRole.READONLY },
+ { address: TEST_SOURCE, role: AccountRole.WRITABLE },
+ { address: TEST_OWNER, role: AccountRole.READONLY_SIGNER },
+ ],
+ data: {
+ withTransactionHash: false,
+ withLamportsChangeAccountMerkleTreeIndex: false,
+ lamportsChangeAccountMerkleTreeIndex: 0,
+ lamportsChangeAccountOwnerIndex: 0,
+ outputQueue: 0,
+ maxTopUp: 65535,
+ cpiContext: null,
+ compressions: [{
+ mode: 0, amount: 1000n, mint: 0, sourceOrRecipient: 1,
+ authority: 2, poolAccountIndex: 0, poolIndex: 0, bump: 0, decimals: 0,
+ }],
+ proof: null,
+ inTokenData: [],
+ outTokenData: [],
+ inLamports: null,
+ outLamports: null,
+ inTlv: null,
+ outTlv: null,
+ },
+ });
+ // 2 fixed + 3 packed = 5
+ expect(ix.accounts).toHaveLength(5);
+ // Packed accounts start at index 2
+ expect(ix.accounts[2].address).toBe(TEST_MINT);
+ expect(ix.accounts[2].role).toBe(AccountRole.READONLY);
+ expect(ix.accounts[3].address).toBe(TEST_SOURCE);
+ expect(ix.accounts[3].role).toBe(AccountRole.WRITABLE);
+ expect(ix.accounts[4].address).toBe(TEST_OWNER);
+ expect(ix.accounts[4].role).toBe(AccountRole.READONLY_SIGNER);
+ });
+
+ it('Path B: full transfer has 7+ fixed accounts', () => {
+ const ix = createTransfer2Instruction({
+ feePayer: TEST_PAYER,
+ packedAccounts: [
+ { address: TEST_MINT, role: AccountRole.READONLY },
+ ],
+ data: {
+ withTransactionHash: false,
+ withLamportsChangeAccountMerkleTreeIndex: false,
+ lamportsChangeAccountMerkleTreeIndex: 0,
+ lamportsChangeAccountOwnerIndex: 0,
+ outputQueue: 0,
+ maxTopUp: 65535,
+ cpiContext: null,
+ compressions: null,
+ proof: null,
+ inTokenData: [{
+ owner: 0, amount: 1000n, hasDelegate: false, delegate: 0,
+ mint: 0, version: 3,
+ merkleContext: { merkleTreePubkeyIndex: 0, queuePubkeyIndex: 0, leafIndex: 0, proveByIndex: true },
+ rootIndex: 0,
+ }],
+ outTokenData: [{
+ owner: 0, amount: 1000n, hasDelegate: false, delegate: 0,
+ mint: 0, version: 3,
+ }],
+ inLamports: null,
+ outLamports: null,
+ inTlv: null,
+ outTlv: null,
+ },
+ });
+ // Path B: 7 fixed + 1 packed = 8
+ expect(ix.accounts).toHaveLength(8);
+ expect(ix.data[0]).toBe(DISCRIMINATOR.TRANSFER2);
+ // Rust parity defaults for system CPI accounts
+ expect(ix.accounts[3].address).toBe(REGISTERED_PROGRAM_PDA);
+ expect(ix.accounts[4].address).toBe(
+ ACCOUNT_COMPRESSION_AUTHORITY_PDA,
+ );
+ // Packed account at index 7 preserves readonly role
+ expect(ix.accounts[7].address).toBe(TEST_MINT);
+ expect(ix.accounts[7].role).toBe(AccountRole.READONLY);
+ });
+
+ it('Path C: CPI context write has lightSystemProgram + feePayer + cpiAuthority + cpiContext + packed', () => {
+ const cpiContextAccount = address('Sysvar1111111111111111111111111111111111111');
+ const ix = createTransfer2Instruction({
+ feePayer: TEST_PAYER,
+ cpiContextAccount,
+ packedAccounts: [
+ { address: TEST_MINT, role: AccountRole.READONLY },
+ { address: TEST_SOURCE, role: AccountRole.WRITABLE },
+ ],
+ data: {
+ withTransactionHash: false,
+ withLamportsChangeAccountMerkleTreeIndex: false,
+ lamportsChangeAccountMerkleTreeIndex: 0,
+ lamportsChangeAccountOwnerIndex: 0,
+ outputQueue: 0,
+ maxTopUp: 65535,
+ cpiContext: { setContext: true, firstSetContext: true },
+ compressions: null,
+ proof: null,
+ inTokenData: [{
+ owner: 0, amount: 1000n, hasDelegate: false, delegate: 0,
+ mint: 0, version: 3,
+ merkleContext: { merkleTreePubkeyIndex: 0, queuePubkeyIndex: 0, leafIndex: 0, proveByIndex: true },
+ rootIndex: 0,
+ }],
+ outTokenData: [{
+ owner: 0, amount: 1000n, hasDelegate: false, delegate: 0,
+ mint: 0, version: 3,
+ }],
+ inLamports: null,
+ outLamports: null,
+ inTlv: null,
+ outTlv: null,
+ },
+ });
+ // Path C: 4 fixed + 2 packed = 6
+ expect(ix.accounts).toHaveLength(6);
+ // Account 0: lightSystemProgram (readonly)
+ expect(ix.accounts[0].address).toBe(LIGHT_SYSTEM_PROGRAM_ID);
+ expect(ix.accounts[0].role).toBe(AccountRole.READONLY);
+ // Account 1: feePayer (writable signer)
+ expect(ix.accounts[1].address).toBe(TEST_PAYER);
+ expect(ix.accounts[1].role).toBe(AccountRole.WRITABLE_SIGNER);
+ // Account 2: cpiAuthorityPda (readonly)
+ expect(ix.accounts[2].address).toBe(CPI_AUTHORITY);
+ expect(ix.accounts[2].role).toBe(AccountRole.READONLY);
+ // Account 3: cpiContext (writable — program writes CPI data to it)
+ expect(ix.accounts[3].address).toBe(cpiContextAccount);
+ expect(ix.accounts[3].role).toBe(AccountRole.WRITABLE);
+ // Packed accounts
+ expect(ix.accounts[4].address).toBe(TEST_MINT);
+ expect(ix.accounts[5].address).toBe(TEST_SOURCE);
+ });
+});
+
+// ============================================================================
+// TEST: Compression factory functions
+// ============================================================================
+
+describe('Compression factory functions', () => {
+ it('createCompress: CToken compression', () => {
+ const comp = createCompress({
+ amount: 5000n,
+ mintIndex: 2,
+ sourceIndex: 1,
+ authorityIndex: 0,
+ });
+ expect(comp.mode).toBe(COMPRESSION_MODE.COMPRESS);
+ expect(comp.amount).toBe(5000n);
+ expect(comp.mint).toBe(2);
+ expect(comp.sourceOrRecipient).toBe(1);
+ expect(comp.authority).toBe(0);
+ expect(comp.poolAccountIndex).toBe(0);
+ expect(comp.poolIndex).toBe(0);
+ expect(comp.bump).toBe(0);
+ expect(comp.decimals).toBe(0);
+ });
+
+ it('createCompressSpl: SPL compression', () => {
+ const comp = createCompressSpl({
+ amount: 5000n,
+ mintIndex: 3,
+ sourceIndex: 4,
+ authorityIndex: 0,
+ poolAccountIndex: 5,
+ poolIndex: 1,
+ bump: 254,
+ decimals: 6,
+ });
+ expect(comp.mode).toBe(COMPRESSION_MODE.COMPRESS);
+ expect(comp.amount).toBe(5000n);
+ expect(comp.mint).toBe(3);
+ expect(comp.sourceOrRecipient).toBe(4);
+ expect(comp.authority).toBe(0);
+ expect(comp.poolAccountIndex).toBe(5);
+ expect(comp.poolIndex).toBe(1);
+ expect(comp.bump).toBe(254);
+ expect(comp.decimals).toBe(6);
+ });
+
+ it('createDecompress: CToken decompression', () => {
+ const comp = createDecompress({
+ amount: 3000n,
+ mintIndex: 2,
+ recipientIndex: 7,
+ });
+ expect(comp.mode).toBe(COMPRESSION_MODE.DECOMPRESS);
+ expect(comp.amount).toBe(3000n);
+ expect(comp.mint).toBe(2);
+ expect(comp.sourceOrRecipient).toBe(7);
+ expect(comp.authority).toBe(0);
+ expect(comp.poolAccountIndex).toBe(0);
+ });
+
+ it('createDecompressSpl: SPL decompression', () => {
+ const comp = createDecompressSpl({
+ amount: 2000n,
+ mintIndex: 3,
+ recipientIndex: 8,
+ poolAccountIndex: 9,
+ poolIndex: 0,
+ bump: 123,
+ decimals: 9,
+ });
+ expect(comp.mode).toBe(COMPRESSION_MODE.DECOMPRESS);
+ expect(comp.amount).toBe(2000n);
+ expect(comp.sourceOrRecipient).toBe(8);
+ expect(comp.authority).toBe(0);
+ expect(comp.poolAccountIndex).toBe(9);
+ expect(comp.poolIndex).toBe(0);
+ expect(comp.bump).toBe(123);
+ expect(comp.decimals).toBe(9);
+ });
+
+ it('createCompressAndClose: repurposed fields', () => {
+ const comp = createCompressAndClose({
+ amount: 1000n,
+ mintIndex: 2,
+ sourceIndex: 1,
+ authorityIndex: 0,
+ rentSponsorIndex: 10,
+ compressedAccountIndex: 11,
+ destinationIndex: 5,
+ });
+ expect(comp.mode).toBe(COMPRESSION_MODE.COMPRESS_AND_CLOSE);
+ expect(comp.amount).toBe(1000n);
+ expect(comp.mint).toBe(2);
+ expect(comp.sourceOrRecipient).toBe(1);
+ expect(comp.authority).toBe(0);
+ // Repurposed fields
+ expect(comp.poolAccountIndex).toBe(10); // rentSponsorIndex
+ expect(comp.poolIndex).toBe(11); // compressedAccountIndex
+ expect(comp.bump).toBe(5); // destinationIndex
+ expect(comp.decimals).toBe(0);
+ });
+});
+
+// ============================================================================
+// TEST: createClaimInstruction
+// ============================================================================
+
+describe('createClaimInstruction', () => {
+ it('builds correct instruction with discriminator and accounts', () => {
+ const ix = createClaimInstruction({
+ rentSponsor: TEST_PAYER,
+ compressionAuthority: TEST_AUTHORITY,
+ compressibleConfig: TEST_MINT,
+ tokenAccounts: [TEST_SOURCE, TEST_DEST],
+ });
+
+ expect(ix.programAddress).toBe(LIGHT_TOKEN_PROGRAM_ID);
+ // 3 fixed + 2 token accounts = 5
+ expect(ix.accounts).toHaveLength(5);
+
+ // Account roles
+ expect(ix.accounts[0].address).toBe(TEST_PAYER);
+ expect(ix.accounts[0].role).toBe(AccountRole.WRITABLE);
+ expect(ix.accounts[1].address).toBe(TEST_AUTHORITY);
+ expect(ix.accounts[1].role).toBe(AccountRole.READONLY_SIGNER);
+ expect(ix.accounts[2].address).toBe(TEST_MINT);
+ expect(ix.accounts[2].role).toBe(AccountRole.READONLY);
+ expect(ix.accounts[3].address).toBe(TEST_SOURCE);
+ expect(ix.accounts[3].role).toBe(AccountRole.WRITABLE);
+ expect(ix.accounts[4].address).toBe(TEST_DEST);
+ expect(ix.accounts[4].role).toBe(AccountRole.WRITABLE);
+
+ // Data: discriminator only (no instruction data)
+ expect(ix.data).toHaveLength(1);
+ expect(ix.data[0]).toBe(DISCRIMINATOR.CLAIM);
+ });
+
+ it('works with no token accounts', () => {
+ const ix = createClaimInstruction({
+ rentSponsor: TEST_PAYER,
+ compressionAuthority: TEST_AUTHORITY,
+ compressibleConfig: TEST_MINT,
+ tokenAccounts: [],
+ });
+ expect(ix.accounts).toHaveLength(3);
+ });
+});
+
+// ============================================================================
+// TEST: createWithdrawFundingPoolInstruction
+// ============================================================================
+
+describe('createWithdrawFundingPoolInstruction', () => {
+ it('builds correct instruction with amount encoding', () => {
+ const ix = createWithdrawFundingPoolInstruction({
+ rentSponsor: TEST_PAYER,
+ compressionAuthority: TEST_AUTHORITY,
+ destination: TEST_DEST,
+ compressibleConfig: TEST_MINT,
+ amount: 1_000_000_000n,
+ });
+
+ expect(ix.programAddress).toBe(LIGHT_TOKEN_PROGRAM_ID);
+ expect(ix.accounts).toHaveLength(5);
+
+ // Account roles
+ expect(ix.accounts[0].address).toBe(TEST_PAYER);
+ expect(ix.accounts[0].role).toBe(AccountRole.WRITABLE);
+ expect(ix.accounts[1].address).toBe(TEST_AUTHORITY);
+ expect(ix.accounts[1].role).toBe(AccountRole.READONLY_SIGNER);
+ expect(ix.accounts[2].address).toBe(TEST_DEST);
+ expect(ix.accounts[2].role).toBe(AccountRole.WRITABLE);
+ expect(ix.accounts[3].address).toBe(SYSTEM_PROGRAM_ID);
+ expect(ix.accounts[3].role).toBe(AccountRole.READONLY);
+ expect(ix.accounts[4].address).toBe(TEST_MINT);
+ expect(ix.accounts[4].role).toBe(AccountRole.READONLY);
+
+ // Data: discriminator (1) + u64 amount (8) = 9 bytes
+ expect(ix.data).toHaveLength(9);
+ expect(ix.data[0]).toBe(DISCRIMINATOR.WITHDRAW_FUNDING_POOL);
+
+ // Decode amount (LE u64)
+ const view = new DataView(ix.data.buffer, ix.data.byteOffset);
+ const amount = view.getBigUint64(1, true);
+ expect(amount).toBe(1_000_000_000n);
+ });
+
+ it('encodes zero amount', () => {
+ const ix = createWithdrawFundingPoolInstruction({
+ rentSponsor: TEST_PAYER,
+ compressionAuthority: TEST_AUTHORITY,
+ destination: TEST_DEST,
+ compressibleConfig: TEST_MINT,
+ amount: 0n,
+ });
+
+ const view = new DataView(ix.data.buffer, ix.data.byteOffset);
+ expect(view.getBigUint64(1, true)).toBe(0n);
+ });
+
+ it('encodes large amount', () => {
+ const largeAmount = 18_446_744_073_709_551_615n; // u64::MAX
+ const ix = createWithdrawFundingPoolInstruction({
+ rentSponsor: TEST_PAYER,
+ compressionAuthority: TEST_AUTHORITY,
+ destination: TEST_DEST,
+ compressibleConfig: TEST_MINT,
+ amount: largeAmount,
+ });
+
+ const view = new DataView(ix.data.buffer, ix.data.byteOffset);
+ expect(view.getBigUint64(1, true)).toBe(largeAmount);
+ });
+});
+
+// ============================================================================
+// TEST: createMintActionInstruction
+// ============================================================================
+
+describe('createMintActionInstruction', () => {
+ const TEST_OUT_QUEUE = address('Vote111111111111111111111111111111111111111');
+ const TEST_MERKLE_TREE = address('BPFLoaderUpgradeab1e11111111111111111111111');
+ const mintActionData = {
+ leafIndex: 0,
+ proveByIndex: false,
+ rootIndex: 0,
+ maxTopUp: 0,
+ createMint: null,
+ actions: [] as [],
+ proof: null,
+ cpiContext: null,
+ mint: null,
+ };
+
+ it('has correct program address', () => {
+ const ix = createMintActionInstruction({
+ authority: TEST_AUTHORITY,
+ feePayer: TEST_PAYER,
+ outOutputQueue: TEST_OUT_QUEUE,
+ merkleTree: TEST_MERKLE_TREE,
+ data: mintActionData,
+ });
+ expect(ix.programAddress).toBe(LIGHT_TOKEN_PROGRAM_ID);
+ });
+
+ it('has correct discriminator byte (103)', () => {
+ const ix = createMintActionInstruction({
+ authority: TEST_AUTHORITY,
+ feePayer: TEST_PAYER,
+ outOutputQueue: TEST_OUT_QUEUE,
+ merkleTree: TEST_MERKLE_TREE,
+ data: mintActionData,
+ });
+ expect(ix.data[0]).toBe(DISCRIMINATOR.MINT_ACTION);
+ expect(ix.data[0]).toBe(103);
+ });
+
+ it('normal path: lightSystemProgram, authority, LightSystemAccounts(6), queues, tree', () => {
+ const ix = createMintActionInstruction({
+ authority: TEST_AUTHORITY,
+ feePayer: TEST_PAYER,
+ outOutputQueue: TEST_OUT_QUEUE,
+ merkleTree: TEST_MERKLE_TREE,
+ data: mintActionData,
+ });
+
+ // lightSystemProgram(1) + authority(1) + LightSystemAccounts(6) + outQueue(1) + merkleTree(1) = 10
+ expect(ix.accounts).toHaveLength(10);
+
+ // Account 0: Light System Program (readonly)
+ expect(ix.accounts[0].address).toBe(LIGHT_SYSTEM_PROGRAM_ID);
+ expect(ix.accounts[0].role).toBe(AccountRole.READONLY);
+
+ // Account 1: authority (signer)
+ expect(ix.accounts[1].address).toBe(TEST_AUTHORITY);
+ expect(ix.accounts[1].role).toBe(AccountRole.READONLY_SIGNER);
+
+ // LightSystemAccounts (6 accounts):
+ // 2: feePayer (writable signer)
+ expect(ix.accounts[2].address).toBe(TEST_PAYER);
+ expect(ix.accounts[2].role).toBe(AccountRole.WRITABLE_SIGNER);
+ // 3: cpiAuthorityPda (readonly)
+ expect(ix.accounts[3].address).toBe(CPI_AUTHORITY);
+ expect(ix.accounts[3].role).toBe(AccountRole.READONLY);
+ // 4: registeredProgramPda (readonly, defaults to REGISTERED_PROGRAM_PDA)
+ expect(ix.accounts[4].address).toBe(REGISTERED_PROGRAM_PDA);
+ expect(ix.accounts[4].role).toBe(AccountRole.READONLY);
+ // 5: accountCompressionAuthority (readonly, defaults to ACCOUNT_COMPRESSION_AUTHORITY_PDA)
+ expect(ix.accounts[5].address).toBe(
+ ACCOUNT_COMPRESSION_AUTHORITY_PDA,
+ );
+ expect(ix.accounts[5].role).toBe(AccountRole.READONLY);
+ // 6: accountCompressionProgram (readonly)
+ expect(ix.accounts[6].address).toBe(ACCOUNT_COMPRESSION_PROGRAM_ID);
+ expect(ix.accounts[6].role).toBe(AccountRole.READONLY);
+ // 7: systemProgram (readonly)
+ expect(ix.accounts[7].address).toBe(SYSTEM_PROGRAM_ID);
+ expect(ix.accounts[7].role).toBe(AccountRole.READONLY);
+
+ // 8: outOutputQueue (writable)
+ expect(ix.accounts[8].address).toBe(TEST_OUT_QUEUE);
+ expect(ix.accounts[8].role).toBe(AccountRole.WRITABLE);
+ // 9: merkleTree (writable)
+ expect(ix.accounts[9].address).toBe(TEST_MERKLE_TREE);
+ expect(ix.accounts[9].role).toBe(AccountRole.WRITABLE);
+ });
+
+ it('includes CPI_AUTHORITY, ACCOUNT_COMPRESSION_PROGRAM_ID, SYSTEM_PROGRAM_ID', () => {
+ const ix = createMintActionInstruction({
+ authority: TEST_AUTHORITY,
+ feePayer: TEST_PAYER,
+ outOutputQueue: TEST_OUT_QUEUE,
+ merkleTree: TEST_MERKLE_TREE,
+ data: mintActionData,
+ });
+
+ const addresses = ix.accounts.map(a => a.address);
+ expect(addresses).toContain(CPI_AUTHORITY);
+ expect(addresses).toContain(ACCOUNT_COMPRESSION_PROGRAM_ID);
+ expect(addresses).toContain(SYSTEM_PROGRAM_ID);
+ });
+
+ it('output queue and merkle tree are writable', () => {
+ const ix = createMintActionInstruction({
+ authority: TEST_AUTHORITY,
+ feePayer: TEST_PAYER,
+ outOutputQueue: TEST_OUT_QUEUE,
+ merkleTree: TEST_MERKLE_TREE,
+ data: mintActionData,
+ });
+
+ const outQueueAccount = ix.accounts.find(a => a.address === TEST_OUT_QUEUE);
+ const treeAccount = ix.accounts.find(a => a.address === TEST_MERKLE_TREE);
+ expect(outQueueAccount?.role).toBe(AccountRole.WRITABLE);
+ expect(treeAccount?.role).toBe(AccountRole.WRITABLE);
+ });
+
+ it('with mintSigner: adds it as signer for createMint', () => {
+ const mintSigner = address('Sysvar1111111111111111111111111111111111111');
+ const ix = createMintActionInstruction({
+ mintSigner,
+ authority: TEST_AUTHORITY,
+ feePayer: TEST_PAYER,
+ outOutputQueue: TEST_OUT_QUEUE,
+ merkleTree: TEST_MERKLE_TREE,
+ data: {
+ ...mintActionData,
+ createMint: {
+ readOnlyAddressTrees: new Uint8Array(4),
+ readOnlyAddressTreeRootIndices: [0, 0, 0, 0],
+ },
+ },
+ });
+
+ const signerAccount = ix.accounts.find(a => a.address === mintSigner);
+ expect(signerAccount).toBeDefined();
+ expect(signerAccount?.role).toBe(AccountRole.READONLY_SIGNER);
+ });
+
+ it('with mintSigner but no createMint: adds as readonly', () => {
+ const mintSigner = address('Sysvar1111111111111111111111111111111111111');
+ const ix = createMintActionInstruction({
+ mintSigner,
+ authority: TEST_AUTHORITY,
+ feePayer: TEST_PAYER,
+ outOutputQueue: TEST_OUT_QUEUE,
+ merkleTree: TEST_MERKLE_TREE,
+ data: mintActionData,
+ });
+
+ const signerAccount = ix.accounts.find(a => a.address === mintSigner);
+ expect(signerAccount).toBeDefined();
+ expect(signerAccount?.role).toBe(AccountRole.READONLY);
+ });
+
+ it('packed accounts preserve their roles', () => {
+ const ix = createMintActionInstruction({
+ authority: TEST_AUTHORITY,
+ feePayer: TEST_PAYER,
+ outOutputQueue: TEST_OUT_QUEUE,
+ merkleTree: TEST_MERKLE_TREE,
+ packedAccounts: [
+ { address: TEST_SOURCE, role: AccountRole.WRITABLE },
+ { address: TEST_DEST, role: AccountRole.READONLY },
+ { address: TEST_OWNER, role: AccountRole.READONLY_SIGNER },
+ ],
+ data: mintActionData,
+ });
+
+ // Packed accounts at the end
+ const lastThree = ix.accounts.slice(-3);
+ expect(lastThree[0].address).toBe(TEST_SOURCE);
+ expect(lastThree[0].role).toBe(AccountRole.WRITABLE);
+ expect(lastThree[1].address).toBe(TEST_DEST);
+ expect(lastThree[1].role).toBe(AccountRole.READONLY);
+ expect(lastThree[2].address).toBe(TEST_OWNER);
+ expect(lastThree[2].role).toBe(AccountRole.READONLY_SIGNER);
+ });
+
+ it('optional accounts: compressibleConfig, cmint, rentSponsor', () => {
+ const ix = createMintActionInstruction({
+ authority: TEST_AUTHORITY,
+ feePayer: TEST_PAYER,
+ compressibleConfig: TEST_CONFIG,
+ cmint: TEST_SOURCE,
+ rentSponsor: TEST_SPONSOR,
+ outOutputQueue: TEST_OUT_QUEUE,
+ merkleTree: TEST_MERKLE_TREE,
+ data: mintActionData,
+ });
+
+ const addresses = ix.accounts.map(a => a.address);
+ expect(addresses).toContain(TEST_CONFIG);
+ expect(addresses).toContain(TEST_SOURCE);
+ expect(addresses).toContain(TEST_SPONSOR);
+
+ // Config is readonly, cmint and rentSponsor are writable
+ const configAccount = ix.accounts.find(a => a.address === TEST_CONFIG);
+ expect(configAccount?.role).toBe(AccountRole.READONLY);
+ const cmintAccount = ix.accounts.find(a => a.address === TEST_SOURCE);
+ expect(cmintAccount?.role).toBe(AccountRole.WRITABLE);
+ const sponsorAccount = ix.accounts.find(a => a.address === TEST_SPONSOR);
+ expect(sponsorAccount?.role).toBe(AccountRole.WRITABLE);
+ });
+
+ it('CPI context path: feePayer + cpiAuthorityPda + cpiContext (3 accounts)', () => {
+ const cpiContext = address('Sysvar1111111111111111111111111111111111111');
+ const ix = createMintActionInstruction({
+ authority: TEST_AUTHORITY,
+ feePayer: TEST_PAYER,
+ outOutputQueue: TEST_OUT_QUEUE,
+ merkleTree: TEST_MERKLE_TREE,
+ cpiContextAccounts: {
+ feePayer: TEST_PAYER,
+ cpiAuthorityPda: CPI_AUTHORITY,
+ cpiContext,
+ },
+ data: mintActionData,
+ });
+
+ // CPI context path: lightSystemProgram(1) + authority(1) + CpiContextLightSystemAccounts(3) = 5
+ expect(ix.accounts).toHaveLength(5);
+
+ // Account 0: Light System Program
+ expect(ix.accounts[0].address).toBe(LIGHT_SYSTEM_PROGRAM_ID);
+ // Account 1: authority
+ expect(ix.accounts[1].address).toBe(TEST_AUTHORITY);
+ // Account 2: feePayer (writable signer)
+ expect(ix.accounts[2].address).toBe(TEST_PAYER);
+ expect(ix.accounts[2].role).toBe(AccountRole.WRITABLE_SIGNER);
+ // Account 3: cpiAuthorityPda (readonly)
+ expect(ix.accounts[3].address).toBe(CPI_AUTHORITY);
+ expect(ix.accounts[3].role).toBe(AccountRole.READONLY);
+ // Account 4: cpiContext (writable — program writes CPI data to it)
+ expect(ix.accounts[4].address).toBe(cpiContext);
+ expect(ix.accounts[4].role).toBe(AccountRole.WRITABLE);
+ });
+});
+
+// ============================================================================
+// TEST: createCompress / createDecompress with tokenProgramIndex
+// ============================================================================
+
+describe('Compression factory with tokenProgramIndex', () => {
+ it('createCompress: tokenProgramIndex sets poolAccountIndex', () => {
+ const comp = createCompress({
+ amount: 5000n,
+ mintIndex: 0,
+ sourceIndex: 2,
+ authorityIndex: 1,
+ tokenProgramIndex: 6,
+ });
+ expect(comp.mode).toBe(COMPRESSION_MODE.COMPRESS);
+ expect(comp.amount).toBe(5000n);
+ expect(comp.mint).toBe(0);
+ expect(comp.sourceOrRecipient).toBe(2);
+ expect(comp.authority).toBe(1);
+ expect(comp.poolAccountIndex).toBe(6);
+ expect(comp.poolIndex).toBe(0);
+ expect(comp.bump).toBe(0);
+ expect(comp.decimals).toBe(0);
+ });
+
+ it('createCompress: defaults poolAccountIndex to 0 without tokenProgramIndex', () => {
+ const comp = createCompress({
+ amount: 1000n,
+ mintIndex: 0,
+ sourceIndex: 2,
+ authorityIndex: 1,
+ });
+ expect(comp.poolAccountIndex).toBe(0);
+ });
+
+ it('createDecompress: tokenProgramIndex sets poolAccountIndex', () => {
+ const comp = createDecompress({
+ amount: 3000n,
+ mintIndex: 0,
+ recipientIndex: 3,
+ tokenProgramIndex: 6,
+ });
+ expect(comp.mode).toBe(COMPRESSION_MODE.DECOMPRESS);
+ expect(comp.amount).toBe(3000n);
+ expect(comp.mint).toBe(0);
+ expect(comp.sourceOrRecipient).toBe(3);
+ expect(comp.authority).toBe(0);
+ expect(comp.poolAccountIndex).toBe(6);
+ expect(comp.poolIndex).toBe(0);
+ expect(comp.bump).toBe(0);
+ expect(comp.decimals).toBe(0);
+ });
+
+ it('createDecompress: defaults poolAccountIndex to 0 without tokenProgramIndex', () => {
+ const comp = createDecompress({
+ amount: 1000n,
+ mintIndex: 0,
+ recipientIndex: 3,
+ });
+ expect(comp.poolAccountIndex).toBe(0);
+ });
+});
+
+// ============================================================================
+// TEST: createWrapInstruction
+// ============================================================================
+
+describe('createWrapInstruction', () => {
+ const TEST_POOL = address('BPFLoaderUpgradeab1e11111111111111111111111');
+ const splInterfaceInfo: SplInterfaceInfo = {
+ poolAddress: TEST_POOL,
+ tokenProgram: SPL_TOKEN_PROGRAM_ID,
+ poolIndex: 0,
+ bump: 254,
+ isInitialized: true,
+ };
+
+ it('has correct program address', () => {
+ const ix = createWrapInstruction({
+ source: TEST_SOURCE,
+ destination: TEST_DEST,
+ owner: TEST_OWNER,
+ mint: TEST_MINT,
+ amount: 1000n,
+ splInterfaceInfo,
+ decimals: 9,
+ });
+ expect(ix.programAddress).toBe(LIGHT_TOKEN_PROGRAM_ID);
+ });
+
+ it('has 10 accounts in correct order (Path A)', () => {
+ const ix = createWrapInstruction({
+ source: TEST_SOURCE,
+ destination: TEST_DEST,
+ owner: TEST_OWNER,
+ mint: TEST_MINT,
+ amount: 1000n,
+ splInterfaceInfo,
+ decimals: 9,
+ });
+ expect(ix.accounts).toHaveLength(10);
+
+ // Path A prefix
+ expect(ix.accounts[0].address).toBe(CPI_AUTHORITY);
+ expect(ix.accounts[0].role).toBe(AccountRole.READONLY);
+ expect(ix.accounts[1].address).toBe(TEST_OWNER); // feePayer defaults to owner
+ expect(ix.accounts[1].role).toBe(AccountRole.WRITABLE_SIGNER);
+
+ // Packed accounts
+ expect(ix.accounts[2].address).toBe(TEST_MINT); // mint (index 0)
+ expect(ix.accounts[2].role).toBe(AccountRole.READONLY);
+ expect(ix.accounts[3].address).toBe(TEST_OWNER); // owner (index 1)
+ expect(ix.accounts[3].role).toBe(AccountRole.READONLY_SIGNER);
+ expect(ix.accounts[4].address).toBe(TEST_SOURCE); // source (index 2)
+ expect(ix.accounts[4].role).toBe(AccountRole.WRITABLE);
+ expect(ix.accounts[5].address).toBe(TEST_DEST); // destination (index 3)
+ expect(ix.accounts[5].role).toBe(AccountRole.WRITABLE);
+ expect(ix.accounts[6].address).toBe(TEST_POOL); // pool (index 4)
+ expect(ix.accounts[6].role).toBe(AccountRole.WRITABLE);
+ expect(ix.accounts[7].address).toBe(SPL_TOKEN_PROGRAM_ID); // tokenProgram (index 5)
+ expect(ix.accounts[7].role).toBe(AccountRole.READONLY);
+ expect(ix.accounts[8].address).toBe(LIGHT_TOKEN_PROGRAM_ID); // ctoken program (index 6)
+ expect(ix.accounts[8].role).toBe(AccountRole.READONLY);
+ expect(ix.accounts[9].address).toBe(SYSTEM_PROGRAM_ID); // system program (index 7)
+ expect(ix.accounts[9].role).toBe(AccountRole.READONLY);
+ });
+
+ it('has discriminator 101 (Transfer2)', () => {
+ const ix = createWrapInstruction({
+ source: TEST_SOURCE,
+ destination: TEST_DEST,
+ owner: TEST_OWNER,
+ mint: TEST_MINT,
+ amount: 1000n,
+ splInterfaceInfo,
+ decimals: 9,
+ });
+ expect(ix.data[0]).toBe(DISCRIMINATOR.TRANSFER2);
+ });
+
+ it('with feePayer: uses separate feePayer', () => {
+ const feePayer = address('Vote111111111111111111111111111111111111111');
+ const ix = createWrapInstruction({
+ source: TEST_SOURCE,
+ destination: TEST_DEST,
+ owner: TEST_OWNER,
+ mint: TEST_MINT,
+ amount: 1000n,
+ splInterfaceInfo,
+ decimals: 9,
+ feePayer,
+ });
+ expect(ix.accounts[1].address).toBe(feePayer);
+ expect(ix.accounts[1].role).toBe(AccountRole.WRITABLE_SIGNER);
+ expect(ix.accounts[3].address).toBe(TEST_OWNER);
+ expect(ix.accounts[3].role).toBe(AccountRole.READONLY_SIGNER);
+ });
+});
+
+// ============================================================================
+// TEST: createUnwrapInstruction
+// ============================================================================
+
+describe('createUnwrapInstruction', () => {
+ const TEST_POOL = address('BPFLoaderUpgradeab1e11111111111111111111111');
+ const splInterfaceInfo: SplInterfaceInfo = {
+ poolAddress: TEST_POOL,
+ tokenProgram: SPL_TOKEN_PROGRAM_ID,
+ poolIndex: 0,
+ bump: 254,
+ isInitialized: true,
+ };
+
+ it('has correct program address', () => {
+ const ix = createUnwrapInstruction({
+ source: TEST_SOURCE,
+ destination: TEST_DEST,
+ owner: TEST_OWNER,
+ mint: TEST_MINT,
+ amount: 1000n,
+ splInterfaceInfo,
+ decimals: 9,
+ });
+ expect(ix.programAddress).toBe(LIGHT_TOKEN_PROGRAM_ID);
+ });
+
+ it('has 10 accounts matching wrap layout', () => {
+ const ix = createUnwrapInstruction({
+ source: TEST_SOURCE,
+ destination: TEST_DEST,
+ owner: TEST_OWNER,
+ mint: TEST_MINT,
+ amount: 1000n,
+ splInterfaceInfo,
+ decimals: 9,
+ });
+ expect(ix.accounts).toHaveLength(10);
+
+ // Path A prefix
+ expect(ix.accounts[0].address).toBe(CPI_AUTHORITY);
+ expect(ix.accounts[0].role).toBe(AccountRole.READONLY);
+ expect(ix.accounts[1].address).toBe(TEST_OWNER);
+ expect(ix.accounts[1].role).toBe(AccountRole.WRITABLE_SIGNER);
+
+ // Packed accounts — same layout as wrap
+ expect(ix.accounts[2].address).toBe(TEST_MINT);
+ expect(ix.accounts[3].address).toBe(TEST_OWNER);
+ expect(ix.accounts[4].address).toBe(TEST_SOURCE);
+ expect(ix.accounts[4].role).toBe(AccountRole.WRITABLE);
+ expect(ix.accounts[5].address).toBe(TEST_DEST);
+ expect(ix.accounts[5].role).toBe(AccountRole.WRITABLE);
+ expect(ix.accounts[6].address).toBe(TEST_POOL);
+ expect(ix.accounts[6].role).toBe(AccountRole.WRITABLE);
+ expect(ix.accounts[7].address).toBe(SPL_TOKEN_PROGRAM_ID);
+ expect(ix.accounts[8].address).toBe(LIGHT_TOKEN_PROGRAM_ID);
+ expect(ix.accounts[9].address).toBe(SYSTEM_PROGRAM_ID);
+ });
+
+ it('has discriminator 101 (Transfer2)', () => {
+ const ix = createUnwrapInstruction({
+ source: TEST_SOURCE,
+ destination: TEST_DEST,
+ owner: TEST_OWNER,
+ mint: TEST_MINT,
+ amount: 1000n,
+ splInterfaceInfo,
+ decimals: 9,
+ });
+ expect(ix.data[0]).toBe(DISCRIMINATOR.TRANSFER2);
+ });
+
+ it('with feePayer: uses separate feePayer', () => {
+ const feePayer = address('Vote111111111111111111111111111111111111111');
+ const ix = createUnwrapInstruction({
+ source: TEST_SOURCE,
+ destination: TEST_DEST,
+ owner: TEST_OWNER,
+ mint: TEST_MINT,
+ amount: 1000n,
+ splInterfaceInfo,
+ decimals: 9,
+ feePayer,
+ });
+ expect(ix.accounts[1].address).toBe(feePayer);
+ expect(ix.accounts[1].role).toBe(AccountRole.WRITABLE_SIGNER);
+ });
+});
diff --git a/js/token-kit/tests/unit/load.test.ts b/js/token-kit/tests/unit/load.test.ts
new file mode 100644
index 0000000000..f83c751656
--- /dev/null
+++ b/js/token-kit/tests/unit/load.test.ts
@@ -0,0 +1,1099 @@
+/**
+ * Unit tests for load functions and actions.
+ *
+ * Tests for:
+ * - loadTokenAccountsForTransfer
+ * - loadAllTokenAccounts
+ * - loadTokenAccount
+ * - loadCompressedAccount
+ * - loadCompressedAccountByHash
+ * - getValidityProofForAccounts
+ * - getOutputTreeInfo
+ * - needsValidityProof
+ * - buildCompressedTransfer
+ * - loadMintContext
+ * - getMintDecimals
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { address } from '@solana/addresses';
+
+import {
+ loadTokenAccountsForTransfer,
+ loadAllTokenAccounts,
+ loadTokenAccount,
+ loadCompressedAccount,
+ loadCompressedAccountByHash,
+ getValidityProofForAccounts,
+ getOutputTreeInfo,
+ needsValidityProof,
+ buildCompressedTransfer,
+ loadMintContext,
+ getMintDecimals,
+ getMintInterface,
+ getAtaInterface,
+ deserializeCompressedMint,
+ IndexerError,
+ IndexerErrorCode,
+ TreeType,
+ DISCRIMINATOR,
+ EXTENSION_DISCRIMINANT,
+ type LightIndexer,
+ type CompressedAccount,
+ type CompressedTokenAccount,
+} from '../../src/index.js';
+import {
+ createMockTokenAccount,
+ createMockTreeInfo,
+ createMockIndexer,
+ createMockCompressedMintData,
+ createBase64MintData,
+ createMockAccountWithHash,
+ createProofInput,
+ MOCK_OWNER,
+ MOCK_MINT,
+ MOCK_CTOKEN_PROGRAM,
+} from './helpers.js';
+
+// ============================================================================
+// TESTS: loadTokenAccountsForTransfer
+// ============================================================================
+
+describe('loadTokenAccountsForTransfer', () => {
+ it('returns inputs, proof, and totalAmount on success', async () => {
+ const accounts = [
+ createMockTokenAccount(500n),
+ createMockTokenAccount(300n),
+ ];
+
+ const mockProof = {
+ proof: { a: new Uint8Array(32), b: new Uint8Array(64), c: new Uint8Array(32) },
+ accounts: [],
+ addresses: [],
+ };
+
+ const indexer = createMockIndexer({
+ getCompressedTokenAccountsByOwner: vi.fn().mockResolvedValue({
+ context: { slot: 100n },
+ value: { items: accounts, cursor: null },
+ }),
+ getValidityProof: vi.fn().mockResolvedValue({
+ context: { slot: 100n },
+ value: mockProof,
+ }),
+ });
+
+ const result = await loadTokenAccountsForTransfer(
+ indexer,
+ MOCK_OWNER,
+ 600n,
+ );
+
+ expect(result.inputs).toHaveLength(2);
+ expect(result.proof).toBe(mockProof);
+ expect(result.totalAmount).toBe(800n);
+
+ for (const input of result.inputs) {
+ expect(input.merkleContext).toBeDefined();
+ expect(input.merkleContext.tree).toBeDefined();
+ expect(input.merkleContext.queue).toBeDefined();
+ }
+ });
+
+ it('throws IndexerError with NotFound when no accounts exist', async () => {
+ const indexer = createMockIndexer({
+ getCompressedTokenAccountsByOwner: vi.fn().mockResolvedValue({
+ context: { slot: 100n },
+ value: { items: [], cursor: null },
+ }),
+ });
+
+ await expect(
+ loadTokenAccountsForTransfer(indexer, MOCK_OWNER, 100n),
+ ).rejects.toThrow(IndexerError);
+
+ try {
+ await loadTokenAccountsForTransfer(indexer, MOCK_OWNER, 100n);
+ } catch (e) {
+ expect(e).toBeInstanceOf(IndexerError);
+ expect((e as IndexerError).code).toBe(IndexerErrorCode.NotFound);
+ }
+ });
+
+ it('respects maxInputs option during selection', async () => {
+ const accounts = [
+ createMockTokenAccount(500n),
+ createMockTokenAccount(400n),
+ createMockTokenAccount(300n),
+ ];
+
+ const indexer = createMockIndexer({
+ getCompressedTokenAccountsByOwner: vi.fn().mockResolvedValue({
+ context: { slot: 100n },
+ value: { items: accounts, cursor: null },
+ }),
+ });
+
+ await expect(
+ loadTokenAccountsForTransfer(indexer, MOCK_OWNER, 700n, {
+ maxInputs: 1,
+ }),
+ ).rejects.toMatchObject({
+ code: IndexerErrorCode.InsufficientBalance,
+ });
+ });
+
+ it('throws IndexerError with InsufficientBalance when balance is too low', async () => {
+ const accounts = [createMockTokenAccount(50n)];
+
+ const indexer = createMockIndexer({
+ getCompressedTokenAccountsByOwner: vi.fn().mockResolvedValue({
+ context: { slot: 100n },
+ value: { items: accounts, cursor: null },
+ }),
+ });
+
+ await expect(
+ loadTokenAccountsForTransfer(indexer, MOCK_OWNER, 1000n),
+ ).rejects.toThrow(IndexerError);
+
+ try {
+ await loadTokenAccountsForTransfer(indexer, MOCK_OWNER, 1000n);
+ } catch (e) {
+ expect(e).toBeInstanceOf(IndexerError);
+ expect((e as IndexerError).code).toBe(
+ IndexerErrorCode.InsufficientBalance,
+ );
+ }
+ });
+});
+
+// ============================================================================
+// TESTS: loadAllTokenAccounts
+// ============================================================================
+
+describe('loadAllTokenAccounts', () => {
+ it('returns items from a single page with no cursor', async () => {
+ const accounts = [
+ createMockTokenAccount(100n),
+ createMockTokenAccount(200n),
+ ];
+
+ const indexer = createMockIndexer({
+ getCompressedTokenAccountsByOwner: vi.fn().mockResolvedValue({
+ context: { slot: 100n },
+ value: { items: accounts, cursor: null },
+ }),
+ });
+
+ const result = await loadAllTokenAccounts(indexer, MOCK_OWNER);
+
+ expect(result).toHaveLength(2);
+ expect(result[0].token.amount).toBe(100n);
+ expect(result[1].token.amount).toBe(200n);
+ });
+
+ it('paginates through multiple pages using cursor', async () => {
+ const page1 = [createMockTokenAccount(100n)];
+ const page2 = [createMockTokenAccount(200n)];
+
+ const mockFn = vi
+ .fn()
+ .mockResolvedValueOnce({
+ context: { slot: 100n },
+ value: { items: page1, cursor: 'cursor-abc' },
+ })
+ .mockResolvedValueOnce({
+ context: { slot: 101n },
+ value: { items: page2, cursor: null },
+ });
+
+ const indexer = createMockIndexer({
+ getCompressedTokenAccountsByOwner: mockFn,
+ });
+
+ const result = await loadAllTokenAccounts(indexer, MOCK_OWNER);
+
+ expect(result).toHaveLength(2);
+ expect(result[0].token.amount).toBe(100n);
+ expect(result[1].token.amount).toBe(200n);
+ expect(mockFn).toHaveBeenCalledTimes(2);
+ });
+
+ it('throws after exceeding maximum page limit', async () => {
+ const mockFn = vi.fn().mockResolvedValue({
+ context: { slot: 100n },
+ value: { items: [createMockTokenAccount(1n)], cursor: 'next' },
+ });
+
+ const indexer = createMockIndexer({
+ getCompressedTokenAccountsByOwner: mockFn,
+ });
+
+ await expect(
+ loadAllTokenAccounts(indexer, MOCK_OWNER),
+ ).rejects.toThrow('Pagination exceeded maximum of 100 pages');
+ });
+});
+
+// ============================================================================
+// TESTS: loadTokenAccount
+// ============================================================================
+
+describe('loadTokenAccount', () => {
+ it('returns the first matching account', async () => {
+ const account = createMockTokenAccount(500n);
+
+ const indexer = createMockIndexer({
+ getCompressedTokenAccountsByOwner: vi.fn().mockResolvedValue({
+ context: { slot: 100n },
+ value: { items: [account], cursor: null },
+ }),
+ });
+
+ const result = await loadTokenAccount(indexer, MOCK_OWNER, MOCK_MINT);
+
+ expect(result).not.toBeNull();
+ expect(result!.token.amount).toBe(500n);
+ });
+
+ it('returns null when no accounts match', async () => {
+ const indexer = createMockIndexer({
+ getCompressedTokenAccountsByOwner: vi.fn().mockResolvedValue({
+ context: { slot: 100n },
+ value: { items: [], cursor: null },
+ }),
+ });
+
+ const result = await loadTokenAccount(indexer, MOCK_OWNER, MOCK_MINT);
+
+ expect(result).toBeNull();
+ });
+});
+
+// ============================================================================
+// TESTS: loadCompressedAccount
+// ============================================================================
+
+describe('loadCompressedAccount', () => {
+ it('returns account when found', async () => {
+ const mockAccount: CompressedAccount = {
+ hash: new Uint8Array(32).fill(0xab),
+ address: null,
+ owner: address('cTokenmWW8bLPjZEBAUgYy3zKxQZW6VKi7bqNFEVv3m'),
+ lamports: 1000n,
+ data: null,
+ leafIndex: 5,
+ treeInfo: createMockTreeInfo(TreeType.StateV2),
+ proveByIndex: false,
+ seq: 10n,
+ slotCreated: 42n,
+ };
+
+ const indexer = createMockIndexer({
+ getCompressedAccount: vi.fn().mockResolvedValue({
+ context: { slot: 100n },
+ value: mockAccount,
+ }),
+ });
+
+ const result = await loadCompressedAccount(indexer, new Uint8Array(32));
+ expect(result).not.toBeNull();
+ expect(result!.lamports).toBe(1000n);
+ expect(result!.leafIndex).toBe(5);
+ });
+
+ it('returns null when not found', async () => {
+ const indexer = createMockIndexer({
+ getCompressedAccount: vi.fn().mockResolvedValue({
+ context: { slot: 100n },
+ value: null,
+ }),
+ });
+
+ const result = await loadCompressedAccount(indexer, new Uint8Array(32));
+ expect(result).toBeNull();
+ });
+});
+
+// ============================================================================
+// TESTS: loadCompressedAccountByHash
+// ============================================================================
+
+describe('loadCompressedAccountByHash', () => {
+ it('returns account when found', async () => {
+ const mockAccount: CompressedAccount = {
+ hash: new Uint8Array(32).fill(0xcd),
+ address: null,
+ owner: address('cTokenmWW8bLPjZEBAUgYy3zKxQZW6VKi7bqNFEVv3m'),
+ lamports: 2000n,
+ data: null,
+ leafIndex: 10,
+ treeInfo: createMockTreeInfo(TreeType.StateV2),
+ proveByIndex: true,
+ seq: 20n,
+ slotCreated: 100n,
+ };
+
+ const indexer = createMockIndexer({
+ getCompressedAccountByHash: vi.fn().mockResolvedValue({
+ context: { slot: 100n },
+ value: mockAccount,
+ }),
+ });
+
+ const result = await loadCompressedAccountByHash(indexer, new Uint8Array(32));
+ expect(result).not.toBeNull();
+ expect(result!.lamports).toBe(2000n);
+ expect(result!.proveByIndex).toBe(true);
+ });
+
+ it('returns null when not found', async () => {
+ const indexer = createMockIndexer({
+ getCompressedAccountByHash: vi.fn().mockResolvedValue({
+ context: { slot: 100n },
+ value: null,
+ }),
+ });
+
+ const result = await loadCompressedAccountByHash(indexer, new Uint8Array(32));
+ expect(result).toBeNull();
+ });
+});
+
+// ============================================================================
+// TESTS: getValidityProofForAccounts
+// ============================================================================
+
+describe('getValidityProofForAccounts', () => {
+ it('fetches proof using account hashes', async () => {
+ const account1 = createMockTokenAccount(100n);
+ account1.account.hash = new Uint8Array(32).fill(0x11);
+ const account2 = createMockTokenAccount(200n);
+ account2.account.hash = new Uint8Array(32).fill(0x22);
+
+ const mockProof = {
+ proof: { a: new Uint8Array(32), b: new Uint8Array(64), c: new Uint8Array(32) },
+ accounts: [],
+ addresses: [],
+ };
+
+ const getValidityProofFn = vi.fn().mockResolvedValue({
+ context: { slot: 100n },
+ value: mockProof,
+ });
+
+ const indexer = createMockIndexer({
+ getValidityProof: getValidityProofFn,
+ });
+
+ const result = await getValidityProofForAccounts(indexer, [account1, account2]);
+
+ expect(result).toBe(mockProof);
+ expect(getValidityProofFn).toHaveBeenCalledTimes(1);
+ const calledHashes = getValidityProofFn.mock.calls[0][0];
+ expect(calledHashes).toHaveLength(2);
+ expect(calledHashes[0]).toEqual(new Uint8Array(32).fill(0x11));
+ expect(calledHashes[1]).toEqual(new Uint8Array(32).fill(0x22));
+ });
+
+ it('handles empty accounts array', async () => {
+ const mockProof = {
+ proof: { a: new Uint8Array(32), b: new Uint8Array(64), c: new Uint8Array(32) },
+ accounts: [],
+ addresses: [],
+ };
+
+ const indexer = createMockIndexer({
+ getValidityProof: vi.fn().mockResolvedValue({
+ context: { slot: 100n },
+ value: mockProof,
+ }),
+ });
+
+ const result = await getValidityProofForAccounts(indexer, []);
+ expect(result).toBe(mockProof);
+ });
+});
+
+// ============================================================================
+// TESTS: getOutputTreeInfo
+// ============================================================================
+
+describe('getOutputTreeInfo', () => {
+ it('returns nextTreeInfo when present', () => {
+ const nextTree = createMockTreeInfo(TreeType.StateV2);
+ nextTree.tree = address('GXtd2izAiMJPwMEjfgTRH3d7k9mjn4Jq3JrWFv9gySYy');
+
+ const currentTree = createMockTreeInfo(TreeType.StateV2, nextTree);
+
+ const result = getOutputTreeInfo(currentTree);
+
+ expect(result).toBe(nextTree);
+ expect(result.tree).toBe(nextTree.tree);
+ });
+
+ it('returns the current tree when no next tree exists', () => {
+ const currentTree = createMockTreeInfo(TreeType.StateV2);
+
+ const result = getOutputTreeInfo(currentTree);
+
+ expect(result).toBe(currentTree);
+ });
+});
+
+// ============================================================================
+// TESTS: needsValidityProof
+// ============================================================================
+
+describe('needsValidityProof', () => {
+ it('returns true when proveByIndex is false', () => {
+ const account: CompressedAccount = {
+ hash: new Uint8Array(32),
+ address: null,
+ owner: address('cTokenmWW8bLPjZEBAUgYy3zKxQZW6VKi7bqNFEVv3m'),
+ lamports: 0n,
+ data: null,
+ leafIndex: 0,
+ treeInfo: createMockTreeInfo(TreeType.StateV2),
+ proveByIndex: false,
+ seq: null,
+ slotCreated: 0n,
+ };
+
+ expect(needsValidityProof(account)).toBe(true);
+ });
+
+ it('returns false when proveByIndex is true', () => {
+ const account: CompressedAccount = {
+ hash: new Uint8Array(32),
+ address: null,
+ owner: address('cTokenmWW8bLPjZEBAUgYy3zKxQZW6VKi7bqNFEVv3m'),
+ lamports: 0n,
+ data: null,
+ leafIndex: 0,
+ treeInfo: createMockTreeInfo(TreeType.StateV2),
+ proveByIndex: true,
+ seq: null,
+ slotCreated: 0n,
+ };
+
+ expect(needsValidityProof(account)).toBe(false);
+ });
+});
+
+// ============================================================================
+// TESTS: buildCompressedTransfer
+// ============================================================================
+
+describe('buildCompressedTransfer', () => {
+ const RECIPIENT = address('GXtd2izAiMJPwMEjfgTRH3d7k9mjn4Jq3JrWFv9gySYy');
+ const FEE_PAYER = address('BPFLoaderUpgradeab1e11111111111111111111111');
+ const DELEGATE = address('Sysvar1111111111111111111111111111111111111');
+ const ALT_TREE = address('Vote111111111111111111111111111111111111111');
+ const ALT_QUEUE = address('11111111111111111111111111111111');
+
+ function decodeTransfer2OutputQueueIndex(data: Uint8Array): number {
+ return data[5];
+ }
+
+ function decodeTransfer2MaxTopUp(data: Uint8Array): number {
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
+ return view.getUint16(6, true);
+ }
+
+ it('builds Transfer2 instruction with correct discriminator', async () => {
+ const accounts = [createMockAccountWithHash(1000n, 0xab, 5)];
+ const mockProof = {
+ proof: { a: new Uint8Array(32), b: new Uint8Array(64), c: new Uint8Array(32) },
+ accounts: [createProofInput(0xab, 10)],
+ addresses: [],
+ };
+ const indexer = createMockIndexer({
+ getCompressedTokenAccountsByOwner: vi.fn().mockResolvedValue({
+ context: { slot: 100n },
+ value: { items: accounts, cursor: null },
+ }),
+ getValidityProof: vi.fn().mockResolvedValue({
+ context: { slot: 100n },
+ value: mockProof,
+ }),
+ });
+
+ const result = await buildCompressedTransfer({
+ indexer,
+ owner: MOCK_OWNER,
+ mint: MOCK_MINT,
+ amount: 500n,
+ recipientOwner: RECIPIENT,
+ feePayer: FEE_PAYER,
+ });
+
+ expect(result.instruction.data[0]).toBe(DISCRIMINATOR.TRANSFER2);
+ expect(result.totalInputAmount).toBe(1000n);
+ });
+
+ it('uses Rust-compatible default maxTopUp (u16::MAX)', async () => {
+ const accounts = [createMockAccountWithHash(1000n, 0xab, 5)];
+ const mockProof = {
+ proof: { a: new Uint8Array(32), b: new Uint8Array(64), c: new Uint8Array(32) },
+ accounts: [createProofInput(0xab, 10)],
+ addresses: [],
+ };
+ const indexer = createMockIndexer({
+ getCompressedTokenAccountsByOwner: vi.fn().mockResolvedValue({
+ context: { slot: 100n },
+ value: { items: accounts, cursor: null },
+ }),
+ getValidityProof: vi.fn().mockResolvedValue({
+ context: { slot: 100n },
+ value: mockProof,
+ }),
+ });
+
+ const result = await buildCompressedTransfer({
+ indexer,
+ owner: MOCK_OWNER,
+ mint: MOCK_MINT,
+ amount: 500n,
+ recipientOwner: RECIPIENT,
+ feePayer: FEE_PAYER,
+ });
+
+ expect(decodeTransfer2MaxTopUp(result.instruction.data)).toBe(65535);
+ });
+
+ it('uses explicit maxTopUp when provided', async () => {
+ const accounts = [createMockAccountWithHash(1000n, 0xab, 5)];
+ const mockProof = {
+ proof: { a: new Uint8Array(32), b: new Uint8Array(64), c: new Uint8Array(32) },
+ accounts: [createProofInput(0xab, 10)],
+ addresses: [],
+ };
+ const indexer = createMockIndexer({
+ getCompressedTokenAccountsByOwner: vi.fn().mockResolvedValue({
+ context: { slot: 100n },
+ value: { items: accounts, cursor: null },
+ }),
+ getValidityProof: vi.fn().mockResolvedValue({
+ context: { slot: 100n },
+ value: mockProof,
+ }),
+ });
+
+ const result = await buildCompressedTransfer({
+ indexer,
+ owner: MOCK_OWNER,
+ mint: MOCK_MINT,
+ amount: 500n,
+ recipientOwner: RECIPIENT,
+ feePayer: FEE_PAYER,
+ maxTopUp: 321,
+ });
+
+ expect(decodeTransfer2MaxTopUp(result.instruction.data)).toBe(321);
+ });
+
+ it('uses nextTreeInfo queue for output queue when present', async () => {
+ const account = createMockAccountWithHash(1000n, 0xab, 5);
+ account.account.treeInfo = createMockTreeInfo(TreeType.StateV2, {
+ tree: ALT_TREE,
+ queue: ALT_QUEUE,
+ treeType: TreeType.StateV2,
+ });
+
+ const mockProof = {
+ proof: { a: new Uint8Array(32), b: new Uint8Array(64), c: new Uint8Array(32) },
+ accounts: [createProofInput(0xab, 10)],
+ addresses: [],
+ };
+ const indexer = createMockIndexer({
+ getCompressedTokenAccountsByOwner: vi.fn().mockResolvedValue({
+ context: { slot: 100n },
+ value: { items: [account], cursor: null },
+ }),
+ getValidityProof: vi.fn().mockResolvedValue({
+ context: { slot: 100n },
+ value: mockProof,
+ }),
+ });
+
+ const result = await buildCompressedTransfer({
+ indexer,
+ owner: MOCK_OWNER,
+ mint: MOCK_MINT,
+ amount: 500n,
+ recipientOwner: RECIPIENT,
+ feePayer: FEE_PAYER,
+ });
+
+ const outputQueueIdx = decodeTransfer2OutputQueueIndex(
+ result.instruction.data,
+ );
+ const packedAccountsOffset = 7;
+ expect(
+ result.instruction.accounts[packedAccountsOffset + outputQueueIdx]
+ .address,
+ ).toBe(ALT_QUEUE);
+ });
+
+ it('returns correct inputs, proof, and totalInputAmount', async () => {
+ const accounts = [
+ createMockAccountWithHash(600n, 0x11, 1),
+ createMockAccountWithHash(400n, 0x22, 2),
+ ];
+ const mockProof = {
+ proof: { a: new Uint8Array(32), b: new Uint8Array(64), c: new Uint8Array(32) },
+ accounts: [createProofInput(0x22, 6), createProofInput(0x11, 5)],
+ addresses: [],
+ };
+ const indexer = createMockIndexer({
+ getCompressedTokenAccountsByOwner: vi.fn().mockResolvedValue({
+ context: { slot: 100n },
+ value: { items: accounts, cursor: null },
+ }),
+ getValidityProof: vi.fn().mockResolvedValue({
+ context: { slot: 100n },
+ value: mockProof,
+ }),
+ });
+
+ const result = await buildCompressedTransfer({
+ indexer,
+ owner: MOCK_OWNER,
+ mint: MOCK_MINT,
+ amount: 800n,
+ recipientOwner: RECIPIENT,
+ feePayer: FEE_PAYER,
+ });
+
+ expect(result.inputs).toHaveLength(2);
+ expect(result.proof).toBe(mockProof);
+ expect(result.totalInputAmount).toBe(1000n);
+ });
+
+ it('forwards maxInputs to selection via loadTokenAccountsForTransfer', async () => {
+ const accounts = [
+ createMockAccountWithHash(500n, 0x11, 1),
+ createMockAccountWithHash(400n, 0x22, 2),
+ createMockAccountWithHash(300n, 0x33, 3),
+ ];
+ const mockProof = {
+ proof: { a: new Uint8Array(32), b: new Uint8Array(64), c: new Uint8Array(32) },
+ accounts: [createProofInput(0x11, 7)],
+ addresses: [],
+ };
+ const indexer = createMockIndexer({
+ getCompressedTokenAccountsByOwner: vi.fn().mockResolvedValue({
+ context: { slot: 100n },
+ value: { items: accounts, cursor: null },
+ }),
+ getValidityProof: vi.fn().mockResolvedValue({
+ context: { slot: 100n },
+ value: mockProof,
+ }),
+ });
+
+ await expect(
+ buildCompressedTransfer({
+ indexer,
+ owner: MOCK_OWNER,
+ mint: MOCK_MINT,
+ amount: 700n,
+ recipientOwner: RECIPIENT,
+ feePayer: FEE_PAYER,
+ maxInputs: 1,
+ }),
+ ).rejects.toMatchObject({
+ code: IndexerErrorCode.InsufficientBalance,
+ });
+ });
+
+ it('includes delegate account in packed accounts when selected input has delegate', async () => {
+ const accounts = [
+ createMockAccountWithHash(1000n, 0xab, 5, DELEGATE),
+ ];
+ const mockProof = {
+ proof: { a: new Uint8Array(32), b: new Uint8Array(64), c: new Uint8Array(32) },
+ accounts: [createProofInput(0xab, 10)],
+ addresses: [],
+ };
+ const indexer = createMockIndexer({
+ getCompressedTokenAccountsByOwner: vi.fn().mockResolvedValue({
+ context: { slot: 100n },
+ value: { items: accounts, cursor: null },
+ }),
+ getValidityProof: vi.fn().mockResolvedValue({
+ context: { slot: 100n },
+ value: mockProof,
+ }),
+ });
+
+ const result = await buildCompressedTransfer({
+ indexer,
+ owner: MOCK_OWNER,
+ mint: MOCK_MINT,
+ amount: 300n,
+ recipientOwner: RECIPIENT,
+ feePayer: FEE_PAYER,
+ });
+ expect(
+ result.instruction.accounts.some((acc) => acc.address === DELEGATE),
+ ).toBe(true);
+ });
+
+ it('throws InvalidResponse when proof does not contain selected input hash', async () => {
+ const accounts = [createMockAccountWithHash(1000n, 0xab, 5)];
+ const mockProof = {
+ proof: { a: new Uint8Array(32), b: new Uint8Array(64), c: new Uint8Array(32) },
+ accounts: [createProofInput(0xcd, 99)],
+ addresses: [],
+ };
+ const indexer = createMockIndexer({
+ getCompressedTokenAccountsByOwner: vi.fn().mockResolvedValue({
+ context: { slot: 100n },
+ value: { items: accounts, cursor: null },
+ }),
+ getValidityProof: vi.fn().mockResolvedValue({
+ context: { slot: 100n },
+ value: mockProof,
+ }),
+ });
+
+ await expect(
+ buildCompressedTransfer({
+ indexer,
+ owner: MOCK_OWNER,
+ mint: MOCK_MINT,
+ amount: 100n,
+ recipientOwner: RECIPIENT,
+ feePayer: FEE_PAYER,
+ }),
+ ).rejects.toMatchObject({
+ code: IndexerErrorCode.InvalidResponse,
+ });
+ });
+
+ it('throws when insufficient balance', async () => {
+ const accounts = [createMockAccountWithHash(100n, 0xab, 5)];
+
+ const indexer = createMockIndexer({
+ getCompressedTokenAccountsByOwner: vi.fn().mockResolvedValue({
+ context: { slot: 100n },
+ value: { items: accounts, cursor: null },
+ }),
+ });
+
+ await expect(
+ buildCompressedTransfer({
+ indexer,
+ owner: MOCK_OWNER,
+ mint: MOCK_MINT,
+ amount: 1000n,
+ recipientOwner: RECIPIENT,
+ feePayer: FEE_PAYER,
+ }),
+ ).rejects.toThrow(IndexerError);
+ });
+});
+
+// ============================================================================
+// TESTS: loadMintContext
+// ============================================================================
+
+describe('loadMintContext', () => {
+ const MINT_SIGNER = address('GXtd2izAiMJPwMEjfgTRH3d7k9mjn4Jq3JrWFv9gySYy');
+
+ function createMockMintData(): Uint8Array {
+ // Create a minimal 149-byte compressed mint data buffer
+ const data = new Uint8Array(149);
+ const view = new DataView(data.buffer);
+ // mintAuthorityOption = 1 (has authority)
+ view.setUint32(0, 1, true);
+ // supply
+ view.setBigUint64(36, 1000000n, true);
+ // decimals
+ data[44] = 9;
+ // isInitialized
+ data[45] = 1;
+ // MintContext at offset 82
+ data[82] = 0; // version
+ data[83] = 0; // cmintDecompressed
+ data[148] = 255; // bump
+ return data;
+ }
+
+ it('loads and deserializes a compressed mint', async () => {
+ const mintData = createMockMintData();
+ const mockAccount: CompressedAccount = {
+ hash: new Uint8Array(32).fill(0xaa),
+ address: new Uint8Array(32).fill(0xbb),
+ owner: address('cTokenmWW8bLPjZEBAUgYy3zKxQZW6VKi7bqNFEVv3m'),
+ lamports: 0n,
+ data: {
+ discriminator: new Uint8Array(8),
+ data: mintData,
+ dataHash: new Uint8Array(32),
+ },
+ leafIndex: 42,
+ treeInfo: createMockTreeInfo(TreeType.StateV2),
+ proveByIndex: true,
+ seq: 5n,
+ slotCreated: 100n,
+ };
+
+ const indexer = createMockIndexer({
+ getCompressedAccount: vi.fn().mockResolvedValue({
+ context: { slot: 100n },
+ value: mockAccount,
+ }),
+ });
+
+ const ctx = await loadMintContext(indexer, MINT_SIGNER);
+
+ expect(ctx.leafIndex).toBe(42);
+ expect(ctx.proveByIndex).toBe(true);
+ expect(ctx.mint.base.decimals).toBe(9);
+ expect(ctx.mint.base.supply).toBe(1000000n);
+ expect(ctx.mintSigner).toBe(MINT_SIGNER);
+ // prove-by-index means no proof fetch
+ expect(ctx.proof).toBeNull();
+ });
+
+ it('throws when mint not found', async () => {
+ const indexer = createMockIndexer({
+ getCompressedAccount: vi.fn().mockResolvedValue({
+ context: { slot: 100n },
+ value: null,
+ }),
+ });
+
+ await expect(
+ loadMintContext(indexer, MINT_SIGNER),
+ ).rejects.toThrow(IndexerError);
+ });
+});
+
+// ============================================================================
+// TESTS: getMintDecimals
+// ============================================================================
+
+describe('getMintDecimals', () => {
+ it('returns decimals from on-chain mint', async () => {
+ // Create a minimal SPL mint buffer (82 bytes)
+ const mintBytes = new Uint8Array(82);
+ mintBytes[44] = 6; // decimals = 6
+
+ const base64Data = btoa(String.fromCharCode(...mintBytes));
+
+ const mockRpc = {
+ getAccountInfo: vi.fn().mockResolvedValue({
+ value: {
+ owner: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'),
+ data: [base64Data, 'base64'],
+ },
+ }),
+ };
+
+ const result = await getMintDecimals(mockRpc, MOCK_MINT);
+ expect(result).toBe(6);
+ });
+
+ it('throws when mint not found', async () => {
+ const mockRpc = {
+ getAccountInfo: vi.fn().mockResolvedValue({ value: null }),
+ };
+
+ await expect(
+ getMintDecimals(mockRpc, MOCK_MINT),
+ ).rejects.toThrow('Mint account not found');
+ });
+});
+
+// ============================================================================
+// TESTS: deserializeCompressedMint
+// ============================================================================
+
+describe('deserializeCompressedMint', () => {
+ it('parses valid 149-byte buffer', () => {
+ const data = createMockCompressedMintData(6, 500000n);
+ const result = deserializeCompressedMint(data);
+
+ expect(result.base.decimals).toBe(6);
+ expect(result.base.supply).toBe(500000n);
+ expect(result.base.mintAuthorityOption).toBe(1);
+ expect(result.base.isInitialized).toBe(true);
+ });
+
+ it('parses mintContext fields', () => {
+ const data = createMockCompressedMintData();
+ const result = deserializeCompressedMint(data);
+
+ expect(result.mintContext.version).toBe(0);
+ expect(result.mintContext.cmintDecompressed).toBe(false);
+ expect(result.mintContext.bump).toBe(254);
+ expect(result.mintContext.splMint).toEqual(new Uint8Array(32).fill(0x22));
+ expect(result.mintContext.mintSigner).toEqual(new Uint8Array(32).fill(0x33));
+ });
+
+ it('throws on data < 149 bytes', () => {
+ const shortData = new Uint8Array(100);
+ expect(() => deserializeCompressedMint(shortData)).toThrow(
+ 'Compressed mint data too short',
+ );
+ });
+
+ it('returns metadataExtensionIndex = -1 when no extensions', () => {
+ const data = createMockCompressedMintData();
+ const result = deserializeCompressedMint(data);
+
+ expect(result.metadataExtensionIndex).toBe(-1);
+ });
+
+ it('finds TOKEN_METADATA extension when present', () => {
+ // Create data with extensions: 4-byte vec len + 2-byte discriminant
+ const base = createMockCompressedMintData();
+ const extData = new Uint8Array(base.length + 6);
+ extData.set(base);
+
+ const extView = new DataView(extData.buffer);
+ // Vec length = 1 extension
+ extView.setUint32(149, 1, true);
+ // TOKEN_METADATA discriminant = 19
+ extView.setUint16(153, EXTENSION_DISCRIMINANT.TOKEN_METADATA, true);
+
+ const result = deserializeCompressedMint(extData);
+ expect(result.metadataExtensionIndex).toBe(0);
+ });
+});
+
+// ============================================================================
+// TESTS: getMintInterface
+// ============================================================================
+
+describe('getMintInterface', () => {
+ it('returns exists=false when RPC returns null', async () => {
+ const mockRpc = {
+ getAccountInfo: vi.fn().mockResolvedValue({ value: null }),
+ };
+
+ const result = await getMintInterface(mockRpc, MOCK_MINT);
+
+ expect(result.exists).toBe(false);
+ expect(result.decimals).toBe(0);
+ expect(result.supply).toBe(0n);
+ expect(result.hasFreezeAuthority).toBe(false);
+ });
+
+ it('parses decimals, supply, freezeAuthority from valid mint data', async () => {
+ const mintData = createBase64MintData(9, 5000000n, true);
+ const mockRpc = {
+ getAccountInfo: vi.fn().mockResolvedValue({
+ value: {
+ owner: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'),
+ data: mintData,
+ },
+ }),
+ };
+
+ const result = await getMintInterface(mockRpc, MOCK_MINT);
+
+ expect(result.exists).toBe(true);
+ expect(result.decimals).toBe(9);
+ expect(result.supply).toBe(5000000n);
+ expect(result.hasFreezeAuthority).toBe(true);
+ });
+
+ it('handles data < 82 bytes gracefully', async () => {
+ const shortData = btoa(String.fromCharCode(...new Uint8Array(40)));
+ const mockRpc = {
+ getAccountInfo: vi.fn().mockResolvedValue({
+ value: {
+ owner: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'),
+ data: [shortData, 'base64'],
+ },
+ }),
+ };
+
+ const result = await getMintInterface(mockRpc, MOCK_MINT);
+
+ expect(result.exists).toBe(true);
+ expect(result.decimals).toBe(0);
+ expect(result.supply).toBe(0n);
+ });
+});
+
+// ============================================================================
+// TESTS: getAtaInterface
+// ============================================================================
+
+describe('getAtaInterface', () => {
+ it('aggregates hot + cold + spl balances', async () => {
+ // Build a mock 72-byte account with balance=500 at offset 64
+ const accountBytes = new Uint8Array(72);
+ const view = new DataView(accountBytes.buffer);
+ view.setBigUint64(64, 500n, true);
+ const base64 = btoa(String.fromCharCode(...accountBytes));
+
+ const hotAddr = address('Vote111111111111111111111111111111111111111');
+ const splAddr = address('11111111111111111111111111111111');
+
+ const mockRpc = {
+ getAccountInfo: vi.fn().mockResolvedValue({
+ value: {
+ owner: MOCK_CTOKEN_PROGRAM,
+ data: [base64, 'base64'],
+ },
+ }),
+ };
+
+ const coldAccount = createMockTokenAccount(300n);
+ const indexer = createMockIndexer({
+ getCompressedTokenAccountsByOwner: vi.fn().mockResolvedValue({
+ context: { slot: 100n },
+ value: { items: [coldAccount], cursor: null },
+ }),
+ });
+
+ const result = await getAtaInterface(
+ mockRpc,
+ indexer,
+ MOCK_OWNER,
+ MOCK_MINT,
+ hotAddr,
+ splAddr,
+ );
+
+ expect(result.hotBalance).toBe(500n);
+ expect(result.splBalance).toBe(500n);
+ expect(result.coldBalance).toBe(300n);
+ expect(result.totalBalance).toBe(1300n);
+ expect(result.coldAccountCount).toBe(1);
+ });
+
+ it('returns zeros when no accounts found and no hot/spl provided', async () => {
+ const mockRpc = {
+ getAccountInfo: vi.fn().mockResolvedValue({ value: null }),
+ };
+
+ const indexer = createMockIndexer({
+ getCompressedTokenAccountsByOwner: vi.fn().mockResolvedValue({
+ context: { slot: 100n },
+ value: { items: [], cursor: null },
+ }),
+ });
+
+ const result = await getAtaInterface(
+ mockRpc,
+ indexer,
+ MOCK_OWNER,
+ MOCK_MINT,
+ );
+
+ expect(result.hotBalance).toBe(0n);
+ expect(result.coldBalance).toBe(0n);
+ expect(result.splBalance).toBe(0n);
+ expect(result.totalBalance).toBe(0n);
+ expect(result.sources).toHaveLength(0);
+ });
+});
diff --git a/js/token-kit/tests/unit/selection.test.ts b/js/token-kit/tests/unit/selection.test.ts
new file mode 100644
index 0000000000..d7ca745d7b
--- /dev/null
+++ b/js/token-kit/tests/unit/selection.test.ts
@@ -0,0 +1,231 @@
+/**
+ * Unit tests for account selection algorithm (selectAccountsForAmount).
+ *
+ * Tests the greedy largest-first selection strategy used to pick
+ * compressed token accounts for transfers.
+ */
+
+import { describe, it, expect } from 'vitest';
+
+import { selectAccountsForAmount, DEFAULT_MAX_INPUTS } from '../../src/index.js';
+import { createMockTokenAccount } from './helpers.js';
+
+describe('selectAccountsForAmount', () => {
+ it('selects single large account when sufficient', () => {
+ const accounts = [
+ createMockTokenAccount(1000n),
+ createMockTokenAccount(500n),
+ createMockTokenAccount(200n),
+ ];
+
+ const result = selectAccountsForAmount(accounts, 800n);
+
+ expect(result.accounts).toHaveLength(1);
+ expect(result.accounts[0].token.amount).toBe(1000n);
+ expect(result.totalAmount).toBe(1000n);
+ });
+
+ it('selects multiple accounts using greedy largest-first strategy', () => {
+ const accounts = [
+ createMockTokenAccount(300n),
+ createMockTokenAccount(500n),
+ createMockTokenAccount(200n),
+ ];
+
+ const result = selectAccountsForAmount(accounts, 700n);
+
+ // Largest first: 500, then 300
+ expect(result.accounts).toHaveLength(2);
+ expect(result.accounts[0].token.amount).toBe(500n);
+ expect(result.accounts[1].token.amount).toBe(300n);
+ expect(result.totalAmount).toBe(800n);
+ });
+
+ it('returns all accounts when total balance is insufficient', () => {
+ const accounts = [
+ createMockTokenAccount(100n),
+ createMockTokenAccount(200n),
+ createMockTokenAccount(50n),
+ ];
+
+ const result = selectAccountsForAmount(accounts, 1000n);
+
+ expect(result.accounts).toHaveLength(3);
+ expect(result.totalAmount).toBe(350n);
+ });
+
+ it('returns zero accounts for empty input', () => {
+ const result = selectAccountsForAmount([], 100n);
+
+ expect(result.accounts).toHaveLength(0);
+ expect(result.totalAmount).toBe(0n);
+ });
+
+ it('returns zero accounts for zero required amount', () => {
+ const accounts = [
+ createMockTokenAccount(100n),
+ createMockTokenAccount(200n),
+ ];
+
+ const result = selectAccountsForAmount(accounts, 0n);
+
+ expect(result.accounts).toHaveLength(0);
+ expect(result.totalAmount).toBe(0n);
+ });
+
+ it('selects exact match with a single account', () => {
+ const accounts = [
+ createMockTokenAccount(100n),
+ createMockTokenAccount(200n),
+ createMockTokenAccount(300n),
+ ];
+
+ const result = selectAccountsForAmount(accounts, 300n);
+
+ expect(result.accounts).toHaveLength(1);
+ expect(result.accounts[0].token.amount).toBe(300n);
+ expect(result.totalAmount).toBe(300n);
+ });
+
+ it('handles already-sorted input correctly', () => {
+ // Descending order (already sorted by the algorithm's preference)
+ const accounts = [
+ createMockTokenAccount(500n),
+ createMockTokenAccount(300n),
+ createMockTokenAccount(100n),
+ ];
+
+ const result = selectAccountsForAmount(accounts, 400n);
+
+ expect(result.accounts).toHaveLength(1);
+ expect(result.accounts[0].token.amount).toBe(500n);
+ expect(result.totalAmount).toBe(500n);
+ });
+
+ it('handles unsorted input correctly', () => {
+ // Reverse order (ascending), algorithm should still pick largest first
+ const accounts = [
+ createMockTokenAccount(50n),
+ createMockTokenAccount(150n),
+ createMockTokenAccount(400n),
+ createMockTokenAccount(100n),
+ ];
+
+ const result = selectAccountsForAmount(accounts, 500n);
+
+ // 400 first, then 150
+ expect(result.accounts).toHaveLength(2);
+ expect(result.accounts[0].token.amount).toBe(400n);
+ expect(result.accounts[1].token.amount).toBe(150n);
+ expect(result.totalAmount).toBe(550n);
+ });
+
+ it('handles large amounts up to max u64', () => {
+ const maxU64 = 18446744073709551615n;
+ const halfMax = 9223372036854775808n;
+
+ const accounts = [
+ createMockTokenAccount(halfMax),
+ createMockTokenAccount(halfMax),
+ ];
+
+ const result = selectAccountsForAmount(accounts, maxU64);
+
+ expect(result.accounts).toHaveLength(2);
+ expect(result.totalAmount).toBe(halfMax + halfMax);
+ });
+
+ it('skips zero-balance accounts naturally since they do not contribute', () => {
+ const accounts = [
+ createMockTokenAccount(0n),
+ createMockTokenAccount(0n),
+ createMockTokenAccount(500n),
+ createMockTokenAccount(0n),
+ ];
+
+ const result = selectAccountsForAmount(accounts, 300n);
+
+ // Algorithm sorts descending: 500, 0, 0, 0
+ // Picks 500 first which satisfies 300, stops.
+ expect(result.accounts).toHaveLength(1);
+ expect(result.accounts[0].token.amount).toBe(500n);
+ expect(result.totalAmount).toBe(500n);
+ });
+
+ it('DEFAULT_MAX_INPUTS is 4', () => {
+ expect(DEFAULT_MAX_INPUTS).toBe(4);
+ });
+
+ it('respects maxInputs cap (default 4)', () => {
+ const accounts = [
+ createMockTokenAccount(100n),
+ createMockTokenAccount(100n),
+ createMockTokenAccount(100n),
+ createMockTokenAccount(100n),
+ createMockTokenAccount(100n),
+ createMockTokenAccount(100n),
+ ];
+
+ // Without explicit maxInputs, defaults to 4
+ const result = selectAccountsForAmount(accounts, 600n);
+
+ // Should select at most 4 accounts even though 6 would be needed
+ expect(result.accounts).toHaveLength(4);
+ expect(result.totalAmount).toBe(400n);
+ });
+
+ it('respects custom maxInputs', () => {
+ const accounts = [
+ createMockTokenAccount(100n),
+ createMockTokenAccount(100n),
+ createMockTokenAccount(100n),
+ createMockTokenAccount(100n),
+ ];
+
+ const result = selectAccountsForAmount(accounts, 400n, 2);
+ expect(result.accounts).toHaveLength(2);
+ expect(result.totalAmount).toBe(200n);
+ });
+
+ it('maxInputs=1 selects only the largest account', () => {
+ const accounts = [
+ createMockTokenAccount(50n),
+ createMockTokenAccount(300n),
+ createMockTokenAccount(100n),
+ ];
+
+ const result = selectAccountsForAmount(accounts, 400n, 1);
+ expect(result.accounts).toHaveLength(1);
+ expect(result.accounts[0].token.amount).toBe(300n);
+ expect(result.totalAmount).toBe(300n);
+ });
+
+ it('zero-balance accounts are skipped and do not count toward maxInputs', () => {
+ const accounts = [
+ createMockTokenAccount(0n),
+ createMockTokenAccount(0n),
+ createMockTokenAccount(0n),
+ createMockTokenAccount(100n),
+ createMockTokenAccount(200n),
+ ];
+
+ // maxInputs=2, but zero accounts should not count
+ const result = selectAccountsForAmount(accounts, 300n, 2);
+ expect(result.accounts).toHaveLength(2);
+ expect(result.accounts[0].token.amount).toBe(200n);
+ expect(result.accounts[1].token.amount).toBe(100n);
+ expect(result.totalAmount).toBe(300n);
+ });
+
+ it('all-zero accounts returns empty selection', () => {
+ const accounts = [
+ createMockTokenAccount(0n),
+ createMockTokenAccount(0n),
+ createMockTokenAccount(0n),
+ ];
+
+ const result = selectAccountsForAmount(accounts, 100n);
+ expect(result.accounts).toHaveLength(0);
+ expect(result.totalAmount).toBe(0n);
+ });
+});
diff --git a/js/token-kit/tests/unit/utils.test.ts b/js/token-kit/tests/unit/utils.test.ts
new file mode 100644
index 0000000000..30d51d1125
--- /dev/null
+++ b/js/token-kit/tests/unit/utils.test.ts
@@ -0,0 +1,424 @@
+/**
+ * Unit tests for Light Token SDK Utils
+ *
+ * Tests for:
+ * - PDA derivation functions
+ * - Validation functions
+ */
+
+import { describe, it, expect } from 'vitest';
+import { address } from '@solana/addresses';
+
+import {
+ deriveAssociatedTokenAddress,
+ getAssociatedTokenAddressWithBump,
+ deriveMintAddress,
+ derivePoolAddress,
+ deriveCompressedAddress,
+ deriveCompressedMintAddress,
+ validatePositiveAmount,
+ validateDecimals,
+ validateAtaDerivation,
+ isLightTokenAccount,
+ determineTransferType,
+ LIGHT_TOKEN_PROGRAM_ID,
+ MINT_ADDRESS_TREE,
+} from '../../src/index.js';
+
+// ============================================================================
+// TEST: PDA Derivation Functions
+// ============================================================================
+
+describe('deriveAssociatedTokenAddress', () => {
+ it('derives correct ATA address', async () => {
+ const owner = address('11111111111111111111111111111111');
+ const mint = address('So11111111111111111111111111111111111111112');
+
+ const result = await deriveAssociatedTokenAddress(owner, mint);
+
+ expect(result.address).toBeDefined();
+ expect(typeof result.bump).toBe('number');
+ expect(result.bump).toBeGreaterThanOrEqual(0);
+ expect(result.bump).toBeLessThanOrEqual(255);
+ });
+
+ it('produces consistent results for same inputs', async () => {
+ const owner = address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA');
+ const mint = address('So11111111111111111111111111111111111111112');
+
+ const result1 = await deriveAssociatedTokenAddress(owner, mint);
+ const result2 = await deriveAssociatedTokenAddress(owner, mint);
+
+ expect(result1.address).toBe(result2.address);
+ expect(result1.bump).toBe(result2.bump);
+ });
+
+ it('produces different addresses for different owners', async () => {
+ const owner1 = address('11111111111111111111111111111111');
+ const owner2 = address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA');
+ const mint = address('So11111111111111111111111111111111111111112');
+
+ const result1 = await deriveAssociatedTokenAddress(owner1, mint);
+ const result2 = await deriveAssociatedTokenAddress(owner2, mint);
+
+ expect(result1.address).not.toBe(result2.address);
+ });
+
+ it('produces different addresses for different mints', async () => {
+ const owner = address('11111111111111111111111111111111');
+ const mint1 = address('So11111111111111111111111111111111111111112');
+ const mint2 = address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA');
+
+ const result1 = await deriveAssociatedTokenAddress(owner, mint1);
+ const result2 = await deriveAssociatedTokenAddress(owner, mint2);
+
+ expect(result1.address).not.toBe(result2.address);
+ });
+});
+
+describe('getAssociatedTokenAddressWithBump', () => {
+ it('returns address when bump matches', async () => {
+ const owner = address('11111111111111111111111111111111');
+ const mint = address('So11111111111111111111111111111111111111112');
+
+ // First derive to get the correct bump
+ const { address: expectedAddress, bump } =
+ await deriveAssociatedTokenAddress(owner, mint);
+
+ // Then verify with bump
+ const result = await getAssociatedTokenAddressWithBump(
+ owner,
+ mint,
+ bump,
+ );
+
+ expect(result).toBe(expectedAddress);
+ });
+
+ it('throws when bump does not match', async () => {
+ const owner = address('11111111111111111111111111111111');
+ const mint = address('So11111111111111111111111111111111111111112');
+
+ // Get the correct bump first
+ const { bump: correctBump } = await deriveAssociatedTokenAddress(
+ owner,
+ mint,
+ );
+
+ // Use wrong bump
+ const wrongBump = (correctBump + 1) % 256;
+
+ await expect(
+ getAssociatedTokenAddressWithBump(owner, mint, wrongBump),
+ ).rejects.toThrow('Bump mismatch');
+ });
+});
+
+describe('deriveMintAddress', () => {
+ it('derives correct mint address', async () => {
+ const mintSigner = address('11111111111111111111111111111111');
+
+ const result = await deriveMintAddress(mintSigner);
+
+ expect(result.address).toBeDefined();
+ expect(typeof result.bump).toBe('number');
+ expect(result.bump).toBeGreaterThanOrEqual(0);
+ expect(result.bump).toBeLessThanOrEqual(255);
+ });
+
+ it('produces consistent results', async () => {
+ const mintSigner = address(
+ 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA',
+ );
+
+ const result1 = await deriveMintAddress(mintSigner);
+ const result2 = await deriveMintAddress(mintSigner);
+
+ expect(result1.address).toBe(result2.address);
+ expect(result1.bump).toBe(result2.bump);
+ });
+});
+
+describe('derivePoolAddress', () => {
+ it('derives correct pool address without index', async () => {
+ const mint = address('So11111111111111111111111111111111111111112');
+
+ const result = await derivePoolAddress(mint);
+
+ expect(result.address).toBeDefined();
+ expect(typeof result.bump).toBe('number');
+ });
+
+ it('derives correct pool address with index', async () => {
+ const mint = address('So11111111111111111111111111111111111111112');
+
+ const result = await derivePoolAddress(mint, 0);
+
+ expect(result.address).toBeDefined();
+ expect(typeof result.bump).toBe('number');
+ });
+
+ it('different indices produce different addresses', async () => {
+ const mint = address('So11111111111111111111111111111111111111112');
+
+ const result0 = await derivePoolAddress(mint, 0);
+ const result1 = await derivePoolAddress(mint, 1);
+
+ expect(result0.address).not.toBe(result1.address);
+ });
+
+ it('no index equals index 0 (both omit index from seeds)', async () => {
+ const mint = address('So11111111111111111111111111111111111111112');
+
+ const resultNoIndex = await derivePoolAddress(mint);
+ const resultIndex0 = await derivePoolAddress(mint, 0);
+
+ // Rust: index 0 means no index bytes in seeds, same as omitting index
+ expect(resultNoIndex.address).toBe(resultIndex0.address);
+ });
+
+ it('restricted pool differs from regular pool', async () => {
+ const mint = address('So11111111111111111111111111111111111111112');
+
+ const regular = await derivePoolAddress(mint, 0, false);
+ const restricted = await derivePoolAddress(mint, 0, true);
+
+ expect(regular.address).not.toBe(restricted.address);
+ });
+
+ it('restricted pool with index differs from without', async () => {
+ const mint = address('So11111111111111111111111111111111111111112');
+
+ const restricted0 = await derivePoolAddress(mint, 0, true);
+ const restricted1 = await derivePoolAddress(mint, 1, true);
+
+ expect(restricted0.address).not.toBe(restricted1.address);
+ });
+
+ it('throws for index > 4', async () => {
+ const mint = address('So11111111111111111111111111111111111111112');
+ await expect(derivePoolAddress(mint, 5)).rejects.toThrow(
+ 'Pool index must be an integer between 0 and 4',
+ );
+ await expect(derivePoolAddress(mint, 255)).rejects.toThrow(
+ 'Pool index must be an integer between 0 and 4',
+ );
+ });
+
+ it('throws for negative index', async () => {
+ const mint = address('So11111111111111111111111111111111111111112');
+ await expect(derivePoolAddress(mint, -1)).rejects.toThrow(
+ 'Pool index must be an integer between 0 and 4',
+ );
+ });
+
+ it('throws for non-integer index', async () => {
+ const mint = address('So11111111111111111111111111111111111111112');
+ await expect(derivePoolAddress(mint, 1.5)).rejects.toThrow(
+ 'Pool index must be an integer between 0 and 4',
+ );
+ });
+});
+
+// ============================================================================
+// TEST: Compressed Address Derivation
+// ============================================================================
+
+describe('deriveCompressedAddress', () => {
+ it('produces a 32-byte result with high bit cleared', () => {
+ const seed = new Uint8Array(32).fill(0x42);
+ const tree = address('amt2kaJA14v3urZbZvnc5v2np8jqvc4Z8zDep5wbtzx');
+ const programId = LIGHT_TOKEN_PROGRAM_ID;
+
+ const result = deriveCompressedAddress(seed, tree, programId);
+
+ expect(result).toBeInstanceOf(Uint8Array);
+ expect(result.length).toBe(32);
+ // High bit must be cleared for BN254 field
+ expect(result[0] & 0x80).toBe(0);
+ });
+
+ it('produces consistent results for same inputs', () => {
+ const seed = new Uint8Array(32).fill(0x01);
+ const tree = address('amt2kaJA14v3urZbZvnc5v2np8jqvc4Z8zDep5wbtzx');
+ const programId = LIGHT_TOKEN_PROGRAM_ID;
+
+ const result1 = deriveCompressedAddress(seed, tree, programId);
+ const result2 = deriveCompressedAddress(seed, tree, programId);
+
+ expect(result1).toEqual(result2);
+ });
+
+ it('produces different results for different seeds', () => {
+ const seed1 = new Uint8Array(32).fill(0x01);
+ const seed2 = new Uint8Array(32).fill(0x02);
+ const tree = address('amt2kaJA14v3urZbZvnc5v2np8jqvc4Z8zDep5wbtzx');
+ const programId = LIGHT_TOKEN_PROGRAM_ID;
+
+ const result1 = deriveCompressedAddress(seed1, tree, programId);
+ const result2 = deriveCompressedAddress(seed2, tree, programId);
+
+ expect(result1).not.toEqual(result2);
+ });
+});
+
+describe('deriveCompressedMintAddress', () => {
+ it('produces a 32-byte result', () => {
+ const mintSigner = address('11111111111111111111111111111111');
+ const result = deriveCompressedMintAddress(mintSigner);
+
+ expect(result).toBeInstanceOf(Uint8Array);
+ expect(result.length).toBe(32);
+ expect(result[0] & 0x80).toBe(0);
+ });
+
+ it('produces consistent results', () => {
+ const mintSigner = address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA');
+
+ const result1 = deriveCompressedMintAddress(mintSigner);
+ const result2 = deriveCompressedMintAddress(mintSigner);
+
+ expect(result1).toEqual(result2);
+ });
+
+ it('uses MINT_ADDRESS_TREE as default', () => {
+ const mintSigner = address('11111111111111111111111111111111');
+
+ const withDefault = deriveCompressedMintAddress(mintSigner);
+ const withExplicit = deriveCompressedMintAddress(mintSigner, MINT_ADDRESS_TREE);
+
+ expect(withDefault).toEqual(withExplicit);
+ });
+});
+
+// ============================================================================
+// TEST: Validation Functions
+// ============================================================================
+
+describe('validatePositiveAmount', () => {
+ it('passes for positive amount', () => {
+ expect(() => validatePositiveAmount(1n)).not.toThrow();
+ expect(() => validatePositiveAmount(100n)).not.toThrow();
+ expect(() =>
+ validatePositiveAmount(BigInt(Number.MAX_SAFE_INTEGER)),
+ ).not.toThrow();
+ });
+
+ it('throws for zero', () => {
+ expect(() => validatePositiveAmount(0n)).toThrow(
+ 'Amount must be positive',
+ );
+ });
+
+ it('throws for negative', () => {
+ expect(() => validatePositiveAmount(-1n)).toThrow(
+ 'Amount must be positive',
+ );
+ expect(() => validatePositiveAmount(-100n)).toThrow(
+ 'Amount must be positive',
+ );
+ });
+});
+
+describe('validateDecimals', () => {
+ it('passes for valid decimals', () => {
+ expect(() => validateDecimals(0)).not.toThrow();
+ expect(() => validateDecimals(6)).not.toThrow();
+ expect(() => validateDecimals(9)).not.toThrow();
+ expect(() => validateDecimals(255)).not.toThrow();
+ });
+
+ it('throws for negative decimals', () => {
+ expect(() => validateDecimals(-1)).toThrow(
+ 'Decimals must be an integer between 0 and 255',
+ );
+ });
+
+ it('throws for decimals > 255', () => {
+ expect(() => validateDecimals(256)).toThrow(
+ 'Decimals must be an integer between 0 and 255',
+ );
+ });
+
+ it('throws for non-integer decimals', () => {
+ expect(() => validateDecimals(1.5)).toThrow(
+ 'Decimals must be an integer between 0 and 255',
+ );
+ expect(() => validateDecimals(6.9)).toThrow(
+ 'Decimals must be an integer between 0 and 255',
+ );
+ });
+});
+
+describe('validateAtaDerivation', () => {
+ it('validates correct ATA derivation', async () => {
+ const owner = address('11111111111111111111111111111111');
+ const mint = address('So11111111111111111111111111111111111111112');
+
+ const { address: ata } = await deriveAssociatedTokenAddress(
+ owner,
+ mint,
+ );
+
+ const isValid = await validateAtaDerivation(ata, owner, mint);
+
+ expect(isValid).toBe(true);
+ });
+
+ it('returns false for wrong ATA', async () => {
+ const owner = address('11111111111111111111111111111111');
+ const mint = address('So11111111111111111111111111111111111111112');
+ const wrongAta = address(
+ 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA',
+ );
+
+ const isValid = await validateAtaDerivation(wrongAta, owner, mint);
+
+ expect(isValid).toBe(false);
+ });
+});
+
+describe('isLightTokenAccount', () => {
+ it('correctly identifies Light token accounts', () => {
+ expect(isLightTokenAccount(LIGHT_TOKEN_PROGRAM_ID)).toBe(true);
+ });
+
+ it('returns false for non-Light accounts', () => {
+ const splToken = address(
+ 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA',
+ );
+ const systemProgram = address('11111111111111111111111111111111');
+
+ expect(isLightTokenAccount(splToken)).toBe(false);
+ expect(isLightTokenAccount(systemProgram)).toBe(false);
+ });
+});
+
+describe('determineTransferType', () => {
+ const lightProgram = LIGHT_TOKEN_PROGRAM_ID;
+ const splProgram = address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA');
+
+ it('returns light-to-light for both Light accounts', () => {
+ expect(determineTransferType(lightProgram, lightProgram)).toBe(
+ 'light-to-light',
+ );
+ });
+
+ it('returns light-to-spl for Light source, SPL dest', () => {
+ expect(determineTransferType(lightProgram, splProgram)).toBe(
+ 'light-to-spl',
+ );
+ });
+
+ it('returns spl-to-light for SPL source, Light dest', () => {
+ expect(determineTransferType(splProgram, lightProgram)).toBe(
+ 'spl-to-light',
+ );
+ });
+
+ it('returns spl-to-spl for both SPL accounts', () => {
+ expect(determineTransferType(splProgram, splProgram)).toBe(
+ 'spl-to-spl',
+ );
+ });
+});
diff --git a/js/token-kit/tsconfig.json b/js/token-kit/tsconfig.json
new file mode 100644
index 0000000000..4a816a14df
--- /dev/null
+++ b/js/token-kit/tsconfig.json
@@ -0,0 +1,24 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "lib": ["ES2022", "DOM"],
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "declaration": true,
+ "declarationMap": true,
+ "sourceMap": true,
+ "outDir": "./dist",
+ "rootDir": "./src",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noImplicitReturns": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src/**/*"],
+ "exclude": ["node_modules", "dist", "tests", "src/generated", "src/idl.ts"]
+}
diff --git a/js/token-kit/vitest.e2e.config.ts b/js/token-kit/vitest.e2e.config.ts
new file mode 100644
index 0000000000..3a70ce302e
--- /dev/null
+++ b/js/token-kit/vitest.e2e.config.ts
@@ -0,0 +1,31 @@
+import { defineConfig } from 'vitest/config';
+import path from 'path';
+
+export default defineConfig({
+ resolve: {
+ alias: {
+ // Resolve to source so compressed-token and token-kit share a
+ // single featureFlags instance (the dist bundles each get their
+ // own copy with __BUILD_VERSION__ baked to V1).
+ '@lightprotocol/stateless.js': path.resolve(
+ __dirname,
+ '../stateless.js/src/index.ts',
+ ),
+ '@lightprotocol/compressed-token': path.resolve(
+ __dirname,
+ '../compressed-token/src/index.ts',
+ ),
+ },
+ },
+ test: {
+ include: ['tests/e2e/**/*.test.ts'],
+ fileParallelism: false,
+ testTimeout: 120_000,
+ hookTimeout: 60_000,
+ reporters: ['verbose'],
+ env: {
+ LIGHT_PROTOCOL_VERSION: 'V2',
+ LIGHT_PROTOCOL_BETA: 'true',
+ },
+ },
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 158713b83f..8e0aa93904 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -435,6 +435,70 @@ importers:
specifier: ^2.1.1
version: 2.1.1(@types/node@22.16.5)(terser@5.43.1)
+ js/token-kit:
+ dependencies:
+ '@noble/hashes':
+ specifier: ^1.4.0
+ version: 1.5.0
+ '@solana/addresses':
+ specifier: ^2.1.0
+ version: 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ '@solana/codecs':
+ specifier: ^2.1.0
+ version: 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ '@solana/instructions':
+ specifier: ^2.1.0
+ version: 2.3.0(typescript@5.9.3)
+ devDependencies:
+ '@codama/nodes':
+ specifier: ^1.4.1
+ version: 1.5.0
+ '@codama/renderers-js':
+ specifier: ^1.2.8
+ version: 1.5.5(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ '@codama/visitors':
+ specifier: ^1.4.1
+ version: 1.5.0
+ '@codama/visitors-core':
+ specifier: ^1.4.1
+ version: 1.5.0
+ '@eslint/js':
+ specifier: 9.36.0
+ version: 9.36.0
+ '@lightprotocol/compressed-token':
+ specifier: workspace:*
+ version: link:../compressed-token
+ '@lightprotocol/stateless.js':
+ specifier: workspace:*
+ version: link:../stateless.js
+ '@solana/kit':
+ specifier: ^2.1.0
+ version: 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.8)(utf-8-validate@5.0.10))
+ '@typescript-eslint/eslint-plugin':
+ specifier: ^8.44.0
+ version: 8.44.0(@typescript-eslint/parser@8.44.0(eslint@9.36.0)(typescript@5.9.3))(eslint@9.36.0)(typescript@5.9.3)
+ '@typescript-eslint/parser':
+ specifier: ^8.44.0
+ version: 8.44.0(eslint@9.36.0)(typescript@5.9.3)
+ codama:
+ specifier: ^1.4.1
+ version: 1.5.0
+ eslint:
+ specifier: ^9.36.0
+ version: 9.36.0
+ prettier:
+ specifier: ^3.3.3
+ version: 3.6.2
+ tsx:
+ specifier: ^4.19.2
+ version: 4.20.5
+ typescript:
+ specifier: ^5.7.3
+ version: 5.9.3
+ vitest:
+ specifier: ^2.1.8
+ version: 2.1.9(@types/node@22.16.5)(terser@5.43.1)
+
sdk-tests/sdk-anchor-test:
dependencies:
'@coral-xyz/anchor':
@@ -746,6 +810,36 @@ packages:
resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==}
engines: {node: '>=6.9.0'}
+ '@codama/cli@1.4.4':
+ resolution: {integrity: sha512-0uLecW/RZC2c1wx3j/eiRAYvilvNY+2DoyEYu/hV0OfM1/uIgIyuy5U+wolV+LY4wLFYdApjYdy+5D32lngCHg==}
+ hasBin: true
+
+ '@codama/errors@1.5.0':
+ resolution: {integrity: sha512-i4cS+S7JaZXhofQHFY3cwzt8rqxUVPNaeJND5VOyKUbtcOi933YXJXk52gDG4mc+CpGqHJijsJjfSpr1lJGxzg==}
+ hasBin: true
+
+ '@codama/node-types@1.5.0':
+ resolution: {integrity: sha512-Ebz2vOUukmNaFXWdkni1ZihXkAIUnPYtqIMXYxKXOxjMP+TGz2q0lGtRo7sqw1pc2ksFBIkfBp5pZsl5p6gwXA==}
+
+ '@codama/nodes@1.5.0':
+ resolution: {integrity: sha512-yg+xmorWiMNjS3n19CGIt/FZ/ZCuDIu+HEY45bq6gHu1MN3RtJZY+Q3v0ErnBPA60D8mNWkvkKoeSZXfzcAvfw==}
+
+ '@codama/renderers-core@1.3.5':
+ resolution: {integrity: sha512-MuZLU+3LZPQb1HuZffwZl+v5JHQDe5LYHGhA1wTMNlwRedYIysSxBjogHNciNIHsKP3JjmqyYmLO5LCEp3hjaQ==}
+
+ '@codama/renderers-js@1.5.5':
+ resolution: {integrity: sha512-zYVw8KGRHFzrpPKAv8PJI1pMy28qc/iEMspMC6Iw915Vsg0od75FUmUhDAvrTwgc28oyCmlrsWv6BNON4AKmqQ==}
+ engines: {node: '>=20.18.0'}
+
+ '@codama/validators@1.5.0':
+ resolution: {integrity: sha512-p3ufDxnCH1jiuHGzcBv4/d+ctzUcKD2K3gX/W8169tC41o9DggjlEpNy1Z6YAAhVb3wHnmXVGA2qmp32rWSfWw==}
+
+ '@codama/visitors-core@1.5.0':
+ resolution: {integrity: sha512-3PIAlBX0a06hIxzyPtQMfQcqWGFBgfbwysSwcXBbvHUYbemwhD6xwlBKJuqTwm9DyFj3faStp5fpvcp03Rjxtw==}
+
+ '@codama/visitors@1.5.0':
+ resolution: {integrity: sha512-SwtQaleXxAaFz6uHygxki621q4nPUDQlnwEhsg+QKOjHpKWXjLYdJof+R8gUiTV/n7/IeNnjvxJTTNfUsvETPQ==}
+
'@coral-xyz/anchor-errors@0.31.1':
resolution: {integrity: sha512-NhNEku4F3zzUSBtrYz84FzYWm48+9OvmT1Hhnwr6GnPQry2dsEqH/ti/7ASjjpoFTWRnPXrjAIT1qM6Isop+LQ==}
engines: {node: '>=10'}
@@ -1865,6 +1959,24 @@ packages:
resolution: {integrity: sha512-PJBmyayrlfxM7nbqjomF4YcT1sApQwZio0NHSsT0EzhJqljRmvhzqZua43TyEs80nJk2Cn2FGPg/N8phH6KeCQ==}
engines: {node: '>=18.0.0'}
+ '@solana/accounts@2.3.0':
+ resolution: {integrity: sha512-QgQTj404Z6PXNOyzaOpSzjgMOuGwG8vC66jSDB+3zHaRcEPRVRd2sVSrd1U6sHtnV3aiaS6YyDuPQMheg4K2jw==}
+ engines: {node: '>=20.18.0'}
+ peerDependencies:
+ typescript: '>=5.3.3'
+
+ '@solana/addresses@2.3.0':
+ resolution: {integrity: sha512-ypTNkY2ZaRFpHLnHAgaW8a83N0/WoqdFvCqf4CQmnMdFsZSdC7qOwcbd7YzdaQn9dy+P2hybewzB+KP7LutxGA==}
+ engines: {node: '>=20.18.0'}
+ peerDependencies:
+ typescript: '>=5.3.3'
+
+ '@solana/assertions@2.3.0':
+ resolution: {integrity: sha512-Ekoet3khNg3XFLN7MIz8W31wPQISpKUGDGTylLptI+JjCDWx3PIa88xjEMqFo02WJ8sBj2NLV64Xg1sBcsHjZQ==}
+ engines: {node: '>=20.18.0'}
+ peerDependencies:
+ typescript: '>=5.3.3'
+
'@solana/buffer-layout-utils@0.2.0':
resolution: {integrity: sha512-szG4sxgJGktbuZYDg2FfNmkMi0DYQoVjN2h7ta1W1hPrwzarcFLBq9UpX1UjNXsNpT9dn+chgprtWGioUAr4/g==}
engines: {node: '>= 10'}
@@ -1892,6 +2004,15 @@ packages:
peerDependencies:
typescript: '>=5.3.3'
+ '@solana/codecs-core@5.4.0':
+ resolution: {integrity: sha512-rQ5jXgiDe2vIU+mYCHDjgwMd9WdzZfh4sc5H6JgYleAUjeTUX6mx8hTV2+pcXvvn27LPrgrt9jfxswbDb8O8ww==}
+ engines: {node: '>=20.18.0'}
+ peerDependencies:
+ typescript: ^5.0.0
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
'@solana/codecs-data-structures@2.0.0-experimental.8618508':
resolution: {integrity: sha512-sLpjL9sqzaDdkloBPV61Rht1tgaKq98BCtIKRuyscIrmVPu3wu0Bavk2n/QekmUzaTsj7K1pVSniM0YqCdnEBw==}
@@ -1905,6 +2026,12 @@ packages:
peerDependencies:
typescript: '>=5'
+ '@solana/codecs-data-structures@2.3.0':
+ resolution: {integrity: sha512-qvU5LE5DqEdYMYgELRHv+HMOx73sSoV1ZZkwIrclwUmwTbTaH8QAJURBj0RhQ/zCne7VuLLOZFFGv6jGigWhSw==}
+ engines: {node: '>=20.18.0'}
+ peerDependencies:
+ typescript: '>=5.3.3'
+
'@solana/codecs-numbers@2.0.0-experimental.8618508':
resolution: {integrity: sha512-EXQKfzFr3CkKKNzKSZPOOOzchXsFe90TVONWsSnVkonO9z+nGKALE0/L9uBmIFGgdzhhU9QQVFvxBMclIDJo2Q==}
@@ -1924,6 +2051,15 @@ packages:
peerDependencies:
typescript: '>=5.3.3'
+ '@solana/codecs-numbers@5.4.0':
+ resolution: {integrity: sha512-z6LMkY+kXWx1alrvIDSAxexY5QLhsso638CjM7XI1u6dB7drTLWKhifyjnm1vOQc1VPVFmbYxTgKKpds8TY8tg==}
+ engines: {node: '>=20.18.0'}
+ peerDependencies:
+ typescript: ^5.0.0
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
'@solana/codecs-strings@2.0.0-experimental.8618508':
resolution: {integrity: sha512-b2yhinr1+oe+JDmnnsV0641KQqqDG8AQ16Z/x7GVWO+AWHMpRlHWVXOq8U1yhPMA4VXxl7i+D+C6ql0VGFp0GA==}
peerDependencies:
@@ -1941,6 +2077,25 @@ packages:
fastestsmallesttextencoderdecoder: ^1.0.22
typescript: '>=5'
+ '@solana/codecs-strings@2.3.0':
+ resolution: {integrity: sha512-y5pSBYwzVziXu521hh+VxqUtp0hYGTl1eWGoc1W+8mdvBdC1kTqm/X7aYQw33J42hw03JjryvYOvmGgk3Qz/Ug==}
+ engines: {node: '>=20.18.0'}
+ peerDependencies:
+ fastestsmallesttextencoderdecoder: ^1.0.22
+ typescript: '>=5.3.3'
+
+ '@solana/codecs-strings@5.4.0':
+ resolution: {integrity: sha512-w0trrjfQDhkCVz7O1GTmHBk9m+MkljKx2uNBbQAD3/yW2Qn9dYiTrZ1/jDVq0/+lPPAUkbT3s3Yo7HUZ2QFmHw==}
+ engines: {node: '>=20.18.0'}
+ peerDependencies:
+ fastestsmallesttextencoderdecoder: ^1.0.22
+ typescript: ^5.0.0
+ peerDependenciesMeta:
+ fastestsmallesttextencoderdecoder:
+ optional: true
+ typescript:
+ optional: true
+
'@solana/codecs@2.0.0-preview.4':
resolution: {integrity: sha512-gLMupqI4i+G4uPi2SGF/Tc1aXcviZF2ybC81x7Q/fARamNSgNOCUUoSCg9nWu1Gid6+UhA7LH80sWI8XjKaRog==}
peerDependencies:
@@ -1951,6 +2106,12 @@ packages:
peerDependencies:
typescript: '>=5'
+ '@solana/codecs@2.3.0':
+ resolution: {integrity: sha512-JVqGPkzoeyU262hJGdH64kNLH0M+Oew2CIPOa/9tR3++q2pEd4jU2Rxdfye9sd0Ce3XJrR5AIa8ZfbyQXzjh+g==}
+ engines: {node: '>=20.18.0'}
+ peerDependencies:
+ typescript: '>=5.3.3'
+
'@solana/errors@2.0.0-preview.4':
resolution: {integrity: sha512-kadtlbRv2LCWr8A9V22On15Us7Nn8BvqNaOB4hXsTB3O0fU40D1ru2l+cReqLcRPij4znqlRzW9Xi0m6J5DIhA==}
hasBin: true
@@ -1970,6 +2131,52 @@ packages:
peerDependencies:
typescript: '>=5.3.3'
+ '@solana/errors@5.4.0':
+ resolution: {integrity: sha512-hNoAOmlZAszaVBrAy1Jf7amHJ8wnUnTU0BqhNQXknbSvirvsYr81yEud2iq18YiCqhyJ9SuQ5kWrSAT0x7S0oA==}
+ engines: {node: '>=20.18.0'}
+ hasBin: true
+ peerDependencies:
+ typescript: ^5.0.0
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
+ '@solana/fast-stable-stringify@2.3.0':
+ resolution: {integrity: sha512-KfJPrMEieUg6D3hfQACoPy0ukrAV8Kio883llt/8chPEG3FVTX9z/Zuf4O01a15xZmBbmQ7toil2Dp0sxMJSxw==}
+ engines: {node: '>=20.18.0'}
+ peerDependencies:
+ typescript: '>=5.3.3'
+
+ '@solana/functional@2.3.0':
+ resolution: {integrity: sha512-AgsPh3W3tE+nK3eEw/W9qiSfTGwLYEvl0rWaxHht/lRcuDVwfKRzeSa5G79eioWFFqr+pTtoCr3D3OLkwKz02Q==}
+ engines: {node: '>=20.18.0'}
+ peerDependencies:
+ typescript: '>=5.3.3'
+
+ '@solana/instructions@2.3.0':
+ resolution: {integrity: sha512-PLMsmaIKu7hEAzyElrk2T7JJx4D+9eRwebhFZpy2PXziNSmFF929eRHKUsKqBFM3cYR1Yy3m6roBZfA+bGE/oQ==}
+ engines: {node: '>=20.18.0'}
+ peerDependencies:
+ typescript: '>=5.3.3'
+
+ '@solana/keys@2.3.0':
+ resolution: {integrity: sha512-ZVVdga79pNH+2pVcm6fr2sWz9HTwfopDVhYb0Lh3dh+WBmJjwkabXEIHey2rUES7NjFa/G7sV8lrUn/v8LDCCQ==}
+ engines: {node: '>=20.18.0'}
+ peerDependencies:
+ typescript: '>=5.3.3'
+
+ '@solana/kit@2.3.0':
+ resolution: {integrity: sha512-sb6PgwoW2LjE5oTFu4lhlS/cGt/NB3YrShEyx7JgWFWysfgLdJnhwWThgwy/4HjNsmtMrQGWVls0yVBHcMvlMQ==}
+ engines: {node: '>=20.18.0'}
+ peerDependencies:
+ typescript: '>=5.3.3'
+
+ '@solana/nominal-types@2.3.0':
+ resolution: {integrity: sha512-uKlMnlP4PWW5UTXlhKM8lcgIaNj8dvd8xO4Y9l+FVvh9RvW2TO0GwUO6JCo7JBzCB0PSqRJdWWaQ8pu1Ti/OkA==}
+ engines: {node: '>=20.18.0'}
+ peerDependencies:
+ typescript: '>=5.3.3'
+
'@solana/options@2.0.0-experimental.8618508':
resolution: {integrity: sha512-fy/nIRAMC3QHvnKi63KEd86Xr/zFBVxNW4nEpVEU2OT0gCEKwHY4Z55YHf7XujhyuM3PNpiBKg/YYw5QlRU4vg==}
@@ -1983,6 +2190,103 @@ packages:
peerDependencies:
typescript: '>=5'
+ '@solana/options@2.3.0':
+ resolution: {integrity: sha512-PPnnZBRCWWoZQ11exPxf//DRzN2C6AoFsDI/u2AsQfYih434/7Kp4XLpfOMT/XESi+gdBMFNNfbES5zg3wAIkw==}
+ engines: {node: '>=20.18.0'}
+ peerDependencies:
+ typescript: '>=5.3.3'
+
+ '@solana/programs@2.3.0':
+ resolution: {integrity: sha512-UXKujV71VCI5uPs+cFdwxybtHZAIZyQkqDiDnmK+DawtOO9mBn4Nimdb/6RjR2CXT78mzO9ZCZ3qfyX+ydcB7w==}
+ engines: {node: '>=20.18.0'}
+ peerDependencies:
+ typescript: '>=5.3.3'
+
+ '@solana/promises@2.3.0':
+ resolution: {integrity: sha512-GjVgutZKXVuojd9rWy1PuLnfcRfqsaCm7InCiZc8bqmJpoghlyluweNc7ml9Y5yQn1P2IOyzh9+p/77vIyNybQ==}
+ engines: {node: '>=20.18.0'}
+ peerDependencies:
+ typescript: '>=5.3.3'
+
+ '@solana/rpc-api@2.3.0':
+ resolution: {integrity: sha512-UUdiRfWoyYhJL9PPvFeJr4aJ554ob2jXcpn4vKmRVn9ire0sCbpQKYx6K8eEKHZWXKrDW8IDspgTl0gT/aJWVg==}
+ engines: {node: '>=20.18.0'}
+ peerDependencies:
+ typescript: '>=5.3.3'
+
+ '@solana/rpc-parsed-types@2.3.0':
+ resolution: {integrity: sha512-B5pHzyEIbBJf9KHej+zdr5ZNAdSvu7WLU2lOUPh81KHdHQs6dEb310LGxcpCc7HVE8IEdO20AbckewDiAN6OCg==}
+ engines: {node: '>=20.18.0'}
+ peerDependencies:
+ typescript: '>=5.3.3'
+
+ '@solana/rpc-spec-types@2.3.0':
+ resolution: {integrity: sha512-xQsb65lahjr8Wc9dMtP7xa0ZmDS8dOE2ncYjlvfyw/h4mpdXTUdrSMi6RtFwX33/rGuztQ7Hwaid5xLNSLvsFQ==}
+ engines: {node: '>=20.18.0'}
+ peerDependencies:
+ typescript: '>=5.3.3'
+
+ '@solana/rpc-spec@2.3.0':
+ resolution: {integrity: sha512-fA2LMX4BMixCrNB2n6T83AvjZ3oUQTu7qyPLyt8gHQaoEAXs8k6GZmu6iYcr+FboQCjUmRPgMaABbcr9j2J9Sw==}
+ engines: {node: '>=20.18.0'}
+ peerDependencies:
+ typescript: '>=5.3.3'
+
+ '@solana/rpc-subscriptions-api@2.3.0':
+ resolution: {integrity: sha512-9mCjVbum2Hg9KGX3LKsrI5Xs0KX390lS+Z8qB80bxhar6MJPugqIPH8uRgLhCW9GN3JprAfjRNl7our8CPvsPQ==}
+ engines: {node: '>=20.18.0'}
+ peerDependencies:
+ typescript: '>=5.3.3'
+
+ '@solana/rpc-subscriptions-channel-websocket@2.3.0':
+ resolution: {integrity: sha512-2oL6ceFwejIgeWzbNiUHI2tZZnaOxNTSerszcin7wYQwijxtpVgUHiuItM/Y70DQmH9sKhmikQp+dqeGalaJxw==}
+ engines: {node: '>=20.18.0'}
+ peerDependencies:
+ typescript: '>=5.3.3'
+ ws: ^8.18.0
+
+ '@solana/rpc-subscriptions-spec@2.3.0':
+ resolution: {integrity: sha512-rdmVcl4PvNKQeA2l8DorIeALCgJEMSu7U8AXJS1PICeb2lQuMeaR+6cs/iowjvIB0lMVjYN2sFf6Q3dJPu6wWg==}
+ engines: {node: '>=20.18.0'}
+ peerDependencies:
+ typescript: '>=5.3.3'
+
+ '@solana/rpc-subscriptions@2.3.0':
+ resolution: {integrity: sha512-Uyr10nZKGVzvCOqwCZgwYrzuoDyUdwtgQRefh13pXIrdo4wYjVmoLykH49Omt6abwStB0a4UL5gX9V4mFdDJZg==}
+ engines: {node: '>=20.18.0'}
+ peerDependencies:
+ typescript: '>=5.3.3'
+
+ '@solana/rpc-transformers@2.3.0':
+ resolution: {integrity: sha512-UuHYK3XEpo9nMXdjyGKkPCOr7WsZsxs7zLYDO1A5ELH3P3JoehvrDegYRAGzBS2VKsfApZ86ZpJToP0K3PhmMA==}
+ engines: {node: '>=20.18.0'}
+ peerDependencies:
+ typescript: '>=5.3.3'
+
+ '@solana/rpc-transport-http@2.3.0':
+ resolution: {integrity: sha512-HFKydmxGw8nAF5N+S0NLnPBDCe5oMDtI2RAmW8DMqP4U3Zxt2XWhvV1SNkAldT5tF0U1vP+is6fHxyhk4xqEvg==}
+ engines: {node: '>=20.18.0'}
+ peerDependencies:
+ typescript: '>=5.3.3'
+
+ '@solana/rpc-types@2.3.0':
+ resolution: {integrity: sha512-O09YX2hED2QUyGxrMOxQ9GzH1LlEwwZWu69QbL4oYmIf6P5dzEEHcqRY6L1LsDVqc/dzAdEs/E1FaPrcIaIIPw==}
+ engines: {node: '>=20.18.0'}
+ peerDependencies:
+ typescript: '>=5.3.3'
+
+ '@solana/rpc@2.3.0':
+ resolution: {integrity: sha512-ZWN76iNQAOCpYC7yKfb3UNLIMZf603JckLKOOLTHuy9MZnTN8XV6uwvDFhf42XvhglgUjGCEnbUqWtxQ9pa/pQ==}
+ engines: {node: '>=20.18.0'}
+ peerDependencies:
+ typescript: '>=5.3.3'
+
+ '@solana/signers@2.3.0':
+ resolution: {integrity: sha512-OSv6fGr/MFRx6J+ZChQMRqKNPGGmdjkqarKkRzkwmv7v8quWsIRnJT5EV8tBy3LI4DLO/A8vKiNSPzvm1TdaiQ==}
+ engines: {node: '>=20.18.0'}
+ peerDependencies:
+ typescript: '>=5.3.3'
+
'@solana/spl-token-group@0.0.5':
resolution: {integrity: sha512-CLJnWEcdoUBpQJfx9WEbX3h6nTdNiUzswfFdkABUik7HVwSNA98u5AYvBVK2H93d9PGMOHAak2lHW9xr+zAJGQ==}
engines: {node: '>=16'}
@@ -2017,6 +2321,36 @@ packages:
resolution: {integrity: sha512-JBMGB0oR4lPttOZ5XiUGyvylwLQjt1CPJa6qQ5oM+MBCndfjz2TKKkw0eATlLLcYmq1jBVsNlJ2cD6ns2GR7lA==}
engines: {node: '>=16'}
+ '@solana/subscribable@2.3.0':
+ resolution: {integrity: sha512-DkgohEDbMkdTWiKAoatY02Njr56WXx9e/dKKfmne8/Ad6/2llUIrax78nCdlvZW9quXMaXPTxZvdQqo9N669Og==}
+ engines: {node: '>=20.18.0'}
+ peerDependencies:
+ typescript: '>=5.3.3'
+
+ '@solana/sysvars@2.3.0':
+ resolution: {integrity: sha512-LvjADZrpZ+CnhlHqfI5cmsRzX9Rpyb1Ox2dMHnbsRNzeKAMhu9w4ZBIaeTdO322zsTr509G1B+k2ABD3whvUBA==}
+ engines: {node: '>=20.18.0'}
+ peerDependencies:
+ typescript: '>=5.3.3'
+
+ '@solana/transaction-confirmation@2.3.0':
+ resolution: {integrity: sha512-UiEuiHCfAAZEKdfne/XljFNJbsKAe701UQHKXEInYzIgBjRbvaeYZlBmkkqtxwcasgBTOmEaEKT44J14N9VZDw==}
+ engines: {node: '>=20.18.0'}
+ peerDependencies:
+ typescript: '>=5.3.3'
+
+ '@solana/transaction-messages@2.3.0':
+ resolution: {integrity: sha512-bgqvWuy3MqKS5JdNLH649q+ngiyOu5rGS3DizSnWwYUd76RxZl1kN6CoqHSrrMzFMvis6sck/yPGG3wqrMlAww==}
+ engines: {node: '>=20.18.0'}
+ peerDependencies:
+ typescript: '>=5.3.3'
+
+ '@solana/transactions@2.3.0':
+ resolution: {integrity: sha512-LnTvdi8QnrQtuEZor5Msje61sDpPstTVwKg4y81tNxDhiyomjuvnSNLAq6QsB9gIxUqbNzPZgOG9IU4I4/Uaug==}
+ engines: {node: '>=20.18.0'}
+ peerDependencies:
+ typescript: '>=5.3.3'
+
'@solana/web3.js@1.98.4':
resolution: {integrity: sha512-vv9lfnvjUsRiq//+j5pBdXig0IQdtzA0BRZ3bXEP4KaIyF1CcaydWqgyzQgfZMNIsWNWmG+AUHwPy4AHOD6gpw==}
@@ -2227,6 +2561,9 @@ packages:
'@vitest/expect@2.1.1':
resolution: {integrity: sha512-YeueunS0HiHiQxk+KEOnq/QMzlUuOzbU1Go+PgAsHvvv3tUkJPm9xWt+6ITNTlzsMXUjmgm5T+U7KBPK2qQV6w==}
+ '@vitest/expect@2.1.9':
+ resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==}
+
'@vitest/mocker@2.1.1':
resolution: {integrity: sha512-LNN5VwOEdJqCmJ/2XJBywB11DLlkbY0ooDJW3uRX5cZyYCrc4PI/ePX0iQhE3BiEGiQmK4GE7Q/PqCkkaiPnrA==}
peerDependencies:
@@ -2239,21 +2576,47 @@ packages:
vite:
optional: true
+ '@vitest/mocker@2.1.9':
+ resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==}
+ peerDependencies:
+ msw: ^2.4.9
+ vite: ^5.0.0
+ peerDependenciesMeta:
+ msw:
+ optional: true
+ vite:
+ optional: true
+
'@vitest/pretty-format@2.1.1':
resolution: {integrity: sha512-SjxPFOtuINDUW8/UkElJYQSFtnWX7tMksSGW0vfjxMneFqxVr8YJ979QpMbDW7g+BIiq88RAGDjf7en6rvLPPQ==}
+ '@vitest/pretty-format@2.1.9':
+ resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==}
+
'@vitest/runner@2.1.1':
resolution: {integrity: sha512-uTPuY6PWOYitIkLPidaY5L3t0JJITdGTSwBtwMjKzo5O6RCOEncz9PUN+0pDidX8kTHYjO0EwUIvhlGpnGpxmA==}
+ '@vitest/runner@2.1.9':
+ resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==}
+
'@vitest/snapshot@2.1.1':
resolution: {integrity: sha512-BnSku1WFy7r4mm96ha2FzN99AZJgpZOWrAhtQfoxjUU5YMRpq1zmHRq7a5K9/NjqonebO7iVDla+VvZS8BOWMw==}
+ '@vitest/snapshot@2.1.9':
+ resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==}
+
'@vitest/spy@2.1.1':
resolution: {integrity: sha512-ZM39BnZ9t/xZ/nF4UwRH5il0Sw93QnZXd9NAZGRpIgj0yvVwPpLd702s/Cx955rGaMlyBQkZJ2Ir7qyY48VZ+g==}
+ '@vitest/spy@2.1.9':
+ resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==}
+
'@vitest/utils@2.1.1':
resolution: {integrity: sha512-Y6Q9TsI+qJ2CC0ZKj6VBb+T8UPz593N113nnUykqwANqhgf3QkZeHFlusgKLTqrnVHbj/XDKZcDHol+dxVT+rQ==}
+ '@vitest/utils@2.1.9':
+ resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==}
+
JSONStream@1.3.5:
resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==}
hasBin: true
@@ -2672,6 +3035,10 @@ packages:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
+ codama@1.5.0:
+ resolution: {integrity: sha512-hhfSzrOiDX3bV7QmJneEBsBk3ln4gIcMJs6P8BlEJ3EFI+P0QZaTT5W61o8Tq0/79hTZeyj0gP65HZ/LYJil+w==}
+ hasBin: true
+
code-excerpt@4.0.0:
resolution: {integrity: sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
@@ -2704,8 +3071,8 @@ packages:
resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==}
engines: {node: '>=18'}
- commander@14.0.1:
- resolution: {integrity: sha512-2JkV3gUZUVrbNA+1sjBOYLsMZ5cEEl8GTFP2a4AVz5hvasAMCQ1D2l2le/cX+pV4N6ZU17zjUahLpIXRrnWL8A==}
+ commander@14.0.2:
+ resolution: {integrity: sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==}
engines: {node: '>=20'}
commander@2.20.3:
@@ -3014,6 +3381,9 @@ packages:
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
engines: {node: '>= 0.4'}
+ es-module-lexer@1.7.0:
+ resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
+
es-object-atoms@1.0.0:
resolution: {integrity: sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==}
engines: {node: '>= 0.4'}
@@ -3214,6 +3584,10 @@ packages:
evp_bytestokey@1.0.3:
resolution: {integrity: sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==}
+ expect-type@1.3.0:
+ resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
+ engines: {node: '>=12.0.0'}
+
eyes@0.1.8:
resolution: {integrity: sha512-GipyPsXO1anza0AOZdy69Im7hGFCNB7Y/NGjDlZGJ3GJJLtwNSb2vrzYrTYJRrRloVx7pl+bhUaTB8yiccPvFQ==}
engines: {node: '> 0.1.90'}
@@ -4037,6 +4411,10 @@ packages:
json-stable-stringify-without-jsonify@1.0.1:
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
+ json-stable-stringify@1.3.0:
+ resolution: {integrity: sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==}
+ engines: {node: '>= 0.4'}
+
json-stringify-safe@5.0.1:
resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==}
@@ -4055,6 +4433,9 @@ packages:
jsonfile@4.0.0:
resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==}
+ jsonify@0.0.1:
+ resolution: {integrity: sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==}
+
jsonparse@1.3.1:
resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==}
engines: {'0': node >= 0.2.0}
@@ -4128,6 +4509,9 @@ packages:
magic-string@0.30.11:
resolution: {integrity: sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==}
+ magic-string@0.30.21:
+ resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
+
magic-string@0.30.5:
resolution: {integrity: sha512-7xlpfBaQaP/T6Vh8MO/EqXSW5En6INHEvEXQiuff7Gku0PWjU3uf6w/j9o7O+SpB5fOAkrI5HeoNgwjEO0pFsA==}
engines: {node: '>=12'}
@@ -5035,6 +5419,9 @@ packages:
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
+ std-env@3.10.0:
+ resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
+
std-env@3.7.0:
resolution: {integrity: sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==}
@@ -5175,6 +5562,9 @@ packages:
tinyexec@0.3.0:
resolution: {integrity: sha512-tVGE0mVJPGb0chKhqmsoosjsS+qUnJVGJpZgsHYQcGoPlG3B51R3PouqTgEGH2Dc9jjFyOqOpix6ZHNMXp1FZg==}
+ tinyexec@0.3.2:
+ resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
+
tinyglobby@0.2.15:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'}
@@ -5360,6 +5750,9 @@ packages:
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
+ undici-types@7.19.0:
+ resolution: {integrity: sha512-Rjk2OWDZf2eiXVQjY2HyE3XPjqW/wXnSZq0QkOsPKZEnaetNNBObTp91LYfGdB8hRbRZk4HFcM/cONw452B0AQ==}
+
unicorn-magic@0.3.0:
resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==}
engines: {node: '>=18'}
@@ -5424,6 +5817,11 @@ packages:
engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true
+ vite-node@2.1.9:
+ resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==}
+ engines: {node: ^18.0.0 || >=20.0.0}
+ hasBin: true
+
vite@5.0.4:
resolution: {integrity: sha512-RzAr8LSvM8lmhB4tQ5OPcBhpjOZRZjuxv9zO5UcxeoY2bd3kP3Ticd40Qma9/BqZ8JS96Ll/jeBX9u+LJZrhVg==}
engines: {node: ^18.0.0 || >=20.0.0}
@@ -5477,6 +5875,31 @@ packages:
jsdom:
optional: true
+ vitest@2.1.9:
+ resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==}
+ engines: {node: ^18.0.0 || >=20.0.0}
+ hasBin: true
+ peerDependencies:
+ '@edge-runtime/vm': '*'
+ '@types/node': ^18.0.0 || >=20.0.0
+ '@vitest/browser': 2.1.9
+ '@vitest/ui': 2.1.9
+ happy-dom: '*'
+ jsdom: '*'
+ peerDependenciesMeta:
+ '@edge-runtime/vm':
+ optional: true
+ '@types/node':
+ optional: true
+ '@vitest/browser':
+ optional: true
+ '@vitest/ui':
+ optional: true
+ happy-dom:
+ optional: true
+ jsdom:
+ optional: true
+
wait-on@7.2.0:
resolution: {integrity: sha512-wCQcHkRazgjG5XoAq9jbTMLpNIjoSlZslrJ2+N9MxDsGEv1HnFoVjOCexL0ESva7Y9cu350j+DWADdk54s4AFQ==}
engines: {node: '>=12.0.0'}
@@ -6331,8 +6754,67 @@ snapshots:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
- '@coral-xyz/anchor-errors@0.31.1': {}
-
+ '@codama/cli@1.4.4':
+ dependencies:
+ '@codama/nodes': 1.5.0
+ '@codama/visitors': 1.5.0
+ '@codama/visitors-core': 1.5.0
+ commander: 14.0.2
+ picocolors: 1.1.1
+ prompts: 2.4.2
+
+ '@codama/errors@1.5.0':
+ dependencies:
+ '@codama/node-types': 1.5.0
+ commander: 14.0.2
+ picocolors: 1.1.1
+
+ '@codama/node-types@1.5.0': {}
+
+ '@codama/nodes@1.5.0':
+ dependencies:
+ '@codama/errors': 1.5.0
+ '@codama/node-types': 1.5.0
+
+ '@codama/renderers-core@1.3.5':
+ dependencies:
+ '@codama/errors': 1.5.0
+ '@codama/nodes': 1.5.0
+ '@codama/visitors-core': 1.5.0
+
+ '@codama/renderers-js@1.5.5(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)':
+ dependencies:
+ '@codama/errors': 1.5.0
+ '@codama/nodes': 1.5.0
+ '@codama/renderers-core': 1.3.5
+ '@codama/visitors-core': 1.5.0
+ '@solana/codecs-strings': 5.4.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ prettier: 3.6.2
+ semver: 7.7.3
+ transitivePeerDependencies:
+ - fastestsmallesttextencoderdecoder
+ - typescript
+
+ '@codama/validators@1.5.0':
+ dependencies:
+ '@codama/errors': 1.5.0
+ '@codama/nodes': 1.5.0
+ '@codama/visitors-core': 1.5.0
+
+ '@codama/visitors-core@1.5.0':
+ dependencies:
+ '@codama/errors': 1.5.0
+ '@codama/nodes': 1.5.0
+ json-stable-stringify: 1.3.0
+
+ '@codama/visitors@1.5.0':
+ dependencies:
+ '@codama/errors': 1.5.0
+ '@codama/nodes': 1.5.0
+ '@codama/visitors-core': 1.5.0
+
+ '@coral-xyz/anchor-errors@0.31.1': {}
+
'@coral-xyz/anchor@0.29.0(bufferutil@4.0.8)(typescript@5.9.2)(utf-8-validate@5.0.10)':
dependencies:
'@coral-xyz/borsh': 0.29.0(@solana/web3.js@1.98.4(bufferutil@4.0.8)(typescript@5.9.2)(utf-8-validate@5.0.10))
@@ -7488,6 +7970,34 @@ snapshots:
'@smithy/types': 4.5.0
tslib: 2.8.1
+ '@solana/accounts@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)':
+ dependencies:
+ '@solana/addresses': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ '@solana/codecs-core': 2.3.0(typescript@5.9.3)
+ '@solana/codecs-strings': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ '@solana/errors': 2.3.0(typescript@5.9.3)
+ '@solana/rpc-spec': 2.3.0(typescript@5.9.3)
+ '@solana/rpc-types': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - fastestsmallesttextencoderdecoder
+
+ '@solana/addresses@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)':
+ dependencies:
+ '@solana/assertions': 2.3.0(typescript@5.9.3)
+ '@solana/codecs-core': 2.3.0(typescript@5.9.3)
+ '@solana/codecs-strings': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ '@solana/errors': 2.3.0(typescript@5.9.3)
+ '@solana/nominal-types': 2.3.0(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - fastestsmallesttextencoderdecoder
+
+ '@solana/assertions@2.3.0(typescript@5.9.3)':
+ dependencies:
+ '@solana/errors': 2.3.0(typescript@5.9.3)
+ typescript: 5.9.3
+
'@solana/buffer-layout-utils@0.2.0(bufferutil@4.0.8)(typescript@5.9.2)(utf-8-validate@5.0.10)':
dependencies:
'@solana/buffer-layout': 4.0.1
@@ -7526,6 +8036,17 @@ snapshots:
'@solana/errors': 2.3.0(typescript@5.9.2)
typescript: 5.9.2
+ '@solana/codecs-core@2.3.0(typescript@5.9.3)':
+ dependencies:
+ '@solana/errors': 2.3.0(typescript@5.9.3)
+ typescript: 5.9.3
+
+ '@solana/codecs-core@5.4.0(typescript@5.9.3)':
+ dependencies:
+ '@solana/errors': 5.4.0(typescript@5.9.3)
+ optionalDependencies:
+ typescript: 5.9.3
+
'@solana/codecs-data-structures@2.0.0-experimental.8618508':
dependencies:
'@solana/codecs-core': 2.0.0-experimental.8618508
@@ -7545,6 +8066,13 @@ snapshots:
'@solana/errors': 2.0.0-rc.1(typescript@5.9.2)
typescript: 5.9.2
+ '@solana/codecs-data-structures@2.3.0(typescript@5.9.3)':
+ dependencies:
+ '@solana/codecs-core': 2.3.0(typescript@5.9.3)
+ '@solana/codecs-numbers': 2.3.0(typescript@5.9.3)
+ '@solana/errors': 2.3.0(typescript@5.9.3)
+ typescript: 5.9.3
+
'@solana/codecs-numbers@2.0.0-experimental.8618508':
dependencies:
'@solana/codecs-core': 2.0.0-experimental.8618508
@@ -7573,6 +8101,19 @@ snapshots:
'@solana/errors': 2.3.0(typescript@5.9.2)
typescript: 5.9.2
+ '@solana/codecs-numbers@2.3.0(typescript@5.9.3)':
+ dependencies:
+ '@solana/codecs-core': 2.3.0(typescript@5.9.3)
+ '@solana/errors': 2.3.0(typescript@5.9.3)
+ typescript: 5.9.3
+
+ '@solana/codecs-numbers@5.4.0(typescript@5.9.3)':
+ dependencies:
+ '@solana/codecs-core': 5.4.0(typescript@5.9.3)
+ '@solana/errors': 5.4.0(typescript@5.9.3)
+ optionalDependencies:
+ typescript: 5.9.3
+
'@solana/codecs-strings@2.0.0-experimental.8618508(fastestsmallesttextencoderdecoder@1.0.22)':
dependencies:
'@solana/codecs-core': 2.0.0-experimental.8618508
@@ -7595,6 +8136,23 @@ snapshots:
fastestsmallesttextencoderdecoder: 1.0.22
typescript: 5.9.2
+ '@solana/codecs-strings@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)':
+ dependencies:
+ '@solana/codecs-core': 2.3.0(typescript@5.9.3)
+ '@solana/codecs-numbers': 2.3.0(typescript@5.9.3)
+ '@solana/errors': 2.3.0(typescript@5.9.3)
+ fastestsmallesttextencoderdecoder: 1.0.22
+ typescript: 5.9.3
+
+ '@solana/codecs-strings@5.4.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)':
+ dependencies:
+ '@solana/codecs-core': 5.4.0(typescript@5.9.3)
+ '@solana/codecs-numbers': 5.4.0(typescript@5.9.3)
+ '@solana/errors': 5.4.0(typescript@5.9.3)
+ optionalDependencies:
+ fastestsmallesttextencoderdecoder: 1.0.22
+ typescript: 5.9.3
+
'@solana/codecs@2.0.0-preview.4(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)':
dependencies:
'@solana/codecs-core': 2.0.0-preview.4(typescript@5.9.2)
@@ -7617,6 +8175,17 @@ snapshots:
transitivePeerDependencies:
- fastestsmallesttextencoderdecoder
+ '@solana/codecs@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)':
+ dependencies:
+ '@solana/codecs-core': 2.3.0(typescript@5.9.3)
+ '@solana/codecs-data-structures': 2.3.0(typescript@5.9.3)
+ '@solana/codecs-numbers': 2.3.0(typescript@5.9.3)
+ '@solana/codecs-strings': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ '@solana/options': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - fastestsmallesttextencoderdecoder
+
'@solana/errors@2.0.0-preview.4(typescript@5.9.2)':
dependencies:
chalk: 5.6.2
@@ -7632,15 +8201,82 @@ snapshots:
'@solana/errors@2.3.0(typescript@4.9.5)':
dependencies:
chalk: 5.6.2
- commander: 14.0.1
+ commander: 14.0.2
typescript: 4.9.5
'@solana/errors@2.3.0(typescript@5.9.2)':
dependencies:
chalk: 5.6.2
- commander: 14.0.1
+ commander: 14.0.2
typescript: 5.9.2
+ '@solana/errors@2.3.0(typescript@5.9.3)':
+ dependencies:
+ chalk: 5.6.2
+ commander: 14.0.2
+ typescript: 5.9.3
+
+ '@solana/errors@5.4.0(typescript@5.9.3)':
+ dependencies:
+ chalk: 5.6.2
+ commander: 14.0.2
+ optionalDependencies:
+ typescript: 5.9.3
+
+ '@solana/fast-stable-stringify@2.3.0(typescript@5.9.3)':
+ dependencies:
+ typescript: 5.9.3
+
+ '@solana/functional@2.3.0(typescript@5.9.3)':
+ dependencies:
+ typescript: 5.9.3
+
+ '@solana/instructions@2.3.0(typescript@5.9.3)':
+ dependencies:
+ '@solana/codecs-core': 2.3.0(typescript@5.9.3)
+ '@solana/errors': 2.3.0(typescript@5.9.3)
+ typescript: 5.9.3
+
+ '@solana/keys@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)':
+ dependencies:
+ '@solana/assertions': 2.3.0(typescript@5.9.3)
+ '@solana/codecs-core': 2.3.0(typescript@5.9.3)
+ '@solana/codecs-strings': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ '@solana/errors': 2.3.0(typescript@5.9.3)
+ '@solana/nominal-types': 2.3.0(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - fastestsmallesttextencoderdecoder
+
+ '@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.8)(utf-8-validate@5.0.10))':
+ dependencies:
+ '@solana/accounts': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ '@solana/addresses': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ '@solana/codecs': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ '@solana/errors': 2.3.0(typescript@5.9.3)
+ '@solana/functional': 2.3.0(typescript@5.9.3)
+ '@solana/instructions': 2.3.0(typescript@5.9.3)
+ '@solana/keys': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ '@solana/programs': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ '@solana/rpc': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ '@solana/rpc-parsed-types': 2.3.0(typescript@5.9.3)
+ '@solana/rpc-spec-types': 2.3.0(typescript@5.9.3)
+ '@solana/rpc-subscriptions': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.8)(utf-8-validate@5.0.10))
+ '@solana/rpc-types': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ '@solana/signers': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ '@solana/sysvars': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ '@solana/transaction-confirmation': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.8)(utf-8-validate@5.0.10))
+ '@solana/transaction-messages': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ '@solana/transactions': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - fastestsmallesttextencoderdecoder
+ - ws
+
+ '@solana/nominal-types@2.3.0(typescript@5.9.3)':
+ dependencies:
+ typescript: 5.9.3
+
'@solana/options@2.0.0-experimental.8618508':
dependencies:
'@solana/codecs-core': 2.0.0-experimental.8618508
@@ -7668,6 +8304,168 @@ snapshots:
transitivePeerDependencies:
- fastestsmallesttextencoderdecoder
+ '@solana/options@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)':
+ dependencies:
+ '@solana/codecs-core': 2.3.0(typescript@5.9.3)
+ '@solana/codecs-data-structures': 2.3.0(typescript@5.9.3)
+ '@solana/codecs-numbers': 2.3.0(typescript@5.9.3)
+ '@solana/codecs-strings': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ '@solana/errors': 2.3.0(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - fastestsmallesttextencoderdecoder
+
+ '@solana/programs@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)':
+ dependencies:
+ '@solana/addresses': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ '@solana/errors': 2.3.0(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - fastestsmallesttextencoderdecoder
+
+ '@solana/promises@2.3.0(typescript@5.9.3)':
+ dependencies:
+ typescript: 5.9.3
+
+ '@solana/rpc-api@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)':
+ dependencies:
+ '@solana/addresses': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ '@solana/codecs-core': 2.3.0(typescript@5.9.3)
+ '@solana/codecs-strings': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ '@solana/errors': 2.3.0(typescript@5.9.3)
+ '@solana/keys': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ '@solana/rpc-parsed-types': 2.3.0(typescript@5.9.3)
+ '@solana/rpc-spec': 2.3.0(typescript@5.9.3)
+ '@solana/rpc-transformers': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ '@solana/rpc-types': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ '@solana/transaction-messages': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ '@solana/transactions': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - fastestsmallesttextencoderdecoder
+
+ '@solana/rpc-parsed-types@2.3.0(typescript@5.9.3)':
+ dependencies:
+ typescript: 5.9.3
+
+ '@solana/rpc-spec-types@2.3.0(typescript@5.9.3)':
+ dependencies:
+ typescript: 5.9.3
+
+ '@solana/rpc-spec@2.3.0(typescript@5.9.3)':
+ dependencies:
+ '@solana/errors': 2.3.0(typescript@5.9.3)
+ '@solana/rpc-spec-types': 2.3.0(typescript@5.9.3)
+ typescript: 5.9.3
+
+ '@solana/rpc-subscriptions-api@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)':
+ dependencies:
+ '@solana/addresses': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ '@solana/keys': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ '@solana/rpc-subscriptions-spec': 2.3.0(typescript@5.9.3)
+ '@solana/rpc-transformers': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ '@solana/rpc-types': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ '@solana/transaction-messages': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ '@solana/transactions': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - fastestsmallesttextencoderdecoder
+
+ '@solana/rpc-subscriptions-channel-websocket@2.3.0(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.8)(utf-8-validate@5.0.10))':
+ dependencies:
+ '@solana/errors': 2.3.0(typescript@5.9.3)
+ '@solana/functional': 2.3.0(typescript@5.9.3)
+ '@solana/rpc-subscriptions-spec': 2.3.0(typescript@5.9.3)
+ '@solana/subscribable': 2.3.0(typescript@5.9.3)
+ typescript: 5.9.3
+ ws: 8.18.3(bufferutil@4.0.8)(utf-8-validate@5.0.10)
+
+ '@solana/rpc-subscriptions-spec@2.3.0(typescript@5.9.3)':
+ dependencies:
+ '@solana/errors': 2.3.0(typescript@5.9.3)
+ '@solana/promises': 2.3.0(typescript@5.9.3)
+ '@solana/rpc-spec-types': 2.3.0(typescript@5.9.3)
+ '@solana/subscribable': 2.3.0(typescript@5.9.3)
+ typescript: 5.9.3
+
+ '@solana/rpc-subscriptions@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.8)(utf-8-validate@5.0.10))':
+ dependencies:
+ '@solana/errors': 2.3.0(typescript@5.9.3)
+ '@solana/fast-stable-stringify': 2.3.0(typescript@5.9.3)
+ '@solana/functional': 2.3.0(typescript@5.9.3)
+ '@solana/promises': 2.3.0(typescript@5.9.3)
+ '@solana/rpc-spec-types': 2.3.0(typescript@5.9.3)
+ '@solana/rpc-subscriptions-api': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ '@solana/rpc-subscriptions-channel-websocket': 2.3.0(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.8)(utf-8-validate@5.0.10))
+ '@solana/rpc-subscriptions-spec': 2.3.0(typescript@5.9.3)
+ '@solana/rpc-transformers': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ '@solana/rpc-types': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ '@solana/subscribable': 2.3.0(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - fastestsmallesttextencoderdecoder
+ - ws
+
+ '@solana/rpc-transformers@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)':
+ dependencies:
+ '@solana/errors': 2.3.0(typescript@5.9.3)
+ '@solana/functional': 2.3.0(typescript@5.9.3)
+ '@solana/nominal-types': 2.3.0(typescript@5.9.3)
+ '@solana/rpc-spec-types': 2.3.0(typescript@5.9.3)
+ '@solana/rpc-types': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - fastestsmallesttextencoderdecoder
+
+ '@solana/rpc-transport-http@2.3.0(typescript@5.9.3)':
+ dependencies:
+ '@solana/errors': 2.3.0(typescript@5.9.3)
+ '@solana/rpc-spec': 2.3.0(typescript@5.9.3)
+ '@solana/rpc-spec-types': 2.3.0(typescript@5.9.3)
+ typescript: 5.9.3
+ undici-types: 7.19.0
+
+ '@solana/rpc-types@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)':
+ dependencies:
+ '@solana/addresses': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ '@solana/codecs-core': 2.3.0(typescript@5.9.3)
+ '@solana/codecs-numbers': 2.3.0(typescript@5.9.3)
+ '@solana/codecs-strings': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ '@solana/errors': 2.3.0(typescript@5.9.3)
+ '@solana/nominal-types': 2.3.0(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - fastestsmallesttextencoderdecoder
+
+ '@solana/rpc@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)':
+ dependencies:
+ '@solana/errors': 2.3.0(typescript@5.9.3)
+ '@solana/fast-stable-stringify': 2.3.0(typescript@5.9.3)
+ '@solana/functional': 2.3.0(typescript@5.9.3)
+ '@solana/rpc-api': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ '@solana/rpc-spec': 2.3.0(typescript@5.9.3)
+ '@solana/rpc-spec-types': 2.3.0(typescript@5.9.3)
+ '@solana/rpc-transformers': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ '@solana/rpc-transport-http': 2.3.0(typescript@5.9.3)
+ '@solana/rpc-types': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - fastestsmallesttextencoderdecoder
+
+ '@solana/signers@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)':
+ dependencies:
+ '@solana/addresses': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ '@solana/codecs-core': 2.3.0(typescript@5.9.3)
+ '@solana/errors': 2.3.0(typescript@5.9.3)
+ '@solana/instructions': 2.3.0(typescript@5.9.3)
+ '@solana/keys': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ '@solana/nominal-types': 2.3.0(typescript@5.9.3)
+ '@solana/transaction-messages': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ '@solana/transactions': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - fastestsmallesttextencoderdecoder
+
'@solana/spl-token-group@0.0.5(@solana/web3.js@1.98.4(bufferutil@4.0.8)(typescript@5.9.2)(utf-8-validate@5.0.10))(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)':
dependencies:
'@solana/codecs': 2.0.0-preview.4(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)
@@ -7731,6 +8529,71 @@ snapshots:
dependencies:
buffer: 6.0.3
+ '@solana/subscribable@2.3.0(typescript@5.9.3)':
+ dependencies:
+ '@solana/errors': 2.3.0(typescript@5.9.3)
+ typescript: 5.9.3
+
+ '@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)':
+ dependencies:
+ '@solana/accounts': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ '@solana/codecs': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ '@solana/errors': 2.3.0(typescript@5.9.3)
+ '@solana/rpc-types': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - fastestsmallesttextencoderdecoder
+
+ '@solana/transaction-confirmation@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.8)(utf-8-validate@5.0.10))':
+ dependencies:
+ '@solana/addresses': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ '@solana/codecs-strings': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ '@solana/errors': 2.3.0(typescript@5.9.3)
+ '@solana/keys': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ '@solana/promises': 2.3.0(typescript@5.9.3)
+ '@solana/rpc': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ '@solana/rpc-subscriptions': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.8)(utf-8-validate@5.0.10))
+ '@solana/rpc-types': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ '@solana/transaction-messages': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ '@solana/transactions': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - fastestsmallesttextencoderdecoder
+ - ws
+
+ '@solana/transaction-messages@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)':
+ dependencies:
+ '@solana/addresses': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ '@solana/codecs-core': 2.3.0(typescript@5.9.3)
+ '@solana/codecs-data-structures': 2.3.0(typescript@5.9.3)
+ '@solana/codecs-numbers': 2.3.0(typescript@5.9.3)
+ '@solana/errors': 2.3.0(typescript@5.9.3)
+ '@solana/functional': 2.3.0(typescript@5.9.3)
+ '@solana/instructions': 2.3.0(typescript@5.9.3)
+ '@solana/nominal-types': 2.3.0(typescript@5.9.3)
+ '@solana/rpc-types': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - fastestsmallesttextencoderdecoder
+
+ '@solana/transactions@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)':
+ dependencies:
+ '@solana/addresses': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ '@solana/codecs-core': 2.3.0(typescript@5.9.3)
+ '@solana/codecs-data-structures': 2.3.0(typescript@5.9.3)
+ '@solana/codecs-numbers': 2.3.0(typescript@5.9.3)
+ '@solana/codecs-strings': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ '@solana/errors': 2.3.0(typescript@5.9.3)
+ '@solana/functional': 2.3.0(typescript@5.9.3)
+ '@solana/instructions': 2.3.0(typescript@5.9.3)
+ '@solana/keys': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ '@solana/nominal-types': 2.3.0(typescript@5.9.3)
+ '@solana/rpc-types': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ '@solana/transaction-messages': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - fastestsmallesttextencoderdecoder
+
'@solana/web3.js@1.98.4(bufferutil@4.0.8)(typescript@5.9.2)(utf-8-validate@5.0.10)':
dependencies:
'@babel/runtime': 7.25.6
@@ -7907,6 +8770,23 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@typescript-eslint/eslint-plugin@8.44.0(@typescript-eslint/parser@8.44.0(eslint@9.36.0)(typescript@5.9.3))(eslint@9.36.0)(typescript@5.9.3)':
+ dependencies:
+ '@eslint-community/regexpp': 4.12.1
+ '@typescript-eslint/parser': 8.44.0(eslint@9.36.0)(typescript@5.9.3)
+ '@typescript-eslint/scope-manager': 8.44.0
+ '@typescript-eslint/type-utils': 8.44.0(eslint@9.36.0)(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.44.0(eslint@9.36.0)(typescript@5.9.3)
+ '@typescript-eslint/visitor-keys': 8.44.0
+ eslint: 9.36.0
+ graphemer: 1.4.0
+ ignore: 7.0.5
+ natural-compare: 1.4.0
+ ts-api-utils: 2.1.0(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
'@typescript-eslint/parser@8.44.0(eslint@9.36.0)(typescript@5.9.2)':
dependencies:
'@typescript-eslint/scope-manager': 8.44.0
@@ -7919,6 +8799,18 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@typescript-eslint/parser@8.44.0(eslint@9.36.0)(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/scope-manager': 8.44.0
+ '@typescript-eslint/types': 8.44.0
+ '@typescript-eslint/typescript-estree': 8.44.0(typescript@5.9.3)
+ '@typescript-eslint/visitor-keys': 8.44.0
+ debug: 4.4.3(supports-color@8.1.1)
+ eslint: 9.36.0
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
'@typescript-eslint/project-service@8.44.0(typescript@5.9.2)':
dependencies:
'@typescript-eslint/tsconfig-utils': 8.44.0(typescript@5.9.2)
@@ -7928,6 +8820,15 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@typescript-eslint/project-service@8.44.0(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/tsconfig-utils': 8.44.0(typescript@5.9.3)
+ '@typescript-eslint/types': 8.44.0
+ debug: 4.4.3(supports-color@8.1.1)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
'@typescript-eslint/scope-manager@7.13.1':
dependencies:
'@typescript-eslint/types': 7.13.1
@@ -7942,6 +8843,10 @@ snapshots:
dependencies:
typescript: 5.9.2
+ '@typescript-eslint/tsconfig-utils@8.44.0(typescript@5.9.3)':
+ dependencies:
+ typescript: 5.9.3
+
'@typescript-eslint/type-utils@8.44.0(eslint@9.36.0)(typescript@5.9.2)':
dependencies:
'@typescript-eslint/types': 8.44.0
@@ -7954,6 +8859,18 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@typescript-eslint/type-utils@8.44.0(eslint@9.36.0)(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/types': 8.44.0
+ '@typescript-eslint/typescript-estree': 8.44.0(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.44.0(eslint@9.36.0)(typescript@5.9.3)
+ debug: 4.4.3(supports-color@8.1.1)
+ eslint: 9.36.0
+ ts-api-utils: 2.1.0(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
'@typescript-eslint/types@7.13.1': {}
'@typescript-eslint/types@8.44.0': {}
@@ -7989,6 +8906,22 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@typescript-eslint/typescript-estree@8.44.0(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/project-service': 8.44.0(typescript@5.9.3)
+ '@typescript-eslint/tsconfig-utils': 8.44.0(typescript@5.9.3)
+ '@typescript-eslint/types': 8.44.0
+ '@typescript-eslint/visitor-keys': 8.44.0
+ debug: 4.4.3(supports-color@8.1.1)
+ fast-glob: 3.3.3
+ is-glob: 4.0.3
+ minimatch: 9.0.5
+ semver: 7.7.2
+ ts-api-utils: 2.1.0(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
'@typescript-eslint/utils@7.13.1(eslint@9.36.0)(typescript@5.9.2)':
dependencies:
'@eslint-community/eslint-utils': 4.7.0(eslint@9.36.0)
@@ -8011,6 +8944,17 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@typescript-eslint/utils@8.44.0(eslint@9.36.0)(typescript@5.9.3)':
+ dependencies:
+ '@eslint-community/eslint-utils': 4.9.0(eslint@9.36.0)
+ '@typescript-eslint/scope-manager': 8.44.0
+ '@typescript-eslint/types': 8.44.0
+ '@typescript-eslint/typescript-estree': 8.44.0(typescript@5.9.3)
+ eslint: 9.36.0
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
'@typescript-eslint/visitor-keys@7.13.1':
dependencies:
'@typescript-eslint/types': 7.13.1
@@ -8028,6 +8972,13 @@ snapshots:
chai: 5.3.3
tinyrainbow: 1.2.0
+ '@vitest/expect@2.1.9':
+ dependencies:
+ '@vitest/spy': 2.1.9
+ '@vitest/utils': 2.1.9
+ chai: 5.3.3
+ tinyrainbow: 1.2.0
+
'@vitest/mocker@2.1.1(@vitest/spy@2.1.1)(vite@5.0.4(@types/node@22.16.5)(terser@5.43.1))':
dependencies:
'@vitest/spy': 2.1.1
@@ -8036,31 +8987,64 @@ snapshots:
optionalDependencies:
vite: 5.0.4(@types/node@22.16.5)(terser@5.43.1)
+ '@vitest/mocker@2.1.9(vite@5.0.4(@types/node@22.16.5)(terser@5.43.1))':
+ dependencies:
+ '@vitest/spy': 2.1.9
+ estree-walker: 3.0.3
+ magic-string: 0.30.21
+ optionalDependencies:
+ vite: 5.0.4(@types/node@22.16.5)(terser@5.43.1)
+
'@vitest/pretty-format@2.1.1':
dependencies:
tinyrainbow: 1.2.0
+ '@vitest/pretty-format@2.1.9':
+ dependencies:
+ tinyrainbow: 1.2.0
+
'@vitest/runner@2.1.1':
dependencies:
'@vitest/utils': 2.1.1
pathe: 1.1.2
+ '@vitest/runner@2.1.9':
+ dependencies:
+ '@vitest/utils': 2.1.9
+ pathe: 1.1.2
+
'@vitest/snapshot@2.1.1':
dependencies:
'@vitest/pretty-format': 2.1.1
magic-string: 0.30.11
pathe: 1.1.2
+ '@vitest/snapshot@2.1.9':
+ dependencies:
+ '@vitest/pretty-format': 2.1.9
+ magic-string: 0.30.21
+ pathe: 1.1.2
+
'@vitest/spy@2.1.1':
dependencies:
tinyspy: 3.0.2
+ '@vitest/spy@2.1.9':
+ dependencies:
+ tinyspy: 3.0.2
+
'@vitest/utils@2.1.1':
dependencies:
'@vitest/pretty-format': 2.1.1
loupe: 3.2.0
tinyrainbow: 1.2.0
+ '@vitest/utils@2.1.9':
+ dependencies:
+ '@vitest/pretty-format': 2.1.9
+ loupe: 3.2.0
+ tinyrainbow: 1.2.0
+
JSONStream@1.3.5:
dependencies:
jsonparse: 1.3.1
@@ -8538,6 +9522,14 @@ snapshots:
strip-ansi: 6.0.1
wrap-ansi: 7.0.0
+ codama@1.5.0:
+ dependencies:
+ '@codama/cli': 1.4.4
+ '@codama/errors': 1.5.0
+ '@codama/nodes': 1.5.0
+ '@codama/validators': 1.5.0
+ '@codama/visitors': 1.5.0
+
code-excerpt@4.0.0:
dependencies:
convert-to-spaces: 2.0.1
@@ -8566,7 +9558,7 @@ snapshots:
commander@13.1.0: {}
- commander@14.0.1: {}
+ commander@14.0.2: {}
commander@2.20.3: {}
@@ -9008,6 +10000,8 @@ snapshots:
es-errors@1.3.0: {}
+ es-module-lexer@1.7.0: {}
+
es-object-atoms@1.0.0:
dependencies:
es-errors: 1.3.0
@@ -9295,6 +10289,8 @@ snapshots:
md5.js: 1.3.5
safe-buffer: 5.2.1
+ expect-type@1.3.0: {}
+
eyes@0.1.8: {}
fast-check@3.23.2:
@@ -10187,6 +11183,14 @@ snapshots:
json-stable-stringify-without-jsonify@1.0.1: {}
+ json-stable-stringify@1.3.0:
+ dependencies:
+ call-bind: 1.0.8
+ call-bound: 1.0.4
+ isarray: 2.0.5
+ jsonify: 0.0.1
+ object-keys: 1.1.1
+
json-stringify-safe@5.0.1: {}
json5@1.0.2:
@@ -10201,6 +11205,8 @@ snapshots:
optionalDependencies:
graceful-fs: 4.2.11
+ jsonify@0.0.1: {}
+
jsonparse@1.3.1: {}
keyv@4.5.4:
@@ -10266,6 +11272,10 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.0
+ magic-string@0.30.21:
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.5.5
+
magic-string@0.30.5:
dependencies:
'@jridgewell/sourcemap-codec': 1.4.15
@@ -11192,6 +12202,8 @@ snapshots:
stackback@0.0.2: {}
+ std-env@3.10.0: {}
+
std-env@3.7.0: {}
stdin-discarder@0.2.2: {}
@@ -11363,6 +12375,8 @@ snapshots:
tinyexec@0.3.0: {}
+ tinyexec@0.3.2: {}
+
tinyglobby@0.2.15:
dependencies:
fdir: 6.5.0(picomatch@4.0.3)
@@ -11392,6 +12406,10 @@ snapshots:
dependencies:
typescript: 5.9.2
+ ts-api-utils@2.1.0(typescript@5.9.3):
+ dependencies:
+ typescript: 5.9.3
+
ts-mocha@10.1.0(mocha@11.7.5):
dependencies:
mocha: 11.7.5
@@ -11587,6 +12605,8 @@ snapshots:
undici-types@6.21.0: {}
+ undici-types@7.19.0: {}
+
unicorn-magic@0.3.0: {}
union@0.5.0:
@@ -11653,6 +12673,23 @@ snapshots:
- supports-color
- terser
+ vite-node@2.1.9(@types/node@22.16.5)(terser@5.43.1):
+ dependencies:
+ cac: 6.7.14
+ debug: 4.4.3(supports-color@8.1.1)
+ es-module-lexer: 1.7.0
+ pathe: 1.1.2
+ vite: 5.0.4(@types/node@22.16.5)(terser@5.43.1)
+ transitivePeerDependencies:
+ - '@types/node'
+ - less
+ - lightningcss
+ - sass
+ - stylus
+ - sugarss
+ - supports-color
+ - terser
+
vite@5.0.4(@types/node@22.16.5)(terser@5.43.1):
dependencies:
esbuild: 0.19.5
@@ -11696,6 +12733,40 @@ snapshots:
- supports-color
- terser
+ vitest@2.1.9(@types/node@22.16.5)(terser@5.43.1):
+ dependencies:
+ '@vitest/expect': 2.1.9
+ '@vitest/mocker': 2.1.9(vite@5.0.4(@types/node@22.16.5)(terser@5.43.1))
+ '@vitest/pretty-format': 2.1.9
+ '@vitest/runner': 2.1.9
+ '@vitest/snapshot': 2.1.9
+ '@vitest/spy': 2.1.9
+ '@vitest/utils': 2.1.9
+ chai: 5.3.3
+ debug: 4.4.3(supports-color@8.1.1)
+ expect-type: 1.3.0
+ magic-string: 0.30.21
+ pathe: 1.1.2
+ std-env: 3.10.0
+ tinybench: 2.9.0
+ tinyexec: 0.3.2
+ tinypool: 1.0.1
+ tinyrainbow: 1.2.0
+ vite: 5.0.4(@types/node@22.16.5)(terser@5.43.1)
+ vite-node: 2.1.9(@types/node@22.16.5)(terser@5.43.1)
+ why-is-node-running: 2.3.0
+ optionalDependencies:
+ '@types/node': 22.16.5
+ transitivePeerDependencies:
+ - less
+ - lightningcss
+ - msw
+ - sass
+ - stylus
+ - sugarss
+ - supports-color
+ - terser
+
wait-on@7.2.0:
dependencies:
axios: 1.12.2
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 3a1786cae1..e7dd08df48 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -5,4 +5,5 @@ packages:
- "sdk-tests/sdk-anchor-test/**"
- "js/stateless.js/**"
- "js/compressed-token/**"
+ - "js/token-kit/**"
- "examples/**"