From 3883e4e305fd46bb593ef41c29a0fbcd1f1de220 Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Wed, 25 Mar 2026 21:31:58 +0000 Subject: [PATCH 01/24] init --- .gitignore | 1 + js/token-interface/README.md | 109 ++ js/token-interface/eslint.config.cjs | 113 ++ js/token-interface/package.json | 93 ++ js/token-interface/rollup.config.js | 79 + js/token-interface/src/account.ts | 241 ++++ js/token-interface/src/errors.ts | 14 + js/token-interface/src/helpers.ts | 54 + js/token-interface/src/index.ts | 4 + js/token-interface/src/instructions/index.ts | 318 ++++ .../src/instructions/nowrap/index.ts | 208 +++ .../src/instructions/raw/index.ts | 132 ++ js/token-interface/src/kit/index.ts | 109 ++ js/token-interface/src/load.ts | 65 + js/token-interface/src/read.ts | 24 + js/token-interface/src/types.ts | 111 ++ .../tests/e2e/approve-revoke.test.ts | 72 + js/token-interface/tests/e2e/ata-read.test.ts | 40 + .../tests/e2e/freeze-thaw.test.ts | 81 ++ js/token-interface/tests/e2e/helpers.ts | 186 +++ js/token-interface/tests/e2e/load.test.ts | 98 ++ js/token-interface/tests/e2e/transfer.test.ts | 245 ++++ js/token-interface/tests/unit/kit.test.ts | 43 + .../tests/unit/public-api.test.ts | 79 + js/token-interface/tests/unit/raw.test.ts | 96 ++ js/token-interface/tsconfig.json | 18 + js/token-interface/tsconfig.test.json | 8 + js/token-interface/vitest.config.ts | 26 + pnpm-lock.yaml | 1285 ++++++++++++++++- pnpm-workspace.yaml | 1 + 30 files changed, 3894 insertions(+), 59 deletions(-) create mode 100644 js/token-interface/README.md create mode 100644 js/token-interface/eslint.config.cjs create mode 100644 js/token-interface/package.json create mode 100644 js/token-interface/rollup.config.js create mode 100644 js/token-interface/src/account.ts create mode 100644 js/token-interface/src/errors.ts create mode 100644 js/token-interface/src/helpers.ts create mode 100644 js/token-interface/src/index.ts create mode 100644 js/token-interface/src/instructions/index.ts create mode 100644 js/token-interface/src/instructions/nowrap/index.ts create mode 100644 js/token-interface/src/instructions/raw/index.ts create mode 100644 js/token-interface/src/kit/index.ts create mode 100644 js/token-interface/src/load.ts create mode 100644 js/token-interface/src/read.ts create mode 100644 js/token-interface/src/types.ts create mode 100644 js/token-interface/tests/e2e/approve-revoke.test.ts create mode 100644 js/token-interface/tests/e2e/ata-read.test.ts create mode 100644 js/token-interface/tests/e2e/freeze-thaw.test.ts create mode 100644 js/token-interface/tests/e2e/helpers.ts create mode 100644 js/token-interface/tests/e2e/load.test.ts create mode 100644 js/token-interface/tests/e2e/transfer.test.ts create mode 100644 js/token-interface/tests/unit/kit.test.ts create mode 100644 js/token-interface/tests/unit/public-api.test.ts create mode 100644 js/token-interface/tests/unit/raw.test.ts create mode 100644 js/token-interface/tsconfig.json create mode 100644 js/token-interface/tsconfig.test.json create mode 100644 js/token-interface/vitest.config.ts diff --git a/.gitignore b/.gitignore index 17c82f39d6..c297e14be5 100644 --- a/.gitignore +++ b/.gitignore @@ -66,6 +66,7 @@ api-docs/ # Third-party dependencies /.local /.vscode +.cursor/ **/.idea **/*.iml diff --git a/js/token-interface/README.md b/js/token-interface/README.md new file mode 100644 index 0000000000..e71bed35db --- /dev/null +++ b/js/token-interface/README.md @@ -0,0 +1,109 @@ +# `@lightprotocol/token-interface` + +Payments-focused helpers for Light rent-free token flows. + +Use this when you want SPL-style transfers with unified sender handling: +- sender side auto wraps/loads into light ATA +- recipient ATA can be light (default), SPL, or Token-2022 via `tokenProgram` + +## RPC client (required) + +All builders expect `createRpc()` from `@lightprotocol/stateless.js`. + +```ts +import { createRpc } from '@lightprotocol/stateless.js'; + +// Add this to your client. It is a superset of web3.js Connection RPC plus Light APIs. +const rpc = createRpc(); +// Optional: createRpc(clusterUrl) +``` + +## Canonical for Kit users + +Use `getTransferInstructionPlan` from `/kit`. + +```ts +import { getTransferInstructionPlan } from '@lightprotocol/token-interface/kit'; + +const transferPlan = await getTransferInstructionPlan({ + rpc, + payer: payer.publicKey, + mint, + sourceOwner: sender.publicKey, + authority: sender.publicKey, + recipient: customer.publicKey, + // Optional destination program: + // tokenProgram: TOKEN_PROGRAM_ID + amount: 25n, +}); +``` + +If you prefer Kit instruction arrays instead of plans: + +```ts +import { buildTransferInstructions } from '@lightprotocol/token-interface/kit'; +``` + +## Canonical for web3.js users + +Use `buildTransferInstructions` from the root export. + +```ts +import { buildTransferInstructions } from '@lightprotocol/token-interface'; + +const instructions = await buildTransferInstructions({ + rpc, + payer: payer.publicKey, + mint, + sourceOwner: sender.publicKey, + authority: sender.publicKey, + recipient: customer.publicKey, + amount: 25n, +}); + +// add memo if needed, then build/sign/send transaction +``` + +Backwards-compatible alias: + +```ts +import { createTransferInstructions } from '@lightprotocol/token-interface'; +``` + +## Raw single-instruction helpers + +Use these when you want manual orchestration: + +```ts +import { + getCreateAtaInstruction, + getLoadInstruction, + getTransferInstruction, +} from '@lightprotocol/token-interface/instructions/raw'; +``` + +## No-wrap instruction-flow builders (advanced) + +If you explicitly want to disable automatic sender wrapping, use: + +```ts +import { buildTransferInstructions } from '@lightprotocol/token-interface/instructions/nowrap'; +``` + +## Read account + +```ts +import { getAta } from '@lightprotocol/token-interface'; + +const account = await getAta({ rpc, owner: customer.publicKey, mint }); +console.log(account.amount, account.hotAmount, account.compressedAmount); +``` + +## Important rules + +- Only one compressed sender account is loaded per call; smaller ones are ignored for that call. +- Transfer always builds checked semantics. +- Canonical builders always use wrap-enabled sender setup (`buildTransferInstructions`, `createLoadInstructions`, `createApproveInstructions`, `createRevokeInstructions`). +- If sender SPL/T22 balances were wrapped by the flow, source SPL/T22 ATAs are closed afterward. +- Recipient ATA is derived from `(recipient, mint, tokenProgram)`; default is light token program. +- Recipient-side load is still intentionally disabled. \ No newline at end of file diff --git a/js/token-interface/eslint.config.cjs b/js/token-interface/eslint.config.cjs new file mode 100644 index 0000000000..54e0f6819f --- /dev/null +++ b/js/token-interface/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', + 'jest.config.js', + 'rollup.config.js', + ], + }, + 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: [ + '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', + jest: '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, + }, + }, + { + 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, + }, + }, +]; diff --git a/js/token-interface/package.json b/js/token-interface/package.json new file mode 100644 index 0000000000..ed6bdba74a --- /dev/null +++ b/js/token-interface/package.json @@ -0,0 +1,93 @@ +{ + "name": "@lightprotocol/token-interface", + "version": "0.23.0", + "description": "JS enhancement layer for SPL and Token-2022 flows using Light rent-free token accounts", + "sideEffects": false, + "main": "dist/cjs/index.cjs", + "type": "module", + "exports": { + ".": { + "require": "./dist/cjs/index.cjs", + "import": "./dist/es/index.js", + "types": "./dist/types/index.d.ts" + }, + "./instructions": { + "require": "./dist/cjs/instructions/index.cjs", + "import": "./dist/es/instructions/index.js", + "types": "./dist/types/instructions/index.d.ts" + }, + "./instructions/raw": { + "require": "./dist/cjs/instructions/raw/index.cjs", + "import": "./dist/es/instructions/raw/index.js", + "types": "./dist/types/instructions/raw/index.d.ts" + }, + "./instructions/nowrap": { + "require": "./dist/cjs/instructions/nowrap/index.cjs", + "import": "./dist/es/instructions/nowrap/index.js", + "types": "./dist/types/instructions/nowrap/index.d.ts" + }, + "./kit": { + "require": "./dist/cjs/kit/index.cjs", + "import": "./dist/es/kit/index.js", + "types": "./dist/types/kit/index.d.ts" + } + }, + "types": "./dist/types/index.d.ts", + "files": [ + "dist" + ], + "maintainers": [ + { + "name": "Light Protocol Maintainers", + "email": "friends@lightprotocol.com" + } + ], + "license": "Apache-2.0", + "peerDependencies": { + "@lightprotocol/stateless.js": "workspace:*", + "@solana/spl-token": ">=0.3.9", + "@solana/web3.js": ">=1.73.5" + }, + "dependencies": { + "@lightprotocol/compressed-token": "workspace:*", + "@solana/compat": "^6.5.0", + "@solana/kit": "^6.5.0" + }, + "devDependencies": { + "@eslint/js": "9.36.0", + "@rollup/plugin-commonjs": "^26.0.1", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-typescript": "^11.1.6", + "@types/node": "^22.5.5", + "@typescript-eslint/eslint-plugin": "^8.44.0", + "@typescript-eslint/parser": "^8.44.0", + "eslint": "^9.36.0", + "eslint-plugin-vitest": "^0.5.4", + "prettier": "^3.3.3", + "rimraf": "^6.0.1", + "rollup": "^4.21.3", + "rollup-plugin-dts": "^6.1.1", + "tslib": "^2.7.0", + "typescript": "^5.6.2", + "vitest": "^2.1.1" + }, + "scripts": { + "build": "pnpm build:v2", + "build:v2": "pnpm build:deps:v2 && pnpm build:bundle", + "build:deps:v2": "pnpm --dir ../compressed-token build:v2", + "build:bundle": "rimraf dist && rollup -c", + "test": "pnpm test:unit:all && pnpm test:e2e:all", + "test:unit:all": "pnpm build:deps:v2 && LIGHT_PROTOCOL_VERSION=V2 EXCLUDE_E2E=true vitest run tests/unit --reporter=verbose", + "test:e2e:all": "pnpm build:deps:v2 && pnpm test-validator && LIGHT_PROTOCOL_VERSION=V2 vitest run tests/e2e --reporter=verbose --bail=1", + "test-validator": "./../../cli/test_bin/run test-validator", + "lint": "eslint .", + "format": "prettier --write ." + }, + "keywords": [ + "light", + "solana", + "token", + "interface", + "payments" + ] +} diff --git a/js/token-interface/rollup.config.js b/js/token-interface/rollup.config.js new file mode 100644 index 0000000000..a8b6a7b9da --- /dev/null +++ b/js/token-interface/rollup.config.js @@ -0,0 +1,79 @@ +import typescript from '@rollup/plugin-typescript'; +import dts from 'rollup-plugin-dts'; +import resolve from '@rollup/plugin-node-resolve'; +import commonjs from '@rollup/plugin-commonjs'; + +const inputs = { + index: 'src/index.ts', + 'instructions/index': 'src/instructions/index.ts', + 'instructions/nowrap/index': 'src/instructions/nowrap/index.ts', + 'instructions/raw/index': 'src/instructions/raw/index.ts', + 'kit/index': 'src/kit/index.ts', +}; + +const external = [ + '@lightprotocol/compressed-token', + '@lightprotocol/stateless.js', + '@solana/compat', + '@solana/kit', + '@solana/spl-token', + '@solana/web3.js', +]; + +const jsConfig = format => ({ + input: inputs, + output: { + dir: `dist/${format}`, + format, + entryFileNames: `[name].${format === 'cjs' ? 'cjs' : 'js'}`, + chunkFileNames: `[name]-[hash].${format === 'cjs' ? 'cjs' : 'js'}`, + sourcemap: true, + }, + external, + plugins: [ + typescript({ + target: format === 'es' ? 'ES2022' : 'ES2017', + outDir: `dist/${format}`, + }), + commonjs(), + resolve({ + extensions: ['.mjs', '.js', '.json', '.ts'], + }), + ], + onwarn(warning, warn) { + if (warning.code !== 'CIRCULAR_DEPENDENCY') { + warn(warning); + } + }, +}); + +const dtsEntry = (input, file) => ({ + input, + output: [{ file, format: 'es' }], + external, + plugins: [ + dts({ + respectExternal: true, + tsconfig: './tsconfig.json', + }), + ], +}); + +export default [ + jsConfig('cjs'), + jsConfig('es'), + dtsEntry('src/index.ts', 'dist/types/index.d.ts'), + dtsEntry( + 'src/instructions/index.ts', + 'dist/types/instructions/index.d.ts', + ), + dtsEntry( + 'src/instructions/raw/index.ts', + 'dist/types/instructions/raw/index.d.ts', + ), + dtsEntry( + 'src/instructions/nowrap/index.ts', + 'dist/types/instructions/nowrap/index.d.ts', + ), + dtsEntry('src/kit/index.ts', 'dist/types/kit/index.d.ts'), +]; diff --git a/js/token-interface/src/account.ts b/js/token-interface/src/account.ts new file mode 100644 index 0000000000..bb168c01c5 --- /dev/null +++ b/js/token-interface/src/account.ts @@ -0,0 +1,241 @@ +import { + getAssociatedTokenAddressInterface, + parseLightTokenCold, + parseLightTokenHot, +} from '@lightprotocol/compressed-token'; +import { + LIGHT_TOKEN_PROGRAM_ID, + type ParsedTokenAccount, + type Rpc, +} from '@lightprotocol/stateless.js'; +import { TokenAccountNotFoundError } from '@solana/spl-token'; +import type { PublicKey } from '@solana/web3.js'; +import type { + GetAtaInput, + TokenInterfaceAccount, + TokenInterfaceParsedAta, +} from './types'; + +const ZERO = BigInt(0); + +function toBigIntAmount(account: ParsedTokenAccount): bigint { + return BigInt(account.parsed.amount.toString()); +} + +function sortCompressedAccounts( + accounts: ParsedTokenAccount[], +): ParsedTokenAccount[] { + return [...accounts].sort((left, right) => { + const leftAmount = toBigIntAmount(left); + const rightAmount = toBigIntAmount(right); + + if (rightAmount > leftAmount) { + return 1; + } + + if (rightAmount < leftAmount) { + return -1; + } + + return ( + right.compressedAccount.leafIndex - left.compressedAccount.leafIndex + ); + }); +} + +function clampDelegatedAmount(amount: bigint, delegatedAmount: bigint): bigint { + return delegatedAmount < amount ? delegatedAmount : amount; +} + +function buildParsedAta( + address: PublicKey, + owner: PublicKey, + mint: PublicKey, + hotParsed: + | ReturnType['parsed'] + | null, + coldParsed: + | ReturnType['parsed'] + | null, +): TokenInterfaceParsedAta { + const hotAmount = hotParsed?.amount ?? ZERO; + const compressedAmount = coldParsed?.amount ?? ZERO; + const amount = hotAmount + compressedAmount; + + let delegate: PublicKey | null = null; + let delegatedAmount = ZERO; + + if (hotParsed?.delegate) { + delegate = hotParsed.delegate; + delegatedAmount = hotParsed.delegatedAmount ?? ZERO; + + if (coldParsed?.delegate?.equals(delegate)) { + delegatedAmount += clampDelegatedAmount( + coldParsed.amount, + coldParsed.delegatedAmount ?? coldParsed.amount, + ); + } + } else if (coldParsed?.delegate) { + delegate = coldParsed.delegate; + delegatedAmount = clampDelegatedAmount( + coldParsed.amount, + coldParsed.delegatedAmount ?? coldParsed.amount, + ); + } + + return { + address, + owner, + mint, + amount, + delegate, + delegatedAmount: clampDelegatedAmount(amount, delegatedAmount), + isInitialized: + hotParsed?.isInitialized === true || coldParsed !== null, + isFrozen: + hotParsed?.isFrozen === true || coldParsed?.isFrozen === true, + }; +} + +function selectPrimaryCompressedAccount( + accounts: ParsedTokenAccount[], +): { + selected: ParsedTokenAccount | null; + ignored: ParsedTokenAccount[]; +} { + const candidates = sortCompressedAccounts( + accounts.filter(account => { + return ( + account.compressedAccount.owner.equals(LIGHT_TOKEN_PROGRAM_ID) && + account.compressedAccount.data !== null && + account.compressedAccount.data.data.length > 0 && + toBigIntAmount(account) > ZERO + ); + }), + ); + + return { + selected: candidates[0] ?? null, + ignored: candidates.slice(1), + }; +} + +export async function getAtaOrNull({ + rpc, + owner, + mint, + commitment, +}: GetAtaInput): Promise { + const address = getAssociatedTokenAddressInterface(mint, owner); + + const [hotInfo, compressedResult] = await Promise.all([ + rpc.getAccountInfo(address, commitment), + rpc.getCompressedTokenAccountsByOwner(owner, { mint }), + ]); + + const hotParsed = + hotInfo && hotInfo.owner.equals(LIGHT_TOKEN_PROGRAM_ID) + ? parseLightTokenHot(address, hotInfo as any).parsed + : null; + + const { selected, ignored } = selectPrimaryCompressedAccount( + compressedResult.items, + ); + const coldParsed = selected + ? parseLightTokenCold(address, selected.compressedAccount).parsed + : null; + + if (!hotParsed && !coldParsed) { + return null; + } + + const parsed = buildParsedAta(address, owner, mint, hotParsed, coldParsed); + const ignoredCompressedAmount = ignored.reduce( + (sum, account) => sum + toBigIntAmount(account), + ZERO, + ); + + return { + address, + owner, + mint, + amount: parsed.amount, + hotAmount: hotParsed?.amount ?? ZERO, + compressedAmount: coldParsed?.amount ?? ZERO, + hasHotAccount: hotParsed !== null, + requiresLoad: coldParsed !== null, + parsed, + compressedAccount: selected, + ignoredCompressedAccounts: ignored, + ignoredCompressedAmount, + }; +} + +export async function getAta(input: GetAtaInput): Promise { + const account = await getAtaOrNull(input); + + if (!account) { + throw new TokenAccountNotFoundError(); + } + + return account; +} + +export function getSpendableAmount( + account: TokenInterfaceAccount, + authority: PublicKey, +): bigint { + if (authority.equals(account.owner)) { + return account.amount; + } + + if ( + account.parsed.delegate !== null && + authority.equals(account.parsed.delegate) + ) { + return clampDelegatedAmount(account.amount, account.parsed.delegatedAmount); + } + + return ZERO; +} + +export function assertAccountNotFrozen( + account: TokenInterfaceAccount, + operation: 'load' | 'transfer' | 'approve' | 'revoke', +): void { + if (account.parsed.isFrozen) { + throw new Error( + `Account is frozen; ${operation} is not allowed.`, + ); + } +} + +export function createSingleCompressedAccountRpc( + rpc: Rpc, + owner: PublicKey, + mint: PublicKey, + selected: ParsedTokenAccount, +): Rpc { + const filteredRpc = Object.create(rpc) as Rpc; + + filteredRpc.getCompressedTokenAccountsByOwner = async ( + queryOwner, + options, + ) => { + const result = await rpc.getCompressedTokenAccountsByOwner( + queryOwner, + options, + ); + + if (queryOwner.equals(owner) && options?.mint?.equals(mint)) { + return { + ...result, + items: [selected], + }; + } + + return result; + }; + + return filteredRpc; +} diff --git a/js/token-interface/src/errors.ts b/js/token-interface/src/errors.ts new file mode 100644 index 0000000000..3d7ec0589a --- /dev/null +++ b/js/token-interface/src/errors.ts @@ -0,0 +1,14 @@ +export class MultiTransactionNotSupportedError extends Error { + readonly operation: string; + readonly batchCount: number; + + constructor(operation: string, batchCount: number) { + super( + `${operation} requires ${batchCount} transactions with the current underlying interface builders. ` + + '@lightprotocol/token-interface only exposes single-transaction instruction builders.', + ); + this.name = 'MultiTransactionNotSupportedError'; + this.operation = operation; + this.batchCount = batchCount; + } +} diff --git a/js/token-interface/src/helpers.ts b/js/token-interface/src/helpers.ts new file mode 100644 index 0000000000..b733e74b7b --- /dev/null +++ b/js/token-interface/src/helpers.ts @@ -0,0 +1,54 @@ +import { + type InterfaceOptions, + getMintInterface, +} from '@lightprotocol/compressed-token'; +import { ComputeBudgetProgram, PublicKey } from '@solana/web3.js'; +import type { Rpc } from '@lightprotocol/stateless.js'; +import type { TransactionInstruction } from '@solana/web3.js'; +import { MultiTransactionNotSupportedError } from './errors'; + +export async function getMintDecimals( + rpc: Rpc, + mint: PublicKey, +): Promise { + const mintInterface = await getMintInterface(rpc, mint); + return mintInterface.mint.decimals; +} + +export function toInterfaceOptions( + owner: PublicKey, + authority?: PublicKey, + wrap = false, +): InterfaceOptions | undefined { + if ((!authority || authority.equals(owner)) && !wrap) { + return undefined; + } + + const options: InterfaceOptions = {}; + if (wrap) { + options.wrap = true; + } + if (authority && !authority.equals(owner)) { + options.delegatePubkey = authority; + } + + return options; +} + +export function normalizeInstructionBatches( + operation: string, + batches: TransactionInstruction[][], +): TransactionInstruction[] { + if (batches.length === 0) { + return []; + } + + if (batches.length > 1) { + throw new MultiTransactionNotSupportedError(operation, batches.length); + } + + return batches[0].filter( + instruction => + !instruction.programId.equals(ComputeBudgetProgram.programId), + ); +} diff --git a/js/token-interface/src/index.ts b/js/token-interface/src/index.ts new file mode 100644 index 0000000000..b4e87ded27 --- /dev/null +++ b/js/token-interface/src/index.ts @@ -0,0 +1,4 @@ +export * from './types'; +export * from './errors'; +export * from './read'; +export * from './instructions'; diff --git a/js/token-interface/src/instructions/index.ts b/js/token-interface/src/instructions/index.ts new file mode 100644 index 0000000000..720c747ad2 --- /dev/null +++ b/js/token-interface/src/instructions/index.ts @@ -0,0 +1,318 @@ +import type { TransactionInstruction } from '@solana/web3.js'; +import { LIGHT_TOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; +import { + createUnwrapInstruction, + getSplInterfaceInfos, +} from '@lightprotocol/compressed-token'; +import { + TOKEN_2022_PROGRAM_ID, + TOKEN_PROGRAM_ID, + createCloseAccountInstruction, + unpackAccount, +} from '@solana/spl-token'; +import { assertAccountNotFrozen, getAta } from '../account'; +import { getMintDecimals } from '../helpers'; +import { createLoadInstructionInternal } from '../load'; +import { getAtaAddress } from '../read'; +import type { + CreateApproveInstructionsInput, + CreateAtaInstructionsInput, + CreateFreezeInstructionsInput, + CreateLoadInstructionsInput, + CreateRevokeInstructionsInput, + CreateThawInstructionsInput, + CreateTransferInstructionsInput, +} from '../types'; +import { + createApproveInstruction, + createAtaInstruction, + createFreezeInstruction, + createRevokeInstruction, + createThawInstruction, + createTransferCheckedInstruction, +} from './raw'; + +/* + * Canonical async instruction builders: transfer, approve, and revoke prepend sender-side load + * instructions when cold storage must be decompressed first. createAtaInstructions only emits the + * ATA creation instruction. createFreezeInstructions and createThawInstructions do not load. + * For manual load-only composition, see createLoadInstructions. + */ + +const ZERO = BigInt(0); + +function toBigIntAmount(amount: number | bigint): bigint { + return BigInt(amount.toString()); +} + +async function buildLoadInstructions( + input: CreateLoadInstructionsInput & { + authority?: CreateTransferInstructionsInput['authority']; + account?: Awaited>; + wrap?: boolean; + }, +): Promise { + const load = await createLoadInstructionInternal(input); + + if (!load) { + return []; + } + + return load.instructions; +} + +async function getDerivedAtaBalance( + rpc: CreateTransferInstructionsInput['rpc'], + owner: CreateTransferInstructionsInput['sourceOwner'], + mint: CreateTransferInstructionsInput['mint'], + programId: typeof TOKEN_PROGRAM_ID | typeof TOKEN_2022_PROGRAM_ID, +): Promise { + const ata = getAtaAddress({ owner, mint, programId }); + const info = await rpc.getAccountInfo(ata); + if (!info || !info.owner.equals(programId)) { + return ZERO; + } + + return unpackAccount(ata, info, programId).amount; +} + +export async function createAtaInstructions({ + payer, + owner, + mint, + programId, +}: CreateAtaInstructionsInput): Promise { + return [createAtaInstruction({ payer, owner, mint, programId })]; +} + +/** + * Advanced: standalone load (decompress) instructions for an ATA, plus create-ATA if missing. + * Prefer the canonical builders (`buildTransferInstructions`, `createApproveInstructions`, + * `createRevokeInstructions`, …), which prepend load automatically when needed. + */ +export async function createLoadInstructions({ + rpc, + payer, + owner, + mint, +}: CreateLoadInstructionsInput): Promise { + return buildLoadInstructions({ + rpc, + payer, + owner, + mint, + wrap: true, + }); +} + +/** + * Canonical web3.js transfer flow builder. + * Returns an instruction array for a single transfer flow (setup + transfer). + */ +export async function buildTransferInstructions({ + rpc, + payer, + mint, + sourceOwner, + authority, + recipient, + tokenProgram, + amount, +}: CreateTransferInstructionsInput): Promise { + const amountBigInt = toBigIntAmount(amount); + const senderLoadInstructions = await buildLoadInstructions({ + rpc, + payer, + owner: sourceOwner, + mint, + authority, + wrap: true, + }); + const recipientTokenProgramId = tokenProgram ?? LIGHT_TOKEN_PROGRAM_ID; + const recipientAta = getAtaAddress({ + owner: recipient, + mint, + programId: recipientTokenProgramId, + }); + const decimals = await getMintDecimals(rpc, mint); + const [senderSplBalance, senderT22Balance] = await Promise.all([ + getDerivedAtaBalance(rpc, sourceOwner, mint, TOKEN_PROGRAM_ID), + getDerivedAtaBalance(rpc, sourceOwner, mint, TOKEN_2022_PROGRAM_ID), + ]); + + const closeWrappedSourceInstructions: TransactionInstruction[] = []; + if (authority.equals(sourceOwner) && senderSplBalance > ZERO) { + closeWrappedSourceInstructions.push( + createCloseAccountInstruction( + getAtaAddress({ + owner: sourceOwner, + mint, + programId: TOKEN_PROGRAM_ID, + }), + sourceOwner, + sourceOwner, + [], + TOKEN_PROGRAM_ID, + ), + ); + } + if (authority.equals(sourceOwner) && senderT22Balance > ZERO) { + closeWrappedSourceInstructions.push( + createCloseAccountInstruction( + getAtaAddress({ + owner: sourceOwner, + mint, + programId: TOKEN_2022_PROGRAM_ID, + }), + sourceOwner, + sourceOwner, + [], + TOKEN_2022_PROGRAM_ID, + ), + ); + } + + const recipientLoadInstructions: TransactionInstruction[] = []; + // Recipient-side load is intentionally disabled until the program allows + // third-party load on behalf of the recipient ATA. + // const recipientLoadInstructions = await buildLoadInstructions({ + // rpc, + // payer, + // owner: recipient, + // mint, + // }); + const senderAta = getAtaAddress({ + owner: sourceOwner, + mint, + }); + let transferInstruction: TransactionInstruction; + if (recipientTokenProgramId.equals(LIGHT_TOKEN_PROGRAM_ID)) { + transferInstruction = createTransferCheckedInstruction({ + source: senderAta, + destination: recipientAta, + mint, + authority, + payer, + amount: amountBigInt, + decimals, + }); + } else { + const splInterfaceInfos = await getSplInterfaceInfos(rpc, mint); + const splInterfaceInfo = splInterfaceInfos.find( + info => + info.isInitialized && + info.tokenProgram.equals(recipientTokenProgramId), + ); + if (!splInterfaceInfo) { + throw new Error( + `No initialized SPL interface found for tokenProgram ${recipientTokenProgramId.toBase58()}.`, + ); + } + transferInstruction = createUnwrapInstruction( + senderAta, + recipientAta, + authority, + mint, + amountBigInt, + splInterfaceInfo, + decimals, + payer, + ); + } + + return [ + ...senderLoadInstructions, + ...closeWrappedSourceInstructions, + createAtaInstruction({ + payer, + owner: recipient, + mint, + programId: recipientTokenProgramId, + }), + ...recipientLoadInstructions, + transferInstruction, + ]; +} + +/** + * Backwards-compatible alias. + */ +export const createTransferInstructions = buildTransferInstructions; + +export async function createApproveInstructions({ + rpc, + payer, + owner, + mint, + delegate, + amount, +}: CreateApproveInstructionsInput): Promise { + const account = await getAta({ + rpc, + owner, + mint, + }); + + assertAccountNotFrozen(account, 'approve'); + + return [ + ...(await buildLoadInstructions({ + rpc, + payer, + owner, + mint, + account, + wrap: true, + })), + createApproveInstruction({ + tokenAccount: account.address, + delegate, + owner, + amount: toBigIntAmount(amount), + payer, + }), + ]; +} + +export async function createRevokeInstructions({ + rpc, + payer, + owner, + mint, +}: CreateRevokeInstructionsInput): Promise { + const account = await getAta({ + rpc, + owner, + mint, + }); + + assertAccountNotFrozen(account, 'revoke'); + + return [ + ...(await buildLoadInstructions({ + rpc, + payer, + owner, + mint, + account, + wrap: true, + })), + createRevokeInstruction({ + tokenAccount: account.address, + owner, + payer, + }), + ]; +} + +export async function createFreezeInstructions( + input: CreateFreezeInstructionsInput, +): Promise { + return [createFreezeInstruction(input)]; +} + +export async function createThawInstructions( + input: CreateThawInstructionsInput, +): Promise { + return [createThawInstruction(input)]; +} diff --git a/js/token-interface/src/instructions/nowrap/index.ts b/js/token-interface/src/instructions/nowrap/index.ts new file mode 100644 index 0000000000..a46e8e9407 --- /dev/null +++ b/js/token-interface/src/instructions/nowrap/index.ts @@ -0,0 +1,208 @@ +import type { TransactionInstruction } from '@solana/web3.js'; +import { LIGHT_TOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; +import { + createUnwrapInstruction, + getSplInterfaceInfos, +} from '@lightprotocol/compressed-token'; +import { getMintDecimals } from '../../helpers'; +import { createLoadInstructionInternal } from '../../load'; +import { getAtaAddress } from '../../read'; +import type { + CreateApproveInstructionsInput, + CreateLoadInstructionsInput, + CreateRevokeInstructionsInput, + CreateTransferInstructionsInput, +} from '../../types'; +import { + createApproveInstruction, + createTransferCheckedInstruction, + createRevokeInstruction, +} from '../raw'; +import { + createAtaInstructions, + createFreezeInstructions, + createThawInstructions, +} from '../index'; +import { getAta } from '../../account'; + +function toBigIntAmount(amount: number | bigint): bigint { + return BigInt(amount.toString()); +} + +async function buildLoadInstructionsNoWrap( + input: CreateLoadInstructionsInput & { + authority?: CreateTransferInstructionsInput['authority']; + account?: Awaited>; + }, +): Promise { + const load = await createLoadInstructionInternal({ + ...input, + wrap: false, + }); + + if (!load) { + return []; + } + + return load.instructions; +} + +/** + * Advanced no-wrap load helper. + */ +export async function createLoadInstructions({ + rpc, + payer, + owner, + mint, +}: CreateLoadInstructionsInput): Promise { + return buildLoadInstructionsNoWrap({ + rpc, + payer, + owner, + mint, + }); +} + +/** + * No-wrap transfer flow builder. + */ +export async function buildTransferInstructions({ + rpc, + payer, + mint, + sourceOwner, + authority, + recipient, + tokenProgram, + amount, +}: CreateTransferInstructionsInput): Promise { + const amountBigInt = toBigIntAmount(amount); + const senderLoadInstructions = await buildLoadInstructionsNoWrap({ + rpc, + payer, + owner: sourceOwner, + mint, + authority, + }); + + const recipientTokenProgramId = tokenProgram ?? LIGHT_TOKEN_PROGRAM_ID; + const recipientAta = getAtaAddress({ + owner: recipient, + mint, + programId: recipientTokenProgramId, + }); + const decimals = await getMintDecimals(rpc, mint); + const senderAta = getAtaAddress({ + owner: sourceOwner, + mint, + }); + + let transferInstruction: TransactionInstruction; + if (recipientTokenProgramId.equals(LIGHT_TOKEN_PROGRAM_ID)) { + transferInstruction = createTransferCheckedInstruction({ + source: senderAta, + destination: recipientAta, + mint, + authority, + payer, + amount: amountBigInt, + decimals, + }); + } else { + const splInterfaceInfos = await getSplInterfaceInfos(rpc, mint); + const splInterfaceInfo = splInterfaceInfos.find( + info => + info.isInitialized && + info.tokenProgram.equals(recipientTokenProgramId), + ); + if (!splInterfaceInfo) { + throw new Error( + `No initialized SPL interface found for tokenProgram ${recipientTokenProgramId.toBase58()}.`, + ); + } + transferInstruction = createUnwrapInstruction( + senderAta, + recipientAta, + authority, + mint, + amountBigInt, + splInterfaceInfo, + decimals, + payer, + ); + } + + return [ + ...senderLoadInstructions, + transferInstruction, + ]; +} + +export const createTransferInstructions = buildTransferInstructions; + +export async function createApproveInstructions({ + rpc, + payer, + owner, + mint, + delegate, + amount, +}: CreateApproveInstructionsInput): Promise { + const account = await getAta({ + rpc, + owner, + mint, + }); + + return [ + ...(await buildLoadInstructionsNoWrap({ + rpc, + payer, + owner, + mint, + account, + })), + createApproveInstruction({ + tokenAccount: account.address, + delegate, + owner, + amount: toBigIntAmount(amount), + payer, + }), + ]; +} + +export async function createRevokeInstructions({ + rpc, + payer, + owner, + mint, +}: CreateRevokeInstructionsInput): Promise { + const account = await getAta({ + rpc, + owner, + mint, + }); + + return [ + ...(await buildLoadInstructionsNoWrap({ + rpc, + payer, + owner, + mint, + account, + })), + createRevokeInstruction({ + tokenAccount: account.address, + owner, + payer, + }), + ]; +} + +export { + createAtaInstructions, + createFreezeInstructions, + createThawInstructions, +}; diff --git a/js/token-interface/src/instructions/raw/index.ts b/js/token-interface/src/instructions/raw/index.ts new file mode 100644 index 0000000000..9a322988c5 --- /dev/null +++ b/js/token-interface/src/instructions/raw/index.ts @@ -0,0 +1,132 @@ +import { + createAssociatedTokenAccountInterfaceIdempotentInstruction, + createLightTokenApproveInstruction, + createLightTokenFreezeAccountInstruction, + createLightTokenRevokeInstruction, + createLightTokenThawAccountInstruction, + createLightTokenTransferCheckedInstruction, +} from '@lightprotocol/compressed-token'; +import { LIGHT_TOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; +import type { TransactionInstruction } from '@solana/web3.js'; +import { createLoadInstructionInternal } from '../../load'; +import { getAtaAddress } from '../../read'; +import type { + CreateFreezeInstructionsInput, + CreateRawApproveInstructionInput, + CreateRawAtaInstructionInput, + CreateRawLoadInstructionInput, + CreateRawRevokeInstructionInput, + CreateRawTransferInstructionInput, + CreateThawInstructionsInput, +} from '../../types'; + +export function createAtaInstruction({ + payer, + owner, + mint, + programId, +}: CreateRawAtaInstructionInput): TransactionInstruction { + const targetProgramId = programId ?? LIGHT_TOKEN_PROGRAM_ID; + const associatedToken = getAtaAddress({ + owner, + mint, + programId: targetProgramId, + }); + + return createAssociatedTokenAccountInterfaceIdempotentInstruction( + payer, + associatedToken, + owner, + mint, + targetProgramId, + ); +} + +export async function createLoadInstruction({ + rpc, + payer, + owner, + mint, +}: CreateRawLoadInstructionInput): Promise { + const load = await createLoadInstructionInternal({ + rpc, + payer, + owner, + mint, + }); + + return load?.instructions[load.instructions.length - 1] ?? null; +} + +export function createTransferCheckedInstruction({ + source, + destination, + mint, + authority, + payer, + amount, + decimals, +}: CreateRawTransferInstructionInput): TransactionInstruction { + return createLightTokenTransferCheckedInstruction( + source, + destination, + mint, + authority, + amount, + decimals, + payer, + ); +} + +export const createTransferInstruction = createTransferCheckedInstruction; +export const getTransferInstruction = createTransferCheckedInstruction; +export const getLoadInstruction = createLoadInstruction; +export const getCreateAtaInstruction = createAtaInstruction; + +export function createApproveInstruction({ + tokenAccount, + delegate, + owner, + amount, + payer, +}: CreateRawApproveInstructionInput): TransactionInstruction { + return createLightTokenApproveInstruction( + tokenAccount, + delegate, + owner, + amount, + payer, + ); +} + +export function createRevokeInstruction({ + tokenAccount, + owner, + payer, +}: CreateRawRevokeInstructionInput): TransactionInstruction { + return createLightTokenRevokeInstruction(tokenAccount, owner, payer); +} + +export function createFreezeInstruction({ + tokenAccount, + mint, + freezeAuthority, +}: CreateFreezeInstructionsInput): TransactionInstruction { + return createLightTokenFreezeAccountInstruction( + tokenAccount, + mint, + freezeAuthority, + ); +} + +export function createThawInstruction({ + tokenAccount, + mint, + freezeAuthority, +}: CreateThawInstructionsInput): TransactionInstruction { + return createLightTokenThawAccountInstruction( + tokenAccount, + mint, + freezeAuthority, + ); +} diff --git a/js/token-interface/src/kit/index.ts b/js/token-interface/src/kit/index.ts new file mode 100644 index 0000000000..a410ff8fb1 --- /dev/null +++ b/js/token-interface/src/kit/index.ts @@ -0,0 +1,109 @@ +import { fromLegacyTransactionInstruction } from '@solana/compat'; +import { + sequentialInstructionPlan, + type InstructionPlan, +} from '@solana/kit'; +import type { TransactionInstruction } from '@solana/web3.js'; +import { + createApproveInstructions as createLegacyApproveInstructions, + buildTransferInstructions as buildLegacyTransferInstructions, + createAtaInstructions as createLegacyAtaInstructions, + createFreezeInstructions as createLegacyFreezeInstructions, + createLoadInstructions as createLegacyLoadInstructions, + createRevokeInstructions as createLegacyRevokeInstructions, + createThawInstructions as createLegacyThawInstructions, +} from '../instructions'; +import type { + CreateApproveInstructionsInput, + CreateAtaInstructionsInput, + CreateFreezeInstructionsInput, + CreateLoadInstructionsInput, + CreateRevokeInstructionsInput, + CreateThawInstructionsInput, + CreateTransferInstructionsInput, +} from '../types'; + +export type KitInstruction = ReturnType; + +export function toKitInstructions( + instructions: TransactionInstruction[], +): KitInstruction[] { + return instructions.map(instruction => + fromLegacyTransactionInstruction(instruction), + ); +} + +export async function createAtaInstructions( + input: CreateAtaInstructionsInput, +): Promise { + return toKitInstructions(await createLegacyAtaInstructions(input)); +} + +/** + * Advanced: standalone load (decompress) instructions for an ATA, plus create-ATA if missing. + * Prefer the canonical builders (`buildTransferInstructions`, `createApproveInstructions`, + * `createRevokeInstructions`, …), which prepend load automatically when needed. + */ +export async function createLoadInstructions( + input: CreateLoadInstructionsInput, +): Promise { + return toKitInstructions(await createLegacyLoadInstructions(input)); +} + +/** + * Canonical Kit instruction-array builder. + * Returns Kit instructions (not an InstructionPlan). + */ +export async function buildTransferInstructions( + input: CreateTransferInstructionsInput, +): Promise { + return toKitInstructions(await buildLegacyTransferInstructions(input)); +} + +/** + * Backwards-compatible alias. + */ +export const createTransferInstructions = buildTransferInstructions; + +/** + * Canonical Kit plan builder. + */ +export async function getTransferInstructionPlan( + input: CreateTransferInstructionsInput, +): Promise { + return sequentialInstructionPlan(await buildTransferInstructions(input)); +} + +export async function createApproveInstructions( + input: CreateApproveInstructionsInput, +): Promise { + return toKitInstructions(await createLegacyApproveInstructions(input)); +} + +export async function createRevokeInstructions( + input: CreateRevokeInstructionsInput, +): Promise { + return toKitInstructions(await createLegacyRevokeInstructions(input)); +} + +export async function createFreezeInstructions( + input: CreateFreezeInstructionsInput, +): Promise { + return toKitInstructions(await createLegacyFreezeInstructions(input)); +} + +export async function createThawInstructions( + input: CreateThawInstructionsInput, +): Promise { + return toKitInstructions(await createLegacyThawInstructions(input)); +} + +export type { + CreateApproveInstructionsInput, + CreateAtaInstructionsInput, + CreateFreezeInstructionsInput, + CreateLoadInstructionsInput, + CreateRevokeInstructionsInput, + CreateThawInstructionsInput, + CreateTransferInstructionsInput, +}; diff --git a/js/token-interface/src/load.ts b/js/token-interface/src/load.ts new file mode 100644 index 0000000000..f305ef2bff --- /dev/null +++ b/js/token-interface/src/load.ts @@ -0,0 +1,65 @@ +import { createLoadAtaInstructions } from '@lightprotocol/compressed-token'; +import type { PublicKey, TransactionInstruction } from '@solana/web3.js'; +import { createSingleCompressedAccountRpc, getAtaOrNull } from './account'; +import { normalizeInstructionBatches, toInterfaceOptions } from './helpers'; +import { getAtaAddress } from './read'; +import type { + CreateLoadInstructionsInput, + TokenInterfaceAccount, +} from './types'; + +interface CreateLoadInstructionInternalInput extends CreateLoadInstructionsInput { + authority?: PublicKey; + account?: TokenInterfaceAccount | null; + wrap?: boolean; +} + +export async function createLoadInstructionInternal({ + rpc, + payer, + owner, + mint, + authority, + account, + wrap = false, +}: CreateLoadInstructionInternalInput): Promise<{ + instructions: TransactionInstruction[]; +} | null> { + const resolvedAccount = + account ?? + (await getAtaOrNull({ + rpc, + owner, + mint, + })); + const targetAta = getAtaAddress({ owner, mint }); + + const effectiveRpc = + resolvedAccount && resolvedAccount.compressedAccount + ? createSingleCompressedAccountRpc( + rpc, + owner, + mint, + resolvedAccount.compressedAccount, + ) + : rpc; + const instructions = normalizeInstructionBatches( + 'createLoadInstruction', + await createLoadAtaInstructions( + effectiveRpc, + targetAta, + owner, + mint, + payer, + toInterfaceOptions(owner, authority, wrap), + ), + ); + + if (instructions.length === 0) { + return null; + } + + return { + instructions, + }; +} diff --git a/js/token-interface/src/read.ts b/js/token-interface/src/read.ts new file mode 100644 index 0000000000..9400e7781c --- /dev/null +++ b/js/token-interface/src/read.ts @@ -0,0 +1,24 @@ +import { + getAssociatedTokenAddressInterface, +} from '@lightprotocol/compressed-token'; +import type { PublicKey } from '@solana/web3.js'; +import { getAta as getTokenInterfaceAta } from './account'; +import type { AtaOwnerInput, GetAtaInput, TokenInterfaceAccount } from './types'; + +export function getAtaAddress({ mint, owner, programId }: AtaOwnerInput): PublicKey { + return getAssociatedTokenAddressInterface(mint, owner, false, programId); +} + +export async function getAta({ + rpc, + owner, + mint, + commitment, +}: GetAtaInput): Promise { + return getTokenInterfaceAta({ + rpc, + owner, + mint, + commitment, + }); +} diff --git a/js/token-interface/src/types.ts b/js/token-interface/src/types.ts new file mode 100644 index 0000000000..5af45d30d2 --- /dev/null +++ b/js/token-interface/src/types.ts @@ -0,0 +1,111 @@ +import type { ParsedTokenAccount, Rpc } from '@lightprotocol/stateless.js'; +import type { Commitment, PublicKey } from '@solana/web3.js'; + +export interface TokenInterfaceParsedAta { + address: PublicKey; + owner: PublicKey; + mint: PublicKey; + amount: bigint; + delegate: PublicKey | null; + delegatedAmount: bigint; + isInitialized: boolean; + isFrozen: boolean; +} + +export interface TokenInterfaceAccount { + address: PublicKey; + owner: PublicKey; + mint: PublicKey; + amount: bigint; + hotAmount: bigint; + compressedAmount: bigint; + hasHotAccount: boolean; + requiresLoad: boolean; + parsed: TokenInterfaceParsedAta; + compressedAccount: ParsedTokenAccount | null; + ignoredCompressedAccounts: ParsedTokenAccount[]; + ignoredCompressedAmount: bigint; +} + +export interface AtaOwnerInput { + owner: PublicKey; + mint: PublicKey; + programId?: PublicKey; +} + +export interface GetAtaInput extends AtaOwnerInput { + rpc: Rpc; + commitment?: Commitment; +} + +export interface CreateAtaInstructionsInput extends AtaOwnerInput { + payer: PublicKey; + programId?: PublicKey; +} + +export interface CreateLoadInstructionsInput extends AtaOwnerInput { + rpc: Rpc; + payer: PublicKey; +} + +export interface CreateTransferInstructionsInput { + rpc: Rpc; + payer: PublicKey; + mint: PublicKey; + sourceOwner: PublicKey; + authority: PublicKey; + recipient: PublicKey; + tokenProgram?: PublicKey; + amount: number | bigint; +} + +export interface CreateApproveInstructionsInput extends AtaOwnerInput { + rpc: Rpc; + payer: PublicKey; + delegate: PublicKey; + amount: number | bigint; +} + +export interface CreateRevokeInstructionsInput extends AtaOwnerInput { + rpc: Rpc; + payer: PublicKey; +} + +export interface CreateFreezeInstructionsInput { + tokenAccount: PublicKey; + mint: PublicKey; + freezeAuthority: PublicKey; +} + +export interface CreateThawInstructionsInput { + tokenAccount: PublicKey; + mint: PublicKey; + freezeAuthority: PublicKey; +} + +export type CreateRawAtaInstructionInput = CreateAtaInstructionsInput; +export type CreateRawLoadInstructionInput = CreateLoadInstructionsInput; + +export interface CreateRawTransferInstructionInput { + source: PublicKey; + destination: PublicKey; + mint: PublicKey; + authority: PublicKey; + payer: PublicKey; + amount: number | bigint; + decimals: number; +} + +export interface CreateRawApproveInstructionInput { + tokenAccount: PublicKey; + delegate: PublicKey; + owner: PublicKey; + amount: number | bigint; + payer?: PublicKey; +} + +export interface CreateRawRevokeInstructionInput { + tokenAccount: PublicKey; + owner: PublicKey; + payer?: PublicKey; +} diff --git a/js/token-interface/tests/e2e/approve-revoke.test.ts b/js/token-interface/tests/e2e/approve-revoke.test.ts new file mode 100644 index 0000000000..db20219d90 --- /dev/null +++ b/js/token-interface/tests/e2e/approve-revoke.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; +import { ComputeBudgetProgram, Keypair } from '@solana/web3.js'; +import { newAccountWithLamports } from '@lightprotocol/stateless.js'; +import { + createApproveInstructions, + createRevokeInstructions, + getAtaAddress, +} from '../../src'; +import { + createMintFixture, + getHotDelegate, + mintCompressedToOwner, + sendInstructions, +} from './helpers'; + +describe('approve and revoke instructions', () => { + it('approves and revokes on the canonical ata', async () => { + const fixture = await createMintFixture(); + const owner = await newAccountWithLamports(fixture.rpc, 1e9); + const delegate = Keypair.generate(); + const tokenAccount = getAtaAddress({ + owner: owner.publicKey, + mint: fixture.mint, + }); + + await mintCompressedToOwner(fixture, owner.publicKey, 4_000n); + + const approveInstructions = await createApproveInstructions({ + rpc: fixture.rpc, + payer: fixture.payer.publicKey, + owner: owner.publicKey, + mint: fixture.mint, + delegate: delegate.publicKey, + amount: 1_500n, + }); + + expect( + approveInstructions.some(instruction => + instruction.programId.equals(ComputeBudgetProgram.programId), + ), + ).toBe(false); + + await sendInstructions(fixture.rpc, fixture.payer, approveInstructions, [ + owner, + ]); + + const delegated = await getHotDelegate(fixture.rpc, tokenAccount); + expect(delegated.delegate?.toBase58()).toBe(delegate.publicKey.toBase58()); + expect(delegated.delegatedAmount).toBe(1_500n); + + const revokeInstructions = await createRevokeInstructions({ + rpc: fixture.rpc, + payer: fixture.payer.publicKey, + owner: owner.publicKey, + mint: fixture.mint, + }); + + expect( + revokeInstructions.some(instruction => + instruction.programId.equals(ComputeBudgetProgram.programId), + ), + ).toBe(false); + + await sendInstructions(fixture.rpc, fixture.payer, revokeInstructions, [ + owner, + ]); + + const revoked = await getHotDelegate(fixture.rpc, tokenAccount); + expect(revoked.delegate).toBeNull(); + expect(revoked.delegatedAmount).toBe(0n); + }); +}); diff --git a/js/token-interface/tests/e2e/ata-read.test.ts b/js/token-interface/tests/e2e/ata-read.test.ts new file mode 100644 index 0000000000..7df37a66ed --- /dev/null +++ b/js/token-interface/tests/e2e/ata-read.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { newAccountWithLamports } from '@lightprotocol/stateless.js'; +import { + createAtaInstructions, + getAta, + getAtaAddress, +} from '../../src'; +import { createMintFixture, sendInstructions } from './helpers'; + +describe('ata creation and reads', () => { + it('creates the canonical ata and reads it back', async () => { + const fixture = await createMintFixture(); + const owner = await newAccountWithLamports(fixture.rpc, 1e9); + const ata = getAtaAddress({ + owner: owner.publicKey, + mint: fixture.mint, + }); + + const instructions = await createAtaInstructions({ + payer: fixture.payer.publicKey, + owner: owner.publicKey, + mint: fixture.mint, + }); + + expect(instructions).toHaveLength(1); + + await sendInstructions(fixture.rpc, fixture.payer, instructions); + + const account = await getAta({ + rpc: fixture.rpc, + owner: owner.publicKey, + mint: fixture.mint, + }); + + expect(account.parsed.address.toBase58()).toBe(ata.toBase58()); + expect(account.parsed.owner.toBase58()).toBe(owner.publicKey.toBase58()); + expect(account.parsed.mint.toBase58()).toBe(fixture.mint.toBase58()); + expect(account.parsed.amount).toBe(0n); + }); +}); diff --git a/js/token-interface/tests/e2e/freeze-thaw.test.ts b/js/token-interface/tests/e2e/freeze-thaw.test.ts new file mode 100644 index 0000000000..7d29a533d7 --- /dev/null +++ b/js/token-interface/tests/e2e/freeze-thaw.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; +import { AccountState } from '@solana/spl-token'; +import { newAccountWithLamports } from '@lightprotocol/stateless.js'; +import { + createAtaInstructions, + createFreezeInstructions, + createLoadInstructions, + createThawInstructions, + getAtaAddress, +} from '../../src'; +import { + createMintFixture, + getHotState, + mintCompressedToOwner, + sendInstructions, +} from './helpers'; + +describe('freeze and thaw instructions', () => { + it('freezes and thaws a loaded hot account', async () => { + const fixture = await createMintFixture({ withFreezeAuthority: true }); + const owner = await newAccountWithLamports(fixture.rpc, 1e9); + const tokenAccount = getAtaAddress({ + owner: owner.publicKey, + mint: fixture.mint, + }); + + await sendInstructions( + fixture.rpc, + fixture.payer, + await createAtaInstructions({ + payer: fixture.payer.publicKey, + owner: owner.publicKey, + mint: fixture.mint, + }), + ); + + await mintCompressedToOwner(fixture, owner.publicKey, 2_500n); + + await sendInstructions( + fixture.rpc, + fixture.payer, + await createLoadInstructions({ + rpc: fixture.rpc, + payer: fixture.payer.publicKey, + owner: owner.publicKey, + mint: fixture.mint, + }), + [owner], + ); + + await sendInstructions( + fixture.rpc, + fixture.payer, + await createFreezeInstructions({ + tokenAccount, + mint: fixture.mint, + freezeAuthority: fixture.freezeAuthority!.publicKey, + }), + [fixture.freezeAuthority!], + ); + + expect(await getHotState(fixture.rpc, tokenAccount)).toBe( + AccountState.Frozen, + ); + + await sendInstructions( + fixture.rpc, + fixture.payer, + await createThawInstructions({ + tokenAccount, + mint: fixture.mint, + freezeAuthority: fixture.freezeAuthority!.publicKey, + }), + [fixture.freezeAuthority!], + ); + + expect(await getHotState(fixture.rpc, tokenAccount)).toBe( + AccountState.Initialized, + ); + }); +}); diff --git a/js/token-interface/tests/e2e/helpers.ts b/js/token-interface/tests/e2e/helpers.ts new file mode 100644 index 0000000000..b1d4f0dbe0 --- /dev/null +++ b/js/token-interface/tests/e2e/helpers.ts @@ -0,0 +1,186 @@ +import { AccountState } from '@solana/spl-token'; +import { Keypair, PublicKey, Signer, TransactionInstruction } from '@solana/web3.js'; +import { + Rpc, + TreeInfo, + VERSION, + bn, + buildAndSignTx, + createRpc, + featureFlags, + newAccountWithLamports, + selectStateTreeInfo, + sendAndConfirmTx, +} from '@lightprotocol/stateless.js'; +import { + TokenPoolInfo, + createMint, + getTokenPoolInfos, + mintTo, + parseLightTokenHot, + selectTokenPoolInfo, +} from '@lightprotocol/compressed-token'; + +featureFlags.version = VERSION.V2; + +export const TEST_TOKEN_DECIMALS = 9; + +export interface MintFixture { + rpc: Rpc; + payer: Signer; + mint: PublicKey; + mintAuthority: Keypair; + stateTreeInfo: TreeInfo; + tokenPoolInfos: TokenPoolInfo[]; + freezeAuthority?: Keypair; +} + +export async function createMintFixture( + options?: { + withFreezeAuthority?: boolean; + payerLamports?: number; + }, +): Promise { + const rpc = createRpc(); + const payer = await newAccountWithLamports( + rpc, + options?.payerLamports ?? 20e9, + ); + const mintAuthority = Keypair.generate(); + const mintKeypair = Keypair.generate(); + const freezeAuthority = options?.withFreezeAuthority + ? Keypair.generate() + : undefined; + + const mint = ( + await createMint( + rpc, + payer, + mintAuthority.publicKey, + TEST_TOKEN_DECIMALS, + mintKeypair, + undefined, + undefined, + freezeAuthority?.publicKey ?? null, + ) + ).mint; + + const stateTreeInfo = selectStateTreeInfo(await rpc.getStateTreeInfos()); + const tokenPoolInfos = await getTokenPoolInfos(rpc, mint); + + return { + rpc, + payer, + mint, + mintAuthority, + stateTreeInfo, + tokenPoolInfos, + freezeAuthority, + }; +} + +export async function mintCompressedToOwner( + fixture: MintFixture, + owner: PublicKey, + amount: bigint, +): Promise { + await mintTo( + fixture.rpc, + fixture.payer, + fixture.mint, + owner, + fixture.mintAuthority, + bn(amount.toString()), + fixture.stateTreeInfo, + selectTokenPoolInfo(fixture.tokenPoolInfos), + ); +} + +export async function mintMultipleColdAccounts( + fixture: MintFixture, + owner: PublicKey, + count: number, + amountPerAccount: bigint, +): Promise { + for (let i = 0; i < count; i += 1) { + await mintCompressedToOwner(fixture, owner, amountPerAccount); + } +} + +export async function sendInstructions( + rpc: Rpc, + payer: Signer, + instructions: TransactionInstruction[], + additionalSigners: Signer[] = [], +): Promise { + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx(instructions, payer, blockhash, additionalSigners); + return sendAndConfirmTx(rpc, tx); +} + +export async function getHotBalance( + rpc: Rpc, + tokenAccount: PublicKey, +): Promise { + const info = await rpc.getAccountInfo(tokenAccount); + if (!info) { + return BigInt(0); + } + + return parseLightTokenHot(tokenAccount, info).parsed.amount; +} + +export async function getHotDelegate( + rpc: Rpc, + tokenAccount: PublicKey, +): Promise<{ delegate: PublicKey | null; delegatedAmount: bigint }> { + const info = await rpc.getAccountInfo(tokenAccount); + if (!info) { + return { delegate: null, delegatedAmount: BigInt(0) }; + } + + const { parsed } = parseLightTokenHot(tokenAccount, info); + return { + delegate: parsed.delegate, + delegatedAmount: parsed.delegatedAmount ?? BigInt(0), + }; +} + +export async function getHotState( + rpc: Rpc, + tokenAccount: PublicKey, +): Promise { + const info = await rpc.getAccountInfo(tokenAccount); + if (!info) { + throw new Error(`Account not found: ${tokenAccount.toBase58()}`); + } + + const { parsed } = parseLightTokenHot(tokenAccount, info); + return parsed.isFrozen + ? AccountState.Frozen + : parsed.isInitialized + ? AccountState.Initialized + : AccountState.Uninitialized; +} + +export async function getCompressedAmounts( + rpc: Rpc, + owner: PublicKey, + mint: PublicKey, +): Promise { + const result = await rpc.getCompressedTokenAccountsByOwner(owner, { mint }); + + return result.items + .map(account => BigInt(account.parsed.amount.toString())) + .sort((left, right) => { + if (right > left) { + return 1; + } + + if (right < left) { + return -1; + } + + return 0; + }); +} diff --git a/js/token-interface/tests/e2e/load.test.ts b/js/token-interface/tests/e2e/load.test.ts new file mode 100644 index 0000000000..495623230f --- /dev/null +++ b/js/token-interface/tests/e2e/load.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest'; +import { ComputeBudgetProgram } from '@solana/web3.js'; +import { newAccountWithLamports } from '@lightprotocol/stateless.js'; +import { + createLoadInstructions, + getAta, + getAtaAddress, +} from '../../src'; +import { + createMintFixture, + getCompressedAmounts, + getHotBalance, + mintCompressedToOwner, + sendInstructions, +} from './helpers'; + +describe('load instructions', () => { + it('getAta only exposes the biggest compressed balance and tracks the ignored ones', async () => { + const fixture = await createMintFixture(); + const owner = await newAccountWithLamports(fixture.rpc, 1e9); + + await mintCompressedToOwner(fixture, owner.publicKey, 400n); + await mintCompressedToOwner(fixture, owner.publicKey, 300n); + await mintCompressedToOwner(fixture, owner.publicKey, 200n); + + const account = await getAta({ + rpc: fixture.rpc, + owner: owner.publicKey, + mint: fixture.mint, + }); + + expect(account.parsed.amount).toBe(400n); + expect(account.compressedAmount).toBe(400n); + expect(account.requiresLoad).toBe(true); + expect(account.ignoredCompressedAccounts).toHaveLength(2); + expect(account.ignoredCompressedAmount).toBe(500n); + }); + + it('loads one compressed balance per call and leaves the smaller ones untouched', async () => { + const fixture = await createMintFixture(); + const owner = await newAccountWithLamports(fixture.rpc, 1e9); + const tokenAccount = getAtaAddress({ + owner: owner.publicKey, + mint: fixture.mint, + }); + + await mintCompressedToOwner(fixture, owner.publicKey, 500n); + await mintCompressedToOwner(fixture, owner.publicKey, 300n); + await mintCompressedToOwner(fixture, owner.publicKey, 200n); + + const firstInstructions = await createLoadInstructions({ + rpc: fixture.rpc, + payer: fixture.payer.publicKey, + owner: owner.publicKey, + mint: fixture.mint, + }); + + expect(firstInstructions.length).toBeGreaterThan(0); + expect( + firstInstructions.some(instruction => + instruction.programId.equals(ComputeBudgetProgram.programId), + ), + ).toBe(false); + + await sendInstructions(fixture.rpc, fixture.payer, firstInstructions, [ + owner, + ]); + + expect(await getHotBalance(fixture.rpc, tokenAccount)).toBe(500n); + expect( + await getCompressedAmounts( + fixture.rpc, + owner.publicKey, + fixture.mint, + ), + ).toEqual([300n, 200n]); + + const secondInstructions = await createLoadInstructions({ + rpc: fixture.rpc, + payer: fixture.payer.publicKey, + owner: owner.publicKey, + mint: fixture.mint, + }); + + await sendInstructions(fixture.rpc, fixture.payer, secondInstructions, [ + owner, + ]); + + expect(await getHotBalance(fixture.rpc, tokenAccount)).toBe(800n); + expect( + await getCompressedAmounts( + fixture.rpc, + owner.publicKey, + fixture.mint, + ), + ).toEqual([200n]); + }); +}); diff --git a/js/token-interface/tests/e2e/transfer.test.ts b/js/token-interface/tests/e2e/transfer.test.ts new file mode 100644 index 0000000000..b9f001a43c --- /dev/null +++ b/js/token-interface/tests/e2e/transfer.test.ts @@ -0,0 +1,245 @@ +import { describe, expect, it } from 'vitest'; +import { ComputeBudgetProgram, Keypair } from '@solana/web3.js'; +import { + TOKEN_PROGRAM_ID, + getAssociatedTokenAddressSync, + unpackAccount, +} from '@solana/spl-token'; +import { newAccountWithLamports } from '@lightprotocol/stateless.js'; +import { + createApproveInstructions, + createAtaInstructions, + createTransferInstructions, + getAta, + getAtaAddress, +} from '../../src'; +import { + createMintFixture, + getCompressedAmounts, + getHotBalance, + mintCompressedToOwner, + sendInstructions, +} from './helpers'; + +describe('transfer instructions', () => { + it('builds a single-transaction transfer flow without compute budget instructions', async () => { + const fixture = await createMintFixture(); + const sender = await newAccountWithLamports(fixture.rpc, 1e9); + const recipient = Keypair.generate(); + + await mintCompressedToOwner(fixture, sender.publicKey, 5_000n); + + const instructions = await createTransferInstructions({ + rpc: fixture.rpc, + payer: fixture.payer.publicKey, + mint: fixture.mint, + sourceOwner: sender.publicKey, + authority: sender.publicKey, + recipient: recipient.publicKey, + amount: 2_000n, + }); + + expect(instructions.length).toBeGreaterThan(0); + expect( + instructions.some(instruction => + instruction.programId.equals(ComputeBudgetProgram.programId), + ), + ).toBe(false); + + await sendInstructions(fixture.rpc, fixture.payer, instructions, [ + sender, + ]); + + const recipientAta = await getAta({ + rpc: fixture.rpc, + owner: recipient.publicKey, + mint: fixture.mint, + }); + const senderAta = getAtaAddress({ + owner: sender.publicKey, + mint: fixture.mint, + }); + + expect(recipientAta.parsed.amount).toBe(2_000n); + expect(await getHotBalance(fixture.rpc, senderAta)).toBe(3_000n); + }); + + it('supports non-light destination path with SPL ATA recipient', async () => { + const fixture = await createMintFixture(); + const sender = await newAccountWithLamports(fixture.rpc, 1e9); + const recipient = await newAccountWithLamports(fixture.rpc, 1e9); + const recipientSplAta = getAssociatedTokenAddressSync( + fixture.mint, + recipient.publicKey, + false, + TOKEN_PROGRAM_ID, + ); + + await mintCompressedToOwner(fixture, sender.publicKey, 3_000n); + + const instructions = await createTransferInstructions({ + rpc: fixture.rpc, + payer: fixture.payer.publicKey, + mint: fixture.mint, + sourceOwner: sender.publicKey, + authority: sender.publicKey, + recipient: recipient.publicKey, + tokenProgram: TOKEN_PROGRAM_ID, + amount: 1_250n, + }); + + await sendInstructions(fixture.rpc, fixture.payer, instructions, [sender]); + + const recipientSplInfo = await fixture.rpc.getAccountInfo(recipientSplAta); + expect(recipientSplInfo).not.toBeNull(); + const recipientSpl = unpackAccount( + recipientSplAta, + recipientSplInfo!, + TOKEN_PROGRAM_ID, + ); + expect(recipientSpl.amount).toBe(1_250n); + }); + + it('passes through on-chain insufficient-funds error for transfer', async () => { + const fixture = await createMintFixture(); + const sender = await newAccountWithLamports(fixture.rpc, 1e9); + const recipient = Keypair.generate(); + + await mintCompressedToOwner(fixture, sender.publicKey, 500n); + await mintCompressedToOwner(fixture, sender.publicKey, 300n); + await mintCompressedToOwner(fixture, sender.publicKey, 200n); + + const instructions = await createTransferInstructions({ + rpc: fixture.rpc, + payer: fixture.payer.publicKey, + mint: fixture.mint, + sourceOwner: sender.publicKey, + authority: sender.publicKey, + recipient: recipient.publicKey, + amount: 600n, + }); + + await expect( + sendInstructions(fixture.rpc, fixture.payer, instructions, [sender]), + ).rejects.toThrow('custom program error'); + }); + + it('does not pre-reject zero amount (on-chain behavior decides)', async () => { + const fixture = await createMintFixture(); + const sender = await newAccountWithLamports(fixture.rpc, 1e9); + const recipient = Keypair.generate(); + const senderAta = getAtaAddress({ + owner: sender.publicKey, + mint: fixture.mint, + }); + + await mintCompressedToOwner(fixture, sender.publicKey, 500n); + + const instructions = await createTransferInstructions({ + rpc: fixture.rpc, + payer: fixture.payer.publicKey, + mint: fixture.mint, + sourceOwner: sender.publicKey, + authority: sender.publicKey, + recipient: recipient.publicKey, + amount: 0n, + }); + + await sendInstructions(fixture.rpc, fixture.payer, instructions, [sender]); + expect(await getHotBalance(fixture.rpc, senderAta)).toBe(500n); + }); + + it('does not load the recipient compressed balance yet', async () => { + const fixture = await createMintFixture(); + const sender = await newAccountWithLamports(fixture.rpc, 1e9); + const recipient = await newAccountWithLamports(fixture.rpc, 1e9); + const recipientAtaAddress = getAtaAddress({ + owner: recipient.publicKey, + mint: fixture.mint, + }); + + await mintCompressedToOwner(fixture, sender.publicKey, 400n); + await mintCompressedToOwner(fixture, recipient.publicKey, 300n); + + const instructions = await createTransferInstructions({ + rpc: fixture.rpc, + payer: fixture.payer.publicKey, + mint: fixture.mint, + sourceOwner: sender.publicKey, + authority: sender.publicKey, + recipient: recipient.publicKey, + amount: 200n, + }); + + await sendInstructions(fixture.rpc, fixture.payer, instructions, [ + sender, + ]); + + expect(await getHotBalance(fixture.rpc, recipientAtaAddress)).toBe(200n); + expect( + await getCompressedAmounts( + fixture.rpc, + recipient.publicKey, + fixture.mint, + ), + ).toEqual([300n]); + + const recipientAta = await getAta({ + rpc: fixture.rpc, + owner: recipient.publicKey, + mint: fixture.mint, + }); + + expect(recipientAta.parsed.amount).toBe(500n); + expect(recipientAta.compressedAmount).toBe(300n); + }); + + it('supports delegated payments after approval', async () => { + const fixture = await createMintFixture(); + const owner = await newAccountWithLamports(fixture.rpc, 1e9); + const delegate = await newAccountWithLamports(fixture.rpc, 1e9); + const recipient = Keypair.generate(); + const ownerAta = getAtaAddress({ + owner: owner.publicKey, + mint: fixture.mint, + }); + + await mintCompressedToOwner(fixture, owner.publicKey, 500n); + + const approveInstructions = await createApproveInstructions({ + rpc: fixture.rpc, + payer: fixture.payer.publicKey, + owner: owner.publicKey, + mint: fixture.mint, + delegate: delegate.publicKey, + amount: 300n, + }); + + await sendInstructions(fixture.rpc, fixture.payer, approveInstructions, [ + owner, + ]); + + const transferInstructions = await createTransferInstructions({ + rpc: fixture.rpc, + payer: fixture.payer.publicKey, + mint: fixture.mint, + sourceOwner: owner.publicKey, + recipient: recipient.publicKey, + amount: 250n, + authority: delegate.publicKey, + }); + + await sendInstructions(fixture.rpc, fixture.payer, transferInstructions, [ + delegate, + ]); + + const recipientAta = await getAta({ + rpc: fixture.rpc, + owner: recipient.publicKey, + mint: fixture.mint, + }); + + expect(recipientAta.parsed.amount).toBe(250n); + expect(await getHotBalance(fixture.rpc, ownerAta)).toBe(250n); + }); +}); diff --git a/js/token-interface/tests/unit/kit.test.ts b/js/token-interface/tests/unit/kit.test.ts new file mode 100644 index 0000000000..3704c88286 --- /dev/null +++ b/js/token-interface/tests/unit/kit.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { Keypair } from '@solana/web3.js'; +import { createAtaInstruction } from '../../src/instructions/raw'; +import { + buildTransferInstructions, + createAtaInstructions, + createTransferInstructions, + getTransferInstructionPlan, + toKitInstructions, +} from '../../src/kit'; + +describe('kit adapter', () => { + it('converts legacy instructions to kit instructions', () => { + const instruction = createAtaInstruction({ + payer: Keypair.generate().publicKey, + owner: Keypair.generate().publicKey, + mint: Keypair.generate().publicKey, + }); + + const converted = toKitInstructions([instruction]); + + expect(converted).toHaveLength(1); + expect(converted[0]).toBeDefined(); + expect(typeof converted[0]).toBe('object'); + }); + + it('wraps canonical builders for kit consumers', async () => { + const instructions = await createAtaInstructions({ + payer: Keypair.generate().publicKey, + owner: Keypair.generate().publicKey, + mint: Keypair.generate().publicKey, + }); + + expect(instructions).toHaveLength(1); + expect(instructions[0]).toBeDefined(); + }); + + it('exports transfer aliases and plan builder', () => { + expect(typeof buildTransferInstructions).toBe('function'); + expect(typeof createTransferInstructions).toBe('function'); + expect(typeof getTransferInstructionPlan).toBe('function'); + }); +}); diff --git a/js/token-interface/tests/unit/public-api.test.ts b/js/token-interface/tests/unit/public-api.test.ts new file mode 100644 index 0000000000..07b3c24e78 --- /dev/null +++ b/js/token-interface/tests/unit/public-api.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; +import { Keypair } from '@solana/web3.js'; +import { LIGHT_TOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; +import { getAssociatedTokenAddressInterface } from '@lightprotocol/compressed-token'; +import { + buildTransferInstructions, + MultiTransactionNotSupportedError, + createAtaInstructions, + createTransferInstructions, + createFreezeInstructions, + createThawInstructions, + getAtaAddress, +} from '../../src'; + +describe('public api', () => { + it('derives the canonical light-token ata address', () => { + const owner = Keypair.generate().publicKey; + const mint = Keypair.generate().publicKey; + + expect(getAtaAddress({ owner, mint }).equals( + getAssociatedTokenAddressInterface(mint, owner), + )).toBe(true); + }); + + it('builds one canonical ata instruction', async () => { + const payer = Keypair.generate().publicKey; + const owner = Keypair.generate().publicKey; + const mint = Keypair.generate().publicKey; + + const instructions = await createAtaInstructions({ + payer, + owner, + mint, + }); + + expect(instructions).toHaveLength(1); + expect(instructions[0].programId.equals(LIGHT_TOKEN_PROGRAM_ID)).toBe( + true, + ); + }); + + it('wraps freeze and thaw as single-instruction arrays', async () => { + const tokenAccount = Keypair.generate().publicKey; + const mint = Keypair.generate().publicKey; + const freezeAuthority = Keypair.generate().publicKey; + + const freezeInstructions = await createFreezeInstructions({ + tokenAccount, + mint, + freezeAuthority, + }); + const thawInstructions = await createThawInstructions({ + tokenAccount, + mint, + freezeAuthority, + }); + + expect(freezeInstructions).toHaveLength(1); + expect(freezeInstructions[0].data[0]).toBe(10); + expect(thawInstructions).toHaveLength(1); + expect(thawInstructions[0].data[0]).toBe(11); + }); + + it('exposes a clear single-transaction error', () => { + const error = new MultiTransactionNotSupportedError( + 'createLoadInstructions', + 2, + ); + + expect(error.name).toBe('MultiTransactionNotSupportedError'); + expect(error.message).toContain('single-transaction'); + expect(error.message).toContain('createLoadInstructions'); + }); + + it('exports transfer builder alias', () => { + expect(typeof buildTransferInstructions).toBe('function'); + expect(typeof createTransferInstructions).toBe('function'); + }); +}); diff --git a/js/token-interface/tests/unit/raw.test.ts b/js/token-interface/tests/unit/raw.test.ts new file mode 100644 index 0000000000..c9d0ef0eb4 --- /dev/null +++ b/js/token-interface/tests/unit/raw.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest'; +import { Keypair } from '@solana/web3.js'; +import { LIGHT_TOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; +import { + createApproveInstruction, + createAtaInstruction, + createFreezeInstruction, + getCreateAtaInstruction, + getLoadInstruction, + getTransferInstruction, + createRevokeInstruction, + createThawInstruction, + createTransferCheckedInstruction, +} from '../../src/instructions/raw'; + +describe('raw instruction builders', () => { + it('creates a canonical light-token ata instruction', () => { + const payer = Keypair.generate().publicKey; + const owner = Keypair.generate().publicKey; + const mint = Keypair.generate().publicKey; + + const instruction = createAtaInstruction({ + payer, + owner, + mint, + }); + + expect(instruction.programId.equals(LIGHT_TOKEN_PROGRAM_ID)).toBe(true); + expect(instruction.keys[0].pubkey.equals(owner)).toBe(true); + expect(instruction.keys[1].pubkey.equals(mint)).toBe(true); + expect(instruction.keys[2].pubkey.equals(payer)).toBe(true); + }); + + it('creates a checked transfer instruction', () => { + const source = Keypair.generate().publicKey; + const destination = Keypair.generate().publicKey; + const mint = Keypair.generate().publicKey; + const authority = Keypair.generate().publicKey; + const payer = Keypair.generate().publicKey; + + const instruction = createTransferCheckedInstruction({ + source, + destination, + mint, + authority, + payer, + amount: 42n, + decimals: 9, + }); + + expect(instruction.programId.equals(LIGHT_TOKEN_PROGRAM_ID)).toBe(true); + expect(instruction.data[0]).toBe(12); + expect(instruction.keys[0].pubkey.equals(source)).toBe(true); + expect(instruction.keys[2].pubkey.equals(destination)).toBe(true); + }); + + it('creates approve, revoke, freeze, and thaw instructions', () => { + const tokenAccount = Keypair.generate().publicKey; + const owner = Keypair.generate().publicKey; + const delegate = Keypair.generate().publicKey; + const mint = Keypair.generate().publicKey; + const freezeAuthority = Keypair.generate().publicKey; + + const approve = createApproveInstruction({ + tokenAccount, + delegate, + owner, + amount: 10n, + }); + const revoke = createRevokeInstruction({ + tokenAccount, + owner, + }); + const freeze = createFreezeInstruction({ + tokenAccount, + mint, + freezeAuthority, + }); + const thaw = createThawInstruction({ + tokenAccount, + mint, + freezeAuthority, + }); + + expect(approve.programId.equals(LIGHT_TOKEN_PROGRAM_ID)).toBe(true); + expect(revoke.programId.equals(LIGHT_TOKEN_PROGRAM_ID)).toBe(true); + expect(freeze.data[0]).toBe(10); + expect(thaw.data[0]).toBe(11); + }); + + it('exports getX raw aliases', () => { + expect(typeof getCreateAtaInstruction).toBe('function'); + expect(typeof getLoadInstruction).toBe('function'); + expect(typeof getTransferInstruction).toBe('function'); + }); +}); diff --git a/js/token-interface/tsconfig.json b/js/token-interface/tsconfig.json new file mode 100644 index 0000000000..7542e4510d --- /dev/null +++ b/js/token-interface/tsconfig.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "importHelpers": true, + "outDir": "./dist", + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "declaration": false, + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Node", + "lib": ["ESNext", "DOM"], + "types": ["node"], + "skipLibCheck": true + }, + "include": ["./src/**/*.ts", "rollup.config.js"] +} diff --git a/js/token-interface/tsconfig.test.json b/js/token-interface/tsconfig.test.json new file mode 100644 index 0000000000..e836181c0e --- /dev/null +++ b/js/token-interface/tsconfig.test.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "esModuleInterop": true, + "rootDirs": ["src", "tests"] + }, + "extends": "./tsconfig.json", + "include": ["./tests/**/*.ts", "vitest.config.ts"] +} diff --git a/js/token-interface/vitest.config.ts b/js/token-interface/vitest.config.ts new file mode 100644 index 0000000000..93411502d2 --- /dev/null +++ b/js/token-interface/vitest.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from 'vitest/config'; +import { resolve } from 'path'; + +export default defineConfig({ + logLevel: 'info', + test: { + include: process.env.EXCLUDE_E2E + ? ['tests/unit/**/*.test.ts'] + : ['tests/**/*.test.ts'], + includeSource: ['src/**/*.{js,ts}'], + fileParallelism: false, + testTimeout: 350000, + hookTimeout: 100000, + reporters: ['verbose'], + }, + define: { + 'import.meta.vitest': false, + }, + build: { + lib: { + formats: ['es', 'cjs'], + entry: resolve(__dirname, 'src/index.ts'), + fileName: 'index', + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 158713b83f..bd29447228 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -435,6 +435,76 @@ importers: specifier: ^2.1.1 version: 2.1.1(@types/node@22.16.5)(terser@5.43.1) + js/token-interface: + dependencies: + '@lightprotocol/compressed-token': + specifier: workspace:* + version: link:../compressed-token + '@lightprotocol/stateless.js': + specifier: workspace:* + version: link:../stateless.js + '@solana/compat': + specifier: ^6.5.0 + version: 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/kit': + specifier: ^6.5.0 + version: 6.5.0(bufferutil@4.0.8)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@5.0.10) + '@solana/spl-token': + specifier: '>=0.3.9' + version: 0.3.11(@solana/web3.js@1.98.4(bufferutil@4.0.8)(typescript@5.9.3)(utf-8-validate@5.0.10))(bufferutil@4.0.8)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@5.0.10) + '@solana/web3.js': + specifier: '>=1.73.5' + version: 1.98.4(bufferutil@4.0.8)(typescript@5.9.3)(utf-8-validate@5.0.10) + devDependencies: + '@eslint/js': + specifier: 9.36.0 + version: 9.36.0 + '@rollup/plugin-commonjs': + specifier: ^26.0.1 + version: 26.0.1(rollup@4.21.3) + '@rollup/plugin-node-resolve': + specifier: ^15.2.3 + version: 15.2.3(rollup@4.21.3) + '@rollup/plugin-typescript': + specifier: ^11.1.6 + version: 11.1.6(rollup@4.21.3)(tslib@2.8.1)(typescript@5.9.3) + '@types/node': + specifier: ^22.5.5 + version: 22.16.5 + '@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 + eslint-plugin-vitest: + specifier: ^0.5.4 + version: 0.5.4(@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))(eslint@9.36.0)(typescript@5.9.3)(vitest@2.1.1(@types/node@22.16.5)(terser@5.43.1)) + prettier: + specifier: ^3.3.3 + version: 3.6.2 + rimraf: + specifier: ^6.0.1 + version: 6.0.1 + rollup: + specifier: ^4.21.3 + version: 4.21.3 + rollup-plugin-dts: + specifier: ^6.1.1 + version: 6.1.1(rollup@4.21.3)(typescript@5.9.3) + tslib: + specifier: ^2.7.0 + version: 2.8.1 + typescript: + specifier: ^5.6.2 + version: 5.9.3 + vitest: + specifier: ^2.1.1 + version: 2.1.1(@types/node@22.16.5)(terser@5.43.1) + sdk-tests/sdk-anchor-test: dependencies: '@coral-xyz/anchor': @@ -1865,6 +1935,33 @@ packages: resolution: {integrity: sha512-PJBmyayrlfxM7nbqjomF4YcT1sApQwZio0NHSsT0EzhJqljRmvhzqZua43TyEs80nJk2Cn2FGPg/N8phH6KeCQ==} engines: {node: '>=18.0.0'} + '@solana/accounts@6.5.0': + resolution: {integrity: sha512-h3zQFjwZjmy+YxgTGOEna6g74Tsn4hTBaBCslwPT4QjqWhywe2JrM2Ab0ANfJcj7g/xrHF5QJ/FnUIcyUTeVfQ==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/addresses@6.5.0': + resolution: {integrity: sha512-iD4/u3CWchQcPofbwzteaE9RnFJSoi654Rnhru5fOu6U2XOte3+7t50d6OxdxQ109ho2LqZyVtyCo2Wb7u1aJQ==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/assertions@6.5.0': + resolution: {integrity: sha512-rEAf40TtC9r6EtJFLe39WID4xnTNT6hdOVRfD1xDzmIQdVOyGgIbJGt2FAuB/uQDKLWneWMnvGDBim+K61Bljw==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + '@solana/buffer-layout-utils@0.2.0': resolution: {integrity: sha512-szG4sxgJGktbuZYDg2FfNmkMi0DYQoVjN2h7ta1W1hPrwzarcFLBq9UpX1UjNXsNpT9dn+chgprtWGioUAr4/g==} engines: {node: '>= 10'} @@ -1892,6 +1989,15 @@ packages: peerDependencies: typescript: '>=5.3.3' + '@solana/codecs-core@6.5.0': + resolution: {integrity: sha512-Wb+YUj7vUKz5CxqZkrkugtQjxOP2fkMKnffySRlAmVAkpRnQvBY/2eP3VJAKTgDD4ru9xHSIQSpDu09hC/cQZg==} + 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 +2011,15 @@ packages: peerDependencies: typescript: '>=5' + '@solana/codecs-data-structures@6.5.0': + resolution: {integrity: sha512-Rxi5zVJ1YA+E6FoSQ7RHP+3DF4U7ski0mJ3H5CsYQP24QLRlBqWB3X6m2n9GHT5O3s49UR0sqeF4oyq0lF8bKw==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + '@solana/codecs-numbers@2.0.0-experimental.8618508': resolution: {integrity: sha512-EXQKfzFr3CkKKNzKSZPOOOzchXsFe90TVONWsSnVkonO9z+nGKALE0/L9uBmIFGgdzhhU9QQVFvxBMclIDJo2Q==} @@ -1924,6 +2039,15 @@ packages: peerDependencies: typescript: '>=5.3.3' + '@solana/codecs-numbers@6.5.0': + resolution: {integrity: sha512-gU/7eYqD+zl2Kwzo7ctt7YHaxF+c3RX164F+iU4X02dwq8DGVcypp+kmEF1QaO6OiShtdryTxhL+JJmEBjhdfA==} + 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 +2065,18 @@ packages: fastestsmallesttextencoderdecoder: ^1.0.22 typescript: '>=5' + '@solana/codecs-strings@6.5.0': + resolution: {integrity: sha512-9TuQQxumA9gWJeJzbv1GUg0+o0nZp204EijX3efR+lgBOKbkU7W0UWp33ygAZ+RvWE+kTs48ePoYoJ7UHpyxkQ==} + 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 +2087,24 @@ packages: peerDependencies: typescript: '>=5' + '@solana/codecs@6.5.0': + resolution: {integrity: sha512-WfqMqUXk4jcCJQ9nfKqjDcCJN2Pt8/AKe/E78z8OcblFGVJnTzcu2yZpE2gsqM+DJyCVKdQmOY+NS8Uckk5e5w==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/compat@6.5.0': + resolution: {integrity: sha512-Z5kfLg9AoxEhZOAx5hu4KnTDdGQbggTVPdBIJtpmgGPH321JVDV2DPUPHfLezx8upN+ftMdweNLS/4DMucNddg==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + '@solana/errors@2.0.0-preview.4': resolution: {integrity: sha512-kadtlbRv2LCWr8A9V22On15Us7Nn8BvqNaOB4hXsTB3O0fU40D1ru2l+cReqLcRPij4znqlRzW9Xi0m6J5DIhA==} hasBin: true @@ -1970,6 +2124,88 @@ packages: peerDependencies: typescript: '>=5.3.3' + '@solana/errors@6.5.0': + resolution: {integrity: sha512-XPc0I8Ck6vgx8Uu+LVLewx/1RWDkXkY3lU+1aN1kmbrPAQWbX4Txk7GPmuIIFpyys8o5aKocYfNxJOPKvfaQhg==} + engines: {node: '>=20.18.0'} + hasBin: true + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/fast-stable-stringify@6.5.0': + resolution: {integrity: sha512-5ATQDwBVZMoenX5KS23uFswtaAGoaZB9TthzUXle3tkU3tOfgQTuEWEoqEBYc7ct0sK6LtyE1XXT/NP5YvAkkQ==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/functional@6.5.0': + resolution: {integrity: sha512-/KYgY7ZpBJfkN8+qlIvxuBpxv32U9jHXIOOJh3U5xk8Ncsa9Ex5VwbU9NkOf43MJjoIamsP0vARCHjcqJwe5JQ==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/instruction-plans@6.5.0': + resolution: {integrity: sha512-zp2asevpyMwvhajHYM1aruYpO+xf3LSwHEI2FK6E2hddYZaEhuBy+bz+NZ1ixCyfx3iXcq7MamlFQc2ySHDyUQ==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/instructions@6.5.0': + resolution: {integrity: sha512-2mQP/1qqr5PCfaVMzs9KofBjpyS7J1sBV6PidGoX9Dg5/4UgwJJ+7yfCVQPn37l1nKCShm4I+pQAy5vbmrxJmA==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/keys@6.5.0': + resolution: {integrity: sha512-CN5jmodX9j5CZKrWLM5XGaRlrLl/Ebl4vgqDXrnwC2NiSfUslLsthuORMuVUTDqkzBX/jd/tgVXFRH2NYNzREQ==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/kit@6.5.0': + resolution: {integrity: sha512-4ysrtqMRd7CTYRv179gQq4kbw9zMsJCLhWjiyOmLZ4co4ld3L654D8ykW7yqWE5PJwF0hzEfheE7oBscO37nvw==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/nominal-types@6.5.0': + resolution: {integrity: sha512-HngIM2nlaDPXk0EDX0PklFqpjGDKuOFnlEKS0bfr2F9CorFwiNhNjhb9lPH+FdgsogD1wJ8wgLMMk1LZWn5kgQ==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/offchain-messages@6.5.0': + resolution: {integrity: sha512-IYuidJCwfXg5xlh3rkflkA1fbTKWTsip8MdI+znvXm87grfqOYCTd6t/SKiV4BhLl/65Tn0wB/zvZ1cmzJqa1w==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + '@solana/options@2.0.0-experimental.8618508': resolution: {integrity: sha512-fy/nIRAMC3QHvnKi63KEd86Xr/zFBVxNW4nEpVEU2OT0gCEKwHY4Z55YHf7XujhyuM3PNpiBKg/YYw5QlRU4vg==} @@ -1983,6 +2219,177 @@ packages: peerDependencies: typescript: '>=5' + '@solana/options@6.5.0': + resolution: {integrity: sha512-jdZjSKGCQpsMFK+3CiUEI7W9iGsndi46R4Abk66ULNLDoMsjvfqNy8kqktm0TN0++EX8dKEecpFwxFaA4VlY5g==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/plugin-core@6.5.0': + resolution: {integrity: sha512-L6N69oNQOAqljH4GnLTaxpwJB0nibW9DrybHZxpGWshyv6b/EvwvkDVRKj5bNqtCG+HRZUHnEhLi1UgZVNkjpQ==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/plugin-interfaces@6.5.0': + resolution: {integrity: sha512-/ZlybbMaR7P4ySersOe1huioMADWze0AzsHbzgkpt5dJUv2tz5cpaKdu7TEVQkUZAFhLdqXQULNGqAU5neOgzg==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/program-client-core@6.5.0': + resolution: {integrity: sha512-eUz1xSeDKySGIjToAryPmlESdj8KX0Np7R+Pjt+kSFGw5Jgmn/Inh4o8luoeEnf5XwbvSPVb4aHpIsDyoUVbIg==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/programs@6.5.0': + resolution: {integrity: sha512-srn3nEROBxCnBpVz/bvLkVln1BZtk3bS3nuReu3yaeOLkKl8b0h1Zp0YmXVyXHzdMcYahsTvKKLR1ZtLZEyEPA==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/promises@6.5.0': + resolution: {integrity: sha512-n5rsA3YwOO2nUst6ghuVw6RSnuZQYqevqBKqVYbw11Z4XezsoQ6hb78opW3J9YNYapw9wLWy6tEfUsJjY+xtGw==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/rpc-api@6.5.0': + resolution: {integrity: sha512-b+kftroO8vZFzLHj7Nk/uATS3HOlBUsUqdGg3eTQrW1pFgkyq5yIoEYHeFF7ApUN/SJLTK86U8ofCaXabd2SXA==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/rpc-parsed-types@6.5.0': + resolution: {integrity: sha512-129c8meL6CxRg56/HfhkFOpwYteQH9Rt0wyXOXZQx3a3FNpcJLd4JdPvxDsLBE3EupEkXLGVku/1bGKz+F2J+g==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/rpc-spec-types@6.5.0': + resolution: {integrity: sha512-XasJp+sOW6PLfNoalzoLnm+j3LEZF8XOQmSrOqv9AGrGxQckkuOf6iXZucWTqeNKdstsOpU28BN2B6qOavfRzQ==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/rpc-spec@6.5.0': + resolution: {integrity: sha512-k4O7Kg0QfVyjUqQovL+WZJ1iuPzq0jiUDcWYgvzFjYVxQDVOIZmAol7yTvLEL4maVmf0tNFDsrDaB6t75MKRZA==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/rpc-subscriptions-api@6.5.0': + resolution: {integrity: sha512-smqNjT2C5Vf9nWGIwiYOLOP744gRWKi2i2g0i3ZVdsfoouvB0d/WTQ2bbWq47MrdV8FSuGnjAOM3dRIwYmYOWw==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/rpc-subscriptions-channel-websocket@6.5.0': + resolution: {integrity: sha512-xRKH3ZwIoV9Zua9Gp0RR0eL8lXNgx+iNIkE3F0ROlOzI48lt4lRJ7jLrHQCN3raVtkatFVuEyZ7e9eLHK9zhAw==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/rpc-subscriptions-spec@6.5.0': + resolution: {integrity: sha512-Mi8g9rNS2lG7lyNkDhOVfQVfDC7hXKgH+BlI5qKGk+8cfyU7VDq6tVjDysu6kBWGOPHZxyCvcL6+xW/EkdVoAg==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/rpc-subscriptions@6.5.0': + resolution: {integrity: sha512-EenogPQw9Iy8VUj8anu7xoBnPk7gu1J6sAi4MTVlNVz02sNjdUBJoSS0PRJZuhSM1ktPTtHrNwqlXP8TxPR7jg==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/rpc-transformers@6.5.0': + resolution: {integrity: sha512-kS0d+LuuSLfsod2cm2xp0mNj65PL1aomwu6VKtubmsdESwPXHIaI9XrpkPCBuhNSz1SwVp4OkfK5O/VOOHYHSw==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/rpc-transport-http@6.5.0': + resolution: {integrity: sha512-A3qgDGiUIHdtAfc2OyazlQa7IvRh+xyl0dmzaZlz4rY7Oc7Xk8jmXtaKGkgXihLyAK3oVSqSz5gn9yEfx55eXA==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/rpc-types@6.5.0': + resolution: {integrity: sha512-hxts27+Z2VNv4IjXGcXkqbj/MgrN9Xtw/4iE1qZk68T2OAb5vA4b8LHchsOHmHvrzZfo8XDvB9mModCdM3JPsQ==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/rpc@6.5.0': + resolution: {integrity: sha512-lGj7ZMVOR3Rf16aByXD6ghrMqw3G8rAMuWCHU4uMKES5M5VLqNv6o71bSyoTxVMGrmYdbALOvCbFMFINAxtoBg==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/signers@6.5.0': + resolution: {integrity: sha512-AL75/DyDUhc+QQ+VGZT7aRwJNzIUTWvmLNXQRlCVhLRuyroXzZEL2WJBs8xOwbZXjY8weacfYT7UNM8qK6ucDg==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + '@solana/spl-token-group@0.0.5': resolution: {integrity: sha512-CLJnWEcdoUBpQJfx9WEbX3h6nTdNiUzswfFdkABUik7HVwSNA98u5AYvBVK2H93d9PGMOHAak2lHW9xr+zAJGQ==} engines: {node: '>=16'} @@ -2011,11 +2418,56 @@ packages: resolution: {integrity: sha512-RO0JD9vPRi4LsAbMUdNbDJ5/cv2z11MGhtAvFeRzT4+hAGE/FUzRi0tkkWtuCfSIU3twC6CtmAihRp/+XXjWsA==} engines: {node: '>=16'} peerDependencies: - '@solana/web3.js': ^1.94.0 + '@solana/web3.js': ^1.94.0 + + '@solana/spl-type-length-value@0.1.0': + resolution: {integrity: sha512-JBMGB0oR4lPttOZ5XiUGyvylwLQjt1CPJa6qQ5oM+MBCndfjz2TKKkw0eATlLLcYmq1jBVsNlJ2cD6ns2GR7lA==} + engines: {node: '>=16'} + + '@solana/subscribable@6.5.0': + resolution: {integrity: sha512-Jmy2NYmQN68FsQzKJ5CY3qrxXBJdb5qtJKp8B4byPPO5liKNIsC59HpT0Tq8MCNSfBMmOkWF2rrVot2/g1iB1A==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/sysvars@6.5.0': + resolution: {integrity: sha512-iLSS5qj0MWNiGH1LN1E4jhGsXH9D3tWSjwaB6zK9LjhLdVYcPfkosBkj7s0EHHrH03QlwiuFdU0Y2kH8Jcp8kw==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/transaction-confirmation@6.5.0': + resolution: {integrity: sha512-hfdRBq4toZj7DRMgBN3F0VtJpmTAEtcVTTDZoiszoSpSVa2cAvFth6KypIqASVFZyi9t4FKolLP8ASd3/39UQg==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/transaction-messages@6.5.0': + resolution: {integrity: sha512-ueXkm5xaRlqYBFAlABhaCKK/DuzIYSot0FybwSDeOQCDy2hvU9Zda16Iwa1n56M0fG+XUvFJz2woG3u9DhQh1g==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true - '@solana/spl-type-length-value@0.1.0': - resolution: {integrity: sha512-JBMGB0oR4lPttOZ5XiUGyvylwLQjt1CPJa6qQ5oM+MBCndfjz2TKKkw0eATlLLcYmq1jBVsNlJ2cD6ns2GR7lA==} - engines: {node: '>=16'} + '@solana/transactions@6.5.0': + resolution: {integrity: sha512-b3eJrrGmwpk64VLHjOrmXKAahPpba42WX/FqSUn4WRXPoQjga7Mb57yp+EaRVeQfjszKCkF+13yu+ni6iv2NFQ==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true '@solana/web3.js@1.98.4': resolution: {integrity: sha512-vv9lfnvjUsRiq//+j5pBdXig0IQdtzA0BRZ3bXEP4KaIyF1CcaydWqgyzQgfZMNIsWNWmG+AUHwPy4AHOD6gpw==} @@ -2704,8 +3156,8 @@ packages: resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} engines: {node: '>=18'} - commander@14.0.1: - resolution: {integrity: sha512-2JkV3gUZUVrbNA+1sjBOYLsMZ5cEEl8GTFP2a4AVz5hvasAMCQ1D2l2le/cX+pV4N6ZU17zjUahLpIXRrnWL8A==} + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} commander@2.20.3: @@ -5360,6 +5812,9 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + unicorn-magic@0.3.0: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} engines: {node: '>=18'} @@ -5613,6 +6068,18 @@ packages: utf-8-validate: optional: true + ws@8.20.0: + resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -7062,6 +7529,15 @@ snapshots: rollup: 4.21.3 tslib: 2.7.0 + '@rollup/plugin-typescript@11.1.6(rollup@4.21.3)(tslib@2.8.1)(typescript@5.9.3)': + dependencies: + '@rollup/pluginutils': 5.1.0(rollup@4.21.3) + resolve: 1.22.8 + typescript: 5.9.3 + optionalDependencies: + rollup: 4.21.3 + tslib: 2.8.1 + '@rollup/pluginutils@5.1.0(rollup@4.21.3)': dependencies: '@types/estree': 1.0.7 @@ -7488,6 +7964,37 @@ snapshots: '@smithy/types': 4.5.0 tslib: 2.8.1 + '@solana/accounts@6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)': + dependencies: + '@solana/addresses': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/codecs-core': 6.5.0(typescript@5.9.3) + '@solana/codecs-strings': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/errors': 6.5.0(typescript@5.9.3) + '@solana/rpc-spec': 6.5.0(typescript@5.9.3) + '@solana/rpc-types': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + + '@solana/addresses@6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)': + dependencies: + '@solana/assertions': 6.5.0(typescript@5.9.3) + '@solana/codecs-core': 6.5.0(typescript@5.9.3) + '@solana/codecs-strings': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/errors': 6.5.0(typescript@5.9.3) + '@solana/nominal-types': 6.5.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + + '@solana/assertions@6.5.0(typescript@5.9.3)': + dependencies: + '@solana/errors': 6.5.0(typescript@5.9.3) + optionalDependencies: + 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 @@ -7500,6 +8007,18 @@ snapshots: - typescript - utf-8-validate + '@solana/buffer-layout-utils@0.2.0(bufferutil@4.0.8)(typescript@5.9.3)(utf-8-validate@5.0.10)': + dependencies: + '@solana/buffer-layout': 4.0.1 + '@solana/web3.js': 1.98.4(bufferutil@4.0.8)(typescript@5.9.3)(utf-8-validate@5.0.10) + bigint-buffer: 1.1.5 + bignumber.js: 9.1.2 + transitivePeerDependencies: + - bufferutil + - encoding + - typescript + - utf-8-validate + '@solana/buffer-layout@4.0.1': dependencies: buffer: 6.0.3 @@ -7526,6 +8045,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@6.5.0(typescript@5.9.3)': + dependencies: + '@solana/errors': 6.5.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 +8075,14 @@ snapshots: '@solana/errors': 2.0.0-rc.1(typescript@5.9.2) typescript: 5.9.2 + '@solana/codecs-data-structures@6.5.0(typescript@5.9.3)': + dependencies: + '@solana/codecs-core': 6.5.0(typescript@5.9.3) + '@solana/codecs-numbers': 6.5.0(typescript@5.9.3) + '@solana/errors': 6.5.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + '@solana/codecs-numbers@2.0.0-experimental.8618508': dependencies: '@solana/codecs-core': 2.0.0-experimental.8618508 @@ -7573,6 +8111,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@6.5.0(typescript@5.9.3)': + dependencies: + '@solana/codecs-core': 6.5.0(typescript@5.9.3) + '@solana/errors': 6.5.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,76 +8146,429 @@ snapshots: fastestsmallesttextencoderdecoder: 1.0.22 typescript: 5.9.2 - '@solana/codecs@2.0.0-preview.4(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)': + '@solana/codecs-strings@6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)': + dependencies: + '@solana/codecs-core': 6.5.0(typescript@5.9.3) + '@solana/codecs-numbers': 6.5.0(typescript@5.9.3) + '@solana/errors': 6.5.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) + '@solana/codecs-data-structures': 2.0.0-preview.4(typescript@5.9.2) + '@solana/codecs-numbers': 2.0.0-preview.4(typescript@5.9.2) + '@solana/codecs-strings': 2.0.0-preview.4(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) + '@solana/options': 2.0.0-preview.4(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) + typescript: 5.9.2 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + + '@solana/codecs@2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)': + dependencies: + '@solana/codecs-core': 2.0.0-rc.1(typescript@5.9.2) + '@solana/codecs-data-structures': 2.0.0-rc.1(typescript@5.9.2) + '@solana/codecs-numbers': 2.0.0-rc.1(typescript@5.9.2) + '@solana/codecs-strings': 2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) + '@solana/options': 2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) + typescript: 5.9.2 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + + '@solana/codecs@6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)': + dependencies: + '@solana/codecs-core': 6.5.0(typescript@5.9.3) + '@solana/codecs-data-structures': 6.5.0(typescript@5.9.3) + '@solana/codecs-numbers': 6.5.0(typescript@5.9.3) + '@solana/codecs-strings': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/options': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + + '@solana/compat@6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)': + dependencies: + '@solana/addresses': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/codecs-core': 6.5.0(typescript@5.9.3) + '@solana/errors': 6.5.0(typescript@5.9.3) + '@solana/instructions': 6.5.0(typescript@5.9.3) + '@solana/keys': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/transactions': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + + '@solana/errors@2.0.0-preview.4(typescript@5.9.2)': + dependencies: + chalk: 5.6.2 + commander: 12.1.0 + typescript: 5.9.2 + + '@solana/errors@2.0.0-rc.1(typescript@5.9.2)': + dependencies: + chalk: 5.6.2 + commander: 12.1.0 + typescript: 5.9.2 + + '@solana/errors@2.3.0(typescript@4.9.5)': + dependencies: + chalk: 5.6.2 + commander: 14.0.3 + typescript: 4.9.5 + + '@solana/errors@2.3.0(typescript@5.9.2)': + dependencies: + chalk: 5.6.2 + commander: 14.0.3 + typescript: 5.9.2 + + '@solana/errors@2.3.0(typescript@5.9.3)': + dependencies: + chalk: 5.6.2 + commander: 14.0.3 + typescript: 5.9.3 + + '@solana/errors@6.5.0(typescript@5.9.3)': + dependencies: + chalk: 5.6.2 + commander: 14.0.3 + optionalDependencies: + typescript: 5.9.3 + + '@solana/fast-stable-stringify@6.5.0(typescript@5.9.3)': + optionalDependencies: + typescript: 5.9.3 + + '@solana/functional@6.5.0(typescript@5.9.3)': + optionalDependencies: + typescript: 5.9.3 + + '@solana/instruction-plans@6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)': + dependencies: + '@solana/errors': 6.5.0(typescript@5.9.3) + '@solana/instructions': 6.5.0(typescript@5.9.3) + '@solana/keys': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/promises': 6.5.0(typescript@5.9.3) + '@solana/transaction-messages': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/transactions': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + + '@solana/instructions@6.5.0(typescript@5.9.3)': + dependencies: + '@solana/codecs-core': 6.5.0(typescript@5.9.3) + '@solana/errors': 6.5.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + + '@solana/keys@6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)': + dependencies: + '@solana/assertions': 6.5.0(typescript@5.9.3) + '@solana/codecs-core': 6.5.0(typescript@5.9.3) + '@solana/codecs-strings': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/errors': 6.5.0(typescript@5.9.3) + '@solana/nominal-types': 6.5.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + + '@solana/kit@6.5.0(bufferutil@4.0.8)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@5.0.10)': + dependencies: + '@solana/accounts': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/addresses': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/codecs': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/errors': 6.5.0(typescript@5.9.3) + '@solana/functional': 6.5.0(typescript@5.9.3) + '@solana/instruction-plans': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/instructions': 6.5.0(typescript@5.9.3) + '@solana/keys': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/offchain-messages': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/plugin-core': 6.5.0(typescript@5.9.3) + '@solana/plugin-interfaces': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/program-client-core': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/programs': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/rpc': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/rpc-api': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/rpc-parsed-types': 6.5.0(typescript@5.9.3) + '@solana/rpc-spec-types': 6.5.0(typescript@5.9.3) + '@solana/rpc-subscriptions': 6.5.0(bufferutil@4.0.8)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@5.0.10) + '@solana/rpc-types': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/signers': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/sysvars': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/transaction-confirmation': 6.5.0(bufferutil@4.0.8)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@5.0.10) + '@solana/transaction-messages': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/transactions': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - bufferutil + - fastestsmallesttextencoderdecoder + - utf-8-validate + + '@solana/nominal-types@6.5.0(typescript@5.9.3)': + optionalDependencies: + typescript: 5.9.3 + + '@solana/offchain-messages@6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)': + dependencies: + '@solana/addresses': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/codecs-core': 6.5.0(typescript@5.9.3) + '@solana/codecs-data-structures': 6.5.0(typescript@5.9.3) + '@solana/codecs-numbers': 6.5.0(typescript@5.9.3) + '@solana/codecs-strings': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/errors': 6.5.0(typescript@5.9.3) + '@solana/keys': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/nominal-types': 6.5.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + + '@solana/options@2.0.0-experimental.8618508': + dependencies: + '@solana/codecs-core': 2.0.0-experimental.8618508 + '@solana/codecs-numbers': 2.0.0-experimental.8618508 + + '@solana/options@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) + '@solana/codecs-data-structures': 2.0.0-preview.4(typescript@5.9.2) + '@solana/codecs-numbers': 2.0.0-preview.4(typescript@5.9.2) + '@solana/codecs-strings': 2.0.0-preview.4(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) + '@solana/errors': 2.0.0-preview.4(typescript@5.9.2) + typescript: 5.9.2 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + + '@solana/options@2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)': + dependencies: + '@solana/codecs-core': 2.0.0-rc.1(typescript@5.9.2) + '@solana/codecs-data-structures': 2.0.0-rc.1(typescript@5.9.2) + '@solana/codecs-numbers': 2.0.0-rc.1(typescript@5.9.2) + '@solana/codecs-strings': 2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) + '@solana/errors': 2.0.0-rc.1(typescript@5.9.2) + typescript: 5.9.2 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + + '@solana/options@6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)': + dependencies: + '@solana/codecs-core': 6.5.0(typescript@5.9.3) + '@solana/codecs-data-structures': 6.5.0(typescript@5.9.3) + '@solana/codecs-numbers': 6.5.0(typescript@5.9.3) + '@solana/codecs-strings': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/errors': 6.5.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + + '@solana/plugin-core@6.5.0(typescript@5.9.3)': + optionalDependencies: + typescript: 5.9.3 + + '@solana/plugin-interfaces@6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)': + dependencies: + '@solana/addresses': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/instruction-plans': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/keys': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/rpc-spec': 6.5.0(typescript@5.9.3) + '@solana/rpc-subscriptions-spec': 6.5.0(typescript@5.9.3) + '@solana/rpc-types': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/signers': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + + '@solana/program-client-core@6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)': + dependencies: + '@solana/accounts': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/addresses': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/codecs-core': 6.5.0(typescript@5.9.3) + '@solana/errors': 6.5.0(typescript@5.9.3) + '@solana/instruction-plans': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/instructions': 6.5.0(typescript@5.9.3) + '@solana/plugin-interfaces': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/rpc-api': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/signers': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + + '@solana/programs@6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)': + dependencies: + '@solana/addresses': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/errors': 6.5.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + + '@solana/promises@6.5.0(typescript@5.9.3)': + optionalDependencies: + typescript: 5.9.3 + + '@solana/rpc-api@6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)': + dependencies: + '@solana/addresses': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/codecs-core': 6.5.0(typescript@5.9.3) + '@solana/codecs-strings': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/errors': 6.5.0(typescript@5.9.3) + '@solana/keys': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/rpc-parsed-types': 6.5.0(typescript@5.9.3) + '@solana/rpc-spec': 6.5.0(typescript@5.9.3) + '@solana/rpc-transformers': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/rpc-types': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/transaction-messages': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/transactions': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + + '@solana/rpc-parsed-types@6.5.0(typescript@5.9.3)': + optionalDependencies: + typescript: 5.9.3 + + '@solana/rpc-spec-types@6.5.0(typescript@5.9.3)': + optionalDependencies: + typescript: 5.9.3 + + '@solana/rpc-spec@6.5.0(typescript@5.9.3)': + dependencies: + '@solana/errors': 6.5.0(typescript@5.9.3) + '@solana/rpc-spec-types': 6.5.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + + '@solana/rpc-subscriptions-api@6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)': dependencies: - '@solana/codecs-core': 2.0.0-preview.4(typescript@5.9.2) - '@solana/codecs-data-structures': 2.0.0-preview.4(typescript@5.9.2) - '@solana/codecs-numbers': 2.0.0-preview.4(typescript@5.9.2) - '@solana/codecs-strings': 2.0.0-preview.4(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) - '@solana/options': 2.0.0-preview.4(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) - typescript: 5.9.2 + '@solana/addresses': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/keys': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/rpc-subscriptions-spec': 6.5.0(typescript@5.9.3) + '@solana/rpc-transformers': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/rpc-types': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/transaction-messages': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/transactions': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 transitivePeerDependencies: - fastestsmallesttextencoderdecoder - '@solana/codecs@2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)': + '@solana/rpc-subscriptions-channel-websocket@6.5.0(bufferutil@4.0.8)(typescript@5.9.3)(utf-8-validate@5.0.10)': dependencies: - '@solana/codecs-core': 2.0.0-rc.1(typescript@5.9.2) - '@solana/codecs-data-structures': 2.0.0-rc.1(typescript@5.9.2) - '@solana/codecs-numbers': 2.0.0-rc.1(typescript@5.9.2) - '@solana/codecs-strings': 2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) - '@solana/options': 2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) - typescript: 5.9.2 + '@solana/errors': 6.5.0(typescript@5.9.3) + '@solana/functional': 6.5.0(typescript@5.9.3) + '@solana/rpc-subscriptions-spec': 6.5.0(typescript@5.9.3) + '@solana/subscribable': 6.5.0(typescript@5.9.3) + ws: 8.20.0(bufferutil@4.0.8)(utf-8-validate@5.0.10) + optionalDependencies: + typescript: 5.9.3 transitivePeerDependencies: - - fastestsmallesttextencoderdecoder + - bufferutil + - utf-8-validate - '@solana/errors@2.0.0-preview.4(typescript@5.9.2)': + '@solana/rpc-subscriptions-spec@6.5.0(typescript@5.9.3)': dependencies: - chalk: 5.6.2 - commander: 12.1.0 - typescript: 5.9.2 + '@solana/errors': 6.5.0(typescript@5.9.3) + '@solana/promises': 6.5.0(typescript@5.9.3) + '@solana/rpc-spec-types': 6.5.0(typescript@5.9.3) + '@solana/subscribable': 6.5.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 - '@solana/errors@2.0.0-rc.1(typescript@5.9.2)': - dependencies: - chalk: 5.6.2 - commander: 12.1.0 - typescript: 5.9.2 + '@solana/rpc-subscriptions@6.5.0(bufferutil@4.0.8)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@5.0.10)': + dependencies: + '@solana/errors': 6.5.0(typescript@5.9.3) + '@solana/fast-stable-stringify': 6.5.0(typescript@5.9.3) + '@solana/functional': 6.5.0(typescript@5.9.3) + '@solana/promises': 6.5.0(typescript@5.9.3) + '@solana/rpc-spec-types': 6.5.0(typescript@5.9.3) + '@solana/rpc-subscriptions-api': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/rpc-subscriptions-channel-websocket': 6.5.0(bufferutil@4.0.8)(typescript@5.9.3)(utf-8-validate@5.0.10) + '@solana/rpc-subscriptions-spec': 6.5.0(typescript@5.9.3) + '@solana/rpc-transformers': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/rpc-types': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/subscribable': 6.5.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - bufferutil + - fastestsmallesttextencoderdecoder + - utf-8-validate - '@solana/errors@2.3.0(typescript@4.9.5)': + '@solana/rpc-transformers@6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)': dependencies: - chalk: 5.6.2 - commander: 14.0.1 - typescript: 4.9.5 + '@solana/errors': 6.5.0(typescript@5.9.3) + '@solana/functional': 6.5.0(typescript@5.9.3) + '@solana/nominal-types': 6.5.0(typescript@5.9.3) + '@solana/rpc-spec-types': 6.5.0(typescript@5.9.3) + '@solana/rpc-types': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder - '@solana/errors@2.3.0(typescript@5.9.2)': + '@solana/rpc-transport-http@6.5.0(typescript@5.9.3)': dependencies: - chalk: 5.6.2 - commander: 14.0.1 - typescript: 5.9.2 + '@solana/errors': 6.5.0(typescript@5.9.3) + '@solana/rpc-spec': 6.5.0(typescript@5.9.3) + '@solana/rpc-spec-types': 6.5.0(typescript@5.9.3) + undici-types: 7.24.6 + optionalDependencies: + typescript: 5.9.3 - '@solana/options@2.0.0-experimental.8618508': + '@solana/rpc-types@6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)': dependencies: - '@solana/codecs-core': 2.0.0-experimental.8618508 - '@solana/codecs-numbers': 2.0.0-experimental.8618508 + '@solana/addresses': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/codecs-core': 6.5.0(typescript@5.9.3) + '@solana/codecs-numbers': 6.5.0(typescript@5.9.3) + '@solana/codecs-strings': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/errors': 6.5.0(typescript@5.9.3) + '@solana/nominal-types': 6.5.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder - '@solana/options@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) - '@solana/codecs-data-structures': 2.0.0-preview.4(typescript@5.9.2) - '@solana/codecs-numbers': 2.0.0-preview.4(typescript@5.9.2) - '@solana/codecs-strings': 2.0.0-preview.4(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) - '@solana/errors': 2.0.0-preview.4(typescript@5.9.2) - typescript: 5.9.2 + '@solana/rpc@6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)': + dependencies: + '@solana/errors': 6.5.0(typescript@5.9.3) + '@solana/fast-stable-stringify': 6.5.0(typescript@5.9.3) + '@solana/functional': 6.5.0(typescript@5.9.3) + '@solana/rpc-api': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/rpc-spec': 6.5.0(typescript@5.9.3) + '@solana/rpc-spec-types': 6.5.0(typescript@5.9.3) + '@solana/rpc-transformers': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/rpc-transport-http': 6.5.0(typescript@5.9.3) + '@solana/rpc-types': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 transitivePeerDependencies: - fastestsmallesttextencoderdecoder - '@solana/options@2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)': - dependencies: - '@solana/codecs-core': 2.0.0-rc.1(typescript@5.9.2) - '@solana/codecs-data-structures': 2.0.0-rc.1(typescript@5.9.2) - '@solana/codecs-numbers': 2.0.0-rc.1(typescript@5.9.2) - '@solana/codecs-strings': 2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) - '@solana/errors': 2.0.0-rc.1(typescript@5.9.2) - typescript: 5.9.2 + '@solana/signers@6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)': + dependencies: + '@solana/addresses': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/codecs-core': 6.5.0(typescript@5.9.3) + '@solana/errors': 6.5.0(typescript@5.9.3) + '@solana/instructions': 6.5.0(typescript@5.9.3) + '@solana/keys': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/nominal-types': 6.5.0(typescript@5.9.3) + '@solana/offchain-messages': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/transaction-messages': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/transactions': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 transitivePeerDependencies: - fastestsmallesttextencoderdecoder @@ -7689,6 +8593,18 @@ snapshots: transitivePeerDependencies: - fastestsmallesttextencoderdecoder + '@solana/spl-token-metadata@0.1.2(@solana/web3.js@1.98.4(bufferutil@4.0.8)(typescript@5.9.3)(utf-8-validate@5.0.10))(fastestsmallesttextencoderdecoder@1.0.22)': + dependencies: + '@solana/codecs-core': 2.0.0-experimental.8618508 + '@solana/codecs-data-structures': 2.0.0-experimental.8618508 + '@solana/codecs-numbers': 2.0.0-experimental.8618508 + '@solana/codecs-strings': 2.0.0-experimental.8618508(fastestsmallesttextencoderdecoder@1.0.22) + '@solana/options': 2.0.0-experimental.8618508 + '@solana/spl-type-length-value': 0.1.0 + '@solana/web3.js': 1.98.4(bufferutil@4.0.8)(typescript@5.9.3)(utf-8-validate@5.0.10) + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + '@solana/spl-token-metadata@0.1.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-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) @@ -7712,6 +8628,20 @@ snapshots: - typescript - utf-8-validate + '@solana/spl-token@0.3.11(@solana/web3.js@1.98.4(bufferutil@4.0.8)(typescript@5.9.3)(utf-8-validate@5.0.10))(bufferutil@4.0.8)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@5.0.10)': + dependencies: + '@solana/buffer-layout': 4.0.1 + '@solana/buffer-layout-utils': 0.2.0(bufferutil@4.0.8)(typescript@5.9.3)(utf-8-validate@5.0.10) + '@solana/spl-token-metadata': 0.1.2(@solana/web3.js@1.98.4(bufferutil@4.0.8)(typescript@5.9.3)(utf-8-validate@5.0.10))(fastestsmallesttextencoderdecoder@1.0.22) + '@solana/web3.js': 1.98.4(bufferutil@4.0.8)(typescript@5.9.3)(utf-8-validate@5.0.10) + buffer: 6.0.3 + transitivePeerDependencies: + - bufferutil + - encoding + - fastestsmallesttextencoderdecoder + - typescript + - utf-8-validate + '@solana/spl-token@0.4.8(@solana/web3.js@1.98.4(bufferutil@4.0.8)(typescript@5.9.2)(utf-8-validate@5.0.10))(bufferutil@4.0.8)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(utf-8-validate@5.0.10)': dependencies: '@solana/buffer-layout': 4.0.1 @@ -7731,6 +8661,79 @@ snapshots: dependencies: buffer: 6.0.3 + '@solana/subscribable@6.5.0(typescript@5.9.3)': + dependencies: + '@solana/errors': 6.5.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + + '@solana/sysvars@6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)': + dependencies: + '@solana/accounts': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/codecs-core': 6.5.0(typescript@5.9.3) + '@solana/codecs-data-structures': 6.5.0(typescript@5.9.3) + '@solana/codecs-numbers': 6.5.0(typescript@5.9.3) + '@solana/errors': 6.5.0(typescript@5.9.3) + '@solana/rpc-types': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + + '@solana/transaction-confirmation@6.5.0(bufferutil@4.0.8)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@5.0.10)': + dependencies: + '@solana/addresses': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/codecs-strings': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/errors': 6.5.0(typescript@5.9.3) + '@solana/keys': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/promises': 6.5.0(typescript@5.9.3) + '@solana/rpc': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/rpc-subscriptions': 6.5.0(bufferutil@4.0.8)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@5.0.10) + '@solana/rpc-types': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/transaction-messages': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/transactions': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - bufferutil + - fastestsmallesttextencoderdecoder + - utf-8-validate + + '@solana/transaction-messages@6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)': + dependencies: + '@solana/addresses': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/codecs-core': 6.5.0(typescript@5.9.3) + '@solana/codecs-data-structures': 6.5.0(typescript@5.9.3) + '@solana/codecs-numbers': 6.5.0(typescript@5.9.3) + '@solana/errors': 6.5.0(typescript@5.9.3) + '@solana/functional': 6.5.0(typescript@5.9.3) + '@solana/instructions': 6.5.0(typescript@5.9.3) + '@solana/nominal-types': 6.5.0(typescript@5.9.3) + '@solana/rpc-types': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + + '@solana/transactions@6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)': + dependencies: + '@solana/addresses': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/codecs-core': 6.5.0(typescript@5.9.3) + '@solana/codecs-data-structures': 6.5.0(typescript@5.9.3) + '@solana/codecs-numbers': 6.5.0(typescript@5.9.3) + '@solana/codecs-strings': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/errors': 6.5.0(typescript@5.9.3) + '@solana/functional': 6.5.0(typescript@5.9.3) + '@solana/instructions': 6.5.0(typescript@5.9.3) + '@solana/keys': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/nominal-types': 6.5.0(typescript@5.9.3) + '@solana/rpc-types': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/transaction-messages': 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + optionalDependencies: + 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 @@ -7754,6 +8757,29 @@ snapshots: - typescript - utf-8-validate + '@solana/web3.js@1.98.4(bufferutil@4.0.8)(typescript@5.9.3)(utf-8-validate@5.0.10)': + dependencies: + '@babel/runtime': 7.25.6 + '@noble/curves': 1.4.2 + '@noble/hashes': 1.5.0 + '@solana/buffer-layout': 4.0.1 + '@solana/codecs-numbers': 2.3.0(typescript@5.9.3) + agentkeepalive: 4.5.0 + bn.js: 5.2.1 + borsh: 0.7.0 + bs58: 4.0.1 + buffer: 6.0.3 + fast-stable-stringify: 1.0.0 + jayson: 4.1.2(bufferutil@4.0.8)(utf-8-validate@5.0.10) + node-fetch: 2.7.0 + rpc-websockets: 9.0.2 + superstruct: 2.0.2 + transitivePeerDependencies: + - bufferutil + - encoding + - typescript + - utf-8-validate + '@solana/web3.js@1.98.4(typescript@4.9.5)': dependencies: '@babel/runtime': 7.25.6 @@ -7907,6 +8933,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 +8962,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 +8983,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 +9006,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 +9022,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': {} @@ -7973,6 +9053,21 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/typescript-estree@7.13.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 7.13.1 + '@typescript-eslint/visitor-keys': 7.13.1 + debug: 4.4.3(supports-color@8.1.1) + globby: 11.1.0 + is-glob: 4.0.3 + minimatch: 9.0.5 + semver: 7.7.3 + ts-api-utils: 1.3.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/typescript-estree@8.44.0(typescript@5.9.2)': dependencies: '@typescript-eslint/project-service': 8.44.0(typescript@5.9.2) @@ -7989,6 +9084,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) @@ -8000,6 +9111,17 @@ snapshots: - supports-color - typescript + '@typescript-eslint/utils@7.13.1(eslint@9.36.0)(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.7.0(eslint@9.36.0) + '@typescript-eslint/scope-manager': 7.13.1 + '@typescript-eslint/types': 7.13.1 + '@typescript-eslint/typescript-estree': 7.13.1(typescript@5.9.3) + eslint: 9.36.0 + transitivePeerDependencies: + - supports-color + - typescript + '@typescript-eslint/utils@8.44.0(eslint@9.36.0)(typescript@5.9.2)': dependencies: '@eslint-community/eslint-utils': 4.9.0(eslint@9.36.0) @@ -8011,6 +9133,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 @@ -8566,7 +9699,7 @@ snapshots: commander@13.1.0: {} - commander@14.0.1: {} + commander@14.0.3: {} commander@2.20.3: {} @@ -9213,6 +10346,17 @@ snapshots: - supports-color - typescript + eslint-plugin-vitest@0.5.4(@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))(eslint@9.36.0)(typescript@5.9.3)(vitest@2.1.1(@types/node@22.16.5)(terser@5.43.1)): + dependencies: + '@typescript-eslint/utils': 7.13.1(eslint@9.36.0)(typescript@5.9.3) + eslint: 9.36.0 + optionalDependencies: + '@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) + vitest: 2.1.1(@types/node@22.16.5)(terser@5.43.1) + transitivePeerDependencies: + - supports-color + - typescript + eslint-scope@8.4.0: dependencies: esrecurse: 4.3.0 @@ -9835,7 +10979,7 @@ snapshots: ansi-escapes: 7.1.0 ansi-styles: 6.2.1 auto-bind: 5.0.1 - chalk: 5.4.1 + chalk: 5.6.2 cli-boxes: 3.0.0 cli-cursor: 4.0.0 cli-truncate: 4.0.0 @@ -10919,6 +12063,14 @@ snapshots: optionalDependencies: '@babel/code-frame': 7.24.2 + rollup-plugin-dts@6.1.1(rollup@4.21.3)(typescript@5.9.3): + dependencies: + magic-string: 0.30.11 + rollup: 4.21.3 + typescript: 5.9.3 + optionalDependencies: + '@babel/code-frame': 7.24.2 + rollup-plugin-polyfill-node@0.13.0(rollup@4.21.3): dependencies: '@rollup/plugin-inject': 5.0.5(rollup@4.21.3) @@ -11388,10 +12540,18 @@ snapshots: dependencies: typescript: 5.9.2 + ts-api-utils@1.3.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + ts-api-utils@2.1.0(typescript@5.9.2): 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 +12747,8 @@ snapshots: undici-types@6.21.0: {} + undici-types@7.24.6: {} + unicorn-magic@0.3.0: {} union@0.5.0: @@ -11859,6 +13021,11 @@ snapshots: bufferutil: 4.0.8 utf-8-validate: 5.0.10 + ws@8.20.0(bufferutil@4.0.8)(utf-8-validate@5.0.10): + optionalDependencies: + bufferutil: 4.0.8 + utf-8-validate: 5.0.10 + y18n@5.0.8: {} yallist@3.1.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3a1786cae1..941d03514b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,4 +5,5 @@ packages: - "sdk-tests/sdk-anchor-test/**" - "js/stateless.js/**" - "js/compressed-token/**" + - "js/token-interface/**" - "examples/**" From a0a9489b388c8fbc9bbe0f19b2e560ec66d73d0a Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Thu, 26 Mar 2026 16:15:35 +0000 Subject: [PATCH 02/24] cleanup --- js/token-interface/README.md | 16 +- js/token-interface/package.json | 23 +- js/token-interface/rollup.config.js | 17 +- js/token-interface/src/account.ts | 19 +- js/token-interface/src/constants.ts | 42 + js/token-interface/src/errors.ts | 2 + js/token-interface/src/helpers.ts | 16 +- js/token-interface/src/instructions/_plan.ts | 22 + .../src/instructions/approve.ts | 126 ++ js/token-interface/src/instructions/ata.ts | 416 ++++++ js/token-interface/src/instructions/burn.ts | 184 +++ js/token-interface/src/instructions/freeze.ts | 93 ++ js/token-interface/src/instructions/index.ts | 327 +---- .../instructions/layout/layout-mint-action.ts | 362 +++++ .../src/instructions/layout/layout-mint.ts | 502 +++++++ .../instructions/layout/layout-transfer2.ts | 540 ++++++++ .../src/instructions/layout/layout.ts | 3 + js/token-interface/src/instructions/load.ts | 1220 +++++++++++++++++ .../src/instructions/nowrap/index.ts | 208 --- .../src/instructions/raw/index.ts | 132 -- js/token-interface/src/instructions/revoke.ts | 107 ++ js/token-interface/src/instructions/thaw.ts | 93 ++ .../src/instructions/transfer.ts | 277 ++++ js/token-interface/src/instructions/unwrap.ts | 120 ++ js/token-interface/src/instructions/wrap.ts | 133 ++ js/token-interface/src/kit/index.ts | 128 +- js/token-interface/src/load-options.ts | 8 + js/token-interface/src/load.ts | 65 - .../src/read/associated-token-address.ts | 23 + js/token-interface/src/read/ata-utils.ts | 108 ++ js/token-interface/src/read/get-account.ts | 1095 +++++++++++++++ js/token-interface/src/read/get-mint.ts | 235 ++++ .../src/{read.ts => read/index.ts} | 16 +- js/token-interface/src/spl-interface.ts | 71 + js/token-interface/src/types.ts | 42 +- .../tests/e2e/freeze-thaw.test.ts | 17 +- js/token-interface/tests/e2e/helpers.ts | 24 +- ...w.test.ts => instruction-builders.test.ts} | 12 +- js/token-interface/tests/unit/kit.test.ts | 10 +- .../tests/unit/public-api.test.ts | 24 +- pnpm-lock.yaml | 33 +- 41 files changed, 6032 insertions(+), 879 deletions(-) create mode 100644 js/token-interface/src/constants.ts create mode 100644 js/token-interface/src/instructions/_plan.ts create mode 100644 js/token-interface/src/instructions/approve.ts create mode 100644 js/token-interface/src/instructions/ata.ts create mode 100644 js/token-interface/src/instructions/burn.ts create mode 100644 js/token-interface/src/instructions/freeze.ts create mode 100644 js/token-interface/src/instructions/layout/layout-mint-action.ts create mode 100644 js/token-interface/src/instructions/layout/layout-mint.ts create mode 100644 js/token-interface/src/instructions/layout/layout-transfer2.ts create mode 100644 js/token-interface/src/instructions/layout/layout.ts create mode 100644 js/token-interface/src/instructions/load.ts delete mode 100644 js/token-interface/src/instructions/nowrap/index.ts delete mode 100644 js/token-interface/src/instructions/raw/index.ts create mode 100644 js/token-interface/src/instructions/revoke.ts create mode 100644 js/token-interface/src/instructions/thaw.ts create mode 100644 js/token-interface/src/instructions/transfer.ts create mode 100644 js/token-interface/src/instructions/unwrap.ts create mode 100644 js/token-interface/src/instructions/wrap.ts create mode 100644 js/token-interface/src/load-options.ts delete mode 100644 js/token-interface/src/load.ts create mode 100644 js/token-interface/src/read/associated-token-address.ts create mode 100644 js/token-interface/src/read/ata-utils.ts create mode 100644 js/token-interface/src/read/get-account.ts create mode 100644 js/token-interface/src/read/get-mint.ts rename js/token-interface/src/{read.ts => read/index.ts} (50%) create mode 100644 js/token-interface/src/spl-interface.ts rename js/token-interface/tests/unit/{raw.test.ts => instruction-builders.test.ts} (87%) diff --git a/js/token-interface/README.md b/js/token-interface/README.md index e71bed35db..dc76536c49 100644 --- a/js/token-interface/README.md +++ b/js/token-interface/README.md @@ -20,12 +20,12 @@ const rpc = createRpc(); ## Canonical for Kit users -Use `getTransferInstructionPlan` from `/kit`. +Use `createTransferInstructionPlan` from `/kit`. ```ts -import { getTransferInstructionPlan } from '@lightprotocol/token-interface/kit'; +import { createTransferInstructionPlan } from '@lightprotocol/token-interface/kit'; -const transferPlan = await getTransferInstructionPlan({ +const transferPlan = await createTransferInstructionPlan({ rpc, payer: payer.publicKey, mint, @@ -76,10 +76,10 @@ Use these when you want manual orchestration: ```ts import { - getCreateAtaInstruction, - getLoadInstruction, - getTransferInstruction, -} from '@lightprotocol/token-interface/instructions/raw'; + createAtaInstruction, + createLoadInstruction, + createTransferCheckedInstruction, +} from '@lightprotocol/token-interface/instructions'; ``` ## No-wrap instruction-flow builders (advanced) @@ -87,7 +87,7 @@ import { If you explicitly want to disable automatic sender wrapping, use: ```ts -import { buildTransferInstructions } from '@lightprotocol/token-interface/instructions/nowrap'; +import { buildTransferInstructionsNowrap } from '@lightprotocol/token-interface/instructions'; ``` ## Read account diff --git a/js/token-interface/package.json b/js/token-interface/package.json index ed6bdba74a..41ce8ad06b 100644 --- a/js/token-interface/package.json +++ b/js/token-interface/package.json @@ -16,16 +16,6 @@ "import": "./dist/es/instructions/index.js", "types": "./dist/types/instructions/index.d.ts" }, - "./instructions/raw": { - "require": "./dist/cjs/instructions/raw/index.cjs", - "import": "./dist/es/instructions/raw/index.js", - "types": "./dist/types/instructions/raw/index.d.ts" - }, - "./instructions/nowrap": { - "require": "./dist/cjs/instructions/nowrap/index.cjs", - "import": "./dist/es/instructions/nowrap/index.js", - "types": "./dist/types/instructions/nowrap/index.d.ts" - }, "./kit": { "require": "./dist/cjs/kit/index.cjs", "import": "./dist/es/kit/index.js", @@ -44,20 +34,27 @@ ], "license": "Apache-2.0", "peerDependencies": { + "@coral-xyz/borsh": "^0.29.0", "@lightprotocol/stateless.js": "workspace:*", "@solana/spl-token": ">=0.3.9", "@solana/web3.js": ">=1.73.5" }, "dependencies": { - "@lightprotocol/compressed-token": "workspace:*", + "@solana/buffer-layout": "^4.0.1", + "@solana/buffer-layout-utils": "^0.2.0", "@solana/compat": "^6.5.0", - "@solana/kit": "^6.5.0" + "@solana/instruction-plans": "^6.5.0", + "@solana/kit": "^6.5.0", + "bn.js": "^5.2.1", + "buffer": "6.0.3" }, "devDependencies": { + "@lightprotocol/compressed-token": "workspace:*", "@eslint/js": "9.36.0", "@rollup/plugin-commonjs": "^26.0.1", "@rollup/plugin-node-resolve": "^15.2.3", "@rollup/plugin-typescript": "^11.1.6", + "@types/bn.js": "^5.1.5", "@types/node": "^22.5.5", "@typescript-eslint/eslint-plugin": "^8.44.0", "@typescript-eslint/parser": "^8.44.0", @@ -74,7 +71,7 @@ "scripts": { "build": "pnpm build:v2", "build:v2": "pnpm build:deps:v2 && pnpm build:bundle", - "build:deps:v2": "pnpm --dir ../compressed-token build:v2", + "build:deps:v2": "pnpm --dir ../stateless.js build:v2", "build:bundle": "rimraf dist && rollup -c", "test": "pnpm test:unit:all && pnpm test:e2e:all", "test:unit:all": "pnpm build:deps:v2 && LIGHT_PROTOCOL_VERSION=V2 EXCLUDE_E2E=true vitest run tests/unit --reporter=verbose", diff --git a/js/token-interface/rollup.config.js b/js/token-interface/rollup.config.js index a8b6a7b9da..beac18c327 100644 --- a/js/token-interface/rollup.config.js +++ b/js/token-interface/rollup.config.js @@ -6,18 +6,21 @@ import commonjs from '@rollup/plugin-commonjs'; const inputs = { index: 'src/index.ts', 'instructions/index': 'src/instructions/index.ts', - 'instructions/nowrap/index': 'src/instructions/nowrap/index.ts', - 'instructions/raw/index': 'src/instructions/raw/index.ts', 'kit/index': 'src/kit/index.ts', }; const external = [ - '@lightprotocol/compressed-token', + '@coral-xyz/borsh', '@lightprotocol/stateless.js', + '@solana/buffer-layout', + '@solana/buffer-layout-utils', '@solana/compat', + '@solana/instruction-plans', '@solana/kit', '@solana/spl-token', '@solana/web3.js', + 'bn.js', + 'buffer', ]; const jsConfig = format => ({ @@ -67,13 +70,5 @@ export default [ 'src/instructions/index.ts', 'dist/types/instructions/index.d.ts', ), - dtsEntry( - 'src/instructions/raw/index.ts', - 'dist/types/instructions/raw/index.d.ts', - ), - dtsEntry( - 'src/instructions/nowrap/index.ts', - 'dist/types/instructions/nowrap/index.d.ts', - ), dtsEntry('src/kit/index.ts', 'dist/types/kit/index.d.ts'), ]; diff --git a/js/token-interface/src/account.ts b/js/token-interface/src/account.ts index bb168c01c5..ec2decd3be 100644 --- a/js/token-interface/src/account.ts +++ b/js/token-interface/src/account.ts @@ -1,8 +1,8 @@ +import { getAssociatedTokenAddress } from './read/associated-token-address'; import { - getAssociatedTokenAddressInterface, parseLightTokenCold, parseLightTokenHot, -} from '@lightprotocol/compressed-token'; +} from './read/get-account'; import { LIGHT_TOKEN_PROGRAM_ID, type ParsedTokenAccount, @@ -126,7 +126,7 @@ export async function getAtaOrNull({ mint, commitment, }: GetAtaInput): Promise { - const address = getAssociatedTokenAddressInterface(mint, owner); + const address = getAssociatedTokenAddress(mint, owner); const [hotInfo, compressedResult] = await Promise.all([ rpc.getAccountInfo(address, commitment), @@ -201,7 +201,7 @@ export function getSpendableAmount( export function assertAccountNotFrozen( account: TokenInterfaceAccount, - operation: 'load' | 'transfer' | 'approve' | 'revoke', + operation: 'load' | 'transfer' | 'approve' | 'revoke' | 'burn' | 'freeze', ): void { if (account.parsed.isFrozen) { throw new Error( @@ -210,6 +210,17 @@ export function assertAccountNotFrozen( } } +export function assertAccountFrozen( + account: TokenInterfaceAccount, + operation: 'thaw', +): void { + if (!account.parsed.isFrozen) { + throw new Error( + `Account is not frozen; ${operation} is not allowed.`, + ); + } +} + export function createSingleCompressedAccountRpc( rpc: Rpc, owner: PublicKey, diff --git a/js/token-interface/src/constants.ts b/js/token-interface/src/constants.ts new file mode 100644 index 0000000000..d96ec34244 --- /dev/null +++ b/js/token-interface/src/constants.ts @@ -0,0 +1,42 @@ +import { Buffer } from 'buffer'; +import { PublicKey } from '@solana/web3.js'; + +export const LIGHT_TOKEN_CONFIG = new PublicKey( + 'ACXg8a7VaqecBWrSbdu73W4Pg9gsqXJ3EXAqkHyhvVXg', +); + +export const LIGHT_TOKEN_RENT_SPONSOR = new PublicKey( + 'r18WwUxfG8kQ69bQPAB2jV6zGNKy3GosFGctjQoV4ti', +); + +export enum TokenDataVersion { + V1 = 1, + V2 = 2, + ShaFlat = 3, +} + +export const POOL_SEED = Buffer.from('pool'); +export const CPI_AUTHORITY_SEED = Buffer.from('cpi_authority'); +export const MAX_TOP_UP = 65535; + +export const COMPRESSED_TOKEN_PROGRAM_ID = new PublicKey( + 'cTokenmWW8bLPjZEBAUgYy3zKxQZW6VKi7bqNFEVv3m', +); + +export function deriveSplPoolPdaWithIndex( + mint: PublicKey, + index: number, +): [PublicKey, number] { + const indexSeed = index === 0 ? Buffer.from([]) : Buffer.from([index & 0xff]); + return PublicKey.findProgramAddressSync( + [POOL_SEED, mint.toBuffer(), indexSeed], + COMPRESSED_TOKEN_PROGRAM_ID, + ); +} + +export function deriveCpiAuthorityPda(): PublicKey { + return PublicKey.findProgramAddressSync( + [CPI_AUTHORITY_SEED], + COMPRESSED_TOKEN_PROGRAM_ID, + )[0]; +} diff --git a/js/token-interface/src/errors.ts b/js/token-interface/src/errors.ts index 3d7ec0589a..88607e73dc 100644 --- a/js/token-interface/src/errors.ts +++ b/js/token-interface/src/errors.ts @@ -1,3 +1,5 @@ +export const ERR_FETCH_BY_OWNER_REQUIRED = 'fetchByOwner is required'; + export class MultiTransactionNotSupportedError extends Error { readonly operation: string; readonly batchCount: number; diff --git a/js/token-interface/src/helpers.ts b/js/token-interface/src/helpers.ts index b733e74b7b..6936c9b74d 100644 --- a/js/token-interface/src/helpers.ts +++ b/js/token-interface/src/helpers.ts @@ -1,7 +1,5 @@ -import { - type InterfaceOptions, - getMintInterface, -} from '@lightprotocol/compressed-token'; +import type { LoadOptions } from './load-options'; +import { getMint } from './read'; import { ComputeBudgetProgram, PublicKey } from '@solana/web3.js'; import type { Rpc } from '@lightprotocol/stateless.js'; import type { TransactionInstruction } from '@solana/web3.js'; @@ -11,20 +9,20 @@ export async function getMintDecimals( rpc: Rpc, mint: PublicKey, ): Promise { - const mintInterface = await getMintInterface(rpc, mint); - return mintInterface.mint.decimals; + const mintInfo = await getMint(rpc, mint); + return mintInfo.mint.decimals; } -export function toInterfaceOptions( +export function toLoadOptions( owner: PublicKey, authority?: PublicKey, wrap = false, -): InterfaceOptions | undefined { +): LoadOptions | undefined { if ((!authority || authority.equals(owner)) && !wrap) { return undefined; } - const options: InterfaceOptions = {}; + const options: LoadOptions = {}; if (wrap) { options.wrap = true; } diff --git a/js/token-interface/src/instructions/_plan.ts b/js/token-interface/src/instructions/_plan.ts new file mode 100644 index 0000000000..362ca17e08 --- /dev/null +++ b/js/token-interface/src/instructions/_plan.ts @@ -0,0 +1,22 @@ +import { fromLegacyTransactionInstruction } from '@solana/compat'; +import { + sequentialInstructionPlan, + type InstructionPlan, +} from '@solana/instruction-plans'; +import type { TransactionInstruction } from '@solana/web3.js'; + +export type KitInstruction = ReturnType; + +export function toKitInstructions( + instructions: TransactionInstruction[], +): KitInstruction[] { + return instructions.map(instruction => + fromLegacyTransactionInstruction(instruction), + ); +} + +export function toInstructionPlan( + instructions: TransactionInstruction[], +): InstructionPlan { + return sequentialInstructionPlan(toKitInstructions(instructions)); +} diff --git a/js/token-interface/src/instructions/approve.ts b/js/token-interface/src/instructions/approve.ts new file mode 100644 index 0000000000..e507bc8160 --- /dev/null +++ b/js/token-interface/src/instructions/approve.ts @@ -0,0 +1,126 @@ +import { SystemProgram, TransactionInstruction } from '@solana/web3.js'; +import { Buffer } from 'buffer'; +import { LIGHT_TOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; +import { assertAccountNotFrozen, getAta } from '../account'; +import type { + CreateApproveInstructionsInput, + CreateRawApproveInstructionInput, +} from '../types'; +import { buildLoadInstructionList } from './load'; +import { toInstructionPlan } from './_plan'; + +const LIGHT_TOKEN_APPROVE_DISCRIMINATOR = 4; + +function toBigIntAmount(amount: number | bigint): bigint { + return BigInt(amount.toString()); +} + +export function createApproveInstruction({ + tokenAccount, + delegate, + owner, + amount, + payer, +}: CreateRawApproveInstructionInput): TransactionInstruction { + const data = Buffer.alloc(9); + data.writeUInt8(LIGHT_TOKEN_APPROVE_DISCRIMINATOR, 0); + data.writeBigUInt64LE(BigInt(amount), 1); + + const effectiveFeePayer = payer ?? owner; + + const keys = [ + { pubkey: tokenAccount, isSigner: false, isWritable: true }, + { pubkey: delegate, isSigner: false, isWritable: false }, + { + pubkey: owner, + isSigner: true, + isWritable: effectiveFeePayer.equals(owner), + }, + { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, + { + pubkey: effectiveFeePayer, + isSigner: !effectiveFeePayer.equals(owner), + isWritable: true, + }, + ]; + + return new TransactionInstruction({ + programId: LIGHT_TOKEN_PROGRAM_ID, + keys, + data, + }); +} + +export async function createApproveInstructions({ + rpc, + payer, + owner, + mint, + delegate, + amount, +}: CreateApproveInstructionsInput): Promise { + const account = await getAta({ + rpc, + owner, + mint, + }); + + assertAccountNotFrozen(account, 'approve'); + + return [ + ...(await buildLoadInstructionList({ + rpc, + payer, + owner, + mint, + account, + wrap: true, + })), + createApproveInstruction({ + tokenAccount: account.address, + delegate, + owner, + amount: toBigIntAmount(amount), + payer, + }), + ]; +} + +export async function createApproveInstructionsNowrap({ + rpc, + payer, + owner, + mint, + delegate, + amount, +}: CreateApproveInstructionsInput): Promise { + const account = await getAta({ + rpc, + owner, + mint, + }); + + return [ + ...(await buildLoadInstructionList({ + rpc, + payer, + owner, + mint, + account, + wrap: false, + })), + createApproveInstruction({ + tokenAccount: account.address, + delegate, + owner, + amount: toBigIntAmount(amount), + payer, + }), + ]; +} + +export async function createApproveInstructionPlan( + input: CreateApproveInstructionsInput, +) { + return toInstructionPlan(await createApproveInstructions(input)); +} diff --git a/js/token-interface/src/instructions/ata.ts b/js/token-interface/src/instructions/ata.ts new file mode 100644 index 0000000000..982433f55a --- /dev/null +++ b/js/token-interface/src/instructions/ata.ts @@ -0,0 +1,416 @@ +import { + PublicKey, + SystemProgram, + TransactionInstruction, +} from '@solana/web3.js'; +import { Buffer } from 'buffer'; +import { + TOKEN_PROGRAM_ID, + createAssociatedTokenAccountInstruction as createSplAssociatedTokenAccountInstruction, + createAssociatedTokenAccountIdempotentInstruction as createSplAssociatedTokenAccountIdempotentInstruction, +} from '@solana/spl-token'; +import { LIGHT_TOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; +import { struct, u8, u32, option, vec, array } from '@coral-xyz/borsh'; +import { LIGHT_TOKEN_CONFIG, LIGHT_TOKEN_RENT_SPONSOR } from '../constants'; +import { getAtaProgramId } from '../read/ata-utils'; +import { getAtaAddress } from '../read'; +import type { CreateRawAtaInstructionInput } from '../types'; +import { toInstructionPlan } from './_plan'; + +const CREATE_ASSOCIATED_TOKEN_ACCOUNT_DISCRIMINATOR = Buffer.from([100]); +const CREATE_ASSOCIATED_TOKEN_ACCOUNT_IDEMPOTENT_DISCRIMINATOR = Buffer.from([ + 102, +]); + +// Matches Rust CompressToPubkey struct +const CompressToPubkeyLayout = struct([ + u8('bump'), + array(u8(), 32, 'programId'), + vec(vec(u8()), 'seeds'), +]); + +// Matches Rust CompressibleExtensionInstructionData struct +// From: program-libs/token-interface/src/instructions/extensions/compressible.rs +const CompressibleExtensionInstructionDataLayout = struct([ + u8('tokenAccountVersion'), + u8('rentPayment'), + u8('compressionOnly'), + u32('writeTopUp'), + option(CompressToPubkeyLayout, 'compressToAccountPubkey'), +]); + +const CreateAssociatedTokenAccountInstructionDataLayout = struct([ + option(CompressibleExtensionInstructionDataLayout, 'compressibleConfig'), +]); + +export interface CompressToPubkey { + bump: number; + programId: number[]; + seeds: number[][]; +} + +export interface CompressibleConfig { + tokenAccountVersion: number; + rentPayment: number; + compressionOnly: number; + writeTopUp: number; + compressToAccountPubkey?: CompressToPubkey | null; +} + +export interface CreateAssociatedLightTokenAccountParams { + compressibleConfig?: CompressibleConfig | null; +} + +/** + * Default compressible config for light-token ATAs - matches Rust SDK defaults. + * + * - tokenAccountVersion: 3 (ShaFlat) - latest hashing scheme + * - rentPayment: 16 - prepay 16 epochs (~24 hours rent) + * - compressionOnly: 1 - required for ATAs + * - writeTopUp: 766 - per-write top-up (~2 epochs rent) when rent < 2 epochs + * - compressToAccountPubkey: null - required for ATAs + * + * Cost breakdown at associated token account creation: + * - Rent sponsor PDA (LIGHT_TOKEN_RENT_SPONSOR) pays: rent exemption (~890,880 lamports) + * - Fee payer pays: compression_cost (11K) + 16 epochs rent (~6,400) = ~17,400 lamports + tx fees + * + * Per-write top-up (transfers): + * - When account rent is below 2 epochs, fee payer pays 766 lamports top-up + * - This keeps the account perpetually funded when actively used + * + * Rent calculation (272-byte compressible lightToken account): + * - rent_per_epoch = base_rent (128) + bytes * rent_per_byte (272 * 1) = 400 lamports + * - 16 epochs = 16 * 400 = 6,400 lamports (24 hours) + * - 2 epochs = 2 * 400 = 800 lamports (~3 hours, writeTopUp = 766 is conservative) + * + * Account size breakdown (272 bytes): + * - 165 bytes: SPL token base layout + * - 1 byte: account_type discriminator + * - 1 byte: Option discriminator for extensions + * - 4 bytes: Vec length prefix + * - 1 byte: extension type discriminant + * - 4 bytes: CompressibleExtension header (decimals_option, decimals, compression_only, is_ata) + * - 96 bytes: CompressionInfo struct + */ +export const DEFAULT_COMPRESSIBLE_CONFIG: CompressibleConfig = { + tokenAccountVersion: 3, // ShaFlat (latest hashing scheme) + rentPayment: 16, // 16 epochs (~24 hours) - matches Rust SDK + compressionOnly: 1, // Required for ATAs + writeTopUp: 766, // Per-write top-up (~2 epochs) - matches Rust SDK + compressToAccountPubkey: null, // Required null for ATAs +}; + +/** @internal */ +function getAssociatedLightTokenAddress( + owner: PublicKey, + mint: PublicKey, +): PublicKey { + return PublicKey.findProgramAddressSync( + [owner.toBuffer(), LIGHT_TOKEN_PROGRAM_ID.toBuffer(), mint.toBuffer()], + LIGHT_TOKEN_PROGRAM_ID, + )[0]; +} + +/** @internal */ +function encodeCreateAssociatedLightTokenAccountData( + params: CreateAssociatedLightTokenAccountParams, + idempotent: boolean, +): Buffer { + const buffer = Buffer.alloc(2000); + const len = CreateAssociatedTokenAccountInstructionDataLayout.encode( + { + compressibleConfig: params.compressibleConfig || null, + }, + buffer, + ); + + const discriminator = idempotent + ? CREATE_ASSOCIATED_TOKEN_ACCOUNT_IDEMPOTENT_DISCRIMINATOR + : CREATE_ASSOCIATED_TOKEN_ACCOUNT_DISCRIMINATOR; + + return Buffer.concat([discriminator, buffer.subarray(0, len)]); +} + +export interface CreateAssociatedLightTokenAccountInstructionParams { + feePayer: PublicKey; + owner: PublicKey; + mint: PublicKey; + compressibleConfig?: CompressibleConfig; + configAccount?: PublicKey; + rentPayerPda?: PublicKey; +} + +/** + * Create instruction for creating an associated light-token account. + * Uses the default rent sponsor PDA by default. + * + * @param feePayer Fee payer public key. + * @param owner Owner of the associated token account. + * @param mint Mint address. + * @param compressibleConfig Compressible configuration (defaults to rent sponsor config). + * @param configAccount Config account (defaults to LIGHT_TOKEN_CONFIG). + * @param rentPayerPda Rent payer PDA (defaults to LIGHT_TOKEN_RENT_SPONSOR). + */ +export function createAssociatedLightTokenAccountInstruction( + feePayer: PublicKey, + owner: PublicKey, + mint: PublicKey, + compressibleConfig: CompressibleConfig | null = DEFAULT_COMPRESSIBLE_CONFIG, + configAccount: PublicKey = LIGHT_TOKEN_CONFIG, + rentPayerPda: PublicKey = LIGHT_TOKEN_RENT_SPONSOR, +): TransactionInstruction { + const associatedTokenAccount = getAssociatedLightTokenAddress(owner, mint); + + const data = encodeCreateAssociatedLightTokenAccountData( + { + compressibleConfig, + }, + false, + ); + + // Account order per Rust processor: + // 0. owner (non-mut, non-signer) + // 1. mint (non-mut, non-signer) + // 2. fee_payer (signer, mut) + // 3. associated_token_account (mut) + // 4. system_program + // Optional (only when compressibleConfig is non-null): + // 5. config account + // 6. rent_payer PDA + const keys: { + pubkey: PublicKey; + isSigner: boolean; + isWritable: boolean; + }[] = [ + { pubkey: owner, isSigner: false, isWritable: false }, + { pubkey: mint, isSigner: false, isWritable: false }, + { pubkey: feePayer, isSigner: true, isWritable: true }, + { + pubkey: associatedTokenAccount, + isSigner: false, + isWritable: true, + }, + { + pubkey: SystemProgram.programId, + isSigner: false, + isWritable: false, + }, + ]; + + if (compressibleConfig) { + keys.push( + { pubkey: configAccount, isSigner: false, isWritable: false }, + { pubkey: rentPayerPda, isSigner: false, isWritable: true }, + ); + } + + return new TransactionInstruction({ + programId: LIGHT_TOKEN_PROGRAM_ID, + keys, + data, + }); +} + +/** + * Create idempotent instruction for creating an associated light-token account. + * Uses the default rent sponsor PDA by default. + * + * @param feePayer Fee payer public key. + * @param owner Owner of the associated token account. + * @param mint Mint address. + * @param compressibleConfig Compressible configuration (defaults to rent sponsor config). + * @param configAccount Config account (defaults to LIGHT_TOKEN_CONFIG). + * @param rentPayerPda Rent payer PDA (defaults to LIGHT_TOKEN_RENT_SPONSOR). + */ +export function createAssociatedLightTokenAccountIdempotentInstruction( + feePayer: PublicKey, + owner: PublicKey, + mint: PublicKey, + compressibleConfig: CompressibleConfig | null = DEFAULT_COMPRESSIBLE_CONFIG, + configAccount: PublicKey = LIGHT_TOKEN_CONFIG, + rentPayerPda: PublicKey = LIGHT_TOKEN_RENT_SPONSOR, +): TransactionInstruction { + const associatedTokenAccount = getAssociatedLightTokenAddress(owner, mint); + + const data = encodeCreateAssociatedLightTokenAccountData( + { + compressibleConfig, + }, + true, + ); + + const keys: { + pubkey: PublicKey; + isSigner: boolean; + isWritable: boolean; + }[] = [ + { pubkey: owner, isSigner: false, isWritable: false }, + { pubkey: mint, isSigner: false, isWritable: false }, + { pubkey: feePayer, isSigner: true, isWritable: true }, + { + pubkey: associatedTokenAccount, + isSigner: false, + isWritable: true, + }, + { + pubkey: SystemProgram.programId, + isSigner: false, + isWritable: false, + }, + ]; + + if (compressibleConfig) { + keys.push( + { pubkey: configAccount, isSigner: false, isWritable: false }, + { pubkey: rentPayerPda, isSigner: false, isWritable: true }, + ); + } + + return new TransactionInstruction({ + programId: LIGHT_TOKEN_PROGRAM_ID, + keys, + data, + }); +} + +/** + * light-token-specific config for createAssociatedTokenAccountInstruction + */ +export interface LightTokenConfig { + compressibleConfig?: CompressibleConfig | null; + configAccount?: PublicKey; + rentPayerPda?: PublicKey; +} + +/** + * Create instruction for creating an associated token account (SPL, Token-2022, + * or light-token). Follows SPL Token API signature with optional light-token config at the + * end. + * + * @param payer Fee payer public key. + * @param associatedToken Associated token account address. + * @param owner Owner of the associated token account. + * @param mint Mint address. + * @param programId Token program ID (default: TOKEN_PROGRAM_ID). + * @param associatedTokenProgramId Associated token program ID. + * @param lightTokenConfig Optional light-token-specific configuration. + */ +function createAssociatedTokenAccountInstruction( + payer: PublicKey, + associatedToken: PublicKey, + owner: PublicKey, + mint: PublicKey, + programId: PublicKey = TOKEN_PROGRAM_ID, + associatedTokenProgramId?: PublicKey, + lightTokenConfig?: LightTokenConfig, +): TransactionInstruction { + const effectiveAssociatedTokenProgramId = + associatedTokenProgramId ?? getAtaProgramId(programId); + + if (programId.equals(LIGHT_TOKEN_PROGRAM_ID)) { + return createAssociatedLightTokenAccountInstruction( + payer, + owner, + mint, + lightTokenConfig?.compressibleConfig, + lightTokenConfig?.configAccount, + lightTokenConfig?.rentPayerPda, + ); + } else { + return createSplAssociatedTokenAccountInstruction( + payer, + associatedToken, + owner, + mint, + programId, + effectiveAssociatedTokenProgramId, + ); + } +} + +/** + * Create idempotent instruction for creating an associated token account (SPL, + * Token-2022, or light-token). Follows SPL Token API signature with optional light-token + * config at the end. + * + * @param payer Fee payer public key. + * @param associatedToken Associated token account address. + * @param owner Owner of the associated token account. + * @param mint Mint address. + * @param programId Token program ID (default: TOKEN_PROGRAM_ID). + * @param associatedTokenProgramId Associated token program ID. + * @param lightTokenConfig Optional light-token-specific configuration. + */ +function createAssociatedTokenAccountIdempotentInstruction( + payer: PublicKey, + associatedToken: PublicKey, + owner: PublicKey, + mint: PublicKey, + programId: PublicKey = TOKEN_PROGRAM_ID, + associatedTokenProgramId?: PublicKey, + lightTokenConfig?: LightTokenConfig, +): TransactionInstruction { + const effectiveAssociatedTokenProgramId = + associatedTokenProgramId ?? getAtaProgramId(programId); + + if (programId.equals(LIGHT_TOKEN_PROGRAM_ID)) { + return createAssociatedLightTokenAccountIdempotentInstruction( + payer, + owner, + mint, + lightTokenConfig?.compressibleConfig, + lightTokenConfig?.configAccount, + lightTokenConfig?.rentPayerPda, + ); + } else { + return createSplAssociatedTokenAccountIdempotentInstruction( + payer, + associatedToken, + owner, + mint, + programId, + effectiveAssociatedTokenProgramId, + ); + } +} + +export const createAta = createAssociatedTokenAccountInstruction; +export const createAtaIdempotent = + createAssociatedTokenAccountIdempotentInstruction; + +export function createAtaInstruction({ + payer, + owner, + mint, + programId, +}: CreateRawAtaInstructionInput): TransactionInstruction { + const targetProgramId = programId ?? LIGHT_TOKEN_PROGRAM_ID; + const associatedToken = getAtaAddress({ + owner, + mint, + programId: targetProgramId, + }); + + return createAtaIdempotent( + payer, + associatedToken, + owner, + mint, + targetProgramId, + ); +} + +export async function createAtaInstructions({ + payer, + owner, + mint, + programId, +}: CreateRawAtaInstructionInput): Promise { + return [createAtaInstruction({ payer, owner, mint, programId })]; +} + +export async function createAtaInstructionPlan( + input: CreateRawAtaInstructionInput, +) { + return toInstructionPlan(await createAtaInstructions(input)); +} diff --git a/js/token-interface/src/instructions/burn.ts b/js/token-interface/src/instructions/burn.ts new file mode 100644 index 0000000000..7f038882b0 --- /dev/null +++ b/js/token-interface/src/instructions/burn.ts @@ -0,0 +1,184 @@ +import { Buffer } from 'buffer'; +import { SystemProgram, TransactionInstruction } from '@solana/web3.js'; +import { LIGHT_TOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; +import { assertAccountNotFrozen, getAta } from '../account'; +import type { + CreateBurnInstructionsInput, + CreateRawBurnCheckedInstructionInput, + CreateRawBurnInstructionInput, +} from '../types'; +import { buildLoadInstructionList } from './load'; +import { toInstructionPlan } from './_plan'; + +const LIGHT_TOKEN_BURN_DISCRIMINATOR = 8; +const LIGHT_TOKEN_BURN_CHECKED_DISCRIMINATOR = 15; + +function toBigIntAmount(amount: number | bigint): bigint { + return BigInt(amount.toString()); +} + +export function createBurnInstruction({ + source, + mint, + authority, + amount, + payer, +}: CreateRawBurnInstructionInput): TransactionInstruction { + const data = Buffer.alloc(9); + data.writeUInt8(LIGHT_TOKEN_BURN_DISCRIMINATOR, 0); + data.writeBigUInt64LE(BigInt(amount), 1); + + const effectivePayer = payer ?? authority; + + return new TransactionInstruction({ + programId: LIGHT_TOKEN_PROGRAM_ID, + keys: [ + { pubkey: source, isSigner: false, isWritable: true }, + { pubkey: mint, isSigner: false, isWritable: true }, + { + pubkey: authority, + isSigner: true, + isWritable: effectivePayer.equals(authority), + }, + { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, + { + pubkey: effectivePayer, + isSigner: !effectivePayer.equals(authority), + isWritable: true, + }, + ], + data, + }); +} + +export function createBurnCheckedInstruction({ + source, + mint, + authority, + amount, + decimals, + payer, +}: CreateRawBurnCheckedInstructionInput): TransactionInstruction { + const data = Buffer.alloc(10); + data.writeUInt8(LIGHT_TOKEN_BURN_CHECKED_DISCRIMINATOR, 0); + data.writeBigUInt64LE(BigInt(amount), 1); + data.writeUInt8(decimals, 9); + + const effectivePayer = payer ?? authority; + + return new TransactionInstruction({ + programId: LIGHT_TOKEN_PROGRAM_ID, + keys: [ + { pubkey: source, isSigner: false, isWritable: true }, + { pubkey: mint, isSigner: false, isWritable: true }, + { + pubkey: authority, + isSigner: true, + isWritable: effectivePayer.equals(authority), + }, + { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, + { + pubkey: effectivePayer, + isSigner: !effectivePayer.equals(authority), + isWritable: true, + }, + ], + data, + }); +} + +export async function createBurnInstructions({ + rpc, + payer, + owner, + mint, + authority, + amount, + decimals, +}: CreateBurnInstructionsInput): Promise { + const account = await getAta({ rpc, owner, mint }); + + assertAccountNotFrozen(account, 'burn'); + + const amountBn = toBigIntAmount(amount); + const burnIx = + decimals !== undefined + ? createBurnCheckedInstruction({ + source: account.address, + mint, + authority, + amount: amountBn, + decimals, + payer, + }) + : createBurnInstruction({ + source: account.address, + mint, + authority, + amount: amountBn, + payer, + }); + + return [ + ...(await buildLoadInstructionList({ + rpc, + payer, + owner, + mint, + account, + wrap: true, + })), + burnIx, + ]; +} + +export async function createBurnInstructionsNowrap({ + rpc, + payer, + owner, + mint, + authority, + amount, + decimals, +}: CreateBurnInstructionsInput): Promise { + const account = await getAta({ rpc, owner, mint }); + + assertAccountNotFrozen(account, 'burn'); + + const amountBn = toBigIntAmount(amount); + const burnIx = + decimals !== undefined + ? createBurnCheckedInstruction({ + source: account.address, + mint, + authority, + amount: amountBn, + decimals, + payer, + }) + : createBurnInstruction({ + source: account.address, + mint, + authority, + amount: amountBn, + payer, + }); + + return [ + ...(await buildLoadInstructionList({ + rpc, + payer, + owner, + mint, + account, + wrap: false, + })), + burnIx, + ]; +} + +export async function createBurnInstructionPlan( + input: CreateBurnInstructionsInput, +) { + return toInstructionPlan(await createBurnInstructions(input)); +} diff --git a/js/token-interface/src/instructions/freeze.ts b/js/token-interface/src/instructions/freeze.ts new file mode 100644 index 0000000000..ef33ac6ae8 --- /dev/null +++ b/js/token-interface/src/instructions/freeze.ts @@ -0,0 +1,93 @@ +import { TransactionInstruction } from '@solana/web3.js'; +import { Buffer } from 'buffer'; +import { LIGHT_TOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; +import { + assertAccountNotFrozen, + getAta, +} from '../account'; +import type { + CreateFreezeInstructionsInput, + CreateRawFreezeInstructionInput, +} from '../types'; +import { buildLoadInstructionList } from './load'; +import { toInstructionPlan } from './_plan'; + +const LIGHT_TOKEN_FREEZE_ACCOUNT_DISCRIMINATOR = Buffer.from([10]); + +export function createFreezeInstruction({ + tokenAccount, + mint, + freezeAuthority, +}: CreateRawFreezeInstructionInput): TransactionInstruction { + return new TransactionInstruction({ + programId: LIGHT_TOKEN_PROGRAM_ID, + keys: [ + { pubkey: tokenAccount, isSigner: false, isWritable: true }, + { pubkey: mint, isSigner: false, isWritable: false }, + { pubkey: freezeAuthority, isSigner: true, isWritable: false }, + ], + data: LIGHT_TOKEN_FREEZE_ACCOUNT_DISCRIMINATOR, + }); +} + +export async function createFreezeInstructions({ + rpc, + payer, + owner, + mint, + freezeAuthority, +}: CreateFreezeInstructionsInput): Promise { + const account = await getAta({ rpc, owner, mint }); + + assertAccountNotFrozen(account, 'freeze'); + + return [ + ...(await buildLoadInstructionList({ + rpc, + payer, + owner, + mint, + account, + wrap: true, + })), + createFreezeInstruction({ + tokenAccount: account.address, + mint, + freezeAuthority, + }), + ]; +} + +export async function createFreezeInstructionsNowrap({ + rpc, + payer, + owner, + mint, + freezeAuthority, +}: CreateFreezeInstructionsInput): Promise { + const account = await getAta({ rpc, owner, mint }); + + assertAccountNotFrozen(account, 'freeze'); + + return [ + ...(await buildLoadInstructionList({ + rpc, + payer, + owner, + mint, + account, + wrap: false, + })), + createFreezeInstruction({ + tokenAccount: account.address, + mint, + freezeAuthority, + }), + ]; +} + +export async function createFreezeInstructionPlan( + input: CreateFreezeInstructionsInput, +) { + return toInstructionPlan(await createFreezeInstructions(input)); +} diff --git a/js/token-interface/src/instructions/index.ts b/js/token-interface/src/instructions/index.ts index 720c747ad2..dcaca06122 100644 --- a/js/token-interface/src/instructions/index.ts +++ b/js/token-interface/src/instructions/index.ts @@ -1,318 +1,9 @@ -import type { TransactionInstruction } from '@solana/web3.js'; -import { LIGHT_TOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; -import { - createUnwrapInstruction, - getSplInterfaceInfos, -} from '@lightprotocol/compressed-token'; -import { - TOKEN_2022_PROGRAM_ID, - TOKEN_PROGRAM_ID, - createCloseAccountInstruction, - unpackAccount, -} from '@solana/spl-token'; -import { assertAccountNotFrozen, getAta } from '../account'; -import { getMintDecimals } from '../helpers'; -import { createLoadInstructionInternal } from '../load'; -import { getAtaAddress } from '../read'; -import type { - CreateApproveInstructionsInput, - CreateAtaInstructionsInput, - CreateFreezeInstructionsInput, - CreateLoadInstructionsInput, - CreateRevokeInstructionsInput, - CreateThawInstructionsInput, - CreateTransferInstructionsInput, -} from '../types'; -import { - createApproveInstruction, - createAtaInstruction, - createFreezeInstruction, - createRevokeInstruction, - createThawInstruction, - createTransferCheckedInstruction, -} from './raw'; - -/* - * Canonical async instruction builders: transfer, approve, and revoke prepend sender-side load - * instructions when cold storage must be decompressed first. createAtaInstructions only emits the - * ATA creation instruction. createFreezeInstructions and createThawInstructions do not load. - * For manual load-only composition, see createLoadInstructions. - */ - -const ZERO = BigInt(0); - -function toBigIntAmount(amount: number | bigint): bigint { - return BigInt(amount.toString()); -} - -async function buildLoadInstructions( - input: CreateLoadInstructionsInput & { - authority?: CreateTransferInstructionsInput['authority']; - account?: Awaited>; - wrap?: boolean; - }, -): Promise { - const load = await createLoadInstructionInternal(input); - - if (!load) { - return []; - } - - return load.instructions; -} - -async function getDerivedAtaBalance( - rpc: CreateTransferInstructionsInput['rpc'], - owner: CreateTransferInstructionsInput['sourceOwner'], - mint: CreateTransferInstructionsInput['mint'], - programId: typeof TOKEN_PROGRAM_ID | typeof TOKEN_2022_PROGRAM_ID, -): Promise { - const ata = getAtaAddress({ owner, mint, programId }); - const info = await rpc.getAccountInfo(ata); - if (!info || !info.owner.equals(programId)) { - return ZERO; - } - - return unpackAccount(ata, info, programId).amount; -} - -export async function createAtaInstructions({ - payer, - owner, - mint, - programId, -}: CreateAtaInstructionsInput): Promise { - return [createAtaInstruction({ payer, owner, mint, programId })]; -} - -/** - * Advanced: standalone load (decompress) instructions for an ATA, plus create-ATA if missing. - * Prefer the canonical builders (`buildTransferInstructions`, `createApproveInstructions`, - * `createRevokeInstructions`, …), which prepend load automatically when needed. - */ -export async function createLoadInstructions({ - rpc, - payer, - owner, - mint, -}: CreateLoadInstructionsInput): Promise { - return buildLoadInstructions({ - rpc, - payer, - owner, - mint, - wrap: true, - }); -} - -/** - * Canonical web3.js transfer flow builder. - * Returns an instruction array for a single transfer flow (setup + transfer). - */ -export async function buildTransferInstructions({ - rpc, - payer, - mint, - sourceOwner, - authority, - recipient, - tokenProgram, - amount, -}: CreateTransferInstructionsInput): Promise { - const amountBigInt = toBigIntAmount(amount); - const senderLoadInstructions = await buildLoadInstructions({ - rpc, - payer, - owner: sourceOwner, - mint, - authority, - wrap: true, - }); - const recipientTokenProgramId = tokenProgram ?? LIGHT_TOKEN_PROGRAM_ID; - const recipientAta = getAtaAddress({ - owner: recipient, - mint, - programId: recipientTokenProgramId, - }); - const decimals = await getMintDecimals(rpc, mint); - const [senderSplBalance, senderT22Balance] = await Promise.all([ - getDerivedAtaBalance(rpc, sourceOwner, mint, TOKEN_PROGRAM_ID), - getDerivedAtaBalance(rpc, sourceOwner, mint, TOKEN_2022_PROGRAM_ID), - ]); - - const closeWrappedSourceInstructions: TransactionInstruction[] = []; - if (authority.equals(sourceOwner) && senderSplBalance > ZERO) { - closeWrappedSourceInstructions.push( - createCloseAccountInstruction( - getAtaAddress({ - owner: sourceOwner, - mint, - programId: TOKEN_PROGRAM_ID, - }), - sourceOwner, - sourceOwner, - [], - TOKEN_PROGRAM_ID, - ), - ); - } - if (authority.equals(sourceOwner) && senderT22Balance > ZERO) { - closeWrappedSourceInstructions.push( - createCloseAccountInstruction( - getAtaAddress({ - owner: sourceOwner, - mint, - programId: TOKEN_2022_PROGRAM_ID, - }), - sourceOwner, - sourceOwner, - [], - TOKEN_2022_PROGRAM_ID, - ), - ); - } - - const recipientLoadInstructions: TransactionInstruction[] = []; - // Recipient-side load is intentionally disabled until the program allows - // third-party load on behalf of the recipient ATA. - // const recipientLoadInstructions = await buildLoadInstructions({ - // rpc, - // payer, - // owner: recipient, - // mint, - // }); - const senderAta = getAtaAddress({ - owner: sourceOwner, - mint, - }); - let transferInstruction: TransactionInstruction; - if (recipientTokenProgramId.equals(LIGHT_TOKEN_PROGRAM_ID)) { - transferInstruction = createTransferCheckedInstruction({ - source: senderAta, - destination: recipientAta, - mint, - authority, - payer, - amount: amountBigInt, - decimals, - }); - } else { - const splInterfaceInfos = await getSplInterfaceInfos(rpc, mint); - const splInterfaceInfo = splInterfaceInfos.find( - info => - info.isInitialized && - info.tokenProgram.equals(recipientTokenProgramId), - ); - if (!splInterfaceInfo) { - throw new Error( - `No initialized SPL interface found for tokenProgram ${recipientTokenProgramId.toBase58()}.`, - ); - } - transferInstruction = createUnwrapInstruction( - senderAta, - recipientAta, - authority, - mint, - amountBigInt, - splInterfaceInfo, - decimals, - payer, - ); - } - - return [ - ...senderLoadInstructions, - ...closeWrappedSourceInstructions, - createAtaInstruction({ - payer, - owner: recipient, - mint, - programId: recipientTokenProgramId, - }), - ...recipientLoadInstructions, - transferInstruction, - ]; -} - -/** - * Backwards-compatible alias. - */ -export const createTransferInstructions = buildTransferInstructions; - -export async function createApproveInstructions({ - rpc, - payer, - owner, - mint, - delegate, - amount, -}: CreateApproveInstructionsInput): Promise { - const account = await getAta({ - rpc, - owner, - mint, - }); - - assertAccountNotFrozen(account, 'approve'); - - return [ - ...(await buildLoadInstructions({ - rpc, - payer, - owner, - mint, - account, - wrap: true, - })), - createApproveInstruction({ - tokenAccount: account.address, - delegate, - owner, - amount: toBigIntAmount(amount), - payer, - }), - ]; -} - -export async function createRevokeInstructions({ - rpc, - payer, - owner, - mint, -}: CreateRevokeInstructionsInput): Promise { - const account = await getAta({ - rpc, - owner, - mint, - }); - - assertAccountNotFrozen(account, 'revoke'); - - return [ - ...(await buildLoadInstructions({ - rpc, - payer, - owner, - mint, - account, - wrap: true, - })), - createRevokeInstruction({ - tokenAccount: account.address, - owner, - payer, - }), - ]; -} - -export async function createFreezeInstructions( - input: CreateFreezeInstructionsInput, -): Promise { - return [createFreezeInstruction(input)]; -} - -export async function createThawInstructions( - input: CreateThawInstructionsInput, -): Promise { - return [createThawInstruction(input)]; -} +export * from './_plan'; +export * from './ata'; +export * from './approve'; +export * from './revoke'; +export * from './transfer'; +export * from './load'; +export * from './burn'; +export * from './freeze'; +export * from './thaw'; diff --git a/js/token-interface/src/instructions/layout/layout-mint-action.ts b/js/token-interface/src/instructions/layout/layout-mint-action.ts new file mode 100644 index 0000000000..04a0ef8c9e --- /dev/null +++ b/js/token-interface/src/instructions/layout/layout-mint-action.ts @@ -0,0 +1,362 @@ +/** + * Borsh layouts for MintAction instruction data + * + * These layouts match the Rust structs in: + * program-libs/light-token-types/src/instructions/mint_action/ + * + * @module mint-action-layout + */ +import { PublicKey } from '@solana/web3.js'; +import { Buffer } from 'buffer'; +import { + struct, + option, + vec, + bool, + u8, + u16, + u32, + u64, + array, + vecU8, + publicKey, + rustEnum, +} from '@coral-xyz/borsh'; +import { bn } from '@lightprotocol/stateless.js'; + +export const MINT_ACTION_DISCRIMINATOR = Buffer.from([103]); + +export const RecipientLayout = struct([publicKey('recipient'), u64('amount')]); + +export const MintToCompressedActionLayout = struct([ + u8('tokenAccountVersion'), + vec(RecipientLayout, 'recipients'), +]); + +export const UpdateAuthorityLayout = struct([ + option(publicKey(), 'newAuthority'), +]); + +export const MintToLightTokenActionLayout = struct([ + u8('accountIndex'), + u64('amount'), +]); + +export const UpdateMetadataFieldActionLayout = struct([ + u8('extensionIndex'), + u8('fieldType'), + vecU8('key'), + vecU8('value'), +]); + +export const UpdateMetadataAuthorityActionLayout = struct([ + u8('extensionIndex'), + publicKey('newAuthority'), +]); + +export const RemoveMetadataKeyActionLayout = struct([ + u8('extensionIndex'), + vecU8('key'), + u8('idempotent'), +]); + +export const DecompressMintActionLayout = struct([ + u8('rentPayment'), + u32('writeTopUp'), +]); + +export const CompressAndCloseCMintActionLayout = struct([u8('idempotent')]); + +export const ActionLayout = rustEnum([ + MintToCompressedActionLayout.replicate('mintToCompressed'), + UpdateAuthorityLayout.replicate('updateMintAuthority'), + UpdateAuthorityLayout.replicate('updateFreezeAuthority'), + MintToLightTokenActionLayout.replicate('mintToLightToken'), + UpdateMetadataFieldActionLayout.replicate('updateMetadataField'), + UpdateMetadataAuthorityActionLayout.replicate('updateMetadataAuthority'), + RemoveMetadataKeyActionLayout.replicate('removeMetadataKey'), + DecompressMintActionLayout.replicate('decompressMint'), + CompressAndCloseCMintActionLayout.replicate('compressAndCloseCMint'), +]); + +export const CompressedProofLayout = struct([ + array(u8(), 32, 'a'), + array(u8(), 64, 'b'), + array(u8(), 32, 'c'), +]); + +export const CpiContextLayout = struct([ + bool('setContext'), + bool('firstSetContext'), + u8('inTreeIndex'), + u8('inQueueIndex'), + u8('outQueueIndex'), + u8('tokenOutQueueIndex'), + u8('assignedAccountIndex'), + array(u8(), 4, 'readOnlyAddressTrees'), + array(u8(), 32, 'addressTreePubkey'), +]); + +export const CreateMintLayout = struct([ + array(u8(), 4, 'readOnlyAddressTrees'), + array(u16(), 4, 'readOnlyAddressTreeRootIndices'), +]); + +export const AdditionalMetadataLayout = struct([vecU8('key'), vecU8('value')]); + +export const TokenMetadataInstructionDataLayout = struct([ + option(publicKey(), 'updateAuthority'), + vecU8('name'), + vecU8('symbol'), + vecU8('uri'), + option(vec(AdditionalMetadataLayout), 'additionalMetadata'), +]); + +const PlaceholderLayout = struct([]); + +export const ExtensionInstructionDataLayout = rustEnum([ + PlaceholderLayout.replicate('placeholder0'), + PlaceholderLayout.replicate('placeholder1'), + PlaceholderLayout.replicate('placeholder2'), + PlaceholderLayout.replicate('placeholder3'), + PlaceholderLayout.replicate('placeholder4'), + PlaceholderLayout.replicate('placeholder5'), + PlaceholderLayout.replicate('placeholder6'), + PlaceholderLayout.replicate('placeholder7'), + PlaceholderLayout.replicate('placeholder8'), + PlaceholderLayout.replicate('placeholder9'), + PlaceholderLayout.replicate('placeholder10'), + PlaceholderLayout.replicate('placeholder11'), + PlaceholderLayout.replicate('placeholder12'), + PlaceholderLayout.replicate('placeholder13'), + PlaceholderLayout.replicate('placeholder14'), + PlaceholderLayout.replicate('placeholder15'), + PlaceholderLayout.replicate('placeholder16'), + PlaceholderLayout.replicate('placeholder17'), + PlaceholderLayout.replicate('placeholder18'), + TokenMetadataInstructionDataLayout.replicate('tokenMetadata'), +]); + +export const CompressedMintMetadataLayout = struct([ + u8('version'), + bool('cmintDecompressed'), + publicKey('mint'), + array(u8(), 32, 'mintSigner'), + u8('bump'), +]); + +export const MintInstructionDataLayout = struct([ + u64('supply'), + u8('decimals'), + CompressedMintMetadataLayout.replicate('metadata'), + option(publicKey(), 'mintAuthority'), + option(publicKey(), 'freezeAuthority'), + option(vec(ExtensionInstructionDataLayout), 'extensions'), +]); + +export const MintActionCompressedInstructionDataLayout = struct([ + u32('leafIndex'), + bool('proveByIndex'), + u16('rootIndex'), + u16('maxTopUp'), + option(CreateMintLayout, 'createMint'), + vec(ActionLayout, 'actions'), + option(CompressedProofLayout, 'proof'), + option(CpiContextLayout, 'cpiContext'), + option(MintInstructionDataLayout, 'mint'), +]); + +export interface ValidityProof { + a: number[]; + b: number[]; + c: number[]; +} + +export interface Recipient { + recipient: PublicKey; + amount: bigint; +} + +export interface MintToCompressedAction { + tokenAccountVersion: number; + recipients: Recipient[]; +} + +export interface UpdateAuthority { + newAuthority: PublicKey | null; +} + +export interface MintToLightTokenAction { + accountIndex: number; + amount: bigint; +} + +export interface UpdateMetadataFieldAction { + extensionIndex: number; + fieldType: number; + key: Buffer; + value: Buffer; +} + +export interface UpdateMetadataAuthorityAction { + extensionIndex: number; + newAuthority: PublicKey; +} + +export interface RemoveMetadataKeyAction { + extensionIndex: number; + key: Buffer; + idempotent: number; +} + +export interface DecompressMintAction { + rentPayment: number; + writeTopUp: number; +} + +export interface CompressAndCloseCMintAction { + idempotent: number; +} + +export type Action = + | { mintToCompressed: MintToCompressedAction } + | { updateMintAuthority: UpdateAuthority } + | { updateFreezeAuthority: UpdateAuthority } + | { mintToLightToken: MintToLightTokenAction } + | { updateMetadataField: UpdateMetadataFieldAction } + | { updateMetadataAuthority: UpdateMetadataAuthorityAction } + | { removeMetadataKey: RemoveMetadataKeyAction } + | { decompressMint: DecompressMintAction } + | { compressAndCloseCMint: CompressAndCloseCMintAction }; + +export interface CpiContext { + setContext: boolean; + firstSetContext: boolean; + inTreeIndex: number; + inQueueIndex: number; + outQueueIndex: number; + tokenOutQueueIndex: number; + assignedAccountIndex: number; + readOnlyAddressTrees: number[]; + addressTreePubkey: number[]; +} + +export interface CreateMint { + readOnlyAddressTrees: number[]; + readOnlyAddressTreeRootIndices: number[]; +} + +export interface AdditionalMetadata { + key: Buffer; + value: Buffer; +} + +export interface TokenMetadataLayoutData { + updateAuthority: PublicKey | null; + name: Buffer; + symbol: Buffer; + uri: Buffer; + additionalMetadata: AdditionalMetadata[] | null; +} + +export type ExtensionInstructionData = { + tokenMetadata: TokenMetadataLayoutData; +}; + +export interface CompressedMintMetadata { + version: number; + cmintDecompressed: boolean; + mint: PublicKey; + mintSigner: number[]; + bump: number; +} + +export interface MintLayoutData { + supply: bigint; + decimals: number; + metadata: CompressedMintMetadata; + mintAuthority: PublicKey | null; + freezeAuthority: PublicKey | null; + extensions: ExtensionInstructionData[] | null; +} + +export interface MintActionCompressedInstructionData { + leafIndex: number; + proveByIndex: boolean; + rootIndex: number; + maxTopUp: number; + createMint: CreateMint | null; + actions: Action[]; + proof: ValidityProof | null; + cpiContext: CpiContext | null; + mint: MintLayoutData | null; +} + +/** + * Encode MintActionCompressedInstructionData to buffer + * + * @param data - The instruction data to encode + * @returns Encoded buffer with discriminator prepended + * @internal + */ +export function encodeMintActionInstructionData( + data: MintActionCompressedInstructionData, +): Buffer { + // Convert bigint fields to BN for Borsh encoding + const convertedActions = data.actions.map(action => { + if ('mintToCompressed' in action && action.mintToCompressed) { + return { + mintToCompressed: { + ...action.mintToCompressed, + recipients: action.mintToCompressed.recipients.map(r => ({ + ...r, + amount: bn(r.amount.toString()), + })), + }, + }; + } + if ('mintToLightToken' in action && action.mintToLightToken) { + return { + mintToLightToken: { + ...action.mintToLightToken, + amount: bn(action.mintToLightToken.amount.toString()), + }, + }; + } + return action; + }); + + const buffer = Buffer.alloc(10000); + + const encodableData = { + ...data, + actions: convertedActions, + mint: data.mint + ? { + ...data.mint, + supply: bn(data.mint.supply.toString()), + } + : null, + }; + const len = MintActionCompressedInstructionDataLayout.encode( + encodableData, + buffer, + ); + + return Buffer.concat([MINT_ACTION_DISCRIMINATOR, buffer.subarray(0, len)]); +} + +/** + * Decode MintActionCompressedInstructionData from buffer + * + * @param buffer - The buffer to decode (including discriminator) + * @returns Decoded instruction data + * @internal + */ +export function decodeMintActionInstructionData( + buffer: Buffer, +): MintActionCompressedInstructionData { + return MintActionCompressedInstructionDataLayout.decode( + buffer.subarray(MINT_ACTION_DISCRIMINATOR.length), + ) as MintActionCompressedInstructionData; +} diff --git a/js/token-interface/src/instructions/layout/layout-mint.ts b/js/token-interface/src/instructions/layout/layout-mint.ts new file mode 100644 index 0000000000..692e0c501a --- /dev/null +++ b/js/token-interface/src/instructions/layout/layout-mint.ts @@ -0,0 +1,502 @@ +import { MINT_SIZE, MintLayout } from '@solana/spl-token'; +import { PublicKey } from '@solana/web3.js'; +import { Buffer } from 'buffer'; +import { struct, u8 } from '@solana/buffer-layout'; +import { publicKey } from '@solana/buffer-layout-utils'; +import { + struct as borshStruct, + vec, + vecU8, + publicKey as borshPublicKey, +} from '@coral-xyz/borsh'; + +/** + * SPL-compatible base mint structure + */ +export interface BaseMint { + /** Optional authority used to mint new tokens */ + mintAuthority: PublicKey | null; + /** Total supply of tokens */ + supply: bigint; + /** Number of base 10 digits to the right of the decimal place */ + decimals: number; + /** Is initialized - for SPL compatibility */ + isInitialized: boolean; + /** Optional authority to freeze token accounts */ + freezeAuthority: PublicKey | null; +} + +/** + * Light mint context (protocol version, SPL mint reference) + */ +export interface MintContext { + /** Protocol version for upgradability */ + version: number; + /** Whether the compressed light mint has been decompressed to a light mint account */ + cmintDecompressed: boolean; + /** PDA of the associated SPL mint */ + splMint: PublicKey; + /** Signer pubkey used to derive the mint PDA */ + mintSigner: Uint8Array; + /** Bump seed for the mint PDA */ + bump: number; +} + +/** + * Raw extension data as stored on-chain + */ +export interface MintExtension { + extensionType: number; + data: Uint8Array; +} + +/** + * Parsed token metadata matching on-chain TokenMetadata extension. + * Fields: updateAuthority, mint, name, symbol, uri, additionalMetadata + */ +export interface TokenMetadata { + /** Authority that can update metadata (None if zero pubkey) */ + updateAuthority?: PublicKey | null; + /** Associated mint pubkey */ + mint: PublicKey; + /** Token name */ + name: string; + /** Token symbol */ + symbol: string; + /** URI pointing to off-chain metadata JSON */ + uri: string; + /** Additional key-value metadata pairs */ + additionalMetadata?: { key: string; value: string }[]; +} + +/** + * Borsh layout for TokenMetadata extension data + * Format: updateAuthority (32) + mint (32) + name + symbol + uri + additional_metadata + */ +export const TokenMetadataLayout = borshStruct([ + borshPublicKey('updateAuthority'), + borshPublicKey('mint'), + vecU8('name'), + vecU8('symbol'), + vecU8('uri'), + vec(borshStruct([vecU8('key'), vecU8('value')]), 'additionalMetadata'), +]); + +/** + * Complete light mint structure (raw format) + */ +export interface CompressedMint { + base: BaseMint; + mintContext: MintContext; + /** Reserved bytes for T22 layout compatibility */ + reserved: Uint8Array; + /** Account type discriminator (1 = Mint) */ + accountType: number; + /** Compression info embedded in mint */ + compression: CompressionInfo; + extensions: MintExtension[] | null; +} + +/** MintContext as stored by the program */ +/** + * Raw mint context for layout encoding (mintSigner and bump are encoded separately) + */ +export interface RawMintContext { + version: number; + cmintDecompressed: number; // bool as u8 + splMint: PublicKey; +} + +/** Buffer layout for de/serializing MintContext */ +export const MintContextLayout = struct([ + u8('version'), + u8('cmintDecompressed'), + publicKey('splMint'), +]); + +/** Byte length of MintContext (excluding mintSigner and bump which are read separately) */ +export const MINT_CONTEXT_SIZE = MintContextLayout.span; // 34 bytes + +/** Additional bytes for mintSigner (32) + bump (1) */ +export const MINT_SIGNER_SIZE = 32; +export const BUMP_SIZE = 1; + +/** Reserved bytes for T22 layout compatibility (padding to reach byte 165) */ +export const RESERVED_SIZE = 16; + +/** Account type discriminator size */ +export const ACCOUNT_TYPE_SIZE = 1; + +/** Account type value for light mint */ +export const ACCOUNT_TYPE_MINT = 1; + +/** + * Rent configuration for compressible accounts + */ +export interface RentConfig { + /** Base rent constant per epoch */ + baseRent: number; + /** Compression cost in lamports */ + compressionCost: number; + /** Lamports per byte per epoch */ + lamportsPerBytePerEpoch: number; + /** Maximum epochs that can be pre-funded */ + maxFundedEpochs: number; + /** Maximum lamports for top-up operation */ + maxTopUp: number; +} + +/** Byte length of RentConfig */ +export const RENT_CONFIG_SIZE = 8; // 2 + 2 + 1 + 1 + 2 + +/** + * Compression info embedded in light mint + */ +export interface CompressionInfo { + /** Config account version (0 = uninitialized) */ + configAccountVersion: number; + /** Whether to compress to pubkey instead of owner */ + compressToPubkey: number; + /** Account version for hashing scheme */ + accountVersion: number; + /** Lamports to top up per write */ + lamportsPerWrite: number; + /** Authority that can compress the account */ + compressionAuthority: PublicKey; + /** Recipient for rent on closure */ + rentSponsor: PublicKey; + /** Last slot rent was claimed */ + lastClaimedSlot: bigint; + /** Rent exemption lamports paid at account creation */ + rentExemptionPaid: number; + /** Reserved for future use */ + reserved: number; + /** Rent configuration */ + rentConfig: RentConfig; +} + +/** Byte length of CompressionInfo */ +export const COMPRESSION_INFO_SIZE = 96; // 2 + 1 + 1 + 4 + 32 + 32 + 8 + 4 + 4 + 8 + +/** + * Calculate the byte length of a TokenMetadata extension from buffer. + * Format: updateAuthority (32) + mint (32) + name (4+len) + symbol (4+len) + uri (4+len) + additional (4 + items) + * @internal + */ +function getTokenMetadataByteLength( + buffer: Buffer, + startOffset: number, +): number { + let offset = startOffset; + + // updateAuthority: 32 bytes + offset += 32; + // mint: 32 bytes + offset += 32; + + // name: Vec + const nameLen = buffer.readUInt32LE(offset); + offset += 4 + nameLen; + + // symbol: Vec + const symbolLen = buffer.readUInt32LE(offset); + offset += 4 + symbolLen; + + // uri: Vec + const uriLen = buffer.readUInt32LE(offset); + offset += 4 + uriLen; + + // additional_metadata: Vec + const additionalCount = buffer.readUInt32LE(offset); + offset += 4; + for (let i = 0; i < additionalCount; i++) { + const keyLen = buffer.readUInt32LE(offset); + offset += 4 + keyLen; + const valueLen = buffer.readUInt32LE(offset); + offset += 4 + valueLen; + } + + return offset - startOffset; +} + +/** + * Get the byte length of an extension based on its type. + * Returns the length of the extension data (excluding the 1-byte discriminant). + * @internal + */ +function getExtensionByteLength( + extensionType: number, + buffer: Buffer, + dataStartOffset: number, +): number { + switch (extensionType) { + case ExtensionType.TokenMetadata: + return getTokenMetadataByteLength(buffer, dataStartOffset); + default: + // For unknown extensions, we can't determine the length + // Return remaining buffer length as fallback + return buffer.length - dataStartOffset; + } +} + +/** + * Deserialize CompressionInfo from buffer at given offset + * @returns Tuple of [CompressionInfo, bytesRead] + * @internal + */ +function deserializeCompressionInfo( + buffer: Buffer, + offset: number, +): [CompressionInfo, number] { + const startOffset = offset; + + const configAccountVersion = buffer.readUInt16LE(offset); + offset += 2; + + const compressToPubkey = buffer.readUInt8(offset); + offset += 1; + + const accountVersion = buffer.readUInt8(offset); + offset += 1; + + const lamportsPerWrite = buffer.readUInt32LE(offset); + offset += 4; + + const compressionAuthority = new PublicKey( + buffer.slice(offset, offset + 32), + ); + offset += 32; + + const rentSponsor = new PublicKey(buffer.slice(offset, offset + 32)); + offset += 32; + + const lastClaimedSlot = buffer.readBigUInt64LE(offset); + offset += 8; + + // Read rent_exemption_paid (u32) and _reserved (u32) + const rentExemptionPaid = buffer.readUInt32LE(offset); + offset += 4; + const reserved = buffer.readUInt32LE(offset); + offset += 4; + + // Read RentConfig (8 bytes) + const baseRent = buffer.readUInt16LE(offset); + offset += 2; + const compressionCost = buffer.readUInt16LE(offset); + offset += 2; + const lamportsPerBytePerEpoch = buffer.readUInt8(offset); + offset += 1; + const maxFundedEpochs = buffer.readUInt8(offset); + offset += 1; + const maxTopUp = buffer.readUInt16LE(offset); + offset += 2; + + const rentConfig: RentConfig = { + baseRent, + compressionCost, + lamportsPerBytePerEpoch, + maxFundedEpochs, + maxTopUp, + }; + + const compressionInfo: CompressionInfo = { + configAccountVersion, + compressToPubkey, + accountVersion, + lamportsPerWrite, + compressionAuthority, + rentSponsor, + lastClaimedSlot, + rentExemptionPaid, + reserved, + rentConfig, + }; + + return [compressionInfo, offset - startOffset]; +} + +/** + * Deserialize a light mint from buffer + * Uses SPL's MintLayout for BaseMint and buffer-layout struct for context + * + * @param data - The raw account data buffer + * @returns The deserialized light mint + */ +export function deserializeMint(data: Buffer | Uint8Array): CompressedMint { + const buffer = data instanceof Buffer ? data : Buffer.from(data); + let offset = 0; + + // 1. Decode BaseMint using SPL's MintLayout (82 bytes) + const rawMint = MintLayout.decode(buffer.slice(offset, offset + MINT_SIZE)); + offset += MINT_SIZE; + + // 2. Decode MintContext using our layout (34 bytes) + const rawContext = MintContextLayout.decode( + buffer.slice(offset, offset + MINT_CONTEXT_SIZE), + ); + offset += MINT_CONTEXT_SIZE; + + // 2b. Read mintSigner (32 bytes) and bump (1 byte) + const mintSigner = buffer.slice(offset, offset + MINT_SIGNER_SIZE); + offset += MINT_SIGNER_SIZE; + const bump = buffer.readUInt8(offset); + offset += BUMP_SIZE; + + // 3. Read reserved bytes (16 bytes) for T22 compatibility + const reserved = buffer.slice(offset, offset + RESERVED_SIZE); + offset += RESERVED_SIZE; + + // 4. Read account_type discriminator (1 byte) + const accountType = buffer.readUInt8(offset); + offset += ACCOUNT_TYPE_SIZE; + + // 5. Read CompressionInfo (96 bytes) + const [compression, compressionBytesRead] = deserializeCompressionInfo( + buffer, + offset, + ); + offset += compressionBytesRead; + + // 6. Parse extensions: Option> + // Borsh format: Option byte + Vec length + (discriminant + variant data) for each + const hasExtensions = buffer.readUInt8(offset) === 1; + offset += 1; + + let extensions: MintExtension[] | null = null; + if (hasExtensions) { + const vecLen = buffer.readUInt32LE(offset); + offset += 4; + + extensions = []; + for (let i = 0; i < vecLen; i++) { + const extensionType = buffer.readUInt8(offset); + offset += 1; + + // Calculate extension data length based on type + const dataLength = getExtensionByteLength( + extensionType, + buffer, + offset, + ); + const extensionData = buffer.slice(offset, offset + dataLength); + offset += dataLength; + + extensions.push({ + extensionType, + data: extensionData, + }); + } + } + + // Convert raw types to our interface with proper null handling + const baseMint: BaseMint = { + mintAuthority: + rawMint.mintAuthorityOption === 1 ? rawMint.mintAuthority : null, + supply: rawMint.supply, + decimals: rawMint.decimals, + isInitialized: rawMint.isInitialized, + freezeAuthority: + rawMint.freezeAuthorityOption === 1 + ? rawMint.freezeAuthority + : null, + }; + + const mintContext: MintContext = { + version: rawContext.version, + cmintDecompressed: rawContext.cmintDecompressed !== 0, + splMint: rawContext.splMint, + mintSigner, + bump, + }; + + const mint: CompressedMint = { + base: baseMint, + mintContext, + reserved, + accountType, + compression, + extensions, + }; + + return mint; +} + +/** + * Extension type constants + */ +export enum ExtensionType { + TokenMetadata = 19, // Name, symbol, uri + // Add more extension types as needed +} + +/** + * Decode TokenMetadata from raw extension data using Borsh layout + * Extension format: updateAuthority (32) + mint (32) + name (Vec) + symbol (Vec) + uri (Vec) + additional (Vec) + */ +function decodeTokenMetadata(data: Uint8Array): TokenMetadata | null { + try { + const buffer = Buffer.from(data); + // Minimum size: 32 (updateAuthority) + 32 (mint) + 4 (name len) + 4 (symbol len) + 4 (uri len) + 4 (additional len) = 80 + if (buffer.length < 80) { + return null; + } + + // Decode using Borsh layout + const decoded = TokenMetadataLayout.decode(buffer) as { + updateAuthority: PublicKey; + mint: PublicKey; + name: Buffer; + symbol: Buffer; + uri: Buffer; + additionalMetadata: { key: Buffer; value: Buffer }[]; + }; + + // Convert zero pubkey to undefined for updateAuthority + const updateAuthorityBytes = decoded.updateAuthority.toBuffer(); + const isZero = updateAuthorityBytes.every((b: number) => b === 0); + const updateAuthority = isZero ? undefined : decoded.updateAuthority; + + // Convert Buffer fields to strings + const name = Buffer.from(decoded.name).toString('utf-8'); + const symbol = Buffer.from(decoded.symbol).toString('utf-8'); + const uri = Buffer.from(decoded.uri).toString('utf-8'); + + // Convert additional metadata + let additionalMetadata: { key: string; value: string }[] | undefined; + if ( + decoded.additionalMetadata && + decoded.additionalMetadata.length > 0 + ) { + additionalMetadata = decoded.additionalMetadata.map(item => ({ + key: Buffer.from(item.key).toString('utf-8'), + value: Buffer.from(item.value).toString('utf-8'), + })); + } + + return { + updateAuthority, + mint: decoded.mint, + name, + symbol, + uri, + additionalMetadata, + }; + } catch { + return null; + } +} + +/** + * Extract and parse TokenMetadata from extensions array + * @param extensions - Array of raw extensions + * @returns Parsed TokenMetadata or null if not found + */ +export function extractTokenMetadata( + extensions: MintExtension[] | null, +): TokenMetadata | null { + if (!extensions) return null; + const metadataExt = extensions.find( + ext => ext.extensionType === ExtensionType.TokenMetadata, + ); + return metadataExt ? decodeTokenMetadata(metadataExt.data) : null; +} diff --git a/js/token-interface/src/instructions/layout/layout-transfer2.ts b/js/token-interface/src/instructions/layout/layout-transfer2.ts new file mode 100644 index 0000000000..0f17dfb141 --- /dev/null +++ b/js/token-interface/src/instructions/layout/layout-transfer2.ts @@ -0,0 +1,540 @@ +import { + struct, + option, + vec, + bool, + u64, + u8, + u16, + u32, + array, +} from '@coral-xyz/borsh'; +import { Buffer } from 'buffer'; +import { bn } from '@lightprotocol/stateless.js'; +import { PublicKey } from '@solana/web3.js'; +import { CompressionInfo } from './layout-mint'; +import { + AdditionalMetadata, + CompressedProofLayout, + TokenMetadataInstructionDataLayout, +} from './layout-mint-action'; + +// Transfer2 discriminator = 101 +export const TRANSFER2_DISCRIMINATOR = Buffer.from([101]); + +// Extension discriminant values (matching Rust enum) +export const EXTENSION_DISCRIMINANT_TOKEN_METADATA = 19; +export const EXTENSION_DISCRIMINANT_COMPRESSED_ONLY = 31; +export const EXTENSION_DISCRIMINANT_COMPRESSIBLE = 32; + +// CompressionMode enum values +export const COMPRESSION_MODE_COMPRESS = 0; +export const COMPRESSION_MODE_DECOMPRESS = 1; +export const COMPRESSION_MODE_COMPRESS_AND_CLOSE = 2; + +/** + * Compression struct for Transfer2 instruction + */ +export interface Compression { + mode: number; + amount: bigint; + mint: number; + sourceOrRecipient: number; + authority: number; + poolAccountIndex: number; + poolIndex: number; + bump: number; + decimals: number; +} + +/** + * Packed merkle context for compressed accounts + */ +export interface PackedMerkleContext { + merkleTreePubkeyIndex: number; + queuePubkeyIndex: number; + leafIndex: number; + proveByIndex: boolean; +} + +/** + * Input token data with context for Transfer2 + */ +export interface MultiInputTokenDataWithContext { + owner: number; + amount: bigint; + hasDelegate: boolean; + delegate: number; + mint: number; + version: number; + merkleContext: PackedMerkleContext; + rootIndex: number; +} + +/** + * Output token data for Transfer2 + */ +export interface MultiTokenTransferOutputData { + owner: number; + amount: bigint; + hasDelegate: boolean; + delegate: number; + mint: number; + version: number; +} + +/** + * CPI context for Transfer2 + */ +export interface CompressedCpiContext { + setContext: boolean; + firstSetContext: boolean; + cpiContextAccountIndex: number; +} + +/** + * Token metadata extension instruction data for Transfer2 TLV + */ +export interface Transfer2TokenMetadata { + updateAuthority: PublicKey | null; + name: Uint8Array; + symbol: Uint8Array; + uri: Uint8Array; + additionalMetadata: AdditionalMetadata[] | null; +} + +/** + * CompressedOnly extension instruction data for Transfer2 TLV + */ +export interface Transfer2CompressedOnly { + delegatedAmount: bigint; + withheldTransferFee: bigint; + isFrozen: boolean; + compressionIndex: number; + isAta: boolean; + bump: number; + ownerIndex: number; +} + +/** + * Extension instruction data types for Transfer2 in_tlv/out_tlv + */ +export type Transfer2ExtensionData = + | { type: 'TokenMetadata'; data: Transfer2TokenMetadata } + | { type: 'CompressedOnly'; data: Transfer2CompressedOnly } + | { type: 'Compressible'; data: CompressionInfo }; + +/** + * Full Transfer2 instruction data + * + * Note on `decimals` field in Compression: + * - For SPL compress/decompress: actual token decimals + * - For CompressAndClose mode: used as `rent_sponsor_is_signer` flag + */ +export interface Transfer2InstructionData { + withTransactionHash: boolean; + withLamportsChangeAccountMerkleTreeIndex: boolean; + lamportsChangeAccountMerkleTreeIndex: number; + lamportsChangeAccountOwnerIndex: number; + outputQueue: number; + maxTopUp: number; + cpiContext: CompressedCpiContext | null; + compressions: Compression[] | null; + proof: { a: number[]; b: number[]; c: number[] } | null; + inTokenData: MultiInputTokenDataWithContext[]; + outTokenData: MultiTokenTransferOutputData[]; + inLamports: bigint[] | null; + outLamports: bigint[] | null; + /** Extensions for input light-token accounts (one array per input account) */ + inTlv: Transfer2ExtensionData[][] | null; + /** Extensions for output light-token accounts (one array per output account) */ + outTlv: Transfer2ExtensionData[][] | null; +} + +// Borsh layouts for extension data + +const CompressedOnlyExtensionInstructionDataLayout = struct([ + u64('delegatedAmount'), + u64('withheldTransferFee'), + bool('isFrozen'), + u8('compressionIndex'), + bool('isAta'), + u8('bump'), + u8('ownerIndex'), +]); + +const RentConfigLayout = struct([ + u16('baseRent'), + u16('compressionCost'), + u8('lamportsPerBytePerEpoch'), + u8('maxFundedEpochs'), + u16('maxTopUp'), +]); + +const CompressionInfoLayout = struct([ + u16('configAccountVersion'), + u8('compressToPubkey'), + u8('accountVersion'), + u32('lamportsPerWrite'), + array(u8(), 32, 'compressionAuthority'), + array(u8(), 32, 'rentSponsor'), + u64('lastClaimedSlot'), + u32('rentExemptionPaid'), + u32('reserved'), + RentConfigLayout.replicate('rentConfig'), +]); + +/** + * Serialize a single Transfer2ExtensionData to bytes + * @internal + */ +function serializeExtensionInstructionData( + ext: Transfer2ExtensionData, +): Uint8Array { + const buffer = Buffer.alloc(1024); + let offset = 0; + + // Write discriminant + if (ext.type === 'TokenMetadata') { + buffer.writeUInt8(EXTENSION_DISCRIMINANT_TOKEN_METADATA, offset); + offset += 1; + const data = { + updateAuthority: ext.data.updateAuthority, + name: ext.data.name, + symbol: ext.data.symbol, + uri: ext.data.uri, + additionalMetadata: ext.data.additionalMetadata + ? ext.data.additionalMetadata.map(m => ({ + key: Buffer.from(m.key), + value: Buffer.from(m.value), + })) + : null, + }; + offset += TokenMetadataInstructionDataLayout.encode( + data, + buffer, + offset, + ); + } else if (ext.type === 'CompressedOnly') { + buffer.writeUInt8(EXTENSION_DISCRIMINANT_COMPRESSED_ONLY, offset); + offset += 1; + const data = { + delegatedAmount: bn(ext.data.delegatedAmount.toString()), + withheldTransferFee: bn(ext.data.withheldTransferFee.toString()), + isFrozen: ext.data.isFrozen, + compressionIndex: ext.data.compressionIndex, + isAta: ext.data.isAta, + bump: ext.data.bump, + ownerIndex: ext.data.ownerIndex, + }; + offset += CompressedOnlyExtensionInstructionDataLayout.encode( + data, + buffer, + offset, + ); + } else if (ext.type === 'Compressible') { + buffer.writeUInt8(EXTENSION_DISCRIMINANT_COMPRESSIBLE, offset); + offset += 1; + const data = { + configAccountVersion: ext.data.configAccountVersion, + compressToPubkey: ext.data.compressToPubkey, + accountVersion: ext.data.accountVersion, + lamportsPerWrite: ext.data.lamportsPerWrite, + compressionAuthority: Array.from( + ext.data.compressionAuthority.toBytes(), + ), + rentSponsor: Array.from(ext.data.rentSponsor.toBytes()), + lastClaimedSlot: bn(ext.data.lastClaimedSlot.toString()), + rentExemptionPaid: ext.data.rentExemptionPaid, + reserved: ext.data.reserved, + rentConfig: ext.data.rentConfig, + }; + offset += CompressionInfoLayout.encode(data, buffer, offset); + } + + return buffer.subarray(0, offset); +} + +/** + * Serialize Vec> to bytes for Borsh + * @internal + */ +function serializeExtensionTlv( + tlv: Transfer2ExtensionData[][] | null, +): Uint8Array | null { + if (tlv === null) { + return null; + } + + const chunks: Uint8Array[] = []; + + // Write outer vec length (4 bytes, little-endian) + const outerLenBuf = Buffer.alloc(4); + outerLenBuf.writeUInt32LE(tlv.length, 0); + chunks.push(outerLenBuf); + + for (const innerVec of tlv) { + // Write inner vec length (4 bytes, little-endian) + const innerLenBuf = Buffer.alloc(4); + innerLenBuf.writeUInt32LE(innerVec.length, 0); + chunks.push(innerLenBuf); + + for (const ext of innerVec) { + chunks.push(serializeExtensionInstructionData(ext)); + } + } + + return Buffer.concat(chunks); +} + +// Borsh layouts +const CompressionLayout = struct([ + u8('mode'), + u64('amount'), + u8('mint'), + u8('sourceOrRecipient'), + u8('authority'), + u8('poolAccountIndex'), + u8('poolIndex'), + u8('bump'), + u8('decimals'), +]); + +const PackedMerkleContextLayout = struct([ + u8('merkleTreePubkeyIndex'), + u8('queuePubkeyIndex'), + u32('leafIndex'), + bool('proveByIndex'), +]); + +const MultiInputTokenDataWithContextLayout = struct([ + u8('owner'), + u64('amount'), + bool('hasDelegate'), + u8('delegate'), + u8('mint'), + u8('version'), + PackedMerkleContextLayout.replicate('merkleContext'), + u16('rootIndex'), +]); + +const MultiTokenTransferOutputDataLayout = struct([ + u8('owner'), + u64('amount'), + bool('hasDelegate'), + u8('delegate'), + u8('mint'), + u8('version'), +]); + +const CompressedCpiContextLayout = struct([ + bool('setContext'), + bool('firstSetContext'), + u8('cpiContextAccountIndex'), +]); + +// Layout without TLV fields - we'll serialize those manually +const Transfer2InstructionDataBaseLayout = struct([ + bool('withTransactionHash'), + bool('withLamportsChangeAccountMerkleTreeIndex'), + u8('lamportsChangeAccountMerkleTreeIndex'), + u8('lamportsChangeAccountOwnerIndex'), + u8('outputQueue'), + u16('maxTopUp'), + option(CompressedCpiContextLayout, 'cpiContext'), + option(vec(CompressionLayout), 'compressions'), + option(CompressedProofLayout, 'proof'), + vec(MultiInputTokenDataWithContextLayout, 'inTokenData'), + vec(MultiTokenTransferOutputDataLayout, 'outTokenData'), + option(vec(u64()), 'inLamports'), + option(vec(u64()), 'outLamports'), +]); + +/** + * Encode Transfer2 instruction data using Borsh + * @internal + */ +export function encodeTransfer2InstructionData( + data: Transfer2InstructionData, +): Buffer { + // Convert bigint values to BN for Borsh encoding + const baseData = { + withTransactionHash: data.withTransactionHash, + withLamportsChangeAccountMerkleTreeIndex: + data.withLamportsChangeAccountMerkleTreeIndex, + lamportsChangeAccountMerkleTreeIndex: + data.lamportsChangeAccountMerkleTreeIndex, + lamportsChangeAccountOwnerIndex: data.lamportsChangeAccountOwnerIndex, + outputQueue: data.outputQueue, + maxTopUp: data.maxTopUp, + cpiContext: data.cpiContext, + compressions: + data.compressions?.map(c => ({ + ...c, + amount: bn(c.amount.toString()), + })) ?? null, + proof: data.proof, + inTokenData: data.inTokenData.map(t => ({ + ...t, + amount: bn(t.amount.toString()), + })), + outTokenData: data.outTokenData.map(t => ({ + ...t, + amount: bn(t.amount.toString()), + })), + inLamports: data.inLamports?.map(v => bn(v.toString())) ?? null, + outLamports: data.outLamports?.map(v => bn(v.toString())) ?? null, + }; + + // Encode base layout + const baseBuffer = Buffer.alloc(4000); + const baseLen = Transfer2InstructionDataBaseLayout.encode( + baseData, + baseBuffer, + ); + + // Manually serialize TLV fields + const chunks: Buffer[] = [ + TRANSFER2_DISCRIMINATOR, + baseBuffer.subarray(0, baseLen), + ]; + + // Serialize inTlv as Option>> + if (data.inTlv === null) { + // Option::None = 0 + chunks.push(Buffer.from([0])); + } else { + // Option::Some = 1 + chunks.push(Buffer.from([1])); + const serialized = serializeExtensionTlv(data.inTlv); + if (serialized) { + chunks.push(Buffer.from(serialized)); + } + } + + // Serialize outTlv as Option>> + if (data.outTlv === null) { + // Option::None = 0 + chunks.push(Buffer.from([0])); + } else { + // Option::Some = 1 + chunks.push(Buffer.from([1])); + const serialized = serializeExtensionTlv(data.outTlv); + if (serialized) { + chunks.push(Buffer.from(serialized)); + } + } + + return Buffer.concat(chunks); +} + +/** + * @internal + * Create a compression struct for wrapping SPL tokens to light-token + * (compress from SPL associated token account) + */ +export function createCompressSpl( + amount: bigint, + mintIndex: number, + sourceIndex: number, + authorityIndex: number, + poolAccountIndex: number, + poolIndex: number, + bump: number, + decimals: number, +): Compression { + return { + mode: COMPRESSION_MODE_COMPRESS, + amount, + mint: mintIndex, + sourceOrRecipient: sourceIndex, + authority: authorityIndex, + poolAccountIndex, + poolIndex, + bump, + decimals, + }; +} + +/** + * @internal + * Create a compression struct for decompressing to light-token associated token account + * @param amount - Amount to decompress + * @param mintIndex - Index of mint in packed accounts + * @param recipientIndex - Index of recipient light-token account in packed accounts + * @param tokenProgramIndex - Index of light-token program in packed accounts (for CPI) + */ +export function createDecompressLightToken( + amount: bigint, + mintIndex: number, + recipientIndex: number, + tokenProgramIndex?: number, +): Compression { + return { + mode: COMPRESSION_MODE_DECOMPRESS, + amount, + mint: mintIndex, + sourceOrRecipient: recipientIndex, + authority: 0, + poolAccountIndex: tokenProgramIndex ?? 0, + poolIndex: 0, + bump: 0, + decimals: 0, + }; +} + +/** + * @internal + * Create a compression struct for compressing light-token (burn from light-token associated token account) + * Used in unwrap flow: light-token associated token account -> pool -> SPL associated token account + * @param amount - Amount to compress (burn from light-token) + * @param mintIndex - Index of mint in packed accounts + * @param sourceIndex - Index of source light-token account in packed accounts + * @param authorityIndex - Index of authority/owner in packed accounts (must sign) + * @param tokenProgramIndex - Index of light-token program in packed accounts (for CPI) + */ +export function createCompressLightToken( + amount: bigint, + mintIndex: number, + sourceIndex: number, + authorityIndex: number, + tokenProgramIndex?: number, +): Compression { + return { + mode: COMPRESSION_MODE_COMPRESS, + amount, + mint: mintIndex, + sourceOrRecipient: sourceIndex, + authority: authorityIndex, + poolAccountIndex: tokenProgramIndex ?? 0, + poolIndex: 0, + bump: 0, + decimals: 0, + }; +} + +/** + * Create a compression struct for decompressing SPL tokens + * @internal + */ +export function createDecompressSpl( + amount: bigint, + mintIndex: number, + recipientIndex: number, + poolAccountIndex: number, + poolIndex: number, + bump: number, + decimals: number, +): Compression { + return { + mode: COMPRESSION_MODE_DECOMPRESS, + amount, + mint: mintIndex, + sourceOrRecipient: recipientIndex, + authority: 0, + poolAccountIndex, + poolIndex, + bump, + decimals, + }; +} diff --git a/js/token-interface/src/instructions/layout/layout.ts b/js/token-interface/src/instructions/layout/layout.ts new file mode 100644 index 0000000000..8641d5c93b --- /dev/null +++ b/js/token-interface/src/instructions/layout/layout.ts @@ -0,0 +1,3 @@ +export * from './layout-mint'; +export * from './layout-mint-action'; +export * from './layout-transfer2'; diff --git a/js/token-interface/src/instructions/load.ts b/js/token-interface/src/instructions/load.ts new file mode 100644 index 0000000000..cdc63f66da --- /dev/null +++ b/js/token-interface/src/instructions/load.ts @@ -0,0 +1,1220 @@ +import { + Rpc, + LIGHT_TOKEN_PROGRAM_ID, + ParsedTokenAccount, + bn, + assertV2Enabled, + LightSystemProgram, + defaultStaticAccountsStruct, + ValidityProofWithContext, +} from '@lightprotocol/stateless.js'; +import { + ComputeBudgetProgram, + PublicKey, + TransactionInstruction, + SystemProgram, +} from '@solana/web3.js'; +import { + TOKEN_PROGRAM_ID, + TOKEN_2022_PROGRAM_ID, + getAssociatedTokenAddressSync, + createAssociatedTokenAccountIdempotentInstruction, + TokenAccountNotFoundError, +} from '@solana/spl-token'; +import { Buffer } from 'buffer'; +import { + AccountView, + checkNotFrozen, + COLD_SOURCE_TYPES, + getAtaView as _getAtaView, + TokenAccountSource, + isAuthorityForAccount, + filterAccountForAuthority, +} from '../read/get-account'; +import { getAssociatedTokenAddress } from '../read/associated-token-address'; +import { createAtaIdempotent } from './ata'; +import { createWrapInstruction } from './wrap'; +import { getSplPoolInfos, type SplPoolInfo } from '../spl-interface'; +import { getAtaProgramId, checkAtaAddress, AtaType } from '../read/ata-utils'; +import type { LoadOptions } from '../load-options'; +import { getMint } from '../read/get-mint'; +import { + COMPRESSED_TOKEN_PROGRAM_ID, + deriveCpiAuthorityPda, + MAX_TOP_UP, + TokenDataVersion, +} from '../constants'; +import { + encodeTransfer2InstructionData, + type Transfer2InstructionData, + type MultiInputTokenDataWithContext, + COMPRESSION_MODE_DECOMPRESS, + type Compression, + type Transfer2ExtensionData, +} from './layout/layout-transfer2'; +import { createSingleCompressedAccountRpc, getAtaOrNull } from '../account'; +import { normalizeInstructionBatches, toLoadOptions } from '../helpers'; +import { getAtaAddress } from '../read'; +import type { + CreateLoadInstructionsInput, + TokenInterfaceAccount, + CreateTransferInstructionsInput, +} from '../types'; +import { toInstructionPlan } from './_plan'; + +const COMPRESSED_ONLY_DISC = 31; +const COMPRESSED_ONLY_SIZE = 17; // u64 + u64 + u8 + +interface ParsedCompressedOnly { + delegatedAmount: bigint; + withheldTransferFee: bigint; + isAta: boolean; +} + +/** + * Parse CompressedOnly extension from a Borsh-serialized TLV buffer + * (Vec). Returns null if no CompressedOnly found. + * @internal + */ +function parseCompressedOnlyFromTlv( + tlv: Buffer | null, +): ParsedCompressedOnly | null { + if (!tlv || tlv.length < 5) return null; + try { + let offset = 0; + const vecLen = tlv.readUInt32LE(offset); + offset += 4; + for (let i = 0; i < vecLen; i++) { + if (offset >= tlv.length) return null; + const disc = tlv[offset]; + offset += 1; + if (disc === COMPRESSED_ONLY_DISC) { + if (offset + COMPRESSED_ONLY_SIZE > tlv.length) return null; + const loDA = BigInt(tlv.readUInt32LE(offset)); + const hiDA = BigInt(tlv.readUInt32LE(offset + 4)); + const delegatedAmount = loDA | (hiDA << BigInt(32)); + const loFee = BigInt(tlv.readUInt32LE(offset + 8)); + const hiFee = BigInt(tlv.readUInt32LE(offset + 12)); + const withheldTransferFee = loFee | (hiFee << BigInt(32)); + const isAta = tlv[offset + 16] !== 0; + return { delegatedAmount, withheldTransferFee, isAta }; + } + const SIZES: Record = { + 29: 8, + 30: 1, + 31: 17, + }; + const size = SIZES[disc]; + if (size === undefined) { + throw new Error( + `parseCompressedOnlyFromTlv: unknown TLV extension discriminant ${disc}`, + ); + } + offset += size; + } + } catch { + // Ignoring unknown TLV extensions. + return null; + } + return null; +} + +/** + * Build inTlv array for Transfer2 from input compressed accounts. + * For each account, if CompressedOnly TLV is present, converts it to + * the instruction format (enriched with is_frozen, compression_index, + * bump, owner_index). Returns null if no accounts have TLV. + * @internal + */ +function buildInTlv( + accounts: ParsedTokenAccount[], + ownerIndex: number, + owner: PublicKey, + mint: PublicKey, +): Transfer2ExtensionData[][] | null { + let hasAny = false; + const result: Transfer2ExtensionData[][] = []; + + for (const acc of accounts) { + const co = parseCompressedOnlyFromTlv(acc.parsed.tlv); + if (!co) { + result.push([]); + continue; + } + hasAny = true; + let bump = 0; + if (co.isAta) { + const seeds = [ + owner.toBuffer(), + LIGHT_TOKEN_PROGRAM_ID.toBuffer(), + mint.toBuffer(), + ]; + const [, b] = PublicKey.findProgramAddressSync( + seeds, + LIGHT_TOKEN_PROGRAM_ID, + ); + bump = b; + } + const isFrozen = acc.parsed.state === 2; + result.push([ + { + type: "CompressedOnly", + data: { + delegatedAmount: co.delegatedAmount, + withheldTransferFee: co.withheldTransferFee, + isFrozen, + // This builder emits a single decompress compression per batch. + // Keep index at 0 unless multi-compression output is added here. + compressionIndex: 0, + isAta: co.isAta, + bump, + ownerIndex, + }, + }, + ]); + } + return hasAny ? result : null; +} + +/** + * Get token data version from compressed account discriminator. + * @internal + */ +function getVersionFromDiscriminator( + discriminator: number[] | undefined, +): number { + if (!discriminator || discriminator.length < 8) { + // Default to ShaFlat for new accounts without discriminator + return TokenDataVersion.ShaFlat; + } + + // V1 has discriminator[0] = 2 + if (discriminator[0] === 2) { + return TokenDataVersion.V1; + } + + // V2 and ShaFlat have version in discriminator[7] + const versionByte = discriminator[7]; + if (versionByte === 3) { + return TokenDataVersion.V2; + } + if (versionByte === 4) { + return TokenDataVersion.ShaFlat; + } + + // Default to ShaFlat + return TokenDataVersion.ShaFlat; +} + +/** + * Build input token data for Transfer2 from parsed token accounts + * @internal + */ +function buildInputTokenData( + accounts: ParsedTokenAccount[], + rootIndices: number[], + packedAccountIndices: Map, +): MultiInputTokenDataWithContext[] { + return accounts.map((acc, i) => { + const ownerKey = acc.parsed.owner.toBase58(); + const mintKey = acc.parsed.mint.toBase58(); + + const version = getVersionFromDiscriminator( + acc.compressedAccount.data?.discriminator, + ); + + return { + owner: packedAccountIndices.get(ownerKey)!, + amount: BigInt(acc.parsed.amount.toString()), + hasDelegate: acc.parsed.delegate !== null, + delegate: acc.parsed.delegate + ? (packedAccountIndices.get(acc.parsed.delegate.toBase58()) ?? 0) + : 0, + mint: packedAccountIndices.get(mintKey)!, + version, + merkleContext: { + merkleTreePubkeyIndex: packedAccountIndices.get( + acc.compressedAccount.treeInfo.tree.toBase58(), + )!, + queuePubkeyIndex: packedAccountIndices.get( + acc.compressedAccount.treeInfo.queue.toBase58(), + )!, + leafIndex: acc.compressedAccount.leafIndex, + proveByIndex: acc.compressedAccount.proveByIndex, + }, + rootIndex: rootIndices[i], + }; + }); +} + +/** + * Create decompress instruction using Transfer2. + * + * @internal Use createLoadAtaInstructions instead. + * + * Supports decompressing to both light-token accounts and SPL token accounts: + * - For light-token destinations: No splPoolInfo needed + * - For SPL destinations: Provide splPoolInfo and decimals + * + * @param payer Fee payer public key + * @param inputCompressedTokenAccounts Input light-token accounts + * @param toAddress Destination token account address (light-token or SPL associated token account) + * @param amount Amount to decompress + * @param validityProof Validity proof (contains compressedProof and rootIndices) + * @param splPoolInfo Optional: SPL pool info for SPL destinations + * @param decimals Mint decimals (required for SPL destinations) + * @param maxTopUp Optional cap on rent top-up (units of 1k lamports; default no cap) + * @param authority Optional signer (owner or delegate). When omitted, owner is the signer. + * @returns TransactionInstruction + */ +export function createDecompressInstruction( + payer: PublicKey, + inputCompressedTokenAccounts: ParsedTokenAccount[], + toAddress: PublicKey, + amount: bigint, + validityProof: ValidityProofWithContext, + splPoolInfo: SplPoolInfo | undefined, + decimals: number, + maxTopUp?: number, + authority?: PublicKey, +): TransactionInstruction { + if (inputCompressedTokenAccounts.length === 0) { + throw new Error("No input light-token accounts provided"); + } + + const mint = inputCompressedTokenAccounts[0].parsed.mint; + const owner = inputCompressedTokenAccounts[0].parsed.owner; + + // Build packed accounts map + // Order: trees/queues first, then mint, owner, light-token account, light-token program + const packedAccountIndices = new Map(); + const packedAccounts: PublicKey[] = []; + + // Collect unique trees and queues + const treeSet = new Set(); + const queueSet = new Set(); + for (const acc of inputCompressedTokenAccounts) { + treeSet.add(acc.compressedAccount.treeInfo.tree.toBase58()); + queueSet.add(acc.compressedAccount.treeInfo.queue.toBase58()); + } + + // Add trees first (owned by account compression program) + for (const tree of treeSet) { + packedAccountIndices.set(tree, packedAccounts.length); + packedAccounts.push(new PublicKey(tree)); + } + + let firstQueueIndex = 0; + let isFirstQueue = true; + for (const queue of queueSet) { + if (isFirstQueue) { + firstQueueIndex = packedAccounts.length; + isFirstQueue = false; + } + packedAccountIndices.set(queue, packedAccounts.length); + packedAccounts.push(new PublicKey(queue)); + } + + // Add mint + const mintIndex = packedAccounts.length; + packedAccountIndices.set(mint.toBase58(), mintIndex); + packedAccounts.push(mint); + + // Add owner + const ownerIndex = packedAccounts.length; + packedAccountIndices.set(owner.toBase58(), ownerIndex); + packedAccounts.push(owner); + + // Add destination token account (light-token or SPL) + const destinationIndex = packedAccounts.length; + packedAccountIndices.set(toAddress.toBase58(), destinationIndex); + packedAccounts.push(toAddress); + + // Add unique delegate pubkeys from input accounts + for (const acc of inputCompressedTokenAccounts) { + if (acc.parsed.delegate) { + const delegateKey = acc.parsed.delegate.toBase58(); + if (!packedAccountIndices.has(delegateKey)) { + packedAccountIndices.set(delegateKey, packedAccounts.length); + packedAccounts.push(acc.parsed.delegate); + } + } + } + + // For SPL decompression, add pool account and token program + let poolAccountIndex = 0; + let poolIndex = 0; + let poolBump = 0; + let tokenProgramIndex = 0; + + if (splPoolInfo) { + // Add SPL interface PDA (token pool) + poolAccountIndex = packedAccounts.length; + packedAccountIndices.set( + splPoolInfo.splPoolPda.toBase58(), + poolAccountIndex, + ); + packedAccounts.push(splPoolInfo.splPoolPda); + + // Add SPL token program + tokenProgramIndex = packedAccounts.length; + packedAccountIndices.set( + splPoolInfo.tokenProgram.toBase58(), + tokenProgramIndex, + ); + packedAccounts.push(splPoolInfo.tokenProgram); + + poolIndex = splPoolInfo.poolIndex; + poolBump = splPoolInfo.bump; + } + + // Build input token data + const inTokenData = buildInputTokenData( + inputCompressedTokenAccounts, + validityProof.rootIndices, + packedAccountIndices, + ); + + // Calculate total input amount and change + const totalInputAmount = inputCompressedTokenAccounts.reduce( + (sum, acc) => sum + BigInt(acc.parsed.amount.toString()), + BigInt(0), + ); + const changeAmount = totalInputAmount - amount; + + const outTokenData: { + owner: number; + amount: bigint; + hasDelegate: boolean; + delegate: number; + mint: number; + version: number; + }[] = []; + + if (changeAmount > 0) { + const version = getVersionFromDiscriminator( + inputCompressedTokenAccounts[0].compressedAccount.data?.discriminator, + ); + + outTokenData.push({ + owner: ownerIndex, + amount: changeAmount, + hasDelegate: false, + delegate: 0, + mint: mintIndex, + version, + }); + } + + // Build decompress compression + // For light-token: pool values are 0 (unused) + // For SPL: pool values point to SPL interface PDA + const compressions: Compression[] = [ + { + mode: COMPRESSION_MODE_DECOMPRESS, + amount, + mint: mintIndex, + sourceOrRecipient: destinationIndex, + authority: 0, // Not needed for decompress + poolAccountIndex: splPoolInfo ? poolAccountIndex : 0, + poolIndex: splPoolInfo ? poolIndex : 0, + bump: splPoolInfo ? poolBump : 0, + decimals, + }, + ]; + + // Build Transfer2 instruction data + const instructionData: Transfer2InstructionData = { + withTransactionHash: false, + withLamportsChangeAccountMerkleTreeIndex: false, + lamportsChangeAccountMerkleTreeIndex: 0, + lamportsChangeAccountOwnerIndex: 0, + outputQueue: firstQueueIndex, // First queue in packed accounts + maxTopUp: maxTopUp ?? MAX_TOP_UP, + cpiContext: null, + compressions, + proof: validityProof.compressedProof + ? { + a: Array.from(validityProof.compressedProof.a), + b: Array.from(validityProof.compressedProof.b), + c: Array.from(validityProof.compressedProof.c), + } + : null, + inTokenData, + outTokenData, + inLamports: null, + outLamports: null, + inTlv: buildInTlv(inputCompressedTokenAccounts, ownerIndex, owner, mint), + outTlv: null, + }; + + const data = encodeTransfer2InstructionData(instructionData); + + // Build accounts for Transfer2 with compressed accounts (full path) + const { + accountCompressionAuthority, + registeredProgramPda, + accountCompressionProgram, + } = defaultStaticAccountsStruct(); + const signerIndex = (() => { + if (!authority || authority.equals(owner)) { + return ownerIndex; + } + const authorityIndex = packedAccountIndices.get(authority.toBase58()); + if (authorityIndex === undefined) { + throw new Error( + `Authority ${authority.toBase58()} is not present in packed accounts`, + ); + } + return authorityIndex; + })(); + + const keys = [ + // 0: light_system_program (non-mutable) + { + pubkey: LightSystemProgram.programId, + isSigner: false, + isWritable: false, + }, + // 1: fee_payer (signer, mutable) + { pubkey: payer, isSigner: true, isWritable: true }, + // 2: cpi_authority_pda + { + pubkey: deriveCpiAuthorityPda(), + isSigner: false, + isWritable: false, + }, + // 3: registered_program_pda + { + pubkey: registeredProgramPda, + isSigner: false, + isWritable: false, + }, + // 4: account_compression_authority + { + pubkey: accountCompressionAuthority, + isSigner: false, + isWritable: false, + }, + // 5: account_compression_program + { + pubkey: accountCompressionProgram, + isSigner: false, + isWritable: false, + }, + // 6: system_program + { + pubkey: SystemProgram.programId, + isSigner: false, + isWritable: false, + }, + // 7+: packed_accounts (trees/queues come first) + ...packedAccounts.map((pubkey, i) => { + const isTreeOrQueue = i < treeSet.size + queueSet.size; + const isDestination = pubkey.equals(toAddress); + const isPool = + splPoolInfo !== undefined && pubkey.equals(splPoolInfo.splPoolPda); + return { + pubkey, + isSigner: i === signerIndex, + isWritable: isTreeOrQueue || isDestination || isPool, + }; + }), + ]; + + return new TransactionInstruction({ + programId: COMPRESSED_TOKEN_PROGRAM_ID, + keys, + data, + }); +} + +const MAX_INPUT_ACCOUNTS = 8; + +function chunkArray(array: T[], chunkSize: number): T[][] { + const chunks: T[][] = []; + for (let i = 0; i < array.length; i += chunkSize) { + chunks.push(array.slice(i, i + chunkSize)); + } + return chunks; +} + +function selectInputsForAmount( + accounts: ParsedTokenAccount[], + neededAmount: bigint, +): ParsedTokenAccount[] { + if (accounts.length === 0 || neededAmount <= BigInt(0)) return []; + + const sorted = [...accounts].sort((a, b) => { + const amtA = BigInt(a.parsed.amount.toString()); + const amtB = BigInt(b.parsed.amount.toString()); + if (amtB > amtA) return 1; + if (amtB < amtA) return -1; + return 0; + }); + + let accumulated = BigInt(0); + let countNeeded = 0; + for (const acc of sorted) { + countNeeded++; + accumulated += BigInt(acc.parsed.amount.toString()); + if (accumulated >= neededAmount) break; + } + + const selectCount = Math.min( + Math.max(countNeeded, MAX_INPUT_ACCOUNTS), + sorted.length, + ); + + return sorted.slice(0, selectCount); +} + +function assertUniqueInputHashes(chunks: ParsedTokenAccount[][]): void { + const seen = new Set(); + for (const chunk of chunks) { + for (const acc of chunk) { + const hashStr = acc.compressedAccount.hash.toString(); + if (seen.has(hashStr)) { + throw new Error( + `Duplicate compressed account hash across chunks: ${hashStr}. ` + + `Each compressed account must appear in exactly one chunk.`, + ); + } + seen.add(hashStr); + } + } +} + +function getCompressedTokenAccountsFromAtaSources( + sources: TokenAccountSource[], +): ParsedTokenAccount[] { + return sources + .filter((source) => source.loadContext !== undefined) + .filter((source) => COLD_SOURCE_TYPES.has(source.type)) + .map((source) => { + const fullData = source.accountInfo.data; + const discriminatorBytes = fullData.subarray( + 0, + Math.min(8, fullData.length), + ); + const accountDataBytes = + fullData.length > 8 ? fullData.subarray(8) : Buffer.alloc(0); + + const compressedAccount = { + treeInfo: source.loadContext!.treeInfo, + hash: source.loadContext!.hash, + leafIndex: source.loadContext!.leafIndex, + proveByIndex: source.loadContext!.proveByIndex, + owner: source.accountInfo.owner, + lamports: bn(source.accountInfo.lamports), + address: null, + data: + fullData.length === 0 + ? null + : { + discriminator: Array.from(discriminatorBytes), + data: Buffer.from(accountDataBytes), + dataHash: new Array(32).fill(0), + }, + readOnly: false, + }; + + const state = !source.parsed.isInitialized + ? 0 + : source.parsed.isFrozen + ? 2 + : 1; + + return { + compressedAccount: compressedAccount as any, + parsed: { + mint: source.parsed.mint, + owner: source.parsed.owner, + amount: bn(source.parsed.amount.toString()), + delegate: source.parsed.delegate, + state, + tlv: source.parsed.tlvData.length > 0 ? source.parsed.tlvData : null, + }, + } satisfies ParsedTokenAccount; + }); +} + +export async function createLoadAtaInstructionsInner( + rpc: Rpc, + ata: PublicKey, + owner: PublicKey, + mint: PublicKey, + decimals: number, + payer?: PublicKey, + loadOptions?: LoadOptions, +): Promise { + assertV2Enabled(); + payer ??= owner; + const wrap = loadOptions?.wrap ?? false; + + const effectiveOwner = owner; + const authorityPubkey = loadOptions?.delegatePubkey ?? owner; + + let accountView: AccountView; + try { + accountView = await _getAtaView( + rpc, + ata, + effectiveOwner, + mint, + undefined, + undefined, + wrap, + ); + } catch (e) { + if (e instanceof TokenAccountNotFoundError) { + return []; + } + throw e; + } + + const isDelegate = !effectiveOwner.equals(authorityPubkey); + if (isDelegate) { + if (!isAuthorityForAccount(accountView, authorityPubkey)) { + throw new Error("Signer is not the owner or a delegate of the account."); + } + accountView = filterAccountForAuthority(accountView, authorityPubkey); + } + + const internalBatches = await _buildLoadBatches( + rpc, + payer, + accountView, + loadOptions, + wrap, + ata, + undefined, + authorityPubkey, + decimals, + ); + + return internalBatches.map((batch) => [ + ComputeBudgetProgram.setComputeUnitLimit({ + units: calculateLoadBatchComputeUnits(batch), + }), + ...batch.instructions, + ]); +} + +interface InternalLoadBatch { + instructions: TransactionInstruction[]; + compressedAccounts: ParsedTokenAccount[]; + wrapCount: number; + hasAtaCreation: boolean; +} + +const CU_ATA_CREATION = 30_000; +const CU_WRAP = 50_000; +const CU_DECOMPRESS_BASE = 50_000; +const CU_FULL_PROOF = 100_000; +const CU_PER_ACCOUNT_PROVE_BY_INDEX = 10_000; +const CU_PER_ACCOUNT_FULL_PROOF = 30_000; +const CU_BUFFER_FACTOR = 1.3; +const CU_MIN = 50_000; +const CU_MAX = 1_400_000; + +function rawLoadBatchComputeUnits(batch: InternalLoadBatch): number { + let cu = 0; + if (batch.hasAtaCreation) cu += CU_ATA_CREATION; + cu += batch.wrapCount * CU_WRAP; + if (batch.compressedAccounts.length > 0) { + cu += CU_DECOMPRESS_BASE; + const needsFullProof = batch.compressedAccounts.some( + (acc) => !(acc.compressedAccount.proveByIndex ?? false), + ); + if (needsFullProof) cu += CU_FULL_PROOF; + for (const acc of batch.compressedAccounts) { + cu += + (acc.compressedAccount.proveByIndex ?? false) + ? CU_PER_ACCOUNT_PROVE_BY_INDEX + : CU_PER_ACCOUNT_FULL_PROOF; + } + } + return cu; +} + +function calculateLoadBatchComputeUnits(batch: InternalLoadBatch): number { + const cu = Math.ceil(rawLoadBatchComputeUnits(batch) * CU_BUFFER_FACTOR); + return Math.max(CU_MIN, Math.min(CU_MAX, cu)); +} + +async function _buildLoadBatches( + rpc: Rpc, + payer: PublicKey, + ata: AccountView, + options: LoadOptions | undefined, + wrap: boolean, + targetAta: PublicKey, + targetAmount: bigint | undefined, + authority: PublicKey | undefined, + decimals: number, +): Promise { + if (!ata._isAta || !ata._owner || !ata._mint) { + throw new Error( + "AccountView must be from getAtaView (requires _isAta, _owner, _mint)", + ); + } + + checkNotFrozen(ata, "load"); + + const owner = ata._owner; + const mint = ata._mint; + const sources = ata._sources ?? []; + + const allCompressedAccounts = + getCompressedTokenAccountsFromAtaSources(sources); + + const lightTokenAtaAddress = getAssociatedTokenAddress(mint, owner); + const splAta = getAssociatedTokenAddressSync( + mint, + owner, + false, + TOKEN_PROGRAM_ID, + getAtaProgramId(TOKEN_PROGRAM_ID), + ); + const t22Ata = getAssociatedTokenAddressSync( + mint, + owner, + false, + TOKEN_2022_PROGRAM_ID, + getAtaProgramId(TOKEN_2022_PROGRAM_ID), + ); + + let ataType: AtaType = "light-token"; + const validation = checkAtaAddress(targetAta, mint, owner); + ataType = validation.type; + if (wrap && ataType !== "light-token") { + throw new Error( + `For wrap=true, targetAta must be light-token associated token account. Got ${ataType} associated token account.`, + ); + } + + const splSource = sources.find((s) => s.type === "spl"); + const t22Source = sources.find((s) => s.type === "token2022"); + const lightTokenHotSource = sources.find((s) => s.type === "light-token-hot"); + const coldSources = sources.filter((s) => COLD_SOURCE_TYPES.has(s.type)); + + const splBalance = splSource?.amount ?? BigInt(0); + const t22Balance = t22Source?.amount ?? BigInt(0); + const coldBalance = coldSources.reduce((sum, s) => sum + s.amount, BigInt(0)); + + if ( + splBalance === BigInt(0) && + t22Balance === BigInt(0) && + coldBalance === BigInt(0) + ) { + return []; + } + + let splPoolInfo: SplPoolInfo | undefined; + const needsSplInfo = + wrap || + ataType === "spl" || + ataType === "token2022" || + splBalance > BigInt(0) || + t22Balance > BigInt(0); + if (needsSplInfo) { + try { + const splPoolInfos = + options?.splPoolInfos ?? (await getSplPoolInfos(rpc, mint)); + splPoolInfo = splPoolInfos.find( + (info: SplPoolInfo) => info.isInitialized, + ); + } catch (e) { + if (splBalance > BigInt(0) || t22Balance > BigInt(0)) { + throw e; + } + } + } + + const setupInstructions: TransactionInstruction[] = []; + let wrapCount = 0; + let needsAtaCreation = false; + + let decompressTarget: PublicKey = lightTokenAtaAddress; + let decompressSplInfo: SplPoolInfo | undefined; + let canDecompress = false; + + if (wrap) { + decompressTarget = lightTokenAtaAddress; + decompressSplInfo = undefined; + canDecompress = true; + + if (!lightTokenHotSource) { + needsAtaCreation = true; + setupInstructions.push( + createAtaIdempotent( + payer, + lightTokenAtaAddress, + owner, + mint, + LIGHT_TOKEN_PROGRAM_ID, + ), + ); + } + + if (splBalance > BigInt(0) && splPoolInfo) { + setupInstructions.push( + createWrapInstruction( + splAta, + lightTokenAtaAddress, + owner, + mint, + splBalance, + splPoolInfo, + decimals, + payer, + ), + ); + wrapCount++; + } + + if (t22Balance > BigInt(0) && splPoolInfo) { + setupInstructions.push( + createWrapInstruction( + t22Ata, + lightTokenAtaAddress, + owner, + mint, + t22Balance, + splPoolInfo, + decimals, + payer, + ), + ); + wrapCount++; + } + } else { + if (ataType === "light-token") { + decompressTarget = lightTokenAtaAddress; + decompressSplInfo = undefined; + canDecompress = true; + if (!lightTokenHotSource) { + needsAtaCreation = true; + setupInstructions.push( + createAtaIdempotent( + payer, + lightTokenAtaAddress, + owner, + mint, + LIGHT_TOKEN_PROGRAM_ID, + ), + ); + } + } else if (ataType === "spl" && splPoolInfo) { + decompressTarget = splAta; + decompressSplInfo = splPoolInfo; + canDecompress = true; + if (!splSource) { + needsAtaCreation = true; + setupInstructions.push( + createAssociatedTokenAccountIdempotentInstruction( + payer, + splAta, + owner, + mint, + TOKEN_PROGRAM_ID, + ), + ); + } + } else if (ataType === "token2022" && splPoolInfo) { + decompressTarget = t22Ata; + decompressSplInfo = splPoolInfo; + canDecompress = true; + if (!t22Source) { + needsAtaCreation = true; + setupInstructions.push( + createAssociatedTokenAccountIdempotentInstruction( + payer, + t22Ata, + owner, + mint, + TOKEN_2022_PROGRAM_ID, + ), + ); + } + } + } + + let accountsToLoad = allCompressedAccounts; + + if ( + targetAmount !== undefined && + canDecompress && + allCompressedAccounts.length > 0 + ) { + const isDelegate = authority !== undefined && !authority.equals(owner); + const hotBalance = (() => { + if (!lightTokenHotSource) return BigInt(0); + if (isDelegate) { + const delegated = + lightTokenHotSource.parsed.delegatedAmount ?? BigInt(0); + return delegated < lightTokenHotSource.amount + ? delegated + : lightTokenHotSource.amount; + } + return lightTokenHotSource.amount; + })(); + let effectiveHotAfterSetup: bigint; + + if (wrap) { + effectiveHotAfterSetup = hotBalance + splBalance + t22Balance; + } else if (ataType === "light-token") { + effectiveHotAfterSetup = hotBalance; + } else if (ataType === "spl") { + effectiveHotAfterSetup = splBalance; + } else { + effectiveHotAfterSetup = t22Balance; + } + + const neededFromCold = + targetAmount > effectiveHotAfterSetup + ? targetAmount - effectiveHotAfterSetup + : BigInt(0); + + if (neededFromCold === BigInt(0)) { + accountsToLoad = []; + } else { + accountsToLoad = selectInputsForAmount( + allCompressedAccounts, + neededFromCold, + ); + } + } + + if (!canDecompress || accountsToLoad.length === 0) { + if (setupInstructions.length === 0) return []; + return [ + { + instructions: setupInstructions, + compressedAccounts: [], + wrapCount, + hasAtaCreation: needsAtaCreation, + }, + ]; + } + + const chunks = chunkArray(accountsToLoad, MAX_INPUT_ACCOUNTS); + assertUniqueInputHashes(chunks); + + const proofs = await Promise.all( + chunks.map(async (chunk) => { + const proofInputs = chunk.map((acc) => ({ + hash: acc.compressedAccount.hash, + tree: acc.compressedAccount.treeInfo.tree, + queue: acc.compressedAccount.treeInfo.queue, + })); + return rpc.getValidityProofV0(proofInputs); + }), + ); + + const idempotentAtaIx = (() => { + if (wrap || ataType === "light-token") { + return createAtaIdempotent( + payer, + lightTokenAtaAddress, + owner, + mint, + LIGHT_TOKEN_PROGRAM_ID, + ); + } else if (ataType === "spl") { + return createAssociatedTokenAccountIdempotentInstruction( + payer, + splAta, + owner, + mint, + TOKEN_PROGRAM_ID, + ); + } else { + return createAssociatedTokenAccountIdempotentInstruction( + payer, + t22Ata, + owner, + mint, + TOKEN_2022_PROGRAM_ID, + ); + } + })(); + + const batches: InternalLoadBatch[] = []; + + for (let i = 0; i < chunks.length; i++) { + const chunk = chunks[i]; + const proof = proofs[i]; + const chunkAmount = chunk.reduce( + (sum, acc) => sum + BigInt(acc.parsed.amount.toString()), + BigInt(0), + ); + + const batchIxs: TransactionInstruction[] = []; + let batchWrapCount = 0; + let batchHasAtaCreation = false; + + if (i === 0) { + batchIxs.push(...setupInstructions); + batchWrapCount = wrapCount; + batchHasAtaCreation = needsAtaCreation; + } else { + batchIxs.push(idempotentAtaIx); + batchHasAtaCreation = true; + } + + const authorityForDecompress = authority ?? owner; + batchIxs.push( + createDecompressInstruction( + payer, + chunk, + decompressTarget, + chunkAmount, + proof, + decompressSplInfo, + decimals, + undefined, + authorityForDecompress, + ), + ); + + batches.push({ + instructions: batchIxs, + compressedAccounts: chunk, + wrapCount: batchWrapCount, + hasAtaCreation: batchHasAtaCreation, + }); + } + + return batches; +} + +export async function createLoadAtaInstructions( + rpc: Rpc, + ata: PublicKey, + owner: PublicKey, + mint: PublicKey, + payer?: PublicKey, + loadOptions?: LoadOptions, +): Promise { + const mintInfo = await getMint(rpc, mint); + return createLoadAtaInstructionsInner( + rpc, + ata, + owner, + mint, + mintInfo.mint.decimals, + payer, + loadOptions, + ); +} + +interface CreateLoadInstructionInternalInput extends CreateLoadInstructionsInput { + authority?: PublicKey; + account?: TokenInterfaceAccount | null; + wrap?: boolean; +} + +export async function createLoadInstructionInternal({ + rpc, + payer, + owner, + mint, + authority, + account, + wrap = false, +}: CreateLoadInstructionInternalInput): Promise<{ + instructions: TransactionInstruction[]; +} | null> { + const resolvedAccount = + account ?? + (await getAtaOrNull({ + rpc, + owner, + mint, + })); + const targetAta = getAtaAddress({ owner, mint }); + + const effectiveRpc = + resolvedAccount && resolvedAccount.compressedAccount + ? createSingleCompressedAccountRpc( + rpc, + owner, + mint, + resolvedAccount.compressedAccount, + ) + : rpc; + const instructions = normalizeInstructionBatches( + 'createLoadInstruction', + await createLoadAtaInstructions( + effectiveRpc, + targetAta, + owner, + mint, + payer, + toLoadOptions(owner, authority, wrap), + ), + ); + + if (instructions.length === 0) { + return null; + } + + return { + instructions, + }; +} + +export async function buildLoadInstructionList( + input: CreateLoadInstructionsInput & { + authority?: CreateTransferInstructionsInput['authority']; + account?: TokenInterfaceAccount | null; + wrap?: boolean; + }, +): Promise { + const load = await createLoadInstructionInternal(input); + + if (!load) { + return []; + } + + return load.instructions; +} + +export async function createLoadInstruction({ + rpc, + payer, + owner, + mint, +}: CreateLoadInstructionsInput): Promise { + const load = await createLoadInstructionInternal({ + rpc, + payer, + owner, + mint, + }); + + return load?.instructions[load.instructions.length - 1] ?? null; +} + +export async function createLoadInstructions({ + rpc, + payer, + owner, + mint, +}: CreateLoadInstructionsInput): Promise { + return buildLoadInstructionList({ + rpc, + payer, + owner, + mint, + wrap: true, + }); +} + +export async function createLoadInstructionPlan( + input: CreateLoadInstructionsInput, +) { + return toInstructionPlan(await createLoadInstructions(input)); +} diff --git a/js/token-interface/src/instructions/nowrap/index.ts b/js/token-interface/src/instructions/nowrap/index.ts deleted file mode 100644 index a46e8e9407..0000000000 --- a/js/token-interface/src/instructions/nowrap/index.ts +++ /dev/null @@ -1,208 +0,0 @@ -import type { TransactionInstruction } from '@solana/web3.js'; -import { LIGHT_TOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; -import { - createUnwrapInstruction, - getSplInterfaceInfos, -} from '@lightprotocol/compressed-token'; -import { getMintDecimals } from '../../helpers'; -import { createLoadInstructionInternal } from '../../load'; -import { getAtaAddress } from '../../read'; -import type { - CreateApproveInstructionsInput, - CreateLoadInstructionsInput, - CreateRevokeInstructionsInput, - CreateTransferInstructionsInput, -} from '../../types'; -import { - createApproveInstruction, - createTransferCheckedInstruction, - createRevokeInstruction, -} from '../raw'; -import { - createAtaInstructions, - createFreezeInstructions, - createThawInstructions, -} from '../index'; -import { getAta } from '../../account'; - -function toBigIntAmount(amount: number | bigint): bigint { - return BigInt(amount.toString()); -} - -async function buildLoadInstructionsNoWrap( - input: CreateLoadInstructionsInput & { - authority?: CreateTransferInstructionsInput['authority']; - account?: Awaited>; - }, -): Promise { - const load = await createLoadInstructionInternal({ - ...input, - wrap: false, - }); - - if (!load) { - return []; - } - - return load.instructions; -} - -/** - * Advanced no-wrap load helper. - */ -export async function createLoadInstructions({ - rpc, - payer, - owner, - mint, -}: CreateLoadInstructionsInput): Promise { - return buildLoadInstructionsNoWrap({ - rpc, - payer, - owner, - mint, - }); -} - -/** - * No-wrap transfer flow builder. - */ -export async function buildTransferInstructions({ - rpc, - payer, - mint, - sourceOwner, - authority, - recipient, - tokenProgram, - amount, -}: CreateTransferInstructionsInput): Promise { - const amountBigInt = toBigIntAmount(amount); - const senderLoadInstructions = await buildLoadInstructionsNoWrap({ - rpc, - payer, - owner: sourceOwner, - mint, - authority, - }); - - const recipientTokenProgramId = tokenProgram ?? LIGHT_TOKEN_PROGRAM_ID; - const recipientAta = getAtaAddress({ - owner: recipient, - mint, - programId: recipientTokenProgramId, - }); - const decimals = await getMintDecimals(rpc, mint); - const senderAta = getAtaAddress({ - owner: sourceOwner, - mint, - }); - - let transferInstruction: TransactionInstruction; - if (recipientTokenProgramId.equals(LIGHT_TOKEN_PROGRAM_ID)) { - transferInstruction = createTransferCheckedInstruction({ - source: senderAta, - destination: recipientAta, - mint, - authority, - payer, - amount: amountBigInt, - decimals, - }); - } else { - const splInterfaceInfos = await getSplInterfaceInfos(rpc, mint); - const splInterfaceInfo = splInterfaceInfos.find( - info => - info.isInitialized && - info.tokenProgram.equals(recipientTokenProgramId), - ); - if (!splInterfaceInfo) { - throw new Error( - `No initialized SPL interface found for tokenProgram ${recipientTokenProgramId.toBase58()}.`, - ); - } - transferInstruction = createUnwrapInstruction( - senderAta, - recipientAta, - authority, - mint, - amountBigInt, - splInterfaceInfo, - decimals, - payer, - ); - } - - return [ - ...senderLoadInstructions, - transferInstruction, - ]; -} - -export const createTransferInstructions = buildTransferInstructions; - -export async function createApproveInstructions({ - rpc, - payer, - owner, - mint, - delegate, - amount, -}: CreateApproveInstructionsInput): Promise { - const account = await getAta({ - rpc, - owner, - mint, - }); - - return [ - ...(await buildLoadInstructionsNoWrap({ - rpc, - payer, - owner, - mint, - account, - })), - createApproveInstruction({ - tokenAccount: account.address, - delegate, - owner, - amount: toBigIntAmount(amount), - payer, - }), - ]; -} - -export async function createRevokeInstructions({ - rpc, - payer, - owner, - mint, -}: CreateRevokeInstructionsInput): Promise { - const account = await getAta({ - rpc, - owner, - mint, - }); - - return [ - ...(await buildLoadInstructionsNoWrap({ - rpc, - payer, - owner, - mint, - account, - })), - createRevokeInstruction({ - tokenAccount: account.address, - owner, - payer, - }), - ]; -} - -export { - createAtaInstructions, - createFreezeInstructions, - createThawInstructions, -}; diff --git a/js/token-interface/src/instructions/raw/index.ts b/js/token-interface/src/instructions/raw/index.ts deleted file mode 100644 index 9a322988c5..0000000000 --- a/js/token-interface/src/instructions/raw/index.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { - createAssociatedTokenAccountInterfaceIdempotentInstruction, - createLightTokenApproveInstruction, - createLightTokenFreezeAccountInstruction, - createLightTokenRevokeInstruction, - createLightTokenThawAccountInstruction, - createLightTokenTransferCheckedInstruction, -} from '@lightprotocol/compressed-token'; -import { LIGHT_TOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; -import type { TransactionInstruction } from '@solana/web3.js'; -import { createLoadInstructionInternal } from '../../load'; -import { getAtaAddress } from '../../read'; -import type { - CreateFreezeInstructionsInput, - CreateRawApproveInstructionInput, - CreateRawAtaInstructionInput, - CreateRawLoadInstructionInput, - CreateRawRevokeInstructionInput, - CreateRawTransferInstructionInput, - CreateThawInstructionsInput, -} from '../../types'; - -export function createAtaInstruction({ - payer, - owner, - mint, - programId, -}: CreateRawAtaInstructionInput): TransactionInstruction { - const targetProgramId = programId ?? LIGHT_TOKEN_PROGRAM_ID; - const associatedToken = getAtaAddress({ - owner, - mint, - programId: targetProgramId, - }); - - return createAssociatedTokenAccountInterfaceIdempotentInstruction( - payer, - associatedToken, - owner, - mint, - targetProgramId, - ); -} - -export async function createLoadInstruction({ - rpc, - payer, - owner, - mint, -}: CreateRawLoadInstructionInput): Promise { - const load = await createLoadInstructionInternal({ - rpc, - payer, - owner, - mint, - }); - - return load?.instructions[load.instructions.length - 1] ?? null; -} - -export function createTransferCheckedInstruction({ - source, - destination, - mint, - authority, - payer, - amount, - decimals, -}: CreateRawTransferInstructionInput): TransactionInstruction { - return createLightTokenTransferCheckedInstruction( - source, - destination, - mint, - authority, - amount, - decimals, - payer, - ); -} - -export const createTransferInstruction = createTransferCheckedInstruction; -export const getTransferInstruction = createTransferCheckedInstruction; -export const getLoadInstruction = createLoadInstruction; -export const getCreateAtaInstruction = createAtaInstruction; - -export function createApproveInstruction({ - tokenAccount, - delegate, - owner, - amount, - payer, -}: CreateRawApproveInstructionInput): TransactionInstruction { - return createLightTokenApproveInstruction( - tokenAccount, - delegate, - owner, - amount, - payer, - ); -} - -export function createRevokeInstruction({ - tokenAccount, - owner, - payer, -}: CreateRawRevokeInstructionInput): TransactionInstruction { - return createLightTokenRevokeInstruction(tokenAccount, owner, payer); -} - -export function createFreezeInstruction({ - tokenAccount, - mint, - freezeAuthority, -}: CreateFreezeInstructionsInput): TransactionInstruction { - return createLightTokenFreezeAccountInstruction( - tokenAccount, - mint, - freezeAuthority, - ); -} - -export function createThawInstruction({ - tokenAccount, - mint, - freezeAuthority, -}: CreateThawInstructionsInput): TransactionInstruction { - return createLightTokenThawAccountInstruction( - tokenAccount, - mint, - freezeAuthority, - ); -} diff --git a/js/token-interface/src/instructions/revoke.ts b/js/token-interface/src/instructions/revoke.ts new file mode 100644 index 0000000000..198ba7f3ac --- /dev/null +++ b/js/token-interface/src/instructions/revoke.ts @@ -0,0 +1,107 @@ +import { SystemProgram, TransactionInstruction } from '@solana/web3.js'; +import { Buffer } from 'buffer'; +import { LIGHT_TOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; +import { assertAccountNotFrozen, getAta } from '../account'; +import type { + CreateRawRevokeInstructionInput, + CreateRevokeInstructionsInput, +} from '../types'; +import { buildLoadInstructionList } from './load'; +import { toInstructionPlan } from './_plan'; + +const LIGHT_TOKEN_REVOKE_DISCRIMINATOR = 5; + +export function createRevokeInstruction({ + tokenAccount, + owner, + payer, +}: CreateRawRevokeInstructionInput): TransactionInstruction { + const effectiveFeePayer = payer ?? owner; + + const keys = [ + { pubkey: tokenAccount, isSigner: false, isWritable: true }, + { + pubkey: owner, + isSigner: true, + isWritable: effectiveFeePayer.equals(owner), + }, + { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, + { + pubkey: effectiveFeePayer, + isSigner: !effectiveFeePayer.equals(owner), + isWritable: true, + }, + ]; + + return new TransactionInstruction({ + programId: LIGHT_TOKEN_PROGRAM_ID, + keys, + data: Buffer.from([LIGHT_TOKEN_REVOKE_DISCRIMINATOR]), + }); +} + +export async function createRevokeInstructions({ + rpc, + payer, + owner, + mint, +}: CreateRevokeInstructionsInput): Promise { + const account = await getAta({ + rpc, + owner, + mint, + }); + + assertAccountNotFrozen(account, 'revoke'); + + return [ + ...(await buildLoadInstructionList({ + rpc, + payer, + owner, + mint, + account, + wrap: true, + })), + createRevokeInstruction({ + tokenAccount: account.address, + owner, + payer, + }), + ]; +} + +export async function createRevokeInstructionsNowrap({ + rpc, + payer, + owner, + mint, +}: CreateRevokeInstructionsInput): Promise { + const account = await getAta({ + rpc, + owner, + mint, + }); + + return [ + ...(await buildLoadInstructionList({ + rpc, + payer, + owner, + mint, + account, + wrap: false, + })), + createRevokeInstruction({ + tokenAccount: account.address, + owner, + payer, + }), + ]; +} + +export async function createRevokeInstructionPlan( + input: CreateRevokeInstructionsInput, +) { + return toInstructionPlan(await createRevokeInstructions(input)); +} diff --git a/js/token-interface/src/instructions/thaw.ts b/js/token-interface/src/instructions/thaw.ts new file mode 100644 index 0000000000..ae90ba367f --- /dev/null +++ b/js/token-interface/src/instructions/thaw.ts @@ -0,0 +1,93 @@ +import { TransactionInstruction } from '@solana/web3.js'; +import { Buffer } from 'buffer'; +import { LIGHT_TOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; +import { + assertAccountFrozen, + getAta, +} from '../account'; +import type { + CreateRawThawInstructionInput, + CreateThawInstructionsInput, +} from '../types'; +import { buildLoadInstructionList } from './load'; +import { toInstructionPlan } from './_plan'; + +const LIGHT_TOKEN_THAW_ACCOUNT_DISCRIMINATOR = Buffer.from([11]); + +export function createThawInstruction({ + tokenAccount, + mint, + freezeAuthority, +}: CreateRawThawInstructionInput): TransactionInstruction { + return new TransactionInstruction({ + programId: LIGHT_TOKEN_PROGRAM_ID, + keys: [ + { pubkey: tokenAccount, isSigner: false, isWritable: true }, + { pubkey: mint, isSigner: false, isWritable: false }, + { pubkey: freezeAuthority, isSigner: true, isWritable: false }, + ], + data: LIGHT_TOKEN_THAW_ACCOUNT_DISCRIMINATOR, + }); +} + +export async function createThawInstructions({ + rpc, + payer, + owner, + mint, + freezeAuthority, +}: CreateThawInstructionsInput): Promise { + const account = await getAta({ rpc, owner, mint }); + + assertAccountFrozen(account, 'thaw'); + + return [ + ...(await buildLoadInstructionList({ + rpc, + payer, + owner, + mint, + account, + wrap: true, + })), + createThawInstruction({ + tokenAccount: account.address, + mint, + freezeAuthority, + }), + ]; +} + +export async function createThawInstructionsNowrap({ + rpc, + payer, + owner, + mint, + freezeAuthority, +}: CreateThawInstructionsInput): Promise { + const account = await getAta({ rpc, owner, mint }); + + assertAccountFrozen(account, 'thaw'); + + return [ + ...(await buildLoadInstructionList({ + rpc, + payer, + owner, + mint, + account, + wrap: false, + })), + createThawInstruction({ + tokenAccount: account.address, + mint, + freezeAuthority, + }), + ]; +} + +export async function createThawInstructionPlan( + input: CreateThawInstructionsInput, +) { + return toInstructionPlan(await createThawInstructions(input)); +} diff --git a/js/token-interface/src/instructions/transfer.ts b/js/token-interface/src/instructions/transfer.ts new file mode 100644 index 0000000000..74fa211004 --- /dev/null +++ b/js/token-interface/src/instructions/transfer.ts @@ -0,0 +1,277 @@ +import { Buffer } from 'buffer'; +import { SystemProgram, TransactionInstruction } from '@solana/web3.js'; +import { LIGHT_TOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; +import { getSplPoolInfos } from '../spl-interface'; +import { createUnwrapInstruction } from './unwrap'; +import { + TOKEN_2022_PROGRAM_ID, + TOKEN_PROGRAM_ID, + createCloseAccountInstruction, + unpackAccount, +} from '@solana/spl-token'; +import { getMintDecimals } from '../helpers'; +import { getAtaAddress } from '../read'; +import type { + CreateRawTransferInstructionInput, + CreateTransferInstructionsInput, +} from '../types'; +import { buildLoadInstructionList } from './load'; +import { toInstructionPlan } from './_plan'; +import { createAtaInstruction } from './ata'; + +const ZERO = BigInt(0); + +const LIGHT_TOKEN_TRANSFER_CHECKED_DISCRIMINATOR = 12; + +function toBigIntAmount(amount: number | bigint): bigint { + return BigInt(amount.toString()); +} + +async function getDerivedAtaBalance( + rpc: CreateTransferInstructionsInput['rpc'], + owner: CreateTransferInstructionsInput['sourceOwner'], + mint: CreateTransferInstructionsInput['mint'], + programId: typeof TOKEN_PROGRAM_ID | typeof TOKEN_2022_PROGRAM_ID, +): Promise { + const ata = getAtaAddress({ owner, mint, programId }); + const info = await rpc.getAccountInfo(ata); + if (!info || !info.owner.equals(programId)) { + return ZERO; + } + + return unpackAccount(ata, info, programId).amount; +} + +export function createTransferCheckedInstruction({ + source, + destination, + mint, + authority, + payer, + amount, + decimals, +}: CreateRawTransferInstructionInput): TransactionInstruction { + const data = Buffer.alloc(10); + data.writeUInt8(LIGHT_TOKEN_TRANSFER_CHECKED_DISCRIMINATOR, 0); + data.writeBigUInt64LE(BigInt(amount), 1); + data.writeUInt8(decimals, 9); + + return new TransactionInstruction({ + programId: LIGHT_TOKEN_PROGRAM_ID, + keys: [ + { pubkey: source, isSigner: false, isWritable: true }, + { pubkey: mint, isSigner: false, isWritable: false }, + { pubkey: destination, isSigner: false, isWritable: true }, + { pubkey: authority, isSigner: true, isWritable: payer.equals(authority) }, + { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, + { + pubkey: payer, + isSigner: !payer.equals(authority), + isWritable: true, + }, + ], + data, + }); +} + +/** + * Canonical web3.js transfer flow builder. + * Returns an instruction array for a single transfer flow (setup + transfer). + */ +export async function buildTransferInstructions({ + rpc, + payer, + mint, + sourceOwner, + authority, + recipient, + tokenProgram, + amount, +}: CreateTransferInstructionsInput): Promise { + const amountBigInt = toBigIntAmount(amount); + const senderLoadInstructions = await buildLoadInstructionList({ + rpc, + payer, + owner: sourceOwner, + mint, + authority, + wrap: true, + }); + const recipientTokenProgramId = tokenProgram ?? LIGHT_TOKEN_PROGRAM_ID; + const recipientAta = getAtaAddress({ + owner: recipient, + mint, + programId: recipientTokenProgramId, + }); + const decimals = await getMintDecimals(rpc, mint); + const [senderSplBalance, senderT22Balance] = await Promise.all([ + getDerivedAtaBalance(rpc, sourceOwner, mint, TOKEN_PROGRAM_ID), + getDerivedAtaBalance(rpc, sourceOwner, mint, TOKEN_2022_PROGRAM_ID), + ]); + + const closeWrappedSourceInstructions: TransactionInstruction[] = []; + if (authority.equals(sourceOwner) && senderSplBalance > ZERO) { + closeWrappedSourceInstructions.push( + createCloseAccountInstruction( + getAtaAddress({ + owner: sourceOwner, + mint, + programId: TOKEN_PROGRAM_ID, + }), + sourceOwner, + sourceOwner, + [], + TOKEN_PROGRAM_ID, + ), + ); + } + if (authority.equals(sourceOwner) && senderT22Balance > ZERO) { + closeWrappedSourceInstructions.push( + createCloseAccountInstruction( + getAtaAddress({ + owner: sourceOwner, + mint, + programId: TOKEN_2022_PROGRAM_ID, + }), + sourceOwner, + sourceOwner, + [], + TOKEN_2022_PROGRAM_ID, + ), + ); + } + + const recipientLoadInstructions: TransactionInstruction[] = []; + const senderAta = getAtaAddress({ + owner: sourceOwner, + mint, + }); + let transferInstruction: TransactionInstruction; + if (recipientTokenProgramId.equals(LIGHT_TOKEN_PROGRAM_ID)) { + transferInstruction = createTransferCheckedInstruction({ + source: senderAta, + destination: recipientAta, + mint, + authority, + payer, + amount: amountBigInt, + decimals, + }); + } else { + const splPoolInfos = await getSplPoolInfos(rpc, mint); + const splPoolInfo = splPoolInfos.find( + info => + info.isInitialized && + info.tokenProgram.equals(recipientTokenProgramId), + ); + if (!splPoolInfo) { + throw new Error( + `No initialized SPL pool found for tokenProgram ${recipientTokenProgramId.toBase58()}.`, + ); + } + transferInstruction = createUnwrapInstruction( + senderAta, + recipientAta, + authority, + mint, + amountBigInt, + splPoolInfo, + decimals, + payer, + ); + } + + return [ + ...senderLoadInstructions, + ...closeWrappedSourceInstructions, + createAtaInstruction({ + payer, + owner: recipient, + mint, + programId: recipientTokenProgramId, + }), + ...recipientLoadInstructions, + transferInstruction, + ]; +} + +/** + * No-wrap transfer flow builder (advanced). + */ +export async function buildTransferInstructionsNowrap({ + rpc, + payer, + mint, + sourceOwner, + authority, + recipient, + tokenProgram, + amount, +}: CreateTransferInstructionsInput): Promise { + const amountBigInt = toBigIntAmount(amount); + const senderLoadInstructions = await buildLoadInstructionList({ + rpc, + payer, + owner: sourceOwner, + mint, + authority, + wrap: false, + }); + + const recipientTokenProgramId = tokenProgram ?? LIGHT_TOKEN_PROGRAM_ID; + const recipientAta = getAtaAddress({ + owner: recipient, + mint, + programId: recipientTokenProgramId, + }); + const decimals = await getMintDecimals(rpc, mint); + const senderAta = getAtaAddress({ + owner: sourceOwner, + mint, + }); + + let transferInstruction: TransactionInstruction; + if (recipientTokenProgramId.equals(LIGHT_TOKEN_PROGRAM_ID)) { + transferInstruction = createTransferCheckedInstruction({ + source: senderAta, + destination: recipientAta, + mint, + authority, + payer, + amount: amountBigInt, + decimals, + }); + } else { + const splPoolInfos = await getSplPoolInfos(rpc, mint); + const splPoolInfo = splPoolInfos.find( + info => + info.isInitialized && + info.tokenProgram.equals(recipientTokenProgramId), + ); + if (!splPoolInfo) { + throw new Error( + `No initialized SPL pool found for tokenProgram ${recipientTokenProgramId.toBase58()}.`, + ); + } + transferInstruction = createUnwrapInstruction( + senderAta, + recipientAta, + authority, + mint, + amountBigInt, + splPoolInfo, + decimals, + payer, + ); + } + + return [...senderLoadInstructions, transferInstruction]; +} + +export async function createTransferInstructionPlan( + input: CreateTransferInstructionsInput, +) { + return toInstructionPlan(await buildTransferInstructions(input)); +} + +export { buildTransferInstructions as createTransferInstructions }; diff --git a/js/token-interface/src/instructions/unwrap.ts b/js/token-interface/src/instructions/unwrap.ts new file mode 100644 index 0000000000..407a962639 --- /dev/null +++ b/js/token-interface/src/instructions/unwrap.ts @@ -0,0 +1,120 @@ +import { + PublicKey, + TransactionInstruction, + SystemProgram, +} from '@solana/web3.js'; +import { LIGHT_TOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; +import { + COMPRESSED_TOKEN_PROGRAM_ID, + deriveCpiAuthorityPda, + MAX_TOP_UP, +} from '../constants'; +import type { SplPoolInfo } from '../spl-interface'; +import { + encodeTransfer2InstructionData, + createCompressLightToken, + createDecompressSpl, + type Transfer2InstructionData, + type Compression, +} from './layout/layout-transfer2'; + +/** + * Create an unwrap instruction that moves tokens from a light-token account to an + * SPL/T22 account. + */ +export function createUnwrapInstruction( + source: PublicKey, + destination: PublicKey, + owner: PublicKey, + mint: PublicKey, + amount: bigint, + splPoolInfo: SplPoolInfo, + decimals: number, + payer: PublicKey = owner, + maxTopUp?: number, +): TransactionInstruction { + const MINT_INDEX = 0; + const OWNER_INDEX = 1; + const SOURCE_INDEX = 2; + const DESTINATION_INDEX = 3; + const POOL_INDEX = 4; + const LIGHT_TOKEN_PROGRAM_INDEX = 6; + + const compressions: Compression[] = [ + createCompressLightToken( + amount, + MINT_INDEX, + SOURCE_INDEX, + OWNER_INDEX, + LIGHT_TOKEN_PROGRAM_INDEX, + ), + createDecompressSpl( + amount, + MINT_INDEX, + DESTINATION_INDEX, + POOL_INDEX, + splPoolInfo.poolIndex, + splPoolInfo.bump, + decimals, + ), + ]; + + const instructionData: Transfer2InstructionData = { + withTransactionHash: false, + withLamportsChangeAccountMerkleTreeIndex: false, + lamportsChangeAccountMerkleTreeIndex: 0, + lamportsChangeAccountOwnerIndex: 0, + outputQueue: 0, + maxTopUp: maxTopUp ?? MAX_TOP_UP, + cpiContext: null, + compressions, + proof: null, + inTokenData: [], + outTokenData: [], + inLamports: null, + outLamports: null, + inTlv: null, + outTlv: null, + }; + + const data = encodeTransfer2InstructionData(instructionData); + + const keys = [ + { + pubkey: deriveCpiAuthorityPda(), + isSigner: false, + isWritable: false, + }, + { pubkey: payer, isSigner: true, isWritable: true }, + { pubkey: mint, isSigner: false, isWritable: false }, + { pubkey: owner, isSigner: true, isWritable: false }, + { pubkey: source, isSigner: false, isWritable: true }, + { pubkey: destination, isSigner: false, isWritable: true }, + { + pubkey: splPoolInfo.splPoolPda, + isSigner: false, + isWritable: true, + }, + { + pubkey: splPoolInfo.tokenProgram, + isSigner: false, + isWritable: false, + }, + { + pubkey: LIGHT_TOKEN_PROGRAM_ID, + isSigner: false, + isWritable: false, + }, + { + pubkey: SystemProgram.programId, + isSigner: false, + isWritable: false, + }, + ]; + + return new TransactionInstruction({ + programId: COMPRESSED_TOKEN_PROGRAM_ID, + keys, + data, + }); +} diff --git a/js/token-interface/src/instructions/wrap.ts b/js/token-interface/src/instructions/wrap.ts new file mode 100644 index 0000000000..4a90404693 --- /dev/null +++ b/js/token-interface/src/instructions/wrap.ts @@ -0,0 +1,133 @@ +import { + PublicKey, + TransactionInstruction, + SystemProgram, +} from '@solana/web3.js'; +import { LIGHT_TOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; +import { + COMPRESSED_TOKEN_PROGRAM_ID, + deriveCpiAuthorityPda, + MAX_TOP_UP, +} from '../constants'; +import type { SplPoolInfo } from '../spl-interface'; +import { + encodeTransfer2InstructionData, + createCompressSpl, + createDecompressLightToken, + type Transfer2InstructionData, + type Compression, +} from './layout/layout-transfer2'; + +/** + * Create a wrap instruction that moves tokens from an SPL/T22 account to a + * light-token account. + * + * @param source Source SPL/T22 token account + * @param destination Destination light-token account + * @param owner Owner of the source account (signer) + * @param mint Mint address + * @param amount Amount to wrap, + * @param splPoolInfo SPL pool info for the compression + * @param decimals Mint decimals (required for transfer_checked) + * @param payer Fee payer (defaults to owner) + * @param maxTopUp Optional cap on rent top-up (units of 1k lamports; default no cap) + * @returns Instruction to wrap tokens + */ +export function createWrapInstruction( + source: PublicKey, + destination: PublicKey, + owner: PublicKey, + mint: PublicKey, + amount: bigint, + splPoolInfo: SplPoolInfo, + decimals: number, + payer: PublicKey = owner, + maxTopUp?: number, +): TransactionInstruction { + const MINT_INDEX = 0; + const OWNER_INDEX = 1; + const SOURCE_INDEX = 2; + const DESTINATION_INDEX = 3; + const POOL_INDEX = 4; + const _SPL_TOKEN_PROGRAM_INDEX = 5; + const LIGHT_TOKEN_PROGRAM_INDEX = 6; + + const compressions: Compression[] = [ + createCompressSpl( + amount, + MINT_INDEX, + SOURCE_INDEX, + OWNER_INDEX, + POOL_INDEX, + splPoolInfo.poolIndex, + splPoolInfo.bump, + decimals, + ), + createDecompressLightToken( + amount, + MINT_INDEX, + DESTINATION_INDEX, + LIGHT_TOKEN_PROGRAM_INDEX, + ), + ]; + + const instructionData: Transfer2InstructionData = { + withTransactionHash: false, + withLamportsChangeAccountMerkleTreeIndex: false, + lamportsChangeAccountMerkleTreeIndex: 0, + lamportsChangeAccountOwnerIndex: 0, + outputQueue: 0, + maxTopUp: maxTopUp ?? MAX_TOP_UP, + cpiContext: null, + compressions, + proof: null, + inTokenData: [], + outTokenData: [], + inLamports: null, + outLamports: null, + inTlv: null, + outTlv: null, + }; + + const data = encodeTransfer2InstructionData(instructionData); + + const keys = [ + { + pubkey: deriveCpiAuthorityPda(), + isSigner: false, + isWritable: false, + }, + { pubkey: payer, isSigner: true, isWritable: true }, + { pubkey: mint, isSigner: false, isWritable: false }, + { pubkey: owner, isSigner: true, isWritable: false }, + { pubkey: source, isSigner: false, isWritable: true }, + { pubkey: destination, isSigner: false, isWritable: true }, + { + pubkey: splPoolInfo.splPoolPda, + isSigner: false, + isWritable: true, + }, + { + pubkey: splPoolInfo.tokenProgram, + isSigner: false, + isWritable: false, + }, + { + pubkey: LIGHT_TOKEN_PROGRAM_ID, + isSigner: false, + isWritable: false, + }, + // System program needed for top-up CPIs when destination has compressible extension + { + pubkey: SystemProgram.programId, + isSigner: false, + isWritable: false, + }, + ]; + + return new TransactionInstruction({ + programId: COMPRESSED_TOKEN_PROGRAM_ID, + keys, + data, + }); +} diff --git a/js/token-interface/src/kit/index.ts b/js/token-interface/src/kit/index.ts index a410ff8fb1..17489c2db8 100644 --- a/js/token-interface/src/kit/index.ts +++ b/js/token-interface/src/kit/index.ts @@ -1,21 +1,26 @@ -import { fromLegacyTransactionInstruction } from '@solana/compat'; -import { - sequentialInstructionPlan, - type InstructionPlan, -} from '@solana/kit'; import type { TransactionInstruction } from '@solana/web3.js'; import { - createApproveInstructions as createLegacyApproveInstructions, - buildTransferInstructions as buildLegacyTransferInstructions, - createAtaInstructions as createLegacyAtaInstructions, - createFreezeInstructions as createLegacyFreezeInstructions, - createLoadInstructions as createLegacyLoadInstructions, - createRevokeInstructions as createLegacyRevokeInstructions, - createThawInstructions as createLegacyThawInstructions, + buildTransferInstructions as buildTransferInstructionsTx, + buildTransferInstructionsNowrap as buildTransferInstructionsNowrapTx, + createApproveInstructions as createApproveInstructionsTx, + createApproveInstructionsNowrap as createApproveInstructionsNowrapTx, + createAtaInstructions as createAtaInstructionsTx, + createBurnInstructions as createBurnInstructionsTx, + createBurnInstructionsNowrap as createBurnInstructionsNowrapTx, + createFreezeInstructions as createFreezeInstructionsTx, + createFreezeInstructionsNowrap as createFreezeInstructionsNowrapTx, + createLoadInstructions as createLoadInstructionsTx, + createRevokeInstructions as createRevokeInstructionsTx, + createRevokeInstructionsNowrap as createRevokeInstructionsNowrapTx, + createThawInstructions as createThawInstructionsTx, + createThawInstructionsNowrap as createThawInstructionsNowrapTx, } from '../instructions'; +import type { KitInstruction } from '../instructions/_plan'; +import { toKitInstructions } from '../instructions/_plan'; import type { CreateApproveInstructionsInput, CreateAtaInstructionsInput, + CreateBurnInstructionsInput, CreateFreezeInstructionsInput, CreateLoadInstructionsInput, CreateRevokeInstructionsInput, @@ -23,84 +28,115 @@ import type { CreateTransferInstructionsInput, } from '../types'; -export type KitInstruction = ReturnType; +export type { KitInstruction }; + +export { + createApproveInstructionPlan, + createAtaInstructionPlan, + createBurnInstructionPlan, + createFreezeInstructionPlan, + createLoadInstructionPlan, + createRevokeInstructionPlan, + createThawInstructionPlan, + createTransferInstructionPlan, + toInstructionPlan, + toKitInstructions, +} from '../instructions'; -export function toKitInstructions( - instructions: TransactionInstruction[], -): KitInstruction[] { - return instructions.map(instruction => - fromLegacyTransactionInstruction(instruction), - ); +function wrap( + instructions: Promise, +): Promise { + return instructions.then(ixs => toKitInstructions(ixs)); } export async function createAtaInstructions( input: CreateAtaInstructionsInput, ): Promise { - return toKitInstructions(await createLegacyAtaInstructions(input)); + return wrap(createAtaInstructionsTx(input)); } -/** - * Advanced: standalone load (decompress) instructions for an ATA, plus create-ATA if missing. - * Prefer the canonical builders (`buildTransferInstructions`, `createApproveInstructions`, - * `createRevokeInstructions`, …), which prepend load automatically when needed. - */ export async function createLoadInstructions( input: CreateLoadInstructionsInput, ): Promise { - return toKitInstructions(await createLegacyLoadInstructions(input)); + return wrap(createLoadInstructionsTx(input)); } -/** - * Canonical Kit instruction-array builder. - * Returns Kit instructions (not an InstructionPlan). - */ export async function buildTransferInstructions( input: CreateTransferInstructionsInput, ): Promise { - return toKitInstructions(await buildLegacyTransferInstructions(input)); + return wrap(buildTransferInstructionsTx(input)); } -/** - * Backwards-compatible alias. - */ -export const createTransferInstructions = buildTransferInstructions; - -/** - * Canonical Kit plan builder. - */ -export async function getTransferInstructionPlan( +export async function buildTransferInstructionsNowrap( input: CreateTransferInstructionsInput, -): Promise { - return sequentialInstructionPlan(await buildTransferInstructions(input)); +): Promise { + return wrap(buildTransferInstructionsNowrapTx(input)); } export async function createApproveInstructions( input: CreateApproveInstructionsInput, ): Promise { - return toKitInstructions(await createLegacyApproveInstructions(input)); + return wrap(createApproveInstructionsTx(input)); +} + +export async function createApproveInstructionsNowrap( + input: CreateApproveInstructionsInput, +): Promise { + return wrap(createApproveInstructionsNowrapTx(input)); } export async function createRevokeInstructions( input: CreateRevokeInstructionsInput, ): Promise { - return toKitInstructions(await createLegacyRevokeInstructions(input)); + return wrap(createRevokeInstructionsTx(input)); +} + +export async function createRevokeInstructionsNowrap( + input: CreateRevokeInstructionsInput, +): Promise { + return wrap(createRevokeInstructionsNowrapTx(input)); } export async function createFreezeInstructions( input: CreateFreezeInstructionsInput, ): Promise { - return toKitInstructions(await createLegacyFreezeInstructions(input)); + return wrap(createFreezeInstructionsTx(input)); +} + +export async function createFreezeInstructionsNowrap( + input: CreateFreezeInstructionsInput, +): Promise { + return wrap(createFreezeInstructionsNowrapTx(input)); } export async function createThawInstructions( input: CreateThawInstructionsInput, ): Promise { - return toKitInstructions(await createLegacyThawInstructions(input)); + return wrap(createThawInstructionsTx(input)); +} + +export async function createThawInstructionsNowrap( + input: CreateThawInstructionsInput, +): Promise { + return wrap(createThawInstructionsNowrapTx(input)); +} + +export async function createBurnInstructions( + input: CreateBurnInstructionsInput, +): Promise { + return wrap(createBurnInstructionsTx(input)); +} + +export async function createBurnInstructionsNowrap( + input: CreateBurnInstructionsInput, +): Promise { + return wrap(createBurnInstructionsNowrapTx(input)); } export type { CreateApproveInstructionsInput, CreateAtaInstructionsInput, + CreateBurnInstructionsInput, CreateFreezeInstructionsInput, CreateLoadInstructionsInput, CreateRevokeInstructionsInput, diff --git a/js/token-interface/src/load-options.ts b/js/token-interface/src/load-options.ts new file mode 100644 index 0000000000..7a288d7e21 --- /dev/null +++ b/js/token-interface/src/load-options.ts @@ -0,0 +1,8 @@ +import type { PublicKey } from '@solana/web3.js'; +import type { SplPoolInfo } from './spl-interface'; + +export interface LoadOptions { + splPoolInfos?: SplPoolInfo[]; + wrap?: boolean; + delegatePubkey?: PublicKey; +} diff --git a/js/token-interface/src/load.ts b/js/token-interface/src/load.ts deleted file mode 100644 index f305ef2bff..0000000000 --- a/js/token-interface/src/load.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { createLoadAtaInstructions } from '@lightprotocol/compressed-token'; -import type { PublicKey, TransactionInstruction } from '@solana/web3.js'; -import { createSingleCompressedAccountRpc, getAtaOrNull } from './account'; -import { normalizeInstructionBatches, toInterfaceOptions } from './helpers'; -import { getAtaAddress } from './read'; -import type { - CreateLoadInstructionsInput, - TokenInterfaceAccount, -} from './types'; - -interface CreateLoadInstructionInternalInput extends CreateLoadInstructionsInput { - authority?: PublicKey; - account?: TokenInterfaceAccount | null; - wrap?: boolean; -} - -export async function createLoadInstructionInternal({ - rpc, - payer, - owner, - mint, - authority, - account, - wrap = false, -}: CreateLoadInstructionInternalInput): Promise<{ - instructions: TransactionInstruction[]; -} | null> { - const resolvedAccount = - account ?? - (await getAtaOrNull({ - rpc, - owner, - mint, - })); - const targetAta = getAtaAddress({ owner, mint }); - - const effectiveRpc = - resolvedAccount && resolvedAccount.compressedAccount - ? createSingleCompressedAccountRpc( - rpc, - owner, - mint, - resolvedAccount.compressedAccount, - ) - : rpc; - const instructions = normalizeInstructionBatches( - 'createLoadInstruction', - await createLoadAtaInstructions( - effectiveRpc, - targetAta, - owner, - mint, - payer, - toInterfaceOptions(owner, authority, wrap), - ), - ); - - if (instructions.length === 0) { - return null; - } - - return { - instructions, - }; -} diff --git a/js/token-interface/src/read/associated-token-address.ts b/js/token-interface/src/read/associated-token-address.ts new file mode 100644 index 0000000000..0283b9d0c8 --- /dev/null +++ b/js/token-interface/src/read/associated-token-address.ts @@ -0,0 +1,23 @@ +import { PublicKey } from '@solana/web3.js'; +import { getAssociatedTokenAddressSync } from '@solana/spl-token'; +import { LIGHT_TOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; +import { getAtaProgramId } from './ata-utils'; + +export function getAssociatedTokenAddress( + mint: PublicKey, + owner: PublicKey, + allowOwnerOffCurve = false, + programId: PublicKey = LIGHT_TOKEN_PROGRAM_ID, + associatedTokenProgramId?: PublicKey, +): PublicKey { + const effectiveAssociatedProgramId = + associatedTokenProgramId ?? getAtaProgramId(programId); + + return getAssociatedTokenAddressSync( + mint, + owner, + allowOwnerOffCurve, + programId, + effectiveAssociatedProgramId, + ); +} diff --git a/js/token-interface/src/read/ata-utils.ts b/js/token-interface/src/read/ata-utils.ts new file mode 100644 index 0000000000..a4b81d2fc9 --- /dev/null +++ b/js/token-interface/src/read/ata-utils.ts @@ -0,0 +1,108 @@ +import { + ASSOCIATED_TOKEN_PROGRAM_ID, + TOKEN_PROGRAM_ID, + TOKEN_2022_PROGRAM_ID, + getAssociatedTokenAddressSync, +} from '@solana/spl-token'; +import { LIGHT_TOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; +import { PublicKey } from '@solana/web3.js'; + +export function getAtaProgramId(tokenProgramId: PublicKey): PublicKey { + if (tokenProgramId.equals(LIGHT_TOKEN_PROGRAM_ID)) { + return LIGHT_TOKEN_PROGRAM_ID; + } + return ASSOCIATED_TOKEN_PROGRAM_ID; +} + +export type AtaType = 'spl' | 'token2022' | 'light-token'; + +export interface AtaValidationResult { + valid: true; + type: AtaType; + programId: PublicKey; +} + +export function checkAtaAddress( + ata: PublicKey, + mint: PublicKey, + owner: PublicKey, + programId?: PublicKey, + allowOwnerOffCurve = false, +): AtaValidationResult { + if (programId) { + const expected = getAssociatedTokenAddressSync( + mint, + owner, + allowOwnerOffCurve, + programId, + getAtaProgramId(programId), + ); + if (ata.equals(expected)) { + return { + valid: true, + type: programIdToAtaType(programId), + programId, + }; + } + throw new Error( + `ATA address mismatch for ${programId.toBase58()}. ` + + `Expected: ${expected.toBase58()}, got: ${ata.toBase58()}`, + ); + } + + const lightTokenExpected = getAssociatedTokenAddressSync( + mint, + owner, + allowOwnerOffCurve, + LIGHT_TOKEN_PROGRAM_ID, + getAtaProgramId(LIGHT_TOKEN_PROGRAM_ID), + ); + if (ata.equals(lightTokenExpected)) { + return { + valid: true, + type: 'light-token', + programId: LIGHT_TOKEN_PROGRAM_ID, + }; + } + + const splExpected = getAssociatedTokenAddressSync( + mint, + owner, + allowOwnerOffCurve, + TOKEN_PROGRAM_ID, + getAtaProgramId(TOKEN_PROGRAM_ID), + ); + if (ata.equals(splExpected)) { + return { valid: true, type: 'spl', programId: TOKEN_PROGRAM_ID }; + } + + const t22Expected = getAssociatedTokenAddressSync( + mint, + owner, + allowOwnerOffCurve, + TOKEN_2022_PROGRAM_ID, + getAtaProgramId(TOKEN_2022_PROGRAM_ID), + ); + if (ata.equals(t22Expected)) { + return { + valid: true, + type: 'token2022', + programId: TOKEN_2022_PROGRAM_ID, + }; + } + + throw new Error( + `ATA address does not match any valid derivation from mint+owner. ` + + `Got: ${ata.toBase58()}, expected one of: ` + + `light-token=${lightTokenExpected.toBase58()}, ` + + `SPL=${splExpected.toBase58()}, ` + + `T22=${t22Expected.toBase58()}`, + ); +} + +function programIdToAtaType(programId: PublicKey): AtaType { + if (programId.equals(LIGHT_TOKEN_PROGRAM_ID)) return 'light-token'; + if (programId.equals(TOKEN_PROGRAM_ID)) return 'spl'; + if (programId.equals(TOKEN_2022_PROGRAM_ID)) return 'token2022'; + throw new Error(`Unknown program ID: ${programId.toBase58()}`); +} diff --git a/js/token-interface/src/read/get-account.ts b/js/token-interface/src/read/get-account.ts new file mode 100644 index 0000000000..c71d8ac871 --- /dev/null +++ b/js/token-interface/src/read/get-account.ts @@ -0,0 +1,1095 @@ +import { AccountInfo, Commitment, PublicKey } from '@solana/web3.js'; +import { + TOKEN_PROGRAM_ID, + TOKEN_2022_PROGRAM_ID, + unpackAccount as unpackAccountSPL, + TokenAccountNotFoundError, + TokenInvalidAccountOwnerError, + getAssociatedTokenAddressSync, + AccountState, + Account, +} from '@solana/spl-token'; +import { + Rpc, + LIGHT_TOKEN_PROGRAM_ID, + MerkleContext, + CompressedAccountWithMerkleContext, + assertV2Enabled, +} from '@lightprotocol/stateless.js'; +import { Buffer } from 'buffer'; +import BN from 'bn.js'; +import { getAtaProgramId, checkAtaAddress } from './ata-utils'; +import { ERR_FETCH_BY_OWNER_REQUIRED } from '../errors'; + +export const TokenAccountSourceType = { + Spl: 'spl', + Token2022: 'token2022', + SplCold: 'spl-cold', + Token2022Cold: 'token2022-cold', + LightTokenHot: 'light-token-hot', + LightTokenCold: 'light-token-cold', +} as const; + +export type TokenAccountSourceTypeValue = + (typeof TokenAccountSourceType)[keyof typeof TokenAccountSourceType]; + +/** Cold (compressed) source types. Used for load/decompress and isCold. */ +export const COLD_SOURCE_TYPES: ReadonlySet = + new Set([ + TokenAccountSourceType.LightTokenCold, + TokenAccountSourceType.SplCold, + TokenAccountSourceType.Token2022Cold, + ]); + +function isColdSourceType(type: TokenAccountSourceTypeValue): boolean { + return COLD_SOURCE_TYPES.has(type); +} + +/** @internal */ +export interface TokenAccountSource { + type: TokenAccountSourceTypeValue; + address: PublicKey; + amount: bigint; + accountInfo: AccountInfo; + loadContext?: MerkleContext; + parsed: Account; +} + +export interface AccountView { + accountInfo: AccountInfo; + parsed: Account; + isCold: boolean; + loadContext?: MerkleContext; + _sources?: TokenAccountSource[]; + _needsConsolidation?: boolean; + _hasDelegate?: boolean; + _anyFrozen?: boolean; + /** True when fetched via getAtaView */ + _isAta?: boolean; + /** Associated token account owner - set by getAtaView */ + _owner?: PublicKey; + /** Associated token account mint - set by getAtaView */ + _mint?: PublicKey; +} + +function toErrorMessage(error: unknown): string { + if (error instanceof Error) return error.message; + return String(error); +} + +function throwRpcFetchFailure(context: string, error: unknown): never { + throw new Error(`${context}: ${toErrorMessage(error)}`); +} + +function throwIfUnexpectedRpcErrors( + context: string, + unexpectedErrors: unknown[], +): void { + if (unexpectedErrors.length > 0) { + throwRpcFetchFailure(context, unexpectedErrors[0]); + } +} + +export type FrozenOperation = + | 'load' + | 'transfer' + | 'unwrap' + | 'approve' + | 'revoke'; + +export function checkNotFrozen( + iface: AccountView, + operation: FrozenOperation, +): void { + if (iface._anyFrozen) { + throw new Error( + `Account is frozen. One or more sources (hot or cold) are frozen; ${operation} is not allowed.`, + ); + } +} + +/** @internal */ +function parseTokenData(data: Buffer): { + mint: PublicKey; + owner: PublicKey; + amount: BN; + delegate: PublicKey | null; + state: number; + tlv: Buffer | null; +} | null { + if (!data || data.length === 0) return null; + + try { + let offset = 0; + const mint = new PublicKey(data.slice(offset, offset + 32)); + offset += 32; + const owner = new PublicKey(data.slice(offset, offset + 32)); + offset += 32; + const amount = new BN(data.slice(offset, offset + 8), 'le'); + offset += 8; + const delegateOption = data[offset]; + offset += 1; + const delegate = delegateOption + ? new PublicKey(data.slice(offset, offset + 32)) + : null; + offset += 32; + const state = data[offset]; + offset += 1; + const tlvOption = data[offset]; + offset += 1; + const tlv = tlvOption ? data.slice(offset) : null; + + return { + mint, + owner, + amount, + delegate, + state, + tlv, + }; + } catch { + return null; + } +} + +/** + * Known extension data sizes by Borsh enum discriminator. + * undefined = variable-length (cannot skip without full parsing). + * @internal + */ +const EXTENSION_DATA_SIZES: Record = { + 0: 0, + 1: 0, + 2: 0, + 3: 0, + 4: 0, + 5: 0, + 6: 0, + 7: 0, + 8: 0, + 9: 0, + 10: 0, + 11: 0, + 12: 0, + 13: 0, + 14: 0, + 15: 0, + 16: 0, + 17: 0, + 18: 0, + 19: undefined, // TokenMetadata (variable) + 20: 0, + 21: 0, + 22: 0, + 23: 0, + 24: 0, + 25: 0, + 26: 0, + 27: 0, // PausableAccountExtension (unit struct) + 28: 0, // PermanentDelegateAccountExtension (unit struct) + 29: 8, // TransferFeeAccountExtension (u64) + 30: 1, // TransferHookAccountExtension (u8) + 31: 17, // CompressedOnlyExtension (u64 + u64 + u8) + 32: undefined, // CompressibleExtension (variable) +}; + +const COMPRESSED_ONLY_DISCRIMINATOR = 31; + +/** + * Extract delegated_amount from CompressedOnly extension in Borsh-serialized + * TLV data (Vec). + * @internal + */ +function extractDelegatedAmountFromTlv(tlv: Buffer | null): bigint | null { + if (!tlv || tlv.length < 5) return null; + + try { + let offset = 0; + const vecLen = tlv.readUInt32LE(offset); + offset += 4; + + for (let i = 0; i < vecLen; i++) { + if (offset >= tlv.length) return null; + + const discriminator = tlv[offset]; + offset += 1; + + if (discriminator === COMPRESSED_ONLY_DISCRIMINATOR) { + if (offset + 8 > tlv.length) return null; + // delegated_amount is the first u64 field + const lo = BigInt(tlv.readUInt32LE(offset)); + const hi = BigInt(tlv.readUInt32LE(offset + 4)); + return lo | (hi << BigInt(32)); + } + + const size = EXTENSION_DATA_SIZES[discriminator]; + if (size === undefined) return null; + offset += size; + } + } catch { + return null; + } + + return null; +} + +/** @internal */ +function convertTokenDataToAccount( + address: PublicKey, + tokenData: { + mint: PublicKey; + owner: PublicKey; + amount: BN; + delegate: PublicKey | null; + state: number; + tlv: Buffer | null; + }, +): Account { + // Determine delegatedAmount for compressed TokenData: + // 1. If CompressedOnly extension present in TLV, use its delegated_amount + // 2. If delegate is set (regular compressed approve), the entire compressed + // account's amount is the delegation (change goes to a separate account) + // 3. Otherwise, 0 + let delegatedAmount = BigInt(0); + const extensionDelegatedAmount = extractDelegatedAmountFromTlv( + tokenData.tlv, + ); + if (extensionDelegatedAmount !== null) { + delegatedAmount = extensionDelegatedAmount; + } else if (tokenData.delegate) { + delegatedAmount = BigInt(tokenData.amount.toString()); + } + + return { + address, + mint: tokenData.mint, + owner: tokenData.owner, + amount: BigInt(tokenData.amount.toString()), + delegate: tokenData.delegate, + delegatedAmount, + isInitialized: tokenData.state !== AccountState.Uninitialized, + isFrozen: tokenData.state === AccountState.Frozen, + isNative: false, + rentExemptReserve: null, + closeAuthority: null, + tlvData: tokenData.tlv ? Buffer.from(tokenData.tlv) : Buffer.alloc(0), + }; +} + +/** Convert compressed account to AccountInfo */ +function toAccountInfo( + compressedAccount: CompressedAccountWithMerkleContext, +): AccountInfo { + const dataDiscriminatorBuffer: Buffer = Buffer.from( + compressedAccount.data!.discriminator, + ); + const dataBuffer: Buffer = Buffer.from(compressedAccount.data!.data); + const data: Buffer = Buffer.concat([dataDiscriminatorBuffer, dataBuffer]); + + return { + executable: false, + owner: compressedAccount.owner, + lamports: compressedAccount.lamports.toNumber(), + data, + rentEpoch: undefined, + }; +} + +/** @internal */ +export function parseLightTokenHot( + address: PublicKey, + accountInfo: AccountInfo, +): { + accountInfo: AccountInfo; + loadContext: undefined; + parsed: Account; + isCold: false; +} { + // Hot light-token accounts use SPL-compatible layout with 4-byte COption tags. + // unpackAccountSPL correctly parses all fields including delegatedAmount, + // isNative, and closeAuthority. + const parsed = unpackAccountSPL( + address, + accountInfo, + LIGHT_TOKEN_PROGRAM_ID, + ); + return { + accountInfo, + loadContext: undefined, + parsed, + isCold: false, + }; +} + +/** @internal */ +export function parseLightTokenCold( + address: PublicKey, + compressedAccount: CompressedAccountWithMerkleContext, +): { + accountInfo: AccountInfo; + loadContext: MerkleContext; + parsed: Account; + isCold: true; +} { + const parsed = parseTokenData(compressedAccount.data!.data); + if (!parsed) throw new Error('Invalid token data'); + return { + accountInfo: toAccountInfo(compressedAccount), + loadContext: { + treeInfo: compressedAccount.treeInfo, + hash: compressedAccount.hash, + leafIndex: compressedAccount.leafIndex, + proveByIndex: compressedAccount.proveByIndex, + }, + parsed: convertTokenDataToAccount(address, parsed), + isCold: true, + }; +} + +/** + * Retrieve associated token account for a given owner and mint. + * + * @param rpc RPC connection + * @param ata Associated token address + * @param owner Owner public key + * @param mint Mint public key + * @param commitment Optional commitment level + * @param programId Optional program ID + * @param wrap Include SPL/T22 balances (default: false) + * @param allowOwnerOffCurve Allow owner to be off-curve (PDA) + * @returns AccountView with associated token account metadata + */ +export async function getAtaView( + rpc: Rpc, + ata: PublicKey, + owner: PublicKey, + mint: PublicKey, + commitment?: Commitment, + programId?: PublicKey, + wrap = false, + allowOwnerOffCurve = false, +): Promise { + assertV2Enabled(); + + // Invariant: ata MUST match a valid derivation from mint+owner. + // Hot path: if programId provided, only validate against that program. + // For wrap=true, additionally require light-token associated token account. + const validation = checkAtaAddress( + ata, + mint, + owner, + programId, + allowOwnerOffCurve, + ); + + if (wrap && validation.type !== 'light-token') { + throw new Error( + `For wrap=true, ata must be the light-token ATA. Got ${validation.type} ATA instead.`, + ); + } + + // Pass both ata address AND fetchByOwner for proper lookups: + // - address is used for on-chain account fetching + // - fetchByOwner is used for light-token lookup by owner+mint + const result = await _getAccountView( + rpc, + ata, + commitment, + programId, + { + owner, + mint, + }, + wrap, + ); + result._isAta = true; + result._owner = owner; + result._mint = mint; + return result; +} + +/** + * @internal + */ +async function _getAccountView( + rpc: Rpc, + address: PublicKey | undefined, + commitment: Commitment | undefined, + programId: PublicKey | undefined, + fetchByOwner: { owner: PublicKey; mint: PublicKey } | undefined, + wrap: boolean, +): Promise { + if (!programId) { + return getUnifiedAccountView( + rpc, + address, + commitment, + fetchByOwner, + wrap, + ); + } + + if (programId.equals(LIGHT_TOKEN_PROGRAM_ID)) { + return getLightTokenAccountView( + rpc, + address, + commitment, + fetchByOwner, + ); + } + + if ( + programId.equals(TOKEN_PROGRAM_ID) || + programId.equals(TOKEN_2022_PROGRAM_ID) + ) { + return getSplOrToken2022AccountView( + rpc, + address, + commitment, + programId, + fetchByOwner, + ); + } + + throw new TokenInvalidAccountOwnerError(); +} + +/** + * @internal + */ +async function _tryFetchSpl( + rpc: Rpc, + address: PublicKey, + commitment?: Commitment, +): Promise<{ + accountInfo: AccountInfo; + parsed: Account; + isCold: false; + loadContext: undefined; +} | null> { + const info = await rpc.getAccountInfo(address, commitment); + if (!info) { + return null; + } + if (!info.owner.equals(TOKEN_PROGRAM_ID)) { + throw new TokenInvalidAccountOwnerError(); + } + const account = unpackAccountSPL(address, info, TOKEN_PROGRAM_ID); + return { + accountInfo: info, + parsed: account, + isCold: false, + loadContext: undefined, + }; +} + +/** + * @internal + */ +async function _tryFetchToken2022( + rpc: Rpc, + address: PublicKey, + commitment?: Commitment, +): Promise<{ + accountInfo: AccountInfo; + parsed: Account; + isCold: false; + loadContext: undefined; +} | null> { + const info = await rpc.getAccountInfo(address, commitment); + if (!info) { + return null; + } + if (!info.owner.equals(TOKEN_2022_PROGRAM_ID)) { + throw new TokenInvalidAccountOwnerError(); + } + const account = unpackAccountSPL(address, info, TOKEN_2022_PROGRAM_ID); + return { + accountInfo: info, + parsed: account, + isCold: false, + loadContext: undefined, + }; +} + +/** + * @internal + */ +async function _tryFetchLightTokenHot( + rpc: Rpc, + address: PublicKey, + commitment?: Commitment, +): Promise<{ + accountInfo: AccountInfo; + loadContext: undefined; + parsed: Account; + isCold: false; +} | null> { + const info = await rpc.getAccountInfo(address, commitment); + if (!info) { + return null; + } + if (!info.owner.equals(LIGHT_TOKEN_PROGRAM_ID)) { + throw new TokenInvalidAccountOwnerError(); + } + return parseLightTokenHot(address, info); +} + + +/** @internal */ +async function getUnifiedAccountView( + rpc: Rpc, + address: PublicKey | undefined, + commitment: Commitment | undefined, + fetchByOwner: { owner: PublicKey; mint: PublicKey } | undefined, + wrap: boolean, +): Promise { + // Canonical address for unified mode is always the light-token associated token account + const lightTokenAta = + address ?? + getAssociatedTokenAddressSync( + fetchByOwner!.mint, + fetchByOwner!.owner, + false, + LIGHT_TOKEN_PROGRAM_ID, + getAtaProgramId(LIGHT_TOKEN_PROGRAM_ID), + ); + + const fetchPromises: Promise<{ + accountInfo: AccountInfo; + parsed: Account; + isCold: boolean; + loadContext?: MerkleContext; + } | null>[] = []; + const fetchTypes: TokenAccountSource['type'][] = []; + const fetchAddresses: PublicKey[] = []; + + // light-token hot + fetchPromises.push(_tryFetchLightTokenHot(rpc, lightTokenAta, commitment)); + fetchTypes.push(TokenAccountSourceType.LightTokenHot); + fetchAddresses.push(lightTokenAta); + + // SPL / Token-2022 (only when wrap is enabled) + if (wrap) { + // Always derive SPL/T22 addresses from owner+mint, not from the passed + // light-token address. SPL and T22 associated token accounts are different from light-token associated token accounts. + if (!fetchByOwner) { + throw new Error( + 'fetchByOwner is required for wrap=true to derive SPL/T22 addresses', + ); + } + const splTokenAta = getAssociatedTokenAddressSync( + fetchByOwner.mint, + fetchByOwner.owner, + false, + TOKEN_PROGRAM_ID, + getAtaProgramId(TOKEN_PROGRAM_ID), + ); + const token2022Ata = getAssociatedTokenAddressSync( + fetchByOwner.mint, + fetchByOwner.owner, + false, + TOKEN_2022_PROGRAM_ID, + getAtaProgramId(TOKEN_2022_PROGRAM_ID), + ); + + fetchPromises.push(_tryFetchSpl(rpc, splTokenAta, commitment)); + fetchTypes.push(TokenAccountSourceType.Spl); + fetchAddresses.push(splTokenAta); + + fetchPromises.push(_tryFetchToken2022(rpc, token2022Ata, commitment)); + fetchTypes.push(TokenAccountSourceType.Token2022); + fetchAddresses.push(token2022Ata); + } + + // Fetch ALL cold light-token accounts (not just one) - important for V1/V2 detection + const coldAccountsPromise = fetchByOwner + ? rpc.getCompressedTokenAccountsByOwner(fetchByOwner.owner, { + mint: fetchByOwner.mint, + }) + : rpc.getCompressedTokenAccountsByOwner(address!); + + const hotResults = await Promise.allSettled(fetchPromises); + const ownerMismatchErrors: TokenInvalidAccountOwnerError[] = []; + const unexpectedErrors: unknown[] = []; + + let coldResult: Awaited | null = null; + try { + coldResult = await coldAccountsPromise; + } catch (error) { + unexpectedErrors.push(error); + } + + // collect all successful hot results + const sources: TokenAccountSource[] = []; + + for (let i = 0; i < hotResults.length; i++) { + const result = hotResults[i]; + if (result.status === 'fulfilled') { + const value = result.value; + if (!value) { + continue; + } + sources.push({ + type: fetchTypes[i], + address: fetchAddresses[i], + amount: value.parsed.amount, + accountInfo: value.accountInfo, + loadContext: value.loadContext, + parsed: value.parsed, + }); + } else if (result.reason instanceof TokenInvalidAccountOwnerError) { + ownerMismatchErrors.push(result.reason); + } else { + unexpectedErrors.push(result.reason); + } + } + + // Add ALL cold light-token accounts (handles both V1 and V2) + if (coldResult) { + for (const item of coldResult.items) { + const compressedAccount = item.compressedAccount; + if ( + compressedAccount && + compressedAccount.data && + compressedAccount.data.data.length > 0 && + compressedAccount.owner.equals(LIGHT_TOKEN_PROGRAM_ID) + ) { + const parsed = parseLightTokenCold( + lightTokenAta, + compressedAccount, + ); + sources.push({ + type: TokenAccountSourceType.LightTokenCold, + address: lightTokenAta, + amount: parsed.parsed.amount, + accountInfo: parsed.accountInfo, + loadContext: parsed.loadContext, + parsed: parsed.parsed, + }); + } + } + } + + throwIfUnexpectedRpcErrors( + 'Failed to fetch token account data from RPC', + unexpectedErrors, + ); + + // account not found + if (sources.length === 0) { + if (ownerMismatchErrors.length > 0) { + throw ownerMismatchErrors[0]; + } + throw new TokenAccountNotFoundError(); + } + + // priority order: light-token hot > light-token cold > SPL/T22 + const priority: TokenAccountSource['type'][] = [ + TokenAccountSourceType.LightTokenHot, + TokenAccountSourceType.LightTokenCold, + TokenAccountSourceType.Spl, + TokenAccountSourceType.Token2022, + ]; + + sources.sort((a, b) => { + const aIdx = priority.indexOf(a.type); + const bIdx = priority.indexOf(b.type); + return aIdx - bIdx; + }); + + return buildAccountViewFromSources(sources, lightTokenAta); +} + +/** @internal */ +async function getLightTokenAccountView( + rpc: Rpc, + address: PublicKey | undefined, + commitment: Commitment | undefined, + fetchByOwner?: { owner: PublicKey; mint: PublicKey }, +): Promise { + // Derive address if not provided + if (!address) { + if (!fetchByOwner) { + throw new Error(ERR_FETCH_BY_OWNER_REQUIRED); + } + address = getAssociatedTokenAddressSync( + fetchByOwner.mint, + fetchByOwner.owner, + false, + LIGHT_TOKEN_PROGRAM_ID, + getAtaProgramId(LIGHT_TOKEN_PROGRAM_ID), + ); + } + + const [onchainResult, compressedResult] = await Promise.allSettled([ + rpc.getAccountInfo(address, commitment), + // Fetch compressed: by owner+mint for associated token accounts, by address for non-ATAs + fetchByOwner + ? rpc.getCompressedTokenAccountsByOwner(fetchByOwner.owner, { + mint: fetchByOwner.mint, + }) + : rpc.getCompressedTokenAccountsByOwner(address), + ]); + const unexpectedErrors: unknown[] = []; + const ownerMismatchErrors: TokenInvalidAccountOwnerError[] = []; + + const onchainAccount = + onchainResult.status === 'fulfilled' ? onchainResult.value : null; + if (onchainResult.status === 'rejected') { + unexpectedErrors.push(onchainResult.reason); + } + const compressedAccounts = + compressedResult.status === 'fulfilled' + ? compressedResult.value.items.map(item => item.compressedAccount) + : []; + if (compressedResult.status === 'rejected') { + unexpectedErrors.push(compressedResult.reason); + } + + const sources: TokenAccountSource[] = []; + + // Collect light-token associated token account (hot balance) + if (onchainAccount) { + if (!onchainAccount.owner.equals(LIGHT_TOKEN_PROGRAM_ID)) { + ownerMismatchErrors.push(new TokenInvalidAccountOwnerError()); + } else { + const parsed = parseLightTokenHot(address, onchainAccount); + sources.push({ + type: TokenAccountSourceType.LightTokenHot, + address, + amount: parsed.parsed.amount, + accountInfo: onchainAccount, + parsed: parsed.parsed, + }); + } + } + + // Collect compressed light-token accounts (cold balance) + for (const compressedAccount of compressedAccounts) { + if ( + compressedAccount && + compressedAccount.data && + compressedAccount.data.data.length > 0 && + compressedAccount.owner.equals(LIGHT_TOKEN_PROGRAM_ID) + ) { + const parsed = parseLightTokenCold(address, compressedAccount); + sources.push({ + type: TokenAccountSourceType.LightTokenCold, + address, + amount: parsed.parsed.amount, + accountInfo: parsed.accountInfo, + loadContext: parsed.loadContext, + parsed: parsed.parsed, + }); + } + } + + throwIfUnexpectedRpcErrors( + 'Failed to fetch token account data from RPC', + unexpectedErrors, + ); + + if (sources.length === 0) { + if (ownerMismatchErrors.length > 0) { + throw ownerMismatchErrors[0]; + } + throw new TokenAccountNotFoundError(); + } + + // Priority: hot > cold + sources.sort((a, b) => { + if (a.type === 'light-token-hot' && b.type === 'light-token-cold') + return -1; + if (a.type === 'light-token-cold' && b.type === 'light-token-hot') + return 1; + return 0; + }); + + return buildAccountViewFromSources(sources, address); +} + +/** @internal */ +async function getSplOrToken2022AccountView( + rpc: Rpc, + address: PublicKey | undefined, + commitment: Commitment | undefined, + programId: PublicKey, + fetchByOwner?: { owner: PublicKey; mint: PublicKey }, +): Promise { + if (!address) { + if (!fetchByOwner) { + throw new Error(ERR_FETCH_BY_OWNER_REQUIRED); + } + address = getAssociatedTokenAddressSync( + fetchByOwner.mint, + fetchByOwner.owner, + false, + programId, + getAtaProgramId(programId), + ); + } + + const hotType: TokenAccountSource['type'] = programId.equals( + TOKEN_PROGRAM_ID, + ) + ? TokenAccountSourceType.Spl + : TokenAccountSourceType.Token2022; + + const coldType: TokenAccountSource['type'] = programId.equals( + TOKEN_PROGRAM_ID, + ) + ? TokenAccountSourceType.SplCold + : TokenAccountSourceType.Token2022Cold; + + // Fetch hot and cold in parallel (neither is required individually) + const [hotResult, coldResult] = await Promise.allSettled([ + rpc.getAccountInfo(address, commitment), + fetchByOwner + ? rpc.getCompressedTokenAccountsByOwner(fetchByOwner.owner, { + mint: fetchByOwner.mint, + }) + : Promise.resolve({ items: [] as any[] }), + ]); + + const sources: TokenAccountSource[] = []; + const unexpectedErrors: unknown[] = []; + const ownerMismatchErrors: TokenInvalidAccountOwnerError[] = []; + + const hotInfo = hotResult.status === 'fulfilled' ? hotResult.value : null; + if (hotResult.status === 'rejected') + unexpectedErrors.push(hotResult.reason); + const coldAccounts = + coldResult.status === 'fulfilled' + ? coldResult.value + : ({ items: [] as any[] } as const); + if (coldResult.status === 'rejected') + unexpectedErrors.push(coldResult.reason); + + // Hot SPL/T22 account (may not exist) + if (hotInfo) { + if (!hotInfo.owner.equals(programId)) { + ownerMismatchErrors.push(new TokenInvalidAccountOwnerError()); + } else { + try { + const account = unpackAccountSPL(address, hotInfo, programId); + sources.push({ + type: hotType, + address, + amount: account.amount, + accountInfo: hotInfo, + parsed: account, + }); + } catch (error) { + unexpectedErrors.push(error); + } + } + } + + // Cold (compressed) accounts + for (const item of coldAccounts.items) { + const compressedAccount = item.compressedAccount; + if ( + compressedAccount && + compressedAccount.data && + compressedAccount.data.data.length > 0 && + compressedAccount.owner.equals(LIGHT_TOKEN_PROGRAM_ID) + ) { + const parsedCold = parseLightTokenCold(address, compressedAccount); + sources.push({ + type: coldType, + address, + amount: parsedCold.parsed.amount, + accountInfo: parsedCold.accountInfo, + loadContext: parsedCold.loadContext, + parsed: parsedCold.parsed, + }); + } + } + + throwIfUnexpectedRpcErrors( + 'Failed to fetch token account data from RPC', + unexpectedErrors, + ); + + if (sources.length === 0) { + if (ownerMismatchErrors.length > 0) { + throw ownerMismatchErrors[0]; + } + throw new TokenAccountNotFoundError(); + } + + return buildAccountViewFromSources(sources, address); +} + +/** @internal */ +function buildAccountViewFromSources( + sources: TokenAccountSource[], + canonicalAddress: PublicKey, +): AccountView { + const totalAmount = sources.reduce( + (sum, src) => sum + src.amount, + BigInt(0), + ); + + const primarySource = sources[0]; + + const hasDelegate = sources.some(src => src.parsed.delegate !== null); + const anyFrozen = sources.some(src => src.parsed.isFrozen); + const hasColdSource = sources.some(src => isColdSourceType(src.type)); + const needsConsolidation = sources.length > 1; + const delegatedContribution = (src: TokenAccountSource): bigint => { + const delegated = src.parsed.delegatedAmount ?? src.amount; + return src.amount < delegated ? src.amount : delegated; + }; + + const sumForDelegate = ( + candidate: PublicKey, + scope: (src: TokenAccountSource) => boolean, + ): bigint => + sources.reduce((sum, src) => { + if (!scope(src)) return sum; + const delegate = src.parsed.delegate; + if (!delegate || !delegate.equals(candidate)) return sum; + return sum + delegatedContribution(src); + }, BigInt(0)); + + const hotDelegatedSource = sources.find( + src => !isColdSourceType(src.type) && src.parsed.delegate !== null, + ); + const coldDelegatedSources = sources.filter( + src => isColdSourceType(src.type) && src.parsed.delegate !== null, + ); + + let canonicalDelegate: PublicKey | null = null; + let canonicalDelegatedAmount = BigInt(0); + + if (hotDelegatedSource?.parsed.delegate) { + // If any hot source is delegated, it always determines canonical delegate. + // Cold delegates only contribute when they match this hot delegate. + canonicalDelegate = hotDelegatedSource.parsed.delegate; + canonicalDelegatedAmount = sumForDelegate( + canonicalDelegate, + () => true, + ); + } else if (coldDelegatedSources.length > 0) { + // No hot delegate: canonical delegate is taken from the most recent + // delegated cold source in source order (source[0] is most recent). + canonicalDelegate = coldDelegatedSources[0].parsed.delegate!; + canonicalDelegatedAmount = sumForDelegate(canonicalDelegate, src => + isColdSourceType(src.type), + ); + } + + const unifiedAccount: Account = { + ...primarySource.parsed, + address: canonicalAddress, + amount: totalAmount, + // Synthetic ATA view models post-load state; any cold source implies initialized. + isInitialized: primarySource.parsed.isInitialized || hasColdSource, + delegate: canonicalDelegate, + delegatedAmount: canonicalDelegatedAmount, + ...(anyFrozen ? { state: AccountState.Frozen, isFrozen: true } : {}), + }; + + return { + accountInfo: primarySource.accountInfo!, + parsed: unifiedAccount, + isCold: isColdSourceType(primarySource.type), + loadContext: primarySource.loadContext, + _sources: sources, + _needsConsolidation: needsConsolidation, + _hasDelegate: hasDelegate, + _anyFrozen: anyFrozen, + }; +} + +/** + * Spendable amount for a given authority (owner or delegate). + * - If authority equals the ATA owner: full parsed.amount. + * - If authority is the canonical delegate: parsed.delegatedAmount (bounded by parsed.amount). + * - Otherwise: 0. + * @internal + */ +function spendableAmountForAuthority( + iface: AccountView, + authority: PublicKey, +): bigint { + const owner = iface._owner; + if (owner && authority.equals(owner)) { + return iface.parsed.amount; + } + const delegate = iface.parsed.delegate; + if (delegate && authority.equals(delegate)) { + const delegated = iface.parsed.delegatedAmount ?? BigInt(0); + return delegated < iface.parsed.amount + ? delegated + : iface.parsed.amount; + } + return BigInt(0); +} + +/** + * Whether the given authority can sign for this ATA (owner or canonical delegate). + * @internal + */ +export function isAuthorityForAccount( + iface: AccountView, + authority: PublicKey, +): boolean { + const owner = iface._owner; + if (owner && authority.equals(owner)) return true; + const delegate = iface.parsed.delegate; + return delegate !== null && authority.equals(delegate); +} + +/** + * @internal + * Canonical authority projection for owner/delegate checks. + */ +export function filterAccountForAuthority( + iface: AccountView, + authority: PublicKey, +): AccountView { + const owner = iface._owner; + if (owner && authority.equals(owner)) { + return iface; + } + const spendable = spendableAmountForAuthority(iface, authority); + const canonicalDelegate = iface.parsed.delegate; + if ( + spendable === BigInt(0) || + canonicalDelegate === null || + !authority.equals(canonicalDelegate) + ) { + return { + ...iface, + _sources: [], + _needsConsolidation: false, + parsed: { ...iface.parsed, amount: BigInt(0) }, + }; + } + const sources = iface._sources ?? []; + const filtered = sources.filter( + src => + src.parsed.delegate !== null && + src.parsed.delegate.equals(canonicalDelegate), + ); + const primary = filtered[0]; + return { + ...iface, + ...(primary + ? { + accountInfo: primary.accountInfo!, + isCold: isColdSourceType(primary.type), + loadContext: primary.loadContext, + } + : {}), + _sources: filtered, + _needsConsolidation: filtered.length > 1, + parsed: { + ...iface.parsed, + amount: spendable, + }, + }; +} diff --git a/js/token-interface/src/read/get-mint.ts b/js/token-interface/src/read/get-mint.ts new file mode 100644 index 0000000000..e8089ac3f6 --- /dev/null +++ b/js/token-interface/src/read/get-mint.ts @@ -0,0 +1,235 @@ +import { PublicKey, Commitment } from '@solana/web3.js'; +import { Buffer } from 'buffer'; +import { + Rpc, + bn, + deriveAddressV2, + LIGHT_TOKEN_PROGRAM_ID, + getDefaultAddressTreeInfo, + MerkleContext, + assertV2Enabled, +} from '@lightprotocol/stateless.js'; +import { + Mint, + getMint as getSplMint, + TOKEN_PROGRAM_ID, + TOKEN_2022_PROGRAM_ID, + TokenAccountNotFoundError, + TokenInvalidAccountOwnerError, +} from '@solana/spl-token'; +import { + deserializeMint, + MintContext, + TokenMetadata, + MintExtension, + extractTokenMetadata, + CompressionInfo, + CompressedMint, +} from '../instructions/layout/layout-mint'; + +export interface MintInfo { + mint: Mint; + programId: PublicKey; + merkleContext?: MerkleContext; + mintContext?: MintContext; + tokenMetadata?: TokenMetadata; + extensions?: MintExtension[]; + /** Compression info for light-token mints */ + compression?: CompressionInfo; +} + +function toErrorMessage(error: unknown): string { + if (error instanceof Error) return error.message; + return String(error); +} + +/** + * Get unified mint info for SPL/T22/light-token mints. + * + * @param rpc RPC connection + * @param address The mint address + * @param commitment Optional commitment level + * @param programId Token program ID. If not provided, tries all programs to + * auto-detect. + * @returns Object with mint, optional merkleContext, mintContext, and + * tokenMetadata + */ +export async function getMint( + rpc: Rpc, + address: PublicKey, + commitment?: Commitment, + programId?: PublicKey, +): Promise { + assertV2Enabled(); + + // try all three programs in parallel + if (!programId) { + const [tokenResult, token2022Result, compressedResult] = + await Promise.allSettled([ + getMint(rpc, address, commitment, TOKEN_PROGRAM_ID), + getMint( + rpc, + address, + commitment, + TOKEN_2022_PROGRAM_ID, + ), + getMint( + rpc, + address, + commitment, + LIGHT_TOKEN_PROGRAM_ID, + ), + ]); + + if (tokenResult.status === 'fulfilled') { + return tokenResult.value; + } + if (token2022Result.status === 'fulfilled') { + return token2022Result.value; + } + if (compressedResult.status === 'fulfilled') { + return compressedResult.value; + } + + const errors = [tokenResult, token2022Result, compressedResult] + .filter( + (result): result is PromiseRejectedResult => + result.status === 'rejected', + ) + .map(result => result.reason); + + const ownerMismatch = errors.find( + error => error instanceof TokenInvalidAccountOwnerError, + ); + if (ownerMismatch) { + throw ownerMismatch; + } + + const allNotFound = + errors.length > 0 && + errors.every(error => error instanceof TokenAccountNotFoundError); + if (allNotFound) { + throw new TokenAccountNotFoundError( + `Mint not found: ${address.toString()}. ` + + `Tried TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, and LIGHT_TOKEN_PROGRAM_ID.`, + ); + } + + const unexpected = errors.find( + error => + !(error instanceof TokenAccountNotFoundError) && + !(error instanceof TokenInvalidAccountOwnerError), + ); + if (unexpected) { + throw new Error( + `Failed to fetch mint data from RPC: ${toErrorMessage(unexpected)}`, + ); + } + + throw new TokenAccountNotFoundError( + `Mint not found: ${address.toString()}. ` + + `Tried TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, and LIGHT_TOKEN_PROGRAM_ID.`, + ); + } + + if (programId.equals(LIGHT_TOKEN_PROGRAM_ID)) { + const addressTree = getDefaultAddressTreeInfo().tree; + const compressedAddress = deriveAddressV2( + address.toBytes(), + addressTree, + LIGHT_TOKEN_PROGRAM_ID, + ); + const compressedAccount = await rpc.getCompressedAccount( + bn(compressedAddress.toBytes()), + ); + + if (!compressedAccount?.data?.data) { + throw new TokenAccountNotFoundError( + `Light mint not found for ${address.toString()}`, + ); + } + if (!compressedAccount.owner.equals(LIGHT_TOKEN_PROGRAM_ID)) { + throw new TokenInvalidAccountOwnerError(); + } + + const compressedData = Buffer.from(compressedAccount.data.data); + + // After decompressMint, the compressed account contains sentinel data (just hash ~32 bytes). + // The actual mint data lives in the light mint account. + // Minimum light mint size is 82 (base) + 34 (context) + 33 (signer+bump) = 149+ bytes. + const SENTINEL_THRESHOLD = 64; + const isDecompressed = compressedData.length < SENTINEL_THRESHOLD; + + let compressedMintData: CompressedMint; + + if (isDecompressed) { + // Light mint account exists - read from light mint account + const cmintAccountInfo = await rpc.getAccountInfo( + address, + commitment, + ); + if (!cmintAccountInfo?.data) { + throw new TokenAccountNotFoundError( + `Decompressed light mint account not found on-chain for ${address.toString()}`, + ); + } + if (!cmintAccountInfo.owner.equals(LIGHT_TOKEN_PROGRAM_ID)) { + throw new TokenInvalidAccountOwnerError(); + } + compressedMintData = deserializeMint( + Buffer.from(cmintAccountInfo.data), + ); + } else { + // Mint is still compressed - use compressed account data + compressedMintData = deserializeMint(compressedData); + } + + const mint: Mint = { + address, + mintAuthority: compressedMintData.base.mintAuthority, + supply: compressedMintData.base.supply, + decimals: compressedMintData.base.decimals, + isInitialized: compressedMintData.base.isInitialized, + freezeAuthority: compressedMintData.base.freezeAuthority, + tlvData: Buffer.alloc(0), + }; + + const merkleContext: MerkleContext = { + treeInfo: compressedAccount.treeInfo, + hash: compressedAccount.hash, + leafIndex: compressedAccount.leafIndex, + proveByIndex: compressedAccount.proveByIndex, + }; + + const tokenMetadata = extractTokenMetadata( + compressedMintData.extensions, + ); + + const result: MintInfo = { + mint, + programId, + merkleContext, + mintContext: compressedMintData.mintContext, + tokenMetadata: tokenMetadata || undefined, + extensions: compressedMintData.extensions || undefined, + compression: compressedMintData.compression, + }; + + if (!result.merkleContext) { + throw new Error( + `Invalid light mint: merkleContext is required for LIGHT_TOKEN_PROGRAM_ID`, + ); + } + if (!result.mintContext) { + throw new Error( + `Invalid light mint: mintContext is required for LIGHT_TOKEN_PROGRAM_ID`, + ); + } + + return result; + } + + // Otherwise, fetch SPL/T22 mint + const mint = await getSplMint(rpc, address, commitment, programId); + return { mint, programId }; +} diff --git a/js/token-interface/src/read.ts b/js/token-interface/src/read/index.ts similarity index 50% rename from js/token-interface/src/read.ts rename to js/token-interface/src/read/index.ts index 9400e7781c..aa604a3ff5 100644 --- a/js/token-interface/src/read.ts +++ b/js/token-interface/src/read/index.ts @@ -1,12 +1,16 @@ -import { - getAssociatedTokenAddressInterface, -} from '@lightprotocol/compressed-token'; import type { PublicKey } from '@solana/web3.js'; -import { getAta as getTokenInterfaceAta } from './account'; -import type { AtaOwnerInput, GetAtaInput, TokenInterfaceAccount } from './types'; +import { getAta as getTokenInterfaceAta } from '../account'; +import type { AtaOwnerInput, GetAtaInput, TokenInterfaceAccount } from '../types'; +import { getAssociatedTokenAddress } from './associated-token-address'; + +export { getAssociatedTokenAddress } from './associated-token-address'; +export * from './ata-utils'; +export { getMint } from './get-mint'; +export type { MintInfo } from './get-mint'; +export * from './get-account'; export function getAtaAddress({ mint, owner, programId }: AtaOwnerInput): PublicKey { - return getAssociatedTokenAddressInterface(mint, owner, false, programId); + return getAssociatedTokenAddress(mint, owner, false, programId); } export async function getAta({ diff --git a/js/token-interface/src/spl-interface.ts b/js/token-interface/src/spl-interface.ts new file mode 100644 index 0000000000..5c84dbb2f1 --- /dev/null +++ b/js/token-interface/src/spl-interface.ts @@ -0,0 +1,71 @@ +import { Commitment, PublicKey } from '@solana/web3.js'; +import { unpackAccount } from '@solana/spl-token'; +import { bn, Rpc } from '@lightprotocol/stateless.js'; +import BN from 'bn.js'; +import { deriveSplPoolPdaWithIndex } from './constants'; + +export type SplPoolInfo = { + mint: PublicKey; + splPoolPda: PublicKey; + tokenProgram: PublicKey; + activity?: { + txs: number; + amountAdded: BN; + amountRemoved: BN; + }; + isInitialized: boolean; + balance: BN; + poolIndex: number; + bump: number; +}; + +export async function getSplPoolInfos( + rpc: Rpc, + mint: PublicKey, + commitment?: Commitment, +): Promise { + const addressesAndBumps = Array.from({ length: 5 }, (_, i) => + deriveSplPoolPdaWithIndex(mint, i), + ); + + const accountInfos = await rpc.getMultipleAccountsInfo( + addressesAndBumps.map(([address]) => address), + commitment, + ); + + if (accountInfos[0] === null) { + throw new Error(`SPL pool not found for mint ${mint.toBase58()}.`); + } + + const parsedInfos = addressesAndBumps.map(([address], i) => + accountInfos[i] ? unpackAccount(address, accountInfos[i], accountInfos[i].owner) : null, + ); + + const tokenProgram = accountInfos[0].owner; + + return parsedInfos.map((parsedInfo, i) => { + if (!parsedInfo) { + return { + mint, + splPoolPda: addressesAndBumps[i][0], + tokenProgram, + activity: undefined, + balance: bn(0), + isInitialized: false, + poolIndex: i, + bump: addressesAndBumps[i][1], + }; + } + + return { + mint, + splPoolPda: parsedInfo.address, + tokenProgram, + activity: undefined, + balance: bn(parsedInfo.amount.toString()), + isInitialized: true, + poolIndex: i, + bump: addressesAndBumps[i][1], + }; + }); +} diff --git a/js/token-interface/src/types.ts b/js/token-interface/src/types.ts index 5af45d30d2..535b7b30f5 100644 --- a/js/token-interface/src/types.ts +++ b/js/token-interface/src/types.ts @@ -71,18 +71,41 @@ export interface CreateRevokeInstructionsInput extends AtaOwnerInput { payer: PublicKey; } -export interface CreateFreezeInstructionsInput { +export interface CreateBurnInstructionsInput extends AtaOwnerInput { + rpc: Rpc; + payer: PublicKey; + authority: PublicKey; + amount: number | bigint; + /** When set, emits BurnChecked; otherwise Burn. */ + decimals?: number; +} + +/** Single freeze ix (hot token account address already known). */ +export interface CreateRawFreezeInstructionInput { tokenAccount: PublicKey; mint: PublicKey; freezeAuthority: PublicKey; } -export interface CreateThawInstructionsInput { +/** Single thaw ix (hot token account address already known). */ +export interface CreateRawThawInstructionInput { tokenAccount: PublicKey; mint: PublicKey; freezeAuthority: PublicKey; } +export interface CreateFreezeInstructionsInput extends AtaOwnerInput { + rpc: Rpc; + payer: PublicKey; + freezeAuthority: PublicKey; +} + +export interface CreateThawInstructionsInput extends AtaOwnerInput { + rpc: Rpc; + payer: PublicKey; + freezeAuthority: PublicKey; +} + export type CreateRawAtaInstructionInput = CreateAtaInstructionsInput; export type CreateRawLoadInstructionInput = CreateLoadInstructionsInput; @@ -96,6 +119,21 @@ export interface CreateRawTransferInstructionInput { decimals: number; } +/** Light-token CTokenBurn (hot account only). `mint` is the CMint account. */ +export interface CreateRawBurnInstructionInput { + source: PublicKey; + mint: PublicKey; + authority: PublicKey; + amount: number | bigint; + payer?: PublicKey; +} + +/** Light-token CTokenBurnChecked (hot account only). */ +export interface CreateRawBurnCheckedInstructionInput + extends CreateRawBurnInstructionInput { + decimals: number; +} + export interface CreateRawApproveInstructionInput { tokenAccount: PublicKey; delegate: PublicKey; diff --git a/js/token-interface/tests/e2e/freeze-thaw.test.ts b/js/token-interface/tests/e2e/freeze-thaw.test.ts index 7d29a533d7..ed18beedb7 100644 --- a/js/token-interface/tests/e2e/freeze-thaw.test.ts +++ b/js/token-interface/tests/e2e/freeze-thaw.test.ts @@ -4,7 +4,6 @@ import { newAccountWithLamports } from '@lightprotocol/stateless.js'; import { createAtaInstructions, createFreezeInstructions, - createLoadInstructions, createThawInstructions, getAtaAddress, } from '../../src'; @@ -39,21 +38,11 @@ describe('freeze and thaw instructions', () => { await sendInstructions( fixture.rpc, fixture.payer, - await createLoadInstructions({ + await createFreezeInstructions({ rpc: fixture.rpc, payer: fixture.payer.publicKey, owner: owner.publicKey, mint: fixture.mint, - }), - [owner], - ); - - await sendInstructions( - fixture.rpc, - fixture.payer, - await createFreezeInstructions({ - tokenAccount, - mint: fixture.mint, freezeAuthority: fixture.freezeAuthority!.publicKey, }), [fixture.freezeAuthority!], @@ -67,7 +56,9 @@ describe('freeze and thaw instructions', () => { fixture.rpc, fixture.payer, await createThawInstructions({ - tokenAccount, + rpc: fixture.rpc, + payer: fixture.payer.publicKey, + owner: owner.publicKey, mint: fixture.mint, freezeAuthority: fixture.freezeAuthority!.publicKey, }), diff --git a/js/token-interface/tests/e2e/helpers.ts b/js/token-interface/tests/e2e/helpers.ts index b1d4f0dbe0..fc6545ffcf 100644 --- a/js/token-interface/tests/e2e/helpers.ts +++ b/js/token-interface/tests/e2e/helpers.ts @@ -12,14 +12,9 @@ import { selectStateTreeInfo, sendAndConfirmTx, } from '@lightprotocol/stateless.js'; -import { - TokenPoolInfo, - createMint, - getTokenPoolInfos, - mintTo, - parseLightTokenHot, - selectTokenPoolInfo, -} from '@lightprotocol/compressed-token'; +import { createMint, mintTo } from '@lightprotocol/compressed-token'; +import { parseLightTokenHot } from '../../src/read'; +import { getSplPoolInfos } from '../../src/spl-interface'; featureFlags.version = VERSION.V2; @@ -31,7 +26,7 @@ export interface MintFixture { mint: PublicKey; mintAuthority: Keypair; stateTreeInfo: TreeInfo; - tokenPoolInfos: TokenPoolInfo[]; + tokenPoolInfos: Awaited>; freezeAuthority?: Keypair; } @@ -66,7 +61,7 @@ export async function createMintFixture( ).mint; const stateTreeInfo = selectStateTreeInfo(await rpc.getStateTreeInfos()); - const tokenPoolInfos = await getTokenPoolInfos(rpc, mint); + const tokenPoolInfos = await getSplPoolInfos(rpc, mint); return { rpc, @@ -84,6 +79,13 @@ export async function mintCompressedToOwner( owner: PublicKey, amount: bigint, ): Promise { + const selectedSplInterfaceInfo = fixture.tokenPoolInfos.find( + info => info.isInitialized, + ); + if (!selectedSplInterfaceInfo) { + throw new Error('No initialized SPL interface info found.'); + } + await mintTo( fixture.rpc, fixture.payer, @@ -92,7 +94,7 @@ export async function mintCompressedToOwner( fixture.mintAuthority, bn(amount.toString()), fixture.stateTreeInfo, - selectTokenPoolInfo(fixture.tokenPoolInfos), + selectedSplInterfaceInfo, ); } diff --git a/js/token-interface/tests/unit/raw.test.ts b/js/token-interface/tests/unit/instruction-builders.test.ts similarity index 87% rename from js/token-interface/tests/unit/raw.test.ts rename to js/token-interface/tests/unit/instruction-builders.test.ts index c9d0ef0eb4..e1545d9c5b 100644 --- a/js/token-interface/tests/unit/raw.test.ts +++ b/js/token-interface/tests/unit/instruction-builders.test.ts @@ -5,15 +5,12 @@ import { createApproveInstruction, createAtaInstruction, createFreezeInstruction, - getCreateAtaInstruction, - getLoadInstruction, - getTransferInstruction, createRevokeInstruction, createThawInstruction, createTransferCheckedInstruction, -} from '../../src/instructions/raw'; +} from '../../src/instructions'; -describe('raw instruction builders', () => { +describe('instruction builders', () => { it('creates a canonical light-token ata instruction', () => { const payer = Keypair.generate().publicKey; const owner = Keypair.generate().publicKey; @@ -88,9 +85,4 @@ describe('raw instruction builders', () => { expect(thaw.data[0]).toBe(11); }); - it('exports getX raw aliases', () => { - expect(typeof getCreateAtaInstruction).toBe('function'); - expect(typeof getLoadInstruction).toBe('function'); - expect(typeof getTransferInstruction).toBe('function'); - }); }); diff --git a/js/token-interface/tests/unit/kit.test.ts b/js/token-interface/tests/unit/kit.test.ts index 3704c88286..28af3b619b 100644 --- a/js/token-interface/tests/unit/kit.test.ts +++ b/js/token-interface/tests/unit/kit.test.ts @@ -1,11 +1,10 @@ import { describe, expect, it } from 'vitest'; import { Keypair } from '@solana/web3.js'; -import { createAtaInstruction } from '../../src/instructions/raw'; +import { createAtaInstruction } from '../../src/instructions'; import { buildTransferInstructions, createAtaInstructions, - createTransferInstructions, - getTransferInstructionPlan, + createTransferInstructionPlan, toKitInstructions, } from '../../src/kit'; @@ -35,9 +34,8 @@ describe('kit adapter', () => { expect(instructions[0]).toBeDefined(); }); - it('exports transfer aliases and plan builder', () => { + it('exports transfer builder and plan builder', () => { expect(typeof buildTransferInstructions).toBe('function'); - expect(typeof createTransferInstructions).toBe('function'); - expect(typeof getTransferInstructionPlan).toBe('function'); + expect(typeof createTransferInstructionPlan).toBe('function'); }); }); diff --git a/js/token-interface/tests/unit/public-api.test.ts b/js/token-interface/tests/unit/public-api.test.ts index 07b3c24e78..f25408939d 100644 --- a/js/token-interface/tests/unit/public-api.test.ts +++ b/js/token-interface/tests/unit/public-api.test.ts @@ -1,14 +1,13 @@ import { describe, expect, it } from 'vitest'; import { Keypair } from '@solana/web3.js'; import { LIGHT_TOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; -import { getAssociatedTokenAddressInterface } from '@lightprotocol/compressed-token'; +import { getAssociatedTokenAddress } from '../../src/read'; import { buildTransferInstructions, MultiTransactionNotSupportedError, createAtaInstructions, - createTransferInstructions, - createFreezeInstructions, - createThawInstructions, + createFreezeInstruction, + createThawInstruction, getAtaAddress, } from '../../src'; @@ -18,7 +17,7 @@ describe('public api', () => { const mint = Keypair.generate().publicKey; expect(getAtaAddress({ owner, mint }).equals( - getAssociatedTokenAddressInterface(mint, owner), + getAssociatedTokenAddress(mint, owner), )).toBe(true); }); @@ -39,26 +38,24 @@ describe('public api', () => { ); }); - it('wraps freeze and thaw as single-instruction arrays', async () => { + it('raw freeze and thaw instructions use light-token discriminators', () => { const tokenAccount = Keypair.generate().publicKey; const mint = Keypair.generate().publicKey; const freezeAuthority = Keypair.generate().publicKey; - const freezeInstructions = await createFreezeInstructions({ + const freeze = createFreezeInstruction({ tokenAccount, mint, freezeAuthority, }); - const thawInstructions = await createThawInstructions({ + const thaw = createThawInstruction({ tokenAccount, mint, freezeAuthority, }); - expect(freezeInstructions).toHaveLength(1); - expect(freezeInstructions[0].data[0]).toBe(10); - expect(thawInstructions).toHaveLength(1); - expect(thawInstructions[0].data[0]).toBe(11); + expect(freeze.data[0]).toBe(10); + expect(thaw.data[0]).toBe(11); }); it('exposes a clear single-transaction error', () => { @@ -72,8 +69,7 @@ describe('public api', () => { expect(error.message).toContain('createLoadInstructions'); }); - it('exports transfer builder alias', () => { + it('exports canonical transfer builder', () => { expect(typeof buildTransferInstructions).toBe('function'); - expect(typeof createTransferInstructions).toBe('function'); }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bd29447228..3c3a32e0cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -437,15 +437,24 @@ importers: js/token-interface: dependencies: - '@lightprotocol/compressed-token': - specifier: workspace:* - version: link:../compressed-token + '@coral-xyz/borsh': + specifier: ^0.29.0 + version: 0.29.0(@solana/web3.js@1.98.4(bufferutil@4.0.8)(typescript@5.9.3)(utf-8-validate@5.0.10)) '@lightprotocol/stateless.js': specifier: workspace:* version: link:../stateless.js + '@solana/buffer-layout': + specifier: ^4.0.1 + version: 4.0.1 + '@solana/buffer-layout-utils': + specifier: ^0.2.0 + version: 0.2.0(bufferutil@4.0.8)(typescript@5.9.3)(utf-8-validate@5.0.10) '@solana/compat': specifier: ^6.5.0 version: 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/instruction-plans': + specifier: ^6.5.0 + version: 6.5.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) '@solana/kit': specifier: ^6.5.0 version: 6.5.0(bufferutil@4.0.8)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@5.0.10) @@ -455,10 +464,19 @@ importers: '@solana/web3.js': specifier: '>=1.73.5' version: 1.98.4(bufferutil@4.0.8)(typescript@5.9.3)(utf-8-validate@5.0.10) + bn.js: + specifier: ^5.2.1 + version: 5.2.1 + buffer: + specifier: 6.0.3 + version: 6.0.3 devDependencies: '@eslint/js': specifier: 9.36.0 version: 9.36.0 + '@lightprotocol/compressed-token': + specifier: workspace:* + version: link:../compressed-token '@rollup/plugin-commonjs': specifier: ^26.0.1 version: 26.0.1(rollup@4.21.3) @@ -468,6 +486,9 @@ importers: '@rollup/plugin-typescript': specifier: ^11.1.6 version: 11.1.6(rollup@4.21.3)(tslib@2.8.1)(typescript@5.9.3) + '@types/bn.js': + specifier: ^5.1.5 + version: 5.2.0 '@types/node': specifier: ^22.5.5 version: 22.16.5 @@ -6849,6 +6870,12 @@ snapshots: bn.js: 5.2.1 buffer-layout: 1.2.2 + '@coral-xyz/borsh@0.29.0(@solana/web3.js@1.98.4(bufferutil@4.0.8)(typescript@5.9.3)(utf-8-validate@5.0.10))': + dependencies: + '@solana/web3.js': 1.98.4(bufferutil@4.0.8)(typescript@5.9.3)(utf-8-validate@5.0.10) + bn.js: 5.2.1 + buffer-layout: 1.2.2 + '@coral-xyz/borsh@0.31.1(@solana/web3.js@1.98.4(typescript@4.9.5))': dependencies: '@solana/web3.js': 1.98.4(typescript@4.9.5) From e2204e06eeeaafeb977f5dac9c3f6f2b173c183e Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Sat, 28 Mar 2026 14:05:12 +0000 Subject: [PATCH 03/24] refactor(token-interface): unify instruction APIs and SPL interface naming --- js/token-interface/src/constants.ts | 2 +- js/token-interface/src/instructions/ata.ts | 168 ++++--- js/token-interface/src/instructions/load.ts | 464 ++++++++++-------- .../src/instructions/transfer.ts | 446 ++++++++--------- js/token-interface/src/instructions/unwrap.ts | 236 +++++---- js/token-interface/src/instructions/wrap.ts | 247 +++++----- js/token-interface/src/load-options.ts | 10 +- js/token-interface/src/spl-interface.ts | 124 ++--- js/token-interface/tests/e2e/helpers.ts | 302 ++++++------ 9 files changed, 1048 insertions(+), 951 deletions(-) diff --git a/js/token-interface/src/constants.ts b/js/token-interface/src/constants.ts index d96ec34244..3a71cf8d1c 100644 --- a/js/token-interface/src/constants.ts +++ b/js/token-interface/src/constants.ts @@ -23,7 +23,7 @@ export const COMPRESSED_TOKEN_PROGRAM_ID = new PublicKey( 'cTokenmWW8bLPjZEBAUgYy3zKxQZW6VKi7bqNFEVv3m', ); -export function deriveSplPoolPdaWithIndex( +export function deriveSplInterfacePdaWithIndex( mint: PublicKey, index: number, ): [PublicKey, number] { diff --git a/js/token-interface/src/instructions/ata.ts b/js/token-interface/src/instructions/ata.ts index 982433f55a..5e1caf946f 100644 --- a/js/token-interface/src/instructions/ata.ts +++ b/js/token-interface/src/instructions/ata.ts @@ -135,7 +135,7 @@ export interface CreateAssociatedLightTokenAccountInstructionParams { feePayer: PublicKey; owner: PublicKey; mint: PublicKey; - compressibleConfig?: CompressibleConfig; + compressibleConfig?: CompressibleConfig | null; configAccount?: PublicKey; rentPayerPda?: PublicKey; } @@ -144,20 +144,23 @@ export interface CreateAssociatedLightTokenAccountInstructionParams { * Create instruction for creating an associated light-token account. * Uses the default rent sponsor PDA by default. * - * @param feePayer Fee payer public key. - * @param owner Owner of the associated token account. - * @param mint Mint address. - * @param compressibleConfig Compressible configuration (defaults to rent sponsor config). - * @param configAccount Config account (defaults to LIGHT_TOKEN_CONFIG). - * @param rentPayerPda Rent payer PDA (defaults to LIGHT_TOKEN_RENT_SPONSOR). + * @param input Associated light-token account input. + * @param input.feePayer Fee payer public key. + * @param input.owner Owner of the associated token account. + * @param input.mint Mint address. + * @param input.compressibleConfig Compressible configuration (defaults to rent sponsor config). + * @param input.configAccount Config account (defaults to LIGHT_TOKEN_CONFIG). + * @param input.rentPayerPda Rent payer PDA (defaults to LIGHT_TOKEN_RENT_SPONSOR). */ export function createAssociatedLightTokenAccountInstruction( - feePayer: PublicKey, - owner: PublicKey, - mint: PublicKey, - compressibleConfig: CompressibleConfig | null = DEFAULT_COMPRESSIBLE_CONFIG, - configAccount: PublicKey = LIGHT_TOKEN_CONFIG, - rentPayerPda: PublicKey = LIGHT_TOKEN_RENT_SPONSOR, + { + feePayer, + owner, + mint, + compressibleConfig = DEFAULT_COMPRESSIBLE_CONFIG, + configAccount = LIGHT_TOKEN_CONFIG, + rentPayerPda = LIGHT_TOKEN_RENT_SPONSOR, + }: CreateAssociatedLightTokenAccountInstructionParams, ): TransactionInstruction { const associatedTokenAccount = getAssociatedLightTokenAddress(owner, mint); @@ -215,20 +218,23 @@ export function createAssociatedLightTokenAccountInstruction( * Create idempotent instruction for creating an associated light-token account. * Uses the default rent sponsor PDA by default. * - * @param feePayer Fee payer public key. - * @param owner Owner of the associated token account. - * @param mint Mint address. - * @param compressibleConfig Compressible configuration (defaults to rent sponsor config). - * @param configAccount Config account (defaults to LIGHT_TOKEN_CONFIG). - * @param rentPayerPda Rent payer PDA (defaults to LIGHT_TOKEN_RENT_SPONSOR). + * @param input Associated light-token account input. + * @param input.feePayer Fee payer public key. + * @param input.owner Owner of the associated token account. + * @param input.mint Mint address. + * @param input.compressibleConfig Compressible configuration (defaults to rent sponsor config). + * @param input.configAccount Config account (defaults to LIGHT_TOKEN_CONFIG). + * @param input.rentPayerPda Rent payer PDA (defaults to LIGHT_TOKEN_RENT_SPONSOR). */ export function createAssociatedLightTokenAccountIdempotentInstruction( - feePayer: PublicKey, - owner: PublicKey, - mint: PublicKey, - compressibleConfig: CompressibleConfig | null = DEFAULT_COMPRESSIBLE_CONFIG, - configAccount: PublicKey = LIGHT_TOKEN_CONFIG, - rentPayerPda: PublicKey = LIGHT_TOKEN_RENT_SPONSOR, + { + feePayer, + owner, + mint, + compressibleConfig = DEFAULT_COMPRESSIBLE_CONFIG, + configAccount = LIGHT_TOKEN_CONFIG, + rentPayerPda = LIGHT_TOKEN_RENT_SPONSOR, + }: CreateAssociatedLightTokenAccountInstructionParams, ): TransactionInstruction { const associatedTokenAccount = getAssociatedLightTokenAddress(owner, mint); @@ -282,40 +288,50 @@ export interface LightTokenConfig { rentPayerPda?: PublicKey; } +export interface CreateAssociatedTokenAccountInstructionInput { + payer: PublicKey; + associatedToken: PublicKey; + owner: PublicKey; + mint: PublicKey; + programId?: PublicKey; + associatedTokenProgramId?: PublicKey; + lightTokenConfig?: LightTokenConfig; +} + /** * Create instruction for creating an associated token account (SPL, Token-2022, - * or light-token). Follows SPL Token API signature with optional light-token config at the - * end. + * or light-token). * - * @param payer Fee payer public key. - * @param associatedToken Associated token account address. - * @param owner Owner of the associated token account. - * @param mint Mint address. - * @param programId Token program ID (default: TOKEN_PROGRAM_ID). - * @param associatedTokenProgramId Associated token program ID. - * @param lightTokenConfig Optional light-token-specific configuration. + * @param input Associated token account input. + * @param input.payer Fee payer public key. + * @param input.associatedToken Associated token account address. + * @param input.owner Owner of the associated token account. + * @param input.mint Mint address. + * @param input.programId Token program ID (default: TOKEN_PROGRAM_ID). + * @param input.associatedTokenProgramId Associated token program ID. + * @param input.lightTokenConfig Optional light-token-specific configuration. */ -function createAssociatedTokenAccountInstruction( - payer: PublicKey, - associatedToken: PublicKey, - owner: PublicKey, - mint: PublicKey, - programId: PublicKey = TOKEN_PROGRAM_ID, - associatedTokenProgramId?: PublicKey, - lightTokenConfig?: LightTokenConfig, -): TransactionInstruction { +function createAssociatedTokenAccountInstruction({ + payer, + associatedToken, + owner, + mint, + programId = TOKEN_PROGRAM_ID, + associatedTokenProgramId, + lightTokenConfig, +}: CreateAssociatedTokenAccountInstructionInput): TransactionInstruction { const effectiveAssociatedTokenProgramId = associatedTokenProgramId ?? getAtaProgramId(programId); if (programId.equals(LIGHT_TOKEN_PROGRAM_ID)) { - return createAssociatedLightTokenAccountInstruction( - payer, + return createAssociatedLightTokenAccountInstruction({ + feePayer: payer, owner, mint, - lightTokenConfig?.compressibleConfig, - lightTokenConfig?.configAccount, - lightTokenConfig?.rentPayerPda, - ); + compressibleConfig: lightTokenConfig?.compressibleConfig, + configAccount: lightTokenConfig?.configAccount, + rentPayerPda: lightTokenConfig?.rentPayerPda, + }); } else { return createSplAssociatedTokenAccountInstruction( payer, @@ -330,38 +346,38 @@ function createAssociatedTokenAccountInstruction( /** * Create idempotent instruction for creating an associated token account (SPL, - * Token-2022, or light-token). Follows SPL Token API signature with optional light-token - * config at the end. + * Token-2022, or light-token). * - * @param payer Fee payer public key. - * @param associatedToken Associated token account address. - * @param owner Owner of the associated token account. - * @param mint Mint address. - * @param programId Token program ID (default: TOKEN_PROGRAM_ID). - * @param associatedTokenProgramId Associated token program ID. - * @param lightTokenConfig Optional light-token-specific configuration. + * @param input Associated token account input. + * @param input.payer Fee payer public key. + * @param input.associatedToken Associated token account address. + * @param input.owner Owner of the associated token account. + * @param input.mint Mint address. + * @param input.programId Token program ID (default: TOKEN_PROGRAM_ID). + * @param input.associatedTokenProgramId Associated token program ID. + * @param input.lightTokenConfig Optional light-token-specific configuration. */ -function createAssociatedTokenAccountIdempotentInstruction( - payer: PublicKey, - associatedToken: PublicKey, - owner: PublicKey, - mint: PublicKey, - programId: PublicKey = TOKEN_PROGRAM_ID, - associatedTokenProgramId?: PublicKey, - lightTokenConfig?: LightTokenConfig, -): TransactionInstruction { +function createAssociatedTokenAccountIdempotentInstruction({ + payer, + associatedToken, + owner, + mint, + programId = TOKEN_PROGRAM_ID, + associatedTokenProgramId, + lightTokenConfig, +}: CreateAssociatedTokenAccountInstructionInput): TransactionInstruction { const effectiveAssociatedTokenProgramId = associatedTokenProgramId ?? getAtaProgramId(programId); if (programId.equals(LIGHT_TOKEN_PROGRAM_ID)) { - return createAssociatedLightTokenAccountIdempotentInstruction( - payer, + return createAssociatedLightTokenAccountIdempotentInstruction({ + feePayer: payer, owner, mint, - lightTokenConfig?.compressibleConfig, - lightTokenConfig?.configAccount, - lightTokenConfig?.rentPayerPda, - ); + compressibleConfig: lightTokenConfig?.compressibleConfig, + configAccount: lightTokenConfig?.configAccount, + rentPayerPda: lightTokenConfig?.rentPayerPda, + }); } else { return createSplAssociatedTokenAccountIdempotentInstruction( payer, @@ -391,13 +407,13 @@ export function createAtaInstruction({ programId: targetProgramId, }); - return createAtaIdempotent( + return createAtaIdempotent({ payer, associatedToken, owner, mint, - targetProgramId, - ); + programId: targetProgramId, + }); } export async function createAtaInstructions({ diff --git a/js/token-interface/src/instructions/load.ts b/js/token-interface/src/instructions/load.ts index cdc63f66da..5d500f9d71 100644 --- a/js/token-interface/src/instructions/load.ts +++ b/js/token-interface/src/instructions/load.ts @@ -1,66 +1,66 @@ import { - Rpc, - LIGHT_TOKEN_PROGRAM_ID, - ParsedTokenAccount, - bn, - assertV2Enabled, - LightSystemProgram, - defaultStaticAccountsStruct, - ValidityProofWithContext, -} from '@lightprotocol/stateless.js'; + Rpc, + LIGHT_TOKEN_PROGRAM_ID, + ParsedTokenAccount, + bn, + assertV2Enabled, + LightSystemProgram, + defaultStaticAccountsStruct, + ValidityProofWithContext, +} from "@lightprotocol/stateless.js"; import { - ComputeBudgetProgram, - PublicKey, - TransactionInstruction, - SystemProgram, -} from '@solana/web3.js'; + ComputeBudgetProgram, + PublicKey, + TransactionInstruction, + SystemProgram, +} from "@solana/web3.js"; import { - TOKEN_PROGRAM_ID, - TOKEN_2022_PROGRAM_ID, - getAssociatedTokenAddressSync, - createAssociatedTokenAccountIdempotentInstruction, - TokenAccountNotFoundError, -} from '@solana/spl-token'; -import { Buffer } from 'buffer'; + TOKEN_PROGRAM_ID, + TOKEN_2022_PROGRAM_ID, + getAssociatedTokenAddressSync, + createAssociatedTokenAccountIdempotentInstruction, + TokenAccountNotFoundError, +} from "@solana/spl-token"; +import { Buffer } from "buffer"; import { - AccountView, - checkNotFrozen, - COLD_SOURCE_TYPES, - getAtaView as _getAtaView, - TokenAccountSource, - isAuthorityForAccount, - filterAccountForAuthority, -} from '../read/get-account'; -import { getAssociatedTokenAddress } from '../read/associated-token-address'; -import { createAtaIdempotent } from './ata'; -import { createWrapInstruction } from './wrap'; -import { getSplPoolInfos, type SplPoolInfo } from '../spl-interface'; -import { getAtaProgramId, checkAtaAddress, AtaType } from '../read/ata-utils'; -import type { LoadOptions } from '../load-options'; -import { getMint } from '../read/get-mint'; + AccountView, + checkNotFrozen, + COLD_SOURCE_TYPES, + getAtaView as _getAtaView, + TokenAccountSource, + isAuthorityForAccount, + filterAccountForAuthority, +} from "../read/get-account"; +import { getAssociatedTokenAddress } from "../read/associated-token-address"; +import { createAtaIdempotent } from "./ata"; +import { createWrapInstruction } from "./wrap"; +import { getSplInterfaces, type SplInterface } from "../spl-interface"; +import { getAtaProgramId, checkAtaAddress, AtaType } from "../read/ata-utils"; +import type { LoadOptions } from "../load-options"; +import { getMint } from "../read/get-mint"; import { - COMPRESSED_TOKEN_PROGRAM_ID, - deriveCpiAuthorityPda, - MAX_TOP_UP, - TokenDataVersion, -} from '../constants'; + COMPRESSED_TOKEN_PROGRAM_ID, + deriveCpiAuthorityPda, + MAX_TOP_UP, + TokenDataVersion, +} from "../constants"; import { - encodeTransfer2InstructionData, - type Transfer2InstructionData, - type MultiInputTokenDataWithContext, - COMPRESSION_MODE_DECOMPRESS, - type Compression, - type Transfer2ExtensionData, -} from './layout/layout-transfer2'; -import { createSingleCompressedAccountRpc, getAtaOrNull } from '../account'; -import { normalizeInstructionBatches, toLoadOptions } from '../helpers'; -import { getAtaAddress } from '../read'; + encodeTransfer2InstructionData, + type Transfer2InstructionData, + type MultiInputTokenDataWithContext, + COMPRESSION_MODE_DECOMPRESS, + type Compression, + type Transfer2ExtensionData, +} from "./layout/layout-transfer2"; +import { createSingleCompressedAccountRpc, getAtaOrNull } from "../account"; +import { normalizeInstructionBatches, toLoadOptions } from "../helpers"; +import { getAtaAddress } from "../read"; import type { - CreateLoadInstructionsInput, - TokenInterfaceAccount, - CreateTransferInstructionsInput, -} from '../types'; -import { toInstructionPlan } from './_plan'; + CreateLoadInstructionsInput, + TokenInterfaceAccount, + CreateTransferInstructionsInput, +} from "../types"; +import { toInstructionPlan } from "./_plan"; const COMPRESSED_ONLY_DISC = 31; const COMPRESSED_ONLY_SIZE = 17; // u64 + u64 + u8 @@ -253,31 +253,42 @@ function buildInputTokenData( * @internal Use createLoadAtaInstructions instead. * * Supports decompressing to both light-token accounts and SPL token accounts: - * - For light-token destinations: No splPoolInfo needed - * - For SPL destinations: Provide splPoolInfo and decimals + * - For light-token destinations: No splInterface needed + * - For SPL destinations: Provide splInterface and decimals * - * @param payer Fee payer public key - * @param inputCompressedTokenAccounts Input light-token accounts - * @param toAddress Destination token account address (light-token or SPL associated token account) - * @param amount Amount to decompress - * @param validityProof Validity proof (contains compressedProof and rootIndices) - * @param splPoolInfo Optional: SPL pool info for SPL destinations - * @param decimals Mint decimals (required for SPL destinations) - * @param maxTopUp Optional cap on rent top-up (units of 1k lamports; default no cap) - * @param authority Optional signer (owner or delegate). When omitted, owner is the signer. + * @param input Decompress instruction input. + * @param input.payer Fee payer public key. + * @param input.inputCompressedTokenAccounts Input light-token accounts. + * @param input.toAddress Destination token account address (light-token or SPL associated token account). + * @param input.amount Amount to decompress. + * @param input.validityProof Validity proof (contains compressedProof and rootIndices). + * @param input.splInterface Optional SPL pool info for SPL destinations. + * @param input.decimals Mint decimals (required for SPL destinations). + * @param input.maxTopUp Optional cap on rent top-up (units of 1k lamports; default no cap). + * @param input.authority Optional signer (owner or delegate). When omitted, owner is the signer. * @returns TransactionInstruction */ -export function createDecompressInstruction( - payer: PublicKey, - inputCompressedTokenAccounts: ParsedTokenAccount[], - toAddress: PublicKey, - amount: bigint, - validityProof: ValidityProofWithContext, - splPoolInfo: SplPoolInfo | undefined, - decimals: number, - maxTopUp?: number, - authority?: PublicKey, -): TransactionInstruction { +export function createDecompressInstruction({ + payer, + inputCompressedTokenAccounts, + toAddress, + amount, + validityProof, + splInterface, + decimals, + maxTopUp, + authority, +}: { + payer: PublicKey; + inputCompressedTokenAccounts: ParsedTokenAccount[]; + toAddress: PublicKey; + amount: bigint; + validityProof: ValidityProofWithContext; + splInterface?: SplInterface; + decimals: number; + maxTopUp?: number; + authority?: PublicKey; +}): TransactionInstruction { if (inputCompressedTokenAccounts.length === 0) { throw new Error("No input light-token accounts provided"); } @@ -347,25 +358,25 @@ export function createDecompressInstruction( let poolBump = 0; let tokenProgramIndex = 0; - if (splPoolInfo) { + if (splInterface) { // Add SPL interface PDA (token pool) poolAccountIndex = packedAccounts.length; packedAccountIndices.set( - splPoolInfo.splPoolPda.toBase58(), + splInterface.poolPda.toBase58(), poolAccountIndex, ); - packedAccounts.push(splPoolInfo.splPoolPda); + packedAccounts.push(splInterface.poolPda); // Add SPL token program tokenProgramIndex = packedAccounts.length; packedAccountIndices.set( - splPoolInfo.tokenProgram.toBase58(), + splInterface.tokenProgramId.toBase58(), tokenProgramIndex, ); - packedAccounts.push(splPoolInfo.tokenProgram); + packedAccounts.push(splInterface.tokenProgramId); - poolIndex = splPoolInfo.poolIndex; - poolBump = splPoolInfo.bump; + poolIndex = splInterface.derivationIndex; + poolBump = splInterface.bump; } // Build input token data @@ -416,9 +427,9 @@ export function createDecompressInstruction( mint: mintIndex, sourceOrRecipient: destinationIndex, authority: 0, // Not needed for decompress - poolAccountIndex: splPoolInfo ? poolAccountIndex : 0, - poolIndex: splPoolInfo ? poolIndex : 0, - bump: splPoolInfo ? poolBump : 0, + poolAccountIndex: splInterface ? poolAccountIndex : 0, + poolIndex: splInterface ? poolIndex : 0, + bump: splInterface ? poolBump : 0, decimals, }, ]; @@ -513,7 +524,7 @@ export function createDecompressInstruction( const isTreeOrQueue = i < treeSet.size + queueSet.size; const isDestination = pubkey.equals(toAddress); const isPool = - splPoolInfo !== undefined && pubkey.equals(splPoolInfo.splPoolPda); + splInterface !== undefined && pubkey.equals(splInterface.poolPda); return { pubkey, isSigner: i === signerIndex, @@ -811,7 +822,7 @@ async function _buildLoadBatches( return []; } - let splPoolInfo: SplPoolInfo | undefined; + let splInterface: SplInterface | undefined; const needsSplInfo = wrap || ataType === "spl" || @@ -820,10 +831,10 @@ async function _buildLoadBatches( t22Balance > BigInt(0); if (needsSplInfo) { try { - const splPoolInfos = - options?.splPoolInfos ?? (await getSplPoolInfos(rpc, mint)); - splPoolInfo = splPoolInfos.find( - (info: SplPoolInfo) => info.isInitialized, + const splInterfaces = + options?.splInterfaces ?? (await getSplInterfaces(rpc, mint)); + splInterface = splInterfaces.find( + (info: SplInterface) => info.isInitialized, ); } catch (e) { if (splBalance > BigInt(0) || t22Balance > BigInt(0)) { @@ -837,7 +848,7 @@ async function _buildLoadBatches( let needsAtaCreation = false; let decompressTarget: PublicKey = lightTokenAtaAddress; - let decompressSplInfo: SplPoolInfo | undefined; + let decompressSplInfo: SplInterface | undefined; let canDecompress = false; if (wrap) { @@ -848,44 +859,44 @@ async function _buildLoadBatches( if (!lightTokenHotSource) { needsAtaCreation = true; setupInstructions.push( - createAtaIdempotent( + createAtaIdempotent({ payer, - lightTokenAtaAddress, + associatedToken: lightTokenAtaAddress, owner, mint, - LIGHT_TOKEN_PROGRAM_ID, - ), + programId: LIGHT_TOKEN_PROGRAM_ID, + }), ); } - if (splBalance > BigInt(0) && splPoolInfo) { + if (splBalance > BigInt(0) && splInterface) { setupInstructions.push( - createWrapInstruction( - splAta, - lightTokenAtaAddress, + createWrapInstruction({ + source: splAta, + destination: lightTokenAtaAddress, owner, mint, - splBalance, - splPoolInfo, + amount: splBalance, + splInterface, decimals, payer, - ), + }), ); wrapCount++; } - if (t22Balance > BigInt(0) && splPoolInfo) { + if (t22Balance > BigInt(0) && splInterface) { setupInstructions.push( - createWrapInstruction( - t22Ata, - lightTokenAtaAddress, + createWrapInstruction({ + source: t22Ata, + destination: lightTokenAtaAddress, owner, mint, - t22Balance, - splPoolInfo, + amount: t22Balance, + splInterface, decimals, payer, - ), + }), ); wrapCount++; } @@ -897,18 +908,18 @@ async function _buildLoadBatches( if (!lightTokenHotSource) { needsAtaCreation = true; setupInstructions.push( - createAtaIdempotent( + createAtaIdempotent({ payer, - lightTokenAtaAddress, + associatedToken: lightTokenAtaAddress, owner, mint, - LIGHT_TOKEN_PROGRAM_ID, - ), + programId: LIGHT_TOKEN_PROGRAM_ID, + }), ); } - } else if (ataType === "spl" && splPoolInfo) { + } else if (ataType === "spl" && splInterface) { decompressTarget = splAta; - decompressSplInfo = splPoolInfo; + decompressSplInfo = splInterface; canDecompress = true; if (!splSource) { needsAtaCreation = true; @@ -922,9 +933,9 @@ async function _buildLoadBatches( ), ); } - } else if (ataType === "token2022" && splPoolInfo) { + } else if (ataType === "token2022" && splInterface) { decompressTarget = t22Ata; - decompressSplInfo = splPoolInfo; + decompressSplInfo = splInterface; canDecompress = true; if (!t22Source) { needsAtaCreation = true; @@ -1015,13 +1026,13 @@ async function _buildLoadBatches( const idempotentAtaIx = (() => { if (wrap || ataType === "light-token") { - return createAtaIdempotent( + return createAtaIdempotent({ payer, - lightTokenAtaAddress, + associatedToken: lightTokenAtaAddress, owner, mint, - LIGHT_TOKEN_PROGRAM_ID, - ); + programId: LIGHT_TOKEN_PROGRAM_ID, + }); } else if (ataType === "spl") { return createAssociatedTokenAccountIdempotentInstruction( payer, @@ -1066,17 +1077,16 @@ async function _buildLoadBatches( const authorityForDecompress = authority ?? owner; batchIxs.push( - createDecompressInstruction( + createDecompressInstruction({ payer, - chunk, - decompressTarget, - chunkAmount, - proof, - decompressSplInfo, + inputCompressedTokenAccounts: chunk, + toAddress: decompressTarget, + amount: chunkAmount, + validityProof: proof, + splInterface: decompressSplInfo, decimals, - undefined, - authorityForDecompress, - ), + authority: authorityForDecompress, + }), ); batches.push({ @@ -1090,14 +1100,33 @@ async function _buildLoadBatches( return batches; } -export async function createLoadAtaInstructions( - rpc: Rpc, - ata: PublicKey, - owner: PublicKey, - mint: PublicKey, - payer?: PublicKey, - loadOptions?: LoadOptions, -): Promise { +/** + * Build load/decompress instruction batches for a specific associated token account. + * + * @param input Load ATA instruction input. + * @param input.rpc RPC connection. + * @param input.ata Target associated token account address. + * @param input.owner Owner of the target token account. + * @param input.mint Mint address. + * @param input.payer Optional fee payer. + * @param input.loadOptions Optional load options. + * @returns Instruction batches that can require multiple transactions. + */ +export async function createLoadAtaInstructions({ + rpc, + ata, + owner, + mint, + payer, + loadOptions, +}: { + rpc: Rpc; + ata: PublicKey; + owner: PublicKey; + mint: PublicKey; + payer?: PublicKey; + loadOptions?: LoadOptions; +}): Promise { const mintInfo = await getMint(rpc, mint); return createLoadAtaInstructionsInner( rpc, @@ -1110,111 +1139,112 @@ export async function createLoadAtaInstructions( ); } -interface CreateLoadInstructionInternalInput extends CreateLoadInstructionsInput { - authority?: PublicKey; - account?: TokenInterfaceAccount | null; - wrap?: boolean; +interface CreateLoadInstructionInternalInput + extends CreateLoadInstructionsInput { + authority?: PublicKey; + account?: TokenInterfaceAccount | null; + wrap?: boolean; } export async function createLoadInstructionInternal({ - rpc, - payer, - owner, - mint, - authority, - account, - wrap = false, + rpc, + payer, + owner, + mint, + authority, + account, + wrap = false, }: CreateLoadInstructionInternalInput): Promise<{ - instructions: TransactionInstruction[]; + instructions: TransactionInstruction[]; } | null> { - const resolvedAccount = - account ?? - (await getAtaOrNull({ - rpc, - owner, - mint, - })); - const targetAta = getAtaAddress({ owner, mint }); - - const effectiveRpc = - resolvedAccount && resolvedAccount.compressedAccount - ? createSingleCompressedAccountRpc( - rpc, - owner, - mint, - resolvedAccount.compressedAccount, - ) - : rpc; - const instructions = normalizeInstructionBatches( - 'createLoadInstruction', - await createLoadAtaInstructions( - effectiveRpc, - targetAta, - owner, - mint, - payer, - toLoadOptions(owner, authority, wrap), - ), - ); + const resolvedAccount = + account ?? + (await getAtaOrNull({ + rpc, + owner, + mint, + })); + const targetAta = getAtaAddress({ owner, mint }); - if (instructions.length === 0) { - return null; - } + const effectiveRpc = + resolvedAccount && resolvedAccount.compressedAccount + ? createSingleCompressedAccountRpc( + rpc, + owner, + mint, + resolvedAccount.compressedAccount, + ) + : rpc; + const instructions = normalizeInstructionBatches( + "createLoadInstruction", + await createLoadAtaInstructions({ + rpc: effectiveRpc, + ata: targetAta, + owner, + mint, + payer, + loadOptions: toLoadOptions(owner, authority, wrap), + }), + ); - return { - instructions, - }; + if (instructions.length === 0) { + return null; + } + + return { + instructions, + }; } export async function buildLoadInstructionList( - input: CreateLoadInstructionsInput & { - authority?: CreateTransferInstructionsInput['authority']; - account?: TokenInterfaceAccount | null; - wrap?: boolean; - }, + input: CreateLoadInstructionsInput & { + authority?: CreateTransferInstructionsInput["authority"]; + account?: TokenInterfaceAccount | null; + wrap?: boolean; + }, ): Promise { - const load = await createLoadInstructionInternal(input); + const load = await createLoadInstructionInternal(input); - if (!load) { - return []; - } + if (!load) { + return []; + } - return load.instructions; + return load.instructions; } export async function createLoadInstruction({ + rpc, + payer, + owner, + mint, +}: CreateLoadInstructionsInput): Promise { + const load = await createLoadInstructionInternal({ rpc, payer, owner, mint, -}: CreateLoadInstructionsInput): Promise { - const load = await createLoadInstructionInternal({ - rpc, - payer, - owner, - mint, - }); + }); - return load?.instructions[load.instructions.length - 1] ?? null; + return load?.instructions[load.instructions.length - 1] ?? null; } export async function createLoadInstructions({ + rpc, + payer, + owner, + mint, +}: CreateLoadInstructionsInput): Promise { + return buildLoadInstructionList({ rpc, payer, owner, mint, -}: CreateLoadInstructionsInput): Promise { - return buildLoadInstructionList({ - rpc, - payer, - owner, - mint, - wrap: true, - }); + wrap: true, + }); } export async function createLoadInstructionPlan( - input: CreateLoadInstructionsInput, + input: CreateLoadInstructionsInput, ) { - return toInstructionPlan(await createLoadInstructions(input)); + return toInstructionPlan(await createLoadInstructions(input)); } diff --git a/js/token-interface/src/instructions/transfer.ts b/js/token-interface/src/instructions/transfer.ts index 74fa211004..b5a4067b86 100644 --- a/js/token-interface/src/instructions/transfer.ts +++ b/js/token-interface/src/instructions/transfer.ts @@ -1,77 +1,81 @@ -import { Buffer } from 'buffer'; -import { SystemProgram, TransactionInstruction } from '@solana/web3.js'; -import { LIGHT_TOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; -import { getSplPoolInfos } from '../spl-interface'; -import { createUnwrapInstruction } from './unwrap'; +import { Buffer } from "buffer"; +import { SystemProgram, TransactionInstruction } from "@solana/web3.js"; +import { LIGHT_TOKEN_PROGRAM_ID } from "@lightprotocol/stateless.js"; +import { getSplInterfaces } from "../spl-interface"; +import { createUnwrapInstruction } from "./unwrap"; import { - TOKEN_2022_PROGRAM_ID, - TOKEN_PROGRAM_ID, - createCloseAccountInstruction, - unpackAccount, -} from '@solana/spl-token'; -import { getMintDecimals } from '../helpers'; -import { getAtaAddress } from '../read'; + TOKEN_2022_PROGRAM_ID, + TOKEN_PROGRAM_ID, + createCloseAccountInstruction, + unpackAccount, +} from "@solana/spl-token"; +import { getMintDecimals } from "../helpers"; +import { getAtaAddress } from "../read"; import type { - CreateRawTransferInstructionInput, - CreateTransferInstructionsInput, -} from '../types'; -import { buildLoadInstructionList } from './load'; -import { toInstructionPlan } from './_plan'; -import { createAtaInstruction } from './ata'; + CreateRawTransferInstructionInput, + CreateTransferInstructionsInput, +} from "../types"; +import { buildLoadInstructionList } from "./load"; +import { toInstructionPlan } from "./_plan"; +import { createAtaInstruction } from "./ata"; const ZERO = BigInt(0); const LIGHT_TOKEN_TRANSFER_CHECKED_DISCRIMINATOR = 12; function toBigIntAmount(amount: number | bigint): bigint { - return BigInt(amount.toString()); + return BigInt(amount.toString()); } async function getDerivedAtaBalance( - rpc: CreateTransferInstructionsInput['rpc'], - owner: CreateTransferInstructionsInput['sourceOwner'], - mint: CreateTransferInstructionsInput['mint'], - programId: typeof TOKEN_PROGRAM_ID | typeof TOKEN_2022_PROGRAM_ID, + rpc: CreateTransferInstructionsInput["rpc"], + owner: CreateTransferInstructionsInput["sourceOwner"], + mint: CreateTransferInstructionsInput["mint"], + programId: typeof TOKEN_PROGRAM_ID | typeof TOKEN_2022_PROGRAM_ID, ): Promise { - const ata = getAtaAddress({ owner, mint, programId }); - const info = await rpc.getAccountInfo(ata); - if (!info || !info.owner.equals(programId)) { - return ZERO; - } + const ata = getAtaAddress({ owner, mint, programId }); + const info = await rpc.getAccountInfo(ata); + if (!info || !info.owner.equals(programId)) { + return ZERO; + } - return unpackAccount(ata, info, programId).amount; + return unpackAccount(ata, info, programId).amount; } export function createTransferCheckedInstruction({ - source, - destination, - mint, - authority, - payer, - amount, - decimals, + source, + destination, + mint, + authority, + payer, + amount, + decimals, }: CreateRawTransferInstructionInput): TransactionInstruction { - const data = Buffer.alloc(10); - data.writeUInt8(LIGHT_TOKEN_TRANSFER_CHECKED_DISCRIMINATOR, 0); - data.writeBigUInt64LE(BigInt(amount), 1); - data.writeUInt8(decimals, 9); + const data = Buffer.alloc(10); + data.writeUInt8(LIGHT_TOKEN_TRANSFER_CHECKED_DISCRIMINATOR, 0); + data.writeBigUInt64LE(BigInt(amount), 1); + data.writeUInt8(decimals, 9); - return new TransactionInstruction({ - programId: LIGHT_TOKEN_PROGRAM_ID, - keys: [ - { pubkey: source, isSigner: false, isWritable: true }, - { pubkey: mint, isSigner: false, isWritable: false }, - { pubkey: destination, isSigner: false, isWritable: true }, - { pubkey: authority, isSigner: true, isWritable: payer.equals(authority) }, - { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, - { - pubkey: payer, - isSigner: !payer.equals(authority), - isWritable: true, - }, - ], - data, - }); + return new TransactionInstruction({ + programId: LIGHT_TOKEN_PROGRAM_ID, + keys: [ + { pubkey: source, isSigner: false, isWritable: true }, + { pubkey: mint, isSigner: false, isWritable: false }, + { pubkey: destination, isSigner: false, isWritable: true }, + { + pubkey: authority, + isSigner: true, + isWritable: payer.equals(authority), + }, + { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, + { + pubkey: payer, + isSigner: !payer.equals(authority), + isWritable: true, + }, + ], + data, + }); } /** @@ -79,199 +83,197 @@ export function createTransferCheckedInstruction({ * Returns an instruction array for a single transfer flow (setup + transfer). */ export async function buildTransferInstructions({ + rpc, + payer, + mint, + sourceOwner, + authority, + recipient, + tokenProgram, + amount, +}: CreateTransferInstructionsInput): Promise { + const amountBigInt = toBigIntAmount(amount); + const senderLoadInstructions = await buildLoadInstructionList({ rpc, payer, + owner: sourceOwner, mint, - sourceOwner, authority, - recipient, - tokenProgram, - amount, -}: CreateTransferInstructionsInput): Promise { - const amountBigInt = toBigIntAmount(amount); - const senderLoadInstructions = await buildLoadInstructionList({ - rpc, - payer, - owner: sourceOwner, - mint, - authority, - wrap: true, - }); - const recipientTokenProgramId = tokenProgram ?? LIGHT_TOKEN_PROGRAM_ID; - const recipientAta = getAtaAddress({ - owner: recipient, - mint, - programId: recipientTokenProgramId, - }); - const decimals = await getMintDecimals(rpc, mint); - const [senderSplBalance, senderT22Balance] = await Promise.all([ - getDerivedAtaBalance(rpc, sourceOwner, mint, TOKEN_PROGRAM_ID), - getDerivedAtaBalance(rpc, sourceOwner, mint, TOKEN_2022_PROGRAM_ID), - ]); + wrap: true, + }); + const recipientTokenProgramId = tokenProgram ?? LIGHT_TOKEN_PROGRAM_ID; + const recipientAta = getAtaAddress({ + owner: recipient, + mint, + programId: recipientTokenProgramId, + }); + const decimals = await getMintDecimals(rpc, mint); + const [senderSplBalance, senderT22Balance] = await Promise.all([ + getDerivedAtaBalance(rpc, sourceOwner, mint, TOKEN_PROGRAM_ID), + getDerivedAtaBalance(rpc, sourceOwner, mint, TOKEN_2022_PROGRAM_ID), + ]); - const closeWrappedSourceInstructions: TransactionInstruction[] = []; - if (authority.equals(sourceOwner) && senderSplBalance > ZERO) { - closeWrappedSourceInstructions.push( - createCloseAccountInstruction( - getAtaAddress({ - owner: sourceOwner, - mint, - programId: TOKEN_PROGRAM_ID, - }), - sourceOwner, - sourceOwner, - [], - TOKEN_PROGRAM_ID, - ), - ); - } - if (authority.equals(sourceOwner) && senderT22Balance > ZERO) { - closeWrappedSourceInstructions.push( - createCloseAccountInstruction( - getAtaAddress({ - owner: sourceOwner, - mint, - programId: TOKEN_2022_PROGRAM_ID, - }), - sourceOwner, - sourceOwner, - [], - TOKEN_2022_PROGRAM_ID, - ), - ); - } + const closeWrappedSourceInstructions: TransactionInstruction[] = []; + if (authority.equals(sourceOwner) && senderSplBalance > ZERO) { + closeWrappedSourceInstructions.push( + createCloseAccountInstruction( + getAtaAddress({ + owner: sourceOwner, + mint, + programId: TOKEN_PROGRAM_ID, + }), + sourceOwner, + sourceOwner, + [], + TOKEN_PROGRAM_ID, + ), + ); + } + if (authority.equals(sourceOwner) && senderT22Balance > ZERO) { + closeWrappedSourceInstructions.push( + createCloseAccountInstruction( + getAtaAddress({ + owner: sourceOwner, + mint, + programId: TOKEN_2022_PROGRAM_ID, + }), + sourceOwner, + sourceOwner, + [], + TOKEN_2022_PROGRAM_ID, + ), + ); + } - const recipientLoadInstructions: TransactionInstruction[] = []; - const senderAta = getAtaAddress({ - owner: sourceOwner, - mint, + const recipientLoadInstructions: TransactionInstruction[] = []; + const senderAta = getAtaAddress({ + owner: sourceOwner, + mint, + }); + let transferInstruction: TransactionInstruction; + if (recipientTokenProgramId.equals(LIGHT_TOKEN_PROGRAM_ID)) { + transferInstruction = createTransferCheckedInstruction({ + source: senderAta, + destination: recipientAta, + mint, + authority, + payer, + amount: amountBigInt, + decimals, }); - let transferInstruction: TransactionInstruction; - if (recipientTokenProgramId.equals(LIGHT_TOKEN_PROGRAM_ID)) { - transferInstruction = createTransferCheckedInstruction({ - source: senderAta, - destination: recipientAta, - mint, - authority, - payer, - amount: amountBigInt, - decimals, - }); - } else { - const splPoolInfos = await getSplPoolInfos(rpc, mint); - const splPoolInfo = splPoolInfos.find( - info => - info.isInitialized && - info.tokenProgram.equals(recipientTokenProgramId), - ); - if (!splPoolInfo) { - throw new Error( - `No initialized SPL pool found for tokenProgram ${recipientTokenProgramId.toBase58()}.`, - ); - } - transferInstruction = createUnwrapInstruction( - senderAta, - recipientAta, - authority, - mint, - amountBigInt, - splPoolInfo, - decimals, - payer, - ); + } else { + const splInterfaces = await getSplInterfaces(rpc, mint); + const splInterface = splInterfaces.find( + (info) => + info.isInitialized && info.tokenProgramId.equals(recipientTokenProgramId), + ); + if (!splInterface) { + throw new Error( + `No initialized SPL pool found for tokenProgram ${recipientTokenProgramId.toBase58()}.`, + ); } + transferInstruction = createUnwrapInstruction({ + source: senderAta, + destination: recipientAta, + owner: authority, + mint, + amount: amountBigInt, + splInterface, + decimals, + payer, + }); + } - return [ - ...senderLoadInstructions, - ...closeWrappedSourceInstructions, - createAtaInstruction({ - payer, - owner: recipient, - mint, - programId: recipientTokenProgramId, - }), - ...recipientLoadInstructions, - transferInstruction, - ]; + return [ + ...senderLoadInstructions, + ...closeWrappedSourceInstructions, + createAtaInstruction({ + payer, + owner: recipient, + mint, + programId: recipientTokenProgramId, + }), + ...recipientLoadInstructions, + transferInstruction, + ]; } /** * No-wrap transfer flow builder (advanced). */ export async function buildTransferInstructionsNowrap({ + rpc, + payer, + mint, + sourceOwner, + authority, + recipient, + tokenProgram, + amount, +}: CreateTransferInstructionsInput): Promise { + const amountBigInt = toBigIntAmount(amount); + const senderLoadInstructions = await buildLoadInstructionList({ rpc, payer, + owner: sourceOwner, mint, - sourceOwner, authority, - recipient, - tokenProgram, - amount, -}: CreateTransferInstructionsInput): Promise { - const amountBigInt = toBigIntAmount(amount); - const senderLoadInstructions = await buildLoadInstructionList({ - rpc, - payer, - owner: sourceOwner, - mint, - authority, - wrap: false, - }); + wrap: false, + }); - const recipientTokenProgramId = tokenProgram ?? LIGHT_TOKEN_PROGRAM_ID; - const recipientAta = getAtaAddress({ - owner: recipient, - mint, - programId: recipientTokenProgramId, - }); - const decimals = await getMintDecimals(rpc, mint); - const senderAta = getAtaAddress({ - owner: sourceOwner, - mint, - }); + const recipientTokenProgramId = tokenProgram ?? LIGHT_TOKEN_PROGRAM_ID; + const recipientAta = getAtaAddress({ + owner: recipient, + mint, + programId: recipientTokenProgramId, + }); + const decimals = await getMintDecimals(rpc, mint); + const senderAta = getAtaAddress({ + owner: sourceOwner, + mint, + }); - let transferInstruction: TransactionInstruction; - if (recipientTokenProgramId.equals(LIGHT_TOKEN_PROGRAM_ID)) { - transferInstruction = createTransferCheckedInstruction({ - source: senderAta, - destination: recipientAta, - mint, - authority, - payer, - amount: amountBigInt, - decimals, - }); - } else { - const splPoolInfos = await getSplPoolInfos(rpc, mint); - const splPoolInfo = splPoolInfos.find( - info => - info.isInitialized && - info.tokenProgram.equals(recipientTokenProgramId), - ); - if (!splPoolInfo) { - throw new Error( - `No initialized SPL pool found for tokenProgram ${recipientTokenProgramId.toBase58()}.`, - ); - } - transferInstruction = createUnwrapInstruction( - senderAta, - recipientAta, - authority, - mint, - amountBigInt, - splPoolInfo, - decimals, - payer, - ); + let transferInstruction: TransactionInstruction; + if (recipientTokenProgramId.equals(LIGHT_TOKEN_PROGRAM_ID)) { + transferInstruction = createTransferCheckedInstruction({ + source: senderAta, + destination: recipientAta, + mint, + authority, + payer, + amount: amountBigInt, + decimals, + }); + } else { + const splInterfaces = await getSplInterfaces(rpc, mint); + const splInterface = splInterfaces.find( + (info) => + info.isInitialized && info.tokenProgramId.equals(recipientTokenProgramId), + ); + if (!splInterface) { + throw new Error( + `No initialized SPL pool found for tokenProgram ${recipientTokenProgramId.toBase58()}.`, + ); } + transferInstruction = createUnwrapInstruction({ + source: senderAta, + destination: recipientAta, + owner: authority, + mint, + amount: amountBigInt, + splInterface, + decimals, + payer, + }); + } - return [...senderLoadInstructions, transferInstruction]; + return [...senderLoadInstructions, transferInstruction]; } export async function createTransferInstructionPlan( - input: CreateTransferInstructionsInput, + input: CreateTransferInstructionsInput, ) { - return toInstructionPlan(await buildTransferInstructions(input)); + return toInstructionPlan(await buildTransferInstructions(input)); } export { buildTransferInstructions as createTransferInstructions }; diff --git a/js/token-interface/src/instructions/unwrap.ts b/js/token-interface/src/instructions/unwrap.ts index 407a962639..360b644fd8 100644 --- a/js/token-interface/src/instructions/unwrap.ts +++ b/js/token-interface/src/instructions/unwrap.ts @@ -1,120 +1,144 @@ import { - PublicKey, - TransactionInstruction, - SystemProgram, -} from '@solana/web3.js'; -import { LIGHT_TOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; + PublicKey, + TransactionInstruction, + SystemProgram, +} from "@solana/web3.js"; +import { LIGHT_TOKEN_PROGRAM_ID } from "@lightprotocol/stateless.js"; import { - COMPRESSED_TOKEN_PROGRAM_ID, - deriveCpiAuthorityPda, - MAX_TOP_UP, -} from '../constants'; -import type { SplPoolInfo } from '../spl-interface'; + COMPRESSED_TOKEN_PROGRAM_ID, + deriveCpiAuthorityPda, + MAX_TOP_UP, +} from "../constants"; +import type { SplInterface } from "../spl-interface"; import { - encodeTransfer2InstructionData, - createCompressLightToken, - createDecompressSpl, - type Transfer2InstructionData, - type Compression, -} from './layout/layout-transfer2'; + encodeTransfer2InstructionData, + createCompressLightToken, + createDecompressSpl, + type Transfer2InstructionData, + type Compression, +} from "./layout/layout-transfer2"; + +export interface CreateUnwrapInstructionInput { + source: PublicKey; + destination: PublicKey; + owner: PublicKey; + mint: PublicKey; + amount: bigint; + splInterface: SplInterface; + decimals: number; + payer?: PublicKey; + maxTopUp?: number; +} /** * Create an unwrap instruction that moves tokens from a light-token account to an * SPL/T22 account. + * + * @param input Unwrap instruction input. + * @param input.source Source light-token account. + * @param input.destination Destination SPL/T22 token account. + * @param input.owner Owner/authority of the source account (signer). + * @param input.mint Mint address. + * @param input.amount Amount to unwrap. + * @param input.splInterface SPL interface info for the decompression. + * @param input.decimals Mint decimals (required for transfer_checked). + * @param input.payer Fee payer (defaults to owner). + * @param input.maxTopUp Optional cap on rent top-up (units of 1k lamports; default no cap). + * @returns Instruction to unwrap tokens */ -export function createUnwrapInstruction( - source: PublicKey, - destination: PublicKey, - owner: PublicKey, - mint: PublicKey, - amount: bigint, - splPoolInfo: SplPoolInfo, - decimals: number, - payer: PublicKey = owner, - maxTopUp?: number, -): TransactionInstruction { - const MINT_INDEX = 0; - const OWNER_INDEX = 1; - const SOURCE_INDEX = 2; - const DESTINATION_INDEX = 3; - const POOL_INDEX = 4; - const LIGHT_TOKEN_PROGRAM_INDEX = 6; +export function createUnwrapInstruction({ + source, + destination, + owner, + mint, + amount, + splInterface, + decimals, + payer = owner, + maxTopUp, +}: CreateUnwrapInstructionInput): TransactionInstruction { + const MINT_INDEX = 0; + const OWNER_INDEX = 1; + const SOURCE_INDEX = 2; + const DESTINATION_INDEX = 3; + const POOL_INDEX = 4; + const LIGHT_TOKEN_PROGRAM_INDEX = 6; - const compressions: Compression[] = [ - createCompressLightToken( - amount, - MINT_INDEX, - SOURCE_INDEX, - OWNER_INDEX, - LIGHT_TOKEN_PROGRAM_INDEX, - ), - createDecompressSpl( - amount, - MINT_INDEX, - DESTINATION_INDEX, - POOL_INDEX, - splPoolInfo.poolIndex, - splPoolInfo.bump, - decimals, - ), - ]; + const compressions: Compression[] = [ + createCompressLightToken( + amount, + MINT_INDEX, + SOURCE_INDEX, + OWNER_INDEX, + LIGHT_TOKEN_PROGRAM_INDEX, + ), + createDecompressSpl( + amount, + MINT_INDEX, + DESTINATION_INDEX, + POOL_INDEX, + splInterface.derivationIndex, + splInterface.bump, + decimals, + ), + ]; - const instructionData: Transfer2InstructionData = { - withTransactionHash: false, - withLamportsChangeAccountMerkleTreeIndex: false, - lamportsChangeAccountMerkleTreeIndex: 0, - lamportsChangeAccountOwnerIndex: 0, - outputQueue: 0, - maxTopUp: maxTopUp ?? MAX_TOP_UP, - cpiContext: null, - compressions, - proof: null, - inTokenData: [], - outTokenData: [], - inLamports: null, - outLamports: null, - inTlv: null, - outTlv: null, - }; + const instructionData: Transfer2InstructionData = { + withTransactionHash: false, + withLamportsChangeAccountMerkleTreeIndex: false, + lamportsChangeAccountMerkleTreeIndex: 0, + lamportsChangeAccountOwnerIndex: 0, + outputQueue: 0, + maxTopUp: maxTopUp ?? MAX_TOP_UP, + cpiContext: null, + compressions, + proof: null, + inTokenData: [], + outTokenData: [], + inLamports: null, + outLamports: null, + inTlv: null, + outTlv: null, + }; - const data = encodeTransfer2InstructionData(instructionData); + const data = encodeTransfer2InstructionData(instructionData); - const keys = [ - { - pubkey: deriveCpiAuthorityPda(), - isSigner: false, - isWritable: false, - }, - { pubkey: payer, isSigner: true, isWritable: true }, - { pubkey: mint, isSigner: false, isWritable: false }, - { pubkey: owner, isSigner: true, isWritable: false }, - { pubkey: source, isSigner: false, isWritable: true }, - { pubkey: destination, isSigner: false, isWritable: true }, - { - pubkey: splPoolInfo.splPoolPda, - isSigner: false, - isWritable: true, - }, - { - pubkey: splPoolInfo.tokenProgram, - isSigner: false, - isWritable: false, - }, - { - pubkey: LIGHT_TOKEN_PROGRAM_ID, - isSigner: false, - isWritable: false, - }, - { - pubkey: SystemProgram.programId, - isSigner: false, - isWritable: false, - }, - ]; + const keys = [ + { + pubkey: deriveCpiAuthorityPda(), + isSigner: false, + isWritable: false, + }, + { pubkey: payer, isSigner: true, isWritable: true }, + { pubkey: mint, isSigner: false, isWritable: false }, + { pubkey: owner, isSigner: true, isWritable: false }, + { pubkey: source, isSigner: false, isWritable: true }, + { pubkey: destination, isSigner: false, isWritable: true }, + { + pubkey: splInterface.poolPda, + isSigner: false, + isWritable: true, + }, + { + pubkey: splInterface.tokenProgramId, + isSigner: false, + isWritable: false, + }, + { + pubkey: LIGHT_TOKEN_PROGRAM_ID, + isSigner: false, + isWritable: false, + }, + { + pubkey: SystemProgram.programId, + isSigner: false, + isWritable: false, + }, + ]; - return new TransactionInstruction({ - programId: COMPRESSED_TOKEN_PROGRAM_ID, - keys, - data, - }); + return new TransactionInstruction({ + programId: COMPRESSED_TOKEN_PROGRAM_ID, + keys, + data, + }); } diff --git a/js/token-interface/src/instructions/wrap.ts b/js/token-interface/src/instructions/wrap.ts index 4a90404693..cd37f0444d 100644 --- a/js/token-interface/src/instructions/wrap.ts +++ b/js/token-interface/src/instructions/wrap.ts @@ -1,133 +1,146 @@ import { - PublicKey, - TransactionInstruction, - SystemProgram, -} from '@solana/web3.js'; -import { LIGHT_TOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; + PublicKey, + TransactionInstruction, + SystemProgram, +} from "@solana/web3.js"; +import { LIGHT_TOKEN_PROGRAM_ID } from "@lightprotocol/stateless.js"; import { - COMPRESSED_TOKEN_PROGRAM_ID, - deriveCpiAuthorityPda, - MAX_TOP_UP, -} from '../constants'; -import type { SplPoolInfo } from '../spl-interface'; + COMPRESSED_TOKEN_PROGRAM_ID, + deriveCpiAuthorityPda, + MAX_TOP_UP, +} from "../constants"; +import type { SplInterface } from "../spl-interface"; import { - encodeTransfer2InstructionData, - createCompressSpl, - createDecompressLightToken, - type Transfer2InstructionData, - type Compression, -} from './layout/layout-transfer2'; + encodeTransfer2InstructionData, + createCompressSpl, + createDecompressLightToken, + type Transfer2InstructionData, + type Compression, +} from "./layout/layout-transfer2"; + +export interface CreateWrapInstructionInput { + source: PublicKey; + destination: PublicKey; + owner: PublicKey; + mint: PublicKey; + amount: bigint; + splInterface: SplInterface; + decimals: number; + payer?: PublicKey; + maxTopUp?: number; +} /** * Create a wrap instruction that moves tokens from an SPL/T22 account to a * light-token account. * - * @param source Source SPL/T22 token account - * @param destination Destination light-token account - * @param owner Owner of the source account (signer) - * @param mint Mint address - * @param amount Amount to wrap, - * @param splPoolInfo SPL pool info for the compression - * @param decimals Mint decimals (required for transfer_checked) - * @param payer Fee payer (defaults to owner) - * @param maxTopUp Optional cap on rent top-up (units of 1k lamports; default no cap) + * @param input Wrap instruction input. + * @param input.source Source SPL/T22 token account. + * @param input.destination Destination light-token account. + * @param input.owner Owner of the source account (signer). + * @param input.mint Mint address. + * @param input.amount Amount to wrap. + * @param input.splInterface SPL interface info for the compression. + * @param input.decimals Mint decimals (required for transfer_checked). + * @param input.payer Fee payer (defaults to owner). + * @param input.maxTopUp Optional cap on rent top-up (units of 1k lamports; default no cap). * @returns Instruction to wrap tokens */ -export function createWrapInstruction( - source: PublicKey, - destination: PublicKey, - owner: PublicKey, - mint: PublicKey, - amount: bigint, - splPoolInfo: SplPoolInfo, - decimals: number, - payer: PublicKey = owner, - maxTopUp?: number, -): TransactionInstruction { - const MINT_INDEX = 0; - const OWNER_INDEX = 1; - const SOURCE_INDEX = 2; - const DESTINATION_INDEX = 3; - const POOL_INDEX = 4; - const _SPL_TOKEN_PROGRAM_INDEX = 5; - const LIGHT_TOKEN_PROGRAM_INDEX = 6; +export function createWrapInstruction({ + source, + destination, + owner, + mint, + amount, + splInterface, + decimals, + payer = owner, + maxTopUp, +}: CreateWrapInstructionInput): TransactionInstruction { + const MINT_INDEX = 0; + const OWNER_INDEX = 1; + const SOURCE_INDEX = 2; + const DESTINATION_INDEX = 3; + const POOL_INDEX = 4; + const _SPL_TOKEN_PROGRAM_INDEX = 5; + const LIGHT_TOKEN_PROGRAM_INDEX = 6; - const compressions: Compression[] = [ - createCompressSpl( - amount, - MINT_INDEX, - SOURCE_INDEX, - OWNER_INDEX, - POOL_INDEX, - splPoolInfo.poolIndex, - splPoolInfo.bump, - decimals, - ), - createDecompressLightToken( - amount, - MINT_INDEX, - DESTINATION_INDEX, - LIGHT_TOKEN_PROGRAM_INDEX, - ), - ]; + const compressions: Compression[] = [ + createCompressSpl( + amount, + MINT_INDEX, + SOURCE_INDEX, + OWNER_INDEX, + POOL_INDEX, + splInterface.derivationIndex, + splInterface.bump, + decimals, + ), + createDecompressLightToken( + amount, + MINT_INDEX, + DESTINATION_INDEX, + LIGHT_TOKEN_PROGRAM_INDEX, + ), + ]; - const instructionData: Transfer2InstructionData = { - withTransactionHash: false, - withLamportsChangeAccountMerkleTreeIndex: false, - lamportsChangeAccountMerkleTreeIndex: 0, - lamportsChangeAccountOwnerIndex: 0, - outputQueue: 0, - maxTopUp: maxTopUp ?? MAX_TOP_UP, - cpiContext: null, - compressions, - proof: null, - inTokenData: [], - outTokenData: [], - inLamports: null, - outLamports: null, - inTlv: null, - outTlv: null, - }; + const instructionData: Transfer2InstructionData = { + withTransactionHash: false, + withLamportsChangeAccountMerkleTreeIndex: false, + lamportsChangeAccountMerkleTreeIndex: 0, + lamportsChangeAccountOwnerIndex: 0, + outputQueue: 0, + maxTopUp: maxTopUp ?? MAX_TOP_UP, + cpiContext: null, + compressions, + proof: null, + inTokenData: [], + outTokenData: [], + inLamports: null, + outLamports: null, + inTlv: null, + outTlv: null, + }; - const data = encodeTransfer2InstructionData(instructionData); + const data = encodeTransfer2InstructionData(instructionData); - const keys = [ - { - pubkey: deriveCpiAuthorityPda(), - isSigner: false, - isWritable: false, - }, - { pubkey: payer, isSigner: true, isWritable: true }, - { pubkey: mint, isSigner: false, isWritable: false }, - { pubkey: owner, isSigner: true, isWritable: false }, - { pubkey: source, isSigner: false, isWritable: true }, - { pubkey: destination, isSigner: false, isWritable: true }, - { - pubkey: splPoolInfo.splPoolPda, - isSigner: false, - isWritable: true, - }, - { - pubkey: splPoolInfo.tokenProgram, - isSigner: false, - isWritable: false, - }, - { - pubkey: LIGHT_TOKEN_PROGRAM_ID, - isSigner: false, - isWritable: false, - }, - // System program needed for top-up CPIs when destination has compressible extension - { - pubkey: SystemProgram.programId, - isSigner: false, - isWritable: false, - }, - ]; + const keys = [ + { + pubkey: deriveCpiAuthorityPda(), + isSigner: false, + isWritable: false, + }, + { pubkey: payer, isSigner: true, isWritable: true }, + { pubkey: mint, isSigner: false, isWritable: false }, + { pubkey: owner, isSigner: true, isWritable: false }, + { pubkey: source, isSigner: false, isWritable: true }, + { pubkey: destination, isSigner: false, isWritable: true }, + { + pubkey: splInterface.poolPda, + isSigner: false, + isWritable: true, + }, + { + pubkey: splInterface.tokenProgramId, + isSigner: false, + isWritable: false, + }, + { + pubkey: LIGHT_TOKEN_PROGRAM_ID, + isSigner: false, + isWritable: false, + }, + // System program needed for top-up CPIs when destination has compressible extension + { + pubkey: SystemProgram.programId, + isSigner: false, + isWritable: false, + }, + ]; - return new TransactionInstruction({ - programId: COMPRESSED_TOKEN_PROGRAM_ID, - keys, - data, - }); + return new TransactionInstruction({ + programId: COMPRESSED_TOKEN_PROGRAM_ID, + keys, + data, + }); } diff --git a/js/token-interface/src/load-options.ts b/js/token-interface/src/load-options.ts index 7a288d7e21..ff113b9a2a 100644 --- a/js/token-interface/src/load-options.ts +++ b/js/token-interface/src/load-options.ts @@ -1,8 +1,8 @@ -import type { PublicKey } from '@solana/web3.js'; -import type { SplPoolInfo } from './spl-interface'; +import type { PublicKey } from "@solana/web3.js"; +import type { SplInterface } from "./spl-interface"; export interface LoadOptions { - splPoolInfos?: SplPoolInfo[]; - wrap?: boolean; - delegatePubkey?: PublicKey; + splInterfaces?: SplInterface[]; + wrap?: boolean; + delegatePubkey?: PublicKey; } diff --git a/js/token-interface/src/spl-interface.ts b/js/token-interface/src/spl-interface.ts index 5c84dbb2f1..05d06f2f44 100644 --- a/js/token-interface/src/spl-interface.ts +++ b/js/token-interface/src/spl-interface.ts @@ -1,71 +1,73 @@ -import { Commitment, PublicKey } from '@solana/web3.js'; -import { unpackAccount } from '@solana/spl-token'; -import { bn, Rpc } from '@lightprotocol/stateless.js'; -import BN from 'bn.js'; -import { deriveSplPoolPdaWithIndex } from './constants'; +import { Commitment, PublicKey } from "@solana/web3.js"; +import { unpackAccount } from "@solana/spl-token"; +import { bn, Rpc } from "@lightprotocol/stateless.js"; +import BN from "bn.js"; +import { deriveSplInterfacePdaWithIndex } from "./constants"; -export type SplPoolInfo = { - mint: PublicKey; - splPoolPda: PublicKey; - tokenProgram: PublicKey; - activity?: { - txs: number; - amountAdded: BN; - amountRemoved: BN; - }; - isInitialized: boolean; - balance: BN; - poolIndex: number; - bump: number; +export type SplInterface = { + mint: PublicKey; + poolPda: PublicKey; + tokenProgramId: PublicKey; + activity?: { + txs: number; + amountAdded: BN; + amountRemoved: BN; + }; + isInitialized: boolean; + balance: BN; + derivationIndex: number; + bump: number; }; -export async function getSplPoolInfos( - rpc: Rpc, - mint: PublicKey, - commitment?: Commitment, -): Promise { - const addressesAndBumps = Array.from({ length: 5 }, (_, i) => - deriveSplPoolPdaWithIndex(mint, i), - ); +export async function getSplInterfaces( + rpc: Rpc, + mint: PublicKey, + commitment?: Commitment, +): Promise { + const addressesAndBumps = Array.from({ length: 5 }, (_, i) => + deriveSplInterfacePdaWithIndex(mint, i), + ); - const accountInfos = await rpc.getMultipleAccountsInfo( - addressesAndBumps.map(([address]) => address), - commitment, - ); + const accountInfos = await rpc.getMultipleAccountsInfo( + addressesAndBumps.map(([address]) => address), + commitment, + ); - if (accountInfos[0] === null) { - throw new Error(`SPL pool not found for mint ${mint.toBase58()}.`); - } + if (accountInfos[0] === null) { + throw new Error(`SPL interface not found for mint ${mint.toBase58()}.`); + } - const parsedInfos = addressesAndBumps.map(([address], i) => - accountInfos[i] ? unpackAccount(address, accountInfos[i], accountInfos[i].owner) : null, - ); + const parsedInfos = addressesAndBumps.map(([address], i) => + accountInfos[i] + ? unpackAccount(address, accountInfos[i], accountInfos[i].owner) + : null, + ); - const tokenProgram = accountInfos[0].owner; + const tokenProgramId = accountInfos[0].owner; - return parsedInfos.map((parsedInfo, i) => { - if (!parsedInfo) { - return { - mint, - splPoolPda: addressesAndBumps[i][0], - tokenProgram, - activity: undefined, - balance: bn(0), - isInitialized: false, - poolIndex: i, - bump: addressesAndBumps[i][1], - }; - } + return parsedInfos.map((parsedInfo, i) => { + if (!parsedInfo) { + return { + mint, + poolPda: addressesAndBumps[i][0], + tokenProgramId, + activity: undefined, + balance: bn(0), + isInitialized: false, + derivationIndex: i, + bump: addressesAndBumps[i][1], + }; + } - return { - mint, - splPoolPda: parsedInfo.address, - tokenProgram, - activity: undefined, - balance: bn(parsedInfo.amount.toString()), - isInitialized: true, - poolIndex: i, - bump: addressesAndBumps[i][1], - }; - }); + return { + mint, + poolPda: parsedInfo.address, + tokenProgramId, + activity: undefined, + balance: bn(parsedInfo.amount.toString()), + isInitialized: true, + derivationIndex: i, + bump: addressesAndBumps[i][1], + }; + }); } diff --git a/js/token-interface/tests/e2e/helpers.ts b/js/token-interface/tests/e2e/helpers.ts index fc6545ffcf..1eb9e64324 100644 --- a/js/token-interface/tests/e2e/helpers.ts +++ b/js/token-interface/tests/e2e/helpers.ts @@ -1,188 +1,198 @@ -import { AccountState } from '@solana/spl-token'; -import { Keypair, PublicKey, Signer, TransactionInstruction } from '@solana/web3.js'; +import { AccountState } from "@solana/spl-token"; import { - Rpc, - TreeInfo, - VERSION, - bn, - buildAndSignTx, - createRpc, - featureFlags, - newAccountWithLamports, - selectStateTreeInfo, - sendAndConfirmTx, -} from '@lightprotocol/stateless.js'; -import { createMint, mintTo } from '@lightprotocol/compressed-token'; -import { parseLightTokenHot } from '../../src/read'; -import { getSplPoolInfos } from '../../src/spl-interface'; + Keypair, + PublicKey, + Signer, + TransactionInstruction, +} from "@solana/web3.js"; +import { + Rpc, + TreeInfo, + VERSION, + bn, + buildAndSignTx, + createRpc, + featureFlags, + newAccountWithLamports, + selectStateTreeInfo, + sendAndConfirmTx, +} from "@lightprotocol/stateless.js"; +import { createMint, mintTo } from "@lightprotocol/compressed-token"; +import { parseLightTokenHot } from "../../src/read"; +import { getSplInterfaces } from "../../src/spl-interface"; featureFlags.version = VERSION.V2; export const TEST_TOKEN_DECIMALS = 9; export interface MintFixture { - rpc: Rpc; - payer: Signer; - mint: PublicKey; - mintAuthority: Keypair; - stateTreeInfo: TreeInfo; - tokenPoolInfos: Awaited>; - freezeAuthority?: Keypair; + rpc: Rpc; + payer: Signer; + mint: PublicKey; + mintAuthority: Keypair; + stateTreeInfo: TreeInfo; + tokenPoolInfos: Awaited>; + freezeAuthority?: Keypair; } -export async function createMintFixture( - options?: { - withFreezeAuthority?: boolean; - payerLamports?: number; - }, -): Promise { - const rpc = createRpc(); - const payer = await newAccountWithLamports( - rpc, - options?.payerLamports ?? 20e9, - ); - const mintAuthority = Keypair.generate(); - const mintKeypair = Keypair.generate(); - const freezeAuthority = options?.withFreezeAuthority - ? Keypair.generate() - : undefined; - - const mint = ( - await createMint( - rpc, - payer, - mintAuthority.publicKey, - TEST_TOKEN_DECIMALS, - mintKeypair, - undefined, - undefined, - freezeAuthority?.publicKey ?? null, - ) - ).mint; - - const stateTreeInfo = selectStateTreeInfo(await rpc.getStateTreeInfos()); - const tokenPoolInfos = await getSplPoolInfos(rpc, mint); - - return { - rpc, - payer, - mint, - mintAuthority, - stateTreeInfo, - tokenPoolInfos, - freezeAuthority, - }; +export async function createMintFixture(options?: { + withFreezeAuthority?: boolean; + payerLamports?: number; +}): Promise { + const rpc = createRpc(); + const payer = await newAccountWithLamports( + rpc, + options?.payerLamports ?? 20e9, + ); + const mintAuthority = Keypair.generate(); + const mintKeypair = Keypair.generate(); + const freezeAuthority = options?.withFreezeAuthority + ? Keypair.generate() + : undefined; + + const mint = ( + await createMint( + rpc, + payer, + mintAuthority.publicKey, + TEST_TOKEN_DECIMALS, + mintKeypair, + undefined, + undefined, + freezeAuthority?.publicKey ?? null, + ) + ).mint; + + const stateTreeInfo = selectStateTreeInfo(await rpc.getStateTreeInfos()); + const tokenPoolInfos = await getSplInterfaces(rpc, mint); + + return { + rpc, + payer, + mint, + mintAuthority, + stateTreeInfo, + tokenPoolInfos, + freezeAuthority, + }; } export async function mintCompressedToOwner( - fixture: MintFixture, - owner: PublicKey, - amount: bigint, + fixture: MintFixture, + owner: PublicKey, + amount: bigint, ): Promise { - const selectedSplInterfaceInfo = fixture.tokenPoolInfos.find( - info => info.isInitialized, - ); - if (!selectedSplInterfaceInfo) { - throw new Error('No initialized SPL interface info found.'); - } - - await mintTo( - fixture.rpc, - fixture.payer, - fixture.mint, - owner, - fixture.mintAuthority, - bn(amount.toString()), - fixture.stateTreeInfo, - selectedSplInterfaceInfo, - ); + const selectedSplInterfaceInfo = fixture.tokenPoolInfos.find( + (info) => info.isInitialized, + ); + if (!selectedSplInterfaceInfo) { + throw new Error("No initialized SPL interface info found."); + } + + const selectedSplInterfaceForMintTo = { + ...selectedSplInterfaceInfo, + splInterfacePda: selectedSplInterfaceInfo.poolPda, + tokenProgram: selectedSplInterfaceInfo.tokenProgramId, + poolIndex: selectedSplInterfaceInfo.derivationIndex, + }; + + await mintTo( + fixture.rpc, + fixture.payer, + fixture.mint, + owner, + fixture.mintAuthority, + bn(amount.toString()), + fixture.stateTreeInfo, + selectedSplInterfaceForMintTo, + ); } export async function mintMultipleColdAccounts( - fixture: MintFixture, - owner: PublicKey, - count: number, - amountPerAccount: bigint, + fixture: MintFixture, + owner: PublicKey, + count: number, + amountPerAccount: bigint, ): Promise { - for (let i = 0; i < count; i += 1) { - await mintCompressedToOwner(fixture, owner, amountPerAccount); - } + for (let i = 0; i < count; i += 1) { + await mintCompressedToOwner(fixture, owner, amountPerAccount); + } } export async function sendInstructions( - rpc: Rpc, - payer: Signer, - instructions: TransactionInstruction[], - additionalSigners: Signer[] = [], + rpc: Rpc, + payer: Signer, + instructions: TransactionInstruction[], + additionalSigners: Signer[] = [], ): Promise { - const { blockhash } = await rpc.getLatestBlockhash(); - const tx = buildAndSignTx(instructions, payer, blockhash, additionalSigners); - return sendAndConfirmTx(rpc, tx); + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx(instructions, payer, blockhash, additionalSigners); + return sendAndConfirmTx(rpc, tx); } export async function getHotBalance( - rpc: Rpc, - tokenAccount: PublicKey, + rpc: Rpc, + tokenAccount: PublicKey, ): Promise { - const info = await rpc.getAccountInfo(tokenAccount); - if (!info) { - return BigInt(0); - } + const info = await rpc.getAccountInfo(tokenAccount); + if (!info) { + return BigInt(0); + } - return parseLightTokenHot(tokenAccount, info).parsed.amount; + return parseLightTokenHot(tokenAccount, info).parsed.amount; } export async function getHotDelegate( - rpc: Rpc, - tokenAccount: PublicKey, + rpc: Rpc, + tokenAccount: PublicKey, ): Promise<{ delegate: PublicKey | null; delegatedAmount: bigint }> { - const info = await rpc.getAccountInfo(tokenAccount); - if (!info) { - return { delegate: null, delegatedAmount: BigInt(0) }; - } - - const { parsed } = parseLightTokenHot(tokenAccount, info); - return { - delegate: parsed.delegate, - delegatedAmount: parsed.delegatedAmount ?? BigInt(0), - }; + const info = await rpc.getAccountInfo(tokenAccount); + if (!info) { + return { delegate: null, delegatedAmount: BigInt(0) }; + } + + const { parsed } = parseLightTokenHot(tokenAccount, info); + return { + delegate: parsed.delegate, + delegatedAmount: parsed.delegatedAmount ?? BigInt(0), + }; } export async function getHotState( - rpc: Rpc, - tokenAccount: PublicKey, + rpc: Rpc, + tokenAccount: PublicKey, ): Promise { - const info = await rpc.getAccountInfo(tokenAccount); - if (!info) { - throw new Error(`Account not found: ${tokenAccount.toBase58()}`); - } - - const { parsed } = parseLightTokenHot(tokenAccount, info); - return parsed.isFrozen - ? AccountState.Frozen - : parsed.isInitialized - ? AccountState.Initialized - : AccountState.Uninitialized; + const info = await rpc.getAccountInfo(tokenAccount); + if (!info) { + throw new Error(`Account not found: ${tokenAccount.toBase58()}`); + } + + const { parsed } = parseLightTokenHot(tokenAccount, info); + return parsed.isFrozen + ? AccountState.Frozen + : parsed.isInitialized + ? AccountState.Initialized + : AccountState.Uninitialized; } export async function getCompressedAmounts( - rpc: Rpc, - owner: PublicKey, - mint: PublicKey, + rpc: Rpc, + owner: PublicKey, + mint: PublicKey, ): Promise { - const result = await rpc.getCompressedTokenAccountsByOwner(owner, { mint }); + const result = await rpc.getCompressedTokenAccountsByOwner(owner, { mint }); - return result.items - .map(account => BigInt(account.parsed.amount.toString())) - .sort((left, right) => { - if (right > left) { - return 1; - } + return result.items + .map((account) => BigInt(account.parsed.amount.toString())) + .sort((left, right) => { + if (right > left) { + return 1; + } - if (right < left) { - return -1; - } + if (right < left) { + return -1; + } - return 0; - }); + return 0; + }); } From f2e01c2a4145fc2b4b9aeeb2b741d97cfc503928 Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Sat, 28 Mar 2026 14:29:54 +0000 Subject: [PATCH 04/24] simplify load --- .../src/instructions/approve.ts | 6 +- js/token-interface/src/instructions/burn.ts | 6 +- js/token-interface/src/instructions/freeze.ts | 6 +- js/token-interface/src/instructions/load.ts | 461 ++++++------------ js/token-interface/src/instructions/revoke.ts | 6 +- js/token-interface/src/instructions/thaw.ts | 6 +- .../src/instructions/transfer.ts | 6 +- 7 files changed, 155 insertions(+), 342 deletions(-) diff --git a/js/token-interface/src/instructions/approve.ts b/js/token-interface/src/instructions/approve.ts index e507bc8160..6fd198f0d2 100644 --- a/js/token-interface/src/instructions/approve.ts +++ b/js/token-interface/src/instructions/approve.ts @@ -6,7 +6,7 @@ import type { CreateApproveInstructionsInput, CreateRawApproveInstructionInput, } from '../types'; -import { buildLoadInstructionList } from './load'; +import { createLoadInstructions } from './load'; import { toInstructionPlan } from './_plan'; const LIGHT_TOKEN_APPROVE_DISCRIMINATOR = 4; @@ -68,7 +68,7 @@ export async function createApproveInstructions({ assertAccountNotFrozen(account, 'approve'); return [ - ...(await buildLoadInstructionList({ + ...(await createLoadInstructions({ rpc, payer, owner, @@ -101,7 +101,7 @@ export async function createApproveInstructionsNowrap({ }); return [ - ...(await buildLoadInstructionList({ + ...(await createLoadInstructions({ rpc, payer, owner, diff --git a/js/token-interface/src/instructions/burn.ts b/js/token-interface/src/instructions/burn.ts index 7f038882b0..8da57202a4 100644 --- a/js/token-interface/src/instructions/burn.ts +++ b/js/token-interface/src/instructions/burn.ts @@ -7,7 +7,7 @@ import type { CreateRawBurnCheckedInstructionInput, CreateRawBurnInstructionInput, } from '../types'; -import { buildLoadInstructionList } from './load'; +import { createLoadInstructions } from './load'; import { toInstructionPlan } from './_plan'; const LIGHT_TOKEN_BURN_DISCRIMINATOR = 8; @@ -120,7 +120,7 @@ export async function createBurnInstructions({ }); return [ - ...(await buildLoadInstructionList({ + ...(await createLoadInstructions({ rpc, payer, owner, @@ -165,7 +165,7 @@ export async function createBurnInstructionsNowrap({ }); return [ - ...(await buildLoadInstructionList({ + ...(await createLoadInstructions({ rpc, payer, owner, diff --git a/js/token-interface/src/instructions/freeze.ts b/js/token-interface/src/instructions/freeze.ts index ef33ac6ae8..ed513d4d57 100644 --- a/js/token-interface/src/instructions/freeze.ts +++ b/js/token-interface/src/instructions/freeze.ts @@ -9,7 +9,7 @@ import type { CreateFreezeInstructionsInput, CreateRawFreezeInstructionInput, } from '../types'; -import { buildLoadInstructionList } from './load'; +import { createLoadInstructions } from './load'; import { toInstructionPlan } from './_plan'; const LIGHT_TOKEN_FREEZE_ACCOUNT_DISCRIMINATOR = Buffer.from([10]); @@ -42,7 +42,7 @@ export async function createFreezeInstructions({ assertAccountNotFrozen(account, 'freeze'); return [ - ...(await buildLoadInstructionList({ + ...(await createLoadInstructions({ rpc, payer, owner, @@ -70,7 +70,7 @@ export async function createFreezeInstructionsNowrap({ assertAccountNotFrozen(account, 'freeze'); return [ - ...(await buildLoadInstructionList({ + ...(await createLoadInstructions({ rpc, payer, owner, diff --git a/js/token-interface/src/instructions/load.ts b/js/token-interface/src/instructions/load.ts index 5d500f9d71..291377cabe 100644 --- a/js/token-interface/src/instructions/load.ts +++ b/js/token-interface/src/instructions/load.ts @@ -53,12 +53,11 @@ import { type Transfer2ExtensionData, } from "./layout/layout-transfer2"; import { createSingleCompressedAccountRpc, getAtaOrNull } from "../account"; -import { normalizeInstructionBatches, toLoadOptions } from "../helpers"; +import { toLoadOptions } from "../helpers"; import { getAtaAddress } from "../read"; import type { CreateLoadInstructionsInput, TokenInterfaceAccount, - CreateTransferInstructionsInput, } from "../types"; import { toInstructionPlan } from "./_plan"; @@ -250,7 +249,7 @@ function buildInputTokenData( /** * Create decompress instruction using Transfer2. * - * @internal Use createLoadAtaInstructions instead. + * @internal Use createLoadInstructions instead. * * Supports decompressing to both light-token accounts and SPL token accounts: * - For light-token destinations: No splInterface needed @@ -540,66 +539,10 @@ export function createDecompressInstruction({ }); } -const MAX_INPUT_ACCOUNTS = 8; - -function chunkArray(array: T[], chunkSize: number): T[][] { - const chunks: T[][] = []; - for (let i = 0; i < array.length; i += chunkSize) { - chunks.push(array.slice(i, i + chunkSize)); - } - return chunks; -} - -function selectInputsForAmount( - accounts: ParsedTokenAccount[], - neededAmount: bigint, -): ParsedTokenAccount[] { - if (accounts.length === 0 || neededAmount <= BigInt(0)) return []; - - const sorted = [...accounts].sort((a, b) => { - const amtA = BigInt(a.parsed.amount.toString()); - const amtB = BigInt(b.parsed.amount.toString()); - if (amtB > amtA) return 1; - if (amtB < amtA) return -1; - return 0; - }); - - let accumulated = BigInt(0); - let countNeeded = 0; - for (const acc of sorted) { - countNeeded++; - accumulated += BigInt(acc.parsed.amount.toString()); - if (accumulated >= neededAmount) break; - } - - const selectCount = Math.min( - Math.max(countNeeded, MAX_INPUT_ACCOUNTS), - sorted.length, - ); - - return sorted.slice(0, selectCount); -} - -function assertUniqueInputHashes(chunks: ParsedTokenAccount[][]): void { - const seen = new Set(); - for (const chunk of chunks) { - for (const acc of chunk) { - const hashStr = acc.compressedAccount.hash.toString(); - if (seen.has(hashStr)) { - throw new Error( - `Duplicate compressed account hash across chunks: ${hashStr}. ` + - `Each compressed account must appear in exactly one chunk.`, - ); - } - seen.add(hashStr); - } - } -} - -function getCompressedTokenAccountsFromAtaSources( +function getCanonicalCompressedTokenAccountFromAtaSources( sources: TokenAccountSource[], -): ParsedTokenAccount[] { - return sources +): ParsedTokenAccount | null { + const candidates = sources .filter((source) => source.loadContext !== undefined) .filter((source) => COLD_SOURCE_TYPES.has(source.type)) .map((source) => { @@ -648,73 +591,24 @@ function getCompressedTokenAccountsFromAtaSources( }, } satisfies ParsedTokenAccount; }); -} - -export async function createLoadAtaInstructionsInner( - rpc: Rpc, - ata: PublicKey, - owner: PublicKey, - mint: PublicKey, - decimals: number, - payer?: PublicKey, - loadOptions?: LoadOptions, -): Promise { - assertV2Enabled(); - payer ??= owner; - const wrap = loadOptions?.wrap ?? false; - const effectiveOwner = owner; - const authorityPubkey = loadOptions?.delegatePubkey ?? owner; - - let accountView: AccountView; - try { - accountView = await _getAtaView( - rpc, - ata, - effectiveOwner, - mint, - undefined, - undefined, - wrap, - ); - } catch (e) { - if (e instanceof TokenAccountNotFoundError) { - return []; - } - throw e; - } - - const isDelegate = !effectiveOwner.equals(authorityPubkey); - if (isDelegate) { - if (!isAuthorityForAccount(accountView, authorityPubkey)) { - throw new Error("Signer is not the owner or a delegate of the account."); - } - accountView = filterAccountForAuthority(accountView, authorityPubkey); + if (candidates.length === 0) { + return null; } - const internalBatches = await _buildLoadBatches( - rpc, - payer, - accountView, - loadOptions, - wrap, - ata, - undefined, - authorityPubkey, - decimals, - ); + candidates.sort((a, b) => { + const amountA = BigInt(a.parsed.amount.toString()); + const amountB = BigInt(b.parsed.amount.toString()); + if (amountB > amountA) return 1; + if (amountB < amountA) return -1; + return b.compressedAccount.leafIndex - a.compressedAccount.leafIndex; + }); - return internalBatches.map((batch) => [ - ComputeBudgetProgram.setComputeUnitLimit({ - units: calculateLoadBatchComputeUnits(batch), - }), - ...batch.instructions, - ]); + return candidates[0]; } -interface InternalLoadBatch { - instructions: TransactionInstruction[]; - compressedAccounts: ParsedTokenAccount[]; +interface LoadInstructionProfile { + compressedAccount: ParsedTokenAccount | null; wrapCount: number; hasAtaCreation: boolean; } @@ -729,32 +623,25 @@ const CU_BUFFER_FACTOR = 1.3; const CU_MIN = 50_000; const CU_MAX = 1_400_000; -function rawLoadBatchComputeUnits(batch: InternalLoadBatch): number { +function rawLoadComputeUnits(profile: LoadInstructionProfile): number { let cu = 0; - if (batch.hasAtaCreation) cu += CU_ATA_CREATION; - cu += batch.wrapCount * CU_WRAP; - if (batch.compressedAccounts.length > 0) { + if (profile.hasAtaCreation) cu += CU_ATA_CREATION; + cu += profile.wrapCount * CU_WRAP; + if (profile.compressedAccount) { cu += CU_DECOMPRESS_BASE; - const needsFullProof = batch.compressedAccounts.some( - (acc) => !(acc.compressedAccount.proveByIndex ?? false), - ); - if (needsFullProof) cu += CU_FULL_PROOF; - for (const acc of batch.compressedAccounts) { - cu += - (acc.compressedAccount.proveByIndex ?? false) - ? CU_PER_ACCOUNT_PROVE_BY_INDEX - : CU_PER_ACCOUNT_FULL_PROOF; - } + cu += (profile.compressedAccount.compressedAccount.proveByIndex ?? false) + ? CU_PER_ACCOUNT_PROVE_BY_INDEX + : CU_FULL_PROOF + CU_PER_ACCOUNT_FULL_PROOF; } return cu; } -function calculateLoadBatchComputeUnits(batch: InternalLoadBatch): number { - const cu = Math.ceil(rawLoadBatchComputeUnits(batch) * CU_BUFFER_FACTOR); +function calculateLoadComputeUnits(profile: LoadInstructionProfile): number { + const cu = Math.ceil(rawLoadComputeUnits(profile) * CU_BUFFER_FACTOR); return Math.max(CU_MIN, Math.min(CU_MAX, cu)); } -async function _buildLoadBatches( +async function _buildLoadInstructions( rpc: Rpc, payer: PublicKey, ata: AccountView, @@ -764,7 +651,10 @@ async function _buildLoadBatches( targetAmount: bigint | undefined, authority: PublicKey | undefined, decimals: number, -): Promise { +): Promise<{ + instructions: TransactionInstruction[]; + profile: LoadInstructionProfile; +}> { if (!ata._isAta || !ata._owner || !ata._mint) { throw new Error( "AccountView must be from getAtaView (requires _isAta, _owner, _mint)", @@ -777,8 +667,8 @@ async function _buildLoadBatches( const mint = ata._mint; const sources = ata._sources ?? []; - const allCompressedAccounts = - getCompressedTokenAccountsFromAtaSources(sources); + const canonicalCompressedAccount = + getCanonicalCompressedTokenAccountFromAtaSources(sources); const lightTokenAtaAddress = getAssociatedTokenAddress(mint, owner); const splAta = getAssociatedTokenAddressSync( @@ -808,18 +698,25 @@ async function _buildLoadBatches( const splSource = sources.find((s) => s.type === "spl"); const t22Source = sources.find((s) => s.type === "token2022"); const lightTokenHotSource = sources.find((s) => s.type === "light-token-hot"); - const coldSources = sources.filter((s) => COLD_SOURCE_TYPES.has(s.type)); - const splBalance = splSource?.amount ?? BigInt(0); const t22Balance = t22Source?.amount ?? BigInt(0); - const coldBalance = coldSources.reduce((sum, s) => sum + s.amount, BigInt(0)); + const coldBalance = canonicalCompressedAccount + ? BigInt(canonicalCompressedAccount.parsed.amount.toString()) + : BigInt(0); if ( splBalance === BigInt(0) && t22Balance === BigInt(0) && coldBalance === BigInt(0) ) { - return []; + return { + instructions: [], + profile: { + compressedAccount: null, + wrapCount: 0, + hasAtaCreation: false, + }, + }; } let splInterface: SplInterface | undefined; @@ -952,12 +849,12 @@ async function _buildLoadBatches( } } - let accountsToLoad = allCompressedAccounts; + let accountToLoad = canonicalCompressedAccount; if ( targetAmount !== undefined && canDecompress && - allCompressedAccounts.length > 0 + canonicalCompressedAccount ) { const isDelegate = authority !== undefined && !authority.equals(owner); const hotBalance = (() => { @@ -989,130 +886,54 @@ async function _buildLoadBatches( : BigInt(0); if (neededFromCold === BigInt(0)) { - accountsToLoad = []; - } else { - accountsToLoad = selectInputsForAmount( - allCompressedAccounts, - neededFromCold, - ); + accountToLoad = null; } } - if (!canDecompress || accountsToLoad.length === 0) { - if (setupInstructions.length === 0) return []; - return [ - { - instructions: setupInstructions, - compressedAccounts: [], + if (!canDecompress || !accountToLoad) { + return { + instructions: setupInstructions, + profile: { + compressedAccount: null, wrapCount, hasAtaCreation: needsAtaCreation, }, - ]; + }; } - const chunks = chunkArray(accountsToLoad, MAX_INPUT_ACCOUNTS); - assertUniqueInputHashes(chunks); - - const proofs = await Promise.all( - chunks.map(async (chunk) => { - const proofInputs = chunk.map((acc) => ({ - hash: acc.compressedAccount.hash, - tree: acc.compressedAccount.treeInfo.tree, - queue: acc.compressedAccount.treeInfo.queue, - })); - return rpc.getValidityProofV0(proofInputs); - }), - ); - - const idempotentAtaIx = (() => { - if (wrap || ataType === "light-token") { - return createAtaIdempotent({ - payer, - associatedToken: lightTokenAtaAddress, - owner, - mint, - programId: LIGHT_TOKEN_PROGRAM_ID, - }); - } else if (ataType === "spl") { - return createAssociatedTokenAccountIdempotentInstruction( - payer, - splAta, - owner, - mint, - TOKEN_PROGRAM_ID, - ); - } else { - return createAssociatedTokenAccountIdempotentInstruction( - payer, - t22Ata, - owner, - mint, - TOKEN_2022_PROGRAM_ID, - ); - } - })(); - - const batches: InternalLoadBatch[] = []; - - for (let i = 0; i < chunks.length; i++) { - const chunk = chunks[i]; - const proof = proofs[i]; - const chunkAmount = chunk.reduce( - (sum, acc) => sum + BigInt(acc.parsed.amount.toString()), - BigInt(0), - ); - - const batchIxs: TransactionInstruction[] = []; - let batchWrapCount = 0; - let batchHasAtaCreation = false; - - if (i === 0) { - batchIxs.push(...setupInstructions); - batchWrapCount = wrapCount; - batchHasAtaCreation = needsAtaCreation; - } else { - batchIxs.push(idempotentAtaIx); - batchHasAtaCreation = true; - } + const proof = await rpc.getValidityProofV0([ + { + hash: accountToLoad.compressedAccount.hash, + tree: accountToLoad.compressedAccount.treeInfo.tree, + queue: accountToLoad.compressedAccount.treeInfo.queue, + }, + ]); + const authorityForDecompress = authority ?? owner; + const amountToDecompress = BigInt(accountToLoad.parsed.amount.toString()); - const authorityForDecompress = authority ?? owner; - batchIxs.push( + return { + instructions: [ + ...setupInstructions, createDecompressInstruction({ payer, - inputCompressedTokenAccounts: chunk, + inputCompressedTokenAccounts: [accountToLoad], toAddress: decompressTarget, - amount: chunkAmount, + amount: amountToDecompress, validityProof: proof, splInterface: decompressSplInfo, decimals, authority: authorityForDecompress, }), - ); - - batches.push({ - instructions: batchIxs, - compressedAccounts: chunk, - wrapCount: batchWrapCount, - hasAtaCreation: batchHasAtaCreation, - }); - } - - return batches; + ], + profile: { + compressedAccount: accountToLoad, + wrapCount, + hasAtaCreation: needsAtaCreation, + }, + }; } -/** - * Build load/decompress instruction batches for a specific associated token account. - * - * @param input Load ATA instruction input. - * @param input.rpc RPC connection. - * @param input.ata Target associated token account address. - * @param input.owner Owner of the target token account. - * @param input.mint Mint address. - * @param input.payer Optional fee payer. - * @param input.loadOptions Optional load options. - * @returns Instruction batches that can require multiple transactions. - */ -export async function createLoadAtaInstructions({ +async function createLoadInstructionsForAta({ rpc, ata, owner, @@ -1126,37 +947,80 @@ export async function createLoadAtaInstructions({ mint: PublicKey; payer?: PublicKey; loadOptions?: LoadOptions; -}): Promise { +}): Promise { const mintInfo = await getMint(rpc, mint); - return createLoadAtaInstructionsInner( + const decimals = mintInfo.mint.decimals; + + assertV2Enabled(); + payer ??= owner; + const wrap = loadOptions?.wrap ?? false; + const authorityPubkey = loadOptions?.delegatePubkey ?? owner; + + let accountView: AccountView; + try { + accountView = await _getAtaView( + rpc, + ata, + owner, + mint, + undefined, + undefined, + wrap, + ); + } catch (e) { + if (e instanceof TokenAccountNotFoundError) { + return []; + } + throw e; + } + + if (!owner.equals(authorityPubkey)) { + if (!isAuthorityForAccount(accountView, authorityPubkey)) { + throw new Error("Signer is not the owner or a delegate of the account."); + } + accountView = filterAccountForAuthority(accountView, authorityPubkey); + } + + const { instructions, profile } = await _buildLoadInstructions( rpc, - ata, - owner, - mint, - mintInfo.mint.decimals, payer, + accountView, loadOptions, + wrap, + ata, + undefined, + authorityPubkey, + decimals, ); + + if (instructions.length === 0) { + return []; + } + + return [ + ComputeBudgetProgram.setComputeUnitLimit({ + units: calculateLoadComputeUnits(profile), + }), + ...instructions, + ]; } -interface CreateLoadInstructionInternalInput +export interface CreateLoadInstructionOptions extends CreateLoadInstructionsInput { authority?: PublicKey; account?: TokenInterfaceAccount | null; wrap?: boolean; } -export async function createLoadInstructionInternal({ +export async function createLoadInstructions({ rpc, payer, owner, mint, authority, account, - wrap = false, -}: CreateLoadInstructionInternalInput): Promise<{ - instructions: TransactionInstruction[]; -} | null> { + wrap = true, +}: CreateLoadInstructionOptions): Promise { const resolvedAccount = account ?? (await getAtaOrNull({ @@ -1175,72 +1039,21 @@ export async function createLoadInstructionInternal({ resolvedAccount.compressedAccount, ) : rpc; - const instructions = normalizeInstructionBatches( - "createLoadInstruction", - await createLoadAtaInstructions({ + const instructions = ( + await createLoadInstructionsForAta({ rpc: effectiveRpc, ata: targetAta, owner, mint, payer, loadOptions: toLoadOptions(owner, authority, wrap), - }), + }) + ).filter( + (instruction) => + !instruction.programId.equals(ComputeBudgetProgram.programId), ); - if (instructions.length === 0) { - return null; - } - - return { - instructions, - }; -} - -export async function buildLoadInstructionList( - input: CreateLoadInstructionsInput & { - authority?: CreateTransferInstructionsInput["authority"]; - account?: TokenInterfaceAccount | null; - wrap?: boolean; - }, -): Promise { - const load = await createLoadInstructionInternal(input); - - if (!load) { - return []; - } - - return load.instructions; -} - -export async function createLoadInstruction({ - rpc, - payer, - owner, - mint, -}: CreateLoadInstructionsInput): Promise { - const load = await createLoadInstructionInternal({ - rpc, - payer, - owner, - mint, - }); - - return load?.instructions[load.instructions.length - 1] ?? null; -} - -export async function createLoadInstructions({ - rpc, - payer, - owner, - mint, -}: CreateLoadInstructionsInput): Promise { - return buildLoadInstructionList({ - rpc, - payer, - owner, - mint, - wrap: true, - }); + return instructions; } export async function createLoadInstructionPlan( diff --git a/js/token-interface/src/instructions/revoke.ts b/js/token-interface/src/instructions/revoke.ts index 198ba7f3ac..91e7be6e95 100644 --- a/js/token-interface/src/instructions/revoke.ts +++ b/js/token-interface/src/instructions/revoke.ts @@ -6,7 +6,7 @@ import type { CreateRawRevokeInstructionInput, CreateRevokeInstructionsInput, } from '../types'; -import { buildLoadInstructionList } from './load'; +import { createLoadInstructions } from './load'; import { toInstructionPlan } from './_plan'; const LIGHT_TOKEN_REVOKE_DISCRIMINATOR = 5; @@ -55,7 +55,7 @@ export async function createRevokeInstructions({ assertAccountNotFrozen(account, 'revoke'); return [ - ...(await buildLoadInstructionList({ + ...(await createLoadInstructions({ rpc, payer, owner, @@ -84,7 +84,7 @@ export async function createRevokeInstructionsNowrap({ }); return [ - ...(await buildLoadInstructionList({ + ...(await createLoadInstructions({ rpc, payer, owner, diff --git a/js/token-interface/src/instructions/thaw.ts b/js/token-interface/src/instructions/thaw.ts index ae90ba367f..fabb9b27fe 100644 --- a/js/token-interface/src/instructions/thaw.ts +++ b/js/token-interface/src/instructions/thaw.ts @@ -9,7 +9,7 @@ import type { CreateRawThawInstructionInput, CreateThawInstructionsInput, } from '../types'; -import { buildLoadInstructionList } from './load'; +import { createLoadInstructions } from './load'; import { toInstructionPlan } from './_plan'; const LIGHT_TOKEN_THAW_ACCOUNT_DISCRIMINATOR = Buffer.from([11]); @@ -42,7 +42,7 @@ export async function createThawInstructions({ assertAccountFrozen(account, 'thaw'); return [ - ...(await buildLoadInstructionList({ + ...(await createLoadInstructions({ rpc, payer, owner, @@ -70,7 +70,7 @@ export async function createThawInstructionsNowrap({ assertAccountFrozen(account, 'thaw'); return [ - ...(await buildLoadInstructionList({ + ...(await createLoadInstructions({ rpc, payer, owner, diff --git a/js/token-interface/src/instructions/transfer.ts b/js/token-interface/src/instructions/transfer.ts index b5a4067b86..bb9e15c22d 100644 --- a/js/token-interface/src/instructions/transfer.ts +++ b/js/token-interface/src/instructions/transfer.ts @@ -15,7 +15,7 @@ import type { CreateRawTransferInstructionInput, CreateTransferInstructionsInput, } from "../types"; -import { buildLoadInstructionList } from "./load"; +import { createLoadInstructions } from "./load"; import { toInstructionPlan } from "./_plan"; import { createAtaInstruction } from "./ata"; @@ -93,7 +93,7 @@ export async function buildTransferInstructions({ amount, }: CreateTransferInstructionsInput): Promise { const amountBigInt = toBigIntAmount(amount); - const senderLoadInstructions = await buildLoadInstructionList({ + const senderLoadInstructions = await createLoadInstructions({ rpc, payer, owner: sourceOwner, @@ -212,7 +212,7 @@ export async function buildTransferInstructionsNowrap({ amount, }: CreateTransferInstructionsInput): Promise { const amountBigInt = toBigIntAmount(amount); - const senderLoadInstructions = await buildLoadInstructionList({ + const senderLoadInstructions = await createLoadInstructions({ rpc, payer, owner: sourceOwner, From b6ce011af8228b50c91849b92a07a9fc5ac54f16 Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Sat, 28 Mar 2026 14:41:15 +0000 Subject: [PATCH 05/24] reduce async --- js/token-interface/src/account.ts | 29 --- .../src/instructions/approve.ts | 2 - js/token-interface/src/instructions/burn.ts | 2 - js/token-interface/src/instructions/freeze.ts | 2 - js/token-interface/src/instructions/load.ts | 192 ++++-------------- js/token-interface/src/instructions/revoke.ts | 2 - js/token-interface/src/instructions/thaw.ts | 2 - 7 files changed, 35 insertions(+), 196 deletions(-) diff --git a/js/token-interface/src/account.ts b/js/token-interface/src/account.ts index ec2decd3be..2a49f5af46 100644 --- a/js/token-interface/src/account.ts +++ b/js/token-interface/src/account.ts @@ -221,32 +221,3 @@ export function assertAccountFrozen( } } -export function createSingleCompressedAccountRpc( - rpc: Rpc, - owner: PublicKey, - mint: PublicKey, - selected: ParsedTokenAccount, -): Rpc { - const filteredRpc = Object.create(rpc) as Rpc; - - filteredRpc.getCompressedTokenAccountsByOwner = async ( - queryOwner, - options, - ) => { - const result = await rpc.getCompressedTokenAccountsByOwner( - queryOwner, - options, - ); - - if (queryOwner.equals(owner) && options?.mint?.equals(mint)) { - return { - ...result, - items: [selected], - }; - } - - return result; - }; - - return filteredRpc; -} diff --git a/js/token-interface/src/instructions/approve.ts b/js/token-interface/src/instructions/approve.ts index 6fd198f0d2..73de28aa77 100644 --- a/js/token-interface/src/instructions/approve.ts +++ b/js/token-interface/src/instructions/approve.ts @@ -73,7 +73,6 @@ export async function createApproveInstructions({ payer, owner, mint, - account, wrap: true, })), createApproveInstruction({ @@ -106,7 +105,6 @@ export async function createApproveInstructionsNowrap({ payer, owner, mint, - account, wrap: false, })), createApproveInstruction({ diff --git a/js/token-interface/src/instructions/burn.ts b/js/token-interface/src/instructions/burn.ts index 8da57202a4..d4175f3627 100644 --- a/js/token-interface/src/instructions/burn.ts +++ b/js/token-interface/src/instructions/burn.ts @@ -125,7 +125,6 @@ export async function createBurnInstructions({ payer, owner, mint, - account, wrap: true, })), burnIx, @@ -170,7 +169,6 @@ export async function createBurnInstructionsNowrap({ payer, owner, mint, - account, wrap: false, })), burnIx, diff --git a/js/token-interface/src/instructions/freeze.ts b/js/token-interface/src/instructions/freeze.ts index ed513d4d57..30dd1ce036 100644 --- a/js/token-interface/src/instructions/freeze.ts +++ b/js/token-interface/src/instructions/freeze.ts @@ -47,7 +47,6 @@ export async function createFreezeInstructions({ payer, owner, mint, - account, wrap: true, })), createFreezeInstruction({ @@ -75,7 +74,6 @@ export async function createFreezeInstructionsNowrap({ payer, owner, mint, - account, wrap: false, })), createFreezeInstruction({ diff --git a/js/token-interface/src/instructions/load.ts b/js/token-interface/src/instructions/load.ts index 291377cabe..13e57a4332 100644 --- a/js/token-interface/src/instructions/load.ts +++ b/js/token-interface/src/instructions/load.ts @@ -52,12 +52,10 @@ import { type Compression, type Transfer2ExtensionData, } from "./layout/layout-transfer2"; -import { createSingleCompressedAccountRpc, getAtaOrNull } from "../account"; import { toLoadOptions } from "../helpers"; import { getAtaAddress } from "../read"; import type { CreateLoadInstructionsInput, - TokenInterfaceAccount, } from "../types"; import { toInstructionPlan } from "./_plan"; @@ -607,40 +605,6 @@ function getCanonicalCompressedTokenAccountFromAtaSources( return candidates[0]; } -interface LoadInstructionProfile { - compressedAccount: ParsedTokenAccount | null; - wrapCount: number; - hasAtaCreation: boolean; -} - -const CU_ATA_CREATION = 30_000; -const CU_WRAP = 50_000; -const CU_DECOMPRESS_BASE = 50_000; -const CU_FULL_PROOF = 100_000; -const CU_PER_ACCOUNT_PROVE_BY_INDEX = 10_000; -const CU_PER_ACCOUNT_FULL_PROOF = 30_000; -const CU_BUFFER_FACTOR = 1.3; -const CU_MIN = 50_000; -const CU_MAX = 1_400_000; - -function rawLoadComputeUnits(profile: LoadInstructionProfile): number { - let cu = 0; - if (profile.hasAtaCreation) cu += CU_ATA_CREATION; - cu += profile.wrapCount * CU_WRAP; - if (profile.compressedAccount) { - cu += CU_DECOMPRESS_BASE; - cu += (profile.compressedAccount.compressedAccount.proveByIndex ?? false) - ? CU_PER_ACCOUNT_PROVE_BY_INDEX - : CU_FULL_PROOF + CU_PER_ACCOUNT_FULL_PROOF; - } - return cu; -} - -function calculateLoadComputeUnits(profile: LoadInstructionProfile): number { - const cu = Math.ceil(rawLoadComputeUnits(profile) * CU_BUFFER_FACTOR); - return Math.max(CU_MIN, Math.min(CU_MAX, cu)); -} - async function _buildLoadInstructions( rpc: Rpc, payer: PublicKey, @@ -651,10 +615,7 @@ async function _buildLoadInstructions( targetAmount: bigint | undefined, authority: PublicKey | undefined, decimals: number, -): Promise<{ - instructions: TransactionInstruction[]; - profile: LoadInstructionProfile; -}> { +): Promise { if (!ata._isAta || !ata._owner || !ata._mint) { throw new Error( "AccountView must be from getAtaView (requires _isAta, _owner, _mint)", @@ -709,14 +670,7 @@ async function _buildLoadInstructions( t22Balance === BigInt(0) && coldBalance === BigInt(0) ) { - return { - instructions: [], - profile: { - compressedAccount: null, - wrapCount: 0, - hasAtaCreation: false, - }, - }; + return []; } let splInterface: SplInterface | undefined; @@ -741,8 +695,6 @@ async function _buildLoadInstructions( } const setupInstructions: TransactionInstruction[] = []; - let wrapCount = 0; - let needsAtaCreation = false; let decompressTarget: PublicKey = lightTokenAtaAddress; let decompressSplInfo: SplInterface | undefined; @@ -754,7 +706,6 @@ async function _buildLoadInstructions( canDecompress = true; if (!lightTokenHotSource) { - needsAtaCreation = true; setupInstructions.push( createAtaIdempotent({ payer, @@ -779,7 +730,6 @@ async function _buildLoadInstructions( payer, }), ); - wrapCount++; } if (t22Balance > BigInt(0) && splInterface) { @@ -795,7 +745,6 @@ async function _buildLoadInstructions( payer, }), ); - wrapCount++; } } else { if (ataType === "light-token") { @@ -803,7 +752,6 @@ async function _buildLoadInstructions( decompressSplInfo = undefined; canDecompress = true; if (!lightTokenHotSource) { - needsAtaCreation = true; setupInstructions.push( createAtaIdempotent({ payer, @@ -819,7 +767,6 @@ async function _buildLoadInstructions( decompressSplInfo = splInterface; canDecompress = true; if (!splSource) { - needsAtaCreation = true; setupInstructions.push( createAssociatedTokenAccountIdempotentInstruction( payer, @@ -835,7 +782,6 @@ async function _buildLoadInstructions( decompressSplInfo = splInterface; canDecompress = true; if (!t22Source) { - needsAtaCreation = true; setupInstructions.push( createAssociatedTokenAccountIdempotentInstruction( payer, @@ -891,14 +837,7 @@ async function _buildLoadInstructions( } if (!canDecompress || !accountToLoad) { - return { - instructions: setupInstructions, - profile: { - compressedAccount: null, - wrapCount, - hasAtaCreation: needsAtaCreation, - }, - }; + return setupInstructions; } const proof = await rpc.getValidityProofV0([ @@ -911,56 +850,48 @@ async function _buildLoadInstructions( const authorityForDecompress = authority ?? owner; const amountToDecompress = BigInt(accountToLoad.parsed.amount.toString()); - return { - instructions: [ - ...setupInstructions, - createDecompressInstruction({ - payer, - inputCompressedTokenAccounts: [accountToLoad], - toAddress: decompressTarget, - amount: amountToDecompress, - validityProof: proof, - splInterface: decompressSplInfo, - decimals, - authority: authorityForDecompress, - }), - ], - profile: { - compressedAccount: accountToLoad, - wrapCount, - hasAtaCreation: needsAtaCreation, - }, - }; + return [ + ...setupInstructions, + createDecompressInstruction({ + payer, + inputCompressedTokenAccounts: [accountToLoad], + toAddress: decompressTarget, + amount: amountToDecompress, + validityProof: proof, + splInterface: decompressSplInfo, + decimals, + authority: authorityForDecompress, + }), + ]; +} + +export interface CreateLoadInstructionOptions + extends CreateLoadInstructionsInput { + authority?: PublicKey; + wrap?: boolean; } -async function createLoadInstructionsForAta({ +export async function createLoadInstructions({ rpc, - ata, + payer, owner, mint, - payer, - loadOptions, -}: { - rpc: Rpc; - ata: PublicKey; - owner: PublicKey; - mint: PublicKey; - payer?: PublicKey; - loadOptions?: LoadOptions; -}): Promise { - const mintInfo = await getMint(rpc, mint); - const decimals = mintInfo.mint.decimals; + authority, + wrap = true, +}: CreateLoadInstructionOptions): Promise { + const targetAta = getAtaAddress({ owner, mint }); + const loadOptions = toLoadOptions(owner, authority, wrap); assertV2Enabled(); payer ??= owner; - const wrap = loadOptions?.wrap ?? false; const authorityPubkey = loadOptions?.delegatePubkey ?? owner; + const mintInfoPromise = getMint(rpc, mint); let accountView: AccountView; try { accountView = await _getAtaView( rpc, - ata, + targetAta, owner, mint, undefined, @@ -973,6 +904,7 @@ async function createLoadInstructionsForAta({ } throw e; } + const decimals = (await mintInfoPromise).mint.decimals; if (!owner.equals(authorityPubkey)) { if (!isAuthorityForAccount(accountView, authorityPubkey)) { @@ -981,13 +913,13 @@ async function createLoadInstructionsForAta({ accountView = filterAccountForAuthority(accountView, authorityPubkey); } - const { instructions, profile } = await _buildLoadInstructions( + const instructions = await _buildLoadInstructions( rpc, payer, accountView, loadOptions, wrap, - ata, + targetAta, undefined, authorityPubkey, decimals, @@ -996,64 +928,10 @@ async function createLoadInstructionsForAta({ if (instructions.length === 0) { return []; } - - return [ - ComputeBudgetProgram.setComputeUnitLimit({ - units: calculateLoadComputeUnits(profile), - }), - ...instructions, - ]; -} - -export interface CreateLoadInstructionOptions - extends CreateLoadInstructionsInput { - authority?: PublicKey; - account?: TokenInterfaceAccount | null; - wrap?: boolean; -} - -export async function createLoadInstructions({ - rpc, - payer, - owner, - mint, - authority, - account, - wrap = true, -}: CreateLoadInstructionOptions): Promise { - const resolvedAccount = - account ?? - (await getAtaOrNull({ - rpc, - owner, - mint, - })); - const targetAta = getAtaAddress({ owner, mint }); - - const effectiveRpc = - resolvedAccount && resolvedAccount.compressedAccount - ? createSingleCompressedAccountRpc( - rpc, - owner, - mint, - resolvedAccount.compressedAccount, - ) - : rpc; - const instructions = ( - await createLoadInstructionsForAta({ - rpc: effectiveRpc, - ata: targetAta, - owner, - mint, - payer, - loadOptions: toLoadOptions(owner, authority, wrap), - }) - ).filter( + return instructions.filter( (instruction) => !instruction.programId.equals(ComputeBudgetProgram.programId), ); - - return instructions; } export async function createLoadInstructionPlan( diff --git a/js/token-interface/src/instructions/revoke.ts b/js/token-interface/src/instructions/revoke.ts index 91e7be6e95..1306748520 100644 --- a/js/token-interface/src/instructions/revoke.ts +++ b/js/token-interface/src/instructions/revoke.ts @@ -60,7 +60,6 @@ export async function createRevokeInstructions({ payer, owner, mint, - account, wrap: true, })), createRevokeInstruction({ @@ -89,7 +88,6 @@ export async function createRevokeInstructionsNowrap({ payer, owner, mint, - account, wrap: false, })), createRevokeInstruction({ diff --git a/js/token-interface/src/instructions/thaw.ts b/js/token-interface/src/instructions/thaw.ts index fabb9b27fe..625352c220 100644 --- a/js/token-interface/src/instructions/thaw.ts +++ b/js/token-interface/src/instructions/thaw.ts @@ -47,7 +47,6 @@ export async function createThawInstructions({ payer, owner, mint, - account, wrap: true, })), createThawInstruction({ @@ -75,7 +74,6 @@ export async function createThawInstructionsNowrap({ payer, owner, mint, - account, wrap: false, })), createThawInstruction({ From 149d806509b872266d7414c6d6c846221b2935f7 Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Sat, 28 Mar 2026 14:48:34 +0000 Subject: [PATCH 06/24] rm redundant rpc --- .../src/instructions/approve.ts | 20 +++++-------------- js/token-interface/src/instructions/burn.ts | 18 +++++++---------- js/token-interface/src/instructions/freeze.ts | 17 +++++----------- js/token-interface/src/instructions/load.ts | 8 +++++++- js/token-interface/src/instructions/revoke.ts | 20 +++++-------------- js/token-interface/src/instructions/thaw.ts | 19 +++++++----------- 6 files changed, 36 insertions(+), 66 deletions(-) diff --git a/js/token-interface/src/instructions/approve.ts b/js/token-interface/src/instructions/approve.ts index 73de28aa77..29d0ac364e 100644 --- a/js/token-interface/src/instructions/approve.ts +++ b/js/token-interface/src/instructions/approve.ts @@ -1,11 +1,11 @@ import { SystemProgram, TransactionInstruction } from '@solana/web3.js'; import { Buffer } from 'buffer'; import { LIGHT_TOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; -import { assertAccountNotFrozen, getAta } from '../account'; import type { CreateApproveInstructionsInput, CreateRawApproveInstructionInput, } from '../types'; +import { getAtaAddress } from '../read'; import { createLoadInstructions } from './load'; import { toInstructionPlan } from './_plan'; @@ -59,13 +59,7 @@ export async function createApproveInstructions({ delegate, amount, }: CreateApproveInstructionsInput): Promise { - const account = await getAta({ - rpc, - owner, - mint, - }); - - assertAccountNotFrozen(account, 'approve'); + const tokenAccount = getAtaAddress({ owner, mint }); return [ ...(await createLoadInstructions({ @@ -76,7 +70,7 @@ export async function createApproveInstructions({ wrap: true, })), createApproveInstruction({ - tokenAccount: account.address, + tokenAccount, delegate, owner, amount: toBigIntAmount(amount), @@ -93,11 +87,7 @@ export async function createApproveInstructionsNowrap({ delegate, amount, }: CreateApproveInstructionsInput): Promise { - const account = await getAta({ - rpc, - owner, - mint, - }); + const tokenAccount = getAtaAddress({ owner, mint }); return [ ...(await createLoadInstructions({ @@ -108,7 +98,7 @@ export async function createApproveInstructionsNowrap({ wrap: false, })), createApproveInstruction({ - tokenAccount: account.address, + tokenAccount, delegate, owner, amount: toBigIntAmount(amount), diff --git a/js/token-interface/src/instructions/burn.ts b/js/token-interface/src/instructions/burn.ts index d4175f3627..ede1042bf0 100644 --- a/js/token-interface/src/instructions/burn.ts +++ b/js/token-interface/src/instructions/burn.ts @@ -1,12 +1,12 @@ import { Buffer } from 'buffer'; import { SystemProgram, TransactionInstruction } from '@solana/web3.js'; import { LIGHT_TOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; -import { assertAccountNotFrozen, getAta } from '../account'; import type { CreateBurnInstructionsInput, CreateRawBurnCheckedInstructionInput, CreateRawBurnInstructionInput, } from '../types'; +import { getAtaAddress } from '../read'; import { createLoadInstructions } from './load'; import { toInstructionPlan } from './_plan'; @@ -96,15 +96,13 @@ export async function createBurnInstructions({ amount, decimals, }: CreateBurnInstructionsInput): Promise { - const account = await getAta({ rpc, owner, mint }); - - assertAccountNotFrozen(account, 'burn'); + const tokenAccount = getAtaAddress({ owner, mint }); const amountBn = toBigIntAmount(amount); const burnIx = decimals !== undefined ? createBurnCheckedInstruction({ - source: account.address, + source: tokenAccount, mint, authority, amount: amountBn, @@ -112,7 +110,7 @@ export async function createBurnInstructions({ payer, }) : createBurnInstruction({ - source: account.address, + source: tokenAccount, mint, authority, amount: amountBn, @@ -140,15 +138,13 @@ export async function createBurnInstructionsNowrap({ amount, decimals, }: CreateBurnInstructionsInput): Promise { - const account = await getAta({ rpc, owner, mint }); - - assertAccountNotFrozen(account, 'burn'); + const tokenAccount = getAtaAddress({ owner, mint }); const amountBn = toBigIntAmount(amount); const burnIx = decimals !== undefined ? createBurnCheckedInstruction({ - source: account.address, + source: tokenAccount, mint, authority, amount: amountBn, @@ -156,7 +152,7 @@ export async function createBurnInstructionsNowrap({ payer, }) : createBurnInstruction({ - source: account.address, + source: tokenAccount, mint, authority, amount: amountBn, diff --git a/js/token-interface/src/instructions/freeze.ts b/js/token-interface/src/instructions/freeze.ts index 30dd1ce036..976cd46b81 100644 --- a/js/token-interface/src/instructions/freeze.ts +++ b/js/token-interface/src/instructions/freeze.ts @@ -1,14 +1,11 @@ import { TransactionInstruction } from '@solana/web3.js'; import { Buffer } from 'buffer'; import { LIGHT_TOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; -import { - assertAccountNotFrozen, - getAta, -} from '../account'; import type { CreateFreezeInstructionsInput, CreateRawFreezeInstructionInput, } from '../types'; +import { getAtaAddress } from '../read'; import { createLoadInstructions } from './load'; import { toInstructionPlan } from './_plan'; @@ -37,9 +34,7 @@ export async function createFreezeInstructions({ mint, freezeAuthority, }: CreateFreezeInstructionsInput): Promise { - const account = await getAta({ rpc, owner, mint }); - - assertAccountNotFrozen(account, 'freeze'); + const tokenAccount = getAtaAddress({ owner, mint }); return [ ...(await createLoadInstructions({ @@ -50,7 +45,7 @@ export async function createFreezeInstructions({ wrap: true, })), createFreezeInstruction({ - tokenAccount: account.address, + tokenAccount, mint, freezeAuthority, }), @@ -64,9 +59,7 @@ export async function createFreezeInstructionsNowrap({ mint, freezeAuthority, }: CreateFreezeInstructionsInput): Promise { - const account = await getAta({ rpc, owner, mint }); - - assertAccountNotFrozen(account, 'freeze'); + const tokenAccount = getAtaAddress({ owner, mint }); return [ ...(await createLoadInstructions({ @@ -77,7 +70,7 @@ export async function createFreezeInstructionsNowrap({ wrap: false, })), createFreezeInstruction({ - tokenAccount: account.address, + tokenAccount, mint, freezeAuthority, }), diff --git a/js/token-interface/src/instructions/load.ts b/js/token-interface/src/instructions/load.ts index 13e57a4332..b12d4412d3 100644 --- a/js/token-interface/src/instructions/load.ts +++ b/js/token-interface/src/instructions/load.ts @@ -615,6 +615,7 @@ async function _buildLoadInstructions( targetAmount: bigint | undefined, authority: PublicKey | undefined, decimals: number, + allowFrozen: boolean, ): Promise { if (!ata._isAta || !ata._owner || !ata._mint) { throw new Error( @@ -622,7 +623,9 @@ async function _buildLoadInstructions( ); } - checkNotFrozen(ata, "load"); + if (!allowFrozen) { + checkNotFrozen(ata, "load"); + } const owner = ata._owner; const mint = ata._mint; @@ -869,6 +872,7 @@ export interface CreateLoadInstructionOptions extends CreateLoadInstructionsInput { authority?: PublicKey; wrap?: boolean; + allowFrozen?: boolean; } export async function createLoadInstructions({ @@ -878,6 +882,7 @@ export async function createLoadInstructions({ mint, authority, wrap = true, + allowFrozen = false, }: CreateLoadInstructionOptions): Promise { const targetAta = getAtaAddress({ owner, mint }); const loadOptions = toLoadOptions(owner, authority, wrap); @@ -923,6 +928,7 @@ export async function createLoadInstructions({ undefined, authorityPubkey, decimals, + allowFrozen, ); if (instructions.length === 0) { diff --git a/js/token-interface/src/instructions/revoke.ts b/js/token-interface/src/instructions/revoke.ts index 1306748520..e81b1c31e0 100644 --- a/js/token-interface/src/instructions/revoke.ts +++ b/js/token-interface/src/instructions/revoke.ts @@ -1,11 +1,11 @@ import { SystemProgram, TransactionInstruction } from '@solana/web3.js'; import { Buffer } from 'buffer'; import { LIGHT_TOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; -import { assertAccountNotFrozen, getAta } from '../account'; import type { CreateRawRevokeInstructionInput, CreateRevokeInstructionsInput, } from '../types'; +import { getAtaAddress } from '../read'; import { createLoadInstructions } from './load'; import { toInstructionPlan } from './_plan'; @@ -46,13 +46,7 @@ export async function createRevokeInstructions({ owner, mint, }: CreateRevokeInstructionsInput): Promise { - const account = await getAta({ - rpc, - owner, - mint, - }); - - assertAccountNotFrozen(account, 'revoke'); + const tokenAccount = getAtaAddress({ owner, mint }); return [ ...(await createLoadInstructions({ @@ -63,7 +57,7 @@ export async function createRevokeInstructions({ wrap: true, })), createRevokeInstruction({ - tokenAccount: account.address, + tokenAccount, owner, payer, }), @@ -76,11 +70,7 @@ export async function createRevokeInstructionsNowrap({ owner, mint, }: CreateRevokeInstructionsInput): Promise { - const account = await getAta({ - rpc, - owner, - mint, - }); + const tokenAccount = getAtaAddress({ owner, mint }); return [ ...(await createLoadInstructions({ @@ -91,7 +81,7 @@ export async function createRevokeInstructionsNowrap({ wrap: false, })), createRevokeInstruction({ - tokenAccount: account.address, + tokenAccount, owner, payer, }), diff --git a/js/token-interface/src/instructions/thaw.ts b/js/token-interface/src/instructions/thaw.ts index 625352c220..ef28125b2c 100644 --- a/js/token-interface/src/instructions/thaw.ts +++ b/js/token-interface/src/instructions/thaw.ts @@ -1,14 +1,11 @@ import { TransactionInstruction } from '@solana/web3.js'; import { Buffer } from 'buffer'; import { LIGHT_TOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; -import { - assertAccountFrozen, - getAta, -} from '../account'; import type { CreateRawThawInstructionInput, CreateThawInstructionsInput, } from '../types'; +import { getAtaAddress } from '../read'; import { createLoadInstructions } from './load'; import { toInstructionPlan } from './_plan'; @@ -37,9 +34,7 @@ export async function createThawInstructions({ mint, freezeAuthority, }: CreateThawInstructionsInput): Promise { - const account = await getAta({ rpc, owner, mint }); - - assertAccountFrozen(account, 'thaw'); + const tokenAccount = getAtaAddress({ owner, mint }); return [ ...(await createLoadInstructions({ @@ -48,9 +43,10 @@ export async function createThawInstructions({ owner, mint, wrap: true, + allowFrozen: true, })), createThawInstruction({ - tokenAccount: account.address, + tokenAccount, mint, freezeAuthority, }), @@ -64,9 +60,7 @@ export async function createThawInstructionsNowrap({ mint, freezeAuthority, }: CreateThawInstructionsInput): Promise { - const account = await getAta({ rpc, owner, mint }); - - assertAccountFrozen(account, 'thaw'); + const tokenAccount = getAtaAddress({ owner, mint }); return [ ...(await createLoadInstructions({ @@ -75,9 +69,10 @@ export async function createThawInstructionsNowrap({ owner, mint, wrap: false, + allowFrozen: true, })), createThawInstruction({ - tokenAccount: account.address, + tokenAccount, mint, freezeAuthority, }), From 285584794a1beab3eb0081460ccb9252180e0445 Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Sat, 28 Mar 2026 15:08:31 +0000 Subject: [PATCH 07/24] add tests --- js/token-interface/src/instructions/burn.ts | 2 + js/token-interface/tests/e2e/ata-read.test.ts | 23 ++++ js/token-interface/tests/e2e/burn.test.ts | 72 ++++++++++ .../tests/e2e/freeze-thaw.test.ts | 70 +++++++++- js/token-interface/tests/e2e/load.test.ts | 14 ++ js/token-interface/tests/e2e/transfer.test.ts | 58 ++++++++ js/token-interface/tests/unit/account.test.ts | 97 ++++++++++++++ .../tests/unit/instruction-builders.test.ts | 124 ++++++++++++++++++ 8 files changed, 459 insertions(+), 1 deletion(-) create mode 100644 js/token-interface/tests/e2e/burn.test.ts create mode 100644 js/token-interface/tests/unit/account.test.ts diff --git a/js/token-interface/src/instructions/burn.ts b/js/token-interface/src/instructions/burn.ts index ede1042bf0..b2ffc2c2b2 100644 --- a/js/token-interface/src/instructions/burn.ts +++ b/js/token-interface/src/instructions/burn.ts @@ -123,6 +123,7 @@ export async function createBurnInstructions({ payer, owner, mint, + authority, wrap: true, })), burnIx, @@ -165,6 +166,7 @@ export async function createBurnInstructionsNowrap({ payer, owner, mint, + authority, wrap: false, })), burnIx, diff --git a/js/token-interface/tests/e2e/ata-read.test.ts b/js/token-interface/tests/e2e/ata-read.test.ts index 7df37a66ed..a9c33af1b2 100644 --- a/js/token-interface/tests/e2e/ata-read.test.ts +++ b/js/token-interface/tests/e2e/ata-read.test.ts @@ -37,4 +37,27 @@ describe('ata creation and reads', () => { expect(account.parsed.mint.toBase58()).toBe(fixture.mint.toBase58()); expect(account.parsed.amount).toBe(0n); }); + + it('replays ATA creation idempotently', async () => { + const fixture = await createMintFixture(); + const owner = await newAccountWithLamports(fixture.rpc, 1e9); + + const instructions = await createAtaInstructions({ + payer: fixture.payer.publicKey, + owner: owner.publicKey, + mint: fixture.mint, + }); + + await sendInstructions(fixture.rpc, fixture.payer, instructions); + await sendInstructions(fixture.rpc, fixture.payer, instructions); + + const account = await getAta({ + rpc: fixture.rpc, + owner: owner.publicKey, + mint: fixture.mint, + }); + + expect(account.parsed.owner.toBase58()).toBe(owner.publicKey.toBase58()); + expect(account.parsed.amount).toBe(0n); + }); }); diff --git a/js/token-interface/tests/e2e/burn.test.ts b/js/token-interface/tests/e2e/burn.test.ts new file mode 100644 index 0000000000..1974a82d63 --- /dev/null +++ b/js/token-interface/tests/e2e/burn.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; +import { Keypair } from '@solana/web3.js'; +import { newAccountWithLamports } from '@lightprotocol/stateless.js'; +import { createBurnInstructions } from '../../src'; +import { + TEST_TOKEN_DECIMALS, + createMintFixture, + mintCompressedToOwner, + sendInstructions, +} from './helpers'; + +describe('burn instructions', () => { + it('surfaces on-chain burn failure for unsupported mint/account combinations', async () => { + const fixture = await createMintFixture(); + const owner = await newAccountWithLamports(fixture.rpc, 1e9); + + await mintCompressedToOwner(fixture, owner.publicKey, 1_000n); + + const burnInstructions = await createBurnInstructions({ + rpc: fixture.rpc, + payer: fixture.payer.publicKey, + owner: owner.publicKey, + mint: fixture.mint, + authority: owner.publicKey, + amount: 300n, + }); + + await expect( + sendInstructions(fixture.rpc, fixture.payer, burnInstructions, [owner]), + ).rejects.toThrow('instruction modified data of an account it does not own'); + }); + + it('fails checked burn with wrong mint decimals', async () => { + const fixture = await createMintFixture(); + const owner = await newAccountWithLamports(fixture.rpc, 1e9); + + await mintCompressedToOwner(fixture, owner.publicKey, 500n); + + const burnInstructions = await createBurnInstructions({ + rpc: fixture.rpc, + payer: fixture.payer.publicKey, + owner: owner.publicKey, + mint: fixture.mint, + authority: owner.publicKey, + amount: 100n, + decimals: TEST_TOKEN_DECIMALS + 1, + }); + + await expect( + sendInstructions(fixture.rpc, fixture.payer, burnInstructions, [owner]), + ).rejects.toThrow(); + }); + + it('rejects burn build for signer that is neither owner nor delegate', async () => { + const fixture = await createMintFixture(); + const owner = await newAccountWithLamports(fixture.rpc, 1e9); + const unauthorized = Keypair.generate(); + + await mintCompressedToOwner(fixture, owner.publicKey, 900n); + + await expect( + createBurnInstructions({ + rpc: fixture.rpc, + payer: fixture.payer.publicKey, + owner: owner.publicKey, + mint: fixture.mint, + authority: unauthorized.publicKey, + amount: 250n, + }), + ).rejects.toThrow('Signer is not the owner or a delegate of the account.'); + }); +}); diff --git a/js/token-interface/tests/e2e/freeze-thaw.test.ts b/js/token-interface/tests/e2e/freeze-thaw.test.ts index ed18beedb7..74dd6b91ce 100644 --- a/js/token-interface/tests/e2e/freeze-thaw.test.ts +++ b/js/token-interface/tests/e2e/freeze-thaw.test.ts @@ -5,6 +5,8 @@ import { createAtaInstructions, createFreezeInstructions, createThawInstructions, + createTransferInstructions, + getAta, getAtaAddress, } from '../../src'; import { @@ -45,7 +47,7 @@ describe('freeze and thaw instructions', () => { mint: fixture.mint, freezeAuthority: fixture.freezeAuthority!.publicKey, }), - [fixture.freezeAuthority!], + [owner, fixture.freezeAuthority!], ); expect(await getHotState(fixture.rpc, tokenAccount)).toBe( @@ -69,4 +71,70 @@ describe('freeze and thaw instructions', () => { AccountState.Initialized, ); }); + + it('blocks transfers while frozen and allows transfers after thaw', async () => { + const fixture = await createMintFixture({ withFreezeAuthority: true }); + const owner = await newAccountWithLamports(fixture.rpc, 1e9); + const recipient = await newAccountWithLamports(fixture.rpc, 1e9); + + await mintCompressedToOwner(fixture, owner.publicKey, 2_500n); + + await sendInstructions( + fixture.rpc, + fixture.payer, + await createFreezeInstructions({ + rpc: fixture.rpc, + payer: fixture.payer.publicKey, + owner: owner.publicKey, + mint: fixture.mint, + freezeAuthority: fixture.freezeAuthority!.publicKey, + }), + [owner, fixture.freezeAuthority!], + ); + + await expect( + createTransferInstructions({ + rpc: fixture.rpc, + payer: fixture.payer.publicKey, + mint: fixture.mint, + sourceOwner: owner.publicKey, + authority: owner.publicKey, + recipient: recipient.publicKey, + amount: 100n, + }), + ).rejects.toThrow('Account is frozen'); + + await sendInstructions( + fixture.rpc, + fixture.payer, + await createThawInstructions({ + rpc: fixture.rpc, + payer: fixture.payer.publicKey, + owner: owner.publicKey, + mint: fixture.mint, + freezeAuthority: fixture.freezeAuthority!.publicKey, + }), + [fixture.freezeAuthority!], + ); + + const transferInstructions = await createTransferInstructions({ + rpc: fixture.rpc, + payer: fixture.payer.publicKey, + mint: fixture.mint, + sourceOwner: owner.publicKey, + authority: owner.publicKey, + recipient: recipient.publicKey, + amount: 100n, + }); + await sendInstructions(fixture.rpc, fixture.payer, transferInstructions, [ + owner, + ]); + + const recipientAta = await getAta({ + rpc: fixture.rpc, + owner: recipient.publicKey, + mint: fixture.mint, + }); + expect(recipientAta.parsed.amount).toBe(100n); + }); }); diff --git a/js/token-interface/tests/e2e/load.test.ts b/js/token-interface/tests/e2e/load.test.ts index 495623230f..c8b7e0e306 100644 --- a/js/token-interface/tests/e2e/load.test.ts +++ b/js/token-interface/tests/e2e/load.test.ts @@ -15,6 +15,20 @@ import { } from './helpers'; describe('load instructions', () => { + it('returns no instructions when account has no hot or cold balance', async () => { + const fixture = await createMintFixture(); + const owner = await newAccountWithLamports(fixture.rpc, 1e9); + + const instructions = await createLoadInstructions({ + rpc: fixture.rpc, + payer: fixture.payer.publicKey, + owner: owner.publicKey, + mint: fixture.mint, + }); + + expect(instructions).toEqual([]); + }); + it('getAta only exposes the biggest compressed balance and tracks the ignored ones', async () => { const fixture = await createMintFixture(); const owner = await newAccountWithLamports(fixture.rpc, 1e9); diff --git a/js/token-interface/tests/e2e/transfer.test.ts b/js/token-interface/tests/e2e/transfer.test.ts index b9f001a43c..ade6976acc 100644 --- a/js/token-interface/tests/e2e/transfer.test.ts +++ b/js/token-interface/tests/e2e/transfer.test.ts @@ -22,6 +22,27 @@ import { } from './helpers'; describe('transfer instructions', () => { + it('rejects transfer build for signer that is neither owner nor delegate', async () => { + const fixture = await createMintFixture(); + const owner = await newAccountWithLamports(fixture.rpc, 1e9); + const unauthorized = await newAccountWithLamports(fixture.rpc, 1e9); + const recipient = Keypair.generate(); + + await mintCompressedToOwner(fixture, owner.publicKey, 500n); + + await expect( + createTransferInstructions({ + rpc: fixture.rpc, + payer: fixture.payer.publicKey, + mint: fixture.mint, + sourceOwner: owner.publicKey, + authority: unauthorized.publicKey, + recipient: recipient.publicKey, + amount: 100n, + }), + ).rejects.toThrow('Signer is not the owner or a delegate of the account.'); + }); + it('builds a single-transaction transfer flow without compute budget instructions', async () => { const fixture = await createMintFixture(); const sender = await newAccountWithLamports(fixture.rpc, 1e9); @@ -242,4 +263,41 @@ describe('transfer instructions', () => { expect(recipientAta.parsed.amount).toBe(250n); expect(await getHotBalance(fixture.rpc, ownerAta)).toBe(250n); }); + + it('rejects delegated transfer above delegated allowance', async () => { + const fixture = await createMintFixture(); + const owner = await newAccountWithLamports(fixture.rpc, 1e9); + const delegate = await newAccountWithLamports(fixture.rpc, 1e9); + const recipient = Keypair.generate(); + + await mintCompressedToOwner(fixture, owner.publicKey, 500n); + + const approveInstructions = await createApproveInstructions({ + rpc: fixture.rpc, + payer: fixture.payer.publicKey, + owner: owner.publicKey, + mint: fixture.mint, + delegate: delegate.publicKey, + amount: 100n, + }); + await sendInstructions(fixture.rpc, fixture.payer, approveInstructions, [ + owner, + ]); + + const transferInstructions = await createTransferInstructions({ + rpc: fixture.rpc, + payer: fixture.payer.publicKey, + mint: fixture.mint, + sourceOwner: owner.publicKey, + recipient: recipient.publicKey, + amount: 150n, + authority: delegate.publicKey, + }); + + await expect( + sendInstructions(fixture.rpc, fixture.payer, transferInstructions, [ + delegate, + ]), + ).rejects.toThrow('custom program error'); + }); }); diff --git a/js/token-interface/tests/unit/account.test.ts b/js/token-interface/tests/unit/account.test.ts new file mode 100644 index 0000000000..7a78fd6899 --- /dev/null +++ b/js/token-interface/tests/unit/account.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; +import { Keypair } from '@solana/web3.js'; +import type { TokenInterfaceAccount } from '../../src'; +import { + assertAccountFrozen, + assertAccountNotFrozen, + getSpendableAmount, +} from '../../src/account'; + +function buildAccount(input: { + owner: ReturnType['publicKey']; + delegate?: ReturnType['publicKey'] | null; + amount: bigint; + delegatedAmount?: bigint; + isFrozen?: boolean; +}): TokenInterfaceAccount { + const mint = Keypair.generate().publicKey; + const parsedDelegate = input.delegate ?? null; + const delegatedAmount = input.delegatedAmount ?? 0n; + const isFrozen = input.isFrozen ?? false; + + return { + address: Keypair.generate().publicKey, + owner: input.owner, + mint, + amount: input.amount, + hotAmount: input.amount, + compressedAmount: 0n, + hasHotAccount: true, + requiresLoad: false, + parsed: { + address: Keypair.generate().publicKey, + owner: input.owner, + mint, + amount: input.amount, + delegate: parsedDelegate, + delegatedAmount, + isInitialized: true, + isFrozen, + }, + compressedAccount: null, + ignoredCompressedAccounts: [], + ignoredCompressedAmount: 0n, + }; +} + +describe('account helpers', () => { + it('returns full amount for owner and delegated amount for delegate', () => { + const owner = Keypair.generate().publicKey; + const delegate = Keypair.generate().publicKey; + const outsider = Keypair.generate().publicKey; + const account = buildAccount({ + owner, + delegate, + amount: 100n, + delegatedAmount: 30n, + }); + + expect(getSpendableAmount(account, owner)).toBe(100n); + expect(getSpendableAmount(account, delegate)).toBe(30n); + expect(getSpendableAmount(account, outsider)).toBe(0n); + }); + + it('clamps delegated spendable amount to total balance', () => { + const owner = Keypair.generate().publicKey; + const delegate = Keypair.generate().publicKey; + const account = buildAccount({ + owner, + delegate, + amount: 20n, + delegatedAmount: 500n, + }); + + expect(getSpendableAmount(account, delegate)).toBe(20n); + }); + + it('throws explicit frozen-state assertion errors', () => { + const owner = Keypair.generate().publicKey; + const frozenAccount = buildAccount({ + owner, + amount: 1n, + isFrozen: true, + }); + const activeAccount = buildAccount({ + owner, + amount: 1n, + isFrozen: false, + }); + + expect(() => assertAccountNotFrozen(frozenAccount, 'transfer')).toThrow( + 'Account is frozen; transfer is not allowed.', + ); + expect(() => assertAccountFrozen(activeAccount, 'thaw')).toThrow( + 'Account is not frozen; thaw is not allowed.', + ); + }); +}); diff --git a/js/token-interface/tests/unit/instruction-builders.test.ts b/js/token-interface/tests/unit/instruction-builders.test.ts index e1545d9c5b..de435478ff 100644 --- a/js/token-interface/tests/unit/instruction-builders.test.ts +++ b/js/token-interface/tests/unit/instruction-builders.test.ts @@ -1,9 +1,13 @@ import { describe, expect, it } from 'vitest'; import { Keypair } from '@solana/web3.js'; import { LIGHT_TOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; +import { TOKEN_PROGRAM_ID } from '@solana/spl-token'; import { createApproveInstruction, createAtaInstruction, + createAssociatedLightTokenAccountInstruction, + createBurnCheckedInstruction, + createBurnInstruction, createFreezeInstruction, createRevokeInstruction, createThawInstruction, @@ -51,6 +55,31 @@ describe('instruction builders', () => { expect(instruction.keys[2].pubkey.equals(destination)).toBe(true); }); + it('marks fee payer as signer when transfer authority differs', () => { + const source = Keypair.generate().publicKey; + const destination = Keypair.generate().publicKey; + const mint = Keypair.generate().publicKey; + const authority = Keypair.generate().publicKey; + const payer = Keypair.generate().publicKey; + + const instruction = createTransferCheckedInstruction({ + source, + destination, + mint, + authority, + payer, + amount: 1n, + decimals: 9, + }); + + expect(instruction.keys[3].pubkey.equals(authority)).toBe(true); + expect(instruction.keys[3].isSigner).toBe(true); + expect(instruction.keys[3].isWritable).toBe(false); + expect(instruction.keys[5].pubkey.equals(payer)).toBe(true); + expect(instruction.keys[5].isSigner).toBe(true); + expect(instruction.keys[5].isWritable).toBe(true); + }); + it('creates approve, revoke, freeze, and thaw instructions', () => { const tokenAccount = Keypair.generate().publicKey; const owner = Keypair.generate().publicKey; @@ -85,4 +114,99 @@ describe('instruction builders', () => { expect(thaw.data[0]).toBe(11); }); + it('uses external fee payer in approve/revoke key metas', () => { + const tokenAccount = Keypair.generate().publicKey; + const owner = Keypair.generate().publicKey; + const delegate = Keypair.generate().publicKey; + const payer = Keypair.generate().publicKey; + + const approve = createApproveInstruction({ + tokenAccount, + delegate, + owner, + amount: 9n, + payer, + }); + const revoke = createRevokeInstruction({ + tokenAccount, + owner, + payer, + }); + + expect(approve.keys[2].pubkey.equals(owner)).toBe(true); + expect(approve.keys[2].isSigner).toBe(true); + expect(approve.keys[2].isWritable).toBe(false); + expect(approve.keys[4].pubkey.equals(payer)).toBe(true); + expect(approve.keys[4].isSigner).toBe(true); + expect(approve.keys[4].isWritable).toBe(true); + + expect(revoke.keys[1].pubkey.equals(owner)).toBe(true); + expect(revoke.keys[1].isSigner).toBe(true); + expect(revoke.keys[1].isWritable).toBe(false); + expect(revoke.keys[3].pubkey.equals(payer)).toBe(true); + expect(revoke.keys[3].isSigner).toBe(true); + expect(revoke.keys[3].isWritable).toBe(true); + }); + + it('encodes burn and burn-checked discriminators and decimals', () => { + const source = Keypair.generate().publicKey; + const mint = Keypair.generate().publicKey; + const authority = Keypair.generate().publicKey; + const payer = Keypair.generate().publicKey; + + const burn = createBurnInstruction({ + source, + mint, + authority, + amount: 123n, + payer, + }); + const burnChecked = createBurnCheckedInstruction({ + source, + mint, + authority, + amount: 123n, + decimals: 9, + payer, + }); + + expect(burn.data[0]).toBe(8); + expect(burnChecked.data[0]).toBe(15); + expect(burnChecked.data[9]).toBe(9); + expect(burn.keys[4].isSigner).toBe(true); + expect(burn.keys[2].isWritable).toBe(false); + }); + + it('creates SPL ATA instruction when SPL token program is requested', () => { + const payer = Keypair.generate().publicKey; + const owner = Keypair.generate().publicKey; + const mint = Keypair.generate().publicKey; + + const instruction = createAtaInstruction({ + payer, + owner, + mint, + programId: TOKEN_PROGRAM_ID, + }); + + expect(instruction.programId.equals(TOKEN_PROGRAM_ID)).toBe(false); + expect(instruction.keys[5].pubkey.equals(TOKEN_PROGRAM_ID)).toBe(true); + }); + + it('omits light-token config/rent keys when compressible config is null', () => { + const feePayer = Keypair.generate().publicKey; + const owner = Keypair.generate().publicKey; + const mint = Keypair.generate().publicKey; + + const instruction = createAssociatedLightTokenAccountInstruction({ + feePayer, + owner, + mint, + compressibleConfig: null, + }); + + expect(instruction.programId.equals(LIGHT_TOKEN_PROGRAM_ID)).toBe(true); + expect(instruction.keys).toHaveLength(5); + }); + }); From 22cdd49d714c2d2927416ea9145ed5cb1cd5271b Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Sat, 28 Mar 2026 20:27:33 +0000 Subject: [PATCH 08/24] dedupe amount helper, unify ix disc --- js/token-interface/src/helpers.ts | 4 +++ .../src/instructions/approve.ts | 5 +--- js/token-interface/src/instructions/ata.ts | 28 ++++++++++++------- js/token-interface/src/instructions/burn.ts | 5 +--- js/token-interface/src/instructions/freeze.ts | 7 +++-- js/token-interface/src/instructions/thaw.ts | 7 +++-- .../src/instructions/transfer.ts | 6 +--- 7 files changed, 35 insertions(+), 27 deletions(-) diff --git a/js/token-interface/src/helpers.ts b/js/token-interface/src/helpers.ts index 6936c9b74d..94f4d8e4d6 100644 --- a/js/token-interface/src/helpers.ts +++ b/js/token-interface/src/helpers.ts @@ -50,3 +50,7 @@ export function normalizeInstructionBatches( !instruction.programId.equals(ComputeBudgetProgram.programId), ); } + +export function toBigIntAmount(amount: number | bigint): bigint { + return BigInt(amount.toString()); +} diff --git a/js/token-interface/src/instructions/approve.ts b/js/token-interface/src/instructions/approve.ts index 29d0ac364e..0c6f5c4b99 100644 --- a/js/token-interface/src/instructions/approve.ts +++ b/js/token-interface/src/instructions/approve.ts @@ -5,16 +5,13 @@ import type { CreateApproveInstructionsInput, CreateRawApproveInstructionInput, } from '../types'; +import { toBigIntAmount } from '../helpers'; import { getAtaAddress } from '../read'; import { createLoadInstructions } from './load'; import { toInstructionPlan } from './_plan'; const LIGHT_TOKEN_APPROVE_DISCRIMINATOR = 4; -function toBigIntAmount(amount: number | bigint): bigint { - return BigInt(amount.toString()); -} - export function createApproveInstruction({ tokenAccount, delegate, diff --git a/js/token-interface/src/instructions/ata.ts b/js/token-interface/src/instructions/ata.ts index 5e1caf946f..b79849a263 100644 --- a/js/token-interface/src/instructions/ata.ts +++ b/js/token-interface/src/instructions/ata.ts @@ -116,19 +116,27 @@ function encodeCreateAssociatedLightTokenAccountData( params: CreateAssociatedLightTokenAccountParams, idempotent: boolean, ): Buffer { - const buffer = Buffer.alloc(2000); - const len = CreateAssociatedTokenAccountInstructionDataLayout.encode( - { - compressibleConfig: params.compressibleConfig || null, - }, - buffer, - ); - const discriminator = idempotent ? CREATE_ASSOCIATED_TOKEN_ACCOUNT_IDEMPOTENT_DISCRIMINATOR : CREATE_ASSOCIATED_TOKEN_ACCOUNT_DISCRIMINATOR; - - return Buffer.concat([discriminator, buffer.subarray(0, len)]); + const payload = { compressibleConfig: params.compressibleConfig || null }; + let size = 64; + + for (;;) { + const buffer = Buffer.alloc(size); + try { + const len = CreateAssociatedTokenAccountInstructionDataLayout.encode( + payload, + buffer, + ); + return Buffer.concat([discriminator, buffer.subarray(0, len)]); + } catch (error) { + if (!(error instanceof RangeError) || size >= 4096) { + throw error; + } + size *= 2; + } + } } export interface CreateAssociatedLightTokenAccountInstructionParams { diff --git a/js/token-interface/src/instructions/burn.ts b/js/token-interface/src/instructions/burn.ts index b2ffc2c2b2..281374f23d 100644 --- a/js/token-interface/src/instructions/burn.ts +++ b/js/token-interface/src/instructions/burn.ts @@ -6,6 +6,7 @@ import type { CreateRawBurnCheckedInstructionInput, CreateRawBurnInstructionInput, } from '../types'; +import { toBigIntAmount } from '../helpers'; import { getAtaAddress } from '../read'; import { createLoadInstructions } from './load'; import { toInstructionPlan } from './_plan'; @@ -13,10 +14,6 @@ import { toInstructionPlan } from './_plan'; const LIGHT_TOKEN_BURN_DISCRIMINATOR = 8; const LIGHT_TOKEN_BURN_CHECKED_DISCRIMINATOR = 15; -function toBigIntAmount(amount: number | bigint): bigint { - return BigInt(amount.toString()); -} - export function createBurnInstruction({ source, mint, diff --git a/js/token-interface/src/instructions/freeze.ts b/js/token-interface/src/instructions/freeze.ts index 976cd46b81..a8fab97f1c 100644 --- a/js/token-interface/src/instructions/freeze.ts +++ b/js/token-interface/src/instructions/freeze.ts @@ -9,13 +9,16 @@ import { getAtaAddress } from '../read'; import { createLoadInstructions } from './load'; import { toInstructionPlan } from './_plan'; -const LIGHT_TOKEN_FREEZE_ACCOUNT_DISCRIMINATOR = Buffer.from([10]); +const LIGHT_TOKEN_FREEZE_ACCOUNT_DISCRIMINATOR = 10; export function createFreezeInstruction({ tokenAccount, mint, freezeAuthority, }: CreateRawFreezeInstructionInput): TransactionInstruction { + const data = Buffer.alloc(1); + data.writeUInt8(LIGHT_TOKEN_FREEZE_ACCOUNT_DISCRIMINATOR, 0); + return new TransactionInstruction({ programId: LIGHT_TOKEN_PROGRAM_ID, keys: [ @@ -23,7 +26,7 @@ export function createFreezeInstruction({ { pubkey: mint, isSigner: false, isWritable: false }, { pubkey: freezeAuthority, isSigner: true, isWritable: false }, ], - data: LIGHT_TOKEN_FREEZE_ACCOUNT_DISCRIMINATOR, + data, }); } diff --git a/js/token-interface/src/instructions/thaw.ts b/js/token-interface/src/instructions/thaw.ts index ef28125b2c..01263c5ab2 100644 --- a/js/token-interface/src/instructions/thaw.ts +++ b/js/token-interface/src/instructions/thaw.ts @@ -9,13 +9,16 @@ import { getAtaAddress } from '../read'; import { createLoadInstructions } from './load'; import { toInstructionPlan } from './_plan'; -const LIGHT_TOKEN_THAW_ACCOUNT_DISCRIMINATOR = Buffer.from([11]); +const LIGHT_TOKEN_THAW_ACCOUNT_DISCRIMINATOR = 11; export function createThawInstruction({ tokenAccount, mint, freezeAuthority, }: CreateRawThawInstructionInput): TransactionInstruction { + const data = Buffer.alloc(1); + data.writeUInt8(LIGHT_TOKEN_THAW_ACCOUNT_DISCRIMINATOR, 0); + return new TransactionInstruction({ programId: LIGHT_TOKEN_PROGRAM_ID, keys: [ @@ -23,7 +26,7 @@ export function createThawInstruction({ { pubkey: mint, isSigner: false, isWritable: false }, { pubkey: freezeAuthority, isSigner: true, isWritable: false }, ], - data: LIGHT_TOKEN_THAW_ACCOUNT_DISCRIMINATOR, + data, }); } diff --git a/js/token-interface/src/instructions/transfer.ts b/js/token-interface/src/instructions/transfer.ts index bb9e15c22d..0ebaa2af8a 100644 --- a/js/token-interface/src/instructions/transfer.ts +++ b/js/token-interface/src/instructions/transfer.ts @@ -9,7 +9,7 @@ import { createCloseAccountInstruction, unpackAccount, } from "@solana/spl-token"; -import { getMintDecimals } from "../helpers"; +import { getMintDecimals, toBigIntAmount } from "../helpers"; import { getAtaAddress } from "../read"; import type { CreateRawTransferInstructionInput, @@ -23,10 +23,6 @@ const ZERO = BigInt(0); const LIGHT_TOKEN_TRANSFER_CHECKED_DISCRIMINATOR = 12; -function toBigIntAmount(amount: number | bigint): bigint { - return BigInt(amount.toString()); -} - async function getDerivedAtaBalance( rpc: CreateTransferInstructionsInput["rpc"], owner: CreateTransferInstructionsInput["sourceOwner"], From 7f8c061574a6e74fc3742802dbb2ab9e92d7478e Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Sat, 28 Mar 2026 20:50:48 +0000 Subject: [PATCH 09/24] upd ci --- .github/workflows/js-token-interface-v2.yml | 95 +++++++++++++++++++++ js/justfile | 14 ++- scripts/lint.sh | 1 + 3 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/js-token-interface-v2.yml diff --git a/.github/workflows/js-token-interface-v2.yml b/.github/workflows/js-token-interface-v2.yml new file mode 100644 index 0000000000..ce69a5b150 --- /dev/null +++ b/.github/workflows/js-token-interface-v2.yml @@ -0,0 +1,95 @@ +on: + push: + branches: + - main + pull_request: + branches: + - "*" + types: + - opened + - synchronize + - reopened + - ready_for_review + +name: js-token-interface-v2 + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + token-interface-js-v2: + name: token-interface-js-v2 + if: github.event.pull_request.draft == false + runs-on: ubuntu-latest + + services: + redis: + image: redis:8.0.1 + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + env: + LIGHT_PROTOCOL_VERSION: V2 + REDIS_URL: redis://localhost:6379 + CI: true + + steps: + - name: Checkout sources + uses: actions/checkout@v6 + with: + submodules: true + + - name: Setup and build + uses: ./.github/actions/setup-and-build + with: + skip-components: "redis,disk-cleanup,go" + cache-key: "js" + + - name: Build token-interface with V2 + run: | + cd js/token-interface + pnpm build:v2 + + - name: Run token-interface unit tests with V2 + run: | + echo "Running token-interface unit tests with retry logic (max 2 attempts)..." + attempt=1 + max_attempts=2 + until just js test-token-interface-unit-v2; do + attempt=$((attempt + 1)) + if [ $attempt -gt $max_attempts ]; then + echo "Tests failed after $max_attempts attempts" + exit 1 + fi + echo "Attempt $attempt/$max_attempts failed, retrying..." + sleep 5 + done + echo "Tests passed on attempt $attempt" + + - name: Run token-interface e2e tests with V2 + run: | + echo "Running token-interface e2e tests with retry logic (max 2 attempts)..." + attempt=1 + max_attempts=2 + until just js test-token-interface-e2e-v2; do + attempt=$((attempt + 1)) + if [ $attempt -gt $max_attempts ]; then + echo "Tests failed after $max_attempts attempts" + exit 1 + fi + echo "Attempt $attempt/$max_attempts failed, retrying..." + sleep 5 + done + echo "Tests passed on attempt $attempt" + + - name: Display prover logs on failure + if: failure() + run: | + echo "=== Displaying prover logs ===" + find . -path "*/test-ledger/*prover*.log" -type f -exec echo "=== Contents of {} ===" \; -exec cat {} \; -exec echo "=== End of {} ===" \; || echo "No prover logs found" diff --git a/js/justfile b/js/justfile index 892573cd7c..151dcf5b1e 100644 --- a/js/justfile +++ b/js/justfile @@ -6,8 +6,9 @@ default: build: cd stateless.js && pnpm build cd compressed-token && pnpm build + cd token-interface && pnpm build -test: test-stateless test-compressed-token +test: test-stateless test-compressed-token test-token-interface test-stateless: cd stateless.js && pnpm test @@ -15,13 +16,24 @@ test-stateless: test-compressed-token: cd compressed-token && pnpm test +test-token-interface: + cd token-interface && pnpm test + +test-token-interface-unit-v2: + cd token-interface && pnpm test:unit:all + +test-token-interface-e2e-v2: + cd token-interface && pnpm test:e2e:all + test-compressed-token-unit-v2: cd compressed-token && pnpm test:unit:all:v2 lint: cd stateless.js && pnpm lint cd compressed-token && pnpm lint + cd token-interface && pnpm lint format: cd stateless.js && pnpm format cd compressed-token && pnpm format + cd token-interface && pnpm format diff --git a/scripts/lint.sh b/scripts/lint.sh index b3fba61500..8c988572ef 100755 --- a/scripts/lint.sh +++ b/scripts/lint.sh @@ -8,6 +8,7 @@ RUSTFMT_NIGHTLY_TOOLCHAIN="${RUSTFMT_NIGHTLY_TOOLCHAIN:-nightly-2025-10-26}" # 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) +(cd js/token-interface && pnpm prettier --write . && pnpm lint) # Rust linting cargo +"$RUSTFMT_NIGHTLY_TOOLCHAIN" fmt --all -- --check From 43009a9c4336352b27f90e7bdb3642a3a29e84be Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Sat, 28 Mar 2026 22:31:47 +0000 Subject: [PATCH 10/24] add perms --- .github/workflows/js-token-interface-v2.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/js-token-interface-v2.yml b/.github/workflows/js-token-interface-v2.yml index ce69a5b150..23e0e217a0 100644 --- a/.github/workflows/js-token-interface-v2.yml +++ b/.github/workflows/js-token-interface-v2.yml @@ -13,6 +13,9 @@ on: name: js-token-interface-v2 +permissions: + contents: read + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true @@ -56,6 +59,10 @@ jobs: cd js/token-interface pnpm build:v2 + - name: Build CLI + run: | + just cli build + - name: Run token-interface unit tests with V2 run: | echo "Running token-interface unit tests with retry logic (max 2 attempts)..." From 0bd3fcc9bf683025dbe638dc2f62aab661eae79d Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Sun, 29 Mar 2026 15:52:46 +0100 Subject: [PATCH 11/24] more robust getsplinterface --- js/token-interface/src/spl-interface.ts | 26 +++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/js/token-interface/src/spl-interface.ts b/js/token-interface/src/spl-interface.ts index 05d06f2f44..84f21cea7b 100644 --- a/js/token-interface/src/spl-interface.ts +++ b/js/token-interface/src/spl-interface.ts @@ -1,5 +1,9 @@ import { Commitment, PublicKey } from "@solana/web3.js"; -import { unpackAccount } from "@solana/spl-token"; +import { + TOKEN_2022_PROGRAM_ID, + TOKEN_PROGRAM_ID, + unpackAccount, +} from "@solana/spl-token"; import { bn, Rpc } from "@lightprotocol/stateless.js"; import BN from "bn.js"; import { deriveSplInterfacePdaWithIndex } from "./constants"; @@ -19,6 +23,13 @@ export type SplInterface = { bump: number; }; +function isSupportedTokenProgramId(programId: PublicKey): boolean { + return ( + programId.equals(TOKEN_PROGRAM_ID) || + programId.equals(TOKEN_2022_PROGRAM_ID) + ); +} + export async function getSplInterfaces( rpc: Rpc, mint: PublicKey, @@ -33,9 +44,18 @@ export async function getSplInterfaces( commitment, ); - if (accountInfos[0] === null) { + const anchorIndex = accountInfos.findIndex( + (accountInfo) => accountInfo !== null, + ); + if (anchorIndex === -1) { throw new Error(`SPL interface not found for mint ${mint.toBase58()}.`); } + const tokenProgramId = accountInfos[anchorIndex]!.owner; + if (!isSupportedTokenProgramId(tokenProgramId)) { + throw new Error( + `Invalid token program owner for SPL interface mint ${mint.toBase58()}: ${tokenProgramId.toBase58()}`, + ); + } const parsedInfos = addressesAndBumps.map(([address], i) => accountInfos[i] @@ -43,8 +63,6 @@ export async function getSplInterfaces( : null, ); - const tokenProgramId = accountInfos[0].owner; - return parsedInfos.map((parsedInfo, i) => { if (!parsedInfo) { return { From 46ee47917f3c6e6ad85b14d7923a429ba09026dd Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Sun, 29 Mar 2026 16:16:10 +0100 Subject: [PATCH 12/24] rm close acc for transfer --- .../src/instructions/transfer.ts | 61 ------------------- js/token-interface/tests/e2e/transfer.test.ts | 10 +++ 2 files changed, 10 insertions(+), 61 deletions(-) diff --git a/js/token-interface/src/instructions/transfer.ts b/js/token-interface/src/instructions/transfer.ts index 0ebaa2af8a..b037e55b1a 100644 --- a/js/token-interface/src/instructions/transfer.ts +++ b/js/token-interface/src/instructions/transfer.ts @@ -3,12 +3,6 @@ import { SystemProgram, TransactionInstruction } from "@solana/web3.js"; import { LIGHT_TOKEN_PROGRAM_ID } from "@lightprotocol/stateless.js"; import { getSplInterfaces } from "../spl-interface"; import { createUnwrapInstruction } from "./unwrap"; -import { - TOKEN_2022_PROGRAM_ID, - TOKEN_PROGRAM_ID, - createCloseAccountInstruction, - unpackAccount, -} from "@solana/spl-token"; import { getMintDecimals, toBigIntAmount } from "../helpers"; import { getAtaAddress } from "../read"; import type { @@ -19,25 +13,8 @@ import { createLoadInstructions } from "./load"; import { toInstructionPlan } from "./_plan"; import { createAtaInstruction } from "./ata"; -const ZERO = BigInt(0); - const LIGHT_TOKEN_TRANSFER_CHECKED_DISCRIMINATOR = 12; -async function getDerivedAtaBalance( - rpc: CreateTransferInstructionsInput["rpc"], - owner: CreateTransferInstructionsInput["sourceOwner"], - mint: CreateTransferInstructionsInput["mint"], - programId: typeof TOKEN_PROGRAM_ID | typeof TOKEN_2022_PROGRAM_ID, -): Promise { - const ata = getAtaAddress({ owner, mint, programId }); - const info = await rpc.getAccountInfo(ata); - if (!info || !info.owner.equals(programId)) { - return ZERO; - } - - return unpackAccount(ata, info, programId).amount; -} - export function createTransferCheckedInstruction({ source, destination, @@ -104,43 +81,6 @@ export async function buildTransferInstructions({ programId: recipientTokenProgramId, }); const decimals = await getMintDecimals(rpc, mint); - const [senderSplBalance, senderT22Balance] = await Promise.all([ - getDerivedAtaBalance(rpc, sourceOwner, mint, TOKEN_PROGRAM_ID), - getDerivedAtaBalance(rpc, sourceOwner, mint, TOKEN_2022_PROGRAM_ID), - ]); - - const closeWrappedSourceInstructions: TransactionInstruction[] = []; - if (authority.equals(sourceOwner) && senderSplBalance > ZERO) { - closeWrappedSourceInstructions.push( - createCloseAccountInstruction( - getAtaAddress({ - owner: sourceOwner, - mint, - programId: TOKEN_PROGRAM_ID, - }), - sourceOwner, - sourceOwner, - [], - TOKEN_PROGRAM_ID, - ), - ); - } - if (authority.equals(sourceOwner) && senderT22Balance > ZERO) { - closeWrappedSourceInstructions.push( - createCloseAccountInstruction( - getAtaAddress({ - owner: sourceOwner, - mint, - programId: TOKEN_2022_PROGRAM_ID, - }), - sourceOwner, - sourceOwner, - [], - TOKEN_2022_PROGRAM_ID, - ), - ); - } - const recipientLoadInstructions: TransactionInstruction[] = []; const senderAta = getAtaAddress({ owner: sourceOwner, @@ -182,7 +122,6 @@ export async function buildTransferInstructions({ return [ ...senderLoadInstructions, - ...closeWrappedSourceInstructions, createAtaInstruction({ payer, owner: recipient, diff --git a/js/token-interface/tests/e2e/transfer.test.ts b/js/token-interface/tests/e2e/transfer.test.ts index ade6976acc..321fa3d3c9 100644 --- a/js/token-interface/tests/e2e/transfer.test.ts +++ b/js/token-interface/tests/e2e/transfer.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { ComputeBudgetProgram, Keypair } from '@solana/web3.js'; import { TOKEN_PROGRAM_ID, + TOKEN_2022_PROGRAM_ID, getAssociatedTokenAddressSync, unpackAccount, } from '@solana/spl-token'; @@ -22,6 +23,14 @@ import { } from './helpers'; describe('transfer instructions', () => { + const isSplOrT22CloseInstruction = ( + instruction: { programId: { equals: (other: unknown) => boolean }; data: Uint8Array }, + ): boolean => + (instruction.programId.equals(TOKEN_PROGRAM_ID) || + instruction.programId.equals(TOKEN_2022_PROGRAM_ID)) && + instruction.data.length > 0 && + instruction.data[0] === 9; + it('rejects transfer build for signer that is neither owner nor delegate', async () => { const fixture = await createMintFixture(); const owner = await newAccountWithLamports(fixture.rpc, 1e9); @@ -66,6 +75,7 @@ describe('transfer instructions', () => { instruction.programId.equals(ComputeBudgetProgram.programId), ), ).toBe(false); + expect(instructions.some(isSplOrT22CloseInstruction)).toBe(false); await sendInstructions(fixture.rpc, fixture.payer, instructions, [ sender, From 822c2a1e9082e5e006ff47781d6650854a43b7f0 Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Sun, 29 Mar 2026 16:24:57 +0100 Subject: [PATCH 13/24] upd typed err in splinterface --- js/token-interface/src/spl-interface.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/js/token-interface/src/spl-interface.ts b/js/token-interface/src/spl-interface.ts index 84f21cea7b..43d3309bbb 100644 --- a/js/token-interface/src/spl-interface.ts +++ b/js/token-interface/src/spl-interface.ts @@ -2,6 +2,8 @@ import { Commitment, PublicKey } from "@solana/web3.js"; import { TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID, + TokenAccountNotFoundError, + TokenInvalidAccountOwnerError, unpackAccount, } from "@solana/spl-token"; import { bn, Rpc } from "@lightprotocol/stateless.js"; @@ -48,11 +50,13 @@ export async function getSplInterfaces( (accountInfo) => accountInfo !== null, ); if (anchorIndex === -1) { - throw new Error(`SPL interface not found for mint ${mint.toBase58()}.`); + throw new TokenAccountNotFoundError( + `SPL interface not found for mint ${mint.toBase58()}.`, + ); } const tokenProgramId = accountInfos[anchorIndex]!.owner; if (!isSupportedTokenProgramId(tokenProgramId)) { - throw new Error( + throw new TokenInvalidAccountOwnerError( `Invalid token program owner for SPL interface mint ${mint.toBase58()}: ${tokenProgramId.toBase58()}`, ); } From df81770eb6f8a4ac22cea4c5e0f9b29d4b7838cb Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Sun, 29 Mar 2026 16:30:59 +0100 Subject: [PATCH 14/24] add test cov for nowrap --- js/token-interface/tests/e2e/transfer.test.ts | 78 ++++++++++++++++++- 1 file changed, 76 insertions(+), 2 deletions(-) diff --git a/js/token-interface/tests/e2e/transfer.test.ts b/js/token-interface/tests/e2e/transfer.test.ts index 321fa3d3c9..91a42110b7 100644 --- a/js/token-interface/tests/e2e/transfer.test.ts +++ b/js/token-interface/tests/e2e/transfer.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { ComputeBudgetProgram, Keypair } from '@solana/web3.js'; +import { ComputeBudgetProgram, Keypair, TransactionInstruction } from '@solana/web3.js'; import { TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, @@ -9,6 +9,7 @@ import { import { newAccountWithLamports } from '@lightprotocol/stateless.js'; import { createApproveInstructions, + buildTransferInstructionsNowrap, createAtaInstructions, createTransferInstructions, getAta, @@ -24,7 +25,7 @@ import { describe('transfer instructions', () => { const isSplOrT22CloseInstruction = ( - instruction: { programId: { equals: (other: unknown) => boolean }; data: Uint8Array }, + instruction: TransactionInstruction, ): boolean => (instruction.programId.equals(TOKEN_PROGRAM_ID) || instruction.programId.equals(TOKEN_2022_PROGRAM_ID)) && @@ -310,4 +311,77 @@ describe('transfer instructions', () => { ]), ).rejects.toThrow('custom program error'); }); + + it('nowrap path fails when balance exists only in SPL ATA, canonical path succeeds', async () => { + const fixture = await createMintFixture(); + const sender = await newAccountWithLamports(fixture.rpc, 1e9); + const recipient = Keypair.generate(); + const senderSplAta = getAssociatedTokenAddressSync( + fixture.mint, + sender.publicKey, + false, + TOKEN_PROGRAM_ID, + ); + + await mintCompressedToOwner(fixture, sender.publicKey, 2_000n); + + // Stage funds into sender SPL ATA. + const toSenderSplInstructions = await createTransferInstructions({ + rpc: fixture.rpc, + payer: fixture.payer.publicKey, + mint: fixture.mint, + sourceOwner: sender.publicKey, + authority: sender.publicKey, + recipient: sender.publicKey, + tokenProgram: TOKEN_PROGRAM_ID, + amount: 1_500n, + }); + await sendInstructions(fixture.rpc, fixture.payer, toSenderSplInstructions, [ + sender, + ]); + + const senderSplInfo = await fixture.rpc.getAccountInfo(senderSplAta); + expect(senderSplInfo).not.toBeNull(); + const senderSpl = unpackAccount( + senderSplAta, + senderSplInfo!, + TOKEN_PROGRAM_ID, + ); + expect(senderSpl.amount).toBe(1_500n); + + // Nowrap does not wrap SPL/T22 balances, so transfer should fail. + const nowrapInstructions = await buildTransferInstructionsNowrap({ + rpc: fixture.rpc, + payer: fixture.payer.publicKey, + mint: fixture.mint, + sourceOwner: sender.publicKey, + authority: sender.publicKey, + recipient: recipient.publicKey, + amount: 1_000n, + }); + await expect( + sendInstructions(fixture.rpc, fixture.payer, nowrapInstructions, [sender]), + ).rejects.toThrow('custom program error'); + + // Canonical transfer wraps SPL first, then succeeds. + const canonicalInstructions = await createTransferInstructions({ + rpc: fixture.rpc, + payer: fixture.payer.publicKey, + mint: fixture.mint, + sourceOwner: sender.publicKey, + authority: sender.publicKey, + recipient: recipient.publicKey, + amount: 1_000n, + }); + await sendInstructions(fixture.rpc, fixture.payer, canonicalInstructions, [ + sender, + ]); + + const recipientAta = await getAta({ + rpc: fixture.rpc, + owner: recipient.publicKey, + mint: fixture.mint, + }); + expect(recipientAta.parsed.amount).toBe(1_000n); + }); }); From 1671d05c2cd4d65c08130798f9a5d2f3619548b7 Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Sun, 29 Mar 2026 16:37:05 +0100 Subject: [PATCH 15/24] add test cov for race condition on transfer --- js/token-interface/tests/e2e/transfer.test.ts | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/js/token-interface/tests/e2e/transfer.test.ts b/js/token-interface/tests/e2e/transfer.test.ts index 91a42110b7..55ed559919 100644 --- a/js/token-interface/tests/e2e/transfer.test.ts +++ b/js/token-interface/tests/e2e/transfer.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { ComputeBudgetProgram, Keypair, TransactionInstruction } from '@solana/web3.js'; import { + createTransferCheckedInstruction as createSplTransferCheckedInstruction, TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, getAssociatedTokenAddressSync, @@ -21,6 +22,7 @@ import { getHotBalance, mintCompressedToOwner, sendInstructions, + TEST_TOKEN_DECIMALS, } from './helpers'; describe('transfer instructions', () => { @@ -384,4 +386,97 @@ describe('transfer instructions', () => { }); expect(recipientAta.parsed.amount).toBe(1_000n); }); + + it('wrapped transfer uses build-time SPL balance and leaves post-build SPL top-up as remainder', async () => { + const fixture = await createMintFixture(); + const sender = await newAccountWithLamports(fixture.rpc, 1e9); + const donor = await newAccountWithLamports(fixture.rpc, 1e9); + const recipient = Keypair.generate(); + const senderSplAta = getAssociatedTokenAddressSync( + fixture.mint, + sender.publicKey, + false, + TOKEN_PROGRAM_ID, + ); + const donorSplAta = getAssociatedTokenAddressSync( + fixture.mint, + donor.publicKey, + false, + TOKEN_PROGRAM_ID, + ); + + await mintCompressedToOwner(fixture, sender.publicKey, 3_000n); + await mintCompressedToOwner(fixture, donor.publicKey, 1_000n); + + // Stage 1: move 1_000 into sender SPL ATA. + const senderToSpl = await createTransferInstructions({ + rpc: fixture.rpc, + payer: fixture.payer.publicKey, + mint: fixture.mint, + sourceOwner: sender.publicKey, + authority: sender.publicKey, + recipient: sender.publicKey, + tokenProgram: TOKEN_PROGRAM_ID, + amount: 1_000n, + }); + await sendInstructions(fixture.rpc, fixture.payer, senderToSpl, [sender]); + + // Stage 2: fund donor SPL ATA with 500. + const donorToSpl = await createTransferInstructions({ + rpc: fixture.rpc, + payer: fixture.payer.publicKey, + mint: fixture.mint, + sourceOwner: donor.publicKey, + authority: donor.publicKey, + recipient: donor.publicKey, + tokenProgram: TOKEN_PROGRAM_ID, + amount: 500n, + }); + await sendInstructions(fixture.rpc, fixture.payer, donorToSpl, [donor]); + + // Build wrapped transfer now (captures sender SPL balance = 1_000). + const wrappedTransfer = await createTransferInstructions({ + rpc: fixture.rpc, + payer: fixture.payer.publicKey, + mint: fixture.mint, + sourceOwner: sender.publicKey, + authority: sender.publicKey, + recipient: recipient.publicKey, + amount: 800n, + }); + + // Race injection: send +300 to sender SPL ATA AFTER wrapped tx is built. + const injectAfterBuild = createSplTransferCheckedInstruction( + donorSplAta, + fixture.mint, + senderSplAta, + donor.publicKey, + 300n, + TEST_TOKEN_DECIMALS, + [], + TOKEN_PROGRAM_ID, + ); + await sendInstructions(fixture.rpc, fixture.payer, [injectAfterBuild], [donor]); + + // Wrapped transfer should still succeed. + await sendInstructions(fixture.rpc, fixture.payer, wrappedTransfer, [sender]); + + // Recipient receives transfer amount. + const recipientAta = await getAta({ + rpc: fixture.rpc, + owner: recipient.publicKey, + mint: fixture.mint, + }); + expect(recipientAta.parsed.amount).toBe(800n); + + // Sender SPL ATA keeps the post-build top-up remainder (300). + const senderSplInfo = await fixture.rpc.getAccountInfo(senderSplAta); + expect(senderSplInfo).not.toBeNull(); + const senderSpl = unpackAccount( + senderSplAta, + senderSplInfo!, + TOKEN_PROGRAM_ID, + ); + expect(senderSpl.amount).toBe(300n); + }); }); From e0354c822d3e2dccb0709fb4880755cfa5f8b9ae Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Sun, 29 Mar 2026 17:13:27 +0100 Subject: [PATCH 16/24] dedup rpc calls between load and transfer --- js/token-interface/src/instructions/load.ts | 25 ++++++++++++++++--- .../src/instructions/transfer.ts | 25 ++++++++++++------- 2 files changed, 37 insertions(+), 13 deletions(-) diff --git a/js/token-interface/src/instructions/load.ts b/js/token-interface/src/instructions/load.ts index b12d4412d3..ef74ae9607 100644 --- a/js/token-interface/src/instructions/load.ts +++ b/js/token-interface/src/instructions/load.ts @@ -873,6 +873,21 @@ export interface CreateLoadInstructionOptions authority?: PublicKey; wrap?: boolean; allowFrozen?: boolean; + splInterfaces?: SplInterface[]; + decimals?: number; +} + +function buildLoadOptions( + owner: PublicKey, + authority: PublicKey | undefined, + wrap: boolean, + splInterfaces: SplInterface[] | undefined, +): LoadOptions | undefined { + const options = toLoadOptions(owner, authority, wrap) ?? {}; + if (splInterfaces) { + options.splInterfaces = splInterfaces; + } + return Object.keys(options).length === 0 ? undefined : options; } export async function createLoadInstructions({ @@ -883,15 +898,16 @@ export async function createLoadInstructions({ authority, wrap = true, allowFrozen = false, + splInterfaces, + decimals, }: CreateLoadInstructionOptions): Promise { const targetAta = getAtaAddress({ owner, mint }); - const loadOptions = toLoadOptions(owner, authority, wrap); + const loadOptions = buildLoadOptions(owner, authority, wrap, splInterfaces); assertV2Enabled(); payer ??= owner; const authorityPubkey = loadOptions?.delegatePubkey ?? owner; - const mintInfoPromise = getMint(rpc, mint); let accountView: AccountView; try { accountView = await _getAtaView( @@ -909,7 +925,8 @@ export async function createLoadInstructions({ } throw e; } - const decimals = (await mintInfoPromise).mint.decimals; + + const resolvedDecimals = decimals ?? (await getMint(rpc, mint)).mint.decimals; if (!owner.equals(authorityPubkey)) { if (!isAuthorityForAccount(accountView, authorityPubkey)) { @@ -927,7 +944,7 @@ export async function createLoadInstructions({ targetAta, undefined, authorityPubkey, - decimals, + resolvedDecimals, allowFrozen, ); diff --git a/js/token-interface/src/instructions/transfer.ts b/js/token-interface/src/instructions/transfer.ts index b037e55b1a..2a1d9ad6a8 100644 --- a/js/token-interface/src/instructions/transfer.ts +++ b/js/token-interface/src/instructions/transfer.ts @@ -66,6 +66,11 @@ export async function buildTransferInstructions({ amount, }: CreateTransferInstructionsInput): Promise { const amountBigInt = toBigIntAmount(amount); + const recipientTokenProgramId = tokenProgram ?? LIGHT_TOKEN_PROGRAM_ID; + const decimals = await getMintDecimals(rpc, mint); + const transferSplInterfaces = recipientTokenProgramId.equals(LIGHT_TOKEN_PROGRAM_ID) + ? undefined + : await getSplInterfaces(rpc, mint); const senderLoadInstructions = await createLoadInstructions({ rpc, payer, @@ -73,14 +78,14 @@ export async function buildTransferInstructions({ mint, authority, wrap: true, + decimals, + splInterfaces: transferSplInterfaces, }); - const recipientTokenProgramId = tokenProgram ?? LIGHT_TOKEN_PROGRAM_ID; const recipientAta = getAtaAddress({ owner: recipient, mint, programId: recipientTokenProgramId, }); - const decimals = await getMintDecimals(rpc, mint); const recipientLoadInstructions: TransactionInstruction[] = []; const senderAta = getAtaAddress({ owner: sourceOwner, @@ -98,8 +103,7 @@ export async function buildTransferInstructions({ decimals, }); } else { - const splInterfaces = await getSplInterfaces(rpc, mint); - const splInterface = splInterfaces.find( + const splInterface = transferSplInterfaces!.find( (info) => info.isInitialized && info.tokenProgramId.equals(recipientTokenProgramId), ); @@ -147,6 +151,11 @@ export async function buildTransferInstructionsNowrap({ amount, }: CreateTransferInstructionsInput): Promise { const amountBigInt = toBigIntAmount(amount); + const recipientTokenProgramId = tokenProgram ?? LIGHT_TOKEN_PROGRAM_ID; + const decimals = await getMintDecimals(rpc, mint); + const transferSplInterfaces = recipientTokenProgramId.equals(LIGHT_TOKEN_PROGRAM_ID) + ? undefined + : await getSplInterfaces(rpc, mint); const senderLoadInstructions = await createLoadInstructions({ rpc, payer, @@ -154,15 +163,14 @@ export async function buildTransferInstructionsNowrap({ mint, authority, wrap: false, + decimals, + splInterfaces: transferSplInterfaces, }); - - const recipientTokenProgramId = tokenProgram ?? LIGHT_TOKEN_PROGRAM_ID; const recipientAta = getAtaAddress({ owner: recipient, mint, programId: recipientTokenProgramId, }); - const decimals = await getMintDecimals(rpc, mint); const senderAta = getAtaAddress({ owner: sourceOwner, mint, @@ -180,8 +188,7 @@ export async function buildTransferInstructionsNowrap({ decimals, }); } else { - const splInterfaces = await getSplInterfaces(rpc, mint); - const splInterface = splInterfaces.find( + const splInterface = transferSplInterfaces!.find( (info) => info.isInitialized && info.tokenProgramId.equals(recipientTokenProgramId), ); From 6b7f9325ee0e5baa11a7452cc1052b147f8992b1 Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Sun, 29 Mar 2026 17:25:01 +0100 Subject: [PATCH 17/24] strict condition for spl interface fetch --- js/token-interface/src/instructions/load.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/js/token-interface/src/instructions/load.ts b/js/token-interface/src/instructions/load.ts index ef74ae9607..619a28199e 100644 --- a/js/token-interface/src/instructions/load.ts +++ b/js/token-interface/src/instructions/load.ts @@ -678,7 +678,6 @@ async function _buildLoadInstructions( let splInterface: SplInterface | undefined; const needsSplInfo = - wrap || ataType === "spl" || ataType === "token2022" || splBalance > BigInt(0) || From 283464ebcc9856bc7d4d7a95a1830d3207524895 Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Sun, 29 Mar 2026 17:29:57 +0100 Subject: [PATCH 18/24] concurrently fetch getMintDecimals and getSplInterfaces --- .../src/instructions/transfer.ts | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/js/token-interface/src/instructions/transfer.ts b/js/token-interface/src/instructions/transfer.ts index 2a1d9ad6a8..ee617a8357 100644 --- a/js/token-interface/src/instructions/transfer.ts +++ b/js/token-interface/src/instructions/transfer.ts @@ -67,10 +67,12 @@ export async function buildTransferInstructions({ }: CreateTransferInstructionsInput): Promise { const amountBigInt = toBigIntAmount(amount); const recipientTokenProgramId = tokenProgram ?? LIGHT_TOKEN_PROGRAM_ID; - const decimals = await getMintDecimals(rpc, mint); - const transferSplInterfaces = recipientTokenProgramId.equals(LIGHT_TOKEN_PROGRAM_ID) - ? undefined - : await getSplInterfaces(rpc, mint); + const [decimals, transferSplInterfaces] = await Promise.all([ + getMintDecimals(rpc, mint), + recipientTokenProgramId.equals(LIGHT_TOKEN_PROGRAM_ID) + ? Promise.resolve(undefined) + : getSplInterfaces(rpc, mint), + ]); const senderLoadInstructions = await createLoadInstructions({ rpc, payer, @@ -152,10 +154,12 @@ export async function buildTransferInstructionsNowrap({ }: CreateTransferInstructionsInput): Promise { const amountBigInt = toBigIntAmount(amount); const recipientTokenProgramId = tokenProgram ?? LIGHT_TOKEN_PROGRAM_ID; - const decimals = await getMintDecimals(rpc, mint); - const transferSplInterfaces = recipientTokenProgramId.equals(LIGHT_TOKEN_PROGRAM_ID) - ? undefined - : await getSplInterfaces(rpc, mint); + const [decimals, transferSplInterfaces] = await Promise.all([ + getMintDecimals(rpc, mint), + recipientTokenProgramId.equals(LIGHT_TOKEN_PROGRAM_ID) + ? Promise.resolve(undefined) + : getSplInterfaces(rpc, mint), + ]); const senderLoadInstructions = await createLoadInstructions({ rpc, payer, From 92fd88bdaaaabd86d8ab1195aa7643b73c0f08a0 Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Sun, 29 Mar 2026 17:36:00 +0100 Subject: [PATCH 19/24] build -> transfer --- js/token-interface/README.md | 20 +++++++------------ .../src/instructions/transfer.ts | 8 +++----- js/token-interface/src/kit/index.ts | 12 +++++------ js/token-interface/tests/e2e/transfer.test.ts | 4 ++-- js/token-interface/tests/unit/kit.test.ts | 4 ++-- .../tests/unit/public-api.test.ts | 4 ++-- 6 files changed, 22 insertions(+), 30 deletions(-) diff --git a/js/token-interface/README.md b/js/token-interface/README.md index dc76536c49..514e9c537d 100644 --- a/js/token-interface/README.md +++ b/js/token-interface/README.md @@ -41,17 +41,17 @@ const transferPlan = await createTransferInstructionPlan({ If you prefer Kit instruction arrays instead of plans: ```ts -import { buildTransferInstructions } from '@lightprotocol/token-interface/kit'; +import { createTransferInstructions } from '@lightprotocol/token-interface/kit'; ``` ## Canonical for web3.js users -Use `buildTransferInstructions` from the root export. +Use `createTransferInstructions` from the root export. ```ts -import { buildTransferInstructions } from '@lightprotocol/token-interface'; +import { createTransferInstructions } from '@lightprotocol/token-interface'; -const instructions = await buildTransferInstructions({ +const instructions = await createTransferInstructions({ rpc, payer: payer.publicKey, mint, @@ -64,12 +64,6 @@ const instructions = await buildTransferInstructions({ // add memo if needed, then build/sign/send transaction ``` -Backwards-compatible alias: - -```ts -import { createTransferInstructions } from '@lightprotocol/token-interface'; -``` - ## Raw single-instruction helpers Use these when you want manual orchestration: @@ -87,7 +81,7 @@ import { If you explicitly want to disable automatic sender wrapping, use: ```ts -import { buildTransferInstructionsNowrap } from '@lightprotocol/token-interface/instructions'; +import { createTransferInstructionsNowrap } from '@lightprotocol/token-interface/instructions'; ``` ## Read account @@ -103,7 +97,7 @@ console.log(account.amount, account.hotAmount, account.compressedAmount); - Only one compressed sender account is loaded per call; smaller ones are ignored for that call. - Transfer always builds checked semantics. -- Canonical builders always use wrap-enabled sender setup (`buildTransferInstructions`, `createLoadInstructions`, `createApproveInstructions`, `createRevokeInstructions`). -- If sender SPL/T22 balances were wrapped by the flow, source SPL/T22 ATAs are closed afterward. +- Canonical builders always use wrap-enabled sender setup (`createTransferInstructions`, `createLoadInstructions`, `createApproveInstructions`, `createRevokeInstructions`). +- If sender SPL/T22 balances are wrapped by the flow, source SPL/T22 ATAs are not auto-closed. - Recipient ATA is derived from `(recipient, mint, tokenProgram)`; default is light token program. - Recipient-side load is still intentionally disabled. \ No newline at end of file diff --git a/js/token-interface/src/instructions/transfer.ts b/js/token-interface/src/instructions/transfer.ts index ee617a8357..29e8158182 100644 --- a/js/token-interface/src/instructions/transfer.ts +++ b/js/token-interface/src/instructions/transfer.ts @@ -55,7 +55,7 @@ export function createTransferCheckedInstruction({ * Canonical web3.js transfer flow builder. * Returns an instruction array for a single transfer flow (setup + transfer). */ -export async function buildTransferInstructions({ +export async function createTransferInstructions({ rpc, payer, mint, @@ -142,7 +142,7 @@ export async function buildTransferInstructions({ /** * No-wrap transfer flow builder (advanced). */ -export async function buildTransferInstructionsNowrap({ +export async function createTransferInstructionsNowrap({ rpc, payer, mint, @@ -219,7 +219,5 @@ export async function buildTransferInstructionsNowrap({ export async function createTransferInstructionPlan( input: CreateTransferInstructionsInput, ) { - return toInstructionPlan(await buildTransferInstructions(input)); + return toInstructionPlan(await createTransferInstructions(input)); } - -export { buildTransferInstructions as createTransferInstructions }; diff --git a/js/token-interface/src/kit/index.ts b/js/token-interface/src/kit/index.ts index 17489c2db8..6aee74ebc3 100644 --- a/js/token-interface/src/kit/index.ts +++ b/js/token-interface/src/kit/index.ts @@ -1,7 +1,7 @@ import type { TransactionInstruction } from '@solana/web3.js'; import { - buildTransferInstructions as buildTransferInstructionsTx, - buildTransferInstructionsNowrap as buildTransferInstructionsNowrapTx, + createTransferInstructions as createTransferInstructionsTx, + createTransferInstructionsNowrap as createTransferInstructionsNowrapTx, createApproveInstructions as createApproveInstructionsTx, createApproveInstructionsNowrap as createApproveInstructionsNowrapTx, createAtaInstructions as createAtaInstructionsTx, @@ -61,16 +61,16 @@ export async function createLoadInstructions( return wrap(createLoadInstructionsTx(input)); } -export async function buildTransferInstructions( +export async function createTransferInstructions( input: CreateTransferInstructionsInput, ): Promise { - return wrap(buildTransferInstructionsTx(input)); + return wrap(createTransferInstructionsTx(input)); } -export async function buildTransferInstructionsNowrap( +export async function createTransferInstructionsNowrap( input: CreateTransferInstructionsInput, ): Promise { - return wrap(buildTransferInstructionsNowrapTx(input)); + return wrap(createTransferInstructionsNowrapTx(input)); } export async function createApproveInstructions( diff --git a/js/token-interface/tests/e2e/transfer.test.ts b/js/token-interface/tests/e2e/transfer.test.ts index 55ed559919..6b717dfa58 100644 --- a/js/token-interface/tests/e2e/transfer.test.ts +++ b/js/token-interface/tests/e2e/transfer.test.ts @@ -10,7 +10,7 @@ import { import { newAccountWithLamports } from '@lightprotocol/stateless.js'; import { createApproveInstructions, - buildTransferInstructionsNowrap, + createTransferInstructionsNowrap, createAtaInstructions, createTransferInstructions, getAta, @@ -352,7 +352,7 @@ describe('transfer instructions', () => { expect(senderSpl.amount).toBe(1_500n); // Nowrap does not wrap SPL/T22 balances, so transfer should fail. - const nowrapInstructions = await buildTransferInstructionsNowrap({ + const nowrapInstructions = await createTransferInstructionsNowrap({ rpc: fixture.rpc, payer: fixture.payer.publicKey, mint: fixture.mint, diff --git a/js/token-interface/tests/unit/kit.test.ts b/js/token-interface/tests/unit/kit.test.ts index 28af3b619b..f76fb70ade 100644 --- a/js/token-interface/tests/unit/kit.test.ts +++ b/js/token-interface/tests/unit/kit.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { Keypair } from '@solana/web3.js'; import { createAtaInstruction } from '../../src/instructions'; import { - buildTransferInstructions, + createTransferInstructions, createAtaInstructions, createTransferInstructionPlan, toKitInstructions, @@ -35,7 +35,7 @@ describe('kit adapter', () => { }); it('exports transfer builder and plan builder', () => { - expect(typeof buildTransferInstructions).toBe('function'); + expect(typeof createTransferInstructions).toBe('function'); expect(typeof createTransferInstructionPlan).toBe('function'); }); }); diff --git a/js/token-interface/tests/unit/public-api.test.ts b/js/token-interface/tests/unit/public-api.test.ts index f25408939d..37575cc1e8 100644 --- a/js/token-interface/tests/unit/public-api.test.ts +++ b/js/token-interface/tests/unit/public-api.test.ts @@ -3,7 +3,7 @@ import { Keypair } from '@solana/web3.js'; import { LIGHT_TOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; import { getAssociatedTokenAddress } from '../../src/read'; import { - buildTransferInstructions, + createTransferInstructions, MultiTransactionNotSupportedError, createAtaInstructions, createFreezeInstruction, @@ -70,6 +70,6 @@ describe('public api', () => { }); it('exports canonical transfer builder', () => { - expect(typeof buildTransferInstructions).toBe('function'); + expect(typeof createTransferInstructions).toBe('function'); }); }); From 6a3bc056d65b99df4bafbed1a0076420f685cd7e Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Sun, 29 Mar 2026 17:54:50 +0100 Subject: [PATCH 20/24] payer optional for all ixns + test cov --- js/token-interface/src/instructions/ata.ts | 24 +++++---- js/token-interface/src/instructions/load.ts | 7 +-- .../src/instructions/transfer.ts | 24 +++++---- js/token-interface/src/types.ts | 18 +++---- .../tests/e2e/approve-revoke.test.ts | 36 +++++++++++++ js/token-interface/tests/e2e/burn.test.ts | 17 +++++++ .../tests/e2e/freeze-thaw.test.ts | 41 +++++++++++++++ js/token-interface/tests/e2e/load.test.ts | 21 ++++++++ js/token-interface/tests/e2e/transfer.test.ts | 26 ++++++++++ .../tests/unit/instruction-builders.test.ts | 50 +++++++++++++++++++ 10 files changed, 232 insertions(+), 32 deletions(-) diff --git a/js/token-interface/src/instructions/ata.ts b/js/token-interface/src/instructions/ata.ts index b79849a263..a3ea9f779b 100644 --- a/js/token-interface/src/instructions/ata.ts +++ b/js/token-interface/src/instructions/ata.ts @@ -140,7 +140,7 @@ function encodeCreateAssociatedLightTokenAccountData( } export interface CreateAssociatedLightTokenAccountInstructionParams { - feePayer: PublicKey; + feePayer?: PublicKey; owner: PublicKey; mint: PublicKey; compressibleConfig?: CompressibleConfig | null; @@ -153,7 +153,7 @@ export interface CreateAssociatedLightTokenAccountInstructionParams { * Uses the default rent sponsor PDA by default. * * @param input Associated light-token account input. - * @param input.feePayer Fee payer public key. + * @param input.feePayer Optional fee payer public key. Defaults to owner. * @param input.owner Owner of the associated token account. * @param input.mint Mint address. * @param input.compressibleConfig Compressible configuration (defaults to rent sponsor config). @@ -170,6 +170,7 @@ export function createAssociatedLightTokenAccountInstruction( rentPayerPda = LIGHT_TOKEN_RENT_SPONSOR, }: CreateAssociatedLightTokenAccountInstructionParams, ): TransactionInstruction { + const effectiveFeePayer = feePayer ?? owner; const associatedTokenAccount = getAssociatedLightTokenAddress(owner, mint); const data = encodeCreateAssociatedLightTokenAccountData( @@ -195,7 +196,7 @@ export function createAssociatedLightTokenAccountInstruction( }[] = [ { pubkey: owner, isSigner: false, isWritable: false }, { pubkey: mint, isSigner: false, isWritable: false }, - { pubkey: feePayer, isSigner: true, isWritable: true }, + { pubkey: effectiveFeePayer, isSigner: true, isWritable: true }, { pubkey: associatedTokenAccount, isSigner: false, @@ -227,7 +228,7 @@ export function createAssociatedLightTokenAccountInstruction( * Uses the default rent sponsor PDA by default. * * @param input Associated light-token account input. - * @param input.feePayer Fee payer public key. + * @param input.feePayer Optional fee payer public key. Defaults to owner. * @param input.owner Owner of the associated token account. * @param input.mint Mint address. * @param input.compressibleConfig Compressible configuration (defaults to rent sponsor config). @@ -244,6 +245,7 @@ export function createAssociatedLightTokenAccountIdempotentInstruction( rentPayerPda = LIGHT_TOKEN_RENT_SPONSOR, }: CreateAssociatedLightTokenAccountInstructionParams, ): TransactionInstruction { + const effectiveFeePayer = feePayer ?? owner; const associatedTokenAccount = getAssociatedLightTokenAddress(owner, mint); const data = encodeCreateAssociatedLightTokenAccountData( @@ -260,7 +262,7 @@ export function createAssociatedLightTokenAccountIdempotentInstruction( }[] = [ { pubkey: owner, isSigner: false, isWritable: false }, { pubkey: mint, isSigner: false, isWritable: false }, - { pubkey: feePayer, isSigner: true, isWritable: true }, + { pubkey: effectiveFeePayer, isSigner: true, isWritable: true }, { pubkey: associatedTokenAccount, isSigner: false, @@ -297,7 +299,7 @@ export interface LightTokenConfig { } export interface CreateAssociatedTokenAccountInstructionInput { - payer: PublicKey; + payer?: PublicKey; associatedToken: PublicKey; owner: PublicKey; mint: PublicKey; @@ -328,12 +330,13 @@ function createAssociatedTokenAccountInstruction({ associatedTokenProgramId, lightTokenConfig, }: CreateAssociatedTokenAccountInstructionInput): TransactionInstruction { + const effectivePayer = payer ?? owner; const effectiveAssociatedTokenProgramId = associatedTokenProgramId ?? getAtaProgramId(programId); if (programId.equals(LIGHT_TOKEN_PROGRAM_ID)) { return createAssociatedLightTokenAccountInstruction({ - feePayer: payer, + feePayer: effectivePayer, owner, mint, compressibleConfig: lightTokenConfig?.compressibleConfig, @@ -342,7 +345,7 @@ function createAssociatedTokenAccountInstruction({ }); } else { return createSplAssociatedTokenAccountInstruction( - payer, + effectivePayer, associatedToken, owner, mint, @@ -374,12 +377,13 @@ function createAssociatedTokenAccountIdempotentInstruction({ associatedTokenProgramId, lightTokenConfig, }: CreateAssociatedTokenAccountInstructionInput): TransactionInstruction { + const effectivePayer = payer ?? owner; const effectiveAssociatedTokenProgramId = associatedTokenProgramId ?? getAtaProgramId(programId); if (programId.equals(LIGHT_TOKEN_PROGRAM_ID)) { return createAssociatedLightTokenAccountIdempotentInstruction({ - feePayer: payer, + feePayer: effectivePayer, owner, mint, compressibleConfig: lightTokenConfig?.compressibleConfig, @@ -388,7 +392,7 @@ function createAssociatedTokenAccountIdempotentInstruction({ }); } else { return createSplAssociatedTokenAccountIdempotentInstruction( - payer, + effectivePayer, associatedToken, owner, mint, diff --git a/js/token-interface/src/instructions/load.ts b/js/token-interface/src/instructions/load.ts index 619a28199e..a5616439f5 100644 --- a/js/token-interface/src/instructions/load.ts +++ b/js/token-interface/src/instructions/load.ts @@ -254,7 +254,7 @@ function buildInputTokenData( * - For SPL destinations: Provide splInterface and decimals * * @param input Decompress instruction input. - * @param input.payer Fee payer public key. + * @param input.payer Optional payer public key. Defaults to authority, then owner. * @param input.inputCompressedTokenAccounts Input light-token accounts. * @param input.toAddress Destination token account address (light-token or SPL associated token account). * @param input.amount Amount to decompress. @@ -276,7 +276,7 @@ export function createDecompressInstruction({ maxTopUp, authority, }: { - payer: PublicKey; + payer?: PublicKey; inputCompressedTokenAccounts: ParsedTokenAccount[]; toAddress: PublicKey; amount: bigint; @@ -476,6 +476,7 @@ export function createDecompressInstruction({ } return authorityIndex; })(); + const effectivePayer = payer ?? authority ?? owner; const keys = [ // 0: light_system_program (non-mutable) @@ -485,7 +486,7 @@ export function createDecompressInstruction({ isWritable: false, }, // 1: fee_payer (signer, mutable) - { pubkey: payer, isSigner: true, isWritable: true }, + { pubkey: effectivePayer, isSigner: true, isWritable: true }, // 2: cpi_authority_pda { pubkey: deriveCpiAuthorityPda(), diff --git a/js/token-interface/src/instructions/transfer.ts b/js/token-interface/src/instructions/transfer.ts index 29e8158182..95c7dd0637 100644 --- a/js/token-interface/src/instructions/transfer.ts +++ b/js/token-interface/src/instructions/transfer.ts @@ -29,6 +29,8 @@ export function createTransferCheckedInstruction({ data.writeBigUInt64LE(BigInt(amount), 1); data.writeUInt8(decimals, 9); + const effectivePayer = payer ?? authority; + return new TransactionInstruction({ programId: LIGHT_TOKEN_PROGRAM_ID, keys: [ @@ -38,12 +40,12 @@ export function createTransferCheckedInstruction({ { pubkey: authority, isSigner: true, - isWritable: payer.equals(authority), + isWritable: effectivePayer.equals(authority), }, { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, { - pubkey: payer, - isSigner: !payer.equals(authority), + pubkey: effectivePayer, + isSigner: !effectivePayer.equals(authority), isWritable: true, }, ], @@ -65,6 +67,7 @@ export async function createTransferInstructions({ tokenProgram, amount, }: CreateTransferInstructionsInput): Promise { + const effectivePayer = payer ?? authority; const amountBigInt = toBigIntAmount(amount); const recipientTokenProgramId = tokenProgram ?? LIGHT_TOKEN_PROGRAM_ID; const [decimals, transferSplInterfaces] = await Promise.all([ @@ -75,7 +78,7 @@ export async function createTransferInstructions({ ]); const senderLoadInstructions = await createLoadInstructions({ rpc, - payer, + payer: effectivePayer, owner: sourceOwner, mint, authority, @@ -100,7 +103,7 @@ export async function createTransferInstructions({ destination: recipientAta, mint, authority, - payer, + payer: effectivePayer, amount: amountBigInt, decimals, }); @@ -122,14 +125,14 @@ export async function createTransferInstructions({ amount: amountBigInt, splInterface, decimals, - payer, + payer: effectivePayer, }); } return [ ...senderLoadInstructions, createAtaInstruction({ - payer, + payer: effectivePayer, owner: recipient, mint, programId: recipientTokenProgramId, @@ -152,6 +155,7 @@ export async function createTransferInstructionsNowrap({ tokenProgram, amount, }: CreateTransferInstructionsInput): Promise { + const effectivePayer = payer ?? authority; const amountBigInt = toBigIntAmount(amount); const recipientTokenProgramId = tokenProgram ?? LIGHT_TOKEN_PROGRAM_ID; const [decimals, transferSplInterfaces] = await Promise.all([ @@ -162,7 +166,7 @@ export async function createTransferInstructionsNowrap({ ]); const senderLoadInstructions = await createLoadInstructions({ rpc, - payer, + payer: effectivePayer, owner: sourceOwner, mint, authority, @@ -187,7 +191,7 @@ export async function createTransferInstructionsNowrap({ destination: recipientAta, mint, authority, - payer, + payer: effectivePayer, amount: amountBigInt, decimals, }); @@ -209,7 +213,7 @@ export async function createTransferInstructionsNowrap({ amount: amountBigInt, splInterface, decimals, - payer, + payer: effectivePayer, }); } diff --git a/js/token-interface/src/types.ts b/js/token-interface/src/types.ts index 535b7b30f5..93f24ada64 100644 --- a/js/token-interface/src/types.ts +++ b/js/token-interface/src/types.ts @@ -39,18 +39,18 @@ export interface GetAtaInput extends AtaOwnerInput { } export interface CreateAtaInstructionsInput extends AtaOwnerInput { - payer: PublicKey; + payer?: PublicKey; programId?: PublicKey; } export interface CreateLoadInstructionsInput extends AtaOwnerInput { rpc: Rpc; - payer: PublicKey; + payer?: PublicKey; } export interface CreateTransferInstructionsInput { rpc: Rpc; - payer: PublicKey; + payer?: PublicKey; mint: PublicKey; sourceOwner: PublicKey; authority: PublicKey; @@ -61,19 +61,19 @@ export interface CreateTransferInstructionsInput { export interface CreateApproveInstructionsInput extends AtaOwnerInput { rpc: Rpc; - payer: PublicKey; + payer?: PublicKey; delegate: PublicKey; amount: number | bigint; } export interface CreateRevokeInstructionsInput extends AtaOwnerInput { rpc: Rpc; - payer: PublicKey; + payer?: PublicKey; } export interface CreateBurnInstructionsInput extends AtaOwnerInput { rpc: Rpc; - payer: PublicKey; + payer?: PublicKey; authority: PublicKey; amount: number | bigint; /** When set, emits BurnChecked; otherwise Burn. */ @@ -96,13 +96,13 @@ export interface CreateRawThawInstructionInput { export interface CreateFreezeInstructionsInput extends AtaOwnerInput { rpc: Rpc; - payer: PublicKey; + payer?: PublicKey; freezeAuthority: PublicKey; } export interface CreateThawInstructionsInput extends AtaOwnerInput { rpc: Rpc; - payer: PublicKey; + payer?: PublicKey; freezeAuthority: PublicKey; } @@ -114,7 +114,7 @@ export interface CreateRawTransferInstructionInput { destination: PublicKey; mint: PublicKey; authority: PublicKey; - payer: PublicKey; + payer?: PublicKey; amount: number | bigint; decimals: number; } diff --git a/js/token-interface/tests/e2e/approve-revoke.test.ts b/js/token-interface/tests/e2e/approve-revoke.test.ts index db20219d90..d7e3aa23ea 100644 --- a/js/token-interface/tests/e2e/approve-revoke.test.ts +++ b/js/token-interface/tests/e2e/approve-revoke.test.ts @@ -69,4 +69,40 @@ describe('approve and revoke instructions', () => { expect(revoked.delegate).toBeNull(); expect(revoked.delegatedAmount).toBe(0n); }); + + it('defaults payer to owner when omitted', async () => { + const fixture = await createMintFixture(); + const owner = await newAccountWithLamports(fixture.rpc, 1e9); + const delegate = Keypair.generate(); + const tokenAccount = getAtaAddress({ + owner: owner.publicKey, + mint: fixture.mint, + }); + + await mintCompressedToOwner(fixture, owner.publicKey, 2_000n); + + const approveInstructions = await createApproveInstructions({ + rpc: fixture.rpc, + owner: owner.publicKey, + mint: fixture.mint, + delegate: delegate.publicKey, + amount: 500n, + }); + await sendInstructions(fixture.rpc, fixture.payer, approveInstructions, [owner]); + + const delegated = await getHotDelegate(fixture.rpc, tokenAccount); + expect(delegated.delegate?.toBase58()).toBe(delegate.publicKey.toBase58()); + expect(delegated.delegatedAmount).toBe(500n); + + const revokeInstructions = await createRevokeInstructions({ + rpc: fixture.rpc, + owner: owner.publicKey, + mint: fixture.mint, + }); + await sendInstructions(fixture.rpc, fixture.payer, revokeInstructions, [owner]); + + const revoked = await getHotDelegate(fixture.rpc, tokenAccount); + expect(revoked.delegate).toBeNull(); + expect(revoked.delegatedAmount).toBe(0n); + }); }); diff --git a/js/token-interface/tests/e2e/burn.test.ts b/js/token-interface/tests/e2e/burn.test.ts index 1974a82d63..85653dc2a8 100644 --- a/js/token-interface/tests/e2e/burn.test.ts +++ b/js/token-interface/tests/e2e/burn.test.ts @@ -69,4 +69,21 @@ describe('burn instructions', () => { }), ).rejects.toThrow('Signer is not the owner or a delegate of the account.'); }); + + it('builds burn instructions when payer is omitted', async () => { + const fixture = await createMintFixture(); + const owner = await newAccountWithLamports(fixture.rpc, 1e9); + + await mintCompressedToOwner(fixture, owner.publicKey, 500n); + + const burnInstructions = await createBurnInstructions({ + rpc: fixture.rpc, + owner: owner.publicKey, + mint: fixture.mint, + authority: owner.publicKey, + amount: 100n, + }); + + expect(burnInstructions.length).toBeGreaterThan(0); + }); }); diff --git a/js/token-interface/tests/e2e/freeze-thaw.test.ts b/js/token-interface/tests/e2e/freeze-thaw.test.ts index 74dd6b91ce..8db81792c6 100644 --- a/js/token-interface/tests/e2e/freeze-thaw.test.ts +++ b/js/token-interface/tests/e2e/freeze-thaw.test.ts @@ -137,4 +137,45 @@ describe('freeze and thaw instructions', () => { }); expect(recipientAta.parsed.amount).toBe(100n); }); + + it('defaults payer to owner when omitted for freeze/thaw builders', async () => { + const fixture = await createMintFixture({ withFreezeAuthority: true }); + const owner = await newAccountWithLamports(fixture.rpc, 1e9); + const tokenAccount = getAtaAddress({ + owner: owner.publicKey, + mint: fixture.mint, + }); + + await mintCompressedToOwner(fixture, owner.publicKey, 1_000n); + + await sendInstructions( + fixture.rpc, + fixture.payer, + await createFreezeInstructions({ + rpc: fixture.rpc, + owner: owner.publicKey, + mint: fixture.mint, + freezeAuthority: fixture.freezeAuthority!.publicKey, + }), + [owner, fixture.freezeAuthority!], + ); + expect(await getHotState(fixture.rpc, tokenAccount)).toBe( + AccountState.Frozen, + ); + + await sendInstructions( + fixture.rpc, + fixture.payer, + await createThawInstructions({ + rpc: fixture.rpc, + owner: owner.publicKey, + mint: fixture.mint, + freezeAuthority: fixture.freezeAuthority!.publicKey, + }), + [fixture.freezeAuthority!], + ); + expect(await getHotState(fixture.rpc, tokenAccount)).toBe( + AccountState.Initialized, + ); + }); }); diff --git a/js/token-interface/tests/e2e/load.test.ts b/js/token-interface/tests/e2e/load.test.ts index c8b7e0e306..caf2c6cc8f 100644 --- a/js/token-interface/tests/e2e/load.test.ts +++ b/js/token-interface/tests/e2e/load.test.ts @@ -109,4 +109,25 @@ describe('load instructions', () => { ), ).toEqual([200n]); }); + + it('defaults payer to owner when omitted', async () => { + const fixture = await createMintFixture(); + const owner = await newAccountWithLamports(fixture.rpc, 1e9); + const tokenAccount = getAtaAddress({ + owner: owner.publicKey, + mint: fixture.mint, + }); + + await mintCompressedToOwner(fixture, owner.publicKey, 250n); + + const instructions = await createLoadInstructions({ + rpc: fixture.rpc, + owner: owner.publicKey, + mint: fixture.mint, + }); + + await sendInstructions(fixture.rpc, fixture.payer, instructions, [owner]); + + expect(await getHotBalance(fixture.rpc, tokenAccount)).toBe(250n); + }); }); diff --git a/js/token-interface/tests/e2e/transfer.test.ts b/js/token-interface/tests/e2e/transfer.test.ts index 6b717dfa58..4410186790 100644 --- a/js/token-interface/tests/e2e/transfer.test.ts +++ b/js/token-interface/tests/e2e/transfer.test.ts @@ -98,6 +98,32 @@ describe('transfer instructions', () => { expect(await getHotBalance(fixture.rpc, senderAta)).toBe(3_000n); }); + it('defaults payer to authority when omitted', async () => { + const fixture = await createMintFixture(); + const sender = await newAccountWithLamports(fixture.rpc, 1e9); + const recipient = Keypair.generate(); + + await mintCompressedToOwner(fixture, sender.publicKey, 1_000n); + + const instructions = await createTransferInstructions({ + rpc: fixture.rpc, + mint: fixture.mint, + sourceOwner: sender.publicKey, + authority: sender.publicKey, + recipient: recipient.publicKey, + amount: 400n, + }); + + await sendInstructions(fixture.rpc, fixture.payer, instructions, [sender]); + + const recipientAta = await getAta({ + rpc: fixture.rpc, + owner: recipient.publicKey, + mint: fixture.mint, + }); + expect(recipientAta.parsed.amount).toBe(400n); + }); + it('supports non-light destination path with SPL ATA recipient', async () => { const fixture = await createMintFixture(); const sender = await newAccountWithLamports(fixture.rpc, 1e9); diff --git a/js/token-interface/tests/unit/instruction-builders.test.ts b/js/token-interface/tests/unit/instruction-builders.test.ts index de435478ff..5e213be4c4 100644 --- a/js/token-interface/tests/unit/instruction-builders.test.ts +++ b/js/token-interface/tests/unit/instruction-builders.test.ts @@ -80,6 +80,28 @@ describe('instruction builders', () => { expect(instruction.keys[5].isWritable).toBe(true); }); + it('defaults transfer payer to authority when omitted', () => { + const source = Keypair.generate().publicKey; + const destination = Keypair.generate().publicKey; + const mint = Keypair.generate().publicKey; + const authority = Keypair.generate().publicKey; + + const instruction = createTransferCheckedInstruction({ + source, + destination, + mint, + authority, + amount: 1n, + decimals: 9, + }); + + expect(instruction.keys[3].pubkey.equals(authority)).toBe(true); + expect(instruction.keys[3].isSigner).toBe(true); + expect(instruction.keys[3].isWritable).toBe(true); + expect(instruction.keys[5].pubkey.equals(authority)).toBe(true); + expect(instruction.keys[5].isSigner).toBe(false); + }); + it('creates approve, revoke, freeze, and thaw instructions', () => { const tokenAccount = Keypair.generate().publicKey; const owner = Keypair.generate().publicKey; @@ -193,6 +215,20 @@ describe('instruction builders', () => { expect(instruction.keys[5].pubkey.equals(TOKEN_PROGRAM_ID)).toBe(true); }); + it('defaults ATA payer to owner when omitted', () => { + const owner = Keypair.generate().publicKey; + const mint = Keypair.generate().publicKey; + + const instruction = createAtaInstruction({ + owner, + mint, + }); + + expect(instruction.programId.equals(LIGHT_TOKEN_PROGRAM_ID)).toBe(true); + expect(instruction.keys[2].pubkey.equals(owner)).toBe(true); + expect(instruction.keys[2].isSigner).toBe(true); + }); + it('omits light-token config/rent keys when compressible config is null', () => { const feePayer = Keypair.generate().publicKey; const owner = Keypair.generate().publicKey; @@ -209,4 +245,18 @@ describe('instruction builders', () => { expect(instruction.keys).toHaveLength(5); }); + it('defaults associated light-token fee payer to owner when omitted', () => { + const owner = Keypair.generate().publicKey; + const mint = Keypair.generate().publicKey; + + const instruction = createAssociatedLightTokenAccountInstruction({ + owner, + mint, + }); + + expect(instruction.programId.equals(LIGHT_TOKEN_PROGRAM_ID)).toBe(true); + expect(instruction.keys[2].pubkey.equals(owner)).toBe(true); + expect(instruction.keys[2].isSigner).toBe(true); + }); + }); From c16c59b3b10911ed35952d48027cc5e6b198e958 Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Sun, 29 Mar 2026 18:11:25 +0100 Subject: [PATCH 21/24] stricter typing --- js/token-interface/src/account.ts | 15 ++- js/token-interface/src/instructions/load.ts | 98 ++++++++++--------- .../src/instructions/transfer.ts | 10 +- js/token-interface/src/read/get-account.ts | 66 +++++++++---- js/token-interface/src/spl-interface.ts | 8 +- 5 files changed, 125 insertions(+), 72 deletions(-) diff --git a/js/token-interface/src/account.ts b/js/token-interface/src/account.ts index 2a49f5af46..bc54a07536 100644 --- a/js/token-interface/src/account.ts +++ b/js/token-interface/src/account.ts @@ -3,6 +3,7 @@ import { parseLightTokenCold, parseLightTokenHot, } from './read/get-account'; +import { Buffer } from 'buffer'; import { LIGHT_TOKEN_PROGRAM_ID, type ParsedTokenAccount, @@ -18,6 +19,18 @@ import type { const ZERO = BigInt(0); +function toBufferAccountInfo( + accountInfo: T, +): Omit & { data: Buffer } { + if (Buffer.isBuffer(accountInfo.data)) { + return accountInfo as Omit & { data: Buffer }; + } + return { + ...accountInfo, + data: Buffer.from(accountInfo.data), + }; +} + function toBigIntAmount(account: ParsedTokenAccount): bigint { return BigInt(account.parsed.amount.toString()); } @@ -135,7 +148,7 @@ export async function getAtaOrNull({ const hotParsed = hotInfo && hotInfo.owner.equals(LIGHT_TOKEN_PROGRAM_ID) - ? parseLightTokenHot(address, hotInfo as any).parsed + ? parseLightTokenHot(address, toBufferAccountInfo(hotInfo)).parsed : null; const { selected, ignored } = selectPrimaryCompressedAccount( diff --git a/js/token-interface/src/instructions/load.ts b/js/token-interface/src/instructions/load.ts index a5616439f5..7b24a81daa 100644 --- a/js/token-interface/src/instructions/load.ts +++ b/js/token-interface/src/instructions/load.ts @@ -541,55 +541,57 @@ export function createDecompressInstruction({ function getCanonicalCompressedTokenAccountFromAtaSources( sources: TokenAccountSource[], ): ParsedTokenAccount | null { - const candidates = sources - .filter((source) => source.loadContext !== undefined) - .filter((source) => COLD_SOURCE_TYPES.has(source.type)) - .map((source) => { - const fullData = source.accountInfo.data; - const discriminatorBytes = fullData.subarray( - 0, - Math.min(8, fullData.length), - ); - const accountDataBytes = - fullData.length > 8 ? fullData.subarray(8) : Buffer.alloc(0); - - const compressedAccount = { - treeInfo: source.loadContext!.treeInfo, - hash: source.loadContext!.hash, - leafIndex: source.loadContext!.leafIndex, - proveByIndex: source.loadContext!.proveByIndex, - owner: source.accountInfo.owner, - lamports: bn(source.accountInfo.lamports), - address: null, - data: - fullData.length === 0 - ? null - : { - discriminator: Array.from(discriminatorBytes), - data: Buffer.from(accountDataBytes), - dataHash: new Array(32).fill(0), - }, - readOnly: false, - }; - - const state = !source.parsed.isInitialized - ? 0 - : source.parsed.isFrozen - ? 2 - : 1; - - return { - compressedAccount: compressedAccount as any, - parsed: { - mint: source.parsed.mint, - owner: source.parsed.owner, - amount: bn(source.parsed.amount.toString()), - delegate: source.parsed.delegate, - state, - tlv: source.parsed.tlvData.length > 0 ? source.parsed.tlvData : null, - }, - } satisfies ParsedTokenAccount; + const candidates: ParsedTokenAccount[] = []; + for (const source of sources) { + if (!COLD_SOURCE_TYPES.has(source.type) || !source.loadContext) { + continue; + } + const loadContext = source.loadContext; + const fullData = source.accountInfo.data; + const discriminatorBytes = fullData.subarray( + 0, + Math.min(8, fullData.length), + ); + const accountDataBytes = + fullData.length > 8 ? fullData.subarray(8) : Buffer.alloc(0); + + const compressedAccount = { + treeInfo: loadContext.treeInfo, + hash: loadContext.hash, + leafIndex: loadContext.leafIndex, + proveByIndex: loadContext.proveByIndex, + owner: source.accountInfo.owner, + lamports: bn(source.accountInfo.lamports), + address: null, + data: + fullData.length === 0 + ? null + : { + discriminator: Array.from(discriminatorBytes), + data: Buffer.from(accountDataBytes), + dataHash: new Array(32).fill(0), + }, + readOnly: false, + } satisfies ParsedTokenAccount["compressedAccount"]; + + const state = !source.parsed.isInitialized + ? 0 + : source.parsed.isFrozen + ? 2 + : 1; + + candidates.push({ + compressedAccount, + parsed: { + mint: source.parsed.mint, + owner: source.parsed.owner, + amount: bn(source.parsed.amount.toString()), + delegate: source.parsed.delegate, + state, + tlv: source.parsed.tlvData.length > 0 ? source.parsed.tlvData : null, + }, }); + } if (candidates.length === 0) { return null; diff --git a/js/token-interface/src/instructions/transfer.ts b/js/token-interface/src/instructions/transfer.ts index 95c7dd0637..ffa52690f0 100644 --- a/js/token-interface/src/instructions/transfer.ts +++ b/js/token-interface/src/instructions/transfer.ts @@ -108,7 +108,10 @@ export async function createTransferInstructions({ decimals, }); } else { - const splInterface = transferSplInterfaces!.find( + if (!transferSplInterfaces) { + throw new Error("Missing SPL interfaces for non-light transfer path."); + } + const splInterface = transferSplInterfaces.find( (info) => info.isInitialized && info.tokenProgramId.equals(recipientTokenProgramId), ); @@ -196,7 +199,10 @@ export async function createTransferInstructionsNowrap({ decimals, }); } else { - const splInterface = transferSplInterfaces!.find( + if (!transferSplInterfaces) { + throw new Error("Missing SPL interfaces for non-light transfer path."); + } + const splInterface = transferSplInterfaces.find( (info) => info.isInitialized && info.tokenProgramId.equals(recipientTokenProgramId), ); diff --git a/js/token-interface/src/read/get-account.ts b/js/token-interface/src/read/get-account.ts index c71d8ac871..befe9da844 100644 --- a/js/token-interface/src/read/get-account.ts +++ b/js/token-interface/src/read/get-account.ts @@ -72,6 +72,10 @@ export interface AccountView { _mint?: PublicKey; } +type CompressedByOwnerResult = Awaited< + ReturnType +>; + function toErrorMessage(error: unknown): string { if (error instanceof Error) return error.message; return String(error); @@ -276,14 +280,25 @@ function convertTokenDataToAccount( }; } +function requireCompressedAccountData( + compressedAccount: CompressedAccountWithMerkleContext, +): NonNullable { + const data = compressedAccount.data; + if (!data) { + throw new Error('Compressed account is missing token data'); + } + return data; +} + /** Convert compressed account to AccountInfo */ function toAccountInfo( compressedAccount: CompressedAccountWithMerkleContext, ): AccountInfo { + const compressedData = requireCompressedAccountData(compressedAccount); const dataDiscriminatorBuffer: Buffer = Buffer.from( - compressedAccount.data!.discriminator, + compressedData.discriminator, ); - const dataBuffer: Buffer = Buffer.from(compressedAccount.data!.data); + const dataBuffer: Buffer = Buffer.from(compressedData.data); const data: Buffer = Buffer.concat([dataDiscriminatorBuffer, dataBuffer]); return { @@ -331,7 +346,7 @@ export function parseLightTokenCold( parsed: Account; isCold: true; } { - const parsed = parseTokenData(compressedAccount.data!.data); + const parsed = parseTokenData(requireCompressedAccountData(compressedAccount).data); if (!parsed) throw new Error('Invalid token data'); return { accountInfo: toAccountInfo(compressedAccount), @@ -544,16 +559,26 @@ async function getUnifiedAccountView( fetchByOwner: { owner: PublicKey; mint: PublicKey } | undefined, wrap: boolean, ): Promise { + if (!address && !fetchByOwner) { + throw new Error(ERR_FETCH_BY_OWNER_REQUIRED); + } + // Canonical address for unified mode is always the light-token associated token account - const lightTokenAta = - address ?? - getAssociatedTokenAddressSync( - fetchByOwner!.mint, - fetchByOwner!.owner, + let lightTokenAta: PublicKey; + if (address) { + lightTokenAta = address; + } else { + if (!fetchByOwner) { + throw new Error(ERR_FETCH_BY_OWNER_REQUIRED); + } + lightTokenAta = getAssociatedTokenAddressSync( + fetchByOwner.mint, + fetchByOwner.owner, false, LIGHT_TOKEN_PROGRAM_ID, getAtaProgramId(LIGHT_TOKEN_PROGRAM_ID), ); + } const fetchPromises: Promise<{ accountInfo: AccountInfo; @@ -607,7 +632,7 @@ async function getUnifiedAccountView( ? rpc.getCompressedTokenAccountsByOwner(fetchByOwner.owner, { mint: fetchByOwner.mint, }) - : rpc.getCompressedTokenAccountsByOwner(address!); + : rpc.getCompressedTokenAccountsByOwner(lightTokenAta); const hotResults = await Promise.allSettled(fetchPromises); const ownerMismatchErrors: TokenInvalidAccountOwnerError[] = []; @@ -849,7 +874,7 @@ async function getSplOrToken2022AccountView( ? rpc.getCompressedTokenAccountsByOwner(fetchByOwner.owner, { mint: fetchByOwner.mint, }) - : Promise.resolve({ items: [] as any[] }), + : Promise.resolve(null as CompressedByOwnerResult | null), ]); const sources: TokenAccountSource[] = []; @@ -860,9 +885,7 @@ async function getSplOrToken2022AccountView( if (hotResult.status === 'rejected') unexpectedErrors.push(hotResult.reason); const coldAccounts = - coldResult.status === 'fulfilled' - ? coldResult.value - : ({ items: [] as any[] } as const); + coldResult.status === 'fulfilled' ? coldResult.value : null; if (coldResult.status === 'rejected') unexpectedErrors.push(coldResult.reason); @@ -887,7 +910,7 @@ async function getSplOrToken2022AccountView( } // Cold (compressed) accounts - for (const item of coldAccounts.items) { + for (const item of coldAccounts?.items ?? []) { const compressedAccount = item.compressedAccount; if ( compressedAccount && @@ -975,10 +998,13 @@ function buildAccountViewFromSources( } else if (coldDelegatedSources.length > 0) { // No hot delegate: canonical delegate is taken from the most recent // delegated cold source in source order (source[0] is most recent). - canonicalDelegate = coldDelegatedSources[0].parsed.delegate!; - canonicalDelegatedAmount = sumForDelegate(canonicalDelegate, src => - isColdSourceType(src.type), - ); + const firstColdDelegate = coldDelegatedSources[0].parsed.delegate; + if (firstColdDelegate) { + canonicalDelegate = firstColdDelegate; + canonicalDelegatedAmount = sumForDelegate(canonicalDelegate, src => + isColdSourceType(src.type), + ); + } } const unifiedAccount: Account = { @@ -993,7 +1019,7 @@ function buildAccountViewFromSources( }; return { - accountInfo: primarySource.accountInfo!, + accountInfo: primarySource.accountInfo, parsed: unifiedAccount, isCold: isColdSourceType(primarySource.type), loadContext: primarySource.loadContext, @@ -1080,7 +1106,7 @@ export function filterAccountForAuthority( ...iface, ...(primary ? { - accountInfo: primary.accountInfo!, + accountInfo: primary.accountInfo, isCold: isColdSourceType(primary.type), loadContext: primary.loadContext, } diff --git a/js/token-interface/src/spl-interface.ts b/js/token-interface/src/spl-interface.ts index 43d3309bbb..dfdd642986 100644 --- a/js/token-interface/src/spl-interface.ts +++ b/js/token-interface/src/spl-interface.ts @@ -54,7 +54,13 @@ export async function getSplInterfaces( `SPL interface not found for mint ${mint.toBase58()}.`, ); } - const tokenProgramId = accountInfos[anchorIndex]!.owner; + const anchorAccountInfo = accountInfos[anchorIndex]; + if (!anchorAccountInfo) { + throw new TokenAccountNotFoundError( + `SPL interface not found for mint ${mint.toBase58()}.`, + ); + } + const tokenProgramId = anchorAccountInfo.owner; if (!isSupportedTokenProgramId(tokenProgramId)) { throw new TokenInvalidAccountOwnerError( `Invalid token program owner for SPL interface mint ${mint.toBase58()}: ${tokenProgramId.toBase58()}`, From 5741b28b6ca97142236178f1472ec2475ae745e1 Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Sun, 29 Mar 2026 19:12:26 +0100 Subject: [PATCH 22/24] refactor load --- js/token-interface/src/instructions/load.ts | 644 ++---------------- .../src/instructions/load/decompress.ts | 512 ++++++++++++++ .../load/select-primary-cold-account.ts | 77 +++ .../tests/unit/instruction-builders.test.ts | 93 ++- 4 files changed, 723 insertions(+), 603 deletions(-) create mode 100644 js/token-interface/src/instructions/load/decompress.ts create mode 100644 js/token-interface/src/instructions/load/select-primary-cold-account.ts diff --git a/js/token-interface/src/instructions/load.ts b/js/token-interface/src/instructions/load.ts index 7b24a81daa..11b1038a7a 100644 --- a/js/token-interface/src/instructions/load.ts +++ b/js/token-interface/src/instructions/load.ts @@ -1,18 +1,12 @@ import { Rpc, LIGHT_TOKEN_PROGRAM_ID, - ParsedTokenAccount, - bn, assertV2Enabled, - LightSystemProgram, - defaultStaticAccountsStruct, - ValidityProofWithContext, } from "@lightprotocol/stateless.js"; import { ComputeBudgetProgram, PublicKey, TransactionInstruction, - SystemProgram, } from "@solana/web3.js"; import { TOKEN_PROGRAM_ID, @@ -21,13 +15,10 @@ import { createAssociatedTokenAccountIdempotentInstruction, TokenAccountNotFoundError, } from "@solana/spl-token"; -import { Buffer } from "buffer"; import { AccountView, checkNotFrozen, - COLD_SOURCE_TYPES, getAtaView as _getAtaView, - TokenAccountSource, isAuthorityForAccount, filterAccountForAuthority, } from "../read/get-account"; @@ -38,575 +29,15 @@ import { getSplInterfaces, type SplInterface } from "../spl-interface"; import { getAtaProgramId, checkAtaAddress, AtaType } from "../read/ata-utils"; import type { LoadOptions } from "../load-options"; import { getMint } from "../read/get-mint"; -import { - COMPRESSED_TOKEN_PROGRAM_ID, - deriveCpiAuthorityPda, - MAX_TOP_UP, - TokenDataVersion, -} from "../constants"; -import { - encodeTransfer2InstructionData, - type Transfer2InstructionData, - type MultiInputTokenDataWithContext, - COMPRESSION_MODE_DECOMPRESS, - type Compression, - type Transfer2ExtensionData, -} from "./layout/layout-transfer2"; import { toLoadOptions } from "../helpers"; import { getAtaAddress } from "../read"; import type { CreateLoadInstructionsInput, } from "../types"; import { toInstructionPlan } from "./_plan"; - -const COMPRESSED_ONLY_DISC = 31; -const COMPRESSED_ONLY_SIZE = 17; // u64 + u64 + u8 - -interface ParsedCompressedOnly { - delegatedAmount: bigint; - withheldTransferFee: bigint; - isAta: boolean; -} - -/** - * Parse CompressedOnly extension from a Borsh-serialized TLV buffer - * (Vec). Returns null if no CompressedOnly found. - * @internal - */ -function parseCompressedOnlyFromTlv( - tlv: Buffer | null, -): ParsedCompressedOnly | null { - if (!tlv || tlv.length < 5) return null; - try { - let offset = 0; - const vecLen = tlv.readUInt32LE(offset); - offset += 4; - for (let i = 0; i < vecLen; i++) { - if (offset >= tlv.length) return null; - const disc = tlv[offset]; - offset += 1; - if (disc === COMPRESSED_ONLY_DISC) { - if (offset + COMPRESSED_ONLY_SIZE > tlv.length) return null; - const loDA = BigInt(tlv.readUInt32LE(offset)); - const hiDA = BigInt(tlv.readUInt32LE(offset + 4)); - const delegatedAmount = loDA | (hiDA << BigInt(32)); - const loFee = BigInt(tlv.readUInt32LE(offset + 8)); - const hiFee = BigInt(tlv.readUInt32LE(offset + 12)); - const withheldTransferFee = loFee | (hiFee << BigInt(32)); - const isAta = tlv[offset + 16] !== 0; - return { delegatedAmount, withheldTransferFee, isAta }; - } - const SIZES: Record = { - 29: 8, - 30: 1, - 31: 17, - }; - const size = SIZES[disc]; - if (size === undefined) { - throw new Error( - `parseCompressedOnlyFromTlv: unknown TLV extension discriminant ${disc}`, - ); - } - offset += size; - } - } catch { - // Ignoring unknown TLV extensions. - return null; - } - return null; -} - -/** - * Build inTlv array for Transfer2 from input compressed accounts. - * For each account, if CompressedOnly TLV is present, converts it to - * the instruction format (enriched with is_frozen, compression_index, - * bump, owner_index). Returns null if no accounts have TLV. - * @internal - */ -function buildInTlv( - accounts: ParsedTokenAccount[], - ownerIndex: number, - owner: PublicKey, - mint: PublicKey, -): Transfer2ExtensionData[][] | null { - let hasAny = false; - const result: Transfer2ExtensionData[][] = []; - - for (const acc of accounts) { - const co = parseCompressedOnlyFromTlv(acc.parsed.tlv); - if (!co) { - result.push([]); - continue; - } - hasAny = true; - let bump = 0; - if (co.isAta) { - const seeds = [ - owner.toBuffer(), - LIGHT_TOKEN_PROGRAM_ID.toBuffer(), - mint.toBuffer(), - ]; - const [, b] = PublicKey.findProgramAddressSync( - seeds, - LIGHT_TOKEN_PROGRAM_ID, - ); - bump = b; - } - const isFrozen = acc.parsed.state === 2; - result.push([ - { - type: "CompressedOnly", - data: { - delegatedAmount: co.delegatedAmount, - withheldTransferFee: co.withheldTransferFee, - isFrozen, - // This builder emits a single decompress compression per batch. - // Keep index at 0 unless multi-compression output is added here. - compressionIndex: 0, - isAta: co.isAta, - bump, - ownerIndex, - }, - }, - ]); - } - return hasAny ? result : null; -} - -/** - * Get token data version from compressed account discriminator. - * @internal - */ -function getVersionFromDiscriminator( - discriminator: number[] | undefined, -): number { - if (!discriminator || discriminator.length < 8) { - // Default to ShaFlat for new accounts without discriminator - return TokenDataVersion.ShaFlat; - } - - // V1 has discriminator[0] = 2 - if (discriminator[0] === 2) { - return TokenDataVersion.V1; - } - - // V2 and ShaFlat have version in discriminator[7] - const versionByte = discriminator[7]; - if (versionByte === 3) { - return TokenDataVersion.V2; - } - if (versionByte === 4) { - return TokenDataVersion.ShaFlat; - } - - // Default to ShaFlat - return TokenDataVersion.ShaFlat; -} - -/** - * Build input token data for Transfer2 from parsed token accounts - * @internal - */ -function buildInputTokenData( - accounts: ParsedTokenAccount[], - rootIndices: number[], - packedAccountIndices: Map, -): MultiInputTokenDataWithContext[] { - return accounts.map((acc, i) => { - const ownerKey = acc.parsed.owner.toBase58(); - const mintKey = acc.parsed.mint.toBase58(); - - const version = getVersionFromDiscriminator( - acc.compressedAccount.data?.discriminator, - ); - - return { - owner: packedAccountIndices.get(ownerKey)!, - amount: BigInt(acc.parsed.amount.toString()), - hasDelegate: acc.parsed.delegate !== null, - delegate: acc.parsed.delegate - ? (packedAccountIndices.get(acc.parsed.delegate.toBase58()) ?? 0) - : 0, - mint: packedAccountIndices.get(mintKey)!, - version, - merkleContext: { - merkleTreePubkeyIndex: packedAccountIndices.get( - acc.compressedAccount.treeInfo.tree.toBase58(), - )!, - queuePubkeyIndex: packedAccountIndices.get( - acc.compressedAccount.treeInfo.queue.toBase58(), - )!, - leafIndex: acc.compressedAccount.leafIndex, - proveByIndex: acc.compressedAccount.proveByIndex, - }, - rootIndex: rootIndices[i], - }; - }); -} - -/** - * Create decompress instruction using Transfer2. - * - * @internal Use createLoadInstructions instead. - * - * Supports decompressing to both light-token accounts and SPL token accounts: - * - For light-token destinations: No splInterface needed - * - For SPL destinations: Provide splInterface and decimals - * - * @param input Decompress instruction input. - * @param input.payer Optional payer public key. Defaults to authority, then owner. - * @param input.inputCompressedTokenAccounts Input light-token accounts. - * @param input.toAddress Destination token account address (light-token or SPL associated token account). - * @param input.amount Amount to decompress. - * @param input.validityProof Validity proof (contains compressedProof and rootIndices). - * @param input.splInterface Optional SPL pool info for SPL destinations. - * @param input.decimals Mint decimals (required for SPL destinations). - * @param input.maxTopUp Optional cap on rent top-up (units of 1k lamports; default no cap). - * @param input.authority Optional signer (owner or delegate). When omitted, owner is the signer. - * @returns TransactionInstruction - */ -export function createDecompressInstruction({ - payer, - inputCompressedTokenAccounts, - toAddress, - amount, - validityProof, - splInterface, - decimals, - maxTopUp, - authority, -}: { - payer?: PublicKey; - inputCompressedTokenAccounts: ParsedTokenAccount[]; - toAddress: PublicKey; - amount: bigint; - validityProof: ValidityProofWithContext; - splInterface?: SplInterface; - decimals: number; - maxTopUp?: number; - authority?: PublicKey; -}): TransactionInstruction { - if (inputCompressedTokenAccounts.length === 0) { - throw new Error("No input light-token accounts provided"); - } - - const mint = inputCompressedTokenAccounts[0].parsed.mint; - const owner = inputCompressedTokenAccounts[0].parsed.owner; - - // Build packed accounts map - // Order: trees/queues first, then mint, owner, light-token account, light-token program - const packedAccountIndices = new Map(); - const packedAccounts: PublicKey[] = []; - - // Collect unique trees and queues - const treeSet = new Set(); - const queueSet = new Set(); - for (const acc of inputCompressedTokenAccounts) { - treeSet.add(acc.compressedAccount.treeInfo.tree.toBase58()); - queueSet.add(acc.compressedAccount.treeInfo.queue.toBase58()); - } - - // Add trees first (owned by account compression program) - for (const tree of treeSet) { - packedAccountIndices.set(tree, packedAccounts.length); - packedAccounts.push(new PublicKey(tree)); - } - - let firstQueueIndex = 0; - let isFirstQueue = true; - for (const queue of queueSet) { - if (isFirstQueue) { - firstQueueIndex = packedAccounts.length; - isFirstQueue = false; - } - packedAccountIndices.set(queue, packedAccounts.length); - packedAccounts.push(new PublicKey(queue)); - } - - // Add mint - const mintIndex = packedAccounts.length; - packedAccountIndices.set(mint.toBase58(), mintIndex); - packedAccounts.push(mint); - - // Add owner - const ownerIndex = packedAccounts.length; - packedAccountIndices.set(owner.toBase58(), ownerIndex); - packedAccounts.push(owner); - - // Add destination token account (light-token or SPL) - const destinationIndex = packedAccounts.length; - packedAccountIndices.set(toAddress.toBase58(), destinationIndex); - packedAccounts.push(toAddress); - - // Add unique delegate pubkeys from input accounts - for (const acc of inputCompressedTokenAccounts) { - if (acc.parsed.delegate) { - const delegateKey = acc.parsed.delegate.toBase58(); - if (!packedAccountIndices.has(delegateKey)) { - packedAccountIndices.set(delegateKey, packedAccounts.length); - packedAccounts.push(acc.parsed.delegate); - } - } - } - - // For SPL decompression, add pool account and token program - let poolAccountIndex = 0; - let poolIndex = 0; - let poolBump = 0; - let tokenProgramIndex = 0; - - if (splInterface) { - // Add SPL interface PDA (token pool) - poolAccountIndex = packedAccounts.length; - packedAccountIndices.set( - splInterface.poolPda.toBase58(), - poolAccountIndex, - ); - packedAccounts.push(splInterface.poolPda); - - // Add SPL token program - tokenProgramIndex = packedAccounts.length; - packedAccountIndices.set( - splInterface.tokenProgramId.toBase58(), - tokenProgramIndex, - ); - packedAccounts.push(splInterface.tokenProgramId); - - poolIndex = splInterface.derivationIndex; - poolBump = splInterface.bump; - } - - // Build input token data - const inTokenData = buildInputTokenData( - inputCompressedTokenAccounts, - validityProof.rootIndices, - packedAccountIndices, - ); - - // Calculate total input amount and change - const totalInputAmount = inputCompressedTokenAccounts.reduce( - (sum, acc) => sum + BigInt(acc.parsed.amount.toString()), - BigInt(0), - ); - const changeAmount = totalInputAmount - amount; - - const outTokenData: { - owner: number; - amount: bigint; - hasDelegate: boolean; - delegate: number; - mint: number; - version: number; - }[] = []; - - if (changeAmount > 0) { - const version = getVersionFromDiscriminator( - inputCompressedTokenAccounts[0].compressedAccount.data?.discriminator, - ); - - outTokenData.push({ - owner: ownerIndex, - amount: changeAmount, - hasDelegate: false, - delegate: 0, - mint: mintIndex, - version, - }); - } - - // Build decompress compression - // For light-token: pool values are 0 (unused) - // For SPL: pool values point to SPL interface PDA - const compressions: Compression[] = [ - { - mode: COMPRESSION_MODE_DECOMPRESS, - amount, - mint: mintIndex, - sourceOrRecipient: destinationIndex, - authority: 0, // Not needed for decompress - poolAccountIndex: splInterface ? poolAccountIndex : 0, - poolIndex: splInterface ? poolIndex : 0, - bump: splInterface ? poolBump : 0, - decimals, - }, - ]; - - // Build Transfer2 instruction data - const instructionData: Transfer2InstructionData = { - withTransactionHash: false, - withLamportsChangeAccountMerkleTreeIndex: false, - lamportsChangeAccountMerkleTreeIndex: 0, - lamportsChangeAccountOwnerIndex: 0, - outputQueue: firstQueueIndex, // First queue in packed accounts - maxTopUp: maxTopUp ?? MAX_TOP_UP, - cpiContext: null, - compressions, - proof: validityProof.compressedProof - ? { - a: Array.from(validityProof.compressedProof.a), - b: Array.from(validityProof.compressedProof.b), - c: Array.from(validityProof.compressedProof.c), - } - : null, - inTokenData, - outTokenData, - inLamports: null, - outLamports: null, - inTlv: buildInTlv(inputCompressedTokenAccounts, ownerIndex, owner, mint), - outTlv: null, - }; - - const data = encodeTransfer2InstructionData(instructionData); - - // Build accounts for Transfer2 with compressed accounts (full path) - const { - accountCompressionAuthority, - registeredProgramPda, - accountCompressionProgram, - } = defaultStaticAccountsStruct(); - const signerIndex = (() => { - if (!authority || authority.equals(owner)) { - return ownerIndex; - } - const authorityIndex = packedAccountIndices.get(authority.toBase58()); - if (authorityIndex === undefined) { - throw new Error( - `Authority ${authority.toBase58()} is not present in packed accounts`, - ); - } - return authorityIndex; - })(); - const effectivePayer = payer ?? authority ?? owner; - - const keys = [ - // 0: light_system_program (non-mutable) - { - pubkey: LightSystemProgram.programId, - isSigner: false, - isWritable: false, - }, - // 1: fee_payer (signer, mutable) - { pubkey: effectivePayer, isSigner: true, isWritable: true }, - // 2: cpi_authority_pda - { - pubkey: deriveCpiAuthorityPda(), - isSigner: false, - isWritable: false, - }, - // 3: registered_program_pda - { - pubkey: registeredProgramPda, - isSigner: false, - isWritable: false, - }, - // 4: account_compression_authority - { - pubkey: accountCompressionAuthority, - isSigner: false, - isWritable: false, - }, - // 5: account_compression_program - { - pubkey: accountCompressionProgram, - isSigner: false, - isWritable: false, - }, - // 6: system_program - { - pubkey: SystemProgram.programId, - isSigner: false, - isWritable: false, - }, - // 7+: packed_accounts (trees/queues come first) - ...packedAccounts.map((pubkey, i) => { - const isTreeOrQueue = i < treeSet.size + queueSet.size; - const isDestination = pubkey.equals(toAddress); - const isPool = - splInterface !== undefined && pubkey.equals(splInterface.poolPda); - return { - pubkey, - isSigner: i === signerIndex, - isWritable: isTreeOrQueue || isDestination || isPool, - }; - }), - ]; - - return new TransactionInstruction({ - programId: COMPRESSED_TOKEN_PROGRAM_ID, - keys, - data, - }); -} - -function getCanonicalCompressedTokenAccountFromAtaSources( - sources: TokenAccountSource[], -): ParsedTokenAccount | null { - const candidates: ParsedTokenAccount[] = []; - for (const source of sources) { - if (!COLD_SOURCE_TYPES.has(source.type) || !source.loadContext) { - continue; - } - const loadContext = source.loadContext; - const fullData = source.accountInfo.data; - const discriminatorBytes = fullData.subarray( - 0, - Math.min(8, fullData.length), - ); - const accountDataBytes = - fullData.length > 8 ? fullData.subarray(8) : Buffer.alloc(0); - - const compressedAccount = { - treeInfo: loadContext.treeInfo, - hash: loadContext.hash, - leafIndex: loadContext.leafIndex, - proveByIndex: loadContext.proveByIndex, - owner: source.accountInfo.owner, - lamports: bn(source.accountInfo.lamports), - address: null, - data: - fullData.length === 0 - ? null - : { - discriminator: Array.from(discriminatorBytes), - data: Buffer.from(accountDataBytes), - dataHash: new Array(32).fill(0), - }, - readOnly: false, - } satisfies ParsedTokenAccount["compressedAccount"]; - - const state = !source.parsed.isInitialized - ? 0 - : source.parsed.isFrozen - ? 2 - : 1; - - candidates.push({ - compressedAccount, - parsed: { - mint: source.parsed.mint, - owner: source.parsed.owner, - amount: bn(source.parsed.amount.toString()), - delegate: source.parsed.delegate, - state, - tlv: source.parsed.tlvData.length > 0 ? source.parsed.tlvData : null, - }, - }); - } - - if (candidates.length === 0) { - return null; - } - - candidates.sort((a, b) => { - const amountA = BigInt(a.parsed.amount.toString()); - const amountB = BigInt(b.parsed.amount.toString()); - if (amountB > amountA) return 1; - if (amountB < amountA) return -1; - return b.compressedAccount.leafIndex - a.compressedAccount.leafIndex; - }); - - return candidates[0]; -} +import { createDecompressInstruction } from "./load/decompress"; +import { selectPrimaryColdCompressedAccountForLoad } from "./load/select-primary-cold-account"; +export { createDecompressInstruction } from "./load/decompress"; async function _buildLoadInstructions( rpc: Rpc, @@ -634,8 +65,8 @@ async function _buildLoadInstructions( const mint = ata._mint; const sources = ata._sources ?? []; - const canonicalCompressedAccount = - getCanonicalCompressedTokenAccountFromAtaSources(sources); + const primaryColdCompressedAccount = + selectPrimaryColdCompressedAccountForLoad(sources); const lightTokenAtaAddress = getAssociatedTokenAddress(mint, owner); const splAta = getAssociatedTokenAddressSync( @@ -667,8 +98,8 @@ async function _buildLoadInstructions( const lightTokenHotSource = sources.find((s) => s.type === "light-token-hot"); const splBalance = splSource?.amount ?? BigInt(0); const t22Balance = t22Source?.amount ?? BigInt(0); - const coldBalance = canonicalCompressedAccount - ? BigInt(canonicalCompressedAccount.parsed.amount.toString()) + const coldBalance = primaryColdCompressedAccount + ? BigInt(primaryColdCompressedAccount.parsed.amount.toString()) : BigInt(0); if ( @@ -679,23 +110,32 @@ async function _buildLoadInstructions( return []; } - let splInterface: SplInterface | undefined; - const needsSplInfo = - ataType === "spl" || - ataType === "token2022" || - splBalance > BigInt(0) || - t22Balance > BigInt(0); - if (needsSplInfo) { - try { - const splInterfaces = - options?.splInterfaces ?? (await getSplInterfaces(rpc, mint)); - splInterface = splInterfaces.find( - (info: SplInterface) => info.isInitialized, + const needsSplProgram = ataType === "spl" || splBalance > BigInt(0); + const needsToken2022Program = ataType === "token2022" || t22Balance > BigInt(0); + + let splProgramInterface: SplInterface | undefined; + let token2022ProgramInterface: SplInterface | undefined; + if (needsSplProgram || needsToken2022Program) { + const splInterfaces = + options?.splInterfaces ?? (await getSplInterfaces(rpc, mint)); + splProgramInterface = splInterfaces.find( + (info) => + info.isInitialized && info.tokenProgramId.equals(TOKEN_PROGRAM_ID), + ); + token2022ProgramInterface = splInterfaces.find( + (info) => + info.isInitialized && info.tokenProgramId.equals(TOKEN_2022_PROGRAM_ID), + ); + + if (needsSplProgram && !splProgramInterface) { + throw new Error( + `No initialized SPL interface found for mint ${mint.toBase58()} and token program ${TOKEN_PROGRAM_ID.toBase58()}.`, + ); + } + if (needsToken2022Program && !token2022ProgramInterface) { + throw new Error( + `No initialized SPL interface found for mint ${mint.toBase58()} and token program ${TOKEN_2022_PROGRAM_ID.toBase58()}.`, ); - } catch (e) { - if (splBalance > BigInt(0) || t22Balance > BigInt(0)) { - throw e; - } } } @@ -722,7 +162,7 @@ async function _buildLoadInstructions( ); } - if (splBalance > BigInt(0) && splInterface) { + if (splBalance > BigInt(0)) { setupInstructions.push( createWrapInstruction({ source: splAta, @@ -730,14 +170,14 @@ async function _buildLoadInstructions( owner, mint, amount: splBalance, - splInterface, + splInterface: splProgramInterface!, decimals, payer, }), ); } - if (t22Balance > BigInt(0) && splInterface) { + if (t22Balance > BigInt(0)) { setupInstructions.push( createWrapInstruction({ source: t22Ata, @@ -745,7 +185,7 @@ async function _buildLoadInstructions( owner, mint, amount: t22Balance, - splInterface, + splInterface: token2022ProgramInterface!, decimals, payer, }), @@ -767,9 +207,9 @@ async function _buildLoadInstructions( }), ); } - } else if (ataType === "spl" && splInterface) { + } else if (ataType === "spl") { decompressTarget = splAta; - decompressSplInfo = splInterface; + decompressSplInfo = splProgramInterface!; canDecompress = true; if (!splSource) { setupInstructions.push( @@ -782,9 +222,9 @@ async function _buildLoadInstructions( ), ); } - } else if (ataType === "token2022" && splInterface) { + } else if (ataType === "token2022") { decompressTarget = t22Ata; - decompressSplInfo = splInterface; + decompressSplInfo = token2022ProgramInterface!; canDecompress = true; if (!t22Source) { setupInstructions.push( @@ -800,12 +240,12 @@ async function _buildLoadInstructions( } } - let accountToLoad = canonicalCompressedAccount; + let accountToLoad = primaryColdCompressedAccount; if ( targetAmount !== undefined && canDecompress && - canonicalCompressedAccount + primaryColdCompressedAccount ) { const isDelegate = authority !== undefined && !authority.equals(owner); const hotBalance = (() => { diff --git a/js/token-interface/src/instructions/load/decompress.ts b/js/token-interface/src/instructions/load/decompress.ts new file mode 100644 index 0000000000..57ff041515 --- /dev/null +++ b/js/token-interface/src/instructions/load/decompress.ts @@ -0,0 +1,512 @@ +import { + LIGHT_TOKEN_PROGRAM_ID, + LightSystemProgram, + ParsedTokenAccount, + ValidityProofWithContext, + defaultStaticAccountsStruct, +} from "@lightprotocol/stateless.js"; +import { Buffer } from "buffer"; +import { PublicKey, SystemProgram, TransactionInstruction } from "@solana/web3.js"; +import { + COMPRESSED_TOKEN_PROGRAM_ID, + MAX_TOP_UP, + TokenDataVersion, + deriveCpiAuthorityPda, +} from "../../constants"; +import { + COMPRESSION_MODE_DECOMPRESS, + Compression, + MultiInputTokenDataWithContext, + Transfer2ExtensionData, + Transfer2InstructionData, + encodeTransfer2InstructionData, +} from "../layout/layout-transfer2"; +import { SplInterface } from "../../spl-interface"; + +const COMPRESSED_ONLY_DISC = 31; +const COMPRESSED_ONLY_SIZE = 17; // u64 + u64 + u8 + +interface ParsedCompressedOnly { + delegatedAmount: bigint; + withheldTransferFee: bigint; + isAta: boolean; +} + +/** + * Parse CompressedOnly extension from a Borsh-serialized TLV buffer + * (Vec). Returns null if no CompressedOnly found. + * @internal + */ +function parseCompressedOnlyFromTlv( + tlv: Buffer | null, +): ParsedCompressedOnly | null { + if (!tlv || tlv.length < 5) return null; + try { + let offset = 0; + const vecLen = tlv.readUInt32LE(offset); + offset += 4; + for (let i = 0; i < vecLen; i++) { + if (offset >= tlv.length) return null; + const disc = tlv[offset]; + offset += 1; + if (disc === COMPRESSED_ONLY_DISC) { + if (offset + COMPRESSED_ONLY_SIZE > tlv.length) return null; + const loDA = BigInt(tlv.readUInt32LE(offset)); + const hiDA = BigInt(tlv.readUInt32LE(offset + 4)); + const delegatedAmount = loDA | (hiDA << BigInt(32)); + const loFee = BigInt(tlv.readUInt32LE(offset + 8)); + const hiFee = BigInt(tlv.readUInt32LE(offset + 12)); + const withheldTransferFee = loFee | (hiFee << BigInt(32)); + const isAta = tlv[offset + 16] !== 0; + return { delegatedAmount, withheldTransferFee, isAta }; + } + const SIZES: Record = { + 29: 8, + 30: 1, + 31: 17, + }; + const size = SIZES[disc]; + if (size === undefined) { + throw new Error( + `parseCompressedOnlyFromTlv: unknown TLV extension discriminant ${disc}`, + ); + } + offset += size; + } + } catch { + // Ignoring unknown TLV extensions. + return null; + } + return null; +} + +/** + * Build inTlv array for Transfer2 from input compressed accounts. + * For each account, if CompressedOnly TLV is present, converts it to + * the instruction format (enriched with is_frozen, compression_index, + * bump, owner_index). Returns null if no accounts have TLV. + * @internal + */ +function buildInTlv( + accounts: ParsedTokenAccount[], + ownerIndex: number, + owner: PublicKey, + mint: PublicKey, +): Transfer2ExtensionData[][] | null { + let hasAny = false; + const result: Transfer2ExtensionData[][] = []; + + for (const acc of accounts) { + const co = parseCompressedOnlyFromTlv(acc.parsed.tlv); + if (!co) { + result.push([]); + continue; + } + hasAny = true; + let bump = 0; + if (co.isAta) { + const seeds = [ + owner.toBuffer(), + LIGHT_TOKEN_PROGRAM_ID.toBuffer(), + mint.toBuffer(), + ]; + const [, b] = PublicKey.findProgramAddressSync( + seeds, + LIGHT_TOKEN_PROGRAM_ID, + ); + bump = b; + } + const isFrozen = acc.parsed.state === 2; + result.push([ + { + type: "CompressedOnly", + data: { + delegatedAmount: co.delegatedAmount, + withheldTransferFee: co.withheldTransferFee, + isFrozen, + // This builder emits a single decompress compression per batch. + // Keep index at 0 unless multi-compression output is added here. + compressionIndex: 0, + isAta: co.isAta, + bump, + ownerIndex, + }, + }, + ]); + } + return hasAny ? result : null; +} + +/** + * Get token data version from compressed account discriminator. + * @internal + */ +function getVersionFromDiscriminator( + discriminator: number[] | undefined, +): number { + if (!discriminator || discriminator.length < 8) { + // Default to ShaFlat for new accounts without discriminator + return TokenDataVersion.ShaFlat; + } + + // V1 has discriminator[0] = 2 + if (discriminator[0] === 2) { + return TokenDataVersion.V1; + } + + // V2 and ShaFlat have version in discriminator[7] + const versionByte = discriminator[7]; + if (versionByte === 3) { + return TokenDataVersion.V2; + } + if (versionByte === 4) { + return TokenDataVersion.ShaFlat; + } + + // Default to ShaFlat + return TokenDataVersion.ShaFlat; +} + +/** + * Build input token data for Transfer2 from parsed token accounts + * @internal + */ +function buildInputTokenData( + accounts: ParsedTokenAccount[], + rootIndices: number[], + packedAccountIndices: Map, +): MultiInputTokenDataWithContext[] { + return accounts.map((acc, i) => { + const ownerKey = acc.parsed.owner.toBase58(); + const mintKey = acc.parsed.mint.toBase58(); + + const version = getVersionFromDiscriminator( + acc.compressedAccount.data?.discriminator, + ); + + return { + owner: packedAccountIndices.get(ownerKey)!, + amount: BigInt(acc.parsed.amount.toString()), + hasDelegate: acc.parsed.delegate !== null, + delegate: acc.parsed.delegate + ? (packedAccountIndices.get(acc.parsed.delegate.toBase58()) ?? 0) + : 0, + mint: packedAccountIndices.get(mintKey)!, + version, + merkleContext: { + merkleTreePubkeyIndex: packedAccountIndices.get( + acc.compressedAccount.treeInfo.tree.toBase58(), + )!, + queuePubkeyIndex: packedAccountIndices.get( + acc.compressedAccount.treeInfo.queue.toBase58(), + )!, + leafIndex: acc.compressedAccount.leafIndex, + proveByIndex: acc.compressedAccount.proveByIndex, + }, + rootIndex: rootIndices[i], + }; + }); +} + +/** + * Create decompress instruction using Transfer2. + * + * @internal Use createLoadInstructions instead. + * + * Supports decompressing to both light-token accounts and SPL token accounts: + * - For light-token destinations: No splInterface needed + * - For SPL destinations: Provide splInterface and decimals + */ +export function createDecompressInstruction({ + payer, + inputCompressedTokenAccounts, + toAddress, + amount, + validityProof, + splInterface, + decimals, + maxTopUp, + authority, +}: { + payer?: PublicKey; + inputCompressedTokenAccounts: ParsedTokenAccount[]; + toAddress: PublicKey; + amount: bigint; + validityProof: ValidityProofWithContext; + splInterface?: SplInterface; + decimals: number; + maxTopUp?: number; + authority?: PublicKey; +}): TransactionInstruction { + if (inputCompressedTokenAccounts.length === 0) { + throw new Error("No input light-token accounts provided"); + } + + const mint = inputCompressedTokenAccounts[0].parsed.mint; + const owner = inputCompressedTokenAccounts[0].parsed.owner; + for (const account of inputCompressedTokenAccounts) { + if (!account.parsed.mint.equals(mint)) { + throw new Error( + "All input light-token accounts must have the same mint for decompress.", + ); + } + if (!account.parsed.owner.equals(owner)) { + throw new Error( + "All input light-token accounts must have the same owner for decompress.", + ); + } + } + + // Build packed accounts map + // Order: trees/queues first, then mint, owner, light-token account, light-token program + const packedAccountIndices = new Map(); + const packedAccounts: PublicKey[] = []; + + // Collect unique trees and queues + const treeSet = new Set(); + const queueSet = new Set(); + for (const acc of inputCompressedTokenAccounts) { + treeSet.add(acc.compressedAccount.treeInfo.tree.toBase58()); + queueSet.add(acc.compressedAccount.treeInfo.queue.toBase58()); + } + + // Add trees first (owned by account compression program) + for (const tree of treeSet) { + packedAccountIndices.set(tree, packedAccounts.length); + packedAccounts.push(new PublicKey(tree)); + } + + let firstQueueIndex = 0; + let isFirstQueue = true; + for (const queue of queueSet) { + if (isFirstQueue) { + firstQueueIndex = packedAccounts.length; + isFirstQueue = false; + } + packedAccountIndices.set(queue, packedAccounts.length); + packedAccounts.push(new PublicKey(queue)); + } + + // Add mint + const mintIndex = packedAccounts.length; + packedAccountIndices.set(mint.toBase58(), mintIndex); + packedAccounts.push(mint); + + // Add owner + const ownerIndex = packedAccounts.length; + packedAccountIndices.set(owner.toBase58(), ownerIndex); + packedAccounts.push(owner); + + // Add destination token account (light-token or SPL) + const destinationIndex = packedAccounts.length; + packedAccountIndices.set(toAddress.toBase58(), destinationIndex); + packedAccounts.push(toAddress); + + // Add unique delegate pubkeys from input accounts + for (const acc of inputCompressedTokenAccounts) { + if (acc.parsed.delegate) { + const delegateKey = acc.parsed.delegate.toBase58(); + if (!packedAccountIndices.has(delegateKey)) { + packedAccountIndices.set(delegateKey, packedAccounts.length); + packedAccounts.push(acc.parsed.delegate); + } + } + } + + // For SPL decompression, add pool account and token program + let poolAccountIndex = 0; + let poolIndex = 0; + let poolBump = 0; + let tokenProgramIndex = 0; + + if (splInterface) { + // Add SPL interface PDA (token pool) + poolAccountIndex = packedAccounts.length; + packedAccountIndices.set( + splInterface.poolPda.toBase58(), + poolAccountIndex, + ); + packedAccounts.push(splInterface.poolPda); + + // Add SPL token program + tokenProgramIndex = packedAccounts.length; + packedAccountIndices.set( + splInterface.tokenProgramId.toBase58(), + tokenProgramIndex, + ); + packedAccounts.push(splInterface.tokenProgramId); + + poolIndex = splInterface.derivationIndex; + poolBump = splInterface.bump; + } + + // Keep token program index materialized to preserve packed account ordering + // and index side effects for instruction construction. + void tokenProgramIndex; + + // Build input token data + const inTokenData = buildInputTokenData( + inputCompressedTokenAccounts, + validityProof.rootIndices, + packedAccountIndices, + ); + + // Calculate total input amount and change + const totalInputAmount = inputCompressedTokenAccounts.reduce( + (sum, acc) => sum + BigInt(acc.parsed.amount.toString()), + BigInt(0), + ); + if (amount > totalInputAmount) { + throw new Error( + `Decompress amount ${amount.toString()} exceeds total input amount ${totalInputAmount.toString()}.`, + ); + } + const changeAmount = totalInputAmount - amount; + + const outTokenData: { + owner: number; + amount: bigint; + hasDelegate: boolean; + delegate: number; + mint: number; + version: number; + }[] = []; + + if (changeAmount > 0) { + const version = getVersionFromDiscriminator( + inputCompressedTokenAccounts[0].compressedAccount.data?.discriminator, + ); + + outTokenData.push({ + owner: ownerIndex, + amount: changeAmount, + hasDelegate: false, + delegate: 0, + mint: mintIndex, + version, + }); + } + + // Build decompress compression + // For light-token: pool values are 0 (unused) + // For SPL: pool values point to SPL interface PDA + const compressions: Compression[] = [ + { + mode: COMPRESSION_MODE_DECOMPRESS, + amount, + mint: mintIndex, + sourceOrRecipient: destinationIndex, + authority: 0, // Not needed for decompress + poolAccountIndex: splInterface ? poolAccountIndex : 0, + poolIndex: splInterface ? poolIndex : 0, + bump: splInterface ? poolBump : 0, + decimals, + }, + ]; + + // Build Transfer2 instruction data + const instructionData: Transfer2InstructionData = { + withTransactionHash: false, + withLamportsChangeAccountMerkleTreeIndex: false, + lamportsChangeAccountMerkleTreeIndex: 0, + lamportsChangeAccountOwnerIndex: 0, + outputQueue: firstQueueIndex, // First queue in packed accounts + maxTopUp: maxTopUp ?? MAX_TOP_UP, + cpiContext: null, + compressions, + proof: validityProof.compressedProof + ? { + a: Array.from(validityProof.compressedProof.a), + b: Array.from(validityProof.compressedProof.b), + c: Array.from(validityProof.compressedProof.c), + } + : null, + inTokenData, + outTokenData, + inLamports: null, + outLamports: null, + inTlv: buildInTlv(inputCompressedTokenAccounts, ownerIndex, owner, mint), + outTlv: null, + }; + + const data = encodeTransfer2InstructionData(instructionData); + + // Build accounts for Transfer2 with compressed accounts (full path) + const { + accountCompressionAuthority, + registeredProgramPda, + accountCompressionProgram, + } = defaultStaticAccountsStruct(); + const signerIndex = (() => { + if (!authority || authority.equals(owner)) { + return ownerIndex; + } + const authorityIndex = packedAccountIndices.get(authority.toBase58()); + if (authorityIndex === undefined) { + throw new Error( + `Authority ${authority.toBase58()} is not present in packed accounts`, + ); + } + return authorityIndex; + })(); + const effectivePayer = payer ?? authority ?? owner; + + const keys = [ + // 0: light_system_program (non-mutable) + { + pubkey: LightSystemProgram.programId, + isSigner: false, + isWritable: false, + }, + // 1: fee_payer (signer, mutable) + { pubkey: effectivePayer, isSigner: true, isWritable: true }, + // 2: cpi_authority_pda + { + pubkey: deriveCpiAuthorityPda(), + isSigner: false, + isWritable: false, + }, + // 3: registered_program_pda + { + pubkey: registeredProgramPda, + isSigner: false, + isWritable: false, + }, + // 4: account_compression_authority + { + pubkey: accountCompressionAuthority, + isSigner: false, + isWritable: false, + }, + // 5: account_compression_program + { + pubkey: accountCompressionProgram, + isSigner: false, + isWritable: false, + }, + // 6: system_program + { + pubkey: SystemProgram.programId, + isSigner: false, + isWritable: false, + }, + // 7+: packed_accounts (trees/queues come first) + ...packedAccounts.map((pubkey, i) => { + const isTreeOrQueue = i < treeSet.size + queueSet.size; + const isDestination = pubkey.equals(toAddress); + const isPool = + splInterface !== undefined && pubkey.equals(splInterface.poolPda); + return { + pubkey, + isSigner: i === signerIndex, + isWritable: isTreeOrQueue || isDestination || isPool, + }; + }), + ]; + + return new TransactionInstruction({ + programId: COMPRESSED_TOKEN_PROGRAM_ID, + keys, + data, + }); +} diff --git a/js/token-interface/src/instructions/load/select-primary-cold-account.ts b/js/token-interface/src/instructions/load/select-primary-cold-account.ts new file mode 100644 index 0000000000..de8a55d4f7 --- /dev/null +++ b/js/token-interface/src/instructions/load/select-primary-cold-account.ts @@ -0,0 +1,77 @@ +import { ParsedTokenAccount, bn } from "@lightprotocol/stateless.js"; +import { Buffer } from "buffer"; +import { COLD_SOURCE_TYPES, TokenAccountSource } from "../../read/get-account"; + +/** + * Default load policy: select one deterministic cold compressed account. + * Priority is highest amount, then highest leaf index. + */ +export function selectPrimaryColdCompressedAccountForLoad( + sources: TokenAccountSource[], +): ParsedTokenAccount | null { + const candidates: ParsedTokenAccount[] = []; + for (const source of sources) { + if (!COLD_SOURCE_TYPES.has(source.type) || !source.loadContext) { + continue; + } + const loadContext = source.loadContext; + const fullData = source.accountInfo.data; + const discriminatorBytes = fullData.subarray( + 0, + Math.min(8, fullData.length), + ); + const accountDataBytes = + fullData.length > 8 ? fullData.subarray(8) : Buffer.alloc(0); + + const compressedAccount = { + treeInfo: loadContext.treeInfo, + hash: loadContext.hash, + leafIndex: loadContext.leafIndex, + proveByIndex: loadContext.proveByIndex, + owner: source.accountInfo.owner, + lamports: bn(source.accountInfo.lamports), + address: null, + data: + fullData.length === 0 + ? null + : { + discriminator: Array.from(discriminatorBytes), + data: Buffer.from(accountDataBytes), + dataHash: new Array(32).fill(0), + }, + readOnly: false, + } satisfies ParsedTokenAccount["compressedAccount"]; + + const state = !source.parsed.isInitialized + ? 0 + : source.parsed.isFrozen + ? 2 + : 1; + + candidates.push({ + compressedAccount, + parsed: { + mint: source.parsed.mint, + owner: source.parsed.owner, + amount: bn(source.parsed.amount.toString()), + delegate: source.parsed.delegate, + state, + tlv: source.parsed.tlvData.length > 0 ? source.parsed.tlvData : null, + }, + }); + } + + if (candidates.length === 0) { + return null; + } + + candidates.sort((a, b) => { + const amountA = BigInt(a.parsed.amount.toString()); + const amountB = BigInt(b.parsed.amount.toString()); + if (amountB > amountA) return 1; + if (amountB < amountA) return -1; + return b.compressedAccount.leafIndex - a.compressedAccount.leafIndex; + }); + + return candidates[0]; +} diff --git a/js/token-interface/tests/unit/instruction-builders.test.ts b/js/token-interface/tests/unit/instruction-builders.test.ts index 5e213be4c4..f5f46bbae2 100644 --- a/js/token-interface/tests/unit/instruction-builders.test.ts +++ b/js/token-interface/tests/unit/instruction-builders.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { Keypair } from '@solana/web3.js'; -import { LIGHT_TOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; +import { Buffer } from 'buffer'; +import { LIGHT_TOKEN_PROGRAM_ID, bn } from '@lightprotocol/stateless.js'; import { TOKEN_PROGRAM_ID } from '@solana/spl-token'; import { createApproveInstruction, @@ -12,9 +13,54 @@ import { createRevokeInstruction, createThawInstruction, createTransferCheckedInstruction, + createDecompressInstruction, } from '../../src/instructions'; describe('instruction builders', () => { + function buildParsedCompressedAccount(params?: { + amount?: bigint; + owner?: ReturnType['publicKey']; + mint?: ReturnType['publicKey']; + tree?: ReturnType['publicKey']; + queue?: ReturnType['publicKey']; + leafIndex?: number; + }) { + const owner = params?.owner ?? Keypair.generate().publicKey; + const mint = params?.mint ?? Keypair.generate().publicKey; + const tree = params?.tree ?? Keypair.generate().publicKey; + const queue = params?.queue ?? Keypair.generate().publicKey; + const amount = params?.amount ?? 10n; + const leafIndex = params?.leafIndex ?? 1; + return { + compressedAccount: { + treeInfo: { + tree, + queue, + }, + hash: new Array(32).fill(0), + leafIndex, + proveByIndex: false, + owner: LIGHT_TOKEN_PROGRAM_ID, + lamports: bn(0), + address: null, + data: { + discriminator: [2, 0, 0, 0, 0, 0, 0, 0], + data: Buffer.alloc(0), + dataHash: new Array(32).fill(0), + }, + readOnly: false, + }, + parsed: { + mint, + owner, + amount: bn(amount.toString()), + delegate: null, + state: 1, + tlv: null, + }, + } as any; + } + it('creates a canonical light-token ata instruction', () => { const payer = Keypair.generate().publicKey; const owner = Keypair.generate().publicKey; @@ -215,6 +261,51 @@ describe('instruction builders', () => { expect(instruction.keys[5].pubkey.equals(TOKEN_PROGRAM_ID)).toBe(true); }); + it('throws when decompress amount exceeds total input amount', () => { + const account = buildParsedCompressedAccount({ amount: 5n }); + const owner = account.parsed.owner; + const destination = Keypair.generate().publicKey; + const validityProof = { + compressedProof: null, + rootIndices: [0], + } as any; + + expect(() => + createDecompressInstruction({ + payer: owner, + inputCompressedTokenAccounts: [account], + toAddress: destination, + amount: 6n, + validityProof, + decimals: 9, + authority: owner, + }), + ).toThrow(/exceeds total input amount/i); + }); + + it('throws when decompress inputs have mixed owners', () => { + const mint = Keypair.generate().publicKey; + const accountA = buildParsedCompressedAccount({ mint }); + const accountB = buildParsedCompressedAccount({ mint }); + const destination = Keypair.generate().publicKey; + const validityProof = { + compressedProof: null, + rootIndices: [0, 0], + } as any; + + expect(() => + createDecompressInstruction({ + payer: accountA.parsed.owner, + inputCompressedTokenAccounts: [accountA, accountB], + toAddress: destination, + amount: 1n, + validityProof, + decimals: 9, + authority: accountA.parsed.owner, + }), + ).toThrow(/same owner/i); + }); + it('defaults ATA payer to owner when omitted', () => { const owner = Keypair.generate().publicKey; const mint = Keypair.generate().publicKey; From 963ac04954b652fa635e01d1a8b82b88f95691d4 Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Sun, 29 Mar 2026 20:25:47 +0100 Subject: [PATCH 23/24] format --- js/token-interface/.prettierrc | 10 + js/token-interface/README.md | 3 +- js/token-interface/rollup.config.js | 5 +- js/token-interface/src/account.ts | 45 +- js/token-interface/src/constants.ts | 3 +- js/token-interface/src/instructions/_plan.ts | 4 +- js/token-interface/src/instructions/ata.ts | 45 +- js/token-interface/src/instructions/burn.ts | 12 +- js/token-interface/src/instructions/load.ts | 718 +++++++------- .../src/instructions/load/decompress.ts | 880 +++++++++--------- .../load/select-primary-cold-account.ts | 129 +-- .../src/instructions/transfer.ts | 400 ++++---- js/token-interface/src/instructions/unwrap.ts | 226 ++--- js/token-interface/src/instructions/wrap.ts | 230 ++--- js/token-interface/src/load-options.ts | 10 +- js/token-interface/src/read/get-account.ts | 12 +- js/token-interface/src/read/get-mint.ts | 14 +- js/token-interface/src/read/index.ts | 12 +- js/token-interface/src/spl-interface.ts | 168 ++-- .../tests/e2e/approve-revoke.test.ts | 28 +- js/token-interface/tests/e2e/ata-read.test.ts | 14 +- js/token-interface/tests/e2e/burn.test.ts | 16 +- .../tests/e2e/freeze-thaw.test.ts | 9 +- js/token-interface/tests/e2e/helpers.ts | 311 ++++--- js/token-interface/tests/e2e/load.test.ts | 10 +- js/token-interface/tests/e2e/transfer.test.ts | 99 +- .../tests/unit/instruction-builders.test.ts | 1 - .../tests/unit/public-api.test.ts | 8 +- scripts/format.sh | 1 + 29 files changed, 1763 insertions(+), 1660 deletions(-) create mode 100644 js/token-interface/.prettierrc diff --git a/js/token-interface/.prettierrc b/js/token-interface/.prettierrc new file mode 100644 index 0000000000..59be93e26f --- /dev/null +++ b/js/token-interface/.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-interface/README.md b/js/token-interface/README.md index 514e9c537d..34e7b08d20 100644 --- a/js/token-interface/README.md +++ b/js/token-interface/README.md @@ -3,6 +3,7 @@ Payments-focused helpers for Light rent-free token flows. Use this when you want SPL-style transfers with unified sender handling: + - sender side auto wraps/loads into light ATA - recipient ATA can be light (default), SPL, or Token-2022 via `tokenProgram` @@ -100,4 +101,4 @@ console.log(account.amount, account.hotAmount, account.compressedAmount); - Canonical builders always use wrap-enabled sender setup (`createTransferInstructions`, `createLoadInstructions`, `createApproveInstructions`, `createRevokeInstructions`). - If sender SPL/T22 balances are wrapped by the flow, source SPL/T22 ATAs are not auto-closed. - Recipient ATA is derived from `(recipient, mint, tokenProgram)`; default is light token program. -- Recipient-side load is still intentionally disabled. \ No newline at end of file +- Recipient-side load is still intentionally disabled. diff --git a/js/token-interface/rollup.config.js b/js/token-interface/rollup.config.js index beac18c327..f1a41daa1a 100644 --- a/js/token-interface/rollup.config.js +++ b/js/token-interface/rollup.config.js @@ -66,9 +66,6 @@ export default [ jsConfig('cjs'), jsConfig('es'), dtsEntry('src/index.ts', 'dist/types/index.d.ts'), - dtsEntry( - 'src/instructions/index.ts', - 'dist/types/instructions/index.d.ts', - ), + dtsEntry('src/instructions/index.ts', 'dist/types/instructions/index.d.ts'), dtsEntry('src/kit/index.ts', 'dist/types/kit/index.d.ts'), ]; diff --git a/js/token-interface/src/account.ts b/js/token-interface/src/account.ts index bc54a07536..68d2aa6de6 100644 --- a/js/token-interface/src/account.ts +++ b/js/token-interface/src/account.ts @@ -1,8 +1,5 @@ import { getAssociatedTokenAddress } from './read/associated-token-address'; -import { - parseLightTokenCold, - parseLightTokenHot, -} from './read/get-account'; +import { parseLightTokenCold, parseLightTokenHot } from './read/get-account'; import { Buffer } from 'buffer'; import { LIGHT_TOKEN_PROGRAM_ID, @@ -64,12 +61,8 @@ function buildParsedAta( address: PublicKey, owner: PublicKey, mint: PublicKey, - hotParsed: - | ReturnType['parsed'] - | null, - coldParsed: - | ReturnType['parsed'] - | null, + hotParsed: ReturnType['parsed'] | null, + coldParsed: ReturnType['parsed'] | null, ): TokenInterfaceParsedAta { const hotAmount = hotParsed?.amount ?? ZERO; const compressedAmount = coldParsed?.amount ?? ZERO; @@ -103,23 +96,21 @@ function buildParsedAta( amount, delegate, delegatedAmount: clampDelegatedAmount(amount, delegatedAmount), - isInitialized: - hotParsed?.isInitialized === true || coldParsed !== null, - isFrozen: - hotParsed?.isFrozen === true || coldParsed?.isFrozen === true, + isInitialized: hotParsed?.isInitialized === true || coldParsed !== null, + isFrozen: hotParsed?.isFrozen === true || coldParsed?.isFrozen === true, }; } -function selectPrimaryCompressedAccount( - accounts: ParsedTokenAccount[], -): { +function selectPrimaryCompressedAccount(accounts: ParsedTokenAccount[]): { selected: ParsedTokenAccount | null; ignored: ParsedTokenAccount[]; } { const candidates = sortCompressedAccounts( accounts.filter(account => { return ( - account.compressedAccount.owner.equals(LIGHT_TOKEN_PROGRAM_ID) && + account.compressedAccount.owner.equals( + LIGHT_TOKEN_PROGRAM_ID, + ) && account.compressedAccount.data !== null && account.compressedAccount.data.data.length > 0 && toBigIntAmount(account) > ZERO @@ -184,7 +175,9 @@ export async function getAtaOrNull({ }; } -export async function getAta(input: GetAtaInput): Promise { +export async function getAta( + input: GetAtaInput, +): Promise { const account = await getAtaOrNull(input); if (!account) { @@ -206,7 +199,10 @@ export function getSpendableAmount( account.parsed.delegate !== null && authority.equals(account.parsed.delegate) ) { - return clampDelegatedAmount(account.amount, account.parsed.delegatedAmount); + return clampDelegatedAmount( + account.amount, + account.parsed.delegatedAmount, + ); } return ZERO; @@ -217,9 +213,7 @@ export function assertAccountNotFrozen( operation: 'load' | 'transfer' | 'approve' | 'revoke' | 'burn' | 'freeze', ): void { if (account.parsed.isFrozen) { - throw new Error( - `Account is frozen; ${operation} is not allowed.`, - ); + throw new Error(`Account is frozen; ${operation} is not allowed.`); } } @@ -228,9 +222,6 @@ export function assertAccountFrozen( operation: 'thaw', ): void { if (!account.parsed.isFrozen) { - throw new Error( - `Account is not frozen; ${operation} is not allowed.`, - ); + throw new Error(`Account is not frozen; ${operation} is not allowed.`); } } - diff --git a/js/token-interface/src/constants.ts b/js/token-interface/src/constants.ts index 3a71cf8d1c..2514305c51 100644 --- a/js/token-interface/src/constants.ts +++ b/js/token-interface/src/constants.ts @@ -27,7 +27,8 @@ export function deriveSplInterfacePdaWithIndex( mint: PublicKey, index: number, ): [PublicKey, number] { - const indexSeed = index === 0 ? Buffer.from([]) : Buffer.from([index & 0xff]); + const indexSeed = + index === 0 ? Buffer.from([]) : Buffer.from([index & 0xff]); return PublicKey.findProgramAddressSync( [POOL_SEED, mint.toBuffer(), indexSeed], COMPRESSED_TOKEN_PROGRAM_ID, diff --git a/js/token-interface/src/instructions/_plan.ts b/js/token-interface/src/instructions/_plan.ts index 362ca17e08..4d30b546b7 100644 --- a/js/token-interface/src/instructions/_plan.ts +++ b/js/token-interface/src/instructions/_plan.ts @@ -5,7 +5,9 @@ import { } from '@solana/instruction-plans'; import type { TransactionInstruction } from '@solana/web3.js'; -export type KitInstruction = ReturnType; +export type KitInstruction = ReturnType< + typeof fromLegacyTransactionInstruction +>; export function toKitInstructions( instructions: TransactionInstruction[], diff --git a/js/token-interface/src/instructions/ata.ts b/js/token-interface/src/instructions/ata.ts index a3ea9f779b..490634276a 100644 --- a/js/token-interface/src/instructions/ata.ts +++ b/js/token-interface/src/instructions/ata.ts @@ -125,10 +125,11 @@ function encodeCreateAssociatedLightTokenAccountData( for (;;) { const buffer = Buffer.alloc(size); try { - const len = CreateAssociatedTokenAccountInstructionDataLayout.encode( - payload, - buffer, - ); + const len = + CreateAssociatedTokenAccountInstructionDataLayout.encode( + payload, + buffer, + ); return Buffer.concat([discriminator, buffer.subarray(0, len)]); } catch (error) { if (!(error instanceof RangeError) || size >= 4096) { @@ -160,16 +161,14 @@ export interface CreateAssociatedLightTokenAccountInstructionParams { * @param input.configAccount Config account (defaults to LIGHT_TOKEN_CONFIG). * @param input.rentPayerPda Rent payer PDA (defaults to LIGHT_TOKEN_RENT_SPONSOR). */ -export function createAssociatedLightTokenAccountInstruction( - { - feePayer, - owner, - mint, - compressibleConfig = DEFAULT_COMPRESSIBLE_CONFIG, - configAccount = LIGHT_TOKEN_CONFIG, - rentPayerPda = LIGHT_TOKEN_RENT_SPONSOR, - }: CreateAssociatedLightTokenAccountInstructionParams, -): TransactionInstruction { +export function createAssociatedLightTokenAccountInstruction({ + feePayer, + owner, + mint, + compressibleConfig = DEFAULT_COMPRESSIBLE_CONFIG, + configAccount = LIGHT_TOKEN_CONFIG, + rentPayerPda = LIGHT_TOKEN_RENT_SPONSOR, +}: CreateAssociatedLightTokenAccountInstructionParams): TransactionInstruction { const effectiveFeePayer = feePayer ?? owner; const associatedTokenAccount = getAssociatedLightTokenAddress(owner, mint); @@ -235,16 +234,14 @@ export function createAssociatedLightTokenAccountInstruction( * @param input.configAccount Config account (defaults to LIGHT_TOKEN_CONFIG). * @param input.rentPayerPda Rent payer PDA (defaults to LIGHT_TOKEN_RENT_SPONSOR). */ -export function createAssociatedLightTokenAccountIdempotentInstruction( - { - feePayer, - owner, - mint, - compressibleConfig = DEFAULT_COMPRESSIBLE_CONFIG, - configAccount = LIGHT_TOKEN_CONFIG, - rentPayerPda = LIGHT_TOKEN_RENT_SPONSOR, - }: CreateAssociatedLightTokenAccountInstructionParams, -): TransactionInstruction { +export function createAssociatedLightTokenAccountIdempotentInstruction({ + feePayer, + owner, + mint, + compressibleConfig = DEFAULT_COMPRESSIBLE_CONFIG, + configAccount = LIGHT_TOKEN_CONFIG, + rentPayerPda = LIGHT_TOKEN_RENT_SPONSOR, +}: CreateAssociatedLightTokenAccountInstructionParams): TransactionInstruction { const effectiveFeePayer = feePayer ?? owner; const associatedTokenAccount = getAssociatedLightTokenAddress(owner, mint); diff --git a/js/token-interface/src/instructions/burn.ts b/js/token-interface/src/instructions/burn.ts index 281374f23d..6a7483a351 100644 --- a/js/token-interface/src/instructions/burn.ts +++ b/js/token-interface/src/instructions/burn.ts @@ -37,7 +37,11 @@ export function createBurnInstruction({ isSigner: true, isWritable: effectivePayer.equals(authority), }, - { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, + { + pubkey: SystemProgram.programId, + isSigner: false, + isWritable: false, + }, { pubkey: effectivePayer, isSigner: !effectivePayer.equals(authority), @@ -73,7 +77,11 @@ export function createBurnCheckedInstruction({ isSigner: true, isWritable: effectivePayer.equals(authority), }, - { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, + { + pubkey: SystemProgram.programId, + isSigner: false, + isWritable: false, + }, { pubkey: effectivePayer, isSigner: !effectivePayer.equals(authority), diff --git a/js/token-interface/src/instructions/load.ts b/js/token-interface/src/instructions/load.ts index 11b1038a7a..24d4ea926c 100644 --- a/js/token-interface/src/instructions/load.ts +++ b/js/token-interface/src/instructions/load.ts @@ -1,406 +1,410 @@ import { - Rpc, - LIGHT_TOKEN_PROGRAM_ID, - assertV2Enabled, -} from "@lightprotocol/stateless.js"; + Rpc, + LIGHT_TOKEN_PROGRAM_ID, + assertV2Enabled, +} from '@lightprotocol/stateless.js'; import { - ComputeBudgetProgram, - PublicKey, - TransactionInstruction, -} from "@solana/web3.js"; + ComputeBudgetProgram, + PublicKey, + TransactionInstruction, +} from '@solana/web3.js'; import { - TOKEN_PROGRAM_ID, - TOKEN_2022_PROGRAM_ID, - getAssociatedTokenAddressSync, - createAssociatedTokenAccountIdempotentInstruction, - TokenAccountNotFoundError, -} from "@solana/spl-token"; + TOKEN_PROGRAM_ID, + TOKEN_2022_PROGRAM_ID, + getAssociatedTokenAddressSync, + createAssociatedTokenAccountIdempotentInstruction, + TokenAccountNotFoundError, +} from '@solana/spl-token'; import { - AccountView, - checkNotFrozen, - getAtaView as _getAtaView, - isAuthorityForAccount, - filterAccountForAuthority, -} from "../read/get-account"; -import { getAssociatedTokenAddress } from "../read/associated-token-address"; -import { createAtaIdempotent } from "./ata"; -import { createWrapInstruction } from "./wrap"; -import { getSplInterfaces, type SplInterface } from "../spl-interface"; -import { getAtaProgramId, checkAtaAddress, AtaType } from "../read/ata-utils"; -import type { LoadOptions } from "../load-options"; -import { getMint } from "../read/get-mint"; -import { toLoadOptions } from "../helpers"; -import { getAtaAddress } from "../read"; -import type { - CreateLoadInstructionsInput, -} from "../types"; -import { toInstructionPlan } from "./_plan"; -import { createDecompressInstruction } from "./load/decompress"; -import { selectPrimaryColdCompressedAccountForLoad } from "./load/select-primary-cold-account"; -export { createDecompressInstruction } from "./load/decompress"; + AccountView, + checkNotFrozen, + getAtaView as _getAtaView, + isAuthorityForAccount, + filterAccountForAuthority, +} from '../read/get-account'; +import { getAssociatedTokenAddress } from '../read/associated-token-address'; +import { createAtaIdempotent } from './ata'; +import { createWrapInstruction } from './wrap'; +import { getSplInterfaces, type SplInterface } from '../spl-interface'; +import { getAtaProgramId, checkAtaAddress, AtaType } from '../read/ata-utils'; +import type { LoadOptions } from '../load-options'; +import { getMint } from '../read/get-mint'; +import { toLoadOptions } from '../helpers'; +import { getAtaAddress } from '../read'; +import type { CreateLoadInstructionsInput } from '../types'; +import { toInstructionPlan } from './_plan'; +import { createDecompressInstruction } from './load/decompress'; +import { selectPrimaryColdCompressedAccountForLoad } from './load/select-primary-cold-account'; +export { createDecompressInstruction } from './load/decompress'; async function _buildLoadInstructions( - rpc: Rpc, - payer: PublicKey, - ata: AccountView, - options: LoadOptions | undefined, - wrap: boolean, - targetAta: PublicKey, - targetAmount: bigint | undefined, - authority: PublicKey | undefined, - decimals: number, - allowFrozen: boolean, + rpc: Rpc, + payer: PublicKey, + ata: AccountView, + options: LoadOptions | undefined, + wrap: boolean, + targetAta: PublicKey, + targetAmount: bigint | undefined, + authority: PublicKey | undefined, + decimals: number, + allowFrozen: boolean, ): Promise { - if (!ata._isAta || !ata._owner || !ata._mint) { - throw new Error( - "AccountView must be from getAtaView (requires _isAta, _owner, _mint)", - ); - } + if (!ata._isAta || !ata._owner || !ata._mint) { + throw new Error( + 'AccountView must be from getAtaView (requires _isAta, _owner, _mint)', + ); + } - if (!allowFrozen) { - checkNotFrozen(ata, "load"); - } + if (!allowFrozen) { + checkNotFrozen(ata, 'load'); + } - const owner = ata._owner; - const mint = ata._mint; - const sources = ata._sources ?? []; + const owner = ata._owner; + const mint = ata._mint; + const sources = ata._sources ?? []; - const primaryColdCompressedAccount = - selectPrimaryColdCompressedAccountForLoad(sources); + const primaryColdCompressedAccount = + selectPrimaryColdCompressedAccountForLoad(sources); - const lightTokenAtaAddress = getAssociatedTokenAddress(mint, owner); - const splAta = getAssociatedTokenAddressSync( - mint, - owner, - false, - TOKEN_PROGRAM_ID, - getAtaProgramId(TOKEN_PROGRAM_ID), - ); - const t22Ata = getAssociatedTokenAddressSync( - mint, - owner, - false, - TOKEN_2022_PROGRAM_ID, - getAtaProgramId(TOKEN_2022_PROGRAM_ID), - ); - - let ataType: AtaType = "light-token"; - const validation = checkAtaAddress(targetAta, mint, owner); - ataType = validation.type; - if (wrap && ataType !== "light-token") { - throw new Error( - `For wrap=true, targetAta must be light-token associated token account. Got ${ataType} associated token account.`, - ); - } - - const splSource = sources.find((s) => s.type === "spl"); - const t22Source = sources.find((s) => s.type === "token2022"); - const lightTokenHotSource = sources.find((s) => s.type === "light-token-hot"); - const splBalance = splSource?.amount ?? BigInt(0); - const t22Balance = t22Source?.amount ?? BigInt(0); - const coldBalance = primaryColdCompressedAccount - ? BigInt(primaryColdCompressedAccount.parsed.amount.toString()) - : BigInt(0); - - if ( - splBalance === BigInt(0) && - t22Balance === BigInt(0) && - coldBalance === BigInt(0) - ) { - return []; - } - - const needsSplProgram = ataType === "spl" || splBalance > BigInt(0); - const needsToken2022Program = ataType === "token2022" || t22Balance > BigInt(0); - - let splProgramInterface: SplInterface | undefined; - let token2022ProgramInterface: SplInterface | undefined; - if (needsSplProgram || needsToken2022Program) { - const splInterfaces = - options?.splInterfaces ?? (await getSplInterfaces(rpc, mint)); - splProgramInterface = splInterfaces.find( - (info) => - info.isInitialized && info.tokenProgramId.equals(TOKEN_PROGRAM_ID), + const lightTokenAtaAddress = getAssociatedTokenAddress(mint, owner); + const splAta = getAssociatedTokenAddressSync( + mint, + owner, + false, + TOKEN_PROGRAM_ID, + getAtaProgramId(TOKEN_PROGRAM_ID), ); - token2022ProgramInterface = splInterfaces.find( - (info) => - info.isInitialized && info.tokenProgramId.equals(TOKEN_2022_PROGRAM_ID), + const t22Ata = getAssociatedTokenAddressSync( + mint, + owner, + false, + TOKEN_2022_PROGRAM_ID, + getAtaProgramId(TOKEN_2022_PROGRAM_ID), ); - if (needsSplProgram && !splProgramInterface) { - throw new Error( - `No initialized SPL interface found for mint ${mint.toBase58()} and token program ${TOKEN_PROGRAM_ID.toBase58()}.`, - ); - } - if (needsToken2022Program && !token2022ProgramInterface) { - throw new Error( - `No initialized SPL interface found for mint ${mint.toBase58()} and token program ${TOKEN_2022_PROGRAM_ID.toBase58()}.`, - ); - } - } - - const setupInstructions: TransactionInstruction[] = []; - - let decompressTarget: PublicKey = lightTokenAtaAddress; - let decompressSplInfo: SplInterface | undefined; - let canDecompress = false; - - if (wrap) { - decompressTarget = lightTokenAtaAddress; - decompressSplInfo = undefined; - canDecompress = true; - - if (!lightTokenHotSource) { - setupInstructions.push( - createAtaIdempotent({ - payer, - associatedToken: lightTokenAtaAddress, - owner, - mint, - programId: LIGHT_TOKEN_PROGRAM_ID, - }), - ); + let ataType: AtaType = 'light-token'; + const validation = checkAtaAddress(targetAta, mint, owner); + ataType = validation.type; + if (wrap && ataType !== 'light-token') { + throw new Error( + `For wrap=true, targetAta must be light-token associated token account. Got ${ataType} associated token account.`, + ); } - if (splBalance > BigInt(0)) { - setupInstructions.push( - createWrapInstruction({ - source: splAta, - destination: lightTokenAtaAddress, - owner, - mint, - amount: splBalance, - splInterface: splProgramInterface!, - decimals, - payer, - }), - ); - } + const splSource = sources.find(s => s.type === 'spl'); + const t22Source = sources.find(s => s.type === 'token2022'); + const lightTokenHotSource = sources.find(s => s.type === 'light-token-hot'); + const splBalance = splSource?.amount ?? BigInt(0); + const t22Balance = t22Source?.amount ?? BigInt(0); + const coldBalance = primaryColdCompressedAccount + ? BigInt(primaryColdCompressedAccount.parsed.amount.toString()) + : BigInt(0); - if (t22Balance > BigInt(0)) { - setupInstructions.push( - createWrapInstruction({ - source: t22Ata, - destination: lightTokenAtaAddress, - owner, - mint, - amount: t22Balance, - splInterface: token2022ProgramInterface!, - decimals, - payer, - }), - ); + if ( + splBalance === BigInt(0) && + t22Balance === BigInt(0) && + coldBalance === BigInt(0) + ) { + return []; } - } else { - if (ataType === "light-token") { - decompressTarget = lightTokenAtaAddress; - decompressSplInfo = undefined; - canDecompress = true; - if (!lightTokenHotSource) { - setupInstructions.push( - createAtaIdempotent({ - payer, - associatedToken: lightTokenAtaAddress, - owner, - mint, - programId: LIGHT_TOKEN_PROGRAM_ID, - }), - ); - } - } else if (ataType === "spl") { - decompressTarget = splAta; - decompressSplInfo = splProgramInterface!; - canDecompress = true; - if (!splSource) { - setupInstructions.push( - createAssociatedTokenAccountIdempotentInstruction( - payer, - splAta, - owner, - mint, - TOKEN_PROGRAM_ID, - ), + + const needsSplProgram = ataType === 'spl' || splBalance > BigInt(0); + const needsToken2022Program = + ataType === 'token2022' || t22Balance > BigInt(0); + + let splProgramInterface: SplInterface | undefined; + let token2022ProgramInterface: SplInterface | undefined; + if (needsSplProgram || needsToken2022Program) { + const splInterfaces = + options?.splInterfaces ?? (await getSplInterfaces(rpc, mint)); + splProgramInterface = splInterfaces.find( + info => + info.isInitialized && + info.tokenProgramId.equals(TOKEN_PROGRAM_ID), ); - } - } else if (ataType === "token2022") { - decompressTarget = t22Ata; - decompressSplInfo = token2022ProgramInterface!; - canDecompress = true; - if (!t22Source) { - setupInstructions.push( - createAssociatedTokenAccountIdempotentInstruction( - payer, - t22Ata, - owner, - mint, - TOKEN_2022_PROGRAM_ID, - ), + token2022ProgramInterface = splInterfaces.find( + info => + info.isInitialized && + info.tokenProgramId.equals(TOKEN_2022_PROGRAM_ID), ); - } + + if (needsSplProgram && !splProgramInterface) { + throw new Error( + `No initialized SPL interface found for mint ${mint.toBase58()} and token program ${TOKEN_PROGRAM_ID.toBase58()}.`, + ); + } + if (needsToken2022Program && !token2022ProgramInterface) { + throw new Error( + `No initialized SPL interface found for mint ${mint.toBase58()} and token program ${TOKEN_2022_PROGRAM_ID.toBase58()}.`, + ); + } } - } - - let accountToLoad = primaryColdCompressedAccount; - - if ( - targetAmount !== undefined && - canDecompress && - primaryColdCompressedAccount - ) { - const isDelegate = authority !== undefined && !authority.equals(owner); - const hotBalance = (() => { - if (!lightTokenHotSource) return BigInt(0); - if (isDelegate) { - const delegated = - lightTokenHotSource.parsed.delegatedAmount ?? BigInt(0); - return delegated < lightTokenHotSource.amount - ? delegated - : lightTokenHotSource.amount; - } - return lightTokenHotSource.amount; - })(); - let effectiveHotAfterSetup: bigint; + + const setupInstructions: TransactionInstruction[] = []; + + let decompressTarget: PublicKey = lightTokenAtaAddress; + let decompressSplInfo: SplInterface | undefined; + let canDecompress = false; if (wrap) { - effectiveHotAfterSetup = hotBalance + splBalance + t22Balance; - } else if (ataType === "light-token") { - effectiveHotAfterSetup = hotBalance; - } else if (ataType === "spl") { - effectiveHotAfterSetup = splBalance; + decompressTarget = lightTokenAtaAddress; + decompressSplInfo = undefined; + canDecompress = true; + + if (!lightTokenHotSource) { + setupInstructions.push( + createAtaIdempotent({ + payer, + associatedToken: lightTokenAtaAddress, + owner, + mint, + programId: LIGHT_TOKEN_PROGRAM_ID, + }), + ); + } + + if (splBalance > BigInt(0)) { + setupInstructions.push( + createWrapInstruction({ + source: splAta, + destination: lightTokenAtaAddress, + owner, + mint, + amount: splBalance, + splInterface: splProgramInterface!, + decimals, + payer, + }), + ); + } + + if (t22Balance > BigInt(0)) { + setupInstructions.push( + createWrapInstruction({ + source: t22Ata, + destination: lightTokenAtaAddress, + owner, + mint, + amount: t22Balance, + splInterface: token2022ProgramInterface!, + decimals, + payer, + }), + ); + } } else { - effectiveHotAfterSetup = t22Balance; + if (ataType === 'light-token') { + decompressTarget = lightTokenAtaAddress; + decompressSplInfo = undefined; + canDecompress = true; + if (!lightTokenHotSource) { + setupInstructions.push( + createAtaIdempotent({ + payer, + associatedToken: lightTokenAtaAddress, + owner, + mint, + programId: LIGHT_TOKEN_PROGRAM_ID, + }), + ); + } + } else if (ataType === 'spl') { + decompressTarget = splAta; + decompressSplInfo = splProgramInterface!; + canDecompress = true; + if (!splSource) { + setupInstructions.push( + createAssociatedTokenAccountIdempotentInstruction( + payer, + splAta, + owner, + mint, + TOKEN_PROGRAM_ID, + ), + ); + } + } else if (ataType === 'token2022') { + decompressTarget = t22Ata; + decompressSplInfo = token2022ProgramInterface!; + canDecompress = true; + if (!t22Source) { + setupInstructions.push( + createAssociatedTokenAccountIdempotentInstruction( + payer, + t22Ata, + owner, + mint, + TOKEN_2022_PROGRAM_ID, + ), + ); + } + } } - const neededFromCold = - targetAmount > effectiveHotAfterSetup - ? targetAmount - effectiveHotAfterSetup - : BigInt(0); + let accountToLoad = primaryColdCompressedAccount; + + if ( + targetAmount !== undefined && + canDecompress && + primaryColdCompressedAccount + ) { + const isDelegate = authority !== undefined && !authority.equals(owner); + const hotBalance = (() => { + if (!lightTokenHotSource) return BigInt(0); + if (isDelegate) { + const delegated = + lightTokenHotSource.parsed.delegatedAmount ?? BigInt(0); + return delegated < lightTokenHotSource.amount + ? delegated + : lightTokenHotSource.amount; + } + return lightTokenHotSource.amount; + })(); + let effectiveHotAfterSetup: bigint; + + if (wrap) { + effectiveHotAfterSetup = hotBalance + splBalance + t22Balance; + } else if (ataType === 'light-token') { + effectiveHotAfterSetup = hotBalance; + } else if (ataType === 'spl') { + effectiveHotAfterSetup = splBalance; + } else { + effectiveHotAfterSetup = t22Balance; + } + + const neededFromCold = + targetAmount > effectiveHotAfterSetup + ? targetAmount - effectiveHotAfterSetup + : BigInt(0); + + if (neededFromCold === BigInt(0)) { + accountToLoad = null; + } + } - if (neededFromCold === BigInt(0)) { - accountToLoad = null; + if (!canDecompress || !accountToLoad) { + return setupInstructions; } - } - - if (!canDecompress || !accountToLoad) { - return setupInstructions; - } - - const proof = await rpc.getValidityProofV0([ - { - hash: accountToLoad.compressedAccount.hash, - tree: accountToLoad.compressedAccount.treeInfo.tree, - queue: accountToLoad.compressedAccount.treeInfo.queue, - }, - ]); - const authorityForDecompress = authority ?? owner; - const amountToDecompress = BigInt(accountToLoad.parsed.amount.toString()); - - return [ - ...setupInstructions, - createDecompressInstruction({ - payer, - inputCompressedTokenAccounts: [accountToLoad], - toAddress: decompressTarget, - amount: amountToDecompress, - validityProof: proof, - splInterface: decompressSplInfo, - decimals, - authority: authorityForDecompress, - }), - ]; + + const proof = await rpc.getValidityProofV0([ + { + hash: accountToLoad.compressedAccount.hash, + tree: accountToLoad.compressedAccount.treeInfo.tree, + queue: accountToLoad.compressedAccount.treeInfo.queue, + }, + ]); + const authorityForDecompress = authority ?? owner; + const amountToDecompress = BigInt(accountToLoad.parsed.amount.toString()); + + return [ + ...setupInstructions, + createDecompressInstruction({ + payer, + inputCompressedTokenAccounts: [accountToLoad], + toAddress: decompressTarget, + amount: amountToDecompress, + validityProof: proof, + splInterface: decompressSplInfo, + decimals, + authority: authorityForDecompress, + }), + ]; } export interface CreateLoadInstructionOptions - extends CreateLoadInstructionsInput { - authority?: PublicKey; - wrap?: boolean; - allowFrozen?: boolean; - splInterfaces?: SplInterface[]; - decimals?: number; + extends CreateLoadInstructionsInput { + authority?: PublicKey; + wrap?: boolean; + allowFrozen?: boolean; + splInterfaces?: SplInterface[]; + decimals?: number; } function buildLoadOptions( - owner: PublicKey, - authority: PublicKey | undefined, - wrap: boolean, - splInterfaces: SplInterface[] | undefined, + owner: PublicKey, + authority: PublicKey | undefined, + wrap: boolean, + splInterfaces: SplInterface[] | undefined, ): LoadOptions | undefined { - const options = toLoadOptions(owner, authority, wrap) ?? {}; - if (splInterfaces) { - options.splInterfaces = splInterfaces; - } - return Object.keys(options).length === 0 ? undefined : options; + const options = toLoadOptions(owner, authority, wrap) ?? {}; + if (splInterfaces) { + options.splInterfaces = splInterfaces; + } + return Object.keys(options).length === 0 ? undefined : options; } export async function createLoadInstructions({ - rpc, - payer, - owner, - mint, - authority, - wrap = true, - allowFrozen = false, - splInterfaces, - decimals, + rpc, + payer, + owner, + mint, + authority, + wrap = true, + allowFrozen = false, + splInterfaces, + decimals, }: CreateLoadInstructionOptions): Promise { - const targetAta = getAtaAddress({ owner, mint }); - const loadOptions = buildLoadOptions(owner, authority, wrap, splInterfaces); - - assertV2Enabled(); - payer ??= owner; - const authorityPubkey = loadOptions?.delegatePubkey ?? owner; - - let accountView: AccountView; - try { - accountView = await _getAtaView( - rpc, - targetAta, - owner, - mint, - undefined, - undefined, - wrap, - ); - } catch (e) { - if (e instanceof TokenAccountNotFoundError) { - return []; + const targetAta = getAtaAddress({ owner, mint }); + const loadOptions = buildLoadOptions(owner, authority, wrap, splInterfaces); + + assertV2Enabled(); + payer ??= owner; + const authorityPubkey = loadOptions?.delegatePubkey ?? owner; + + let accountView: AccountView; + try { + accountView = await _getAtaView( + rpc, + targetAta, + owner, + mint, + undefined, + undefined, + wrap, + ); + } catch (e) { + if (e instanceof TokenAccountNotFoundError) { + return []; + } + throw e; } - throw e; - } - const resolvedDecimals = decimals ?? (await getMint(rpc, mint)).mint.decimals; + const resolvedDecimals = + decimals ?? (await getMint(rpc, mint)).mint.decimals; - if (!owner.equals(authorityPubkey)) { - if (!isAuthorityForAccount(accountView, authorityPubkey)) { - throw new Error("Signer is not the owner or a delegate of the account."); + if (!owner.equals(authorityPubkey)) { + if (!isAuthorityForAccount(accountView, authorityPubkey)) { + throw new Error( + 'Signer is not the owner or a delegate of the account.', + ); + } + accountView = filterAccountForAuthority(accountView, authorityPubkey); } - accountView = filterAccountForAuthority(accountView, authorityPubkey); - } - const instructions = await _buildLoadInstructions( - rpc, - payer, - accountView, - loadOptions, - wrap, - targetAta, - undefined, - authorityPubkey, - resolvedDecimals, - allowFrozen, - ); - - if (instructions.length === 0) { - return []; - } - return instructions.filter( - (instruction) => - !instruction.programId.equals(ComputeBudgetProgram.programId), - ); + const instructions = await _buildLoadInstructions( + rpc, + payer, + accountView, + loadOptions, + wrap, + targetAta, + undefined, + authorityPubkey, + resolvedDecimals, + allowFrozen, + ); + + if (instructions.length === 0) { + return []; + } + return instructions.filter( + instruction => + !instruction.programId.equals(ComputeBudgetProgram.programId), + ); } export async function createLoadInstructionPlan( - input: CreateLoadInstructionsInput, + input: CreateLoadInstructionsInput, ) { - return toInstructionPlan(await createLoadInstructions(input)); + return toInstructionPlan(await createLoadInstructions(input)); } diff --git a/js/token-interface/src/instructions/load/decompress.ts b/js/token-interface/src/instructions/load/decompress.ts index 57ff041515..e001419bfd 100644 --- a/js/token-interface/src/instructions/load/decompress.ts +++ b/js/token-interface/src/instructions/load/decompress.ts @@ -1,35 +1,39 @@ import { - LIGHT_TOKEN_PROGRAM_ID, - LightSystemProgram, - ParsedTokenAccount, - ValidityProofWithContext, - defaultStaticAccountsStruct, -} from "@lightprotocol/stateless.js"; -import { Buffer } from "buffer"; -import { PublicKey, SystemProgram, TransactionInstruction } from "@solana/web3.js"; + LIGHT_TOKEN_PROGRAM_ID, + LightSystemProgram, + ParsedTokenAccount, + ValidityProofWithContext, + defaultStaticAccountsStruct, +} from '@lightprotocol/stateless.js'; +import { Buffer } from 'buffer'; import { - COMPRESSED_TOKEN_PROGRAM_ID, - MAX_TOP_UP, - TokenDataVersion, - deriveCpiAuthorityPda, -} from "../../constants"; + PublicKey, + SystemProgram, + TransactionInstruction, +} from '@solana/web3.js'; import { - COMPRESSION_MODE_DECOMPRESS, - Compression, - MultiInputTokenDataWithContext, - Transfer2ExtensionData, - Transfer2InstructionData, - encodeTransfer2InstructionData, -} from "../layout/layout-transfer2"; -import { SplInterface } from "../../spl-interface"; + COMPRESSED_TOKEN_PROGRAM_ID, + MAX_TOP_UP, + TokenDataVersion, + deriveCpiAuthorityPda, +} from '../../constants'; +import { + COMPRESSION_MODE_DECOMPRESS, + Compression, + MultiInputTokenDataWithContext, + Transfer2ExtensionData, + Transfer2InstructionData, + encodeTransfer2InstructionData, +} from '../layout/layout-transfer2'; +import { SplInterface } from '../../spl-interface'; const COMPRESSED_ONLY_DISC = 31; const COMPRESSED_ONLY_SIZE = 17; // u64 + u64 + u8 interface ParsedCompressedOnly { - delegatedAmount: bigint; - withheldTransferFee: bigint; - isAta: boolean; + delegatedAmount: bigint; + withheldTransferFee: bigint; + isAta: boolean; } /** @@ -38,46 +42,46 @@ interface ParsedCompressedOnly { * @internal */ function parseCompressedOnlyFromTlv( - tlv: Buffer | null, + tlv: Buffer | null, ): ParsedCompressedOnly | null { - if (!tlv || tlv.length < 5) return null; - try { - let offset = 0; - const vecLen = tlv.readUInt32LE(offset); - offset += 4; - for (let i = 0; i < vecLen; i++) { - if (offset >= tlv.length) return null; - const disc = tlv[offset]; - offset += 1; - if (disc === COMPRESSED_ONLY_DISC) { - if (offset + COMPRESSED_ONLY_SIZE > tlv.length) return null; - const loDA = BigInt(tlv.readUInt32LE(offset)); - const hiDA = BigInt(tlv.readUInt32LE(offset + 4)); - const delegatedAmount = loDA | (hiDA << BigInt(32)); - const loFee = BigInt(tlv.readUInt32LE(offset + 8)); - const hiFee = BigInt(tlv.readUInt32LE(offset + 12)); - const withheldTransferFee = loFee | (hiFee << BigInt(32)); - const isAta = tlv[offset + 16] !== 0; - return { delegatedAmount, withheldTransferFee, isAta }; - } - const SIZES: Record = { - 29: 8, - 30: 1, - 31: 17, - }; - const size = SIZES[disc]; - if (size === undefined) { - throw new Error( - `parseCompressedOnlyFromTlv: unknown TLV extension discriminant ${disc}`, - ); - } - offset += size; + if (!tlv || tlv.length < 5) return null; + try { + let offset = 0; + const vecLen = tlv.readUInt32LE(offset); + offset += 4; + for (let i = 0; i < vecLen; i++) { + if (offset >= tlv.length) return null; + const disc = tlv[offset]; + offset += 1; + if (disc === COMPRESSED_ONLY_DISC) { + if (offset + COMPRESSED_ONLY_SIZE > tlv.length) return null; + const loDA = BigInt(tlv.readUInt32LE(offset)); + const hiDA = BigInt(tlv.readUInt32LE(offset + 4)); + const delegatedAmount = loDA | (hiDA << BigInt(32)); + const loFee = BigInt(tlv.readUInt32LE(offset + 8)); + const hiFee = BigInt(tlv.readUInt32LE(offset + 12)); + const withheldTransferFee = loFee | (hiFee << BigInt(32)); + const isAta = tlv[offset + 16] !== 0; + return { delegatedAmount, withheldTransferFee, isAta }; + } + const SIZES: Record = { + 29: 8, + 30: 1, + 31: 17, + }; + const size = SIZES[disc]; + if (size === undefined) { + throw new Error( + `parseCompressedOnlyFromTlv: unknown TLV extension discriminant ${disc}`, + ); + } + offset += size; + } + } catch { + // Ignoring unknown TLV extensions. + return null; } - } catch { - // Ignoring unknown TLV extensions. return null; - } - return null; } /** @@ -88,53 +92,53 @@ function parseCompressedOnlyFromTlv( * @internal */ function buildInTlv( - accounts: ParsedTokenAccount[], - ownerIndex: number, - owner: PublicKey, - mint: PublicKey, + accounts: ParsedTokenAccount[], + ownerIndex: number, + owner: PublicKey, + mint: PublicKey, ): Transfer2ExtensionData[][] | null { - let hasAny = false; - const result: Transfer2ExtensionData[][] = []; - - for (const acc of accounts) { - const co = parseCompressedOnlyFromTlv(acc.parsed.tlv); - if (!co) { - result.push([]); - continue; - } - hasAny = true; - let bump = 0; - if (co.isAta) { - const seeds = [ - owner.toBuffer(), - LIGHT_TOKEN_PROGRAM_ID.toBuffer(), - mint.toBuffer(), - ]; - const [, b] = PublicKey.findProgramAddressSync( - seeds, - LIGHT_TOKEN_PROGRAM_ID, - ); - bump = b; + let hasAny = false; + const result: Transfer2ExtensionData[][] = []; + + for (const acc of accounts) { + const co = parseCompressedOnlyFromTlv(acc.parsed.tlv); + if (!co) { + result.push([]); + continue; + } + hasAny = true; + let bump = 0; + if (co.isAta) { + const seeds = [ + owner.toBuffer(), + LIGHT_TOKEN_PROGRAM_ID.toBuffer(), + mint.toBuffer(), + ]; + const [, b] = PublicKey.findProgramAddressSync( + seeds, + LIGHT_TOKEN_PROGRAM_ID, + ); + bump = b; + } + const isFrozen = acc.parsed.state === 2; + result.push([ + { + type: 'CompressedOnly', + data: { + delegatedAmount: co.delegatedAmount, + withheldTransferFee: co.withheldTransferFee, + isFrozen, + // This builder emits a single decompress compression per batch. + // Keep index at 0 unless multi-compression output is added here. + compressionIndex: 0, + isAta: co.isAta, + bump, + ownerIndex, + }, + }, + ]); } - const isFrozen = acc.parsed.state === 2; - result.push([ - { - type: "CompressedOnly", - data: { - delegatedAmount: co.delegatedAmount, - withheldTransferFee: co.withheldTransferFee, - isFrozen, - // This builder emits a single decompress compression per batch. - // Keep index at 0 unless multi-compression output is added here. - compressionIndex: 0, - isAta: co.isAta, - bump, - ownerIndex, - }, - }, - ]); - } - return hasAny ? result : null; + return hasAny ? result : null; } /** @@ -142,29 +146,29 @@ function buildInTlv( * @internal */ function getVersionFromDiscriminator( - discriminator: number[] | undefined, + discriminator: number[] | undefined, ): number { - if (!discriminator || discriminator.length < 8) { - // Default to ShaFlat for new accounts without discriminator - return TokenDataVersion.ShaFlat; - } - - // V1 has discriminator[0] = 2 - if (discriminator[0] === 2) { - return TokenDataVersion.V1; - } - - // V2 and ShaFlat have version in discriminator[7] - const versionByte = discriminator[7]; - if (versionByte === 3) { - return TokenDataVersion.V2; - } - if (versionByte === 4) { - return TokenDataVersion.ShaFlat; - } + if (!discriminator || discriminator.length < 8) { + // Default to ShaFlat for new accounts without discriminator + return TokenDataVersion.ShaFlat; + } + + // V1 has discriminator[0] = 2 + if (discriminator[0] === 2) { + return TokenDataVersion.V1; + } + + // V2 and ShaFlat have version in discriminator[7] + const versionByte = discriminator[7]; + if (versionByte === 3) { + return TokenDataVersion.V2; + } + if (versionByte === 4) { + return TokenDataVersion.ShaFlat; + } - // Default to ShaFlat - return TokenDataVersion.ShaFlat; + // Default to ShaFlat + return TokenDataVersion.ShaFlat; } /** @@ -172,40 +176,41 @@ function getVersionFromDiscriminator( * @internal */ function buildInputTokenData( - accounts: ParsedTokenAccount[], - rootIndices: number[], - packedAccountIndices: Map, + accounts: ParsedTokenAccount[], + rootIndices: number[], + packedAccountIndices: Map, ): MultiInputTokenDataWithContext[] { - return accounts.map((acc, i) => { - const ownerKey = acc.parsed.owner.toBase58(); - const mintKey = acc.parsed.mint.toBase58(); + return accounts.map((acc, i) => { + const ownerKey = acc.parsed.owner.toBase58(); + const mintKey = acc.parsed.mint.toBase58(); - const version = getVersionFromDiscriminator( - acc.compressedAccount.data?.discriminator, - ); + const version = getVersionFromDiscriminator( + acc.compressedAccount.data?.discriminator, + ); - return { - owner: packedAccountIndices.get(ownerKey)!, - amount: BigInt(acc.parsed.amount.toString()), - hasDelegate: acc.parsed.delegate !== null, - delegate: acc.parsed.delegate - ? (packedAccountIndices.get(acc.parsed.delegate.toBase58()) ?? 0) - : 0, - mint: packedAccountIndices.get(mintKey)!, - version, - merkleContext: { - merkleTreePubkeyIndex: packedAccountIndices.get( - acc.compressedAccount.treeInfo.tree.toBase58(), - )!, - queuePubkeyIndex: packedAccountIndices.get( - acc.compressedAccount.treeInfo.queue.toBase58(), - )!, - leafIndex: acc.compressedAccount.leafIndex, - proveByIndex: acc.compressedAccount.proveByIndex, - }, - rootIndex: rootIndices[i], - }; - }); + return { + owner: packedAccountIndices.get(ownerKey)!, + amount: BigInt(acc.parsed.amount.toString()), + hasDelegate: acc.parsed.delegate !== null, + delegate: acc.parsed.delegate + ? (packedAccountIndices.get(acc.parsed.delegate.toBase58()) ?? + 0) + : 0, + mint: packedAccountIndices.get(mintKey)!, + version, + merkleContext: { + merkleTreePubkeyIndex: packedAccountIndices.get( + acc.compressedAccount.treeInfo.tree.toBase58(), + )!, + queuePubkeyIndex: packedAccountIndices.get( + acc.compressedAccount.treeInfo.queue.toBase58(), + )!, + leafIndex: acc.compressedAccount.leafIndex, + proveByIndex: acc.compressedAccount.proveByIndex, + }, + rootIndex: rootIndices[i], + }; + }); } /** @@ -218,295 +223,302 @@ function buildInputTokenData( * - For SPL destinations: Provide splInterface and decimals */ export function createDecompressInstruction({ - payer, - inputCompressedTokenAccounts, - toAddress, - amount, - validityProof, - splInterface, - decimals, - maxTopUp, - authority, + payer, + inputCompressedTokenAccounts, + toAddress, + amount, + validityProof, + splInterface, + decimals, + maxTopUp, + authority, }: { - payer?: PublicKey; - inputCompressedTokenAccounts: ParsedTokenAccount[]; - toAddress: PublicKey; - amount: bigint; - validityProof: ValidityProofWithContext; - splInterface?: SplInterface; - decimals: number; - maxTopUp?: number; - authority?: PublicKey; + payer?: PublicKey; + inputCompressedTokenAccounts: ParsedTokenAccount[]; + toAddress: PublicKey; + amount: bigint; + validityProof: ValidityProofWithContext; + splInterface?: SplInterface; + decimals: number; + maxTopUp?: number; + authority?: PublicKey; }): TransactionInstruction { - if (inputCompressedTokenAccounts.length === 0) { - throw new Error("No input light-token accounts provided"); - } - - const mint = inputCompressedTokenAccounts[0].parsed.mint; - const owner = inputCompressedTokenAccounts[0].parsed.owner; - for (const account of inputCompressedTokenAccounts) { - if (!account.parsed.mint.equals(mint)) { - throw new Error( - "All input light-token accounts must have the same mint for decompress.", - ); + if (inputCompressedTokenAccounts.length === 0) { + throw new Error('No input light-token accounts provided'); } - if (!account.parsed.owner.equals(owner)) { - throw new Error( - "All input light-token accounts must have the same owner for decompress.", - ); + + const mint = inputCompressedTokenAccounts[0].parsed.mint; + const owner = inputCompressedTokenAccounts[0].parsed.owner; + for (const account of inputCompressedTokenAccounts) { + if (!account.parsed.mint.equals(mint)) { + throw new Error( + 'All input light-token accounts must have the same mint for decompress.', + ); + } + if (!account.parsed.owner.equals(owner)) { + throw new Error( + 'All input light-token accounts must have the same owner for decompress.', + ); + } } - } - - // Build packed accounts map - // Order: trees/queues first, then mint, owner, light-token account, light-token program - const packedAccountIndices = new Map(); - const packedAccounts: PublicKey[] = []; - - // Collect unique trees and queues - const treeSet = new Set(); - const queueSet = new Set(); - for (const acc of inputCompressedTokenAccounts) { - treeSet.add(acc.compressedAccount.treeInfo.tree.toBase58()); - queueSet.add(acc.compressedAccount.treeInfo.queue.toBase58()); - } - - // Add trees first (owned by account compression program) - for (const tree of treeSet) { - packedAccountIndices.set(tree, packedAccounts.length); - packedAccounts.push(new PublicKey(tree)); - } - - let firstQueueIndex = 0; - let isFirstQueue = true; - for (const queue of queueSet) { - if (isFirstQueue) { - firstQueueIndex = packedAccounts.length; - isFirstQueue = false; + + // Build packed accounts map + // Order: trees/queues first, then mint, owner, light-token account, light-token program + const packedAccountIndices = new Map(); + const packedAccounts: PublicKey[] = []; + + // Collect unique trees and queues + const treeSet = new Set(); + const queueSet = new Set(); + for (const acc of inputCompressedTokenAccounts) { + treeSet.add(acc.compressedAccount.treeInfo.tree.toBase58()); + queueSet.add(acc.compressedAccount.treeInfo.queue.toBase58()); } - packedAccountIndices.set(queue, packedAccounts.length); - packedAccounts.push(new PublicKey(queue)); - } - - // Add mint - const mintIndex = packedAccounts.length; - packedAccountIndices.set(mint.toBase58(), mintIndex); - packedAccounts.push(mint); - - // Add owner - const ownerIndex = packedAccounts.length; - packedAccountIndices.set(owner.toBase58(), ownerIndex); - packedAccounts.push(owner); - - // Add destination token account (light-token or SPL) - const destinationIndex = packedAccounts.length; - packedAccountIndices.set(toAddress.toBase58(), destinationIndex); - packedAccounts.push(toAddress); - - // Add unique delegate pubkeys from input accounts - for (const acc of inputCompressedTokenAccounts) { - if (acc.parsed.delegate) { - const delegateKey = acc.parsed.delegate.toBase58(); - if (!packedAccountIndices.has(delegateKey)) { - packedAccountIndices.set(delegateKey, packedAccounts.length); - packedAccounts.push(acc.parsed.delegate); - } + + // Add trees first (owned by account compression program) + for (const tree of treeSet) { + packedAccountIndices.set(tree, packedAccounts.length); + packedAccounts.push(new PublicKey(tree)); } - } - - // For SPL decompression, add pool account and token program - let poolAccountIndex = 0; - let poolIndex = 0; - let poolBump = 0; - let tokenProgramIndex = 0; - - if (splInterface) { - // Add SPL interface PDA (token pool) - poolAccountIndex = packedAccounts.length; - packedAccountIndices.set( - splInterface.poolPda.toBase58(), - poolAccountIndex, - ); - packedAccounts.push(splInterface.poolPda); - // Add SPL token program - tokenProgramIndex = packedAccounts.length; - packedAccountIndices.set( - splInterface.tokenProgramId.toBase58(), - tokenProgramIndex, - ); - packedAccounts.push(splInterface.tokenProgramId); + let firstQueueIndex = 0; + let isFirstQueue = true; + for (const queue of queueSet) { + if (isFirstQueue) { + firstQueueIndex = packedAccounts.length; + isFirstQueue = false; + } + packedAccountIndices.set(queue, packedAccounts.length); + packedAccounts.push(new PublicKey(queue)); + } - poolIndex = splInterface.derivationIndex; - poolBump = splInterface.bump; - } + // Add mint + const mintIndex = packedAccounts.length; + packedAccountIndices.set(mint.toBase58(), mintIndex); + packedAccounts.push(mint); + + // Add owner + const ownerIndex = packedAccounts.length; + packedAccountIndices.set(owner.toBase58(), ownerIndex); + packedAccounts.push(owner); + + // Add destination token account (light-token or SPL) + const destinationIndex = packedAccounts.length; + packedAccountIndices.set(toAddress.toBase58(), destinationIndex); + packedAccounts.push(toAddress); + + // Add unique delegate pubkeys from input accounts + for (const acc of inputCompressedTokenAccounts) { + if (acc.parsed.delegate) { + const delegateKey = acc.parsed.delegate.toBase58(); + if (!packedAccountIndices.has(delegateKey)) { + packedAccountIndices.set(delegateKey, packedAccounts.length); + packedAccounts.push(acc.parsed.delegate); + } + } + } - // Keep token program index materialized to preserve packed account ordering - // and index side effects for instruction construction. - void tokenProgramIndex; + // For SPL decompression, add pool account and token program + let poolAccountIndex = 0; + let poolIndex = 0; + let poolBump = 0; + let tokenProgramIndex = 0; + + if (splInterface) { + // Add SPL interface PDA (token pool) + poolAccountIndex = packedAccounts.length; + packedAccountIndices.set( + splInterface.poolPda.toBase58(), + poolAccountIndex, + ); + packedAccounts.push(splInterface.poolPda); - // Build input token data - const inTokenData = buildInputTokenData( - inputCompressedTokenAccounts, - validityProof.rootIndices, - packedAccountIndices, - ); - - // Calculate total input amount and change - const totalInputAmount = inputCompressedTokenAccounts.reduce( - (sum, acc) => sum + BigInt(acc.parsed.amount.toString()), - BigInt(0), - ); - if (amount > totalInputAmount) { - throw new Error( - `Decompress amount ${amount.toString()} exceeds total input amount ${totalInputAmount.toString()}.`, - ); - } - const changeAmount = totalInputAmount - amount; + // Add SPL token program + tokenProgramIndex = packedAccounts.length; + packedAccountIndices.set( + splInterface.tokenProgramId.toBase58(), + tokenProgramIndex, + ); + packedAccounts.push(splInterface.tokenProgramId); - const outTokenData: { - owner: number; - amount: bigint; - hasDelegate: boolean; - delegate: number; - mint: number; - version: number; - }[] = []; - - if (changeAmount > 0) { - const version = getVersionFromDiscriminator( - inputCompressedTokenAccounts[0].compressedAccount.data?.discriminator, + poolIndex = splInterface.derivationIndex; + poolBump = splInterface.bump; + } + + // Keep token program index materialized to preserve packed account ordering + // and index side effects for instruction construction. + void tokenProgramIndex; + + // Build input token data + const inTokenData = buildInputTokenData( + inputCompressedTokenAccounts, + validityProof.rootIndices, + packedAccountIndices, ); - outTokenData.push({ - owner: ownerIndex, - amount: changeAmount, - hasDelegate: false, - delegate: 0, - mint: mintIndex, - version, - }); - } - - // Build decompress compression - // For light-token: pool values are 0 (unused) - // For SPL: pool values point to SPL interface PDA - const compressions: Compression[] = [ - { - mode: COMPRESSION_MODE_DECOMPRESS, - amount, - mint: mintIndex, - sourceOrRecipient: destinationIndex, - authority: 0, // Not needed for decompress - poolAccountIndex: splInterface ? poolAccountIndex : 0, - poolIndex: splInterface ? poolIndex : 0, - bump: splInterface ? poolBump : 0, - decimals, - }, - ]; - - // Build Transfer2 instruction data - const instructionData: Transfer2InstructionData = { - withTransactionHash: false, - withLamportsChangeAccountMerkleTreeIndex: false, - lamportsChangeAccountMerkleTreeIndex: 0, - lamportsChangeAccountOwnerIndex: 0, - outputQueue: firstQueueIndex, // First queue in packed accounts - maxTopUp: maxTopUp ?? MAX_TOP_UP, - cpiContext: null, - compressions, - proof: validityProof.compressedProof - ? { - a: Array.from(validityProof.compressedProof.a), - b: Array.from(validityProof.compressedProof.b), - c: Array.from(validityProof.compressedProof.c), - } - : null, - inTokenData, - outTokenData, - inLamports: null, - outLamports: null, - inTlv: buildInTlv(inputCompressedTokenAccounts, ownerIndex, owner, mint), - outTlv: null, - }; - - const data = encodeTransfer2InstructionData(instructionData); - - // Build accounts for Transfer2 with compressed accounts (full path) - const { - accountCompressionAuthority, - registeredProgramPda, - accountCompressionProgram, - } = defaultStaticAccountsStruct(); - const signerIndex = (() => { - if (!authority || authority.equals(owner)) { - return ownerIndex; + // Calculate total input amount and change + const totalInputAmount = inputCompressedTokenAccounts.reduce( + (sum, acc) => sum + BigInt(acc.parsed.amount.toString()), + BigInt(0), + ); + if (amount > totalInputAmount) { + throw new Error( + `Decompress amount ${amount.toString()} exceeds total input amount ${totalInputAmount.toString()}.`, + ); } - const authorityIndex = packedAccountIndices.get(authority.toBase58()); - if (authorityIndex === undefined) { - throw new Error( - `Authority ${authority.toBase58()} is not present in packed accounts`, - ); + const changeAmount = totalInputAmount - amount; + + const outTokenData: { + owner: number; + amount: bigint; + hasDelegate: boolean; + delegate: number; + mint: number; + version: number; + }[] = []; + + if (changeAmount > 0) { + const version = getVersionFromDiscriminator( + inputCompressedTokenAccounts[0].compressedAccount.data + ?.discriminator, + ); + + outTokenData.push({ + owner: ownerIndex, + amount: changeAmount, + hasDelegate: false, + delegate: 0, + mint: mintIndex, + version, + }); } - return authorityIndex; - })(); - const effectivePayer = payer ?? authority ?? owner; - - const keys = [ - // 0: light_system_program (non-mutable) - { - pubkey: LightSystemProgram.programId, - isSigner: false, - isWritable: false, - }, - // 1: fee_payer (signer, mutable) - { pubkey: effectivePayer, isSigner: true, isWritable: true }, - // 2: cpi_authority_pda - { - pubkey: deriveCpiAuthorityPda(), - isSigner: false, - isWritable: false, - }, - // 3: registered_program_pda - { - pubkey: registeredProgramPda, - isSigner: false, - isWritable: false, - }, - // 4: account_compression_authority - { - pubkey: accountCompressionAuthority, - isSigner: false, - isWritable: false, - }, - // 5: account_compression_program - { - pubkey: accountCompressionProgram, - isSigner: false, - isWritable: false, - }, - // 6: system_program - { - pubkey: SystemProgram.programId, - isSigner: false, - isWritable: false, - }, - // 7+: packed_accounts (trees/queues come first) - ...packedAccounts.map((pubkey, i) => { - const isTreeOrQueue = i < treeSet.size + queueSet.size; - const isDestination = pubkey.equals(toAddress); - const isPool = - splInterface !== undefined && pubkey.equals(splInterface.poolPda); - return { - pubkey, - isSigner: i === signerIndex, - isWritable: isTreeOrQueue || isDestination || isPool, - }; - }), - ]; - - return new TransactionInstruction({ - programId: COMPRESSED_TOKEN_PROGRAM_ID, - keys, - data, - }); + + // Build decompress compression + // For light-token: pool values are 0 (unused) + // For SPL: pool values point to SPL interface PDA + const compressions: Compression[] = [ + { + mode: COMPRESSION_MODE_DECOMPRESS, + amount, + mint: mintIndex, + sourceOrRecipient: destinationIndex, + authority: 0, // Not needed for decompress + poolAccountIndex: splInterface ? poolAccountIndex : 0, + poolIndex: splInterface ? poolIndex : 0, + bump: splInterface ? poolBump : 0, + decimals, + }, + ]; + + // Build Transfer2 instruction data + const instructionData: Transfer2InstructionData = { + withTransactionHash: false, + withLamportsChangeAccountMerkleTreeIndex: false, + lamportsChangeAccountMerkleTreeIndex: 0, + lamportsChangeAccountOwnerIndex: 0, + outputQueue: firstQueueIndex, // First queue in packed accounts + maxTopUp: maxTopUp ?? MAX_TOP_UP, + cpiContext: null, + compressions, + proof: validityProof.compressedProof + ? { + a: Array.from(validityProof.compressedProof.a), + b: Array.from(validityProof.compressedProof.b), + c: Array.from(validityProof.compressedProof.c), + } + : null, + inTokenData, + outTokenData, + inLamports: null, + outLamports: null, + inTlv: buildInTlv( + inputCompressedTokenAccounts, + ownerIndex, + owner, + mint, + ), + outTlv: null, + }; + + const data = encodeTransfer2InstructionData(instructionData); + + // Build accounts for Transfer2 with compressed accounts (full path) + const { + accountCompressionAuthority, + registeredProgramPda, + accountCompressionProgram, + } = defaultStaticAccountsStruct(); + const signerIndex = (() => { + if (!authority || authority.equals(owner)) { + return ownerIndex; + } + const authorityIndex = packedAccountIndices.get(authority.toBase58()); + if (authorityIndex === undefined) { + throw new Error( + `Authority ${authority.toBase58()} is not present in packed accounts`, + ); + } + return authorityIndex; + })(); + const effectivePayer = payer ?? authority ?? owner; + + const keys = [ + // 0: light_system_program (non-mutable) + { + pubkey: LightSystemProgram.programId, + isSigner: false, + isWritable: false, + }, + // 1: fee_payer (signer, mutable) + { pubkey: effectivePayer, isSigner: true, isWritable: true }, + // 2: cpi_authority_pda + { + pubkey: deriveCpiAuthorityPda(), + isSigner: false, + isWritable: false, + }, + // 3: registered_program_pda + { + pubkey: registeredProgramPda, + isSigner: false, + isWritable: false, + }, + // 4: account_compression_authority + { + pubkey: accountCompressionAuthority, + isSigner: false, + isWritable: false, + }, + // 5: account_compression_program + { + pubkey: accountCompressionProgram, + isSigner: false, + isWritable: false, + }, + // 6: system_program + { + pubkey: SystemProgram.programId, + isSigner: false, + isWritable: false, + }, + // 7+: packed_accounts (trees/queues come first) + ...packedAccounts.map((pubkey, i) => { + const isTreeOrQueue = i < treeSet.size + queueSet.size; + const isDestination = pubkey.equals(toAddress); + const isPool = + splInterface !== undefined && + pubkey.equals(splInterface.poolPda); + return { + pubkey, + isSigner: i === signerIndex, + isWritable: isTreeOrQueue || isDestination || isPool, + }; + }), + ]; + + return new TransactionInstruction({ + programId: COMPRESSED_TOKEN_PROGRAM_ID, + keys, + data, + }); } diff --git a/js/token-interface/src/instructions/load/select-primary-cold-account.ts b/js/token-interface/src/instructions/load/select-primary-cold-account.ts index de8a55d4f7..183220b822 100644 --- a/js/token-interface/src/instructions/load/select-primary-cold-account.ts +++ b/js/token-interface/src/instructions/load/select-primary-cold-account.ts @@ -1,77 +1,80 @@ -import { ParsedTokenAccount, bn } from "@lightprotocol/stateless.js"; -import { Buffer } from "buffer"; -import { COLD_SOURCE_TYPES, TokenAccountSource } from "../../read/get-account"; +import { ParsedTokenAccount, bn } from '@lightprotocol/stateless.js'; +import { Buffer } from 'buffer'; +import { COLD_SOURCE_TYPES, TokenAccountSource } from '../../read/get-account'; /** * Default load policy: select one deterministic cold compressed account. * Priority is highest amount, then highest leaf index. */ export function selectPrimaryColdCompressedAccountForLoad( - sources: TokenAccountSource[], + sources: TokenAccountSource[], ): ParsedTokenAccount | null { - const candidates: ParsedTokenAccount[] = []; - for (const source of sources) { - if (!COLD_SOURCE_TYPES.has(source.type) || !source.loadContext) { - continue; - } - const loadContext = source.loadContext; - const fullData = source.accountInfo.data; - const discriminatorBytes = fullData.subarray( - 0, - Math.min(8, fullData.length), - ); - const accountDataBytes = - fullData.length > 8 ? fullData.subarray(8) : Buffer.alloc(0); + const candidates: ParsedTokenAccount[] = []; + for (const source of sources) { + if (!COLD_SOURCE_TYPES.has(source.type) || !source.loadContext) { + continue; + } + const loadContext = source.loadContext; + const fullData = source.accountInfo.data; + const discriminatorBytes = fullData.subarray( + 0, + Math.min(8, fullData.length), + ); + const accountDataBytes = + fullData.length > 8 ? fullData.subarray(8) : Buffer.alloc(0); - const compressedAccount = { - treeInfo: loadContext.treeInfo, - hash: loadContext.hash, - leafIndex: loadContext.leafIndex, - proveByIndex: loadContext.proveByIndex, - owner: source.accountInfo.owner, - lamports: bn(source.accountInfo.lamports), - address: null, - data: - fullData.length === 0 - ? null - : { - discriminator: Array.from(discriminatorBytes), - data: Buffer.from(accountDataBytes), - dataHash: new Array(32).fill(0), - }, - readOnly: false, - } satisfies ParsedTokenAccount["compressedAccount"]; + const compressedAccount = { + treeInfo: loadContext.treeInfo, + hash: loadContext.hash, + leafIndex: loadContext.leafIndex, + proveByIndex: loadContext.proveByIndex, + owner: source.accountInfo.owner, + lamports: bn(source.accountInfo.lamports), + address: null, + data: + fullData.length === 0 + ? null + : { + discriminator: Array.from(discriminatorBytes), + data: Buffer.from(accountDataBytes), + dataHash: new Array(32).fill(0), + }, + readOnly: false, + } satisfies ParsedTokenAccount['compressedAccount']; - const state = !source.parsed.isInitialized - ? 0 - : source.parsed.isFrozen - ? 2 - : 1; + const state = !source.parsed.isInitialized + ? 0 + : source.parsed.isFrozen + ? 2 + : 1; - candidates.push({ - compressedAccount, - parsed: { - mint: source.parsed.mint, - owner: source.parsed.owner, - amount: bn(source.parsed.amount.toString()), - delegate: source.parsed.delegate, - state, - tlv: source.parsed.tlvData.length > 0 ? source.parsed.tlvData : null, - }, - }); - } + candidates.push({ + compressedAccount, + parsed: { + mint: source.parsed.mint, + owner: source.parsed.owner, + amount: bn(source.parsed.amount.toString()), + delegate: source.parsed.delegate, + state, + tlv: + source.parsed.tlvData.length > 0 + ? source.parsed.tlvData + : null, + }, + }); + } - if (candidates.length === 0) { - return null; - } + if (candidates.length === 0) { + return null; + } - candidates.sort((a, b) => { - const amountA = BigInt(a.parsed.amount.toString()); - const amountB = BigInt(b.parsed.amount.toString()); - if (amountB > amountA) return 1; - if (amountB < amountA) return -1; - return b.compressedAccount.leafIndex - a.compressedAccount.leafIndex; - }); + candidates.sort((a, b) => { + const amountA = BigInt(a.parsed.amount.toString()); + const amountB = BigInt(b.parsed.amount.toString()); + if (amountB > amountA) return 1; + if (amountB < amountA) return -1; + return b.compressedAccount.leafIndex - a.compressedAccount.leafIndex; + }); - return candidates[0]; + return candidates[0]; } diff --git a/js/token-interface/src/instructions/transfer.ts b/js/token-interface/src/instructions/transfer.ts index ffa52690f0..8682b03aaa 100644 --- a/js/token-interface/src/instructions/transfer.ts +++ b/js/token-interface/src/instructions/transfer.ts @@ -1,56 +1,60 @@ -import { Buffer } from "buffer"; -import { SystemProgram, TransactionInstruction } from "@solana/web3.js"; -import { LIGHT_TOKEN_PROGRAM_ID } from "@lightprotocol/stateless.js"; -import { getSplInterfaces } from "../spl-interface"; -import { createUnwrapInstruction } from "./unwrap"; -import { getMintDecimals, toBigIntAmount } from "../helpers"; -import { getAtaAddress } from "../read"; +import { Buffer } from 'buffer'; +import { SystemProgram, TransactionInstruction } from '@solana/web3.js'; +import { LIGHT_TOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; +import { getSplInterfaces } from '../spl-interface'; +import { createUnwrapInstruction } from './unwrap'; +import { getMintDecimals, toBigIntAmount } from '../helpers'; +import { getAtaAddress } from '../read'; import type { - CreateRawTransferInstructionInput, - CreateTransferInstructionsInput, -} from "../types"; -import { createLoadInstructions } from "./load"; -import { toInstructionPlan } from "./_plan"; -import { createAtaInstruction } from "./ata"; + CreateRawTransferInstructionInput, + CreateTransferInstructionsInput, +} from '../types'; +import { createLoadInstructions } from './load'; +import { toInstructionPlan } from './_plan'; +import { createAtaInstruction } from './ata'; const LIGHT_TOKEN_TRANSFER_CHECKED_DISCRIMINATOR = 12; export function createTransferCheckedInstruction({ - source, - destination, - mint, - authority, - payer, - amount, - decimals, + source, + destination, + mint, + authority, + payer, + amount, + decimals, }: CreateRawTransferInstructionInput): TransactionInstruction { - const data = Buffer.alloc(10); - data.writeUInt8(LIGHT_TOKEN_TRANSFER_CHECKED_DISCRIMINATOR, 0); - data.writeBigUInt64LE(BigInt(amount), 1); - data.writeUInt8(decimals, 9); + const data = Buffer.alloc(10); + data.writeUInt8(LIGHT_TOKEN_TRANSFER_CHECKED_DISCRIMINATOR, 0); + data.writeBigUInt64LE(BigInt(amount), 1); + data.writeUInt8(decimals, 9); - const effectivePayer = payer ?? authority; + const effectivePayer = payer ?? authority; - return new TransactionInstruction({ - programId: LIGHT_TOKEN_PROGRAM_ID, - keys: [ - { pubkey: source, isSigner: false, isWritable: true }, - { pubkey: mint, isSigner: false, isWritable: false }, - { pubkey: destination, isSigner: false, isWritable: true }, - { - pubkey: authority, - isSigner: true, - isWritable: effectivePayer.equals(authority), - }, - { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, - { - pubkey: effectivePayer, - isSigner: !effectivePayer.equals(authority), - isWritable: true, - }, - ], - data, - }); + return new TransactionInstruction({ + programId: LIGHT_TOKEN_PROGRAM_ID, + keys: [ + { pubkey: source, isSigner: false, isWritable: true }, + { pubkey: mint, isSigner: false, isWritable: false }, + { pubkey: destination, isSigner: false, isWritable: true }, + { + pubkey: authority, + isSigner: true, + isWritable: effectivePayer.equals(authority), + }, + { + pubkey: SystemProgram.programId, + isSigner: false, + isWritable: false, + }, + { + pubkey: effectivePayer, + isSigner: !effectivePayer.equals(authority), + isWritable: true, + }, + ], + data, + }); } /** @@ -58,176 +62,182 @@ export function createTransferCheckedInstruction({ * Returns an instruction array for a single transfer flow (setup + transfer). */ export async function createTransferInstructions({ - rpc, - payer, - mint, - sourceOwner, - authority, - recipient, - tokenProgram, - amount, -}: CreateTransferInstructionsInput): Promise { - const effectivePayer = payer ?? authority; - const amountBigInt = toBigIntAmount(amount); - const recipientTokenProgramId = tokenProgram ?? LIGHT_TOKEN_PROGRAM_ID; - const [decimals, transferSplInterfaces] = await Promise.all([ - getMintDecimals(rpc, mint), - recipientTokenProgramId.equals(LIGHT_TOKEN_PROGRAM_ID) - ? Promise.resolve(undefined) - : getSplInterfaces(rpc, mint), - ]); - const senderLoadInstructions = await createLoadInstructions({ rpc, - payer: effectivePayer, - owner: sourceOwner, + payer, mint, + sourceOwner, authority, - wrap: true, - decimals, - splInterfaces: transferSplInterfaces, - }); - const recipientAta = getAtaAddress({ - owner: recipient, - mint, - programId: recipientTokenProgramId, - }); - const recipientLoadInstructions: TransactionInstruction[] = []; - const senderAta = getAtaAddress({ - owner: sourceOwner, - mint, - }); - let transferInstruction: TransactionInstruction; - if (recipientTokenProgramId.equals(LIGHT_TOKEN_PROGRAM_ID)) { - transferInstruction = createTransferCheckedInstruction({ - source: senderAta, - destination: recipientAta, - mint, - authority, - payer: effectivePayer, - amount: amountBigInt, - decimals, + recipient, + tokenProgram, + amount, +}: CreateTransferInstructionsInput): Promise { + const effectivePayer = payer ?? authority; + const amountBigInt = toBigIntAmount(amount); + const recipientTokenProgramId = tokenProgram ?? LIGHT_TOKEN_PROGRAM_ID; + const [decimals, transferSplInterfaces] = await Promise.all([ + getMintDecimals(rpc, mint), + recipientTokenProgramId.equals(LIGHT_TOKEN_PROGRAM_ID) + ? Promise.resolve(undefined) + : getSplInterfaces(rpc, mint), + ]); + const senderLoadInstructions = await createLoadInstructions({ + rpc, + payer: effectivePayer, + owner: sourceOwner, + mint, + authority, + wrap: true, + decimals, + splInterfaces: transferSplInterfaces, }); - } else { - if (!transferSplInterfaces) { - throw new Error("Missing SPL interfaces for non-light transfer path."); - } - const splInterface = transferSplInterfaces.find( - (info) => - info.isInitialized && info.tokenProgramId.equals(recipientTokenProgramId), - ); - if (!splInterface) { - throw new Error( - `No initialized SPL pool found for tokenProgram ${recipientTokenProgramId.toBase58()}.`, - ); - } - transferInstruction = createUnwrapInstruction({ - source: senderAta, - destination: recipientAta, - owner: authority, - mint, - amount: amountBigInt, - splInterface, - decimals, - payer: effectivePayer, + const recipientAta = getAtaAddress({ + owner: recipient, + mint, + programId: recipientTokenProgramId, + }); + const recipientLoadInstructions: TransactionInstruction[] = []; + const senderAta = getAtaAddress({ + owner: sourceOwner, + mint, }); - } + let transferInstruction: TransactionInstruction; + if (recipientTokenProgramId.equals(LIGHT_TOKEN_PROGRAM_ID)) { + transferInstruction = createTransferCheckedInstruction({ + source: senderAta, + destination: recipientAta, + mint, + authority, + payer: effectivePayer, + amount: amountBigInt, + decimals, + }); + } else { + if (!transferSplInterfaces) { + throw new Error( + 'Missing SPL interfaces for non-light transfer path.', + ); + } + const splInterface = transferSplInterfaces.find( + info => + info.isInitialized && + info.tokenProgramId.equals(recipientTokenProgramId), + ); + if (!splInterface) { + throw new Error( + `No initialized SPL pool found for tokenProgram ${recipientTokenProgramId.toBase58()}.`, + ); + } + transferInstruction = createUnwrapInstruction({ + source: senderAta, + destination: recipientAta, + owner: authority, + mint, + amount: amountBigInt, + splInterface, + decimals, + payer: effectivePayer, + }); + } - return [ - ...senderLoadInstructions, - createAtaInstruction({ - payer: effectivePayer, - owner: recipient, - mint, - programId: recipientTokenProgramId, - }), - ...recipientLoadInstructions, - transferInstruction, - ]; + return [ + ...senderLoadInstructions, + createAtaInstruction({ + payer: effectivePayer, + owner: recipient, + mint, + programId: recipientTokenProgramId, + }), + ...recipientLoadInstructions, + transferInstruction, + ]; } /** * No-wrap transfer flow builder (advanced). */ export async function createTransferInstructionsNowrap({ - rpc, - payer, - mint, - sourceOwner, - authority, - recipient, - tokenProgram, - amount, -}: CreateTransferInstructionsInput): Promise { - const effectivePayer = payer ?? authority; - const amountBigInt = toBigIntAmount(amount); - const recipientTokenProgramId = tokenProgram ?? LIGHT_TOKEN_PROGRAM_ID; - const [decimals, transferSplInterfaces] = await Promise.all([ - getMintDecimals(rpc, mint), - recipientTokenProgramId.equals(LIGHT_TOKEN_PROGRAM_ID) - ? Promise.resolve(undefined) - : getSplInterfaces(rpc, mint), - ]); - const senderLoadInstructions = await createLoadInstructions({ rpc, - payer: effectivePayer, - owner: sourceOwner, + payer, mint, + sourceOwner, authority, - wrap: false, - decimals, - splInterfaces: transferSplInterfaces, - }); - const recipientAta = getAtaAddress({ - owner: recipient, - mint, - programId: recipientTokenProgramId, - }); - const senderAta = getAtaAddress({ - owner: sourceOwner, - mint, - }); - - let transferInstruction: TransactionInstruction; - if (recipientTokenProgramId.equals(LIGHT_TOKEN_PROGRAM_ID)) { - transferInstruction = createTransferCheckedInstruction({ - source: senderAta, - destination: recipientAta, - mint, - authority, - payer: effectivePayer, - amount: amountBigInt, - decimals, + recipient, + tokenProgram, + amount, +}: CreateTransferInstructionsInput): Promise { + const effectivePayer = payer ?? authority; + const amountBigInt = toBigIntAmount(amount); + const recipientTokenProgramId = tokenProgram ?? LIGHT_TOKEN_PROGRAM_ID; + const [decimals, transferSplInterfaces] = await Promise.all([ + getMintDecimals(rpc, mint), + recipientTokenProgramId.equals(LIGHT_TOKEN_PROGRAM_ID) + ? Promise.resolve(undefined) + : getSplInterfaces(rpc, mint), + ]); + const senderLoadInstructions = await createLoadInstructions({ + rpc, + payer: effectivePayer, + owner: sourceOwner, + mint, + authority, + wrap: false, + decimals, + splInterfaces: transferSplInterfaces, }); - } else { - if (!transferSplInterfaces) { - throw new Error("Missing SPL interfaces for non-light transfer path."); - } - const splInterface = transferSplInterfaces.find( - (info) => - info.isInitialized && info.tokenProgramId.equals(recipientTokenProgramId), - ); - if (!splInterface) { - throw new Error( - `No initialized SPL pool found for tokenProgram ${recipientTokenProgramId.toBase58()}.`, - ); - } - transferInstruction = createUnwrapInstruction({ - source: senderAta, - destination: recipientAta, - owner: authority, - mint, - amount: amountBigInt, - splInterface, - decimals, - payer: effectivePayer, + const recipientAta = getAtaAddress({ + owner: recipient, + mint, + programId: recipientTokenProgramId, }); - } + const senderAta = getAtaAddress({ + owner: sourceOwner, + mint, + }); + + let transferInstruction: TransactionInstruction; + if (recipientTokenProgramId.equals(LIGHT_TOKEN_PROGRAM_ID)) { + transferInstruction = createTransferCheckedInstruction({ + source: senderAta, + destination: recipientAta, + mint, + authority, + payer: effectivePayer, + amount: amountBigInt, + decimals, + }); + } else { + if (!transferSplInterfaces) { + throw new Error( + 'Missing SPL interfaces for non-light transfer path.', + ); + } + const splInterface = transferSplInterfaces.find( + info => + info.isInitialized && + info.tokenProgramId.equals(recipientTokenProgramId), + ); + if (!splInterface) { + throw new Error( + `No initialized SPL pool found for tokenProgram ${recipientTokenProgramId.toBase58()}.`, + ); + } + transferInstruction = createUnwrapInstruction({ + source: senderAta, + destination: recipientAta, + owner: authority, + mint, + amount: amountBigInt, + splInterface, + decimals, + payer: effectivePayer, + }); + } - return [...senderLoadInstructions, transferInstruction]; + return [...senderLoadInstructions, transferInstruction]; } export async function createTransferInstructionPlan( - input: CreateTransferInstructionsInput, + input: CreateTransferInstructionsInput, ) { - return toInstructionPlan(await createTransferInstructions(input)); + return toInstructionPlan(await createTransferInstructions(input)); } diff --git a/js/token-interface/src/instructions/unwrap.ts b/js/token-interface/src/instructions/unwrap.ts index 360b644fd8..c70a9ccff4 100644 --- a/js/token-interface/src/instructions/unwrap.ts +++ b/js/token-interface/src/instructions/unwrap.ts @@ -1,33 +1,33 @@ import { - PublicKey, - TransactionInstruction, - SystemProgram, -} from "@solana/web3.js"; -import { LIGHT_TOKEN_PROGRAM_ID } from "@lightprotocol/stateless.js"; + PublicKey, + TransactionInstruction, + SystemProgram, +} from '@solana/web3.js'; +import { LIGHT_TOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; import { - COMPRESSED_TOKEN_PROGRAM_ID, - deriveCpiAuthorityPda, - MAX_TOP_UP, -} from "../constants"; -import type { SplInterface } from "../spl-interface"; + COMPRESSED_TOKEN_PROGRAM_ID, + deriveCpiAuthorityPda, + MAX_TOP_UP, +} from '../constants'; +import type { SplInterface } from '../spl-interface'; import { - encodeTransfer2InstructionData, - createCompressLightToken, - createDecompressSpl, - type Transfer2InstructionData, - type Compression, -} from "./layout/layout-transfer2"; + encodeTransfer2InstructionData, + createCompressLightToken, + createDecompressSpl, + type Transfer2InstructionData, + type Compression, +} from './layout/layout-transfer2'; export interface CreateUnwrapInstructionInput { - source: PublicKey; - destination: PublicKey; - owner: PublicKey; - mint: PublicKey; - amount: bigint; - splInterface: SplInterface; - decimals: number; - payer?: PublicKey; - maxTopUp?: number; + source: PublicKey; + destination: PublicKey; + owner: PublicKey; + mint: PublicKey; + amount: bigint; + splInterface: SplInterface; + decimals: number; + payer?: PublicKey; + maxTopUp?: number; } /** @@ -47,98 +47,98 @@ export interface CreateUnwrapInstructionInput { * @returns Instruction to unwrap tokens */ export function createUnwrapInstruction({ - source, - destination, - owner, - mint, - amount, - splInterface, - decimals, - payer = owner, - maxTopUp, + source, + destination, + owner, + mint, + amount, + splInterface, + decimals, + payer = owner, + maxTopUp, }: CreateUnwrapInstructionInput): TransactionInstruction { - const MINT_INDEX = 0; - const OWNER_INDEX = 1; - const SOURCE_INDEX = 2; - const DESTINATION_INDEX = 3; - const POOL_INDEX = 4; - const LIGHT_TOKEN_PROGRAM_INDEX = 6; + const MINT_INDEX = 0; + const OWNER_INDEX = 1; + const SOURCE_INDEX = 2; + const DESTINATION_INDEX = 3; + const POOL_INDEX = 4; + const LIGHT_TOKEN_PROGRAM_INDEX = 6; - const compressions: Compression[] = [ - createCompressLightToken( - amount, - MINT_INDEX, - SOURCE_INDEX, - OWNER_INDEX, - LIGHT_TOKEN_PROGRAM_INDEX, - ), - createDecompressSpl( - amount, - MINT_INDEX, - DESTINATION_INDEX, - POOL_INDEX, - splInterface.derivationIndex, - splInterface.bump, - decimals, - ), - ]; + const compressions: Compression[] = [ + createCompressLightToken( + amount, + MINT_INDEX, + SOURCE_INDEX, + OWNER_INDEX, + LIGHT_TOKEN_PROGRAM_INDEX, + ), + createDecompressSpl( + amount, + MINT_INDEX, + DESTINATION_INDEX, + POOL_INDEX, + splInterface.derivationIndex, + splInterface.bump, + decimals, + ), + ]; - const instructionData: Transfer2InstructionData = { - withTransactionHash: false, - withLamportsChangeAccountMerkleTreeIndex: false, - lamportsChangeAccountMerkleTreeIndex: 0, - lamportsChangeAccountOwnerIndex: 0, - outputQueue: 0, - maxTopUp: maxTopUp ?? MAX_TOP_UP, - cpiContext: null, - compressions, - proof: null, - inTokenData: [], - outTokenData: [], - inLamports: null, - outLamports: null, - inTlv: null, - outTlv: null, - }; + const instructionData: Transfer2InstructionData = { + withTransactionHash: false, + withLamportsChangeAccountMerkleTreeIndex: false, + lamportsChangeAccountMerkleTreeIndex: 0, + lamportsChangeAccountOwnerIndex: 0, + outputQueue: 0, + maxTopUp: maxTopUp ?? MAX_TOP_UP, + cpiContext: null, + compressions, + proof: null, + inTokenData: [], + outTokenData: [], + inLamports: null, + outLamports: null, + inTlv: null, + outTlv: null, + }; - const data = encodeTransfer2InstructionData(instructionData); + const data = encodeTransfer2InstructionData(instructionData); - const keys = [ - { - pubkey: deriveCpiAuthorityPda(), - isSigner: false, - isWritable: false, - }, - { pubkey: payer, isSigner: true, isWritable: true }, - { pubkey: mint, isSigner: false, isWritable: false }, - { pubkey: owner, isSigner: true, isWritable: false }, - { pubkey: source, isSigner: false, isWritable: true }, - { pubkey: destination, isSigner: false, isWritable: true }, - { - pubkey: splInterface.poolPda, - isSigner: false, - isWritable: true, - }, - { - pubkey: splInterface.tokenProgramId, - isSigner: false, - isWritable: false, - }, - { - pubkey: LIGHT_TOKEN_PROGRAM_ID, - isSigner: false, - isWritable: false, - }, - { - pubkey: SystemProgram.programId, - isSigner: false, - isWritable: false, - }, - ]; + const keys = [ + { + pubkey: deriveCpiAuthorityPda(), + isSigner: false, + isWritable: false, + }, + { pubkey: payer, isSigner: true, isWritable: true }, + { pubkey: mint, isSigner: false, isWritable: false }, + { pubkey: owner, isSigner: true, isWritable: false }, + { pubkey: source, isSigner: false, isWritable: true }, + { pubkey: destination, isSigner: false, isWritable: true }, + { + pubkey: splInterface.poolPda, + isSigner: false, + isWritable: true, + }, + { + pubkey: splInterface.tokenProgramId, + isSigner: false, + isWritable: false, + }, + { + pubkey: LIGHT_TOKEN_PROGRAM_ID, + isSigner: false, + isWritable: false, + }, + { + pubkey: SystemProgram.programId, + isSigner: false, + isWritable: false, + }, + ]; - return new TransactionInstruction({ - programId: COMPRESSED_TOKEN_PROGRAM_ID, - keys, - data, - }); + return new TransactionInstruction({ + programId: COMPRESSED_TOKEN_PROGRAM_ID, + keys, + data, + }); } diff --git a/js/token-interface/src/instructions/wrap.ts b/js/token-interface/src/instructions/wrap.ts index cd37f0444d..2b53bcd58a 100644 --- a/js/token-interface/src/instructions/wrap.ts +++ b/js/token-interface/src/instructions/wrap.ts @@ -1,33 +1,33 @@ import { - PublicKey, - TransactionInstruction, - SystemProgram, -} from "@solana/web3.js"; -import { LIGHT_TOKEN_PROGRAM_ID } from "@lightprotocol/stateless.js"; + PublicKey, + TransactionInstruction, + SystemProgram, +} from '@solana/web3.js'; +import { LIGHT_TOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; import { - COMPRESSED_TOKEN_PROGRAM_ID, - deriveCpiAuthorityPda, - MAX_TOP_UP, -} from "../constants"; -import type { SplInterface } from "../spl-interface"; + COMPRESSED_TOKEN_PROGRAM_ID, + deriveCpiAuthorityPda, + MAX_TOP_UP, +} from '../constants'; +import type { SplInterface } from '../spl-interface'; import { - encodeTransfer2InstructionData, - createCompressSpl, - createDecompressLightToken, - type Transfer2InstructionData, - type Compression, -} from "./layout/layout-transfer2"; + encodeTransfer2InstructionData, + createCompressSpl, + createDecompressLightToken, + type Transfer2InstructionData, + type Compression, +} from './layout/layout-transfer2'; export interface CreateWrapInstructionInput { - source: PublicKey; - destination: PublicKey; - owner: PublicKey; - mint: PublicKey; - amount: bigint; - splInterface: SplInterface; - decimals: number; - payer?: PublicKey; - maxTopUp?: number; + source: PublicKey; + destination: PublicKey; + owner: PublicKey; + mint: PublicKey; + amount: bigint; + splInterface: SplInterface; + decimals: number; + payer?: PublicKey; + maxTopUp?: number; } /** @@ -47,100 +47,100 @@ export interface CreateWrapInstructionInput { * @returns Instruction to wrap tokens */ export function createWrapInstruction({ - source, - destination, - owner, - mint, - amount, - splInterface, - decimals, - payer = owner, - maxTopUp, + source, + destination, + owner, + mint, + amount, + splInterface, + decimals, + payer = owner, + maxTopUp, }: CreateWrapInstructionInput): TransactionInstruction { - const MINT_INDEX = 0; - const OWNER_INDEX = 1; - const SOURCE_INDEX = 2; - const DESTINATION_INDEX = 3; - const POOL_INDEX = 4; - const _SPL_TOKEN_PROGRAM_INDEX = 5; - const LIGHT_TOKEN_PROGRAM_INDEX = 6; + const MINT_INDEX = 0; + const OWNER_INDEX = 1; + const SOURCE_INDEX = 2; + const DESTINATION_INDEX = 3; + const POOL_INDEX = 4; + const _SPL_TOKEN_PROGRAM_INDEX = 5; + const LIGHT_TOKEN_PROGRAM_INDEX = 6; - const compressions: Compression[] = [ - createCompressSpl( - amount, - MINT_INDEX, - SOURCE_INDEX, - OWNER_INDEX, - POOL_INDEX, - splInterface.derivationIndex, - splInterface.bump, - decimals, - ), - createDecompressLightToken( - amount, - MINT_INDEX, - DESTINATION_INDEX, - LIGHT_TOKEN_PROGRAM_INDEX, - ), - ]; + const compressions: Compression[] = [ + createCompressSpl( + amount, + MINT_INDEX, + SOURCE_INDEX, + OWNER_INDEX, + POOL_INDEX, + splInterface.derivationIndex, + splInterface.bump, + decimals, + ), + createDecompressLightToken( + amount, + MINT_INDEX, + DESTINATION_INDEX, + LIGHT_TOKEN_PROGRAM_INDEX, + ), + ]; - const instructionData: Transfer2InstructionData = { - withTransactionHash: false, - withLamportsChangeAccountMerkleTreeIndex: false, - lamportsChangeAccountMerkleTreeIndex: 0, - lamportsChangeAccountOwnerIndex: 0, - outputQueue: 0, - maxTopUp: maxTopUp ?? MAX_TOP_UP, - cpiContext: null, - compressions, - proof: null, - inTokenData: [], - outTokenData: [], - inLamports: null, - outLamports: null, - inTlv: null, - outTlv: null, - }; + const instructionData: Transfer2InstructionData = { + withTransactionHash: false, + withLamportsChangeAccountMerkleTreeIndex: false, + lamportsChangeAccountMerkleTreeIndex: 0, + lamportsChangeAccountOwnerIndex: 0, + outputQueue: 0, + maxTopUp: maxTopUp ?? MAX_TOP_UP, + cpiContext: null, + compressions, + proof: null, + inTokenData: [], + outTokenData: [], + inLamports: null, + outLamports: null, + inTlv: null, + outTlv: null, + }; - const data = encodeTransfer2InstructionData(instructionData); + const data = encodeTransfer2InstructionData(instructionData); - const keys = [ - { - pubkey: deriveCpiAuthorityPda(), - isSigner: false, - isWritable: false, - }, - { pubkey: payer, isSigner: true, isWritable: true }, - { pubkey: mint, isSigner: false, isWritable: false }, - { pubkey: owner, isSigner: true, isWritable: false }, - { pubkey: source, isSigner: false, isWritable: true }, - { pubkey: destination, isSigner: false, isWritable: true }, - { - pubkey: splInterface.poolPda, - isSigner: false, - isWritable: true, - }, - { - pubkey: splInterface.tokenProgramId, - isSigner: false, - isWritable: false, - }, - { - pubkey: LIGHT_TOKEN_PROGRAM_ID, - isSigner: false, - isWritable: false, - }, - // System program needed for top-up CPIs when destination has compressible extension - { - pubkey: SystemProgram.programId, - isSigner: false, - isWritable: false, - }, - ]; + const keys = [ + { + pubkey: deriveCpiAuthorityPda(), + isSigner: false, + isWritable: false, + }, + { pubkey: payer, isSigner: true, isWritable: true }, + { pubkey: mint, isSigner: false, isWritable: false }, + { pubkey: owner, isSigner: true, isWritable: false }, + { pubkey: source, isSigner: false, isWritable: true }, + { pubkey: destination, isSigner: false, isWritable: true }, + { + pubkey: splInterface.poolPda, + isSigner: false, + isWritable: true, + }, + { + pubkey: splInterface.tokenProgramId, + isSigner: false, + isWritable: false, + }, + { + pubkey: LIGHT_TOKEN_PROGRAM_ID, + isSigner: false, + isWritable: false, + }, + // System program needed for top-up CPIs when destination has compressible extension + { + pubkey: SystemProgram.programId, + isSigner: false, + isWritable: false, + }, + ]; - return new TransactionInstruction({ - programId: COMPRESSED_TOKEN_PROGRAM_ID, - keys, - data, - }); + return new TransactionInstruction({ + programId: COMPRESSED_TOKEN_PROGRAM_ID, + keys, + data, + }); } diff --git a/js/token-interface/src/load-options.ts b/js/token-interface/src/load-options.ts index ff113b9a2a..4495cbf272 100644 --- a/js/token-interface/src/load-options.ts +++ b/js/token-interface/src/load-options.ts @@ -1,8 +1,8 @@ -import type { PublicKey } from "@solana/web3.js"; -import type { SplInterface } from "./spl-interface"; +import type { PublicKey } from '@solana/web3.js'; +import type { SplInterface } from './spl-interface'; export interface LoadOptions { - splInterfaces?: SplInterface[]; - wrap?: boolean; - delegatePubkey?: PublicKey; + splInterfaces?: SplInterface[]; + wrap?: boolean; + delegatePubkey?: PublicKey; } diff --git a/js/token-interface/src/read/get-account.ts b/js/token-interface/src/read/get-account.ts index befe9da844..fe29cd312f 100644 --- a/js/token-interface/src/read/get-account.ts +++ b/js/token-interface/src/read/get-account.ts @@ -346,7 +346,9 @@ export function parseLightTokenCold( parsed: Account; isCold: true; } { - const parsed = parseTokenData(requireCompressedAccountData(compressedAccount).data); + const parsed = parseTokenData( + requireCompressedAccountData(compressedAccount).data, + ); if (!parsed) throw new Error('Invalid token data'); return { accountInfo: toAccountInfo(compressedAccount), @@ -445,12 +447,7 @@ async function _getAccountView( } if (programId.equals(LIGHT_TOKEN_PROGRAM_ID)) { - return getLightTokenAccountView( - rpc, - address, - commitment, - fetchByOwner, - ); + return getLightTokenAccountView(rpc, address, commitment, fetchByOwner); } if ( @@ -550,7 +547,6 @@ async function _tryFetchLightTokenHot( return parseLightTokenHot(address, info); } - /** @internal */ async function getUnifiedAccountView( rpc: Rpc, diff --git a/js/token-interface/src/read/get-mint.ts b/js/token-interface/src/read/get-mint.ts index e8089ac3f6..d979b42dbf 100644 --- a/js/token-interface/src/read/get-mint.ts +++ b/js/token-interface/src/read/get-mint.ts @@ -67,18 +67,8 @@ export async function getMint( const [tokenResult, token2022Result, compressedResult] = await Promise.allSettled([ getMint(rpc, address, commitment, TOKEN_PROGRAM_ID), - getMint( - rpc, - address, - commitment, - TOKEN_2022_PROGRAM_ID, - ), - getMint( - rpc, - address, - commitment, - LIGHT_TOKEN_PROGRAM_ID, - ), + getMint(rpc, address, commitment, TOKEN_2022_PROGRAM_ID), + getMint(rpc, address, commitment, LIGHT_TOKEN_PROGRAM_ID), ]); if (tokenResult.status === 'fulfilled') { diff --git a/js/token-interface/src/read/index.ts b/js/token-interface/src/read/index.ts index aa604a3ff5..5ab0196d71 100644 --- a/js/token-interface/src/read/index.ts +++ b/js/token-interface/src/read/index.ts @@ -1,6 +1,10 @@ import type { PublicKey } from '@solana/web3.js'; import { getAta as getTokenInterfaceAta } from '../account'; -import type { AtaOwnerInput, GetAtaInput, TokenInterfaceAccount } from '../types'; +import type { + AtaOwnerInput, + GetAtaInput, + TokenInterfaceAccount, +} from '../types'; import { getAssociatedTokenAddress } from './associated-token-address'; export { getAssociatedTokenAddress } from './associated-token-address'; @@ -9,7 +13,11 @@ export { getMint } from './get-mint'; export type { MintInfo } from './get-mint'; export * from './get-account'; -export function getAtaAddress({ mint, owner, programId }: AtaOwnerInput): PublicKey { +export function getAtaAddress({ + mint, + owner, + programId, +}: AtaOwnerInput): PublicKey { return getAssociatedTokenAddress(mint, owner, false, programId); } diff --git a/js/token-interface/src/spl-interface.ts b/js/token-interface/src/spl-interface.ts index dfdd642986..5786b77f91 100644 --- a/js/token-interface/src/spl-interface.ts +++ b/js/token-interface/src/spl-interface.ts @@ -1,101 +1,101 @@ -import { Commitment, PublicKey } from "@solana/web3.js"; +import { Commitment, PublicKey } from '@solana/web3.js'; import { - TOKEN_2022_PROGRAM_ID, - TOKEN_PROGRAM_ID, - TokenAccountNotFoundError, - TokenInvalidAccountOwnerError, - unpackAccount, -} from "@solana/spl-token"; -import { bn, Rpc } from "@lightprotocol/stateless.js"; -import BN from "bn.js"; -import { deriveSplInterfacePdaWithIndex } from "./constants"; + TOKEN_2022_PROGRAM_ID, + TOKEN_PROGRAM_ID, + TokenAccountNotFoundError, + TokenInvalidAccountOwnerError, + unpackAccount, +} from '@solana/spl-token'; +import { bn, Rpc } from '@lightprotocol/stateless.js'; +import BN from 'bn.js'; +import { deriveSplInterfacePdaWithIndex } from './constants'; export type SplInterface = { - mint: PublicKey; - poolPda: PublicKey; - tokenProgramId: PublicKey; - activity?: { - txs: number; - amountAdded: BN; - amountRemoved: BN; - }; - isInitialized: boolean; - balance: BN; - derivationIndex: number; - bump: number; + mint: PublicKey; + poolPda: PublicKey; + tokenProgramId: PublicKey; + activity?: { + txs: number; + amountAdded: BN; + amountRemoved: BN; + }; + isInitialized: boolean; + balance: BN; + derivationIndex: number; + bump: number; }; function isSupportedTokenProgramId(programId: PublicKey): boolean { - return ( - programId.equals(TOKEN_PROGRAM_ID) || - programId.equals(TOKEN_2022_PROGRAM_ID) - ); + return ( + programId.equals(TOKEN_PROGRAM_ID) || + programId.equals(TOKEN_2022_PROGRAM_ID) + ); } export async function getSplInterfaces( - rpc: Rpc, - mint: PublicKey, - commitment?: Commitment, + rpc: Rpc, + mint: PublicKey, + commitment?: Commitment, ): Promise { - const addressesAndBumps = Array.from({ length: 5 }, (_, i) => - deriveSplInterfacePdaWithIndex(mint, i), - ); - - const accountInfos = await rpc.getMultipleAccountsInfo( - addressesAndBumps.map(([address]) => address), - commitment, - ); - - const anchorIndex = accountInfos.findIndex( - (accountInfo) => accountInfo !== null, - ); - if (anchorIndex === -1) { - throw new TokenAccountNotFoundError( - `SPL interface not found for mint ${mint.toBase58()}.`, + const addressesAndBumps = Array.from({ length: 5 }, (_, i) => + deriveSplInterfacePdaWithIndex(mint, i), ); - } - const anchorAccountInfo = accountInfos[anchorIndex]; - if (!anchorAccountInfo) { - throw new TokenAccountNotFoundError( - `SPL interface not found for mint ${mint.toBase58()}.`, + + const accountInfos = await rpc.getMultipleAccountsInfo( + addressesAndBumps.map(([address]) => address), + commitment, ); - } - const tokenProgramId = anchorAccountInfo.owner; - if (!isSupportedTokenProgramId(tokenProgramId)) { - throw new TokenInvalidAccountOwnerError( - `Invalid token program owner for SPL interface mint ${mint.toBase58()}: ${tokenProgramId.toBase58()}`, + + const anchorIndex = accountInfos.findIndex( + accountInfo => accountInfo !== null, ); - } + if (anchorIndex === -1) { + throw new TokenAccountNotFoundError( + `SPL interface not found for mint ${mint.toBase58()}.`, + ); + } + const anchorAccountInfo = accountInfos[anchorIndex]; + if (!anchorAccountInfo) { + throw new TokenAccountNotFoundError( + `SPL interface not found for mint ${mint.toBase58()}.`, + ); + } + const tokenProgramId = anchorAccountInfo.owner; + if (!isSupportedTokenProgramId(tokenProgramId)) { + throw new TokenInvalidAccountOwnerError( + `Invalid token program owner for SPL interface mint ${mint.toBase58()}: ${tokenProgramId.toBase58()}`, + ); + } - const parsedInfos = addressesAndBumps.map(([address], i) => - accountInfos[i] - ? unpackAccount(address, accountInfos[i], accountInfos[i].owner) - : null, - ); + const parsedInfos = addressesAndBumps.map(([address], i) => + accountInfos[i] + ? unpackAccount(address, accountInfos[i], accountInfos[i].owner) + : null, + ); - return parsedInfos.map((parsedInfo, i) => { - if (!parsedInfo) { - return { - mint, - poolPda: addressesAndBumps[i][0], - tokenProgramId, - activity: undefined, - balance: bn(0), - isInitialized: false, - derivationIndex: i, - bump: addressesAndBumps[i][1], - }; - } + return parsedInfos.map((parsedInfo, i) => { + if (!parsedInfo) { + return { + mint, + poolPda: addressesAndBumps[i][0], + tokenProgramId, + activity: undefined, + balance: bn(0), + isInitialized: false, + derivationIndex: i, + bump: addressesAndBumps[i][1], + }; + } - return { - mint, - poolPda: parsedInfo.address, - tokenProgramId, - activity: undefined, - balance: bn(parsedInfo.amount.toString()), - isInitialized: true, - derivationIndex: i, - bump: addressesAndBumps[i][1], - }; - }); + return { + mint, + poolPda: parsedInfo.address, + tokenProgramId, + activity: undefined, + balance: bn(parsedInfo.amount.toString()), + isInitialized: true, + derivationIndex: i, + bump: addressesAndBumps[i][1], + }; + }); } diff --git a/js/token-interface/tests/e2e/approve-revoke.test.ts b/js/token-interface/tests/e2e/approve-revoke.test.ts index d7e3aa23ea..9eee3e490c 100644 --- a/js/token-interface/tests/e2e/approve-revoke.test.ts +++ b/js/token-interface/tests/e2e/approve-revoke.test.ts @@ -40,12 +40,17 @@ describe('approve and revoke instructions', () => { ), ).toBe(false); - await sendInstructions(fixture.rpc, fixture.payer, approveInstructions, [ - owner, - ]); + await sendInstructions( + fixture.rpc, + fixture.payer, + approveInstructions, + [owner], + ); const delegated = await getHotDelegate(fixture.rpc, tokenAccount); - expect(delegated.delegate?.toBase58()).toBe(delegate.publicKey.toBase58()); + expect(delegated.delegate?.toBase58()).toBe( + delegate.publicKey.toBase58(), + ); expect(delegated.delegatedAmount).toBe(1_500n); const revokeInstructions = await createRevokeInstructions({ @@ -88,10 +93,17 @@ describe('approve and revoke instructions', () => { delegate: delegate.publicKey, amount: 500n, }); - await sendInstructions(fixture.rpc, fixture.payer, approveInstructions, [owner]); + await sendInstructions( + fixture.rpc, + fixture.payer, + approveInstructions, + [owner], + ); const delegated = await getHotDelegate(fixture.rpc, tokenAccount); - expect(delegated.delegate?.toBase58()).toBe(delegate.publicKey.toBase58()); + expect(delegated.delegate?.toBase58()).toBe( + delegate.publicKey.toBase58(), + ); expect(delegated.delegatedAmount).toBe(500n); const revokeInstructions = await createRevokeInstructions({ @@ -99,7 +111,9 @@ describe('approve and revoke instructions', () => { owner: owner.publicKey, mint: fixture.mint, }); - await sendInstructions(fixture.rpc, fixture.payer, revokeInstructions, [owner]); + await sendInstructions(fixture.rpc, fixture.payer, revokeInstructions, [ + owner, + ]); const revoked = await getHotDelegate(fixture.rpc, tokenAccount); expect(revoked.delegate).toBeNull(); diff --git a/js/token-interface/tests/e2e/ata-read.test.ts b/js/token-interface/tests/e2e/ata-read.test.ts index a9c33af1b2..478f698956 100644 --- a/js/token-interface/tests/e2e/ata-read.test.ts +++ b/js/token-interface/tests/e2e/ata-read.test.ts @@ -1,10 +1,6 @@ import { describe, expect, it } from 'vitest'; import { newAccountWithLamports } from '@lightprotocol/stateless.js'; -import { - createAtaInstructions, - getAta, - getAtaAddress, -} from '../../src'; +import { createAtaInstructions, getAta, getAtaAddress } from '../../src'; import { createMintFixture, sendInstructions } from './helpers'; describe('ata creation and reads', () => { @@ -33,7 +29,9 @@ describe('ata creation and reads', () => { }); expect(account.parsed.address.toBase58()).toBe(ata.toBase58()); - expect(account.parsed.owner.toBase58()).toBe(owner.publicKey.toBase58()); + expect(account.parsed.owner.toBase58()).toBe( + owner.publicKey.toBase58(), + ); expect(account.parsed.mint.toBase58()).toBe(fixture.mint.toBase58()); expect(account.parsed.amount).toBe(0n); }); @@ -57,7 +55,9 @@ describe('ata creation and reads', () => { mint: fixture.mint, }); - expect(account.parsed.owner.toBase58()).toBe(owner.publicKey.toBase58()); + expect(account.parsed.owner.toBase58()).toBe( + owner.publicKey.toBase58(), + ); expect(account.parsed.amount).toBe(0n); }); }); diff --git a/js/token-interface/tests/e2e/burn.test.ts b/js/token-interface/tests/e2e/burn.test.ts index 85653dc2a8..264b9b5cbc 100644 --- a/js/token-interface/tests/e2e/burn.test.ts +++ b/js/token-interface/tests/e2e/burn.test.ts @@ -26,8 +26,12 @@ describe('burn instructions', () => { }); await expect( - sendInstructions(fixture.rpc, fixture.payer, burnInstructions, [owner]), - ).rejects.toThrow('instruction modified data of an account it does not own'); + sendInstructions(fixture.rpc, fixture.payer, burnInstructions, [ + owner, + ]), + ).rejects.toThrow( + 'instruction modified data of an account it does not own', + ); }); it('fails checked burn with wrong mint decimals', async () => { @@ -47,7 +51,9 @@ describe('burn instructions', () => { }); await expect( - sendInstructions(fixture.rpc, fixture.payer, burnInstructions, [owner]), + sendInstructions(fixture.rpc, fixture.payer, burnInstructions, [ + owner, + ]), ).rejects.toThrow(); }); @@ -67,7 +73,9 @@ describe('burn instructions', () => { authority: unauthorized.publicKey, amount: 250n, }), - ).rejects.toThrow('Signer is not the owner or a delegate of the account.'); + ).rejects.toThrow( + 'Signer is not the owner or a delegate of the account.', + ); }); it('builds burn instructions when payer is omitted', async () => { diff --git a/js/token-interface/tests/e2e/freeze-thaw.test.ts b/js/token-interface/tests/e2e/freeze-thaw.test.ts index 8db81792c6..057f9bcd37 100644 --- a/js/token-interface/tests/e2e/freeze-thaw.test.ts +++ b/js/token-interface/tests/e2e/freeze-thaw.test.ts @@ -126,9 +126,12 @@ describe('freeze and thaw instructions', () => { recipient: recipient.publicKey, amount: 100n, }); - await sendInstructions(fixture.rpc, fixture.payer, transferInstructions, [ - owner, - ]); + await sendInstructions( + fixture.rpc, + fixture.payer, + transferInstructions, + [owner], + ); const recipientAta = await getAta({ rpc: fixture.rpc, diff --git a/js/token-interface/tests/e2e/helpers.ts b/js/token-interface/tests/e2e/helpers.ts index 1eb9e64324..55b44a5692 100644 --- a/js/token-interface/tests/e2e/helpers.ts +++ b/js/token-interface/tests/e2e/helpers.ts @@ -1,198 +1,203 @@ -import { AccountState } from "@solana/spl-token"; +import { AccountState } from '@solana/spl-token'; import { - Keypair, - PublicKey, - Signer, - TransactionInstruction, -} from "@solana/web3.js"; + Keypair, + PublicKey, + Signer, + TransactionInstruction, +} from '@solana/web3.js'; import { - Rpc, - TreeInfo, - VERSION, - bn, - buildAndSignTx, - createRpc, - featureFlags, - newAccountWithLamports, - selectStateTreeInfo, - sendAndConfirmTx, -} from "@lightprotocol/stateless.js"; -import { createMint, mintTo } from "@lightprotocol/compressed-token"; -import { parseLightTokenHot } from "../../src/read"; -import { getSplInterfaces } from "../../src/spl-interface"; + Rpc, + TreeInfo, + VERSION, + bn, + buildAndSignTx, + createRpc, + featureFlags, + newAccountWithLamports, + selectStateTreeInfo, + sendAndConfirmTx, +} from '@lightprotocol/stateless.js'; +import { createMint, mintTo } from '@lightprotocol/compressed-token'; +import { parseLightTokenHot } from '../../src/read'; +import { getSplInterfaces } from '../../src/spl-interface'; featureFlags.version = VERSION.V2; export const TEST_TOKEN_DECIMALS = 9; export interface MintFixture { - rpc: Rpc; - payer: Signer; - mint: PublicKey; - mintAuthority: Keypair; - stateTreeInfo: TreeInfo; - tokenPoolInfos: Awaited>; - freezeAuthority?: Keypair; + rpc: Rpc; + payer: Signer; + mint: PublicKey; + mintAuthority: Keypair; + stateTreeInfo: TreeInfo; + tokenPoolInfos: Awaited>; + freezeAuthority?: Keypair; } export async function createMintFixture(options?: { - withFreezeAuthority?: boolean; - payerLamports?: number; + withFreezeAuthority?: boolean; + payerLamports?: number; }): Promise { - const rpc = createRpc(); - const payer = await newAccountWithLamports( - rpc, - options?.payerLamports ?? 20e9, - ); - const mintAuthority = Keypair.generate(); - const mintKeypair = Keypair.generate(); - const freezeAuthority = options?.withFreezeAuthority - ? Keypair.generate() - : undefined; - - const mint = ( - await createMint( - rpc, - payer, - mintAuthority.publicKey, - TEST_TOKEN_DECIMALS, - mintKeypair, - undefined, - undefined, - freezeAuthority?.publicKey ?? null, - ) - ).mint; - - const stateTreeInfo = selectStateTreeInfo(await rpc.getStateTreeInfos()); - const tokenPoolInfos = await getSplInterfaces(rpc, mint); - - return { - rpc, - payer, - mint, - mintAuthority, - stateTreeInfo, - tokenPoolInfos, - freezeAuthority, - }; + const rpc = createRpc(); + const payer = await newAccountWithLamports( + rpc, + options?.payerLamports ?? 20e9, + ); + const mintAuthority = Keypair.generate(); + const mintKeypair = Keypair.generate(); + const freezeAuthority = options?.withFreezeAuthority + ? Keypair.generate() + : undefined; + + const mint = ( + await createMint( + rpc, + payer, + mintAuthority.publicKey, + TEST_TOKEN_DECIMALS, + mintKeypair, + undefined, + undefined, + freezeAuthority?.publicKey ?? null, + ) + ).mint; + + const stateTreeInfo = selectStateTreeInfo(await rpc.getStateTreeInfos()); + const tokenPoolInfos = await getSplInterfaces(rpc, mint); + + return { + rpc, + payer, + mint, + mintAuthority, + stateTreeInfo, + tokenPoolInfos, + freezeAuthority, + }; } export async function mintCompressedToOwner( - fixture: MintFixture, - owner: PublicKey, - amount: bigint, + fixture: MintFixture, + owner: PublicKey, + amount: bigint, ): Promise { - const selectedSplInterfaceInfo = fixture.tokenPoolInfos.find( - (info) => info.isInitialized, - ); - if (!selectedSplInterfaceInfo) { - throw new Error("No initialized SPL interface info found."); - } - - const selectedSplInterfaceForMintTo = { - ...selectedSplInterfaceInfo, - splInterfacePda: selectedSplInterfaceInfo.poolPda, - tokenProgram: selectedSplInterfaceInfo.tokenProgramId, - poolIndex: selectedSplInterfaceInfo.derivationIndex, - }; - - await mintTo( - fixture.rpc, - fixture.payer, - fixture.mint, - owner, - fixture.mintAuthority, - bn(amount.toString()), - fixture.stateTreeInfo, - selectedSplInterfaceForMintTo, - ); + const selectedSplInterfaceInfo = fixture.tokenPoolInfos.find( + info => info.isInitialized, + ); + if (!selectedSplInterfaceInfo) { + throw new Error('No initialized SPL interface info found.'); + } + + const selectedSplInterfaceForMintTo = { + ...selectedSplInterfaceInfo, + splInterfacePda: selectedSplInterfaceInfo.poolPda, + tokenProgram: selectedSplInterfaceInfo.tokenProgramId, + poolIndex: selectedSplInterfaceInfo.derivationIndex, + }; + + await mintTo( + fixture.rpc, + fixture.payer, + fixture.mint, + owner, + fixture.mintAuthority, + bn(amount.toString()), + fixture.stateTreeInfo, + selectedSplInterfaceForMintTo, + ); } export async function mintMultipleColdAccounts( - fixture: MintFixture, - owner: PublicKey, - count: number, - amountPerAccount: bigint, + fixture: MintFixture, + owner: PublicKey, + count: number, + amountPerAccount: bigint, ): Promise { - for (let i = 0; i < count; i += 1) { - await mintCompressedToOwner(fixture, owner, amountPerAccount); - } + for (let i = 0; i < count; i += 1) { + await mintCompressedToOwner(fixture, owner, amountPerAccount); + } } export async function sendInstructions( - rpc: Rpc, - payer: Signer, - instructions: TransactionInstruction[], - additionalSigners: Signer[] = [], + rpc: Rpc, + payer: Signer, + instructions: TransactionInstruction[], + additionalSigners: Signer[] = [], ): Promise { - const { blockhash } = await rpc.getLatestBlockhash(); - const tx = buildAndSignTx(instructions, payer, blockhash, additionalSigners); - return sendAndConfirmTx(rpc, tx); + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx( + instructions, + payer, + blockhash, + additionalSigners, + ); + return sendAndConfirmTx(rpc, tx); } export async function getHotBalance( - rpc: Rpc, - tokenAccount: PublicKey, + rpc: Rpc, + tokenAccount: PublicKey, ): Promise { - const info = await rpc.getAccountInfo(tokenAccount); - if (!info) { - return BigInt(0); - } + const info = await rpc.getAccountInfo(tokenAccount); + if (!info) { + return BigInt(0); + } - return parseLightTokenHot(tokenAccount, info).parsed.amount; + return parseLightTokenHot(tokenAccount, info).parsed.amount; } export async function getHotDelegate( - rpc: Rpc, - tokenAccount: PublicKey, + rpc: Rpc, + tokenAccount: PublicKey, ): Promise<{ delegate: PublicKey | null; delegatedAmount: bigint }> { - const info = await rpc.getAccountInfo(tokenAccount); - if (!info) { - return { delegate: null, delegatedAmount: BigInt(0) }; - } - - const { parsed } = parseLightTokenHot(tokenAccount, info); - return { - delegate: parsed.delegate, - delegatedAmount: parsed.delegatedAmount ?? BigInt(0), - }; + const info = await rpc.getAccountInfo(tokenAccount); + if (!info) { + return { delegate: null, delegatedAmount: BigInt(0) }; + } + + const { parsed } = parseLightTokenHot(tokenAccount, info); + return { + delegate: parsed.delegate, + delegatedAmount: parsed.delegatedAmount ?? BigInt(0), + }; } export async function getHotState( - rpc: Rpc, - tokenAccount: PublicKey, + rpc: Rpc, + tokenAccount: PublicKey, ): Promise { - const info = await rpc.getAccountInfo(tokenAccount); - if (!info) { - throw new Error(`Account not found: ${tokenAccount.toBase58()}`); - } - - const { parsed } = parseLightTokenHot(tokenAccount, info); - return parsed.isFrozen - ? AccountState.Frozen - : parsed.isInitialized - ? AccountState.Initialized - : AccountState.Uninitialized; + const info = await rpc.getAccountInfo(tokenAccount); + if (!info) { + throw new Error(`Account not found: ${tokenAccount.toBase58()}`); + } + + const { parsed } = parseLightTokenHot(tokenAccount, info); + return parsed.isFrozen + ? AccountState.Frozen + : parsed.isInitialized + ? AccountState.Initialized + : AccountState.Uninitialized; } export async function getCompressedAmounts( - rpc: Rpc, - owner: PublicKey, - mint: PublicKey, + rpc: Rpc, + owner: PublicKey, + mint: PublicKey, ): Promise { - const result = await rpc.getCompressedTokenAccountsByOwner(owner, { mint }); + const result = await rpc.getCompressedTokenAccountsByOwner(owner, { mint }); - return result.items - .map((account) => BigInt(account.parsed.amount.toString())) - .sort((left, right) => { - if (right > left) { - return 1; - } + return result.items + .map(account => BigInt(account.parsed.amount.toString())) + .sort((left, right) => { + if (right > left) { + return 1; + } - if (right < left) { - return -1; - } + if (right < left) { + return -1; + } - return 0; - }); + return 0; + }); } diff --git a/js/token-interface/tests/e2e/load.test.ts b/js/token-interface/tests/e2e/load.test.ts index caf2c6cc8f..8eb7189f7d 100644 --- a/js/token-interface/tests/e2e/load.test.ts +++ b/js/token-interface/tests/e2e/load.test.ts @@ -1,11 +1,7 @@ import { describe, expect, it } from 'vitest'; import { ComputeBudgetProgram } from '@solana/web3.js'; import { newAccountWithLamports } from '@lightprotocol/stateless.js'; -import { - createLoadInstructions, - getAta, - getAtaAddress, -} from '../../src'; +import { createLoadInstructions, getAta, getAtaAddress } from '../../src'; import { createMintFixture, getCompressedAmounts, @@ -126,7 +122,9 @@ describe('load instructions', () => { mint: fixture.mint, }); - await sendInstructions(fixture.rpc, fixture.payer, instructions, [owner]); + await sendInstructions(fixture.rpc, fixture.payer, instructions, [ + owner, + ]); expect(await getHotBalance(fixture.rpc, tokenAccount)).toBe(250n); }); diff --git a/js/token-interface/tests/e2e/transfer.test.ts b/js/token-interface/tests/e2e/transfer.test.ts index 4410186790..ca9723257f 100644 --- a/js/token-interface/tests/e2e/transfer.test.ts +++ b/js/token-interface/tests/e2e/transfer.test.ts @@ -1,9 +1,13 @@ import { describe, expect, it } from 'vitest'; -import { ComputeBudgetProgram, Keypair, TransactionInstruction } from '@solana/web3.js'; +import { + ComputeBudgetProgram, + Keypair, + TransactionInstruction, +} from '@solana/web3.js'; import { createTransferCheckedInstruction as createSplTransferCheckedInstruction, TOKEN_PROGRAM_ID, - TOKEN_2022_PROGRAM_ID, + TOKEN_2022_PROGRAM_ID, getAssociatedTokenAddressSync, unpackAccount, } from '@solana/spl-token'; @@ -52,7 +56,9 @@ describe('transfer instructions', () => { recipient: recipient.publicKey, amount: 100n, }), - ).rejects.toThrow('Signer is not the owner or a delegate of the account.'); + ).rejects.toThrow( + 'Signer is not the owner or a delegate of the account.', + ); }); it('builds a single-transaction transfer flow without compute budget instructions', async () => { @@ -114,7 +120,9 @@ describe('transfer instructions', () => { amount: 400n, }); - await sendInstructions(fixture.rpc, fixture.payer, instructions, [sender]); + await sendInstructions(fixture.rpc, fixture.payer, instructions, [ + sender, + ]); const recipientAta = await getAta({ rpc: fixture.rpc, @@ -148,9 +156,12 @@ describe('transfer instructions', () => { amount: 1_250n, }); - await sendInstructions(fixture.rpc, fixture.payer, instructions, [sender]); + await sendInstructions(fixture.rpc, fixture.payer, instructions, [ + sender, + ]); - const recipientSplInfo = await fixture.rpc.getAccountInfo(recipientSplAta); + const recipientSplInfo = + await fixture.rpc.getAccountInfo(recipientSplAta); expect(recipientSplInfo).not.toBeNull(); const recipientSpl = unpackAccount( recipientSplAta, @@ -180,7 +191,9 @@ describe('transfer instructions', () => { }); await expect( - sendInstructions(fixture.rpc, fixture.payer, instructions, [sender]), + sendInstructions(fixture.rpc, fixture.payer, instructions, [ + sender, + ]), ).rejects.toThrow('custom program error'); }); @@ -205,7 +218,9 @@ describe('transfer instructions', () => { amount: 0n, }); - await sendInstructions(fixture.rpc, fixture.payer, instructions, [sender]); + await sendInstructions(fixture.rpc, fixture.payer, instructions, [ + sender, + ]); expect(await getHotBalance(fixture.rpc, senderAta)).toBe(500n); }); @@ -235,7 +250,9 @@ describe('transfer instructions', () => { sender, ]); - expect(await getHotBalance(fixture.rpc, recipientAtaAddress)).toBe(200n); + expect(await getHotBalance(fixture.rpc, recipientAtaAddress)).toBe( + 200n, + ); expect( await getCompressedAmounts( fixture.rpc, @@ -275,9 +292,12 @@ describe('transfer instructions', () => { amount: 300n, }); - await sendInstructions(fixture.rpc, fixture.payer, approveInstructions, [ - owner, - ]); + await sendInstructions( + fixture.rpc, + fixture.payer, + approveInstructions, + [owner], + ); const transferInstructions = await createTransferInstructions({ rpc: fixture.rpc, @@ -289,9 +309,12 @@ describe('transfer instructions', () => { authority: delegate.publicKey, }); - await sendInstructions(fixture.rpc, fixture.payer, transferInstructions, [ - delegate, - ]); + await sendInstructions( + fixture.rpc, + fixture.payer, + transferInstructions, + [delegate], + ); const recipientAta = await getAta({ rpc: fixture.rpc, @@ -319,9 +342,12 @@ describe('transfer instructions', () => { delegate: delegate.publicKey, amount: 100n, }); - await sendInstructions(fixture.rpc, fixture.payer, approveInstructions, [ - owner, - ]); + await sendInstructions( + fixture.rpc, + fixture.payer, + approveInstructions, + [owner], + ); const transferInstructions = await createTransferInstructions({ rpc: fixture.rpc, @@ -364,9 +390,12 @@ describe('transfer instructions', () => { tokenProgram: TOKEN_PROGRAM_ID, amount: 1_500n, }); - await sendInstructions(fixture.rpc, fixture.payer, toSenderSplInstructions, [ - sender, - ]); + await sendInstructions( + fixture.rpc, + fixture.payer, + toSenderSplInstructions, + [sender], + ); const senderSplInfo = await fixture.rpc.getAccountInfo(senderSplAta); expect(senderSplInfo).not.toBeNull(); @@ -388,7 +417,9 @@ describe('transfer instructions', () => { amount: 1_000n, }); await expect( - sendInstructions(fixture.rpc, fixture.payer, nowrapInstructions, [sender]), + sendInstructions(fixture.rpc, fixture.payer, nowrapInstructions, [ + sender, + ]), ).rejects.toThrow('custom program error'); // Canonical transfer wraps SPL first, then succeeds. @@ -401,9 +432,12 @@ describe('transfer instructions', () => { recipient: recipient.publicKey, amount: 1_000n, }); - await sendInstructions(fixture.rpc, fixture.payer, canonicalInstructions, [ - sender, - ]); + await sendInstructions( + fixture.rpc, + fixture.payer, + canonicalInstructions, + [sender], + ); const recipientAta = await getAta({ rpc: fixture.rpc, @@ -445,7 +479,9 @@ describe('transfer instructions', () => { tokenProgram: TOKEN_PROGRAM_ID, amount: 1_000n, }); - await sendInstructions(fixture.rpc, fixture.payer, senderToSpl, [sender]); + await sendInstructions(fixture.rpc, fixture.payer, senderToSpl, [ + sender, + ]); // Stage 2: fund donor SPL ATA with 500. const donorToSpl = await createTransferInstructions({ @@ -482,10 +518,17 @@ describe('transfer instructions', () => { [], TOKEN_PROGRAM_ID, ); - await sendInstructions(fixture.rpc, fixture.payer, [injectAfterBuild], [donor]); + await sendInstructions( + fixture.rpc, + fixture.payer, + [injectAfterBuild], + [donor], + ); // Wrapped transfer should still succeed. - await sendInstructions(fixture.rpc, fixture.payer, wrappedTransfer, [sender]); + await sendInstructions(fixture.rpc, fixture.payer, wrappedTransfer, [ + sender, + ]); // Recipient receives transfer amount. const recipientAta = await getAta({ diff --git a/js/token-interface/tests/unit/instruction-builders.test.ts b/js/token-interface/tests/unit/instruction-builders.test.ts index f5f46bbae2..9f014cb8da 100644 --- a/js/token-interface/tests/unit/instruction-builders.test.ts +++ b/js/token-interface/tests/unit/instruction-builders.test.ts @@ -349,5 +349,4 @@ describe('instruction builders', () => { expect(instruction.keys[2].pubkey.equals(owner)).toBe(true); expect(instruction.keys[2].isSigner).toBe(true); }); - }); diff --git a/js/token-interface/tests/unit/public-api.test.ts b/js/token-interface/tests/unit/public-api.test.ts index 37575cc1e8..b7a3a81030 100644 --- a/js/token-interface/tests/unit/public-api.test.ts +++ b/js/token-interface/tests/unit/public-api.test.ts @@ -16,9 +16,11 @@ describe('public api', () => { const owner = Keypair.generate().publicKey; const mint = Keypair.generate().publicKey; - expect(getAtaAddress({ owner, mint }).equals( - getAssociatedTokenAddress(mint, owner), - )).toBe(true); + expect( + getAtaAddress({ owner, mint }).equals( + getAssociatedTokenAddress(mint, owner), + ), + ).toBe(true); }); it('builds one canonical ata instruction', async () => { diff --git a/scripts/format.sh b/scripts/format.sh index 0eb7d645ca..5a4449c473 100755 --- a/scripts/format.sh +++ b/scripts/format.sh @@ -5,6 +5,7 @@ set -e # JS formatting cd js/stateless.js && pnpm format && cd ../.. cd js/compressed-token && pnpm format && cd ../.. +cd js/token-interface && pnpm format && cd ../.. # Rust formatting cargo +nightly fmt --all From ff7552d9ba82b6e69a56d71a1d88cb5a343924c4 Mon Sep 17 00:00:00 2001 From: ananas Date: Sun, 29 Mar 2026 22:46:29 +0100 Subject: [PATCH 24/24] refactor(token-interface): migrate tests from vitest to cucumber-js Replace all vitest test files with Gherkin feature files and cucumber-js TypeScript step definitions. 11 unit scenarios and 11 E2E scenarios preserve full test coverage. - Add @cucumber/cucumber and tsx, remove vitest and eslint-plugin-vitest - Create cucumber.js config with unit/e2e/default profiles - Create World class and hooks infrastructure - Write 8 feature files and 9 step definition files - Delete all *.test.ts files and vitest.config.ts - Update eslint, tsconfig, and package.json scripts - Fix CI branch filter ("*" -> "**") for slash-containing base branches - Update pnpm-lock.yaml --- .github/workflows/js-token-interface-v2.yml | 2 +- .github/workflows/js-v2.yml | 2 +- js/token-interface/cucumber.js | 26 + js/token-interface/eslint.config.cjs | 13 +- .../features/e2e/approve-revoke.feature | 13 + .../features/e2e/ata-read.feature | 8 + .../features/e2e/freeze-thaw.feature | 10 + js/token-interface/features/e2e/load.feature | 21 + .../features/e2e/transfer.feature | 48 ++ .../unit/instruction-builders.feature | 28 + .../features/unit/kit-adapter.feature | 18 + .../features/unit/public-api.feature | 30 + js/token-interface/package.json | 10 +- .../tests/e2e/approve-revoke.test.ts | 122 ---- js/token-interface/tests/e2e/ata-read.test.ts | 63 -- .../tests/e2e/freeze-thaw.test.ts | 184 ------ js/token-interface/tests/e2e/load.test.ts | 131 ---- js/token-interface/tests/e2e/transfer.test.ts | 551 ----------------- .../e2e/approve-revoke.steps.ts | 127 ++++ .../step-definitions/e2e/ata-read.steps.ts | 62 ++ .../step-definitions/e2e/common.steps.ts | 200 ++++++ .../step-definitions/e2e/freeze-thaw.steps.ts | 90 +++ .../tests/step-definitions/e2e/load.steps.ts | 169 +++++ .../step-definitions/e2e/transfer.steps.ts | 275 +++++++++ .../unit/instruction-builders.steps.ts | 129 ++++ .../unit/kit-adapter.steps.ts | 71 +++ .../step-definitions/unit/public-api.steps.ts | 146 +++++ js/token-interface/tests/support/hooks.ts | 5 + js/token-interface/tests/support/world.ts | 42 ++ .../tests/unit/instruction-builders.test.ts | 352 ----------- js/token-interface/tests/unit/kit.test.ts | 41 -- .../tests/unit/public-api.test.ts | 77 --- js/token-interface/tsconfig.test.json | 2 +- js/token-interface/vitest.config.ts | 26 - pnpm-lock.yaml | 584 ++++++++++++++++-- 35 files changed, 2061 insertions(+), 1617 deletions(-) create mode 100644 js/token-interface/cucumber.js create mode 100644 js/token-interface/features/e2e/approve-revoke.feature create mode 100644 js/token-interface/features/e2e/ata-read.feature create mode 100644 js/token-interface/features/e2e/freeze-thaw.feature create mode 100644 js/token-interface/features/e2e/load.feature create mode 100644 js/token-interface/features/e2e/transfer.feature create mode 100644 js/token-interface/features/unit/instruction-builders.feature create mode 100644 js/token-interface/features/unit/kit-adapter.feature create mode 100644 js/token-interface/features/unit/public-api.feature delete mode 100644 js/token-interface/tests/e2e/approve-revoke.test.ts delete mode 100644 js/token-interface/tests/e2e/ata-read.test.ts delete mode 100644 js/token-interface/tests/e2e/freeze-thaw.test.ts delete mode 100644 js/token-interface/tests/e2e/load.test.ts delete mode 100644 js/token-interface/tests/e2e/transfer.test.ts create mode 100644 js/token-interface/tests/step-definitions/e2e/approve-revoke.steps.ts create mode 100644 js/token-interface/tests/step-definitions/e2e/ata-read.steps.ts create mode 100644 js/token-interface/tests/step-definitions/e2e/common.steps.ts create mode 100644 js/token-interface/tests/step-definitions/e2e/freeze-thaw.steps.ts create mode 100644 js/token-interface/tests/step-definitions/e2e/load.steps.ts create mode 100644 js/token-interface/tests/step-definitions/e2e/transfer.steps.ts create mode 100644 js/token-interface/tests/step-definitions/unit/instruction-builders.steps.ts create mode 100644 js/token-interface/tests/step-definitions/unit/kit-adapter.steps.ts create mode 100644 js/token-interface/tests/step-definitions/unit/public-api.steps.ts create mode 100644 js/token-interface/tests/support/hooks.ts create mode 100644 js/token-interface/tests/support/world.ts delete mode 100644 js/token-interface/tests/unit/instruction-builders.test.ts delete mode 100644 js/token-interface/tests/unit/kit.test.ts delete mode 100644 js/token-interface/tests/unit/public-api.test.ts delete mode 100644 js/token-interface/vitest.config.ts diff --git a/.github/workflows/js-token-interface-v2.yml b/.github/workflows/js-token-interface-v2.yml index 23e0e217a0..c95d46f298 100644 --- a/.github/workflows/js-token-interface-v2.yml +++ b/.github/workflows/js-token-interface-v2.yml @@ -4,7 +4,7 @@ on: - main pull_request: branches: - - "*" + - "**" types: - opened - synchronize diff --git a/.github/workflows/js-v2.yml b/.github/workflows/js-v2.yml index 2408996181..8a0da02be2 100644 --- a/.github/workflows/js-v2.yml +++ b/.github/workflows/js-v2.yml @@ -4,7 +4,7 @@ on: - main pull_request: branches: - - "*" + - "**" types: - opened - synchronize diff --git a/js/token-interface/cucumber.js b/js/token-interface/cucumber.js new file mode 100644 index 0000000000..f181c869c6 --- /dev/null +++ b/js/token-interface/cucumber.js @@ -0,0 +1,26 @@ +const common = { + requireModule: ['tsx'], + require: [ + 'tests/support/**/*.ts', + 'tests/step-definitions/**/*.steps.ts', + ], + format: ['progress'], + formatOptions: { snippetInterface: 'async-await' }, +}; + +const defaultProfile = { + ...common, + paths: ['features/**/*.feature'], +}; + +export { defaultProfile as default }; + +export const unit = { + ...common, + paths: ['features/unit/**/*.feature'], +}; + +export const e2e = { + ...common, + paths: ['features/e2e/**/*.feature'], +}; diff --git a/js/token-interface/eslint.config.cjs b/js/token-interface/eslint.config.cjs index 54e0f6819f..15c2201db4 100644 --- a/js/token-interface/eslint.config.cjs +++ b/js/token-interface/eslint.config.cjs @@ -36,9 +36,7 @@ module.exports = [ { files: [ 'tests/**/*.ts', - '**/*.test.ts', - '**/*.spec.ts', - 'vitest.config.ts', + '**/*.steps.ts', ], languageOptions: { parser: tsParser, @@ -52,15 +50,6 @@ module.exports = [ __dirname: 'readonly', __filename: 'readonly', Buffer: 'readonly', - describe: 'readonly', - it: 'readonly', - expect: 'readonly', - beforeEach: 'readonly', - afterEach: 'readonly', - beforeAll: 'readonly', - afterAll: 'readonly', - jest: 'readonly', - test: 'readonly', }, }, plugins: { diff --git a/js/token-interface/features/e2e/approve-revoke.feature b/js/token-interface/features/e2e/approve-revoke.feature new file mode 100644 index 0000000000..929a07fb9f --- /dev/null +++ b/js/token-interface/features/e2e/approve-revoke.feature @@ -0,0 +1,13 @@ +@e2e +Feature: Approve and revoke delegation + + Scenario: Full approve then revoke cycle + Given a fresh mint fixture + And an owner with 4000 compressed tokens + And a new delegate + When the owner approves the delegate for 1500 tokens + Then the delegate is set on the token account with amount 1500 + And no compute budget instructions were included + When the owner revokes the delegation + Then the delegate is cleared and delegated amount is 0 + And no compute budget instructions were included in revoke diff --git a/js/token-interface/features/e2e/ata-read.feature b/js/token-interface/features/e2e/ata-read.feature new file mode 100644 index 0000000000..3c118cf973 --- /dev/null +++ b/js/token-interface/features/e2e/ata-read.feature @@ -0,0 +1,8 @@ +@e2e +Feature: ATA creation and reads + + Scenario: Create and read back an ATA + Given a fresh mint fixture + And a new owner + When the owner creates an ATA + Then reading the ATA returns the correct address, owner, mint, and zero balance diff --git a/js/token-interface/features/e2e/freeze-thaw.feature b/js/token-interface/features/e2e/freeze-thaw.feature new file mode 100644 index 0000000000..fe9dfd5732 --- /dev/null +++ b/js/token-interface/features/e2e/freeze-thaw.feature @@ -0,0 +1,10 @@ +@e2e +Feature: Freeze and thaw token accounts + + Scenario: Freeze then thaw a hot account + Given a fresh mint fixture with freeze authority + And an owner with a created ATA and 2500 compressed tokens + When the freeze authority freezes the account + Then the account state is "Frozen" + When the freeze authority thaws the account + Then the account state is "Initialized" diff --git a/js/token-interface/features/e2e/load.feature b/js/token-interface/features/e2e/load.feature new file mode 100644 index 0000000000..39a9e3d389 --- /dev/null +++ b/js/token-interface/features/e2e/load.feature @@ -0,0 +1,21 @@ +@e2e +Feature: Load compressed balances into hot account + + Scenario: getAta exposes the biggest compressed balance + Given a fresh mint fixture + And an owner with compressed mints of 400, 300, and 200 tokens + When I read the owner ATA + Then the ATA amount is 400 + And the ATA compressed amount is 400 + And the ATA requires load + And there are 2 ignored compressed accounts totaling 500 + + Scenario: Load one compressed balance per call + Given a fresh mint fixture + And an owner with compressed mints of 500, 300, and 200 tokens + When I load the first compressed balance + Then the hot balance is 500 + And compressed accounts are 300 and 200 + When I load the next compressed balance + Then the hot balance is 800 + And compressed accounts are 200 diff --git a/js/token-interface/features/e2e/transfer.feature b/js/token-interface/features/e2e/transfer.feature new file mode 100644 index 0000000000..3e7910e98b --- /dev/null +++ b/js/token-interface/features/e2e/transfer.feature @@ -0,0 +1,48 @@ +@e2e +Feature: Token transfers + + Scenario: Single-transaction transfer between light-token accounts + Given a fresh mint fixture + And a funded sender with 5000 compressed tokens + And a new recipient + When the sender transfers 2000 tokens to the recipient + Then the recipient ATA balance is 2000 + And the sender ATA balance is 3000 + + Scenario: Transfer to an SPL ATA recipient + Given a fresh mint fixture + And a funded sender with 3000 compressed tokens + And a funded recipient with SOL + When the sender transfers 1250 tokens to the recipient SPL ATA + Then the recipient SPL ATA balance is 1250 + + Scenario: Insufficient funds error propagates from on-chain + Given a fresh mint fixture + And a sender with compressed mints of 500, 300, and 200 tokens + And a new recipient + When the sender attempts to transfer 600 tokens + Then the transaction fails with "custom program error" + + Scenario: Zero-amount transfer preserves balance + Given a fresh mint fixture + And a funded sender with 500 compressed tokens + And a new recipient + When the sender transfers 0 tokens to the recipient + Then the sender ATA balance is 500 + + Scenario: Recipient compressed balance is not loaded during transfer + Given a fresh mint fixture + And a funded sender with 400 compressed tokens + And a funded recipient with 300 compressed tokens + When the sender transfers 200 tokens to the recipient + Then the recipient hot balance is 200 + And the recipient still has 300 in compressed accounts + And the recipient total ATA amount is 500 with 300 compressed + + Scenario: Delegated transfer after approval + Given a fresh mint fixture + And an owner with 500 compressed tokens + And a delegate approved for 300 tokens + When the delegate transfers 250 tokens to a new recipient + Then the recipient ATA balance is 250 + And the owner ATA balance is 250 diff --git a/js/token-interface/features/unit/instruction-builders.feature b/js/token-interface/features/unit/instruction-builders.feature new file mode 100644 index 0000000000..4a51320397 --- /dev/null +++ b/js/token-interface/features/unit/instruction-builders.feature @@ -0,0 +1,28 @@ +@unit +Feature: Light-token instruction builders + Low-level instruction builders produce correct program IDs, + discriminators, and account orderings without any RPC calls. + + Scenario: Create a canonical light-token ATA instruction + Given random keypairs for "payer", "owner", and "mint" + When I build a create-ATA instruction for "payer", "owner", and "mint" + Then the instruction program ID is the light-token program + And account key 0 is "owner" + And account key 1 is "mint" + And account key 2 is "payer" + + Scenario: Create a checked transfer instruction + Given random keypairs for "source", "destination", "mint", "authority", and "payer" + When I build a checked transfer instruction for 42 tokens with 9 decimals + Then the instruction program ID is the light-token program + And the instruction discriminator byte is 12 + And account key 0 is "source" + And account key 2 is "destination" + + Scenario: Create approve, revoke, freeze, and thaw instructions + Given random keypairs for "tokenAccount", "owner", "delegate", "mint", and "freezeAuthority" + When I build approve, revoke, freeze, and thaw instructions + Then the approve instruction targets the light-token program + And the revoke instruction targets the light-token program + And the freeze discriminator byte is 10 + And the thaw discriminator byte is 11 diff --git a/js/token-interface/features/unit/kit-adapter.feature b/js/token-interface/features/unit/kit-adapter.feature new file mode 100644 index 0000000000..ce41afa344 --- /dev/null +++ b/js/token-interface/features/unit/kit-adapter.feature @@ -0,0 +1,18 @@ +@unit +Feature: Solana Kit adapter + The kit module converts legacy web3.js instructions to + Solana Kit compatible instruction objects. + + Scenario: Convert legacy instructions to kit instructions + Given a legacy create-ATA instruction + When I convert it to kit instructions + Then the result is a list of 1 kit instruction object + + Scenario: Wrap canonical builders for kit consumers + Given random keypairs for "payer", "owner", and "mint" + When I call the kit createAtaInstructions builder + Then the result is a list of 1 kit instruction + + Scenario: Transfer and plan builders are exported + Then createTransferInstructions from kit is a function + And createTransferInstructionPlan from kit is a function diff --git a/js/token-interface/features/unit/public-api.feature b/js/token-interface/features/unit/public-api.feature new file mode 100644 index 0000000000..91ec5d0b00 --- /dev/null +++ b/js/token-interface/features/unit/public-api.feature @@ -0,0 +1,30 @@ +@unit +Feature: Public API surface + The root package export exposes address derivation, instruction + builders, discriminators, and error types. + + Scenario: Derive the canonical light-token ATA address + Given random keypairs for "owner" and "mint" + When I derive the ATA address for "owner" and "mint" + Then it matches the low-level getAssociatedTokenAddress result + + Scenario: Build one canonical ATA instruction + Given random keypairs for "payer", "owner", and "mint" + When I build an ATA instruction list for "payer", "owner", and "mint" + Then the result is a list of 1 instruction + And the first instruction program ID is the light-token program + + Scenario: Raw freeze and thaw discriminators + Given random keypairs for "tokenAccount", "mint", and "freezeAuthority" + When I build raw freeze and thaw instructions + Then the freeze discriminator byte is 10 + And the thaw discriminator byte is 11 + + Scenario: Single-transaction error is clear + When I create a MultiTransactionNotSupportedError for "createLoadInstructions" with batch count 2 + Then the error name is "MultiTransactionNotSupportedError" + And the error message contains "single-transaction" + And the error message contains "createLoadInstructions" + + Scenario: Canonical transfer builder is exported + Then createTransferInstructions is a function diff --git a/js/token-interface/package.json b/js/token-interface/package.json index 41ce8ad06b..1ce3fa9bf0 100644 --- a/js/token-interface/package.json +++ b/js/token-interface/package.json @@ -59,14 +59,14 @@ "@typescript-eslint/eslint-plugin": "^8.44.0", "@typescript-eslint/parser": "^8.44.0", "eslint": "^9.36.0", - "eslint-plugin-vitest": "^0.5.4", + "@cucumber/cucumber": "^11.0.0", "prettier": "^3.3.3", "rimraf": "^6.0.1", "rollup": "^4.21.3", "rollup-plugin-dts": "^6.1.1", "tslib": "^2.7.0", - "typescript": "^5.6.2", - "vitest": "^2.1.1" + "tsx": "^4.19.0", + "typescript": "^5.6.2" }, "scripts": { "build": "pnpm build:v2", @@ -74,8 +74,8 @@ "build:deps:v2": "pnpm --dir ../stateless.js build:v2", "build:bundle": "rimraf dist && rollup -c", "test": "pnpm test:unit:all && pnpm test:e2e:all", - "test:unit:all": "pnpm build:deps:v2 && LIGHT_PROTOCOL_VERSION=V2 EXCLUDE_E2E=true vitest run tests/unit --reporter=verbose", - "test:e2e:all": "pnpm build:deps:v2 && pnpm test-validator && LIGHT_PROTOCOL_VERSION=V2 vitest run tests/e2e --reporter=verbose --bail=1", + "test:unit:all": "pnpm build:deps:v2 && LIGHT_PROTOCOL_VERSION=V2 pnpm exec cucumber-js --profile unit", + "test:e2e:all": "pnpm build:deps:v2 && pnpm test-validator && LIGHT_PROTOCOL_VERSION=V2 pnpm exec cucumber-js --profile e2e", "test-validator": "./../../cli/test_bin/run test-validator", "lint": "eslint .", "format": "prettier --write ." diff --git a/js/token-interface/tests/e2e/approve-revoke.test.ts b/js/token-interface/tests/e2e/approve-revoke.test.ts deleted file mode 100644 index 9eee3e490c..0000000000 --- a/js/token-interface/tests/e2e/approve-revoke.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { ComputeBudgetProgram, Keypair } from '@solana/web3.js'; -import { newAccountWithLamports } from '@lightprotocol/stateless.js'; -import { - createApproveInstructions, - createRevokeInstructions, - getAtaAddress, -} from '../../src'; -import { - createMintFixture, - getHotDelegate, - mintCompressedToOwner, - sendInstructions, -} from './helpers'; - -describe('approve and revoke instructions', () => { - it('approves and revokes on the canonical ata', async () => { - const fixture = await createMintFixture(); - const owner = await newAccountWithLamports(fixture.rpc, 1e9); - const delegate = Keypair.generate(); - const tokenAccount = getAtaAddress({ - owner: owner.publicKey, - mint: fixture.mint, - }); - - await mintCompressedToOwner(fixture, owner.publicKey, 4_000n); - - const approveInstructions = await createApproveInstructions({ - rpc: fixture.rpc, - payer: fixture.payer.publicKey, - owner: owner.publicKey, - mint: fixture.mint, - delegate: delegate.publicKey, - amount: 1_500n, - }); - - expect( - approveInstructions.some(instruction => - instruction.programId.equals(ComputeBudgetProgram.programId), - ), - ).toBe(false); - - await sendInstructions( - fixture.rpc, - fixture.payer, - approveInstructions, - [owner], - ); - - const delegated = await getHotDelegate(fixture.rpc, tokenAccount); - expect(delegated.delegate?.toBase58()).toBe( - delegate.publicKey.toBase58(), - ); - expect(delegated.delegatedAmount).toBe(1_500n); - - const revokeInstructions = await createRevokeInstructions({ - rpc: fixture.rpc, - payer: fixture.payer.publicKey, - owner: owner.publicKey, - mint: fixture.mint, - }); - - expect( - revokeInstructions.some(instruction => - instruction.programId.equals(ComputeBudgetProgram.programId), - ), - ).toBe(false); - - await sendInstructions(fixture.rpc, fixture.payer, revokeInstructions, [ - owner, - ]); - - const revoked = await getHotDelegate(fixture.rpc, tokenAccount); - expect(revoked.delegate).toBeNull(); - expect(revoked.delegatedAmount).toBe(0n); - }); - - it('defaults payer to owner when omitted', async () => { - const fixture = await createMintFixture(); - const owner = await newAccountWithLamports(fixture.rpc, 1e9); - const delegate = Keypair.generate(); - const tokenAccount = getAtaAddress({ - owner: owner.publicKey, - mint: fixture.mint, - }); - - await mintCompressedToOwner(fixture, owner.publicKey, 2_000n); - - const approveInstructions = await createApproveInstructions({ - rpc: fixture.rpc, - owner: owner.publicKey, - mint: fixture.mint, - delegate: delegate.publicKey, - amount: 500n, - }); - await sendInstructions( - fixture.rpc, - fixture.payer, - approveInstructions, - [owner], - ); - - const delegated = await getHotDelegate(fixture.rpc, tokenAccount); - expect(delegated.delegate?.toBase58()).toBe( - delegate.publicKey.toBase58(), - ); - expect(delegated.delegatedAmount).toBe(500n); - - const revokeInstructions = await createRevokeInstructions({ - rpc: fixture.rpc, - owner: owner.publicKey, - mint: fixture.mint, - }); - await sendInstructions(fixture.rpc, fixture.payer, revokeInstructions, [ - owner, - ]); - - const revoked = await getHotDelegate(fixture.rpc, tokenAccount); - expect(revoked.delegate).toBeNull(); - expect(revoked.delegatedAmount).toBe(0n); - }); -}); diff --git a/js/token-interface/tests/e2e/ata-read.test.ts b/js/token-interface/tests/e2e/ata-read.test.ts deleted file mode 100644 index 478f698956..0000000000 --- a/js/token-interface/tests/e2e/ata-read.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { newAccountWithLamports } from '@lightprotocol/stateless.js'; -import { createAtaInstructions, getAta, getAtaAddress } from '../../src'; -import { createMintFixture, sendInstructions } from './helpers'; - -describe('ata creation and reads', () => { - it('creates the canonical ata and reads it back', async () => { - const fixture = await createMintFixture(); - const owner = await newAccountWithLamports(fixture.rpc, 1e9); - const ata = getAtaAddress({ - owner: owner.publicKey, - mint: fixture.mint, - }); - - const instructions = await createAtaInstructions({ - payer: fixture.payer.publicKey, - owner: owner.publicKey, - mint: fixture.mint, - }); - - expect(instructions).toHaveLength(1); - - await sendInstructions(fixture.rpc, fixture.payer, instructions); - - const account = await getAta({ - rpc: fixture.rpc, - owner: owner.publicKey, - mint: fixture.mint, - }); - - expect(account.parsed.address.toBase58()).toBe(ata.toBase58()); - expect(account.parsed.owner.toBase58()).toBe( - owner.publicKey.toBase58(), - ); - expect(account.parsed.mint.toBase58()).toBe(fixture.mint.toBase58()); - expect(account.parsed.amount).toBe(0n); - }); - - it('replays ATA creation idempotently', async () => { - const fixture = await createMintFixture(); - const owner = await newAccountWithLamports(fixture.rpc, 1e9); - - const instructions = await createAtaInstructions({ - payer: fixture.payer.publicKey, - owner: owner.publicKey, - mint: fixture.mint, - }); - - await sendInstructions(fixture.rpc, fixture.payer, instructions); - await sendInstructions(fixture.rpc, fixture.payer, instructions); - - const account = await getAta({ - rpc: fixture.rpc, - owner: owner.publicKey, - mint: fixture.mint, - }); - - expect(account.parsed.owner.toBase58()).toBe( - owner.publicKey.toBase58(), - ); - expect(account.parsed.amount).toBe(0n); - }); -}); diff --git a/js/token-interface/tests/e2e/freeze-thaw.test.ts b/js/token-interface/tests/e2e/freeze-thaw.test.ts deleted file mode 100644 index 057f9bcd37..0000000000 --- a/js/token-interface/tests/e2e/freeze-thaw.test.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { AccountState } from '@solana/spl-token'; -import { newAccountWithLamports } from '@lightprotocol/stateless.js'; -import { - createAtaInstructions, - createFreezeInstructions, - createThawInstructions, - createTransferInstructions, - getAta, - getAtaAddress, -} from '../../src'; -import { - createMintFixture, - getHotState, - mintCompressedToOwner, - sendInstructions, -} from './helpers'; - -describe('freeze and thaw instructions', () => { - it('freezes and thaws a loaded hot account', async () => { - const fixture = await createMintFixture({ withFreezeAuthority: true }); - const owner = await newAccountWithLamports(fixture.rpc, 1e9); - const tokenAccount = getAtaAddress({ - owner: owner.publicKey, - mint: fixture.mint, - }); - - await sendInstructions( - fixture.rpc, - fixture.payer, - await createAtaInstructions({ - payer: fixture.payer.publicKey, - owner: owner.publicKey, - mint: fixture.mint, - }), - ); - - await mintCompressedToOwner(fixture, owner.publicKey, 2_500n); - - await sendInstructions( - fixture.rpc, - fixture.payer, - await createFreezeInstructions({ - rpc: fixture.rpc, - payer: fixture.payer.publicKey, - owner: owner.publicKey, - mint: fixture.mint, - freezeAuthority: fixture.freezeAuthority!.publicKey, - }), - [owner, fixture.freezeAuthority!], - ); - - expect(await getHotState(fixture.rpc, tokenAccount)).toBe( - AccountState.Frozen, - ); - - await sendInstructions( - fixture.rpc, - fixture.payer, - await createThawInstructions({ - rpc: fixture.rpc, - payer: fixture.payer.publicKey, - owner: owner.publicKey, - mint: fixture.mint, - freezeAuthority: fixture.freezeAuthority!.publicKey, - }), - [fixture.freezeAuthority!], - ); - - expect(await getHotState(fixture.rpc, tokenAccount)).toBe( - AccountState.Initialized, - ); - }); - - it('blocks transfers while frozen and allows transfers after thaw', async () => { - const fixture = await createMintFixture({ withFreezeAuthority: true }); - const owner = await newAccountWithLamports(fixture.rpc, 1e9); - const recipient = await newAccountWithLamports(fixture.rpc, 1e9); - - await mintCompressedToOwner(fixture, owner.publicKey, 2_500n); - - await sendInstructions( - fixture.rpc, - fixture.payer, - await createFreezeInstructions({ - rpc: fixture.rpc, - payer: fixture.payer.publicKey, - owner: owner.publicKey, - mint: fixture.mint, - freezeAuthority: fixture.freezeAuthority!.publicKey, - }), - [owner, fixture.freezeAuthority!], - ); - - await expect( - createTransferInstructions({ - rpc: fixture.rpc, - payer: fixture.payer.publicKey, - mint: fixture.mint, - sourceOwner: owner.publicKey, - authority: owner.publicKey, - recipient: recipient.publicKey, - amount: 100n, - }), - ).rejects.toThrow('Account is frozen'); - - await sendInstructions( - fixture.rpc, - fixture.payer, - await createThawInstructions({ - rpc: fixture.rpc, - payer: fixture.payer.publicKey, - owner: owner.publicKey, - mint: fixture.mint, - freezeAuthority: fixture.freezeAuthority!.publicKey, - }), - [fixture.freezeAuthority!], - ); - - const transferInstructions = await createTransferInstructions({ - rpc: fixture.rpc, - payer: fixture.payer.publicKey, - mint: fixture.mint, - sourceOwner: owner.publicKey, - authority: owner.publicKey, - recipient: recipient.publicKey, - amount: 100n, - }); - await sendInstructions( - fixture.rpc, - fixture.payer, - transferInstructions, - [owner], - ); - - const recipientAta = await getAta({ - rpc: fixture.rpc, - owner: recipient.publicKey, - mint: fixture.mint, - }); - expect(recipientAta.parsed.amount).toBe(100n); - }); - - it('defaults payer to owner when omitted for freeze/thaw builders', async () => { - const fixture = await createMintFixture({ withFreezeAuthority: true }); - const owner = await newAccountWithLamports(fixture.rpc, 1e9); - const tokenAccount = getAtaAddress({ - owner: owner.publicKey, - mint: fixture.mint, - }); - - await mintCompressedToOwner(fixture, owner.publicKey, 1_000n); - - await sendInstructions( - fixture.rpc, - fixture.payer, - await createFreezeInstructions({ - rpc: fixture.rpc, - owner: owner.publicKey, - mint: fixture.mint, - freezeAuthority: fixture.freezeAuthority!.publicKey, - }), - [owner, fixture.freezeAuthority!], - ); - expect(await getHotState(fixture.rpc, tokenAccount)).toBe( - AccountState.Frozen, - ); - - await sendInstructions( - fixture.rpc, - fixture.payer, - await createThawInstructions({ - rpc: fixture.rpc, - owner: owner.publicKey, - mint: fixture.mint, - freezeAuthority: fixture.freezeAuthority!.publicKey, - }), - [fixture.freezeAuthority!], - ); - expect(await getHotState(fixture.rpc, tokenAccount)).toBe( - AccountState.Initialized, - ); - }); -}); diff --git a/js/token-interface/tests/e2e/load.test.ts b/js/token-interface/tests/e2e/load.test.ts deleted file mode 100644 index 8eb7189f7d..0000000000 --- a/js/token-interface/tests/e2e/load.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { ComputeBudgetProgram } from '@solana/web3.js'; -import { newAccountWithLamports } from '@lightprotocol/stateless.js'; -import { createLoadInstructions, getAta, getAtaAddress } from '../../src'; -import { - createMintFixture, - getCompressedAmounts, - getHotBalance, - mintCompressedToOwner, - sendInstructions, -} from './helpers'; - -describe('load instructions', () => { - it('returns no instructions when account has no hot or cold balance', async () => { - const fixture = await createMintFixture(); - const owner = await newAccountWithLamports(fixture.rpc, 1e9); - - const instructions = await createLoadInstructions({ - rpc: fixture.rpc, - payer: fixture.payer.publicKey, - owner: owner.publicKey, - mint: fixture.mint, - }); - - expect(instructions).toEqual([]); - }); - - it('getAta only exposes the biggest compressed balance and tracks the ignored ones', async () => { - const fixture = await createMintFixture(); - const owner = await newAccountWithLamports(fixture.rpc, 1e9); - - await mintCompressedToOwner(fixture, owner.publicKey, 400n); - await mintCompressedToOwner(fixture, owner.publicKey, 300n); - await mintCompressedToOwner(fixture, owner.publicKey, 200n); - - const account = await getAta({ - rpc: fixture.rpc, - owner: owner.publicKey, - mint: fixture.mint, - }); - - expect(account.parsed.amount).toBe(400n); - expect(account.compressedAmount).toBe(400n); - expect(account.requiresLoad).toBe(true); - expect(account.ignoredCompressedAccounts).toHaveLength(2); - expect(account.ignoredCompressedAmount).toBe(500n); - }); - - it('loads one compressed balance per call and leaves the smaller ones untouched', async () => { - const fixture = await createMintFixture(); - const owner = await newAccountWithLamports(fixture.rpc, 1e9); - const tokenAccount = getAtaAddress({ - owner: owner.publicKey, - mint: fixture.mint, - }); - - await mintCompressedToOwner(fixture, owner.publicKey, 500n); - await mintCompressedToOwner(fixture, owner.publicKey, 300n); - await mintCompressedToOwner(fixture, owner.publicKey, 200n); - - const firstInstructions = await createLoadInstructions({ - rpc: fixture.rpc, - payer: fixture.payer.publicKey, - owner: owner.publicKey, - mint: fixture.mint, - }); - - expect(firstInstructions.length).toBeGreaterThan(0); - expect( - firstInstructions.some(instruction => - instruction.programId.equals(ComputeBudgetProgram.programId), - ), - ).toBe(false); - - await sendInstructions(fixture.rpc, fixture.payer, firstInstructions, [ - owner, - ]); - - expect(await getHotBalance(fixture.rpc, tokenAccount)).toBe(500n); - expect( - await getCompressedAmounts( - fixture.rpc, - owner.publicKey, - fixture.mint, - ), - ).toEqual([300n, 200n]); - - const secondInstructions = await createLoadInstructions({ - rpc: fixture.rpc, - payer: fixture.payer.publicKey, - owner: owner.publicKey, - mint: fixture.mint, - }); - - await sendInstructions(fixture.rpc, fixture.payer, secondInstructions, [ - owner, - ]); - - expect(await getHotBalance(fixture.rpc, tokenAccount)).toBe(800n); - expect( - await getCompressedAmounts( - fixture.rpc, - owner.publicKey, - fixture.mint, - ), - ).toEqual([200n]); - }); - - it('defaults payer to owner when omitted', async () => { - const fixture = await createMintFixture(); - const owner = await newAccountWithLamports(fixture.rpc, 1e9); - const tokenAccount = getAtaAddress({ - owner: owner.publicKey, - mint: fixture.mint, - }); - - await mintCompressedToOwner(fixture, owner.publicKey, 250n); - - const instructions = await createLoadInstructions({ - rpc: fixture.rpc, - owner: owner.publicKey, - mint: fixture.mint, - }); - - await sendInstructions(fixture.rpc, fixture.payer, instructions, [ - owner, - ]); - - expect(await getHotBalance(fixture.rpc, tokenAccount)).toBe(250n); - }); -}); diff --git a/js/token-interface/tests/e2e/transfer.test.ts b/js/token-interface/tests/e2e/transfer.test.ts deleted file mode 100644 index ca9723257f..0000000000 --- a/js/token-interface/tests/e2e/transfer.test.ts +++ /dev/null @@ -1,551 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - ComputeBudgetProgram, - Keypair, - TransactionInstruction, -} from '@solana/web3.js'; -import { - createTransferCheckedInstruction as createSplTransferCheckedInstruction, - TOKEN_PROGRAM_ID, - TOKEN_2022_PROGRAM_ID, - getAssociatedTokenAddressSync, - unpackAccount, -} from '@solana/spl-token'; -import { newAccountWithLamports } from '@lightprotocol/stateless.js'; -import { - createApproveInstructions, - createTransferInstructionsNowrap, - createAtaInstructions, - createTransferInstructions, - getAta, - getAtaAddress, -} from '../../src'; -import { - createMintFixture, - getCompressedAmounts, - getHotBalance, - mintCompressedToOwner, - sendInstructions, - TEST_TOKEN_DECIMALS, -} from './helpers'; - -describe('transfer instructions', () => { - const isSplOrT22CloseInstruction = ( - instruction: TransactionInstruction, - ): boolean => - (instruction.programId.equals(TOKEN_PROGRAM_ID) || - instruction.programId.equals(TOKEN_2022_PROGRAM_ID)) && - instruction.data.length > 0 && - instruction.data[0] === 9; - - it('rejects transfer build for signer that is neither owner nor delegate', async () => { - const fixture = await createMintFixture(); - const owner = await newAccountWithLamports(fixture.rpc, 1e9); - const unauthorized = await newAccountWithLamports(fixture.rpc, 1e9); - const recipient = Keypair.generate(); - - await mintCompressedToOwner(fixture, owner.publicKey, 500n); - - await expect( - createTransferInstructions({ - rpc: fixture.rpc, - payer: fixture.payer.publicKey, - mint: fixture.mint, - sourceOwner: owner.publicKey, - authority: unauthorized.publicKey, - recipient: recipient.publicKey, - amount: 100n, - }), - ).rejects.toThrow( - 'Signer is not the owner or a delegate of the account.', - ); - }); - - it('builds a single-transaction transfer flow without compute budget instructions', async () => { - const fixture = await createMintFixture(); - const sender = await newAccountWithLamports(fixture.rpc, 1e9); - const recipient = Keypair.generate(); - - await mintCompressedToOwner(fixture, sender.publicKey, 5_000n); - - const instructions = await createTransferInstructions({ - rpc: fixture.rpc, - payer: fixture.payer.publicKey, - mint: fixture.mint, - sourceOwner: sender.publicKey, - authority: sender.publicKey, - recipient: recipient.publicKey, - amount: 2_000n, - }); - - expect(instructions.length).toBeGreaterThan(0); - expect( - instructions.some(instruction => - instruction.programId.equals(ComputeBudgetProgram.programId), - ), - ).toBe(false); - expect(instructions.some(isSplOrT22CloseInstruction)).toBe(false); - - await sendInstructions(fixture.rpc, fixture.payer, instructions, [ - sender, - ]); - - const recipientAta = await getAta({ - rpc: fixture.rpc, - owner: recipient.publicKey, - mint: fixture.mint, - }); - const senderAta = getAtaAddress({ - owner: sender.publicKey, - mint: fixture.mint, - }); - - expect(recipientAta.parsed.amount).toBe(2_000n); - expect(await getHotBalance(fixture.rpc, senderAta)).toBe(3_000n); - }); - - it('defaults payer to authority when omitted', async () => { - const fixture = await createMintFixture(); - const sender = await newAccountWithLamports(fixture.rpc, 1e9); - const recipient = Keypair.generate(); - - await mintCompressedToOwner(fixture, sender.publicKey, 1_000n); - - const instructions = await createTransferInstructions({ - rpc: fixture.rpc, - mint: fixture.mint, - sourceOwner: sender.publicKey, - authority: sender.publicKey, - recipient: recipient.publicKey, - amount: 400n, - }); - - await sendInstructions(fixture.rpc, fixture.payer, instructions, [ - sender, - ]); - - const recipientAta = await getAta({ - rpc: fixture.rpc, - owner: recipient.publicKey, - mint: fixture.mint, - }); - expect(recipientAta.parsed.amount).toBe(400n); - }); - - it('supports non-light destination path with SPL ATA recipient', async () => { - const fixture = await createMintFixture(); - const sender = await newAccountWithLamports(fixture.rpc, 1e9); - const recipient = await newAccountWithLamports(fixture.rpc, 1e9); - const recipientSplAta = getAssociatedTokenAddressSync( - fixture.mint, - recipient.publicKey, - false, - TOKEN_PROGRAM_ID, - ); - - await mintCompressedToOwner(fixture, sender.publicKey, 3_000n); - - const instructions = await createTransferInstructions({ - rpc: fixture.rpc, - payer: fixture.payer.publicKey, - mint: fixture.mint, - sourceOwner: sender.publicKey, - authority: sender.publicKey, - recipient: recipient.publicKey, - tokenProgram: TOKEN_PROGRAM_ID, - amount: 1_250n, - }); - - await sendInstructions(fixture.rpc, fixture.payer, instructions, [ - sender, - ]); - - const recipientSplInfo = - await fixture.rpc.getAccountInfo(recipientSplAta); - expect(recipientSplInfo).not.toBeNull(); - const recipientSpl = unpackAccount( - recipientSplAta, - recipientSplInfo!, - TOKEN_PROGRAM_ID, - ); - expect(recipientSpl.amount).toBe(1_250n); - }); - - it('passes through on-chain insufficient-funds error for transfer', async () => { - const fixture = await createMintFixture(); - const sender = await newAccountWithLamports(fixture.rpc, 1e9); - const recipient = Keypair.generate(); - - await mintCompressedToOwner(fixture, sender.publicKey, 500n); - await mintCompressedToOwner(fixture, sender.publicKey, 300n); - await mintCompressedToOwner(fixture, sender.publicKey, 200n); - - const instructions = await createTransferInstructions({ - rpc: fixture.rpc, - payer: fixture.payer.publicKey, - mint: fixture.mint, - sourceOwner: sender.publicKey, - authority: sender.publicKey, - recipient: recipient.publicKey, - amount: 600n, - }); - - await expect( - sendInstructions(fixture.rpc, fixture.payer, instructions, [ - sender, - ]), - ).rejects.toThrow('custom program error'); - }); - - it('does not pre-reject zero amount (on-chain behavior decides)', async () => { - const fixture = await createMintFixture(); - const sender = await newAccountWithLamports(fixture.rpc, 1e9); - const recipient = Keypair.generate(); - const senderAta = getAtaAddress({ - owner: sender.publicKey, - mint: fixture.mint, - }); - - await mintCompressedToOwner(fixture, sender.publicKey, 500n); - - const instructions = await createTransferInstructions({ - rpc: fixture.rpc, - payer: fixture.payer.publicKey, - mint: fixture.mint, - sourceOwner: sender.publicKey, - authority: sender.publicKey, - recipient: recipient.publicKey, - amount: 0n, - }); - - await sendInstructions(fixture.rpc, fixture.payer, instructions, [ - sender, - ]); - expect(await getHotBalance(fixture.rpc, senderAta)).toBe(500n); - }); - - it('does not load the recipient compressed balance yet', async () => { - const fixture = await createMintFixture(); - const sender = await newAccountWithLamports(fixture.rpc, 1e9); - const recipient = await newAccountWithLamports(fixture.rpc, 1e9); - const recipientAtaAddress = getAtaAddress({ - owner: recipient.publicKey, - mint: fixture.mint, - }); - - await mintCompressedToOwner(fixture, sender.publicKey, 400n); - await mintCompressedToOwner(fixture, recipient.publicKey, 300n); - - const instructions = await createTransferInstructions({ - rpc: fixture.rpc, - payer: fixture.payer.publicKey, - mint: fixture.mint, - sourceOwner: sender.publicKey, - authority: sender.publicKey, - recipient: recipient.publicKey, - amount: 200n, - }); - - await sendInstructions(fixture.rpc, fixture.payer, instructions, [ - sender, - ]); - - expect(await getHotBalance(fixture.rpc, recipientAtaAddress)).toBe( - 200n, - ); - expect( - await getCompressedAmounts( - fixture.rpc, - recipient.publicKey, - fixture.mint, - ), - ).toEqual([300n]); - - const recipientAta = await getAta({ - rpc: fixture.rpc, - owner: recipient.publicKey, - mint: fixture.mint, - }); - - expect(recipientAta.parsed.amount).toBe(500n); - expect(recipientAta.compressedAmount).toBe(300n); - }); - - it('supports delegated payments after approval', async () => { - const fixture = await createMintFixture(); - const owner = await newAccountWithLamports(fixture.rpc, 1e9); - const delegate = await newAccountWithLamports(fixture.rpc, 1e9); - const recipient = Keypair.generate(); - const ownerAta = getAtaAddress({ - owner: owner.publicKey, - mint: fixture.mint, - }); - - await mintCompressedToOwner(fixture, owner.publicKey, 500n); - - const approveInstructions = await createApproveInstructions({ - rpc: fixture.rpc, - payer: fixture.payer.publicKey, - owner: owner.publicKey, - mint: fixture.mint, - delegate: delegate.publicKey, - amount: 300n, - }); - - await sendInstructions( - fixture.rpc, - fixture.payer, - approveInstructions, - [owner], - ); - - const transferInstructions = await createTransferInstructions({ - rpc: fixture.rpc, - payer: fixture.payer.publicKey, - mint: fixture.mint, - sourceOwner: owner.publicKey, - recipient: recipient.publicKey, - amount: 250n, - authority: delegate.publicKey, - }); - - await sendInstructions( - fixture.rpc, - fixture.payer, - transferInstructions, - [delegate], - ); - - const recipientAta = await getAta({ - rpc: fixture.rpc, - owner: recipient.publicKey, - mint: fixture.mint, - }); - - expect(recipientAta.parsed.amount).toBe(250n); - expect(await getHotBalance(fixture.rpc, ownerAta)).toBe(250n); - }); - - it('rejects delegated transfer above delegated allowance', async () => { - const fixture = await createMintFixture(); - const owner = await newAccountWithLamports(fixture.rpc, 1e9); - const delegate = await newAccountWithLamports(fixture.rpc, 1e9); - const recipient = Keypair.generate(); - - await mintCompressedToOwner(fixture, owner.publicKey, 500n); - - const approveInstructions = await createApproveInstructions({ - rpc: fixture.rpc, - payer: fixture.payer.publicKey, - owner: owner.publicKey, - mint: fixture.mint, - delegate: delegate.publicKey, - amount: 100n, - }); - await sendInstructions( - fixture.rpc, - fixture.payer, - approveInstructions, - [owner], - ); - - const transferInstructions = await createTransferInstructions({ - rpc: fixture.rpc, - payer: fixture.payer.publicKey, - mint: fixture.mint, - sourceOwner: owner.publicKey, - recipient: recipient.publicKey, - amount: 150n, - authority: delegate.publicKey, - }); - - await expect( - sendInstructions(fixture.rpc, fixture.payer, transferInstructions, [ - delegate, - ]), - ).rejects.toThrow('custom program error'); - }); - - it('nowrap path fails when balance exists only in SPL ATA, canonical path succeeds', async () => { - const fixture = await createMintFixture(); - const sender = await newAccountWithLamports(fixture.rpc, 1e9); - const recipient = Keypair.generate(); - const senderSplAta = getAssociatedTokenAddressSync( - fixture.mint, - sender.publicKey, - false, - TOKEN_PROGRAM_ID, - ); - - await mintCompressedToOwner(fixture, sender.publicKey, 2_000n); - - // Stage funds into sender SPL ATA. - const toSenderSplInstructions = await createTransferInstructions({ - rpc: fixture.rpc, - payer: fixture.payer.publicKey, - mint: fixture.mint, - sourceOwner: sender.publicKey, - authority: sender.publicKey, - recipient: sender.publicKey, - tokenProgram: TOKEN_PROGRAM_ID, - amount: 1_500n, - }); - await sendInstructions( - fixture.rpc, - fixture.payer, - toSenderSplInstructions, - [sender], - ); - - const senderSplInfo = await fixture.rpc.getAccountInfo(senderSplAta); - expect(senderSplInfo).not.toBeNull(); - const senderSpl = unpackAccount( - senderSplAta, - senderSplInfo!, - TOKEN_PROGRAM_ID, - ); - expect(senderSpl.amount).toBe(1_500n); - - // Nowrap does not wrap SPL/T22 balances, so transfer should fail. - const nowrapInstructions = await createTransferInstructionsNowrap({ - rpc: fixture.rpc, - payer: fixture.payer.publicKey, - mint: fixture.mint, - sourceOwner: sender.publicKey, - authority: sender.publicKey, - recipient: recipient.publicKey, - amount: 1_000n, - }); - await expect( - sendInstructions(fixture.rpc, fixture.payer, nowrapInstructions, [ - sender, - ]), - ).rejects.toThrow('custom program error'); - - // Canonical transfer wraps SPL first, then succeeds. - const canonicalInstructions = await createTransferInstructions({ - rpc: fixture.rpc, - payer: fixture.payer.publicKey, - mint: fixture.mint, - sourceOwner: sender.publicKey, - authority: sender.publicKey, - recipient: recipient.publicKey, - amount: 1_000n, - }); - await sendInstructions( - fixture.rpc, - fixture.payer, - canonicalInstructions, - [sender], - ); - - const recipientAta = await getAta({ - rpc: fixture.rpc, - owner: recipient.publicKey, - mint: fixture.mint, - }); - expect(recipientAta.parsed.amount).toBe(1_000n); - }); - - it('wrapped transfer uses build-time SPL balance and leaves post-build SPL top-up as remainder', async () => { - const fixture = await createMintFixture(); - const sender = await newAccountWithLamports(fixture.rpc, 1e9); - const donor = await newAccountWithLamports(fixture.rpc, 1e9); - const recipient = Keypair.generate(); - const senderSplAta = getAssociatedTokenAddressSync( - fixture.mint, - sender.publicKey, - false, - TOKEN_PROGRAM_ID, - ); - const donorSplAta = getAssociatedTokenAddressSync( - fixture.mint, - donor.publicKey, - false, - TOKEN_PROGRAM_ID, - ); - - await mintCompressedToOwner(fixture, sender.publicKey, 3_000n); - await mintCompressedToOwner(fixture, donor.publicKey, 1_000n); - - // Stage 1: move 1_000 into sender SPL ATA. - const senderToSpl = await createTransferInstructions({ - rpc: fixture.rpc, - payer: fixture.payer.publicKey, - mint: fixture.mint, - sourceOwner: sender.publicKey, - authority: sender.publicKey, - recipient: sender.publicKey, - tokenProgram: TOKEN_PROGRAM_ID, - amount: 1_000n, - }); - await sendInstructions(fixture.rpc, fixture.payer, senderToSpl, [ - sender, - ]); - - // Stage 2: fund donor SPL ATA with 500. - const donorToSpl = await createTransferInstructions({ - rpc: fixture.rpc, - payer: fixture.payer.publicKey, - mint: fixture.mint, - sourceOwner: donor.publicKey, - authority: donor.publicKey, - recipient: donor.publicKey, - tokenProgram: TOKEN_PROGRAM_ID, - amount: 500n, - }); - await sendInstructions(fixture.rpc, fixture.payer, donorToSpl, [donor]); - - // Build wrapped transfer now (captures sender SPL balance = 1_000). - const wrappedTransfer = await createTransferInstructions({ - rpc: fixture.rpc, - payer: fixture.payer.publicKey, - mint: fixture.mint, - sourceOwner: sender.publicKey, - authority: sender.publicKey, - recipient: recipient.publicKey, - amount: 800n, - }); - - // Race injection: send +300 to sender SPL ATA AFTER wrapped tx is built. - const injectAfterBuild = createSplTransferCheckedInstruction( - donorSplAta, - fixture.mint, - senderSplAta, - donor.publicKey, - 300n, - TEST_TOKEN_DECIMALS, - [], - TOKEN_PROGRAM_ID, - ); - await sendInstructions( - fixture.rpc, - fixture.payer, - [injectAfterBuild], - [donor], - ); - - // Wrapped transfer should still succeed. - await sendInstructions(fixture.rpc, fixture.payer, wrappedTransfer, [ - sender, - ]); - - // Recipient receives transfer amount. - const recipientAta = await getAta({ - rpc: fixture.rpc, - owner: recipient.publicKey, - mint: fixture.mint, - }); - expect(recipientAta.parsed.amount).toBe(800n); - - // Sender SPL ATA keeps the post-build top-up remainder (300). - const senderSplInfo = await fixture.rpc.getAccountInfo(senderSplAta); - expect(senderSplInfo).not.toBeNull(); - const senderSpl = unpackAccount( - senderSplAta, - senderSplInfo!, - TOKEN_PROGRAM_ID, - ); - expect(senderSpl.amount).toBe(300n); - }); -}); diff --git a/js/token-interface/tests/step-definitions/e2e/approve-revoke.steps.ts b/js/token-interface/tests/step-definitions/e2e/approve-revoke.steps.ts new file mode 100644 index 0000000000..5b0f1e33d5 --- /dev/null +++ b/js/token-interface/tests/step-definitions/e2e/approve-revoke.steps.ts @@ -0,0 +1,127 @@ +import assert from 'node:assert/strict'; +import { When, Then } from '@cucumber/cucumber'; +import { ComputeBudgetProgram } from '@solana/web3.js'; +import { + createApproveInstructions, + createRevokeInstructions, + getAtaAddress, +} from '../../../src/index.js'; +import { getHotDelegate, sendInstructions } from '../../e2e/helpers.js'; +import type { TokenInterfaceWorld } from '../../support/world.js'; + +When( + 'the owner approves the delegate for {int} tokens', + async function (this: TokenInterfaceWorld, amount: number) { + assert.ok(this.fixture, 'fixture must be created first'); + assert.ok(this.owner, 'owner must be created first'); + assert.ok(this.delegate, 'delegate must be created first'); + + const instructions = await createApproveInstructions({ + rpc: this.fixture.rpc, + payer: this.fixture.payer.publicKey, + owner: this.owner.publicKey, + mint: this.fixture.mint, + delegate: this.delegate.publicKey, + amount: BigInt(amount), + }); + + this.lastApproveInstructions = instructions; + + await sendInstructions( + this.fixture.rpc, + this.fixture.payer, + instructions, + [this.owner], + ); + }, +); + +Then( + 'the delegate is set on the token account with amount {int}', + async function (this: TokenInterfaceWorld, expectedAmount: number) { + assert.ok(this.fixture, 'fixture must be created first'); + assert.ok(this.owner, 'owner must be created first'); + assert.ok(this.delegate, 'delegate must be created first'); + + const tokenAccount = getAtaAddress({ + owner: this.owner.publicKey, + mint: this.fixture.mint, + }); + + const delegateInfo = await getHotDelegate( + this.fixture.rpc, + tokenAccount, + ); + + assert.strictEqual( + delegateInfo.delegate?.toBase58(), + this.delegate.publicKey.toBase58(), + ); + assert.strictEqual(delegateInfo.delegatedAmount, BigInt(expectedAmount)); + }, +); + +Then( + 'no compute budget instructions were included', + function (this: TokenInterfaceWorld) { + const hasComputeBudget = this.lastApproveInstructions.some((ix) => + ix.programId.equals(ComputeBudgetProgram.programId), + ); + assert.strictEqual(hasComputeBudget, false); + }, +); + +When( + 'the owner revokes the delegation', + async function (this: TokenInterfaceWorld) { + assert.ok(this.fixture, 'fixture must be created first'); + assert.ok(this.owner, 'owner must be created first'); + + const instructions = await createRevokeInstructions({ + rpc: this.fixture.rpc, + payer: this.fixture.payer.publicKey, + owner: this.owner.publicKey, + mint: this.fixture.mint, + }); + + this.lastRevokeInstructions = instructions; + + await sendInstructions( + this.fixture.rpc, + this.fixture.payer, + instructions, + [this.owner], + ); + }, +); + +Then( + 'the delegate is cleared and delegated amount is 0', + async function (this: TokenInterfaceWorld) { + assert.ok(this.fixture, 'fixture must be created first'); + assert.ok(this.owner, 'owner must be created first'); + + const tokenAccount = getAtaAddress({ + owner: this.owner.publicKey, + mint: this.fixture.mint, + }); + + const delegateInfo = await getHotDelegate( + this.fixture.rpc, + tokenAccount, + ); + + assert.strictEqual(delegateInfo.delegate, null); + assert.strictEqual(delegateInfo.delegatedAmount, BigInt(0)); + }, +); + +Then( + 'no compute budget instructions were included in revoke', + function (this: TokenInterfaceWorld) { + const hasComputeBudget = this.lastRevokeInstructions.some((ix) => + ix.programId.equals(ComputeBudgetProgram.programId), + ); + assert.strictEqual(hasComputeBudget, false); + }, +); diff --git a/js/token-interface/tests/step-definitions/e2e/ata-read.steps.ts b/js/token-interface/tests/step-definitions/e2e/ata-read.steps.ts new file mode 100644 index 0000000000..14d40d2431 --- /dev/null +++ b/js/token-interface/tests/step-definitions/e2e/ata-read.steps.ts @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict'; +import { When, Then } from '@cucumber/cucumber'; +import { + createAtaInstructions, + getAta, + getAtaAddress, +} from '../../../src/index.js'; +import { sendInstructions } from '../../e2e/helpers.js'; +import type { TokenInterfaceWorld } from '../../support/world.js'; + +When( + 'the owner creates an ATA', + async function (this: TokenInterfaceWorld) { + assert.ok(this.fixture, 'fixture must be created first'); + assert.ok(this.owner, 'owner must be created first'); + + const instructions = await createAtaInstructions({ + payer: this.fixture.payer.publicKey, + owner: this.owner.publicKey, + mint: this.fixture.mint, + }); + + await sendInstructions( + this.fixture.rpc, + this.fixture.payer, + instructions, + ); + }, +); + +Then( + 'reading the ATA returns the correct address, owner, mint, and zero balance', + async function (this: TokenInterfaceWorld) { + assert.ok(this.fixture, 'fixture must be created first'); + assert.ok(this.owner, 'owner must be created first'); + + const expectedAddress = getAtaAddress({ + owner: this.owner.publicKey, + mint: this.fixture.mint, + }); + + const account = await getAta({ + rpc: this.fixture.rpc, + owner: this.owner.publicKey, + mint: this.fixture.mint, + }); + + assert.strictEqual( + account.parsed.address.toBase58(), + expectedAddress.toBase58(), + ); + assert.strictEqual( + account.parsed.owner.toBase58(), + this.owner.publicKey.toBase58(), + ); + assert.strictEqual( + account.parsed.mint.toBase58(), + this.fixture.mint.toBase58(), + ); + assert.strictEqual(account.parsed.amount, BigInt(0)); + }, +); diff --git a/js/token-interface/tests/step-definitions/e2e/common.steps.ts b/js/token-interface/tests/step-definitions/e2e/common.steps.ts new file mode 100644 index 0000000000..cc14c32555 --- /dev/null +++ b/js/token-interface/tests/step-definitions/e2e/common.steps.ts @@ -0,0 +1,200 @@ +import assert from 'node:assert/strict'; +import { Given } from '@cucumber/cucumber'; +import { Keypair } from '@solana/web3.js'; +import { newAccountWithLamports } from '@lightprotocol/stateless.js'; +import { + createAtaInstructions, + createApproveInstructions, + getAtaAddress, +} from '../../../src/index.js'; +import { + createMintFixture, + mintCompressedToOwner, + sendInstructions, +} from '../../e2e/helpers.js'; +import type { TokenInterfaceWorld } from '../../support/world.js'; + +Given( + 'a fresh mint fixture', + async function (this: TokenInterfaceWorld) { + this.fixture = await createMintFixture(); + }, +); + +Given( + 'a fresh mint fixture with freeze authority', + async function (this: TokenInterfaceWorld) { + this.fixture = await createMintFixture({ withFreezeAuthority: true }); + }, +); + +Given( + 'a new owner', + async function (this: TokenInterfaceWorld) { + assert.ok(this.fixture, 'fixture must be created first'); + this.owner = await newAccountWithLamports(this.fixture.rpc, 1e9); + }, +); + +Given( + 'a new recipient', + function (this: TokenInterfaceWorld) { + this.recipient = Keypair.generate(); + }, +); + +Given( + 'a new delegate', + function (this: TokenInterfaceWorld) { + this.delegate = Keypair.generate(); + }, +); + +Given( + 'a funded sender with {int} compressed tokens', + async function (this: TokenInterfaceWorld, amount: number) { + assert.ok(this.fixture, 'fixture must be created first'); + this.sender = await newAccountWithLamports(this.fixture.rpc, 1e9); + await mintCompressedToOwner( + this.fixture, + this.sender.publicKey, + BigInt(amount), + ); + }, +); + +Given( + 'a funded recipient with SOL', + async function (this: TokenInterfaceWorld) { + assert.ok(this.fixture, 'fixture must be created first'); + this.recipient = await newAccountWithLamports(this.fixture.rpc, 1e9); + }, +); + +Given( + 'a funded recipient with {int} compressed tokens', + async function (this: TokenInterfaceWorld, amount: number) { + assert.ok(this.fixture, 'fixture must be created first'); + this.recipient = await newAccountWithLamports(this.fixture.rpc, 1e9); + await mintCompressedToOwner( + this.fixture, + this.recipient.publicKey, + BigInt(amount), + ); + }, +); + +Given( + 'an owner with {int} compressed tokens', + async function (this: TokenInterfaceWorld, amount: number) { + assert.ok(this.fixture, 'fixture must be created first'); + this.owner = await newAccountWithLamports(this.fixture.rpc, 1e9); + await mintCompressedToOwner( + this.fixture, + this.owner.publicKey, + BigInt(amount), + ); + }, +); + +Given( + 'an owner with a created ATA and {int} compressed tokens', + async function (this: TokenInterfaceWorld, amount: number) { + assert.ok(this.fixture, 'fixture must be created first'); + this.owner = await newAccountWithLamports(this.fixture.rpc, 1e9); + + const ataIxs = await createAtaInstructions({ + payer: this.fixture.payer.publicKey, + owner: this.owner.publicKey, + mint: this.fixture.mint, + }); + await sendInstructions(this.fixture.rpc, this.fixture.payer, ataIxs); + + await mintCompressedToOwner( + this.fixture, + this.owner.publicKey, + BigInt(amount), + ); + }, +); + +Given( + 'an owner with compressed mints of {int}, {int}, and {int} tokens', + async function ( + this: TokenInterfaceWorld, + amount1: number, + amount2: number, + amount3: number, + ) { + assert.ok(this.fixture, 'fixture must be created first'); + this.owner = await newAccountWithLamports(this.fixture.rpc, 1e9); + await mintCompressedToOwner( + this.fixture, + this.owner.publicKey, + BigInt(amount1), + ); + await mintCompressedToOwner( + this.fixture, + this.owner.publicKey, + BigInt(amount2), + ); + await mintCompressedToOwner( + this.fixture, + this.owner.publicKey, + BigInt(amount3), + ); + }, +); + +Given( + 'a sender with compressed mints of {int}, {int}, and {int} tokens', + async function ( + this: TokenInterfaceWorld, + amount1: number, + amount2: number, + amount3: number, + ) { + assert.ok(this.fixture, 'fixture must be created first'); + this.sender = await newAccountWithLamports(this.fixture.rpc, 1e9); + await mintCompressedToOwner( + this.fixture, + this.sender.publicKey, + BigInt(amount1), + ); + await mintCompressedToOwner( + this.fixture, + this.sender.publicKey, + BigInt(amount2), + ); + await mintCompressedToOwner( + this.fixture, + this.sender.publicKey, + BigInt(amount3), + ); + }, +); + +Given( + 'a delegate approved for {int} tokens', + async function (this: TokenInterfaceWorld, amount: number) { + assert.ok(this.fixture, 'fixture must be created first'); + assert.ok(this.owner, 'owner must be created first'); + this.delegate = await newAccountWithLamports(this.fixture.rpc, 1e9); + + const approveIxs = await createApproveInstructions({ + rpc: this.fixture.rpc, + payer: this.fixture.payer.publicKey, + owner: this.owner.publicKey, + mint: this.fixture.mint, + delegate: this.delegate.publicKey, + amount: BigInt(amount), + }); + + await sendInstructions( + this.fixture.rpc, + this.fixture.payer, + approveIxs, + [this.owner], + ); + }, +); diff --git a/js/token-interface/tests/step-definitions/e2e/freeze-thaw.steps.ts b/js/token-interface/tests/step-definitions/e2e/freeze-thaw.steps.ts new file mode 100644 index 0000000000..606b6bd153 --- /dev/null +++ b/js/token-interface/tests/step-definitions/e2e/freeze-thaw.steps.ts @@ -0,0 +1,90 @@ +import assert from 'node:assert/strict'; +import { When, Then } from '@cucumber/cucumber'; +import { AccountState } from '@solana/spl-token'; +import { + createFreezeInstructions, + createThawInstructions, + getAtaAddress, +} from '../../../src/index.js'; +import { getHotState, sendInstructions } from '../../e2e/helpers.js'; +import type { TokenInterfaceWorld } from '../../support/world.js'; + +When( + 'the freeze authority freezes the account', + async function (this: TokenInterfaceWorld) { + assert.ok(this.fixture, 'fixture must be created first'); + assert.ok(this.owner, 'owner must be created first'); + assert.ok( + this.fixture.freezeAuthority, + 'freeze authority must exist on fixture', + ); + + const instructions = await createFreezeInstructions({ + rpc: this.fixture.rpc, + payer: this.fixture.payer.publicKey, + owner: this.owner.publicKey, + mint: this.fixture.mint, + freezeAuthority: this.fixture.freezeAuthority.publicKey, + }); + + await sendInstructions( + this.fixture.rpc, + this.fixture.payer, + instructions, + [this.owner, this.fixture.freezeAuthority], + ); + }, +); + +Then( + 'the account state is {string}', + async function (this: TokenInterfaceWorld, expectedState: string) { + assert.ok(this.fixture, 'fixture must be created first'); + assert.ok(this.owner, 'owner must be created first'); + + const tokenAccount = getAtaAddress({ + owner: this.owner.publicKey, + mint: this.fixture.mint, + }); + + const state = await getHotState(this.fixture.rpc, tokenAccount); + + const stateMap: Record = { + Frozen: AccountState.Frozen, + Initialized: AccountState.Initialized, + Uninitialized: AccountState.Uninitialized, + }; + + assert.strictEqual(state, stateMap[expectedState]); + }, +); + +When( + 'the freeze authority thaws the account', + async function (this: TokenInterfaceWorld) { + assert.ok(this.fixture, 'fixture must be created first'); + assert.ok(this.owner, 'owner must be created first'); + assert.ok( + this.fixture.freezeAuthority, + 'freeze authority must exist on fixture', + ); + + const instructions = await createThawInstructions({ + rpc: this.fixture.rpc, + payer: this.fixture.payer.publicKey, + owner: this.owner.publicKey, + mint: this.fixture.mint, + freezeAuthority: this.fixture.freezeAuthority.publicKey, + }); + + // Thaw only needs freezeAuthority as signer. The account is frozen + // so createLoadInstructions inside createThawInstructions produces + // no load instructions (nothing to load into a frozen account). + await sendInstructions( + this.fixture.rpc, + this.fixture.payer, + instructions, + [this.fixture.freezeAuthority], + ); + }, +); diff --git a/js/token-interface/tests/step-definitions/e2e/load.steps.ts b/js/token-interface/tests/step-definitions/e2e/load.steps.ts new file mode 100644 index 0000000000..ae065f74d7 --- /dev/null +++ b/js/token-interface/tests/step-definitions/e2e/load.steps.ts @@ -0,0 +1,169 @@ +import assert from 'node:assert/strict'; +import { When, Then } from '@cucumber/cucumber'; +import { + createLoadInstructions, + getAta, + getAtaAddress, +} from '../../../src/index.js'; +import { + getCompressedAmounts, + getHotBalance, + sendInstructions, +} from '../../e2e/helpers.js'; +import type { TokenInterfaceWorld } from '../../support/world.js'; + +When( + 'I read the owner ATA', + async function (this: TokenInterfaceWorld) { + assert.ok(this.fixture, 'fixture must be created first'); + assert.ok(this.owner, 'owner must be created first'); + + this.resultAccount = await getAta({ + rpc: this.fixture.rpc, + owner: this.owner.publicKey, + mint: this.fixture.mint, + }); + }, +); + +Then( + 'the ATA amount is {int}', + function (this: TokenInterfaceWorld, expected: number) { + assert.ok(this.resultAccount, 'resultAccount must be set'); + assert.strictEqual(this.resultAccount.parsed.amount, BigInt(expected)); + }, +); + +Then( + 'the ATA compressed amount is {int}', + function (this: TokenInterfaceWorld, expected: number) { + assert.ok(this.resultAccount, 'resultAccount must be set'); + assert.strictEqual( + this.resultAccount.compressedAmount, + BigInt(expected), + ); + }, +); + +Then( + 'the ATA requires load', + function (this: TokenInterfaceWorld) { + assert.ok(this.resultAccount, 'resultAccount must be set'); + assert.strictEqual(this.resultAccount.requiresLoad, true); + }, +); + +Then( + 'there are {int} ignored compressed accounts totaling {int}', + function ( + this: TokenInterfaceWorld, + count: number, + totalIgnored: number, + ) { + assert.ok(this.resultAccount, 'resultAccount must be set'); + assert.strictEqual( + this.resultAccount.ignoredCompressedAccounts.length, + count, + ); + assert.strictEqual( + this.resultAccount.ignoredCompressedAmount, + BigInt(totalIgnored), + ); + }, +); + +When( + 'I load the first compressed balance', + async function (this: TokenInterfaceWorld) { + assert.ok(this.fixture, 'fixture must be created first'); + assert.ok(this.owner, 'owner must be created first'); + + const instructions = await createLoadInstructions({ + rpc: this.fixture.rpc, + payer: this.fixture.payer.publicKey, + owner: this.owner.publicKey, + mint: this.fixture.mint, + }); + + await sendInstructions( + this.fixture.rpc, + this.fixture.payer, + instructions, + [this.owner], + ); + }, +); + +Then( + 'the hot balance is {int}', + async function (this: TokenInterfaceWorld, expected: number) { + assert.ok(this.fixture, 'fixture must be created first'); + assert.ok(this.owner, 'owner must be created first'); + + const tokenAccount = getAtaAddress({ + owner: this.owner.publicKey, + mint: this.fixture.mint, + }); + + const balance = await getHotBalance(this.fixture.rpc, tokenAccount); + assert.strictEqual(balance, BigInt(expected)); + }, +); + +Then( + 'compressed accounts are {int} and {int}', + async function ( + this: TokenInterfaceWorld, + amount1: number, + amount2: number, + ) { + assert.ok(this.fixture, 'fixture must be created first'); + assert.ok(this.owner, 'owner must be created first'); + + const amounts = await getCompressedAmounts( + this.fixture.rpc, + this.owner.publicKey, + this.fixture.mint, + ); + + assert.deepStrictEqual(amounts, [BigInt(amount1), BigInt(amount2)]); + }, +); + +When( + 'I load the next compressed balance', + async function (this: TokenInterfaceWorld) { + assert.ok(this.fixture, 'fixture must be created first'); + assert.ok(this.owner, 'owner must be created first'); + + const instructions = await createLoadInstructions({ + rpc: this.fixture.rpc, + payer: this.fixture.payer.publicKey, + owner: this.owner.publicKey, + mint: this.fixture.mint, + }); + + await sendInstructions( + this.fixture.rpc, + this.fixture.payer, + instructions, + [this.owner], + ); + }, +); + +Then( + 'compressed accounts are {int}', + async function (this: TokenInterfaceWorld, amount: number) { + assert.ok(this.fixture, 'fixture must be created first'); + assert.ok(this.owner, 'owner must be created first'); + + const amounts = await getCompressedAmounts( + this.fixture.rpc, + this.owner.publicKey, + this.fixture.mint, + ); + + assert.deepStrictEqual(amounts, [BigInt(amount)]); + }, +); diff --git a/js/token-interface/tests/step-definitions/e2e/transfer.steps.ts b/js/token-interface/tests/step-definitions/e2e/transfer.steps.ts new file mode 100644 index 0000000000..f95f91b02d --- /dev/null +++ b/js/token-interface/tests/step-definitions/e2e/transfer.steps.ts @@ -0,0 +1,275 @@ +import assert from 'node:assert/strict'; +import { When, Then } from '@cucumber/cucumber'; +import { Keypair } from '@solana/web3.js'; +import { + TOKEN_PROGRAM_ID, + getAssociatedTokenAddressSync, + unpackAccount, +} from '@solana/spl-token'; +import { + createTransferInstructions, + getAta, + getAtaAddress, +} from '../../../src/index.js'; +import { + getCompressedAmounts, + getHotBalance, + sendInstructions, +} from '../../e2e/helpers.js'; +import type { TokenInterfaceWorld } from '../../support/world.js'; + +When( + 'the sender transfers {int} tokens to the recipient', + async function (this: TokenInterfaceWorld, amount: number) { + assert.ok(this.fixture, 'fixture must be created first'); + assert.ok(this.sender, 'sender must be created first'); + assert.ok(this.recipient, 'recipient must be created first'); + + const instructions = await createTransferInstructions({ + rpc: this.fixture.rpc, + payer: this.fixture.payer.publicKey, + mint: this.fixture.mint, + sourceOwner: this.sender.publicKey, + authority: this.sender.publicKey, + recipient: this.recipient.publicKey, + amount: BigInt(amount), + }); + + await sendInstructions( + this.fixture.rpc, + this.fixture.payer, + instructions, + [this.sender], + ); + }, +); + +Then( + 'the recipient ATA balance is {int}', + async function (this: TokenInterfaceWorld, expected: number) { + assert.ok(this.fixture, 'fixture must be created first'); + assert.ok(this.recipient, 'recipient must be created first'); + + const account = await getAta({ + rpc: this.fixture.rpc, + owner: this.recipient.publicKey, + mint: this.fixture.mint, + }); + + assert.strictEqual(account.parsed.amount, BigInt(expected)); + }, +); + +Then( + 'the sender ATA balance is {int}', + async function (this: TokenInterfaceWorld, expected: number) { + assert.ok(this.fixture, 'fixture must be created first'); + assert.ok(this.sender, 'sender must be created first'); + + const senderAta = getAtaAddress({ + owner: this.sender.publicKey, + mint: this.fixture.mint, + }); + + const balance = await getHotBalance(this.fixture.rpc, senderAta); + assert.strictEqual(balance, BigInt(expected)); + }, +); + +When( + 'the sender transfers {int} tokens to the recipient SPL ATA', + async function (this: TokenInterfaceWorld, amount: number) { + assert.ok(this.fixture, 'fixture must be created first'); + assert.ok(this.sender, 'sender must be created first'); + assert.ok(this.recipient, 'recipient must be created first'); + + const instructions = await createTransferInstructions({ + rpc: this.fixture.rpc, + payer: this.fixture.payer.publicKey, + mint: this.fixture.mint, + sourceOwner: this.sender.publicKey, + authority: this.sender.publicKey, + recipient: this.recipient.publicKey, + tokenProgram: TOKEN_PROGRAM_ID, + amount: BigInt(amount), + }); + + await sendInstructions( + this.fixture.rpc, + this.fixture.payer, + instructions, + [this.sender], + ); + }, +); + +Then( + 'the recipient SPL ATA balance is {int}', + async function (this: TokenInterfaceWorld, expected: number) { + assert.ok(this.fixture, 'fixture must be created first'); + assert.ok(this.recipient, 'recipient must be created first'); + + const recipientSplAta = getAssociatedTokenAddressSync( + this.fixture.mint, + this.recipient.publicKey, + false, + TOKEN_PROGRAM_ID, + ); + + const recipientSplInfo = await this.fixture.rpc.getAccountInfo( + recipientSplAta, + ); + assert.ok(recipientSplInfo, 'SPL ATA account should exist'); + + const recipientSpl = unpackAccount( + recipientSplAta, + recipientSplInfo, + TOKEN_PROGRAM_ID, + ); + assert.strictEqual(recipientSpl.amount, BigInt(expected)); + }, +); + +When( + 'the sender attempts to transfer {int} tokens', + async function (this: TokenInterfaceWorld, amount: number) { + assert.ok(this.fixture, 'fixture must be created first'); + assert.ok(this.sender, 'sender must be created first'); + assert.ok(this.recipient, 'recipient must be created first'); + + try { + const instructions = await createTransferInstructions({ + rpc: this.fixture.rpc, + payer: this.fixture.payer.publicKey, + mint: this.fixture.mint, + sourceOwner: this.sender.publicKey, + authority: this.sender.publicKey, + recipient: this.recipient.publicKey, + amount: BigInt(amount), + }); + + await sendInstructions( + this.fixture.rpc, + this.fixture.payer, + instructions, + [this.sender], + ); + } catch (error) { + this.resultError = error as Error; + } + }, +); + +Then( + 'the transaction fails with {string}', + function (this: TokenInterfaceWorld, expectedMessage: string) { + assert.ok(this.resultError, 'an error should have been thrown'); + assert.ok( + this.resultError.message.includes(expectedMessage), + `expected error message to contain "${expectedMessage}", got: "${this.resultError.message}"`, + ); + }, +); + +Then( + 'the recipient hot balance is {int}', + async function (this: TokenInterfaceWorld, expected: number) { + assert.ok(this.fixture, 'fixture must be created first'); + assert.ok(this.recipient, 'recipient must be created first'); + + const recipientAtaAddress = getAtaAddress({ + owner: this.recipient.publicKey, + mint: this.fixture.mint, + }); + + const balance = await getHotBalance( + this.fixture.rpc, + recipientAtaAddress, + ); + assert.strictEqual(balance, BigInt(expected)); + }, +); + +Then( + 'the recipient still has {int} in compressed accounts', + async function (this: TokenInterfaceWorld, expected: number) { + assert.ok(this.fixture, 'fixture must be created first'); + assert.ok(this.recipient, 'recipient must be created first'); + + const amounts = await getCompressedAmounts( + this.fixture.rpc, + this.recipient.publicKey, + this.fixture.mint, + ); + + assert.deepStrictEqual(amounts, [BigInt(expected)]); + }, +); + +Then( + 'the recipient total ATA amount is {int} with {int} compressed', + async function ( + this: TokenInterfaceWorld, + totalAmount: number, + compressedAmount: number, + ) { + assert.ok(this.fixture, 'fixture must be created first'); + assert.ok(this.recipient, 'recipient must be created first'); + + const recipientAta = await getAta({ + rpc: this.fixture.rpc, + owner: this.recipient.publicKey, + mint: this.fixture.mint, + }); + + assert.strictEqual(recipientAta.parsed.amount, BigInt(totalAmount)); + assert.strictEqual( + recipientAta.compressedAmount, + BigInt(compressedAmount), + ); + }, +); + +When( + 'the delegate transfers {int} tokens to a new recipient', + async function (this: TokenInterfaceWorld, amount: number) { + assert.ok(this.fixture, 'fixture must be created first'); + assert.ok(this.owner, 'owner must be created first'); + assert.ok(this.delegate, 'delegate must be created first'); + + this.recipient = Keypair.generate(); + + const instructions = await createTransferInstructions({ + rpc: this.fixture.rpc, + payer: this.fixture.payer.publicKey, + mint: this.fixture.mint, + sourceOwner: this.owner.publicKey, + authority: this.delegate.publicKey, + recipient: this.recipient.publicKey, + amount: BigInt(amount), + }); + + await sendInstructions( + this.fixture.rpc, + this.fixture.payer, + instructions, + [this.delegate], + ); + }, +); + +Then( + 'the owner ATA balance is {int}', + async function (this: TokenInterfaceWorld, expected: number) { + assert.ok(this.fixture, 'fixture must be created first'); + assert.ok(this.owner, 'owner must be created first'); + + const ownerAta = getAtaAddress({ + owner: this.owner.publicKey, + mint: this.fixture.mint, + }); + + const balance = await getHotBalance(this.fixture.rpc, ownerAta); + assert.strictEqual(balance, BigInt(expected)); + }, +); diff --git a/js/token-interface/tests/step-definitions/unit/instruction-builders.steps.ts b/js/token-interface/tests/step-definitions/unit/instruction-builders.steps.ts new file mode 100644 index 0000000000..35d0278e5c --- /dev/null +++ b/js/token-interface/tests/step-definitions/unit/instruction-builders.steps.ts @@ -0,0 +1,129 @@ +import assert from 'node:assert/strict'; +import { Given, When, Then } from '@cucumber/cucumber'; +import { Keypair } from '@solana/web3.js'; +import { LIGHT_TOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; +import { + createApproveInstruction, + createAtaInstruction, + createFreezeInstruction, + createRevokeInstruction, + createThawInstruction, + createTransferCheckedInstruction, +} from '../../../src/instructions/index.js'; +import type { TokenInterfaceWorld } from '../../support/world.js'; + +Given( + 'random keypairs for {string}, {string}, {string}, {string}, and {string}', + function ( + this: TokenInterfaceWorld, + n1: string, + n2: string, + n3: string, + n4: string, + n5: string, + ) { + for (const name of [n1, n2, n3, n4, n5]) { + this.keypairs[name] = Keypair.generate().publicKey; + } + }, +); + +When( + 'I build a create-ATA instruction for {string}, {string}, and {string}', + function ( + this: TokenInterfaceWorld, + payerKey: string, + ownerKey: string, + mintKey: string, + ) { + this.instruction = createAtaInstruction({ + payer: this.keypairs[payerKey], + owner: this.keypairs[ownerKey], + mint: this.keypairs[mintKey], + }); + }, +); + +Then( + 'the instruction program ID is the light-token program', + function (this: TokenInterfaceWorld) { + assert.ok(this.instruction!.programId.equals(LIGHT_TOKEN_PROGRAM_ID)); + }, +); + +Then( + 'account key {int} is {string}', + function (this: TokenInterfaceWorld, index: number, name: string) { + assert.ok(this.instruction!.keys[index].pubkey.equals(this.keypairs[name])); + }, +); + +When( + 'I build a checked transfer instruction for {int} tokens with {int} decimals', + function (this: TokenInterfaceWorld, amount: number, decimals: number) { + this.instruction = createTransferCheckedInstruction({ + source: this.keypairs['source'], + destination: this.keypairs['destination'], + mint: this.keypairs['mint'], + authority: this.keypairs['authority'], + payer: this.keypairs['payer'], + amount: BigInt(amount), + decimals, + }); + }, +); + +Then( + 'the instruction discriminator byte is {int}', + function (this: TokenInterfaceWorld, disc: number) { + assert.strictEqual(this.instruction!.data[0], disc); + }, +); + +When( + 'I build approve, revoke, freeze, and thaw instructions', + function (this: TokenInterfaceWorld) { + this.builtInstructions['approve'] = createApproveInstruction({ + tokenAccount: this.keypairs['tokenAccount'], + delegate: this.keypairs['delegate'], + owner: this.keypairs['owner'], + amount: 10n, + }); + this.builtInstructions['revoke'] = createRevokeInstruction({ + tokenAccount: this.keypairs['tokenAccount'], + owner: this.keypairs['owner'], + }); + this.builtInstructions['freeze'] = createFreezeInstruction({ + tokenAccount: this.keypairs['tokenAccount'], + mint: this.keypairs['mint'], + freezeAuthority: this.keypairs['freezeAuthority'], + }); + this.builtInstructions['thaw'] = createThawInstruction({ + tokenAccount: this.keypairs['tokenAccount'], + mint: this.keypairs['mint'], + freezeAuthority: this.keypairs['freezeAuthority'], + }); + }, +); + +Then( + 'the approve instruction targets the light-token program', + function (this: TokenInterfaceWorld) { + assert.ok( + this.builtInstructions['approve'].programId.equals( + LIGHT_TOKEN_PROGRAM_ID, + ), + ); + }, +); + +Then( + 'the revoke instruction targets the light-token program', + function (this: TokenInterfaceWorld) { + assert.ok( + this.builtInstructions['revoke'].programId.equals( + LIGHT_TOKEN_PROGRAM_ID, + ), + ); + }, +); diff --git a/js/token-interface/tests/step-definitions/unit/kit-adapter.steps.ts b/js/token-interface/tests/step-definitions/unit/kit-adapter.steps.ts new file mode 100644 index 0000000000..570c5eb021 --- /dev/null +++ b/js/token-interface/tests/step-definitions/unit/kit-adapter.steps.ts @@ -0,0 +1,71 @@ +import assert from 'node:assert/strict'; +import { Given, When, Then } from '@cucumber/cucumber'; +import { Keypair } from '@solana/web3.js'; +import { createAtaInstruction } from '../../../src/instructions/index.js'; +import { + createTransferInstructions, + createAtaInstructions, + createTransferInstructionPlan, + toKitInstructions, +} from '../../../src/kit/index.js'; +import type { TokenInterfaceWorld } from '../../support/world.js'; + +Given( + 'a legacy create-ATA instruction', + function (this: TokenInterfaceWorld) { + this.instruction = createAtaInstruction({ + payer: Keypair.generate().publicKey, + owner: Keypair.generate().publicKey, + mint: Keypair.generate().publicKey, + }); + }, +); + +When( + 'I convert it to kit instructions', + function (this: TokenInterfaceWorld) { + this.kitInstructions = toKitInstructions([this.instruction!]); + }, +); + +Then( + 'the result is a list of {int} kit instruction object(s)', + function (this: TokenInterfaceWorld, count: number) { + assert.strictEqual(this.kitInstructions!.length, count); + assert.ok(this.kitInstructions![0] !== undefined); + assert.strictEqual(typeof this.kitInstructions![0], 'object'); + }, +); + +When( + 'I call the kit createAtaInstructions builder', + async function (this: TokenInterfaceWorld) { + this.kitInstructions = await createAtaInstructions({ + payer: this.keypairs['payer'], + owner: this.keypairs['owner'], + mint: this.keypairs['mint'], + }); + }, +); + +Then( + 'the result is a list of {int} kit instruction(s)', + function (this: TokenInterfaceWorld, count: number) { + assert.strictEqual(this.kitInstructions!.length, count); + assert.ok(this.kitInstructions![0] !== undefined); + }, +); + +Then( + 'createTransferInstructions from kit is a function', + function () { + assert.strictEqual(typeof createTransferInstructions, 'function'); + }, +); + +Then( + 'createTransferInstructionPlan from kit is a function', + function () { + assert.strictEqual(typeof createTransferInstructionPlan, 'function'); + }, +); diff --git a/js/token-interface/tests/step-definitions/unit/public-api.steps.ts b/js/token-interface/tests/step-definitions/unit/public-api.steps.ts new file mode 100644 index 0000000000..9fa3eaf723 --- /dev/null +++ b/js/token-interface/tests/step-definitions/unit/public-api.steps.ts @@ -0,0 +1,146 @@ +import assert from 'node:assert/strict'; +import { Given, When, Then } from '@cucumber/cucumber'; +import { Keypair } from '@solana/web3.js'; +import { LIGHT_TOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; +import { getAssociatedTokenAddress } from '../../../src/read/index.js'; +import { + createTransferInstructions, + MultiTransactionNotSupportedError, + createAtaInstructions, + createFreezeInstruction, + createThawInstruction, + getAtaAddress, +} from '../../../src/index.js'; +import type { TokenInterfaceWorld } from '../../support/world.js'; + +Given( + 'random keypairs for {string} and {string}', + function (this: TokenInterfaceWorld, name1: string, name2: string) { + this.keypairs[name1] = Keypair.generate().publicKey; + this.keypairs[name2] = Keypair.generate().publicKey; + }, +); + +Given( + 'random keypairs for {string}, {string}, and {string}', + function ( + this: TokenInterfaceWorld, + name1: string, + name2: string, + name3: string, + ) { + this.keypairs[name1] = Keypair.generate().publicKey; + this.keypairs[name2] = Keypair.generate().publicKey; + this.keypairs[name3] = Keypair.generate().publicKey; + }, +); + +When( + 'I derive the ATA address for {string} and {string}', + function (this: TokenInterfaceWorld, ownerKey: string, mintKey: string) { + const derived = getAtaAddress({ + owner: this.keypairs[ownerKey], + mint: this.keypairs[mintKey], + }); + this.keypairs['derivedAta'] = derived; + }, +); + +Then( + 'it matches the low-level getAssociatedTokenAddress result', + function (this: TokenInterfaceWorld) { + const expected = getAssociatedTokenAddress( + this.keypairs['mint'], + this.keypairs['owner'], + ); + assert.ok(this.keypairs['derivedAta'].equals(expected)); + }, +); + +When( + 'I build an ATA instruction list for {string}, {string}, and {string}', + async function ( + this: TokenInterfaceWorld, + payerKey: string, + ownerKey: string, + mintKey: string, + ) { + this.instructions = await createAtaInstructions({ + payer: this.keypairs[payerKey], + owner: this.keypairs[ownerKey], + mint: this.keypairs[mintKey], + }); + }, +); + +Then( + 'the result is a list of {int} instruction(s)', + function (this: TokenInterfaceWorld, count: number) { + assert.strictEqual(this.instructions.length, count); + }, +); + +Then( + 'the first instruction program ID is the light-token program', + function (this: TokenInterfaceWorld) { + assert.ok(this.instructions[0].programId.equals(LIGHT_TOKEN_PROGRAM_ID)); + }, +); + +When( + 'I build raw freeze and thaw instructions', + function (this: TokenInterfaceWorld) { + this.builtInstructions['freeze'] = createFreezeInstruction({ + tokenAccount: this.keypairs['tokenAccount'], + mint: this.keypairs['mint'], + freezeAuthority: this.keypairs['freezeAuthority'], + }); + this.builtInstructions['thaw'] = createThawInstruction({ + tokenAccount: this.keypairs['tokenAccount'], + mint: this.keypairs['mint'], + freezeAuthority: this.keypairs['freezeAuthority'], + }); + }, +); + +Then( + 'the freeze discriminator byte is {int}', + function (this: TokenInterfaceWorld, disc: number) { + assert.strictEqual(this.builtInstructions['freeze'].data[0], disc); + }, +); + +Then( + 'the thaw discriminator byte is {int}', + function (this: TokenInterfaceWorld, disc: number) { + assert.strictEqual(this.builtInstructions['thaw'].data[0], disc); + }, +); + +When( + 'I create a MultiTransactionNotSupportedError for {string} with batch count {int}', + function (this: TokenInterfaceWorld, funcName: string, count: number) { + this.errorInstance = new MultiTransactionNotSupportedError( + funcName, + count, + ); + }, +); + +Then( + 'the error name is {string}', + function (this: TokenInterfaceWorld, name: string) { + assert.strictEqual(this.errorInstance!.name, name); + }, +); + +Then( + 'the error message contains {string}', + function (this: TokenInterfaceWorld, substring: string) { + assert.ok(this.errorInstance!.message.includes(substring)); + }, +); + +Then('createTransferInstructions is a function', function () { + assert.strictEqual(typeof createTransferInstructions, 'function'); +}); diff --git a/js/token-interface/tests/support/hooks.ts b/js/token-interface/tests/support/hooks.ts new file mode 100644 index 0000000000..b0d315a1b2 --- /dev/null +++ b/js/token-interface/tests/support/hooks.ts @@ -0,0 +1,5 @@ +import { setDefaultTimeout } from '@cucumber/cucumber'; + +// E2E tests interact with on-chain state and need long timeouts (350 seconds). +// Unit tests complete in <100ms naturally. +setDefaultTimeout(350_000); diff --git a/js/token-interface/tests/support/world.ts b/js/token-interface/tests/support/world.ts new file mode 100644 index 0000000000..54c35fae53 --- /dev/null +++ b/js/token-interface/tests/support/world.ts @@ -0,0 +1,42 @@ +import { World, IWorldOptions, setWorldConstructor } from '@cucumber/cucumber'; +import type { Keypair, PublicKey, TransactionInstruction } from '@solana/web3.js'; +import type { MintFixture } from '../e2e/helpers.js'; + +export class TokenInterfaceWorld extends World { + // ---- Fixture state (e2e) ---- + fixture?: MintFixture; + owner?: Keypair; + sender?: Keypair; + recipient?: Keypair; + delegate?: Keypair; + + // ---- Instruction results ---- + instructions: TransactionInstruction[] = []; + lastApproveInstructions: TransactionInstruction[] = []; + lastRevokeInstructions: TransactionInstruction[] = []; + transactionSignature?: string; + + // ---- Assertion targets ---- + resultBalance?: bigint; + resultAmounts?: bigint[]; + resultError?: Error; + resultAccount?: any; + resultDelegateInfo?: { + delegate: PublicKey | null; + delegatedAmount: bigint; + }; + resultState?: number; + + // ---- Unit test state ---- + keypairs: Record = {}; + instruction?: TransactionInstruction; + builtInstructions: Record = {}; + kitInstructions?: any[]; + errorInstance?: Error; + + constructor(options: IWorldOptions) { + super(options); + } +} + +setWorldConstructor(TokenInterfaceWorld); diff --git a/js/token-interface/tests/unit/instruction-builders.test.ts b/js/token-interface/tests/unit/instruction-builders.test.ts deleted file mode 100644 index 9f014cb8da..0000000000 --- a/js/token-interface/tests/unit/instruction-builders.test.ts +++ /dev/null @@ -1,352 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { Keypair } from '@solana/web3.js'; -import { Buffer } from 'buffer'; -import { LIGHT_TOKEN_PROGRAM_ID, bn } from '@lightprotocol/stateless.js'; -import { TOKEN_PROGRAM_ID } from '@solana/spl-token'; -import { - createApproveInstruction, - createAtaInstruction, - createAssociatedLightTokenAccountInstruction, - createBurnCheckedInstruction, - createBurnInstruction, - createFreezeInstruction, - createRevokeInstruction, - createThawInstruction, - createTransferCheckedInstruction, - createDecompressInstruction, -} from '../../src/instructions'; - -describe('instruction builders', () => { - function buildParsedCompressedAccount(params?: { - amount?: bigint; - owner?: ReturnType['publicKey']; - mint?: ReturnType['publicKey']; - tree?: ReturnType['publicKey']; - queue?: ReturnType['publicKey']; - leafIndex?: number; - }) { - const owner = params?.owner ?? Keypair.generate().publicKey; - const mint = params?.mint ?? Keypair.generate().publicKey; - const tree = params?.tree ?? Keypair.generate().publicKey; - const queue = params?.queue ?? Keypair.generate().publicKey; - const amount = params?.amount ?? 10n; - const leafIndex = params?.leafIndex ?? 1; - return { - compressedAccount: { - treeInfo: { - tree, - queue, - }, - hash: new Array(32).fill(0), - leafIndex, - proveByIndex: false, - owner: LIGHT_TOKEN_PROGRAM_ID, - lamports: bn(0), - address: null, - data: { - discriminator: [2, 0, 0, 0, 0, 0, 0, 0], - data: Buffer.alloc(0), - dataHash: new Array(32).fill(0), - }, - readOnly: false, - }, - parsed: { - mint, - owner, - amount: bn(amount.toString()), - delegate: null, - state: 1, - tlv: null, - }, - } as any; - } - - it('creates a canonical light-token ata instruction', () => { - const payer = Keypair.generate().publicKey; - const owner = Keypair.generate().publicKey; - const mint = Keypair.generate().publicKey; - - const instruction = createAtaInstruction({ - payer, - owner, - mint, - }); - - expect(instruction.programId.equals(LIGHT_TOKEN_PROGRAM_ID)).toBe(true); - expect(instruction.keys[0].pubkey.equals(owner)).toBe(true); - expect(instruction.keys[1].pubkey.equals(mint)).toBe(true); - expect(instruction.keys[2].pubkey.equals(payer)).toBe(true); - }); - - it('creates a checked transfer instruction', () => { - const source = Keypair.generate().publicKey; - const destination = Keypair.generate().publicKey; - const mint = Keypair.generate().publicKey; - const authority = Keypair.generate().publicKey; - const payer = Keypair.generate().publicKey; - - const instruction = createTransferCheckedInstruction({ - source, - destination, - mint, - authority, - payer, - amount: 42n, - decimals: 9, - }); - - expect(instruction.programId.equals(LIGHT_TOKEN_PROGRAM_ID)).toBe(true); - expect(instruction.data[0]).toBe(12); - expect(instruction.keys[0].pubkey.equals(source)).toBe(true); - expect(instruction.keys[2].pubkey.equals(destination)).toBe(true); - }); - - it('marks fee payer as signer when transfer authority differs', () => { - const source = Keypair.generate().publicKey; - const destination = Keypair.generate().publicKey; - const mint = Keypair.generate().publicKey; - const authority = Keypair.generate().publicKey; - const payer = Keypair.generate().publicKey; - - const instruction = createTransferCheckedInstruction({ - source, - destination, - mint, - authority, - payer, - amount: 1n, - decimals: 9, - }); - - expect(instruction.keys[3].pubkey.equals(authority)).toBe(true); - expect(instruction.keys[3].isSigner).toBe(true); - expect(instruction.keys[3].isWritable).toBe(false); - expect(instruction.keys[5].pubkey.equals(payer)).toBe(true); - expect(instruction.keys[5].isSigner).toBe(true); - expect(instruction.keys[5].isWritable).toBe(true); - }); - - it('defaults transfer payer to authority when omitted', () => { - const source = Keypair.generate().publicKey; - const destination = Keypair.generate().publicKey; - const mint = Keypair.generate().publicKey; - const authority = Keypair.generate().publicKey; - - const instruction = createTransferCheckedInstruction({ - source, - destination, - mint, - authority, - amount: 1n, - decimals: 9, - }); - - expect(instruction.keys[3].pubkey.equals(authority)).toBe(true); - expect(instruction.keys[3].isSigner).toBe(true); - expect(instruction.keys[3].isWritable).toBe(true); - expect(instruction.keys[5].pubkey.equals(authority)).toBe(true); - expect(instruction.keys[5].isSigner).toBe(false); - }); - - it('creates approve, revoke, freeze, and thaw instructions', () => { - const tokenAccount = Keypair.generate().publicKey; - const owner = Keypair.generate().publicKey; - const delegate = Keypair.generate().publicKey; - const mint = Keypair.generate().publicKey; - const freezeAuthority = Keypair.generate().publicKey; - - const approve = createApproveInstruction({ - tokenAccount, - delegate, - owner, - amount: 10n, - }); - const revoke = createRevokeInstruction({ - tokenAccount, - owner, - }); - const freeze = createFreezeInstruction({ - tokenAccount, - mint, - freezeAuthority, - }); - const thaw = createThawInstruction({ - tokenAccount, - mint, - freezeAuthority, - }); - - expect(approve.programId.equals(LIGHT_TOKEN_PROGRAM_ID)).toBe(true); - expect(revoke.programId.equals(LIGHT_TOKEN_PROGRAM_ID)).toBe(true); - expect(freeze.data[0]).toBe(10); - expect(thaw.data[0]).toBe(11); - }); - - it('uses external fee payer in approve/revoke key metas', () => { - const tokenAccount = Keypair.generate().publicKey; - const owner = Keypair.generate().publicKey; - const delegate = Keypair.generate().publicKey; - const payer = Keypair.generate().publicKey; - - const approve = createApproveInstruction({ - tokenAccount, - delegate, - owner, - amount: 9n, - payer, - }); - const revoke = createRevokeInstruction({ - tokenAccount, - owner, - payer, - }); - - expect(approve.keys[2].pubkey.equals(owner)).toBe(true); - expect(approve.keys[2].isSigner).toBe(true); - expect(approve.keys[2].isWritable).toBe(false); - expect(approve.keys[4].pubkey.equals(payer)).toBe(true); - expect(approve.keys[4].isSigner).toBe(true); - expect(approve.keys[4].isWritable).toBe(true); - - expect(revoke.keys[1].pubkey.equals(owner)).toBe(true); - expect(revoke.keys[1].isSigner).toBe(true); - expect(revoke.keys[1].isWritable).toBe(false); - expect(revoke.keys[3].pubkey.equals(payer)).toBe(true); - expect(revoke.keys[3].isSigner).toBe(true); - expect(revoke.keys[3].isWritable).toBe(true); - }); - - it('encodes burn and burn-checked discriminators and decimals', () => { - const source = Keypair.generate().publicKey; - const mint = Keypair.generate().publicKey; - const authority = Keypair.generate().publicKey; - const payer = Keypair.generate().publicKey; - - const burn = createBurnInstruction({ - source, - mint, - authority, - amount: 123n, - payer, - }); - const burnChecked = createBurnCheckedInstruction({ - source, - mint, - authority, - amount: 123n, - decimals: 9, - payer, - }); - - expect(burn.data[0]).toBe(8); - expect(burnChecked.data[0]).toBe(15); - expect(burnChecked.data[9]).toBe(9); - expect(burn.keys[4].isSigner).toBe(true); - expect(burn.keys[2].isWritable).toBe(false); - }); - - it('creates SPL ATA instruction when SPL token program is requested', () => { - const payer = Keypair.generate().publicKey; - const owner = Keypair.generate().publicKey; - const mint = Keypair.generate().publicKey; - - const instruction = createAtaInstruction({ - payer, - owner, - mint, - programId: TOKEN_PROGRAM_ID, - }); - - expect(instruction.programId.equals(TOKEN_PROGRAM_ID)).toBe(false); - expect(instruction.keys[5].pubkey.equals(TOKEN_PROGRAM_ID)).toBe(true); - }); - - it('throws when decompress amount exceeds total input amount', () => { - const account = buildParsedCompressedAccount({ amount: 5n }); - const owner = account.parsed.owner; - const destination = Keypair.generate().publicKey; - const validityProof = { - compressedProof: null, - rootIndices: [0], - } as any; - - expect(() => - createDecompressInstruction({ - payer: owner, - inputCompressedTokenAccounts: [account], - toAddress: destination, - amount: 6n, - validityProof, - decimals: 9, - authority: owner, - }), - ).toThrow(/exceeds total input amount/i); - }); - - it('throws when decompress inputs have mixed owners', () => { - const mint = Keypair.generate().publicKey; - const accountA = buildParsedCompressedAccount({ mint }); - const accountB = buildParsedCompressedAccount({ mint }); - const destination = Keypair.generate().publicKey; - const validityProof = { - compressedProof: null, - rootIndices: [0, 0], - } as any; - - expect(() => - createDecompressInstruction({ - payer: accountA.parsed.owner, - inputCompressedTokenAccounts: [accountA, accountB], - toAddress: destination, - amount: 1n, - validityProof, - decimals: 9, - authority: accountA.parsed.owner, - }), - ).toThrow(/same owner/i); - }); - - it('defaults ATA payer to owner when omitted', () => { - const owner = Keypair.generate().publicKey; - const mint = Keypair.generate().publicKey; - - const instruction = createAtaInstruction({ - owner, - mint, - }); - - expect(instruction.programId.equals(LIGHT_TOKEN_PROGRAM_ID)).toBe(true); - expect(instruction.keys[2].pubkey.equals(owner)).toBe(true); - expect(instruction.keys[2].isSigner).toBe(true); - }); - - it('omits light-token config/rent keys when compressible config is null', () => { - const feePayer = Keypair.generate().publicKey; - const owner = Keypair.generate().publicKey; - const mint = Keypair.generate().publicKey; - - const instruction = createAssociatedLightTokenAccountInstruction({ - feePayer, - owner, - mint, - compressibleConfig: null, - }); - - expect(instruction.programId.equals(LIGHT_TOKEN_PROGRAM_ID)).toBe(true); - expect(instruction.keys).toHaveLength(5); - }); - - it('defaults associated light-token fee payer to owner when omitted', () => { - const owner = Keypair.generate().publicKey; - const mint = Keypair.generate().publicKey; - - const instruction = createAssociatedLightTokenAccountInstruction({ - owner, - mint, - }); - - expect(instruction.programId.equals(LIGHT_TOKEN_PROGRAM_ID)).toBe(true); - expect(instruction.keys[2].pubkey.equals(owner)).toBe(true); - expect(instruction.keys[2].isSigner).toBe(true); - }); -}); diff --git a/js/token-interface/tests/unit/kit.test.ts b/js/token-interface/tests/unit/kit.test.ts deleted file mode 100644 index f76fb70ade..0000000000 --- a/js/token-interface/tests/unit/kit.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { Keypair } from '@solana/web3.js'; -import { createAtaInstruction } from '../../src/instructions'; -import { - createTransferInstructions, - createAtaInstructions, - createTransferInstructionPlan, - toKitInstructions, -} from '../../src/kit'; - -describe('kit adapter', () => { - it('converts legacy instructions to kit instructions', () => { - const instruction = createAtaInstruction({ - payer: Keypair.generate().publicKey, - owner: Keypair.generate().publicKey, - mint: Keypair.generate().publicKey, - }); - - const converted = toKitInstructions([instruction]); - - expect(converted).toHaveLength(1); - expect(converted[0]).toBeDefined(); - expect(typeof converted[0]).toBe('object'); - }); - - it('wraps canonical builders for kit consumers', async () => { - const instructions = await createAtaInstructions({ - payer: Keypair.generate().publicKey, - owner: Keypair.generate().publicKey, - mint: Keypair.generate().publicKey, - }); - - expect(instructions).toHaveLength(1); - expect(instructions[0]).toBeDefined(); - }); - - it('exports transfer builder and plan builder', () => { - expect(typeof createTransferInstructions).toBe('function'); - expect(typeof createTransferInstructionPlan).toBe('function'); - }); -}); diff --git a/js/token-interface/tests/unit/public-api.test.ts b/js/token-interface/tests/unit/public-api.test.ts deleted file mode 100644 index b7a3a81030..0000000000 --- a/js/token-interface/tests/unit/public-api.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { Keypair } from '@solana/web3.js'; -import { LIGHT_TOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; -import { getAssociatedTokenAddress } from '../../src/read'; -import { - createTransferInstructions, - MultiTransactionNotSupportedError, - createAtaInstructions, - createFreezeInstruction, - createThawInstruction, - getAtaAddress, -} from '../../src'; - -describe('public api', () => { - it('derives the canonical light-token ata address', () => { - const owner = Keypair.generate().publicKey; - const mint = Keypair.generate().publicKey; - - expect( - getAtaAddress({ owner, mint }).equals( - getAssociatedTokenAddress(mint, owner), - ), - ).toBe(true); - }); - - it('builds one canonical ata instruction', async () => { - const payer = Keypair.generate().publicKey; - const owner = Keypair.generate().publicKey; - const mint = Keypair.generate().publicKey; - - const instructions = await createAtaInstructions({ - payer, - owner, - mint, - }); - - expect(instructions).toHaveLength(1); - expect(instructions[0].programId.equals(LIGHT_TOKEN_PROGRAM_ID)).toBe( - true, - ); - }); - - it('raw freeze and thaw instructions use light-token discriminators', () => { - const tokenAccount = Keypair.generate().publicKey; - const mint = Keypair.generate().publicKey; - const freezeAuthority = Keypair.generate().publicKey; - - const freeze = createFreezeInstruction({ - tokenAccount, - mint, - freezeAuthority, - }); - const thaw = createThawInstruction({ - tokenAccount, - mint, - freezeAuthority, - }); - - expect(freeze.data[0]).toBe(10); - expect(thaw.data[0]).toBe(11); - }); - - it('exposes a clear single-transaction error', () => { - const error = new MultiTransactionNotSupportedError( - 'createLoadInstructions', - 2, - ); - - expect(error.name).toBe('MultiTransactionNotSupportedError'); - expect(error.message).toContain('single-transaction'); - expect(error.message).toContain('createLoadInstructions'); - }); - - it('exports canonical transfer builder', () => { - expect(typeof createTransferInstructions).toBe('function'); - }); -}); diff --git a/js/token-interface/tsconfig.test.json b/js/token-interface/tsconfig.test.json index e836181c0e..eea543a7dc 100644 --- a/js/token-interface/tsconfig.test.json +++ b/js/token-interface/tsconfig.test.json @@ -4,5 +4,5 @@ "rootDirs": ["src", "tests"] }, "extends": "./tsconfig.json", - "include": ["./tests/**/*.ts", "vitest.config.ts"] + "include": ["./tests/**/*.ts"] } diff --git a/js/token-interface/vitest.config.ts b/js/token-interface/vitest.config.ts deleted file mode 100644 index 93411502d2..0000000000 --- a/js/token-interface/vitest.config.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { defineConfig } from 'vitest/config'; -import { resolve } from 'path'; - -export default defineConfig({ - logLevel: 'info', - test: { - include: process.env.EXCLUDE_E2E - ? ['tests/unit/**/*.test.ts'] - : ['tests/**/*.test.ts'], - includeSource: ['src/**/*.{js,ts}'], - fileParallelism: false, - testTimeout: 350000, - hookTimeout: 100000, - reporters: ['verbose'], - }, - define: { - 'import.meta.vitest': false, - }, - build: { - lib: { - formats: ['es', 'cjs'], - entry: resolve(__dirname, 'src/index.ts'), - fileName: 'index', - }, - }, -}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3c3a32e0cd..1ea03ff484 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -471,6 +471,9 @@ importers: specifier: 6.0.3 version: 6.0.3 devDependencies: + '@cucumber/cucumber': + specifier: ^11.0.0 + version: 11.3.0 '@eslint/js': specifier: 9.36.0 version: 9.36.0 @@ -501,9 +504,6 @@ importers: eslint: specifier: ^9.36.0 version: 9.36.0 - eslint-plugin-vitest: - specifier: ^0.5.4 - version: 0.5.4(@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))(eslint@9.36.0)(typescript@5.9.3)(vitest@2.1.1(@types/node@22.16.5)(terser@5.43.1)) prettier: specifier: ^3.3.3 version: 3.6.2 @@ -519,12 +519,12 @@ importers: tslib: specifier: ^2.7.0 version: 2.8.1 + tsx: + specifier: ^4.19.0 + version: 4.20.5 typescript: specifier: ^5.6.2 version: 5.9.3 - vitest: - specifier: ^2.1.1 - version: 2.1.1(@types/node@22.16.5)(terser@5.43.1) sdk-tests/sdk-anchor-test: dependencies: @@ -837,6 +837,10 @@ packages: resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} engines: {node: '>=6.9.0'} + '@colors/colors@1.5.0': + resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} + engines: {node: '>=0.1.90'} + '@coral-xyz/anchor-errors@0.31.1': resolution: {integrity: sha512-NhNEku4F3zzUSBtrYz84FzYWm48+9OvmT1Hhnwr6GnPQry2dsEqH/ti/7ASjjpoFTWRnPXrjAIT1qM6Isop+LQ==} engines: {node: '>=10'} @@ -865,6 +869,64 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@cucumber/ci-environment@10.0.1': + resolution: {integrity: sha512-/+ooDMPtKSmvcPMDYnMZt4LuoipfFfHaYspStI4shqw8FyKcfQAmekz6G+QKWjQQrvM+7Hkljwx58MEwPCwwzg==} + + '@cucumber/cucumber-expressions@18.0.1': + resolution: {integrity: sha512-NSid6bI+7UlgMywl5octojY5NXnxR9uq+JisjOrO52VbFsQM6gTWuQFE8syI10KnIBEdPzuEUSVEeZ0VFzRnZA==} + + '@cucumber/cucumber@11.3.0': + resolution: {integrity: sha512-1YGsoAzRfDyVOnRMTSZP/EcFsOBElOKa2r+5nin0DJAeK+Mp0mzjcmSllMgApGtck7Ji87wwy3kFONfHUHMn4g==} + engines: {node: 18 || 20 || 22 || >=23} + hasBin: true + + '@cucumber/gherkin-streams@5.0.1': + resolution: {integrity: sha512-/7VkIE/ASxIP/jd4Crlp4JHXqdNFxPGQokqWqsaCCiqBiu5qHoKMxcWNlp9njVL/n9yN4S08OmY3ZR8uC5x74Q==} + hasBin: true + peerDependencies: + '@cucumber/gherkin': '>=22.0.0' + '@cucumber/message-streams': '>=4.0.0' + '@cucumber/messages': '>=17.1.1' + + '@cucumber/gherkin-utils@9.2.0': + resolution: {integrity: sha512-3nmRbG1bUAZP3fAaUBNmqWO0z0OSkykZZotfLjyhc8KWwDSOrOmMJlBTd474lpA8EWh4JFLAX3iXgynBqBvKzw==} + hasBin: true + + '@cucumber/gherkin@30.0.4': + resolution: {integrity: sha512-pb7lmAJqweZRADTTsgnC3F5zbTh3nwOB1M83Q9ZPbUKMb3P76PzK6cTcPTJBHWy3l7isbigIv+BkDjaca6C8/g==} + + '@cucumber/gherkin@31.0.0': + resolution: {integrity: sha512-wlZfdPif7JpBWJdqvHk1Mkr21L5vl4EfxVUOS4JinWGf3FLRV6IKUekBv5bb5VX79fkDcfDvESzcQ8WQc07Wgw==} + + '@cucumber/html-formatter@21.10.1': + resolution: {integrity: sha512-isaaNMNnBYThsvaHy7i+9kkk9V3+rhgdkt0pd6TCY6zY1CSRZQ7tG6ST9pYyRaECyfbCeF7UGH0KpNEnh6UNvQ==} + peerDependencies: + '@cucumber/messages': '>=18' + + '@cucumber/junit-xml-formatter@0.7.1': + resolution: {integrity: sha512-AzhX+xFE/3zfoYeqkT7DNq68wAQfBcx4Dk9qS/ocXM2v5tBv6eFQ+w8zaSfsktCjYzu4oYRH/jh4USD1CYHfaQ==} + peerDependencies: + '@cucumber/messages': '*' + + '@cucumber/message-streams@4.0.1': + resolution: {integrity: sha512-Kxap9uP5jD8tHUZVjTWgzxemi/0uOsbGjd4LBOSxcJoOCRbESFwemUzilJuzNTB8pcTQUh8D5oudUyxfkJOKmA==} + peerDependencies: + '@cucumber/messages': '>=17.1.1' + + '@cucumber/messages@26.0.1': + resolution: {integrity: sha512-DIxSg+ZGariumO+Lq6bn4kOUIUET83A4umrnWmidjGFl8XxkBieUZtsmNbLYgH/gnsmP07EfxxdTr0hOchV1Sg==} + + '@cucumber/messages@27.2.0': + resolution: {integrity: sha512-f2o/HqKHgsqzFLdq6fAhfG1FNOQPdBdyMGpKwhb7hZqg0yZtx9BVqkTyuoNk83Fcvk3wjMVfouFXXHNEk4nddA==} + + '@cucumber/query@13.6.0': + resolution: {integrity: sha512-tiDneuD5MoWsJ9VKPBmQok31mSX9Ybl+U4wqDoXeZgsXHDURqzM3rnpWVV3bC34y9W6vuFxrlwF/m7HdOxwqRw==} + peerDependencies: + '@cucumber/messages': '*' + + '@cucumber/tag-expressions@6.1.2': + resolution: {integrity: sha512-xa3pER+ntZhGCxRXSguDTKEHTZpUUsp+RzTRNnit+vi5cqnk6abLdSLg5i3HZXU3c74nQ8afQC6IT507EN74oQ==} + '@esbuild-plugins/node-globals-polyfill@0.2.3': resolution: {integrity: sha512-r3MIryXDeXDOZh7ih1l/yE9ZLORCd5e8vWg02azWRGj5SPTuoh69A2AIyn0Z31V/kHBfZ4HgWJ+OK3GTTwLmnw==} peerDependencies: @@ -2503,6 +2565,10 @@ packages: resolution: {integrity: sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==} engines: {node: '>=14.16'} + '@teppeis/multimaps@3.0.0': + resolution: {integrity: sha512-ID7fosbc50TbT0MK0EG12O+gAP3W3Aa/Pz4DaTtQtEvlc9Odaqi0de+xuZ7Li2GtK4HzEX7IuRWS/JmZLksR3Q==} + engines: {node: '>=14'} + '@tsconfig/node10@1.0.9': resolution: {integrity: sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==} @@ -2575,6 +2641,9 @@ packages: '@types/node@22.16.5': resolution: {integrity: sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ==} + '@types/normalize-package-data@2.4.4': + resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} + '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} @@ -2590,6 +2659,9 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/uuid@10.0.0': + resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} + '@types/uuid@8.3.4': resolution: {integrity: sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==} @@ -2772,6 +2844,10 @@ packages: resolution: {integrity: sha512-YdhtCd19sKRKfAAUsrcC1wzm4JuzJoiX4pOJqIoW2qmKj5WzG/dL8uUJ0361zaXtHqK7gEhOwtAtz7t3Yq3X5g==} engines: {node: '>=18'} + ansi-regex@4.1.1: + resolution: {integrity: sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==} + engines: {node: '>=6'} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -2800,6 +2876,9 @@ packages: resolution: {integrity: sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==} engines: {node: '>=14'} + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + arg@4.1.3: resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} @@ -2856,6 +2935,9 @@ packages: asn1.js@4.10.1: resolution: {integrity: sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw==} + assertion-error-formatter@3.0.0: + resolution: {integrity: sha512-6YyAVLrEze0kQ7CmJfUgrLHb+Y7XghmL2Ie7ijVa2Y9ynP3LV+VDiwFk62Dn0qtqbmY0BT0ss6p1xxpiF2PYbQ==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -3109,6 +3191,9 @@ packages: cipher-base@1.0.4: resolution: {integrity: sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q==} + class-transformer@0.5.1: + resolution: {integrity: sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==} + clean-stack@3.0.1: resolution: {integrity: sha512-lR9wNiMRcVQjSB3a7xXGLuz4cr4wJuuXlaAEbRutGowQTmlp7R72/DOgN21e8jdwblMWl9UOJMJXarX94pzKdg==} engines: {node: '>=10'} @@ -3133,6 +3218,10 @@ packages: resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} engines: {node: '>=6'} + cli-table3@0.6.5: + resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} + engines: {node: 10.* || >= 12.*} + cli-truncate@4.0.0: resolution: {integrity: sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==} engines: {node: '>=18'} @@ -3169,6 +3258,10 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} + commander@10.0.1: + resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} + engines: {node: '>=14'} + commander@12.1.0: resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} engines: {node: '>=18'} @@ -3188,6 +3281,10 @@ packages: resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==} engines: {node: '>= 6'} + commander@9.1.0: + resolution: {integrity: sha512-i0/MaqBtdbnJ4XQs4Pmyb+oFQl+q0lsAmokVUH92SlSw4fkeAcG3bVon+Qt7hmtF+u3Het6o4VgrcY3qAoEB6w==} + engines: {node: ^12.20.0 || >=14} + commondir@1.0.1: resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} @@ -3463,6 +3560,9 @@ packages: error-ex@1.3.2: resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} + error-stack-parser@2.1.4: + resolution: {integrity: sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==} + es-abstract@1.22.2: resolution: {integrity: sha512-YoxfFcDmhjOgWPWsV13+2RNjq1F6UQnfs+8TftwNqtzlmFzEXvlUwdrNrYeaizfjQzRMxkZ6ElWMOJIFKdVqwA==} engines: {node: '>= 0.4'} @@ -3748,6 +3848,10 @@ packages: ffjavascript@0.3.1: resolution: {integrity: sha512-4PbK1WYodQtuF47D4pRI5KUg3Q392vuP5WjE1THSnceHdXwU3ijaoS0OqxTzLknCtz4Z2TtABzkBdBdMn3B/Aw==} + figures@3.2.0: + resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} + engines: {node: '>=8'} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -3766,6 +3870,10 @@ packages: resolution: {integrity: sha512-/U4CYp1214Xrp3u3Fqr9yNynUrr5Le4y0SsJh2lMDDSbpwYSz3M2SMWQC+wqcx79cN8PQtHQIL8KnuY9M66fdg==} hasBin: true + find-up-simple@1.0.1: + resolution: {integrity: sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==} + engines: {node: '>=18'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -3940,20 +4048,27 @@ packages: glob@10.4.5: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@10.5.0: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@11.0.0: resolution: {integrity: sha512-9UiX/Bl6J2yaBbxKoEBRm4Cipxgok8kQYcOPEhScPwebu2I0HoQOuYdIO6S3hLuWoZgpDpwQZMzTFxgpkyT76g==} engines: {node: 20 || >=22} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + global-dirs@3.0.1: + resolution: {integrity: sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA==} + engines: {node: '>=10'} globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} @@ -4003,6 +4118,10 @@ packages: graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + has-ansi@4.0.1: + resolution: {integrity: sha512-Qr4RtTm30xvEdqUXbSBVWDu+PrTokJOwe/FU+VdfJPk+MXAPoeOzKpRyrDTnZIJwAkQ4oBLTU53nu0HrkF/Z2A==} + engines: {node: '>=8'} + has-bigints@1.0.2: resolution: {integrity: sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==} @@ -4160,6 +4279,10 @@ packages: resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} engines: {node: '>=12'} + index-to-position@1.2.0: + resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==} + engines: {node: '>=18'} + inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. @@ -4170,6 +4293,10 @@ packages: ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + ini@2.0.0: + resolution: {integrity: sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==} + engines: {node: '>=10'} + ink@5.0.1: resolution: {integrity: sha512-ae4AW/t8jlkj/6Ou21H2av0wxTk8vrGzXv+v2v7j4in+bl1M5XRMVbfNghzhBokV++FjF8RBDJvYo+ttR9YVRg==} engines: {node: '>=18'} @@ -4305,6 +4432,10 @@ packages: engines: {node: '>=18'} hasBin: true + is-installed-globally@0.4.0: + resolution: {integrity: sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ==} + engines: {node: '>=10'} + is-interactive@2.0.0: resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} engines: {node: '>=12'} @@ -4539,6 +4670,9 @@ packages: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} + knuth-shuffle-seeded@1.0.6: + resolution: {integrity: sha512-9pFH0SplrfyKyojCLxZfMcvkhf5hH0d+UwR9nTVJ/DDQJGuzcXjTwB7TP7sDfehSudlGGaOLblmEWqv04ERVWg==} + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -4560,6 +4694,12 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.mergewith@4.6.2: + resolution: {integrity: sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==} + + lodash.sortby@4.7.0: + resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==} + lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} @@ -4598,6 +4738,10 @@ packages: lunr@2.3.9: resolution: {integrity: sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==} + luxon@3.6.1: + resolution: {integrity: sha512-tJLxrKJhO2ukZ5z0gyjY1zPh3Rh88Ej9P7jNrZiHMUXHae1yvI2imgOZtL1TO8TW6biMMKfTtAOoEJANgtWBMQ==} + engines: {node: '>=12'} + magic-string@0.30.11: resolution: {integrity: sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==} @@ -4655,6 +4799,11 @@ packages: engines: {node: '>=4'} hasBin: true + mime@3.0.0: + resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} + engines: {node: '>=10.0.0'} + hasBin: true + mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} @@ -4711,6 +4860,11 @@ packages: resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} hasBin: true + mkdirp@2.1.6: + resolution: {integrity: sha512-+hEnITedc8LAtIP9u3HJDFIdcLV2vXP33sqLLIzkv1Db1zO/1OxbvYf0Y1OC/S/Qo5dxHXepofhmxL02PsKe+A==} + engines: {node: '>=10'} + hasBin: true + mkdirp@3.0.1: resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==} engines: {node: '>=10'} @@ -4735,6 +4889,9 @@ packages: resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} engines: {node: ^18.17.0 || >=20.5.0} + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + nanoid@3.3.8: resolution: {integrity: sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -4869,6 +5026,10 @@ packages: - which - write-file-atomic + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + object-hash@3.0.0: resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} engines: {node: '>= 6'} @@ -4970,6 +5131,10 @@ packages: package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + pad-right@0.2.2: + resolution: {integrity: sha512-4cy8M95ioIGolCoMmm2cMntGR1lPLEbOMzOKu8bzjuJP6JpzEMQcDHmh7hHLYGgob+nKe1YHFMaG4V59HQa89g==} + engines: {node: '>=0.10.0'} + pako@2.1.0: resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==} @@ -4992,6 +5157,10 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} + parse-json@8.3.0: + resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==} + engines: {node: '>=18'} + pascal-case@3.1.2: resolution: {integrity: sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==} @@ -5120,10 +5289,17 @@ packages: process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + prompts@2.4.2: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} engines: {node: '>= 6'} + property-expr@2.0.6: + resolution: {integrity: sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==} + proto-list@1.2.4: resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} @@ -5175,6 +5351,14 @@ packages: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} + read-package-up@11.0.0: + resolution: {integrity: sha512-MbgfoNPANMdb4oRBNg5eqLbB2t2r+o5Ua1pNt8BqGp4I0FJZhuVSOj3PaBPni4azWuSzEdNn2evevzVmEk1ohQ==} + engines: {node: '>=18'} + + read-pkg@9.0.1: + resolution: {integrity: sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA==} + engines: {node: '>=18'} + read-yaml-file@2.1.0: resolution: {integrity: sha512-UkRNRIwnhG+y7hpqnycCL/xbTk7+ia9VuVTC0S+zVbwd65DI9eUpRMfsWIGrCWxTU/mi+JW8cHQCrv+zfCbEPQ==} engines: {node: '>=10.13'} @@ -5194,6 +5378,9 @@ packages: resolution: {integrity: sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==} engines: {node: '>= 0.10'} + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -5201,6 +5388,13 @@ packages: regenerator-runtime@0.14.0: resolution: {integrity: sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==} + regexp-match-indices@1.0.2: + resolution: {integrity: sha512-DwZuAkt8NF5mKwGGER1EGh2PRqyvhRhhLviH+R8y8dIuaQROlUfXjt4s9ZTXstIsSkptf06BSvwcEmmfheJJWQ==} + + regexp-tree@0.1.27: + resolution: {integrity: sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==} + hasBin: true + regexp.prototype.flags@1.5.1: resolution: {integrity: sha512-sy6TXMN+hnP/wMy+ISxg3krXx7BAtWVO4UouuCN/ziM9UEne0euamVNafDfvC83bRNr95y0V5iijeDQFUNpvrg==} engines: {node: '>= 0.4'} @@ -5217,6 +5411,10 @@ packages: resolution: {integrity: sha512-GdekYuwLXLxMuFTwAPg5UKGLW/UXzQrZvH/Zj791BQif5T05T0RsaLfHc9q3ZOKi7n+BoprPD9mJ0O0k4xzUlw==} engines: {node: '>=14'} + repeat-string@1.6.1: + resolution: {integrity: sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==} + engines: {node: '>=0.10'} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -5350,6 +5548,9 @@ packages: secure-compare@3.0.1: resolution: {integrity: sha512-AckIIV90rPDcBcglUwXPF3kg0P0qmPsPXAj6BBEENQE1p5yA1xfmDJzfi1Tappj37Pv2mVbKpL3Z1T+Nn7k1Qw==} + seed-random@2.2.0: + resolution: {integrity: sha512-34EQV6AAHQGhoc0tn/96a9Fsi6v2xdqe/dMUwljGRaFOzR3EgRmECvD0O8vi8X+/uQ50LGHfkNu/Eue5TPKZkQ==} + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -5359,6 +5560,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.7.1: + resolution: {integrity: sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==} + engines: {node: '>=10'} + hasBin: true + semver@7.7.2: resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} engines: {node: '>=10'} @@ -5508,6 +5714,9 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + stackframe@1.3.4: + resolution: {integrity: sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==} + std-env@3.7.0: resolution: {integrity: sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==} @@ -5515,6 +5724,10 @@ packages: resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} engines: {node: '>=18'} + string-argv@0.3.1: + resolution: {integrity: sha512-a1uQGz7IyVy9YwhqjZIZu1c8JO8dNIe20xBmSS6qu9kv++k3JGzCVmprbNN5Kn+BgzD5E7YYwg1CcjuJMRNsvg==} + engines: {node: '>=0.6.19'} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -5632,6 +5845,13 @@ packages: text-encoding-utf-8@1.0.2: resolution: {integrity: sha512-8bw4MY9WjdsD2aMtO0OzOCY3pXGYNx2d2FfHRVUKkiCPDWjKuOlhLVASS+pD7VkLTVjW268LYJHwsnPFlBpbAg==} + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + through@2.3.8: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} @@ -5639,6 +5859,9 @@ packages: resolution: {integrity: sha512-Kw36UHxJEELq2VUqdaSGR2/8cAsPgMtvX8uGVU6Jk26O66PhXec0A5ZnRYs47btbtwPDpXXF66+Fo3vimCM9aQ==} engines: {node: '>=16'} + tiny-case@1.0.3: + resolution: {integrity: sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==} + tiny-jsonc@1.0.2: resolution: {integrity: sha512-f5QDAfLq6zIVSyCZQZhhyl0QS6MvAyTxgz4X4x3+EoCktNWEYJ6PeoEA97fyb98njpBNNi88ybpD7m+BDFXaCw==} @@ -5675,6 +5898,9 @@ packages: toml@3.0.0: resolution: {integrity: sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==} + toposort@2.0.2: + resolution: {integrity: sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} @@ -5747,10 +5973,18 @@ packages: resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} engines: {node: '>=10'} + type-fest@2.19.0: + resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} + engines: {node: '>=12.20'} + type-fest@4.35.0: resolution: {integrity: sha512-2/AwEFQDFEy30iOLjrvHDIH7e4HEWH+f1Yl1bI5XMqzuoCUqwYCdxachgsgv0og/JdVZUhbfjcJAoHj5L1753A==} engines: {node: '>=16'} + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + typed-array-buffer@1.0.0: resolution: {integrity: sha512-Y8KTSIglk9OZEr8zywiIHG/kmQ7KWyjseXs1CbSo8vC42w7hg2HgYTxSWwP0+is7bWDc1H+Fo026CpHFwm8tkw==} engines: {node: '>= 0.4'} @@ -5836,6 +6070,10 @@ packages: undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + unicorn-magic@0.1.0: + resolution: {integrity: sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==} + engines: {node: '>=18'} + unicorn-magic@0.3.0: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} engines: {node: '>=18'} @@ -5870,9 +6108,20 @@ packages: resolution: {integrity: sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==} engines: {node: '>=6.14.2'} + util-arity@1.1.0: + resolution: {integrity: sha512-kkyIsXKwemfSy8ZEoaIz06ApApnWsk5hQO0vLjZS6UkBiGiW++Jsyb8vSBoc0WKlffGoGs5yYy/j5pp8zckrFA==} + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + uuid@10.0.0: + resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + hasBin: true + + uuid@11.0.5: + resolution: {integrity: sha512-508e6IcKLrhxKdBbcA2b4KQZlLVp2+J5UwQ6F7Drckkc5N9ZJwFa4TgWtsww9UG8fGHbm6gbV19TdM5pQ4GaIA==} + hasBin: true + uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} hasBin: true @@ -5977,6 +6226,7 @@ packages: whatwg-encoding@2.0.0: resolution: {integrity: sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==} engines: {node: '>=12'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} @@ -6101,6 +6351,10 @@ packages: utf-8-validate: optional: true + xmlbuilder@15.1.1: + resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} + engines: {node: '>=8.0'} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -6153,6 +6407,9 @@ packages: yoga-wasm-web@0.3.3: resolution: {integrity: sha512-N+d4UJSJbt/R3wqY7Coqs5pcV0aUj2j9IaQ3rNj9bVCLld8tTGKRa2USARjnvZJWVx1NDmQev8EknoczaOQDOA==} + yup@1.6.1: + resolution: {integrity: sha512-JED8pB50qbA4FOkDol0bYF/p60qSEDQqBD0/qeIrUCG1KbPBIQ776fCUNb9ldbPcSTxA69g/47XTo4TqWiuXOA==} + snapshots: '@alcalzone/ansi-tokenize@0.1.3': @@ -6819,6 +7076,9 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@colors/colors@1.5.0': + optional: true + '@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)': @@ -6886,6 +7146,116 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@cucumber/ci-environment@10.0.1': {} + + '@cucumber/cucumber-expressions@18.0.1': + dependencies: + regexp-match-indices: 1.0.2 + + '@cucumber/cucumber@11.3.0': + dependencies: + '@cucumber/ci-environment': 10.0.1 + '@cucumber/cucumber-expressions': 18.0.1 + '@cucumber/gherkin': 30.0.4 + '@cucumber/gherkin-streams': 5.0.1(@cucumber/gherkin@30.0.4)(@cucumber/message-streams@4.0.1(@cucumber/messages@27.2.0))(@cucumber/messages@27.2.0) + '@cucumber/gherkin-utils': 9.2.0 + '@cucumber/html-formatter': 21.10.1(@cucumber/messages@27.2.0) + '@cucumber/junit-xml-formatter': 0.7.1(@cucumber/messages@27.2.0) + '@cucumber/message-streams': 4.0.1(@cucumber/messages@27.2.0) + '@cucumber/messages': 27.2.0 + '@cucumber/tag-expressions': 6.1.2 + assertion-error-formatter: 3.0.0 + capital-case: 1.0.4 + chalk: 4.1.2 + cli-table3: 0.6.5 + commander: 10.0.1 + debug: 4.4.3(supports-color@8.1.1) + error-stack-parser: 2.1.4 + figures: 3.2.0 + glob: 10.5.0 + has-ansi: 4.0.1 + indent-string: 4.0.0 + is-installed-globally: 0.4.0 + is-stream: 2.0.1 + knuth-shuffle-seeded: 1.0.6 + lodash.merge: 4.6.2 + lodash.mergewith: 4.6.2 + luxon: 3.6.1 + mime: 3.0.0 + mkdirp: 2.1.6 + mz: 2.7.0 + progress: 2.0.3 + read-package-up: 11.0.0 + semver: 7.7.1 + string-argv: 0.3.1 + supports-color: 8.1.1 + type-fest: 4.41.0 + util-arity: 1.1.0 + yaml: 2.8.1 + yup: 1.6.1 + + '@cucumber/gherkin-streams@5.0.1(@cucumber/gherkin@30.0.4)(@cucumber/message-streams@4.0.1(@cucumber/messages@27.2.0))(@cucumber/messages@27.2.0)': + dependencies: + '@cucumber/gherkin': 30.0.4 + '@cucumber/message-streams': 4.0.1(@cucumber/messages@27.2.0) + '@cucumber/messages': 27.2.0 + commander: 9.1.0 + source-map-support: 0.5.21 + + '@cucumber/gherkin-utils@9.2.0': + dependencies: + '@cucumber/gherkin': 31.0.0 + '@cucumber/messages': 27.2.0 + '@teppeis/multimaps': 3.0.0 + commander: 13.1.0 + source-map-support: 0.5.21 + + '@cucumber/gherkin@30.0.4': + dependencies: + '@cucumber/messages': 26.0.1 + + '@cucumber/gherkin@31.0.0': + dependencies: + '@cucumber/messages': 26.0.1 + + '@cucumber/html-formatter@21.10.1(@cucumber/messages@27.2.0)': + dependencies: + '@cucumber/messages': 27.2.0 + + '@cucumber/junit-xml-formatter@0.7.1(@cucumber/messages@27.2.0)': + dependencies: + '@cucumber/messages': 27.2.0 + '@cucumber/query': 13.6.0(@cucumber/messages@27.2.0) + '@teppeis/multimaps': 3.0.0 + luxon: 3.6.1 + xmlbuilder: 15.1.1 + + '@cucumber/message-streams@4.0.1(@cucumber/messages@27.2.0)': + dependencies: + '@cucumber/messages': 27.2.0 + + '@cucumber/messages@26.0.1': + dependencies: + '@types/uuid': 10.0.0 + class-transformer: 0.5.1 + reflect-metadata: 0.2.2 + uuid: 10.0.0 + + '@cucumber/messages@27.2.0': + dependencies: + '@types/uuid': 10.0.0 + class-transformer: 0.5.1 + reflect-metadata: 0.2.2 + uuid: 11.0.5 + + '@cucumber/query@13.6.0(@cucumber/messages@27.2.0)': + dependencies: + '@cucumber/messages': 27.2.0 + '@teppeis/multimaps': 3.0.0 + lodash.sortby: 4.7.0 + + '@cucumber/tag-expressions@6.1.2': {} + '@esbuild-plugins/node-globals-polyfill@0.2.3(esbuild@0.25.10)': dependencies: esbuild: 0.25.10 @@ -8840,6 +9210,8 @@ snapshots: dependencies: defer-to-connect: 2.0.1 + '@teppeis/multimaps@3.0.0': {} + '@tsconfig/node10@1.0.9': {} '@tsconfig/node12@1.0.11': {} @@ -8907,6 +9279,8 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/normalize-package-data@2.4.4': {} + '@types/prop-types@15.7.15': {} '@types/react@18.3.24': @@ -8923,6 +9297,8 @@ snapshots: '@types/unist@3.0.3': {} + '@types/uuid@10.0.0': {} + '@types/uuid@8.3.4': {} '@types/uuid@9.0.8': {} @@ -9080,21 +9456,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@7.13.1(typescript@5.9.3)': - dependencies: - '@typescript-eslint/types': 7.13.1 - '@typescript-eslint/visitor-keys': 7.13.1 - debug: 4.4.3(supports-color@8.1.1) - globby: 11.1.0 - is-glob: 4.0.3 - minimatch: 9.0.5 - semver: 7.7.3 - ts-api-utils: 1.3.0(typescript@5.9.3) - optionalDependencies: - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/typescript-estree@8.44.0(typescript@5.9.2)': dependencies: '@typescript-eslint/project-service': 8.44.0(typescript@5.9.2) @@ -9138,17 +9499,6 @@ snapshots: - supports-color - typescript - '@typescript-eslint/utils@7.13.1(eslint@9.36.0)(typescript@5.9.3)': - dependencies: - '@eslint-community/eslint-utils': 4.7.0(eslint@9.36.0) - '@typescript-eslint/scope-manager': 7.13.1 - '@typescript-eslint/types': 7.13.1 - '@typescript-eslint/typescript-estree': 7.13.1(typescript@5.9.3) - eslint: 9.36.0 - transitivePeerDependencies: - - supports-color - - typescript - '@typescript-eslint/utils@8.44.0(eslint@9.36.0)(typescript@5.9.2)': dependencies: '@eslint-community/eslint-utils': 4.9.0(eslint@9.36.0) @@ -9259,6 +9609,8 @@ snapshots: dependencies: environment: 1.1.0 + ansi-regex@4.1.1: {} + ansi-regex@5.0.1: {} ansi-regex@6.1.0: {} @@ -9278,6 +9630,8 @@ snapshots: ansis@3.17.0: {} + any-promise@1.3.0: {} + arg@4.1.3: {} argparse@2.0.1: {} @@ -9370,6 +9724,12 @@ snapshots: inherits: 2.0.4 minimalistic-assert: 1.0.1 + assertion-error-formatter@3.0.0: + dependencies: + diff: 4.0.2 + pad-right: 0.2.2 + repeat-string: 1.6.1 + assertion-error@2.0.1: {} async-function@1.0.0: {} @@ -9665,6 +10025,8 @@ snapshots: inherits: 2.0.4 safe-buffer: 5.2.1 + class-transformer@0.5.1: {} + clean-stack@3.0.1: dependencies: escape-string-regexp: 4.0.0 @@ -9685,6 +10047,12 @@ snapshots: cli-spinners@2.9.2: {} + cli-table3@0.6.5: + dependencies: + string-width: 4.2.3 + optionalDependencies: + '@colors/colors': 1.5.0 + cli-truncate@4.0.0: dependencies: slice-ansi: 5.0.0 @@ -9722,6 +10090,8 @@ snapshots: dependencies: delayed-stream: 1.0.0 + commander@10.0.1: {} + commander@12.1.0: {} commander@13.1.0: {} @@ -9732,6 +10102,8 @@ snapshots: commander@5.1.0: {} + commander@9.1.0: {} + commondir@1.0.1: {} concat-map@0.0.1: {} @@ -10015,6 +10387,10 @@ snapshots: dependencies: is-arrayish: 0.2.1 + error-stack-parser@2.1.4: + dependencies: + stackframe: 1.3.4 + es-abstract@1.22.2: dependencies: array-buffer-byte-length: 1.0.0 @@ -10277,8 +10653,7 @@ snapshots: escalade@3.2.0: {} - escape-string-regexp@1.0.5: - optional: true + escape-string-regexp@1.0.5: {} escape-string-regexp@2.0.0: {} @@ -10373,17 +10748,6 @@ snapshots: - supports-color - typescript - eslint-plugin-vitest@0.5.4(@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))(eslint@9.36.0)(typescript@5.9.3)(vitest@2.1.1(@types/node@22.16.5)(terser@5.43.1)): - dependencies: - '@typescript-eslint/utils': 7.13.1(eslint@9.36.0)(typescript@5.9.3) - eslint: 9.36.0 - optionalDependencies: - '@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) - vitest: 2.1.1(@types/node@22.16.5)(terser@5.43.1) - transitivePeerDependencies: - - supports-color - - typescript - eslint-scope@8.4.0: dependencies: esrecurse: 4.3.0 @@ -10527,6 +10891,10 @@ snapshots: wasmcurves: 0.2.2 web-worker: 1.2.0 + figures@3.2.0: + dependencies: + escape-string-regexp: 1.0.5 + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -10549,6 +10917,8 @@ snapshots: transitivePeerDependencies: - supports-color + find-up-simple@1.0.1: {} + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -10759,6 +11129,10 @@ snapshots: once: 1.4.0 path-is-absolute: 1.0.1 + global-dirs@3.0.1: + dependencies: + ini: 2.0.0 + globals@14.0.0: {} globals@15.9.0: {} @@ -10827,6 +11201,10 @@ snapshots: graphemer@1.4.0: {} + has-ansi@4.0.1: + dependencies: + ansi-regex: 4.1.1 + has-bigints@1.0.2: {} has-bigints@1.1.0: {} @@ -10991,6 +11369,8 @@ snapshots: indent-string@5.0.0: {} + index-to-position@1.2.0: {} + inflight@1.0.6: dependencies: once: 1.4.0 @@ -11000,6 +11380,8 @@ snapshots: ini@1.3.8: {} + ini@2.0.0: {} + ink@5.0.1(@types/react@18.3.24)(bufferutil@4.0.8)(react@18.3.1)(utf-8-validate@5.0.10): dependencies: '@alcalzone/ansi-tokenize': 0.1.3 @@ -11164,6 +11546,11 @@ snapshots: is-in-ci@0.1.0: {} + is-installed-globally@0.4.0: + dependencies: + global-dirs: 3.0.1 + is-path-inside: 3.0.3 + is-interactive@2.0.0: {} is-map@2.0.3: {} @@ -11380,6 +11767,10 @@ snapshots: kleur@3.0.3: {} + knuth-shuffle-seeded@1.0.6: + dependencies: + seed-random: 2.2.0 + levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -11399,6 +11790,10 @@ snapshots: lodash.merge@4.6.2: {} + lodash.mergewith@4.6.2: {} + + lodash.sortby@4.7.0: {} + lodash@4.17.21: {} log-symbols@4.1.0: @@ -11433,6 +11828,8 @@ snapshots: lunr@2.3.9: {} + luxon@3.6.1: {} + magic-string@0.30.11: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 @@ -11489,6 +11886,8 @@ snapshots: mime@1.6.0: {} + mime@3.0.0: {} + mimic-fn@2.1.0: {} mimic-function@5.0.1: {} @@ -11531,6 +11930,8 @@ snapshots: dependencies: minimist: 1.2.8 + mkdirp@2.1.6: {} + mkdirp@3.0.1: {} mocha@11.7.5: @@ -11565,6 +11966,12 @@ snapshots: mute-stream@2.0.0: {} + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + nanoid@3.3.8: {} natural-compare@1.4.0: {} @@ -11621,6 +12028,8 @@ snapshots: npm@10.9.3: {} + object-assign@4.1.1: {} + object-hash@3.0.0: {} object-inspect@1.12.3: {} @@ -11767,6 +12176,10 @@ snapshots: package-json-from-dist@1.0.1: {} + pad-right@0.2.2: + dependencies: + repeat-string: 1.6.1 + pako@2.1.0: {} param-case@3.0.4: @@ -11799,6 +12212,12 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 + parse-json@8.3.0: + dependencies: + '@babel/code-frame': 7.27.1 + index-to-position: 1.2.0 + type-fest: 4.41.0 + pascal-case@3.1.2: dependencies: no-case: 3.0.4 @@ -11899,11 +12318,15 @@ snapshots: process-nextick-args@2.0.1: {} + progress@2.0.3: {} + prompts@2.4.2: dependencies: kleur: 3.0.3 sisteransi: 1.0.5 + property-expr@2.0.6: {} + proto-list@1.2.4: {} proxy-from-env@1.1.0: {} @@ -11952,6 +12375,20 @@ snapshots: dependencies: loose-envify: 1.4.0 + read-package-up@11.0.0: + dependencies: + find-up-simple: 1.0.1 + read-pkg: 9.0.1 + type-fest: 4.41.0 + + read-pkg@9.0.1: + dependencies: + '@types/normalize-package-data': 2.4.4 + normalize-package-data: 6.0.2 + parse-json: 8.3.0 + type-fest: 4.41.0 + unicorn-magic: 0.1.0 + read-yaml-file@2.1.0: dependencies: js-yaml: 4.1.1 @@ -11979,6 +12416,8 @@ snapshots: dependencies: resolve: 1.22.8 + reflect-metadata@0.2.2: {} + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.8 @@ -11992,6 +12431,12 @@ snapshots: regenerator-runtime@0.14.0: {} + regexp-match-indices@1.0.2: + dependencies: + regexp-tree: 0.1.27 + + regexp-tree@0.1.27: {} + regexp.prototype.flags@1.5.1: dependencies: call-bind: 1.0.8 @@ -12018,6 +12463,8 @@ snapshots: dependencies: '@pnpm/npm-conf': 2.3.1 + repeat-string@1.6.1: {} + require-directory@2.1.1: {} requires-port@1.0.0: {} @@ -12200,10 +12647,14 @@ snapshots: secure-compare@3.0.1: {} + seed-random@2.2.0: {} + semver@6.3.1: {} semver@7.6.3: {} + semver@7.7.1: {} + semver@7.7.2: {} semver@7.7.3: {} @@ -12371,10 +12822,14 @@ snapshots: stackback@0.0.2: {} + stackframe@1.3.4: {} + std-env@3.7.0: {} stdin-discarder@0.2.2: {} + string-argv@0.3.1: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -12532,10 +12987,20 @@ snapshots: text-encoding-utf-8@1.0.2: {} + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + through@2.3.8: {} tightrope@0.2.0: {} + tiny-case@1.0.3: {} + tiny-jsonc@1.0.2: {} tinybench@2.9.0: {} @@ -12561,16 +13026,14 @@ snapshots: toml@3.0.0: {} + toposort@2.0.2: {} + tr46@0.0.3: {} ts-api-utils@1.3.0(typescript@5.9.2): dependencies: typescript: 5.9.2 - ts-api-utils@1.3.0(typescript@5.9.3): - dependencies: - typescript: 5.9.3 - ts-api-utils@2.1.0(typescript@5.9.2): dependencies: typescript: 5.9.2 @@ -12647,8 +13110,12 @@ snapshots: type-fest@0.21.3: {} + type-fest@2.19.0: {} + type-fest@4.35.0: {} + type-fest@4.41.0: {} + typed-array-buffer@1.0.0: dependencies: call-bind: 1.0.8 @@ -12776,6 +13243,8 @@ snapshots: undici-types@7.24.6: {} + unicorn-magic@0.1.0: {} + unicorn-magic@0.3.0: {} union@0.5.0: @@ -12809,8 +13278,14 @@ snapshots: node-gyp-build: 4.6.1 optional: true + util-arity@1.1.0: {} + util-deprecate@1.0.2: {} + uuid@10.0.0: {} + + uuid@11.0.5: {} + uuid@8.3.2: {} uuid@9.0.1: {} @@ -13053,6 +13528,8 @@ snapshots: bufferutil: 4.0.8 utf-8-validate: 5.0.10 + xmlbuilder@15.1.1: {} + y18n@5.0.8: {} yallist@3.1.1: {} @@ -13091,3 +13568,10 @@ snapshots: yoctocolors-cjs@2.1.3: {} yoga-wasm-web@0.3.3: {} + + yup@1.6.1: + dependencies: + property-expr: 2.0.6 + tiny-case: 1.0.3 + toposort: 2.0.2 + type-fest: 2.19.0