From 775443a0de15a66f9333e4d2cba7682d78491f68 Mon Sep 17 00:00:00 2001 From: tilo-14 Date: Wed, 18 Mar 2026 01:52:33 +0000 Subject: [PATCH 01/14] feat(compressed-token): add approve/revoke delegation for light-token ATAs Add TypeScript SDK functions to call the on-chain CTokenApprove (discriminator 4) and CTokenRevoke (discriminator 5) instruction handlers for light-token associated token accounts. New files: - instructions/approve-revoke.ts: sync instruction builders matching Rust SDK layout - actions/approve-interface.ts: async actions with cold loading + tx sending - tests/e2e/approve-revoke-light-token.test.ts: unit + E2E tests Also adds getLightTokenDelegate helper and extends FrozenOperation type. --- js/compressed-token/src/index.ts | 6 + .../src/v3/actions/approve-interface.ts | 393 +++++++++++++++ js/compressed-token/src/v3/actions/index.ts | 1 + .../src/v3/get-account-interface.ts | 2 +- .../src/v3/instructions/approve-revoke.ts | 106 ++++ .../src/v3/instructions/index.ts | 1 + js/compressed-token/src/v3/unified/index.ts | 120 +++++ .../e2e/approve-revoke-light-token.test.ts | 476 ++++++++++++++++++ .../tests/e2e/light-token-account-helpers.ts | 13 + 9 files changed, 1117 insertions(+), 1 deletion(-) create mode 100644 js/compressed-token/src/v3/actions/approve-interface.ts create mode 100644 js/compressed-token/src/v3/instructions/approve-revoke.ts create mode 100644 js/compressed-token/tests/e2e/approve-revoke-light-token.test.ts diff --git a/js/compressed-token/src/index.ts b/js/compressed-token/src/index.ts index c8c8cd34ec..d7844443c9 100644 --- a/js/compressed-token/src/index.ts +++ b/js/compressed-token/src/index.ts @@ -62,6 +62,8 @@ export { createLightTokenThawAccountInstruction, createLightTokenTransferInstruction, createLightTokenTransferCheckedInstruction, + createLightTokenApproveInstruction, + createLightTokenRevokeInstruction, // Types TokenMetadataInstructionData, CompressibleConfig, @@ -80,6 +82,10 @@ export { createTransferInterfaceInstructions, createTransferToAccountInterfaceInstructions, sliceLast, + approveInterface, + createApproveInterfaceInstructions, + revokeInterface, + createRevokeInterfaceInstructions, wrap, mintTo as mintToLightToken, mintToCompressed, diff --git a/js/compressed-token/src/v3/actions/approve-interface.ts b/js/compressed-token/src/v3/actions/approve-interface.ts new file mode 100644 index 0000000000..51c6dc22b4 --- /dev/null +++ b/js/compressed-token/src/v3/actions/approve-interface.ts @@ -0,0 +1,393 @@ +import { + ConfirmOptions, + PublicKey, + Signer, + TransactionSignature, + ComputeBudgetProgram, + TransactionInstruction, +} from '@solana/web3.js'; +import { + Rpc, + buildAndSignTx, + sendAndConfirmTx, + dedupeSigner, + assertBetaEnabled, +} from '@lightprotocol/stateless.js'; +import BN from 'bn.js'; +import { + createLightTokenApproveInstruction, + createLightTokenRevokeInstruction, +} from '../instructions/approve-revoke'; +import { getAssociatedTokenAddressInterface } from '../get-associated-token-address-interface'; +import { getMintInterface } from '../get-mint-interface'; +import { + getAtaInterface as _getAtaInterface, + checkNotFrozen, +} from '../get-account-interface'; +import { + _buildLoadBatches, + calculateLoadBatchComputeUnits, + type InternalLoadBatch, +} from '../instructions/load-ata'; +import { calculateCombinedCU } from '../instructions/calculate-combined-cu'; +import { assertTransactionSizeWithinLimit } from '../utils/estimate-tx-size'; +import { sliceLast } from './slice-last'; + +const APPROVE_BASE_CU = 10_000; + +function calculateApproveCU(loadBatch: InternalLoadBatch | null): number { + return calculateCombinedCU(APPROVE_BASE_CU, loadBatch); +} + +/** + * Approve a delegate for a light-token associated token account. + * + * Loads cold accounts if needed, then sends the approve instruction. + * + * @param rpc RPC connection + * @param payer Fee payer (signer) + * @param tokenAccount Light-token ATA address + * @param mint Mint address + * @param delegate Delegate to approve + * @param amount Amount to delegate + * @param owner Owner of the token account (signer) + * @param confirmOptions Optional confirm options + * @returns Transaction signature + */ +export async function approveInterface( + rpc: Rpc, + payer: Signer, + tokenAccount: PublicKey, + mint: PublicKey, + delegate: PublicKey, + amount: number | bigint | BN, + owner: Signer, + confirmOptions?: ConfirmOptions, +): Promise { + assertBetaEnabled(); + + const expectedAta = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); + if (!tokenAccount.equals(expectedAta)) { + throw new Error( + `Token account mismatch. Expected ${expectedAta.toBase58()}, got ${tokenAccount.toBase58()}`, + ); + } + + const mintInterface = await getMintInterface(rpc, mint); + const batches = await createApproveInterfaceInstructions( + rpc, + payer.publicKey, + mint, + tokenAccount, + delegate, + amount, + owner.publicKey, + mintInterface.mint.decimals, + ); + + const additionalSigners = dedupeSigner(payer, [owner]); + const { rest: loads, last: approveIxs } = sliceLast(batches); + + await Promise.all( + loads.map(async ixs => { + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx( + ixs, + payer, + blockhash, + additionalSigners, + ); + return sendAndConfirmTx(rpc, tx, confirmOptions); + }), + ); + + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx( + approveIxs, + payer, + blockhash, + additionalSigners, + ); + return sendAndConfirmTx(rpc, tx, confirmOptions); +} + +/** + * Build instruction batches for approving a delegate on a light-token ATA. + * + * Returns `TransactionInstruction[][]`. Send [0..n-2] in parallel, then [n-1]. + * + * @param rpc RPC connection + * @param payer Fee payer public key + * @param mint Mint address + * @param tokenAccount Light-token ATA address + * @param delegate Delegate to approve + * @param amount Amount to delegate + * @param owner Owner public key + * @param decimals Token decimals + * @returns Instruction batches + */ +export async function createApproveInterfaceInstructions( + rpc: Rpc, + payer: PublicKey, + mint: PublicKey, + tokenAccount: PublicKey, + delegate: PublicKey, + amount: number | bigint | BN, + owner: PublicKey, + decimals: number, +): Promise { + assertBetaEnabled(); + + const amountBigInt = BigInt(amount.toString()); + + const accountInterface = await _getAtaInterface( + rpc, + tokenAccount, + owner, + mint, + ); + + checkNotFrozen(accountInterface, 'approve'); + + const internalBatches = await _buildLoadBatches( + rpc, + payer, + accountInterface, + undefined, + false, + tokenAccount, + undefined, + owner, + decimals, + ); + + const approveIx = createLightTokenApproveInstruction( + tokenAccount, + delegate, + owner, + amountBigInt, + payer, + ); + + const numSigners = payer.equals(owner) ? 1 : 2; + + if (internalBatches.length === 0) { + const cu = calculateApproveCU(null); + const txIxs = [ + ComputeBudgetProgram.setComputeUnitLimit({ units: cu }), + approveIx, + ]; + assertTransactionSizeWithinLimit(txIxs, numSigners, 'Batch'); + return [txIxs]; + } + + if (internalBatches.length === 1) { + const batch = internalBatches[0]; + const cu = calculateApproveCU(batch); + const txIxs = [ + ComputeBudgetProgram.setComputeUnitLimit({ units: cu }), + ...batch.instructions, + approveIx, + ]; + assertTransactionSizeWithinLimit(txIxs, numSigners, 'Batch'); + return [txIxs]; + } + + const result: TransactionInstruction[][] = []; + + for (let i = 0; i < internalBatches.length - 1; i++) { + const batch = internalBatches[i]; + const cu = calculateLoadBatchComputeUnits(batch); + const txIxs = [ + ComputeBudgetProgram.setComputeUnitLimit({ units: cu }), + ...batch.instructions, + ]; + assertTransactionSizeWithinLimit(txIxs, numSigners, 'Batch'); + result.push(txIxs); + } + + const lastBatch = internalBatches[internalBatches.length - 1]; + const lastCu = calculateApproveCU(lastBatch); + const lastTxIxs = [ + ComputeBudgetProgram.setComputeUnitLimit({ units: lastCu }), + ...lastBatch.instructions, + approveIx, + ]; + assertTransactionSizeWithinLimit(lastTxIxs, numSigners, 'Batch'); + result.push(lastTxIxs); + + return result; +} + +/** + * Revoke delegation for a light-token associated token account. + * + * Loads cold accounts if needed, then sends the revoke instruction. + * + * @param rpc RPC connection + * @param payer Fee payer (signer) + * @param tokenAccount Light-token ATA address + * @param mint Mint address + * @param owner Owner of the token account (signer) + * @param confirmOptions Optional confirm options + * @returns Transaction signature + */ +export async function revokeInterface( + rpc: Rpc, + payer: Signer, + tokenAccount: PublicKey, + mint: PublicKey, + owner: Signer, + confirmOptions?: ConfirmOptions, +): Promise { + assertBetaEnabled(); + + const expectedAta = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); + if (!tokenAccount.equals(expectedAta)) { + throw new Error( + `Token account mismatch. Expected ${expectedAta.toBase58()}, got ${tokenAccount.toBase58()}`, + ); + } + + const mintInterface = await getMintInterface(rpc, mint); + const batches = await createRevokeInterfaceInstructions( + rpc, + payer.publicKey, + mint, + tokenAccount, + owner.publicKey, + mintInterface.mint.decimals, + ); + + const additionalSigners = dedupeSigner(payer, [owner]); + const { rest: loads, last: revokeIxs } = sliceLast(batches); + + await Promise.all( + loads.map(async ixs => { + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx( + ixs, + payer, + blockhash, + additionalSigners, + ); + return sendAndConfirmTx(rpc, tx, confirmOptions); + }), + ); + + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx(revokeIxs, payer, blockhash, additionalSigners); + return sendAndConfirmTx(rpc, tx, confirmOptions); +} + +/** + * Build instruction batches for revoking delegation on a light-token ATA. + * + * Returns `TransactionInstruction[][]`. Send [0..n-2] in parallel, then [n-1]. + * + * @param rpc RPC connection + * @param payer Fee payer public key + * @param mint Mint address + * @param tokenAccount Light-token ATA address + * @param owner Owner public key + * @param decimals Token decimals + * @returns Instruction batches + */ +export async function createRevokeInterfaceInstructions( + rpc: Rpc, + payer: PublicKey, + mint: PublicKey, + tokenAccount: PublicKey, + owner: PublicKey, + decimals: number, +): Promise { + assertBetaEnabled(); + + const accountInterface = await _getAtaInterface( + rpc, + tokenAccount, + owner, + mint, + ); + + checkNotFrozen(accountInterface, 'revoke'); + + const internalBatches = await _buildLoadBatches( + rpc, + payer, + accountInterface, + undefined, + false, + tokenAccount, + undefined, + owner, + decimals, + ); + + const revokeIx = createLightTokenRevokeInstruction( + tokenAccount, + owner, + payer, + ); + + const numSigners = payer.equals(owner) ? 1 : 2; + + if (internalBatches.length === 0) { + const cu = calculateApproveCU(null); + const txIxs = [ + ComputeBudgetProgram.setComputeUnitLimit({ units: cu }), + revokeIx, + ]; + assertTransactionSizeWithinLimit(txIxs, numSigners, 'Batch'); + return [txIxs]; + } + + if (internalBatches.length === 1) { + const batch = internalBatches[0]; + const cu = calculateApproveCU(batch); + const txIxs = [ + ComputeBudgetProgram.setComputeUnitLimit({ units: cu }), + ...batch.instructions, + revokeIx, + ]; + assertTransactionSizeWithinLimit(txIxs, numSigners, 'Batch'); + return [txIxs]; + } + + const result: TransactionInstruction[][] = []; + + for (let i = 0; i < internalBatches.length - 1; i++) { + const batch = internalBatches[i]; + const cu = calculateLoadBatchComputeUnits(batch); + const txIxs = [ + ComputeBudgetProgram.setComputeUnitLimit({ units: cu }), + ...batch.instructions, + ]; + assertTransactionSizeWithinLimit(txIxs, numSigners, 'Batch'); + result.push(txIxs); + } + + const lastBatch = internalBatches[internalBatches.length - 1]; + const lastCu = calculateApproveCU(lastBatch); + const lastTxIxs = [ + ComputeBudgetProgram.setComputeUnitLimit({ units: lastCu }), + ...lastBatch.instructions, + revokeIx, + ]; + assertTransactionSizeWithinLimit(lastTxIxs, numSigners, 'Batch'); + result.push(lastTxIxs); + + return result; +} + +export { sliceLast } from './slice-last'; +export { + createLightTokenApproveInstruction, + createLightTokenRevokeInstruction, +} from '../instructions/approve-revoke'; diff --git a/js/compressed-token/src/v3/actions/index.ts b/js/compressed-token/src/v3/actions/index.ts index 6873e9f6c5..8673f1beed 100644 --- a/js/compressed-token/src/v3/actions/index.ts +++ b/js/compressed-token/src/v3/actions/index.ts @@ -12,3 +12,4 @@ export * from './transfer-interface'; export * from './wrap'; export * from './unwrap'; export * from './load-ata'; +export * from './approve-interface'; diff --git a/js/compressed-token/src/v3/get-account-interface.ts b/js/compressed-token/src/v3/get-account-interface.ts index 48d7cfb3dc..21bf2ac33e 100644 --- a/js/compressed-token/src/v3/get-account-interface.ts +++ b/js/compressed-token/src/v3/get-account-interface.ts @@ -92,7 +92,7 @@ function throwIfUnexpectedRpcErrors( } } -export type FrozenOperation = 'load' | 'transfer' | 'unwrap'; +export type FrozenOperation = 'load' | 'transfer' | 'unwrap' | 'approve' | 'revoke'; export function checkNotFrozen( iface: AccountInterface, diff --git a/js/compressed-token/src/v3/instructions/approve-revoke.ts b/js/compressed-token/src/v3/instructions/approve-revoke.ts new file mode 100644 index 0000000000..5b90ca5ac5 --- /dev/null +++ b/js/compressed-token/src/v3/instructions/approve-revoke.ts @@ -0,0 +1,106 @@ +import { + PublicKey, + TransactionInstruction, + SystemProgram, +} from '@solana/web3.js'; +import { Buffer } from 'buffer'; +import { LIGHT_TOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; + +const LIGHT_TOKEN_APPROVE_DISCRIMINATOR = 4; +const LIGHT_TOKEN_REVOKE_DISCRIMINATOR = 5; + +/** + * Create an instruction to approve a delegate for a light-token account. + * + * Account order per program: + * 0. token_account (mutable) - the light-token account + * 1. delegate (readonly) - the delegate to approve + * 2. owner (signer) - owner of the token account + * 3. system_program (readonly) - for rent top-ups via CPI + * 4. fee_payer (mutable, signer) - pays for rent top-ups + * + * @param tokenAccount The light-token account to set delegation on + * @param delegate The delegate to approve + * @param owner Owner of the token account (signer) + * @param amount Amount of tokens to delegate + * @param feePayer Optional fee payer for rent top-ups (defaults to owner) + * @returns TransactionInstruction + */ +export function createLightTokenApproveInstruction( + tokenAccount: PublicKey, + delegate: PublicKey, + owner: PublicKey, + amount: number | bigint, + feePayer?: PublicKey, +): TransactionInstruction { + const data = Buffer.alloc(9); + data.writeUInt8(LIGHT_TOKEN_APPROVE_DISCRIMINATOR, 0); + data.writeBigUInt64LE(BigInt(amount), 1); + + const effectiveFeePayer = feePayer ?? 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, + }); +} + +/** + * Create an instruction to revoke delegation for a light-token account. + * + * Account order per program: + * 0. token_account (mutable) - the light-token account + * 1. owner (signer) - owner of the token account + * 2. system_program (readonly) - for rent top-ups via CPI + * 3. fee_payer (mutable, signer) - pays for rent top-ups + * + * @param tokenAccount The light-token account to revoke delegation on + * @param owner Owner of the token account (signer) + * @param feePayer Optional fee payer for rent top-ups (defaults to owner) + * @returns TransactionInstruction + */ +export function createLightTokenRevokeInstruction( + tokenAccount: PublicKey, + owner: PublicKey, + feePayer?: PublicKey, +): TransactionInstruction { + const effectiveFeePayer = feePayer ?? 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]), + }); +} diff --git a/js/compressed-token/src/v3/instructions/index.ts b/js/compressed-token/src/v3/instructions/index.ts index 4c4e29e369..597261b811 100644 --- a/js/compressed-token/src/v3/instructions/index.ts +++ b/js/compressed-token/src/v3/instructions/index.ts @@ -12,3 +12,4 @@ export * from './transfer-interface'; export * from './wrap'; export * from './unwrap'; export * from './freeze-thaw'; +export * from './approve-revoke'; diff --git a/js/compressed-token/src/v3/unified/index.ts b/js/compressed-token/src/v3/unified/index.ts index c61a60cf0a..630f953373 100644 --- a/js/compressed-token/src/v3/unified/index.ts +++ b/js/compressed-token/src/v3/unified/index.ts @@ -37,6 +37,12 @@ import { createTransferToAccountInterfaceInstructions as _createTransferToAccountInterfaceInstructions, } from '../actions/transfer-interface'; import type { TransferOptions as _TransferOptions } from '../actions/transfer-interface'; +import { + approveInterface as _approveInterface, + createApproveInterfaceInstructions as _createApproveInterfaceInstructions, + revokeInterface as _revokeInterface, + createRevokeInterfaceInstructions as _createRevokeInterfaceInstructions, +} from '../actions/approve-interface'; import { _getOrCreateAtaInterface } from '../actions/get-or-create-ata-interface'; import { createUnwrapInstructions as _createUnwrapInstructions, @@ -493,6 +499,118 @@ export type { _TransferOptions as TransferToAccountOptions, }; +/** + * Approve a delegate for a light-token associated token account. + * + * @param rpc RPC connection + * @param payer Fee payer (signer) + * @param tokenAccount Light-token ATA address + * @param mint Mint address + * @param delegate Delegate to approve + * @param amount Amount to delegate + * @param owner Owner of the token account (signer) + * @param confirmOptions Optional confirm options + * @returns Transaction signature + */ +export async function approveInterface( + rpc: Rpc, + payer: Signer, + tokenAccount: PublicKey, + mint: PublicKey, + delegate: PublicKey, + amount: number | bigint | BN, + owner: Signer, + confirmOptions?: ConfirmOptions, +) { + return _approveInterface( + rpc, + payer, + tokenAccount, + mint, + delegate, + amount, + owner, + confirmOptions, + ); +} + +/** + * Build instruction batches for approving a delegate on a light-token ATA. + */ +export async function createApproveInterfaceInstructions( + rpc: Rpc, + payer: PublicKey, + mint: PublicKey, + tokenAccount: PublicKey, + delegate: PublicKey, + amount: number | bigint | BN, + owner: PublicKey, + decimals: number, +): Promise { + const mintInterface = await getMintInterface(rpc, mint); + return _createApproveInterfaceInstructions( + rpc, + payer, + mint, + tokenAccount, + delegate, + amount, + owner, + decimals ?? mintInterface.mint.decimals, + ); +} + +/** + * Revoke delegation for a light-token associated token account. + * + * @param rpc RPC connection + * @param payer Fee payer (signer) + * @param tokenAccount Light-token ATA address + * @param mint Mint address + * @param owner Owner of the token account (signer) + * @param confirmOptions Optional confirm options + * @returns Transaction signature + */ +export async function revokeInterface( + rpc: Rpc, + payer: Signer, + tokenAccount: PublicKey, + mint: PublicKey, + owner: Signer, + confirmOptions?: ConfirmOptions, +) { + return _revokeInterface( + rpc, + payer, + tokenAccount, + mint, + owner, + confirmOptions, + ); +} + +/** + * Build instruction batches for revoking delegation on a light-token ATA. + */ +export async function createRevokeInterfaceInstructions( + rpc: Rpc, + payer: PublicKey, + mint: PublicKey, + tokenAccount: PublicKey, + owner: PublicKey, + decimals: number, +): Promise { + const mintInterface = await getMintInterface(rpc, mint); + return _createRevokeInterfaceInstructions( + rpc, + payer, + mint, + tokenAccount, + owner, + decimals ?? mintInterface.mint.decimals, + ); +} + export { getAccountInterface, AccountInterface, @@ -540,6 +658,8 @@ export { createLightTokenTransferCheckedInstruction, createLightTokenFreezeAccountInstruction, createLightTokenThawAccountInstruction, + createLightTokenApproveInstruction, + createLightTokenRevokeInstruction, // Types TokenMetadataInstructionData, CompressibleConfig, diff --git a/js/compressed-token/tests/e2e/approve-revoke-light-token.test.ts b/js/compressed-token/tests/e2e/approve-revoke-light-token.test.ts new file mode 100644 index 0000000000..7e0f693a3f --- /dev/null +++ b/js/compressed-token/tests/e2e/approve-revoke-light-token.test.ts @@ -0,0 +1,476 @@ +/** + * E2E tests for createLightTokenApproveInstruction and + * createLightTokenRevokeInstruction (native discriminator 4/5). + * + * These instructions operate on decompressed light-token (hot) accounts, + * mimicking SPL Token approve/revoke semantics through the LightToken program. + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import { Keypair, PublicKey, Signer, SystemProgram } from '@solana/web3.js'; +import { + Rpc, + bn, + newAccountWithLamports, + createRpc, + selectStateTreeInfo, + TreeInfo, + VERSION, + featureFlags, + buildAndSignTx, + sendAndConfirmTx, + LIGHT_TOKEN_PROGRAM_ID, +} from '@lightprotocol/stateless.js'; +import { TOKEN_PROGRAM_ID } from '@solana/spl-token'; +import { createMint, mintTo } from '../../src/actions'; +import { + getTokenPoolInfos, + selectTokenPoolInfo, + TokenPoolInfo, +} from '../../src/utils/get-token-pool-infos'; +import { getAssociatedTokenAddressInterface } from '../../src/v3/get-associated-token-address-interface'; +import { createAtaInterfaceIdempotent } from '../../src/v3/actions/create-ata-interface'; +import { loadAta } from '../../src/v3/actions/load-ata'; +import { + createLightTokenApproveInstruction, + createLightTokenRevokeInstruction, +} from '../../src/v3/instructions/approve-revoke'; +import { + getLightTokenBalance, + getLightTokenDelegate, +} from './light-token-account-helpers'; + +featureFlags.version = VERSION.V2; + +const TEST_TOKEN_DECIMALS = 9; + +// --------------------------------------------------------------------------- +// Unit tests (no RPC required) +// --------------------------------------------------------------------------- + +describe('createLightTokenApproveInstruction - unit', () => { + const tokenAccount = Keypair.generate().publicKey; + const delegate = Keypair.generate().publicKey; + const owner = Keypair.generate().publicKey; + + it('uses LIGHT_TOKEN_PROGRAM_ID as programId', () => { + const ix = createLightTokenApproveInstruction( + tokenAccount, + delegate, + owner, + BigInt(100), + ); + expect(ix.programId.equals(LIGHT_TOKEN_PROGRAM_ID)).toBe(true); + }); + + it('encodes discriminator byte 4', () => { + const ix = createLightTokenApproveInstruction( + tokenAccount, + delegate, + owner, + BigInt(100), + ); + expect(ix.data[0]).toBe(4); + }); + + it('encodes amount as u64 LE', () => { + const amount = BigInt(1_000_000_000); + const ix = createLightTokenApproveInstruction( + tokenAccount, + delegate, + owner, + amount, + ); + expect(ix.data.length).toBe(9); + const decoded = ix.data.readBigUInt64LE(1); + expect(decoded).toBe(amount); + }); + + it('has exactly 5 account metas in correct order (owner == feePayer)', () => { + const ix = createLightTokenApproveInstruction( + tokenAccount, + delegate, + owner, + BigInt(100), + ); + expect(ix.keys.length).toBe(5); + + // 0: token_account - mutable, not signer + expect(ix.keys[0].pubkey.equals(tokenAccount)).toBe(true); + expect(ix.keys[0].isWritable).toBe(true); + expect(ix.keys[0].isSigner).toBe(false); + + // 1: delegate - readonly, not signer + expect(ix.keys[1].pubkey.equals(delegate)).toBe(true); + expect(ix.keys[1].isWritable).toBe(false); + expect(ix.keys[1].isSigner).toBe(false); + + // 2: owner - signer, writable (because owner == feePayer) + expect(ix.keys[2].pubkey.equals(owner)).toBe(true); + expect(ix.keys[2].isSigner).toBe(true); + expect(ix.keys[2].isWritable).toBe(true); + + // 3: system_program - readonly + expect(ix.keys[3].pubkey.equals(SystemProgram.programId)).toBe(true); + expect(ix.keys[3].isWritable).toBe(false); + expect(ix.keys[3].isSigner).toBe(false); + + // 4: fee_payer (== owner) - writable, not signer (owner already signed) + expect(ix.keys[4].pubkey.equals(owner)).toBe(true); + expect(ix.keys[4].isWritable).toBe(true); + expect(ix.keys[4].isSigner).toBe(false); + }); + + it('has correct flags with separate fee payer', () => { + const feePayer = Keypair.generate().publicKey; + const ix = createLightTokenApproveInstruction( + tokenAccount, + delegate, + owner, + BigInt(100), + feePayer, + ); + expect(ix.keys.length).toBe(5); + + // 2: owner - signer, readonly (feePayer pays) + expect(ix.keys[2].pubkey.equals(owner)).toBe(true); + expect(ix.keys[2].isSigner).toBe(true); + expect(ix.keys[2].isWritable).toBe(false); + + // 4: fee_payer - writable, signer + expect(ix.keys[4].pubkey.equals(feePayer)).toBe(true); + expect(ix.keys[4].isWritable).toBe(true); + expect(ix.keys[4].isSigner).toBe(true); + }); +}); + +describe('createLightTokenRevokeInstruction - unit', () => { + const tokenAccount = Keypair.generate().publicKey; + const owner = Keypair.generate().publicKey; + + it('uses LIGHT_TOKEN_PROGRAM_ID as programId', () => { + const ix = createLightTokenRevokeInstruction(tokenAccount, owner); + expect(ix.programId.equals(LIGHT_TOKEN_PROGRAM_ID)).toBe(true); + }); + + it('encodes discriminator byte 5', () => { + const ix = createLightTokenRevokeInstruction(tokenAccount, owner); + expect(ix.data.length).toBe(1); + expect(ix.data[0]).toBe(5); + }); + + it('has exactly 4 account metas in correct order (owner == feePayer)', () => { + const ix = createLightTokenRevokeInstruction(tokenAccount, owner); + expect(ix.keys.length).toBe(4); + + // 0: token_account - mutable, not signer + expect(ix.keys[0].pubkey.equals(tokenAccount)).toBe(true); + expect(ix.keys[0].isWritable).toBe(true); + expect(ix.keys[0].isSigner).toBe(false); + + // 1: owner - signer, writable (because owner == feePayer) + expect(ix.keys[1].pubkey.equals(owner)).toBe(true); + expect(ix.keys[1].isSigner).toBe(true); + expect(ix.keys[1].isWritable).toBe(true); + + // 2: system_program - readonly + expect(ix.keys[2].pubkey.equals(SystemProgram.programId)).toBe(true); + expect(ix.keys[2].isWritable).toBe(false); + expect(ix.keys[2].isSigner).toBe(false); + + // 3: fee_payer (== owner) - writable, not signer + expect(ix.keys[3].pubkey.equals(owner)).toBe(true); + expect(ix.keys[3].isWritable).toBe(true); + expect(ix.keys[3].isSigner).toBe(false); + }); + + it('has correct flags with separate fee payer', () => { + const feePayer = Keypair.generate().publicKey; + const ix = createLightTokenRevokeInstruction( + tokenAccount, + owner, + feePayer, + ); + expect(ix.keys.length).toBe(4); + + // 1: owner - signer, readonly + expect(ix.keys[1].pubkey.equals(owner)).toBe(true); + expect(ix.keys[1].isSigner).toBe(true); + expect(ix.keys[1].isWritable).toBe(false); + + // 3: fee_payer - writable, signer + expect(ix.keys[3].pubkey.equals(feePayer)).toBe(true); + expect(ix.keys[3].isWritable).toBe(true); + expect(ix.keys[3].isSigner).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// E2E tests +// --------------------------------------------------------------------------- + +describe('LightToken approve/revoke - E2E', () => { + let rpc: Rpc; + let payer: Signer; + let mint: PublicKey; + let mintAuthority: Keypair; + let stateTreeInfo: TreeInfo; + let tokenPoolInfos: TokenPoolInfo[]; + + beforeAll(async () => { + rpc = createRpc(); + payer = await newAccountWithLamports(rpc, 10e9); + mintAuthority = Keypair.generate(); + const mintKeypair = Keypair.generate(); + + mint = ( + await createMint( + rpc, + payer, + mintAuthority.publicKey, + TEST_TOKEN_DECIMALS, + mintKeypair, + undefined, + TOKEN_PROGRAM_ID, + ) + ).mint; + + stateTreeInfo = selectStateTreeInfo(await rpc.getStateTreeInfos()); + tokenPoolInfos = await getTokenPoolInfos(rpc, mint); + }, 60_000); + + it('should approve a delegate on a hot light-token account', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + const delegate = Keypair.generate(); + const lightTokenAta = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); + + // Create hot ATA and mint tokens + await createAtaInterfaceIdempotent(rpc, payer, mint, owner.publicKey); + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(1000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + await loadAta(rpc, lightTokenAta, owner, mint, payer); + + // Approve delegate for 500 tokens + const approveIx = createLightTokenApproveInstruction( + lightTokenAta, + delegate.publicKey, + owner.publicKey, + BigInt(500), + (payer as Keypair).publicKey, + ); + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx([approveIx], payer, blockhash, [ + owner as Keypair, + ]); + await sendAndConfirmTx(rpc, tx); + + // Verify delegate is set + const { delegate: actualDelegate, delegatedAmount } = + await getLightTokenDelegate(rpc, lightTokenAta); + expect(actualDelegate).not.toBeNull(); + expect(actualDelegate!.equals(delegate.publicKey)).toBe(true); + expect(delegatedAmount).toBe(BigInt(500)); + + // Balance unchanged + const balance = await getLightTokenBalance(rpc, lightTokenAta); + expect(balance).toBe(BigInt(1000)); + }, 60_000); + + it('should revoke a delegate on a hot light-token account', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + const delegate = Keypair.generate(); + const lightTokenAta = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); + + // Setup: create ATA, mint, load, approve + await createAtaInterfaceIdempotent(rpc, payer, mint, owner.publicKey); + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(1000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + await loadAta(rpc, lightTokenAta, owner, mint, payer); + + const approveIx = createLightTokenApproveInstruction( + lightTokenAta, + delegate.publicKey, + owner.publicKey, + BigInt(500), + (payer as Keypair).publicKey, + ); + let { blockhash } = await rpc.getLatestBlockhash(); + let tx = buildAndSignTx([approveIx], payer, blockhash, [ + owner as Keypair, + ]); + await sendAndConfirmTx(rpc, tx); + + // Confirm delegate is set + const beforeRevoke = await getLightTokenDelegate(rpc, lightTokenAta); + expect(beforeRevoke.delegate).not.toBeNull(); + + // Revoke + const revokeIx = createLightTokenRevokeInstruction( + lightTokenAta, + owner.publicKey, + (payer as Keypair).publicKey, + ); + ({ blockhash } = await rpc.getLatestBlockhash()); + tx = buildAndSignTx([revokeIx], payer, blockhash, [owner as Keypair]); + await sendAndConfirmTx(rpc, tx); + + // Verify delegate is cleared + const afterRevoke = await getLightTokenDelegate(rpc, lightTokenAta); + expect(afterRevoke.delegate).toBeNull(); + expect(afterRevoke.delegatedAmount).toBe(BigInt(0)); + }, 60_000); + + it('should overwrite delegate with a new approval', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + const delegate1 = Keypair.generate(); + const delegate2 = Keypair.generate(); + const lightTokenAta = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); + + await createAtaInterfaceIdempotent(rpc, payer, mint, owner.publicKey); + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(1000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + await loadAta(rpc, lightTokenAta, owner, mint, payer); + + // Approve delegate1 + let ix = createLightTokenApproveInstruction( + lightTokenAta, + delegate1.publicKey, + owner.publicKey, + BigInt(300), + (payer as Keypair).publicKey, + ); + let { blockhash } = await rpc.getLatestBlockhash(); + let tx = buildAndSignTx([ix], payer, blockhash, [owner as Keypair]); + await sendAndConfirmTx(rpc, tx); + + let info = await getLightTokenDelegate(rpc, lightTokenAta); + expect(info.delegate!.equals(delegate1.publicKey)).toBe(true); + expect(info.delegatedAmount).toBe(BigInt(300)); + + // Overwrite with delegate2 and different amount + ix = createLightTokenApproveInstruction( + lightTokenAta, + delegate2.publicKey, + owner.publicKey, + BigInt(700), + (payer as Keypair).publicKey, + ); + ({ blockhash } = await rpc.getLatestBlockhash()); + tx = buildAndSignTx([ix], payer, blockhash, [owner as Keypair]); + await sendAndConfirmTx(rpc, tx); + + info = await getLightTokenDelegate(rpc, lightTokenAta); + expect(info.delegate!.equals(delegate2.publicKey)).toBe(true); + expect(info.delegatedAmount).toBe(BigInt(700)); + }, 60_000); + + it('should fail when non-owner tries to approve', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + const wrongSigner = Keypair.generate(); + const delegate = Keypair.generate(); + const lightTokenAta = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); + + await createAtaInterfaceIdempotent(rpc, payer, mint, owner.publicKey); + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(100), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + await loadAta(rpc, lightTokenAta, owner, mint, payer); + + // Try to approve with wrong signer + const ix = createLightTokenApproveInstruction( + lightTokenAta, + delegate.publicKey, + wrongSigner.publicKey, + BigInt(50), + (payer as Keypair).publicKey, + ); + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx([ix], payer, blockhash, [wrongSigner]); + + await expect(sendAndConfirmTx(rpc, tx)).rejects.toThrow(); + }, 60_000); + + it('should work with separate fee payer', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + const sponsor = await newAccountWithLamports(rpc, 1e9); + const delegate = Keypair.generate(); + const lightTokenAta = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); + + await createAtaInterfaceIdempotent(rpc, payer, mint, owner.publicKey); + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(500), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + await loadAta(rpc, lightTokenAta, owner, mint, payer); + + // Approve with sponsor as fee payer + const ix = createLightTokenApproveInstruction( + lightTokenAta, + delegate.publicKey, + owner.publicKey, + BigInt(250), + sponsor.publicKey, + ); + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx([ix], sponsor, blockhash, [ + owner as Keypair, + ]); + await sendAndConfirmTx(rpc, tx); + + const { delegate: actualDelegate, delegatedAmount } = + await getLightTokenDelegate(rpc, lightTokenAta); + expect(actualDelegate!.equals(delegate.publicKey)).toBe(true); + expect(delegatedAmount).toBe(BigInt(250)); + }, 60_000); +}); diff --git a/js/compressed-token/tests/e2e/light-token-account-helpers.ts b/js/compressed-token/tests/e2e/light-token-account-helpers.ts index 3e2163401a..e378b993c3 100644 --- a/js/compressed-token/tests/e2e/light-token-account-helpers.ts +++ b/js/compressed-token/tests/e2e/light-token-account-helpers.ts @@ -17,6 +17,19 @@ export async function getLightTokenBalance( return parsed.amount; } +export async function getLightTokenDelegate( + rpc: Rpc, + address: PublicKey, +): Promise<{ delegate: PublicKey | null; delegatedAmount: bigint }> { + const info = await rpc.getAccountInfo(address); + if (!info) return { delegate: null, delegatedAmount: BigInt(0) }; + const { parsed } = parseLightTokenHot(address, info); + return { + delegate: parsed.delegate, + delegatedAmount: parsed.delegatedAmount ?? BigInt(0), + }; +} + export async function getLightTokenState( rpc: Rpc, address: PublicKey, From 0c30b491071ef677fa6c1f4edc9b913f9c5471dd Mon Sep 17 00:00:00 2001 From: tilo-14 Date: Wed, 18 Mar 2026 02:02:52 +0000 Subject: [PATCH 02/14] fix(sdk): make decimals optional in unified approve/revoke wrappers Avoid unnecessary getMintInterface RPC call when caller provides decimals. --- js/compressed-token/src/v3/unified/index.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/js/compressed-token/src/v3/unified/index.ts b/js/compressed-token/src/v3/unified/index.ts index 630f953373..c8da01f0ae 100644 --- a/js/compressed-token/src/v3/unified/index.ts +++ b/js/compressed-token/src/v3/unified/index.ts @@ -545,9 +545,10 @@ export async function createApproveInterfaceInstructions( delegate: PublicKey, amount: number | bigint | BN, owner: PublicKey, - decimals: number, + decimals?: number, ): Promise { - const mintInterface = await getMintInterface(rpc, mint); + const resolvedDecimals = + decimals ?? (await getMintInterface(rpc, mint)).mint.decimals; return _createApproveInterfaceInstructions( rpc, payer, @@ -556,7 +557,7 @@ export async function createApproveInterfaceInstructions( delegate, amount, owner, - decimals ?? mintInterface.mint.decimals, + resolvedDecimals, ); } @@ -598,16 +599,17 @@ export async function createRevokeInterfaceInstructions( mint: PublicKey, tokenAccount: PublicKey, owner: PublicKey, - decimals: number, + decimals?: number, ): Promise { - const mintInterface = await getMintInterface(rpc, mint); + const resolvedDecimals = + decimals ?? (await getMintInterface(rpc, mint)).mint.decimals; return _createRevokeInterfaceInstructions( rpc, payer, mint, tokenAccount, owner, - decimals ?? mintInterface.mint.decimals, + resolvedDecimals, ); } From 026cadd2fd05308fcdfd91f8b58aabbe3c0f0434 Mon Sep 17 00:00:00 2001 From: tilo-14 Date: Wed, 18 Mar 2026 21:08:06 +0000 Subject: [PATCH 03/14] feat(sdk): add transferDelegated for light-token ATAs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add transferDelegatedInterface action and unified wrapper, completing the approve → transfer → revoke delegation flow for light-token ATAs. --- js/compressed-token/src/index.ts | 2 + js/compressed-token/src/v3/actions/index.ts | 1 + .../actions/transfer-delegated-interface.ts | 96 +++++++++++ js/compressed-token/src/v3/unified/index.ts | 76 +++++++++ .../e2e/transfer-delegated-failures.test.ts | 145 +++++++++++++++++ .../e2e/transfer-delegated-interface.test.ts | 149 ++++++++++++++++++ 6 files changed, 469 insertions(+) create mode 100644 js/compressed-token/src/v3/actions/transfer-delegated-interface.ts create mode 100644 js/compressed-token/tests/e2e/transfer-delegated-failures.test.ts create mode 100644 js/compressed-token/tests/e2e/transfer-delegated-interface.test.ts diff --git a/js/compressed-token/src/index.ts b/js/compressed-token/src/index.ts index d7844443c9..6072302bea 100644 --- a/js/compressed-token/src/index.ts +++ b/js/compressed-token/src/index.ts @@ -86,6 +86,8 @@ export { createApproveInterfaceInstructions, revokeInterface, createRevokeInterfaceInstructions, + transferDelegatedInterface, + createTransferDelegatedInterfaceInstructions, wrap, mintTo as mintToLightToken, mintToCompressed, diff --git a/js/compressed-token/src/v3/actions/index.ts b/js/compressed-token/src/v3/actions/index.ts index 8673f1beed..bca76538ba 100644 --- a/js/compressed-token/src/v3/actions/index.ts +++ b/js/compressed-token/src/v3/actions/index.ts @@ -13,3 +13,4 @@ export * from './wrap'; export * from './unwrap'; export * from './load-ata'; export * from './approve-interface'; +export * from './transfer-delegated-interface'; diff --git a/js/compressed-token/src/v3/actions/transfer-delegated-interface.ts b/js/compressed-token/src/v3/actions/transfer-delegated-interface.ts new file mode 100644 index 0000000000..09490d6290 --- /dev/null +++ b/js/compressed-token/src/v3/actions/transfer-delegated-interface.ts @@ -0,0 +1,96 @@ +import { + ConfirmOptions, + PublicKey, + Signer, + TransactionInstruction, + TransactionSignature, +} from '@solana/web3.js'; +import { Rpc, assertBetaEnabled } from '@lightprotocol/stateless.js'; +import BN from 'bn.js'; +import { transferInterface } from './transfer-interface'; +import { createTransferInterfaceInstructions } from '../instructions/transfer-interface'; + +/** + * Transfer tokens from a light-token ATA as an approved delegate. + * + * Convenience wrapper around {@link transferInterface} that makes the + * delegate-transfer API explicit: the delegate is a {@link Signer} (authority), + * the owner is a {@link PublicKey} (used only for ATA derivation, does NOT + * sign). + * + * @param rpc RPC connection + * @param payer Fee payer (signer) + * @param source Source light-token ATA (owner's account) + * @param mint Mint address + * @param destination Destination light-token ATA + * @param delegate Delegate authority (signer) + * @param owner Owner of the source ATA (does not sign) + * @param amount Amount to transfer (must be within approved allowance) + * @param confirmOptions Optional confirm options + * @returns Transaction signature + */ +export async function transferDelegatedInterface( + rpc: Rpc, + payer: Signer, + source: PublicKey, + mint: PublicKey, + destination: PublicKey, + delegate: Signer, + owner: PublicKey, + amount: number | bigint | BN, + confirmOptions?: ConfirmOptions, +): Promise { + assertBetaEnabled(); + + return transferInterface( + rpc, + payer, + source, + mint, + destination, + delegate, + amount, + undefined, + confirmOptions, + { owner }, + ); +} + +/** + * Build instruction batches for a delegated transfer on a light-token ATA. + * + * Returns `TransactionInstruction[][]`. Send [0..n-2] in parallel, then [n-1]. + * + * @param rpc RPC connection + * @param payer Fee payer public key + * @param mint Mint address + * @param amount Amount to transfer + * @param delegate Delegate public key (authority) + * @param owner Owner of the source ATA (for derivation) + * @param destination Destination ATA address + * @param decimals Token decimals + * @returns Instruction batches + */ +export async function createTransferDelegatedInterfaceInstructions( + rpc: Rpc, + payer: PublicKey, + mint: PublicKey, + amount: number | bigint | BN, + delegate: PublicKey, + owner: PublicKey, + destination: PublicKey, + decimals: number, +): Promise { + assertBetaEnabled(); + + return createTransferInterfaceInstructions( + rpc, + payer, + mint, + amount, + delegate, + destination, + decimals, + { owner }, + ); +} diff --git a/js/compressed-token/src/v3/unified/index.ts b/js/compressed-token/src/v3/unified/index.ts index c8da01f0ae..fce8b95f96 100644 --- a/js/compressed-token/src/v3/unified/index.ts +++ b/js/compressed-token/src/v3/unified/index.ts @@ -43,6 +43,10 @@ import { revokeInterface as _revokeInterface, createRevokeInterfaceInstructions as _createRevokeInterfaceInstructions, } from '../actions/approve-interface'; +import { + transferDelegatedInterface as _transferDelegatedInterface, + createTransferDelegatedInterfaceInstructions as _createTransferDelegatedInterfaceInstructions, +} from '../actions/transfer-delegated-interface'; import { _getOrCreateAtaInterface } from '../actions/get-or-create-ata-interface'; import { createUnwrapInstructions as _createUnwrapInstructions, @@ -613,6 +617,78 @@ export async function createRevokeInterfaceInstructions( ); } +/** + * Transfer tokens from a light-token ATA as an approved delegate. + * + * Auto-detects mint type (light-token, SPL, or Token-2022) and dispatches + * to the appropriate instruction. + * + * @param rpc RPC connection + * @param payer Fee payer (signer) + * @param source Source ATA (owner's account) + * @param mint Mint address + * @param recipient Recipient wallet address (ATA derived + created internally) + * @param delegate Delegate authority (signer) + * @param owner Owner of the source ATA (does not sign) + * @param amount Amount to transfer + * @param confirmOptions Optional confirm options + * @returns Transaction signature + */ +export async function transferDelegatedInterface( + rpc: Rpc, + payer: Signer, + source: PublicKey, + mint: PublicKey, + recipient: PublicKey, + delegate: Signer, + owner: PublicKey, + amount: number | bigint | BN, + confirmOptions?: ConfirmOptions, +) { + const mintInfo = await getMintInterface(rpc, mint); + return _transferDelegatedInterface( + rpc, + payer, + source, + mint, + recipient, + delegate, + owner, + amount, + confirmOptions, + mintInfo.programId, + ); +} + +/** + * Build instruction batches for a delegated transfer on a light-token ATA. + * + * Auto-detects mint type (light-token, SPL, or Token-2022). + */ +export async function createTransferDelegatedInterfaceInstructions( + rpc: Rpc, + payer: PublicKey, + mint: PublicKey, + amount: number | bigint | BN, + delegate: PublicKey, + owner: PublicKey, + recipient: PublicKey, + decimals?: number, +): Promise { + const mintInfo = await getMintInterface(rpc, mint); + const resolvedDecimals = decimals ?? mintInfo.mint.decimals; + return _createTransferDelegatedInterfaceInstructions( + rpc, + payer, + mint, + amount, + delegate, + owner, + recipient, + resolvedDecimals, + mintInfo.programId, + ); +} export { getAccountInterface, AccountInterface, diff --git a/js/compressed-token/tests/e2e/transfer-delegated-failures.test.ts b/js/compressed-token/tests/e2e/transfer-delegated-failures.test.ts new file mode 100644 index 0000000000..7a740efbf7 --- /dev/null +++ b/js/compressed-token/tests/e2e/transfer-delegated-failures.test.ts @@ -0,0 +1,145 @@ +/** + * Test that delegated transfers fail correctly. + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import { Keypair, PublicKey } from '@solana/web3.js'; +import { + Rpc, + createRpc, + featureFlags, + VERSION, +} from '@lightprotocol/stateless.js'; +import { + createMintInterface, + createAtaInterface, + getAssociatedTokenAddressInterface, + approveInterface, + revokeInterface, + transferDelegatedInterface, + mintToInterface, +} from '../../src'; + +featureFlags.version = VERSION.V2; + +const RPC_URL = 'http://127.0.0.1:8899'; +const PHOTON_URL = 'http://127.0.0.1:8784'; +const PROVER_URL = 'http://127.0.0.1:3001'; + +async function fundAccount(rpc: Rpc, kp: Keypair, lamports: number) { + const sig = await rpc.requestAirdrop(kp.publicKey, lamports); + await rpc.confirmTransaction(sig); +} + +describe('transferDelegatedInterface - failure cases', () => { + let rpc: Rpc; + let payer: Keypair; + let owner: Keypair; + let delegate: Keypair; + let stranger: Keypair; + let recipient: Keypair; + let mint: PublicKey; + let ownerAta: PublicKey; + let recipientAta: PublicKey; + + beforeAll(async () => { + rpc = createRpc(RPC_URL, PHOTON_URL, PROVER_URL); + payer = Keypair.generate(); + owner = Keypair.generate(); + delegate = Keypair.generate(); + stranger = Keypair.generate(); + recipient = Keypair.generate(); + + await fundAccount(rpc, payer, 10e9); + await fundAccount(rpc, owner, 10e9); + await fundAccount(rpc, delegate, 10e9); + await fundAccount(rpc, stranger, 10e9); + + const mintKeypair = Keypair.generate(); + const { mint: mintPubkey } = await createMintInterface( + rpc, + payer, + payer, + null, + 9, + mintKeypair, + ); + mint = mintPubkey; + + await createAtaInterface(rpc, payer, mint, owner.publicKey); + ownerAta = getAssociatedTokenAddressInterface(mint, owner.publicKey); + + await createAtaInterface(rpc, payer, mint, recipient.publicKey); + recipientAta = getAssociatedTokenAddressInterface( + mint, + recipient.publicKey, + ); + + await mintToInterface( + rpc, + payer, + mint, + ownerAta, + payer, + 1_000_000_000, + ); + + // Approve delegate for 500M + await approveInterface( + rpc, + payer, + ownerAta, + mint, + delegate.publicKey, + 500_000_000, + owner, + ); + }, 120_000); + + it('rejects transfer exceeding delegated allowance', async () => { + await expect( + transferDelegatedInterface( + rpc, + payer, + ownerAta, + mint, + recipientAta, + delegate, + owner.publicKey, + 600_000_000, // > 500M allowance + ), + ).rejects.toThrow(); + }, 30_000); + + it('rejects transfer from unapproved signer', async () => { + await expect( + transferDelegatedInterface( + rpc, + payer, + ownerAta, + mint, + recipientAta, + stranger, // not approved + owner.publicKey, + 100_000_000, + ), + ).rejects.toThrow(); + }, 30_000); + + it('rejects transfer after revoke', async () => { + // Revoke first + await revokeInterface(rpc, payer, ownerAta, mint, owner); + + await expect( + transferDelegatedInterface( + rpc, + payer, + ownerAta, + mint, + recipientAta, + delegate, + owner.publicKey, + 100_000_000, + ), + ).rejects.toThrow(); + }, 60_000); +}); \ No newline at end of file diff --git a/js/compressed-token/tests/e2e/transfer-delegated-interface.test.ts b/js/compressed-token/tests/e2e/transfer-delegated-interface.test.ts new file mode 100644 index 0000000000..a99a14c073 --- /dev/null +++ b/js/compressed-token/tests/e2e/transfer-delegated-interface.test.ts @@ -0,0 +1,149 @@ +/** + * E2E smoke test: approve → delegated transfer → check → revoke + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import { Keypair, PublicKey } from '@solana/web3.js'; +import { + Rpc, + createRpc, + featureFlags, + VERSION, +} from '@lightprotocol/stateless.js'; +import { + createMintInterface, + createAtaInterface, + getAssociatedTokenAddressInterface, + getAtaInterface, + approveInterface, + revokeInterface, + transferDelegatedInterface, + mintToInterface, +} from '../../src'; + +featureFlags.version = VERSION.V2; + +const RPC_URL = 'http://127.0.0.1:8899'; +const PHOTON_URL = 'http://127.0.0.1:8784'; +const PROVER_URL = 'http://127.0.0.1:3001'; + +async function fundAccount(rpc: Rpc, kp: Keypair, lamports: number) { + const sig = await rpc.requestAirdrop(kp.publicKey, lamports); + await rpc.confirmTransaction(sig); +} + +describe('transferDelegatedInterface - e2e', () => { + let rpc: Rpc; + let payer: Keypair; + let owner: Keypair; + let delegate: Keypair; + let recipient: Keypair; + let mint: PublicKey; + let ownerAta: PublicKey; + let recipientAta: PublicKey; + + beforeAll(async () => { + rpc = createRpc(RPC_URL, PHOTON_URL, PROVER_URL); + payer = Keypair.generate(); + owner = Keypair.generate(); + delegate = Keypair.generate(); + recipient = Keypair.generate(); + + await fundAccount(rpc, payer, 10e9); + await fundAccount(rpc, owner, 10e9); + await fundAccount(rpc, delegate, 10e9); + + // Create mint + const mintKeypair = Keypair.generate(); + const { mint: mintPubkey } = await createMintInterface( + rpc, + payer, + payer, + null, + 9, + mintKeypair, + ); + mint = mintPubkey; + + // Create ATAs + await createAtaInterface(rpc, payer, mint, owner.publicKey); + ownerAta = getAssociatedTokenAddressInterface(mint, owner.publicKey); + + await createAtaInterface(rpc, payer, mint, recipient.publicKey); + recipientAta = getAssociatedTokenAddressInterface( + mint, + recipient.publicKey, + ); + + // Mint 1B tokens to owner + await mintToInterface(rpc, payer, mint, ownerAta, payer, 1_000_000_000); + }, 120_000); + + it('approve → delegated transfer → verify balances → revoke', async () => { + // 1. Approve delegate for 500M + await approveInterface( + rpc, + payer, + ownerAta, + mint, + delegate.publicKey, + 500_000_000, + owner, + ); + + const afterApprove = await getAtaInterface( + rpc, + ownerAta, + owner.publicKey, + mint, + ); + expect(afterApprove.parsed.delegate?.toBase58()).toBe( + delegate.publicKey.toBase58(), + ); + expect(afterApprove.parsed.delegatedAmount).toBe( + BigInt(500_000_000), + ); + + // 2. Delegate transfer 200M + const sig = await transferDelegatedInterface( + rpc, + payer, + ownerAta, + mint, + recipientAta, + delegate, + owner.publicKey, + 200_000_000, + ); + expect(sig).toBeTruthy(); + + // 3. Verify balances + const ownerAfter = await getAtaInterface( + rpc, + ownerAta, + owner.publicKey, + mint, + ); + expect(ownerAfter.parsed.amount).toBe(BigInt(800_000_000)); + expect(ownerAfter.parsed.delegatedAmount).toBe(BigInt(300_000_000)); + + const recipientAfter = await getAtaInterface( + rpc, + recipientAta, + recipient.publicKey, + mint, + ); + expect(recipientAfter.parsed.amount).toBe(BigInt(200_000_000)); + + // 4. Revoke + await revokeInterface(rpc, payer, ownerAta, mint, owner); + + const afterRevoke = await getAtaInterface( + rpc, + ownerAta, + owner.publicKey, + mint, + ); + expect(afterRevoke.parsed.delegate).toBeNull(); + expect(afterRevoke.parsed.delegatedAmount).toBe(BigInt(0)); + }, 120_000); +}); From 19adc69b490dc79985c3561219e2b2a7af661613 Mon Sep 17 00:00:00 2001 From: tilo-14 Date: Wed, 18 Mar 2026 22:07:55 +0000 Subject: [PATCH 04/14] add spl t22 support --- .../src/v3/actions/approve-interface.ts | 99 ++++- .../actions/transfer-delegated-interface.ts | 31 +- js/compressed-token/src/v3/unified/index.ts | 40 +- .../tests/e2e/approve-revoke-spl-t22.test.ts | 249 ++++++++++++ .../e2e/transfer-delegated-spl-t22.test.ts | 361 ++++++++++++++++++ 5 files changed, 747 insertions(+), 33 deletions(-) create mode 100644 js/compressed-token/tests/e2e/approve-revoke-spl-t22.test.ts create mode 100644 js/compressed-token/tests/e2e/transfer-delegated-spl-t22.test.ts diff --git a/js/compressed-token/src/v3/actions/approve-interface.ts b/js/compressed-token/src/v3/actions/approve-interface.ts index 51c6dc22b4..8120297b91 100644 --- a/js/compressed-token/src/v3/actions/approve-interface.ts +++ b/js/compressed-token/src/v3/actions/approve-interface.ts @@ -12,7 +12,14 @@ import { sendAndConfirmTx, dedupeSigner, assertBetaEnabled, + LIGHT_TOKEN_PROGRAM_ID, } from '@lightprotocol/stateless.js'; +import { + TOKEN_PROGRAM_ID, + TOKEN_2022_PROGRAM_ID, + createApproveInstruction as createSplApproveInstruction, + createRevokeInstruction as createSplRevokeInstruction, +} from '@solana/spl-token'; import BN from 'bn.js'; import { createLightTokenApproveInstruction, @@ -40,18 +47,20 @@ function calculateApproveCU(loadBatch: InternalLoadBatch | null): number { } /** - * Approve a delegate for a light-token associated token account. + * Approve a delegate for an associated token account. * - * Loads cold accounts if needed, then sends the approve instruction. + * Supports light-token, SPL, and Token-2022 mints. For light-token mints, + * loads cold accounts if needed before sending the approve instruction. * * @param rpc RPC connection * @param payer Fee payer (signer) - * @param tokenAccount Light-token ATA address + * @param tokenAccount ATA address * @param mint Mint address * @param delegate Delegate to approve * @param amount Amount to delegate * @param owner Owner of the token account (signer) * @param confirmOptions Optional confirm options + * @param programId Token program ID (default: LIGHT_TOKEN_PROGRAM_ID) * @returns Transaction signature */ export async function approveInterface( @@ -63,12 +72,15 @@ export async function approveInterface( amount: number | bigint | BN, owner: Signer, confirmOptions?: ConfirmOptions, + programId: PublicKey = LIGHT_TOKEN_PROGRAM_ID, ): Promise { assertBetaEnabled(); const expectedAta = getAssociatedTokenAddressInterface( mint, owner.publicKey, + false, + programId, ); if (!tokenAccount.equals(expectedAta)) { throw new Error( @@ -86,6 +98,7 @@ export async function approveInterface( amount, owner.publicKey, mintInterface.mint.decimals, + programId, ); const additionalSigners = dedupeSigner(payer, [owner]); @@ -115,18 +128,20 @@ export async function approveInterface( } /** - * Build instruction batches for approving a delegate on a light-token ATA. + * Build instruction batches for approving a delegate on an ATA. * + * Supports light-token, SPL, and Token-2022 mints. * Returns `TransactionInstruction[][]`. Send [0..n-2] in parallel, then [n-1]. * * @param rpc RPC connection * @param payer Fee payer public key * @param mint Mint address - * @param tokenAccount Light-token ATA address + * @param tokenAccount ATA address * @param delegate Delegate to approve * @param amount Amount to delegate * @param owner Owner public key * @param decimals Token decimals + * @param programId Token program ID (default: LIGHT_TOKEN_PROGRAM_ID) * @returns Instruction batches */ export async function createApproveInterfaceInstructions( @@ -138,20 +153,49 @@ export async function createApproveInterfaceInstructions( amount: number | bigint | BN, owner: PublicKey, decimals: number, + programId: PublicKey = LIGHT_TOKEN_PROGRAM_ID, ): Promise { assertBetaEnabled(); const amountBigInt = BigInt(amount.toString()); + const isSplOrT22 = + programId.equals(TOKEN_PROGRAM_ID) || + programId.equals(TOKEN_2022_PROGRAM_ID); + const accountInterface = await _getAtaInterface( rpc, tokenAccount, owner, mint, + undefined, + isSplOrT22 ? programId : undefined, ); checkNotFrozen(accountInterface, 'approve'); + if (isSplOrT22) { + const approveIx = createSplApproveInstruction( + tokenAccount, + delegate, + owner, + amountBigInt, + [], + programId, + ); + + const numSigners = payer.equals(owner) ? 1 : 2; + const txIxs = [ + ComputeBudgetProgram.setComputeUnitLimit({ + units: APPROVE_BASE_CU, + }), + approveIx, + ]; + assertTransactionSizeWithinLimit(txIxs, numSigners, 'Batch'); + return [txIxs]; + } + + // Light-token path: load cold accounts if needed const internalBatches = await _buildLoadBatches( rpc, payer, @@ -223,16 +267,18 @@ export async function createApproveInterfaceInstructions( } /** - * Revoke delegation for a light-token associated token account. + * Revoke delegation for an associated token account. * - * Loads cold accounts if needed, then sends the revoke instruction. + * Supports light-token, SPL, and Token-2022 mints. For light-token mints, + * loads cold accounts if needed before sending the revoke instruction. * * @param rpc RPC connection * @param payer Fee payer (signer) - * @param tokenAccount Light-token ATA address + * @param tokenAccount ATA address * @param mint Mint address * @param owner Owner of the token account (signer) * @param confirmOptions Optional confirm options + * @param programId Token program ID (default: LIGHT_TOKEN_PROGRAM_ID) * @returns Transaction signature */ export async function revokeInterface( @@ -242,12 +288,15 @@ export async function revokeInterface( mint: PublicKey, owner: Signer, confirmOptions?: ConfirmOptions, + programId: PublicKey = LIGHT_TOKEN_PROGRAM_ID, ): Promise { assertBetaEnabled(); const expectedAta = getAssociatedTokenAddressInterface( mint, owner.publicKey, + false, + programId, ); if (!tokenAccount.equals(expectedAta)) { throw new Error( @@ -263,6 +312,7 @@ export async function revokeInterface( tokenAccount, owner.publicKey, mintInterface.mint.decimals, + programId, ); const additionalSigners = dedupeSigner(payer, [owner]); @@ -287,16 +337,18 @@ export async function revokeInterface( } /** - * Build instruction batches for revoking delegation on a light-token ATA. + * Build instruction batches for revoking delegation on an ATA. * + * Supports light-token, SPL, and Token-2022 mints. * Returns `TransactionInstruction[][]`. Send [0..n-2] in parallel, then [n-1]. * * @param rpc RPC connection * @param payer Fee payer public key * @param mint Mint address - * @param tokenAccount Light-token ATA address + * @param tokenAccount ATA address * @param owner Owner public key * @param decimals Token decimals + * @param programId Token program ID (default: LIGHT_TOKEN_PROGRAM_ID) * @returns Instruction batches */ export async function createRevokeInterfaceInstructions( @@ -306,18 +358,45 @@ export async function createRevokeInterfaceInstructions( tokenAccount: PublicKey, owner: PublicKey, decimals: number, + programId: PublicKey = LIGHT_TOKEN_PROGRAM_ID, ): Promise { assertBetaEnabled(); + const isSplOrT22 = + programId.equals(TOKEN_PROGRAM_ID) || + programId.equals(TOKEN_2022_PROGRAM_ID); + const accountInterface = await _getAtaInterface( rpc, tokenAccount, owner, mint, + undefined, + isSplOrT22 ? programId : undefined, ); checkNotFrozen(accountInterface, 'revoke'); + if (isSplOrT22) { + const revokeIx = createSplRevokeInstruction( + tokenAccount, + owner, + [], + programId, + ); + + const numSigners = payer.equals(owner) ? 1 : 2; + const txIxs = [ + ComputeBudgetProgram.setComputeUnitLimit({ + units: APPROVE_BASE_CU, + }), + revokeIx, + ]; + assertTransactionSizeWithinLimit(txIxs, numSigners, 'Batch'); + return [txIxs]; + } + + // Light-token path: load cold accounts if needed const internalBatches = await _buildLoadBatches( rpc, payer, diff --git a/js/compressed-token/src/v3/actions/transfer-delegated-interface.ts b/js/compressed-token/src/v3/actions/transfer-delegated-interface.ts index 09490d6290..6cc0150edd 100644 --- a/js/compressed-token/src/v3/actions/transfer-delegated-interface.ts +++ b/js/compressed-token/src/v3/actions/transfer-delegated-interface.ts @@ -5,28 +5,33 @@ import { TransactionInstruction, TransactionSignature, } from '@solana/web3.js'; -import { Rpc, assertBetaEnabled } from '@lightprotocol/stateless.js'; +import { + Rpc, + assertBetaEnabled, + LIGHT_TOKEN_PROGRAM_ID, +} from '@lightprotocol/stateless.js'; import BN from 'bn.js'; import { transferInterface } from './transfer-interface'; import { createTransferInterfaceInstructions } from '../instructions/transfer-interface'; /** - * Transfer tokens from a light-token ATA as an approved delegate. + * Transfer tokens from an ATA as an approved delegate. * - * Convenience wrapper around {@link transferInterface} that makes the - * delegate-transfer API explicit: the delegate is a {@link Signer} (authority), - * the owner is a {@link PublicKey} (used only for ATA derivation, does NOT - * sign). + * Supports light-token, SPL, and Token-2022 mints. Convenience wrapper + * around {@link transferInterface} that makes the delegate-transfer API + * explicit: the delegate is a {@link Signer} (authority), the owner is a + * {@link PublicKey} (used only for ATA derivation, does NOT sign). * * @param rpc RPC connection * @param payer Fee payer (signer) - * @param source Source light-token ATA (owner's account) + * @param source Source ATA (owner's account) * @param mint Mint address - * @param destination Destination light-token ATA + * @param destination Destination ATA * @param delegate Delegate authority (signer) * @param owner Owner of the source ATA (does not sign) * @param amount Amount to transfer (must be within approved allowance) * @param confirmOptions Optional confirm options + * @param programId Token program ID (default: LIGHT_TOKEN_PROGRAM_ID) * @returns Transaction signature */ export async function transferDelegatedInterface( @@ -39,6 +44,7 @@ export async function transferDelegatedInterface( owner: PublicKey, amount: number | bigint | BN, confirmOptions?: ConfirmOptions, + programId: PublicKey = LIGHT_TOKEN_PROGRAM_ID, ): Promise { assertBetaEnabled(); @@ -50,15 +56,16 @@ export async function transferDelegatedInterface( destination, delegate, amount, - undefined, + programId, confirmOptions, { owner }, ); } /** - * Build instruction batches for a delegated transfer on a light-token ATA. + * Build instruction batches for a delegated transfer on an ATA. * + * Supports light-token, SPL, and Token-2022 mints. * Returns `TransactionInstruction[][]`. Send [0..n-2] in parallel, then [n-1]. * * @param rpc RPC connection @@ -69,6 +76,7 @@ export async function transferDelegatedInterface( * @param owner Owner of the source ATA (for derivation) * @param destination Destination ATA address * @param decimals Token decimals + * @param programId Token program ID (default: LIGHT_TOKEN_PROGRAM_ID) * @returns Instruction batches */ export async function createTransferDelegatedInterfaceInstructions( @@ -80,6 +88,7 @@ export async function createTransferDelegatedInterfaceInstructions( owner: PublicKey, destination: PublicKey, decimals: number, + programId: PublicKey = LIGHT_TOKEN_PROGRAM_ID, ): Promise { assertBetaEnabled(); @@ -91,6 +100,6 @@ export async function createTransferDelegatedInterfaceInstructions( delegate, destination, decimals, - { owner }, + { owner, programId }, ); } diff --git a/js/compressed-token/src/v3/unified/index.ts b/js/compressed-token/src/v3/unified/index.ts index fce8b95f96..6650f5ac20 100644 --- a/js/compressed-token/src/v3/unified/index.ts +++ b/js/compressed-token/src/v3/unified/index.ts @@ -504,11 +504,14 @@ export type { }; /** - * Approve a delegate for a light-token associated token account. + * Approve a delegate for an associated token account. + * + * Auto-detects mint type (light-token, SPL, or Token-2022) and dispatches + * to the appropriate instruction. * * @param rpc RPC connection * @param payer Fee payer (signer) - * @param tokenAccount Light-token ATA address + * @param tokenAccount ATA address * @param mint Mint address * @param delegate Delegate to approve * @param amount Amount to delegate @@ -526,6 +529,7 @@ export async function approveInterface( owner: Signer, confirmOptions?: ConfirmOptions, ) { + const mintInfo = await getMintInterface(rpc, mint); return _approveInterface( rpc, payer, @@ -535,11 +539,14 @@ export async function approveInterface( amount, owner, confirmOptions, + mintInfo.programId, ); } /** - * Build instruction batches for approving a delegate on a light-token ATA. + * Build instruction batches for approving a delegate on an ATA. + * + * Auto-detects mint type (light-token, SPL, or Token-2022). */ export async function createApproveInterfaceInstructions( rpc: Rpc, @@ -551,8 +558,8 @@ export async function createApproveInterfaceInstructions( owner: PublicKey, decimals?: number, ): Promise { - const resolvedDecimals = - decimals ?? (await getMintInterface(rpc, mint)).mint.decimals; + const mintInfo = await getMintInterface(rpc, mint); + const resolvedDecimals = decimals ?? mintInfo.mint.decimals; return _createApproveInterfaceInstructions( rpc, payer, @@ -562,15 +569,19 @@ export async function createApproveInterfaceInstructions( amount, owner, resolvedDecimals, + mintInfo.programId, ); } /** - * Revoke delegation for a light-token associated token account. + * Revoke delegation for an associated token account. + * + * Auto-detects mint type (light-token, SPL, or Token-2022) and dispatches + * to the appropriate instruction. * * @param rpc RPC connection * @param payer Fee payer (signer) - * @param tokenAccount Light-token ATA address + * @param tokenAccount ATA address * @param mint Mint address * @param owner Owner of the token account (signer) * @param confirmOptions Optional confirm options @@ -584,6 +595,7 @@ export async function revokeInterface( owner: Signer, confirmOptions?: ConfirmOptions, ) { + const mintInfo = await getMintInterface(rpc, mint); return _revokeInterface( rpc, payer, @@ -591,11 +603,14 @@ export async function revokeInterface( mint, owner, confirmOptions, + mintInfo.programId, ); } /** - * Build instruction batches for revoking delegation on a light-token ATA. + * Build instruction batches for revoking delegation on an ATA. + * + * Auto-detects mint type (light-token, SPL, or Token-2022). */ export async function createRevokeInterfaceInstructions( rpc: Rpc, @@ -605,8 +620,8 @@ export async function createRevokeInterfaceInstructions( owner: PublicKey, decimals?: number, ): Promise { - const resolvedDecimals = - decimals ?? (await getMintInterface(rpc, mint)).mint.decimals; + const mintInfo = await getMintInterface(rpc, mint); + const resolvedDecimals = decimals ?? mintInfo.mint.decimals; return _createRevokeInterfaceInstructions( rpc, payer, @@ -614,11 +629,12 @@ export async function createRevokeInterfaceInstructions( tokenAccount, owner, resolvedDecimals, + mintInfo.programId, ); } /** - * Transfer tokens from a light-token ATA as an approved delegate. + * Transfer tokens from an ATA as an approved delegate. * * Auto-detects mint type (light-token, SPL, or Token-2022) and dispatches * to the appropriate instruction. @@ -661,7 +677,7 @@ export async function transferDelegatedInterface( } /** - * Build instruction batches for a delegated transfer on a light-token ATA. + * Build instruction batches for a delegated transfer on an ATA. * * Auto-detects mint type (light-token, SPL, or Token-2022). */ diff --git a/js/compressed-token/tests/e2e/approve-revoke-spl-t22.test.ts b/js/compressed-token/tests/e2e/approve-revoke-spl-t22.test.ts new file mode 100644 index 0000000000..3eb18d3995 --- /dev/null +++ b/js/compressed-token/tests/e2e/approve-revoke-spl-t22.test.ts @@ -0,0 +1,249 @@ +/** + * E2E tests for approve/revoke on SPL and Token-2022 ATAs + * via approveInterface and revokeInterface. + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import { Keypair, PublicKey } from '@solana/web3.js'; +import { + Rpc, + createRpc, + featureFlags, + VERSION, +} from '@lightprotocol/stateless.js'; +import { + createMint as createSplMint, + getOrCreateAssociatedTokenAccount, + mintTo, + getAccount, + TOKEN_PROGRAM_ID, + TOKEN_2022_PROGRAM_ID, +} from '@solana/spl-token'; +import { + approveInterface, + revokeInterface, + getAssociatedTokenAddressInterface, +} from '../../src'; + +featureFlags.version = VERSION.V2; + +const RPC_URL = 'http://127.0.0.1:8899'; +const PHOTON_URL = 'http://127.0.0.1:8784'; +const PROVER_URL = 'http://127.0.0.1:3001'; +const DECIMALS = 9; +const MINT_AMOUNT = 1_000_000_000n; + +async function fundAccount(rpc: Rpc, kp: Keypair, lamports: number) { + const sig = await rpc.requestAirdrop(kp.publicKey, lamports); + await rpc.confirmTransaction(sig); +} + +describe('approveInterface / revokeInterface - SPL mint', () => { + let rpc: Rpc; + let payer: Keypair; + let owner: Keypair; + let delegate: Keypair; + let splMint: PublicKey; + let ownerAta: PublicKey; + + beforeAll(async () => { + rpc = createRpc(RPC_URL, PHOTON_URL, PROVER_URL); + payer = Keypair.generate(); + owner = Keypair.generate(); + delegate = Keypair.generate(); + + await fundAccount(rpc, payer, 10e9); + await fundAccount(rpc, owner, 10e9); + + // Create SPL mint + splMint = await createSplMint( + rpc, + payer as Keypair, + payer.publicKey, + null, + DECIMALS, + undefined, + undefined, + TOKEN_PROGRAM_ID, + ); + + // Create ATA and mint tokens + const ataInfo = await getOrCreateAssociatedTokenAccount( + rpc, + payer as Keypair, + splMint, + owner.publicKey, + false, + undefined, + undefined, + TOKEN_PROGRAM_ID, + ); + ownerAta = ataInfo.address; + + await mintTo( + rpc, + payer as Keypair, + splMint, + ownerAta, + payer, + MINT_AMOUNT, + [], + undefined, + TOKEN_PROGRAM_ID, + ); + }, 120_000); + + it('approve delegate on SPL ATA', async () => { + const sig = await approveInterface( + rpc, + payer, + ownerAta, + splMint, + delegate.publicKey, + 500_000_000n, + owner, + undefined, + TOKEN_PROGRAM_ID, + ); + expect(sig).toBeTruthy(); + + const account = await getAccount(rpc, ownerAta, undefined, TOKEN_PROGRAM_ID); + expect(account.delegate?.toBase58()).toBe(delegate.publicKey.toBase58()); + expect(account.delegatedAmount).toBe(500_000_000n); + }, 60_000); + + it('revoke delegate on SPL ATA', async () => { + const sig = await revokeInterface( + rpc, + payer, + ownerAta, + splMint, + owner, + undefined, + TOKEN_PROGRAM_ID, + ); + expect(sig).toBeTruthy(); + + const account = await getAccount(rpc, ownerAta, undefined, TOKEN_PROGRAM_ID); + expect(account.delegate).toBeNull(); + expect(account.delegatedAmount).toBe(0n); + }, 60_000); + + it('rejects approve from non-owner', async () => { + const stranger = Keypair.generate(); + await fundAccount(rpc, stranger, 1e9); + + const strangerAta = getAssociatedTokenAddressInterface( + splMint, + stranger.publicKey, + false, + TOKEN_PROGRAM_ID, + ); + + // strangerAta doesn't match ownerAta, should fail with mismatch + await expect( + approveInterface( + rpc, + payer, + ownerAta, + splMint, + delegate.publicKey, + 100n, + stranger, + undefined, + TOKEN_PROGRAM_ID, + ), + ).rejects.toThrow(); + }, 60_000); +}); + +describe('approveInterface / revokeInterface - Token-2022 mint', () => { + let rpc: Rpc; + let payer: Keypair; + let owner: Keypair; + let delegate: Keypair; + let t22Mint: PublicKey; + let ownerAta: PublicKey; + + beforeAll(async () => { + rpc = createRpc(RPC_URL, PHOTON_URL, PROVER_URL); + payer = Keypair.generate(); + owner = Keypair.generate(); + delegate = Keypair.generate(); + + await fundAccount(rpc, payer, 10e9); + await fundAccount(rpc, owner, 10e9); + + // Create Token-2022 mint + t22Mint = await createSplMint( + rpc, + payer as Keypair, + payer.publicKey, + null, + DECIMALS, + undefined, + undefined, + TOKEN_2022_PROGRAM_ID, + ); + + // Create ATA and mint tokens + const ataInfo = await getOrCreateAssociatedTokenAccount( + rpc, + payer as Keypair, + t22Mint, + owner.publicKey, + false, + undefined, + undefined, + TOKEN_2022_PROGRAM_ID, + ); + ownerAta = ataInfo.address; + + await mintTo( + rpc, + payer as Keypair, + t22Mint, + ownerAta, + payer, + MINT_AMOUNT, + [], + undefined, + TOKEN_2022_PROGRAM_ID, + ); + }, 120_000); + + it('approve delegate on T22 ATA', async () => { + const sig = await approveInterface( + rpc, + payer, + ownerAta, + t22Mint, + delegate.publicKey, + 500_000_000n, + owner, + undefined, + TOKEN_2022_PROGRAM_ID, + ); + expect(sig).toBeTruthy(); + + const account = await getAccount(rpc, ownerAta, undefined, TOKEN_2022_PROGRAM_ID); + expect(account.delegate?.toBase58()).toBe(delegate.publicKey.toBase58()); + expect(account.delegatedAmount).toBe(500_000_000n); + }, 60_000); + + it('revoke delegate on T22 ATA', async () => { + const sig = await revokeInterface( + rpc, + payer, + ownerAta, + t22Mint, + owner, + undefined, + TOKEN_2022_PROGRAM_ID, + ); + expect(sig).toBeTruthy(); + + const account = await getAccount(rpc, ownerAta, undefined, TOKEN_2022_PROGRAM_ID); + expect(account.delegate).toBeNull(); + expect(account.delegatedAmount).toBe(0n); + }, 60_000); +}); diff --git a/js/compressed-token/tests/e2e/transfer-delegated-spl-t22.test.ts b/js/compressed-token/tests/e2e/transfer-delegated-spl-t22.test.ts new file mode 100644 index 0000000000..b3a8f6a2a3 --- /dev/null +++ b/js/compressed-token/tests/e2e/transfer-delegated-spl-t22.test.ts @@ -0,0 +1,361 @@ +/** + * E2E tests: full approve → delegated transfer → revoke cycle + * with SPL and Token-2022 mints. + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import { Keypair, PublicKey } from '@solana/web3.js'; +import { + Rpc, + createRpc, + featureFlags, + VERSION, +} from '@lightprotocol/stateless.js'; +import { + createMint as createSplMint, + getOrCreateAssociatedTokenAccount, + mintTo, + getAccount, + TOKEN_PROGRAM_ID, + TOKEN_2022_PROGRAM_ID, +} from '@solana/spl-token'; +import { + approveInterface, + revokeInterface, + transferDelegatedInterface, +} from '../../src'; + +featureFlags.version = VERSION.V2; + +const RPC_URL = 'http://127.0.0.1:8899'; +const PHOTON_URL = 'http://127.0.0.1:8784'; +const PROVER_URL = 'http://127.0.0.1:3001'; +const DECIMALS = 9; +const MINT_AMOUNT = 1_000_000_000n; + +async function fundAccount(rpc: Rpc, kp: Keypair, lamports: number) { + const sig = await rpc.requestAirdrop(kp.publicKey, lamports); + await rpc.confirmTransaction(sig); +} + +describe('delegated transfer - SPL mint', () => { + let rpc: Rpc; + let payer: Keypair; + let owner: Keypair; + let delegate: Keypair; + let stranger: Keypair; + let recipient: Keypair; + let splMint: PublicKey; + let ownerAta: PublicKey; + let recipientAta: PublicKey; + + beforeAll(async () => { + rpc = createRpc(RPC_URL, PHOTON_URL, PROVER_URL); + payer = Keypair.generate(); + owner = Keypair.generate(); + delegate = Keypair.generate(); + stranger = Keypair.generate(); + recipient = Keypair.generate(); + + await fundAccount(rpc, payer, 10e9); + await fundAccount(rpc, owner, 10e9); + await fundAccount(rpc, delegate, 10e9); + await fundAccount(rpc, stranger, 10e9); + + splMint = await createSplMint( + rpc, + payer as Keypair, + payer.publicKey, + null, + DECIMALS, + undefined, + undefined, + TOKEN_PROGRAM_ID, + ); + + const ownerAtaInfo = await getOrCreateAssociatedTokenAccount( + rpc, + payer as Keypair, + splMint, + owner.publicKey, + false, + undefined, + undefined, + TOKEN_PROGRAM_ID, + ); + ownerAta = ownerAtaInfo.address; + + const recipientAtaInfo = await getOrCreateAssociatedTokenAccount( + rpc, + payer as Keypair, + splMint, + recipient.publicKey, + false, + undefined, + undefined, + TOKEN_PROGRAM_ID, + ); + recipientAta = recipientAtaInfo.address; + + await mintTo( + rpc, + payer as Keypair, + splMint, + ownerAta, + payer, + MINT_AMOUNT, + [], + undefined, + TOKEN_PROGRAM_ID, + ); + }, 120_000); + + it('approve → delegated transfer → verify → revoke', async () => { + // Approve + await approveInterface( + rpc, + payer, + ownerAta, + splMint, + delegate.publicKey, + 500_000_000n, + owner, + undefined, + TOKEN_PROGRAM_ID, + ); + + // Delegated transfer + const sig = await transferDelegatedInterface( + rpc, + payer, + ownerAta, + splMint, + recipientAta, + delegate, + owner.publicKey, + 200_000_000n, + undefined, + TOKEN_PROGRAM_ID, + ); + expect(sig).toBeTruthy(); + + // Verify balances + const ownerAccount = await getAccount(rpc, ownerAta, undefined, TOKEN_PROGRAM_ID); + expect(ownerAccount.amount).toBe(800_000_000n); + expect(ownerAccount.delegatedAmount).toBe(300_000_000n); + + const recipientAccount = await getAccount(rpc, recipientAta, undefined, TOKEN_PROGRAM_ID); + expect(recipientAccount.amount).toBe(200_000_000n); + + // Revoke + await revokeInterface( + rpc, + payer, + ownerAta, + splMint, + owner, + undefined, + TOKEN_PROGRAM_ID, + ); + + const afterRevoke = await getAccount(rpc, ownerAta, undefined, TOKEN_PROGRAM_ID); + expect(afterRevoke.delegate).toBeNull(); + expect(afterRevoke.delegatedAmount).toBe(0n); + }, 120_000); + + it('rejects transfer exceeding allowance', async () => { + // Re-approve for next tests + await approveInterface( + rpc, + payer, + ownerAta, + splMint, + delegate.publicKey, + 100_000_000n, + owner, + undefined, + TOKEN_PROGRAM_ID, + ); + + await expect( + transferDelegatedInterface( + rpc, + payer, + ownerAta, + splMint, + recipientAta, + delegate, + owner.publicKey, + 200_000_000n, // > 100M allowance + undefined, + TOKEN_PROGRAM_ID, + ), + ).rejects.toThrow(); + }, 60_000); + + it('rejects transfer from unauthorized delegate', async () => { + await expect( + transferDelegatedInterface( + rpc, + payer, + ownerAta, + splMint, + recipientAta, + stranger, + owner.publicKey, + 50_000_000n, + undefined, + TOKEN_PROGRAM_ID, + ), + ).rejects.toThrow(); + }, 60_000); + + it('rejects transfer after revoke', async () => { + await revokeInterface( + rpc, + payer, + ownerAta, + splMint, + owner, + undefined, + TOKEN_PROGRAM_ID, + ); + + await expect( + transferDelegatedInterface( + rpc, + payer, + ownerAta, + splMint, + recipientAta, + delegate, + owner.publicKey, + 50_000_000n, + undefined, + TOKEN_PROGRAM_ID, + ), + ).rejects.toThrow(); + }, 60_000); +}); + +describe('delegated transfer - Token-2022 mint', () => { + let rpc: Rpc; + let payer: Keypair; + let owner: Keypair; + let delegate: Keypair; + let recipient: Keypair; + let t22Mint: PublicKey; + let ownerAta: PublicKey; + let recipientAta: PublicKey; + + beforeAll(async () => { + rpc = createRpc(RPC_URL, PHOTON_URL, PROVER_URL); + payer = Keypair.generate(); + owner = Keypair.generate(); + delegate = Keypair.generate(); + recipient = Keypair.generate(); + + await fundAccount(rpc, payer, 10e9); + await fundAccount(rpc, owner, 10e9); + await fundAccount(rpc, delegate, 10e9); + + t22Mint = await createSplMint( + rpc, + payer as Keypair, + payer.publicKey, + null, + DECIMALS, + undefined, + undefined, + TOKEN_2022_PROGRAM_ID, + ); + + const ownerAtaInfo = await getOrCreateAssociatedTokenAccount( + rpc, + payer as Keypair, + t22Mint, + owner.publicKey, + false, + undefined, + undefined, + TOKEN_2022_PROGRAM_ID, + ); + ownerAta = ownerAtaInfo.address; + + const recipientAtaInfo = await getOrCreateAssociatedTokenAccount( + rpc, + payer as Keypair, + t22Mint, + recipient.publicKey, + false, + undefined, + undefined, + TOKEN_2022_PROGRAM_ID, + ); + recipientAta = recipientAtaInfo.address; + + await mintTo( + rpc, + payer as Keypair, + t22Mint, + ownerAta, + payer, + MINT_AMOUNT, + [], + undefined, + TOKEN_2022_PROGRAM_ID, + ); + }, 120_000); + + it('approve → delegated transfer → verify → revoke', async () => { + // Approve + await approveInterface( + rpc, + payer, + ownerAta, + t22Mint, + delegate.publicKey, + 500_000_000n, + owner, + undefined, + TOKEN_2022_PROGRAM_ID, + ); + + // Delegated transfer + const sig = await transferDelegatedInterface( + rpc, + payer, + ownerAta, + t22Mint, + recipientAta, + delegate, + owner.publicKey, + 200_000_000n, + undefined, + TOKEN_2022_PROGRAM_ID, + ); + expect(sig).toBeTruthy(); + + // Verify balances + const ownerAccount = await getAccount(rpc, ownerAta, undefined, TOKEN_2022_PROGRAM_ID); + expect(ownerAccount.amount).toBe(800_000_000n); + expect(ownerAccount.delegatedAmount).toBe(300_000_000n); + + const recipientAccount = await getAccount(rpc, recipientAta, undefined, TOKEN_2022_PROGRAM_ID); + expect(recipientAccount.amount).toBe(200_000_000n); + + // Revoke + await revokeInterface( + rpc, + payer, + ownerAta, + t22Mint, + owner, + undefined, + TOKEN_2022_PROGRAM_ID, + ); + + const afterRevoke = await getAccount(rpc, ownerAta, undefined, TOKEN_2022_PROGRAM_ID); + expect(afterRevoke.delegate).toBeNull(); + expect(afterRevoke.delegatedAmount).toBe(0n); + }, 120_000); +}); From 81d96625aa346b5aae90703349775821fe70bdd9 Mon Sep 17 00:00:00 2001 From: tilo-14 Date: Thu, 19 Mar 2026 21:32:47 +0000 Subject: [PATCH 05/14] refactor(sdk): align transferDelegated with wallet-recipient API Update transferDelegatedInterface and createTransferDelegatedInterfaceInstructions to accept a recipient wallet address instead of an explicit destination token account, matching the transferInterface convention from PR #2354. ATA derivation and idempotent creation now happen internally for all programId variants (light-token, SPL, Token-2022). --- .../actions/transfer-delegated-interface.ts | 18 ++++++----- .../e2e/transfer-delegated-failures.test.ts | 15 +++------ .../e2e/transfer-delegated-interface.test.ts | 8 ++--- .../e2e/transfer-delegated-spl-t22.test.ts | 31 +++++++------------ 4 files changed, 29 insertions(+), 43 deletions(-) diff --git a/js/compressed-token/src/v3/actions/transfer-delegated-interface.ts b/js/compressed-token/src/v3/actions/transfer-delegated-interface.ts index 6cc0150edd..0aad3ebb12 100644 --- a/js/compressed-token/src/v3/actions/transfer-delegated-interface.ts +++ b/js/compressed-token/src/v3/actions/transfer-delegated-interface.ts @@ -11,8 +11,10 @@ import { LIGHT_TOKEN_PROGRAM_ID, } from '@lightprotocol/stateless.js'; import BN from 'bn.js'; -import { transferInterface } from './transfer-interface'; -import { createTransferInterfaceInstructions } from '../instructions/transfer-interface'; +import { + transferInterface, + createTransferInterfaceInstructions, +} from './transfer-interface'; /** * Transfer tokens from an ATA as an approved delegate. @@ -26,7 +28,7 @@ import { createTransferInterfaceInstructions } from '../instructions/transfer-in * @param payer Fee payer (signer) * @param source Source ATA (owner's account) * @param mint Mint address - * @param destination Destination ATA + * @param recipient Recipient wallet address (ATA derived + created internally) * @param delegate Delegate authority (signer) * @param owner Owner of the source ATA (does not sign) * @param amount Amount to transfer (must be within approved allowance) @@ -39,7 +41,7 @@ export async function transferDelegatedInterface( payer: Signer, source: PublicKey, mint: PublicKey, - destination: PublicKey, + recipient: PublicKey, delegate: Signer, owner: PublicKey, amount: number | bigint | BN, @@ -53,7 +55,7 @@ export async function transferDelegatedInterface( payer, source, mint, - destination, + recipient, delegate, amount, programId, @@ -74,7 +76,7 @@ export async function transferDelegatedInterface( * @param amount Amount to transfer * @param delegate Delegate public key (authority) * @param owner Owner of the source ATA (for derivation) - * @param destination Destination ATA address + * @param recipient Recipient wallet address (ATA derived + created internally) * @param decimals Token decimals * @param programId Token program ID (default: LIGHT_TOKEN_PROGRAM_ID) * @returns Instruction batches @@ -86,7 +88,7 @@ export async function createTransferDelegatedInterfaceInstructions( amount: number | bigint | BN, delegate: PublicKey, owner: PublicKey, - destination: PublicKey, + recipient: PublicKey, decimals: number, programId: PublicKey = LIGHT_TOKEN_PROGRAM_ID, ): Promise { @@ -98,7 +100,7 @@ export async function createTransferDelegatedInterfaceInstructions( mint, amount, delegate, - destination, + recipient, decimals, { owner, programId }, ); diff --git a/js/compressed-token/tests/e2e/transfer-delegated-failures.test.ts b/js/compressed-token/tests/e2e/transfer-delegated-failures.test.ts index 7a740efbf7..103e19e0da 100644 --- a/js/compressed-token/tests/e2e/transfer-delegated-failures.test.ts +++ b/js/compressed-token/tests/e2e/transfer-delegated-failures.test.ts @@ -39,7 +39,6 @@ describe('transferDelegatedInterface - failure cases', () => { let recipient: Keypair; let mint: PublicKey; let ownerAta: PublicKey; - let recipientAta: PublicKey; beforeAll(async () => { rpc = createRpc(RPC_URL, PHOTON_URL, PROVER_URL); @@ -68,12 +67,6 @@ describe('transferDelegatedInterface - failure cases', () => { await createAtaInterface(rpc, payer, mint, owner.publicKey); ownerAta = getAssociatedTokenAddressInterface(mint, owner.publicKey); - await createAtaInterface(rpc, payer, mint, recipient.publicKey); - recipientAta = getAssociatedTokenAddressInterface( - mint, - recipient.publicKey, - ); - await mintToInterface( rpc, payer, @@ -102,7 +95,7 @@ describe('transferDelegatedInterface - failure cases', () => { payer, ownerAta, mint, - recipientAta, + recipient.publicKey, delegate, owner.publicKey, 600_000_000, // > 500M allowance @@ -117,7 +110,7 @@ describe('transferDelegatedInterface - failure cases', () => { payer, ownerAta, mint, - recipientAta, + recipient.publicKey, stranger, // not approved owner.publicKey, 100_000_000, @@ -135,11 +128,11 @@ describe('transferDelegatedInterface - failure cases', () => { payer, ownerAta, mint, - recipientAta, + recipient.publicKey, delegate, owner.publicKey, 100_000_000, ), ).rejects.toThrow(); }, 60_000); -}); \ No newline at end of file +}); diff --git a/js/compressed-token/tests/e2e/transfer-delegated-interface.test.ts b/js/compressed-token/tests/e2e/transfer-delegated-interface.test.ts index a99a14c073..8e949c1df6 100644 --- a/js/compressed-token/tests/e2e/transfer-delegated-interface.test.ts +++ b/js/compressed-token/tests/e2e/transfer-delegated-interface.test.ts @@ -64,11 +64,9 @@ describe('transferDelegatedInterface - e2e', () => { ); mint = mintPubkey; - // Create ATAs + // Create owner ATA + derive recipient ATA (created by transferDelegated) await createAtaInterface(rpc, payer, mint, owner.publicKey); ownerAta = getAssociatedTokenAddressInterface(mint, owner.publicKey); - - await createAtaInterface(rpc, payer, mint, recipient.publicKey); recipientAta = getAssociatedTokenAddressInterface( mint, recipient.publicKey, @@ -103,13 +101,13 @@ describe('transferDelegatedInterface - e2e', () => { BigInt(500_000_000), ); - // 2. Delegate transfer 200M + // 2. Delegate transfer 200M (recipient wallet — ATA created internally) const sig = await transferDelegatedInterface( rpc, payer, ownerAta, mint, - recipientAta, + recipient.publicKey, delegate, owner.publicKey, 200_000_000, diff --git a/js/compressed-token/tests/e2e/transfer-delegated-spl-t22.test.ts b/js/compressed-token/tests/e2e/transfer-delegated-spl-t22.test.ts index b3a8f6a2a3..1b42dcafcd 100644 --- a/js/compressed-token/tests/e2e/transfer-delegated-spl-t22.test.ts +++ b/js/compressed-token/tests/e2e/transfer-delegated-spl-t22.test.ts @@ -13,6 +13,7 @@ import { import { createMint as createSplMint, getOrCreateAssociatedTokenAccount, + getAssociatedTokenAddressSync, mintTo, getAccount, TOKEN_PROGRAM_ID, @@ -84,17 +85,13 @@ describe('delegated transfer - SPL mint', () => { ); ownerAta = ownerAtaInfo.address; - const recipientAtaInfo = await getOrCreateAssociatedTokenAccount( - rpc, - payer as Keypair, + // Derive recipient ATA for balance assertions (created by transferDelegated) + recipientAta = getAssociatedTokenAddressSync( splMint, recipient.publicKey, false, - undefined, - undefined, TOKEN_PROGRAM_ID, ); - recipientAta = recipientAtaInfo.address; await mintTo( rpc, @@ -123,13 +120,13 @@ describe('delegated transfer - SPL mint', () => { TOKEN_PROGRAM_ID, ); - // Delegated transfer + // Delegated transfer (recipient wallet — ATA created internally) const sig = await transferDelegatedInterface( rpc, payer, ownerAta, splMint, - recipientAta, + recipient.publicKey, delegate, owner.publicKey, 200_000_000n, @@ -182,7 +179,7 @@ describe('delegated transfer - SPL mint', () => { payer, ownerAta, splMint, - recipientAta, + recipient.publicKey, delegate, owner.publicKey, 200_000_000n, // > 100M allowance @@ -199,7 +196,7 @@ describe('delegated transfer - SPL mint', () => { payer, ownerAta, splMint, - recipientAta, + recipient.publicKey, stranger, owner.publicKey, 50_000_000n, @@ -226,7 +223,7 @@ describe('delegated transfer - SPL mint', () => { payer, ownerAta, splMint, - recipientAta, + recipient.publicKey, delegate, owner.publicKey, 50_000_000n, @@ -281,17 +278,13 @@ describe('delegated transfer - Token-2022 mint', () => { ); ownerAta = ownerAtaInfo.address; - const recipientAtaInfo = await getOrCreateAssociatedTokenAccount( - rpc, - payer as Keypair, + // Derive recipient ATA for balance assertions (created by transferDelegated) + recipientAta = getAssociatedTokenAddressSync( t22Mint, recipient.publicKey, false, - undefined, - undefined, TOKEN_2022_PROGRAM_ID, ); - recipientAta = recipientAtaInfo.address; await mintTo( rpc, @@ -320,13 +313,13 @@ describe('delegated transfer - Token-2022 mint', () => { TOKEN_2022_PROGRAM_ID, ); - // Delegated transfer + // Delegated transfer (recipient wallet — ATA created internally) const sig = await transferDelegatedInterface( rpc, payer, ownerAta, t22Mint, - recipientAta, + recipient.publicKey, delegate, owner.publicKey, 200_000_000n, From b5a21f37f7ebd56aad3a319ee4993301dcca966d Mon Sep 17 00:00:00 2001 From: tilo-14 Date: Fri, 20 Mar 2026 15:27:06 +0000 Subject: [PATCH 06/14] 1st batch commnets --- js/compressed-token/src/index.ts | 2 - .../src/v3/actions/approve-interface.ts | 309 +---------------- js/compressed-token/src/v3/actions/index.ts | 1 - .../actions/transfer-delegated-interface.ts | 107 ------ .../src/v3/instructions/approve-interface.ts | 312 ++++++++++++++++++ .../src/v3/instructions/index.ts | 1 + js/compressed-token/src/v3/unified/index.ts | 81 +---- .../e2e/transfer-delegated-failures.test.ts | 20 +- .../e2e/transfer-delegated-interface.test.ts | 8 +- .../e2e/transfer-delegated-spl-t22.test.ts | 32 +- 10 files changed, 365 insertions(+), 508 deletions(-) delete mode 100644 js/compressed-token/src/v3/actions/transfer-delegated-interface.ts create mode 100644 js/compressed-token/src/v3/instructions/approve-interface.ts diff --git a/js/compressed-token/src/index.ts b/js/compressed-token/src/index.ts index 6072302bea..d7844443c9 100644 --- a/js/compressed-token/src/index.ts +++ b/js/compressed-token/src/index.ts @@ -86,8 +86,6 @@ export { createApproveInterfaceInstructions, revokeInterface, createRevokeInterfaceInstructions, - transferDelegatedInterface, - createTransferDelegatedInterfaceInstructions, wrap, mintTo as mintToLightToken, mintToCompressed, diff --git a/js/compressed-token/src/v3/actions/approve-interface.ts b/js/compressed-token/src/v3/actions/approve-interface.ts index 8120297b91..c607101e45 100644 --- a/js/compressed-token/src/v3/actions/approve-interface.ts +++ b/js/compressed-token/src/v3/actions/approve-interface.ts @@ -3,8 +3,6 @@ import { PublicKey, Signer, TransactionSignature, - ComputeBudgetProgram, - TransactionInstruction, } from '@solana/web3.js'; import { Rpc, @@ -14,38 +12,15 @@ import { assertBetaEnabled, LIGHT_TOKEN_PROGRAM_ID, } from '@lightprotocol/stateless.js'; -import { - TOKEN_PROGRAM_ID, - TOKEN_2022_PROGRAM_ID, - createApproveInstruction as createSplApproveInstruction, - createRevokeInstruction as createSplRevokeInstruction, -} from '@solana/spl-token'; import BN from 'bn.js'; import { - createLightTokenApproveInstruction, - createLightTokenRevokeInstruction, -} from '../instructions/approve-revoke'; + createApproveInterfaceInstructions, + createRevokeInterfaceInstructions, +} from '../instructions/approve-interface'; import { getAssociatedTokenAddressInterface } from '../get-associated-token-address-interface'; import { getMintInterface } from '../get-mint-interface'; -import { - getAtaInterface as _getAtaInterface, - checkNotFrozen, -} from '../get-account-interface'; -import { - _buildLoadBatches, - calculateLoadBatchComputeUnits, - type InternalLoadBatch, -} from '../instructions/load-ata'; -import { calculateCombinedCU } from '../instructions/calculate-combined-cu'; -import { assertTransactionSizeWithinLimit } from '../utils/estimate-tx-size'; import { sliceLast } from './slice-last'; -const APPROVE_BASE_CU = 10_000; - -function calculateApproveCU(loadBatch: InternalLoadBatch | null): number { - return calculateCombinedCU(APPROVE_BASE_CU, loadBatch); -} - /** * Approve a delegate for an associated token account. * @@ -61,6 +36,7 @@ function calculateApproveCU(loadBatch: InternalLoadBatch | null): number { * @param owner Owner of the token account (signer) * @param confirmOptions Optional confirm options * @param programId Token program ID (default: LIGHT_TOKEN_PROGRAM_ID) + * @param wrap When true and mint is SPL/T22, wrap into light-token then approve * @returns Transaction signature */ export async function approveInterface( @@ -73,6 +49,7 @@ export async function approveInterface( owner: Signer, confirmOptions?: ConfirmOptions, programId: PublicKey = LIGHT_TOKEN_PROGRAM_ID, + wrap = false, ): Promise { assertBetaEnabled(); @@ -99,6 +76,7 @@ export async function approveInterface( owner.publicKey, mintInterface.mint.decimals, programId, + wrap, ); const additionalSigners = dedupeSigner(payer, [owner]); @@ -127,145 +105,6 @@ export async function approveInterface( return sendAndConfirmTx(rpc, tx, confirmOptions); } -/** - * Build instruction batches for approving a delegate on an ATA. - * - * Supports light-token, SPL, and Token-2022 mints. - * Returns `TransactionInstruction[][]`. Send [0..n-2] in parallel, then [n-1]. - * - * @param rpc RPC connection - * @param payer Fee payer public key - * @param mint Mint address - * @param tokenAccount ATA address - * @param delegate Delegate to approve - * @param amount Amount to delegate - * @param owner Owner public key - * @param decimals Token decimals - * @param programId Token program ID (default: LIGHT_TOKEN_PROGRAM_ID) - * @returns Instruction batches - */ -export async function createApproveInterfaceInstructions( - rpc: Rpc, - payer: PublicKey, - mint: PublicKey, - tokenAccount: PublicKey, - delegate: PublicKey, - amount: number | bigint | BN, - owner: PublicKey, - decimals: number, - programId: PublicKey = LIGHT_TOKEN_PROGRAM_ID, -): Promise { - assertBetaEnabled(); - - const amountBigInt = BigInt(amount.toString()); - - const isSplOrT22 = - programId.equals(TOKEN_PROGRAM_ID) || - programId.equals(TOKEN_2022_PROGRAM_ID); - - const accountInterface = await _getAtaInterface( - rpc, - tokenAccount, - owner, - mint, - undefined, - isSplOrT22 ? programId : undefined, - ); - - checkNotFrozen(accountInterface, 'approve'); - - if (isSplOrT22) { - const approveIx = createSplApproveInstruction( - tokenAccount, - delegate, - owner, - amountBigInt, - [], - programId, - ); - - const numSigners = payer.equals(owner) ? 1 : 2; - const txIxs = [ - ComputeBudgetProgram.setComputeUnitLimit({ - units: APPROVE_BASE_CU, - }), - approveIx, - ]; - assertTransactionSizeWithinLimit(txIxs, numSigners, 'Batch'); - return [txIxs]; - } - - // Light-token path: load cold accounts if needed - const internalBatches = await _buildLoadBatches( - rpc, - payer, - accountInterface, - undefined, - false, - tokenAccount, - undefined, - owner, - decimals, - ); - - const approveIx = createLightTokenApproveInstruction( - tokenAccount, - delegate, - owner, - amountBigInt, - payer, - ); - - const numSigners = payer.equals(owner) ? 1 : 2; - - if (internalBatches.length === 0) { - const cu = calculateApproveCU(null); - const txIxs = [ - ComputeBudgetProgram.setComputeUnitLimit({ units: cu }), - approveIx, - ]; - assertTransactionSizeWithinLimit(txIxs, numSigners, 'Batch'); - return [txIxs]; - } - - if (internalBatches.length === 1) { - const batch = internalBatches[0]; - const cu = calculateApproveCU(batch); - const txIxs = [ - ComputeBudgetProgram.setComputeUnitLimit({ units: cu }), - ...batch.instructions, - approveIx, - ]; - assertTransactionSizeWithinLimit(txIxs, numSigners, 'Batch'); - return [txIxs]; - } - - const result: TransactionInstruction[][] = []; - - for (let i = 0; i < internalBatches.length - 1; i++) { - const batch = internalBatches[i]; - const cu = calculateLoadBatchComputeUnits(batch); - const txIxs = [ - ComputeBudgetProgram.setComputeUnitLimit({ units: cu }), - ...batch.instructions, - ]; - assertTransactionSizeWithinLimit(txIxs, numSigners, 'Batch'); - result.push(txIxs); - } - - const lastBatch = internalBatches[internalBatches.length - 1]; - const lastCu = calculateApproveCU(lastBatch); - const lastTxIxs = [ - ComputeBudgetProgram.setComputeUnitLimit({ units: lastCu }), - ...lastBatch.instructions, - approveIx, - ]; - assertTransactionSizeWithinLimit(lastTxIxs, numSigners, 'Batch'); - result.push(lastTxIxs); - - return result; -} - /** * Revoke delegation for an associated token account. * @@ -279,6 +118,7 @@ export async function createApproveInterfaceInstructions( * @param owner Owner of the token account (signer) * @param confirmOptions Optional confirm options * @param programId Token program ID (default: LIGHT_TOKEN_PROGRAM_ID) + * @param wrap When true and mint is SPL/T22, wrap into light-token then revoke * @returns Transaction signature */ export async function revokeInterface( @@ -289,6 +129,7 @@ export async function revokeInterface( owner: Signer, confirmOptions?: ConfirmOptions, programId: PublicKey = LIGHT_TOKEN_PROGRAM_ID, + wrap = false, ): Promise { assertBetaEnabled(); @@ -313,6 +154,7 @@ export async function revokeInterface( owner.publicKey, mintInterface.mint.decimals, programId, + wrap, ); const additionalSigners = dedupeSigner(payer, [owner]); @@ -336,135 +178,10 @@ export async function revokeInterface( return sendAndConfirmTx(rpc, tx, confirmOptions); } -/** - * Build instruction batches for revoking delegation on an ATA. - * - * Supports light-token, SPL, and Token-2022 mints. - * Returns `TransactionInstruction[][]`. Send [0..n-2] in parallel, then [n-1]. - * - * @param rpc RPC connection - * @param payer Fee payer public key - * @param mint Mint address - * @param tokenAccount ATA address - * @param owner Owner public key - * @param decimals Token decimals - * @param programId Token program ID (default: LIGHT_TOKEN_PROGRAM_ID) - * @returns Instruction batches - */ -export async function createRevokeInterfaceInstructions( - rpc: Rpc, - payer: PublicKey, - mint: PublicKey, - tokenAccount: PublicKey, - owner: PublicKey, - decimals: number, - programId: PublicKey = LIGHT_TOKEN_PROGRAM_ID, -): Promise { - assertBetaEnabled(); - - const isSplOrT22 = - programId.equals(TOKEN_PROGRAM_ID) || - programId.equals(TOKEN_2022_PROGRAM_ID); - - const accountInterface = await _getAtaInterface( - rpc, - tokenAccount, - owner, - mint, - undefined, - isSplOrT22 ? programId : undefined, - ); - - checkNotFrozen(accountInterface, 'revoke'); - - if (isSplOrT22) { - const revokeIx = createSplRevokeInstruction( - tokenAccount, - owner, - [], - programId, - ); - - const numSigners = payer.equals(owner) ? 1 : 2; - const txIxs = [ - ComputeBudgetProgram.setComputeUnitLimit({ - units: APPROVE_BASE_CU, - }), - revokeIx, - ]; - assertTransactionSizeWithinLimit(txIxs, numSigners, 'Batch'); - return [txIxs]; - } - - // Light-token path: load cold accounts if needed - const internalBatches = await _buildLoadBatches( - rpc, - payer, - accountInterface, - undefined, - false, - tokenAccount, - undefined, - owner, - decimals, - ); - - const revokeIx = createLightTokenRevokeInstruction( - tokenAccount, - owner, - payer, - ); - - const numSigners = payer.equals(owner) ? 1 : 2; - - if (internalBatches.length === 0) { - const cu = calculateApproveCU(null); - const txIxs = [ - ComputeBudgetProgram.setComputeUnitLimit({ units: cu }), - revokeIx, - ]; - assertTransactionSizeWithinLimit(txIxs, numSigners, 'Batch'); - return [txIxs]; - } - - if (internalBatches.length === 1) { - const batch = internalBatches[0]; - const cu = calculateApproveCU(batch); - const txIxs = [ - ComputeBudgetProgram.setComputeUnitLimit({ units: cu }), - ...batch.instructions, - revokeIx, - ]; - assertTransactionSizeWithinLimit(txIxs, numSigners, 'Batch'); - return [txIxs]; - } - - const result: TransactionInstruction[][] = []; - - for (let i = 0; i < internalBatches.length - 1; i++) { - const batch = internalBatches[i]; - const cu = calculateLoadBatchComputeUnits(batch); - const txIxs = [ - ComputeBudgetProgram.setComputeUnitLimit({ units: cu }), - ...batch.instructions, - ]; - assertTransactionSizeWithinLimit(txIxs, numSigners, 'Batch'); - result.push(txIxs); - } - - const lastBatch = internalBatches[internalBatches.length - 1]; - const lastCu = calculateApproveCU(lastBatch); - const lastTxIxs = [ - ComputeBudgetProgram.setComputeUnitLimit({ units: lastCu }), - ...lastBatch.instructions, - revokeIx, - ]; - assertTransactionSizeWithinLimit(lastTxIxs, numSigners, 'Batch'); - result.push(lastTxIxs); - - return result; -} - +export { + createApproveInterfaceInstructions, + createRevokeInterfaceInstructions, +} from '../instructions/approve-interface'; export { sliceLast } from './slice-last'; export { createLightTokenApproveInstruction, diff --git a/js/compressed-token/src/v3/actions/index.ts b/js/compressed-token/src/v3/actions/index.ts index bca76538ba..8673f1beed 100644 --- a/js/compressed-token/src/v3/actions/index.ts +++ b/js/compressed-token/src/v3/actions/index.ts @@ -13,4 +13,3 @@ export * from './wrap'; export * from './unwrap'; export * from './load-ata'; export * from './approve-interface'; -export * from './transfer-delegated-interface'; diff --git a/js/compressed-token/src/v3/actions/transfer-delegated-interface.ts b/js/compressed-token/src/v3/actions/transfer-delegated-interface.ts deleted file mode 100644 index 0aad3ebb12..0000000000 --- a/js/compressed-token/src/v3/actions/transfer-delegated-interface.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { - ConfirmOptions, - PublicKey, - Signer, - TransactionInstruction, - TransactionSignature, -} from '@solana/web3.js'; -import { - Rpc, - assertBetaEnabled, - LIGHT_TOKEN_PROGRAM_ID, -} from '@lightprotocol/stateless.js'; -import BN from 'bn.js'; -import { - transferInterface, - createTransferInterfaceInstructions, -} from './transfer-interface'; - -/** - * Transfer tokens from an ATA as an approved delegate. - * - * Supports light-token, SPL, and Token-2022 mints. Convenience wrapper - * around {@link transferInterface} that makes the delegate-transfer API - * explicit: the delegate is a {@link Signer} (authority), the owner is a - * {@link PublicKey} (used only for ATA derivation, does NOT sign). - * - * @param rpc RPC connection - * @param payer Fee payer (signer) - * @param source Source ATA (owner's account) - * @param mint Mint address - * @param recipient Recipient wallet address (ATA derived + created internally) - * @param delegate Delegate authority (signer) - * @param owner Owner of the source ATA (does not sign) - * @param amount Amount to transfer (must be within approved allowance) - * @param confirmOptions Optional confirm options - * @param programId Token program ID (default: LIGHT_TOKEN_PROGRAM_ID) - * @returns Transaction signature - */ -export async function transferDelegatedInterface( - rpc: Rpc, - payer: Signer, - source: PublicKey, - mint: PublicKey, - recipient: PublicKey, - delegate: Signer, - owner: PublicKey, - amount: number | bigint | BN, - confirmOptions?: ConfirmOptions, - programId: PublicKey = LIGHT_TOKEN_PROGRAM_ID, -): Promise { - assertBetaEnabled(); - - return transferInterface( - rpc, - payer, - source, - mint, - recipient, - delegate, - amount, - programId, - confirmOptions, - { owner }, - ); -} - -/** - * Build instruction batches for a delegated transfer on an ATA. - * - * Supports light-token, SPL, and Token-2022 mints. - * Returns `TransactionInstruction[][]`. Send [0..n-2] in parallel, then [n-1]. - * - * @param rpc RPC connection - * @param payer Fee payer public key - * @param mint Mint address - * @param amount Amount to transfer - * @param delegate Delegate public key (authority) - * @param owner Owner of the source ATA (for derivation) - * @param recipient Recipient wallet address (ATA derived + created internally) - * @param decimals Token decimals - * @param programId Token program ID (default: LIGHT_TOKEN_PROGRAM_ID) - * @returns Instruction batches - */ -export async function createTransferDelegatedInterfaceInstructions( - rpc: Rpc, - payer: PublicKey, - mint: PublicKey, - amount: number | bigint | BN, - delegate: PublicKey, - owner: PublicKey, - recipient: PublicKey, - decimals: number, - programId: PublicKey = LIGHT_TOKEN_PROGRAM_ID, -): Promise { - assertBetaEnabled(); - - return createTransferInterfaceInstructions( - rpc, - payer, - mint, - amount, - delegate, - recipient, - decimals, - { owner, programId }, - ); -} diff --git a/js/compressed-token/src/v3/instructions/approve-interface.ts b/js/compressed-token/src/v3/instructions/approve-interface.ts new file mode 100644 index 0000000000..9a305ce6b9 --- /dev/null +++ b/js/compressed-token/src/v3/instructions/approve-interface.ts @@ -0,0 +1,312 @@ +import { + PublicKey, + ComputeBudgetProgram, + TransactionInstruction, +} from '@solana/web3.js'; +import { + Rpc, + assertBetaEnabled, + LIGHT_TOKEN_PROGRAM_ID, +} from '@lightprotocol/stateless.js'; +import { + TOKEN_PROGRAM_ID, + TOKEN_2022_PROGRAM_ID, + createApproveInstruction as createSplApproveInstruction, + createRevokeInstruction as createSplRevokeInstruction, +} from '@solana/spl-token'; +import BN from 'bn.js'; +import { + createLightTokenApproveInstruction, + createLightTokenRevokeInstruction, +} from './approve-revoke'; +import { + getAtaInterface as _getAtaInterface, + checkNotFrozen, +} from '../get-account-interface'; +import { + _buildLoadBatches, + calculateLoadBatchComputeUnits, + type InternalLoadBatch, +} from './load-ata'; +import { calculateCombinedCU } from './calculate-combined-cu'; +import { assertTransactionSizeWithinLimit } from '../utils/estimate-tx-size'; + +const APPROVE_BASE_CU = 10_000; + +function calculateApproveCU(loadBatch: InternalLoadBatch | null): number { + return calculateCombinedCU(APPROVE_BASE_CU, loadBatch); +} + +/** + * Build instruction batches for approving a delegate on an ATA. + * + * Supports light-token, SPL, and Token-2022 mints. + * Returns `TransactionInstruction[][]`. Send [0..n-2] in parallel, then [n-1]. + * + * @param rpc RPC connection + * @param payer Fee payer public key + * @param mint Mint address + * @param tokenAccount ATA address + * @param delegate Delegate to approve + * @param amount Amount to delegate + * @param owner Owner public key + * @param decimals Token decimals + * @param programId Token program ID (default: LIGHT_TOKEN_PROGRAM_ID) + * @param wrap When true and mint is SPL/T22, wrap into light-token then approve + * @returns Instruction batches + */ +export async function createApproveInterfaceInstructions( + rpc: Rpc, + payer: PublicKey, + mint: PublicKey, + tokenAccount: PublicKey, + delegate: PublicKey, + amount: number | bigint | BN, + owner: PublicKey, + decimals: number, + programId: PublicKey = LIGHT_TOKEN_PROGRAM_ID, + wrap = false, +): Promise { + assertBetaEnabled(); + + const amountBigInt = BigInt(amount.toString()); + + const isSplOrT22 = + programId.equals(TOKEN_PROGRAM_ID) || + programId.equals(TOKEN_2022_PROGRAM_ID); + + const accountInterface = await _getAtaInterface( + rpc, + tokenAccount, + owner, + mint, + undefined, + programId.equals(LIGHT_TOKEN_PROGRAM_ID) ? undefined : programId, + wrap, + ); + + checkNotFrozen(accountInterface, 'approve'); + + if (isSplOrT22 && !wrap) { + const approveIx = createSplApproveInstruction( + tokenAccount, + delegate, + owner, + amountBigInt, + [], + programId, + ); + + const numSigners = payer.equals(owner) ? 1 : 2; + const txIxs = [ + ComputeBudgetProgram.setComputeUnitLimit({ + units: APPROVE_BASE_CU, + }), + approveIx, + ]; + assertTransactionSizeWithinLimit(txIxs, numSigners, 'Batch'); + return [txIxs]; + } + + // Light-token path: load cold accounts if needed + const internalBatches = await _buildLoadBatches( + rpc, + payer, + accountInterface, + undefined, + wrap, + tokenAccount, + undefined, + owner, + decimals, + ); + + const approveIx = createLightTokenApproveInstruction( + tokenAccount, + delegate, + owner, + amountBigInt, + payer, + ); + + const numSigners = payer.equals(owner) ? 1 : 2; + + if (internalBatches.length === 0) { + const cu = calculateApproveCU(null); + const txIxs = [ + ComputeBudgetProgram.setComputeUnitLimit({ units: cu }), + approveIx, + ]; + assertTransactionSizeWithinLimit(txIxs, numSigners, 'Batch'); + return [txIxs]; + } + + if (internalBatches.length === 1) { + const batch = internalBatches[0]; + const cu = calculateApproveCU(batch); + const txIxs = [ + ComputeBudgetProgram.setComputeUnitLimit({ units: cu }), + ...batch.instructions, + approveIx, + ]; + assertTransactionSizeWithinLimit(txIxs, numSigners, 'Batch'); + return [txIxs]; + } + + const result: TransactionInstruction[][] = []; + + for (let i = 0; i < internalBatches.length - 1; i++) { + const batch = internalBatches[i]; + const cu = calculateLoadBatchComputeUnits(batch); + const txIxs = [ + ComputeBudgetProgram.setComputeUnitLimit({ units: cu }), + ...batch.instructions, + ]; + assertTransactionSizeWithinLimit(txIxs, numSigners, 'Batch'); + result.push(txIxs); + } + + const lastBatch = internalBatches[internalBatches.length - 1]; + const lastCu = calculateApproveCU(lastBatch); + const lastTxIxs = [ + ComputeBudgetProgram.setComputeUnitLimit({ units: lastCu }), + ...lastBatch.instructions, + approveIx, + ]; + assertTransactionSizeWithinLimit(lastTxIxs, numSigners, 'Batch'); + result.push(lastTxIxs); + + return result; +} + +/** + * Build instruction batches for revoking delegation on an ATA. + * + * Supports light-token, SPL, and Token-2022 mints. + * Returns `TransactionInstruction[][]`. Send [0..n-2] in parallel, then [n-1]. + * + * @param rpc RPC connection + * @param payer Fee payer public key + * @param mint Mint address + * @param tokenAccount ATA address + * @param owner Owner public key + * @param decimals Token decimals + * @param programId Token program ID (default: LIGHT_TOKEN_PROGRAM_ID) + * @param wrap When true and mint is SPL/T22, wrap into light-token then revoke + * @returns Instruction batches + */ +export async function createRevokeInterfaceInstructions( + rpc: Rpc, + payer: PublicKey, + mint: PublicKey, + tokenAccount: PublicKey, + owner: PublicKey, + decimals: number, + programId: PublicKey = LIGHT_TOKEN_PROGRAM_ID, + wrap = false, +): Promise { + assertBetaEnabled(); + + const isSplOrT22 = + programId.equals(TOKEN_PROGRAM_ID) || + programId.equals(TOKEN_2022_PROGRAM_ID); + + const accountInterface = await _getAtaInterface( + rpc, + tokenAccount, + owner, + mint, + undefined, + programId.equals(LIGHT_TOKEN_PROGRAM_ID) ? undefined : programId, + wrap, + ); + + checkNotFrozen(accountInterface, 'revoke'); + + if (isSplOrT22 && !wrap) { + const revokeIx = createSplRevokeInstruction( + tokenAccount, + owner, + [], + programId, + ); + + const numSigners = payer.equals(owner) ? 1 : 2; + const txIxs = [ + ComputeBudgetProgram.setComputeUnitLimit({ + units: APPROVE_BASE_CU, + }), + revokeIx, + ]; + assertTransactionSizeWithinLimit(txIxs, numSigners, 'Batch'); + return [txIxs]; + } + + // Light-token path: load cold accounts if needed + const internalBatches = await _buildLoadBatches( + rpc, + payer, + accountInterface, + undefined, + wrap, + tokenAccount, + undefined, + owner, + decimals, + ); + + const revokeIx = createLightTokenRevokeInstruction( + tokenAccount, + owner, + payer, + ); + + const numSigners = payer.equals(owner) ? 1 : 2; + + if (internalBatches.length === 0) { + const cu = calculateApproveCU(null); + const txIxs = [ + ComputeBudgetProgram.setComputeUnitLimit({ units: cu }), + revokeIx, + ]; + assertTransactionSizeWithinLimit(txIxs, numSigners, 'Batch'); + return [txIxs]; + } + + if (internalBatches.length === 1) { + const batch = internalBatches[0]; + const cu = calculateApproveCU(batch); + const txIxs = [ + ComputeBudgetProgram.setComputeUnitLimit({ units: cu }), + ...batch.instructions, + revokeIx, + ]; + assertTransactionSizeWithinLimit(txIxs, numSigners, 'Batch'); + return [txIxs]; + } + + const result: TransactionInstruction[][] = []; + + for (let i = 0; i < internalBatches.length - 1; i++) { + const batch = internalBatches[i]; + const cu = calculateLoadBatchComputeUnits(batch); + const txIxs = [ + ComputeBudgetProgram.setComputeUnitLimit({ units: cu }), + ...batch.instructions, + ]; + assertTransactionSizeWithinLimit(txIxs, numSigners, 'Batch'); + result.push(txIxs); + } + + const lastBatch = internalBatches[internalBatches.length - 1]; + const lastCu = calculateApproveCU(lastBatch); + const lastTxIxs = [ + ComputeBudgetProgram.setComputeUnitLimit({ units: lastCu }), + ...lastBatch.instructions, + revokeIx, + ]; + assertTransactionSizeWithinLimit(lastTxIxs, numSigners, 'Batch'); + result.push(lastTxIxs); + + return result; +} diff --git a/js/compressed-token/src/v3/instructions/index.ts b/js/compressed-token/src/v3/instructions/index.ts index 597261b811..d54431f0a7 100644 --- a/js/compressed-token/src/v3/instructions/index.ts +++ b/js/compressed-token/src/v3/instructions/index.ts @@ -13,3 +13,4 @@ export * from './wrap'; export * from './unwrap'; export * from './freeze-thaw'; export * from './approve-revoke'; +export * from './approve-interface'; diff --git a/js/compressed-token/src/v3/unified/index.ts b/js/compressed-token/src/v3/unified/index.ts index 6650f5ac20..e829adf3bc 100644 --- a/js/compressed-token/src/v3/unified/index.ts +++ b/js/compressed-token/src/v3/unified/index.ts @@ -43,10 +43,7 @@ import { revokeInterface as _revokeInterface, createRevokeInterfaceInstructions as _createRevokeInterfaceInstructions, } from '../actions/approve-interface'; -import { - transferDelegatedInterface as _transferDelegatedInterface, - createTransferDelegatedInterfaceInstructions as _createTransferDelegatedInterfaceInstructions, -} from '../actions/transfer-delegated-interface'; + import { _getOrCreateAtaInterface } from '../actions/get-or-create-ata-interface'; import { createUnwrapInstructions as _createUnwrapInstructions, @@ -540,6 +537,7 @@ export async function approveInterface( owner, confirmOptions, mintInfo.programId, + true, // wrap=true for unified ); } @@ -570,6 +568,7 @@ export async function createApproveInterfaceInstructions( owner, resolvedDecimals, mintInfo.programId, + true, // wrap=true for unified ); } @@ -604,6 +603,7 @@ export async function revokeInterface( owner, confirmOptions, mintInfo.programId, + true, // wrap=true for unified ); } @@ -630,81 +630,10 @@ export async function createRevokeInterfaceInstructions( owner, resolvedDecimals, mintInfo.programId, + true, // wrap=true for unified ); } -/** - * Transfer tokens from an ATA as an approved delegate. - * - * Auto-detects mint type (light-token, SPL, or Token-2022) and dispatches - * to the appropriate instruction. - * - * @param rpc RPC connection - * @param payer Fee payer (signer) - * @param source Source ATA (owner's account) - * @param mint Mint address - * @param recipient Recipient wallet address (ATA derived + created internally) - * @param delegate Delegate authority (signer) - * @param owner Owner of the source ATA (does not sign) - * @param amount Amount to transfer - * @param confirmOptions Optional confirm options - * @returns Transaction signature - */ -export async function transferDelegatedInterface( - rpc: Rpc, - payer: Signer, - source: PublicKey, - mint: PublicKey, - recipient: PublicKey, - delegate: Signer, - owner: PublicKey, - amount: number | bigint | BN, - confirmOptions?: ConfirmOptions, -) { - const mintInfo = await getMintInterface(rpc, mint); - return _transferDelegatedInterface( - rpc, - payer, - source, - mint, - recipient, - delegate, - owner, - amount, - confirmOptions, - mintInfo.programId, - ); -} - -/** - * Build instruction batches for a delegated transfer on an ATA. - * - * Auto-detects mint type (light-token, SPL, or Token-2022). - */ -export async function createTransferDelegatedInterfaceInstructions( - rpc: Rpc, - payer: PublicKey, - mint: PublicKey, - amount: number | bigint | BN, - delegate: PublicKey, - owner: PublicKey, - recipient: PublicKey, - decimals?: number, -): Promise { - const mintInfo = await getMintInterface(rpc, mint); - const resolvedDecimals = decimals ?? mintInfo.mint.decimals; - return _createTransferDelegatedInterfaceInstructions( - rpc, - payer, - mint, - amount, - delegate, - owner, - recipient, - resolvedDecimals, - mintInfo.programId, - ); -} export { getAccountInterface, AccountInterface, diff --git a/js/compressed-token/tests/e2e/transfer-delegated-failures.test.ts b/js/compressed-token/tests/e2e/transfer-delegated-failures.test.ts index 103e19e0da..437719e859 100644 --- a/js/compressed-token/tests/e2e/transfer-delegated-failures.test.ts +++ b/js/compressed-token/tests/e2e/transfer-delegated-failures.test.ts @@ -15,7 +15,7 @@ import { getAssociatedTokenAddressInterface, approveInterface, revokeInterface, - transferDelegatedInterface, + transferInterface, mintToInterface, } from '../../src'; @@ -90,30 +90,34 @@ describe('transferDelegatedInterface - failure cases', () => { it('rejects transfer exceeding delegated allowance', async () => { await expect( - transferDelegatedInterface( + transferInterface( rpc, payer, ownerAta, mint, recipient.publicKey, delegate, - owner.publicKey, 600_000_000, // > 500M allowance + undefined, + undefined, + { owner: owner.publicKey }, ), ).rejects.toThrow(); }, 30_000); it('rejects transfer from unapproved signer', async () => { await expect( - transferDelegatedInterface( + transferInterface( rpc, payer, ownerAta, mint, recipient.publicKey, stranger, // not approved - owner.publicKey, 100_000_000, + undefined, + undefined, + { owner: owner.publicKey }, ), ).rejects.toThrow(); }, 30_000); @@ -123,15 +127,17 @@ describe('transferDelegatedInterface - failure cases', () => { await revokeInterface(rpc, payer, ownerAta, mint, owner); await expect( - transferDelegatedInterface( + transferInterface( rpc, payer, ownerAta, mint, recipient.publicKey, delegate, - owner.publicKey, 100_000_000, + undefined, + undefined, + { owner: owner.publicKey }, ), ).rejects.toThrow(); }, 60_000); diff --git a/js/compressed-token/tests/e2e/transfer-delegated-interface.test.ts b/js/compressed-token/tests/e2e/transfer-delegated-interface.test.ts index 8e949c1df6..353a85b880 100644 --- a/js/compressed-token/tests/e2e/transfer-delegated-interface.test.ts +++ b/js/compressed-token/tests/e2e/transfer-delegated-interface.test.ts @@ -16,7 +16,7 @@ import { getAtaInterface, approveInterface, revokeInterface, - transferDelegatedInterface, + transferInterface, mintToInterface, } from '../../src'; @@ -102,15 +102,17 @@ describe('transferDelegatedInterface - e2e', () => { ); // 2. Delegate transfer 200M (recipient wallet — ATA created internally) - const sig = await transferDelegatedInterface( + const sig = await transferInterface( rpc, payer, ownerAta, mint, recipient.publicKey, delegate, - owner.publicKey, 200_000_000, + undefined, + undefined, + { owner: owner.publicKey }, ); expect(sig).toBeTruthy(); diff --git a/js/compressed-token/tests/e2e/transfer-delegated-spl-t22.test.ts b/js/compressed-token/tests/e2e/transfer-delegated-spl-t22.test.ts index 1b42dcafcd..d8cd2e7edc 100644 --- a/js/compressed-token/tests/e2e/transfer-delegated-spl-t22.test.ts +++ b/js/compressed-token/tests/e2e/transfer-delegated-spl-t22.test.ts @@ -22,7 +22,7 @@ import { import { approveInterface, revokeInterface, - transferDelegatedInterface, + transferInterface, } from '../../src'; featureFlags.version = VERSION.V2; @@ -121,17 +121,17 @@ describe('delegated transfer - SPL mint', () => { ); // Delegated transfer (recipient wallet — ATA created internally) - const sig = await transferDelegatedInterface( + const sig = await transferInterface( rpc, payer, ownerAta, splMint, recipient.publicKey, delegate, - owner.publicKey, 200_000_000n, - undefined, TOKEN_PROGRAM_ID, + undefined, + { owner: owner.publicKey }, ); expect(sig).toBeTruthy(); @@ -174,34 +174,34 @@ describe('delegated transfer - SPL mint', () => { ); await expect( - transferDelegatedInterface( + transferInterface( rpc, payer, ownerAta, splMint, recipient.publicKey, delegate, - owner.publicKey, 200_000_000n, // > 100M allowance - undefined, TOKEN_PROGRAM_ID, + undefined, + { owner: owner.publicKey }, ), ).rejects.toThrow(); }, 60_000); it('rejects transfer from unauthorized delegate', async () => { await expect( - transferDelegatedInterface( + transferInterface( rpc, payer, ownerAta, splMint, recipient.publicKey, stranger, - owner.publicKey, 50_000_000n, - undefined, TOKEN_PROGRAM_ID, + undefined, + { owner: owner.publicKey }, ), ).rejects.toThrow(); }, 60_000); @@ -218,17 +218,17 @@ describe('delegated transfer - SPL mint', () => { ); await expect( - transferDelegatedInterface( + transferInterface( rpc, payer, ownerAta, splMint, recipient.publicKey, delegate, - owner.publicKey, 50_000_000n, - undefined, TOKEN_PROGRAM_ID, + undefined, + { owner: owner.publicKey }, ), ).rejects.toThrow(); }, 60_000); @@ -314,17 +314,17 @@ describe('delegated transfer - Token-2022 mint', () => { ); // Delegated transfer (recipient wallet — ATA created internally) - const sig = await transferDelegatedInterface( + const sig = await transferInterface( rpc, payer, ownerAta, t22Mint, recipient.publicKey, delegate, - owner.publicKey, 200_000_000n, - undefined, TOKEN_2022_PROGRAM_ID, + undefined, + { owner: owner.publicKey }, ); expect(sig).toBeTruthy(); From 943a7ad374797c27fcaca4e1c3bd0555cfc2e469 Mon Sep 17 00:00:00 2001 From: tilo-14 Date: Fri, 20 Mar 2026 15:55:03 +0000 Subject: [PATCH 07/14] docs(sdk): document load-all behavior in approve/revoke JSDoc; add owner==feePayer E2E test Add @remarks to approve/revoke functions documenting that for light-token mints, all cold (compressed) balances are loaded into the hot ATA regardless of the delegation amount. Add E2E test covering the owner==feePayer code path which was previously only tested at the unit level. --- .../src/v3/actions/approve-interface.ts | 17 +++--- .../src/v3/instructions/approve-interface.ts | 23 ++++++-- js/compressed-token/src/v3/unified/index.ts | 31 +++++++--- .../e2e/approve-revoke-light-token.test.ts | 56 +++++++++++++++++++ 4 files changed, 105 insertions(+), 22 deletions(-) diff --git a/js/compressed-token/src/v3/actions/approve-interface.ts b/js/compressed-token/src/v3/actions/approve-interface.ts index c607101e45..626c8ab108 100644 --- a/js/compressed-token/src/v3/actions/approve-interface.ts +++ b/js/compressed-token/src/v3/actions/approve-interface.ts @@ -27,6 +27,10 @@ import { sliceLast } from './slice-last'; * Supports light-token, SPL, and Token-2022 mints. For light-token mints, * loads cold accounts if needed before sending the approve instruction. * + * @remarks For light-token mints, all cold (compressed) balances are loaded + * into the hot ATA, not just the delegation amount. The `amount` parameter + * only controls the delegate's spending limit. + * * @param rpc RPC connection * @param payer Fee payer (signer) * @param tokenAccount ATA address @@ -111,6 +115,9 @@ export async function approveInterface( * Supports light-token, SPL, and Token-2022 mints. For light-token mints, * loads cold accounts if needed before sending the revoke instruction. * + * @remarks For light-token mints, all cold (compressed) balances are loaded + * into the hot ATA before the revoke instruction. + * * @param rpc RPC connection * @param payer Fee payer (signer) * @param tokenAccount ATA address @@ -177,13 +184,3 @@ export async function revokeInterface( const tx = buildAndSignTx(revokeIxs, payer, blockhash, additionalSigners); return sendAndConfirmTx(rpc, tx, confirmOptions); } - -export { - createApproveInterfaceInstructions, - createRevokeInterfaceInstructions, -} from '../instructions/approve-interface'; -export { sliceLast } from './slice-last'; -export { - createLightTokenApproveInstruction, - createLightTokenRevokeInstruction, -} from '../instructions/approve-revoke'; diff --git a/js/compressed-token/src/v3/instructions/approve-interface.ts b/js/compressed-token/src/v3/instructions/approve-interface.ts index 9a305ce6b9..f8d3cbc978 100644 --- a/js/compressed-token/src/v3/instructions/approve-interface.ts +++ b/js/compressed-token/src/v3/instructions/approve-interface.ts @@ -37,12 +37,23 @@ function calculateApproveCU(loadBatch: InternalLoadBatch | null): number { return calculateCombinedCU(APPROVE_BASE_CU, loadBatch); } +const REVOKE_BASE_CU = 10_000; + +function calculateRevokeCU(loadBatch: InternalLoadBatch | null): number { + return calculateCombinedCU(REVOKE_BASE_CU, loadBatch); +} + /** * Build instruction batches for approving a delegate on an ATA. * * Supports light-token, SPL, and Token-2022 mints. * Returns `TransactionInstruction[][]`. Send [0..n-2] in parallel, then [n-1]. * + * @remarks For light-token mints, all cold (compressed) balances are loaded + * into the hot ATA before the approve instruction. The `amount` parameter + * only controls the delegate's spending limit, not the number of accounts + * loaded. Users with many cold accounts may see additional load transactions. + * * @param rpc RPC connection * @param payer Fee payer public key * @param mint Mint address @@ -185,6 +196,10 @@ export async function createApproveInterfaceInstructions( * Supports light-token, SPL, and Token-2022 mints. * Returns `TransactionInstruction[][]`. Send [0..n-2] in parallel, then [n-1]. * + * @remarks For light-token mints, all cold (compressed) balances are loaded + * into the hot ATA before the revoke instruction. Users with many cold + * accounts may see additional load transactions. + * * @param rpc RPC connection * @param payer Fee payer public key * @param mint Mint address @@ -234,7 +249,7 @@ export async function createRevokeInterfaceInstructions( const numSigners = payer.equals(owner) ? 1 : 2; const txIxs = [ ComputeBudgetProgram.setComputeUnitLimit({ - units: APPROVE_BASE_CU, + units: REVOKE_BASE_CU, }), revokeIx, ]; @@ -264,7 +279,7 @@ export async function createRevokeInterfaceInstructions( const numSigners = payer.equals(owner) ? 1 : 2; if (internalBatches.length === 0) { - const cu = calculateApproveCU(null); + const cu = calculateRevokeCU(null); const txIxs = [ ComputeBudgetProgram.setComputeUnitLimit({ units: cu }), revokeIx, @@ -275,7 +290,7 @@ export async function createRevokeInterfaceInstructions( if (internalBatches.length === 1) { const batch = internalBatches[0]; - const cu = calculateApproveCU(batch); + const cu = calculateRevokeCU(batch); const txIxs = [ ComputeBudgetProgram.setComputeUnitLimit({ units: cu }), ...batch.instructions, @@ -299,7 +314,7 @@ export async function createRevokeInterfaceInstructions( } const lastBatch = internalBatches[internalBatches.length - 1]; - const lastCu = calculateApproveCU(lastBatch); + const lastCu = calculateRevokeCU(lastBatch); const lastTxIxs = [ ComputeBudgetProgram.setComputeUnitLimit({ units: lastCu }), ...lastBatch.instructions, diff --git a/js/compressed-token/src/v3/unified/index.ts b/js/compressed-token/src/v3/unified/index.ts index e829adf3bc..64bab5e766 100644 --- a/js/compressed-token/src/v3/unified/index.ts +++ b/js/compressed-token/src/v3/unified/index.ts @@ -39,10 +39,12 @@ import { import type { TransferOptions as _TransferOptions } from '../actions/transfer-interface'; import { approveInterface as _approveInterface, - createApproveInterfaceInstructions as _createApproveInterfaceInstructions, revokeInterface as _revokeInterface, - createRevokeInterfaceInstructions as _createRevokeInterfaceInstructions, } from '../actions/approve-interface'; +import { + createApproveInterfaceInstructions as _createApproveInterfaceInstructions, + createRevokeInterfaceInstructions as _createRevokeInterfaceInstructions, +} from '../instructions/approve-interface'; import { _getOrCreateAtaInterface } from '../actions/get-or-create-ata-interface'; import { @@ -506,6 +508,10 @@ export type { * Auto-detects mint type (light-token, SPL, or Token-2022) and dispatches * to the appropriate instruction. * + * @remarks For light-token mints, all cold (compressed) balances are loaded + * into the hot ATA, not just the delegation amount. The `amount` parameter + * only controls the delegate's spending limit. + * * @param rpc RPC connection * @param payer Fee payer (signer) * @param tokenAccount ATA address @@ -526,7 +532,6 @@ export async function approveInterface( owner: Signer, confirmOptions?: ConfirmOptions, ) { - const mintInfo = await getMintInterface(rpc, mint); return _approveInterface( rpc, payer, @@ -536,7 +541,7 @@ export async function approveInterface( amount, owner, confirmOptions, - mintInfo.programId, + undefined, // programId: use default LIGHT_TOKEN_PROGRAM_ID true, // wrap=true for unified ); } @@ -545,6 +550,11 @@ export async function approveInterface( * Build instruction batches for approving a delegate on an ATA. * * Auto-detects mint type (light-token, SPL, or Token-2022). + * + * @remarks For light-token mints, all cold (compressed) balances are loaded + * into the hot ATA before the approve instruction. The `amount` parameter + * only controls the delegate's spending limit, not the number of accounts + * loaded. */ export async function createApproveInterfaceInstructions( rpc: Rpc, @@ -567,7 +577,7 @@ export async function createApproveInterfaceInstructions( amount, owner, resolvedDecimals, - mintInfo.programId, + undefined, // programId: use default LIGHT_TOKEN_PROGRAM_ID true, // wrap=true for unified ); } @@ -578,6 +588,9 @@ export async function createApproveInterfaceInstructions( * Auto-detects mint type (light-token, SPL, or Token-2022) and dispatches * to the appropriate instruction. * + * @remarks For light-token mints, all cold (compressed) balances are loaded + * into the hot ATA before the revoke instruction. + * * @param rpc RPC connection * @param payer Fee payer (signer) * @param tokenAccount ATA address @@ -594,7 +607,6 @@ export async function revokeInterface( owner: Signer, confirmOptions?: ConfirmOptions, ) { - const mintInfo = await getMintInterface(rpc, mint); return _revokeInterface( rpc, payer, @@ -602,7 +614,7 @@ export async function revokeInterface( mint, owner, confirmOptions, - mintInfo.programId, + undefined, // programId: use default LIGHT_TOKEN_PROGRAM_ID true, // wrap=true for unified ); } @@ -611,6 +623,9 @@ export async function revokeInterface( * Build instruction batches for revoking delegation on an ATA. * * Auto-detects mint type (light-token, SPL, or Token-2022). + * + * @remarks For light-token mints, all cold (compressed) balances are loaded + * into the hot ATA before the revoke instruction. */ export async function createRevokeInterfaceInstructions( rpc: Rpc, @@ -629,7 +644,7 @@ export async function createRevokeInterfaceInstructions( tokenAccount, owner, resolvedDecimals, - mintInfo.programId, + undefined, // programId: use default LIGHT_TOKEN_PROGRAM_ID true, // wrap=true for unified ); } diff --git a/js/compressed-token/tests/e2e/approve-revoke-light-token.test.ts b/js/compressed-token/tests/e2e/approve-revoke-light-token.test.ts index 7e0f693a3f..29f1c43fdc 100644 --- a/js/compressed-token/tests/e2e/approve-revoke-light-token.test.ts +++ b/js/compressed-token/tests/e2e/approve-revoke-light-token.test.ts @@ -396,6 +396,62 @@ describe('LightToken approve/revoke - E2E', () => { expect(info.delegatedAmount).toBe(BigInt(700)); }, 60_000); + it('should approve and revoke when owner is also fee payer', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + const delegate = Keypair.generate(); + const lightTokenAta = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); + + // Create hot ATA and mint tokens (use global payer for setup) + await createAtaInterfaceIdempotent(rpc, payer, mint, owner.publicKey); + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(1000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + await loadAta(rpc, lightTokenAta, owner, mint, payer); + + // Approve with owner as fee payer (omit feePayer param) + const approveIx = createLightTokenApproveInstruction( + lightTokenAta, + delegate.publicKey, + owner.publicKey, + BigInt(500), + ); + let { blockhash } = await rpc.getLatestBlockhash(); + // owner is sole signer — acts as both tx fee payer and instruction owner + let tx = buildAndSignTx([approveIx], owner, blockhash); + await sendAndConfirmTx(rpc, tx); + + // Verify delegate is set + const { delegate: actualDelegate, delegatedAmount } = + await getLightTokenDelegate(rpc, lightTokenAta); + expect(actualDelegate).not.toBeNull(); + expect(actualDelegate!.equals(delegate.publicKey)).toBe(true); + expect(delegatedAmount).toBe(BigInt(500)); + + // Revoke with owner as fee payer + const revokeIx = createLightTokenRevokeInstruction( + lightTokenAta, + owner.publicKey, + ); + ({ blockhash } = await rpc.getLatestBlockhash()); + tx = buildAndSignTx([revokeIx], owner, blockhash); + await sendAndConfirmTx(rpc, tx); + + // Verify delegate is cleared + const afterRevoke = await getLightTokenDelegate(rpc, lightTokenAta); + expect(afterRevoke.delegate).toBeNull(); + expect(afterRevoke.delegatedAmount).toBe(BigInt(0)); + }, 60_000); + it('should fail when non-owner tries to approve', async () => { const owner = await newAccountWithLamports(rpc, 1e9); const wrongSigner = Keypair.generate(); From f0afd591e56a28141f51a17107715890724ffdb1 Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Fri, 20 Mar 2026 18:08:59 +0000 Subject: [PATCH 08/14] add regression tests --- js/compressed-token/src/v3/unified/index.ts | 8 +- .../unit/approve-revoke-regressions.test.ts | 141 ++++++++++++++++++ 2 files changed, 145 insertions(+), 4 deletions(-) create mode 100644 js/compressed-token/tests/unit/approve-revoke-regressions.test.ts diff --git a/js/compressed-token/src/v3/unified/index.ts b/js/compressed-token/src/v3/unified/index.ts index 64bab5e766..e07a69c7d9 100644 --- a/js/compressed-token/src/v3/unified/index.ts +++ b/js/compressed-token/src/v3/unified/index.ts @@ -566,8 +566,8 @@ export async function createApproveInterfaceInstructions( owner: PublicKey, decimals?: number, ): Promise { - const mintInfo = await getMintInterface(rpc, mint); - const resolvedDecimals = decimals ?? mintInfo.mint.decimals; + const resolvedDecimals = + decimals ?? (await getMintInterface(rpc, mint)).mint.decimals; return _createApproveInterfaceInstructions( rpc, payer, @@ -635,8 +635,8 @@ export async function createRevokeInterfaceInstructions( owner: PublicKey, decimals?: number, ): Promise { - const mintInfo = await getMintInterface(rpc, mint); - const resolvedDecimals = decimals ?? mintInfo.mint.decimals; + const resolvedDecimals = + decimals ?? (await getMintInterface(rpc, mint)).mint.decimals; return _createRevokeInterfaceInstructions( rpc, payer, diff --git a/js/compressed-token/tests/unit/approve-revoke-regressions.test.ts b/js/compressed-token/tests/unit/approve-revoke-regressions.test.ts new file mode 100644 index 0000000000..24f0958aaa --- /dev/null +++ b/js/compressed-token/tests/unit/approve-revoke-regressions.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Keypair } from '@solana/web3.js'; +import { type Rpc } from '@lightprotocol/stateless.js'; +import { + createLightTokenApproveInstruction, + createLightTokenRevokeInstruction, +} from '../../src/v3/instructions/approve-revoke'; + +const { + createApproveInterfaceInstructionsMock, + createRevokeInterfaceInstructionsMock, + getMintInterfaceMock, +} = vi.hoisted(() => ({ + createApproveInterfaceInstructionsMock: vi.fn().mockResolvedValue([[]]), + createRevokeInterfaceInstructionsMock: vi.fn().mockResolvedValue([[]]), + getMintInterfaceMock: vi.fn().mockResolvedValue({ + mint: { decimals: 9 }, + }), +})); + +vi.mock('../../src/v3/instructions/approve-interface', () => ({ + createApproveInterfaceInstructions: createApproveInterfaceInstructionsMock, + createRevokeInterfaceInstructions: createRevokeInterfaceInstructionsMock, +})); + +vi.mock('../../src/v3/get-mint-interface', () => ({ + getMintInterface: getMintInterfaceMock, +})); + +import { + createApproveInterfaceInstructions as unifiedCreateApproveInterfaceInstructions, + createRevokeInterfaceInstructions as unifiedCreateRevokeInterfaceInstructions, +} from '../../src/v3/unified'; + +describe('approve/revoke regressions', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('skips mint RPC in unified approve/revoke when decimals are provided', async () => { + const rpc = {} as Rpc; + const payer = Keypair.generate().publicKey; + const mint = Keypair.generate().publicKey; + const tokenAccount = Keypair.generate().publicKey; + const delegate = Keypair.generate().publicKey; + const owner = Keypair.generate().publicKey; + + await unifiedCreateApproveInterfaceInstructions( + rpc, + payer, + mint, + tokenAccount, + delegate, + 10n, + owner, + 6, + ); + await unifiedCreateRevokeInterfaceInstructions( + rpc, + payer, + mint, + tokenAccount, + owner, + 6, + ); + + expect(getMintInterfaceMock).not.toHaveBeenCalled(); + expect(createApproveInterfaceInstructionsMock).toHaveBeenCalledWith( + rpc, + payer, + mint, + tokenAccount, + delegate, + 10n, + owner, + 6, + undefined, + true, + ); + expect(createRevokeInterfaceInstructionsMock).toHaveBeenCalledWith( + rpc, + payer, + mint, + tokenAccount, + owner, + 6, + undefined, + true, + ); + }); + + it('fetches mint decimals in unified approve/revoke when decimals omitted', async () => { + const rpc = {} as Rpc; + const payer = Keypair.generate().publicKey; + const mint = Keypair.generate().publicKey; + const tokenAccount = Keypair.generate().publicKey; + const delegate = Keypair.generate().publicKey; + const owner = Keypair.generate().publicKey; + + await unifiedCreateApproveInterfaceInstructions( + rpc, + payer, + mint, + tokenAccount, + delegate, + 10n, + owner, + ); + await unifiedCreateRevokeInterfaceInstructions( + rpc, + payer, + mint, + tokenAccount, + owner, + ); + + expect(getMintInterfaceMock).toHaveBeenCalledTimes(2); + expect(createApproveInterfaceInstructionsMock).toHaveBeenCalledWith( + rpc, + payer, + mint, + tokenAccount, + delegate, + 10n, + owner, + 9, + undefined, + true, + ); + expect(createRevokeInterfaceInstructionsMock).toHaveBeenCalledWith( + rpc, + payer, + mint, + tokenAccount, + owner, + 9, + undefined, + true, + ); + }); +}); From d9a406b0cc86efa6cf03c5382b2381ea92905e1e Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Fri, 20 Mar 2026 19:47:02 +0000 Subject: [PATCH 09/14] fixes --- .../src/v3/actions/approve-interface.ts | 26 ++++++++++++++++--- .../src/v3/instructions/approve-interface.ts | 11 ++++++-- js/compressed-token/src/v3/unified/index.ts | 13 ++++++++++ .../e2e/transfer-delegated-spl-t22.test.ts | 4 +-- .../unit/approve-revoke-regressions.test.ts | 4 +++ 5 files changed, 50 insertions(+), 8 deletions(-) diff --git a/js/compressed-token/src/v3/actions/approve-interface.ts b/js/compressed-token/src/v3/actions/approve-interface.ts index 626c8ab108..de8fb0dffb 100644 --- a/js/compressed-token/src/v3/actions/approve-interface.ts +++ b/js/compressed-token/src/v3/actions/approve-interface.ts @@ -16,10 +16,12 @@ import BN from 'bn.js'; import { createApproveInterfaceInstructions, createRevokeInterfaceInstructions, + type ApproveRevokeOptions, } from '../instructions/approve-interface'; import { getAssociatedTokenAddressInterface } from '../get-associated-token-address-interface'; import { getMintInterface } from '../get-mint-interface'; import { sliceLast } from './slice-last'; +import { TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID } from '@solana/spl-token'; /** * Approve a delegate for an associated token account. @@ -54,6 +56,8 @@ export async function approveInterface( confirmOptions?: ConfirmOptions, programId: PublicKey = LIGHT_TOKEN_PROGRAM_ID, wrap = false, + options?: ApproveRevokeOptions, + decimals?: number, ): Promise { assertBetaEnabled(); @@ -69,7 +73,12 @@ export async function approveInterface( ); } - const mintInterface = await getMintInterface(rpc, mint); + const isSplOrT22 = + programId.equals(TOKEN_PROGRAM_ID) || + programId.equals(TOKEN_2022_PROGRAM_ID); + const resolvedDecimals = + decimals ?? + (isSplOrT22 && !wrap ? 0 : (await getMintInterface(rpc, mint)).mint.decimals); const batches = await createApproveInterfaceInstructions( rpc, payer.publicKey, @@ -78,9 +87,10 @@ export async function approveInterface( delegate, amount, owner.publicKey, - mintInterface.mint.decimals, + resolvedDecimals, programId, wrap, + options, ); const additionalSigners = dedupeSigner(payer, [owner]); @@ -137,6 +147,8 @@ export async function revokeInterface( confirmOptions?: ConfirmOptions, programId: PublicKey = LIGHT_TOKEN_PROGRAM_ID, wrap = false, + options?: ApproveRevokeOptions, + decimals?: number, ): Promise { assertBetaEnabled(); @@ -152,16 +164,22 @@ export async function revokeInterface( ); } - const mintInterface = await getMintInterface(rpc, mint); + const isSplOrT22 = + programId.equals(TOKEN_PROGRAM_ID) || + programId.equals(TOKEN_2022_PROGRAM_ID); + const resolvedDecimals = + decimals ?? + (isSplOrT22 && !wrap ? 0 : (await getMintInterface(rpc, mint)).mint.decimals); const batches = await createRevokeInterfaceInstructions( rpc, payer.publicKey, mint, tokenAccount, owner.publicKey, - mintInterface.mint.decimals, + resolvedDecimals, programId, wrap, + options, ); const additionalSigners = dedupeSigner(payer, [owner]); diff --git a/js/compressed-token/src/v3/instructions/approve-interface.ts b/js/compressed-token/src/v3/instructions/approve-interface.ts index f8d3cbc978..3b01d746af 100644 --- a/js/compressed-token/src/v3/instructions/approve-interface.ts +++ b/js/compressed-token/src/v3/instructions/approve-interface.ts @@ -30,6 +30,7 @@ import { } from './load-ata'; import { calculateCombinedCU } from './calculate-combined-cu'; import { assertTransactionSizeWithinLimit } from '../utils/estimate-tx-size'; +import type { SplInterfaceInfo } from '../../utils/get-token-pool-infos'; const APPROVE_BASE_CU = 10_000; @@ -43,6 +44,10 @@ function calculateRevokeCU(loadBatch: InternalLoadBatch | null): number { return calculateCombinedCU(REVOKE_BASE_CU, loadBatch); } +export interface ApproveRevokeOptions { + splInterfaceInfos?: SplInterfaceInfo[]; +} + /** * Build instruction batches for approving a delegate on an ATA. * @@ -77,6 +82,7 @@ export async function createApproveInterfaceInstructions( decimals: number, programId: PublicKey = LIGHT_TOKEN_PROGRAM_ID, wrap = false, + options?: ApproveRevokeOptions, ): Promise { assertBetaEnabled(); @@ -124,7 +130,7 @@ export async function createApproveInterfaceInstructions( rpc, payer, accountInterface, - undefined, + options, wrap, tokenAccount, undefined, @@ -219,6 +225,7 @@ export async function createRevokeInterfaceInstructions( decimals: number, programId: PublicKey = LIGHT_TOKEN_PROGRAM_ID, wrap = false, + options?: ApproveRevokeOptions, ): Promise { assertBetaEnabled(); @@ -262,7 +269,7 @@ export async function createRevokeInterfaceInstructions( rpc, payer, accountInterface, - undefined, + options, wrap, tokenAccount, undefined, diff --git a/js/compressed-token/src/v3/unified/index.ts b/js/compressed-token/src/v3/unified/index.ts index e07a69c7d9..5515afef50 100644 --- a/js/compressed-token/src/v3/unified/index.ts +++ b/js/compressed-token/src/v3/unified/index.ts @@ -44,6 +44,7 @@ import { import { createApproveInterfaceInstructions as _createApproveInterfaceInstructions, createRevokeInterfaceInstructions as _createRevokeInterfaceInstructions, + type ApproveRevokeOptions, } from '../instructions/approve-interface'; import { _getOrCreateAtaInterface } from '../actions/get-or-create-ata-interface'; @@ -531,6 +532,8 @@ export async function approveInterface( amount: number | bigint | BN, owner: Signer, confirmOptions?: ConfirmOptions, + options?: ApproveRevokeOptions, + decimals?: number, ) { return _approveInterface( rpc, @@ -543,6 +546,8 @@ export async function approveInterface( confirmOptions, undefined, // programId: use default LIGHT_TOKEN_PROGRAM_ID true, // wrap=true for unified + options, + decimals, ); } @@ -565,6 +570,7 @@ export async function createApproveInterfaceInstructions( amount: number | bigint | BN, owner: PublicKey, decimals?: number, + options?: ApproveRevokeOptions, ): Promise { const resolvedDecimals = decimals ?? (await getMintInterface(rpc, mint)).mint.decimals; @@ -579,6 +585,7 @@ export async function createApproveInterfaceInstructions( resolvedDecimals, undefined, // programId: use default LIGHT_TOKEN_PROGRAM_ID true, // wrap=true for unified + options, ); } @@ -606,6 +613,8 @@ export async function revokeInterface( mint: PublicKey, owner: Signer, confirmOptions?: ConfirmOptions, + options?: ApproveRevokeOptions, + decimals?: number, ) { return _revokeInterface( rpc, @@ -616,6 +625,8 @@ export async function revokeInterface( confirmOptions, undefined, // programId: use default LIGHT_TOKEN_PROGRAM_ID true, // wrap=true for unified + options, + decimals, ); } @@ -634,6 +645,7 @@ export async function createRevokeInterfaceInstructions( tokenAccount: PublicKey, owner: PublicKey, decimals?: number, + options?: ApproveRevokeOptions, ): Promise { const resolvedDecimals = decimals ?? (await getMintInterface(rpc, mint)).mint.decimals; @@ -646,6 +658,7 @@ export async function createRevokeInterfaceInstructions( resolvedDecimals, undefined, // programId: use default LIGHT_TOKEN_PROGRAM_ID true, // wrap=true for unified + options, ); } diff --git a/js/compressed-token/tests/e2e/transfer-delegated-spl-t22.test.ts b/js/compressed-token/tests/e2e/transfer-delegated-spl-t22.test.ts index d8cd2e7edc..149acabef1 100644 --- a/js/compressed-token/tests/e2e/transfer-delegated-spl-t22.test.ts +++ b/js/compressed-token/tests/e2e/transfer-delegated-spl-t22.test.ts @@ -131,7 +131,7 @@ describe('delegated transfer - SPL mint', () => { 200_000_000n, TOKEN_PROGRAM_ID, undefined, - { owner: owner.publicKey }, + { owner: owner.publicKey, splInterfaceInfos: [] }, ); expect(sig).toBeTruthy(); @@ -324,7 +324,7 @@ describe('delegated transfer - Token-2022 mint', () => { 200_000_000n, TOKEN_2022_PROGRAM_ID, undefined, - { owner: owner.publicKey }, + { owner: owner.publicKey, splInterfaceInfos: [] }, ); expect(sig).toBeTruthy(); diff --git a/js/compressed-token/tests/unit/approve-revoke-regressions.test.ts b/js/compressed-token/tests/unit/approve-revoke-regressions.test.ts index 24f0958aaa..7b736d1673 100644 --- a/js/compressed-token/tests/unit/approve-revoke-regressions.test.ts +++ b/js/compressed-token/tests/unit/approve-revoke-regressions.test.ts @@ -76,6 +76,7 @@ describe('approve/revoke regressions', () => { 6, undefined, true, + undefined, ); expect(createRevokeInterfaceInstructionsMock).toHaveBeenCalledWith( rpc, @@ -86,6 +87,7 @@ describe('approve/revoke regressions', () => { 6, undefined, true, + undefined, ); }); @@ -126,6 +128,7 @@ describe('approve/revoke regressions', () => { 9, undefined, true, + undefined, ); expect(createRevokeInterfaceInstructionsMock).toHaveBeenCalledWith( rpc, @@ -136,6 +139,7 @@ describe('approve/revoke regressions', () => { 9, undefined, true, + undefined, ); }); }); From 951357a54da7605bfb363c4f1f8657989015f6a0 Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Sat, 21 Mar 2026 00:18:07 +0000 Subject: [PATCH 10/14] update changelog --- js/compressed-token/CHANGELOG.md | 15 +++++++ .../src/v3/actions/approve-interface.ts | 35 ++++++---------- .../src/v3/get-account-interface.ts | 7 +++- .../src/v3/instructions/approve-interface.ts | 10 ++--- js/compressed-token/src/v3/unified/index.ts | 9 ++-- .../e2e/approve-revoke-light-token.test.ts | 4 +- .../tests/e2e/approve-revoke-spl-t22.test.ts | 36 +++++++++++++--- .../e2e/transfer-delegated-failures.test.ts | 9 +--- .../e2e/transfer-delegated-interface.test.ts | 4 +- .../e2e/transfer-delegated-spl-t22.test.ts | 42 ++++++++++++++++--- js/stateless.js/CHANGELOG.md | 6 +++ 11 files changed, 115 insertions(+), 62 deletions(-) diff --git a/js/compressed-token/CHANGELOG.md b/js/compressed-token/CHANGELOG.md index b744dbfd81..55dd82a2cf 100644 --- a/js/compressed-token/CHANGELOG.md +++ b/js/compressed-token/CHANGELOG.md @@ -1,3 +1,18 @@ +## [0.23.0-beta.11] + +### Added + +- **Delegate approval on the interface stack (SPL, Token-2022, light-token).** + - **Actions:** `approveInterface`, `revokeInterface` — send approve/revoke with the same multi-transaction pattern as transfers when cold/load batches are needed (parallel prefix via `sliceLast`, then final approve/revoke). + - **Instruction builders:** `createApproveInterfaceInstructions`, `createRevokeInterfaceInstructions` — return `TransactionInstruction[][]`; mirror `createTransferToAccountInterfaceInstructions` batching semantics. + - **Light-token primitives:** `createLightTokenApproveInstruction`, `createLightTokenRevokeInstruction` — exported from the package root alongside other light-token instruction helpers. +- **`InterfaceOptions` on approve/revoke** — the same `options?: InterfaceOptions` used by `transferInterface` (e.g. `splInterfaceInfos`) is accepted on approve/revoke actions and instruction builders and is passed through to `_buildLoadBatches` on light/wrap paths. + +### Changed + +- **`approveInterface` / `revokeInterface` (v3 actions):** Optional trailing parameters after `wrap`: `options?: InterfaceOptions`, `decimals?: number`. When `programId` is SPL or Token-2022 and `wrap` is `false`, **mint decimals are no longer fetched** via `getMintInterface` unless you pass `decimals` or the flow needs them for load/wrap. +- **`@lightprotocol/compressed-token/unified`:** `approveInterface`, `revokeInterface`, `createApproveInterfaceInstructions`, and `createRevokeInterfaceInstructions` forward optional `options` and `decimals` to the v3 implementations (still defaulting to unified `wrap: true` behavior). + ## [0.23.0-beta.10] ### Breaking Changes diff --git a/js/compressed-token/src/v3/actions/approve-interface.ts b/js/compressed-token/src/v3/actions/approve-interface.ts index de8fb0dffb..83b02452cb 100644 --- a/js/compressed-token/src/v3/actions/approve-interface.ts +++ b/js/compressed-token/src/v3/actions/approve-interface.ts @@ -16,11 +16,11 @@ import BN from 'bn.js'; import { createApproveInterfaceInstructions, createRevokeInterfaceInstructions, - type ApproveRevokeOptions, } from '../instructions/approve-interface'; import { getAssociatedTokenAddressInterface } from '../get-associated-token-address-interface'; import { getMintInterface } from '../get-mint-interface'; import { sliceLast } from './slice-last'; +import type { InterfaceOptions } from './transfer-interface'; import { TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID } from '@solana/spl-token'; /** @@ -56,7 +56,7 @@ export async function approveInterface( confirmOptions?: ConfirmOptions, programId: PublicKey = LIGHT_TOKEN_PROGRAM_ID, wrap = false, - options?: ApproveRevokeOptions, + options?: InterfaceOptions, decimals?: number, ): Promise { assertBetaEnabled(); @@ -78,7 +78,9 @@ export async function approveInterface( programId.equals(TOKEN_2022_PROGRAM_ID); const resolvedDecimals = decimals ?? - (isSplOrT22 && !wrap ? 0 : (await getMintInterface(rpc, mint)).mint.decimals); + (isSplOrT22 && !wrap + ? 0 + : (await getMintInterface(rpc, mint)).mint.decimals); const batches = await createApproveInterfaceInstructions( rpc, payer.publicKey, @@ -99,23 +101,13 @@ export async function approveInterface( await Promise.all( loads.map(async ixs => { const { blockhash } = await rpc.getLatestBlockhash(); - const tx = buildAndSignTx( - ixs, - payer, - blockhash, - additionalSigners, - ); + const tx = buildAndSignTx(ixs, payer, blockhash, additionalSigners); return sendAndConfirmTx(rpc, tx, confirmOptions); }), ); const { blockhash } = await rpc.getLatestBlockhash(); - const tx = buildAndSignTx( - approveIxs, - payer, - blockhash, - additionalSigners, - ); + const tx = buildAndSignTx(approveIxs, payer, blockhash, additionalSigners); return sendAndConfirmTx(rpc, tx, confirmOptions); } @@ -147,7 +139,7 @@ export async function revokeInterface( confirmOptions?: ConfirmOptions, programId: PublicKey = LIGHT_TOKEN_PROGRAM_ID, wrap = false, - options?: ApproveRevokeOptions, + options?: InterfaceOptions, decimals?: number, ): Promise { assertBetaEnabled(); @@ -169,7 +161,9 @@ export async function revokeInterface( programId.equals(TOKEN_2022_PROGRAM_ID); const resolvedDecimals = decimals ?? - (isSplOrT22 && !wrap ? 0 : (await getMintInterface(rpc, mint)).mint.decimals); + (isSplOrT22 && !wrap + ? 0 + : (await getMintInterface(rpc, mint)).mint.decimals); const batches = await createRevokeInterfaceInstructions( rpc, payer.publicKey, @@ -188,12 +182,7 @@ export async function revokeInterface( await Promise.all( loads.map(async ixs => { const { blockhash } = await rpc.getLatestBlockhash(); - const tx = buildAndSignTx( - ixs, - payer, - blockhash, - additionalSigners, - ); + const tx = buildAndSignTx(ixs, payer, blockhash, additionalSigners); return sendAndConfirmTx(rpc, tx, confirmOptions); }), ); diff --git a/js/compressed-token/src/v3/get-account-interface.ts b/js/compressed-token/src/v3/get-account-interface.ts index 21bf2ac33e..7d0beea546 100644 --- a/js/compressed-token/src/v3/get-account-interface.ts +++ b/js/compressed-token/src/v3/get-account-interface.ts @@ -92,7 +92,12 @@ function throwIfUnexpectedRpcErrors( } } -export type FrozenOperation = 'load' | 'transfer' | 'unwrap' | 'approve' | 'revoke'; +export type FrozenOperation = + | 'load' + | 'transfer' + | 'unwrap' + | 'approve' + | 'revoke'; export function checkNotFrozen( iface: AccountInterface, diff --git a/js/compressed-token/src/v3/instructions/approve-interface.ts b/js/compressed-token/src/v3/instructions/approve-interface.ts index 3b01d746af..e4e6d83c18 100644 --- a/js/compressed-token/src/v3/instructions/approve-interface.ts +++ b/js/compressed-token/src/v3/instructions/approve-interface.ts @@ -30,7 +30,7 @@ import { } from './load-ata'; import { calculateCombinedCU } from './calculate-combined-cu'; import { assertTransactionSizeWithinLimit } from '../utils/estimate-tx-size'; -import type { SplInterfaceInfo } from '../../utils/get-token-pool-infos'; +import type { InterfaceOptions } from '../actions/transfer-interface'; const APPROVE_BASE_CU = 10_000; @@ -44,10 +44,6 @@ function calculateRevokeCU(loadBatch: InternalLoadBatch | null): number { return calculateCombinedCU(REVOKE_BASE_CU, loadBatch); } -export interface ApproveRevokeOptions { - splInterfaceInfos?: SplInterfaceInfo[]; -} - /** * Build instruction batches for approving a delegate on an ATA. * @@ -82,7 +78,7 @@ export async function createApproveInterfaceInstructions( decimals: number, programId: PublicKey = LIGHT_TOKEN_PROGRAM_ID, wrap = false, - options?: ApproveRevokeOptions, + options?: InterfaceOptions, ): Promise { assertBetaEnabled(); @@ -225,7 +221,7 @@ export async function createRevokeInterfaceInstructions( decimals: number, programId: PublicKey = LIGHT_TOKEN_PROGRAM_ID, wrap = false, - options?: ApproveRevokeOptions, + options?: InterfaceOptions, ): Promise { assertBetaEnabled(); diff --git a/js/compressed-token/src/v3/unified/index.ts b/js/compressed-token/src/v3/unified/index.ts index 5515afef50..c3221af208 100644 --- a/js/compressed-token/src/v3/unified/index.ts +++ b/js/compressed-token/src/v3/unified/index.ts @@ -44,7 +44,6 @@ import { import { createApproveInterfaceInstructions as _createApproveInterfaceInstructions, createRevokeInterfaceInstructions as _createRevokeInterfaceInstructions, - type ApproveRevokeOptions, } from '../instructions/approve-interface'; import { _getOrCreateAtaInterface } from '../actions/get-or-create-ata-interface'; @@ -532,7 +531,7 @@ export async function approveInterface( amount: number | bigint | BN, owner: Signer, confirmOptions?: ConfirmOptions, - options?: ApproveRevokeOptions, + options?: InterfaceOptions, decimals?: number, ) { return _approveInterface( @@ -570,7 +569,7 @@ export async function createApproveInterfaceInstructions( amount: number | bigint | BN, owner: PublicKey, decimals?: number, - options?: ApproveRevokeOptions, + options?: InterfaceOptions, ): Promise { const resolvedDecimals = decimals ?? (await getMintInterface(rpc, mint)).mint.decimals; @@ -613,7 +612,7 @@ export async function revokeInterface( mint: PublicKey, owner: Signer, confirmOptions?: ConfirmOptions, - options?: ApproveRevokeOptions, + options?: InterfaceOptions, decimals?: number, ) { return _revokeInterface( @@ -645,7 +644,7 @@ export async function createRevokeInterfaceInstructions( tokenAccount: PublicKey, owner: PublicKey, decimals?: number, - options?: ApproveRevokeOptions, + options?: InterfaceOptions, ): Promise { const resolvedDecimals = decimals ?? (await getMintInterface(rpc, mint)).mint.decimals; diff --git a/js/compressed-token/tests/e2e/approve-revoke-light-token.test.ts b/js/compressed-token/tests/e2e/approve-revoke-light-token.test.ts index 29f1c43fdc..e3e1e4e03a 100644 --- a/js/compressed-token/tests/e2e/approve-revoke-light-token.test.ts +++ b/js/compressed-token/tests/e2e/approve-revoke-light-token.test.ts @@ -519,9 +519,7 @@ describe('LightToken approve/revoke - E2E', () => { sponsor.publicKey, ); const { blockhash } = await rpc.getLatestBlockhash(); - const tx = buildAndSignTx([ix], sponsor, blockhash, [ - owner as Keypair, - ]); + const tx = buildAndSignTx([ix], sponsor, blockhash, [owner as Keypair]); await sendAndConfirmTx(rpc, tx); const { delegate: actualDelegate, delegatedAmount } = diff --git a/js/compressed-token/tests/e2e/approve-revoke-spl-t22.test.ts b/js/compressed-token/tests/e2e/approve-revoke-spl-t22.test.ts index 3eb18d3995..1b3f0286a9 100644 --- a/js/compressed-token/tests/e2e/approve-revoke-spl-t22.test.ts +++ b/js/compressed-token/tests/e2e/approve-revoke-spl-t22.test.ts @@ -106,8 +106,15 @@ describe('approveInterface / revokeInterface - SPL mint', () => { ); expect(sig).toBeTruthy(); - const account = await getAccount(rpc, ownerAta, undefined, TOKEN_PROGRAM_ID); - expect(account.delegate?.toBase58()).toBe(delegate.publicKey.toBase58()); + const account = await getAccount( + rpc, + ownerAta, + undefined, + TOKEN_PROGRAM_ID, + ); + expect(account.delegate?.toBase58()).toBe( + delegate.publicKey.toBase58(), + ); expect(account.delegatedAmount).toBe(500_000_000n); }, 60_000); @@ -123,7 +130,12 @@ describe('approveInterface / revokeInterface - SPL mint', () => { ); expect(sig).toBeTruthy(); - const account = await getAccount(rpc, ownerAta, undefined, TOKEN_PROGRAM_ID); + const account = await getAccount( + rpc, + ownerAta, + undefined, + TOKEN_PROGRAM_ID, + ); expect(account.delegate).toBeNull(); expect(account.delegatedAmount).toBe(0n); }, 60_000); @@ -225,8 +237,15 @@ describe('approveInterface / revokeInterface - Token-2022 mint', () => { ); expect(sig).toBeTruthy(); - const account = await getAccount(rpc, ownerAta, undefined, TOKEN_2022_PROGRAM_ID); - expect(account.delegate?.toBase58()).toBe(delegate.publicKey.toBase58()); + const account = await getAccount( + rpc, + ownerAta, + undefined, + TOKEN_2022_PROGRAM_ID, + ); + expect(account.delegate?.toBase58()).toBe( + delegate.publicKey.toBase58(), + ); expect(account.delegatedAmount).toBe(500_000_000n); }, 60_000); @@ -242,7 +261,12 @@ describe('approveInterface / revokeInterface - Token-2022 mint', () => { ); expect(sig).toBeTruthy(); - const account = await getAccount(rpc, ownerAta, undefined, TOKEN_2022_PROGRAM_ID); + const account = await getAccount( + rpc, + ownerAta, + undefined, + TOKEN_2022_PROGRAM_ID, + ); expect(account.delegate).toBeNull(); expect(account.delegatedAmount).toBe(0n); }, 60_000); diff --git a/js/compressed-token/tests/e2e/transfer-delegated-failures.test.ts b/js/compressed-token/tests/e2e/transfer-delegated-failures.test.ts index 437719e859..c320b02ef3 100644 --- a/js/compressed-token/tests/e2e/transfer-delegated-failures.test.ts +++ b/js/compressed-token/tests/e2e/transfer-delegated-failures.test.ts @@ -67,14 +67,7 @@ describe('transferDelegatedInterface - failure cases', () => { await createAtaInterface(rpc, payer, mint, owner.publicKey); ownerAta = getAssociatedTokenAddressInterface(mint, owner.publicKey); - await mintToInterface( - rpc, - payer, - mint, - ownerAta, - payer, - 1_000_000_000, - ); + await mintToInterface(rpc, payer, mint, ownerAta, payer, 1_000_000_000); // Approve delegate for 500M await approveInterface( diff --git a/js/compressed-token/tests/e2e/transfer-delegated-interface.test.ts b/js/compressed-token/tests/e2e/transfer-delegated-interface.test.ts index 353a85b880..dedda7d98a 100644 --- a/js/compressed-token/tests/e2e/transfer-delegated-interface.test.ts +++ b/js/compressed-token/tests/e2e/transfer-delegated-interface.test.ts @@ -97,9 +97,7 @@ describe('transferDelegatedInterface - e2e', () => { expect(afterApprove.parsed.delegate?.toBase58()).toBe( delegate.publicKey.toBase58(), ); - expect(afterApprove.parsed.delegatedAmount).toBe( - BigInt(500_000_000), - ); + expect(afterApprove.parsed.delegatedAmount).toBe(BigInt(500_000_000)); // 2. Delegate transfer 200M (recipient wallet — ATA created internally) const sig = await transferInterface( diff --git a/js/compressed-token/tests/e2e/transfer-delegated-spl-t22.test.ts b/js/compressed-token/tests/e2e/transfer-delegated-spl-t22.test.ts index 149acabef1..fedf46beb2 100644 --- a/js/compressed-token/tests/e2e/transfer-delegated-spl-t22.test.ts +++ b/js/compressed-token/tests/e2e/transfer-delegated-spl-t22.test.ts @@ -136,11 +136,21 @@ describe('delegated transfer - SPL mint', () => { expect(sig).toBeTruthy(); // Verify balances - const ownerAccount = await getAccount(rpc, ownerAta, undefined, TOKEN_PROGRAM_ID); + const ownerAccount = await getAccount( + rpc, + ownerAta, + undefined, + TOKEN_PROGRAM_ID, + ); expect(ownerAccount.amount).toBe(800_000_000n); expect(ownerAccount.delegatedAmount).toBe(300_000_000n); - const recipientAccount = await getAccount(rpc, recipientAta, undefined, TOKEN_PROGRAM_ID); + const recipientAccount = await getAccount( + rpc, + recipientAta, + undefined, + TOKEN_PROGRAM_ID, + ); expect(recipientAccount.amount).toBe(200_000_000n); // Revoke @@ -154,7 +164,12 @@ describe('delegated transfer - SPL mint', () => { TOKEN_PROGRAM_ID, ); - const afterRevoke = await getAccount(rpc, ownerAta, undefined, TOKEN_PROGRAM_ID); + const afterRevoke = await getAccount( + rpc, + ownerAta, + undefined, + TOKEN_PROGRAM_ID, + ); expect(afterRevoke.delegate).toBeNull(); expect(afterRevoke.delegatedAmount).toBe(0n); }, 120_000); @@ -329,11 +344,21 @@ describe('delegated transfer - Token-2022 mint', () => { expect(sig).toBeTruthy(); // Verify balances - const ownerAccount = await getAccount(rpc, ownerAta, undefined, TOKEN_2022_PROGRAM_ID); + const ownerAccount = await getAccount( + rpc, + ownerAta, + undefined, + TOKEN_2022_PROGRAM_ID, + ); expect(ownerAccount.amount).toBe(800_000_000n); expect(ownerAccount.delegatedAmount).toBe(300_000_000n); - const recipientAccount = await getAccount(rpc, recipientAta, undefined, TOKEN_2022_PROGRAM_ID); + const recipientAccount = await getAccount( + rpc, + recipientAta, + undefined, + TOKEN_2022_PROGRAM_ID, + ); expect(recipientAccount.amount).toBe(200_000_000n); // Revoke @@ -347,7 +372,12 @@ describe('delegated transfer - Token-2022 mint', () => { TOKEN_2022_PROGRAM_ID, ); - const afterRevoke = await getAccount(rpc, ownerAta, undefined, TOKEN_2022_PROGRAM_ID); + const afterRevoke = await getAccount( + rpc, + ownerAta, + undefined, + TOKEN_2022_PROGRAM_ID, + ); expect(afterRevoke.delegate).toBeNull(); expect(afterRevoke.delegatedAmount).toBe(0n); }, 120_000); diff --git a/js/stateless.js/CHANGELOG.md b/js/stateless.js/CHANGELOG.md index 43ec744619..72f8fed305 100644 --- a/js/stateless.js/CHANGELOG.md +++ b/js/stateless.js/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [0.23.0-beta.11] + +### Changed + +- **Release alignment:** Version bumped with `@lightprotocol/compressed-token@0.23.0-beta.11` for a coordinated JS SDK beta. No functional API changes in `stateless.js` in this release. + ## [0.23.0-beta.10] ### Breaking Changes From 8657ee6a3c2969ca6196533d7a5d161a2ea3bd24 Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Sat, 21 Mar 2026 00:27:00 +0000 Subject: [PATCH 11/14] upd changelog --- js/compressed-token/CHANGELOG.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/js/compressed-token/CHANGELOG.md b/js/compressed-token/CHANGELOG.md index 55dd82a2cf..ccf37611f4 100644 --- a/js/compressed-token/CHANGELOG.md +++ b/js/compressed-token/CHANGELOG.md @@ -2,16 +2,16 @@ ### Added -- **Delegate approval on the interface stack (SPL, Token-2022, light-token).** - - **Actions:** `approveInterface`, `revokeInterface` — send approve/revoke with the same multi-transaction pattern as transfers when cold/load batches are needed (parallel prefix via `sliceLast`, then final approve/revoke). - - **Instruction builders:** `createApproveInterfaceInstructions`, `createRevokeInterfaceInstructions` — return `TransactionInstruction[][]`; mirror `createTransferToAccountInterfaceInstructions` batching semantics. - - **Light-token primitives:** `createLightTokenApproveInstruction`, `createLightTokenRevokeInstruction` — exported from the package root alongside other light-token instruction helpers. -- **`InterfaceOptions` on approve/revoke** — the same `options?: InterfaceOptions` used by `transferInterface` (e.g. `splInterfaceInfos`) is accepted on approve/revoke actions and instruction builders and is passed through to `_buildLoadBatches` on light/wrap paths. +- **Delegate approval and revocation** for SPL Token, Token-2022, and light-token, aligned with existing interface helpers: + - **Actions:** `approveInterface`, `revokeInterface`. + - **Instruction builders:** `createApproveInterfaceInstructions`, `createRevokeInterfaceInstructions` — each inner array is one transaction’s instructions (same batching style as other interface instruction builders). + - **Program-level helpers:** `createLightTokenApproveInstruction`, `createLightTokenRevokeInstruction` +- **Shared options:** approve/revoke accept optional `InterfaceOptions` (same type as `transferInterface`), including `splInterfaceInfos` when you need to supply SPL interface pool accounts explicitly. ### Changed -- **`approveInterface` / `revokeInterface` (v3 actions):** Optional trailing parameters after `wrap`: `options?: InterfaceOptions`, `decimals?: number`. When `programId` is SPL or Token-2022 and `wrap` is `false`, **mint decimals are no longer fetched** via `getMintInterface` unless you pass `decimals` or the flow needs them for load/wrap. -- **`@lightprotocol/compressed-token/unified`:** `approveInterface`, `revokeInterface`, `createApproveInterfaceInstructions`, and `createRevokeInterfaceInstructions` forward optional `options` and `decimals` to the v3 implementations (still defaulting to unified `wrap: true` behavior). +- **`approveInterface` / `revokeInterface`:** optional `options?: InterfaceOptions` and `decimals?: number` after `wrap`. For SPL or Token-2022 with `wrap: false`, the SDK skips an extra mint fetch used only for decimals on that path (you can still pass `decimals` when your flow requires it). +- **`@lightprotocol/compressed-token/unified`:** approve/revoke APIs accept the same optional `options` and `decimals`; unified entrypoints keep their existing default wrapping behavior (`wrap: true`). ## [0.23.0-beta.10] From b1fc8cdbec6817810c8ba6b31191c7bda089da4d Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Tue, 17 Mar 2026 12:34:30 +0000 Subject: [PATCH 12/14] fix: packedaccounts in js should not turn bool to number --- js/compressed-token/rollup.config.js | 2 +- js/stateless.js/rollup.config.js | 2 +- js/stateless.js/src/utils/instruction.ts | 26 ++++++++++--- .../tests/unit/utils/packed-accounts.test.ts | 39 +++++++++++++++++++ 4 files changed, 62 insertions(+), 7 deletions(-) create mode 100644 js/stateless.js/tests/unit/utils/packed-accounts.test.ts diff --git a/js/compressed-token/rollup.config.js b/js/compressed-token/rollup.config.js index b478bd90e8..ece04a6462 100644 --- a/js/compressed-token/rollup.config.js +++ b/js/compressed-token/rollup.config.js @@ -64,7 +64,7 @@ const rolls = (fmt, env) => ({ drop_console: false, drop_debugger: true, passes: 3, - booleans_as_integers: true, + booleans_as_integers: false, keep_fargs: false, keep_fnames: false, keep_infinity: true, diff --git a/js/stateless.js/rollup.config.js b/js/stateless.js/rollup.config.js index 2f2b08652e..7f0f0ad20c 100644 --- a/js/stateless.js/rollup.config.js +++ b/js/stateless.js/rollup.config.js @@ -44,7 +44,7 @@ const rolls = (fmt, env) => ({ drop_console: false, drop_debugger: true, passes: 3, - booleans_as_integers: true, + booleans_as_integers: false, keep_fargs: false, keep_fnames: false, keep_infinity: true, diff --git a/js/stateless.js/src/utils/instruction.ts b/js/stateless.js/src/utils/instruction.ts index cb824ef4ec..02b1249e81 100644 --- a/js/stateless.js/src/utils/instruction.ts +++ b/js/stateless.js/src/utils/instruction.ts @@ -2,6 +2,9 @@ import { AccountMeta, PublicKey, SystemProgram } from '@solana/web3.js'; import { defaultStaticAccountsStruct, featureFlags } from '../constants'; import { LightSystemProgram } from '../programs'; +const toStrictBool = (value: unknown): boolean => + value === true || value === 1; + export class PackedAccounts { private preAccounts: AccountMeta[] = []; private systemAccounts: AccountMeta[] = []; @@ -40,7 +43,11 @@ export class PackedAccounts { } addPreAccountsMeta(accountMeta: AccountMeta): void { - this.preAccounts.push(accountMeta); + this.preAccounts.push({ + pubkey: accountMeta.pubkey, + isSigner: toStrictBool(accountMeta.isSigner), + isWritable: toStrictBool(accountMeta.isWritable), + }); } /** @@ -81,7 +88,11 @@ export class PackedAccounts { return entry[0]; } const index = this.nextIndex++; - const meta: AccountMeta = { pubkey, isSigner, isWritable }; + const meta: AccountMeta = { + pubkey, + isSigner: toStrictBool(isSigner), + isWritable: toStrictBool(isWritable), + }; this.map.set(pubkey, [index, meta]); return index; } @@ -105,11 +116,16 @@ export class PackedAccounts { } { const packed = this.hashSetAccountsToMetas(); const [systemStart, packedStart] = this.getOffsets(); + const normalize = (meta: AccountMeta): AccountMeta => ({ + pubkey: meta.pubkey, + isSigner: toStrictBool(meta.isSigner), + isWritable: toStrictBool(meta.isWritable), + }); return { remainingAccounts: [ - ...this.preAccounts, - ...this.systemAccounts, - ...packed, + ...this.preAccounts.map(normalize), + ...this.systemAccounts.map(normalize), + ...packed.map(normalize), ], systemStart, packedStart, diff --git a/js/stateless.js/tests/unit/utils/packed-accounts.test.ts b/js/stateless.js/tests/unit/utils/packed-accounts.test.ts new file mode 100644 index 0000000000..ae30bf1618 --- /dev/null +++ b/js/stateless.js/tests/unit/utils/packed-accounts.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { PublicKey } from '@solana/web3.js'; +import { PackedAccounts } from '../../../src/utils/instruction'; + +describe('PackedAccounts runtime boolean normalization', () => { + it('normalizes numeric AccountMeta flags from addPreAccountsMeta', () => { + const packedAccounts = new PackedAccounts(); + const pubkey = PublicKey.unique(); + + packedAccounts.addPreAccountsMeta({ + pubkey, + isSigner: 0 as unknown as boolean, + isWritable: 1 as unknown as boolean, + }); + + const [meta] = packedAccounts.toAccountMetas().remainingAccounts; + expect(typeof meta.isSigner).toBe('boolean'); + expect(typeof meta.isWritable).toBe('boolean'); + expect(meta.isSigner).toBe(false); + expect(meta.isWritable).toBe(true); + }); + + it('normalizes numeric flags from insertOrGetConfig', () => { + const packedAccounts = new PackedAccounts(); + const pubkey = PublicKey.unique(); + + packedAccounts.insertOrGetConfig( + pubkey, + 1 as unknown as boolean, + 0 as unknown as boolean, + ); + + const [meta] = packedAccounts.toAccountMetas().remainingAccounts; + expect(typeof meta.isSigner).toBe('boolean'); + expect(typeof meta.isWritable).toBe('boolean'); + expect(meta.isSigner).toBe(true); + expect(meta.isWritable).toBe(false); + }); +}); From 588c4843041ee71b42c139077e9f2741132bc65c Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Sat, 21 Mar 2026 00:35:42 +0000 Subject: [PATCH 13/14] cherry pick bool fix --- cli/package.json | 2 +- js/compressed-token/CHANGELOG.md | 4 ++++ js/compressed-token/package.json | 2 +- js/stateless.js/CHANGELOG.md | 7 ++++++- js/stateless.js/package.json | 2 +- 5 files changed, 13 insertions(+), 4 deletions(-) diff --git a/cli/package.json b/cli/package.json index ae818675e3..514ce466fb 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@lightprotocol/zk-compression-cli", - "version": "0.28.0-beta.10", + "version": "0.28.0-beta.11", "description": "ZK Compression: Secure Scaling on Solana", "maintainers": [ { diff --git a/js/compressed-token/CHANGELOG.md b/js/compressed-token/CHANGELOG.md index ccf37611f4..e6ea6125b9 100644 --- a/js/compressed-token/CHANGELOG.md +++ b/js/compressed-token/CHANGELOG.md @@ -13,6 +13,10 @@ - **`approveInterface` / `revokeInterface`:** optional `options?: InterfaceOptions` and `decimals?: number` after `wrap`. For SPL or Token-2022 with `wrap: false`, the SDK skips an extra mint fetch used only for decimals on that path (you can still pass `decimals` when your flow requires it). - **`@lightprotocol/compressed-token/unified`:** approve/revoke APIs accept the same optional `options` and `decimals`; unified entrypoints keep their existing default wrapping behavior (`wrap: true`). +### Fixed + +- **Browser bundles:** Terser no longer rewrites booleans to integers in minified output, keeping `AccountMeta` flags compatible with `@solana/web3.js` and runtime expectations (same change as `stateless.js`; see [#2347](https://github.com/Lightprotocol/light-protocol/pull/2347)). + ## [0.23.0-beta.10] ### Breaking Changes diff --git a/js/compressed-token/package.json b/js/compressed-token/package.json index 1489d42cd1..da8d0edc95 100644 --- a/js/compressed-token/package.json +++ b/js/compressed-token/package.json @@ -1,6 +1,6 @@ { "name": "@lightprotocol/compressed-token", - "version": "0.23.0-beta.10", + "version": "0.23.0-beta.11", "description": "JS client to interact with the compressed-token program", "sideEffects": false, "main": "dist/cjs/node/index.cjs", diff --git a/js/stateless.js/CHANGELOG.md b/js/stateless.js/CHANGELOG.md index 72f8fed305..550e352001 100644 --- a/js/stateless.js/CHANGELOG.md +++ b/js/stateless.js/CHANGELOG.md @@ -2,9 +2,14 @@ ## [0.23.0-beta.11] +### Fixed + +- **Browser bundles / minified builds:** Terser `booleans_as_integers` is disabled so `isSigner` / `isWritable` on `AccountMeta` stay real booleans (see [#2347](https://github.com/Lightprotocol/light-protocol/pull/2347)). +- **`PackedAccounts`:** `addPreAccountsMeta` and `insertOrGetConfig` normalize signer/writable flags so `1`/`0` from minified or external callers are treated consistently as booleans. + ### Changed -- **Release alignment:** Version bumped with `@lightprotocol/compressed-token@0.23.0-beta.11` for a coordinated JS SDK beta. No functional API changes in `stateless.js` in this release. +- **Release alignment:** Version bumped with `@lightprotocol/compressed-token@0.23.0-beta.11` for a coordinated JS SDK beta. ## [0.23.0-beta.10] diff --git a/js/stateless.js/package.json b/js/stateless.js/package.json index 3c89a2bf22..9034dde743 100644 --- a/js/stateless.js/package.json +++ b/js/stateless.js/package.json @@ -1,6 +1,6 @@ { "name": "@lightprotocol/stateless.js", - "version": "0.23.0-beta.10", + "version": "0.23.0-beta.11", "description": "JavaScript API for Light & ZK Compression", "sideEffects": false, "main": "dist/cjs/node/index.cjs", From 643f2b5fe93f4066346141c0167d9af7c8db3542 Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Sat, 21 Mar 2026 00:38:38 +0000 Subject: [PATCH 14/14] bump versions again --- cli/package.json | 2 +- js/compressed-token/package.json | 2 +- js/stateless.js/package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cli/package.json b/cli/package.json index 514ce466fb..4f780bf567 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@lightprotocol/zk-compression-cli", - "version": "0.28.0-beta.11", + "version": "0.28.0-beta.12", "description": "ZK Compression: Secure Scaling on Solana", "maintainers": [ { diff --git a/js/compressed-token/package.json b/js/compressed-token/package.json index da8d0edc95..378ff64425 100644 --- a/js/compressed-token/package.json +++ b/js/compressed-token/package.json @@ -1,6 +1,6 @@ { "name": "@lightprotocol/compressed-token", - "version": "0.23.0-beta.11", + "version": "0.23.0-beta.12", "description": "JS client to interact with the compressed-token program", "sideEffects": false, "main": "dist/cjs/node/index.cjs", diff --git a/js/stateless.js/package.json b/js/stateless.js/package.json index 9034dde743..635ba27119 100644 --- a/js/stateless.js/package.json +++ b/js/stateless.js/package.json @@ -1,6 +1,6 @@ { "name": "@lightprotocol/stateless.js", - "version": "0.23.0-beta.11", + "version": "0.23.0-beta.12", "description": "JavaScript API for Light & ZK Compression", "sideEffects": false, "main": "dist/cjs/node/index.cjs",