Supporting Confidential Transfers on Solana
Background
The Confidential Transfer extension lets Token-2022 mints keep transfer amounts and account balances encrypted onchain. Balances are encrypted, so displaying them requires the owner's decryption keys, and sending requires generating zero-knowledge proofs on the client.
This guide is for teams integrating confidential transfer tokens (wallets, explorers, exchanges, custodians, indexers) rather than issuers configuring a mint. If you are building the underlying flow from scratch, start with the step-by-step pages linked below; this guide focuses on what your product needs to do to support these tokens well.
Account addresses, the mint, and the owner of each account stay public. Only amounts and balances are encrypted, so everything else, including who transacts with whom, remains visible. This gives confidentiality from public observers, not anonymity.
Resources
- Confidential Transfer overview and step-by-step guides
- Extension Rust code
@solana-program/token-2022JS client (instructions, account state parsing, key derivation, and high-level confidential transfer helpers)@solana/zk-sdkWASM SDK (encryption primitives and proof data generation)@solana-program/zk-elgamal-proofJS client (proof verification instructions)spl-token-clientRust crate (high-level end-to-end helpers in Rust)
TL;DR
- Every confidential token account still has a public balance plus an encrypted pending and available balance. A token can move freely between public and confidential states.
- To display a confidential balance you need the owner's keys. The recommended approach is to derive them from the owner's wallet, decrypt the available balance with the AES key (fast), and decrypt pending amounts with the ElGamal key when needed.
- To send confidentially you generate ZK proofs on the client (equality,
ciphertext validity, range), usually placed in temporary proof context state
accounts, then submit the transfer. Both
@solana-program/token-2022(JS) andspl-token-client(Rust) provide high-level helpers that build the proofs and sequence the transactions for you. - Confidential transfers currently span a few dependent transactions because the proofs exceed today's transaction size limit. An upcoming change (transaction format v1, landing with Agave v4.2) raises that limit and is expected to let a confidential transfer execute in a single onchain transaction.
- A wallet that adds no support still works: it shows the public balance and standard transfers keep functioning. Confidential funds stay accessible to any confidential-aware client with the owner's keys.
Terms
- ElGamal keypair: per-account public-key keypair used to encrypt balances and build ZK proofs. The public key is stored on the account.
- AES key: per-account symmetric key used to encrypt the "decryptable available balance" so the owner can read their available balance in constant time, without solving a discrete log.
- Pending balance: encrypted balance of funds received via deposits or incoming transfers that have not yet been applied. Cannot be spent directly.
- Available balance: encrypted balance that can be transferred or withdrawn.
- Apply: the step that moves pending into available.
- Proof context state account: a temporary onchain account holding a pre-verified ZK proof, referenced by a confidential instruction and then closed to reclaim rent.
- Auditor: an optional global ElGamal key on the mint; when set, every transfer additionally encrypts its amount under this key so a designated party can decrypt it.
Account model and balances
When an account is configured for confidential transfers, the
ConfidentialTransferAccount extension stores (among other fields):
elgamal_pubkey: the account's ElGamal public key.pending_balance_lo/pending_balance_hi: ElGamal ciphertexts of the pending balance, split into low and high bits (decryption of the high bits is more expensive, so they are kept separate).available_balance: ElGamal ciphertext of the spendable balance.decryptable_available_balance: an AES ciphertext of the same available balance that the owner can decrypt instantly. This is the field to use for display.allow_confidential_credits/allow_non_confidential_credits: whether the account currently accepts incoming confidential or public credits.pending_balance_credit_counterandmaximum_pending_balance_credit_counter: how many credits have accrued to the pending balance and the cap before an apply is required.
All of these fields are returned in parsed account state over standard RPC. Anyone can read the ciphertexts, but only key holders can recover the underlying amounts.
Key management
Each confidential account uses two keys: an ElGamal keypair for encryption and proofs, and an AES key for fast balance decryption. How you produce and store those keys is an integration choice. A few common options:
- Derive from a wallet signature (recommended default). The owner signs a
canonical, domain-separated message and the keys are derived from that
signature, so they are reproducible from the wallet alone and you do not have
to store them. The seed is deterministic per account: the JS helpers below
bind it to the
(owner, mint)pair, while the Rust example seeds from the token account address (for an associated token account these are equivalent, since that address is itself derived from the owner and mint). - Derive from independent key material. For passkeys, secure enclaves, or
MPC setups, derive the keys from a WebAuthn PRF output or other input key
material so that the confidential keys are not tied to the account's signing
key.
@solana/zk-sdkexposesConfidentialKeys.fromIkmandConfidentialKeys.fromPrffor this. - Generate and manage directly. Custodial and MPC providers may prefer to generate the keys and manage them like any other sensitive key material.
The @solana-program/token-2022 client ships helpers for the recommended path:
import {deriveElGamalKeypairForOwnerMint,deriveAeKeyForOwnerMint} from "@solana-program/token-2022";// `owner` signs a domain-separated message; the keys are bound to (owner, mint).const { elgamalPubkey, secretKey } = await deriveElGamalKeypairForOwnerMint({signer: owner,owner: owner.address,mint});const aesKey = await deriveAeKeyForOwnerMint({signer: owner,owner: owner.address,mint});
Confidential keys are decryption keys. Treat a request to sign the derivation message like unlocking a private view of the user's balances. Prefer deriving on demand over storing them; if you do store them (for example in a custodial service), protect them with the same rigor as signing keys.
The derivation helpers return serialized key material. The high-level operation
helpers (shown later) take WASM ElGamalKeypair and AeKey objects, so rebuild
them from the bytes when you need them:
Creating and configuring accounts
Before an account can hold a confidential balance it needs the
ConfidentialTransferAccount extension, which adds roughly 295 bytes to the
token account (on the order of 0.0015 SOL in extra rent). Setup is two steps:
create the token account, then configure the extension.
Configuring normally requires the account owner to sign and supply a proof that they own the ElGamal public key being set on the account. A wallet or exchange can create and fund the bare token account for a user, but it cannot configure the confidential extension on the user's behalf without that signature.
Because of this, avoid silently provisioning confidential accounts for every user: it costs extra rent and still needs owner involvement. Configure on first use, or when the user opts into confidential transfers.
The ElGamal registry removes the per-account owner step. A user registers
their ElGamal public key once (signing a proof at registration), and the
registry entry is reusable across every mint. After that, a third party can
configure confidential accounts for the user with
ConfigureAccountWithRegistry, which needs no owner signature or proof at
configuration time, only a payer for rent. This is the mechanism to use if you
want to provision confidential accounts for users smoothly.
Registry configuration is available in the Rust spl-token-client
(confidential_transfer_configure_token_account_with_registry) and the
spl-elgamal-registry program today. Equivalent helpers in the
@solana-program/token-2022 JS client are not available yet, so JS
integrations that need the registry path should track that client's releases.
Displaying balances
A robust integration shows the public balance using the standard token balance, detects the confidential extension, and, when the user has unlocked their keys, decrypts and shows the available balance.
The available balance is best read from decryptable_available_balance using
the AES key, which is constant time. Decrypting the ElGamal available_balance
directly requires solving a discrete log and should be avoided for routine
display.
import { AeCiphertext } from "@solana/zk-sdk/bundler";import { fetchToken } from "@solana-program/token-2022";import { unwrapOption } from "@solana/kit";const account = await fetchToken(rpc, tokenAccountAddress);// `extensions` is an Option<Array<Extension>>; each extension is a tagged union.const extensions = unwrapOption(account.data.extensions) ?? [];const ct = extensions.find((e) => e.__kind === "ConfidentialTransferAccount");if (ct) {// Fast path: decrypt the AES "decryptable available balance" for display.const ciphertext = AeCiphertext.fromBytes(new Uint8Array(ct.decryptableAvailableBalance));// `aesKey` is the rebuilt AeKey object (see the rebuild snippet above), not raw bytes.const availableBalance = ciphertext?.decrypt(aesKey); // bigint | undefinedconsole.log("Available (confidential):", availableBalance);}
When the user has not unlocked their keys, show the public balance and indicate that a confidential balance exists but is locked, rather than showing zero.
Receiving transfers
Incoming deposits and transfers land in the pending balance and are not
spendable until applied. Each credit increments
pending_balance_credit_counter; once it reaches
maximum_pending_balance_credit_counter, the account cannot receive more
confidential credits until the owner applies the pending balance.
Integrations that hold funds on behalf of users should:
- Surface pending vs available separately so users understand why a just-received amount is not yet spendable.
- Apply the pending balance on the user's behalf at sensible times (for example before a send) so balances do not get stuck.
import { getApplyConfidentialPendingBalanceInstructionFromToken } from "@solana-program/token-2022";// Builds a single instruction; no proofs are needed to apply.const instruction = getApplyConfidentialPendingBalanceInstructionFromToken({token: tokenAccountAddress,tokenAccount, // decoded Token accountauthority: owner,elgamalSecretKey: elgamalKeypair.secret(),aesKey});
See the Apply Pending Balance page for the full flow.
Sending transfers
A confidential transfer requires three zero-knowledge proofs generated on the client:
- Equality proof that the sender's new balance ciphertext encrypts the same value as a fresh commitment the sender can open.
- Ciphertext validity proof that the transfer-amount ciphertexts are well-formed under the source, destination, and (if set) auditor keys.
- Range proof that the amount and the sender's remaining balance are valid non-negative integers, which is what prevents minting value out of thin air.
These proofs are larger than today's transaction size limit allows inline, so the usual pattern is to create proof context state accounts, verify each proof into them, reference them from the transfer instruction, then close them to reclaim rent. That spans a few dependent transactions. Transaction format v1 (landing with Agave v4.2) raises the size limit and is expected to let a confidential transfer run in a single onchain transaction, which will simplify this flow.
You do not have to assemble the proofs by hand. Both clients provide a high-level helper that builds the proofs and produces the instructions to submit:
import { getConfidentialTransferInstructionPlan } from "@solana-program/token-2022";// Returns an instruction plan covering proof setup, the transfer, and cleanup.const plan = await getConfidentialTransferInstructionPlan({rpc,payer, // funds rent for the temporary proof context state accountssourceToken,mint,destinationToken,sourceTokenAccount, // decoded Token account for the sourcedestinationTokenAccount, // decoded Token account for the destination,// or pass `destinationElgamalPubkey` directly insteadauthority: owner,amount,sourceElgamalKeypair, // ElGamal keypair for the source accountaesKey, // AES key for the source accountauditorElgamalPubkey // optional, read from the mint config});// Execute the plan with your instruction-plan executor of choice.
If you need finer control, the lower-level building blocks are available too:
@solana/zk-sdk generates each proof's data
(CiphertextCommitmentEqualityProofData,
BatchedGroupedCiphertext3HandlesValidityProofData,
BatchedRangeProofU128Data), @solana-program/zk-elgamal-proof provides the
proof verification instructions, and @solana-program/token-2022 provides the
confidentialTransfer and context state account instructions.
See the Transfer Tokens page for the detailed instruction sequence.
Withdrawing
Withdrawing moves funds from the confidential available balance back to the
public balance, after which they behave like any normal token balance.
Withdrawal also requires proofs (an equality and a range proof), exposed as
getConfidentialWithdrawInstructionPlan in the JS client and
confidential_transfer_withdraw in the Rust client. Apply any pending balance
first so the full amount is available. See
Withdraw Tokens.
Related confidential extensions
Two optional extensions build on confidential transfers. Both are mainly the issuer's responsibility, but each changes something an integrator should handle:
- Confidential transfer fees. When a mint pairs confidential transfers with
a transfer fee, sends use a with-fee
transfer path (
confidential_transfer_transfer_with_feein the Rust client), and the withheld fee is encrypted like the amount. Collecting withheld fees (harvest and withdraw) is the fee authority's job, not the integrator's. - Confidential mint and burn. A mint with this extension issues and burns supply confidentially, which disables the public deposit and withdraw path. Tokens cannot move between the public and confidential balances on such a mint, so do not surface deposit or withdraw for it.
For the protocol-level details of both, see the confidential balances documentation.
Parsing confidential transfer transactions
Explorers and indexers need to recognize and label confidential transfer
activity without being able to read the amounts. The
@solana-program/token-2022 client exposes identifyToken2022Instruction to
classify each Token-2022 instruction, plus per-instruction parsers (for example
parseConfidentialTransferInstruction) to decode accounts and non-secret
fields. The encrypted amounts stay ciphertexts: display them as confidential
rather than rendering the bytes as a number.
import {identifyToken2022Instruction,Token2022Instruction,TOKEN_2022_PROGRAM_ADDRESS} from "@solana-program/token-2022";for (const ix of instructions) {if (ix.programAddress !== TOKEN_2022_PROGRAM_ADDRESS) continue;const kind = identifyToken2022Instruction(ix);switch (kind) {case Token2022Instruction.ConfidentialTransfer:case Token2022Instruction.ConfidentialTransferWithFee:console.log("Confidential transfer (amount encrypted)");break;case Token2022Instruction.ConfidentialDeposit:console.log("Deposit to confidential balance");break;case Token2022Instruction.ConfidentialWithdraw:console.log("Withdraw from confidential balance");break;case Token2022Instruction.ApplyConfidentialPendingBalance:console.log("Apply pending balance");break;default:break;}}
Indexing limitations to plan for:
- No amounts for confidential transfers. A
ConfidentialTransfercarries encrypted amounts, so volume, flow, and transfer-size analytics cannot be computed from it. Mark these transfers as confidential rather than recording a zero or a raw ciphertext. - No balance deltas. Confidential moves do not change the public token balance, so the pre/post token balance diffing that drives most transfer indexing does not capture them. The pending and available balances are ciphertexts.
- Deposits and withdrawals are in the clear.
ConfidentialDepositandConfidentialWithdrawcarry cleartext amounts, so you can still index the flow into and out of the confidential pool, just not the transfers within it. - One transfer spans several transactions today. Proof context state accounts are created, used, and closed around the transfer, so correlate the related transactions rather than treating each in isolation. This collapses once single-transaction confidential transfers land (see above).
- Auditor mints are decryptable. If a mint has a global auditor and you hold the auditor key, you can decrypt transfer amounts for that mint (see below).
Auditors and compliance
A mint may configure a global auditor ElGamal public key. When set, every confidential transfer must include the amount encrypted under the auditor key, so the holder of the auditor secret key can decrypt all transfer amounts for that mint. The validity proof covers the auditor ciphertext, so as an integrator you do not do anything special on the send path beyond passing the auditor key from the mint config (the high-level helpers read it for you).
If your product is the auditor (for example a regulated issuer or a compliance provider), you decrypt transfer amounts with the auditor secret key the same way an owner decrypts their own balance. Owners can also selectively share their per-account keys with a specific party without exposing them publicly.
Transaction monitoring (KYT)
For know-your-transaction and AML providers, confidential transfers change what is observable, not the overall model:
- Address and graph analysis is unaffected. Senders, recipients, mints, and account owners stay public, so address screening, sanctions matching, and counterparty graph analysis work the same as for any token.
- Amount-based heuristics need the auditor key. Structuring, threshold reporting, and volume-based risk scoring cannot read confidential transfer amounts on their own. Deposit and withdraw amounts stay in the clear, so the size of flows entering and leaving the confidential pool is still visible.
- Coverage comes from the auditor model. On mints that configure a global auditor, a provider operating with the auditor key (or receiving selective disclosure from the user or issuer) can recover transfer amounts. Issuers in regulated contexts should plan to configure an auditor or support selective disclosure so monitoring is possible.
Backwards compatibility
- A confidential token account always has a public balance. Wallets and apps that do not support the extension keep working and display the public balance.
- Standard transfers still function for the public balance as long as the destination allows non-confidential credits.
- Confidential balances are not visible to non-supporting tooling, but the funds are not lost: any confidential-aware client can access them with the owner's keys.
- Because amounts are encrypted, supply- and volume-level analytics that rely on reading transfer amounts will not see confidential activity. Plan dashboards and accounting around this.
Recommended Integration Priorities Per Platform
General Requirements
| Requirement | Description | Priority |
|---|---|---|
| Detect the extension | Recognize the Confidential Transfer extension on mints and accounts and handle these tokens explicitly rather than assuming a public-only model. | P0 |
| Never lose confidential funds | Even without full support, surface that a confidential balance exists so users do not assume their account is empty. | P0 |
| Sound key management | Choose a key strategy for ElGamal and AES keys. Deriving from the owner's wallet is the recommended default; if you store keys, protect them like signing keys. | P0 |
Wallets
| Requirement | Description | Priority |
|---|---|---|
| Display public balance | Always show the public balance using standard token balance reads. | P0 |
| Unlock and display available balance | Let the user unlock keys via a signature and show the decrypted available balance (via the AES decryptable balance). | P0 |
| Show pending vs available | Surface pending balances separately and prompt to apply when funds are received. | P1 |
| Apply pending automatically | Apply pending balances at sensible moments (e.g. before a send) so funds do not get stuck. | P1 |
| Send confidentially | Support deposit, transfer, and withdraw flows with client-side proof generation. | P1 |
| Locked-state UX | When keys are not unlocked, clearly indicate a confidential balance exists rather than showing zero. | P1 |
| Onboarding / education | Help users understand what stays private (amounts and balances) and the key-unlock step. | P2 |
Explorers and Indexers
| Requirement | Description | Priority |
|---|---|---|
| Label confidential accounts and mints | Clearly mark accounts and mints that use the extension, and show the public balance. | P0 |
| Parse confidential instructions | Decode configure, deposit, apply, transfer, and withdraw instructions and display their type (not the amounts). | P0 |
| Do not display encrypted amounts as numbers | Never render ciphertext fields as if they were plaintext balances; show them as confidential. | P0 |
| Index public deposit/withdraw flows | Record the cleartext amounts on deposit and withdraw to track flow in and out of the confidential pool. | P1 |
| Correlate multi-transaction transfers | Group the proof setup, transfer, and cleanup transactions that make up one confidential transfer. | P1 |
| Show auditor configuration | Surface whether a mint has a global auditor configured. | P1 |
| Proof account lifecycle | Recognize proof context state account creation and closing so transactions read sensibly. | P2 |
Exchanges and Custodians
| Requirement | Description | Priority |
|---|---|---|
| Track public and confidential state | Account for both public and confidential balances when crediting deposits and computing holdings. | P0 |
| Apply pending on deposits | Apply pending balances when confidential deposits arrive so credited amounts are accurate. | P0 |
| Secure key handling | If holding confidential funds custodially, manage ElGamal/AES keys with the same rigor as signing keys. | P0 |
| Account provisioning via registry | To set up confidential accounts for users, have users register an ElGamal key once and provision with the registry path rather than requiring a signature per account. | P1 |
| Withdrawals to users | Support confidential or public withdrawals depending on the destination account configuration. | P1 |
| Compliance / auditor support | Where required, use auditor keys or selective disclosure to meet reporting obligations. | P1 |
| Internal accounting on raw amounts | Reconcile against decrypted amounts at the edge and store them; do not attempt to aggregate ciphertexts. | P1 |
Compliance and KYT Providers
| Requirement | Description | Priority |
|---|---|---|
| Address and graph screening | Screen senders, recipients, mints, and owners and run counterparty graph analysis; these stay public. | P0 |
| Track public deposit/withdraw | Monitor cleartext deposit and withdraw amounts as the observable entry and exit points of the confidential pool. | P0 |
| Auditor-key amount visibility | For auditor-enabled mints, decrypt transfer amounts using the auditor key to support amount-based detection. | P1 |
| Selective disclosure intake | Support amount disclosures shared by users or issuers via per-account keys. | P2 |
Is this page helpful?