diff --git a/clients/js/src/confidentialTransferArithmetic.ts b/clients/js/src/confidentialTransferArithmetic.ts index 3234e89a3..a6a4ad22d 100644 --- a/clients/js/src/confidentialTransferArithmetic.ts +++ b/clients/js/src/confidentialTransferArithmetic.ts @@ -51,6 +51,15 @@ export function extractCiphertextFromGroupedBytes(groupedCiphertext: ReadonlyUin return ciphertext; } +function addCiphertexts(left: ReadonlyUint8Array, right: ReadonlyUint8Array) { + const leftPoints = ciphertextToPoints(left); + const rightPoints = ciphertextToPoints(right); + return pointsToCiphertext( + leftPoints.commitment.add(rightPoints.commitment), + leftPoints.handle.add(rightPoints.handle), + ); +} + function subtractCiphertexts(left: ReadonlyUint8Array, right: ReadonlyUint8Array) { const leftPoints = ciphertextToPoints(left); const rightPoints = ciphertextToPoints(right); @@ -70,6 +79,20 @@ function combineLoHiCiphertexts(ciphertextLo: ReadonlyUint8Array, ciphertextHi: ); } +/** + * Combines lo/hi ciphertext halves (hi << bitLength + lo) and adds the result + * to `left`. Used to compute the new encrypted supply ciphertext after a + * confidential mint. + */ +export function addWithLoHiCiphertexts( + left: ReadonlyUint8Array, + ciphertextLo: ReadonlyUint8Array, + ciphertextHi: ReadonlyUint8Array, + bitLength: bigint, +) { + return addCiphertexts(left, combineLoHiCiphertexts(ciphertextLo, ciphertextHi, bitLength)); +} + /** * Combines lo/hi ciphertext halves (hi << bitLength + lo) and subtracts the * result from `left`. Used to compute the new available-balance ciphertext diff --git a/clients/js/src/confidentialTransferHelpers.ts b/clients/js/src/confidentialTransferHelpers.ts index 38aab5480..1cb91dc8c 100644 --- a/clients/js/src/confidentialTransferHelpers.ts +++ b/clients/js/src/confidentialTransferHelpers.ts @@ -58,6 +58,7 @@ import { } from '@solana/zk-sdk/bundler'; import { + addWithLoHiCiphertexts, extractCiphertextFromGroupedBytes, subtractAmountFromCiphertext, subtractWithLoHiCiphertexts, @@ -71,13 +72,17 @@ import { fetchMint, findAssociatedTokenPda, getApplyConfidentialPendingBalanceInstruction, + getConfidentialBurnInstruction, + getConfidentialMintInstruction, getConfidentialTransferInstruction, getConfidentialTransferWithFeeInstruction, getConfidentialWithdrawInstruction, getConfigureConfidentialTransferAccountInstruction, getCreateAssociatedTokenIdempotentInstruction, getEmptyConfidentialTransferAccountInstruction, + getPermissionedConfidentialBurnInstruction, getReallocateInstruction, + getUpdateConfidentialMintBurnDecryptableSupplyInstruction, fetchToken, } from './generated'; @@ -89,6 +94,10 @@ const FEE_AMOUNT_LO_BIT_LENGTH = 16n; const FEE_AMOUNT_HI_BIT_LENGTH = 32n; const REMAINING_BALANCE_BIT_LENGTH = 64; const RANGE_PROOF_PADDING_BIT_LENGTH = 16; +// A mint/burn amount is range-proven as a 16-bit low half + 32-bit high half, so +// it must fit in 48 bits (matching the Rust reference); a larger amount would +// otherwise silently produce a range proof the on-chain verifier rejects. +const MAX_MINT_BURN_AMOUNT = (1n << (TRANSFER_AMOUNT_LO_BIT_LENGTH + TRANSFER_AMOUNT_HI_BIT_LENGTH)) - 1n; const MAX_FEE_BASIS_POINTS_SUB_ONE = 9_999n; const MAX_FEE_BASIS_POINTS = 10_000n; const DELTA_BIT_LENGTH = 16; @@ -100,7 +109,6 @@ const PEDERSEN_ARITHMETIC_ERROR = 'Confidential transfer with fee requires @solana/zk-sdk Pedersen commitment and opening arithmetic.'; type ConfidentialTransferAccountExtension = Extract; -type ConfidentialTransferMintExtension = Extract; type TransferFeeConfigExtension = Extract; type TransferFee = TransferFeeConfigExtension['olderTransferFee']; type PedersenCommitmentWithArithmetic = PedersenCommitment & { @@ -265,6 +273,70 @@ type RecordBackedContextStateProofMode = Omit; + programAddress?: Address; +}; + +type GetConfidentialMintInstructionPlanBaseInput = GetConfidentialMintBurnInstructionPlanBaseInput & { + /** Decoded destination token account, read for its ElGamal public key. */ + destinationTokenAccount: Token; + /** The supply ElGamal keypair (backs the equality proof; encrypts the new supply). */ + supplyElgamalKeypair: ElGamalKeypair; + /** The supply AES key (decrypts the current supply and encrypts the new decryptable supply). */ + supplyAesKey: AeKey; +}; + +export type GetConfidentialMintInstructionPlanInput = GetConfidentialMintInstructionPlanBaseInput & + ConfidentialTransferContextStateProofMode; + +type GetConfidentialBurnInstructionPlanBaseInput = GetConfidentialMintBurnInstructionPlanBaseInput & { + /** Decoded source token account, read for its available-balance ciphertext and ElGamal public key. */ + sourceTokenAccount: Token; + /** The source account's ElGamal keypair (backs the equality proof). */ + sourceElgamalKeypair: ElGamalKeypair; + /** The source account's AES key (decrypts and re-encrypts the available balance). */ + aesKey: AeKey; +}; + +export type GetConfidentialBurnInstructionPlanInput = GetConfidentialBurnInstructionPlanBaseInput & + ConfidentialTransferContextStateProofMode; + +export type GetPermissionedConfidentialBurnInstructionPlanInput = GetConfidentialBurnInstructionPlanInput & { + /** + * The authority configured on the mint's `PermissionedBurn` extension. It + * must sign every permissioned burn; the token-2022 program rejects the + * standard burn variant when this extension is present. + */ + permissionedBurnAuthority: TransactionSigner; +}; + +export type GetUpdateConfidentialMintBurnDecryptableSupplyInstructionFromSupplyInput = { + mint: Address; + authority: Address | TransactionSigner; + /** The supply AES key that encrypts the decryptable supply. */ + supplyAesKey: AeKey; + /** The true current supply to encode into the decryptable supply. */ + supply: number | bigint; + multiSigners?: Array; + programAddress?: Address; +}; + function getTokenProgramAddress(programAddress?: Address) { return programAddress ?? TOKEN_2022_PROGRAM_ADDRESS; } @@ -292,21 +364,6 @@ function getRequiredConfidentialTransferAccountExtension(tokenAccount: Token): C return extension; } -function getRequiredConfidentialTransferMintExtension(mint: Mint): ConfidentialTransferMintExtension { - if (!isSome(mint.extensions)) { - throw new Error('Mint account is missing extensions.'); - } - - const extension = mint.extensions.value.find(candidate => candidate.__kind === 'ConfidentialTransferMint') as - | ConfidentialTransferMintExtension - | undefined; - if (!extension) { - throw new Error('Mint account is missing the ConfidentialTransferMint extension.'); - } - - return extension; -} - function getRequiredMintExtension( mintAccount: Mint, kind: TKind, @@ -349,13 +406,25 @@ function getDefaultAuditorElGamalPubkey() { return ElGamalPubkey.fromBytes(new Uint8Array(32)); } -async function getAuditorElGamalPubkey(input: GetConfidentialTransferInstructionPlanInput) { +/** + * Resolves the auditor ElGamal public key for any confidential instruction that + * carries auditor ciphertexts (transfer, transfer-with-fee, mint and burn): from + * an explicit override, else from the mint's `ConfidentialTransferMint` + * extension, fetching the mint only when the caller did not supply it. Falls + * back to the zero auditor key when the mint configures no auditor. + */ +async function getAuditorElGamalPubkey(input: { + auditorElgamalPubkey?: Address; + mintAccount?: Mint; + mint: Address; + rpc: Rpc; +}) { if (input.auditorElgamalPubkey) { return getElGamalPubkeyFromAddress(input.auditorElgamalPubkey); } const mint = input.mintAccount ?? (await fetchMint(input.rpc, input.mint)).data; - const extension = getRequiredConfidentialTransferMintExtension(mint); + const extension = getRequiredMintExtension(mint, 'ConfidentialTransferMint'); return isSome(extension.auditorElgamalPubkey) ? getElGamalPubkeyFromAddress(extension.auditorElgamalPubkey.value) : getDefaultAuditorElGamalPubkey(); @@ -1294,3 +1363,393 @@ export async function getConfidentialTransferWithFeeInstructionPlan( ]), ]); } + +function assertMintBurnAmount(amount: bigint, label: 'Mint' | 'Burn'): void { + if (amount <= 0n) { + throw new Error(`${label} amount must be positive.`); + } + if (amount > MAX_MINT_BURN_AMOUNT) { + throw new Error( + `${label} amount exceeds the maximum confidential mint/burn amount (2^48 - 1 = ${MAX_MINT_BURN_AMOUNT}).`, + ); + } +} + +/** + * Returns an instruction plan that confidentially mints `amount` tokens into a + * token account's pending balance, encrypting the amount on-chain and advancing + * the mint's encrypted supply. Splits the amount into lo/hi halves and verifies + * the three required proofs (equality, grouped-ciphertext validity, batched + * range) via context-state accounts. + * + * The amount is grouped-encrypted under `[destination, supply, auditor]`; the + * supply handle (index 1) is homomorphically added to the mint's current supply + * ciphertext, and the auditor handle (index 2) is carried by the instruction. + * + * **The mint's two supply representations must be in sync.** The equality proof + * asserts that the mint's `confidentialSupply` ElGamal ciphertext plus `amount` + * equals `AES_decrypt(decryptableSupply) + amount`. If the two have drifted — + * e.g. after `ApplyPendingBurn`, which advances the ElGamal supply but cannot + * re-encrypt the AES form — the proof is rejected on-chain. Re-sync first with + * {@link getUpdateConfidentialMintBurnDecryptableSupplyInstructionFromSupply}. + */ +export async function getConfidentialMintInstructionPlan( + input: GetConfidentialMintInstructionPlanInput, +): Promise { + const mintBurnExtension = getRequiredMintExtension(input.mintAccount, 'ConfidentialMintBurn'); + const amount = BigInt(input.amount); + assertMintBurnAmount(amount, 'Mint'); + + const currentSupply = input.supplyAesKey.decrypt(parseAeCiphertext(mintBurnExtension.decryptableSupply)); + const newSupply = currentSupply + amount; + assertU64Amount(newSupply, 'New supply after mint'); + + const [amountLo, amountHi] = splitAmount(amount, TRANSFER_AMOUNT_LO_BIT_LENGTH); + const destinationPubkey = getElGamalPubkeyFromAddress( + getRequiredConfidentialTransferAccountExtension(input.destinationTokenAccount).elgamalPubkey, + ); + const supplyPubkey = input.supplyElgamalKeypair.pubkey(); + const auditorPubkey = await getAuditorElGamalPubkey(input); + + const openingLo = new PedersenOpening(); + const openingHi = new PedersenOpening(); + // Grouped handle order for MINT: [destination, supply, auditor]. + const groupedCiphertextLo = GroupedElGamalCiphertext3Handles.encryptWith( + destinationPubkey, + supplyPubkey, + auditorPubkey, + amountLo, + openingLo, + ); + const groupedCiphertextHi = GroupedElGamalCiphertext3Handles.encryptWith( + destinationPubkey, + supplyPubkey, + auditorPubkey, + amountHi, + openingHi, + ); + + const groupedCiphertextLoBytes = groupedCiphertextLo.toBytes(); + const groupedCiphertextHiBytes = groupedCiphertextHi.toBytes(); + // New supply ciphertext = current supply + combine_lo_hi(supply handle, index 1). + const supplyCiphertextLo = extractCiphertextFromGroupedBytes(groupedCiphertextLoBytes, 1); + const supplyCiphertextHi = extractCiphertextFromGroupedBytes(groupedCiphertextHiBytes, 1); + const mintAmountAuditorCiphertextLo = extractCiphertextFromGroupedBytes(groupedCiphertextLoBytes, 2); + const mintAmountAuditorCiphertextHi = extractCiphertextFromGroupedBytes(groupedCiphertextHiBytes, 2); + + const newSupplyCiphertext = parseElGamalCiphertext( + addWithLoHiCiphertexts( + mintBurnExtension.confidentialSupply, + supplyCiphertextLo, + supplyCiphertextHi, + TRANSFER_AMOUNT_LO_BIT_LENGTH, + ), + ); + + const newSupplyOpening = new PedersenOpening(); + const newSupplyCommitment = PedersenCommitment.from(newSupply, newSupplyOpening); + + const equalityProofData = new CiphertextCommitmentEqualityProofData( + input.supplyElgamalKeypair, + newSupplyCiphertext, + newSupplyCommitment, + newSupplyOpening, + newSupply, + ); + const ciphertextValidityProofData = new BatchedGroupedCiphertext3HandlesValidityProofData( + destinationPubkey, + supplyPubkey, + auditorPubkey, + groupedCiphertextLo, + groupedCiphertextHi, + amountLo, + amountHi, + openingLo, + openingHi, + ); + + const commitmentLo = PedersenCommitment.fromBytes(groupedCiphertextLoBytes.slice(0, 32)); + const commitmentHi = PedersenCommitment.fromBytes(groupedCiphertextHiBytes.slice(0, 32)); + const paddingOpening = new PedersenOpening(); + const paddingCommitment = PedersenCommitment.from(0n, paddingOpening); + const rangeProofData = new BatchedRangeProofU128Data( + [newSupplyCommitment, commitmentLo, commitmentHi, paddingCommitment], + new BigUint64Array([newSupply, amountLo, amountHi, 0n]), + Uint8Array.from([ + REMAINING_BALANCE_BIT_LENGTH, + Number(TRANSFER_AMOUNT_LO_BIT_LENGTH), + Number(TRANSFER_AMOUNT_HI_BIT_LENGTH), + RANGE_PROOF_PADDING_BIT_LENGTH, + ]), + [newSupplyOpening, openingLo, openingHi, paddingOpening], + ); + + const [equalityProofPlan, ciphertextValidityProofPlan, rangeProofPlan] = await Promise.all([ + buildContextStateProofPlan( + equalityProofData.toBytes(), + verifyCiphertextCommitmentEquality, + input.payer, + input.rpc, + ), + buildContextStateProofPlan( + ciphertextValidityProofData.toBytes(), + verifyBatchedGroupedCiphertext3HandlesValidity, + input.payer, + input.rpc, + ), + buildContextStateProofPlan(rangeProofData.toBytes(), verifyBatchedRangeProofU128, input.payer, input.rpc), + ]); + + return sequentialInstructionPlan([ + parallelInstructionPlan([equalityProofPlan.setup, ciphertextValidityProofPlan.setup, rangeProofPlan.setup]), + getConfidentialMintInstruction( + { + token: input.token, + mint: input.mint, + equalityRecord: equalityProofPlan.address, + ciphertextValidityRecord: ciphertextValidityProofPlan.address, + rangeRecord: rangeProofPlan.address, + authority: input.authority, + newDecryptableSupply: input.supplyAesKey.encrypt(newSupply).toBytes(), + mintAmountAuditorCiphertextLo, + mintAmountAuditorCiphertextHi, + equalityProofInstructionOffset: 0, + ciphertextValidityProofInstructionOffset: 0, + rangeProofInstructionOffset: 0, + multiSigners: input.multiSigners, + }, + { programAddress: getTokenProgramAddress(input.programAddress) }, + ), + parallelInstructionPlan([ + equalityProofPlan.cleanup, + ciphertextValidityProofPlan.cleanup, + rangeProofPlan.cleanup, + ]), + ]); +} + +/** + * Computes the three burn proofs (equality, grouped-ciphertext validity, U128 + * range), builds their context-state setup/cleanup plans, and assembles the + * burn-instruction arguments shared by both the standard and permissioned burn + * variants. Shared by {@link getConfidentialBurnInstructionPlan} and + * {@link getPermissionedConfidentialBurnInstructionPlan} — the two differ only + * in which middle instruction they emit and its signer set, not in the proofs. + * + * The amount is grouped-encrypted under `[source, supply, auditor]`; the source + * handle (index 0) is homomorphically subtracted from the account's available + * balance, and the auditor handle (index 2) is carried by the instruction. + */ +async function buildConfidentialBurnProofPlan(input: GetConfidentialBurnInstructionPlanInput) { + const sourceAccount = getRequiredConfidentialTransferAccountExtension(input.sourceTokenAccount); + const mintBurnExtension = getRequiredMintExtension(input.mintAccount, 'ConfidentialMintBurn'); + const amount = BigInt(input.amount); + assertMintBurnAmount(amount, 'Burn'); + + const currentAvailableBalance = decryptAvailableBalance(sourceAccount, input.aesKey); + const remainingBalance = computeNewAvailableBalance(currentAvailableBalance, amount); + + const [amountLo, amountHi] = splitAmount(amount, TRANSFER_AMOUNT_LO_BIT_LENGTH); + const sourcePubkey = input.sourceElgamalKeypair.pubkey(); + const supplyPubkey = getElGamalPubkeyFromAddress(mintBurnExtension.supplyElgamalPubkey); + const auditorPubkey = await getAuditorElGamalPubkey(input); + + const openingLo = new PedersenOpening(); + const openingHi = new PedersenOpening(); + // Grouped handle order for BURN: [source, supply, auditor]. + const groupedCiphertextLo = GroupedElGamalCiphertext3Handles.encryptWith( + sourcePubkey, + supplyPubkey, + auditorPubkey, + amountLo, + openingLo, + ); + const groupedCiphertextHi = GroupedElGamalCiphertext3Handles.encryptWith( + sourcePubkey, + supplyPubkey, + auditorPubkey, + amountHi, + openingHi, + ); + + const groupedCiphertextLoBytes = groupedCiphertextLo.toBytes(); + const groupedCiphertextHiBytes = groupedCiphertextHi.toBytes(); + // New available balance ciphertext = current balance − combine_lo_hi(source handle, index 0). + const sourceCiphertextLo = extractCiphertextFromGroupedBytes(groupedCiphertextLoBytes, 0); + const sourceCiphertextHi = extractCiphertextFromGroupedBytes(groupedCiphertextHiBytes, 0); + const burnAmountAuditorCiphertextLo = extractCiphertextFromGroupedBytes(groupedCiphertextLoBytes, 2); + const burnAmountAuditorCiphertextHi = extractCiphertextFromGroupedBytes(groupedCiphertextHiBytes, 2); + + const newAvailableBalanceCiphertext = parseElGamalCiphertext( + subtractWithLoHiCiphertexts( + sourceAccount.availableBalance, + sourceCiphertextLo, + sourceCiphertextHi, + TRANSFER_AMOUNT_LO_BIT_LENGTH, + ), + ); + + const newAvailableBalanceOpening = new PedersenOpening(); + const newAvailableBalanceCommitment = PedersenCommitment.from(remainingBalance, newAvailableBalanceOpening); + + const equalityProofData = new CiphertextCommitmentEqualityProofData( + input.sourceElgamalKeypair, + newAvailableBalanceCiphertext, + newAvailableBalanceCommitment, + newAvailableBalanceOpening, + remainingBalance, + ); + const ciphertextValidityProofData = new BatchedGroupedCiphertext3HandlesValidityProofData( + sourcePubkey, + supplyPubkey, + auditorPubkey, + groupedCiphertextLo, + groupedCiphertextHi, + amountLo, + amountHi, + openingLo, + openingHi, + ); + + const commitmentLo = PedersenCommitment.fromBytes(groupedCiphertextLoBytes.slice(0, 32)); + const commitmentHi = PedersenCommitment.fromBytes(groupedCiphertextHiBytes.slice(0, 32)); + const paddingOpening = new PedersenOpening(); + const paddingCommitment = PedersenCommitment.from(0n, paddingOpening); + const rangeProofData = new BatchedRangeProofU128Data( + [newAvailableBalanceCommitment, commitmentLo, commitmentHi, paddingCommitment], + new BigUint64Array([remainingBalance, amountLo, amountHi, 0n]), + Uint8Array.from([ + REMAINING_BALANCE_BIT_LENGTH, + Number(TRANSFER_AMOUNT_LO_BIT_LENGTH), + Number(TRANSFER_AMOUNT_HI_BIT_LENGTH), + RANGE_PROOF_PADDING_BIT_LENGTH, + ]), + [newAvailableBalanceOpening, openingLo, openingHi, paddingOpening], + ); + + const [equalityProofPlan, ciphertextValidityProofPlan, rangeProofPlan] = await Promise.all([ + buildContextStateProofPlan( + equalityProofData.toBytes(), + verifyCiphertextCommitmentEquality, + input.payer, + input.rpc, + ), + buildContextStateProofPlan( + ciphertextValidityProofData.toBytes(), + verifyBatchedGroupedCiphertext3HandlesValidity, + input.payer, + input.rpc, + ), + buildContextStateProofPlan(rangeProofData.toBytes(), verifyBatchedRangeProofU128, input.payer, input.rpc), + ]); + + return { + setup: parallelInstructionPlan([ + equalityProofPlan.setup, + ciphertextValidityProofPlan.setup, + rangeProofPlan.setup, + ]), + cleanup: parallelInstructionPlan([ + equalityProofPlan.cleanup, + ciphertextValidityProofPlan.cleanup, + rangeProofPlan.cleanup, + ]), + // Arguments common to both burn instruction variants. The account owner + // (`authority`) always signs; the variant-specific signer field + // (`multiSigners` / `permissionedBurnAuthority`) is added by the caller. + burnArgs: { + token: input.token, + mint: input.mint, + equalityRecord: equalityProofPlan.address, + ciphertextValidityRecord: ciphertextValidityProofPlan.address, + rangeRecord: rangeProofPlan.address, + authority: input.authority, + newDecryptableAvailableBalance: input.aesKey.encrypt(remainingBalance).toBytes(), + burnAmountAuditorCiphertextLo, + burnAmountAuditorCiphertextHi, + equalityProofInstructionOffset: 0, + ciphertextValidityProofInstructionOffset: 0, + rangeProofInstructionOffset: 0, + }, + }; +} + +/** + * Returns an instruction plan that confidentially burns `amount` tokens from a + * token account's available balance, encrypting the amount on-chain and + * advancing the mint's encrypted pending burn. Symmetric to + * `getConfidentialMintInstructionPlan`. + * + * Emits the **standard** `ConfidentialBurn` instruction. For mints carrying the + * `PermissionedBurn` extension the token-2022 program rejects this variant — use + * {@link getPermissionedConfidentialBurnInstructionPlan} instead. + */ +export async function getConfidentialBurnInstructionPlan( + input: GetConfidentialBurnInstructionPlanInput, +): Promise { + const { setup, cleanup, burnArgs } = await buildConfidentialBurnProofPlan(input); + return sequentialInstructionPlan([ + setup, + getConfidentialBurnInstruction( + { ...burnArgs, multiSigners: input.multiSigners }, + { programAddress: getTokenProgramAddress(input.programAddress) }, + ), + cleanup, + ]); +} + +/** + * Like {@link getConfidentialBurnInstructionPlan}, but emits the **permissioned** + * burn variant, which token-2022 requires for mints carrying the + * `PermissionedBurn` extension (it rejects the standard variant on such mints + * with `TokenError::InvalidInstruction`). Identical proofs; the only difference + * is the mint's configured `permissionedBurnAuthority` co-signs alongside the + * account owner (`authority`). + */ +export async function getPermissionedConfidentialBurnInstructionPlan( + input: GetPermissionedConfidentialBurnInstructionPlanInput, +): Promise { + const { setup, cleanup, burnArgs } = await buildConfidentialBurnProofPlan(input); + return sequentialInstructionPlan([ + setup, + getPermissionedConfidentialBurnInstruction( + { + ...burnArgs, + multiSigners: input.multiSigners, + permissionedBurnAuthority: input.permissionedBurnAuthority, + }, + { programAddress: getTokenProgramAddress(input.programAddress) }, + ), + cleanup, + ]); +} + +/** + * Re-encrypts and updates the mint's decryptable supply to `supply` under the + * supply AES key. Signed by the mint authority. No proof required — returns a + * single instruction. + * + * The confidential supply is maintained on-chain both as an ElGamal ciphertext + * (updated homomorphically by mint/burn) and as a cheap-to-decrypt AES + * "decryptable supply". The two can drift — e.g. `ApplyPendingBurn` advances the + * ElGamal supply but cannot re-encrypt the AES form — so the authority uses this + * to re-assert the decryptable supply to the true supply it tracks. + */ +export function getUpdateConfidentialMintBurnDecryptableSupplyInstructionFromSupply( + input: GetUpdateConfidentialMintBurnDecryptableSupplyInstructionFromSupplyInput, +): Instruction { + const supply = BigInt(input.supply); + // Supply is a u64 on-chain; reject out-of-range values before handing them + // to the WASM AES encrypt (which would otherwise fail opaquely or wrap). + assertU64Amount(supply, 'Supply'); + + return getUpdateConfidentialMintBurnDecryptableSupplyInstruction( + { + mint: input.mint, + authority: input.authority, + newDecryptableSupply: input.supplyAesKey.encrypt(supply).toBytes(), + multiSigners: input.multiSigners, + }, + { programAddress: getTokenProgramAddress(input.programAddress) }, + ); +} diff --git a/clients/js/test/_setup.ts b/clients/js/test/_setup.ts index c396df6a6..92de725c6 100644 --- a/clients/js/test/_setup.ts +++ b/clients/js/test/_setup.ts @@ -1,6 +1,6 @@ import path from 'node:path'; -import { systemProgram } from '@solana-program/system'; +import { getCreateAccountInstruction, systemProgram } from '@solana-program/system'; import { Address, Transaction, @@ -14,6 +14,7 @@ import { createTransactionPlanner, extendClient, generateKeyPairSigner, + getAddressDecoder, isSome, lamports, none, @@ -29,20 +30,24 @@ import { planAndSendTransactions } from '@solana/kit-plugin-instruction-plan'; import { TransactionMetadata, litesvm } from '@solana/kit-plugin-litesvm'; import { solanaLocalRpc } from '@solana/kit-plugin-rpc'; import { airdropSigner, generatedSigner } from '@solana/kit-plugin-signer'; -import { AeKey, ElGamalKeypair } from '@solana/zk-sdk/bundler'; +import { AeCiphertext, AeKey, ElGamalKeypair } from '@solana/zk-sdk/bundler'; import { Extension, ExtensionArgs, + Mint, TOKEN_2022_PROGRAM_ADDRESS, Token, associatedTokenProgram, extension, + fetchMint, fetchToken, findAssociatedTokenPda, getConfidentialDepositInstruction, getCreateTokenInstructionPlan, + getInitializeMultisig2Instruction, getMintToInstruction, + getMultisigSize, token2022Program, } from '../src'; import { @@ -281,6 +286,120 @@ export const createConfidentialMint = async (input: { return { mint: mint.address, mintAuthority }; }; +// Creates a mint configured for confidential mint/burn: it carries both the +// `ConfidentialTransferMint` extension (auto-approve, required by mint/burn) and +// the `ConfidentialMintBurn` extension with a fresh supply ElGamal keypair and +// AES key and a zero-initialized encrypted/decryptable supply. +export const createConfidentialMintBurnMint = async (input: { + client: Client; + payer: TransactionSigner; + decimals?: number; + auditorElgamalPubkey?: Address; + /** + * When provided, the mint additionally carries the `PermissionedBurn` + * extension with this authority — which forces confidential burns to use + * the permissioned variant. + */ + permissionedBurnAuthority?: TransactionSigner; +}): Promise<{ + mint: Address; + mintAuthority: TransactionSigner; + supplyElgamalKeypair: ElGamalKeypair; + supplyAesKey: AeKey; +}> => { + const [mintAuthority, mint] = await Promise.all([generateKeyPairSigner(), generateKeyPairSigner()]); + const supplyElgamalKeypair = new ElGamalKeypair(); + const supplyAesKey = new AeKey(); + const supplyElgamalPubkey = getAddressDecoder().decode(new Uint8Array(supplyElgamalKeypair.pubkey().toBytes())); + + await input.client.token2022.instructions + .createMint({ + payer: input.payer, + newMint: mint, + decimals: input.decimals ?? 2, + mintAuthority, + extensions: [ + extension('ConfidentialTransferMint', { + authority: some(mintAuthority.address), + autoApproveNewAccounts: true, + auditorElgamalPubkey: input.auditorElgamalPubkey ? some(input.auditorElgamalPubkey) : none(), + }), + extension('ConfidentialMintBurn', { + // The confidential supply and pending burn are zero-initialized; + // the decryptable supply is an AES encryption of zero. + confidentialSupply: new Uint8Array(64).fill(0), + decryptableSupply: supplyAesKey.encrypt(0n).toBytes(), + supplyElgamalPubkey, + pendingBurn: new Uint8Array(64).fill(0), + }), + ...(input.permissionedBurnAuthority + ? [extension('PermissionedBurn', { authority: some(input.permissionedBurnAuthority.address) })] + : []), + ], + }) + .sendTransaction(); + return { mint: mint.address, mintAuthority, supplyElgamalKeypair, supplyAesKey }; +}; + +// Returns the `ConfidentialMintBurn` extension of a decoded mint, throwing if the +// mint does not carry it. +export const getConfidentialMintBurnExtension = (mint: Mint) => { + if (!isSome(mint.extensions)) { + throw new Error('Mint account is missing extensions.'); + } + const extension = mint.extensions.value.find(candidate => candidate.__kind === 'ConfidentialMintBurn'); + if (!extension || extension.__kind !== 'ConfidentialMintBurn') { + throw new Error('Mint account is missing the ConfidentialMintBurn extension.'); + } + return extension; +}; + +// Fetches a mint-burn mint and decrypts its AES `decryptableSupply` — the +// cheap-to-decrypt supply representation the mint authority re-asserts via +// `UpdateDecryptableSupply`. +export const fetchDecryptableSupply = async (input: { + client: Client; + mint: Address; + supplyAesKey: AeKey; +}): Promise => { + const { data: mintAccount } = await fetchMint(input.client.rpc, input.mint); + const mintBurnExtension = getConfidentialMintBurnExtension(mintAccount); + const ciphertext = AeCiphertext.fromBytes(new Uint8Array(mintBurnExtension.decryptableSupply)); + if (!ciphertext) { + throw new Error('Failed to decode the decryptable supply ciphertext.'); + } + return input.supplyAesKey.decrypt(ciphertext); +}; + +// Creates an M-of-N Token-2022 multisig account owned by `signers`. Instructions +// authorized by the multisig pass its address as the authority and the signing +// members as `multiSigners`. +export const createMultisig = async (input: { + client: Client; + payer: TransactionSigner; + signers: Array; + m?: number; +}): Promise
=> { + const multisig = await generateKeyPairSigner(); + const space = getMultisigSize(); + const rent = await input.client.rpc.getMinimumBalanceForRentExemption(BigInt(space)).send(); + await input.client.sendTransaction([ + getCreateAccountInstruction({ + payer: input.payer, + newAccount: multisig, + lamports: rent, + space, + programAddress: TOKEN_2022_PROGRAM_ADDRESS, + }), + getInitializeMultisig2Instruction({ + multisig: multisig.address, + m: input.m ?? input.signers.length, + signers: input.signers.map(signer => signer.address), + }), + ]); + return multisig.address; +}; + export type ConfidentialTokenAccount = { token: Address; elgamalKeypair: ElGamalKeypair; @@ -288,17 +407,20 @@ export type ConfidentialTokenAccount = { }; // Creates and configures an associated token account for confidential transfers. +// The owner may be a multisig address, in which case its signing members must be +// passed as `multiSigners`. export const createConfidentialTokenAccount = async (input: { client: Client; payer: TransactionSigner; - owner: TransactionSigner; + owner: Address | TransactionSigner; mint: Address; includeConfidentialTransferFeeAmount?: boolean; + multiSigners?: Array; }): Promise => { const elgamalKeypair = new ElGamalKeypair(); const aesKey = new AeKey(); const [token] = await findAssociatedTokenPda({ - owner: input.owner.address, + owner: typeof input.owner === 'string' ? input.owner : input.owner.address, tokenProgram: TOKEN_2022_PROGRAM_ADDRESS, mint: input.mint, }); @@ -311,6 +433,7 @@ export const createConfidentialTokenAccount = async (input: { elgamalKeypair, aesKey, includeConfidentialTransferFeeAmount: input.includeConfidentialTransferFeeAmount, + multiSigners: input.multiSigners, }), ); return { token, elgamalKeypair, aesKey }; diff --git a/clients/js/test/confidentialTransferArithmetic.test.ts b/clients/js/test/confidentialTransferArithmetic.test.ts new file mode 100644 index 000000000..e8c47fce4 --- /dev/null +++ b/clients/js/test/confidentialTransferArithmetic.test.ts @@ -0,0 +1,104 @@ +import { + ElGamalCiphertext, + ElGamalKeypair, + GroupedElGamalCiphertext3Handles, + PedersenOpening, +} from '@solana/zk-sdk/node'; +import { expect, it } from 'vitest'; + +import { + addWithLoHiCiphertexts, + extractCiphertextFromGroupedBytes, + subtractWithLoHiCiphertexts, +} from '../src/confidentialTransferArithmetic'; + +const LO_BIT_LENGTH = 16n; + +function splitAmount(amount: bigint, bitLength: bigint): [bigint, bigint] { + const mask = (1n << bitLength) - 1n; + return [amount & mask, amount >> bitLength]; +} + +// Builds the raw bytes of a grouped 3-handle ciphertext of `amount` under +// `[pubkey0, pubkey1, pubkey2]`, the layout the mint/burn helpers encrypt into. +function groupedCiphertextBytes( + pubkey0: ReturnType, + pubkey1: ReturnType, + pubkey2: ReturnType, + amount: bigint, +): Uint8Array { + return GroupedElGamalCiphertext3Handles.encryptWith( + pubkey0, + pubkey1, + pubkey2, + amount, + new PedersenOpening(), + ).toBytes(); +} + +function decrypt(keypair: ElGamalKeypair, ciphertextBytes: Uint8Array): bigint { + const ciphertext = ElGamalCiphertext.fromBytes(new Uint8Array(ciphertextBytes)); + if (!ciphertext) { + throw new Error('Failed to decode ciphertext.'); + } + return keypair.secret().decrypt(ciphertext); +} + +// Exercises the exact arithmetic `getConfidentialMintInstructionPlan` performs: +// the amount is grouped-encrypted under [destination, supply, auditor], the +// supply handle (index 1) is combined lo/hi and homomorphically added to the +// mint's current encrypted supply. An amount that straddles the 16-bit boundary +// exercises both the lo and hi halves. +it('addWithLoHiCiphertexts adds the grouped mint amount to the running supply', () => { + const supply = new ElGamalKeypair(); + const destination = new ElGamalKeypair(); + const auditor = new ElGamalKeypair(); + + const currentSupply = 10n; + const amount = 65541n; // lo = 5, hi = 1 + const [amountLo, amountHi] = splitAmount(amount, LO_BIT_LENGTH); + + const currentSupplyCiphertext = new Uint8Array(supply.pubkey().encryptU64(currentSupply).toBytes()); + const groupedLo = groupedCiphertextBytes(destination.pubkey(), supply.pubkey(), auditor.pubkey(), amountLo); + const groupedHi = groupedCiphertextBytes(destination.pubkey(), supply.pubkey(), auditor.pubkey(), amountHi); + const supplyCiphertextLo = extractCiphertextFromGroupedBytes(groupedLo, 1); + const supplyCiphertextHi = extractCiphertextFromGroupedBytes(groupedHi, 1); + + const newSupplyCiphertext = addWithLoHiCiphertexts( + currentSupplyCiphertext, + supplyCiphertextLo, + supplyCiphertextHi, + LO_BIT_LENGTH, + ); + + expect(decrypt(supply, newSupplyCiphertext)).toBe(currentSupply + amount); +}); + +// Exercises the arithmetic `getConfidentialBurnInstructionPlan` performs: the +// amount is grouped-encrypted under [source, supply, auditor], the source handle +// (index 0) is combined lo/hi and homomorphically subtracted from the account's +// available balance. +it('subtractWithLoHiCiphertexts subtracts the grouped burn amount from the balance', () => { + const source = new ElGamalKeypair(); + const supply = new ElGamalKeypair(); + const auditor = new ElGamalKeypair(); + + const currentBalance = 65600n; + const amount = 65541n; // lo = 5, hi = 1 + const [amountLo, amountHi] = splitAmount(amount, LO_BIT_LENGTH); + + const currentBalanceCiphertext = new Uint8Array(source.pubkey().encryptU64(currentBalance).toBytes()); + const groupedLo = groupedCiphertextBytes(source.pubkey(), supply.pubkey(), auditor.pubkey(), amountLo); + const groupedHi = groupedCiphertextBytes(source.pubkey(), supply.pubkey(), auditor.pubkey(), amountHi); + const sourceCiphertextLo = extractCiphertextFromGroupedBytes(groupedLo, 0); + const sourceCiphertextHi = extractCiphertextFromGroupedBytes(groupedHi, 0); + + const newBalanceCiphertext = subtractWithLoHiCiphertexts( + currentBalanceCiphertext, + sourceCiphertextLo, + sourceCiphertextHi, + LO_BIT_LENGTH, + ); + + expect(decrypt(source, newBalanceCiphertext)).toBe(currentBalance - amount); +}); diff --git a/clients/js/test/extensions/confidentialMintBurn/getConfidentialMintBurnInstructionPlan.test.ts b/clients/js/test/extensions/confidentialMintBurn/getConfidentialMintBurnInstructionPlan.test.ts new file mode 100644 index 000000000..ffb2b1552 --- /dev/null +++ b/clients/js/test/extensions/confidentialMintBurn/getConfidentialMintBurnInstructionPlan.test.ts @@ -0,0 +1,122 @@ +import { expect, it } from 'vitest'; + +import { fetchMint, fetchToken, getApplyConfidentialPendingBurnInstruction } from '../../../src'; +import { + decryptConfidentialTransferBalance, + getApplyConfidentialPendingBalanceInstructionFromToken, + getConfidentialBurnInstructionPlan, + getConfidentialMintInstructionPlan, + getUpdateConfidentialMintBurnDecryptableSupplyInstructionFromSupply, +} from '../../../src/confidential'; +import { + createConfidentialMintBurnMint, + createConfidentialTokenAccount, + createValidatorClient, + fetchDecryptableSupply, + generateKeyPairSignerWithSol, +} from '../../_setup'; + +const DECIMALS = 2; +const MINT_AMOUNT = 500n; +const BURN_AMOUNT = 200n; + +it('confidentially mints into, applies, burns from, and re-syncs the supply of a mint-burn mint', async () => { + // Given a mint-burn mint (both ConfidentialTransferMint + ConfidentialMintBurn) + // and a confidential token account for an owner. + const client = await createValidatorClient(); + const payer = client.payer; + const owner = await generateKeyPairSignerWithSol(client); + const { mint, mintAuthority, supplyElgamalKeypair, supplyAesKey } = await createConfidentialMintBurnMint({ + client, + payer, + decimals: DECIMALS, + }); + const account = await createConfidentialTokenAccount({ client, payer, owner, mint }); + + // When the authority confidentially mints into the account's pending balance. + const [{ data: destinationTokenAccount }, { data: mintAccount }] = await Promise.all([ + fetchToken(client.rpc, account.token), + fetchMint(client.rpc, mint), + ]); + await client.sendTransactions( + await getConfidentialMintInstructionPlan({ + payer, + rpc: client.rpc, + token: account.token, + mint, + mintAccount, + destinationTokenAccount, + authority: mintAuthority, + amount: MINT_AMOUNT, + supplyElgamalKeypair, + supplyAesKey, + }), + ); + + // And the owner applies the pending balance so the minted amount is available. + const { data: afterMint } = await fetchToken(client.rpc, account.token); + await client.sendTransaction([ + getApplyConfidentialPendingBalanceInstructionFromToken({ + token: account.token, + tokenAccount: afterMint, + authority: owner, + elgamalSecretKey: account.elgamalKeypair.secret(), + aesKey: account.aesKey, + }), + ]); + + // Then the account's available balance decrypts to the minted amount. + const { data: appliedAccount } = await fetchToken(client.rpc, account.token); + expect( + decryptConfidentialTransferBalance({ + tokenAccount: appliedAccount, + elgamalSecretKey: account.elgamalKeypair.secret(), + aesKey: account.aesKey, + }).availableBalance, + ).toBe(MINT_AMOUNT); + + // When the owner confidentially burns part of the available balance. + const [{ data: sourceTokenAccount }, { data: mintForBurn }] = await Promise.all([ + fetchToken(client.rpc, account.token), + fetchMint(client.rpc, mint), + ]); + await client.sendTransactions( + await getConfidentialBurnInstructionPlan({ + payer, + rpc: client.rpc, + token: account.token, + mint, + mintAccount: mintForBurn, + sourceTokenAccount, + authority: owner, + amount: BURN_AMOUNT, + sourceElgamalKeypair: account.elgamalKeypair, + aesKey: account.aesKey, + }), + ); + + // Then the available balance drops by the burnt amount. + const { data: afterBurn } = await fetchToken(client.rpc, account.token); + expect( + decryptConfidentialTransferBalance({ + tokenAccount: afterBurn, + elgamalSecretKey: account.elgamalKeypair.secret(), + aesKey: account.aesKey, + }).availableBalance, + ).toBe(MINT_AMOUNT - BURN_AMOUNT); + + // Finally the authority applies the mint's pending burn and re-syncs the + // decryptable supply (ApplyPendingBurn advances the encrypted supply but + // cannot re-encrypt the AES decryptable supply). + await client.sendTransaction([ + getApplyConfidentialPendingBurnInstruction({ mint, authority: mintAuthority }), + getUpdateConfidentialMintBurnDecryptableSupplyInstructionFromSupply({ + mint, + authority: mintAuthority, + supplyAesKey, + supply: MINT_AMOUNT - BURN_AMOUNT, + }), + ]); + + expect(await fetchDecryptableSupply({ client, mint, supplyAesKey })).toBe(MINT_AMOUNT - BURN_AMOUNT); +}); diff --git a/clients/js/test/extensions/confidentialMintBurn/getPermissionedConfidentialBurnInstructionPlan.test.ts b/clients/js/test/extensions/confidentialMintBurn/getPermissionedConfidentialBurnInstructionPlan.test.ts new file mode 100644 index 000000000..67d0fd9dd --- /dev/null +++ b/clients/js/test/extensions/confidentialMintBurn/getPermissionedConfidentialBurnInstructionPlan.test.ts @@ -0,0 +1,212 @@ +import { expect, it } from 'vitest'; + +import { fetchMint, fetchToken, getApplyConfidentialPendingBurnInstruction } from '../../../src'; +import { + decryptConfidentialTransferBalance, + getApplyConfidentialPendingBalanceInstructionFromToken, + getConfidentialMintInstructionPlan, + getPermissionedConfidentialBurnInstructionPlan, + getUpdateConfidentialMintBurnDecryptableSupplyInstructionFromSupply, +} from '../../../src/confidential'; +import { + createConfidentialMintBurnMint, + createConfidentialTokenAccount, + createMultisig, + createValidatorClient, + fetchDecryptableSupply, + generateKeyPairSignerWithSol, +} from '../../_setup'; + +const DECIMALS = 2; +const MINT_AMOUNT = 500n; +const BURN_AMOUNT = 200n; + +it('confidentially burns from a mint carrying the PermissionedBurn extension', async () => { + // Given a mint-burn mint that ALSO carries the PermissionedBurn extension + // (as tokenized-security mints do). token-2022 rejects the standard + // confidential burn on such mints, so the permissioned variant is required. + const client = await createValidatorClient(); + const payer = client.payer; + const owner = await generateKeyPairSignerWithSol(client); + const permissionedBurnAuthority = await generateKeyPairSignerWithSol(client); + const { mint, mintAuthority, supplyElgamalKeypair, supplyAesKey } = await createConfidentialMintBurnMint({ + client, + payer, + decimals: DECIMALS, + permissionedBurnAuthority, + }); + const account = await createConfidentialTokenAccount({ client, payer, owner, mint }); + + // When the authority confidentially mints into the account's pending balance. + const [{ data: destinationTokenAccount }, { data: mintAccount }] = await Promise.all([ + fetchToken(client.rpc, account.token), + fetchMint(client.rpc, mint), + ]); + await client.sendTransactions( + await getConfidentialMintInstructionPlan({ + payer, + rpc: client.rpc, + token: account.token, + mint, + mintAccount, + destinationTokenAccount, + authority: mintAuthority, + amount: MINT_AMOUNT, + supplyElgamalKeypair, + supplyAesKey, + }), + ); + + // And the owner applies the pending balance so the minted amount is available. + const { data: afterMint } = await fetchToken(client.rpc, account.token); + await client.sendTransaction([ + getApplyConfidentialPendingBalanceInstructionFromToken({ + token: account.token, + tokenAccount: afterMint, + authority: owner, + elgamalSecretKey: account.elgamalKeypair.secret(), + aesKey: account.aesKey, + }), + ]); + + // When the owner confidentially burns part of the available balance via the + // permissioned variant, co-signed by the mint's permissioned burn authority. + const [{ data: sourceTokenAccount }, { data: mintForBurn }] = await Promise.all([ + fetchToken(client.rpc, account.token), + fetchMint(client.rpc, mint), + ]); + await client.sendTransactions( + await getPermissionedConfidentialBurnInstructionPlan({ + payer, + rpc: client.rpc, + token: account.token, + mint, + mintAccount: mintForBurn, + sourceTokenAccount, + authority: owner, + permissionedBurnAuthority, + amount: BURN_AMOUNT, + sourceElgamalKeypair: account.elgamalKeypair, + aesKey: account.aesKey, + }), + ); + + // Then the available balance drops by the burnt amount. + const { data: afterBurn } = await fetchToken(client.rpc, account.token); + expect( + decryptConfidentialTransferBalance({ + tokenAccount: afterBurn, + elgamalSecretKey: account.elgamalKeypair.secret(), + aesKey: account.aesKey, + }).availableBalance, + ).toBe(MINT_AMOUNT - BURN_AMOUNT); + + // Finally the authority applies the mint's pending burn and re-syncs the + // decryptable supply. + await client.sendTransaction([ + getApplyConfidentialPendingBurnInstruction({ mint, authority: mintAuthority }), + getUpdateConfidentialMintBurnDecryptableSupplyInstructionFromSupply({ + mint, + authority: mintAuthority, + supplyAesKey, + supply: MINT_AMOUNT - BURN_AMOUNT, + }), + ]); + + expect(await fetchDecryptableSupply({ client, mint, supplyAesKey })).toBe(MINT_AMOUNT - BURN_AMOUNT); +}); + +it('confidentially burns from an account owned by a multisig via the permissioned variant', async () => { + // Given a mint-burn + PermissionedBurn mint and a confidential token account + // owned by a 2-of-2 multisig, so the burn's `authority` is the multisig + // address and both members must be passed as `multiSigners`. + const client = await createValidatorClient(); + const payer = client.payer; + const permissionedBurnAuthority = await generateKeyPairSignerWithSol(client); + const [signerA, signerB] = await Promise.all([ + generateKeyPairSignerWithSol(client), + generateKeyPairSignerWithSol(client), + ]); + const multiSigners = [signerA, signerB]; + const { mint, mintAuthority, supplyElgamalKeypair, supplyAesKey } = await createConfidentialMintBurnMint({ + client, + payer, + decimals: DECIMALS, + permissionedBurnAuthority, + }); + const multisig = await createMultisig({ client, payer, signers: multiSigners }); + const account = await createConfidentialTokenAccount({ + client, + payer, + owner: multisig, + mint, + multiSigners, + }); + + // When the mint authority confidentially mints into the multisig-owned account + // and the multisig members apply the pending balance. + const [{ data: destinationTokenAccount }, { data: mintAccount }] = await Promise.all([ + fetchToken(client.rpc, account.token), + fetchMint(client.rpc, mint), + ]); + await client.sendTransactions( + await getConfidentialMintInstructionPlan({ + payer, + rpc: client.rpc, + token: account.token, + mint, + mintAccount, + destinationTokenAccount, + authority: mintAuthority, + amount: MINT_AMOUNT, + supplyElgamalKeypair, + supplyAesKey, + }), + ); + + const { data: afterMint } = await fetchToken(client.rpc, account.token); + await client.sendTransaction([ + getApplyConfidentialPendingBalanceInstructionFromToken({ + token: account.token, + tokenAccount: afterMint, + authority: multisig, + multiSigners, + elgamalSecretKey: account.elgamalKeypair.secret(), + aesKey: account.aesKey, + }), + ]); + + // And the multisig members burn part of the available balance, co-signed by + // the mint's permissioned burn authority. + const [{ data: sourceTokenAccount }, { data: mintForBurn }] = await Promise.all([ + fetchToken(client.rpc, account.token), + fetchMint(client.rpc, mint), + ]); + await client.sendTransactions( + await getPermissionedConfidentialBurnInstructionPlan({ + payer, + rpc: client.rpc, + token: account.token, + mint, + mintAccount: mintForBurn, + sourceTokenAccount, + authority: multisig, + multiSigners, + permissionedBurnAuthority, + amount: BURN_AMOUNT, + sourceElgamalKeypair: account.elgamalKeypair, + aesKey: account.aesKey, + }), + ); + + // Then the available balance drops by the burnt amount — which only holds if + // the plan forwarded `multiSigners` to the permissioned burn instruction. + const { data: afterBurn } = await fetchToken(client.rpc, account.token); + expect( + decryptConfidentialTransferBalance({ + tokenAccount: afterBurn, + elgamalSecretKey: account.elgamalKeypair.secret(), + aesKey: account.aesKey, + }).availableBalance, + ).toBe(MINT_AMOUNT - BURN_AMOUNT); +});