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/**"