# Audit of Aptos Confidential Assets

- **Client**: Aptos Labs
- **Date**: March 26th, 2026
- **Tags**: aptos,sigma-protocols,fiat-shamir,confidential-transfers,move

## 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](https://github.com/aptos-labs/aptos-core/tree/alin/confidential-assets-v1.1) (commit: `d16aa0d0da828c335e117d70e5be5ccaea009ee9`), reviewed via [PR #18973](https://github.com/aptos-labs/aptos-core/pull/18973/changes)

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 arithmetic
* `confidential_amount.move` -- Transfer amount ciphertext bundles
* `confidential_range_proofs.move` -- Bulletproofs range proof wrapper
* `ristretto255_twisted_elgamal.move` -- Key generation and basepoint
* `sigma_protocols/sigma_protocol.move` -- Generic prove/verify
* `sigma_protocols/sigma_protocol_fiat_shamir.move` -- Fiat-Shamir transform
* `sigma_protocols/sigma_protocol_statement.move` -- Statement type
* `sigma_protocols/sigma_protocol_statement_builder.move` -- Statement builder
* `sigma_protocols/sigma_protocol_proof.move` -- Proof type
* `sigma_protocols/sigma_protocol_witness.move` -- Witness type
* `sigma_protocols/sigma_protocol_homomorphism.move` -- Homomorphism framework
* `sigma_protocols/sigma_protocol_representation.move` -- Representation type
* `sigma_protocols/sigma_protocol_representation_vec.move` -- RepresentationVec
* `sigma_protocols/sigma_protocol_utils.move` -- Utilities
* `sigma_protocols/proofs/sigma_protocol_registration.move` -- $\mathcal{R}_\text{dl}$ (Schnorr PoK)
* `sigma_protocols/proofs/sigma_protocol_withdraw.move` -- $\mathcal{R}_\text{withdraw}$
* `sigma_protocols/proofs/sigma_protocol_transfer.move` -- $\mathcal{R}_\text{transfer}$
* `sigma_protocols/proofs/sigma_protocol_key_rotation.move` -- $\mathcal{R}_\text{keyrot}$

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.

1. It supports **user key rotation**.
2. It supports **auditor key rotation** by maintaining encrypted balances for auditors. 
3. 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 $b$-bit chunks and encrypts each chunk individually. A single chunk is encrypted under encryption key $\text{ek}$ as:

$$P = v \cdot G + r \cdot H, \quad R = r \cdot \text{ek}$$

where $G$ and $H$ are generators with unknown discrete log relation, $v$ is the plaintext chunk value, and $r$ is the encryption randomness. The first component $P$ is a Pedersen commitment. Decryption recovers $v \cdot G = P - \text{dk} \cdot R$ and then solves a $b$-bit discrete log via Baby-Step Giant-Step (BSGS).

In the Aptos deployment, $b = 16$ bits per chunk, $\ell = 8$ chunks for available balances (supporting up to $2^{128}$ total), and $n = 4$ chunks for pending balances and transfer amounts (supporting up to $2^{64}$). 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 ($n = 4$ chunks: $P$, $R$ components) |
| `available_balance` | Spendable balance ($\ell = 8$ chunks: $P$, $R$, and $R_\text{aud}$ components for auditor) |
| `normalized` | Whether all available balance chunks are within $[0, 2^b)$ |
| `transfers_received` | Counter of incoming operations since last rollover (capped at $2^b = 65536$) |
| `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 | $\Sigma$: $\mathcal{R}_\text{dl}$ (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 | $\Sigma$: $\mathcal{R}_\text{withdraw}$ (with $v=0$) + Bulletproofs | `VeiledCoins.Normalize` |
| `withdraw_to` | Withdraw a public amount from available balance | $\Sigma$: $\mathcal{R}_\text{withdraw}$ + Bulletproofs | `VeiledCoins.Withdraw` |
| `confidential_transfer` | Transfer a secret amount to another user | $\Sigma$: $\mathcal{R}_\text{transfer}$ + 2x Bulletproofs | `VeiledCoins.Txfer` |
| `rotate_encryption_key` | Rotate user's EK, re-encrypt $R$ components | $\Sigma$: $\mathcal{R}_\text{keyrot}$ | `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:

- **$\Sigma$-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 $\Sigma$-protocol framework described in detail in the [Sigma Protocols](#sigma-protocols) section. The four NP relations ($\mathcal{R}_\text{dl}$, $\mathcal{R}_\text{withdraw}$, $\mathcal{R}_\text{transfer}$, $\mathcal{R}_\text{keyrot}$) each define their own homomorphism $\psi$ and transformation function $f$, 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 $[0, 2^b)$, which prevents negative balances and overflow. These are used alongside $\Sigma$-proofs in withdrawals ($1\times$), normalizations ($1\times$), and transfers ($2\times$: 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 $\Sigma$-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 $\Sigma$-protocol proof (no range proof is needed).

**Registration**. A user registers for a specific asset type by providing their encryption key $\text{ek}$ and a $\Sigma$-protocol proof of knowledge of the corresponding decryption key $\text{dk}$ (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 $n$ chunks and added to the user's pending balance as a "no-randomness" encryption: $P_i = v_i \cdot G$ with $R_i = \mathcal{O}$ (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 $P$ and $R$ 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 $2b$ bits wide. The auditor's $R_\text{aud}$ 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 $[0, 2^b)$. 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 $v$ from the available balance. The user provides a new available balance ciphertext (computed offline by decrypting, subtracting $v$, and re-encrypting with fresh randomness) along with:

- A $\Sigma$-protocol proof ($\mathcal{R}_\text{withdraw}$) that the new balance correctly reflects the old balance minus $v$
- A Bulletproofs range proof that every chunk of the new balance is in $[0, 2^b)$

The contract replaces the available balance, transfers $v$ 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 $\Sigma$-protocol proof ($\mathcal{R}_\text{transfer}$) 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 $\text{ek}$ and re-encrypted $R$ components, along with a $\Sigma$-protocol proof ($\mathcal{R}_\text{keyrot}$) that $R_i^\text{new} = \delta \cdot R_i^\text{old}$ where $\delta = \text{dk}_\text{old} / \text{dk}_\text{new}$. The $P$ components are unchanged (Pedersen commitments are key-independent) and $R_\text{aud}$ 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):

![Pre-Rollover States](/img/reports/aptos-confidential-assets/sa.svg)

- **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 $P$ components (but $R = 0$ since deposits use no randomness). Available balance remains zero.
- **State 2**: After receiving a confidential transfer. Pending balance has both non-zero $P$ and $R$ components (transfers use encryption randomness). Available balance remains zero.

**Post-rollover states** (pending balance has been merged into available):

![Post-Rollover States](/img/reports/aptos-confidential-assets/sb.svg)

- **State 3**: After rollover. Pending balance is reset to zero, available balance contains the accumulated funds. `normalized = false` (chunks may exceed $b$ bits after addition). The auditor's $R_\text{aud}$ 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`, $R_\text{aud}$ is refreshed.
- **State 9**: After pause + key rotation. The $R$ 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):

![Combined States (Unnormalized)](/img/reports/aptos-confidential-assets/sc.svg)

- **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 $b$ bits).

**Combined states — normalized** (both pending and available balances are non-zero):

![Combined States (Normalized)](/img/reports/aptos-confidential-assets/sd.svg)

- **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 $R_\text{aud}$ 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 $R_\text{aud}$ 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 $R$ components for the transfer amount only (not the balance).

The auditor's available balance ciphertext ($R_\text{aud}$) becomes stale after a rollover (which adds to $P$ and $R$ but not $R_\text{aud}$). It is refreshed whenever the user normalizes, withdraws, or transfers out.

## Sigma Protocols

Aptos Confidential Assets uses $\Sigma$-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 $w$ satisfying an arithmetic relation $\psi(w) = f(X)$, where $X$ is a public statement known to the on-chain verifier, $\psi \ :  \ \mathbb{F}^k \to \mathbb{G}^m$ is a group homomorphism applied to the witness, and $f \ : \  \mathbb{G}^{n_1} \times \mathbb{F}^{n_2} \to \mathbb{G}^m$ 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 $\psi$ and $f$ functions and the corresponding statement builder.

Proofs follow the standard three-move structure made non-interactive via the Fiat-Shamir transform:

1. **Commit.** The prover picks random $\alpha \in \mathbb{F}^k$ and computes commitments $A = \psi(\alpha) \in \mathbb{G}^m$.
2. **Challenge.** A challenge scalar $e$ and a batching scalar $\beta$ are derived deterministically by hashing (SHA2-512) a domain separator, the statement, and $A$. The batching coefficients used in verification are the powers $1, \beta, \beta^2, \ldots, \beta^{m-1}$.
3. **Respond.** The prover computes responses $\sigma = \alpha + e \cdot w \in \mathbb{F}^k$.

The verifier checks the proof by evaluating the batched equation:

$$\sum_{i=1}^{m} \beta_i \bigl(A_i + e \cdot f(X)_i - \psi(\sigma)_i\bigr) = \mathcal{O}$$

where $\mathcal{O}$ is the identity point. Both $\psi(\sigma)$ and $f(X)$ 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 $k$. 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 ($\mathcal{R}_\text{dl}$).** Proves knowledge of a decryption key $\text{dk}$ such that $\text{dk} \cdot \text{ek} = H$ (equivalently, $\text{ek} = \text{dk}^{-1} \cdot H$), where $H$ is the encryption-key basepoint and $\text{ek}$ is the user's public encryption key. This is a standard proof of discrete logarithm with $k=1$ witness scalar and $m=1$ equation.

- **Key rotation ($\mathcal{R}_\text{keyrot}$).** Proves correct re-encryption of the available balance under a new encryption key. The prover demonstrates knowledge of the old decryption key $\text{dk}$, a rotation factor $\delta$, and its inverse $\delta^{-1}$, showing that the new key $\text{ek}_\text{new} = \delta \cdot \text{ek}$ and each balance ciphertext component $R_i^\text{new} = \delta \cdot R_i^\text{old}$. The witness has $k=3$ scalars and the output has $m = 3 + \ell$ equations, where $\ell$ is the number of balance chunks.

- **Withdrawal ($\mathcal{R}_\text{withdraw}$).** Proves correct balance update when withdrawing a public amount $v$. 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 $k = 1 + 2\ell$ scalars. When an effective auditor is present, additional equations bind the auditor's ciphertext component. This protocol is also used for normalization (withdrawal with $v=0$).

- **Transfer ($\mathcal{R}_\text{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 $\Sigma.\text{Verify}(\mathcal{R}_{\text{dl}}, \text{ek}; \pi)$. 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 $\psi(\sigma) = A + e \cdot f(X)$. The implementation derives batching coefficients $\beta$ from the Fiat-Shamir transcript, scales each output dimension by $\beta^i$, and checks a single combined multi-scalar multiplication (MSM) equals the identity. This is a standard technique with negligible soundness loss ($\sim 1/p$) and is a performance optimization only.

**Enhanced Fiat-Shamir domain separation**. The paper describes a generic Fiat-Shamir transform $e = \text{Hash}(X, A)$. 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 $k$, 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 $e$ and $\beta$ 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 $1, \beta, \beta^2, \ldots, \beta^{m-1}$ used to collapse the $m$ verification equations into a single MSM are derived deterministically from the Fiat-Shamir transcript (via the single scalar $\beta$) 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 $\beta$ 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 $\beta$ 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 $\beta$ 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 $\psi$ and $f$. This is safe today because each proof module hard-codes its own $\psi$ and $f$ 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 $\psi$ and $f$ 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 $\psi$ 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:

- **$f$ correctness**: The dual of the existing $\psi$ correctness tests, verifying the public transformation function $f$ against manually computed values. A bug in $f$ could cause the verifier to check the wrong public equation even if $\psi$ is correct.
- **Dimension cross-checks**: Asserts that the output dimension $m$ and witness dimension $k$ of each protocol match the values specified in the paper, independently of the code's own internal length checks.
- **Homomorphism verification**: Dynamically checks that $\psi(w_1 + w_2) = \psi(w_1) + \psi(w_2)$, 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 $A_i$ in the proof, and every response scalar $\sigma_j$, 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](https://aptos.dev/network/blockchain/governance)), making front-running impractical. Emergency governance proposals have a shorter timeline of hours, but have never been used to date.

## Findings

### Balance Mismatch With Deflationary or Fee-Bearing Dispatchable Fungible Assets

- **Severity**: Medium
- **Location**: confidential_asset.move

**Description**. The `deposit` function in `confidential_asset.move` transfers the user-specified amount into the pool via a dispatchable transfer and then credits the same amount to the user's pending balance. The transfer uses the dispatchable fungible asset interface, whose internal `transfer` logic delegates to `withdraw` and `deposit`:

```rust
public entry fun transfer<T: key>(
    sender: &signer,
    from: Object<T>,
    to: Object<T>,
    amount: u64,
) acquires TransferRefStore {
    let fa = withdraw(sender, from, amount);
    deposit(to, fa);
}
```

The confidential asset deposit performs this transfer and then credits the user unconditionally with the requested amount:

```rust
// Step 1: Transfer the asset from the user's account into the confidential asset pool
let depositor_fa_store = primary_fungible_store::primary_store(addr, asset_type);
dispatchable_fungible_asset::transfer(depositor, depositor_fa_store, pool_fa_store, amount);
```

```rust
add_assign_pending(&mut ca_store.pending_balance, &new_pending_u64_no_randomness(amount));
ca_store.transfers_received += 1;
```

However, [dispatchable fungible assets](https://github.com/aptos-labs/aptos-core/blob/6e1c32c2991ef237a67c5054e13686ffb075c761/aptos-move/framework/aptos-framework/doc/dispatchable_fungible_asset.md) may implement transfer fees, deflationary mechanics, or other hooks that reduce the amount actually received by the pool. In such cases, the pool receives fewer tokens than the amount credited to the user's pending balance.

For example, if a user deposits 100 tokens of a dispatchable FA with a 1% transfer fee, only 99 tokens arrive in the pool, but the user's pending balance is credited with 100. Over multiple deposits, the pool becomes progressively underfunded relative to the total credited balances. This can result in later withdrawals failing because the pool lacks sufficient tokens to cover them.

This issue does not affect APT or standard fungible assets without transfer hooks.

**Impact**. For any confidential asset backed by a deflationary or fee-bearing dispatchable fungible asset, the pool's actual token balance will diverge from the sum of users' credited balances. Users who attempt to withdraw later may find that the pool has insufficient funds, effectively locking their tokens.

**Recommendation**. Compare the pool balance before and after the dispatchable transfer and credit the user's pending balance with the actual amount received rather than the requested amount.

**Client Response**. Fixed in commit `d16aa0d0da828c335e117d70e5be5ccaea009ee9`. 

We completely removed support for DFAs until we better understand how to integrate them. The confidential asset contract enforces non-DFA via the following now:

```rust
fun is_safe_for_confidentiality(asset_type: &Object<fungible_asset::Metadata>): bool {
    !fungible_asset::is_asset_type_dispatchable(asset_type)
}
```

Further, the fix measures the pool balance before and after the transfer, then credits the user's pending balance with the actual amount received rather than the requested amount.

### Auditor Key Epoch Not Tracked Per User Account

- **Severity**: Low
- **Location**: confidential_asset.move

**Description**. The specification defines an `auditorCtr` field on each user account that tracks which auditor encryption key (EK) the account's available balance is encrypted under. Per the paper, this counter is set to the contract's global `auditor.ctr` during:

- **Registration**: `acc.auditorCtr <- C.auditor.ctr` (the initial available balance is encrypted under the current auditor EK)
- **Withdrawal**: `acc.auditorCtr <- C.auditor.ctr` (the new available balance ciphertext includes a fresh auditor component)
- **Transfer** (sender side): `acc.auditorCtr <- C.auditor.ctr` (same reasoning as withdrawal)

The specification notes that this field is write-only on-chain and is meant to be read off-chain by auditors so they can determine whether an account's available balance ciphertext is encrypted under their current EK or a previous one.

In the implementation, the `AuditorEK` struct includes an `epoch` field that is correctly incremented when the auditor key is changed. View functions (`get_global_auditor_epoch`, `get_auditor_epoch_for_asset_type`, `get_effective_auditor_epoch`) exist to query the current epoch. However, the per-user `ConfidentialStore` does not contain an auditor epoch field, and no operation writes the epoch into user account state. As a result, auditors have no on-chain mechanism to determine which auditor EK a given account's available balance is encrypted under.

**Impact**. After an auditor key rotation, auditors cannot distinguish between accounts whose available balances were re-encrypted under the new EK (via a withdrawal, transfer, or normalization) and accounts that still hold ciphertexts under the old EK. This forces auditors to attempt decryption with multiple keys, where each attempt involves solving a 32-bit discrete log and noticing that it fails, or maintain off-chain state tracking all user operations, undermining the auditability guarantees of the protocol.

**Recommendation**. Add an `auditor_epoch` field to the `ConfidentialStore` and set it to the effective auditor epoch during registration, withdrawal, transfer (sender side), and rollover, consistent with the specification.

**Client Response**. 

Fixed in commit `b5d3b6ae650668aa03b1e4d2b87a90fd8b850e2c`.

Actually, we need `ConfidentialStore` to track **both** the auditor EK epoch and the auditor's type via an `is_global[_auditor]` flag.

We've addressed this by adding an `auditor_hint: Option<EffectiveAuditorHint>` to the ConfidentialStore resource, where:

```rust
enum EffectiveAuditorHint has store, drop, copy {
    V1 {
        is_global: bool,
        epoch: u64,
    }
}
```

This `auditor_hint` field is `None` when either:
1. auditing is disabled (no global nor asset-specific auditors are set), or
2. the user just registered (with an empty balance)

Whenever the auditor balance ciphertext is re-encrypted for the auditor (e.g., in a withdrawal, normalization or a transfer), the `auditor_hint` field is set to `Some(...)` with its `is_global` and `epoch` fields set to match the effective auditor.

### Missing Parameter Validation Assertions in init_module

- **Severity**: Low
- **Location**: confidential_asset.move

**Description**. The `init_module` function in `confidential_asset.move` already includes a number of defensive assertions to validate configuration constants at deployment time, such as:

```rust
assert!(signer::address_of(deployer) == @aptos_experimental, error::internal(E_INTERNAL_ERROR));
assert!(math64::pow(2, get_chunk_size_bits()) == get_chunk_upper_bound(), error::internal(E_INTERNAL_ERROR));
assert!(
    bulletproofs::get_max_range_bits() >= confidential_range_proofs::get_bulletproofs_num_bits(),
    error::internal(E_RANGE_PROOF_SYSTEM_HAS_INSUFFICIENT_RANGE)
);
```

However, several important parameter relationships are not explicitly validated. The current constant values satisfy all of these invariants, so the protocol is safe as deployed. But if any constants are changed in a future update without re-verifying these relationships, the consequences could range from broken range proofs to modular wraparound in balance arithmetic, which would break the protocol's security guarantees.

The missing assertions are:

- `CHUNK_SIZE_BITS` (defined in `confidential_balance.move`) must equal `BULLETPROOFS_NUM_BITS` (defined in `confidential_range_proofs.move`). Both are currently 16 but defined in separate modules with no cross-check. If one is updated independently, the range proofs would validate a different bit-width than the balance chunks actually use, silently breaking the protocol's soundness guarantees. The existing check that `bulletproofs::get_max_range_bits() >= BULLETPROOFS_NUM_BITS` only validates that the underlying proof system supports the requested range, it does not enforce that the range proof bit-width matches the chunk size.
- The available balance must have more chunks than the pending balance, required for safe rollover from pending into available.
- The maximum representable available balance ($2^{b \cdot \ell}$) must be smaller than the Ristretto255 scalar field order, otherwise balance arithmetic wraps modularly, breaking correctness.
- The same must hold for the maximum pending/transfer amount ($2^{b \cdot n}$).
- The maximum transfer amount must fit in a `u64`, since `deposit` and `withdraw` use `u64` amounts.

**Recommendation**. Add the following assertions to `init_module` alongside the existing ones:

```rust
// Chunk size matches range proof bits
assert!(get_chunk_size_bits() == confidential_range_proofs::get_bulletproofs_num_bits());
// Available must have more chunks than pending (rollover safety)
assert!(AVAILABLE_BALANCE_CHUNKS > PENDING_BALANCE_CHUNKS);
// B^ell < p, no modular wraparound on available balances
assert!(get_chunk_size_bits() * AVAILABLE_BALANCE_CHUNKS < 252);
// B^n < no modular wraparound on pending/transfer amounts
assert!(get_chunk_size_bits() * PENDING_BALANCE_CHUNKS < 252);
// Max transfer fits in u64, since deposit/withdraw use u64 amounts
assert!(get_chunk_size_bits() * PENDING_BALANCE_CHUNKS <= 64);
```

**Client Response**. 

Fixed in commit `0df064fb42118229b47bf978c9bef639624d80bc`.

Mostly agree, with some minor differences:

1. Removing the redundant `get_bulletproofs_num_bits()` function and replaced it with `get_chunk_size_bits()` in that assert.
2. Asserted that the # of available chunks is $\ge$ (not >) than the # of pending chunks.
3. Asserted that the balances/amounts are $\le$ 252 bits (not $<$ 252)
    - This is because the Ristretto255 group order $\ell$ is a 253-bit number: the highest power of 2 in its binary representation is is 252  (see $\ell$ defined [here]())
    - So any 252-bit number will be $<\ell$.
    - $\Rightarrow$ it is sufficient to check (for example)  $\mathsf{chunk\_size\_bits} \cdot \mathsf{num\_of\_avail\_chunks} \le 252$ because this is guaranteed to be $< \ell$.
4. Asserted that `get_chunk_size_bits() * PENDING_BALANCE_CHUNKS == 64` (not $\ge 64$), because we *must* support all 64-bit amounts.
5. Additionally, checked that `get_chunk_size_bits() * AVAILABLE_BALANCE_CHUNKS == 128`

### Sigma Proof API Visibility Is More Permissive Than Necessary

- **Severity**: Low
- **Location**: sigma_protocol_proof.move, sigma_protocol.move

**Description**. The confidential asset protocol relies on sigma proofs being verified together with their accompanying range proofs to guarantee soundness. A sigma proof alone does not prove non-negativity of balances or transfer amounts, without the range proof, a malicious user could construct proofs for negative values that satisfy the sigma protocol relation but violate the protocol's invariants. The production code in `confidential_asset.move` always verifies both proof types together, so the protocol is currently safe.

However, the module-level API does not enforce this coupling:

1. **`new_proof` is `public`** (`sigma_protocol_proof.move:23`), allowing any on-chain module to construct a `Proof` object. This function only checks that `_A.length() == compressed_A.length()` but does not verify that the compressed points are actually compressions of the decompressed points. In contrast, `new_proof_from_bytes` (`sigma_protocol_proof.move:38`) is correctly restricted to `public(friend)` and guarantees consistency by construction via `deserialize_points`. A third-party module could use `new_proof` to construct a `Proof` with inconsistent compressed and decompressed points.

2. **The sigma protocol `verify` function is `public(friend)`** (`sigma_protocol.move:146`), but the proof and statement types are more broadly accessible. A module building on top of the confidential asset framework could verify a sigma proof without the accompanying range proof, which would be unsound.

The production code is not affected because `confidential_asset.move` is the only friend module that calls `verify`, and it always pairs sigma verification with range proof verification. The concern is that the current visibility allows third-party modules to misuse these primitives.

**Recommendation**. Restrict `new_proof` to `public(friend)` to match `new_proof_from_bytes`. More broadly, review the visibility of proof and statement types across the sigma protocol modules to ensure that the only way to verify a sigma proof on-chain is through the `confidential_asset` module, which enforces the required sigma-plus-range-proof coupling.

**Client Response**. 

Fixed in commit `b680b20d199cf5bf501333ba3d2e29c9ddc4eb47`.

We've minimized visibility throughout the whole codebase.

The only `public` functions remaining in the `confidential_asset/` Move source code are:

**public fun**

* get_num_pending_chunks
* get_num_available_chunks
* get_bulletproofs_dst
* set_allow_listing
* set_confidentiality_for_apt
* set_confidentiality_for_asset_type
* set_asset_specific_auditor
* set_global_auditor
* has_confidential_store
* is_confidentiality_enabled_for_asset_type
* is_allow_listing_required
* get_pending_balance
* get_available_balance
* get_encryption_key
* is_normalized
* incoming_transfers_paused
* get_effective_auditor_hint
* get_effective_auditor_config
* get_total_confidential_supply
* get_num_transfers_received
* get_max_transfers_before_rollover

**public entry fun**

* register_raw
* deposit
* withdraw_to_raw
* confidential_transfer_raw
* rotate_encryption_key_raw
* normalize_raw
* rollover_pending_balance
* rollover_pending_balance_and_pause
* set_incoming_transfers_paused

### Dead Code in Confidential Balance Module

- **Severity**: Informational
- **Location**: confidential_balance.move

**Description**. The following functions are unused in the codebase:

- `set_R` in `confidential_balance.move`, the active variant `set_available_R` is used instead.
- `new_compressed_pending_from_p_and_r` in `confidential_balance.move` (lines 96–99).

Both appear to be remnants of earlier refactors.

**Recommendation**. Remove the dead code to reduce the surface area for confusion and maintenance burden.

**Client Response**. 

Fixed in commit `1b979f73a0f6c1855e4b81c7c70140d2b63734da`, removed dead code.

### Effective Auditor Epoch Can Be Misleading

- **Severity**: Informational
- **Location**: confidential_asset.move

**Description**. The `get_effective_auditor_epoch` view function (line 846) returns the epoch of the *effective* auditor for an asset: the asset-specific auditor's epoch if one is set, or the global auditor's epoch otherwise. The asset-specific and global auditor epochs are maintained as independent counters, each incremented only when its respective auditor key is rotated.

This creates a non-monotonic epoch sequence when the asset-specific auditor is removed. For example:

1. Global auditor is set (`global epoch == 1`).
2. An asset-specific auditor is installed and rotated twice (asset `epoch == 3`). The effective epoch is 3.
3. The asset-specific auditor is removed via `set_auditor_for_asset_type` with an empty key. The effective auditor falls back to the global auditor, and the effective epoch drops from 3 to 1.

Any off-chain system that assumes the effective epoch is monotonically increasing (e.g., to determine whether an account's balance was encrypted under the current auditor key) would misinterpret this transition. The epoch decrease is indistinguishable from a rollback or data corruption. Also it would not be clear from this call wether the current auditor is the global or the asset specific one.

**Recommendation**. Consider returning a richer type from `get_effective_auditor` that distinguishes between asset-level and global auditors (e.g., an enum or struct indicating the auditor source alongside the epoch). This would allow consumers to correctly interpret epoch values without assuming monotonicity across source changes. Alternatively, unify the epoch counter so that any auditor change (asset or global) increments a single per-asset effective epoch.

**Client Response**. 

Fixed in commit `20b4e8538acce4588feb912b5c5aff3a54e86728`.

Fixed by:

1. Removing all APIs that only return the epoch
2. Introducing `AuditorConfig` and `EffectiveAuditorConfig`

To prevent an application developer from calling the global or asset-specific APIs instead of the effective API, we will cautiously deploy only with the effective API as public and leave the other ones as private (so that we can upgrade to public later, if needed).

### Inconsistent Output-Length Assertions Across Sigma Protocols

- **Severity**: Informational
- **Location**: sigma_protocol_withdraw.move, sigma_protocol_transfer.move, sigma_protocol_key_rotation.move

**Description**. The `psi()` functions in all four sigma protocol modules include an output-length assertion (e.g., `sigma_protocol_key_rotation.move:266`) that checks the computed result has the expected dimension. The `f()` function in the key rotation module (`sigma_protocol_key_rotation.move:307`) also includes such an assertion. However, the `f()` functions in the withdrawal (`sigma_protocol_withdraw.move:391`) and transfer (`sigma_protocol_transfer.move:555`) modules do not have this assertion.

This inconsistency does not constitute a vulnerability. The `psi()` functions locally enforce their expected output dimension via `e_wrong_output_len()`, and the generic verifier in `sigma_protocol.move:162` requires `f()` to produce a row count consistent with the proof commitment length `m`. A wrong-length `f()` output would therefore still cause an abort at verification time.

Nevertheless, the assertion in `f()` serves as useful defense-in-depth by catching mismatches earlier and pointing directly to the source of the problem, rather than surfacing the error later during verification. Its presence in key rotation but absence in withdrawal and transfer is a code consistency issue.

**Recommendation**. Unify the defense-in-depth approach across all sigma protocol modules: either add the output-length assertion to the `f()` functions in withdrawal and transfer to match key rotation, or remove it from key rotation if the central verifier check is deemed sufficient.

**Client Response**. Addressed in PR [#18973](https://github.com/aptos-labs/aptos-core/pull/18973) (commit `3044960972`): the `f()` output-length check was made consistent across all four sigma protocols, and the comment was updated to clarify it is for early detection rather than being crucial for security.

### Incorrect Insufficient Amount Test

- **Severity**: Informational
- **Location**: confidential_asset_tests.move

**Description**. The test `fail if insufficient amount` at line 889 of `confidential_asset_tests.move` does not actually test the insufficient amount scenario. In the test, Alice receives 100 coins and deposits 100 to the confidential asset, which succeeds as a normal happy-path operation. The test passes without triggering any expected failure. A correct version of this test should have Alice attempt to deposit more than her available balance (e.g., 200 coins when she only has 100), and assert that the transaction aborts.

**Recommendation**. Fix the `fail if insufficient amount` test to actually trigger the failure case. For example, the following corrected test deposits 200 coins when Alice only has 100, properly triggering an abort:

```rust
// Fixed version: Alice has 100 coins but tries to deposit 200 via the legacy coin path.
#[
    test(
        confidential_asset = @aptos_experimental,
        aptos_fx = @aptos_framework,
        alice = @0xa1
    )
]
#[expected_failure]
fun fail_deposit_with_coins_if_insufficient_amount_v2(
    confidential_asset: signer, aptos_fx: signer, alice: signer
) {
    chain_id::initialize_for_test(&aptos_fx, 4);
    confidential_asset::init_module_for_testing(&confidential_asset);
    coin::create_coin_conversion_map(&aptos_fx);

    let alice_addr = signer::address_of(&alice);

    let (burn_cap, freeze_cap, mint_cap) =
        coin::initialize<MockCoin>(
            &confidential_asset,
            utf8(b"MockCoin"),
            utf8(b"MC"),
            0,
            false
        );

    let coin_amount = coin::mint(100, &mint_cap);
    coin::destroy_burn_cap(burn_cap);
    coin::destroy_freeze_cap(freeze_cap);
    coin::destroy_mint_cap(mint_cap);

    account::create_account_if_does_not_exist(alice_addr);
    coin::register<MockCoin>(&alice);
    coin::deposit(alice_addr, coin_amount);

    coin::create_pairing<MockCoin>(&aptos_fx);

    let token = coin::paired_metadata<MockCoin>().extract();

    let (alice_dk, alice_ek) = generate_twisted_elgamal_keypair();

    register(&alice, &alice_dk, alice_ek, token);
    // Alice only has 100 coins but tries to deposit 200, should abort.
    confidential_asset::deposit(&alice, token, 200);
}
```

**Client Response**. Addressed in PR [#18973](https://github.com/aptos-labs/aptos-core/pull/18973) (commit `3044960972`): the test was fixed.

### Minor Code Inconsistencies and Stale Comments

- **Severity**: Informational
- **Location**: confidential_asset.move

**Description**. Several minor inconsistencies and stale comments were found in `confidential_asset.move`:

1. **Stale comment referencing removed parameter** (line 467). The comment `"Note: Sender's amount is not used;y only included for indexing..."` contains a typo (`;y` should be `; only`) and references a `sender_amount` parameter that existed in v1.0 but no longer exists in v1.1.

2. **Inconsistent section header numbering** (lines 43, 96, 108, 167, 205). Section headers such as `// === Errors (2 out of 15) ===` use an `(N out of M)` numbering scheme where the denominators are inconsistent (15 vs 14) and do not correspond to any obvious count of sections, files, or functions. These appear to be stale from an earlier draft.

3. **Ambiguous comment** (line 599). The comment `"A components remain stale"` inside `rollover_pending_balance` (after `add_assign_available_excluding_auditor`) is unclear. From context, it likely refers to the auditor's $R_{\text{aud}}$ ciphertext components and should say `"R_aud components remain stale"` or `"auditor ciphertext components remain stale"`.

4. **Inconsistent overflow check pattern** (lines 334-340 vs 483-487). In `deposit`, the `transfers_received` counter is checked *before* incrementing (`transfers_received < MAX`), while in `transfer`, the counter is incremented *first* and then checked (`transfers_received <= MAX`). Both paths permit exactly 65,536 incoming operations and the behavioral outcome is identical due to transaction-level rollback on failure. However, the inconsistency reduces readability.

**Recommendation**. Fix the typo and stale parameter reference, update or remove the section header numbering scheme, clarify the ambiguous comment, and align the overflow check pattern across `deposit` and `transfer` for consistency.

**Client Response**. 

Fixed in commits:

* `796de1eab78af61314e0d4a6a72b0e5b27935010` 
* `fc417fb39ea667b13e8deeb5b70fbe6e556a8940`
* `8b370506e3f0e2a46cfe4bf19e6887ad4fae40f0`
* `52108c79d261f7e47c959848ea7ba5ab6639be17`

### Incomplete Event Coverage for State-Mutating Operations

- **Severity**: Informational
- **Location**: confidential_asset.move

**Description**. Events are emitted only for the three value-moving operations: `Deposited` (line 343), `Withdrawn` (line 397), `Normalized` (line 399), and `Transferred` (line 491). The following state-mutating operations do not emit events:

- `register` / `register_raw`: creates a new `ConfidentialStore`
- `rollover_pending_balance` / `rollover_pending_balance_and_pause`: merges pending into available, resets `transfers_received`
- `rotate_encryption_key`_ changes the encryption key and re-encrypts the available balance
- `set_incoming_transfers_paused`: toggles the `pause_incoming` flag
- `set_allow_listing`: enables or disables the asset allowlist
- `set_confidentiality_for_asset_type`: enables confidentiality for an asset
- `set_auditor_for_asset_type`: sets or removes the asset-specific auditor
- `set_global_auditor`: sets or removes the global auditor

Without events for these operations, off-chain indexers and SDKs cannot track account lifecycle (registration, key rotation, pause state) or governance changes (auditor rotations, allowlist updates) without polling or replaying all transactions.

**Recommendation**. Add dedicated events for each of the above operations. The cost is low (one `event::emit` call per function), and the benefit is significant for SDK integration, indexer completeness, and auditor tooling. 

**Client Response**. 

Fixed in commit `a6db14ea6eeac955bd6ef805f3a30311c7a24bab`.

Covered much more events now, and also added more fields to the events.
This will make it easier to index on the Aptos side, and thus easier for Aptos Move developers.

### Statement Builder Lacks Index Assertions for Point Ordering

- **Severity**: Informational
- **Location**: sigma_protocol_transfer.move, sigma_protocol_withdraw.move, sigma_protocol_key_rotation.move, sigma_protocol_registration.move

**Description**. The statement builders in all four sigma proof modules append points in an order that must exactly match the hardcoded `IDX_*` / `START_IDX_*` constants used later by `psi()` and `f()`. Currently, there is no constructor-side assertion of that ordering, even though the builder API already exposes the assigned index: `add_point` and `add_points` both return it.

For example, in `new_transfer_statement`, the code relies on comments and append order alone:

```rust
let b = new_builder();
b.add_point(basepoint_compressed());                    // G
b.add_point(get_encryption_key_basepoint_compressed()); // H
b.add_point(compressed_ek_sender);                      // ek_sender
b.add_point(compressed_ek_recip);                       // ek_recip
b.add_points(compressed_old_balance.get_compressed_P()); // old_P
```

The current ordering was verified to be correct. However, if a future refactor swaps two `add_point` calls or inserts one in the wrong position, the statement can still have the correct length but the wrong semantic layout. The existing `assert_*_statement_is_well_formed()` checks catch size mismatches but not ordering mistakes within a correctly-sized statement.

**Recommendation**. Use the return values of `add_point` and `add_points` to assert the expected index at construction time. For fixed-index points:

```rust
let b = new_builder();
assert!(b.add_point(basepoint_compressed()) == IDX_G, E_BUILDER_ORDER);
assert!(b.add_point(get_encryption_key_basepoint_compressed()) == IDX_H, E_BUILDER_ORDER);
assert!(b.add_point(compressed_ek_sender) == IDX_EK_SENDER, E_BUILDER_ORDER);
assert!(b.add_point(compressed_ek_recip) == IDX_EK_RECIP, E_BUILDER_ORDER);
assert!(b.add_points(compressed_old_balance.get_compressed_P()) == START_IDX_OLD_P, E_BUILDER_ORDER);
```

For variable-sized sections such as voluntary auditors in the transfer statement:

```rust
let compressed_R_volun_auds = compressed_amount.get_compressed_R_volun_auds();
let volun_stride = 1 + get_n(); // ek + n amount_R points per auditor
vector::range(0, num_volun).for_each(|i| {
    let expected_base = START_IDX_VOLUN + i * volun_stride;
    assert!(b.add_point(compressed_ek_volun_auds[i]) == expected_base, E_BUILDER_ORDER);
    assert!(b.add_points(&compressed_R_volun_auds[i]) == expected_base + 1, E_BUILDER_ORDER);
});
```

**Client Response**. 

Fixed in commit `3092430ec2723eb43fe17240160dd90fc56315ad`.

Addressed as recommended, in an abundance of caution.

---

This report was published on the [zkSecurity Audit Reports](https://reports.zksecurity.xyz) site by [ZK Security](https://www.zksecurity.xyz), a leading security firm specialized in zero-knowledge proofs, MPC, FHE, and advanced cryptography. For the full list of audit reports, see [llms.txt](https://reports.zksecurity.xyz/llms.txt).
