In a recent post on gasless USDC checkout on Solana I flagged confidential transfers as the case the fee-payer pattern doesn't cover. Enough merchants pinged us about it that it deserves its own write-up.
The short version: SPL Token-2022's confidential transfer extension
encrypts the amount of every transfer on chain. The sender, the
receiver, and any party holding the auditor key can decrypt it.
Everyone else sees a Pedersen commitment and a stack of ZK proofs.
That is great for payroll and B2B invoicing, and a serious problem
for a payment gateway that needs to reconcile invoices, fire
payment_intent.succeeded webhooks with a real amount field, and
hand merchants a number their accountant can match.
This post is how we wired Token-2022 confidential transfers into BchainPay without breaking that contract.
What confidential transfers actually hide#
Token-2022 (program ID TokenzQd…) is the successor to the original
SPL Token program. The confidential transfer extension is one of
roughly twenty extensions the new program ships with. When a mint
opts in and a holder configures their account, three things change
about every transfer from that account:
- The amount stops being a
u64field on the instruction. It is replaced by an ElGamal-encrypted ciphertext under the destination's public key, plus a Pedersen commitment. - The transfer ships with a bundle of ZK proofs — range proofs, equality proofs, and validity proofs — that convince the runtime the ciphertext is well-formed and the sender's balance doesn't go negative, all without revealing the amount.
- Each holder maintains a pending balance and an available
balance. Incoming transfers land in pending; the holder must
explicitly call
applyPendingBalanceto move funds into available before they can be spent.
The sender, the receiver, and a designated auditor can decrypt the amount off-chain. Block explorers, MEV bots, and your competitor across the street cannot. For a payroll provider that's the whole point. For a payment gateway it's the constraint we have to design around.
The merchant problem in one paragraph#
A payment gateway's job is to tell a merchant "invoice
inv_4f2a was paid in full at 14:02:11 UTC for 1,250.00 USDC."
If the on-chain transfer hides the amount, the gateway either has to
trust the shopper's claim, hold the merchant's auditor key and decrypt
server-side, or reject confidential transfers entirely. The third
option is the easy one and the wrong one — it leaves real volume on
the table from B2B and high-net-worth shoppers who are exactly the
people who care about transaction privacy.
The right answer is the second one, with discipline.
Mint setup: the two keys that matter#
A merchant who wants to accept confidential USDC (or any Token-2022 mint with the extension enabled) configures their token account once. Two ElGamal keypairs are generated:
- The encryption key (
elgamal_pubkey), used by senders to encrypt the amount under the merchant's public key. Stored on chain in the account'sConfidentialTransferAccountstate. - The auditor key, configured at the mint level by the mint authority. Issuers like Circle don't enable an auditor key on permissionless USDC mints, so for our gateway we run a dedicated gateway-managed mint wrapper for merchants who want a reconciliation guarantee. The wrapper mints 1:1 against deposited USDC and sets BchainPay's auditor key at issuance.
The auditor key is the load-bearing piece. With it, the gateway can decrypt every confidential transfer to the merchant's account and emit accurate webhooks. Without it, you are guessing.
import {
createInitializeConfidentialTransferAccountInstruction,
createApplyPendingBalanceInstruction,
} from '@solana/spl-token';
import { ElGamalKeypair, AeKey } from '@solana/zk-token-sdk';
export async function configureMerchantAccount(merchant: Keypair, mint: PublicKey) {
const elgamal = ElGamalKeypair.fromSecret(merchant.secretKey);
const ae = AeKey.fromSecret(merchant.secretKey);
const ix = createInitializeConfidentialTransferAccountInstruction({
account: getAssociatedTokenAddressSync(mint, merchant.publicKey, false, TOKEN_2022_PROGRAM_ID),
mint,
elgamalPubkey: elgamal.publicKey,
decryptableZeroBalance: ae.encrypt(0n),
maximumPendingBalanceCreditCounter: 65536n,
});
return ix;
}Two non-obvious bits:
maximumPendingBalanceCreditCounteris the cap on inbound transfers betweenapplyPendingBalancecalls. We set it to 65,536; high-volume merchants who don't sweep often will hit this and start rejecting payments. Set a worker to apply pending balances at least every 60 seconds.decryptableZeroBalanceis the encrypted form of0under the AES key (AeKey) the merchant holds locally. Token-2022 stores it so the merchant's wallet can decrypt the available-balance counter without having to scan history.
Building a confidential transfer for checkout#
When the shopper hits Pay, BchainPay returns a partially built transaction with the confidential transfer instruction prepared. The shopper's wallet performs the encryption and proof generation locally, signs, and sends.
import {
createTransferInstruction,
createApplyPendingBalanceInstruction,
TOKEN_2022_PROGRAM_ID,
} from '@solana/spl-token';
import { ProofInstruction } from '@solana/zk-token-sdk';
export async function buildConfidentialTransfer({
conn, mint, shopper, merchant, amount, decimals, auditorPubkey,
}: BuildArgs) {
const sourceAta = getAssociatedTokenAddressSync(mint, shopper, false, TOKEN_2022_PROGRAM_ID);
const destAta = getAssociatedTokenAddressSync(mint, merchant, false, TOKEN_2022_PROGRAM_ID);
// Off-chain: derive ciphertexts + proofs. The shopper's wallet
// does this; we ship the destination + auditor pubkey with the intent.
const proofs = await generateTransferProofs({
amount: BigInt(amount * 10 ** decimals),
sourceKeys: shopper.elgamal,
destPubkey: merchant.elgamalPubkey,
auditorPubkey,
});
return [
// 1. Push range/validity proofs into proof-context accounts.
...proofs.contextInstructions,
// 2. The actual confidential transfer ix.
createConfidentialTransferInstruction({
source: sourceAta,
mint,
destination: destAta,
owner: shopper.publicKey,
newSourceDecryptableAvailableBalance: proofs.newSourceBalance,
proofData: proofs.transferProofData,
programId: TOKEN_2022_PROGRAM_ID,
}),
];
}The proof-context accounts are the part nobody warns you about. Token-2022 split the original monolithic transfer instruction into up to four sub-instructions, each of which writes a verified proof to a temporary account. The transfer ix then references those accounts. If you're used to Solana transactions fitting in 1,232 bytes, prepare to lean hard on transaction v0 + address lookup tables — a single confidential transfer with all four proofs can brush against the limit.
Server-side: decrypting with the auditor key#
Once the transaction confirms, our indexer picks up the transfer. The decryption is a few lines:
import { ElGamalKeypair, decryptU64 } from '@solana/zk-token-sdk';
export function decryptAmount(ciphertext: Uint8Array, auditor: ElGamalKeypair): bigint {
// The auditor ciphertext is a separate field in the transfer ix.
// Token-2022 attaches one ciphertext per recipient (dest + auditor).
const amount = decryptU64(ciphertext, auditor.secretKey);
if (amount === null) {
throw new Error('auditor key cannot decrypt — wrong mint or rotated key');
}
return amount;
}decryptU64 is the discrete-log step. It only succeeds for amounts
in a 32-bit range by default; Token-2022 transfers split larger
amounts into a low-32 + high-32 ciphertext pair to keep this
tractable. Our helper hides the recombination.
Auditor keys get rotated. We store every historical (mint, auditor_pubkey, valid_from, valid_until) tuple and dispatch
decryption to the right one based on the slot the transfer landed
in. Do not assume your current auditor key can decrypt 90-day-old
transfers.
What the merchant sees#
The webhook payload looks identical to a normal SPL transfer payment, with one extra field:
{
"event": "payment_intent.succeeded",
"data": {
"id": "pi_2J5d8q7Wm",
"amount": "1250000000",
"currency": "USDC",
"chain": "solana",
"tx_signature": "5Tn…",
"confidential": true,
"auditor_decryption": {
"verified": true,
"key_id": "auk_2026_q2"
}
}
}The auditor_decryption.verified: true flag is the contract: we
only fire the webhook after we've decrypted with the auditor key
and confirmed that the decrypted amount matches what the shopper
committed to in the payment intent. If those don't match, we fire
payment_intent.amount_mismatch instead and put the transfer on a
manual-review queue. In ten months of running this we've seen four
mismatches — all four were buggy wallets, not malice.
The reconciliation gotcha#
The pending-vs-available-balance split breaks one assumption a lot
of integrations make: a confirmed transfer is not yet a spendable
balance. From the runtime's perspective the transfer is finalized;
from the merchant's perspective the funds sit in pending until they
call applyPendingBalance. If your treasury auto-sweep job (see our
treasury auto-sweep
post)
reads getTokenAccountBalance and only sees the available portion,
your sweep will lag your webhooks by one cycle.
Two fixes:
- Read
confidentialTransferAccount.pendingBalanceandavailableBalanceseparately from the parsed account data, not the public token-balance RPC. - Apply pending balances proactively from the same worker that emits webhooks, not from the sweep job. That keeps the available balance honest by the time the sweep runs.
When to turn it off#
Confidential transfers cost real compute units — roughly 6× the CU of a vanilla SPL transfer at current proof sizes. On a busy slot that translates into priority-fee competitiveness issues and slightly higher gasless-relayer costs. We turn confidential transfers off for:
- Any payment intent under $10. The privacy benefit is negligible and the relayer cost ratio gets ugly.
- Merchants on the free plan. The auditor decryption pipeline is metered.
- Mints whose auditor key we don't control. We will accept the
transfer, but the webhook is fired without
auditor_decryption.verifiedand the merchant has to reconcile off the shopper's signed payment intent instead.
Key takeaways#
- Token-2022 confidential transfers encrypt the amount on chain with ElGamal, leaving a Pedersen commitment and a stack of ZK proofs visible. Senders, receivers, and auditors can decrypt; no one else can.
- A payment gateway needs the auditor key to keep its
payment_intent.succeededcontract honest. We run a wrapped Token-2022 USDC mint with BchainPay's auditor key configured at issuance for merchants who opt in. - Configure
maximumPendingBalanceCreditCounterand run a worker that callsapplyPendingBalanceat least every 60 seconds, or high-volume merchants will start rejecting transfers. - Confidential transfers are a multi-instruction sequence with proof-context accounts. Use v0 transactions and address lookup tables, or you'll blow the 1,232-byte size limit.
- Always re-decrypt with the auditor key server-side and compare
against the payment-intent amount before firing the webhook. Fire
amount_mismatchand manually review otherwise. - Confidential transfers cost ~6× the compute units of a vanilla SPL transfer. Disable them under your fee-floor and for mints where you don't control the auditor key.