Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions packages/tokens/src/confidential/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ The module ships one token contract plus three satellites, each with the standar
| `mod.rs`, `storage.rs` | The `ConfidentialToken` trait — eleven entry points, of which `revoke_spender` is proofless — and the storage/orchestration layer |
| `verifier/` | Separate contract holding per-circuit UltraHonk verification keys |
| `auditor/` | Separate contract holding the auditor key registry |
| `compliance/` | `ComplianceHooks` — freeze, SAC passthrough, policy contract, clawback |
| `compliance/` | `ComplianceHooks` — freeze, SAC passthrough, policy contract — plus the opt-in `ConfidentialClawback` trait (seizure and forced revoke) |
| `circuits/` | Noir workspace, compiled by `nargo`, not `cargo` |
| `docs/` | The protocol specification (see below) |

Expand Down Expand Up @@ -60,16 +60,16 @@ Five things exist in more than one file. Changing the normative copy means grepp
| Content | Normative source | Copies live in |
|:---|:---|:---|
| The 17 domain-separation tags | `DESIGN_cont.md` §13 | `SDK.md` §4.8, referenced by `SELECTIVE_DISCLOSURE.md` |
| Sponge lane assignment (lane 0 = amount mask, lane 1 = balance/allowance/randomness, lane 2 = sender-auditor secret-escrow slot) | `DESIGN.md` §2.5 | `SDK.md` §4.3 and §11 |
| Per-circuit scalar-multiplication counts | `DESIGN_cont.md` §10.3 | `OVERVIEW.md` |
| Checkpoint event set (`Withdraw`, `Transfer` sender side, `SetSpender`) | `DESIGN.md` §5.2 | `INDEXER.md`, `SDK.md` |
| Sponge lane assignment (lane 0 = amount mask, lane 1 = balance/allowance/randomness, lane 2 = sender-auditor secret-escrow slot; only `δ_aud_s` is squeezed three-wide) | `DESIGN.md` §2.5 | `SDK.md` §4.3 and §11 |
| Per-circuit scalar-multiplication counts and ACIR opcode counts | `DESIGN_cont.md` §10.3 | `OVERVIEW.md` (scalar-mul figures), `circuits/CLAUDE.md` (ACIR counts) |
| Checkpoint event set (`Withdraw`, `Transfer` sender side, `SetSpender`) and the `T₀` anchor set (`Register`, `Merge`, `Clawback`) | `DESIGN.md` §5.2 | `INDEXER.md`, `SDK.md` |
| Replay-window anchor `T₀` | `DESIGN.md` §5.2 | `INDEXER.md`, `OVERVIEW.md` |

The tags are a cross-language wire contract. `DESIGN_cont.md` §13 assigns all seventeen and no other document may; `circuits/lib/src/lib.nr` implements 1–13 and 17, because 14 is derived off-circuit and 15–16 belong to the off-chain disclosure layer. That gap is intentional. Changing any assigned value is a new deployment, not an upgrade.

### Editing rules

- **Cite, do not restate.** Every drift bug in the recent history came from a second copy of something. When tempted to summarise a neighbouring section, write `§N` instead.
- **Match the file's math style, not a global one.** `DESIGN*.md` and `SELECTIVE_DISCLOSURE.md` use `$$…$$` with backslash-escaped subscripts (`$$\mathbb{F}\_r$$`); `SDK.md` uses `$$…$$` unescaped; `OVERVIEW.md` uses single `$…$`; `INDEXER.md` and `COMPLIANCE.md` use backticked ASCII and no LaTeX.
- **Match the file's math style, not a global one.** `DESIGN*.md` and `SELECTIVE_DISCLOSURE.md` use `$$…$$` with backslash-escaped subscripts (`$$\mathbb{F}\_r$$`); `SDK.md` uses `$$…$$` unescaped; `OVERVIEW.md` uses single `$…$`; `INDEXER.md` and `COMPLIANCE.md` use backticked ASCII and no LaTeX (the single `$$…$$` formula in `COMPLIANCE.md` §2.2 is the one exception).
- **Symbols are a maintained namespace.** `sk`/`vk`/`dvk_i`/`PVK`/`Y`; `r_e` and `R_e = r_e·H`; `σ` (operation salt) is distinct from `σ_a` (per-delegation allowance salt); tilde means ciphertext; `C_spend` / `C_receive` / `C_transfer` / `C_a`. An audit finding once required renaming the `tx` subscript to `transfer` across the whole module.
- Prose is full-width — no hard wrapping. One paragraph or list item per line.
4 changes: 2 additions & 2 deletions packages/tokens/src/confidential/circuits/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ It appears in all fourteen `Nargo.toml` files and looks like a mistake. Nargo re

### Do not prune unused public inputs

`_acct_f` in `register/src/main.nr` is referenced by no gate and looks like dead code. It is the replay binding: UltraHonk absorbs every public input into the transcript, so a proof produced for one account fails when the contract assembles the blob for another. Removing it lets anyone replay a legitimate registration's published proof and payload to mint duplicate-key accounts. Each operation circuit declares its exact public-input count in a header comment — withdraw 16, transfer / spender_transfer 25, set_spender 26 — and the count is part of the contract with the on-chain assembler.
`_acct_f` in `register/src/main.nr` is referenced by no gate and looks like dead code. It is the replay binding: UltraHonk absorbs every public input into the transcript, so a proof produced for one account fails when the contract assembles the blob for another. Removing it lets anyone replay a legitimate registration's published proof and payload to mint duplicate-key accounts. `clawback/src/main.nr` has **three** of them — `addr_f`, `_acct_f`, `_dest_f` — and `_dest_f` is the one with teeth: it binds the settlement destination, and dropping it turns a compromised clawback signer from a griefer into a thief. Each operation circuit declares its exact public-input count in a header comment — clawback 8, withdraw 16, transfer / spender_transfer 25, set_spender 26 — and the count is part of the contract with the on-chain assembler.

### Package names are load-bearing

Expand Down Expand Up @@ -58,7 +58,7 @@ LC_ALL=C nargo info | grep '^|' | LC_ALL=C sort > constraints.baseline

`LC_ALL=C` is mandatory on **both** sides of the pipe — byte order is the only ordering stable between macOS and the Ubuntu runner. The redirect overwrites the file's header comments; re-paste them, because CI's failure message asks for them.

Two non-obvious consequences: adding or removing a **gadget** changes the baseline even when no circuit logic changed, and the ACIR opcode counts are quoted in prose at `../docs/DESIGN_cont.md` §10.3 (Register 33, Withdraw 95, Transfer 134, SetSpender 135, SpenderTransfer 136). Nothing enforces that second copy — update it in the same PR.
Two non-obvious consequences: adding or removing a **gadget** changes the baseline even when no circuit logic changed, and the ACIR opcode counts are quoted in prose at `../docs/DESIGN_cont.md` §10.3 (Register 33, Clawback 51, Withdraw 95, Transfer 134, SetSpender 135, SpenderTransfer 136). Nothing enforces that second copy — update it in the same PR.

### `vks/`

Expand Down
1 change: 1 addition & 0 deletions packages/tokens/src/confidential/circuits/Nargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ members = [
"transfer",
"set_spender",
"spender_transfer",
"clawback",
"gadgets/assert_on_curve",
"gadgets/commit",
"gadgets/ecdh",
Expand Down
10 changes: 10 additions & 0 deletions packages/tokens/src/confidential/circuits/clawback/Nargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
[package]
name = "circuit_clawback"
type = "bin"
authors = ["OpenZeppelin"]
# Nargo requirements cannot name prereleases; the supported toolchain is
# nargo 1.0.0-beta.11 + bb 0.87.0, pinned in .github/workflows/noir.yml.
compiler_version = "=1.0.0"

[dependencies]
stellar_confidential_lib = { path = "../lib" }
134 changes: 134 additions & 0 deletions packages/tokens/src/confidential/circuits/clawback/src/main.nr
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
use stellar_confidential_lib::commit;

mod tests;

// Clawback circuit -- design doc Section 7.13 (COMPLIANCE.md Section 5).
//
// Proves that a compliance seizure of the public amount `alpha` is bounded by
// what the target actually holds, without revealing either balance. It is the
// only circuit in the system with no key-ownership constraint and no `r_e`:
// both openings are pinned by Pedersen binding directly, so there is nothing
// left to forge and nothing to pin.
//
// Constraints
// -----------
// CB1 C_spend = v_s * G + r_s * H Prover knows the opening of the
// target's spendable commitment.
// CB2 C_receive = v_r * G + r_r * H Prover knows the opening of the
// target's receiving commitment.
// CB3 v_s, v_r, alpha, and
// v_s + v_r - alpha in [0, 2^127) The seize bound (Section 2.6).
// The final term is the
// soundness-critical one: it is
// what makes
// alpha <= v_s + v_r, and without
// it an over-seize would drive the
// committed value negative mod F_r
// -- openable arithmetically but
// never again satisfiable under
// W4 / T4, i.e. an unspendable
// account.
//
// Who can produce this witness
// ----------------------------
// Anyone holding both Pedersen openings: the auditor, which tracks them
// forward from the sender-auditor secret-escrow slot (W_a5 / T_a9 / S_a6), or
// the owner, which derives them from `vk`. **Not the compliance admin**, which
// holds no blinding. That asymmetry is the whole of COMPLIANCE.md Section
// 5.2's "neither party can act alone": the admin decides whether to seize, the
// witness-holder decides how much and where to. A colluding owner supplying
// the witness is harmless -- it is the party being seized from.
//
// Identity encoding
// -----------------
// The circuit MUST accept (0, 0) as the identity for either commitment, opened
// as (0, 0). `C_receive = O` whenever the target has nothing pending, which is
// the common case. `commit(0, 0)` returns the identity point, whose
// `EmbeddedCurvePoint` encoding has x = y = 0, so the equality checks below
// hold without a special case.
//
// Point-validation doctrine (Section 10.8)
// ----------------------------------------
// C_spend and C_receive are the only points, and both are bound to in-circuit
// multi_scalar_mul outputs (CB1, CB2), so they are on-curve by construction.
// There is no public-input key to validate: the circuit consumes no auditor
// key, no spending key, and no ephemeral.
//
// Public inputs (8 fields, in design-doc canonical order)
// -------------------------------------------------------
// Idx Param Symbol Source / Note
// --- ----- ------ ----------------------------------------
// 0 c_spend_x C_spend.x Loaded from the target's
// 1 c_spend_y C_spend.y `spendable_commitment`.
// 2 c_receive_x C_receive.x Loaded from the target's
// 3 c_receive_y C_receive.y `receiving_commitment`.
// 4 alpha alpha Public seize amount, from the invocation.
// The contract pre-checks alpha > 0 before
// assembling the blob; CB3 closes
// alpha < 2^127 in-circuit.
// 5 addr_f addr_f Loaded from instance storage.
// 6 _acct_f acct_f `address_to_field(account)`, computed
// per-call.
// 7 _dest_f dest_f `address_to_field(destination)` under
// `Some`, the zero field under `None`;
// computed per-call.
//
// `addr_f`, `_acct_f`, and `_dest_f` are referenced by no gate and look like
// dead parameters. They are the bindings, on the `register/_acct_f` precedent:
// UltraHonk absorbs every public input into the transcript, so a proof
// produced against one contract, account, or settlement destination fails when
// the contract assembles the blob for another. Removing `_dest_f` in
// particular would turn a compromised clawback signer from a griefer into a
// thief -- it could take a witness the auditor built for one destination and
// settle it to an address of its own choosing. Do not prune them.
//
// The first two additionally cover the degenerate case of two accounts whose
// commitments are both O. Anti-replay comes from C_spend and C_receive
// themselves: both change on a successful seize, so a proof never verifies
// twice -- which holds only because the contract rejects alpha = 0, the sole
// state-preserving case (C_receive = O together with alpha = 0).
//
// Private witnesses
// -----------------
// v_s Plaintext spendable-balance value.
// r_s Plaintext blinding factor for C_spend.
// v_r Plaintext receiving-balance value.
// r_r Plaintext blinding factor for C_receive.

fn main(
v_s: Field,
r_s: Field,
v_r: Field,
r_r: Field,
c_spend_x: pub Field,
c_spend_y: pub Field,
c_receive_x: pub Field,
c_receive_y: pub Field,
alpha: pub Field,
addr_f: pub Field,
_acct_f: pub Field,
_dest_f: pub Field,
) {
// addr_f is a binding-only input like _acct_f and _dest_f; naming it
// without a leading underscore would warn, so consume it explicitly.
let _ = addr_f;

// CB1
let c_spend_derived = commit(v_s, r_s);
assert(c_spend_derived.x == c_spend_x);
assert(c_spend_derived.y == c_spend_y);

// CB2
let c_receive_derived = commit(v_r, r_r);
assert(c_receive_derived.x == c_receive_x);
assert(c_receive_derived.y == c_receive_y);

// CB3 -- Section 2.6's 127-bit decomposition / recomposition pattern.
// The sum check is the soundness-critical step: v_s and v_r each in range
// does not bound their difference from alpha.
v_s.assert_max_bit_size::<127>();
v_r.assert_max_bit_size::<127>();
alpha.assert_max_bit_size::<127>();
let v_new = v_s + v_r - alpha;
v_new.assert_max_bit_size::<127>();
}
Loading
Loading