diff --git a/js/compressed-token/src/index.ts b/js/compressed-token/src/index.ts index ab4648087d..eea615f8c1 100644 --- a/js/compressed-token/src/index.ts +++ b/js/compressed-token/src/index.ts @@ -77,6 +77,9 @@ export { createUnwrapInstructions, createDecompressInterfaceInstruction, createLightTokenTransferInstruction, + createLightTokenTransferCheckedInstruction, + createTransferInterfaceInstruction, + createTransferInterfaceCheckedInstruction, // Types TokenMetadataInstructionData, CompressibleConfig, @@ -91,6 +94,7 @@ export { getAssociatedTokenAddressInterface, getOrCreateAtaInterface, transferInterface, + transferInterfaceChecked, createTransferInterfaceInstructions, sliceLast, decompressInterface, diff --git a/js/compressed-token/src/v3/actions/transfer-interface.ts b/js/compressed-token/src/v3/actions/transfer-interface.ts index 5a7c0bc18a..a815cd9de3 100644 --- a/js/compressed-token/src/v3/actions/transfer-interface.ts +++ b/js/compressed-token/src/v3/actions/transfer-interface.ts @@ -22,7 +22,10 @@ import { getMint, } from '@solana/spl-token'; import BN from 'bn.js'; -import { createLightTokenTransferInstruction } from '../instructions/transfer-interface'; +import { + createLightTokenTransferInstruction, + createLightTokenTransferCheckedInstruction, +} from '../instructions/transfer-interface'; import { createAssociatedTokenAccountInterfaceIdempotentInstruction } from '../instructions/create-ata-interface'; import { getAssociatedTokenAddressInterface } from '../get-associated-token-address-interface'; import { type SplInterfaceInfo } from '../../utils/get-token-pool-infos'; @@ -156,6 +159,11 @@ export interface TransferOptions extends InterfaceOptions { * Default: true. */ ensureRecipientAta?: boolean; + /** + * When set, uses transfer_checked instructions (discriminator 12) that + * validate decimals on-chain. Undefined uses basic transfer (discriminator 3). + */ + checkedDecimals?: number; } /** @@ -281,6 +289,7 @@ export async function createTransferInterfaceInstructions( wrap = false, programId = LIGHT_TOKEN_PROGRAM_ID, ensureRecipientAta = true, + checkedDecimals, ...interfaceOptions } = options ?? {}; @@ -375,7 +384,7 @@ export async function createTransferInterfaceInstructions( amountBigInt, ); - // Transfer instruction: dispatch based on program + // Transfer instruction: dispatch based on program and checked mode let transferIx: TransactionInstruction; if (isSplOrT22 && !wrap) { const mintInfo = await getMint(rpc, mint, undefined, programId); @@ -385,10 +394,19 @@ export async function createTransferInterfaceInstructions( recipientAta, sender, amountBigInt, - mintInfo.decimals, + checkedDecimals ?? mintInfo.decimals, [], programId, ); + } else if (checkedDecimals !== undefined) { + transferIx = createLightTokenTransferCheckedInstruction( + senderAta, + mint, + recipientAta, + sender, + amountBigInt, + checkedDecimals, + ); } else { transferIx = createLightTokenTransferInstruction( senderAta, @@ -478,3 +496,99 @@ export async function createTransferInterfaceInstructions( return result; } + +/** + * Transfer tokens using the light-token interface with decimals validation. + * + * Like SPL Token's transferChecked, the on-chain program validates that the + * provided `decimals` matches the mint's decimals field, preventing + * decimal-related transfer errors (e.g. sending 1e9 when you meant 1e6). + * + * Creates the recipient associated token account if it does not exist. + * + * @param rpc RPC connection + * @param payer Fee payer (signer) + * @param source Source light-token associated token account address + * @param mint Mint address + * @param destination Recipient wallet public key + * @param owner Source owner (signer) + * @param amount Amount to transfer + * @param decimals Expected decimals of the mint (validated on-chain) + * @param programId Token program ID (default: LIGHT_TOKEN_PROGRAM_ID) + * @param confirmOptions Optional confirm options + * @param options Optional interface options + * @param wrap Include SPL/T22 wrapping (default: false) + * @returns Transaction signature + */ +export async function transferInterfaceChecked( + rpc: Rpc, + payer: Signer, + source: PublicKey, + mint: PublicKey, + destination: PublicKey, + owner: Signer, + amount: number | bigint | BN, + decimals: number, + programId: PublicKey = LIGHT_TOKEN_PROGRAM_ID, + confirmOptions?: ConfirmOptions, + options?: InterfaceOptions, + wrap = false, +): Promise { + assertBetaEnabled(); + + // Validate source matches owner + const expectedSource = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + false, + programId, + ); + if (!source.equals(expectedSource)) { + throw new Error( + `Source mismatch. Expected ${expectedSource.toBase58()}, got ${source.toBase58()}`, + ); + } + + const amountBigInt = BigInt(amount.toString()); + + const batches = await createTransferInterfaceInstructions( + rpc, + payer.publicKey, + mint, + amountBigInt, + owner.publicKey, + destination, + { + ...options, + wrap, + programId, + ensureRecipientAta: true, + checkedDecimals: decimals, + }, + ); + + const additionalSigners = dedupeSigner(payer, [owner]); + const { rest: loads, last: transferIxs } = sliceLast(batches); + + // Send load transactions in parallel (if any) + if (loads.length > 0) { + await Promise.all( + loads.map(async ixs => { + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx( + ixs, + payer, + blockhash, + additionalSigners, + ); + return sendAndConfirmTx(rpc, tx, confirmOptions); + }), + ); + } + + // Send transfer transaction + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx(transferIxs, payer, blockhash, additionalSigners); + + return sendAndConfirmTx(rpc, tx, confirmOptions); +} diff --git a/js/compressed-token/src/v3/instructions/transfer-interface.ts b/js/compressed-token/src/v3/instructions/transfer-interface.ts index f17ae911b0..389dbc7257 100644 --- a/js/compressed-token/src/v3/instructions/transfer-interface.ts +++ b/js/compressed-token/src/v3/instructions/transfer-interface.ts @@ -1,15 +1,27 @@ import { PublicKey, + Signer, TransactionInstruction, SystemProgram, } from '@solana/web3.js'; import { LIGHT_TOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; +import { + TOKEN_2022_PROGRAM_ID, + TOKEN_PROGRAM_ID, + createTransferInstruction as createSplTransferInstruction, + createTransferCheckedInstruction as createSplTransferCheckedInstruction, +} from '@solana/spl-token'; /** * Light token transfer instruction discriminator */ const LIGHT_TOKEN_TRANSFER_DISCRIMINATOR = 3; +/** + * Light token transfer_checked instruction discriminator (SPL-compatible) + */ +const LIGHT_TOKEN_TRANSFER_CHECKED_DISCRIMINATOR = 12; + /** * Create a light-token transfer instruction. * @@ -63,3 +75,165 @@ export function createLightTokenTransferInstruction( data, }); } + +/** + * Create a light-token transfer_checked instruction. + * + * Account order matches SPL Token's transferChecked: + * [source, mint, destination, authority] + * + * On-chain, the program validates that `decimals` matches the mint's decimals + * field, preventing decimal-related transfer errors. + * + * @param source Source light-token account + * @param mint Mint account (used for decimals validation) + * @param destination Destination light-token account + * @param owner Owner of the source account (signer, also pays for compressible extension top-ups) + * @param amount Amount to transfer + * @param decimals Expected decimals of the mint + * @returns Transaction instruction for light-token transfer_checked + */ +export function createLightTokenTransferCheckedInstruction( + source: PublicKey, + mint: PublicKey, + destination: PublicKey, + owner: PublicKey, + amount: number | bigint, + decimals: number, +): TransactionInstruction { + // Instruction data format: + // byte 0: discriminator (12) + // bytes 1-8: amount (u64 LE) + // byte 9: decimals (u8) + const data = Buffer.alloc(10); + data.writeUInt8(LIGHT_TOKEN_TRANSFER_CHECKED_DISCRIMINATOR, 0); + data.writeBigUInt64LE(BigInt(amount), 1); + data.writeUInt8(decimals, 9); + + const keys = [ + { pubkey: source, isSigner: false, isWritable: true }, + { pubkey: mint, isSigner: false, isWritable: false }, + { pubkey: destination, isSigner: false, isWritable: true }, + { pubkey: owner, isSigner: true, isWritable: true }, + ]; + + return new TransactionInstruction({ + programId: LIGHT_TOKEN_PROGRAM_ID, + keys, + data, + }); +} + +/** + * Construct a transfer instruction for SPL/T22/light-token. Defaults to + * light-token program. + * + * @param source Source token account + * @param destination Destination token account + * @param owner Owner of the source account (signer) + * @param amount Amount to transfer + * @param multiSigners Multi-signers (SPL/T22 only) + * @param programId Token program ID (default: LIGHT_TOKEN_PROGRAM_ID) + * @returns instruction for transfer + */ +export function createTransferInterfaceInstruction( + source: PublicKey, + destination: PublicKey, + owner: PublicKey, + amount: number | bigint, + multiSigners: (Signer | PublicKey)[] = [], + programId: PublicKey = LIGHT_TOKEN_PROGRAM_ID, +): TransactionInstruction { + if (programId.equals(LIGHT_TOKEN_PROGRAM_ID)) { + if (multiSigners.length > 0) { + throw new Error( + 'Light token transfer does not support multi-signers. Use a single owner.', + ); + } + return createLightTokenTransferInstruction( + source, + destination, + owner, + amount, + ); + } + + if ( + programId.equals(TOKEN_PROGRAM_ID) || + programId.equals(TOKEN_2022_PROGRAM_ID) + ) { + return createSplTransferInstruction( + source, + destination, + owner, + amount, + multiSigners.map(pk => + pk instanceof PublicKey ? pk : pk.publicKey, + ), + programId, + ); + } + + throw new Error(`Unsupported program ID: ${programId.toBase58()}`); +} + +/** + * Construct a transfer_checked instruction for SPL/T22/light-token. Defaults to + * light-token program. On-chain, validates that `decimals` matches the mint. + * + * @param source Source token account + * @param mint Mint account + * @param destination Destination token account + * @param owner Owner of the source account (signer) + * @param amount Amount to transfer + * @param decimals Expected decimals of the mint + * @param multiSigners Multi-signers (SPL/T22 only) + * @param programId Token program ID (default: LIGHT_TOKEN_PROGRAM_ID) + * @returns instruction for transfer_checked + */ +export function createTransferInterfaceCheckedInstruction( + source: PublicKey, + mint: PublicKey, + destination: PublicKey, + owner: PublicKey, + amount: number | bigint, + decimals: number, + multiSigners: (Signer | PublicKey)[] = [], + programId: PublicKey = LIGHT_TOKEN_PROGRAM_ID, +): TransactionInstruction { + if (programId.equals(LIGHT_TOKEN_PROGRAM_ID)) { + if (multiSigners.length > 0) { + throw new Error( + 'Light token transfer does not support multi-signers. Use a single owner.', + ); + } + return createLightTokenTransferCheckedInstruction( + source, + mint, + destination, + owner, + amount, + decimals, + ); + } + + if ( + programId.equals(TOKEN_PROGRAM_ID) || + programId.equals(TOKEN_2022_PROGRAM_ID) + ) { + return createSplTransferCheckedInstruction( + source, + mint, + destination, + owner, + amount, + decimals, + multiSigners.map(pk => + pk instanceof PublicKey ? pk : pk.publicKey, + ), + programId, + ); + } + + throw new Error(`Unsupported program ID: ${programId.toBase58()}`); +} diff --git a/js/compressed-token/src/v3/unified/index.ts b/js/compressed-token/src/v3/unified/index.ts index 8f3f66590e..48448254e2 100644 --- a/js/compressed-token/src/v3/unified/index.ts +++ b/js/compressed-token/src/v3/unified/index.ts @@ -32,6 +32,7 @@ import { import { createAssociatedTokenAccountInterfaceIdempotentInstruction } from '../instructions/create-ata-interface'; import { transferInterface as _transferInterface, + transferInterfaceChecked as _transferInterfaceChecked, createTransferInterfaceInstructions as _createTransferInterfaceInstructions, } from '../actions/transfer-interface'; import type { TransferOptions as _TransferOptions } from '../actions/transfer-interface'; @@ -240,6 +241,52 @@ export async function transferInterface( ); } +/** + * Transfer tokens using the unified ata interface with decimals validation. + * + * Like SPL Token's transferChecked, the on-chain program validates that the + * provided `decimals` matches the mint's decimals field. Destination must exist. + * + * @param rpc RPC connection + * @param payer Fee payer (signer) + * @param source Source light-token associated token account address + * @param mint Mint address + * @param destination Destination light-token associated token account address (must exist) + * @param owner Source owner (signer) + * @param amount Amount to transfer + * @param decimals Expected decimals of the mint (validated on-chain) + * @param confirmOptions Optional confirm options + * @param options Optional interface options + * @returns Transaction signature + */ +export async function transferInterfaceChecked( + rpc: Rpc, + payer: Signer, + source: PublicKey, + mint: PublicKey, + destination: PublicKey, + owner: Signer, + amount: number | bigint | BN, + decimals: number, + confirmOptions?: ConfirmOptions, + options?: InterfaceOptions, +) { + return _transferInterfaceChecked( + rpc, + payer, + source, + mint, + destination, + owner, + amount, + decimals, + undefined, // programId: use default LIGHT_TOKEN_PROGRAM_ID + confirmOptions, + options, + true, // wrap=true for unified + ); +} + /** * Get or create light-token ATA with unified balance detection and auto-loading. * @@ -464,6 +511,9 @@ export { createUnwrapInstruction, createDecompressInterfaceInstruction, createLightTokenTransferInstruction, + createLightTokenTransferCheckedInstruction, + createTransferInterfaceInstruction, + createTransferInterfaceCheckedInstruction, // Types TokenMetadataInstructionData, CompressibleConfig, diff --git a/js/compressed-token/tests/e2e/transfer-interface.test.ts b/js/compressed-token/tests/e2e/transfer-interface.test.ts index 5c163d71b0..8db529b2ff 100644 --- a/js/compressed-token/tests/e2e/transfer-interface.test.ts +++ b/js/compressed-token/tests/e2e/transfer-interface.test.ts @@ -26,13 +26,18 @@ import { getAssociatedTokenAddressInterface } from '../../src/v3/get-associated- import { getOrCreateAtaInterface } from '../../src/v3/actions/get-or-create-ata-interface'; import { transferInterface, + transferInterfaceChecked, createTransferInterfaceInstructions, } from '../../src/v3/actions/transfer-interface'; import { loadAta, createLoadAtaInstructions, } from '../../src/v3/actions/load-ata'; -import { createLightTokenTransferInstruction } from '../../src/v3/instructions/transfer-interface'; +import { + createLightTokenTransferInstruction, + createLightTokenTransferCheckedInstruction, + createTransferInterfaceCheckedInstruction, +} from '../../src/v3/instructions/transfer-interface'; import { LIGHT_TOKEN_RENT_SPONSOR, TOTAL_COMPRESSION_COST, @@ -792,4 +797,251 @@ describe('transfer-interface', () => { expect(senderAfter.amount).toBe(BigInt(2500)); }, 120_000); }); + + // ================================================================ + // TRANSFER CHECKED (discriminator 12) + // ================================================================ + describe('createLightTokenTransferCheckedInstruction', () => { + it('should create instruction with 4 accounts (source, mint, dest, authority)', () => { + const source = Keypair.generate().publicKey; + const destination = Keypair.generate().publicKey; + const owner = Keypair.generate().publicKey; + const amount = BigInt(1000); + + const ix = createLightTokenTransferCheckedInstruction( + source, + mint, + destination, + owner, + amount, + TEST_TOKEN_DECIMALS, + ); + + expect(ix.programId.equals(LIGHT_TOKEN_PROGRAM_ID)).toBe(true); + expect(ix.keys.length).toBe(4); + expect(ix.keys[0].pubkey.equals(source)).toBe(true); + expect(ix.keys[0].isWritable).toBe(true); + expect(ix.keys[1].pubkey.equals(mint)).toBe(true); + expect(ix.keys[1].isWritable).toBe(false); + expect(ix.keys[2].pubkey.equals(destination)).toBe(true); + expect(ix.keys[2].isWritable).toBe(true); + expect(ix.keys[3].pubkey.equals(owner)).toBe(true); + expect(ix.keys[3].isSigner).toBe(true); + expect(ix.keys[3].isWritable).toBe(true); + }); + + it('should encode discriminator 12, amount, and decimals', () => { + const source = Keypair.generate().publicKey; + const destination = Keypair.generate().publicKey; + const owner = Keypair.generate().publicKey; + const amount = BigInt(42000); + + const ix = createLightTokenTransferCheckedInstruction( + source, + mint, + destination, + owner, + amount, + TEST_TOKEN_DECIMALS, + ); + + expect(ix.data.length).toBe(10); + expect(ix.data[0]).toBe(12); + expect(ix.data.readBigUInt64LE(1)).toBe(BigInt(42000)); + expect(ix.data[9]).toBe(TEST_TOKEN_DECIMALS); + }); + }); + + describe('createTransferInterfaceCheckedInstruction', () => { + it('should route to light-token checked instruction by default', () => { + const source = Keypair.generate().publicKey; + const destination = Keypair.generate().publicKey; + const owner = Keypair.generate().publicKey; + + const ix = createTransferInterfaceCheckedInstruction( + source, + mint, + destination, + owner, + BigInt(500), + TEST_TOKEN_DECIMALS, + ); + + expect(ix.programId.equals(LIGHT_TOKEN_PROGRAM_ID)).toBe(true); + expect(ix.keys.length).toBe(4); + expect(ix.data[0]).toBe(12); + }); + }); + + describe('transferInterfaceChecked action', () => { + it('should transfer with correct decimals from hot balance', async () => { + const sender = await newAccountWithLamports(rpc, 1e9); + const recipient = Keypair.generate(); + + // Mint and load sender + await mintTo( + rpc, + payer, + mint, + sender.publicKey, + mintAuthority, + bn(5000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + const senderAta = getAssociatedTokenAddressInterface( + mint, + sender.publicKey, + ); + await loadAta(rpc, senderAta, sender, mint); + + // Create recipient ATA + const recipientAta = await getOrCreateAtaInterface( + rpc, + payer, + mint, + recipient.publicKey, + ); + + const sourceAta = getAssociatedTokenAddressInterface( + mint, + sender.publicKey, + ); + + // transferInterfaceChecked with correct decimals + const signature = await transferInterfaceChecked( + rpc, + payer, + sourceAta, + mint, + recipient.publicKey, + sender, + BigInt(1000), + TEST_TOKEN_DECIMALS, + ); + + expect(signature).toBeDefined(); + + // Verify balances + const senderAtaInfo = await rpc.getAccountInfo(sourceAta); + const senderBalance = senderAtaInfo!.data.readBigUInt64LE(64); + expect(senderBalance).toBe(BigInt(4000)); + + const recipientAtaInfo = await rpc.getAccountInfo( + recipientAta.parsed.address, + ); + const recipientBalance = recipientAtaInfo!.data.readBigUInt64LE(64); + expect(recipientBalance).toBe(BigInt(1000)); + }); + + it('should fail with wrong decimals', async () => { + const sender = await newAccountWithLamports(rpc, 1e9); + const recipient = Keypair.generate(); + + // Mint and load sender + await mintTo( + rpc, + payer, + mint, + sender.publicKey, + mintAuthority, + bn(5000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + const senderAta = getAssociatedTokenAddressInterface( + mint, + sender.publicKey, + ); + await loadAta(rpc, senderAta, sender, mint); + + // Create recipient ATA + const recipientAta = await getOrCreateAtaInterface( + rpc, + payer, + mint, + recipient.publicKey, + ); + + const sourceAta = getAssociatedTokenAddressInterface( + mint, + sender.publicKey, + ); + + const wrongDecimals = TEST_TOKEN_DECIMALS + 1; + + // transferInterfaceChecked with wrong decimals should fail + await expect( + transferInterfaceChecked( + rpc, + payer, + sourceAta, + mint, + recipient.publicKey, + sender, + BigInt(1000), + wrongDecimals, + ), + ).rejects.toThrow(); + }); + + it('should auto-load from cold with correct decimals', async () => { + const sender = await newAccountWithLamports(rpc, 1e9); + const recipient = Keypair.generate(); + + // Mint compressed tokens (cold) - don't load + await mintTo( + rpc, + payer, + mint, + sender.publicKey, + mintAuthority, + bn(3000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + // Create recipient ATA + const recipientAta = await getOrCreateAtaInterface( + rpc, + payer, + mint, + recipient.publicKey, + ); + + const sourceAta = getAssociatedTokenAddressInterface( + mint, + sender.publicKey, + ); + + // Transfer should auto-load cold balance and use checked transfer + const signature = await transferInterfaceChecked( + rpc, + payer, + sourceAta, + mint, + recipient.publicKey, + sender, + BigInt(2000), + TEST_TOKEN_DECIMALS, + LIGHT_TOKEN_PROGRAM_ID, + undefined, + { splInterfaceInfos: tokenPoolInfos }, + ); + + expect(signature).toBeDefined(); + + // Verify recipient received tokens + const recipientAtaInfo = await rpc.getAccountInfo( + recipientAta.parsed.address, + ); + const recipientBalance = recipientAtaInfo!.data.readBigUInt64LE(64); + expect(recipientBalance).toBe(BigInt(2000)); + + // Sender should have change + const senderAtaInfo = await rpc.getAccountInfo(sourceAta); + const senderBalance = senderAtaInfo!.data.readBigUInt64LE(64); + expect(senderBalance).toBe(BigInt(1000)); + }); + }); });