Skip to content

Commit 0bd606e

Browse files
tilo-14tilo-14SwenSchaeferjohann
authored
feat(sdk): add approve/revoke delegation for light-token ATAs (#2351)
* feat(compressed-token): add approve/revoke delegation for light-token ATAs Add TypeScript SDK functions to call the on-chain CTokenApprove (discriminator 4) and CTokenRevoke (discriminator 5) instruction handlers for light-token associated token accounts. New files: - instructions/approve-revoke.ts: sync instruction builders matching Rust SDK layout - actions/approve-interface.ts: async actions with cold loading + tx sending - tests/e2e/approve-revoke-light-token.test.ts: unit + E2E tests Also adds getLightTokenDelegate helper and extends FrozenOperation type. * fix(sdk): make decimals optional in unified approve/revoke wrappers Avoid unnecessary getMintInterface RPC call when caller provides decimals. * feat(sdk): add transferDelegated for light-token ATAs Add transferDelegatedInterface action and unified wrapper, completing the approve → transfer → revoke delegation flow for light-token ATAs. * add spl t22 support * refactor(sdk): align transferDelegated with wallet-recipient API Update transferDelegatedInterface and createTransferDelegatedInterfaceInstructions to accept a recipient wallet address instead of an explicit destination token account, matching the transferInterface convention from PR #2354. ATA derivation and idempotent creation now happen internally for all programId variants (light-token, SPL, Token-2022). * 1st batch commnets * docs(sdk): document load-all behavior in approve/revoke JSDoc; add owner==feePayer E2E test Add @remarks to approve/revoke functions documenting that for light-token mints, all cold (compressed) balances are loaded into the hot ATA regardless of the delegation amount. Add E2E test covering the owner==feePayer code path which was previously only tested at the unit level. * add regression tests * fixes * update changelog * upd changelog * fix: packedaccounts in js should not turn bool to number * cherry pick bool fix * bump versions again --------- Co-authored-by: tilo-14 <tilo@luminouslabs.com> Co-authored-by: Swenschaeferjohann <swen@lightprotocol.com>
1 parent 715efe2 commit 0bd606e

24 files changed

Lines changed: 2538 additions & 11 deletions

cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@lightprotocol/zk-compression-cli",
3-
"version": "0.28.0-beta.10",
3+
"version": "0.28.0-beta.12",
44
"description": "ZK Compression: Secure Scaling on Solana",
55
"maintainers": [
66
{

js/compressed-token/CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,22 @@
1+
## [0.23.0-beta.11]
2+
3+
### Added
4+
5+
- **Delegate approval and revocation** for SPL Token, Token-2022, and light-token, aligned with existing interface helpers:
6+
- **Actions:** `approveInterface`, `revokeInterface`.
7+
- **Instruction builders:** `createApproveInterfaceInstructions`, `createRevokeInterfaceInstructions` — each inner array is one transaction’s instructions (same batching style as other interface instruction builders).
8+
- **Program-level helpers:** `createLightTokenApproveInstruction`, `createLightTokenRevokeInstruction`
9+
- **Shared options:** approve/revoke accept optional `InterfaceOptions` (same type as `transferInterface`), including `splInterfaceInfos` when you need to supply SPL interface pool accounts explicitly.
10+
11+
### Changed
12+
13+
- **`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).
14+
- **`@lightprotocol/compressed-token/unified`:** approve/revoke APIs accept the same optional `options` and `decimals`; unified entrypoints keep their existing default wrapping behavior (`wrap: true`).
15+
16+
### Fixed
17+
18+
- **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)).
19+
120
## [0.23.0-beta.10]
221

322
### Breaking Changes

js/compressed-token/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@lightprotocol/compressed-token",
3-
"version": "0.23.0-beta.10",
3+
"version": "0.23.0-beta.12",
44
"description": "JS client to interact with the compressed-token program",
55
"sideEffects": false,
66
"main": "dist/cjs/node/index.cjs",

js/compressed-token/rollup.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ const rolls = (fmt, env) => ({
6464
drop_console: false,
6565
drop_debugger: true,
6666
passes: 3,
67-
booleans_as_integers: true,
67+
booleans_as_integers: false,
6868
keep_fargs: false,
6969
keep_fnames: false,
7070
keep_infinity: true,

js/compressed-token/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ export {
6262
createLightTokenThawAccountInstruction,
6363
createLightTokenTransferInstruction,
6464
createLightTokenTransferCheckedInstruction,
65+
createLightTokenApproveInstruction,
66+
createLightTokenRevokeInstruction,
6567
// Types
6668
TokenMetadataInstructionData,
6769
CompressibleConfig,
@@ -80,6 +82,10 @@ export {
8082
createTransferInterfaceInstructions,
8183
createTransferToAccountInterfaceInstructions,
8284
sliceLast,
85+
approveInterface,
86+
createApproveInterfaceInstructions,
87+
revokeInterface,
88+
createRevokeInterfaceInstructions,
8389
wrap,
8490
mintTo as mintToLightToken,
8591
mintToCompressed,
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import {
2+
ConfirmOptions,
3+
PublicKey,
4+
Signer,
5+
TransactionSignature,
6+
} from '@solana/web3.js';
7+
import {
8+
Rpc,
9+
buildAndSignTx,
10+
sendAndConfirmTx,
11+
dedupeSigner,
12+
assertBetaEnabled,
13+
LIGHT_TOKEN_PROGRAM_ID,
14+
} from '@lightprotocol/stateless.js';
15+
import BN from 'bn.js';
16+
import {
17+
createApproveInterfaceInstructions,
18+
createRevokeInterfaceInstructions,
19+
} from '../instructions/approve-interface';
20+
import { getAssociatedTokenAddressInterface } from '../get-associated-token-address-interface';
21+
import { getMintInterface } from '../get-mint-interface';
22+
import { sliceLast } from './slice-last';
23+
import type { InterfaceOptions } from './transfer-interface';
24+
import { TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID } from '@solana/spl-token';
25+
26+
/**
27+
* Approve a delegate for an associated token account.
28+
*
29+
* Supports light-token, SPL, and Token-2022 mints. For light-token mints,
30+
* loads cold accounts if needed before sending the approve instruction.
31+
*
32+
* @remarks For light-token mints, all cold (compressed) balances are loaded
33+
* into the hot ATA, not just the delegation amount. The `amount` parameter
34+
* only controls the delegate's spending limit.
35+
*
36+
* @param rpc RPC connection
37+
* @param payer Fee payer (signer)
38+
* @param tokenAccount ATA address
39+
* @param mint Mint address
40+
* @param delegate Delegate to approve
41+
* @param amount Amount to delegate
42+
* @param owner Owner of the token account (signer)
43+
* @param confirmOptions Optional confirm options
44+
* @param programId Token program ID (default: LIGHT_TOKEN_PROGRAM_ID)
45+
* @param wrap When true and mint is SPL/T22, wrap into light-token then approve
46+
* @returns Transaction signature
47+
*/
48+
export async function approveInterface(
49+
rpc: Rpc,
50+
payer: Signer,
51+
tokenAccount: PublicKey,
52+
mint: PublicKey,
53+
delegate: PublicKey,
54+
amount: number | bigint | BN,
55+
owner: Signer,
56+
confirmOptions?: ConfirmOptions,
57+
programId: PublicKey = LIGHT_TOKEN_PROGRAM_ID,
58+
wrap = false,
59+
options?: InterfaceOptions,
60+
decimals?: number,
61+
): Promise<TransactionSignature> {
62+
assertBetaEnabled();
63+
64+
const expectedAta = getAssociatedTokenAddressInterface(
65+
mint,
66+
owner.publicKey,
67+
false,
68+
programId,
69+
);
70+
if (!tokenAccount.equals(expectedAta)) {
71+
throw new Error(
72+
`Token account mismatch. Expected ${expectedAta.toBase58()}, got ${tokenAccount.toBase58()}`,
73+
);
74+
}
75+
76+
const isSplOrT22 =
77+
programId.equals(TOKEN_PROGRAM_ID) ||
78+
programId.equals(TOKEN_2022_PROGRAM_ID);
79+
const resolvedDecimals =
80+
decimals ??
81+
(isSplOrT22 && !wrap
82+
? 0
83+
: (await getMintInterface(rpc, mint)).mint.decimals);
84+
const batches = await createApproveInterfaceInstructions(
85+
rpc,
86+
payer.publicKey,
87+
mint,
88+
tokenAccount,
89+
delegate,
90+
amount,
91+
owner.publicKey,
92+
resolvedDecimals,
93+
programId,
94+
wrap,
95+
options,
96+
);
97+
98+
const additionalSigners = dedupeSigner(payer, [owner]);
99+
const { rest: loads, last: approveIxs } = sliceLast(batches);
100+
101+
await Promise.all(
102+
loads.map(async ixs => {
103+
const { blockhash } = await rpc.getLatestBlockhash();
104+
const tx = buildAndSignTx(ixs, payer, blockhash, additionalSigners);
105+
return sendAndConfirmTx(rpc, tx, confirmOptions);
106+
}),
107+
);
108+
109+
const { blockhash } = await rpc.getLatestBlockhash();
110+
const tx = buildAndSignTx(approveIxs, payer, blockhash, additionalSigners);
111+
return sendAndConfirmTx(rpc, tx, confirmOptions);
112+
}
113+
114+
/**
115+
* Revoke delegation for an associated token account.
116+
*
117+
* Supports light-token, SPL, and Token-2022 mints. For light-token mints,
118+
* loads cold accounts if needed before sending the revoke instruction.
119+
*
120+
* @remarks For light-token mints, all cold (compressed) balances are loaded
121+
* into the hot ATA before the revoke instruction.
122+
*
123+
* @param rpc RPC connection
124+
* @param payer Fee payer (signer)
125+
* @param tokenAccount ATA address
126+
* @param mint Mint address
127+
* @param owner Owner of the token account (signer)
128+
* @param confirmOptions Optional confirm options
129+
* @param programId Token program ID (default: LIGHT_TOKEN_PROGRAM_ID)
130+
* @param wrap When true and mint is SPL/T22, wrap into light-token then revoke
131+
* @returns Transaction signature
132+
*/
133+
export async function revokeInterface(
134+
rpc: Rpc,
135+
payer: Signer,
136+
tokenAccount: PublicKey,
137+
mint: PublicKey,
138+
owner: Signer,
139+
confirmOptions?: ConfirmOptions,
140+
programId: PublicKey = LIGHT_TOKEN_PROGRAM_ID,
141+
wrap = false,
142+
options?: InterfaceOptions,
143+
decimals?: number,
144+
): Promise<TransactionSignature> {
145+
assertBetaEnabled();
146+
147+
const expectedAta = getAssociatedTokenAddressInterface(
148+
mint,
149+
owner.publicKey,
150+
false,
151+
programId,
152+
);
153+
if (!tokenAccount.equals(expectedAta)) {
154+
throw new Error(
155+
`Token account mismatch. Expected ${expectedAta.toBase58()}, got ${tokenAccount.toBase58()}`,
156+
);
157+
}
158+
159+
const isSplOrT22 =
160+
programId.equals(TOKEN_PROGRAM_ID) ||
161+
programId.equals(TOKEN_2022_PROGRAM_ID);
162+
const resolvedDecimals =
163+
decimals ??
164+
(isSplOrT22 && !wrap
165+
? 0
166+
: (await getMintInterface(rpc, mint)).mint.decimals);
167+
const batches = await createRevokeInterfaceInstructions(
168+
rpc,
169+
payer.publicKey,
170+
mint,
171+
tokenAccount,
172+
owner.publicKey,
173+
resolvedDecimals,
174+
programId,
175+
wrap,
176+
options,
177+
);
178+
179+
const additionalSigners = dedupeSigner(payer, [owner]);
180+
const { rest: loads, last: revokeIxs } = sliceLast(batches);
181+
182+
await Promise.all(
183+
loads.map(async ixs => {
184+
const { blockhash } = await rpc.getLatestBlockhash();
185+
const tx = buildAndSignTx(ixs, payer, blockhash, additionalSigners);
186+
return sendAndConfirmTx(rpc, tx, confirmOptions);
187+
}),
188+
);
189+
190+
const { blockhash } = await rpc.getLatestBlockhash();
191+
const tx = buildAndSignTx(revokeIxs, payer, blockhash, additionalSigners);
192+
return sendAndConfirmTx(rpc, tx, confirmOptions);
193+
}

js/compressed-token/src/v3/actions/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ export * from './transfer-interface';
1212
export * from './wrap';
1313
export * from './unwrap';
1414
export * from './load-ata';
15+
export * from './approve-interface';

js/compressed-token/src/v3/get-account-interface.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,12 @@ function throwIfUnexpectedRpcErrors(
9292
}
9393
}
9494

95-
export type FrozenOperation = 'load' | 'transfer' | 'unwrap';
95+
export type FrozenOperation =
96+
| 'load'
97+
| 'transfer'
98+
| 'unwrap'
99+
| 'approve'
100+
| 'revoke';
96101

97102
export function checkNotFrozen(
98103
iface: AccountInterface,

0 commit comments

Comments
 (0)