From 3883e4e305fd46bb593ef41c29a0fbcd1f1de220 Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Wed, 25 Mar 2026 21:31:58 +0000 Subject: [PATCH 01/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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