diff --git a/content/stellar-contracts/index.mdx b/content/stellar-contracts/index.mdx index 0bb78d77..6df8c0f2 100644 --- a/content/stellar-contracts/index.mdx +++ b/content/stellar-contracts/index.mdx @@ -16,6 +16,7 @@ for access control and contract management. * **[Non-Fungible Tokens (NFTs)](/stellar-contracts/tokens/non-fungible/non-fungible)**: Unique digital assets with verifiable ownership. * **[Real World Assets (RWAs)](/stellar-contracts/tokens/rwa/rwa)**: Digital assets representing real-world assets. * **[Vault](/stellar-contracts/tokens/vault/vault)**: Digital assets representing a fixed or dynamic supply of identical units. +* **[Confidential Token](/stellar-contracts/tokens/confidential/confidential)**: SEP-41 wrapper with private balances and transfer amounts, verified on-chain with zero-knowledge proofs. ## Access Control @@ -51,7 +52,8 @@ we use the following convention: * RWA: `3XX` * Vault: `4XX` -Any future tokens will continue from `5XX`, `6XX`, and so on. +Any future tokens will continue from `5XX`, `6XX`, and so on. The [Confidential Token](/stellar-contracts/tokens/confidential/confidential) +is the exception: it is split across four modules and uses the four-digit codes listed below. Similarly, utilities and other modules have their own error codes: @@ -65,9 +67,15 @@ Similarly, utilities and other modules have their own error codes: * Access Control: `20XX` * Ownable: `21XX` * Role Transfer (internal common module for 2-step role transfer): `22XX` -* Accounts: `3XXX` +* Accounts: `30XX`–`32XX` +* Confidential Token: `33XX`–`36XX` + * Auditor: `33XX` + * Verifier: `34XX` + * Token: `35XX` + * Compliance: `36XX` * Governance: `4XXX` * Fee Abstraction: `5XXX` +* ZK Email: `6XXX` ## Important Notes As a deliberate design choice, this library manages the TTL for temporary and persistent storage items. diff --git a/content/stellar-contracts/tokens/confidential/auditing.mdx b/content/stellar-contracts/tokens/confidential/auditing.mdx new file mode 100644 index 00000000..8b07823b --- /dev/null +++ b/content/stellar-contracts/tokens/confidential/auditing.mdx @@ -0,0 +1,237 @@ +--- +title: Auditing +--- + +[Source Code](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/confidential/src/auditor) + +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](/stellar-contracts/tokens/confidential/compliance#restrict-auditor-selection). + +Only the **key** registered under an `auditor_id` can change, through rotation (see +[Key Rotation](#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](https://github.com/OpenZeppelin/stellar-contracts/blob/main/packages/confidential/docs/protocol/auditing.md#auditor-visibility-properties) +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_transfer` of 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](/stellar-contracts/tokens/confidential/clawback) + resets it in the same way. +- **Sender's auditor: the spendable balance from each checkpoint onward.** A withdrawal, an outgoing transfer, or + `set_spender` each hand the sender's auditor both the new spendable value and its blinding. Together these are a + full opening, called a *checkpoint*. An account has one auditor key for both its sender and recipient channels. + So an auditor that also decrypted every inbound flow can carry that opening forward through merges and + `revoke_spender` folds 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](#running-an-auditor)). + +## Spender Auditing + +Delegated spending is audited with the same two-auditor model: + +- At `set_spender`, the owner's auditor learns the escrowed amount, the owner's post-escrow balance, and the + blinding of the allowance commitment the operation wrote. +- At each `confidential_transfer_from`, the recipient's auditor gets the amount and `r_transfer`, as for any + transfer. The owner's auditor gets the amount, the remaining allowance and the new allowance blinding. +- `revoke_spender` publishes no auditor ciphertext. The auditor folds the allowance opening it took from the + delegation's last `SetSpender` or `SpenderTransfer` event back into the owner's spendable balance. A + [forced revocation](/stellar-contracts/tokens/confidential/clawback#forced-revocation) emits the same + `RevokeSpender` event and is folded the same way. + +Every allowance opening comes from the event that wrote that state, not from contract storage. An auditor that +misses a delegation event cannot recover that opening later. See [Spenders](/stellar-contracts/tokens/confidential/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. + +```rust +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 +} +``` + +`register_key` and `rotate_key` are privileged and have no default implementation. The implementor wires access +control on `operator` and delegates to the matching function in `auditor::storage`. The `auditor_id` is any `u32` +the registry operator chooses; nothing assigns it automatically. `register_key` reverts with +`AuditorAlreadyRegistered` if the id is taken, so it never overwrites a key, and `rotate_key` reverts with +`AuditorNotRegistered` if the id has no key. There is no function to remove a key. The two writes emit +`AuditorRegistered` (`auditor_id`, `point`) and `AuditorRotated` (`auditor_id`, `old_point`, `new_point`). + +Keys are Grumpkin points encoded as 64 bytes: the `x` coordinate followed by the `y` coordinate, each 32 bytes, +big-endian. Every write rejects the identity point (`IdentityPoint`) and any key that is not canonically encoded or +not on the curve (`PointNotOnCurve`). An identity key would make every auditor ciphertext trivially decryptable, +because the salt is public. + +Keys live in persistent storage. Each `get_key` call extends the entry's TTL to 30 days once it drops below 29 +days, so the tokens' own lookups keep an id in use alive. `register_key` and `rotate_key` don't extend it. An id +that no token reads lasts only as long as its initial TTL unless the operator extends it, and once archived it must +be restored before its next use. + +The example below gates both writes behind a `manager` role. See [Installation](/stellar-contracts/tokens/confidential/confidential#installation) +for the crate dependency and the `alloc` feature it requires. + +```rust +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 `AuditorRotated` event, 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.** For the spendable balance it starts over at the account's next checkpoint + (withdrawal, outgoing transfer, or `set_spender`). For an allowance it starts at the delegation's next + state-changing event. Until then it reads individual amounts but holds no full opening. Even after that + checkpoint, a merge that folds transfers received before the rotation, or a `revoke_spender` of a delegation last + written before it, breaks the spendable opening again until the next checkpoint. If the receiving balance holds + transfers from before the rotation, the new key can open it only after the next merge. A new-key holder therefore + cannot build a clawback proof for an account until it holds both openings. + +## Running an Auditor + +The library provides the `ConfidentialAuditor` trait and the `auditor::storage` helpers; a deployable registry is +the [example contract](https://github.com/OpenZeppelin/stellar-contracts/tree/main/examples/confidential/auditor). +It does not include an auditor client. An auditor operator has to run software that: + +1. **Tracks its accounts.** It indexes the token's `Register` events, whose data carries the account's + `auditor_id`, to learn which accounts are bound to its id and so which channel of each event is its own. +2. **Decrypts every relevant event** with its secret key, using the ephemeral key and salt published in the event. +3. **Maintains openings.** It replaces the spendable opening at each checkpoint, adds the amount and `r_transfer` + of 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 each `Merge`. On each `RevokeSpender` it 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 each + `Clawback` as a merge minus the public amount. +4. **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. +5. **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](/stellar-contracts/tokens/confidential/indexing). +6. **Keeps every historical secret key** for as long as old events may need decrypting. +7. **Produces clawback proofs**, where the deployment enables seizure. The auditor's secret key and the openings + it maintains are the witness for the [Clawback](/stellar-contracts/tokens/confidential/clawback) circuit. + +The client-side requirements are specified in the [Auditor Client specification](https://github.com/OpenZeppelin/stellar-contracts/blob/main/packages/confidential/docs/sdk/auditor-client.md). +The full auditing model, including how each capability is bounded, is in the [auditing specification](https://github.com/OpenZeppelin/stellar-contracts/blob/main/packages/confidential/docs/protocol/auditing.md). diff --git a/content/stellar-contracts/tokens/confidential/clawback.mdx b/content/stellar-contracts/tokens/confidential/clawback.mdx new file mode 100644 index 00000000..78c4434d --- /dev/null +++ b/content/stellar-contracts/tokens/confidential/clawback.mdx @@ -0,0 +1,197 @@ +--- +title: Clawback +--- + +[Source Code](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/confidential/src/compliance) + +Clawback seizes value from a single frozen confidential account. It reduces the account's confidential claim by a +public amount, proves that amount doesn't exceed what the account holds, and settles the matching underlying +tokens over a public path. The name mirrors clawback on Stellar Classic and SAC assets, but the mechanism is +separate: it is delivered by the opt-in `ConfidentialClawback` trait of the [compliance extension](/stellar-contracts/tokens/confidential/compliance). +A deployment that doesn't implement the trait has no seizure capability at all. + +## The Pooled-Custody Problem + +Once a user deposits, the underlying SEP-41 ledger lists the confidential token contract as the holder of those +funds, not the depositor. All deposits sit in one pooled balance. An issuer's SAC-level `clawback` against the +contract's address would debit the whole pool and, with it, every holder. Seizing from one account therefore has to +happen at the confidential layer, by reducing that account's committed claim. + +The contract doesn't know the target's balance: it stores only commitments. So the amount has to be checked against +the committed values without revealing them, and without trusting the admin's word for them. That check is the job +of the clawback proof. + +## Roles + +Seizure is coordinated between parties and no single one can carry it out alone: + +| Role | Decides | Does | +| ---- | ------- | ---- | +| **Token admin** | *Whether* to seize | Freezes the account, then authorizes `force_revoke_spender` and `clawback` through the deployment's access control. | +| **Auditor** | *How much* and *where to* | Holds the secret key of the auditor the account is bound to, plus the openings of the account's balances. Produces the proof, which binds the amount and the destination. | +| **Issuer** (SAC admin) | How the pool is reconciled | When the underlying is a SAC and the seizure leaves the funds in the pool, extracts the surplus with the SAC's own `clawback`. | + +The admin cannot produce the proof and the auditor cannot pass the admin's access check. Deployments usually give +the seizure authority its own role, separate from the role that freezes accounts. + +The proof has to come from the auditor. Knowing an account's balance isn't enough. An account whose history is +only registration, deposits, and merges has balances anyone can reconstruct from public events. The circuit +therefore also requires the auditor's secret key, which stops anyone else from proving a seizure of such an account. + +## Usage + +`ConfidentialClawback` extends `ConfidentialCompliance`, and its two write methods have no default body, for the +same reason. Building on the contract from the [Compliance](/stellar-contracts/tokens/confidential/compliance#usage) +page, with a dedicated `clawback` role: + +```rust +use stellar_confidential::{ + compliance::{ClawbackData, ConfidentialClawback}, + storage::decode_data, +}; + +#[contractimpl(contracttrait)] +impl ConfidentialClawback for ConfidentialTokenContract { + #[only_role(operator, "clawback")] + fn clawback( + e: &Env, + account: Address, + amount: i128, + destination: Option
, + data: Bytes, + operator: Address, + ) { + let data: ClawbackData = decode_data(e, &data); + compliance::clawback(e, &account, amount, &destination, &data.proof); + } + + #[only_role(operator, "clawback")] + fn force_revoke_spender(e: &Env, account: Address, spender: Address, operator: Address) { + compliance::force_revoke_spender(e, &account, &spender); + } +} +``` + +The `data` argument is an XDR-encoded `ClawbackData { proof }`, not the raw proof bytes. A malformed envelope fails +with `InvalidData`. The verifier also needs the `Clawback` key registered (see +[Registering Verification Keys](/stellar-contracts/tokens/confidential/proof-system#registering-verification-keys)). + +The trait also provides `clawback_nonce(account)`, a read that returns how many seizures the account has been +through. The auditor reads it into each new proof (see [Replay Protection](#replay-protection)). + + +The freeze precondition only protects the seizure if frozen accounts are actually blocked. The trait bounds don't +enforce this. With `type Hooks = NoHooks`, `freeze` sets a flag that nothing checks, and the target can move its +balance before the seizure lands. The only sign is an `InvalidProof` error. A deployment that implements +`ConfidentialClawback` has to wire `ComplianceHooks`, or a custom `Hooks` that rejects frozen accounts in every +balance-moving callback. + + +## Seizure Flow + +```mermaid +sequenceDiagram + participant Admin as Token admin + participant Token as Confidential token + participant Auditor + participant Issuer as Issuer (SAC admin) + participant SAC as Underlying SAC + Admin->>Token: freeze(account) + Admin->>Token: force_revoke_spender(account, spender), per delegation + Auditor->>Token: read commitments and clawback_nonce + Auditor-->>Admin: proof for (amount, destination) + Admin->>Token: clawback(account, amount, destination, data) + opt destination is None + Issuer->>SAC: clawback(token contract, amount) + end +``` + +When `clawback` is called, after the access check on `operator`, the contract: + +1. **Checks preconditions.** The account must be frozen (`AccountNotFrozen`), `amount` must be strictly positive + (`InvalidClawbackAmount`), and `destination` must not be the token contract itself (`InvalidClawbackDestination`). +2. **Builds the public inputs** entirely from on-chain state and the call arguments: both of the account's + commitments, the key of its auditor, the amount, the contract and account addresses, the destination, and the + account's clawback nonce. +3. **Verifies the proof** against the clawback circuit. The proof shows that the prover holds the auditor's secret + key, knows the openings of both balances, and that `amount` is no more than spendable plus receiving. +4. **Updates the balances.** The receiving balance is merged into the spendable balance and `amount` is debited, with + no fresh randomness. The owner and the auditor can both compute the new opening from the public `amount`, so the + account stays usable once it is unfrozen. +5. **Advances the clawback nonce**, then settles the underlying (below) and emits a `Clawback` event with topics + `"clawback"` and `account`, and data `amount` and `destination`. + +Error codes are listed under [Errors](/stellar-contracts/tokens/confidential/compliance#errors). + +Like `force_revoke_spender`, `clawback` runs no `Hooks` callback: a freeze-aware hook would reject the very account +it acts on. + +## Settlement + +The `destination` argument, bound into the proof by the auditor, decides what happens to the underlying tokens: + +| `destination` | Underlying | Pool afterwards | +| ------------- | ---------- | --------------- | +| `Some(d)` | Exactly `amount` is transferred to `d` in the same call. | Still equals the sum of all confidential claims. | +| `None` | Nothing moves. | Holds `amount` more than the claims against it, until the issuer extracts the surplus. | + +`d` goes through no compliance check: the auditor chose it when building the proof, and the operator confirms it as +an argument. A proof built for one destination cannot be submitted with another. The underlying's own rules still +apply to the transfer: on a SAC, `d` has to be authorized for the asset (for a `G...` account, an authorized +trustline), or the whole seizure reverts. + +`None` relies on the issuer being able to extract the surplus: + +- The underlying has to be a SAC whose issuer enabled clawback (`AUTH_CLAWBACK_ENABLED`) before the token + contract's first deposit. The SAC fixes a contract balance's clawback flag when the balance is created, so + enabling it later doesn't help. +- Over native XLM or a plain SEP-41 token, nothing can extract the surplus, because the contract has no sweep + function. Use `Some(d)` there. + + +With `destination = None`, **seize first, then extract.** The confidential seizure and the issuer's SAC `clawback` +against the token contract's address are separate transactions, and nothing in the contract enforces their order. +Seizing first leaves the pool briefly over-collateralized, which harms no one. Extracting first leaves it short, and +the holders who withdraw last bear the shortfall. The shortfall becomes permanent if the seizure never goes through, +for example because the auditor produces no proof. An issuer that extracts more than the accumulated surplus creates +the same shortfall. + + +## Forced Revocation + +`clawback` only sees the account's spendable and receiving balances. Allowances escrowed to spenders are out of its +reach. `force_revoke_spender(account, spender, operator)` brings them back. On a frozen account, it performs the +same proofless fold as the owner's `revoke_spender`, under the admin's authorization instead of the owner's. It +emits the same `RevokeSpender` event. Expired delegations can be revoked too: expiry blocks spending, not reclaiming. + +Revocation changes the spendable balance, so revoke every delegation **before** the auditor builds the proof. The +auditor's opening survives the fold only if it observed the delegation's last `SetSpender` or `SpenderTransfer` +event (see [Auditing](/stellar-contracts/tokens/confidential/auditing#spender-auditing)). + +The contract keeps no list of an account's delegations. Find the spenders from the account's `SetSpender` and +`RevokeSpender` events, and confirm each one with `get_spender_delegation`. The freeze blocks new delegations, so +the list can't grow during the seizure. A delegation that is left out isn't seized, and its spender can use it again +once the account is unfrozen, unless it has expired. + +## Replay Protection + +Both commitments are public inputs of the proof. Any change between building the proof and submitting it, such as an +inbound transfer, a merge, or a revocation, makes verification fail with `InvalidProof`. The freeze holds the +commitments still during that window. An auditor key rotation in the same window also invalidates the proof. + +Matching commitments alone don't prove a proof is fresh. A seizure of `amount` followed by a credit of the same +`amount` with zero blinding can restore the exact commitments the old proof was built against. The **clawback +nonce** closes that gap: it is part of every proof and advances on each seizure, so a proof can execute at most once. + +## Wallet and Auditor Consequences + +Owners and auditors apply a `Clawback` event as a merge followed by a public debit of `amount`. The event carries no +encrypted balance, so it doesn't count as a checkpoint for the auditor. It resets the receiving side like a merge +does, and the account's next withdrawal, outgoing transfer, or `set_spender` re-anchors the spendable side as usual. +A `RevokeSpender` emitted by `force_revoke_spender` looks exactly like one initiated by the owner. + +Wallets, auditors, and indexers have to archive `Clawback` events with the rest of an account's history: recovering +a balance depends on them. See [Indexing and Recovery](/stellar-contracts/tokens/confidential/indexing). + +The [clawback specification](https://github.com/OpenZeppelin/stellar-contracts/blob/main/packages/confidential/docs/compliance.md#clawback) +covers the circuit and the contract flow in full. diff --git a/content/stellar-contracts/tokens/confidential/compliance.mdx b/content/stellar-contracts/tokens/confidential/compliance.mdx new file mode 100644 index 00000000..7eaaa5cd --- /dev/null +++ b/content/stellar-contracts/tokens/confidential/compliance.mdx @@ -0,0 +1,446 @@ +--- +title: Compliance +--- + +[Source Code](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/confidential/src/compliance) + +The compliance extension adds optional controls, configured by the deployer, on top of the Confidential Token: +per-account freezing, passthrough of a Stellar Asset Contract's (SAC) authorization, an external authorization +policy, and opt-in [seizure](/stellar-contracts/tokens/confidential/clawback). [Auditing](/stellar-contracts/tokens/confidential/auditing) +is always on and needs none of this. Compliance is for deployments that also have to *restrict* who can use the +token. + +A deployment with no compliance configuration pays one instance-storage read per operation and otherwise behaves +like a plain Confidential Token. + +## Overview + +The [compliance](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/confidential/src/compliance) +module provides: + +- **`ComplianceHooks`**: a ready-made [`Hooks`](/stellar-contracts/tokens/confidential/confidential#hooks) implementation + that runs every token entry point through the active configuration. Wire it as `type Hooks = ComplianceHooks;`. +- **`ConfidentialCompliance`**: the admin-facing trait for `freeze`, `unfreeze`, `set_compliance_config`, and the + `is_frozen` and `compliance_config` reads. +- **`ConfidentialClawback`**: the opt-in seizure trait, covered on the [Clawback](/stellar-contracts/tokens/confidential/clawback) + page. A deployment that doesn't implement it keeps freeze and policy checks but cannot seize funds. +- **`Policy`**: the cross-contract interface for an external allowlist, denylist, KYC, or sanctions registry. +- **Storage helpers** in `compliance::storage` (`gate_account`, `check_policy`, `check_sac`, and others) for + writing custom hooks. + +## Configuration + +All controls are driven by a single `ComplianceConfig` entry in instance storage: + +```rust +pub struct ComplianceConfig { + pub policy: Option
, + pub sac_passthrough: bool, +} +``` + +| Field | Purpose | +| ----- | ------- | +| `policy` | Address of an external [policy contract](#policy-contract), or `None` for no policy check. | +| `sac_passthrough` | When `true`, the check on every fund-holding address also asks the underlying SAC's `authorized()` view (see [SAC Authorization Passthrough](#sac-authorization-passthrough)). | + +The initial configuration is usually written in the constructor with `compliance::storage::set_compliance_config`. +Later changes go through the admin-gated `set_compliance_config` entry point. It also works when no configuration +exists yet, so compliance can be switched on after deployment. The same storage function serves both cases and +replaces the whole configuration in one call. + +Until a configuration exists, `ComplianceHooks` does nothing, `is_frozen` returns `false`, `compliance_config` +returns `None`, and `freeze` and `unfreeze` revert with `NotConfigured`. Once written, a configuration can't be +deleted. Setting `policy: None` and `sac_passthrough: false` turns those checks off, but freezes keep applying. + + +Enable `sac_passthrough` only when the underlying token is a Stellar Asset Contract. `authorized()` is part of the +SAC admin interface, not SEP-41, so over a plain SEP-41 token every checked operation fails on the missing function. + + +## Usage + +The write methods of `ConfidentialCompliance` have no default body, because the choice of access-control module +is yours. Each override authorizes `operator` and then calls the matching storage function. The example below +uses role-based access control, with separate roles for freezing and for configuration changes: + +```rust +// `Bytes` and `Vec` are used by the traits' 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::{ + compliance::{ + storage as compliance, ComplianceConfig, ComplianceHooks, ConfidentialCompliance, + }, + storage as confidential, + ConfidentialAccount, // used by the trait's default methods + ConfidentialToken, + SpenderDelegation, // used by the trait's default methods +}; + +const FREEZER_ROLE: Symbol = symbol_short!("freezer"); + +#[contract] +pub struct ConfidentialTokenContract; + +#[contractimpl] +impl ConfidentialTokenContract { + pub fn __constructor( + e: &Env, + token: Address, + verifier: Address, + auditor: Address, + admin: Address, + freezer: Address, + compliance_config: Option, + ) { + confidential::set_underlying_asset(e, &token); + confidential::set_verifier(e, &verifier); + confidential::set_auditor(e, &auditor); + confidential::set_address_as_field_element(e); + + access_control::set_admin(e, &admin); + access_control::grant_role_no_auth(e, &freezer, &FREEZER_ROLE, &admin); + + if let Some(config) = compliance_config { + compliance::set_compliance_config(e, &config); + } + } +} + +#[contractimpl(contracttrait)] +impl ConfidentialToken for ConfidentialTokenContract { + type Hooks = ComplianceHooks; +} + +#[contractimpl(contracttrait)] +impl ConfidentialCompliance for ConfidentialTokenContract { + #[only_role(operator, "freezer")] + fn freeze(e: &Env, account: Address, operator: Address) { + compliance::freeze(e, &account); + } + + #[only_role(operator, "freezer")] + fn unfreeze(e: &Env, account: Address, operator: Address) { + compliance::unfreeze(e, &account); + } + + // The admin grants the `manager` role after deployment. + #[only_role(operator, "manager")] + fn set_compliance_config(e: &Env, config: ComplianceConfig, operator: Address) { + compliance::set_compliance_config(e, &config); + } +} + +#[contractimpl(contracttrait)] +impl AccessControl for ConfidentialTokenContract {} +``` + +A deployment with a single compliance authority can use [Ownable](/stellar-contracts/access/ownable) and +`#[only_owner]` instead. In that case the macro performs the check and `operator` is informational only. + + +`compliance::storage::set_compliance_config` and the other storage helpers, such as `freeze`, `clawback`, and +`force_revoke_spender`, skip authorization checks. Call them only from the constructor or from entry points that do +their own authorization. + + +## Checks + +When a configuration is present, `ComplianceHooks` runs each relevant address through up to three checks: + +1. **Freeze**: the address must not be frozen (`AccountFrozen`). +2. **Policy**: if `policy` is set, `policy.is_authorized(address, token)` must return `true` (`NotAuthorizedByPolicy`). +3. **SAC**: if `sac_passthrough` is enabled, the underlying SAC's `authorized(address)` must return `true` (`NotAuthorizedBySac`). + +Which addresses go through which checks depends on the entry point: + +| Entry point | Freeze | Policy | SAC | +| ----------- | ------ | ------ | --- | +| `register` | — | `account` | `account` | +| `deposit` | `from`, `to` | `from`, `to` | `from`, `to` | +| `merge` | `account` | `account` | `account` | +| `withdraw` | `from`, `to` | `from`, `to` | `from`, `to` | +| `confidential_transfer` | `from`, `to` | `from`, `to` | `from`, `to` | +| `confidential_transfer_from` | `from`, `to` | `from`, `to`, `spender` | `from`, `to` | +| `set_spender` | `account` | `account`, `spender` | `account` | +| `revoke_spender` | `account` | `account` | `account` | + +`register` skips the freeze check because the account doesn't exist yet. `freeze` doesn't require registration, +though, so an address frozen in advance can still register, but can do nothing else. + +### Freezing + +A frozen account cannot deposit, receive, transfer, withdraw, merge, or manage its spenders. Its commitments stay +exactly as they are, so nothing can accumulate in the account and nothing can leave it until it is unfrozen. Because +a frozen owner is also checked as `from` in `confidential_transfer_from`, freezing the owner halts its delegations +too. + +The freeze targets ownership of funds, so a **spender** is never subject to the freeze or SAC checks: a spender holds +no funds of its own. This matches the allowance models of the library's fungible and RWA tokens. Spenders are +screened by the policy instead. + +Freezing is also the precondition for [clawback](/stellar-contracts/tokens/confidential/clawback). The seizure +entry points don't run the hooks, so they can act on a frozen account. + +### SAC Authorization Passthrough + +With `sac_passthrough = true`, an issuer's own `set_authorized` / deauthorization on the underlying SAC takes +effect at the confidential layer automatically, with no state mirrored by the token admin. The cost is one extra +cross-contract call per checked address per operation. Like the freeze, it checks only addresses that hold funds, +never the spender. + +What the SAC answers depends on the address and the asset: + +- **Classic account (`G...`)**: `authorized` reads the account's trustline for the asset and traps if there is + none. Every checked `G...` address needs a trustline, including the account at `register` and the recipient of a + `deposit` or `withdraw`. A missing trustline fails with the SAC's own error, not `NotAuthorizedBySac`. +- **Contract (`C...`)**: with no SAC balance yet, `authorized` returns `true` unless the asset has + `AUTH_REQUIRED` set. On such an asset, the issuer has to authorize a contract account, such as a smart wallet, + before it can use the token. +- **Native XLM**: `authorized` always returns `true`, so passthrough has no effect. + +## Policy Contract + +The policy is any contract implementing the `Policy` interface: + +```rust +pub trait Policy { + fn is_authorized(e: Env, account: Address, token: Address) -> bool; +} +``` + +This one boolean callback covers allowlists, denylists, and delegation to an identity registry, attestation +provider, or sanctions oracle. List management and identity proofs stay inside the policy contract. The token +passes its own address as `token`, so one registry can serve several confidential tokens and still apply per-token +rules. + +A minimal owner-managed allowlist: + +```rust +use soroban_sdk::{contract, contractimpl, Address, Env}; +use stellar_access::ownable::{self as ownable, Ownable}; +use stellar_macros::only_owner; +use stellar_confidential::compliance::Policy; + +#[contract] +pub struct AllowListPolicy; + +#[contractimpl] +impl AllowListPolicy { + pub fn __constructor(e: &Env, owner: Address) { + ownable::set_owner(e, &owner); + } + + #[only_owner] + pub fn set_allowed(e: &Env, account: Address, allowed: bool) { + // A production registry also manages the TTL of these entries. + if allowed { + e.storage().persistent().set(&account, &true); + } else { + e.storage().persistent().remove(&account); + } + } +} + +#[contractimpl] +impl Policy for AllowListPolicy { + fn is_authorized(e: Env, account: Address, _token: Address) -> bool { + e.storage().persistent().has(&account) + } +} + +#[contractimpl(contracttrait)] +impl Ownable for AllowListPolicy {} +``` + +Unlike the freeze, the policy also screens spenders. `set_spender` checks the spender when the delegation is +granted, and `confidential_transfer_from` checks it again at spend time. `revoke_spender` deliberately doesn't check +the spender: revocation is the owner's way out of a delegation to a spender that has since become non-compliant. + +The policy address can be rotated, or removed by setting it to `None`, with `set_compliance_config`. It is part of +the deployment's trust surface. A deployment over a SAC can rely on `sac_passthrough` and skip the policy entirely. + +## Customizing the Hooks Trait + +`ComplianceHooks` is one implementation of `Hooks`. Deployments that need different behavior write their own +implementation, usually reusing the same `compliance::storage` primitives and changing only the callbacks that need +it. A callback only has to revert, with `panic_with_error!`, to reject an operation. + + +Every `Hooks` method has an empty default body. A custom implementation has to **forward every callback it doesn't +customize** to `ComplianceHooks` explicitly. Any callback it leaves out becomes ungated, which silently disables +freezing for that operation and makes the clawback freeze precondition meaningless. + + +### Exempt Unregistered Depositors from Screening + +`deposit` is the one operation where `from` may be an address that never registered: the depositor only needs to +hold the underlying token. The default `ComplianceHooks::on_deposit` screens `from` whether or not it is registered. +An unregistered depositor gets through if it isn't frozen, passes the policy, and, with `sac_passthrough`, is +authorized by the SAC. Under an allowlist that lists only onboarded users, that rejects every outside payer. + +This implementation exempts an unregistered sender from the freeze and policy checks and keeps only the SAC check: + +```rust +use soroban_sdk::{Address, Env}; +use stellar_confidential::{ + compliance::{storage as compliance, ComplianceHooks}, + storage::account_exists, + Hooks, RegisterPayload, SetSpenderPayload, SpenderTransferPayload, TransferPayload, + WithdrawPayload, +}; + +pub struct PermissiveDepositHooks; + +impl Hooks for PermissiveDepositHooks { + fn on_deposit(e: &Env, from: &Address, to: &Address, _amount: i128) { + let Some(config) = compliance::compliance_config(e) else { + return; + }; + if account_exists(e, from) { + compliance::gate_account(e, from, &config); + } else if config.sac_passthrough { + // The SEP-41 transfer would fail anyway for a depositor the SAC + // has unauthorized. + compliance::check_sac(e, from, &config); + } + compliance::gate_account(e, to, &config); + } + + // Every other callback keeps the default compliance behavior. + fn on_register(e: &Env, account: &Address, auditor_id: u32, payload: &RegisterPayload) { + ComplianceHooks::on_register(e, account, auditor_id, payload); + } + + fn on_merge(e: &Env, account: &Address) { + ComplianceHooks::on_merge(e, account); + } + + fn on_withdraw( + e: &Env, + from: &Address, + to: &Address, + amount: i128, + payload: &WithdrawPayload, + ) { + ComplianceHooks::on_withdraw(e, from, to, amount, payload); + } + + fn on_transfer(e: &Env, from: &Address, to: &Address, payload: &TransferPayload) { + ComplianceHooks::on_transfer(e, from, to, payload); + } + + fn on_spender_transfer( + e: &Env, + spender: &Address, + from: &Address, + to: &Address, + payload: &SpenderTransferPayload, + ) { + ComplianceHooks::on_spender_transfer(e, spender, from, to, payload); + } + + fn on_set_spender( + e: &Env, + account: &Address, + spender: &Address, + live_until_ledger: u32, + payload: &SetSpenderPayload, + ) { + ComplianceHooks::on_set_spender(e, account, spender, live_until_ledger, payload); + } + + fn on_revoke_spender(e: &Env, account: &Address, spender: &Address) { + ComplianceHooks::on_revoke_spender(e, account, spender); + } +} +``` + +Wire it with `type Hooks = PermissiveDepositHooks;`. This suits deployments that accept inbound payments from +counterparties that never register, such as an exchange wallet paying into a payroll pool. Skipping the *policy* +check on an unregistered sender is a trade-off, not a recommendation: it also lets in an outside address that the +policy would deny, or that the admin froze before it registered. A deployment that screens with a denylist or +sanctions policy doesn't need this variant, because the default `ComplianceHooks` already accepts unregistered +depositors that the policy allows. + +### Permit Deposits Only for Oneself + +Require the depositor to be the recipient, so nobody can deposit on someone else's behalf. For example, this +prevents unsolicited "dusting" deposits that complicate auditor bookkeeping: + +```rust +impl Hooks for SelfDepositOnlyHooks { + fn on_deposit(e: &Env, from: &Address, to: &Address, amount: i128) { + if from != to { + panic_with_error!(e, ComplianceError::NotAuthorizedByPolicy); + } + ComplianceHooks::on_deposit(e, from, to, amount); + } + + // ...forward every other callback to ComplianceHooks, as above... +} +``` + +### Restrict Auditor Selection + +At `register`, the account owner picks the `auditor_id` the account binds to. The core only checks that the id +exists in the auditor registry, and `ComplianceHooks::on_register` deliberately leaves the choice open. On a shared +registry, a deployment with designated auditors checks the choice against its own approved set: + +```rust +impl Hooks for ApprovedAuditorHooks { + fn on_register(e: &Env, account: &Address, auditor_id: u32, payload: &RegisterPayload) { + let approved: Vec = e + .storage() + .instance() + .get(&DataKey::ApprovedAuditors) + .unwrap_or_else(|| Vec::new(e)); + if !approved.contains(auditor_id) { + panic_with_error!(e, DeploymentError::AuditorNotApproved); + } + ComplianceHooks::on_register(e, account, auditor_id, payload); + } + + // ...forward every other callback to ComplianceHooks, as above... +} +``` + +`DataKey::ApprovedAuditors` and `DeploymentError::AuditorNotApproved` are defined by the deployment. The list is +filled at construction or through an admin entry point. An account's `auditor_id` can never change after +registration, so this check is the only point where a deployment controls which auditors may ever see an account's +activity. + +The same mechanism supports per-deposit rate limits, allowlists keyed on the amount, writes to a separate audit +log, or any other check that has to run atomically with the token operation. For purely observational needs, +subscribe to the token's events instead. + +## Events + +| Event | Topics | Data | Emitted by | +| ----- | ------ | ---- | ---------- | +| `Frozen` | `"frozen"`, `account` | — | `freeze` | +| `Unfrozen` | `"unfrozen"`, `account` | — | `unfreeze` | +| `ComplianceConfigChanged` | `"compliance_config_changed"` | `policy`, `sac_passthrough` | Every `compliance::storage::set_compliance_config` call, including the one in the constructor | + +Seizures emit `Clawback` and, for forced revocation, `RevokeSpender` (see +[Clawback](/stellar-contracts/tokens/confidential/clawback#seizure-flow)). + +## Errors + +| Error | Code | Raised when | +| ----- | ---- | ----------- | +| `NotConfigured` | 3600 | `freeze` or `unfreeze` is called before a configuration exists | +| `AccountFrozen` | 3601 | A checked address is frozen | +| `NotAuthorizedByPolicy` | 3602 | The policy returns `false` for a checked address | +| `NotAuthorizedBySac` | 3603 | The SAC's `authorized` returns `false` for a checked address | +| `AccountNotFrozen` | 3604 | The target of `clawback` or `force_revoke_spender` isn't frozen | +| `InvalidClawbackAmount` | 3605 | The `clawback` amount isn't strictly positive | +| `InvalidClawbackDestination` | 3606 | The `clawback` destination is the token contract itself | + +On a deployment with no configuration, `is_frozen` always returns `false`, so `clawback` and `force_revoke_spender` +fail with `AccountNotFrozen` (3604), not `NotConfigured`. + +See the [compliance specification](https://github.com/OpenZeppelin/stellar-contracts/blob/main/packages/confidential/docs/compliance.md) +for the full design rationale. diff --git a/content/stellar-contracts/tokens/confidential/confidential.mdx b/content/stellar-contracts/tokens/confidential/confidential.mdx new file mode 100644 index 00000000..e4fa38b6 --- /dev/null +++ b/content/stellar-contracts/tokens/confidential/confidential.mdx @@ -0,0 +1,279 @@ +--- +title: Confidential Token +--- + +[Source Code](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/confidential) + +The Confidential Token adds private balances and transfers to any SEP-41 token. Users deposit regular tokens +into a confidential token contract, and from that point on their balances and the amounts they transfer are hidden +from the public while remaining fully verifiable by the network. Every operation that spends private state carries +a zero-knowledge proof that the contract verifies on-chain before updating any balance. + +The module provides **confidentiality, not anonymity**: sender and recipient addresses stay visible on-chain, +while amounts and balances do not. + +## Overview + +The [confidential](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/confidential) +module wraps an existing SEP-41 token rather than replacing it. Balances are stored as Pedersen commitments on the +Grumpkin curve, which the contract updates homomorphically (by adding and replacing curve points) without ever +learning the amounts behind them. Recipients and auditors recover the amounts that concern them by decrypting +ciphertexts published in the contract's events. + +| Hidden | Visible | +| ----------------------------------------------------------- | ------------------------------------------------------------------------------- | +| Balances (spendable, receiving, and spender allowances) | Sender and recipient addresses of every operation | +| Amounts moved by confidential transfers | Deposit and withdrawal amounts, since they cross into or out of the public token | +| | Which operation was called, and when | + +Building confidentiality as a separate contract has several advantages: + +- **Works with any SEP-41 token**, including XLM through its Stellar Asset Contract (SAC). No changes are required + from the issuer, as long as the token satisfies the [underlying token requirements](#underlying-token-requirements). +- **Evolves independently** of the token standard: upgrades to the privacy layer never touch the underlying asset. +- **Is opt-in**: the underlying token keeps working as it does today, and users choose when to move funds into + and out of the confidential layer. + +## Architecture + +### Core Components + +A deployment consists of three contracts wired together: + +- **Confidential Token**: Implements the `ConfidentialToken` trait. It holds all deposited tokens in a single pooled + balance, stores one `ConfidentialAccount` per registered address and one `SpenderDelegation` per owner-spender pair, + and calls the verifier for every operation that requires a proof. +- **Auditor Registry**: Implements the `ConfidentialAuditor` trait and stores auditor public keys indexed by + `auditor_id`. A single registry can serve many tokens. See [Auditing](/stellar-contracts/tokens/confidential/auditing). +- **Verifier Registry**: Implements the `ConfidentialVerifier` trait and stores one UltraHonk verification key per + circuit. It can be shared by every token that runs the same protocol version. + See [Proof System](/stellar-contracts/tokens/confidential/proof-system). + +```mermaid +graph LR + W[Wallet] -->|operation + proof| T[Confidential Token] + T -->|verify_proof| V[Verifier Registry] + T -->|auditor key lookup| A[Auditor Registry] + T <-->|deposit / withdraw| U[Underlying SEP-41 Token] + T -.->|events| I[Indexer] + I -.->|event history| W +``` + +### Off-Chain Components + +Because the contract never sees balances or confidential transfer amounts, a large part of the protocol runs on the +client: + +- **Wallet**: Derives keys, generates proofs, decrypts events, and tracks the openings (value and blinding factor) + of the account's commitments. See [Wallet Integration](/stellar-contracts/tokens/confidential/wallet-integration). +- **Indexer**: A durable archive of the contract's events, which wallets and auditors need to recover state. + See [Indexing and Recovery](/stellar-contracts/tokens/confidential/indexing). +- **Circuits**: Six Noir circuits compiled to UltraHonk, shipped in the + [`circuits`](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/confidential/circuits) + directory together with their pinned verification keys. + + +The library ships the on-chain modules (traits and storage helpers), example auditor and verifier registry contracts +under [`examples/confidential`](https://github.com/OpenZeppelin/stellar-contracts/tree/main/examples/confidential), +and the Noir circuits. The client SDK, the indexer, and the +[selective disclosure](/stellar-contracts/tokens/confidential/selective-disclosure) layer are specified in the +[protocol documentation](https://github.com/OpenZeppelin/stellar-contracts/blob/main/packages/confidential/docs/README.md) +but are not part of the library. + + +## Key Concepts + +| Concept | Description | +| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Commitment** | A balance stored as a single curve point `C = v·G + r·H`. The contract sees only the point; the opening `(v, r)` lives in the owner's wallet. | +| **Spendable balance** | The part of an account's funds available for transfers and withdrawals. Only operations the owner authorizes modify it, apart from the [compliance extension](/stellar-contracts/tokens/confidential/clawback)'s seizure functions. | +| **Receiving balance** | A separate accumulator where incoming deposits and transfers land. | +| **Merge** | An owner-authorized operation that folds the receiving balance into the spendable balance. It requires no proof. | +| **Zero-knowledge proof** | Generated by the wallet and attached to an operation. It proves the operation is valid (sufficient funds, correct balance updates, honest encryption) without revealing any amount. See [Zero-Knowledge Setting](#zero-knowledge-setting) for the current status. | +| **Auditor** | A key chosen by each account at registration. Every transfer (direct or by a spender), withdrawal, and spender setup produces ciphertexts that this key can decrypt. | +| **Spender** | A registered address authorized by an owner to spend from a capped, time-limited allowance. See [Spenders](/stellar-contracts/tokens/confidential/spenders). | + +## Installation + +The confidential token lives in its own crate, `stellar-confidential`, separate from `stellar-tokens`. The crate is +not published on crates.io yet, because its UltraHonk verifier backend is itself a git dependency. Add it from the +repository, pinned to a commit: + +```toml +[dependencies] +# Use the soroban-sdk version that stellar-contracts uses at the pinned commit. +soroban-sdk = { version = "28.0.0", features = ["alloc"] } +stellar-confidential = { git = "https://github.com/OpenZeppelin/stellar-contracts", rev = "" } +``` + +The verifier backend links Rust's `alloc` crate, so every contract that depends on `stellar-confidential` (the token +and both registries) must provide exactly one global allocator. Either enable the `alloc` feature of `soroban-sdk`, as +above, or register your own `#[global_allocator]`, but not both. Without an allocator, the wasm build fails with +`no global memory allocator found but one is required`. See +[Packaging](https://github.com/OpenZeppelin/stellar-contracts/blob/main/packages/confidential/README.md#packaging) +for the reasons behind this setup. + +## Usage + +Deploying a confidential token involves three steps: + +1. Deploy the verifier registry and register the verification key of each circuit. + See [Proof System](/stellar-contracts/tokens/confidential/proof-system). +2. Deploy an auditor registry, or reuse an existing one, and register at least one auditor key. + See [Auditing](/stellar-contracts/tokens/confidential/auditing). +3. Deploy the confidential token, passing the underlying token, the verifier registry, and the auditor registry + to its constructor. + +Here's what a basic confidential token contract looks like: + +```rust +use soroban_sdk::{contract, contractimpl, Address, Bytes, Env}; +use stellar_confidential::{ + storage as confidential, ConfidentialAccount, ConfidentialToken, NoHooks, SpenderDelegation, +}; // `Bytes`, `ConfidentialAccount` and `SpenderDelegation` are used by the trait's default methods + +#[contract] +pub struct ConfidentialTokenContract; + +#[contractimpl] +impl ConfidentialTokenContract { + pub fn __constructor(e: &Env, underlying_asset: Address, verifier: Address, auditor: Address) { + // The SEP-41 token being wrapped. Set once; cannot be changed. + confidential::set_underlying_asset(e, &underlying_asset); + // The verifier and auditor registries. + confidential::set_verifier(e, &verifier); + confidential::set_auditor(e, &auditor); + // Binds every proof to this contract's address. Set once; cannot be changed. + confidential::set_address_as_field_element(e); + } +} + +#[contractimpl(contracttrait)] +impl ConfidentialToken for ConfidentialTokenContract { + type Hooks = NoHooks; +} +``` + +The `ConfidentialToken` trait provides default implementations for every entry point: `register`, `deposit`, +`merge`, `withdraw`, `confidential_transfer`, `confidential_transfer_from`, `set_spender`, `revoke_spender`, and the +read methods. Each state-changing entry point authorizes the caller, decodes the `data: Bytes` payload built by the +wallet, runs the matching hook, verifies the proof when one is required, and applies the state change. +See [Operations](/stellar-contracts/tokens/confidential/operations) for what each one does. + +### Initialization + +The setters in `storage` bypass authorization checks and should only be called from the constructor or from +admin functions that implement their own access control: + +- `set_underlying_asset` and `set_address_as_field_element` are **single-shot**: a second call reverts. + The address field is derived from the contract's own address and bound into every account's key derivation, + so changing it would invalidate every registered account. +- `set_verifier` and `set_auditor` can be called again. Rotating either one changes which proofs the contract + accepts or which keys auditor ciphertexts are encrypted under, so any rotation path should sit behind a + multisig or a timelock. The strongest posture is not to expose one at all. A new auditor registry also + re-points every account's `auditor_id`: an account whose id is missing from it can no longer spend. Either + rotation also invalidates proofs that are in flight, which wallets then rebuild with a fresh salt. + +## Hooks + +The `Hooks` associated type is the extension point of the token. Its callbacks run at every state-changing entry point, +after authorization and payload decoding, and before any state is modified: + +- `on_register`, `on_deposit`, `on_merge`, `on_withdraw` +- `on_transfer`, `on_spender_transfer` +- `on_set_spender`, `on_revoke_spender` + +Callbacks of operations that carry a payload receive the decoded payload (the proof itself is not forwarded). +Every callback has an empty default body, so an implementation overrides only what it needs, and rejects an operation +by panicking with an error. + +Two implementations are provided: + +- `NoHooks`: every callback is a no-op. Use it for deployments without additional controls. +- `ComplianceHooks`: account freezing, SAC authorization passthrough, and an external authorization policy. + See [Compliance](/stellar-contracts/tokens/confidential/compliance). + +For pure observability, subscribe to the token's events instead of implementing a hook. + +## Underlying Token Requirements + +The contract credits and debits confidential balances by the exact amount it passes to the underlying token's +`transfer`, without re-measuring its own balance. The underlying token must therefore: + +- **Move exactly the requested amount**: no fee deducted in transit. +- **Not rebase**: the contract's balance changes only through transfers the contract itself initiates. +- **Revert on failure**: a failed `transfer` must revert the whole `deposit` or `withdraw`. + +The Stellar Asset Contract and the OpenZeppelin [Fungible Token](/stellar-contracts/tokens/fungible/fungible) meet +these requirements. Wrapping any other token is the deployer's responsibility. + + +When the underlying token is a SAC, its issuer has two powers over the contract's pooled balance. +**Freezing or deauthorizing** the contract's address blocks every deposit and withdrawal for all holders until it +is lifted; confidential transfers between registered accounts keep working. **Clawing back** from the contract's +address removes value from the pool, leaving it holding less than the sum of all confidential balances: withdrawals +become first-come-first-served, and whoever exits last cannot fully redeem. See +[Clawback](/stellar-contracts/tokens/confidential/clawback) for how to seize funds from one account without +creating a shortfall for everyone else. + + +## Security Considerations + +### Zero-Knowledge Setting + + +The on-chain verifier currently implements only the non-zero-knowledge UltraHonk flavor, so proofs are generated +without the zero-knowledge setting and carry no witness-hiding guarantee: a proof can leak information about its +private inputs, which include keys, balances, blinding factors, and amounts. Until the verifier supports the +zero-knowledge flavor, the confidentiality of balances and amounts is not backed by the proof system. The proving +recipe is provisional and will be finalized together with the verifier. See +[Generating Proofs](/stellar-contracts/tokens/confidential/proof-system#generating-proofs) for the current status. + + +### Verification Keys + +Soundness rests on the verification keys in the verifier registry matching the intended circuits. A wrong key makes +the contract accept forged proofs, so updating a key should be treated as a break-glass operation. +See [Proof System](/stellar-contracts/tokens/confidential/proof-system). + +### Griefing Resistance + +Incoming deposits and transfers only touch the recipient's receiving balance, while spend proofs reference only the +spendable balance. A third party can therefore not invalidate an owner's in-flight proof by sending funds, and +cannot trigger a merge on someone else's account since merging requires the owner's authorization. + +### Replay Protection + +Every spend proof takes the current on-chain commitment as a public input, and a successful operation replaces that +commitment. A replayed proof references a commitment that no longer exists and fails verification, so no separate +nonce is needed. + +### Key Custody + +A viewing key cannot move funds, but it is not a safely shareable read-only credential. Whoever holds it can read +the account's balances and incoming amounts, and can retroactively open the commitments of the transfers the +account sent. Auditor keys deserve the same level of custody. To prove a single fact to a third party, use +[selective disclosure](/stellar-contracts/tokens/confidential/selective-disclosure) instead of sharing a key. + +### Confidentiality Limits + +Addresses are public, and so are the amounts that enter and leave through `deposit` and `withdraw`. Correlating +those amounts over time can reveal information that the confidential layer otherwise hides, so integrations +should not assume that every amount is private. + +## TTL Management + +Accounts and spender delegations live in persistent storage. Whenever an operation reads one, the library extends +its TTL back to 30 days once fewer than 29 remain. A new entry starts with the network's minimum persistent TTL +until an operation first reads it. An entry that is not touched for longer than its TTL is archived and must be +restored before its next use. + +The underlying token, verifier, auditor, and address field are stored in **instance storage**, whose TTL is the +responsibility of the deployer, as with every other module in this library. + +## Specification + +This documentation covers what is needed to deploy and integrate the module. The normative +[protocol specification](https://github.com/OpenZeppelin/stellar-contracts/blob/main/packages/confidential/docs/README.md) +covers the cryptographic construction, every circuit constraint, and the companion specifications for compliance, +the SDK, indexing, and selective disclosure. diff --git a/content/stellar-contracts/tokens/confidential/indexing.mdx b/content/stellar-contracts/tokens/confidential/indexing.mdx new file mode 100644 index 00000000..dbebdcad --- /dev/null +++ b/content/stellar-contracts/tokens/confidential/indexing.mdx @@ -0,0 +1,146 @@ +--- +title: Indexing and Recovery +--- + +[Specification](https://github.com/OpenZeppelin/stellar-contracts/blob/main/packages/confidential/docs/indexer.md) + +A confidential balance can only be spent by someone who knows its opening, and the contract never stores openings. A +wallet that loses its local state rebuilds them from its keys and the account's event history. This page describes +that recovery procedure and the durable event archive, the *indexer*, it depends on. + + +This page summarizes the indexer specification that accompanies the library. The library does not ship an indexer. +Deployments that want users to be able to recover from seed need to run, or contract, an archive that meets this +specification. + + +## Why an Indexer Is Required + +Stellar RPC keeps events only for a bounded window, which a node reports through `getHealth` as `oldestLedger` and +`ledgerRetentionWindow`. Once an account's events fall out of that window, a wallet without local state can still see +its commitments on-chain but cannot reconstruct the openings needed to spend them. Without a conforming archive, +wallet-local state should be treated as unrecoverable once events age out of RPC. Wallets with RPC-only access must +therefore sync at least once per retention window and warn when they have not: a crediting event, or a +`RevokeSpender` after the latest checkpoint, that ages out before the wallet applies it is lost for good. + +RPC and the archive work together. A client can read the recent tail from RPC and older history from the archive, +stitching the two at a *seam*. The seam sits strictly above the RPC retention floor, with a margin, because the floor +advances while the client is querying. The two legs cover disjoint ledger ranges, and the client still deduplicates +by event id at the boundary. The archive does not have to keep up with the chain head, but it must never fall behind +the seam. If a configured archive fails, +the whole sync fails: silently falling back to RPC alone and saving the resulting sync position would skip the +archived range forever. + +## How Recovery Works + +Recovery uses two anchors in the account's history: + +```mermaid +graph LR + R[Register] -.-> T0["T₀: last Merge or Clawback
at or before the checkpoint"] + T0 --> CP["Latest checkpoint
Withdraw, outgoing Transfer, or SetSpender"] + CP --> NOW[Now] +``` + +1. **Spendable side: the latest checkpoint.** Every withdrawal, outgoing transfer, and `set_spender` emits the owner's + new spendable balance, encrypted under the viewing key, together with the salt that determines the new blinding. + The latest such event recovers the spendable opening in a single lookup, without any earlier history. An account + with no checkpoint since `Register` starts its spendable opening at zero. +2. **Receiving side: replay from `T₀`.** The receiving balance has no checkpoint. The wallet finds `T₀`, the most + recent merge or clawback at or before the checkpoint (or `Register` if there is none), starts the receiving + opening at zero, and replays every later event in order: + - Deposits and incoming transfers credit the receiving side, including the incoming side of a self-transfer. + - Checkpoint events leave the spendable side alone, since step 1 already captured it. + - A `RevokeSpender` at or before the checkpoint is skipped, since the checkpoint already absorbed it. One after + the checkpoint folds into the spendable side. + - A merge after the checkpoint folds as usual, and a clawback folds the same way and then subtracts its public + `amount` from the spendable side. +3. **Verify.** The wallet re-commits both openings and compares them against the on-chain commitments. + +The replay window is set by how long the account went without merging before its latest checkpoint, not by how often +it spends. Wallets that merge regularly keep it short; in the worst case, funds received but never merged, it reaches +back to registration. See +[Recovery](https://github.com/OpenZeppelin/stellar-contracts/blob/main/packages/confidential/docs/protocol/wallet-state.md#recovery) +for the exact procedure. + +Recovery needs only the viewing key, the event history, and the on-chain commitments, and nothing from the auditor, +counterparties, or the contract admin. Because commitments are binding, a tampered or truncated history cannot +produce an opening that verifies; it produces a detectable mismatch. Incoming-transfer spam, even zero-value, costs +the recipient indexer storage and replay time but never affects correctness. + +## What the Indexer Stores + +For every in-scope event, the archive keeps the contract id, ledger sequence and close time, transaction hash, the +transaction's position within its ledger, the event index, and the event's topics and data. Verbatim XDR is the +recommended payload format: it is the on-chain wire form and survives renames in the Rust bindings. A decoded form is +also acceptable if it reproduces the on-chain event exactly. + +**Events in scope.** These events play a role in recovery: + +- `Register` starts an account's history and bounds the worst-case replay. +- `Deposit` and the recipient side of `Transfer` and `SpenderTransfer` are replayed into the receiving opening. +- `Merge` and, with the compliance extension, `Clawback` are the receiving-side anchors. +- `Withdraw`, the sender side of `Transfer`, and `SetSpender` are spendable-side checkpoints. Spenders recover their + allowance from the on-chain delegation entry, not from the archive. +- `RevokeSpender` folds the reclaimed allowance back into the spendable opening. The compliance extension's forced + revocation emits the same event. + +Configuration and compliance events are not needed for balance recovery, but are worth archiving for forensics. + +**Account attribution.** An event belongs to every account address in its topics (a `Transfer` to both sender and +recipient, a `SpenderTransfer` to owner, recipient, and spender), never to the transaction's source account. The +indexer can filter per account on the server, which is recommended for busy contracts, or serve the whole contract +stream for clients to filter. + +**Ordering.** Replay is order-sensitive, so the archive preserves the total order +`(ledger_seq, tx_application_order, event_index)`. The event id `(ledger_seq, tx_hash, event_index)` identifies an +event but does not order it within a ledger. + +## Ingestion and Retention + +- **Source.** Any source that yields the complete, final event stream works: Stellar RPC `getEvents`, Horizon, or a + captive core. Stellar ledgers are final at close, so there are no reorgs to handle. +- **Freshness and gaps.** The archive tracks which ledger ranges it has ingested and backfills gaps while the source + still has them. A gap that can no longer be filled is permanent and must be reported as incomplete. +- **Idempotency.** Ingestion is at-least-once, deduplicated by event id. +- **Retention.** The full per-account history is kept indefinitely, because no pruning horizon is safe. A dormant + account's latest checkpoint can be arbitrarily old, an account that never merges replays from registration, and a + `RevokeSpender` after the latest checkpoint is the only surviving record of the allowance it folded, since the + delegation entry is deleted in the same call. + +## API Capabilities + +The specification defines capabilities rather than a transport, with a recommended REST shape in +[API Surface](https://github.com/OpenZeppelin/stellar-contracts/blob/main/packages/confidential/docs/indexer.md#api-surface): + +- **Ordered history** (required): all in-scope events for an account within a ledger range, in total order, + paginated, each with its event id and payload. +- **Completeness signal** (required): every response states whether the indexer holds a gap-free history for the + whole requested range. +- **Ingestion status** (required): the latest fully ingested ledger, so clients can bound staleness and check it + against their seam. +- **Latest checkpoint** (recommended): the most recent checkpoint for an account at or before a given ledger, so a + dormant account need not download the spend history before it. It does not return `T₀`, so the client still reads + the ordered history from `T₀` onward, which for an account that never merged is its whole history. + +The specification is versioned with the protocol documentation; breaking changes to the archived record or the +required capabilities bump that version. + +## Trust Model + +The indexer is trusted only for availability and completeness, never for confidentiality or integrity: + +- **Confidentiality.** Everything it holds is public chain data, so a curious indexer learns nothing a chain observer + does not. +- **Integrity.** Wallets verify reconstructed openings against the on-chain commitments, so tampering is detected. +- **Withholding.** A malicious or broken indexer can deny recovery. RPC independently serves the recent window, and + for older history, wallets should support several independent archive endpoints, with deployments running or + contracting at least two. Clients must pass the completeness signal on to their callers, because an incomplete + range and a tampered one both end in the same failed consistency check. + +### Auditor Recovery + +Auditors depend on the archive even more than holders do. The blinding of each allowance commitment is escrowed only +in the `SetSpender` or `SpenderTransfer` event that wrote it, never in contract storage, so a missed or unarchived +event leaves the auditor without that opening. An auditor client should check each reconstructed allowance opening +against the stored allowance commitment. See [Auditing](/stellar-contracts/tokens/confidential/auditing). diff --git a/content/stellar-contracts/tokens/confidential/operations.mdx b/content/stellar-contracts/tokens/confidential/operations.mdx new file mode 100644 index 00000000..369ae41a --- /dev/null +++ b/content/stellar-contracts/tokens/confidential/operations.mdx @@ -0,0 +1,255 @@ +--- +title: Operations +--- + +[Source Code](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/confidential) + +The `ConfidentialToken` trait exposes eight state-changing entry points and three read methods. Every +state-changing entry point authorizes its caller with `require_auth()`. Operations that consume private state also +carry a zero-knowledge proof, which the token checks through the +[verifier registry](/stellar-contracts/tokens/confidential/proof-system) before changing any storage. + +This page covers the owner's lifecycle: registering, moving value in and out, and transferring. +Delegated spending has its own page: [Spenders](/stellar-contracts/tokens/confidential/spenders). + +## Entry Points + +| Function | Authorized by | Proof | Effect | +| --- | --- | --- | --- | +| `register(account, auditor_id, data)` | `account` | Yes | Creates the confidential account, with both balances at zero | +| `deposit(from, to, amount)` | `from` | No | Pulls `amount` of the underlying token into the contract and credits `to`'s receiving balance | +| `merge(account)` | `account` | No | Moves the receiving balance into the spendable balance | +| `confidential_transfer(from, to, data)` | `from` | Yes | Debits `from`'s spendable balance and credits `to`'s receiving balance with a hidden amount | +| `withdraw(from, to, amount, data)` | `from` | Yes | Debits `from`'s spendable balance and sends `amount` of the underlying token to `to` | +| `set_spender(account, spender, live_until_ledger, data)` | `account` | Yes | Locks part of the spendable balance into an allowance for `spender` | +| `confidential_transfer_from(spender, from, to, data)` | `spender` | Yes | Spends from the allowance `from` granted to `spender` | +| `revoke_spender(account, spender)` | `account` | No | Returns the remaining allowance to the spendable balance | + +Each entry point checks authorization first, then decodes `data`, then runs the deployment's +[`Hooks`](/stellar-contracts/tokens/confidential/confidential#hooks) callback, then hands off to the storage layer. The +storage layer verifies the proof, updates state and emits the event. + +## Spendable and Receiving Balances + +Every account holds two balances, each stored as a Pedersen commitment `C = v·G + r·H` that hides the +value `v` behind a blinding factor `r`: + +- **Spendable balance**: what the owner can send, withdraw, or delegate. Only operations the owner + authorizes can change it, except in deployments with the compliance extension, where the admin's + [`clawback`](/stellar-contracts/tokens/confidential/clawback) and `force_revoke_spender` can too. +- **Receiving balance**: where deposits and incoming transfers land. The contract adds to it + homomorphically, with no proof or action from the recipient. + +The split prevents griefing. A spend proof references the spendable commitment as it is on-chain when +the proof is built. Incoming transfers touch only the receiving commitment, so no other user can +change the commitment an in-flight proof references. An account can also receive any number of transfers +without the owner having to do anything. See +[Griefing Resistance](https://github.com/OpenZeppelin/stellar-contracts/blob/main/packages/confidential/docs/protocol/security.md#griefing-resistance). + +A proof is also tied to the auditor keys and the verification key in force when it is built. If the +deployment rotates an [auditor key](/stellar-contracts/tokens/confidential/auditing#key-rotation) or the +[verifier](/stellar-contracts/tokens/confidential/proof-system#updating-a-verification-key) while a proof +is in flight, the call reverts with `InvalidProof` and no state change. The wallet rebuilds the proof with a +fresh salt and resubmits. + +The cost is that received funds must be merged before they can be spent. + +## Register + +An account must register before it can receive deposits or transfers. The wallet derives a spending +key and a viewing key from a single secret (see [Wallet Integration](/stellar-contracts/tokens/confidential/wallet-integration)) +and proves the two public keys are well formed and tied to this token contract. + +The caller also picks an `auditor_id`. It must exist in the auditor registry; an unknown id reverts with +the registry's `AuditorNotRegistered`. Once set it cannot change, +and it decides which auditor receives the encrypted copies of this account's activity (see +[Auditing](/stellar-contracts/tokens/confidential/auditing)). The core does not restrict which auditor an account +picks; deployments that need to restrict it do so in `Hooks::on_register`. + +Registration is single-use: a second call for the same address reverts with `AccountAlreadyRegistered`. +The contract checks this after the auditor lookup and proof verification, so a retry with an invalid +proof fails with `InvalidProof` first. The proof is also bound to the registering address, so published +registration data cannot be replayed to create a duplicate-key account under a different address. + +## Deposit + +```rust +// `from` authorizes the underlying SEP-41 transfer; `to` must be registered. +token_client.deposit(&depositor, &recipient, &500); +``` + +The contract pulls `amount` of the underlying token from `from` and adds a zero-blinding commitment +`amount·G` to `to`'s receiving balance. No proof is needed because the amount is already public. The +depositor doesn't need a confidential account; only the recipient does. Negative amounts revert with +`NegativeAmount`. + +## Merge + +```rust +token_client.merge(&owner); +``` + +Merge adds the receiving commitment to the spendable commitment and resets the receiving balance to +zero. It needs no proof: adding two commitments yields a commitment to the sum of their values and +blinding factors, so no value is created or lost. Only the owner can authorize it. + +The owner's wallet knows the new opening because it has tracked every deposit and transfer credited to +the receiving balance. Wallets should prompt a merge before any spend so that received funds are +available. + +## Confidential Transfer + +```rust +// `transfer_data` is the XDR-encoded `TransferData` built by the sender's wallet. +token_client.confidential_transfer(&sender, &recipient, &transfer_data); +``` + +The sender's wallet proves that the sender holds at least the transfer amount, that the new spendable +commitment is the old balance minus the amount, and that the amount is encrypted correctly for the +recipient and both auditors. The contract checks the proof, replaces the sender's spendable +commitment, and adds the transfer commitment to the recipient's receiving balance. + +```mermaid +sequenceDiagram + participant S as Sender wallet + participant T as Confidential Token + participant A as Auditor registry + participant V as Verifier registry + participant R as Recipient wallet + + S->>T: confidential_balance(sender), confidential_balance(recipient) + S->>A: get_key(sender auditor_id), get_key(recipient auditor_id) + S->>S: Build proof, commitments and ciphertexts + S->>T: confidential_transfer(from, to, data) + T->>A: get_key(...) for both auditor ids + T->>V: verify_proof(Transfer, public_inputs, proof) + V-->>T: true + T->>T: Replace sender spendable, add to recipient receiving + T-->>R: Transfer event + R->>R: Decrypt amount, update receiving balance +``` + +The recipient's wallet watches for `Transfer` events where it is `to`, and also for `SpenderTransfer` +events, which credit it the same way (see +[Spenders](/stellar-contracts/tokens/confidential/spenders#spending-from-an-allowance)). It derives a +shared secret from the event's ephemeral public key and its own viewing key. With that secret and the +event's salt, it decrypts the amount and derives the blinding factor. The recipient does nothing on-chain. + +Observers see that `from` transferred to `to`. They do not see the amount or either balance. + +## Withdraw + +```rust +// `withdraw_data` is the XDR-encoded `WithdrawData` built by the owner's wallet. +token_client.withdraw(&owner, &destination, &200, &withdraw_data); +``` + +The wallet proves that the spendable balance covers `amount` and that the new commitment is correct. +The contract replaces the spendable commitment and sends `amount` of the underlying token to `to`, which +can be any address. The withdrawn amount is public; the remaining balance stays hidden. + +## Payload Encoding + +Proof-carrying operations take a single `data: Bytes` argument: the XDR encoding of a `…Data` struct +(`RegisterData`, `WithdrawData`, `TransferData`, `SetSpenderData`, `SpenderTransferData`). Each struct +contains the operation's `payload` and the `proof` bytes. The payload holds the values the prover +produced: the two public keys for `register`, and the new commitments, ephemeral key, salt and +ciphertexts for the other operations. All of these come from the wallet. The contract loads everything +else itself, from its own storage, the auditor registry, or the call's arguments (such as `amount`). + +```rust +use soroban_sdk::xdr::ToXdr; +use stellar_confidential::{TransferData, TransferPayload}; + +// `payload` and `proof` are produced by the wallet's prover. +let data = TransferData { payload, proof }.to_xdr(&e); +token_client.confidential_transfer(&sender, &recipient, &data); +``` + +The structs are `#[contracttype]` values and Soroban's XDR encoding is canonical, so any client +compiled against the same type definitions produces identical bytes. A payload that does not decode +reverts with `InvalidData`. A field or point coordinate that is not the canonical encoding of its field +element reverts with `NonCanonicalEncoding` (see +[Proof System](/stellar-contracts/tokens/confidential/proof-system)). + +## Authorization Model + +Authorization and proofs do different jobs, and every proof-carrying operation requires both. +`require_auth()` proves that the named address approved this exact invocation, arguments included. The +proof shows that whoever built it knows secrets consistent with the on-chain state the contract +supplies. Because of this, a proof is useless to anyone who can't also authorize as the account, and an +authorization is useless without a valid proof. + +In the core, `confidential_transfer_from` is the one case where the funds' owner does not sign: the owner +approved the spender ahead of time at `set_spender`. Deployments with the compliance extension add two +admin-authorized exceptions, `clawback` and `force_revoke_spender` (see +[Clawback](/stellar-contracts/tokens/confidential/clawback)). + +Proofs cannot be replayed. Each spending proof takes the current spendable (or allowance) commitment +as an input, and a successful call replaces that commitment. A replayed proof therefore points at a +commitment that no longer exists and fails verification. No nonce or nullifier is needed. + +## Events + +Events are how wallets, auditors and indexers learn what happened, because storage holds only +commitments. + +- `Register`, `Deposit` and `Merge` carry only addresses, the `auditor_id`, and the public deposit + amount. +- `Transfer` carries the ephemeral public key, the salt, the amount encrypted for the recipient, the + sender's encrypted new balance (for the sender's own recovery), and the ciphertexts for the + recipient's and the sender's auditors. +- `Withdraw` carries the public amount, the ephemeral key, the salt, the encrypted new balance, and the + sender-auditor ciphertexts. +- `SetSpender`, `SpenderTransfer` and `RevokeSpender` are covered on the + [Spenders](/stellar-contracts/tokens/confidential/spenders#events) page. + +A wallet that loses its local state rebuilds both balances from these events. It restores the spendable +balance from its latest checkpoint (a `Withdraw`, a `Transfer` it sent, or a `SetSpender`), then replays +the events that follow. Stellar RPC +keeps events for a limited window only, so recovery depends on a durable archive (see +[Indexing and Recovery](/stellar-contracts/tokens/confidential/indexing)). + +## Read Methods + +```rust +let account = token_client.confidential_balance(&owner); +let active = token_client.is_spender(&owner, &spender); +let delegation = token_client.get_spender_delegation(&owner, &spender); +``` + +`confidential_balance` returns the `ConfidentialAccount` stored for an address. It reverts if the +address isn't registered. + +| Field | Description | +| --- | --- | +| `spending_public_key` | Grumpkin point authorizing spends; set at registration | +| `viewing_public_key` | Grumpkin point senders use to encrypt for this account; set at registration | +| `spendable_commitment` | Commitment to the spendable balance | +| `receiving_commitment` | Commitment to the receiving balance | +| `auditor_id` | Auditor key index fixed at registration | + +The balances are commitments, not numbers. Only a wallet holding the account's openings can turn them +into amounts. Wallets read this struct to bootstrap; indexers read it to check that the state they +replayed matches the chain. + +`is_spender` and `get_spender_delegation` are described on the +[Spenders](/stellar-contracts/tokens/confidential/spenders) page. + +## Failed Transactions + +Every proof-carrying operation except `register` carries a salt: `sigma`, or `sigma_a_new` for a spender +transfer. The salt determines the new commitment's blinding factor and every encryption mask. The wallet +must sample a fresh salt for every attempt, so when a transaction reverts, it picks a new salt and +rebuilds the proof. Reusing the old salt would let an observer link the failed attempt to the retry, +and a retry with a different amount would leak the difference between the two amounts. The contract +cannot enforce freshness; it is the wallet's job (see +[Salts and Retries](/stellar-contracts/tokens/confidential/wallet-integration#salts-and-retries)). The salt is +published in the event, so the owner and the auditor can still reconstruct the randomness. See +[Revert Safety](https://github.com/OpenZeppelin/stellar-contracts/blob/main/packages/confidential/docs/protocol/security.md#revert-safety). + +## Further Reading + +The full constraint set, public inputs and post-verification state for each operation are specified in +[Operations](https://github.com/OpenZeppelin/stellar-contracts/blob/main/packages/confidential/docs/protocol/operations/README.md) +and [Interface](https://github.com/OpenZeppelin/stellar-contracts/blob/main/packages/confidential/docs/protocol/interface.md). diff --git a/content/stellar-contracts/tokens/confidential/proof-system.mdx b/content/stellar-contracts/tokens/confidential/proof-system.mdx new file mode 100644 index 00000000..0b420ac0 --- /dev/null +++ b/content/stellar-contracts/tokens/confidential/proof-system.mdx @@ -0,0 +1,302 @@ +--- +title: Proof System +--- + +[Source Code](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/confidential/src/verifier) + +Every confidential operation that consumes private state comes with a proof that the state change is +valid. The proofs are meant to be zero-knowledge, but the current verifier does not support that yet; +see [Generating Proofs](#generating-proofs). The circuits are written in [Noir](https://noir-lang.org/) +and compiled to UltraHonk. Proofs are generated client-side by the wallet. 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](https://github.com/NethermindEth/rs-soroban-ultrahonk). + +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](/stellar-contracts/tokens/confidential/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](/stellar-contracts/tokens/confidential/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](https://github.com/OpenZeppelin/stellar-contracts/blob/main/packages/confidential/docs/protocol/proof-system.md#circuit-cost-analysis). + +## Verification Flow + +For every proof-carrying operation, the token contract: + +1. 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. +2. 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 `data` payload. The order + is fixed per circuit. Each field element and point coordinate is checked for canonical encoding. +3. Calls `verify_proof(circuit_type, public_inputs, proof)` on the verifier registry, and reverts with + `InvalidProof` if it returns `false`. A missing or unparsable key is not a `false`: the registry + panics with `VerificationKeyNotRegistered` (`#3401`) or `InvalidVerificationKey` (`#3403`), and + that error aborts the token call. +4. 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](https://github.com/OpenZeppelin/stellar-contracts/blob/main/packages/confidential/docs/protocol/operations/README.md#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: + +```rust +// `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); + } + + #[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 {} +``` + +Like every contract that depends on `stellar-confidential`, the registry needs the crate as a git +dependency and a global allocator. See [Installation](/stellar-contracts/tokens/confidential/confidential#installation). + +This example gates key updates behind the same `manager` role as registration, purely for +illustration. See [Updating a Verification Key](#updating-a-verification-key) for what a production +deployment should do instead. + +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/`](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/confidential/circuits/vks), +two files per circuit: + +- `.vk.bin`: the 1760-byte packed binary key the on-chain verifier parses. **Register this one.** +- `.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. 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: + +```rust +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); + + 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)); +} +``` + +On a live network, the manager signs the same `register_verification_key` call, passing the contents +of the `.vk.bin` file as `verification_key`. + +| `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) | + +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](/stellar-contracts/governance/timelock-controller). +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](https://github.com/OpenZeppelin/stellar-contracts/blob/main/packages/confidential/docs/protocol/system-model.md#governance-and-upgradeability). + +## Generating Proofs + +Proving happens in the wallet. Proofs the deployed verifier accepts must be generated with the same +pinned toolchain and two non-default settings: + +```bash +nargo execute --package circuit_ +bb prove -s ultra_honk --oracle_hash keccak \ + -b target/circuit_.json -w target/.gz -o +``` + +- `--oracle_hash keccak` is required. The on-chain verifier reproduces the Fiat-Shamir transcript with + Keccak, while `bb` defaults to Poseidon2. +- `--zk` must not be passed. + + +The on-chain verifier currently implements only the non-zero-knowledge UltraHonk flavor, so proofs are +generated without `--zk`. Until the verifier supports the zero-knowledge flavor, proofs have no +witness-hiding guarantee. They carry data that depends on the private inputs (spending key, balances, +blinding factors, amounts) and are not guaranteed to hide them. The +[verifier's README](https://github.com/NethermindEth/rs-soroban-ultrahonk/blob/097da17df8b0b971d71885566034958a8caebab4/crates/ultrahonk-soroban-verifier/README.md) +at the pinned revision warns against using it for privacy-sensitive circuits. Upstream marks this +proving recipe as provisional: it will be finalized together with the verifier, including the +zero-knowledge setting. Check the +[verification-key README](https://github.com/OpenZeppelin/stellar-contracts/blob/main/packages/confidential/circuits/vks/README.md) +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](https://github.com/OpenZeppelin/stellar-contracts/blob/main/packages/confidential/docs/protocol/proof-system.md#structured-reference-string). + +## Network Requirements + +UltraHonk verification and the on-chain Grumpkin arithmetic depend on the BN254 host functions +introduced by [CAP-80](https://github.com/stellar/stellar-protocol/blob/master/core/cap-0080.md). The +rollout spans two protocol versions, and both are needed: + +- **Protocol 25:** `bn254_g1_add`, `bn254_g1_mul`, `bn254_multi_pairing_check`. +- **Protocol 26:** `bn254_g1_msm`, `bn254_g1_is_on_curve`, and the `bn254_fr_*` scalar-field + arithmetic that underlies Grumpkin point operations. + +The token also hashes addresses with the `poseidon2_permutation` host function, available since +protocol 25. + +The host functions alone require **protocol 26**. In practice the minimum is higher: the library pins +`soroban-sdk` 28, and a contract built with it can only be deployed and run on a network at +**protocol 28** or later. + +## Further Reading + +- [Proof System](https://github.com/OpenZeppelin/stellar-contracts/blob/main/packages/confidential/docs/protocol/proof-system.md): circuit summary, Noir primitives, on-chain point arithmetic +- [Circuit sources](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/confidential/circuits) diff --git a/content/stellar-contracts/tokens/confidential/selective-disclosure.mdx b/content/stellar-contracts/tokens/confidential/selective-disclosure.mdx new file mode 100644 index 00000000..22028d39 --- /dev/null +++ b/content/stellar-contracts/tokens/confidential/selective-disclosure.mdx @@ -0,0 +1,151 @@ +--- +title: Selective Disclosure +--- + +[Specification](https://github.com/OpenZeppelin/stellar-contracts/blob/main/packages/confidential/docs/selective-disclosure/README.md) + +The confidential token hides amounts by default, but regulated finance routinely asks an account holder to prove one +specific fact to one specific party. A bank's compliance desk asks to confirm the amount of an incoming payment, a tax +authority asks for the total received over a quarter, and a KYC provider asks for evidence that a balance is below a +threshold. Selective disclosure answers these requests with a zero-knowledge proof bound to a single on-chain event (or +the current balance) and to a single recipient, without handing over a viewing key. + + +Selective disclosure is an off-chain layer specified alongside the library. It needs no changes to the token contract, +but the library does not ship the disclosure circuits: the circuits in `circuits/` today cover only the core +operations. Disclosure proofs are verified off-chain by the recipient, not by the token's verifier registry. + + +## The Selective-Disclosure Gap + +[Auditing](/stellar-contracts/tokens/confidential/auditing) gives each auditor a key that decrypts every transfer of +the accounts in its scope. That suits regulator access, but it is the wrong tool for routine requests: + +- **Granularity.** An auditor key cannot decrypt one transfer without being able to decrypt all of them. +- **Counterparties.** Many requests come from parties that are not the auditor, and routing all of them through the + auditor concentrates trust and adds latency. +- **Recipient binding.** A plaintext answer can be re-shared or replayed, with nothing tying it to the party that + asked. + +Handing over the viewing key is not an alternative, since it exposes the account's entire history (see +[Wallet Integration](/stellar-contracts/tokens/confidential/wallet-integration)). + +## Design Goals + +Each proof covers one named on-chain event, a finite set of events, or the current balance, never an account's +history. It is bound to the recipient's public key and a fresh nonce, so it cannot answer a different request and its +value opens only for that recipient. The recipient checks it against the event log, account records, and auditor +registry without trusting the prover. Either the holder (with its viewing key) or the auditor (with its auditor key) +can be the prover, and the contract's storage, entry points, and ciphertexts stay unchanged. + +**Non-goals.** The layer proves positive statements only ("this event paid me X"), never completeness ("there are no +other transfers from Y"); completeness still routes through the auditor. Disclosures are not logged on-chain, since +that would leak exactly the metadata the protocol hides, and there is no on-chain registry of recipients. + +## Disclosure Types + +| Disclosure | Produced by | Proves | +| --- | --- | --- | +| D-recipient | The holder that received a `Transfer` or `SpenderTransfer` | "This on-chain payment paid me this amount." | +| D-sender | The originator: the holder for a `Transfer`, the spender for a `SpenderTransfer` | "I sent this on-chain payment, for this amount, to the address in the event." | +| D-auditor | The auditor of either party | The amount of a transfer in its scope. Variants disclose the sender's post-transfer balance (or allowance) and its opening. | +| D-balance | The holder | The current spendable balance is at least, or at most, a threshold, or its exact value. | +| Aggregate | The holder, sender, or auditor | The total across a set of transfers, as an exact value or as at least a threshold, without revealing individual amounts. | + +Predicate-style variants reveal no value at all: the proof's validity asserts the predicate. Value-revealing variants +encrypt the disclosed value to the recipient. D-balance attests to the balance *at verification time*, since a proof +against a stale commitment fails to verify, whereas the D-auditor balance variant is historical and anchored to a +specific transfer. The commitment can also change without the holder's involvement, through a compliance clawback or +forced revocation, in which case the holder proves again against the new one. + +## How a Disclosure Works + +A recipient publishes a long-lived key pair out of band, for example through web PKI or a certificate. For each +request, it issues a fresh nonce over an authenticated channel. The recipient key and nonce together bind the +resulting proof. + +```mermaid +sequenceDiagram + participant R as Recipient + participant H as Holder wallet + participant L as Stellar ledger + R->>H: Request: recipient public key, fresh nonce, event reference + H->>L: Read the event and account records + H->>H: Generate disclosure proof locally + H->>R: Proof bundle (off-chain) + R->>L: Resolve the event and account records independently + R->>R: Build public inputs, verify proof, decrypt value +``` + +The **proof bundle** carries only a circuit identifier (which selects the pinned verification key), a reference to each +disclosed event (transaction hash and event index), the UltraHonk proof, and the disclosed value encrypted to the +recipient's key. It deliberately carries no event payload, account address, or other public input. The recipient takes +all of those from the chain itself. D-balance is the exception: with no event to reference, its bundle names the +disclosing account, which the recipient checks against the one agreed in the request, and its predicate variants +carry no ciphertext. + +The recipient's verifier then runs a fixed sequence of checks, any of which rejects the bundle: + +1. Resolve each event reference to exactly one transfer-family event emitted by the deployed confidential token + contract, and take its fields verbatim. +2. Determine the disclosing account from the event, never from the bundle: `to` for D-recipient; `from` (or `spender`) + and `to` for D-sender. D-auditor uses the auditor key instead. +3. Read auxiliary state: the contract's address field and, for D-auditor, the auditor key that was active at the + event's ledger, rejecting the bundle if it cannot resolve one. The registry shipped with the library keeps only + the current key for each `auditor_id`, so for a transfer made before a rotation, one option is to reconstruct the + earlier key from the registry's `AuditorRotated` events, which carry the old and the new key. The registry emits + those events, not the token, so they are outside the indexer's scope: beyond the RPC window the verifier needs + its own archive of them. +4. Build the public inputs from chain data, its own key and nonce, and only the encrypted value from the bundle. +5. Verify the proof against the pinned verification key for the circuit. +6. Decrypt the disclosed value. + +A disclosure leaves no on-chain trace: no transaction, event, or state change. It relies only on public chain data: +`confidential_balance` for public viewing keys and the spendable commitment, the auditor registry's key lookup, the +transfer-family events, and the contract address field. That field has no getter, so the verifier reads it from +instance storage or recomputes it from the contract address. See +[Verifier Protocol](https://github.com/OpenZeppelin/stellar-contracts/blob/main/packages/confidential/docs/selective-disclosure/protocol.md#verifier-protocol). + + +The verifier's checks are what bind a proof to a specific on-chain event. A verifier that accepts public inputs from +the bundle instead of resolving them from the chain voids that binding. The verification-key set is a trusted input +too: a key for a tampered circuit can make false statements verify. + + +### On-Chain Verification + +Disclosure proofs are UltraHonk proofs, so a contract could verify them. Doing so publishes the disclosure's existence, +the recipient, the referenced event, and its timing. That trade-off is worth it only when the result has to gate +another on-chain action, such as a compliance escrow or a permissioned pool. Such a verifier is a separate protocol, +outside this specification; the outline is in +[On-Chain Verification](https://github.com/OpenZeppelin/stellar-contracts/blob/main/packages/confidential/docs/selective-disclosure/protocol.md#on-chain-verification). + +## Security Properties and Limits + +- **Soundness.** A disclosed amount equals the value the on-chain commitment commits to; forging one would require + breaking Poseidon2 preimage resistance or discrete log on Grumpkin. The binding to a specific event additionally + relies on per-operation salts being unique, which makes each event's ephemeral key unique. +- **Recipient binding.** Anyone holding a bundle can verify it but cannot decrypt the value; they only learn that some + value was disclosed. The nonce prevents reusing the proof for another request. +- **Cherry-picking.** A holder can disclose some transfers and withhold others. Recipients that need completeness + should request a D-auditor disclosure, since the auditor sees every event in its scope. +- **Out of scope.** Once decrypted, protecting the value is the recipient's responsibility; compelling a holder to + disclose is a legal matter, with auditor variants as the cryptographic backstop. Addresses are public anyway, so + disclosure protects only amounts. +- **Spender transfers.** The owner cannot produce a D-sender proof for a `SpenderTransfer`, because the ephemeral key + derives from the spender's viewing key. The owner can route through its auditor (D-auditor), or ask the spender for + a D-sender proof, which proves that the spender paid. Delegation provenance comes from the `SetSpender` event and + the delegation entry. +- **Older transfers.** A transfer whose ephemeral scalar was not derived deterministically may not be disclosable by + its sender. The wallet tests this by recomputing the scalar and comparing it with the event's ephemeral key, and + reports a distinct "not disclosable" outcome. + +## Implementation Notes + +The specification defines four circuits: `disclose_recipient`, `disclose_sender`, and `disclose_auditor`, each with an +aggregate form over a bounded number of events, and `disclose_balance` in at-least, at-most, and exact-value variants. +A disclosing wallet keeps its current spendable opening for D-balance and indexes event references so users can pick +events by date or counterparty. The verifier should be a standalone library, independent of any wallet, that returns +either the disclosed value or a typed error saying whether proof verification, on-chain state, or decryption failed. +For aggregates, it must reject bundles in which two references resolve to the same event. See +[Security and Implementation Notes](https://github.com/OpenZeppelin/stellar-contracts/blob/main/packages/confidential/docs/selective-disclosure/security.md#implementation-notes). diff --git a/content/stellar-contracts/tokens/confidential/spenders.mdx b/content/stellar-contracts/tokens/confidential/spenders.mdx new file mode 100644 index 00000000..f1bbe732 --- /dev/null +++ b/content/stellar-contracts/tokens/confidential/spenders.mdx @@ -0,0 +1,201 @@ +--- +title: Spenders +--- + +[Source Code](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/confidential) + +A spender is an address the owner authorizes to send confidential transfers from a capped allowance +that expires. Spenders let automated services, payment processors, custodians, or DeFi integrations +move funds for an owner without the owner sharing their spending key. + +Unlike a SEP-41 allowance, which is only a permission to pull from the owner's balance, a confidential +allowance is **escrowed**. `set_spender` moves the amount out of the owner's spendable balance into a +separate per-spender commitment. The spender can only ever reach that escrow, so a compromised or +malicious spender can lose at most the allowance. + +## Lifecycle + +```mermaid +stateDiagram-v2 + [*] --> Active: set_spender + Active --> Active: confidential_transfer_from + Active --> Expired: ledger > live_until_ledger + Active --> [*]: revoke_spender / force_revoke_spender + Expired --> [*]: revoke_spender / force_revoke_spender +``` + +`force_revoke_spender` exists only in deployments with the compliance extension (see +[Revoking a Spender](#revoking-a-spender)). + +Each `(owner, spender)` pair holds at most one delegation. `set_spender` reverts with +`DelegationAlreadyExists` while an entry exists for the pair, even if it has expired. To re-delegate to +the same spender, the owner first calls `revoke_spender` and then `set_spender` again. + +## Setting a Spender + +```rust +// `set_spender_data` is the XDR-encoded `SetSpenderData` built by the owner's wallet. +token_client.set_spender(&owner, &spender, &live_until_ledger, &set_spender_data); +``` + +The spender must already be a registered confidential account. The owner's wallet needs the spender's +spending public key to hand over the key the spender uses to read its allowance. + +The owner's proof shows that: + +- the allowance comes out of the spendable balance, and the balance covers it; +- the new spendable commitment and the allowance commitment are well formed; +- a **delegation viewing key** for this spender is derived correctly and encrypted to the spender's + spending key; +- the owner's auditor receives the escrowed amount, the owner's new balance and its blinding factor, and + the blinding factor of the allowance commitment. + +The delegation viewing key is derived from the owner's viewing key and the spender's address. It +reveals only this spender's allowance in this token contract, and it cannot be used to recover the +owner's viewing key. Because it is escrowed on-chain inside the delegation entry, owner and spender +never need to exchange keys off-chain. + +The owner's spendable balance drops by the allowance as soon as the call succeeds. + +## Spending from an Allowance + +```rust +// Authorized by the spender, not the owner. +token_client.confidential_transfer_from(&spender, &owner, &recipient, &spender_transfer_data); +``` + +Only the spender signs. The owner approved in advance at `set_spender`, and that approval lives in the +delegation entry until it expires or is revoked. The call reverts with `DelegationNotFound` if there is +no entry, and with `DelegationExpired` once the current ledger is past `live_until_ledger`. + +The spender's proof shows that the allowance covers the amount and that the new allowance commitment is +correct. It also encrypts the amount for the recipient and both auditors, as a direct transfer does. +The recipient decrypts it as it would a direct +[confidential transfer](/stellar-contracts/tokens/confidential/operations#confidential-transfer), except +that the credit arrives as a `SpenderTransfer` event rather than `Transfer`, and the salt to use is the +event's `sigma_a_new`. A recipient wallet that watches only `Transfer` events misses these credits. + +Two more details differ from a direct transfer: + +- **Sender-side auditing follows the funds.** The ciphertexts on the sender side go to the **owner's** + auditor, not the spender's, and include the remaining allowance after the transfer. +- **Masks use a replacement salt.** The delegation stores its current salt, `allowance_salt`, which only + opens the current allowance. Every encryption mask is keyed to the replacement salt `sigma_a_new` that + the spender's wallet picks, and the contract writes it back as the new `allowance_salt`. A reverted + call leaves the stored salt unchanged, so the wallet must sample a fresh `sigma_a_new` for every + attempt, retries included. The circuit only rejects a value equal to the stored salt; it cannot + enforce freshness. + +## Revoking a Spender + +```rust +token_client.revoke_spender(&owner, &spender); +``` + +Revocation needs no proof. The contract adds the remaining allowance commitment back to the owner's +spendable commitment and deletes the delegation entry. It works for both active and expired +delegations. + +The `RevokeSpender` event carries the encrypted allowance and its salt. Since the entry is deleted in +the same call, these event fields are the only place the owner's wallet can read them to learn how much +came back. + +In deployments with the compliance extension, the token admin can also end a frozen account's +delegation with [`force_revoke_spender`](/stellar-contracts/tokens/confidential/clawback#forced-revocation). +It needs no authorization from the owner and runs no hook, but it folds the allowance back the same way +and emits the same `RevokeSpender` event. Owner wallets must therefore handle a `RevokeSpender` they did +not initiate. + +## Expiry + +`live_until_ledger` limits how long the spender can spend, but the escrowed funds stay put: + +- While `ledger.sequence() <= live_until_ledger`, the spender can call `confidential_transfer_from`. +- After that, spending reverts, but the escrowed value **stays on-chain** in the delegation entry until + the owner calls `revoke_spender`. + +`set_spender` does not validate `live_until_ledger`. The value is bound by the owner's authorization, +but the contract does not compare it with the current ledger and the proof does not cover it. A value +already in the past creates a delegation that is expired from the start; its escrow comes back only +through `revoke_spender`. Wallets should check the value before submitting. + +Delegations live in persistent storage, not temporary storage, so an expired escrow is never deleted +automatically. Its TTL is still finite: a new delegation starts with the network's minimum TTL, and the +library extends it only when an operation reads the entry. An escrow that nobody touches for long +enough is archived and must be restored before `revoke_spender` or `confidential_transfer_from` can +use it (see [TTL Management](/stellar-contracts/tokens/confidential/confidential#ttl-management)). +Wallets should show upcoming expirations and prompt the owner to renew or revoke. + +## Owner Operations While Spenders Are Active + +The owner's spendable balance and each allowance are separate commitments, so they never need to be +kept in sync. The owner can transfer, withdraw, merge, or set up more spenders while delegations are +active. None of these touch an allowance, so a spender's in-flight proof stays valid. Spender +transfers never touch the owner's spendable balance either. The one owner action that does affect a +spender is `revoke_spender`, which removes the allowance the spender's next proof would reference. + +## Reading Delegation State + +`is_spender(account, spender)` returns `true` only if a delegation exists **and** has not expired. It +returns `false` for a missing, revoked, or expired delegation, so it answers "can this spender spend +now?", not "is value escrowed?". + +`get_spender_delegation(account, spender)` returns the raw `SpenderDelegation` without the expiry +filter, and reverts if no entry exists. Compare `live_until_ledger` with the current ledger to tell an +active delegation from an expired one. + +| Field | Description | +| --- | --- | +| `allowance_commitment` | Commitment to the remaining allowance | +| `a_tilde` | The remaining allowance, encrypted under the delegation viewing key | +| `escrowed_dvk` | The delegation viewing key, encrypted to the spender's spending key | +| `allowance_salt` | Salt of the current allowance state; replaced on every spender transfer | +| `live_until_ledger` | Last ledger at which the spender may spend | + +## Events + +Each event's first topic is its name in snake case (`set_spender`, `spender_transfer`, `revoke_spender`), +followed by the addresses below. + +| Event | Topic addresses | Data | +| --- | --- | --- | +| `SetSpender` | `account`, `spender` | `live_until_ledger`, `r_e_point`, `sigma`, `b_tilde`, `v_tilde_aud_s`, `b_tilde_aud_s`, `r_tilde_aud_s`, `r_a_tilde_aud_s` | +| `SpenderTransfer` | `spender`, `from`, `to` | `r_e_point`, `v_tilde`, `sigma_a_new`, `v_tilde_aud_r`, `r_tilde_aud_r`, `v_tilde_aud_s`, `a_tilde_aud_s`, `r_tilde_aud_s` | +| `RevokeSpender` | `account`, `spender` | `a_tilde`, `allowance_salt` | + +- **`SetSpender`** is a spendable-balance checkpoint for the owner, like a `Withdraw` or an outgoing + `Transfer`. `b_tilde` and `sigma` let the owner's wallet recover its new spendable balance during + recovery. The `…_aud_s` fields are the owner-auditor ciphertexts. +- **`SpenderTransfer`** carries the amount encrypted for the recipient (`v_tilde`), the ciphertexts for + the recipient's auditor and the owner's auditor, and the new salt. It does not carry the new encrypted + allowance, which the contract writes only to the delegation entry; read it with + `get_spender_delegation`. +- **`RevokeSpender`** is described in [Revoking a Spender](#revoking-a-spender). It is emitted by both + `revoke_spender` and `force_revoke_spender`. + +## Spender Wallets + +A spender rebuilds its allowance from the delegation entry instead of replaying events. Its wallet +reads the entry and decrypts `escrowed_dvk` with its own spending key. It then reads the current +allowance from `a_tilde` and `allowance_salt`, and builds the next `confidential_transfer_from` proof. +The owner's wallet can read the same allowance, because it can derive the delegation viewing key from +its own viewing key. See [Wallet Integration](/stellar-contracts/tokens/confidential/wallet-integration). + +A spender can see its own allowance, but never the owner's spendable balance. + +## Auditing and Disclosure + +The owner's auditor sees the escrowed amount at `set_spender`, and the amount and remaining allowance on +every spender transfer. See [Auditing](/stellar-contracts/tokens/confidential/auditing). + +A spender transfer derives its encryption randomness from the spender's own viewing key. The owner +therefore cannot produce a sender-side +[selective disclosure](/stellar-contracts/tokens/confidential/selective-disclosure) for it on its own: +either the spender cooperates, or the owner's auditor discloses the transfer instead. + +## Further Reading + +- [Set Spender](https://github.com/OpenZeppelin/stellar-contracts/blob/main/packages/confidential/docs/protocol/operations/set-spender.md), including [Delegation Key Escrow](https://github.com/OpenZeppelin/stellar-contracts/blob/main/packages/confidential/docs/protocol/operations/set-spender.md#delegation-key-escrow) +- [Spender Transfer](https://github.com/OpenZeppelin/stellar-contracts/blob/main/packages/confidential/docs/protocol/operations/spender-transfer.md) +- [Revoke Spender](https://github.com/OpenZeppelin/stellar-contracts/blob/main/packages/confidential/docs/protocol/operations/revoke-spender.md) +- [Spender Delegation](https://github.com/OpenZeppelin/stellar-contracts/blob/main/packages/confidential/docs/protocol/account-state.md#spender-delegation) diff --git a/content/stellar-contracts/tokens/confidential/wallet-integration.mdx b/content/stellar-contracts/tokens/confidential/wallet-integration.mdx new file mode 100644 index 00000000..3d503733 --- /dev/null +++ b/content/stellar-contracts/tokens/confidential/wallet-integration.mdx @@ -0,0 +1,203 @@ +--- +title: Wallet Integration +--- + +[Specification](https://github.com/OpenZeppelin/stellar-contracts/blob/main/packages/confidential/docs/sdk/README.md) + +Apart from the public amounts of deposits and withdrawals, the confidential token contract never sees an amount. It +stores Pedersen commitments, verifies proofs, and emits encrypted ciphertexts. Every balance, transfer +amount, or allowance a user sees is computed by client software, the *wallet*. This page describes what a wallet must +do to operate a confidential account correctly and safely. + + +This page summarizes the SDK specification that accompanies the library. The library does not ship an SDK: it ships +the on-chain contracts and the Noir circuits. The specification defines obligations rather than an API, so an +implementation in any language can conform to it. + + +## Why the Client Is Load-Bearing + +A balance is a commitment `C = v·G + r·H`. The chain stores the point, but the opening `(v, r)` needed to spend lives +only in wallet state: if a wallet loses or misaccumulates it, the funds become unspendable, and the contract cannot +help because it never knew the value. Every amount is client-decrypted, so a decryption bug yields a plausible wrong +number rather than a visible failure. And all keys, blinding factors, and salts originate in the client, so +confidentiality reduces to the wallet's key handling and random number generator. + +## Keys + +All keys of a confidential account derive from a single spending secret `sk`: + +| Key | Derived from | Published on-chain | Purpose | +| --- | --- | --- | --- | +| Spending key `sk` | The account's root, or imported directly | Spending public key `Y = sk·H` | Witness in every proof the owner produces | +| Viewing key `vk` | `sk` and the token contract address | Public viewing key `PVK = vk·H` | Decrypts incoming transfers and balance checkpoints; cannot spend | +| Delegation viewing key `dvk_i` | `vk` and the spender's address | Escrowed to the spender at `set_spender` | Lets one spender track its own allowance, and nothing else | + +Because `vk` is bound to the contract address, keys for one deployment reveal nothing about another. + + +The viewing key carries more authority than "read-only" suggests. Besides decrypting balances and incoming transfers, +it lets its holder recompute the ephemeral scalar of every transfer the account sent, and therefore open those +transfer commitments retroactively. Never present `vk` as a shareable credential. To prove a single fact to a third +party, use [Selective Disclosure](/stellar-contracts/tokens/confidential/selective-disclosure). + + +### Deriving the Spending Key + +The SDK specification fixes one +[derivation](https://github.com/OpenZeppelin/stellar-contracts/blob/main/packages/confidential/docs/sdk/key-derivation.md#derivation) +for every client: HKDF-SHA-512 over a *root*, bound to both the token contract and the account address, so one root +never yields linkable keys on two addresses. What controls the address determines the root: + +- **Signer root.** For an ed25519-controlled address, the root is a deterministic + [SEP-53](https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0053.md) signature over a message + naming the protocol, the contract, and the account, so the user reproduces it from the key they already hold. The + wallet must verify the signature against the expected signer and reject signers that do not return the same + signature twice, which typically rules out threshold and MPC signers. +- **Raw root.** For a smart account or other contract address, or a custody stack that cannot sign arbitrary + messages, the root is 32 raw bytes from the controlling custody mechanism. Nothing else reproduces it, so the + wallet must surface it for backup at creation. +- **Direct import.** An `sk` sampled directly from a CSPRNG, with no root behind it, stays usable as a first-class + account secret and bypasses the derivation. Only an exported copy of `sk` recovers it. + +With a signer root, whoever obtains the ed25519 secret gains both view and spend access, and since `register` is +single-use, `sk` cannot be rotated: remediation means registering a new address and moving the funds. On recovery, a +wallet derives `sk` for each candidate address and compares `Y = sk·H` against the spending public key stored +on-chain. + +The [key derivation specification](https://github.com/OpenZeppelin/stellar-contracts/blob/main/packages/confidential/docs/sdk/key-derivation.md) +also requires a wallet to: + +- record, per account, which of the three forms produced `sk`, and never offer a recovery path that form cannot + satisfy; +- record the enrolled ed25519 signer instead of assuming the master key, since an address can have several signers, + and should warn on sync when that signer has been rotated off the address, which orphans the root; +- offer `sk` export as a direct-import backup even for signer roots, ideally before the account first receives funds, + because a signer can be deterministic within a session but not across sessions; +- state, where it offers to create an account, that the account's confidentiality is bounded by the secrecy of its + signing key. + +## Holder Wallet + +### Tracking Balances + +The wallet keeps one opening for the spendable balance and one for the receiving balance, and updates them from the +contract's events: + +- **Deposits and incoming transfers** add to the receiving opening. Transfer amounts and blindings are recovered by + ECDH between the viewing key and the event's ephemeral public key. +- **Merge** folds the receiving opening into the spendable one and resets the receiving side. `RevokeSpender` and, + with the compliance extension, `Clawback` are also folds. +- **Withdrawals, outgoing transfers, and `set_spender`** *overwrite* the spendable opening from the encrypted balance + and salt in the event, so a wallet that missed earlier events still converges on the spendable side. +- **A self-transfer** is applied on both sides. + +Blindings compose in the Grumpkin scalar field, whose modulus `q` is larger than the modulus `r` of Noir's `Field`. +Fold them as unbounded integers and +[reduce modulo `q`](https://github.com/OpenZeppelin/stellar-contracts/blob/main/packages/confidential/docs/sdk/crypto-core.md#blinding-accumulation) +only where a blinding leaves the wallet for the curve: a proof witness or a consistency recommit. Reducing modulo `r` +instead, or at every fold, yields an opening that no longer matches the on-chain point. + +Events must be applied in emission order, deduplicated by event id, and idempotently, since a duplicated credit +inflates the balance. Persisted wallet state is not a cache: once events age out of Stellar RPC, state that no archive +holds is lost for good (see [Indexing and Recovery](/stellar-contracts/tokens/confidential/indexing)). + +### Consistency Checks + +After every sync and before building any proof, the wallet re-commits each opening and compares it against the +on-chain commitment from `confidential_balance`. A missed event, a duplicate, or a bug then shows up as a mismatch +rather than a wrong balance. The wallet must not spend from mismatched state and should report which balance diverged. + +### Salts and Retries + +Every attempt samples a fresh salt from a CSPRNG, including retries after a reverted or dropped transaction. The salt +is the only freshness input to the operation's blinding, its ephemeral key (derived deterministically from the +originator's viewing key and the salt), and every encryption mask. Reusing one is therefore a confidentiality failure, +not only a linkability one. The contract cannot enforce freshness, so wallets must never cache, reuse, or cycle salts. + +### In-Flight Operations and Merging + +A wallet may show the projected balance right after submission, but must reconcile it against the emitted event +before treating it as confirmed. + +Received funds become spendable only after a merge. Merge needs no proof and is cheap, so a wallet should merge +automatically, or prompt, before a spend the spendable balance cannot cover. Regular merges also shorten the replay +window for recovery. + +### The Unspendable-Blinding Case + +After a proofless fold (merge, revocation, or clawback), the spendable blinding can, with negligible probability (about +2⁻¹²⁷ per fold), land outside the range a proof can witness. The commitment stays valid and consistency checks pass, +but no proof can be built against it until the next merge that folds in an inbound confidential transfer; deposits +alone do not resolve it. Wallets should test for this before proving and surface it as a distinct state with that +recovery path. + +## Proving and Submission + +- **Witness assembly.** Assemble public inputs in exactly the order the contract does: a permutation of two same-typed + inputs proves a different statement. Leave inputs the contract loads from its own state out of the payload. +- **Canonical encoding.** Every field value the wallet emits, in a payload, proof input, or persisted state, must be + the [canonical](https://github.com/OpenZeppelin/stellar-contracts/blob/main/packages/confidential/docs/sdk/crypto-core.md#canonicality) + representative below `r`. Reject non-canonical values at the wallet's own boundary: the contract rejects them too, + but by then local state may already hold bytes that recovery cannot reproduce. +- **Toolchain pinning.** Generate proofs with the toolchain and flags pinned in + [`circuits/vks/README.md`](https://github.com/OpenZeppelin/stellar-contracts/blob/main/packages/confidential/circuits/vks/README.md), + notably a Keccak Fiat-Shamir transcript (many backends default to Poseidon2, which verifies locally and fails + on-chain) with zero-knowledge mode off. That recipe is provisional and will be finalized together with the + verifier, including the zero-knowledge setting (see + [Generating Proofs](/stellar-contracts/tokens/confidential/proof-system#generating-proofs) for what the current + setting implies). Check that the circuit's verification key matches the deployed one before submitting. +- **Where proving runs.** Keep the prover pluggable (in-process WASM, native, or remote). Remote proving discloses the + spending key, balance, and amount to the prover, so it must be explicit opt-in, never a fallback. +- **Submission.** Authorize with the principal the entry point expects, which for `confidential_transfer_from` is the + spender, not the owner (see [Operations](/stellar-contracts/tokens/confidential/operations)). Simulating first + catches stale commitments, frozen accounts, and expired delegations without a fee. +- **Failures.** Distinguish proof verification failures (check the verification key and witness) from state + mismatches (re-sync and rebuild): they look alike on the wire but have opposite remedies. A rotation of an auditor + key or of the verifier between proving and submission also invalidates an in-flight proof; the wallet rebuilds it + against the new key with a fresh salt (see + [Key Rotation](/stellar-contracts/tokens/confidential/auditing#key-rotation)). + +## Roles + +The specification separates the holder, spender, auditor, and disclosure-recipient roles, each holding distinct key +material and structurally unable to exceed its capability. A spender wallet, for example, rebuilds allowance state +from the on-chain delegation entry and must never reach the owner's spendable balance (see +[Spenders](/stellar-contracts/tokens/confidential/spenders)), and an auditor client must never be able to build a +spending witness (see [Auditing](/stellar-contracts/tokens/confidential/auditing)). + +## Requirements + +- **Secrets stay local.** The root, keys, ephemeral scalars, and cached openings never leave the device, except under + opted-in remote proving, and never reach logs, telemetry, or crash reports. +- **Storage at rest.** A persisted opening is as sensitive as its amount, and a persisted root is equivalent to the + funds, so encrypt both. Never persist a signer root; re-derive it on demand. +- **Randomness and supply chain.** Use a CSPRNG and fail rather than fall back. Pin verification keys and the proving + toolchain; never fetch them at runtime from a mutable location. +- **Host compromise.** Software cannot defend against a compromised host, and a hardware device that does not prove + internally must expose `sk` to the host, since `sk` is a proof witness. +- **Scale.** Proving targets single-digit seconds. Inbound volume is limited only by transaction fees, so do not size + storage on the user's own activity. + +Conformance is defined against the language-agnostic fixtures in +[`circuits/lib/testdata`](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/confidential/circuits/lib/testdata): +an implementation reproduces every fixture byte-for-byte, and runs the real compiled circuits against its own +witnesses, including tampered ones that must be rejected. See +[Conformance and Versioning](https://github.com/OpenZeppelin/stellar-contracts/blob/main/packages/confidential/docs/sdk/requirements.md#conformance-and-versioning). + + +Two vectors the specification +[requires](https://github.com/OpenZeppelin/stellar-contracts/blob/main/packages/confidential/docs/sdk/conformance.md#vectors-this-specification-requires) +do not exist yet: the ephemeral-scalar derivation, and the chain from a root (including its SEP-53 signature) to `sk`, +`vk`, `Y`, and `PVK`. Until they land, no fixture checks that two clients derive the same account from the same +backup, or the same ephemeral scalars, so transfers sent from one client may not be disclosable from another. + + +## UX Considerations + +Confidential transfers should feel like regular ones, with two visible differences: **proof generation latency** when +sending, withdrawing, or delegating, and **the merge step** before received funds become spendable, which the wallet +should make nearly automatic. Keys, commitments, proofs, salts, and auditor ciphertexts stay behind the wallet, and +the holder selects an auditor once at registration. Incoming transfers never invalidate an in-flight spend proof, and +an expired delegation still holds its escrow until the owner revokes it, so wallets should surface upcoming +expirations. diff --git a/src/navigation/stellar.json b/src/navigation/stellar.json index 9c341127..66ff9563 100644 --- a/src/navigation/stellar.json +++ b/src/navigation/stellar.json @@ -67,6 +67,80 @@ "type": "page", "name": "Vault", "url": "/stellar-contracts/tokens/vault/vault" + }, + { + "type": "folder", + "name": "Confidential", + "index": { + "type": "page", + "name": "Confidential Token", + "url": "/stellar-contracts/tokens/confidential/confidential" + }, + "children": [ + { + "type": "folder", + "name": "Operations", + "index": { + "type": "page", + "name": "Operations", + "url": "/stellar-contracts/tokens/confidential/operations" + }, + "children": [ + { + "type": "page", + "name": "Spenders", + "url": "/stellar-contracts/tokens/confidential/spenders" + } + ] + }, + { + "type": "page", + "name": "Auditing", + "url": "/stellar-contracts/tokens/confidential/auditing" + }, + { + "type": "folder", + "name": "Compliance", + "index": { + "type": "page", + "name": "Compliance", + "url": "/stellar-contracts/tokens/confidential/compliance" + }, + "children": [ + { + "type": "page", + "name": "Clawback", + "url": "/stellar-contracts/tokens/confidential/clawback" + } + ] + }, + { + "type": "page", + "name": "Proof System", + "url": "/stellar-contracts/tokens/confidential/proof-system" + }, + { + "type": "folder", + "name": "Wallet Integration", + "index": { + "type": "page", + "name": "Wallet Integration", + "url": "/stellar-contracts/tokens/confidential/wallet-integration" + }, + "children": [ + { + "type": "page", + "name": "Indexing and Recovery", + "url": "/stellar-contracts/tokens/confidential/indexing" + }, + { + "type": "page", + "name": "Selective Disclosure", + "url": "/stellar-contracts/tokens/confidential/selective-disclosure" + } + ] + } + ] } ] },