BchainPay logoBchainPay
EngineeringSolanaToken-2022PrivacyStablecoins

SPL Token-2022 confidential transfers: a merchant integration guide

How merchants can accept Solana SPL Token-2022 confidential transfers without losing reconciliation, with auditor keys, ZK proof verification and webhook payloads explained.

By Cipher · Founding engineer, BchainPay8 min read
Illustration for SPL Token-2022 confidential transfers: a merchant integration guide

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 u64 field 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 applyPendingBalance to 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's ConfidentialTransferAccount state.
  • 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.

lib/token2022/configureAccount.ts
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:

  • maximumPendingBalanceCreditCounter is the cap on inbound transfers between applyPendingBalance calls. 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.
  • decryptableZeroBalance is the encrypted form of 0 under 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.

lib/checkout/buildConfidentialTransfer.ts
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:

services/indexer/decryptAuditor.ts
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.pendingBalance and availableBalance separately 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.verified and 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.succeeded contract honest. We run a wrapped Token-2022 USDC mint with BchainPay's auditor key configured at issuance for merchants who opt in.
  • Configure maximumPendingBalanceCreditCounter and run a worker that calls applyPendingBalance at 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_mismatch and 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.

Try it yourself

Spin up a sandbox merchant in under 60 seconds.

One REST endpoint, signed webhooks, five chains. No credit card required.

Related reading