How to create a token account with the Confidential Transfer extension
The Confidential Transfer extension enables private token transfers by adding extra state to the token account. This section explains how to create a token account with this extension enabled.
The following diagram shows the steps involved in creating a token account with the Confidential Transfer extension:
Confidential Transfer Token Account State
The extension adds the ConfidentialTransferAccount state to the token account:
#[repr(C)]#[derive(Clone, Copy, Debug, Default, PartialEq, Pod, Zeroable)]pub struct ConfidentialTransferAccount {/// `true` if this account has been approved for use. All confidential/// transfer operations for the account will fail until approval is/// granted.pub approved: PodBool,/// The public key associated with ElGamal encryptionpub elgamal_pubkey: PodElGamalPubkey,/// The low 16 bits of the pending balance (encrypted by `elgamal_pubkey`)pub pending_balance_lo: EncryptedBalance,/// The high 48 bits of the pending balance (encrypted by `elgamal_pubkey`)pub pending_balance_hi: EncryptedBalance,/// The available balance (encrypted by `encryption_pubkey`)pub available_balance: EncryptedBalance,/// The decryptable available balancepub decryptable_available_balance: DecryptableBalance,/// If `false`, the extended account rejects any incoming confidential/// transferspub allow_confidential_credits: PodBool,/// If `false`, the base account rejects any incoming transferspub allow_non_confidential_credits: PodBool,/// The total number of `Deposit` and `Transfer` instructions that have/// credited `pending_balance`pub pending_balance_credit_counter: PodU64,/// The maximum number of `Deposit` and `Transfer` instructions that can/// credit `pending_balance` before the `ApplyPendingBalance`/// instruction is executedpub maximum_pending_balance_credit_counter: PodU64,/// The `expected_pending_balance_credit_counter` value that was included in/// the last `ApplyPendingBalance` instructionpub expected_pending_balance_credit_counter: PodU64,/// The actual `pending_balance_credit_counter` when the last/// `ApplyPendingBalance` instruction was executedpub actual_pending_balance_credit_counter: PodU64,}
The ConfidentialTransferAccount contains several fields to manage
confidential transfers:
-
approved: The account's approval status for confidential transfers. If the mint account's
auto_approve_new_accountsconfiguration is set astrue, all token accounts are automatically approved for confidential transfers. -
elgamal_pubkey: The ElGamal public key used to encrypt balances and transfer amounts.
-
pending_balance_lo: The encrypted lower 16 bits of the pending balance. The balance is split into high and low parts for efficient decryption.
-
pending_balance_hi: The encrypted higher 48 bits of the pending balance. The balance is split into high and low parts for efficient decryption.
-
available_balance: The encrypted balance available for transfers.
-
decryptable_available_balance: The available balance encrypted with an Advanced Encryption Standard (AES) key for efficient decryption by the account owner.
-
allow_confidential_credits: If true, allows incoming confidential transfers.
-
allow_non_confidential_credits: If true, allows incoming non-confidential transfers.
-
pending_balance_credit_counter: Counts incoming pending balance credits from deposit and transfer instructions.
-
maximum_pending_balance_credit_counter: The count limit of pending credits before requiring an
ApplyPendingBalanceinstruction to convert the pending balance to the available balance. -
expected_pending_balance_credit_counter: The
pending_balance_credit_countervalue provided by the client through the instruction data the last time theApplyPendingBalanceinstruction was processed. -
actual_pending_balance_credit_counter: The
pending_balance_credit_countervalue on the token account at the time the lastApplyPendingBalanceinstruction was processed.
Pending vs Available Balance
Confidential balances are separated into pending and available balances to prevent DoS attacks. Without this separation, an attacker could repeatedly send tokens to a token account, blocking the token account owner's ability to transfer tokens. The token account owner would be unable to transfer tokens because the encrypted balance would change between when the transaction is submitted and when it is processed, resulting in a failed transaction.
All deposits and transfer amounts are initially added to the pending balance.
Token account owners must use the ApplyPendingBalance instruction to
convert the pending balance to the available balance. Incoming transfers or
deposits don't affect a token account's available balance.
Pending Balance High/Low Split
The confidential pending balance is split into pending_balance_lo and
pending_balance_hi because ElGamal decryption requires more computation for
larger numbers. You can find the ciphertext arithmetic implementation
here,
which is used in the ApplyPendingBalance instruction
here.
Pending Balance Credit Counters
When calling the ApplyPendingBalance instruction to convert the pending
balance to the available balance:
-
The client looks up current pending and available balances, encrypts the sum, and provides a
decryptable_available_balanceencrypted using the token account owner's AES key. -
The expected and actual pending credit counters track changes to the counter value between when the
ApplyPendingBalanceinstruction is created and processed:expected_pending_balance_credit_counter: Thepending_balance_credit_countervalue when the client creates theApplyPendingBalanceinstructionactual_pending_balance_credit_counter: Thepending_balance_credit_countervalue on the token account at the time theApplyPendingBalanceinstruction is processed
Matching expected/actual counters indicate the decryptable_available_balance
matches the available_balance.
When fetching a token account's state to read the
decryptable_available_balance, different expected/actual counters values
require the client to look up recent deposit/transfer instructions matching the
counter difference to calculate the correct balance.
Balance Reconciliation Process
When the expected and actual pending balance counters differ, follow these steps
to reconcile the decryptable_available_balance:
- Start with the
decryptable_available_balancefrom the token account - Fetch the most recent transactions including deposit and transfer
instructions up to the counter difference (actual - expected):
- Add public amounts from deposit instructions
- Decrypt and add destination ciphertext amounts from transfer instructions
Required Instructions
Creating and configuring a token account for confidential transfers uses the following instructions, which all fit in a single transaction:
-
Create the Token Account: Invoke the Associated Token Program's
AssociatedTokenAccountInstruction::Createinstruction to create the token account at its deterministic address. -
Reallocate Account Space: Invoke the Token Extension Program's
TokenInstruction::Reallocateinstruction to add space for theConfidentialTransferAccountstate. -
Verify the Pubkey Validity Proof: Create an account owned by the ZK ElGamal Proof program, then invoke its
VerifyPubkeyValidityinstruction to verify the proof and store the verified result in that context state account. -
Configure Confidential Transfers: Invoke the Token Extension Program's ConfidentialTransferInstruction::ConfigureAccount instruction, referencing the proof context state account via
ProofLocation::ContextStateAccount, to initialize theConfidentialTransferAccountstate.
Only the token account owner can configure a token account for confidential transfers.
The ConfigureAccount instruction requires client-side generation of
encryption keys and a proof that can only be generated by the token account
owner.
The pubkey validity proof verifies that the account's ElGamal public key is
valid. It is generated with build_pubkey_validity_proof_data, verified on
chain by the ZK ElGamal Proof program into a context state account, and then
referenced from ConfigureAccount via
ProofLocation::ContextStateAccount, so no proof bytes travel in the token
instruction itself. For implementation details, see:
Example Code
The following code creates an associated token account and configures it for confidential transfers against an existing confidential mint.
Confidential transfers depend on the ZK ElGamal Proof program, which is enabled
on mainnet and devnet. A stock solana-test-validator does not enable it, but a
mainnet-forking local validator such as Surfpool does.
Run the example against one of those (the code uses devnet) with a funded payer,
and replace the mint placeholder with a mint created per
Create a Mint.
Rust
// The native ZK ElGamal Proof program verifies the proof on chain.const ZK_PROOF_PROGRAM_ID: Pubkey =solana_pubkey::pubkey!("ZkE1Gama1Proof11111111111111111111111111111");fn main() -> Result<()> {// Use a cluster whose ZK ElGamal Proof program is enabled (mainnet, devnet).let rpc_client = RpcClient::new_with_commitment(String::from("https://api.devnet.solana.com"),CommitmentConfig::confirmed(),);// The Solana CLI default keypair, used as fee payer, mint authority, and// token account owner.let payer = load_keypair()?;let decimals: u8 = 2;// Setup: create a confidential mint for the token account.let mint = create_confidential_mint(&rpc_client, &payer, decimals)?;let token_account = get_associated_token_address_with_program_id(&payer.pubkey(),&mint,&spl_token_2022::id(),);// 1. Create the associated token account.let create_ata_ix = create_associated_token_account(&payer.pubkey(), // funding account&payer.pubkey(), // token account owner&mint,&spl_token_2022::id(),);// 2. Add space for the ConfidentialTransferAccount extension.let realloc_ix = reallocate(&spl_token_2022::id(),&token_account,&payer.pubkey(), // payer&payer.pubkey(), // owner&[&payer.pubkey()],&[ExtensionType::ConfidentialTransferAccount],)?;// 3. Derive the owner's ElGamal keypair and AES key from a signature over// the token account address. The same signer and address always derive// the same keys, so the owner can recover them from their wallet.let (elgamal_keypair, aes_key) = derive_confidential_keys(&payer, &token_account.to_bytes()).map_err(|e| anyhow::anyhow!("derive confidential keys: {e}"))?;// Initial decryptable available balance of 0, encrypted with the AES key.let decryptable_balance: PodAeCiphertext = aes_key.encrypt(0).into();let maximum_pending_balance_credit_counter: u64 = 65_536;// 4. Generate the pubkey-validity proof, then pre-verify it into a context// state account owned by the ZK ElGamal Proof program. configure_account// references the verified proof by account, so no proof bytes travel in// the token instruction itself.let proof_data = build_pubkey_validity_proof_data(&elgamal_keypair).map_err(|e| anyhow::anyhow!("generate pubkey validity proof: {e}"))?;let proof_account = Keypair::new();let context_state_size = size_of::<ProofContextState<PubkeyValidityProofContext>>();let context_state_rent =rpc_client.get_minimum_balance_for_rent_exemption(context_state_size)?;let create_proof_account_ix = system_instruction::create_account(&payer.pubkey(),&proof_account.pubkey(),context_state_rent,context_state_size as u64,&ZK_PROOF_PROGRAM_ID,);let proof_account_address: Address = proof_account.pubkey().to_bytes().into();let owner_address: Address = payer.pubkey().to_bytes().into();let verify_proof_ix = ProofInstruction::VerifyPubkeyValidity.encode_verify_proof(Some(ContextStateInfo {context_state_account: &proof_account_address,context_state_authority: &owner_address,}),&proof_data,);// 5. Configure the account, pointing at the pre-verified proof account.let proof_location: ProofLocation<PubkeyValidityProofData> =ProofLocation::ContextStateAccount(&proof_account.pubkey());let configure_account_ixs = configure_account(&spl_token_2022::id(),&token_account,&mint,&decryptable_balance,maximum_pending_balance_credit_counter,&payer.pubkey(), // owner&[],proof_location,)?;// Everything fits in a single transaction.let mut instructions = vec![create_ata_ix,realloc_ix,create_proof_account_ix,verify_proof_ix,];instructions.extend(configure_account_ixs);let blockhash = rpc_client.get_latest_blockhash()?;let transaction = Transaction::new_signed_with_payer(&instructions,Some(&payer.pubkey()),&[&payer, &proof_account],blockhash,);let signature = rpc_client.send_and_confirm_transaction(&transaction)?;println!("Configured token account {token_account} for confidential transfers: {signature}");Ok(())}
Typescript
const client = await createClient().use(signerFromFile(join(homedir(), ".config/solana/id.json"))).use(solanaRpc({rpcUrl: "https://api.devnet.solana.com"}));// The Solana CLI default keypair, used as fee payer, mint authority, and// token account owner.const owner = client.payer;const decimals = 2;// Setup: create a confidential mint for the token account.const mint = await createConfidentialMint(client, owner, decimals);const [tokenAccount] = await findAssociatedTokenPda({owner: owner.address,tokenProgram: TOKEN_2022_PROGRAM_ADDRESS,mint});// Derive recoverable ElGamal and AES keys bound to (owner, mint). Re-deriving// from the same wallet always yields the same keys, so the owner can recover// them rather than having to back up a separate secret.const derivedElGamal = await deriveElGamalKeypairForOwnerMint({signer: owner,owner: owner.address,mint});const elgamalKeypair = ElGamalKeypair.fromSecretKey(ElGamalSecretKey.fromBytes(derivedElGamal.secretKey));const aesKey = AeKey.fromBytes(await deriveAeKeyForOwnerMint({ signer: owner, owner: owner.address, mint }));// Build the create-ATA + reallocate + verify-proof + configure plan, then send.// The helper returns an instruction plan because the steps may span more than// one transaction.const plan = await getCreateConfidentialTransferAccountInstructionPlan({rpc: client.rpc,payer: owner,owner,mint,elgamalKeypair,aesKey});const result = await client.sendTransaction(plan);console.log(`Configured token account ${tokenAccount} for confidential transfers: ${result.context.signature}`);
The TypeScript helpers live in the @solana-program/token-2022/confidential
subpath and build on @solana/zk-sdk for the encryption primitives. owner
and client come from your @solana/kit setup; the returned instruction plan
is sent with @solana/kit's instruction-plan support, which splits the work
across transactions when the proofs are too large for one.
Is this page helpful?