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..e9443e99 --- /dev/null +++ b/content/stellar-contracts/tokens/confidential/auditing.mdx @@ -0,0 +1,221 @@ +--- +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 uses the same auditor for its sender and recipient channels, + so an auditor that also decrypted every inbound flow can carry that opening forward through merges 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 uses the same two-auditor model. The table above shows what each auditor learns at +`set_spender` and on each spender transfer: the recipient's auditor sees a spender transfer like any other, and the +owner's auditor tracks the allowance from the escrowed amount onward. Two things are specific to delegations: + +- **Revocation publishes no auditor ciphertext.** On `revoke_spender`, the auditor folds the allowance opening it + took from the delegation's 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. +- **Allowance openings exist only in events.** Each one comes from the event that wrote that state, not from + contract storage, so an auditor that misses a delegation event can't recover that opening later. + +See [Spenders](/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 +} +``` + +The registry depends on `stellar-confidential`, like the token (see +[Installation](/stellar-contracts/tokens/confidential/confidential#installation)). The example below gates all writes +behind a `manager` role: + +```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.** It can read individual amounts right away, but it holds no full opening of the + spendable balance until the account's next checkpoint (withdrawal, outgoing transfer, or `set_spender`), and none + of an allowance until the delegation's next state-changing event. Older state can break the spendable opening + again after that checkpoint: merging transfers received before the rotation, or revoking a delegation last + written before it, leaves it unknown until the next checkpoint. A receiving balance that holds pre-rotation + transfers opens only after the next merge. Until it holds both openings, the new key's holder can't build a + clawback proof for the account. + +## Running an Auditor + +The library provides the `ConfidentialAuditor` trait and the `auditor::storage` helpers; a deployable registry is +the [example contract](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. + +Key custody, rotation planning, and how to use the access an auditor key grants are covered in +[Best Practices for Auditors](/stellar-contracts/tokens/confidential/best-practices/auditors). +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/best-practices/account-holders.mdx b/content/stellar-contracts/tokens/confidential/best-practices/account-holders.mdx new file mode 100644 index 00000000..9241a7d1 --- /dev/null +++ b/content/stellar-contracts/tokens/confidential/best-practices/account-holders.mdx @@ -0,0 +1,82 @@ +--- +title: Best Practices for Account Holders +--- + +These practices are for anyone who holds a confidential account, and for the wallet developers who choose defaults +on their behalf. The wallet carries out most of them: [Wallet Integration](/stellar-contracts/tokens/confidential/wallet-integration) +states what a wallet must do, and this page states what a holder should do and why. The +[Privacy Model](/stellar-contracts/tokens/confidential/privacy-model) explains what the chain makes public. + +## Before Registering + +- **Choose the auditor deliberately.** The `auditor_id` picked at registration can never change. That auditor sees + the amount of every transfer the account sends or receives and, from the account's first spend onward, its + balances. The choice is public too. Find out who holds the auditor key and under which rules it may be used. +- **Know what your keys derive from.** When the wallet derives the account's keys from its Stellar signing key, + that key controls both spending and viewing, and the account's keys can't be rotated: registration is single-use, + so replacing a compromised signing key means registering a new address and moving the funds. Export the spending + secret as a backup before the account receives funds. +- **Don't expect anonymity.** Your address appears in every operation you take part in. The confidential token hides + amounts and balances, not whom you deal with. + +## Keeping Amounts Private + +- **Keep value inside the confidential layer.** Deposit and withdrawal amounts are public, and a balance built only + from them is public too. Pay other holders with confidential transfers instead of withdrawing to the underlying + token and depositing again. +- **Don't mirror amounts at the boundary.** Withdrawing exactly what you received, or depositing exactly what you are + about to send, reveals the hidden amount. Prefer common amounts for deposits and withdrawals, and don't tie their + timing to specific transfers. +- **Expect counterparties to know.** The recipient of a transfer learns its amount, and so does the auditor on each + side. +- **Never share your viewing key.** It reveals your whole history and the openings of every transfer you sent, and + since it is bound to your account at registration, it can't be revoked. To prove one fact, such as the amount of a + payment or that a balance is above a threshold, use + [Selective Disclosure](/stellar-contracts/tokens/confidential/selective-disclosure). +- **Prove locally.** A remote prover sees your spending key, your balance, and the amount of every proof it builds. + Opt in to one only if you would trust it with your entire confidential history. + +## Protecting Your Balance + +The chain stores only commitments. The opening of each balance, which every spend needs, lives in your wallet, and +the wallet rebuilds it from your keys and your account's event history when it loses its local state. + +- **Back up what recovers your keys.** That is the Stellar signing key for an account whose keys derive from it, and + the root or the exported spending secret otherwise. Losing it means losing the funds. +- **Keep old signing keys.** If you rotate the signer that controls your address, keep the previous key: an account + whose keys derive from it can't be re-derived without it. +- **Sync regularly.** Stellar RPC keeps events only for a limited window. A wallet that relies on RPC alone must sync + at least once per window, because credits that age out before it applies them are lost unless an + [archive](/stellar-contracts/tokens/confidential/indexing) holds them. Prefer wallets that use one. +- **Merge regularly.** Received funds become spendable only after a merge, and regular merges also shorten the history + a recovery has to replay. +- **Resync before spending after a warning.** A wallet that reports a mismatch between its local balance and the + on-chain commitment has missed or misapplied an event. Spending from that state fails. + +### Recovery Scenarios + +| Situation | What recovers the account | +| --- | --- | +| Device lost, keys derived from the signing key | The signing key, plus the event history from RPC or an archive | +| Device lost, keys from a raw root or an imported secret | The backup of that root or secret, plus the event history. Without the backup, the funds can't be spent. | +| Signing key rotated off the address and discarded | Only a previously exported spending secret | +| Wallet unsynced for longer than the RPC window, no archive | Nothing, for the credits in the events that aged out | + + +Recover with the same wallet software that created the account where possible. The conformance vectors for key +derivation don't exist yet, so two wallets are not guaranteed to derive the same account from the same backup. See +[Requirements](/stellar-contracts/tokens/confidential/wallet-integration#requirements). + + +## Working with Spenders + +- **Escrow only what the spender needs, for as long as it needs it.** The allowance is all a compromised spender can + take, and the expiry bounds how long it can take it. +- **Check the expiry before delegating.** `set_spender` accepts a `live_until_ledger` that has already passed, which + creates a delegation that is expired from the start. +- **Revoke expired delegations.** Expiry stops the spender but doesn't return the escrow; only `revoke_spender` does. + An escrow that nobody touches for long enough is also archived, and must be restored before it can be revoked. +- **Remember that delegations are public.** Each owner-spender pair, its expiry, and every transfer the spender makes + on your behalf are visible on-chain. +- **Plan how to disclose spender transfers.** You can't produce a sender-side disclosure for a transfer your spender + made: ask the spender, or your auditor. See [Spenders](/stellar-contracts/tokens/confidential/spenders#auditing-and-disclosure). diff --git a/content/stellar-contracts/tokens/confidential/best-practices/administrators.mdx b/content/stellar-contracts/tokens/confidential/best-practices/administrators.mdx new file mode 100644 index 00000000..f97bba78 --- /dev/null +++ b/content/stellar-contracts/tokens/confidential/best-practices/administrators.mdx @@ -0,0 +1,80 @@ +--- +title: Best Practices for Administrators +--- + +These practices are for whoever deploys a confidential token and holds its privileged roles: the token's admin and +compliance roles, the managers of the verifier and auditor registries, and the issuer of the underlying asset. + +The [testnet deployment guide](https://github.com/OpenZeppelin/stellar-contracts/blob/main/examples/confidential/README.md) +walks through a complete deployment with the Stellar CLI. This page covers what changes between that guide and a +production deployment. + +## Roles and Governance + +- **Give every role its own account.** The testnet guide lets one account hold every role. In production, keep the + token's admin, freezing, configuration, and seizure roles on separate accounts, and keep the registries' managers + apart from the token's admin, so that no single key can both reconfigure the token and seize funds. +- **Prefer immutability.** The strongest posture fixes every verification key and the token's links to its + registries at deployment: register the keys in the verifier's constructor, make `update_verification_key` always + panic, and expose no path that calls `set_verifier` or `set_auditor` after construction. See + [Updating a Verification Key](/stellar-contracts/tokens/confidential/proof-system#updating-a-verification-key). +- **Gate what stays mutable.** A verification key update, a new verifier or auditor registry, and an auditor key + rotation each change which proofs the token accepts or who can read its ciphertexts. Put each behind a multisig + and a [timelock](/stellar-contracts/governance/timelock-controller), and publish enough material for anyone to + check the change before it activates. +- **Remember that wallets pin verification keys.** Wallets must not fetch keys at runtime, so a key update breaks + every wallet that hasn't shipped the new key, and every update fails the proofs in flight. +- **Monitor configuration events.** Watch `verification_key_updated` on the verifier registry, `auditor_rotated` on + the auditor registry, and `verifier_set`, `auditor_set` and `compliance_config_changed` on the token. An event no + one planned is an incident. + +## Deploying + +- **Choose the underlying token carefully.** It must meet the + [underlying token requirements](/stellar-contracts/tokens/confidential/confidential#underlying-token-requirements). + Over a Stellar Asset Contract (SAC), the issuer can freeze or claw back the token contract's pooled balance, which + affects every holder at once. +- **Register the pinned keys, then check them.** Register the `.vk.bin` file of every circuit the deployment uses, + from the release you build against, and the `Clawback` key only with the clawback extension. Read each key back + with `get_verification_key` and compare it byte for byte with the pinned file. See + [Registering Verification Keys](/stellar-contracts/tokens/confidential/proof-system#registering-verification-keys). +- **Never reuse example keys.** The testnet guide registers the Grumpkin generator as its auditor key. Its secret is + `1`, so anyone can decrypt everything encrypted to it. Register only keys generated by the auditor that will hold + them. +- **Restrict auditor selection** if the deployment has designated auditors. An account's `auditor_id` never changes, + so `register` is the only point where a deployment controls who may see an account's activity. See + [Restrict Auditor Selection](/stellar-contracts/tokens/confidential/compliance#restrict-auditor-selection). + +## Supporting Holders and Auditors + +- **Publish what users need to verify the deployment.** That means the addresses of the token and both registries, + the circuit source revision, the proving toolchain versions, the verification key files, the protocol + documentation version, and the auditor ids holders may choose, with who operates each. +- **Run or contract at least two independent archives** that meet the + [indexer specification](/stellar-contracts/tokens/confidential/indexing). Without one, holders cannot recover once + events age out of Stellar RPC, and auditors lose the openings that exist only in events. +- **Serve the whole contract stream** alongside per-account queries, so wallets and auditors can sync without telling + the archive which accounts they follow. +- **Announce auditor key rotations.** A rotation fails every proof in flight, and the new key holds no openings until + each account's next checkpoint, so plan it with the auditor. See + [Key Rotation](/stellar-contracts/tokens/confidential/auditing#key-rotation). + +## Compliance Operations + +- **Wire the hooks before enabling seizure.** A token that implements `ConfidentialClawback` needs + `ComplianceHooks`, or a custom `Hooks` that forwards every callback it doesn't customize. Otherwise a freeze + blocks nothing and the target can move its balance before the seizure lands. +- **Enable `sac_passthrough` only over a SAC.** Over any other token, every checked operation fails. +- **Decide who may deposit for whom.** By default anyone can deposit into any registered account. Unsolicited + deposits complicate an auditor's bookkeeping, while outside payers may be part of your use case. The + [Compliance](/stellar-contracts/tokens/confidential/compliance#customizing-the-hooks-trait) page has hooks for + both directions. +- **Follow the seizure order.** Freeze the account, find its delegations from its `SetSpender` and `RevokeSpender` + events, revoke each with `force_revoke_spender`, and only then ask the auditor for the proof. Prefer a + `destination`. With `destination = None`, seize first and extract the surplus at the SAC afterwards, never the + other way round. See [Clawback](/stellar-contracts/tokens/confidential/clawback#seizure-flow). +- **Plan the SAC flags before the first deposit.** Extracting a surplus with the SAC's own `clawback` requires + `AUTH_CLAWBACK_ENABLED` on the issuer before the token contract's first deposit. Enabling it later doesn't apply to + the contract's existing balance. +- **Expect compliance actions to be public.** Freezes and clawbacks name the account, and a clawback publishes its + amount and destination. diff --git a/content/stellar-contracts/tokens/confidential/best-practices/auditors.mdx b/content/stellar-contracts/tokens/confidential/best-practices/auditors.mdx new file mode 100644 index 00000000..35f8b4e8 --- /dev/null +++ b/content/stellar-contracts/tokens/confidential/best-practices/auditors.mdx @@ -0,0 +1,78 @@ +--- +title: Best Practices for Auditors +--- + +These practices are for whoever holds the secret of an auditor key registered in an auditor registry. +[Auditing](/stellar-contracts/tokens/confidential/auditing) explains what an auditor can see and what its software +has to do; this page covers operating that capability safely. + +The protocol trusts the auditor with two things: protecting its secret key, and using its access only on legitimate +request. Nothing on-chain enforces either. + +## Key Management + +- **Generate the key yourself.** Generate the key pair with an auditor client, from a cryptographically secure + random source, and hand the registry manager only the public point. Never accept a key generated by someone else, + or a test key such as the one in the testnet deployment guide, whose secret is `1`. +- **Custody it at least as carefully as a viewing key.** The key decrypts every ciphertext addressed to it, for every + account bound to its id. The openings it yields are self-verifying: anyone who receives one can check it against + the chain. +- **Keep every historical key.** Each key decrypts only the events produced while it was active, so a replaced key is + still needed for as long as its events may be reviewed. The registry's `auditor_rotated` events record when each + key took over. + + +**Losing the auditor key is permanent.** Ciphertexts can't be re-encrypted to a new key, so everything encrypted to +a lost key stays unreadable. Until a replacement key has collected fresh openings, the auditor also cannot produce +[clawback](/stellar-contracts/tokens/confidential/clawback) proofs for its accounts. Back up each key before it is +registered. + + +- **Rotate immediately if the key is compromised.** A compromised key reveals the amounts and balances of every + bound account for as long as it stays active, but it can't spend and it can't read events from before it became + active. Rotation protects only what comes after it. +- **Expect a gap after every rotation.** A rotation fails every proof in flight, and the new key holds no openings + until each account's next withdrawal, outgoing transfer, or `set_spender`. See + [Key Rotation](/stellar-contracts/tokens/confidential/auditing#key-rotation). + +## Running the Auditor Client + +- **Ingest continuously and archive everything.** The blinding of each allowance is escrowed only in the event that + wrote it, so an event missed now can't be recovered later. Run or contract an archive that meets the + [indexer specification](/stellar-contracts/tokens/confidential/indexing), rather than relying on Stellar RPC's + limited window. +- **Check every opening against the chain.** Re-commit each opening and compare it with the stored commitment. A + mismatch means a missed, reordered, or pruned event. Where you audit both parties of a transfer, also check that + the amount decrypts to the same value on both channels. +- **Don't reveal your scope.** Asking an RPC provider or a third-party archive for specific accounts tells it which + accounts you audit. Fetch the whole contract stream, or run your own node and archive. +- **Use software built for the auditor role.** An auditor client must not be able to build a spending witness, and + must keep its key and openings out of logs and crash reports. See the + [Auditor Client specification](https://github.com/OpenZeppelin/stellar-contracts/blob/main/packages/confidential/docs/sdk/auditor-client.md). + +## Using Access + +- **Decrypt only on legitimate request.** The key makes everything in scope readable; whether a given read is + justified is a policy question. Keep a record of each access and its reason. +- **Answer narrow questions narrowly.** Hand a requester only what it asked for. Where the deployment supports + [selective disclosure](/stellar-contracts/tokens/confidential/selective-disclosure), a D-auditor proof + discloses one transfer's amount to one recipient, bound to its request, instead of decrypted records that can be + passed on. +- **Attest to completeness only with a gap-free history.** A holder can disclose some transfers and withhold others, + so requests for completeness come to the auditor. Confirm that the archive reports a complete history for the whole + range first. + +## Supporting Seizures + +In a deployment with the [clawback extension](/stellar-contracts/tokens/confidential/clawback), the auditor produces +the proof that fixes the seized amount and its destination. + +- **Prove only after the freeze and every forced revocation.** Any change to the account's commitments between + proving and submission fails the proof, and each revocation changes the spendable balance. +- **Verify both openings and read the current `clawback_nonce` before proving.** A proof built on stale state or an + old nonce is rejected. +- **Check the destination.** The proof binds the destination you were asked for, and the admin can't change it. On + a SAC, the destination has to be authorized for the asset (for a `G...` account, an authorized trustline), or the + whole seizure reverts. +- **Apply the `Clawback` event like a merge followed by a debit** of its public amount. It carries no encrypted + balance, so it is not a checkpoint. diff --git a/content/stellar-contracts/tokens/confidential/clawback.mdx b/content/stellar-contracts/tokens/confidential/clawback.mdx new file mode 100644 index 00000000..4360dd98 --- /dev/null +++ b/content/stellar-contracts/tokens/confidential/clawback.mdx @@ -0,0 +1,196 @@ +--- +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. + +Knowing an account's balance isn't enough to build the proof. An account whose history is only registration, +deposits, and merges has balances anyone can reconstruct from public events, so the circuit also requires the +auditor's secret key, and only the auditor can prove a seizure. + +## 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, + ) { + // `data` is an XDR-encoded `ClawbackData { proof }`, not the raw proof bytes. + // A malformed envelope reverts with `InvalidData`. + 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 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`. + +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..53cf7fa6 --- /dev/null +++ b/content/stellar-contracts/tokens/confidential/compliance.mdx @@ -0,0 +1,427 @@ +--- +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 { + /// Address of an external policy contract, or `None` for no policy check. + pub policy: Option
, + /// When `true`, the check on every fund-holding address also asks the + /// underlying SAC's `authorized()` view. + pub sac_passthrough: bool, +} +``` + +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 { + /// `token` is the calling confidential token, so one registry can serve several + /// tokens and still apply per-token rules. + 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. + +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. Besides the examples below, +custom hooks can enforce 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. + + +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, +}; + +// Wire it with `type Hooks = PermissiveDepositHooks;`. +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); + } +} +``` + +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) { + // `DataKey` and `DeploymentError` are defined by the deployment, which fills the + // approved list at construction or through an admin entry point. + 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... +} +``` + +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. + +## 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)). + +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..099b025b --- /dev/null +++ b/content/stellar-contracts/tokens/confidential/confidential.mdx @@ -0,0 +1,203 @@ +--- +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. + +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. + +## 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), the Noir circuits, and example contracts under +[`examples/confidential`](https://github.com/OpenZeppelin/stellar-contracts/tree/main/examples/confidential): the +verifier and auditor registries, an allowlist policy, a token with the compliance extension, and a factory. 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](/stellar-contracts/tokens/confidential/privacy-model#zero-knowledge-setting). | +| **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`. Add it to the +contract's `Cargo.toml`, pinned to an exact version as the library recommends for all its crates: + +```toml +[dependencies] +stellar-confidential = "=0.8.0" +``` + +The contract must use a `soroban-sdk` version compatible with the one `stellar-confidential` depends on. + +## 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. + +The [testnet deployment guide](https://github.com/OpenZeppelin/stellar-contracts/blob/main/examples/confidential/README.md) +walks through these steps with the Stellar CLI and the example contracts, and also deploys an allowlist policy and +the compliance extension. [Best Practices for Administrators](/stellar-contracts/tokens/confidential/best-practices/administrators) +covers what changes for a production deployment. + +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; + // Every entry point and read method uses the trait's default implementation. +} +``` + +See [Operations](/stellar-contracts/tokens/confidential/operations) for what each entry point does. + +## 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 + +- **Proofs** use the non-zero-knowledge flavor of UltraHonk, the only one the on-chain verifier supports. Soundness + is unaffected. Amount and balance privacy rests on how the circuits are built instead, and a proof lowers the + security of the prover's long-term key from about 127 to about 100 bits. The proving recipe stays provisional + until the zero-knowledge flavor is supported. See + [Zero-Knowledge Setting](/stellar-contracts/tokens/confidential/privacy-model#zero-knowledge-setting). +- **Soundness** rests on the verification keys in the verifier registry matching the intended circuits. A wrong key + makes the contract accept forged proofs. See [Proof System](/stellar-contracts/tokens/confidential/proof-system). +- **Privacy** covers amounts and balances, not addresses or timing, and some balances can be inferred from public + flows. See [Privacy Model](/stellar-contracts/tokens/confidential/privacy-model). +- **Operations** such as key custody, backups, governance, and seizures are covered by role in the Best Practices + for [account holders](/stellar-contracts/tokens/confidential/best-practices/account-holders), + [administrators](/stellar-contracts/tokens/confidential/best-practices/administrators), and + [auditors](/stellar-contracts/tokens/confidential/best-practices/auditors). + +## 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..08ce41fc --- /dev/null +++ b/content/stellar-contracts/tokens/confidential/indexing.mdx @@ -0,0 +1,147 @@ +--- +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 can't reconstruct the openings needed to spend them. Without a conforming archive, treat +wallet state as unrecoverable once its events age out of RPC. A wallet with RPC-only access must sync at least once +per retention window and warn the user when it hasn't: 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 + T0["T0"] -->|"replay receiving side"| CP[Latest checkpoint] + CP -->|"replay both sides"| 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. Per-account queries tell the indexer which accounts a client follows (see +[Trust Model](#trust-model)), so an indexer should offer both. + +**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 its contents tell a curious indexer nothing a + chain observer doesn't already know. Its queries do: a client that asks for one account's history tells the + indexer which account it follows, from which network address, and when, and an auditor's queries reveal which + accounts it audits. Clients that need to hide this fetch the whole contract stream and filter locally, or run + their own archive. +- **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 exists only in +the `SetSpender` or `SpenderTransfer` event that wrote it, so a missed or unarchived event leaves the auditor without +that opening. See [Running an Auditor](/stellar-contracts/tokens/confidential/auditing#running-an-auditor). diff --git a/content/stellar-contracts/tokens/confidential/operations.mdx b/content/stellar-contracts/tokens/confidential/operations.mdx new file mode 100644 index 00000000..05629439 --- /dev/null +++ b/content/stellar-contracts/tokens/confidential/operations.mdx @@ -0,0 +1,243 @@ +--- +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, and an account can receive any number of transfers +without the owner doing anything. The cost is one extra step: received funds must be merged before they +can be spent. See +[Griefing Resistance](https://github.com/OpenZeppelin/stellar-contracts/blob/main/packages/confidential/docs/protocol/security.md#griefing-resistance). + +## 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`, which decides who receives the encrypted copies of this account's +activity (see [Auditing](/stellar-contracts/tokens/confidential/auditing)). It can never change. The id must +exist in the auditor registry, or the call reverts with the registry's `AuditorNotRegistered`. Beyond that, +the core lets an account pick any auditor; deployments that need to restrict the choice do so in +`Hooks::on_register` (see [Restrict Auditor Selection](/stellar-contracts/tokens/confidential/compliance#restrict-auditor-selection)). + +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 +// `depositor` authorizes the underlying SEP-41 transfer and needs no confidential account; +// `recipient` must be registered. A negative amount reverts with `NegativeAmount`. +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. + +## Merge + +```rust +// Authorized by `owner`. Adds the receiving commitment to the spendable one and resets the +// receiving balance to zero. +token_client.merge(&owner); +``` + +Merge 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. + +The owner's wallet knows the new opening because it has tracked every deposit and transfer credited to +the receiving balance. Wallets should merge, or prompt the owner to, before a spend the spendable +balance can't cover. + +## Confidential Transfer + +```rust +// `transfer_data` is the XDR-encoded `TransferData` built by the sender's wallet. +// Replaces the sender's spendable commitment and adds the transfer commitment to the +// recipient's receiving balance. +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. + +```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, although public +deposits and withdrawals can let them infer some of it (see +[Privacy Model](/stellar-contracts/tokens/confidential/privacy-model#what-observers-can-infer)). + +## Withdraw + +```rust +// `withdraw_data` is the XDR-encoded `WithdrawData` built by the owner's wallet. +// Replaces the owner's spendable commitment and sends the underlying tokens to +// `destination`, which can be any address. +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 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); +// Reverts with `InvalidData` if `data` doesn't decode, and with `NonCanonicalEncoding` +// if a field or point coordinate isn't the canonical encoding of its field element. +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. + +## 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 + +Storage holds only commitments, so events are how wallets, auditors and indexers learn what happened. +[What Each Event Reveals](/stellar-contracts/tokens/confidential/privacy-model#what-each-event-reveals) lists +every event with its public and encrypted fields. + +A wallet that loses its local state rebuilds both balances from these events. Stellar RPC keeps events +only for a limited window, so recovery depends on a durable archive; see +[Indexing and Recovery](/stellar-contracts/tokens/confidential/indexing) for the procedure. + +## Read Methods + +```rust +// Returns the `ConfidentialAccount` below; reverts if `owner` isn't registered. +let account = token_client.confidential_balance(&owner); +let active = token_client.is_spender(&owner, &spender); +let delegation = token_client.get_spender_delegation(&owner, &spender); +``` + +| 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 + +A proof is 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 updates a +[verification key](/stellar-contracts/tokens/confidential/proof-system#updating-a-verification-key) while a +proof is in flight, the call reverts with `InvalidProof` and nothing changes. The wallet rebuilds the proof +and resubmits. + +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, so each +attempt needs a fresh one, retries included. Reusing a salt would let an observer link the failed attempt +to the retry, and a retry with a different amount would reveal the difference between the two amounts. The +contract can't enforce freshness, so this 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/privacy-model.mdx b/content/stellar-contracts/tokens/confidential/privacy-model.mdx new file mode 100644 index 00000000..d0faa90c --- /dev/null +++ b/content/stellar-contracts/tokens/confidential/privacy-model.mdx @@ -0,0 +1,160 @@ +--- +title: Privacy Model +--- + +[Specification](https://github.com/OpenZeppelin/stellar-contracts/blob/main/packages/confidential/docs/protocol/security.md#privacy-properties) + +The confidential token hides how much an account holds and how much it sends. It does not hide who transacts with +whom, when, or which operation they call. Most of what is public about amounts comes from wrapping a public token, so +this page starts there. It then covers what each party can learn, what every event publishes, what a proof can reveal +(see [Zero-Knowledge Setting](#zero-knowledge-setting)), what an observer can infer by combining events, and what can +be learned off-chain. + +## Boundary with the Underlying Token + +The confidential token is a layer on top of an existing SEP-41 token, which stays transparent. Value enters the layer +only through `deposit` and leaves it only through `withdraw` or a clawback, and every crossing settles on the +underlying token in public. Two things follow from this boundary: + +- **Every crossing publishes its amount.** Deposit, withdrawal and clawback amounts are public. A clawback counts as a + crossing because the seized amount has to be reconciled against the pool of underlying tokens the contract holds. + [What Observers Can Infer](#what-observers-can-infer) shows how these amounts can reveal balances and transfers. +- **The underlying token's history stays public.** Where a deposit's tokens came from, and where withdrawn tokens go + next, can be traced on the underlying token like any other transfer. Both ends of an account's confidential + activity are linked to its public history. + +A native confidential token, whose balances are confidential from issuance, avoids most of this. Value is minted +straight into confidential balances and never exists in transparent form, so there are no deposits or withdrawals to +watch, no public history to trace, and every holder is on the private side. Amounts are public only where the issuer +chooses, typically at minting and burning. This library implements only the wrapped model. + +The boundary doesn't explain everything. The transaction graph, timing, auditor choice, transaction sources, freezes, +and off-chain visibility described below follow from addresses being public and from how clients handle keys and data, +so a native token inherits them. Outside privacy, the +[underlying token requirements](/stellar-contracts/tokens/confidential/confidential#underlying-token-requirements) +and the [pooled-custody problem](/stellar-contracts/tokens/confidential/clawback#the-pooled-custody-problem) behind +clawback also come from wrapping. + +## Who Sees What + +| Party | What it learns | +| --- | --- | +| **Anyone** | Every address involved in every operation, which operation it was and when it ran, the amounts of deposits, withdrawals and clawbacks, every delegation and its expiry, and the `auditor_id` each account chose. | +| **The holder** | Everything about its own account: both balances, every amount it sent or received, and the allowances it granted. | +| **A counterparty** | The amount of each transfer between the two accounts. Never the other account's balance. | +| **A spender** | Its own allowance and the amounts it sends from it. Never the owner's spendable balance. | +| **The account's auditor** | The amount of every transfer the account sends or receives and, from the account's first spend onward, its balances and allowances. See [Auditing](/stellar-contracts/tokens/confidential/auditing). | +| **A viewing-key holder** | Everything the holder's wallet can read, including the openings of every transfer the account originated. It cannot spend. | +| **A disclosure recipient** | Only the fact a [selective disclosure](/stellar-contracts/tokens/confidential/selective-disclosure) proof attests to. | + +An auditor learns about an account it doesn't audit only through that account's transfers with accounts it does. + +## What Each Event Reveals + +Events and invocation arguments are public. `Transfer`, `Withdraw`, `SetSpender` and `SpenderTransfer` also carry an +ephemeral public key and a salt, which let the intended readers decrypt and reveal nothing on their own. + +| Event | Public | Encrypted | Readable by | +| --- | --- | --- | --- | +| `Register` | `account`, `auditor_id` | | | +| `Deposit` | `from`, `to`, `amount` | | | +| `Merge` | `account` | | | +| `Transfer` | `from`, `to` | The amount | Recipient, both auditors | +| | | The sender's new balance | Sender, sender's auditor | +| `Withdraw` | `from`, `to`, `amount` | The sender's new balance | Sender, sender's auditor | +| `SetSpender` | `account`, `spender`, `live_until_ledger` | The owner's new balance | Owner, owner's auditor | +| | | The escrowed amount | Owner's auditor | +| `SpenderTransfer` | `spender`, `from`, `to` | The amount | Recipient, both auditors | +| | | The remaining allowance | Owner's auditor | +| `RevokeSpender` | `account`, `spender` | The returned allowance | Owner, spender | +| `Clawback` | `account`, `amount`, `destination` | | | +| `Frozen`, `Unfrozen` | `account` | | | + +`SetSpender` and `SpenderTransfer` also pass the allowance encrypted for the owner and the spender (`a_tilde`) as a +call argument and write it to the delegation's storage, so both can read the escrowed amount and the remaining +allowance there. `RevokeSpender` republishes that ciphertext. The owner's auditor can't decrypt it, but already knows +the returned allowance from the last `SetSpender` or `SpenderTransfer`. + +`Clawback`, `Frozen` and `Unfrozen` exist only in deployments with the +[compliance extension](/stellar-contracts/tokens/confidential/compliance). + +Contract storage is public too. `confidential_balance` returns an account's public keys, commitments and +`auditor_id`, and `get_spender_delegation` returns a delegation's expiry. With the compliance extension, `is_frozen` +and `clawback_nonce` return whether an account is frozen and how many seizures it has been through. + +## Zero-Knowledge Setting + +Proofs are invocation arguments, so they are public too. The on-chain verifier implements only the +non-zero-knowledge flavor of UltraHonk, so proofs are generated without the zero-knowledge setting. That leaves +soundness intact but drops the guarantee that a proof hides its witness. The zero-knowledge flavor masks every +committed witness polynomial with fresh randomness, so a proof reveals nothing about its witness by construction. The +non-zero-knowledge flavor commits to the witness as it is, so a proof is a deterministic function of the witness and +carries no such guarantee. + +The circuits close one known channel. Without masking, lookup read counts and read tags are committed in the clear, +so a lookup indexed by a secret would make public which table rows it hit. Barretenberg multiplies by a constant +base point with fixed-base lookup tables when that point is one of its precomputed generators, as the Pedersen +generators `G` and `H` are, and the rows hit would follow windows of the secret scalar. The circuits therefore pass +the generators to every scalar multiplication as witnesses, which moves it to the variable-base path. CI rejects any +scalar multiplication with a constant base point and any circuit operation outside a list of those Barretenberg +builds without lookup tables. + +This closes the lookup channel only; it does not make proofs private. Whether other parts of a non-zero-knowledge +proof, such as the wire commitments or the opened evaluations, reveal private values has not been established. One +case is known: the proof's commitment to its lookup inverses and that polynomial's opened evaluation determine two +points in BN254, the curve the proof system works over, whose discrete logarithm is the prover's long-term key. +Recovering the key from them is a discrete-logarithm problem in BN254, estimated at about 100 bits of security. That +is the level the proof system's soundness already relies on, but below the roughly 127 bits that protect the key +behind its public key on Grumpkin. + +Zero knowledge requires the zero-knowledge flavor of the verifier, which Nethermind has +[in progress](https://github.com/NethermindEth/rs-soroban-ultrahonk/pull/38). Adopting it changes the proving +recipe and the verifier dependency, not the circuits or the verification keys. See the package +[README](https://github.com/OpenZeppelin/stellar-contracts/blob/main/packages/confidential/README.md#zero-knowledge-setting) +and [Generating Proofs](/stellar-contracts/tokens/confidential/proof-system#generating-proofs). + +## What Observers Can Infer + +Combining public events over time reveals more than any single event. The first two patterns come from amounts +crossing the [boundary with the underlying token](#boundary-with-the-underlying-token); the others come from +addresses being public: + +- **Balances built only from public flows are public.** Deposit and withdrawal amounts are public, and a merge only + moves value between an account's own balances. An account whose value has only entered through deposits and left + through withdrawals therefore has a balance anyone can compute. It becomes hidden once a confidential transfer, sent + or received, touches the account, and even then earlier flows bound it: an account known to hold 1,000 that sends a + transfer holds at most 1,000 afterwards. +- **Matching amounts link flows.** A deposit of 1,234.56 into one account, a transfer from it to a second account, + and a withdrawal of 1,234.56 from the second strongly suggest what the transfer moved. The protocol cannot hide an + amount that appears in public on both sides. +- **The transaction graph is public.** Every transfer names its sender and recipient, so observers can see who pays + whom, how often, and how many transfers an account receives. Each `SetSpender` publishes an owner-spender pair and + its expiry, and each `SpenderTransfer` names the owner it spends for. +- **Timing reveals habits.** Merges, spends, and withdrawals that closely follow incoming transfers, or recur at fixed + intervals, show when a holder is active and can link accounts operated together. +- **The auditor choice is public.** On a registry shared by several institutions, an account's `auditor_id` can hint + at whom the holder banks with or which jurisdiction it falls under. +- **Transaction sources are public.** The Stellar transaction that carries an operation names a source account, + which pays the fee. When it differs from the account in the event, the two are linked. +- **Compliance actions are public.** Freezes and clawbacks name the account. A clawback also publishes its + destination and, because it crosses to the underlying token, its amount. + +## Off-Chain Visibility + +The chain is not the only place where an account's activity can be seen: + +- **Queries.** A wallet or auditor that asks an RPC provider or an indexer for one account's events tells it which + account it follows, from which network address, and when. Fetching the whole contract stream and filtering + locally, or running one's own node and archive, avoids this. See + [Trust Model](/stellar-contracts/tokens/confidential/indexing#trust-model). +- **Client software.** Keys, decrypted amounts, balances and openings that reach logs, analytics or crash reports + are beyond what the protocol can protect. +- **Disclosed values.** Once a disclosure recipient decrypts a value, protecting it is the recipient's + responsibility. + +## Improving Privacy + +Most of these patterns are under the control of the holder and its wallet; see +[Best Practices for Account Holders](/stellar-contracts/tokens/confidential/best-practices/account-holders). What a +deployment can do for its users is covered in +[Best Practices for Administrators](/stellar-contracts/tokens/confidential/best-practices/administrators). 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..b079cda3 --- /dev/null +++ b/content/stellar-contracts/tokens/confidential/proof-system.mdx @@ -0,0 +1,288 @@ +--- +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 circuits are written in [Noir](https://noir-lang.org/) and compiled to UltraHonk, and the +wallet generates proofs on the client. The token verifies each one with a cross-contract call to a +separate **verifier registry**, which stores one verification key (VK) per circuit and runs the UltraHonk +verifier from [NethermindEth/rs-soroban-ultrahonk](https://github.com/NethermindEth/rs-soroban-ultrahonk). +That verifier supports only the non-zero-knowledge flavor of UltraHonk, so the circuits themselves are +built to keep amounts and balances private; see [Generating Proofs](#generating-proofs). + +The library ships the circuit sources, the pinned verification keys, and the verifier registry module. +It does not ship a prover or client SDK; see [Wallet Integration](/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); + } + + // Shares the `manager` role with registration purely for illustration. See + // "Updating a Verification Key" for what a production deployment should do instead. + #[only_role(operator, "manager")] + fn update_verification_key( + e: &Env, + circuit_type: CircuitType, + new_verification_key: Bytes, + operator: Address, + ) { + verifier::update_verification_key(e, circuit_type, &new_verification_key); + } +} + +#[contractimpl(contracttrait)] +impl AccessControl for ConfidentialVerifierContract {} +``` + +The registry depends on `stellar-confidential`, like the token. See +[Installation](/stellar-contracts/tokens/confidential/confidential#installation). + +Verification keys live in **instance** storage. As with every module in this library, extending the +instance TTL is the contract developer's responsibility. + +## Registering Verification Keys + +The pinned keys are committed under +[`circuits/vks/`](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: + +| `CircuitType` | Key file | +| --- | --- | +| `Register` | `register.vk.bin` | +| `Withdraw` | `withdraw.vk.bin` | +| `Transfer` | `transfer.vk.bin` | +| `SpenderTransfer` | `spender_transfer.vk.bin` | +| `SetSpender` | `set_spender.vk.bin` | +| `Clawback` | `clawback.vk.bin` (only with the clawback extension) | + +Registration fails with `VerificationKeyAlreadyRegistered` if that circuit already has a key. + +The registry stores the bytes without inspecting them. A wrong file, such as a `.vk.json`, registers +without error and fails only once `verify_proof` runs, with `InvalidVerificationKey`. Registration is +single-shot per circuit, so fixing it then takes `update_verification_key`. After registering, call +`verify_proof` once per circuit with junk bytes. It returns `false` if the key parses and panics with +`InvalidVerificationKey` if it doesn't. + +The test below, modeled on the example contract's tests, registers the Transfer key with the registry +above and runs that check: + +```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); + + // On a live network, the manager signs this same call with the `.vk.bin` contents. + client.register_verification_key( + &CircuitType::Transfer, + &Bytes::from_array(&e, TRANSFER_VK), + &manager, + ); + + // The key parses: a junk proof returns `false` instead of panicking with + // `InvalidVerificationKey`. + let junk = Bytes::from_array(&e, &[0u8; 32]); + assert!(!client.verify_proof(&CircuitType::Transfer, &junk, &junk)); +} +``` + +The keys can be reproduced from the circuit sources with the pinned toolchain (`nargo 1.0.0-beta.11`, +`bb 0.87.0`), and CI fails if a regenerated key differs from the committed one. Publish the circuit +source, the toolchain versions and the key files with every deployment, so that users can check the +keys themselves. + +## Updating a Verification Key + + +Updating a verification key is soundness-critical. A wrong, corrupted or malicious key makes its +circuit accept forged proofs: minting value, draining accounts, or impersonating users. The registry +stores keys as opaque bytes and cannot detect a bad one. Activating a new key also makes every +in-flight proof for that circuit fail, so wallets must regenerate and resubmit. + + +The strongest posture is full immutability: fix the token's dependencies and every verification key at +deployment, and require a fresh deployment for any circuit change. If a deployment exposes +`update_verification_key` at all, keep it for fixing a discovered soundness bug. Put it behind strong +governance, at minimum a multisig plus a [timelock](/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 the settings below: + +```bash +nargo execute --package circuit_ +# --oracle_hash keccak is required: the on-chain verifier reproduces the Fiat-Shamir +# transcript with Keccak, while bb defaults to Poseidon2. +# Don't pass --zk: the on-chain verifier implements only the non-zero-knowledge flavor. +bb prove -s ultra_honk --oracle_hash keccak \ + -b target/circuit_.json -w target/.gz -o +``` + +The [verifier's README](https://github.com/NethermindEth/rs-soroban-ultrahonk/blob/9aae9581eab7bc4c69209c0322e54e119ed3180e/crates/ultrahonk-soroban-verifier/README.md) +for release 0.1.0 warns that such a proof carries witness-dependent messages and gives no +witness-hiding guarantee, so private inputs stay hidden only as far as the circuit hides them. The +confidential circuits are built for that setting, with one residual effect on the prover's long-term key. +[Zero-Knowledge Setting](/stellar-contracts/tokens/confidential/privacy-model#zero-knowledge-setting) +explains both. + + +Privacy depends on the circuits keeping witness data out of lookup tables. Passing `G` or `H` to a +scalar multiplication as a constant, for example, makes Barretenberg use its fixed-base lookup tables, +and the proof then reveals which table rows the secret scalar's windows hit. Anyone modifying the +circuits must keep +[`check_witness_lookups.sh`](https://github.com/OpenZeppelin/stellar-contracts/blob/main/packages/confidential/circuits/scripts/check_witness_lookups.sh) passing. + + +The proving recipe is provisional and will be finalized together with the verifier, including the +zero-knowledge setting. Check the +[verification-key README](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). + +## 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..be13ae1a --- /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 reveals 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 whole +history. It is bound to the recipient's public key and a fresh nonce, so it can't be reused for a different request, +and only that recipient can open its value. The recipient checks it against the event log, account records, and +auditor registry, so it doesn't have to trust the prover. The prover can be the holder (with its viewing key) or the +auditor (with its auditor key), and nothing about the contract's storage, entry points, or ciphertexts changes. + +**Non-goals.** The layer proves positive statements only ("this event paid me X"), never completeness ("there are no +other transfers from Y"), which still goes through the auditor. Disclosures are not logged on-chain, since +that would reveal 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..e560e568 --- /dev/null +++ b/content/stellar-contracts/tokens/confidential/spenders.mdx @@ -0,0 +1,182 @@ +--- +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 + +A delegation is active from `set_spender` until the ledger passes `live_until_ledger`, and expired +after that. Expiry only stops spending: the remaining allowance stays escrowed in the delegation entry +until `revoke_spender` deletes the entry and returns the allowance to the owner's spendable balance. In +deployments with the compliance extension, the token admin can also end a frozen account's delegation +with `force_revoke_spender` (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. + +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. + +## Setting a Spender + +```rust +// `set_spender_data` is the XDR-encoded `SetSpenderData` built by the owner's wallet. +// `spender` must already be registered. Moves the allowance out of the owner's spendable +// balance into escrow. +token_client.set_spender(&owner, &spender, &live_until_ledger, &set_spender_data); +``` + +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. + +## Spending from an Allowance + +```rust +// Authorized by the spender, not the owner. Reverts with `DelegationNotFound` if there is +// no delegation, and with `DelegationExpired` once the ledger is past `live_until_ledger`. +token_client.confidential_transfer_from(&spender, &owner, &recipient, &spender_transfer_data); +``` + +The owner approved in advance at `set_spender`, and that approval lives in the delegation entry until +it expires or is revoked. + +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 the salt of its current state, + `allowance_salt`, which only opens the current allowance. The spender's wallet picks a new salt, + `sigma_a_new`, keys every encryption mask to it, and the contract stores it as the new + `allowance_salt`. A reverted call leaves the stored salt unchanged, so the wallet must pick a fresh + `sigma_a_new` for every attempt, retries included. The circuit rejects only a value equal to the + stored salt; it can't enforce freshness. + +## Revoking a Spender + +```rust +// Needs no proof. Adds the remaining allowance commitment back to the owner's spendable +// commitment and deletes the delegation entry, whether active or expired. +token_client.revoke_spender(&owner, &spender); +``` + +The `RevokeSpender` event carries the encrypted allowance and its salt. Because the call deletes the +entry, the event is the only place the owner's wallet can read 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 + +The spender can call `confidential_transfer_from` while `ledger.sequence() <= live_until_ledger`. After +that, spending reverts, but the escrow stays in the delegation entry until the owner revokes it (see +[Lifecycle](#lifecycle)). + +`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. + +## 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 + +| 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..f4ed80fa --- /dev/null +++ b/content/stellar-contracts/tokens/confidential/wallet-integration.mdx @@ -0,0 +1,208 @@ +--- +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 Wallet Matters + +A balance is a commitment `C = v·G + r·H`. The chain stores the point, but the opening `(v, r)` needed to spend it +lives only in the wallet. If the wallet loses the opening or adds it up wrong, the funds can't be spent, and the +contract can't help because it never knew the value. Every amount is decrypted by the client, so a decryption bug +shows a plausible wrong number rather than an error. And all keys, blinding factors, and salts come from the client, +so confidentiality is only as strong as 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. + [Generating Proofs](/stellar-contracts/tokens/confidential/proof-system#generating-proofs) has the commands and + the recipe's current status. 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)). The operational practices for +each role are in Best Practices, for +[account holders](/stellar-contracts/tokens/confidential/best-practices/account-holders) and +[auditors](/stellar-contracts/tokens/confidential/best-practices/auditors). + +## Requirements + +- **Secrets stay local.** The root, keys, ephemeral scalars, and cached openings never leave the device, except under + opted-in remote proving. +- **Nothing sensitive is logged.** Secrets, witness material, decrypted amounts, balances, and openings never reach + logs, telemetry, analytics, error reports, or crash dumps. +- **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 has to hand `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..08dc5ead 100644 --- a/src/navigation/stellar.json +++ b/src/navigation/stellar.json @@ -67,6 +67,106 @@ "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": "page", + "name": "Privacy Model", + "url": "/stellar-contracts/tokens/confidential/privacy-model" + }, + { + "type": "page", + "name": "Proof System", + "url": "/stellar-contracts/tokens/confidential/proof-system" + }, + { + "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": "page", + "name": "Selective Disclosure", + "url": "/stellar-contracts/tokens/confidential/selective-disclosure" + }, + { + "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": "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": "folder", + "name": "Best Practices", + "children": [ + { + "type": "page", + "name": "Account Holders", + "url": "/stellar-contracts/tokens/confidential/best-practices/account-holders" + }, + { + "type": "page", + "name": "Administrators", + "url": "/stellar-contracts/tokens/confidential/best-practices/administrators" + }, + { + "type": "page", + "name": "Auditors", + "url": "/stellar-contracts/tokens/confidential/best-practices/auditors" + } + ] + } + ] } ] },