Introduction
On March 9th, 2026, zkSecurity was commissioned to perform a security audit of Aptos Labs’ Confidential Assets protocol. Over the course of two weeks (20 engineering days), two consultants reviewed the code and the accompanying specification in search of bugs. A number of observations and findings have been reported to the Aptos Labs team. The findings are detailed in the latter section of this report.
Scope
The scope of the audit covered the Confidential Assets implementation in the Aptos Core repository:
- aptos-core (commit:
d16aa0d0da828c335e117d70e5be5ccaea009ee9), reviewed via PR #18973
All fix commits referenced in this report are relative to the alin/confidential-assets-v1.1 branch in that PR. Note that this branch may be deleted in the future as part of repository cleanup.
The following Move files were audited (~5,200 lines of code):
confidential_asset.move– Main contract (registration, deposit, withdraw, transfer, rollover, normalize, key rotation, governance)confidential_balance.move– Balance types (Pending/Available), chunk arithmeticconfidential_amount.move– Transfer amount ciphertext bundlesconfidential_range_proofs.move– Bulletproofs range proof wrapperristretto255_twisted_elgamal.move– Key generation and basepointsigma_protocols/sigma_protocol.move– Generic prove/verifysigma_protocols/sigma_protocol_fiat_shamir.move– Fiat-Shamir transformsigma_protocols/sigma_protocol_statement.move– Statement typesigma_protocols/sigma_protocol_statement_builder.move– Statement buildersigma_protocols/sigma_protocol_proof.move– Proof typesigma_protocols/sigma_protocol_witness.move– Witness typesigma_protocols/sigma_protocol_homomorphism.move– Homomorphism frameworksigma_protocols/sigma_protocol_representation.move– Representation typesigma_protocols/sigma_protocol_representation_vec.move– RepresentationVecsigma_protocols/sigma_protocol_utils.move– Utilitiessigma_protocols/proofs/sigma_protocol_registration.move– (Schnorr PoK)sigma_protocols/proofs/sigma_protocol_withdraw.move–sigma_protocols/proofs/sigma_protocol_transfer.move–sigma_protocols/proofs/sigma_protocol_key_rotation.move–
The reference specification used during the audit was the How to Veil a Coin paper provided by the Aptos Labs team.
The codebase was found to be of high quality, well-structured, and accompanied by thorough tests. The modular separation between the generic sigma protocol framework and the individual proof instantiations made the code easier to follow and audit.
Confidential Assets
Aptos Confidential Assets is a protocol that enables confidential transfers on the Aptos blockchain. User balances are stored as encrypted ciphertexts on-chain, and transfers reveal neither the amount nor the sender’s or recipient’s balances. The protocol builds on the well-known confidential asset framework introduced by Zether and later modified by PGC and Solana’s confidential transfers. Unlike previous protocols, it has three distinguishing features.
- It supports user key rotation.
- It supports auditor key rotation by maintaining encrypted balances for auditors.
- It only requires computing 32-bit discrete logs while supporting up to 256-bit balances and transferred amounts.
Encryption Scheme
Balances and amounts are encrypted using chunked’n’twisted ElGamal, a variant of Twisted ElGamal that splits a value into multiple -bit chunks and encrypts each chunk individually. A single chunk is encrypted under encryption key as:
where and are generators with unknown discrete log relation, is the plaintext chunk value, and is the encryption randomness. The first component is a Pedersen commitment. Decryption recovers and then solves a -bit discrete log via Baby-Step Giant-Step (BSGS).
In the Aptos deployment, bits per chunk, chunks for available balances (supporting up to total), and chunks for pending balances and transfer amounts (supporting up to ). Decrypting each 16-bit chunk requires only a small BSGS table lookup.
Account State
Each registered user holds a ConfidentialStore per asset type, containing:
| Field | Description |
|---|---|
ek |
User’s compressed encryption key |
pending_balance |
Incoming transfers accumulate here ( chunks: , components) |
available_balance |
Spendable balance ( chunks: , , and components for auditor) |
normalized |
Whether all available balance chunks are within |
transfers_received |
Counter of incoming operations since last rollover (capped at ) |
pause_incoming |
Whether incoming deposits and transfers are blocked (used during key rotation) |
Protocol Operations
The following table summarizes the main entry points in confidential_asset.move and their corresponding specification algorithms:
| Function | Description | ZK Proofs | Spec Reference |
|---|---|---|---|
register |
Register an encryption key for an asset type | : (Schnorr PoK of dk) | VeiledCoins.Register |
deposit |
Transfer public tokens into the user’s confidential [pending] balance. | None (amount is public) | VeiledCoins.Deposit |
rollover_pending_balance |
Merge pending balance into available balance | None (homomorphic addition) | VeiledCoins.Rollover |
rollover_pending_balance_and_pause |
Rollover + pause incoming (before key rotation) | None | VeiledCoins.Rollover |
normalize |
Re-encrypt available balance, prove chunks in range | : (with ) + Bulletproofs | VeiledCoins.Normalize |
withdraw_to |
Withdraw a public amount from available balance | : + Bulletproofs | VeiledCoins.Withdraw |
confidential_transfer |
Transfer a secret amount to another user | : + 2x Bulletproofs | VeiledCoins.Txfer |
rotate_encryption_key |
Rotate user’s EK, re-encrypt components | : | VeiledCoins.UserKeyRot |
Additionally, there are administrative functions for managing auditors and asset allow-listing:
| Function | Description |
|---|---|
set_auditor_for_asset_type |
Set or remove the per-asset auditor EK |
set_global_auditor |
Set or remove the global (fallback) auditor EK |
set_confidentiality_for_asset_type |
Enable or disable confidentiality for an asset type |
set_allow_listing |
Configure the allow-listing policy for asset types |
Each operation that modifies encrypted balances requires one or two types of zero-knowledge proofs to ensure correctness without revealing secret values:
- -protocol proofs prove arithmetic relationships between ciphertexts (e.g., that the new balance equals the old balance minus the withdrawn amount, or that the same plaintext is encrypted under multiple keys). These are instantiated through the generic -protocol framework described in detail in the Sigma Protocols section. The four NP relations (, , , ) each define their own homomorphism and transformation function , but share the same prover, verifier, and Fiat-Shamir machinery. More on them in the next section.
- Bulletproofs range proofs prove that every chunk of a ciphertext lies in , which prevents negative balances and overflow. These are used alongside -proofs in withdrawals (), normalizations (), and transfers (: one for the new balance, one for the transfer amount).
For operations that modify encrypted balances (withdrawals, normalizations, and transfers), the two proof types are always verified together in confidential_asset.move. A -proof alone would be insufficient because it does not constrain the range of chunk values; conversely, a range proof alone would not prove the relationship between old and new balances. Registration and key rotation only require a -protocol proof (no range proof is needed).
Registration. A user registers for a specific asset type by providing their encryption key and a -protocol proof of knowledge of the corresponding decryption key (a standard Schnorr proof). The contract creates a ConfidentialStore with zero balances.
Deposit. A user transfers public fungible asset tokens into the confidential pool. The deposited amount is split into chunks and added to the user’s pending balance as a “no-randomness” encryption: with (since the amount is public, no encryption randomness is needed). The pool holds the actual tokens. No zero-knowledge proof is required since the amount is public.
Rollover. Merges the pending balance into the available balance using the homomorphic property of Twisted ElGamal: the and components are added element-wise. The pending balance is then reset to zero and transfers_received is reset. Rollover requires normalized = true to prevent chunk overflow: after addition, available chunks can be up to bits wide. The auditor’s component is not updated during rollover and becomes stale until the next normalize/withdraw/transfer.
Normalization. Re-encrypts the available balance with fresh randomness and proves via Bulletproofs that every chunk lies in . This is implemented as a withdrawal of amount zero. Normalization is required before the next rollover to bound post-rollover chunk sizes.
Withdrawal. Withdraws a public amount from the available balance. The user provides a new available balance ciphertext (computed offline by decrypting, subtracting , and re-encrypting with fresh randomness) along with:
- A -protocol proof () that the new balance correctly reflects the old balance minus
- A Bulletproofs range proof that every chunk of the new balance is in
The contract replaces the available balance, transfers tokens from the pool to the recipient, and sets normalized = true.
Confidential transfer. Transfers a secret amount from the sender’s available balance to the recipient’s pending balance. The sender provides: the new available balance ciphertext, the encrypted transfer amount under all relevant keys (sender, recipient, effective auditor, and optional voluntary auditors), and three proofs:
- A -protocol proof () that the sender’s balance is correctly decremented, and that the same plaintext amount is encrypted under all keys
- Two Bulletproofs range proofs: one for the new available balance chunks and one for the transfer amount chunks
No real tokens move during a confidential transfer, the pool balance is unchanged. Tokens only enter the pool on deposit and leave on withdrawal.
Key rotation. Allows a user to rotate their encryption key. The protocol requires that incoming transfers are paused and the pending balance has been rolled over and is zero (to avoid undecryptable funds). The user provides the new and re-encrypted components, along with a -protocol proof () that where . The components are unchanged (Pedersen commitments are key-independent) and is unchanged (encrypted under the auditor’s key, not the user’s).
Account State Machine
Each account progresses through a series of states depending on which operations have been performed. The protocol enforces specific preconditions on each operation to maintain invariants.
Pre-rollover states (no available balance yet):
- State 0: After registration. All balances are zero (identity points),
normalized = true,transfers_received = 0. - State 1: After one or more deposits. Pending balance has non-zero components (but since deposits use no randomness). Available balance remains zero.
- State 2: After receiving a confidential transfer. Pending balance has both non-zero and components (transfers use encryption randomness). Available balance remains zero.
Post-rollover states (pending balance has been merged into available):
- State 3: After rollover. Pending balance is reset to zero, available balance contains the accumulated funds.
normalized = false(chunks may exceed bits after addition). The auditor’s component becomes stale. - State 4: After normalize, withdraw, or transfer-out from State 3. Available balance is re-encrypted with fresh randomness and proven in range.
normalized = true, is refreshed. - State 9: After pause + key rotation. The component of the available balance is re-encrypted under the new key. Incoming transfers are paused.
Combined states — unnormalized (both pending and available balances are non-zero):
- States 5-6: After receiving deposits or transfers with both pending and available balances present. The available balance is not yet normalized (chunks may exceed bits).
Combined states — normalized (both pending and available balances are non-zero):
- States 7-8: After normalizing, withdrawing, or transferring out from States 5-6, or after receiving new deposits/transfers. The available balance has been normalized and the auditor’s component is fresh.
Note: The state diagrams above show pause and unpause transitions only where they are most relevant for clarity. In practice, pause and unpause can occur from any state.
Auditing
The protocol supports two levels of optional auditors and an additional voluntary mechanism:
- Global auditor: An optional auditor that applies to all asset types. It can be set or removed by an admin (governance) via
set_global_auditor. - Asset-specific auditor: An optional auditor that can be configured per asset type via
set_auditor_for_asset_type, overriding the global auditor for that asset. - Effective auditor: The auditor whose encryption key is actually used to create components in the available balance and transfer amounts. For a given asset type, the effective auditor is resolved as follows: if an asset-specific auditor is installed, it is used; otherwise, if a global auditor is installed, it is used; otherwise, there is no effective auditor for that asset.
- Voluntary auditors: Optional auditors chosen by the sender per transfer. They receive components for the transfer amount only (not the balance).
The auditor’s available balance ciphertext () becomes stale after a rollover (which adds to and but not ). It is refreshed whenever the user normalizes, withdraws, or transfers out.
Sigma Protocols
Aptos Confidential Assets uses -protocols to prove that state transitions (registration, withdrawal, transfer, key rotation) are performed correctly without revealing secret values such as balances, amounts, or decryption keys. Each protocol proves knowledge of a secret witness satisfying an arithmetic relation , where is a public statement known to the on-chain verifier, is a group homomorphism applied to the witness, and is a transformation function applied to the public statement. All operations are performed over the ristretto255 group.
Framework
The implementation is organized as a generic framework with four protocol-specific instantiations. The core modules provide the data structures (Statement, Witness, Proof), the Fiat-Shamir challenge derivation, and the verification logic. Each of the four proof modules defines its own and functions and the corresponding statement builder.
Proofs follow the standard three-move structure made non-interactive via the Fiat-Shamir transform:
- Commit. The prover picks random and computes commitments .
- Challenge. A challenge scalar and a batching scalar are derived deterministically by hashing (SHA2-512) a domain separator, the statement, and . The batching coefficients used in verification are the powers .
- Respond. The prover computes responses .
The verifier checks the proof by evaluating the batched equation:
where is the identity point. Both and are returned as index-based representations into the statement, allowing the entire check to be performed in a single multi-scalar multiplication (MSM).
Domain Separation and Replay Protection
The Fiat-Shamir challenge binds the proof to a domain separator that includes: the contract address, the chain ID, a protocol identifier string (e.g., "WithdrawalV1"), and a session identifier encoding the transaction’s participants and parameters. The hash input also includes the fully-qualified phantom type name of the protocol, the full statement (points and scalars), the proof commitments, and the witness dimension . The session identifier varies per protocol; for example, the transfer session encodes sender, recipient, asset type, chunk counts, and auditor configuration. This provides strong context separation across protocols and sessions.
Protocol Instantiations
The four instantiated protocols are:
-
Registration (). Proves knowledge of a decryption key such that (equivalently, ), where is the encryption-key basepoint and is the user’s public encryption key. This is a standard proof of discrete logarithm with witness scalar and equation.
-
Key rotation (). Proves correct re-encryption of the available balance under a new encryption key. The prover demonstrates knowledge of the old decryption key , a rotation factor , and its inverse , showing that the new key and each balance ciphertext component . The witness has scalars and the output has equations, where is the number of balance chunks.
-
Withdrawal (). Proves correct balance update when withdrawing a public amount . The prover demonstrates knowledge of the decryption key and the new per-chunk amounts and randomnesses, showing consistency between the old balance, the withdrawn amount, and the new balance commitment. The witness has scalars. When an effective auditor is present, additional equations bind the auditor’s ciphertext component. This protocol is also used for normalization (withdrawal with ).
-
Transfer (). The most complex protocol, proving a correct confidential transfer. The prover demonstrates that the sender’s balance is correctly updated and that the same secret transfer amount is encrypted under the sender’s, recipient’s, and (optionally) auditors’ encryption keys. The witness includes the sender’s decryption key, new balance amounts and randomnesses, and the secret transfer amount and its randomness. The number of equations scales with the number of balance chunks, transfer chunks, and auditors.
Differences Between the Spec and the Implementation
The implementation differs from the paper (specification) in a few notable ways. These differences do not introduce security issues but are worth documenting for clarity.
No mandatory global auditor at deployment. The paper’s VeiledCoins.Deploy algorithm (Algorithm 4) requires a global auditor encryption key (EK) to be set at deployment, along with a zero-knowledge proof of knowledge of the corresponding decryption key (DK) verified via . The implementation does not require setting a global auditor during deployment. The global auditor EK can be installed later via the AuditorKeyRot function. Additionally, the implemented AuditorKeyRot does not require a ZKPoK of the auditor’s DK. The rationale is that if an auditor sets an invalid EK, the only consequence is that the auditor cannot decrypt user balances and amounts.
Multi-asset support. The paper assumes a single asset type. The implementation supports multiple asset types (e.g., APT, USDC), with user registration and balance checks enforced per asset.
Asset-specific auditors and effective auditor resolution. The implementation allows installing an asset-specific auditor that overrides the global auditor for a given asset. The effective auditor for an asset is the asset-specific auditor if set, or the global auditor otherwise (see get_effective_auditor).
Zero deposits (to self). The paper’s VeiledCoins.Deposit algorithm, which is used to add public coins into a user’s own confidential balance, requires the deposited amount to be non-zero. The implementation allows zero deposits. This is benign: a zero deposit is a no-op that only costs the sender gas, and users can only harm themselves by performing one.
Withdrawal to arbitrary address. The paper’s VeiledCoins.Withdraw algorithm only allows withdrawing to the sender’s own address. The implementation allows withdrawing to an arbitrary recipient address via the withdraw_to function.
Batched MSM verification. The paper verifies sigma protocol proofs by directly checking . The implementation derives batching coefficients from the Fiat-Shamir transcript, scales each output dimension by , and checks a single combined multi-scalar multiplication (MSM) equals the identity. This is a standard technique with negligible soundness loss () and is a performance optimization only.
Enhanced Fiat-Shamir domain separation. The paper describes a generic Fiat-Shamir transform . The implementation provides extensive domain separation via a DomainSeparator that binds the contract address, chain ID, a protocol identifier (e.g., "AptosConfidentialAsset/RegistrationV1"), a session ID (BCS-serialized struct containing sender, recipient, asset type, chunk counts, and auditor flags), and the Move type name of a phantom marker. Challenge derivation uses SHA2-512 with a two-pass scheme (seed then challenge plus batching coefficients). This is a significant improvement over the paper, preventing cross-protocol, cross-chain, and cross-context proof replay attacks.
Summary and strategic recommendations
Fiat-Shamir transform
As described above, the Fiat-Shamir transcript binds the contract address, chain ID, protocol identifier, session, the full statement (points and scalars), proof commitments, witness dimension , and the phantom protocol type name. This provides strong context separation across protocols and sessions.
Transaction sequence number. Including the Aptos transaction sequence number in the domain separator would provide an additional layer of replay protection. That said, the current design already benefits from Aptos’s native transaction replay prevention: each transaction includes a sender sequence number enforced at the mempool and execution layers, so replaying an identical transaction is rejected by the framework before proof verification is reached. The practical attack surface is limited to a sender replaying their own older proofs, which does not yield any advantage since the sender already knows their own witness. If integrating the sequence number is straightforward, it would be a low-cost hardening measure. Otherwise, the current guarantees appear sufficient.
Key rotation resume bit. The rotate_encryption_key function accepts a resume_incoming_transfers boolean that controls whether to unpause incoming transfers after rotation. This flag is intentionally excluded from the sigma protocol session and proof. This is correct: the resume bit is an operational choice that does not affect the cryptographic statement being proved (correct re-encryption of the balance), and including it would unnecessarily couple proof generation to an operational (not security) parameter.
Hash construction. The Fiat-Shamir implementation first hashes a common serialized transcript (domain separator, statement, commitments, witness dimension) with SHA2-512, then derives and from sha2_512(transcript_digest || 0x00) and sha2_512(transcript_digest || 0x01) respectively. While a proper sponge construction (e.g., STROBE or TurboSHAKE) would be more principled and extensible, the current two-pass derivation with distinct suffixes is sound in practice: the two derivations operate on non-overlapping hash inputs, and SHA2-512’s collision resistance prevents cross-contamination. We do not see a concrete exploitation path.
Deterministic batching scalars. The batching coefficients used to collapse the verification equations into a single MSM are derived deterministically from the Fiat-Shamir transcript (via the single scalar ) rather than from an independent source of randomness. This is standard practice and currently appears correct: all statement points are committed to in the hash, so the prover cannot adaptively choose a statement that cancels specific equations under the derived values. Note that this property is potentially fragile under refactoring. If a future code change omits a point from the Fiat-Shamir input while still including it in the verification equations, the prover could exploit the deterministic values to forge proofs. The generic sigma protocol framework mitigates this risk by design: the StatementBuilder ensures that every point added to the statement is automatically included in the Fiat-Shamir transcript, so individual protocol implementations cannot accidentally omit elements. The concern would only arise from a change to the framework layer itself, not from adding or modifying a protocol instantiation. Exploring an alternative design that introduces non-deterministic batching (e.g., using on-chain verifiable randomness for the scalars) would be interesting future work to further strengthen this check, provided a suitable randomness source is available that does not weaken the overall security model.
Transcript completeness and relation serialization. The Fiat-Shamir challenge currently binds the domain separator, the statement, and the prover’s commitments, but does not explicitly bind the verification equations and . This is safe today because each proof module hard-codes its own and as compiled Move closures, and thus a prover cannot substitute alternative relations. However, the binding between the challenge and the equations being checked is implicit rather than cryptographic. Since sigma protocols for discrete-log relations are inherently linear in the witness, every and in the current codebase is already a sparse linear representation, where each output row is a linear combination of statement points weighted by witness or statement scalars. Representing these relations as explicit serializable data would allow them to be included in the Fiat-Shamir transcript, making the binding between the challenge and the verification equations explicit rather than relying on the implicit fact that the current relation logic is hard-coded in Move bytecode. The overall code complexity of such a refactoring is expected to be comparable to the current approach.
Sigma protocol test coverage
Originally the project had sigma proof modules with 20 foundational tests covering happy-path proof correctness (proof_correctness), basic soundness against invalid proofs (proof_soundness), and correctness against hand-computed values (psi_correctness). These tests confirm the implementation works on the happy path and rejects some invalid inputs. During the audit, we extended this suite with 87 more granular tests across the four protocols that exercise individual proof components in isolation, test ordering sensitivity, and verify that every element of the statement and proof is actually checked by the verifier. The added tests fall into several categories:
- correctness: The dual of the existing correctness tests, verifying the public transformation function against manually computed values. A bug in could cause the verifier to check the wrong public equation even if is correct.
- Dimension cross-checks: Asserts that the output dimension and witness dimension of each protocol match the values specified in the paper, independently of the code’s own internal length checks.
- Homomorphism verification: Dynamically checks that , confirming the function is actually a group homomorphism as required by the security proof.
- Cross-statement replay: Generates a valid proof for one statement and checks it fails against a different statement with the same session type.
- Exhaustive mutation sweeps: Iterates over every point in the statement, every commitment in the proof, and every response scalar , corrupting each one individually and asserting rejection. This brute-force sweep catches any unchecked element regardless of its role, ensuring every witness component, statement point, and proof element is actually bound by the verifier.
- Adjacent point swap tests: Sweeps all adjacent pairs in the statement and swaps each, asserting rejection. Catches positions where two points are interchangeable without the verifier noticing.
- Auditor boundary tests (withdrawal): Verifies that proofs generated with an auditor fail in a no-auditor context and vice versa, and that swapping the auditor key causes rejection.
These tests were provided to the Aptos team as a basis for more systematically covering the sigma protocol codebase. They are meant as inspiration to be included in the project’s test suite, possibly after refactoring them into a more modular or abstract form.
Architecture decisions
Dust and zero-amount transfer spam. Any user can send a very small amount (or even zero) via confidential_transfer to another user, incrementing the recipient’s transfers_received counter and forcing them to perform a rollover before they can deposit or get some amount transfered to them. Adding a range proof to enforce that the transfer amount is non-zero would not be a practical solution: it would make every transfer more expensive even in benign scenarios, and depending on the token it may still be possible for an attacker to spam transfers for less than the gas cost. This is a known limitation that does not severely affect the protocol.
Auditor key rotation and proof invalidation. Aptos governance can replace the global or asset-specific auditor key via set_global_auditor or set_auditor_for_asset_type. Because the auditor key is bound into the transfer proofs, a governance proposal that rotates the auditor key would invalidate any in-flight user transactions, forcing users to regenerate their proofs with the new key. In practice, this is not a realistic attack vector: standard Aptos governance proposals require approval by a significant fraction of the stake and take multiple days to pass (see details here), making front-running impractical. Emergency governance proposals have a shorter timeline of hours, but have never been used to date.