Skip to content
Open
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
13 changes: 7 additions & 6 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 (`clawback`, `force_revoke_spender`) |
| `circuits/` | Noir workspace, compiled by `nargo`, not `cargo` |
| `docs/` | The protocol specification (see below) |

Expand All @@ -25,13 +25,13 @@ Balances are Pedersen commitments on Grumpkin. Every operation that opens or re-

## Canonical encoding is a security boundary

The public-input blob is a positional concatenation of 32-byte big-endian `Bn254Fr` representatives, in the order given by each circuit's table in DESIGN §7. Grumpkin points contribute two limbs (`x` then `y`).
The public-input blob is a positional concatenation of 32-byte big-endian `Bn254Fr` representatives, in the order given by each circuit's table in DESIGN §7 (COMPLIANCE §5.3 for the clawback circuit). Grumpkin points contribute two limbs (`x` then `y`).

Soroban's host silently reduces values `≥ r` modulo `r` rather than rejecting them, so `x` and `x + r` deserialise to the same field element. Every caller-supplied scalar and coordinate must therefore reach `verify_proof` through `append_field` / `append_point`, which call `Grumpkin::is_canonical_field` / `is_canonical_point`. Bypassing those helpers breaks byte-uniqueness of stored state and emitted events even though proofs still verify.

## Code cites the spec by section number

Rust carries roughly sixteen `DESIGN §N` / `DESIGN_cont §N` references in doc comments. Renumbering a spec section silently invalidates them — nothing checks. Before renumbering, grep the module for the old number.
Rust carries roughly two dozen `DESIGN §N` / `DESIGN_cont §N` / `COMPLIANCE §N` references in doc comments, and `circuits/clawback/src/main.nr` cites `COMPLIANCE.md` by section too. Renumbering a spec section silently invalidates them — nothing checks. Before renumbering, grep the module for the old number.

## Tests

Expand All @@ -55,23 +55,24 @@ Everything else defers by citation: `SDK.md`, `SELECTIVE_DISCLOSURE.md`, `INDEXE

### Duplicated tables that drift

Five things exist in more than one file. Changing the normative copy means grepping for every other one:
Six things exist in more than one file. Changing the normative copy means grepping for every other one:

| Content | Normative source | Copies live in |
|:---|:---|:---|
| Domain-separation tag assignments | `DESIGN_cont.md` §13 | `SDK.md` §4.8, referenced by `SELECTIVE_DISCLOSURE.md` |
| Sponge lane assignment | `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 | `DESIGN.md` §5.2 | `INDEXER.md`, `SDK.md` |
| Replay-window anchor `T₀` | `DESIGN.md` §5.2 | `INDEXER.md`, `OVERVIEW.md` |
| Replay-window anchor `T₀` (`Register`, `Merge`, `Clawback`) | `DESIGN.md` §5.2 | `INDEXER.md`, `OVERVIEW.md`, `SDK.md`, `COMPLIANCE.md` §5.7 |
| ACIR opcode counts | `circuits/constraints.baseline` | `DESIGN_cont.md` §10.3, `circuits/CLAUDE.md` |

The tags are a cross-language wire contract. `DESIGN_cont.md` §13 is their only authoritative source: it assigns every value, and it states which subset `circuits/lib/src/lib.nr` implements and why the remainder are absent. Changing any assigned value is a new deployment, not an upgrade.

### Economy

Every sentence is a maintenance liability: a claim written twice has to be fixed twice, and the second copy is the one that goes stale. Doc work here trends net-negative in lines.

- **One owning section per claim.** Every fact has exactly one home; everywhere else writes `§N`. When tempted to summarise a neighbouring section for the reader's convenience, cite it instead;
- **One owning section per claim.** Every fact has exactly one home; everywhere else writes `§N`. When tempted to summarise a neighbouring section for the reader's convenience, cite it instead.
- **Say it once, then move on.** No second-register restatement, no paragraph-closing punchline, no recap of the section's own argument in its last sentence. If a paragraph's content survives deleting it, delete it.
- **Gloss a symbol at its definition site only.** Re-glossing `s` or `r_e` in each section that uses them is three more places to update when a name changes.
- **No pre-stating.** A `Note:` or lead-in that previews what the next paragraph spells out in full is a duplicate. Fold it into the argument.
Expand Down
8 changes: 4 additions & 4 deletions packages/tokens/src/confidential/circuits/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,11 @@ There is no `nargo fmt` step in CI and no formatter config — Noir formatting i

### `compiler_version = "=1.0.0"` is deliberate

It appears in all thirteen package `Nargo.toml` files — the workspace manifest `circuits/Nargo.toml` carries only `[workspace]` — and looks like a mistake. Nargo requirements cannot name prereleases, so the real toolchain — **nargo 1.0.0-beta.11 + bb 0.87.0**, pinned in `.github/workflows/noir.yml` — is documented in the comment above it instead. Do not "correct" this to `=1.0.0-beta.11`.
It appears in all fourteen package `Nargo.toml` files — the workspace manifest `circuits/Nargo.toml` carries only `[workspace]` — and looks like a mistake. Nargo requirements cannot name prereleases, so the real toolchain — **nargo 1.0.0-beta.11 + bb 0.87.0**, pinned in `.github/workflows/noir.yml` — is documented in the comment above it instead. Do not "correct" this to `=1.0.0-beta.11`.

### 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` carries three such bindings — `addr_f`, `_acct_f`, `_dest_f` — and removing `_dest_f` lets a compromised clawback signer settle a witness to a destination of its own choosing. 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 @@ -60,7 +60,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 Expand Up @@ -88,4 +88,4 @@ Note the standing obligation on the future TS SDK (`../docs/SDK.md` §6.1): its

## Version bumps

`.github/workflows/noir.yml` is the source of truth for `NARGO_VERSION` and `BB_VERSION`, and the two must be bumped together — the VK pipeline is byte-sensitive to bb. The string `1.0.0-beta.11` is duplicated across all thirteen package `Nargo.toml` files, `constraints.baseline`, `vks/README.md` (which also restates the bb version), and this file. A bump touches all of them and requires regenerating both the baseline and the VKs. The installer scripts in CI are pinned to git commits with SHA256 verification; bump URL and hash together.
`.github/workflows/noir.yml` is the source of truth for `NARGO_VERSION` and `BB_VERSION`, and the two must be bumped together — the VK pipeline is byte-sensitive to bb. The string `1.0.0-beta.11` is duplicated across all fourteen package `Nargo.toml` files, `constraints.baseline`, `vks/README.md` (which also restates the bb version), and this file. A bump touches all of them and requires regenerating both the baseline and the VKs. The installer scripts in CI are pinned to git commits with SHA256 verification; bump URL and hash together.
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" }
101 changes: 101 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,101 @@
use stellar_confidential_lib::commit;

mod tests;

// Clawback circuit -- COMPLIANCE.md Section 5.3.
//
// 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.
//
// 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 establishes
// alpha <= v_s + v_r.
//
// 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.
// `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
// ----------------------------------------
// 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.
//
// Public inputs (8 fields, in COMPLIANCE.md Section 5.3 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: their presence in the public-input set is the binding, on
// the `register/_acct_f` precedent.
//
// 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.
// Range on v_s and v_r alone does not bound v_s + v_r - 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