diff --git a/cli/package.json b/cli/package.json index ae818675e3..4f780bf567 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.12", "description": "ZK Compression: Secure Scaling on Solana", "maintainers": [ { diff --git a/js/compressed-token/CHANGELOG.md b/js/compressed-token/CHANGELOG.md index b744dbfd81..e6ea6125b9 100644 --- a/js/compressed-token/CHANGELOG.md +++ b/js/compressed-token/CHANGELOG.md @@ -1,3 +1,22 @@ +## [0.23.0-beta.11] + +### Added + +- **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`:** 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..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.10", + "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/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/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..83b02452cb --- /dev/null +++ b/js/compressed-token/src/v3/actions/approve-interface.ts @@ -0,0 +1,193 @@ +import { + ConfirmOptions, + PublicKey, + Signer, + TransactionSignature, +} from '@solana/web3.js'; +import { + Rpc, + buildAndSignTx, + sendAndConfirmTx, + dedupeSigner, + assertBetaEnabled, + LIGHT_TOKEN_PROGRAM_ID, +} from '@lightprotocol/stateless.js'; +import BN from 'bn.js'; +import { + createApproveInterfaceInstructions, + createRevokeInterfaceInstructions, +} 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'; + +/** + * Approve a delegate for an associated token account. + * + * 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 + * @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) + * @param wrap When true and mint is SPL/T22, wrap into light-token then approve + * @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, + programId: PublicKey = LIGHT_TOKEN_PROGRAM_ID, + wrap = false, + options?: InterfaceOptions, + decimals?: number, +): Promise { + assertBetaEnabled(); + + const expectedAta = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + false, + programId, + ); + if (!tokenAccount.equals(expectedAta)) { + throw new Error( + `Token account mismatch. Expected ${expectedAta.toBase58()}, got ${tokenAccount.toBase58()}`, + ); + } + + 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, + mint, + tokenAccount, + delegate, + amount, + owner.publicKey, + resolvedDecimals, + programId, + wrap, + options, + ); + + 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); +} + +/** + * Revoke delegation for an associated token account. + * + * 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 + * @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) + * @param wrap When true and mint is SPL/T22, wrap into light-token then revoke + * @returns Transaction signature + */ +export async function revokeInterface( + rpc: Rpc, + payer: Signer, + tokenAccount: PublicKey, + mint: PublicKey, + owner: Signer, + confirmOptions?: ConfirmOptions, + programId: PublicKey = LIGHT_TOKEN_PROGRAM_ID, + wrap = false, + options?: InterfaceOptions, + decimals?: number, +): Promise { + assertBetaEnabled(); + + const expectedAta = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + false, + programId, + ); + if (!tokenAccount.equals(expectedAta)) { + throw new Error( + `Token account mismatch. Expected ${expectedAta.toBase58()}, got ${tokenAccount.toBase58()}`, + ); + } + + 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, + resolvedDecimals, + programId, + wrap, + options, + ); + + 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); +} 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..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'; +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 new file mode 100644 index 0000000000..e4e6d83c18 --- /dev/null +++ b/js/compressed-token/src/v3/instructions/approve-interface.ts @@ -0,0 +1,330 @@ +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'; +import type { InterfaceOptions } from '../actions/transfer-interface'; + +const APPROVE_BASE_CU = 10_000; + +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 + * @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, + options?: InterfaceOptions, +): 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, + options, + 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]. + * + * @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 + * @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, + options?: InterfaceOptions, +): 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: REVOKE_BASE_CU, + }), + revokeIx, + ]; + assertTransactionSizeWithinLimit(txIxs, numSigners, 'Batch'); + return [txIxs]; + } + + // Light-token path: load cold accounts if needed + const internalBatches = await _buildLoadBatches( + rpc, + payer, + accountInterface, + options, + wrap, + tokenAccount, + undefined, + owner, + decimals, + ); + + const revokeIx = createLightTokenRevokeInstruction( + tokenAccount, + owner, + payer, + ); + + const numSigners = payer.equals(owner) ? 1 : 2; + + if (internalBatches.length === 0) { + const cu = calculateRevokeCU(null); + const txIxs = [ + ComputeBudgetProgram.setComputeUnitLimit({ units: cu }), + revokeIx, + ]; + assertTransactionSizeWithinLimit(txIxs, numSigners, 'Batch'); + return [txIxs]; + } + + if (internalBatches.length === 1) { + const batch = internalBatches[0]; + const cu = calculateRevokeCU(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 = calculateRevokeCU(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/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..d54431f0a7 100644 --- a/js/compressed-token/src/v3/instructions/index.ts +++ b/js/compressed-token/src/v3/instructions/index.ts @@ -12,3 +12,5 @@ export * from './transfer-interface'; 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 c61a60cf0a..c3221af208 100644 --- a/js/compressed-token/src/v3/unified/index.ts +++ b/js/compressed-token/src/v3/unified/index.ts @@ -37,6 +37,15 @@ import { createTransferToAccountInterfaceInstructions as _createTransferToAccountInterfaceInstructions, } from '../actions/transfer-interface'; import type { TransferOptions as _TransferOptions } from '../actions/transfer-interface'; +import { + approveInterface as _approveInterface, + revokeInterface as _revokeInterface, +} 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 { createUnwrapInstructions as _createUnwrapInstructions, @@ -493,6 +502,165 @@ export type { _TransferOptions as TransferToAccountOptions, }; +/** + * Approve a delegate for an associated token account. + * + * 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 + * @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, + options?: InterfaceOptions, + decimals?: number, +) { + return _approveInterface( + rpc, + payer, + tokenAccount, + mint, + delegate, + amount, + owner, + confirmOptions, + undefined, // programId: use default LIGHT_TOKEN_PROGRAM_ID + true, // wrap=true for unified + options, + decimals, + ); +} + +/** + * 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, + payer: PublicKey, + mint: PublicKey, + tokenAccount: PublicKey, + delegate: PublicKey, + amount: number | bigint | BN, + owner: PublicKey, + decimals?: number, + options?: InterfaceOptions, +): Promise { + const resolvedDecimals = + decimals ?? (await getMintInterface(rpc, mint)).mint.decimals; + return _createApproveInterfaceInstructions( + rpc, + payer, + mint, + tokenAccount, + delegate, + amount, + owner, + resolvedDecimals, + undefined, // programId: use default LIGHT_TOKEN_PROGRAM_ID + true, // wrap=true for unified + options, + ); +} + +/** + * Revoke delegation for an associated token account. + * + * 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 + * @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, + options?: InterfaceOptions, + decimals?: number, +) { + return _revokeInterface( + rpc, + payer, + tokenAccount, + mint, + owner, + confirmOptions, + undefined, // programId: use default LIGHT_TOKEN_PROGRAM_ID + true, // wrap=true for unified + options, + decimals, + ); +} + +/** + * 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, + payer: PublicKey, + mint: PublicKey, + tokenAccount: PublicKey, + owner: PublicKey, + decimals?: number, + options?: InterfaceOptions, +): Promise { + const resolvedDecimals = + decimals ?? (await getMintInterface(rpc, mint)).mint.decimals; + return _createRevokeInterfaceInstructions( + rpc, + payer, + mint, + tokenAccount, + owner, + resolvedDecimals, + undefined, // programId: use default LIGHT_TOKEN_PROGRAM_ID + true, // wrap=true for unified + options, + ); +} + export { getAccountInterface, AccountInterface, @@ -540,6 +708,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..e3e1e4e03a --- /dev/null +++ b/js/compressed-token/tests/e2e/approve-revoke-light-token.test.ts @@ -0,0 +1,530 @@ +/** + * 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 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(); + 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/approve-revoke-spl-t22.test.ts b/js/compressed-token/tests/e2e/approve-revoke-spl-t22.test.ts new file mode 100644 index 0000000000..1b3f0286a9 --- /dev/null +++ b/js/compressed-token/tests/e2e/approve-revoke-spl-t22.test.ts @@ -0,0 +1,273 @@ +/** + * 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/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, 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..c320b02ef3 --- /dev/null +++ b/js/compressed-token/tests/e2e/transfer-delegated-failures.test.ts @@ -0,0 +1,137 @@ +/** + * 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, + transferInterface, + 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; + + 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 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( + transferInterface( + rpc, + payer, + ownerAta, + mint, + recipient.publicKey, + delegate, + 600_000_000, // > 500M allowance + undefined, + undefined, + { owner: owner.publicKey }, + ), + ).rejects.toThrow(); + }, 30_000); + + it('rejects transfer from unapproved signer', async () => { + await expect( + transferInterface( + rpc, + payer, + ownerAta, + mint, + recipient.publicKey, + stranger, // not approved + 100_000_000, + undefined, + undefined, + { owner: owner.publicKey }, + ), + ).rejects.toThrow(); + }, 30_000); + + it('rejects transfer after revoke', async () => { + // Revoke first + await revokeInterface(rpc, payer, ownerAta, mint, owner); + + await expect( + transferInterface( + rpc, + payer, + ownerAta, + mint, + recipient.publicKey, + delegate, + 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 new file mode 100644 index 0000000000..dedda7d98a --- /dev/null +++ b/js/compressed-token/tests/e2e/transfer-delegated-interface.test.ts @@ -0,0 +1,147 @@ +/** + * 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, + transferInterface, + 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 owner ATA + derive recipient ATA (created by transferDelegated) + await createAtaInterface(rpc, payer, mint, owner.publicKey); + ownerAta = getAssociatedTokenAddressInterface(mint, owner.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 (recipient wallet — ATA created internally) + const sig = await transferInterface( + rpc, + payer, + ownerAta, + mint, + recipient.publicKey, + delegate, + 200_000_000, + undefined, + undefined, + { owner: owner.publicKey }, + ); + 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); +}); 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..fedf46beb2 --- /dev/null +++ b/js/compressed-token/tests/e2e/transfer-delegated-spl-t22.test.ts @@ -0,0 +1,384 @@ +/** + * 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, + getAssociatedTokenAddressSync, + mintTo, + getAccount, + TOKEN_PROGRAM_ID, + TOKEN_2022_PROGRAM_ID, +} from '@solana/spl-token'; +import { + approveInterface, + revokeInterface, + transferInterface, +} 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; + + // Derive recipient ATA for balance assertions (created by transferDelegated) + recipientAta = getAssociatedTokenAddressSync( + splMint, + recipient.publicKey, + false, + TOKEN_PROGRAM_ID, + ); + + 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 (recipient wallet — ATA created internally) + const sig = await transferInterface( + rpc, + payer, + ownerAta, + splMint, + recipient.publicKey, + delegate, + 200_000_000n, + TOKEN_PROGRAM_ID, + undefined, + { owner: owner.publicKey, splInterfaceInfos: [] }, + ); + 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( + transferInterface( + rpc, + payer, + ownerAta, + splMint, + recipient.publicKey, + delegate, + 200_000_000n, // > 100M allowance + TOKEN_PROGRAM_ID, + undefined, + { owner: owner.publicKey }, + ), + ).rejects.toThrow(); + }, 60_000); + + it('rejects transfer from unauthorized delegate', async () => { + await expect( + transferInterface( + rpc, + payer, + ownerAta, + splMint, + recipient.publicKey, + stranger, + 50_000_000n, + TOKEN_PROGRAM_ID, + undefined, + { owner: owner.publicKey }, + ), + ).rejects.toThrow(); + }, 60_000); + + it('rejects transfer after revoke', async () => { + await revokeInterface( + rpc, + payer, + ownerAta, + splMint, + owner, + undefined, + TOKEN_PROGRAM_ID, + ); + + await expect( + transferInterface( + rpc, + payer, + ownerAta, + splMint, + recipient.publicKey, + delegate, + 50_000_000n, + TOKEN_PROGRAM_ID, + undefined, + { owner: owner.publicKey }, + ), + ).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; + + // Derive recipient ATA for balance assertions (created by transferDelegated) + recipientAta = getAssociatedTokenAddressSync( + t22Mint, + recipient.publicKey, + false, + TOKEN_2022_PROGRAM_ID, + ); + + 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 (recipient wallet — ATA created internally) + const sig = await transferInterface( + rpc, + payer, + ownerAta, + t22Mint, + recipient.publicKey, + delegate, + 200_000_000n, + TOKEN_2022_PROGRAM_ID, + undefined, + { owner: owner.publicKey, splInterfaceInfos: [] }, + ); + 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); +}); 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..7b736d1673 --- /dev/null +++ b/js/compressed-token/tests/unit/approve-revoke-regressions.test.ts @@ -0,0 +1,145 @@ +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, + undefined, + ); + expect(createRevokeInterfaceInstructionsMock).toHaveBeenCalledWith( + rpc, + payer, + mint, + tokenAccount, + owner, + 6, + undefined, + true, + undefined, + ); + }); + + 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, + undefined, + ); + expect(createRevokeInterfaceInstructionsMock).toHaveBeenCalledWith( + rpc, + payer, + mint, + tokenAccount, + owner, + 9, + undefined, + true, + undefined, + ); + }); +}); diff --git a/js/stateless.js/CHANGELOG.md b/js/stateless.js/CHANGELOG.md index 43ec744619..550e352001 100644 --- a/js/stateless.js/CHANGELOG.md +++ b/js/stateless.js/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [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. + ## [0.23.0-beta.10] ### Breaking Changes diff --git a/js/stateless.js/package.json b/js/stateless.js/package.json index 3c89a2bf22..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.10", + "version": "0.23.0-beta.12", "description": "JavaScript API for Light & ZK Compression", "sideEffects": false, "main": "dist/cjs/node/index.cjs", 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); + }); +});