Skip to content

Commit fbe3f5c

Browse files
tilo-14claude
andcommitted
feat: add TransferChecked (discriminator 12) to TypeScript SDK
Add checked transfer variant that validates decimals on-chain, matching SPL Token's transferChecked semantics. New instruction builders: - createLightTokenTransferCheckedInstruction: light-token specific (disc 12) - createTransferInterfaceInstruction: interface router for basic transfer - createTransferInterfaceCheckedInstruction: interface router for checked New action: - transferInterfaceChecked: high-level action with decimals validation TransferOptions gains checkedDecimals field used by createTransferInterfaceInstructions to select checked vs basic transfer. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 12bec0c commit fbe3f5c

5 files changed

Lines changed: 598 additions & 4 deletions

File tree

js/compressed-token/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,9 @@ export {
7777
createUnwrapInstructions,
7878
createDecompressInterfaceInstruction,
7979
createLightTokenTransferInstruction,
80+
createLightTokenTransferCheckedInstruction,
81+
createTransferInterfaceInstruction,
82+
createTransferInterfaceCheckedInstruction,
8083
// Types
8184
TokenMetadataInstructionData,
8285
CompressibleConfig,
@@ -91,6 +94,7 @@ export {
9194
getAssociatedTokenAddressInterface,
9295
getOrCreateAtaInterface,
9396
transferInterface,
97+
transferInterfaceChecked,
9498
createTransferInterfaceInstructions,
9599
sliceLast,
96100
decompressInterface,

js/compressed-token/src/v3/actions/transfer-interface.ts

Lines changed: 117 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,10 @@ import {
2222
getMint,
2323
} from '@solana/spl-token';
2424
import BN from 'bn.js';
25-
import { createLightTokenTransferInstruction } from '../instructions/transfer-interface';
25+
import {
26+
createLightTokenTransferInstruction,
27+
createLightTokenTransferCheckedInstruction,
28+
} from '../instructions/transfer-interface';
2629
import { createAssociatedTokenAccountInterfaceIdempotentInstruction } from '../instructions/create-ata-interface';
2730
import { getAssociatedTokenAddressInterface } from '../get-associated-token-address-interface';
2831
import { type SplInterfaceInfo } from '../../utils/get-token-pool-infos';
@@ -156,6 +159,11 @@ export interface TransferOptions extends InterfaceOptions {
156159
* Default: true.
157160
*/
158161
ensureRecipientAta?: boolean;
162+
/**
163+
* When set, uses transfer_checked instructions (discriminator 12) that
164+
* validate decimals on-chain. Undefined uses basic transfer (discriminator 3).
165+
*/
166+
checkedDecimals?: number;
159167
}
160168

161169
/**
@@ -281,6 +289,7 @@ export async function createTransferInterfaceInstructions(
281289
wrap = false,
282290
programId = LIGHT_TOKEN_PROGRAM_ID,
283291
ensureRecipientAta = true,
292+
checkedDecimals,
284293
...interfaceOptions
285294
} = options ?? {};
286295

@@ -375,7 +384,7 @@ export async function createTransferInterfaceInstructions(
375384
amountBigInt,
376385
);
377386

378-
// Transfer instruction: dispatch based on program
387+
// Transfer instruction: dispatch based on program and checked mode
379388
let transferIx: TransactionInstruction;
380389
if (isSplOrT22 && !wrap) {
381390
const mintInfo = await getMint(rpc, mint, undefined, programId);
@@ -385,10 +394,19 @@ export async function createTransferInterfaceInstructions(
385394
recipientAta,
386395
sender,
387396
amountBigInt,
388-
mintInfo.decimals,
397+
checkedDecimals ?? mintInfo.decimals,
389398
[],
390399
programId,
391400
);
401+
} else if (checkedDecimals !== undefined) {
402+
transferIx = createLightTokenTransferCheckedInstruction(
403+
senderAta,
404+
mint,
405+
recipientAta,
406+
sender,
407+
amountBigInt,
408+
checkedDecimals,
409+
);
392410
} else {
393411
transferIx = createLightTokenTransferInstruction(
394412
senderAta,
@@ -478,3 +496,99 @@ export async function createTransferInterfaceInstructions(
478496

479497
return result;
480498
}
499+
500+
/**
501+
* Transfer tokens using the light-token interface with decimals validation.
502+
*
503+
* Like SPL Token's transferChecked, the on-chain program validates that the
504+
* provided `decimals` matches the mint's decimals field, preventing
505+
* decimal-related transfer errors (e.g. sending 1e9 when you meant 1e6).
506+
*
507+
* Creates the recipient associated token account if it does not exist.
508+
*
509+
* @param rpc RPC connection
510+
* @param payer Fee payer (signer)
511+
* @param source Source light-token associated token account address
512+
* @param mint Mint address
513+
* @param destination Recipient wallet public key
514+
* @param owner Source owner (signer)
515+
* @param amount Amount to transfer
516+
* @param decimals Expected decimals of the mint (validated on-chain)
517+
* @param programId Token program ID (default: LIGHT_TOKEN_PROGRAM_ID)
518+
* @param confirmOptions Optional confirm options
519+
* @param options Optional interface options
520+
* @param wrap Include SPL/T22 wrapping (default: false)
521+
* @returns Transaction signature
522+
*/
523+
export async function transferInterfaceChecked(
524+
rpc: Rpc,
525+
payer: Signer,
526+
source: PublicKey,
527+
mint: PublicKey,
528+
destination: PublicKey,
529+
owner: Signer,
530+
amount: number | bigint | BN,
531+
decimals: number,
532+
programId: PublicKey = LIGHT_TOKEN_PROGRAM_ID,
533+
confirmOptions?: ConfirmOptions,
534+
options?: InterfaceOptions,
535+
wrap = false,
536+
): Promise<TransactionSignature> {
537+
assertBetaEnabled();
538+
539+
// Validate source matches owner
540+
const expectedSource = getAssociatedTokenAddressInterface(
541+
mint,
542+
owner.publicKey,
543+
false,
544+
programId,
545+
);
546+
if (!source.equals(expectedSource)) {
547+
throw new Error(
548+
`Source mismatch. Expected ${expectedSource.toBase58()}, got ${source.toBase58()}`,
549+
);
550+
}
551+
552+
const amountBigInt = BigInt(amount.toString());
553+
554+
const batches = await createTransferInterfaceInstructions(
555+
rpc,
556+
payer.publicKey,
557+
mint,
558+
amountBigInt,
559+
owner.publicKey,
560+
destination,
561+
{
562+
...options,
563+
wrap,
564+
programId,
565+
ensureRecipientAta: true,
566+
checkedDecimals: decimals,
567+
},
568+
);
569+
570+
const additionalSigners = dedupeSigner(payer, [owner]);
571+
const { rest: loads, last: transferIxs } = sliceLast(batches);
572+
573+
// Send load transactions in parallel (if any)
574+
if (loads.length > 0) {
575+
await Promise.all(
576+
loads.map(async ixs => {
577+
const { blockhash } = await rpc.getLatestBlockhash();
578+
const tx = buildAndSignTx(
579+
ixs,
580+
payer,
581+
blockhash,
582+
additionalSigners,
583+
);
584+
return sendAndConfirmTx(rpc, tx, confirmOptions);
585+
}),
586+
);
587+
}
588+
589+
// Send transfer transaction
590+
const { blockhash } = await rpc.getLatestBlockhash();
591+
const tx = buildAndSignTx(transferIxs, payer, blockhash, additionalSigners);
592+
593+
return sendAndConfirmTx(rpc, tx, confirmOptions);
594+
}

js/compressed-token/src/v3/instructions/transfer-interface.ts

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,27 @@
11
import {
22
PublicKey,
3+
Signer,
34
TransactionInstruction,
45
SystemProgram,
56
} from '@solana/web3.js';
67
import { LIGHT_TOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js';
8+
import {
9+
TOKEN_2022_PROGRAM_ID,
10+
TOKEN_PROGRAM_ID,
11+
createTransferInstruction as createSplTransferInstruction,
12+
createTransferCheckedInstruction as createSplTransferCheckedInstruction,
13+
} from '@solana/spl-token';
714

815
/**
916
* Light token transfer instruction discriminator
1017
*/
1118
const LIGHT_TOKEN_TRANSFER_DISCRIMINATOR = 3;
1219

20+
/**
21+
* Light token transfer_checked instruction discriminator (SPL-compatible)
22+
*/
23+
const LIGHT_TOKEN_TRANSFER_CHECKED_DISCRIMINATOR = 12;
24+
1325
/**
1426
* Create a light-token transfer instruction.
1527
*
@@ -63,3 +75,165 @@ export function createLightTokenTransferInstruction(
6375
data,
6476
});
6577
}
78+
79+
/**
80+
* Create a light-token transfer_checked instruction.
81+
*
82+
* Account order matches SPL Token's transferChecked:
83+
* [source, mint, destination, authority]
84+
*
85+
* On-chain, the program validates that `decimals` matches the mint's decimals
86+
* field, preventing decimal-related transfer errors.
87+
*
88+
* @param source Source light-token account
89+
* @param mint Mint account (used for decimals validation)
90+
* @param destination Destination light-token account
91+
* @param owner Owner of the source account (signer, also pays for compressible extension top-ups)
92+
* @param amount Amount to transfer
93+
* @param decimals Expected decimals of the mint
94+
* @returns Transaction instruction for light-token transfer_checked
95+
*/
96+
export function createLightTokenTransferCheckedInstruction(
97+
source: PublicKey,
98+
mint: PublicKey,
99+
destination: PublicKey,
100+
owner: PublicKey,
101+
amount: number | bigint,
102+
decimals: number,
103+
): TransactionInstruction {
104+
// Instruction data format:
105+
// byte 0: discriminator (12)
106+
// bytes 1-8: amount (u64 LE)
107+
// byte 9: decimals (u8)
108+
const data = Buffer.alloc(10);
109+
data.writeUInt8(LIGHT_TOKEN_TRANSFER_CHECKED_DISCRIMINATOR, 0);
110+
data.writeBigUInt64LE(BigInt(amount), 1);
111+
data.writeUInt8(decimals, 9);
112+
113+
const keys = [
114+
{ pubkey: source, isSigner: false, isWritable: true },
115+
{ pubkey: mint, isSigner: false, isWritable: false },
116+
{ pubkey: destination, isSigner: false, isWritable: true },
117+
{ pubkey: owner, isSigner: true, isWritable: true },
118+
];
119+
120+
return new TransactionInstruction({
121+
programId: LIGHT_TOKEN_PROGRAM_ID,
122+
keys,
123+
data,
124+
});
125+
}
126+
127+
/**
128+
* Construct a transfer instruction for SPL/T22/light-token. Defaults to
129+
* light-token program.
130+
*
131+
* @param source Source token account
132+
* @param destination Destination token account
133+
* @param owner Owner of the source account (signer)
134+
* @param amount Amount to transfer
135+
* @param multiSigners Multi-signers (SPL/T22 only)
136+
* @param programId Token program ID (default: LIGHT_TOKEN_PROGRAM_ID)
137+
* @returns instruction for transfer
138+
*/
139+
export function createTransferInterfaceInstruction(
140+
source: PublicKey,
141+
destination: PublicKey,
142+
owner: PublicKey,
143+
amount: number | bigint,
144+
multiSigners: (Signer | PublicKey)[] = [],
145+
programId: PublicKey = LIGHT_TOKEN_PROGRAM_ID,
146+
): TransactionInstruction {
147+
if (programId.equals(LIGHT_TOKEN_PROGRAM_ID)) {
148+
if (multiSigners.length > 0) {
149+
throw new Error(
150+
'Light token transfer does not support multi-signers. Use a single owner.',
151+
);
152+
}
153+
return createLightTokenTransferInstruction(
154+
source,
155+
destination,
156+
owner,
157+
amount,
158+
);
159+
}
160+
161+
if (
162+
programId.equals(TOKEN_PROGRAM_ID) ||
163+
programId.equals(TOKEN_2022_PROGRAM_ID)
164+
) {
165+
return createSplTransferInstruction(
166+
source,
167+
destination,
168+
owner,
169+
amount,
170+
multiSigners.map(pk =>
171+
pk instanceof PublicKey ? pk : pk.publicKey,
172+
),
173+
programId,
174+
);
175+
}
176+
177+
throw new Error(`Unsupported program ID: ${programId.toBase58()}`);
178+
}
179+
180+
/**
181+
* Construct a transfer_checked instruction for SPL/T22/light-token. Defaults to
182+
* light-token program. On-chain, validates that `decimals` matches the mint.
183+
*
184+
* @param source Source token account
185+
* @param mint Mint account
186+
* @param destination Destination token account
187+
* @param owner Owner of the source account (signer)
188+
* @param amount Amount to transfer
189+
* @param decimals Expected decimals of the mint
190+
* @param multiSigners Multi-signers (SPL/T22 only)
191+
* @param programId Token program ID (default: LIGHT_TOKEN_PROGRAM_ID)
192+
* @returns instruction for transfer_checked
193+
*/
194+
export function createTransferInterfaceCheckedInstruction(
195+
source: PublicKey,
196+
mint: PublicKey,
197+
destination: PublicKey,
198+
owner: PublicKey,
199+
amount: number | bigint,
200+
decimals: number,
201+
multiSigners: (Signer | PublicKey)[] = [],
202+
programId: PublicKey = LIGHT_TOKEN_PROGRAM_ID,
203+
): TransactionInstruction {
204+
if (programId.equals(LIGHT_TOKEN_PROGRAM_ID)) {
205+
if (multiSigners.length > 0) {
206+
throw new Error(
207+
'Light token transfer does not support multi-signers. Use a single owner.',
208+
);
209+
}
210+
return createLightTokenTransferCheckedInstruction(
211+
source,
212+
mint,
213+
destination,
214+
owner,
215+
amount,
216+
decimals,
217+
);
218+
}
219+
220+
if (
221+
programId.equals(TOKEN_PROGRAM_ID) ||
222+
programId.equals(TOKEN_2022_PROGRAM_ID)
223+
) {
224+
return createSplTransferCheckedInstruction(
225+
source,
226+
mint,
227+
destination,
228+
owner,
229+
amount,
230+
decimals,
231+
multiSigners.map(pk =>
232+
pk instanceof PublicKey ? pk : pk.publicKey,
233+
),
234+
programId,
235+
);
236+
}
237+
238+
throw new Error(`Unsupported program ID: ${programId.toBase58()}`);
239+
}

0 commit comments

Comments
 (0)