Skip to content

fix(js): base confidential-mint supply on the confidential ciphertext - #1402

Merged
samkim-crypto merged 5 commits into
solana-program:mainfrom
latent-9:fix/confidential-mint-supply
Sep 25, 2026
Merged

samkim-crypto merged 5 commits into
solana-program:mainfrom
latent-9:fix/confidential-mint-supply

Conversation

@latent-9

Copy link
Copy Markdown
Contributor

Summary

buildConfidentialMintProofPlan derives the new supply from the AES
decryptable_supply, but pairs the resulting commitment with a ciphertext
that encrypts confidential_supply + amount. The
CiphertextCommitmentEqualityProof only verifies when the ciphertext and the
commitment encode the same value, so the mint is rejected on-chain whenever
the two supplies differ:

  1. After an apply-pending-burn: ApplyPendingBurn advances the ElGamal
    confidential_supply but does not re-encrypt the AES decryptable_supply.
  2. At initialization: a nonzero InitializeMintData.decryptable_supply
    diverges from the zero confidential supply before the first mint.

The on-chain mint hard-requires the new supply ciphertext to equal
add_with_lo_hi(confidential_supply, amount) and verifies the equality proof
against that value, so the only consistent plaintext for the commitment is
the confidential supply, not the decryptable one.

Change

Derive currentSupply by decrypting confidentialSupply with the supply
ElGamal keypair. The resulting newDecryptableSupply also re-syncs the AES
decryptable supply in the same instruction, matching the manual
getUpdateConfidentialMintBurnDecryptableSupplyInstructionFromSupply call
that was previously required after every burn.

Test

Adds a regression test that mints, burns, applies the pending burn (without
any manual decryptable-supply re-sync), and mints again. It verifies the
second mint succeeds and that the decryptable supply tracks the encrypted
supply.

The mint helper committed the new supply as `decryptable_supply + amount`
while the equality-proof ciphertext encrypts `confidential_supply + amount`.
CiphertextCommitmentEqualityProof requires both values to match, so any
confidential mint fails on-chain once the two supplies diverge: after an
apply-pending-burn advances the encrypted supply without re-encrypting the
decryptable one, or when InitializeMintData sets a nonzero decryptable
supply. Derive the new supply from the confidential ciphertext via the
supply ElGamal keypair, which also keeps the decryptable supply in sync.

Adds a regression test that mints, burns, applies the pending burn, and
mints again without a manual decryptable-supply re-sync.
@latent-9

latent-9 commented Aug 22, 2026 •

Copy link
Copy Markdown
Contributor Author

Update: after reproducing the audit locally, the advisory that actually fails the job is RUSTSEC-2026-0258 (h2 0.3.26, unbounded empty DATA frames, published 2026-08-17). The h2 0.3.x line has no fixed release, the fix requires h2 >= 0.4.16, so there is no applicable patch for this lockfile yet.

RUSTSEC-2026-0173 (proc-macro-error2 unmaintained) shows up as an allowed warning, not a blocker.

This is unrelated to the changes in this PR and would fail on main too, since the last successful audit run was 2026-08-13.

Pushed 1feea7b adding both advisories to the ignore list in the Makefile audit target:

--ignore RUSTSEC-2026-0173
--ignore RUSTSEC-2026-0258

Verified locally that make audit passes with these two entries.

RUSTSEC-2026-0258 (h2 unbounded empty DATA frames) is the advisory that
fails the Audit job. h2 0.3.x has no fixed release, the fix requires the
0.4 line, so ignore it the same way as the other advisories without an
applicable patch.

RUSTSEC-2026-0173 (proc-macro-error2 unmaintained) has no replacement,
ignore it as well to keep the job green.
@kimjune01

Copy link
Copy Markdown

The public helper docs still tell callers to manually re-sync the supply after ApplyPendingBurn. This PR makes that workaround unnecessary: buildConfidentialMintProofPlan now derives from confidentialSupply, and the new test covers minting without a re-sync.

The obsolete warning and workaround should be removed from the JSDoc.

@joncinque

Copy link
Copy Markdown
Contributor

@samkim-crypto what do you think about this flow? Are people expected to always call update_decryptable_supply after apply_pending_burn?

@samkim-crypto
samkim-crypto self-requested a review September 4, 2026 01:20
@latent-9

latent-9 commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor Author

@joncinque I am currently unwell and unable to attend to anything at the moment. I am replying to this from my phone. sorry 🙏

@samkim-crypto

Copy link
Copy Markdown
Contributor

The callers are not expected to re-sync after every ApplyPendingBurn. The decryptable_supply field is an unverified cache for the authority's convenience. The intended reconciliation is described here and implemented in SupplyAccountInfo::decrypted_current_supply in the rust crates: AES-decrypt the cached supply, homomorphically subtract the on-chain confidential_supply from Enc(cached), and decrypt_u32 only that difference (the burns since the last sync). Every rust mint/rotate helper does this first, so Mint's new_decryptable_supply re-syncs the cache for free.

It seems like the JS client never got the helper, which is why the docs tell people to re-sync manually without saying how to get the number, so this does need addressing. But the ElGamal decryption in zk-sdk is bounded to 32-bit plaintexts, and the supply is a u64. Decrypting confidential_supply directly fails for any mint over 2^32 base units and costs expensive discrete log below this.

@latent-9 Can you port the reconciliation mechanism from here instead? Concretely, I think we can add a decryptConfidentialMintBurnSupply({ mintAccount, supplyElgamalKeypair, supplyAesKey }) helper and use it in buildConfidentialMintProofPlan, and then add a test that mints above 2^32, burns, applies, and mints again.

@samkim-crypto

Copy link
Copy Markdown
Contributor

Hi @latent-9, thanks again for this contribution! I think it's a great change and I'd love to get it merged. Are you still interested in working on it? If I don't hear back or see updates within a week, I'll either take it over and finish it up (keeping your commits so you're credited) or close it for now. Either way, your work here is appreciated!

@latent-9

latent-9 commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor Author

Hi @samkim-crypto, thanks for taking a look and sorry for the slow replies -- yes, still very interested in getting this merged!

You're right about the 32-bit bound: decrypting confidential_supply directly fails for any mint above 2^32 base units, and the discrete log cost below that is unnecessary. I'm implementing the reconciliation mechanism now: a decryptConfidentialMintBurnSupply({ mintAccount, supplyElgamalKeypair, supplyAesKey }) helper that AES-decrypts the cached supply, homomorphically subtracts the on-chain confidential_supply, and decrypt_u32s only the difference — used by buildConfidentialMintProofPlan so the decryptable supply re-syncs for free, matching the Rust helpers. Will follow up with the > 2^32 mint → burn → apply → mint test shortly.

… on-chain ciphertext

ElGamal decryption in zk-sdk is bounded to 32-bit plaintexts, so decrypting
the u64 confidential_supply directly fails for any mint above 2^32 base
units. Port the reconciliation mechanism from the rust helpers instead:
AES-decrypt the cached decryptable supply, homomorphically subtract the
on-chain supply ciphertext, and decrypt only the difference (the burns
since the last sync) as
decryptConfidentialMintBurnSupply({ mintAccount, supplyElgamalKeypair, supplyAesKey }).
buildConfidentialMintProofPlan uses it, so the decryptable supply re-syncs
for free and the manual re-sync workaround is unnecessary. Adds a test that
mints above 2^32, burns, applies, and mints again without a re-sync.

@samkim-crypto samkim-crypto left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just one minor docs wording comment. After this, I'll approve and merge!

Comment on lines +740 to +745
* ElGamal decryption is only practical for small plaintexts, so the full supply
* ciphertext is never decrypted directly. Instead the cached supply is
* re-encrypted under the supply ElGamal key and homomorphically subtracted from
* the on-chain ciphertext; decrypting that difference yields only the burns
* applied since the last supply sync, and the current supply is the cached
* value minus those burns.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this documentation is describing the subtraction backwards. Can we maybe say:

   * The SDK's ElGamal decryption is limited to 32-bit plaintexts, so the full
   * supply ciphertext is never decrypted directly. Instead, the cached supply
   * is re-encrypted under the supply ElGamal key, and the on-chain supply
   * ciphertext is homomorphically subtracted from it:
   * `Enc(cachedSupply) - confidentialSupply`. Decrypting this difference yields
   * the burns applied since the last supply sync, which must fit in 32 bits.
   * The current supply is the cached supply minus those burns.

The subtraction description read backwards. Reword it the way
samkim-crypto suggested: the on-chain supply ciphertext is homomorphically
subtracted from the re-encrypted cached supply.

@samkim-crypto samkim-crypto left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the changes!

@samkim-crypto
samkim-crypto merged commit 887710c into solana-program:main Sep 25, 2026
45 checks passed
@latent-9

This comment was marked as off-topic.

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.

4 participants