Skip to content

feat(clients/js): add confidential mint/burn instruction plan helpers - #1357

Open
eldarik wants to merge 3 commits into
solana-program:mainfrom
eldarik:feat-js-confidential-mint-burn
Open

feat(clients/js): add confidential mint/burn instruction plan helpers#1357
eldarik wants to merge 3 commits into
solana-program:mainfrom
eldarik:feat-js-confidential-mint-burn

Conversation

@eldarik

@eldarik eldarik commented Jul 28, 2026

Copy link
Copy Markdown

Adds the confidential mint/burn path to the /confidential subpath, which today ships InstructionPlan helpers for transfer, withdraw and configure but nothing for ConfidentialMintBurn — leaving downstream consumers to re-derive the three-proof orchestration themselves.

Supersedes #1338, which I closed after mistakenly agreeing it duplicated #1253. It does not: #1253 added the fee-aware transfer path (TransferFeeConfig + ConfidentialTransferFee, five proofs, U256 range), and contains no ConfidentialMintBurn handling. This PR is rebased onto main now that #1253 has landed, and builds on its primitives rather than re-introducing its own.

What's added

  • getConfidentialMintInstructionPlan / getConfidentialBurnInstructionPlan — build the equality, batched grouped-3-handles validity and U128 range proofs, verify them via context-state accounts, and assemble the token instruction, reusing the existing transfer/withdraw plumbing (buildContextStateProofPlan).
  • getPermissionedConfidentialBurnInstructionPlan — the permissioned burn variant. token-2022 rejects the standard ConfidentialBurn on mints carrying the PermissionedBurn extension, so this is required for those mints. Identical proofs; the mint's configured permissionedBurnAuthority co-signs alongside the account owner.
  • getUpdateConfidentialMintBurnDecryptableSupplyInstructionFromSupply — re-encrypts the decryptable supply under the supply AES key. The ElGamal supply and the AES decryptable supply can drift (e.g. ApplyPendingBurn advances the former but cannot re-encrypt the latter), so the authority uses this to re-assert it.
  • addWithLoHiCiphertexts in confidentialTransferArithmetic.ts — the one missing arithmetic primitive, mirroring the existing subtractWithLoHiCiphertexts, for homomorphically adding the minted amount to the encrypted supply.

Reusing #1253's primitives

#1338 and #1253 both incidentally grew their own copies of two small helpers. Rather than reintroduce mine, this PR adopts #1253's and converges the file on one of each:

  • Mint-extension lookup — dropped my getRequiredConfidentialMintBurnExtension for getRequiredMintExtension(mintAccount, 'ConfidentialMintBurn'). While there, getRequiredConfidentialTransferMintExtension folds into the same generic, since that is what the generic was written to replace.
  • Auditor resolution — dropped my resolveAuditorElGamalPubkey and widened the existing async getAuditorElGamalPubkey to a structural input, so transfer, transfer-with-fee, mint and burn all resolve the auditor key through one function. This keeps the fetch-the-mint fallback rather than my version's zero-pubkey fallback, which would have silently produced proofs the program rejects for a mint with a configured auditor.
  • u64 guards — dropped my local U64_MAX comparisons for assertU64Amount. assertMintBurnAmount stays: its 2^48−1 bound is specific to the lo/hi split used for the mint/burn range proof and has no analogue in the fee path.

The mint/burn input types compose ConfidentialTransferContextStateProofMode for payer/rpc/proofMode, matching the transfer inputs, instead of redeclaring them.

Testing

  • test/confidentialTransferArithmetic.test.ts — offline check that addWithLoHiCiphertexts matches a plaintext add.
  • test/extensions/confidentialMintBurn/getConfidentialMintBurnInstructionPlan.test.ts — validator e2e: mint → apply → burn → apply-pending-burn → update-supply against the on-chain ZK verifiers.
  • test/extensions/confidentialMintBurn/getPermissionedConfidentialBurnInstructionPlan.test.ts — confidential burn on a PermissionedBurn mint.

Full suite green against a local validator (76 files / 149 tests), including #1253's transfer-with-fee test — worth noting since the auditor resolver is now shared by every proof path. tsc --noEmit, pnpm lint and pnpm format clean.

eldarik added 2 commits July 28, 2026 23:06
Token-2022's /confidential subpath ships InstructionPlan helpers for
transfer, withdraw and configure, but nothing for confidential mint/burn
— downstream consumers had to re-derive the three-proof orchestration
themselves. Add helpers so that logic lives upstream.

- getConfidentialMintInstructionPlan / getConfidentialBurnInstructionPlan:
  build the equality, batched grouped-3-handles validity and U128 range
  proofs, verify them via context-state accounts, and assemble the token
  instruction — reusing the existing transfer/withdraw plumbing.
- getUpdateConfidentialMintBurnDecryptableSupplyInstructionFromSupply:
  re-encrypt the decryptable supply under the supply AES key (u64-guarded).
- addWithLoHiCiphertexts: the one missing arithmetic primitive, mirroring
  subtractWithLoHiCiphertexts, for homomorphically adding the minted
  amount to the encrypted supply.
- test setup: createConfidentialMintBurnMint, initializing a mint with
  both the ConfidentialTransferMint and ConfidentialMintBurn extensions.

The helpers reuse the primitives introduced by solana-program#1253 rather than adding
their own: getRequiredMintExtension for the ConfidentialMintBurn lookup
and assertU64Amount for the supply guards. getAuditorElGamalPubkey is
widened to a structural input so transfer, transfer-with-fee, mint and
burn all resolve the auditor key through one function, and main's
now-redundant getRequiredConfidentialTransferMintExtension folds into
the generic lookup.

Covered by a new offline arithmetic test and a validator e2e that
exercises mint -> apply -> burn -> apply-pending-burn -> update-supply
against the on-chain ZK verifiers.
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.
@eldarik
eldarik force-pushed the feat-js-confidential-mint-burn branch from 5f375a4 to 43f8c74 Compare July 28, 2026 18:06
@eldarik

eldarik commented Jul 28, 2026

Copy link
Copy Markdown
Author

@lorisleiva since we closed #1338 I've created a new PR with re-using helpers from #1253. Could you please take a look?

@gitteri

gitteri commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Review

Verified the proof construction against confidential/proof-generation/src/{mint,burn}.rs and the port is faithful:

  • Bit-length split (MAX_MINT_BURN_AMOUNT = 2^48-1, lo=16 / hi=32) matches MINT/BURN_AMOUNT_LO/HI_BIT_LENGTH.
  • Grouped handle order (mint [destination, supply, auditor], burn [source, supply, auditor]) matches.
  • Homomorphic update: mint adds combine_lo_hi(supply handle @ index 1) to confidentialSupply, burn subtracts combine_lo_hi(source handle @ index 0) from availableBalance. Matches to_elgamal_ciphertext(1)/(0).
  • Auditor ciphertext extracted at index 2, and the range proof (commitments/amounts/bit lengths [64, 16, 32, 16]) matches build_batched_range_proof_u128_data.

Guards look right too: assertMintBurnAmount, assertU64Amount(newSupply) mirroring the Rust checked_add, and burn's computeNewAvailableBalance handling the underflow.

Bug: permissioned burn silently drops multiSigners

GetPermissionedConfidentialBurnInstructionPlanInput extends GetConfidentialBurnInstructionPlanInput, so it inherits multiSigners?. But the wrapper only forwards permissionedBurnAuthority:

getPermissionedConfidentialBurnInstruction(
    { ...burnArgs, permissionedBurnAuthority: input.permissionedBurnAuthority },
    ...
)

burnArgs intentionally excludes multiSigners, and the standard burn adds it back ({ ...burnArgs, multiSigners: input.multiSigners }), but the permissioned variant does not. The generated getPermissionedConfidentialBurnInstruction does accept multiSigners (permissionedConfidentialBurn.ts:261, appended as remaining accounts at :319).

So if the account owner (authority) is a multisig, a caller passing multiSigners gets them silently discarded, producing an instruction missing required signers that fails on-chain, with no type error since the field is a valid input.

Fix: forward them:

{ ...burnArgs, multiSigners: input.multiSigners, permissionedBurnAuthority: input.permissionedBurnAuthority }

(If dropping were intentional the type should Omit<…, 'multiSigners'>, but since the underlying instruction supports it, forwarding is the right fix.)

Minor (non-blocking)

  • Mint has an undocumented sync precondition: the equality proof is built from confidentialSupply (ElGamal) + amount proven equal to AES_decrypt(decryptableSupply) + amount. If those two on-chain supply representations have drifted (e.g. after ApplyPendingBurn), the proof is rejected on-chain. Worth a line in the getConfidentialMintInstructionPlan JSDoc pointing callers to the update helper to re-sync first. The e2e test mints on a fresh mint so never hits the drifted case.
  • getConfidentialMintBurnExtension and the final decryptable-supply assertion block are copy-pasted across both e2e test files; could move to _setup.ts next to createConfidentialMintBurnMint.
  • The mint and burn base input types repeat a fair bit (token/mint/mintAccount/authority/amount/auditorElgamalPubkey?/multiSigners?/programAddress?); a small shared base would trim it.

Test coverage

The multiSigners bug is uncovered: neither burn test uses a multisig authority. Otherwise coverage is solid, offline arithmetic round-trip plus full mint → apply → burn → apply-pending-burn → update-supply e2e on both the standard and permissioned paths.

Cryptographically correct and idiomatic. The permissioned-burn multiSigners drop is the one thing worth fixing before merge.

GetPermissionedConfidentialBurnInstructionPlanInput extends the standard
burn input and so inherits multiSigners, but the wrapper only forwarded
permissionedBurnAuthority — the shared burnArgs deliberately exclude
multiSigners and only the standard variant added them back. A caller
burning from a multisig-owned account therefore had its signers silently
dropped, producing an instruction that fails on-chain with
MissingRequiredSignature and no type error to catch it. The generated
getPermissionedConfidentialBurnInstruction does accept multiSigners, so
forward them.

Cover the case with a validator e2e that burns from an account owned by
a 2-of-2 multisig via the permissioned variant; it fails with
MissingRequiredSignature without the fix. Supporting it needed
createMultisig in the test harness and a widened
createConfidentialTokenAccount that accepts an address owner plus
multiSigners.

Also, from the same review pass:

- Document getConfidentialMintInstructionPlan's supply-sync
  precondition: the equality proof ties confidentialSupply to
  AES_decrypt(decryptableSupply), so drift (e.g. after ApplyPendingBurn)
  makes the proof fail on-chain — point callers at the update helper.
- Factor the fields shared by the mint and burn plan inputs into
  GetConfidentialMintBurnInstructionPlanBaseInput.
- Move the duplicated getConfidentialMintBurnExtension and
  decryptable-supply assertion block out of both mint/burn e2e tests
  into _setup as fetchDecryptableSupply.
@eldarik

eldarik commented Jul 30, 2026

Copy link
Copy Markdown
Author

@gitteri thanks for the review. I've addressed the feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants