Skip to content

Derive confidential transfer auditor from mint#1269

Open
catmcgee wants to merge 1 commit into
solana-program:mainfrom
catmcgee:catmcgee/confidential-transfer-auto-auditor
Open

Derive confidential transfer auditor from mint#1269
catmcgee wants to merge 1 commit into
solana-program:mainfrom
catmcgee:catmcgee/confidential-transfer-auto-auditor

Conversation

@catmcgee

@catmcgee catmcgee commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Updates getConfidentialTransferInstructionPlan so callers do not have to manually fetch the mint just to discover the configured auditor key.

Behavior:

  • if auditorElgamalPubkey is passed, the helper uses it as before
  • if it is omitted, the helper fetches the mint account and reads ConfidentialTransferMint.auditorElgamalPubkey
  • if the mint has no auditor configured, the helper keeps using the default zero auditor key
  • if the mint is missing the ConfidentialTransferMint extension, the helper now fails earlier with a clearer error

This avoids the current confusing path where a mint with an auditor configured can produce proof/transfer data using the default auditor key, and the transaction then fails without pointing the caller at the missing auditor input.

@joncinque joncinque requested a review from lorisleiva June 22, 2026 10:08
@lorisleiva

Copy link
Copy Markdown
Member

@trevor-cortex

@trevor-cortex trevor-cortex left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Summary

Makes auditorElgamalPubkey optional on getConfidentialTransferInstructionPlan by fetching it from the mint's ConfidentialTransferMint extension when omitted. Adds a small helper (getRequiredConfidentialTransferMintExtension) mirroring the existing token-account version, splits out ConfidentialTransferContextStateProofMode so transfer's rpc requires GetAccountInfoApi (withdraw is untouched), and adds an integration test that exercises the auto-fetch path on a mint configured with a real auditor key.

Overall the change is well-scoped and a real ergonomics win — the previous behavior of silently using the zero auditor when the mint actually has one configured was a foot-gun, and failing earlier with a clearer message on a mint that's missing the extension is the right call.

Things to watch / discuss

  • Extra RPC roundtrip when auditorElgamalPubkey is omitted. Each call to getConfidentialTransferInstructionPlan without an auditor now triggers a fetchMint. Many callers (e.g. a UI that already fetched the mint to render decimals/extensions) will have the Mint in hand and just want the helper to do the right thing. Worth considering whether the input shape could optionally accept a pre-fetched mint: Mint (similar to how sourceTokenAccount: Token is already passed in) to skip the extra request. The current shape is fine for v1, but documenting the perf trade-off in the doc comment, or following up with a mintAccount?: Mint overload, would be a nice next step.
  • PR description vs. behavior — minor. The description says the helper now fails earlier when the mint is missing the ConfidentialTransferMint extension. That's true only when auditorElgamalPubkey is omitted; if a caller explicitly passes the auditor key, the mint is never fetched and an invalid mint will still surface as a downstream transaction failure. Not a regression and likely the right trade-off (don't pay an RPC tax when the caller has already supplied the needed data), but worth being explicit about in the doc comment so callers know what they're opting out of.
  • Type duplication. ConfidentialTransferContextStateProofMode is identical to ContextStateProofMode except rpc also requires GetAccountInfoApi. Reasonable to keep withdraw's signature stable, but if more helpers grow this requirement it'd be worth collapsing into a generic (ContextStateProofMode<ExtraApi = never>) rather than forking again.

Notes for subsequent reviewers

  • The default-auditor fallback inside getAuditorElGamalPubkey (when the mint's auditorElgamalPubkey is None) preserves prior behavior — please confirm that's actually the program-side convention for "no auditor" on confidential transfers; if zero is just a sentinel here and not a meaningful key, no action needed.
  • The new test only asserts the transfer succeeds when the auditor is auto-fetched. It doesn't verify the auditor's ciphertext was actually produced with the configured key (vs. silently falling back to the default). A decrypt-side assertion would be stronger but is probably overkill for an integration test; flagging in case anyone wants to harden it.
  • No test covers the new 'Mint account is missing the ConfidentialTransferMint extension.' error path. Easy to add as a unit-level test if you want regression coverage on the error message.

return isSome(extension.auditorElgamalPubkey)
? getElGamalPubkeyFromAddress(extension.auditorElgamalPubkey.value)
: getDefaultAuditorElGamalPubkey();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Two small things worth considering here:

  1. Doc comment. This is now the canonical place the auditor is resolved, but there's no JSDoc explaining the resolution order (explicit input → mint extension → zero default) or the fact that omitting auditorElgamalPubkey costs an extra RPC fetchMint. A short comment would save future readers a trip into the helper.
  2. Optional pre-fetched mint. Consider accepting an optional mint?: Mint on the input (parallel to how sourceTokenAccount: Token is already passed in pre-fetched). Callers building higher-level flows often already have the mint loaded, and this would let them skip the extra round-trip while still benefiting from the auto-resolution. Happy to defer to a follow-up PR.

Comment on lines +188 to +200
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nit: this is a near-exact clone of getRequiredConfidentialTransferAccountExtension above, just for the mint extension. If a third copy ever shows up it'd be worth extracting a small generic like getRequiredExtension<K extends Extension['__kind']>(account: { extensions: ... }, kind: K). Not blocking for this PR.

const client = await createValidatorClient();
const payer = client.payer;
const [sourceOwner, destinationOwner] = await Promise.all([
generateKeyPairSignerWithSol(client),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Reusing an ElGamalKeypair().pubkey().toBytes() as an Address via getAddressDecoder().decode(...) works (both are 32 bytes), but it's mixing two unrelated key types in test setup and could confuse future readers. A short inline comment explaining why this works — "ElGamal pubkeys and Solana addresses share the same 32-byte layout, so we reuse the address decoder to thread the key through createConfidentialMint's Address-typed field" — would help.

Comment on lines +113 to +116
const updatedDestination = await fetchAssociatedToken(client, destinationOwner.address, mint);
const destinationConfidential = getTokenExtension(updatedDestination, 'ConfidentialTransferAccount');
expect(destinationConfidential.pendingBalanceCreditCounter).toBe(1n);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This assertion is identical to the one in the test above and only proves the transfer succeeded — it doesn't actually verify the auto-fetched auditor key was used. If the new getAuditorElGamalPubkey path silently fell back to getDefaultAuditorElGamalPubkey() (e.g. a bug where isSome(extension.auditorElgamalPubkey) returned false despite being set), this test would still pass.

A stronger assertion would decrypt one of the destination's auditor ciphertexts with the auditor's ElGamal secret key and confirm it round-trips to the transferred amount. Worth doing if practical; otherwise consider at least asserting via a spy/log that the mint-fetch branch was taken.

@trevor-cortex

Copy link
Copy Markdown

Re-triggered, but the diff is unchanged since my previous review — same three files, same hunks. Nothing new to flag. Happy to take another pass once there are commits to look at, or to dig into any of the prior points (extra RPC roundtrip / optional pre-fetched Mint, type duplication, or the test only asserting transfer success rather than auditor key usage) if you'd like to discuss.

@lorisleiva

Copy link
Copy Markdown
Member

Thanks @catmcgee! The only thing I'm not sure about is the Extra RPC roundtrip from the AI review. It might make more sense to accept a Mint account instead of fetching it since we may be forcing a double fetch otherwise (as the app will likely have it already loaded). Wdyt?

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.

3 participants