Skip to content

Commit 5f375a4

Browse files
eldarikclaude
andcommitted
feat(clients/js): add permissioned confidential burn instruction plan
Extract the shared proof setup/cleanup/args from getConfidentialBurnInstructionPlan into buildConfidentialBurnProofPlan, then add getPermissionedConfidentialBurnInstructionPlan on top of it. The two variants compute identical proofs and differ only in the middle instruction and its signer set: the standard variant carries multiSigners, while the permissioned variant has the mint's configured permissionedBurnAuthority co-sign alongside the account owner. token-2022 rejects the standard ConfidentialBurn instruction on mints carrying the PermissionedBurn extension, so this variant is required for such mints (as tokenized-security mints use). Support the case in the test harness by letting createConfidentialMintBurnMint optionally attach the PermissionedBurn extension, and add an integration test covering a confidential burn on a permissioned mint. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent bfd859c commit 5f375a4

3 files changed

Lines changed: 230 additions & 28 deletions

File tree

clients/js/src/confidentialTransferHelpers.ts

Lines changed: 87 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ import {
8080
getConfigureConfidentialTransferAccountInstruction,
8181
getCreateAssociatedTokenIdempotentInstruction,
8282
getEmptyConfidentialTransferAccountInstruction,
83+
getPermissionedConfidentialBurnInstruction,
8384
getReallocateInstruction,
8485
getUpdateConfidentialMintBurnDecryptableSupplyInstruction,
8586
fetchToken,
@@ -328,6 +329,15 @@ type GetConfidentialBurnInstructionPlanBaseInput = {
328329
export type GetConfidentialBurnInstructionPlanInput = GetConfidentialBurnInstructionPlanBaseInput &
329330
ConfidentialTransferContextStateProofMode;
330331

332+
export type GetPermissionedConfidentialBurnInstructionPlanInput = GetConfidentialBurnInstructionPlanInput & {
333+
/**
334+
* The authority configured on the mint's `PermissionedBurn` extension. It
335+
* must sign every permissioned burn; the token-2022 program rejects the
336+
* standard burn variant when this extension is present.
337+
*/
338+
permissionedBurnAuthority: TransactionSigner;
339+
};
340+
331341
export type GetUpdateConfidentialMintBurnDecryptableSupplyInstructionFromSupplyInput = {
332342
mint: Address;
333343
authority: Address | TransactionSigner;
@@ -1524,18 +1534,18 @@ export async function getConfidentialMintInstructionPlan(
15241534
}
15251535

15261536
/**
1527-
* Returns an instruction plan that confidentially burns `amount` tokens from a
1528-
* token account's available balance, encrypting the amount on-chain and
1529-
* advancing the mint's encrypted pending burn. Symmetric to
1530-
* `getConfidentialMintInstructionPlan`.
1537+
* Computes the three burn proofs (equality, grouped-ciphertext validity, U128
1538+
* range), builds their context-state setup/cleanup plans, and assembles the
1539+
* burn-instruction arguments shared by both the standard and permissioned burn
1540+
* variants. Shared by {@link getConfidentialBurnInstructionPlan} and
1541+
* {@link getPermissionedConfidentialBurnInstructionPlan} — the two differ only
1542+
* in which middle instruction they emit and its signer set, not in the proofs.
15311543
*
15321544
* The amount is grouped-encrypted under `[source, supply, auditor]`; the source
15331545
* handle (index 0) is homomorphically subtracted from the account's available
15341546
* balance, and the auditor handle (index 2) is carried by the instruction.
15351547
*/
1536-
export async function getConfidentialBurnInstructionPlan(
1537-
input: GetConfidentialBurnInstructionPlanInput,
1538-
): Promise<InstructionPlan> {
1548+
async function buildConfidentialBurnProofPlan(input: GetConfidentialBurnInstructionPlanInput) {
15391549
const sourceAccount = getRequiredConfidentialTransferAccountExtension(input.sourceTokenAccount);
15401550
const mintBurnExtension = getRequiredMintExtension(input.mintAccount, 'ConfidentialMintBurn');
15411551
const amount = BigInt(input.amount);
@@ -1638,31 +1648,80 @@ export async function getConfidentialBurnInstructionPlan(
16381648
buildContextStateProofPlan(rangeProofData.toBytes(), verifyBatchedRangeProofU128, input.payer, input.rpc),
16391649
]);
16401650

1641-
return sequentialInstructionPlan([
1642-
parallelInstructionPlan([equalityProofPlan.setup, ciphertextValidityProofPlan.setup, rangeProofPlan.setup]),
1643-
getConfidentialBurnInstruction(
1644-
{
1645-
token: input.token,
1646-
mint: input.mint,
1647-
equalityRecord: equalityProofPlan.address,
1648-
ciphertextValidityRecord: ciphertextValidityProofPlan.address,
1649-
rangeRecord: rangeProofPlan.address,
1650-
authority: input.authority,
1651-
newDecryptableAvailableBalance: input.aesKey.encrypt(remainingBalance).toBytes(),
1652-
burnAmountAuditorCiphertextLo,
1653-
burnAmountAuditorCiphertextHi,
1654-
equalityProofInstructionOffset: 0,
1655-
ciphertextValidityProofInstructionOffset: 0,
1656-
rangeProofInstructionOffset: 0,
1657-
multiSigners: input.multiSigners,
1658-
},
1659-
{ programAddress: getTokenProgramAddress(input.programAddress) },
1660-
),
1661-
parallelInstructionPlan([
1651+
return {
1652+
setup: parallelInstructionPlan([
1653+
equalityProofPlan.setup,
1654+
ciphertextValidityProofPlan.setup,
1655+
rangeProofPlan.setup,
1656+
]),
1657+
cleanup: parallelInstructionPlan([
16621658
equalityProofPlan.cleanup,
16631659
ciphertextValidityProofPlan.cleanup,
16641660
rangeProofPlan.cleanup,
16651661
]),
1662+
// Arguments common to both burn instruction variants. The account owner
1663+
// (`authority`) always signs; the variant-specific signer field
1664+
// (`multiSigners` / `permissionedBurnAuthority`) is added by the caller.
1665+
burnArgs: {
1666+
token: input.token,
1667+
mint: input.mint,
1668+
equalityRecord: equalityProofPlan.address,
1669+
ciphertextValidityRecord: ciphertextValidityProofPlan.address,
1670+
rangeRecord: rangeProofPlan.address,
1671+
authority: input.authority,
1672+
newDecryptableAvailableBalance: input.aesKey.encrypt(remainingBalance).toBytes(),
1673+
burnAmountAuditorCiphertextLo,
1674+
burnAmountAuditorCiphertextHi,
1675+
equalityProofInstructionOffset: 0,
1676+
ciphertextValidityProofInstructionOffset: 0,
1677+
rangeProofInstructionOffset: 0,
1678+
},
1679+
};
1680+
}
1681+
1682+
/**
1683+
* Returns an instruction plan that confidentially burns `amount` tokens from a
1684+
* token account's available balance, encrypting the amount on-chain and
1685+
* advancing the mint's encrypted pending burn. Symmetric to
1686+
* `getConfidentialMintInstructionPlan`.
1687+
*
1688+
* Emits the **standard** `ConfidentialBurn` instruction. For mints carrying the
1689+
* `PermissionedBurn` extension the token-2022 program rejects this variant — use
1690+
* {@link getPermissionedConfidentialBurnInstructionPlan} instead.
1691+
*/
1692+
export async function getConfidentialBurnInstructionPlan(
1693+
input: GetConfidentialBurnInstructionPlanInput,
1694+
): Promise<InstructionPlan> {
1695+
const { setup, cleanup, burnArgs } = await buildConfidentialBurnProofPlan(input);
1696+
return sequentialInstructionPlan([
1697+
setup,
1698+
getConfidentialBurnInstruction(
1699+
{ ...burnArgs, multiSigners: input.multiSigners },
1700+
{ programAddress: getTokenProgramAddress(input.programAddress) },
1701+
),
1702+
cleanup,
1703+
]);
1704+
}
1705+
1706+
/**
1707+
* Like {@link getConfidentialBurnInstructionPlan}, but emits the **permissioned**
1708+
* burn variant, which token-2022 requires for mints carrying the
1709+
* `PermissionedBurn` extension (it rejects the standard variant on such mints
1710+
* with `TokenError::InvalidInstruction`). Identical proofs; the only difference
1711+
* is the mint's configured `permissionedBurnAuthority` co-signs alongside the
1712+
* account owner (`authority`).
1713+
*/
1714+
export async function getPermissionedConfidentialBurnInstructionPlan(
1715+
input: GetPermissionedConfidentialBurnInstructionPlanInput,
1716+
): Promise<InstructionPlan> {
1717+
const { setup, cleanup, burnArgs } = await buildConfidentialBurnProofPlan(input);
1718+
return sequentialInstructionPlan([
1719+
setup,
1720+
getPermissionedConfidentialBurnInstruction(
1721+
{ ...burnArgs, permissionedBurnAuthority: input.permissionedBurnAuthority },
1722+
{ programAddress: getTokenProgramAddress(input.programAddress) },
1723+
),
1724+
cleanup,
16661725
]);
16671726
}
16681727

clients/js/test/_setup.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,12 @@ export const createConfidentialMintBurnMint = async (input: {
291291
payer: TransactionSigner;
292292
decimals?: number;
293293
auditorElgamalPubkey?: Address;
294+
/**
295+
* When provided, the mint additionally carries the `PermissionedBurn`
296+
* extension with this authority — which forces confidential burns to use
297+
* the permissioned variant.
298+
*/
299+
permissionedBurnAuthority?: TransactionSigner;
294300
}): Promise<{
295301
mint: Address;
296302
mintAuthority: TransactionSigner;
@@ -322,6 +328,9 @@ export const createConfidentialMintBurnMint = async (input: {
322328
supplyElgamalPubkey,
323329
pendingBurn: new Uint8Array(64).fill(0),
324330
}),
331+
...(input.permissionedBurnAuthority
332+
? [extension('PermissionedBurn', { authority: some(input.permissionedBurnAuthority.address) })]
333+
: []),
325334
],
326335
})
327336
.sendTransaction();
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { isSome } from '@solana/kit';
2+
import { AeCiphertext } from '@solana/zk-sdk/bundler';
3+
import { expect, it } from 'vitest';
4+
5+
import { Mint, fetchMint, fetchToken, getApplyConfidentialPendingBurnInstruction } from '../../../src';
6+
import {
7+
decryptConfidentialTransferBalance,
8+
getApplyConfidentialPendingBalanceInstructionFromToken,
9+
getConfidentialMintInstructionPlan,
10+
getPermissionedConfidentialBurnInstructionPlan,
11+
getUpdateConfidentialMintBurnDecryptableSupplyInstructionFromSupply,
12+
} from '../../../src/confidential';
13+
import {
14+
createConfidentialMintBurnMint,
15+
createConfidentialTokenAccount,
16+
createValidatorClient,
17+
generateKeyPairSignerWithSol,
18+
} from '../../_setup';
19+
20+
const DECIMALS = 2;
21+
const MINT_AMOUNT = 500n;
22+
const BURN_AMOUNT = 200n;
23+
24+
function getConfidentialMintBurnExtension(mint: Mint) {
25+
if (!isSome(mint.extensions)) {
26+
throw new Error('Mint account is missing extensions.');
27+
}
28+
const extension = mint.extensions.value.find(candidate => candidate.__kind === 'ConfidentialMintBurn');
29+
if (!extension || extension.__kind !== 'ConfidentialMintBurn') {
30+
throw new Error('Mint account is missing the ConfidentialMintBurn extension.');
31+
}
32+
return extension;
33+
}
34+
35+
it('confidentially burns from a mint carrying the PermissionedBurn extension', async () => {
36+
// Given a mint-burn mint that ALSO carries the PermissionedBurn extension
37+
// (as tokenized-security mints do). token-2022 rejects the standard
38+
// confidential burn on such mints, so the permissioned variant is required.
39+
const client = await createValidatorClient();
40+
const payer = client.payer;
41+
const owner = await generateKeyPairSignerWithSol(client);
42+
const permissionedBurnAuthority = await generateKeyPairSignerWithSol(client);
43+
const { mint, mintAuthority, supplyElgamalKeypair, supplyAesKey } = await createConfidentialMintBurnMint({
44+
client,
45+
payer,
46+
decimals: DECIMALS,
47+
permissionedBurnAuthority,
48+
});
49+
const account = await createConfidentialTokenAccount({ client, payer, owner, mint });
50+
51+
// When the authority confidentially mints into the account's pending balance.
52+
const [{ data: destinationTokenAccount }, { data: mintAccount }] = await Promise.all([
53+
fetchToken(client.rpc, account.token),
54+
fetchMint(client.rpc, mint),
55+
]);
56+
await client.sendTransactions(
57+
await getConfidentialMintInstructionPlan({
58+
payer,
59+
rpc: client.rpc,
60+
token: account.token,
61+
mint,
62+
mintAccount,
63+
destinationTokenAccount,
64+
authority: mintAuthority,
65+
amount: MINT_AMOUNT,
66+
supplyElgamalKeypair,
67+
supplyAesKey,
68+
}),
69+
);
70+
71+
// And the owner applies the pending balance so the minted amount is available.
72+
const { data: afterMint } = await fetchToken(client.rpc, account.token);
73+
await client.sendTransaction([
74+
getApplyConfidentialPendingBalanceInstructionFromToken({
75+
token: account.token,
76+
tokenAccount: afterMint,
77+
authority: owner,
78+
elgamalSecretKey: account.elgamalKeypair.secret(),
79+
aesKey: account.aesKey,
80+
}),
81+
]);
82+
83+
// When the owner confidentially burns part of the available balance via the
84+
// permissioned variant, co-signed by the mint's permissioned burn authority.
85+
const [{ data: sourceTokenAccount }, { data: mintForBurn }] = await Promise.all([
86+
fetchToken(client.rpc, account.token),
87+
fetchMint(client.rpc, mint),
88+
]);
89+
await client.sendTransactions(
90+
await getPermissionedConfidentialBurnInstructionPlan({
91+
payer,
92+
rpc: client.rpc,
93+
token: account.token,
94+
mint,
95+
mintAccount: mintForBurn,
96+
sourceTokenAccount,
97+
authority: owner,
98+
permissionedBurnAuthority,
99+
amount: BURN_AMOUNT,
100+
sourceElgamalKeypair: account.elgamalKeypair,
101+
aesKey: account.aesKey,
102+
}),
103+
);
104+
105+
// Then the available balance drops by the burnt amount.
106+
const { data: afterBurn } = await fetchToken(client.rpc, account.token);
107+
expect(
108+
decryptConfidentialTransferBalance({
109+
tokenAccount: afterBurn,
110+
elgamalSecretKey: account.elgamalKeypair.secret(),
111+
aesKey: account.aesKey,
112+
}).availableBalance,
113+
).toBe(MINT_AMOUNT - BURN_AMOUNT);
114+
115+
// Finally the authority applies the mint's pending burn and re-syncs the
116+
// decryptable supply.
117+
await client.sendTransaction([
118+
getApplyConfidentialPendingBurnInstruction({ mint, authority: mintAuthority }),
119+
getUpdateConfidentialMintBurnDecryptableSupplyInstructionFromSupply({
120+
mint,
121+
authority: mintAuthority,
122+
supplyAesKey,
123+
supply: MINT_AMOUNT - BURN_AMOUNT,
124+
}),
125+
]);
126+
127+
const { data: finalMint } = await fetchMint(client.rpc, mint);
128+
const mintBurnExtension = getConfidentialMintBurnExtension(finalMint);
129+
const decryptableSupplyCiphertext = AeCiphertext.fromBytes(new Uint8Array(mintBurnExtension.decryptableSupply));
130+
if (!decryptableSupplyCiphertext) {
131+
throw new Error('Failed to decode the decryptable supply ciphertext.');
132+
}
133+
expect(supplyAesKey.decrypt(decryptableSupplyCiphertext)).toBe(MINT_AMOUNT - BURN_AMOUNT);
134+
});

0 commit comments

Comments
 (0)