Confidential Asset (CA)
The Confidential Asset Standard (also known as “Confidential Asset” or “CA”) is a privacy-focused protocol for managing Fungible Assets (FA). It allows users to perform transactions with hidden FA amounts while keeping sender and recipient addresses publicly visible.
This standard allows any FA to be wrapped into a corresponding Confidential Asset, ensuring compatibility with existing tokens. It supports 64-bit transfers, and balances of up to 128 bits.
Operations on Confidential Asset balances (confidential balances), require zero-knowledge proofs (ZKPs) to verify transaction correctness without revealing hidden amounts and other sensitive data.
Confidential Store
Section titled “Confidential Store”For every confidential asset a user registers, they generate a unique keypair:
- An encryption key (EK) stored on-chain.
- A decryption key (DK) kept securely by the user.
These keys are standalone and should not be confused with the user’s Aptos account keys.
Each confidential balance is split into two parts:
pending_balance- accumulates all incoming transactions.available_balance- used exclusively for outgoing transactions.
Both balances are encrypted with the same user’s EK, ensuring underlying amounts remain private.
The confidential balance, encryption key, and related state are stored in the ConfidentialStore resource.
The ConfidentialStore is instantiated for each confidential asset the user has and managed by the confidential_asset module:
enum ConfidentialStore has key { V1 { pause_incoming: bool, normalized: bool, transfers_received: u64, pending_balance: CompressedPendingBalance, available_balance: CompressedAvailableBalance, ek: CompressedRistretto, }}Confidential Balance
Section titled “Confidential Balance”Confidential balances handle token amounts by splitting them into smaller units called chunks.
Each chunk represents a portion of the total amount and is encrypted individually using the user’s EK.
Each encrypted chunk consists of two curve points (P, R) forming a Twisted ElGamal ciphertext.
Chunks
Section titled “Chunks”The pending balance consists of four chunks that hold all incoming transfers. It can handle up to 2^16 64-bit transfers before requiring a rollover to the available balance. During this accumulation, the pending balance chunks can grow up to 32 bits each.
The available balance consists of eight chunks, supporting 128-bit values. After any operation the available balance should be normalized back to 16-bit chunks to maintain efficient decryption.
The CompressedPendingBalance and CompressedAvailableBalance structs represent these two balance types:
struct CompressedPendingBalance has store, drop, copy { P: vector<CompressedRistretto>, R: vector<CompressedRistretto>,}struct CompressedAvailableBalance has store, drop, copy { P: vector<CompressedRistretto>, R: vector<CompressedRistretto>, R_aud: vector<CompressedRistretto>,}Encryption and Decryption
Section titled “Encryption and Decryption”Encryption involves:
- Splitting the total amount into 16-bit chunks.
- Applying the user’s EK to encrypt each chunk individually as a Twisted ElGamal ciphertext
(P, R).
Decryption involves:
- Applying the user’s DK to decrypt each chunk.
- Solving a discrete logarithm (DL) problem for each chunk to recover the original values.
- Combining the recovered values to reconstruct the total amount.
Normalization
Section titled “Normalization”Normalization ensures chunks are always reduced to manageable sizes (16 bits). Without normalization, chunks can grow too large, making the decryption process (solving DL) significantly slower or even impractical. This mechanism is automatically applied to the available balance after each transfer or withdrawal, ensuring that users can always decrypt their balances, even as balances grow through multiple transactions. Only after a rollover, users are required to normalize the available balance manually.
Homomorphic Encryption
Section titled “Homomorphic Encryption”The protocol utilizes Homomorphic encryption, allowing arithmetic operations on confidential balances without their decryption. This capability is essential for updating the receiver’s pending balance during transfers and for rollovers, where the user’s pending balance is added to the available one.
Architecture
Section titled “Architecture”The diagram below shows the relationship between Confidential Asset modules:
Users interact with the confidential_asset module to perform operations on their confidential balances.
The confidential_asset module uses separate modules for each type of balance (confidential_pending_balance,
confidential_available_balance, confidential_amount), along with sigma protocol modules for ZKP verification
and confidential_range_proofs for range proof verification.
Under the hood, the ristretto255_twisted_elgamal module provides Twisted ElGamal encryption primitives.
Entry Functions
Section titled “Entry Functions”Register
Section titled “Register”public entry fun register_raw( sender: &signer, asset_type: Object<Metadata>, ek: vector<u8>, sigma_proto_comm: vector<vector<u8>>, sigma_proto_resp: vector<vector<u8>>)Users must register a ConfidentialStore for each asset type they intend to transact with.
As part of this process, users generate a keypair (EK and DK) on their end and submit
a sigma protocol proof (proving knowledge of the DK corresponding to the given EK).
When a ConfidentialStore is first registered, the confidential balance is set to zero,
represented as zero ciphertexts for both the pending_balance and available_balance.
Deposit
Section titled “Deposit”public entry fun deposit( depositor: &signer, asset_type: Object<Metadata>, amount: u64)The deposit function brings tokens into the protocol, transferring the passed amount
from the primary FA store of the depositor to their own pending balance.
The amount in this function is publicly visible, as adding new tokens to the protocol requires a normal transfer. However, tokens within the protocol become obfuscated through confidential transfers, ensuring privacy in subsequent transactions.
Rollover Pending Balance
Section titled “Rollover Pending Balance”public entry fun rollover_pending_balance( sender: &signer, asset_type: Object<Metadata>)public entry fun rollover_pending_balance_and_pause( sender: &signer, asset_type: Object<Metadata>)The rollover_pending_balance function adds the pending balance to the available one, resetting the pending balance to zero.
It works with no additional proofs as this function utilizes properties of the Homomorphic encryption used in the protocol.
The rollover_pending_balance_and_pause variant additionally pauses incoming transfers after the rollover,
which is useful when preparing for a key rotation.
Confidential Transfer
Section titled “Confidential Transfer”public entry fun confidential_transfer_raw( sender: &signer, asset_type: Object<Metadata>, to: address, new_balance_P: vector<vector<u8>>, new_balance_R: vector<vector<u8>>, new_balance_R_eff_aud: vector<vector<u8>>, amount_P: vector<vector<u8>>, amount_R_sender: vector<vector<u8>>, amount_R_recip: vector<vector<u8>>, amount_R_eff_aud: vector<vector<u8>>, ek_volun_auds: vector<vector<u8>>, amount_R_volun_auds: vector<vector<vector<u8>>>, zkrp_new_balance: vector<u8>, zkrp_amount: vector<u8>, sigma_proto_comm: vector<vector<u8>>, sigma_proto_resp: vector<vector<u8>>)The confidential_transfer_raw function transfers tokens from the sender’s available balance to the recipient’s
pending balance. The sender encrypts the transferred amount under the recipient’s encryption key, enabling the recipient’s
confidential balance to be updated homomorphically.
The transfer amount is also encrypted under the sender’s key (for the sender’s records) and under any auditor keys.
The function requires:
- New balance ciphertexts (
new_balance_P,new_balance_R,new_balance_R_eff_aud): the sender’s updated available balance after the transfer. - Amount ciphertexts (
amount_P,amount_R_sender,amount_R_recip,amount_R_eff_aud): the transfer amount encrypted under the sender’s, recipient’s, and auditor’s keys. - Voluntary auditor keys and ciphertexts (
ek_volun_auds,amount_R_volun_auds): optional additional auditor encryption keys and amount ciphertexts. - Range proofs (
zkrp_new_balance,zkrp_amount): proving the new balance and transfer amount are non-negative and within range. - Sigma protocol proof (
sigma_proto_comm,sigma_proto_resp): proving the correctness of the transfer.
Withdraw
Section titled “Withdraw”public entry fun withdraw_to_raw( sender: &signer, asset_type: Object<Metadata>, to: address, amount: u64, new_balance_P: vector<vector<u8>>, new_balance_R: vector<vector<u8>>, new_balance_R_aud: vector<vector<u8>>, zkrp_new_balance: vector<u8>, sigma_proto_comm: vector<vector<u8>>, sigma_proto_resp: vector<vector<u8>>)The withdraw_to_raw function allows a user to withdraw tokens from the protocol,
transferring the passed amount from the available balance of the sender to the primary FA store of the recipient.
This function enables users to release tokens while not revealing their remaining balances.
The withdrawn amount itself is publicly visible (as a u64), but the sender’s remaining balance stays hidden.
Rotate Encryption Key
Section titled “Rotate Encryption Key”public entry fun rotate_encryption_key_raw( sender: &signer, asset_type: Object<Metadata>, new_ek: vector<u8>, resume_incoming_transfers: bool, new_R: vector<vector<u8>>, sigma_proto_comm: vector<vector<u8>>, sigma_proto_resp: vector<vector<u8>>)The rotate_encryption_key_raw function modifies the user’s EK and re-encrypts the available balance R components with the new EK.
The resume_incoming_transfers parameter controls whether incoming transfers are unpaused after the rotation.
To facilitate the rotation process:
- The pending balance must first be rolled over and incoming transfers paused by calling
rollover_pending_balance_and_pause. This prevents new transfers from altering the pending balance during the key rotation. - Then the EK can be rotated using
rotate_encryption_key_raw, optionally resuming incoming transfers.
Normalize
Section titled “Normalize”public entry fun normalize_raw( sender: &signer, asset_type: Object<Metadata>, new_balance_P: vector<vector<u8>>, new_balance_R: vector<vector<u8>>, new_balance_R_aud: vector<vector<u8>>, zkrp_new_balance: vector<u8>, sigma_proto_comm: vector<vector<u8>>, sigma_proto_resp: vector<vector<u8>>)The normalize_raw function ensures that the available balance is reduced to 16-bit chunks for efficient decryption.
This is necessary only before the rollover_pending_balance operation, which requires the available balance to be normalized beforehand.
All other functions, such as withdraw_to_raw or confidential_transfer_raw, handle normalization implicitly, making manual normalization unnecessary in those cases.
Pause/Unpause Incoming Transfers
Section titled “Pause/Unpause Incoming Transfers”public entry fun set_incoming_transfers_paused( owner: &signer, asset_type: Object<Metadata>, paused: bool)The set_incoming_transfers_paused function allows a user to pause or unpause incoming confidential transfers.
When paused, other users cannot transfer tokens to this user’s pending balance.
This is primarily used during key rotation to ensure the pending balance remains empty while the rotation is in progress.
Auditors
Section titled “Auditors”The protocol supports auditors who can decrypt transfer amounts for compliance purposes. Auditors hold their own Twisted ElGamal keypair and receive encrypted copies of transfer amounts under their EK.
There are three types of auditors:
- Global auditor — set by governance, applies to all asset types.
- Asset-specific auditor — set per asset type by the asset creator.
- Effective auditor — the asset-specific auditor if set, otherwise the global auditor. This auditor is mandatory: if an effective auditor exists, all transfers must include ciphertexts for it.
- Voluntary auditors — additional auditors specified by the sender at transfer time.
View Functions
Section titled “View Functions”#[view]public fun has_confidential_store( user: address, asset_type: Object<Metadata>): bool
#[view]public fun get_pending_balance( owner: address, asset_type: Object<Metadata>): CompressedPendingBalance
#[view]public fun get_available_balance( owner: address, asset_type: Object<Metadata>): CompressedAvailableBalance
#[view]public fun get_encryption_key( user: address, asset_type: Object<Metadata>): CompressedRistretto
#[view]public fun is_normalized( user: address, asset_type: Object<Metadata>): bool
#[view]public fun incoming_transfers_paused( user: address, asset_type: Object<Metadata>): bool
#[view]public fun get_effective_auditor( asset_type: Object<Metadata>): Option<CompressedRistretto>
#[view]public fun get_auditor_for_asset_type( asset_type: Object<Metadata>): Option<CompressedRistretto>
#[view]public fun get_global_auditor(): Option<CompressedRistretto>