Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions js/compressed-token/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ export {
createUnwrapInstructions,
createDecompressInterfaceInstruction,
createLightTokenTransferInstruction,
createLightTokenTransferCheckedInstruction,
createTransferInterfaceInstruction,
createTransferInterfaceCheckedInstruction,
// Types
TokenMetadataInstructionData,
CompressibleConfig,
Expand All @@ -91,6 +94,7 @@ export {
getAssociatedTokenAddressInterface,
getOrCreateAtaInterface,
transferInterface,
transferInterfaceChecked,
createTransferInterfaceInstructions,
sliceLast,
decompressInterface,
Expand Down
120 changes: 117 additions & 3 deletions js/compressed-token/src/v3/actions/transfer-interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -281,6 +289,7 @@ export async function createTransferInterfaceInstructions(
wrap = false,
programId = LIGHT_TOKEN_PROGRAM_ID,
ensureRecipientAta = true,
checkedDecimals,
...interfaceOptions
} = options ?? {};

Expand Down Expand Up @@ -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);
Expand All @@ -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,
Expand Down Expand Up @@ -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<TransactionSignature> {
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);
}
174 changes: 174 additions & 0 deletions js/compressed-token/src/v3/instructions/transfer-interface.ts
Original file line number Diff line number Diff line change
@@ -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.
*
Expand Down Expand Up @@ -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()}`);
}
Loading
Loading