Auditing
The Confidential Token hides balances and amounts from the public, but not from everyone. Every account is bound to an auditor, and each operation that moves private value encrypts the relevant figures to that auditor's key. The zero-knowledge proof of the operation checks this encryption, so it cannot be skipped or faked, and the account holder never has to take a separate step.
This page covers what auditors can see, how auditor keys are managed, and what an auditor operator has to run off-chain.
Dual-Auditor Model
Each account picks an auditor_id when it calls register. A confidential transfer then produces two sets of
ciphertexts in the same transaction:
- One for the recipient's auditor, the auditor bound to the receiving account.
- One for the sender's auditor, the auditor bound to the sending account (for a spender transfer, the owner's auditor).
Withdrawals and set_spender produce ciphertexts for the sender's (owner's) auditor only. A spender's own auditor
receives nothing, either at set_spender or on spender transfers. Deposits, merges, revoke_spender and clawbacks
produce none: a deposit amount is already public, and the others only combine existing commitments (a clawback also
subtracts a public amount).
Each auditor decrypts its ciphertexts from the public event using its own secret key, the ephemeral public key and
the per-operation salt published in that event (for a spender transfer, the new allowance salt). The one exception
is the allowance blinding escrowed at set_spender, which is masked with the spender's address from the event
topic instead of the salt. Decryption needs no viewing key, no cooperation from the account holder and no extra
on-chain read.
Auditing is scoped to accounts: an auditor learns only about operations that involve accounts bound to its
auditor_id. That includes the amount of every transfer between those accounts and counterparties bound to other
auditors.
Auditor Binding
The auditor_id is stored in the account's ConfidentialAccount entry and cannot be changed after
registration. The core contract only checks that the id exists in the auditor registry. Any auditor in a shared
registry is therefore a valid choice unless the deployment restricts it. To allow only designated auditors,
override on_register, as shown in Restrict Auditor Selection.
Only the key registered under an auditor_id can change, through rotation (see
Key Rotation).
What Each Auditor Sees
| Data | Recipient's auditor | Sender's (owner's) auditor |
|---|---|---|
| Transfer amount (transfers and spender transfers) | Yes | Yes |
Per-transfer blinding factor r_transfer | Yes | No |
| Sender's post-transfer balance | No | Yes |
| Post-withdrawal balance | n/a | Yes (the withdrawal amount itself is public) |
Spendable-balance blinding after a withdrawal, outgoing transfer, or set_spender | No | Yes |
Escrowed allowance and post-escrow balance at set_spender | n/a | Yes |
Allowance blinding at set_spender | n/a | Yes |
| Post-transfer allowance and its blinding (spender transfers) | No | Yes |
The recipient's auditor never sees the sender's balance. The auditing specification gives the exact ciphertext layout for each event.
Opening Capabilities
Balances are Pedersen commitments C = v·G + r·H. Knowing both the value v and the blinding r (the
opening) means knowing the balance exactly. The table above gives each auditor enough to reconstruct an
opening in the following cases:
- Recipient's auditor: the receiving balance between merges. Deposits are added with zero blinding, and the
recipient's auditor decrypts the amount and
r_transferof every inbound transfer. So it can sum these to get the full opening of the receiving balance. A merge folds the receiving balance into the spendable balance, and this reconstruction then starts again from zero. A clawback resets it in the same way. - Sender's auditor: the spendable balance from each checkpoint onward. A withdrawal, an outgoing transfer, or
set_spendereach hand the sender's auditor both the new spendable value and its blinding. Together these are a full opening, called a checkpoint. An account uses the same auditor for its sender and recipient channels, so an auditor that also decrypted every inbound flow can carry that opening forward through merges andrevoke_spenderfolds by doing the same additions the contract does.
Both capabilities are forward-only: a key can decrypt only ciphertexts produced while it was the active key. Both also depend on the auditor having actually observed every event involved (see Running an Auditor).
Spender Auditing
Delegated spending uses the same two-auditor model. The table above shows what each auditor learns at
set_spender and on each spender transfer: the recipient's auditor sees a spender transfer like any other, and the
owner's auditor tracks the allowance from the escrowed amount onward. Two things are specific to delegations:
- Revocation publishes no auditor ciphertext. On
revoke_spender, the auditor folds the allowance opening it took from the delegation's lastSetSpenderorSpenderTransferevent back into the owner's spendable balance. A forced revocation emits the sameRevokeSpenderevent and is folded the same way. - Allowance openings exist only in events. Each one comes from the event that wrote that state, not from contract storage, so an auditor that misses a delegation event can't recover that opening later.
See Spenders for the delegation flows themselves.
The Auditor Registry
Auditor public keys live in a separate contract implementing the ConfidentialAuditor trait. The token stores the
registry's address at construction and calls get_key each time it builds the public inputs of an operation that
encrypts to an auditor. It also calls get_key at register, to check that the id exists, and at clawback, whose
proof takes the auditor key as a public input. Because the registry is separate:
- One registry can serve many confidential tokens.
- Keys can be registered and rotated without redeploying any token.
- Key-management authority is separate from the token's admin powers.
pub trait ConfidentialAuditor {
fn register_key(e: &Env, auditor_id: u32, point: BytesN<64>, operator: Address);
fn rotate_key(e: &Env, auditor_id: u32, new_point: BytesN<64>, operator: Address);
fn get_key(e: &Env, auditor_id: u32) -> BytesN<64>; // default implementation provided
}The registry depends on stellar-confidential, like the token (see
Installation). The example below gates all writes
behind a manager role:
use soroban_sdk::{contract, contractimpl, symbol_short, Address, BytesN, Env, Symbol, Vec};
use stellar_access::access_control::{self as access_control, AccessControl};
use stellar_macros::only_role;
use stellar_confidential::auditor::{storage as auditor, ConfidentialAuditor};
const MANAGER_ROLE: Symbol = symbol_short!("manager");
#[contract]
pub struct ConfidentialAuditorContract;
#[contractimpl]
impl ConfidentialAuditorContract {
pub fn __constructor(e: &Env, admin: Address, manager: Address) {
access_control::set_admin(e, &admin);
access_control::grant_role_no_auth(e, &manager, &MANAGER_ROLE, &admin);
}
}
#[contractimpl(contracttrait)]
impl ConfidentialAuditor for ConfidentialAuditorContract {
#[only_role(operator, "manager")]
fn register_key(e: &Env, auditor_id: u32, point: BytesN<64>, operator: Address) {
auditor::register_key(e, auditor_id, &point);
}
#[only_role(operator, "manager")]
fn rotate_key(e: &Env, auditor_id: u32, new_point: BytesN<64>, operator: Address) {
auditor::rotate_key(e, auditor_id, &new_point);
}
}
#[contractimpl(contracttrait)]
impl AccessControl for ConfidentialAuditorContract {}Whoever can call rotate_key decides who can read future auditor ciphertexts for every account bound to that
auditor_id. Gate it at least as tightly as the token's own admin functions.
The same applies one level up. The token's set_auditor storage function has no single-shot guard, so if the token
exposes it, its caller can point every account at a different registry. For accounts whose auditor_id is missing
from the new registry, every operation that needs their key, including transfers to them, then reverts with
AuditorNotRegistered.
Key Rotation
The registry shipped with the library keeps one current key per auditor_id, and rotate_key overwrites it in
place. Production deployments can build a registry that keeps versioned keys with activation ledgers instead.
The token and the circuits don't depend on either design: they always use whichever key the registry currently
returns.
- In-flight proofs fail cleanly. A proof built against the old key no longer matches the key the contract fetches, so verification fails and the call reverts with no state change. The wallet rebuilds the proof against the new key, with a fresh salt, and resubmits. An observer cannot link the retry to the failed attempt.
- The auditor keeps its old secrets. Each historical key decrypts the events produced while it was active. The
registry's
AuditorRotatedevent, which carries the old and the new key, is the on-chain record of each switch. To decrypt an event, the auditor uses it to resolve which of its keys was active at that point and uses that secret. - A retired key keeps what it already opened. Rotation does not revoke openings the old key's holder already has.
- A new key starts with nothing. It can read individual amounts right away, but it holds no full opening of the
spendable balance until the account's next checkpoint (withdrawal, outgoing transfer, or
set_spender), and none of an allowance until the delegation's next state-changing event. Older state can break the spendable opening again after that checkpoint: merging transfers received before the rotation, or revoking a delegation last written before it, leaves it unknown until the next checkpoint. A receiving balance that holds pre-rotation transfers opens only after the next merge. Until it holds both openings, the new key's holder can't build a clawback proof for the account.
Running an Auditor
The library provides the ConfidentialAuditor trait and the auditor::storage helpers; a deployable registry is
the example contract.
It does not include an auditor client. An auditor operator has to run software that:
- Tracks its accounts. It indexes the token's
Registerevents, whose data carries the account'sauditor_id, to learn which accounts are bound to its id and so which channel of each event is its own. - Decrypts every relevant event with its secret key, using the ephemeral key and salt published in the event.
- Maintains openings. It replaces the spendable opening at each checkpoint, adds the amount and
r_transferof every inbound transfer and spender transfer to the receiving opening, treats each deposit as(amount, 0), and folds the receiving opening into the spendable one on eachMerge. On eachRevokeSpenderit folds in the allowance opening it recorded; if it holds no record for that delegation, it marks the spendable opening unavailable until the next checkpoint rather than carry a stale one. Where seizure is enabled, it applies eachClawbackas a merge minus the public amount. - Checks openings against chain state. It recomputes the commitment from its opening and compares it with the stored one. A mismatch means a missed, reordered, or pruned event, not a wrong balance.
- Archives events durably. The blindings escrowed to an auditor exist only in events, and Stellar RPC keeps only a limited window of history. A durable archive is required; see Indexing and Recovery.
- Keeps every historical secret key for as long as old events may need decrypting.
- Produces clawback proofs, where the deployment enables seizure. The auditor's secret key and the openings it maintains are the witness for the Clawback circuit.
Key custody, rotation planning, and how to use the access an auditor key grants are covered in Best Practices for Auditors. The client-side requirements are specified in the Auditor Client specification. The full auditing model, including how each capability is bounded, is in the auditing specification.