diff --git a/.github/workflows/token-kit.yml b/.github/workflows/token-kit.yml new file mode 100644 index 0000000000..e03c1845b3 --- /dev/null +++ b/.github/workflows/token-kit.yml @@ -0,0 +1,67 @@ +on: + push: + branches: + - main + paths: + - "js/token-sdk/**" + - "js/token-client/**" + - "js/token-idl/**" + - "pnpm-lock.yaml" + pull_request: + branches: + - "*" + paths: + - "js/token-sdk/**" + - "js/token-client/**" + - "js/token-idl/**" + - "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 + + 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: + version: 9 + run_install: false + + - name: Install just + uses: extractions/setup-just@v2 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build token-sdk + run: cd js/token-sdk && pnpm build + + - name: Run token-sdk unit tests + run: just js test-token-sdk + + - name: Run token-client unit tests + run: just js test-token-client + + - name: Lint token-sdk + run: just js lint-token-kit diff --git a/js/compressed-token/src/index.ts b/js/compressed-token/src/index.ts index 48405d215b..2e021cddec 100644 --- a/js/compressed-token/src/index.ts +++ b/js/compressed-token/src/index.ts @@ -81,6 +81,7 @@ export { getOrCreateAtaInterface, transferInterface, decompressInterface, + decompressMint, wrap, mintTo as mintToCToken, mintToCompressed, @@ -90,6 +91,8 @@ export { updateMetadataField, updateMetadataAuthority, removeMetadataKey, + createAssociatedCTokenAccount, + createAssociatedCTokenAccountIdempotent, // Action types InterfaceOptions, // Helpers @@ -120,6 +123,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..fbbdd2d31e 100644 --- a/js/justfile +++ b/js/justfile @@ -7,6 +7,10 @@ build: cd stateless.js && pnpm build cd compressed-token && pnpm build +build-token-kit: + cd token-sdk && pnpm build + cd token-client && pnpm build + test: test-stateless test-compressed-token test-stateless: @@ -18,10 +22,32 @@ test-compressed-token: test-compressed-token-unit-v2: cd compressed-token && pnpm test:unit:all:v2 +test-token-sdk: + cd token-sdk && pnpm test + +test-token-client: + cd token-client && pnpm test + +test-token-kit: test-token-sdk test-token-client + +start-validator: + ./../cli/test_bin/run test-validator + +test-token-sdk-e2e: start-validator + cd token-sdk && LIGHT_PROTOCOL_VERSION=V2 LIGHT_PROTOCOL_BETA=true pnpm test:e2e + +test-token-client-e2e: start-validator + cd token-client && LIGHT_PROTOCOL_VERSION=V2 LIGHT_PROTOCOL_BETA=true pnpm test:e2e + +test-token-kit-e2e: start-validator test-token-sdk-e2e test-token-client-e2e + lint: cd stateless.js && pnpm lint cd compressed-token && pnpm lint +lint-token-kit: + cd token-sdk && pnpm lint + format: cd stateless.js && pnpm format cd compressed-token && pnpm format diff --git a/js/token-client/package.json b/js/token-client/package.json new file mode 100644 index 0000000000..b9b95fffb5 --- /dev/null +++ b/js/token-client/package.json @@ -0,0 +1,54 @@ +{ + "name": "@lightprotocol/token-client", + "version": "0.1.0", + "description": "Light Protocol indexer client for compressed tokens", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "files": [ + "dist", + "src" + ], + "scripts": { + "build": "tsc", + "clean": "rm -rf dist", + "test": "vitest run tests/unit/", + "test:e2e": "vitest run --config vitest.e2e.config.ts", + "test:watch": "vitest" + }, + "peerDependencies": { + "@solana/kit": "^2.1.0" + }, + "dependencies": { + "@lightprotocol/token-sdk": "workspace:*", + "@solana/addresses": "^2.1.0", + "@solana/codecs": "^2.1.0", + "@solana/instructions": "^2.1.0" + }, + "devDependencies": { + "typescript": "^5.7.3", + "vitest": "^2.1.8" + }, + "engines": { + "node": ">=18" + }, + "keywords": [ + "solana", + "light-protocol", + "compressed-token", + "indexer", + "zk-compression" + ], + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/Lightprotocol/light-protocol.git", + "directory": "js/token-client" + } +} diff --git a/js/token-client/src/actions.ts b/js/token-client/src/actions.ts new file mode 100644 index 0000000000..09d1fa5d58 --- /dev/null +++ b/js/token-client/src/actions.ts @@ -0,0 +1,270 @@ +/** + * 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 } from '@solana/addresses'; +import { AccountRole, type Instruction, type AccountMeta } from '@solana/instructions'; + +import type { LightIndexer } from './indexer.js'; +import { + loadTokenAccountsForTransfer, + getOutputTreeInfo, + type InputTokenAccount, + type LoadTokenAccountsOptions, +} from './load.js'; + +import { + IndexerError, + IndexerErrorCode, + type ValidityProofWithContext, +} from '@lightprotocol/token-sdk'; +import { createTransfer2Instruction } from '@lightprotocol/token-sdk'; + +/** + * 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; +} + +/** + * Builds a compressed token transfer (Transfer2) instruction by loading accounts, + * selecting inputs, fetching a validity proof, and creating the instruction. + * + * This is the primary high-level API for compressed token transfers. + * + * Flow: + * 1. Fetch token accounts from the indexer + * 2. Select accounts that cover the requested amount + * 3. Fetch a validity proof for the selected accounts + * 4. Create the Transfer2 instruction with proof and merkle contexts + * + * @param indexer - Light indexer client + * @param params - Transfer parameters + * @returns The instruction, inputs, and proof + * + * @example + * ```typescript + * const result = await buildCompressedTransfer(indexer, { + * owner: ownerAddress, + * mint: mintAddress, + * amount: 1000n, + * recipientOwner: recipientAddress, + * feePayer: payerAddress, + * }); + * // result.instruction is the Transfer2 instruction + * ``` + */ +export async function buildCompressedTransfer( + indexer: LightIndexer, + params: { + /** 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( + indexer, + params.owner, + params.amount, + options, + ); + if (loaded.inputs.length === 0) { + throw new IndexerError( + IndexerErrorCode.InvalidResponse, + 'No inputs were selected for transfer', + ); + } + + const hashToKey = (hash: Uint8Array): string => + Array.from(hash, (b) => b.toString(16).padStart(2, '0')).join(''); + + const proofRootIndexByHash = new Map(); + for (const proofInput of loaded.proof.accounts) { + const key = hashToKey(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); + } + + // Build packed accounts: output queue, merkle trees, owner, recipient, mint + // The caller should construct Transfer2Params with the full packed accounts + // and merkle contexts from loaded.inputs and loaded.proof. + // + // For now, construct the Transfer2 instruction data from the loaded data. + 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; + } + + // Add mint (readonly) + const mintIdx = getOrAddPacked(params.mint, AccountRole.READONLY); + + // Add owner (readonly) + const ownerIdx = getOrAddPacked(params.owner, AccountRole.READONLY); + + // Add recipient (readonly) + const recipientIdx = getOrAddPacked( + params.recipientOwner, + AccountRole.READONLY, + ); + + // Add merkle tree/queue pairs (writable) + for (const input of loaded.inputs) { + getOrAddPacked(input.merkleContext.tree, AccountRole.WRITABLE); + getOrAddPacked(input.merkleContext.queue, AccountRole.WRITABLE); + } + + // Build input token data from loaded accounts + const inTokenData = loaded.inputs.map((input) => { + const treeIdx = getOrAddPacked( + input.merkleContext.tree, + AccountRole.WRITABLE, + ); + const queueIdx = getOrAddPacked( + input.merkleContext.queue, + AccountRole.WRITABLE, + ); + + const inputHashKey = hashToKey(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: 3, // V2 token accounts + merkleContext: { + merkleTreePubkeyIndex: treeIdx, + queuePubkeyIndex: queueIdx, + leafIndex: input.merkleContext.leafIndex, + proveByIndex: input.merkleContext.proveByIndex, + }, + rootIndex, + }; + }); + + // Build output token data + // Output 0: recipient gets the requested amount + // Output 1: change back to sender (if any) + const outTokenData = [ + { + owner: recipientIdx, + amount: params.amount, + hasDelegate: false, + delegate: 0, + mint: mintIdx, + version: 3, + }, + ]; + + if (loaded.totalAmount > params.amount) { + outTokenData.push({ + owner: ownerIdx, + amount: loaded.totalAmount - params.amount, + hasDelegate: false, + delegate: 0, + mint: mintIdx, + version: 3, + }); + } + + // Output queue follows rollover semantics: + // use nextTreeInfo.queue when present, otherwise current queue. + const outputTreeInfo = getOutputTreeInfo( + loaded.inputs[0].tokenAccount.account.treeInfo, + ); + + // Get output queue index from output tree info + const outputQueueIdx = getOrAddPacked( + outputTreeInfo.queue, + AccountRole.WRITABLE, + ); + + 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, + }; +} diff --git a/js/token-client/src/index.ts b/js/token-client/src/index.ts new file mode 100644 index 0000000000..7039b08385 --- /dev/null +++ b/js/token-client/src/index.ts @@ -0,0 +1,63 @@ +/** + * Light Protocol Token Client + * + * Indexer client and account loading functions for compressed tokens. + * + * @example + * ```typescript + * import { + * createLightIndexer, + * loadTokenAccountsForTransfer, + * selectAccountsForAmount, + * } from '@lightprotocol/token-client'; + * + * // Types from token-sdk: + * import { TreeType, CompressedTokenAccount } from '@lightprotocol/token-sdk'; + * + * const indexer = createLightIndexer('https://photon.helius.dev'); + * const loaded = await loadTokenAccountsForTransfer(indexer, owner, 1000n); + * ``` + * + * @packageDocumentation + */ + +// Indexer +export { + type LightIndexer, + PhotonIndexer, + createLightIndexer, + isLightIndexerAvailable, +} from './indexer.js'; + +// Load functions +export { + // Types + type InputTokenAccount, + type MerkleContext, + type LoadedTokenAccounts, + type LoadTokenAccountsOptions, + type SelectedAccounts, + + // Load functions + loadTokenAccountsForTransfer, + loadTokenAccount, + loadAllTokenAccounts, + loadCompressedAccount, + loadCompressedAccountByHash, + + // Account selection + selectAccountsForAmount, + DEFAULT_MAX_INPUTS, + + // Proof helpers + getValidityProofForAccounts, + needsValidityProof, + getTreeInfo, + getOutputTreeInfo, +} from './load.js'; + +// Actions (high-level builders) +export { + buildCompressedTransfer, + type BuildTransferResult, +} from './actions.js'; diff --git a/js/token-client/src/indexer.ts b/js/token-client/src/indexer.ts new file mode 100644 index 0000000000..e058ab05c1 --- /dev/null +++ b/js/token-client/src/indexer.ts @@ -0,0 +1,616 @@ +/** + * 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 CompressedAccountData, + type AccountProofInputs, + type AddressProofInputs, + type RootIndex, + TreeType, + AccountState, + IndexerError, + IndexerErrorCode, + assertV2Tree, +} from '@lightprotocol/token-sdk'; + +// ============================================================================ +// 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>; +} + +// ============================================================================ +// 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; + // base58Encoder: string -> Uint8Array (for decoding base58 strings FROM API) + private readonly base58Encoder = getBase58Encoder(); + // base58Decoder: Uint8Array -> string (for encoding bytes TO base58 for API) + private readonly base58Decoder = 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), + }; + } + + // ======================================================================== + // 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 + assertV2Tree(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), + ), + }; + } + + /** + * Convert bytes to base58 string. + * Uses the decoder because it decodes bytes FROM internal format TO base58 string. + */ + private bytesToBase58(bytes: Uint8Array): string { + return this.base58Decoder.decode(bytes); + } + + /** + * Convert base58 string to bytes. + * Uses the encoder because it encodes base58 string TO internal byte format. + */ + private base58ToBytes(str: string): Uint8Array { + // The encoder returns ReadonlyUint8Array, so we need to copy to mutable Uint8Array + return Uint8Array.from(this.base58Encoder.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 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-client/src/load.ts b/js/token-client/src/load.ts new file mode 100644 index 0000000000..0bb7fa5ce4 --- /dev/null +++ b/js/token-client/src/load.ts @@ -0,0 +1,381 @@ +/** + * 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 '@lightprotocol/token-sdk'; + +// ============================================================================ +// 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) { + 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; +} diff --git a/js/token-client/tests/e2e/actions.test.ts b/js/token-client/tests/e2e/actions.test.ts new file mode 100644 index 0000000000..432b2b338f --- /dev/null +++ b/js/token-client/tests/e2e/actions.test.ts @@ -0,0 +1,81 @@ +/** + * E2E tests for buildCompressedTransfer. + * + * Requires a running local validator + indexer + prover. + */ + +import { describe, it, expect, beforeAll } from 'vitest'; +import { + getTestRpc, + fundAccount, + createTestMint, + mintCompressedTokens, + toKitAddress, + type Signer, + type Rpc, +} from './helpers/setup.js'; + +import { PhotonIndexer, buildCompressedTransfer } from '../../src/index.js'; +import { DISCRIMINATOR } from '@lightprotocol/token-sdk'; + +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 () => { + rpc = getTestRpc(); + payer = await fundAccount(rpc); + + const created = await createTestMint(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-client/tests/e2e/helpers/setup.ts b/js/token-client/tests/e2e/helpers/setup.ts new file mode 100644 index 0000000000..12ae96b0ad --- /dev/null +++ b/js/token-client/tests/e2e/helpers/setup.ts @@ -0,0 +1,208 @@ +/** + * E2E test setup helpers for token-client tests. + * + * Wraps the legacy SDK (stateless.js, compressed-token) to provide + * test fixtures: creating mints, funding accounts, and sending Kit v2 + * instructions through the web3.js v1 transaction pipeline. + * + * 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, +} from '@lightprotocol/stateless.js'; +import { createMint, mintTo } from '@lightprotocol/compressed-token'; + +import { AccountRole, type Instruction } from '@solana/instructions'; +import { type Address, address } from '@solana/addresses'; + +// ============================================================================ +// 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); +} + +// ============================================================================ +// TYPE ALIASES (structural — no web3.js import) +// ============================================================================ + +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; +} + +// ============================================================================ +// MINT HELPERS +// ============================================================================ + +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); + + 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), + }; +} + +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), + ); +} + +// ============================================================================ +// INSTRUCTION CONVERSION +// ============================================================================ + +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()), + }; +} + +export function toKitAddress(pubkey: any): Address { + return address(pubkey.toBase58()); +} + +// ============================================================================ +// TRANSACTION HELPERS +// ============================================================================ + +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); +} + +// ============================================================================ +// QUERY HELPERS +// ============================================================================ + +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; +} + +export type { Rpc }; diff --git a/js/token-client/tests/e2e/indexer.test.ts b/js/token-client/tests/e2e/indexer.test.ts new file mode 100644 index 0000000000..4fa9bb3e09 --- /dev/null +++ b/js/token-client/tests/e2e/indexer.test.ts @@ -0,0 +1,105 @@ +/** + * E2E tests for PhotonIndexer against a real endpoint. + */ + +import { describe, it, expect, beforeAll } from 'vitest'; +import { + getTestRpc, + fundAccount, + createTestMint, + mintCompressedTokens, + toKitAddress, + 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 () => { + rpc = getTestRpc(); + payer = await fundAccount(rpc); + + const created = await createTestMint(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-client/tests/e2e/load.test.ts b/js/token-client/tests/e2e/load.test.ts new file mode 100644 index 0000000000..dd2e873336 --- /dev/null +++ b/js/token-client/tests/e2e/load.test.ts @@ -0,0 +1,130 @@ +/** + * E2E tests for load functions with a real indexer. + */ + +import { describe, it, expect, beforeAll } from 'vitest'; +import { + getTestRpc, + fundAccount, + createTestMint, + mintCompressedTokens, + toKitAddress, + 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 () => { + rpc = getTestRpc(); + payer = await fundAccount(rpc); + + const created = await createTestMint(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-client/tests/unit/client.test.ts b/js/token-client/tests/unit/client.test.ts new file mode 100644 index 0000000000..c1381bf58e --- /dev/null +++ b/js/token-client/tests/unit/client.test.ts @@ -0,0 +1,76 @@ +/** + * 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 { + assertV2Tree, + TreeType, + IndexerError, + IndexerErrorCode, +} from '@lightprotocol/token-sdk'; + +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('assertV2Tree', () => { + it('throws for StateV1 tree type', () => { + expect(() => assertV2Tree(TreeType.StateV1)).toThrow(IndexerError); + expect(() => assertV2Tree(TreeType.StateV1)).toThrow( + 'V1 tree types are not supported', + ); + }); + + it('throws for AddressV1 tree type', () => { + expect(() => assertV2Tree(TreeType.AddressV1)).toThrow(IndexerError); + expect(() => assertV2Tree(TreeType.AddressV1)).toThrow( + 'V1 tree types are not supported', + ); + }); + + it('passes for V2 tree types', () => { + expect(() => assertV2Tree(TreeType.StateV2)).not.toThrow(); + expect(() => assertV2Tree(TreeType.AddressV2)).not.toThrow(); + }); + + it('throws InvalidResponse error code for V1 trees', () => { + try { + assertV2Tree(TreeType.StateV1); + expect.fail('Expected assertV2Tree to throw'); + } catch (e) { + expect(e).toBeInstanceOf(IndexerError); + expect((e as IndexerError).code).toBe( + IndexerErrorCode.InvalidResponse, + ); + } + }); +}); + diff --git a/js/token-client/tests/unit/indexer.test.ts b/js/token-client/tests/unit/indexer.test.ts new file mode 100644 index 0000000000..f66f7a4b08 --- /dev/null +++ b/js/token-client/tests/unit/indexer.test.ts @@ -0,0 +1,418 @@ +/** + * 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 } from '../../src/index.js'; + +import { IndexerError, IndexerErrorCode, TreeType } from '@lightprotocol/token-sdk'; + +// ============================================================================ +// 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('throws IndexerError for 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); + + 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( + 'V1 tree types are not supported', + ); + } + }); + + 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-client/tests/unit/load.test.ts b/js/token-client/tests/unit/load.test.ts new file mode 100644 index 0000000000..124fa69a68 --- /dev/null +++ b/js/token-client/tests/unit/load.test.ts @@ -0,0 +1,865 @@ +/** + * Unit tests for load functions and actions. + * + * Tests for: + * - loadTokenAccountsForTransfer + * - loadAllTokenAccounts + * - loadTokenAccount + * - loadCompressedAccount + * - loadCompressedAccountByHash + * - getValidityProofForAccounts + * - getOutputTreeInfo + * - needsValidityProof + * - buildCompressedTransfer + */ + +import { describe, it, expect, vi } from 'vitest'; +import { address } from '@solana/addresses'; + +import { + loadTokenAccountsForTransfer, + loadAllTokenAccounts, + loadTokenAccount, + loadCompressedAccount, + loadCompressedAccountByHash, + getValidityProofForAccounts, + getOutputTreeInfo, + needsValidityProof, + type LightIndexer, +} from '../../src/index.js'; + +import { + buildCompressedTransfer, +} from '../../src/index.js'; + +import { + IndexerError, + IndexerErrorCode, + TreeType, + AccountState, + DISCRIMINATOR, + type TreeInfo, + type CompressedTokenAccount, + type CompressedAccount, +} from '@lightprotocol/token-sdk'; + +// ============================================================================ +// TEST HELPERS +// ============================================================================ + +const MOCK_OWNER = address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'); +const MOCK_MINT = address('So11111111111111111111111111111111111111112'); + +function createMockIndexer(overrides?: Partial): LightIndexer { + return { + getCompressedAccount: vi.fn(), + getCompressedAccountByHash: vi.fn(), + getCompressedTokenAccountsByOwner: vi.fn(), + getMultipleCompressedAccounts: vi.fn(), + getValidityProof: vi.fn(), + ...overrides, + }; +} + +function createMockTokenAccount(amount: bigint): CompressedTokenAccount { + const mockTreeInfo: TreeInfo = { + tree: address('amt2kaJA14v3urZbZvnc5v2np8jqvc4Z8zDep5wbtzx'), + queue: address('SySTEM1eSU2p4BGQfQpimFEWWSC1XDFeun3Nqzz3rT7'), + treeType: TreeType.StateV2, + }; + const mockAccount: CompressedAccount = { + hash: new Uint8Array(32), + address: null, + owner: address('cTokenmWW8bLPjZEBAUgYy3zKxQZW6VKi7bqNFEVv3m'), + 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, + }; +} + +function createMockTreeInfo( + treeType: TreeType, + nextTree?: TreeInfo, +): TreeInfo { + return { + tree: address('amt2kaJA14v3urZbZvnc5v2np8jqvc4Z8zDep5wbtzx'), + queue: address('SySTEM1eSU2p4BGQfQpimFEWWSC1XDFeun3Nqzz3rT7'), + treeType, + nextTreeInfo: nextTree, + }; +} + +// ============================================================================ +// 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); + + // Verify each input has merkleContext + 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 () => { + // Always return a cursor to trigger infinite pagination + 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); + // Verify it was called with the correct hashes + 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 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; + } + + 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), + }; + } + + 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), + ]; + // Reverse order on purpose to verify hash-based mapping, not position-based. + 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); + }); +}); diff --git a/js/token-client/tests/unit/selection.test.ts b/js/token-client/tests/unit/selection.test.ts new file mode 100644 index 0000000000..790445e485 --- /dev/null +++ b/js/token-client/tests/unit/selection.test.ts @@ -0,0 +1,278 @@ +/** + * 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 { address } from '@solana/addresses'; + +import { selectAccountsForAmount, DEFAULT_MAX_INPUTS } from '../../src/index.js'; + +import { + type CompressedTokenAccount, + type CompressedAccount, + type TreeInfo, + TreeType, + AccountState, +} from '@lightprotocol/token-sdk'; + +// ============================================================================ +// TEST HELPERS +// ============================================================================ + +function createMockTokenAccount(amount: bigint): CompressedTokenAccount { + const mockTreeInfo: TreeInfo = { + tree: address('amt2kaJA14v3urZbZvnc5v2np8jqvc4Z8zDep5wbtzx'), + queue: address('SySTEM1eSU2p4BGQfQpimFEWWSC1XDFeun3Nqzz3rT7'), + treeType: TreeType.StateV2, + }; + const mockAccount: CompressedAccount = { + hash: new Uint8Array(32), + address: null, + owner: address('cTokenmWW8bLPjZEBAUgYy3zKxQZW6VKi7bqNFEVv3m'), + lamports: 0n, + data: null, + leafIndex: 0, + treeInfo: mockTreeInfo, + proveByIndex: false, + seq: null, + slotCreated: 0n, + }; + return { + token: { + mint: address('So11111111111111111111111111111111111111112'), + owner: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + amount, + delegate: null, + state: AccountState.Initialized, + tlv: null, + }, + account: mockAccount, + }; +} + +// ============================================================================ +// TESTS +// ============================================================================ + +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-client/tsconfig.json b/js/token-client/tsconfig.json new file mode 100644 index 0000000000..780b37e2d1 --- /dev/null +++ b/js/token-client/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"] +} diff --git a/js/token-client/vitest.e2e.config.ts b/js/token-client/vitest.e2e.config.ts new file mode 100644 index 0000000000..291b87daa5 --- /dev/null +++ b/js/token-client/vitest.e2e.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['tests/e2e/**/*.test.ts'], + fileParallelism: false, + testTimeout: 120_000, + hookTimeout: 60_000, + reporters: ['verbose'], + }, +}); diff --git a/js/token-idl/.prettierignore b/js/token-idl/.prettierignore new file mode 100644 index 0000000000..00b694961e --- /dev/null +++ b/js/token-idl/.prettierignore @@ -0,0 +1,4 @@ +node_modules +dist +build +coverage diff --git a/js/token-idl/.prettierrc b/js/token-idl/.prettierrc new file mode 100644 index 0000000000..59be93e26f --- /dev/null +++ b/js/token-idl/.prettierrc @@ -0,0 +1,10 @@ +{ + "semi": true, + "trailingComma": "all", + "singleQuote": true, + "printWidth": 80, + "useTabs": false, + "tabWidth": 4, + "bracketSpacing": true, + "arrowParens": "avoid" +} diff --git a/js/token-idl/eslint.config.cjs b/js/token-idl/eslint.config.cjs new file mode 100644 index 0000000000..66152ccf0a --- /dev/null +++ b/js/token-idl/eslint.config.cjs @@ -0,0 +1,66 @@ +const js = require('@eslint/js'); +const tseslint = require('@typescript-eslint/eslint-plugin'); +const tsParser = require('@typescript-eslint/parser'); + +module.exports = [ + { + ignores: [ + 'node_modules/**', + 'dist/**', + 'build/**', + '*.config.js', + 'eslint.config.js', + 'eslint.config.cjs', + ], + }, + js.configs.recommended, + { + files: ['**/*.js', '**/*.cjs', '**/*.mjs'], + languageOptions: { + ecmaVersion: 2022, + sourceType: 'module', + globals: { + require: 'readonly', + module: 'readonly', + process: 'readonly', + __dirname: 'readonly', + __filename: 'readonly', + exports: 'readonly', + console: 'readonly', + Buffer: 'readonly', + }, + }, + }, + { + files: ['src/**/*.ts', 'scripts/**/*.ts'], + languageOptions: { + parser: tsParser, + parserOptions: { + ecmaVersion: 2022, + sourceType: 'module', + }, + globals: { + process: 'readonly', + console: 'readonly', + __dirname: 'readonly', + __filename: 'readonly', + Buffer: 'readonly', + }, + }, + plugins: { + '@typescript-eslint': tseslint, + }, + rules: { + ...tseslint.configs.recommended.rules, + '@typescript-eslint/ban-ts-comment': 0, + '@typescript-eslint/no-explicit-any': 0, + '@typescript-eslint/no-var-requires': 0, + '@typescript-eslint/no-unused-vars': 0, + '@typescript-eslint/no-require-imports': 0, + 'no-prototype-builtins': 0, + 'no-undef': 0, + 'no-unused-vars': 0, + 'no-redeclare': 0, + }, + }, +]; diff --git a/js/token-idl/package.json b/js/token-idl/package.json new file mode 100644 index 0000000000..63adc03804 --- /dev/null +++ b/js/token-idl/package.json @@ -0,0 +1,27 @@ +{ + "name": "@lightprotocol/token-idl", + "version": "0.1.0", + "description": "Light Protocol Token IDL and Codama client generation", + "type": "module", + "private": true, + "scripts": { + "generate": "tsx scripts/generate-clients.ts", + "build": "pnpm run generate", + "lint": "eslint .", + "format": "prettier --write ." + }, + "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", + "@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" + } +} diff --git a/js/token-idl/scripts/generate-clients.ts b/js/token-idl/scripts/generate-clients.ts new file mode 100644 index 0000000000..dbc0240c96 --- /dev/null +++ b/js/token-idl/scripts/generate-clients.ts @@ -0,0 +1,57 @@ +/** + * Generate TypeScript clients from the Light Token IDL using Codama. + */ + +import { createFromRoot } from 'codama'; +import { renderJavaScriptVisitor } 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, + '../../token-sdk/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( + renderJavaScriptVisitor(typescriptOutputDir, { + formatCode: true, + dependencyMap: { + // Map codama codecs to @solana/codecs + generatedPackage: '@lightprotocol/token-sdk', + }, + }), +); + +console.log('Generation complete!'); diff --git a/js/token-idl/src/idl.ts b/js/token-idl/src/idl.ts new file mode 100644 index 0000000000..2ddf03698d --- /dev/null +++ b/js/token-idl/src/idl.ts @@ -0,0 +1,1198 @@ +/** + * 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, +} 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, +} as const; + +// ============================================================================ +// TYPE DEFINITIONS +// ============================================================================ + +/** Compression mode enum for Transfer2 */ +const compressionModeType = numberTypeNode('u8'); + +/** 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() }), +]); + +/** Input token data with context */ +const multiInputTokenDataType = structTypeNode([ + structFieldTypeNode({ name: 'owner', type: numberTypeNode('u8') }), + structFieldTypeNode({ name: 'amount', type: numberTypeNode('u64') }), + structFieldTypeNode({ name: 'hasDelegate', type: booleanTypeNode() }), + structFieldTypeNode({ name: 'delegate', type: numberTypeNode('u8') }), + structFieldTypeNode({ name: 'mint', type: numberTypeNode('u8') }), + structFieldTypeNode({ name: 'version', type: numberTypeNode('u8') }), + structFieldTypeNode({ + name: 'merkleContext', + type: packedMerkleContextType, + }), + structFieldTypeNode({ name: 'rootIndex', type: numberTypeNode('u16') }), +]); + +/** Output token data */ +const multiTokenOutputDataType = structTypeNode([ + structFieldTypeNode({ name: 'owner', type: numberTypeNode('u8') }), + structFieldTypeNode({ name: 'amount', type: numberTypeNode('u64') }), + structFieldTypeNode({ name: 'hasDelegate', type: booleanTypeNode() }), + structFieldTypeNode({ name: 'delegate', type: numberTypeNode('u8') }), + structFieldTypeNode({ name: 'mint', type: numberTypeNode('u8') }), + structFieldTypeNode({ name: 'version', type: numberTypeNode('u8') }), +]); + +/** 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()), + }), + ]), + ), + }), +]); + +// ============================================================================ +// 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), + ), + }), + // 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-idl/tsconfig.json b/js/token-idl/tsconfig.json new file mode 100644 index 0000000000..6f5308104c --- /dev/null +++ b/js/token-idl/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*", "scripts/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/js/token-sdk/.prettierignore b/js/token-sdk/.prettierignore new file mode 100644 index 0000000000..b709f5b6b2 --- /dev/null +++ b/js/token-sdk/.prettierignore @@ -0,0 +1,6 @@ +node_modules +dist +build +coverage +*.d.ts.map +*.js.map diff --git a/js/token-sdk/.prettierrc b/js/token-sdk/.prettierrc new file mode 100644 index 0000000000..59be93e26f --- /dev/null +++ b/js/token-sdk/.prettierrc @@ -0,0 +1,10 @@ +{ + "semi": true, + "trailingComma": "all", + "singleQuote": true, + "printWidth": 80, + "useTabs": false, + "tabWidth": 4, + "bracketSpacing": true, + "arrowParens": "avoid" +} diff --git a/js/token-sdk/eslint.config.cjs b/js/token-sdk/eslint.config.cjs new file mode 100644 index 0000000000..06f1a6f6b0 --- /dev/null +++ b/js/token-sdk/eslint.config.cjs @@ -0,0 +1,113 @@ +const js = require('@eslint/js'); +const tseslint = require('@typescript-eslint/eslint-plugin'); +const tsParser = require('@typescript-eslint/parser'); + +module.exports = [ + { + ignores: [ + 'node_modules/**', + 'dist/**', + 'build/**', + 'coverage/**', + '*.config.js', + 'eslint.config.js', + 'eslint.config.cjs', + ], + }, + js.configs.recommended, + { + files: ['**/*.js', '**/*.cjs', '**/*.mjs'], + languageOptions: { + ecmaVersion: 2022, + sourceType: 'module', + globals: { + require: 'readonly', + module: 'readonly', + process: 'readonly', + __dirname: 'readonly', + __filename: 'readonly', + exports: 'readonly', + console: 'readonly', + Buffer: 'readonly', + }, + }, + }, + { + files: ['src/**/*.ts', 'src/**/*.tsx'], + languageOptions: { + parser: tsParser, + parserOptions: { + project: './tsconfig.json', + ecmaVersion: 2022, + sourceType: 'module', + }, + globals: { + process: 'readonly', + console: 'readonly', + __dirname: 'readonly', + __filename: 'readonly', + Buffer: 'readonly', + }, + }, + plugins: { + '@typescript-eslint': tseslint, + }, + rules: { + ...tseslint.configs.recommended.rules, + '@typescript-eslint/ban-ts-comment': 0, + '@typescript-eslint/no-explicit-any': 0, + '@typescript-eslint/no-var-requires': 0, + '@typescript-eslint/no-unused-vars': 0, + '@typescript-eslint/no-require-imports': 0, + 'no-prototype-builtins': 0, + 'no-undef': 0, + 'no-unused-vars': 0, + 'no-redeclare': 0, + }, + }, + { + files: [ + 'tests/**/*.ts', + '**/*.test.ts', + '**/*.spec.ts', + 'vitest.config.ts', + ], + languageOptions: { + parser: tsParser, + parserOptions: { + ecmaVersion: 2022, + sourceType: 'module', + }, + globals: { + process: 'readonly', + console: 'readonly', + __dirname: 'readonly', + __filename: 'readonly', + Buffer: 'readonly', + describe: 'readonly', + it: 'readonly', + expect: 'readonly', + beforeEach: 'readonly', + afterEach: 'readonly', + beforeAll: 'readonly', + afterAll: 'readonly', + test: 'readonly', + }, + }, + plugins: { + '@typescript-eslint': tseslint, + }, + rules: { + ...tseslint.configs.recommended.rules, + '@typescript-eslint/ban-ts-comment': 0, + '@typescript-eslint/no-explicit-any': 0, + '@typescript-eslint/no-var-requires': 0, + '@typescript-eslint/no-unused-vars': 0, + '@typescript-eslint/no-require-imports': 0, + 'no-prototype-builtins': 0, + 'no-undef': 0, + 'no-unused-vars': 0, + 'no-redeclare': 0, + }, + }, +]; diff --git a/js/token-sdk/package.json b/js/token-sdk/package.json new file mode 100644 index 0000000000..c6808b0d86 --- /dev/null +++ b/js/token-sdk/package.json @@ -0,0 +1,75 @@ +{ + "name": "@lightprotocol/token-sdk", + "version": "0.1.0", + "description": "Light Protocol Token SDK 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", + "src" + ], + "scripts": { + "build": "tsc", + "clean": "rm -rf dist", + "test": "vitest run tests/unit/", + "test:unit": "vitest run tests/unit/", + "test:e2e": "LIGHT_PROTOCOL_VERSION=V2 LIGHT_PROTOCOL_BETA=true 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": { + "@solana/addresses": "^2.1.0", + "@solana/codecs": "^2.1.0", + "@solana/instructions": "^2.1.0", + "@solana/signers": "^2.1.0", + "@solana/transaction-messages": "^2.1.0", + "@solana/keys": "^2.1.0" + }, + "devDependencies": { + "@solana/kit": "^2.1.0", + "@eslint/js": "9.36.0", + "@typescript-eslint/eslint-plugin": "^8.44.0", + "@typescript-eslint/parser": "^8.44.0", + "eslint": "^9.36.0", + "prettier": "^3.3.3", + "typescript": "^5.7.3", + "vitest": "^2.1.8" + }, + "engines": { + "node": ">=18" + }, + "keywords": [ + "solana", + "light-protocol", + "compressed-token", + "zk-compression", + "web3" + ], + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/Lightprotocol/light-protocol.git", + "directory": "js/token-sdk" + } +} diff --git a/js/token-sdk/src/client/index.ts b/js/token-sdk/src/client/index.ts new file mode 100644 index 0000000000..f7b5cfa030 --- /dev/null +++ b/js/token-sdk/src/client/index.ts @@ -0,0 +1,41 @@ +/** + * Light Token SDK Client Types + * + * Types for interacting with the Light Protocol indexer (Photon). + * Implementation moved to @lightprotocol/token-client package. + */ + +// Types only - implementation in @lightprotocol/token-client +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, + + // Validation + assertV2Tree, +} from './types.js'; diff --git a/js/token-sdk/src/client/types.ts b/js/token-sdk/src/client/types.ts new file mode 100644 index 0000000000..9f6f42edaf --- /dev/null +++ b/js/token-sdk/src/client/types.ts @@ -0,0 +1,298 @@ +/** + * 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 | null; +} + +// ============================================================================ +// 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 +// ============================================================================ + +/** + * Assert that tree is V2. Throws if V1. + * + * The SDK only supports V2 trees. V1 trees from the indexer response + * must be rejected to ensure proper protocol compatibility. + * + * @param treeType - The tree type to validate + * @throws IndexerError if tree type is V1 + */ +export function assertV2Tree(treeType: TreeType): void { + if (treeType === TreeType.StateV1 || treeType === TreeType.AddressV1) { + throw new IndexerError( + IndexerErrorCode.InvalidResponse, + `V1 tree types are not supported. Got: ${TreeType[treeType]}`, + ); + } +} diff --git a/js/token-sdk/src/codecs/compressible.ts b/js/token-sdk/src/codecs/compressible.ts new file mode 100644 index 0000000000..653ffcbb77 --- /dev/null +++ b/js/token-sdk/src/codecs/compressible.ts @@ -0,0 +1,248 @@ +/** + * Compressible extension codecs using Solana Kit patterns. + */ + +import { + type Codec, + type Decoder, + type Encoder, + combineCodec, + getStructDecoder, + getStructEncoder, + getU8Decoder, + getU8Encoder, + getU32Decoder, + getU32Encoder, + getBytesDecoder, + getBytesEncoder, + getArrayDecoder, + getArrayEncoder, + addDecoderSizePrefix, + addEncoderSizePrefix, + getOptionEncoder, + getOptionDecoder, + fixEncoderSize, + fixDecoderSize, +} from '@solana/codecs'; +import { getAddressCodec, type Address } from '@solana/addresses'; + +import type { + CompressToPubkey, + CompressibleExtensionInstructionData, + CreateAtaInstructionData, + CreateTokenAccountInstructionData, +} from './types.js'; + +import { DISCRIMINATOR } from '../constants.js'; + +// ============================================================================ +// VEC CODEC (Borsh-style: u32 length prefix) +// ============================================================================ + +function getVecEncoder(itemEncoder: Encoder): Encoder { + return addEncoderSizePrefix( + getArrayEncoder(itemEncoder), + getU32Encoder(), + ) as Encoder; +} + +function getVecDecoder(itemDecoder: Decoder): Decoder { + return addDecoderSizePrefix(getArrayDecoder(itemDecoder), getU32Decoder()); +} + +// ============================================================================ +// 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-sdk/src/codecs/index.ts b/js/token-sdk/src/codecs/index.ts new file mode 100644 index 0000000000..4e82cb5ddc --- /dev/null +++ b/js/token-sdk/src/codecs/index.ts @@ -0,0 +1,91 @@ +/** + * Light Token SDK Codecs + * + * Serialization codecs for Light Token instruction data using Solana Kit patterns. + */ + +// Types +export * from './types.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'; + +// 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-sdk/src/codecs/instructions.ts b/js/token-sdk/src/codecs/instructions.ts new file mode 100644 index 0000000000..1fc4251e21 --- /dev/null +++ b/js/token-sdk/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-sdk/src/codecs/transfer2.ts b/js/token-sdk/src/codecs/transfer2.ts new file mode 100644 index 0000000000..3ad4a5557d --- /dev/null +++ b/js/token-sdk/src/codecs/transfer2.ts @@ -0,0 +1,560 @@ +/** + * 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, + getArrayDecoder, + getArrayEncoder, + getBytesDecoder, + getBytesEncoder, + addDecoderSizePrefix, + addEncoderSizePrefix, + 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 type { + Compression, + PackedMerkleContext, + MultiInputTokenDataWithContext, + MultiTokenTransferOutputData, + CompressedCpiContext, + CompressedProof, + Transfer2InstructionData, + ExtensionInstructionData, + TokenMetadataExtension, + CompressedOnlyExtension, + CompressionInfo, + RentConfig, +} from './types.js'; + +import { DISCRIMINATOR } 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()); + +// ============================================================================ +// VECTOR CODECS (with u32 length prefix for Borsh compatibility) +// ============================================================================ + +/** + * Creates an encoder for a Vec type (Borsh style: u32 length prefix). + */ +function getVecEncoder(itemEncoder: Encoder): Encoder { + return addEncoderSizePrefix( + getArrayEncoder(itemEncoder), + getU32Encoder(), + ) as Encoder; +} + +/** + * Creates a decoder for a Vec type (Borsh style: u32 length prefix). + */ +function getVecDecoder(itemDecoder: Decoder): Decoder { + return addDecoderSizePrefix(getArrayDecoder(itemDecoder), getU32Decoder()); +} + +// ============================================================================ +// 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); +} + +function writeU32(value: number): Uint8Array { + const buf = new Uint8Array(4); + new DataView(buf.buffer).setUint32(0, value, true); + return buf; +} + +function writeU16(value: number): Uint8Array { + const buf = new Uint8Array(2); + new DataView(buf.buffer).setUint16(0, value, true); + return buf; +} + +function writeU64(value: bigint): Uint8Array { + const buf = new Uint8Array(8); + new DataView(buf.buffer).setBigUint64(0, value, true); + return buf; +} + +function writeBool(value: boolean): Uint8Array { + return new Uint8Array([value ? 1 : 0]); +} + +/** Borsh Vec encoding: u32 length + bytes */ +function writeVecBytes(bytes: ReadonlyUint8Array): Uint8Array { + return concatBytes([writeU32(bytes.length), new Uint8Array(bytes)]); +} + +/** Borsh Option encoding: 0x00 for None, 0x01 + data for Some */ +function writeOption( + value: unknown | null, + encoder: (v: unknown) => Uint8Array, +): Uint8Array { + if (value === null || value === undefined) { + return new Uint8Array([0]); + } + return concatBytes([new Uint8Array([1]), encoder(value)]); +} + +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; +} + +/** + * Encodes a single ExtensionInstructionData with its Borsh enum discriminant. + */ +export function encodeExtensionInstructionData( + ext: ExtensionInstructionData, +): Uint8Array { + switch (ext.type) { + case 'TokenMetadata': + return concatBytes([ + new Uint8Array([19]), // discriminant + encodeTokenMetadata(ext.data), + ]); + case 'PausableAccount': + // Marker extension: discriminant only, zero data bytes + return new Uint8Array([27]); + case 'PermanentDelegateAccount': + // Marker extension: discriminant only, zero data bytes + return new Uint8Array([28]); + case 'TransferFeeAccount': + // Rust Placeholder29: unit variant, discriminant only (no data) + return new Uint8Array([29]); + case 'TransferHookAccount': + // Rust Placeholder30: unit variant, discriminant only (no data) + return new Uint8Array([30]); + case 'CompressedOnly': + return concatBytes([ + new Uint8Array([31]), // discriminant + encodeCompressedOnly(ext.data), + ]); + case 'Compressible': + return concatBytes([ + new Uint8Array([32]), // discriminant + encodeCompressionInfo(ext.data), + ]); + } +} + +function encodeTokenMetadata(data: TokenMetadataExtension): Uint8Array { + const chunks: Uint8Array[] = []; + + // Option - update_authority + chunks.push( + writeOption(data.updateAuthority, (v) => + new Uint8Array(getAddressCodec().encode(v as Address)), + ), + ); + + // Vec fields + chunks.push(writeVecBytes(data.name)); + chunks.push(writeVecBytes(data.symbol)); + chunks.push(writeVecBytes(data.uri)); + + // Option> + chunks.push( + writeOption(data.additionalMetadata, (v) => { + const items = v as 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-sdk/src/codecs/types.ts b/js/token-sdk/src/codecs/types.ts new file mode 100644 index 0000000000..bbf4f1ca43 --- /dev/null +++ b/js/token-sdk/src/codecs/types.ts @@ -0,0 +1,336 @@ +/** + * 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; +} + +/** + * Transfer fee account extension data. + */ +export interface TransferFeeAccountExtension { + /** Withheld transfer fee amount */ + withheldAmount: bigint; +} + +/** + * Transfer hook account extension data. + */ +export interface TransferHookAccountExtension { + /** Reentrancy guard (always 0 at rest in Light Protocol) */ + transferring: number; +} + +/** + * 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-sdk/src/constants.ts b/js/token-sdk/src/constants.ts new file mode 100644 index 0000000000..3e7ecf4736 --- /dev/null +++ b/js/token-sdk/src/constants.ts @@ -0,0 +1,213 @@ +/** + * 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; diff --git a/js/token-sdk/src/index.ts b/js/token-sdk/src/index.ts new file mode 100644 index 0000000000..92bb095b01 --- /dev/null +++ b/js/token-sdk/src/index.ts @@ -0,0 +1,267 @@ +/** + * Light Protocol Token SDK + * + * TypeScript SDK for Light Protocol compressed tokens using Solana Kit (web3.js v2). + * + * @example + * ```typescript + * import { + * createTransferInstruction, + * createAssociatedTokenAccountInstruction, + * deriveAssociatedTokenAddress, + * LIGHT_TOKEN_PROGRAM_ID, + * } from '@lightprotocol/token-sdk'; + * + * // 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, + * }); + * ``` + * + * @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, +} from './constants.js'; + +// ============================================================================ +// UTILITIES +// ============================================================================ + +export { + // PDA derivation + deriveAssociatedTokenAddress, + getAssociatedTokenAddressWithBump, + deriveMintAddress, + derivePoolAddress, + + // Validation + isLightTokenAccount, + determineTransferType, + validateAtaDerivation, + validatePositiveAmount, + validateDecimals, +} from './utils/index.js'; + +// ============================================================================ +// CODECS +// ============================================================================ + +export { + // Types + type Compression, + type PackedMerkleContext, + type MultiInputTokenDataWithContext, + type MultiTokenTransferOutputData, + type CompressedCpiContext, + type CompressedProof, + type TokenMetadataExtension, + type CompressedOnlyExtension, + type TransferFeeAccountExtension, + type TransferHookAccountExtension, + type RentConfig, + type CompressionInfo, + type ExtensionInstructionData, + type Transfer2InstructionData, + type CompressToPubkey, + type CompressibleExtensionInstructionData, + type CreateAtaInstructionData, + type CreateTokenAccountInstructionData, + + // Transfer2 codecs + getCompressionCodec, + getPackedMerkleContextCodec, + getMultiInputTokenDataCodec, + getMultiTokenOutputDataCodec, + getCpiContextCodec, + getCompressedProofCodec, + encodeTransfer2InstructionData, + type Transfer2BaseInstructionData, + + // Compressible codecs + getCompressToPubkeyCodec, + 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 TransferType, + 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, + + // 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, + + // Rent management + createClaimInstruction, + type ClaimParams, + createWithdrawFundingPoolInstruction, + type WithdrawFundingPoolParams, +} from './instructions/index.js'; + +// ============================================================================ +// CLIENT TYPES (Indexer & load functions in @lightprotocol/token-client) +// ============================================================================ + +export { + // Validation + 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, +} from './client/index.js'; diff --git a/js/token-sdk/src/instructions/approve.ts b/js/token-sdk/src/instructions/approve.ts new file mode 100644 index 0000000000..efd857b931 --- /dev/null +++ b/js/token-sdk/src/instructions/approve.ts @@ -0,0 +1,137 @@ +/** + * 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, + encodeMaxTopUp, +} from '../codecs/instructions.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 in units of 1,000 lamports (optional) */ + 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); + + // Build accounts - owner is always WRITABLE_SIGNER (payer at index 2) + const accounts: AccountMeta[] = [ + { address: tokenAccount, role: AccountRole.WRITABLE }, + { address: delegate, role: AccountRole.READONLY }, + { address: owner, role: AccountRole.WRITABLE_SIGNER }, + ]; + + // Build instruction data: discriminator + amount [+ maxTopUp] + const baseBytes = getAmountInstructionEncoder().encode({ + discriminator: DISCRIMINATOR.APPROVE, + amount, + }); + const maxTopUpBytes = encodeMaxTopUp(maxTopUp); + + const data = new Uint8Array(baseBytes.length + maxTopUpBytes.length); + data.set(new Uint8Array(baseBytes), 0); + if (maxTopUpBytes.length > 0) { + data.set(maxTopUpBytes, baseBytes.length); + } + + return { + programAddress: LIGHT_TOKEN_PROGRAM_ID, + accounts, + data, + }; +} + +/** + * 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 in units of 1,000 lamports (optional) */ + 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; + + // Build accounts - owner is always WRITABLE_SIGNER (payer at index 1) + const accounts: AccountMeta[] = [ + { address: tokenAccount, role: AccountRole.WRITABLE }, + { address: owner, role: AccountRole.WRITABLE_SIGNER }, + ]; + + // Build instruction data: discriminator [+ maxTopUp] + const baseBytes = getDiscriminatorOnlyEncoder().encode({ + discriminator: DISCRIMINATOR.REVOKE, + }); + const maxTopUpBytes = encodeMaxTopUp(maxTopUp); + + const data = new Uint8Array(baseBytes.length + maxTopUpBytes.length); + data.set(new Uint8Array(baseBytes), 0); + if (maxTopUpBytes.length > 0) { + data.set(maxTopUpBytes, baseBytes.length); + } + + return { + programAddress: LIGHT_TOKEN_PROGRAM_ID, + accounts, + data, + }; +} diff --git a/js/token-sdk/src/instructions/burn.ts b/js/token-sdk/src/instructions/burn.ts new file mode 100644 index 0000000000..0e7ff9759d --- /dev/null +++ b/js/token-sdk/src/instructions/burn.ts @@ -0,0 +1,170 @@ +/** + * 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, + encodeMaxTopUp, +} from '../codecs/instructions.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); + + // Build accounts + 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 }, + ]; + + // Add fee payer if provided + if (feePayer) { + accounts.push({ address: feePayer, role: AccountRole.WRITABLE_SIGNER }); + } + + // Build instruction data: discriminator + amount [+ maxTopUp] + const baseBytes = getAmountInstructionEncoder().encode({ + discriminator: DISCRIMINATOR.BURN, + amount, + }); + const maxTopUpBytes = encodeMaxTopUp(maxTopUp); + + const data = new Uint8Array(baseBytes.length + maxTopUpBytes.length); + data.set(new Uint8Array(baseBytes), 0); + if (maxTopUpBytes.length > 0) { + data.set(maxTopUpBytes, baseBytes.length); + } + + return { + programAddress: LIGHT_TOKEN_PROGRAM_ID, + accounts, + data, + }; +} + +/** + * 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); + + // Build accounts + 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 }, + ]; + + // Add fee payer if provided + if (feePayer) { + accounts.push({ address: feePayer, role: AccountRole.WRITABLE_SIGNER }); + } + + // Build instruction data: discriminator + amount + decimals [+ maxTopUp] + const baseBytes = getCheckedInstructionEncoder().encode({ + discriminator: DISCRIMINATOR.BURN_CHECKED, + amount, + decimals, + }); + const maxTopUpBytes = encodeMaxTopUp(maxTopUp); + + const data = new Uint8Array(baseBytes.length + maxTopUpBytes.length); + data.set(new Uint8Array(baseBytes), 0); + if (maxTopUpBytes.length > 0) { + data.set(maxTopUpBytes, baseBytes.length); + } + + return { + programAddress: LIGHT_TOKEN_PROGRAM_ID, + accounts, + data, + }; +} diff --git a/js/token-sdk/src/instructions/close.ts b/js/token-sdk/src/instructions/close.ts new file mode 100644 index 0000000000..54097a7418 --- /dev/null +++ b/js/token-sdk/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-sdk/src/instructions/create-ata.ts b/js/token-sdk/src/instructions/create-ata.ts new file mode 100644 index 0000000000..03dd8af228 --- /dev/null +++ b/js/token-sdk/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-sdk/src/instructions/freeze-thaw.ts b/js/token-sdk/src/instructions/freeze-thaw.ts new file mode 100644 index 0000000000..f0d1f14b5c --- /dev/null +++ b/js/token-sdk/src/instructions/freeze-thaw.ts @@ -0,0 +1,101 @@ +/** + * 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 a token account. + */ +export interface FreezeParams { + /** Token account to freeze */ + tokenAccount: Address; + /** Mint address */ + mint: Address; + /** Freeze authority - must be signer */ + freezeAuthority: Address; +} + +/** + * Creates a freeze instruction (discriminator: 10). + * + * Freezes a token account, preventing transfers. + * + * @param params - Freeze parameters + * @returns The freeze instruction + */ +export function createFreezeInstruction(params: FreezeParams): Instruction { + const { tokenAccount, mint, freezeAuthority } = params; + + // Build accounts + const accounts: AccountMeta[] = [ + { address: tokenAccount, role: AccountRole.WRITABLE }, + { address: mint, role: AccountRole.READONLY }, + { address: freezeAuthority, role: AccountRole.READONLY_SIGNER }, + ]; + + // Build instruction data (just discriminator) + const data = new Uint8Array( + getDiscriminatorOnlyEncoder().encode({ + discriminator: DISCRIMINATOR.FREEZE, + }), + ); + + return { + programAddress: LIGHT_TOKEN_PROGRAM_ID, + accounts, + data, + }; +} + +/** + * Parameters for thawing a token account. + */ +export interface ThawParams { + /** Token account to thaw */ + tokenAccount: Address; + /** Mint address */ + mint: Address; + /** Freeze authority - must be signer */ + freezeAuthority: Address; +} + +/** + * Creates a thaw instruction (discriminator: 11). + * + * Thaws a frozen token account, allowing transfers again. + * + * @param params - Thaw parameters + * @returns The thaw instruction + */ +export function createThawInstruction(params: ThawParams): Instruction { + const { tokenAccount, mint, freezeAuthority } = params; + + // Build accounts + const accounts: AccountMeta[] = [ + { address: tokenAccount, role: AccountRole.WRITABLE }, + { address: mint, role: AccountRole.READONLY }, + { address: freezeAuthority, role: AccountRole.READONLY_SIGNER }, + ]; + + // Build instruction data (just discriminator) + const data = new Uint8Array( + getDiscriminatorOnlyEncoder().encode({ + discriminator: DISCRIMINATOR.THAW, + }), + ); + + return { + programAddress: LIGHT_TOKEN_PROGRAM_ID, + accounts, + data, + }; +} diff --git a/js/token-sdk/src/instructions/index.ts b/js/token-sdk/src/instructions/index.ts new file mode 100644 index 0000000000..388079ece1 --- /dev/null +++ b/js/token-sdk/src/instructions/index.ts @@ -0,0 +1,17 @@ +/** + * 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'; diff --git a/js/token-sdk/src/instructions/mint-to.ts b/js/token-sdk/src/instructions/mint-to.ts new file mode 100644 index 0000000000..650a5d443b --- /dev/null +++ b/js/token-sdk/src/instructions/mint-to.ts @@ -0,0 +1,170 @@ +/** + * 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, + encodeMaxTopUp, +} from '../codecs/instructions.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); + + // Build accounts + 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 }, + ]; + + // Add fee payer if provided + if (feePayer) { + accounts.push({ address: feePayer, role: AccountRole.WRITABLE_SIGNER }); + } + + // Build instruction data: discriminator + amount [+ maxTopUp] + const baseBytes = getAmountInstructionEncoder().encode({ + discriminator: DISCRIMINATOR.MINT_TO, + amount, + }); + const maxTopUpBytes = encodeMaxTopUp(maxTopUp); + + const data = new Uint8Array(baseBytes.length + maxTopUpBytes.length); + data.set(new Uint8Array(baseBytes), 0); + if (maxTopUpBytes.length > 0) { + data.set(maxTopUpBytes, baseBytes.length); + } + + return { + programAddress: LIGHT_TOKEN_PROGRAM_ID, + accounts, + data, + }; +} + +/** + * 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); + + // Build accounts + 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 }, + ]; + + // Add fee payer if provided + if (feePayer) { + accounts.push({ address: feePayer, role: AccountRole.WRITABLE_SIGNER }); + } + + // Build instruction data: discriminator + amount + decimals [+ maxTopUp] + const baseBytes = getCheckedInstructionEncoder().encode({ + discriminator: DISCRIMINATOR.MINT_TO_CHECKED, + amount, + decimals, + }); + const maxTopUpBytes = encodeMaxTopUp(maxTopUp); + + const data = new Uint8Array(baseBytes.length + maxTopUpBytes.length); + data.set(new Uint8Array(baseBytes), 0); + if (maxTopUpBytes.length > 0) { + data.set(maxTopUpBytes, baseBytes.length); + } + + return { + programAddress: LIGHT_TOKEN_PROGRAM_ID, + accounts, + data, + }; +} diff --git a/js/token-sdk/src/instructions/transfer-interface.ts b/js/token-sdk/src/instructions/transfer-interface.ts new file mode 100644 index 0000000000..6cf7dc5440 --- /dev/null +++ b/js/token-sdk/src/instructions/transfer-interface.ts @@ -0,0 +1,121 @@ +/** + * 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 } from '../utils/validation.js'; +import { createTransferInstruction } from './transfer.js'; + +/** + * Transfer type for routing. + */ +export type TransferType = + | 'light-to-light' + | 'light-to-spl' + | 'spl-to-light' + | 'spl-to-spl'; + +/** + * 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-sdk/src/instructions/transfer.ts b/js/token-sdk/src/instructions/transfer.ts new file mode 100644 index 0000000000..da781974bc --- /dev/null +++ b/js/token-sdk/src/instructions/transfer.ts @@ -0,0 +1,169 @@ +/** + * 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, + encodeMaxTopUp, +} from '../codecs/instructions.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); + + // Build 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 }, + ]; + + // Add fee payer if provided + if (feePayer) { + accounts.push({ address: feePayer, role: AccountRole.WRITABLE_SIGNER }); + } + + // Build instruction data: discriminator + amount [+ maxTopUp] + const baseBytes = getAmountInstructionEncoder().encode({ + discriminator: DISCRIMINATOR.TRANSFER, + amount, + }); + const maxTopUpBytes = encodeMaxTopUp(maxTopUp); + + const data = new Uint8Array(baseBytes.length + maxTopUpBytes.length); + data.set(new Uint8Array(baseBytes), 0); + if (maxTopUpBytes.length > 0) { + data.set(maxTopUpBytes, baseBytes.length); + } + + return { + programAddress: LIGHT_TOKEN_PROGRAM_ID, + accounts, + data, + }; +} + +/** + * 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); + + // Build 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 }, + ]; + + // Add fee payer if provided + if (feePayer) { + accounts.push({ address: feePayer, role: AccountRole.WRITABLE_SIGNER }); + } + + // Build instruction data: discriminator + amount + decimals [+ maxTopUp] + const baseBytes = getCheckedInstructionEncoder().encode({ + discriminator: DISCRIMINATOR.TRANSFER_CHECKED, + amount, + decimals, + }); + const maxTopUpBytes = encodeMaxTopUp(maxTopUp); + + const data = new Uint8Array(baseBytes.length + maxTopUpBytes.length); + data.set(new Uint8Array(baseBytes), 0); + if (maxTopUpBytes.length > 0) { + data.set(maxTopUpBytes, baseBytes.length); + } + + return { + programAddress: LIGHT_TOKEN_PROGRAM_ID, + accounts, + data, + }; +} diff --git a/js/token-sdk/src/utils/derivation.ts b/js/token-sdk/src/utils/derivation.ts new file mode 100644 index 0000000000..4d25b14dd2 --- /dev/null +++ b/js/token-sdk/src/utils/derivation.ts @@ -0,0 +1,147 @@ +/** + * PDA derivation utilities for Light Token accounts. + */ + +import { + type Address, + getAddressCodec, + getProgramDerivedAddress, +} from '@solana/addresses'; + +import { + LIGHT_TOKEN_PROGRAM_ID, + COMPRESSED_MINT_SEED, + POOL_SEED, + RESTRICTED_POOL_SEED, +} 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 }> { + 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 }; +} diff --git a/js/token-sdk/src/utils/index.ts b/js/token-sdk/src/utils/index.ts new file mode 100644 index 0000000000..0f8d4365f5 --- /dev/null +++ b/js/token-sdk/src/utils/index.ts @@ -0,0 +1,18 @@ +/** + * Light Token SDK Utilities + */ + +export { + deriveAssociatedTokenAddress, + getAssociatedTokenAddressWithBump, + deriveMintAddress, + derivePoolAddress, +} from './derivation.js'; + +export { + isLightTokenAccount, + determineTransferType, + validateAtaDerivation, + validatePositiveAmount, + validateDecimals, +} from './validation.js'; diff --git a/js/token-sdk/src/utils/validation.ts b/js/token-sdk/src/utils/validation.ts new file mode 100644 index 0000000000..50eb1874fe --- /dev/null +++ b/js/token-sdk/src/utils/validation.ts @@ -0,0 +1,99 @@ +/** + * 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'; + +// ============================================================================ +// 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, +): 'light-to-light' | 'light-to-spl' | 'spl-to-light' | 'spl-to-spl' { + 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-sdk/tests/e2e/approve.test.ts b/js/token-sdk/tests/e2e/approve.test.ts new file mode 100644 index 0000000000..9954354eeb --- /dev/null +++ b/js/token-sdk/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-sdk/tests/e2e/ata.test.ts b/js/token-sdk/tests/e2e/ata.test.ts new file mode 100644 index 0000000000..34daa34fba --- /dev/null +++ b/js/token-sdk/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-sdk/tests/e2e/close.test.ts b/js/token-sdk/tests/e2e/close.test.ts new file mode 100644 index 0000000000..fa79b512c1 --- /dev/null +++ b/js/token-sdk/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-sdk/tests/e2e/freeze-thaw.test.ts b/js/token-sdk/tests/e2e/freeze-thaw.test.ts new file mode 100644 index 0000000000..4a5164b0c2 --- /dev/null +++ b/js/token-sdk/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-sdk/tests/e2e/helpers/setup.ts b/js/token-sdk/tests/e2e/helpers/setup.ts new file mode 100644 index 0000000000..92110fc41a --- /dev/null +++ b/js/token-sdk/tests/e2e/helpers/setup.ts @@ -0,0 +1,365 @@ +/** + * E2E test setup helpers for CToken accounts. + * + * Uses the legacy SDK (stateless.js, compressed-token) to create CToken + * fixtures: decompressed mints, on-chain CToken accounts with balances, + * and a bridge to send Kit v2 instructions via web3.js v1 transactions. + */ + +import { + Rpc, + createRpc, + newAccountWithLamports, + buildAndSignTx, + sendAndConfirmTx, + dedupeSigner, + VERSION, + featureFlags, +} from '@lightprotocol/stateless.js'; +import { + createMintInterface, + decompressMint, + createAssociatedCTokenAccount, + getAssociatedCTokenAddress, + mintToCToken, +} 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 +// ============================================================================ + +/** + * 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), + }; +} + +// ============================================================================ +// 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; +} + +// ============================================================================ +// 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-sdk/tests/e2e/mint-burn.test.ts b/js/token-sdk/tests/e2e/mint-burn.test.ts new file mode 100644 index 0000000000..873b13d5d4 --- /dev/null +++ b/js/token-sdk/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-sdk/tests/e2e/smoke.test.ts b/js/token-sdk/tests/e2e/smoke.test.ts new file mode 100644 index 0000000000..b0bba97f92 --- /dev/null +++ b/js/token-sdk/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-sdk/tests/e2e/transfer.test.ts b/js/token-sdk/tests/e2e/transfer.test.ts new file mode 100644 index 0000000000..f6d2b580b6 --- /dev/null +++ b/js/token-sdk/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-sdk/tests/unit/codecs.test.ts b/js/token-sdk/tests/unit/codecs.test.ts new file mode 100644 index 0000000000..92324e80c0 --- /dev/null +++ b/js/token-sdk/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-sdk/tests/unit/instructions.test.ts b/js/token-sdk/tests/unit/instructions.test.ts new file mode 100644 index 0000000000..59a2c7c507 --- /dev/null +++ b/js/token-sdk/tests/unit/instructions.test.ts @@ -0,0 +1,2464 @@ +/** + * 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, + + // 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, + COMPRESSION_MODE, + + // Codecs + getAmountInstructionCodec, + getCheckedInstructionCodec, + getDiscriminatorOnlyCodec, + decodeMaxTopUp, +} 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); + }); +}); + +// ============================================================================ +// 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'); + }); +}); + +// ============================================================================ +// 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); + }); +}); diff --git a/js/token-sdk/tests/unit/utils.test.ts b/js/token-sdk/tests/unit/utils.test.ts new file mode 100644 index 0000000000..5f80807939 --- /dev/null +++ b/js/token-sdk/tests/unit/utils.test.ts @@ -0,0 +1,326 @@ +/** + * 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, + validatePositiveAmount, + validateDecimals, + validateAtaDerivation, + isLightTokenAccount, + determineTransferType, + LIGHT_TOKEN_PROGRAM_ID, +} from '../../src/index.js'; + +// ============================================================================ +// TEST: PDA Derivation Functions +// ============================================================================ + +describe('deriveAssociatedTokenAddress', () => { + it('6.1 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('6.1.1 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('6.1.2 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('6.1.3 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('6.2 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('6.2.1 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('6.3 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('6.3.1 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('6.4 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('6.4.1 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('6.4.2 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('6.4.3 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('6.4.4 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('6.4.5 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); + }); +}); + +// ============================================================================ +// TEST: Validation Functions +// ============================================================================ + +describe('validatePositiveAmount', () => { + it('7.1 passes for positive amount', () => { + expect(() => validatePositiveAmount(1n)).not.toThrow(); + expect(() => validatePositiveAmount(100n)).not.toThrow(); + expect(() => + validatePositiveAmount(BigInt(Number.MAX_SAFE_INTEGER)), + ).not.toThrow(); + }); + + it('7.1.1 throws for zero', () => { + expect(() => validatePositiveAmount(0n)).toThrow( + 'Amount must be positive', + ); + }); + + it('7.1.2 throws for negative', () => { + expect(() => validatePositiveAmount(-1n)).toThrow( + 'Amount must be positive', + ); + expect(() => validatePositiveAmount(-100n)).toThrow( + 'Amount must be positive', + ); + }); +}); + +describe('validateDecimals', () => { + it('7.2 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('7.2.1 throws for negative decimals', () => { + expect(() => validateDecimals(-1)).toThrow( + 'Decimals must be an integer between 0 and 255', + ); + }); + + it('7.2.2 throws for decimals > 255', () => { + expect(() => validateDecimals(256)).toThrow( + 'Decimals must be an integer between 0 and 255', + ); + }); + + it('7.2.3 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('7.3 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('7.3.1 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('7.4 correctly identifies Light token accounts', () => { + expect(isLightTokenAccount(LIGHT_TOKEN_PROGRAM_ID)).toBe(true); + }); + + it('7.4.1 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('7.5 returns light-to-light for both Light accounts', () => { + expect(determineTransferType(lightProgram, lightProgram)).toBe( + 'light-to-light', + ); + }); + + it('7.5.1 returns light-to-spl for Light source, SPL dest', () => { + expect(determineTransferType(lightProgram, splProgram)).toBe( + 'light-to-spl', + ); + }); + + it('7.5.2 returns spl-to-light for SPL source, Light dest', () => { + expect(determineTransferType(splProgram, lightProgram)).toBe( + 'spl-to-light', + ); + }); + + it('7.5.3 returns spl-to-spl for both SPL accounts', () => { + expect(determineTransferType(splProgram, splProgram)).toBe( + 'spl-to-spl', + ); + }); +}); diff --git a/js/token-sdk/tsconfig.json b/js/token-sdk/tsconfig.json new file mode 100644 index 0000000000..780b37e2d1 --- /dev/null +++ b/js/token-sdk/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"] +} diff --git a/js/token-sdk/vitest.e2e.config.ts b/js/token-sdk/vitest.e2e.config.ts new file mode 100644 index 0000000000..c7fcfc3dce --- /dev/null +++ b/js/token-sdk/vitest.e2e.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + 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..f74192323a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -435,6 +435,128 @@ importers: specifier: ^2.1.1 version: 2.1.1(@types/node@22.16.5)(terser@5.43.1) + js/token-client: + dependencies: + '@lightprotocol/token-sdk': + specifier: workspace:* + version: link:../token-sdk + '@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) + '@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)) + devDependencies: + '@lightprotocol/compressed-token': + specifier: workspace:* + version: link:../compressed-token + '@lightprotocol/stateless.js': + specifier: workspace:* + version: link:../stateless.js + 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) + + js/token-idl: + 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 + '@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 + + js/token-sdk: + dependencies: + '@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) + '@solana/keys': + specifier: ^2.1.0 + version: 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/signers': + specifier: ^2.1.0 + version: 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/transaction-messages': + specifier: ^2.1.0 + version: 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + devDependencies: + '@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) + eslint: + specifier: ^9.36.0 + version: 9.36.0 + prettier: + specifier: ^3.3.3 + version: 3.6.2 + 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 +868,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 +2017,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 +2062,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 +2084,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 +2109,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 +2135,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 +2164,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 +2189,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 +2248,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 +2379,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 +2619,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 +2634,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 +3093,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} @@ -2708,6 +3133,10 @@ packages: resolution: {integrity: sha512-2JkV3gUZUVrbNA+1sjBOYLsMZ5cEEl8GTFP2a4AVz5hvasAMCQ1D2l2le/cX+pV4N6ZU17zjUahLpIXRrnWL8A==} engines: {node: '>=20'} + commander@14.0.2: + resolution: {integrity: sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==} + engines: {node: '>=20'} + commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -3014,6 +3443,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 +3646,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 +4473,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 +4495,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 +4571,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 +5481,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 +5624,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 +5812,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 +5879,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 +5937,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,6 +6816,65 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@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)': @@ -7488,6 +8032,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 +8098,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 +8128,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 +8163,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 +8198,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 +8237,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 @@ -7641,6 +8272,73 @@ snapshots: commander: 14.0.1 typescript: 5.9.2 + '@solana/errors@2.3.0(typescript@5.9.3)': + dependencies: + chalk: 5.6.2 + commander: 14.0.1 + 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 +8366,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 +8591,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 +8832,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 +8861,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 +8882,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 +8905,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 +8921,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 +8968,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 +9006,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 +9034,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 +9049,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 +9584,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 @@ -8568,6 +9622,8 @@ snapshots: commander@14.0.1: {} + commander@14.0.2: {} + commander@2.20.3: {} commander@5.1.0: {} @@ -9008,6 +10064,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 +10353,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 +11247,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 +11269,8 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + jsonify@0.0.1: {} + jsonparse@1.3.1: {} keyv@4.5.4: @@ -10266,6 +11336,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 +12266,8 @@ snapshots: stackback@0.0.2: {} + std-env@3.10.0: {} + std-env@3.7.0: {} stdin-discarder@0.2.2: {} @@ -11363,6 +12439,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 +12470,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 +12669,8 @@ snapshots: undici-types@6.21.0: {} + undici-types@7.19.0: {} + unicorn-magic@0.3.0: {} union@0.5.0: @@ -11653,6 +12737,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 +12797,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..771fd57a33 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,4 +5,7 @@ packages: - "sdk-tests/sdk-anchor-test/**" - "js/stateless.js/**" - "js/compressed-token/**" + - "js/token-idl/**" + - "js/token-sdk/**" + - "js/token-client/**" - "examples/**" diff --git a/scripts/lint.sh b/scripts/lint.sh index 80dfd168d8..6e62a2a7b8 100755 --- a/scripts/lint.sh +++ b/scripts/lint.sh @@ -2,9 +2,11 @@ set -e -# JS linting (use subshells to avoid directory issues) -(cd js/stateless.js && pnpm prettier --write . && pnpm lint) -(cd js/compressed-token && pnpm prettier --write . && pnpm lint) +# JS linting +cd js/stateless.js && pnpm prettier --check . && pnpm lint && cd ../.. +cd js/compressed-token && pnpm prettier --check . && pnpm lint && cd ../.. +cd js/token-idl && pnpm prettier --check . && pnpm lint && cd ../.. +cd js/token-sdk && pnpm prettier --check . && pnpm lint && cd ../.. # Rust linting cargo +nightly fmt --all -- --check