Proof System
Every confidential operation that consumes private state comes with a proof that the state change is valid. The circuits are written in Noir and compiled to UltraHonk, and the wallet generates proofs on the client. The token verifies each one with a cross-contract call to a separate verifier registry, which stores one verification key (VK) per circuit and runs the UltraHonk verifier from NethermindEth/rs-soroban-ultrahonk. That verifier supports only the non-zero-knowledge flavor of UltraHonk, so the circuits themselves are built to keep amounts and balances private; see Generating Proofs.
The library ships the circuit sources, the pinned verification keys, and the verifier registry module. It does not ship a prover or client SDK; see Wallet Integration.
Circuits
| Circuit | CircuitType | Entry point | What it proves |
|---|---|---|---|
| Register | Register = 0 | register | The spending and viewing keys are well formed, derive from one secret, and are bound to this token contract |
| Withdraw | Withdraw = 1 | withdraw | The spendable balance covers the public amount; the new commitment is correct; the sender's auditor receives the new balance |
| Transfer | Transfer = 2 | confidential_transfer | The balance covers the hidden amount; amounts are in range; the amount is encrypted correctly for the recipient and for both auditors |
| Spender Transfer | SpenderTransfer = 3 | confidential_transfer_from | The allowance covers the amount; the new allowance commitment is correct; recipient and auditor encryptions are correct |
| Set Spender | SetSpender = 4 | set_spender | The allowance is carved out of the spendable balance; the delegation viewing key is derived and escrowed to the spender correctly |
| Clawback | Clawback = 100 | clawback (compliance extension) | The seized amount does not exceed the target's total holdings; see Clawback |
deposit, merge and revoke_spender have no circuit. They only combine commitments, which the
contract does directly with Grumpkin point arithmetic. deposit first forms the commitment
amount·G, with zero blinding, on-chain.
The CircuitType values are part of the on-chain interface and are never renumbered. Core circuits use
0..=4; circuits shipped by optional extensions start at 100. A deployment that does not enable
clawback does not need the Clawback key.
Elliptic-curve scalar multiplication dominates proving cost. Counted as multi_scalar_mul calls,
Register uses 2, Withdraw 5, Set Spender 7, Transfer and Spender Transfer 8 each, and Clawback 3. The
design targets proof generation in single-digit seconds on contemporary hardware. See
Circuit Cost Analysis.
Verification Flow
For every proof-carrying operation, the token contract:
- Loads the trusted inputs from its own state: the account's current commitments and public keys, the relevant auditor keys from the auditor registry, the delegation entry for spender operations, and its own contract-address field.
- Combines them with values derived from the invocation arguments, such as the withdrawn amount or
the registering address, and with the values the prover supplied in the
datapayload. The order is fixed per circuit. Each field element and point coordinate is checked for canonical encoding. - Calls
verify_proof(circuit_type, public_inputs, proof)on the verifier registry, and reverts withInvalidProofif it returnsfalse. A missing or unparsable key is not afalse: the registry panics withVerificationKeyNotRegistered(#3401) orInvalidVerificationKey(#3403), and that error aborts the token call. - Applies the state change and emits the event.
The contract builds the public inputs itself rather than accepting them from the caller. The verifier
sees only field elements and can't tell which account or contract they describe. If the contract
accepted, say, the sender's public key from the payload, a perfectly sound proof could verify against
the wrong account. Only invocation arguments, which require_auth() covers, and values the circuit
constrains may come from the caller. See
Public Input Sources.
The canonical-encoding check covers a quirk of the Soroban host. The host silently reduces a 32-byte
value ≥ r modulo the field order instead of rejecting it, so two different byte strings could map to
the same field element. The contract rejects non-canonical values with NonCanonicalEncoding before
verification. That way every value it stores or emits has exactly one byte representation.
Proofs are also bound to their context. Owner and clawback proofs commit to a field derived from the token's contract address, which is computed once at construction. Spender-transfer proofs are bound indirectly, through the allowance commitment that a set-spender proof created on that token. A proof for one token deployment therefore never verifies on another, even when both share the same verifier registry. Registration proofs are additionally bound to the registering address. Clawback proofs are bound to the target account, the settlement destination and a per-account nonce.
Verifier Registry
The verifier registry is a separate contract so that VK management is scoped to its own admin, distinct from the token's, and so that keys can be managed without redeploying the token. A single registry can serve every confidential token on the same protocol version, because per-token binding happens inside the circuits, not in the verifier.
The ConfidentialVerifier trait provides verify_proof and get_verification_key as defaults.
Registering and updating keys are privileged, so the implementor writes those two methods with its own
access control:
// `Vec` is used by the AccessControl trait's default methods.
use soroban_sdk::{contract, contractimpl, symbol_short, Address, Bytes, Env, Symbol, Vec};
use stellar_access::access_control::{self as access_control, AccessControl};
use stellar_macros::only_role;
use stellar_confidential::verifier::{
storage as verifier, CircuitType, ConfidentialVerifier,
};
const MANAGER_ROLE: Symbol = symbol_short!("manager");
#[contract]
pub struct ConfidentialVerifierContract;
#[contractimpl]
impl ConfidentialVerifierContract {
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 ConfidentialVerifier for ConfidentialVerifierContract {
#[only_role(operator, "manager")]
fn register_verification_key(
e: &Env,
circuit_type: CircuitType,
verification_key: Bytes,
operator: Address,
) {
verifier::register_verification_key(e, circuit_type, &verification_key);
}
// Shares the `manager` role with registration purely for illustration. See
// "Updating a Verification Key" for what a production deployment should do instead.
#[only_role(operator, "manager")]
fn update_verification_key(
e: &Env,
circuit_type: CircuitType,
new_verification_key: Bytes,
operator: Address,
) {
verifier::update_verification_key(e, circuit_type, &new_verification_key);
}
}
#[contractimpl(contracttrait)]
impl AccessControl for ConfidentialVerifierContract {}The registry depends on stellar-confidential, like the token. See
Installation.
Verification keys live in instance storage. As with every module in this library, extending the instance TTL is the contract developer's responsibility.
Registering Verification Keys
The pinned keys are committed under
circuits/vks/,
two files per circuit:
<name>.vk.bin: the 1760-byte packed binary key the on-chain verifier parses. Register this one.<name>.vk.json: a JSON array of field elements, for review only. The verifier can't parse it.
Register one key per CircuitType before the token goes live:
CircuitType | Key file |
|---|---|
Register | register.vk.bin |
Withdraw | withdraw.vk.bin |
Transfer | transfer.vk.bin |
SpenderTransfer | spender_transfer.vk.bin |
SetSpender | set_spender.vk.bin |
Clawback | clawback.vk.bin (only with the clawback extension) |
Registration fails with VerificationKeyAlreadyRegistered if that circuit already has a key.
The registry stores the bytes without inspecting them. A wrong file, such as a .vk.json, registers
without error and fails only once verify_proof runs, with InvalidVerificationKey. Registration is
single-shot per circuit, so fixing it then takes update_verification_key. After registering, call
verify_proof once per circuit with junk bytes. It returns false if the key parses and panics with
InvalidVerificationKey if it doesn't.
The test below, modeled on the example contract's tests, registers the Transfer key with the registry above and runs that check:
use soroban_sdk::{testutils::Address as _, Address, Bytes, Env};
use stellar_confidential::verifier::CircuitType;
use crate::contract::{ConfidentialVerifierContract, ConfidentialVerifierContractClient};
const TRANSFER_VK: &[u8; 1760] = include_bytes!("vks/transfer.vk.bin");
#[test]
fn register_transfer_key() {
let e = Env::default();
e.mock_all_auths();
let admin = Address::generate(&e);
let manager = Address::generate(&e);
let verifier_id = e.register(ConfidentialVerifierContract, (&admin, &manager));
let client = ConfidentialVerifierContractClient::new(&e, &verifier_id);
// On a live network, the manager signs this same call with the `.vk.bin` contents.
client.register_verification_key(
&CircuitType::Transfer,
&Bytes::from_array(&e, TRANSFER_VK),
&manager,
);
// The key parses: a junk proof returns `false` instead of panicking with
// `InvalidVerificationKey`.
let junk = Bytes::from_array(&e, &[0u8; 32]);
assert!(!client.verify_proof(&CircuitType::Transfer, &junk, &junk));
}The keys can be reproduced from the circuit sources with the pinned toolchain (nargo 1.0.0-beta.11,
bb 0.87.0), and CI fails if a regenerated key differs from the committed one. Publish the circuit
source, the toolchain versions and the key files with every deployment, so that users can check the
keys themselves.
Updating a Verification Key
Updating a verification key is soundness-critical. A wrong, corrupted or malicious key makes its circuit accept forged proofs: minting value, draining accounts, or impersonating users. The registry stores keys as opaque bytes and cannot detect a bad one. Activating a new key also makes every in-flight proof for that circuit fail, so wallets must regenerate and resubmit.
The strongest posture is full immutability: fix the token's dependencies and every verification key at
deployment, and require a fresh deployment for any circuit change. If a deployment exposes
update_verification_key at all, keep it for fixing a discovered soundness bug. Put it behind strong
governance, at minimum a multisig plus a timelock.
Publish enough material for anyone to reproduce the new key before it activates.
The trait has no default for update_verification_key, so every implementation exposes it as an
entry point. For an immutable registry, pass every key to __constructor and register it there with
verifier::register_verification_key. Then make update_verification_key always panic, and
register_verification_key too if no circuit is left to add.
The same applies to the token's link to its verifier. set_underlying_asset and the contract-address
field can only be set once, but set_verifier and set_auditor can be called again. If your token
contract exposes a path that calls set_verifier after construction, that path is as
soundness-critical as a key update. Monitor the registry's verification_key_updated event and the
token's verifier_set event. See
Governance and Upgradeability.
Generating Proofs
Proving happens in the wallet. Proofs the deployed verifier accepts must be generated with the same pinned toolchain and the settings below:
nargo execute --package circuit_<name> <witness_name>
# --oracle_hash keccak is required: the on-chain verifier reproduces the Fiat-Shamir
# transcript with Keccak, while bb defaults to Poseidon2.
# Don't pass --zk: the on-chain verifier implements only the non-zero-knowledge flavor.
bb prove -s ultra_honk --oracle_hash keccak \
-b target/circuit_<name>.json -w target/<witness_name>.gz -o <out_dir>The verifier's README for release 0.1.0 warns that such a proof carries witness-dependent messages and gives no witness-hiding guarantee, so private inputs stay hidden only as far as the circuit hides them. The confidential circuits are built for that setting, with one residual effect on the prover's long-term key. Zero-Knowledge Setting explains both.
Privacy depends on the circuits keeping witness data out of lookup tables. Passing G or H to a
scalar multiplication as a constant, for example, makes Barretenberg use its fixed-base lookup tables,
and the proof then reveals which table rows the secret scalar's windows hit. Anyone modifying the
circuits must keep
check_witness_lookups.sh passing.
The proving recipe is provisional and will be finalized together with the verifier, including the zero-knowledge setting. Check the verification-key README for the current status.
Trusted Setup
UltraHonk relies on a structured reference string (SRS) produced by a setup ceremony. Anyone who knew the ceremony's secret could forge proofs for any statement. The Noir/Barretenberg toolchain uses the Aztec Ignition SRS. That ceremony had 176 independent participants and is secure as long as at least one of them destroyed their contribution. Its transcript and verification code are public.
The verifier contract does not store the full SRS. It embeds only the two G2 points its final pairing check uses. The verification keys are derived from the SRS offline, and anyone with the circuit source and the public transcript can check them. See Structured Reference String.
Further Reading
- Proof System: circuit summary, Noir primitives, on-chain point arithmetic
- Circuit sources