Create a Token Account

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:

Create Token Account with Confidential Transfer Extension

Confidential Transfer Token Account State

The extension adds the ConfidentialTransferAccount state to the token account:

Confidential Token Account State
#[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 encryption
pub 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 balance
pub decryptable_available_balance: DecryptableBalance,
/// If `false`, the extended account rejects any incoming confidential
/// transfers
pub allow_confidential_credits: PodBool,
/// If `false`, the base account rejects any incoming transfers
pub 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 executed
pub maximum_pending_balance_credit_counter: PodU64,
/// The `expected_pending_balance_credit_counter` value that was included in
/// the last `ApplyPendingBalance` instruction
pub expected_pending_balance_credit_counter: PodU64,
/// The actual `pending_balance_credit_counter` when the last
/// `ApplyPendingBalance` instruction was executed
pub 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_accounts configuration is set as true, 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 ApplyPendingBalance instruction to convert the pending balance to the available balance.

  • expected_pending_balance_credit_counter: The pending_balance_credit_counter value provided by the client through the instruction data the last time the ApplyPendingBalance instruction was processed.

  • actual_pending_balance_credit_counter: The pending_balance_credit_counter value on the token account at the time the last ApplyPendingBalance instruction 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:

  1. The client looks up current pending and available balances, encrypts the sum, and provides a decryptable_available_balance encrypted using the token account owner's AES key.

  2. The expected and actual pending credit counters track changes to the counter value between when the ApplyPendingBalance instruction is created and processed:

    • expected_pending_balance_credit_counter: The pending_balance_credit_counter value when the client creates the ApplyPendingBalance instruction
    • actual_pending_balance_credit_counter: The pending_balance_credit_counter value on the token account at the time the ApplyPendingBalance instruction 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:

  1. Start with the decryptable_available_balance from the token account
  2. 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:

  1. Create the Token Account: Invoke the Associated Token Program's AssociatedTokenAccountInstruction::Create instruction to create the token account at its deterministic address.

  2. Reallocate Account Space: Invoke the Token Extension Program's TokenInstruction::Reallocate instruction to add space for the ConfidentialTransferAccount state.

  3. Verify the Pubkey Validity Proof: Create an account owned by the ZK ElGamal Proof program, then invoke its VerifyPubkeyValidity instruction to verify the proof and store the verified result in that context state account.

  4. Configure Confidential Transfers: Invoke the Token Extension Program's ConfidentialTransferInstruction::ConfigureAccount instruction, referencing the proof context state account via ProofLocation::ContextStateAccount, to initialize the ConfidentialTransferAccount state.

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?

Table of Contents

Edit Page
© 2026 Solana Foundation. All rights reserved.