From 9f780e22825e8ee04ff13d40dd06921a4462fbff Mon Sep 17 00:00:00 2001 From: brozorec <9572072+brozorec@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:31:06 +0200 Subject: [PATCH] feat(confidential): add compliance clawback Adds the ConfidentialClawback trait: an admin- and auditor-gated seizure of a frozen account's confidential claim, plus a forced revoke that pulls escrowed allowances back into reach of the seize. The Clawback circuit proves knowledge of the openings of C_spend and C_receive and bounds the seize by their sum (CB1-CB3). Post-verification is the merge rule plus a public debit with no fresh randomness, so the seized account stays spendable. Settlement is `None` (pool left over-collateralized) or `Some(d)` (exactly `amount` transferred), with the destination bound into the proof. --- packages/tokens/src/confidential/CLAUDE.md | 10 +- .../src/confidential/circuits/CLAUDE.md | 4 +- .../src/confidential/circuits/Nargo.toml | 1 + .../confidential/circuits/clawback/Nargo.toml | 10 + .../circuits/clawback/src/main.nr | 134 ++++++++++ .../circuits/clawback/src/tests.nr | 203 ++++++++++++++ .../circuits/constraints.baseline | 3 + .../src/confidential/circuits/lib/src/lib.nr | 18 +- .../circuits/scripts/extract_vks.sh | 1 + .../circuits/set_spender/src/main.nr | 16 +- .../circuits/spender_transfer/src/tests.nr | 2 +- .../circuits/transfer/src/main.nr | 4 +- .../circuits/vks/clawback.vk.json | 1 + .../circuits/withdraw/src/main.nr | 4 +- .../tokens/src/confidential/compliance/mod.rs | 166 +++++++++++- .../src/confidential/compliance/storage.rs | 217 ++++++++++++++- .../src/confidential/compliance/test.rs | 251 +++++++++++++++++- .../src/confidential/docs/COMPLIANCE.md | 225 +++++++++++++--- .../tokens/src/confidential/docs/DESIGN.md | 72 ++++- .../src/confidential/docs/DESIGN_cont.md | 145 ++++++---- .../tokens/src/confidential/docs/INDEXER.md | 20 +- .../tokens/src/confidential/docs/OVERVIEW.md | 43 ++- packages/tokens/src/confidential/docs/SDK.md | 51 ++-- packages/tokens/src/confidential/mod.rs | 33 ++- packages/tokens/src/confidential/storage.rs | 41 ++- .../tokens/src/confidential/verifier/mod.rs | 9 + .../tokens/src/confidential/verifier/test.rs | 5 +- 27 files changed, 1505 insertions(+), 184 deletions(-) create mode 100644 packages/tokens/src/confidential/circuits/clawback/Nargo.toml create mode 100644 packages/tokens/src/confidential/circuits/clawback/src/main.nr create mode 100644 packages/tokens/src/confidential/circuits/clawback/src/tests.nr create mode 100644 packages/tokens/src/confidential/circuits/vks/clawback.vk.json diff --git a/packages/tokens/src/confidential/CLAUDE.md b/packages/tokens/src/confidential/CLAUDE.md index 3e150e1f6..2eb68f235 100644 --- a/packages/tokens/src/confidential/CLAUDE.md +++ b/packages/tokens/src/confidential/CLAUDE.md @@ -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) | @@ -60,9 +60,9 @@ 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. @@ -70,6 +70,6 @@ The tags are a cross-language wire contract. `DESIGN_cont.md` §13 assigns all s ### 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. diff --git a/packages/tokens/src/confidential/circuits/CLAUDE.md b/packages/tokens/src/confidential/circuits/CLAUDE.md index d31ce43f6..9161674e3 100644 --- a/packages/tokens/src/confidential/circuits/CLAUDE.md +++ b/packages/tokens/src/confidential/circuits/CLAUDE.md @@ -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 @@ -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/` diff --git a/packages/tokens/src/confidential/circuits/Nargo.toml b/packages/tokens/src/confidential/circuits/Nargo.toml index e9ff6b457..a665460b2 100644 --- a/packages/tokens/src/confidential/circuits/Nargo.toml +++ b/packages/tokens/src/confidential/circuits/Nargo.toml @@ -6,6 +6,7 @@ members = [ "transfer", "set_spender", "spender_transfer", + "clawback", "gadgets/assert_on_curve", "gadgets/commit", "gadgets/ecdh", diff --git a/packages/tokens/src/confidential/circuits/clawback/Nargo.toml b/packages/tokens/src/confidential/circuits/clawback/Nargo.toml new file mode 100644 index 000000000..0582bd93e --- /dev/null +++ b/packages/tokens/src/confidential/circuits/clawback/Nargo.toml @@ -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" } diff --git a/packages/tokens/src/confidential/circuits/clawback/src/main.nr b/packages/tokens/src/confidential/circuits/clawback/src/main.nr new file mode 100644 index 000000000..1af76ee04 --- /dev/null +++ b/packages/tokens/src/confidential/circuits/clawback/src/main.nr @@ -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>(); +} diff --git a/packages/tokens/src/confidential/circuits/clawback/src/tests.nr b/packages/tokens/src/confidential/circuits/clawback/src/tests.nr new file mode 100644 index 000000000..a7cd65b73 --- /dev/null +++ b/packages/tokens/src/confidential/circuits/clawback/src/tests.nr @@ -0,0 +1,203 @@ +use crate::main; +use stellar_confidential_lib::commit; + +// Canonical fixture inputs. +// +// V_S / R_S reuse the lib's pinned `commit` vector (lib/testdata), so C_SPEND +// is derivable from -- and pinned by -- the lib's `fixtures_match_testdata` +// test. V_R / R_R and ALPHA are local to this circuit. ALPHA = 700 exceeds +// V_R alone, so the happy path exercises a seize that necessarily draws on +// both commitments rather than one. +global V_S: Field = 1000; +global R_S: Field = 42; +global V_R: Field = 500; +global R_R: Field = 77; +global ALPHA: Field = 700; + +global ADDR_F: Field = 0xbeef; +global ACCT_F: Field = 0xfeed; +global DEST_F: Field = 0xcafe; + +// C_spend = commit(1000, 42) (= lib `commit` fixture). +global C_SPEND_X: Field = 0x195a5d8ecd032fe1696054b28f0852b5f03a613681991ace823245ab2f97ed05; +global C_SPEND_Y: Field = 0x0e67489ecfee0e581dce7db22a383c635886cfb616c3b3bb033bff0c46e9789e; + +// C_receive = commit(500, 77). Pinned by `clawback_fixtures_match_lib`. +global C_RECEIVE_X: Field = 0x13399238a890131dbbe17b7e1d4bf333057906c812fc4c718b2c6efad3e97a08; +global C_RECEIVE_Y: Field = 0x2f5dc4cdf9796114e546848c941e7f7fc09ea5dbf2542ea242005dccffb6b7e8; + +#[test] +fn print_fixtures() { + // One-shot harness: prints C_receive for the chosen fixture tuple. Run + // with `nargo test --package circuit_clawback print_fixtures --show-output` + // and paste the values into the globals above. + let c_receive = commit(V_R, R_R); + let rx = c_receive.x; + let ry = c_receive.y; + println(f"C_RECEIVE_X = {rx}"); + println(f"C_RECEIVE_Y = {ry}"); +} + +#[test] +fn clawback_fixtures_match_lib() { + // Pins the fixtures to the lib primitives, so drift is reported here + // rather than as a less informative failure deep inside `main`. + let c_spend = commit(V_S, R_S); + let c_receive = commit(V_R, R_R); + assert(c_spend.x == C_SPEND_X); + assert(c_spend.y == C_SPEND_Y); + assert(c_receive.x == C_RECEIVE_X); + assert(c_receive.y == C_RECEIVE_Y); +} + +#[test] +fn matches_fixture() { + main( + V_S, + R_S, + V_R, + R_R, + C_SPEND_X, + C_SPEND_Y, + C_RECEIVE_X, + C_RECEIVE_Y, + ALPHA, + ADDR_F, + ACCT_F, + DEST_F, + ); +} + +#[test] +fn empty_receiving_side_is_the_identity() { + // C_receive = O is the common case: the target has nothing pending. The + // circuit must open it as (0, 0) with no special case, and `commit(0, 0)` + // must encode as x = y = 0. + let identity = commit(0, 0); + assert(identity.x == 0); + assert(identity.y == 0); + + main(V_S, R_S, 0, 0, C_SPEND_X, C_SPEND_Y, 0, 0, ALPHA, ADDR_F, ACCT_F, DEST_F); +} + +#[test] +fn seize_of_the_entire_claim_is_admissible() { + // alpha == v_s + v_r leaves the account at zero, which is in range. + main( + V_S, + R_S, + V_R, + R_R, + C_SPEND_X, + C_SPEND_Y, + C_RECEIVE_X, + C_RECEIVE_Y, + V_S + V_R, + ADDR_F, + ACCT_F, + DEST_F, + ); +} + +#[test] +fn none_destination_sentinel_verifies() { + // The contract passes the zero field for `destination: None`. Nothing in + // the circuit distinguishes it -- the point of the test is that the + // sentinel is a legal public input, not a rejected one. + main( + V_S, + R_S, + V_R, + R_R, + C_SPEND_X, + C_SPEND_Y, + C_RECEIVE_X, + C_RECEIVE_Y, + ALPHA, + ADDR_F, + ACCT_F, + 0, + ); +} + +#[test(should_fail)] +fn over_seize_is_rejected() { + // alpha > v_s + v_r makes v_s + v_r - alpha wrap mod F_r, landing far + // above 2^127. This is CB3's soundness-critical case: without the sum + // check the seize would produce a commitment the owner can open but can + // never spend from again. + main( + V_S, + R_S, + V_R, + R_R, + C_SPEND_X, + C_SPEND_Y, + C_RECEIVE_X, + C_RECEIVE_Y, + V_S + V_R + 1, + ADDR_F, + ACCT_F, + DEST_F, + ); +} + +#[test(should_fail)] +fn out_of_range_alpha_is_rejected() { + // alpha >= 2^127 fails CB3's own range check before the sum is formed. + main( + V_S, + R_S, + V_R, + R_R, + C_SPEND_X, + C_SPEND_Y, + C_RECEIVE_X, + C_RECEIVE_Y, + 0x80000000000000000000000000000000, + ADDR_F, + ACCT_F, + DEST_F, + ); +} + +#[test(should_fail)] +fn wrong_spendable_opening_is_rejected() { + // A witness that does not open the on-chain C_spend fails CB1. This is + // what stops the compliance admin -- which holds no blinding -- from + // producing a witness of its own. + main( + V_S + 1, + R_S, + V_R, + R_R, + C_SPEND_X, + C_SPEND_Y, + C_RECEIVE_X, + C_RECEIVE_Y, + ALPHA, + ADDR_F, + ACCT_F, + DEST_F, + ); +} + +#[test(should_fail)] +fn wrong_receiving_opening_is_rejected() { + // Same for CB2: an inflated v_r would otherwise let a prover claim a + // larger seizable total than the account holds. + main( + V_S, + R_S, + V_R + 1, + R_R, + C_SPEND_X, + C_SPEND_Y, + C_RECEIVE_X, + C_RECEIVE_Y, + ALPHA, + ADDR_F, + ACCT_F, + DEST_F, + ); +} diff --git a/packages/tokens/src/confidential/circuits/constraints.baseline b/packages/tokens/src/confidential/circuits/constraints.baseline index f58e9e6f2..4a6d98cae 100644 --- a/packages/tokens/src/confidential/circuits/constraints.baseline +++ b/packages/tokens/src/confidential/circuits/constraints.baseline @@ -18,6 +18,9 @@ # Toolchain: nargo 1.0.0-beta.11 # | Package | Function | Expression Width | ACIR Opcodes | Brillig Opcodes | +| circuit_clawback | decompose_hint | N/A | N/A | 30 | +| circuit_clawback | lte_hint | N/A | N/A | 33 | +| circuit_clawback | main | Bounded { width: 4 } | 51 | 63 | | circuit_register | decompose_hint | N/A | N/A | 30 | | circuit_register | directive_invert | N/A | N/A | 9 | | circuit_register | lte_hint | N/A | N/A | 33 | diff --git a/packages/tokens/src/confidential/circuits/lib/src/lib.nr b/packages/tokens/src/confidential/circuits/lib/src/lib.nr index 74edc12d4..371fdea61 100644 --- a/packages/tokens/src/confidential/circuits/lib/src/lib.nr +++ b/packages/tokens/src/confidential/circuits/lib/src/lib.nr @@ -1,8 +1,8 @@ //! # Confidential Token -- Noir Primitives //! -//! Shared cryptographic primitives consumed by the five circuits described in -//! Section 7 of the design document (Register, Withdraw, Transfer, -//! SpenderTransfer, SetSpender). +//! Shared cryptographic primitives consumed by the six circuits described in +//! Section 7 of the design document (Register, Withdraw, Transfer, SpenderTransfer, +//! SetSpender, Clawback). //! //! Every constraint that involves a Pedersen commitment, a Poseidon2 hash with //! domain separation, an ECDH derivation, or a 128-bit range check funnels @@ -140,10 +140,9 @@ pub mod domain { /// (`delta_aud_s`). /// /// Widened *everywhere it appears*, which is what keeps it in a single - /// sponge mode per Section 2.5: - /// `sponge_squeeze_2(d, s, sigma)[0]` is the same field element as - /// `poseidon_with_domain(d, [s, sigma])`, so a tag used in both modes - /// would collapse one onto the other. + /// sponge mode per Section 2.5: `sponge_squeeze_2(d, s, sigma)[0]` is the + /// same field element as `poseidon_with_domain(d, [s, sigma])`, so a tag + /// used in both modes would collapse one onto the other. pub global AUDITOR_SENDER: Field = 11; /// Recipient-auditor channel tag for Poseidon2 sponge masks /// (Section 2.5, Section 8.1). Squeeze 1 yields the amount mask; squeeze @@ -338,8 +337,9 @@ pub fn encrypt_esc_dvk(dvk: Field, s: Field, op_i: Field) -> Field { /// is, and `op_i` is a nonce by parallel with S12. /// /// This is what gives the auditor the opening of `C_a`: with `dvk_i` it can -/// derive `r_a = derive_allow_r(dvk_i, sigma_a)`. Any fold of `C_a` into -/// `C_spend` therefore leaves the auditor's accumulator in sync. +/// derive `r_a = derive_allow_r(dvk_i, sigma_a)`, which is the prerequisite +/// for a proofless revoke that folds `C_a` into `C_spend` without desyncing +/// the auditor's accumulator. pub fn encrypt_esc_dvk_auditor(dvk: Field, s_a_s: Field, op_i: Field) -> Field { dvk + poseidon_with_domain(domain::ESCROWED_DELEGATION_VIEWING_KEY_AUDITOR, [s_a_s, op_i]) } diff --git a/packages/tokens/src/confidential/circuits/scripts/extract_vks.sh b/packages/tokens/src/confidential/circuits/scripts/extract_vks.sh index 325e0a3c1..2840ead05 100755 --- a/packages/tokens/src/confidential/circuits/scripts/extract_vks.sh +++ b/packages/tokens/src/confidential/circuits/scripts/extract_vks.sh @@ -27,6 +27,7 @@ CIRCUITS=( "transfer" "set_spender" "spender_transfer" + "clawback" ) OUT_DIR="vks" diff --git a/packages/tokens/src/confidential/circuits/set_spender/src/main.nr b/packages/tokens/src/confidential/circuits/set_spender/src/main.nr index d95737b01..978e3f754 100644 --- a/packages/tokens/src/confidential/circuits/set_spender/src/main.nr +++ b/packages/tokens/src/confidential/circuits/set_spender/src/main.nr @@ -64,13 +64,15 @@ mod tests; // Auditor-side escrow // of dvk_i. Hands the // owner's auditor the -// full opening of C_a. -// Folding an unopenable -// C_a into C_spend -// would desync the -// auditor's accumulator -// for the account -// permanently. +// full opening of C_a, +// which is what makes a +// proofless revoke +// possible: folding an +// unopenable C_a into +// C_spend would desync +// the auditor's +// accumulator for the +// account permanently. // A separate single- // output Poseidon // rather than a sponge diff --git a/packages/tokens/src/confidential/circuits/spender_transfer/src/tests.nr b/packages/tokens/src/confidential/circuits/spender_transfer/src/tests.nr index 3b0a18fa7..d2fc71d94 100644 --- a/packages/tokens/src/confidential/circuits/spender_transfer/src/tests.nr +++ b/packages/tokens/src/confidential/circuits/spender_transfer/src/tests.nr @@ -395,7 +395,7 @@ fn full_allowance_transfer() { // which is a valid commitment to zero. a_tilde' and a_tilde_aud_s both // bind to 0 (plus mask) so the owner's auditor sees the post-transfer // allowance reach zero. The delegation entry isn't deleted (that's - // revoke_spender's job) -- it now escrows zero value. + // revoke_spender's job, Section 7.9) -- it now escrows zero value. let pvk_recipient = EmbeddedCurvePoint { x: PVK_RECIPIENT_X, y: PVK_RECIPIENT_Y, is_infinite: false }; let k_aud_r = EmbeddedCurvePoint { x: K_AUD_R_X, y: K_AUD_R_Y, is_infinite: false }; diff --git a/packages/tokens/src/confidential/circuits/transfer/src/main.nr b/packages/tokens/src/confidential/circuits/transfer/src/main.nr index 2574c68f9..734dd78ec 100644 --- a/packages/tokens/src/confidential/circuits/transfer/src/main.nr +++ b/packages/tokens/src/confidential/circuits/transfer/src/main.nr @@ -84,7 +84,9 @@ mod tests; // Lets the auditor // track the full // opening of C_spend -// forward. +// forward -- the +// Clawback circuit's +// CB1 witness. // // Point-validation doctrine (Section 10.8) // ---------------------------------------- diff --git a/packages/tokens/src/confidential/circuits/vks/clawback.vk.json b/packages/tokens/src/confidential/circuits/vks/clawback.vk.json new file mode 100644 index 000000000..3d1b33ae9 --- /dev/null +++ b/packages/tokens/src/confidential/circuits/vks/clawback.vk.json @@ -0,0 +1 @@ +["0x0000000000000000000000000000000000000000000000000000000000008000","0x0000000000000000000000000000000000000000000000000000000000000018","0x0000000000000000000000000000000000000000000000000000000000000001","0x0000000000000000000000000000000000000000000000000000000000000008","0x0000000000000000000000000000003e29e35fa10d1374ee392d84015d55146f","0x00000000000000000000000000000000001aa499cf6c3c66d45cd37fc096dba0","0x000000000000000000000000000000e76665815609031e4aef4b456e5a69e81b","0x00000000000000000000000000000000000275c73958f82ba5d3605894738aee","0x0000000000000000000000000000007e272a70bbb936fdd4f8cce42a4fbb1414","0x000000000000000000000000000000000002c85229ff29f3f8dead1204da9097","0x00000000000000000000000000000017c1ef152e3a105b96bfbcdb3072cca45f","0x000000000000000000000000000000000022710699446a710ca9b4c2ef9d6872","0x0000000000000000000000000000000f0380611fed80283c6556f5ab3c1376f9","0x000000000000000000000000000000000020f6a0cee0c5044f9e331b0d1ec278","0x000000000000000000000000000000cc51082c78adb5636fb36ed6648a9334b5","0x00000000000000000000000000000000001f083692f4552918229d0976f3bdc0","0x0000000000000000000000000000000e023b951ec6139c58a52454903248aa63","0x0000000000000000000000000000000000029558e854db74dcf911661dac1ad0","0x000000000000000000000000000000b412d561055e7b0ff0f640b06bdae6edc9","0x000000000000000000000000000000000024567c57baa70dfd813136f398c174","0x000000000000000000000000000000f850f4dd17afeadb66f7d5af2098a067ae","0x00000000000000000000000000000000000a5fb82a741c2e85ec02218d7d8c6e","0x000000000000000000000000000000bf13d957cf9d3528432c9ce697fb6d2dde","0x00000000000000000000000000000000002b54eaa1f515448224b99a014a9fd7","0x000000000000000000000000000000328b2b2e721066afc8c65415579291cec6","0x00000000000000000000000000000000001551cd1cbfb9c65cbc6eead6511776","0x000000000000000000000000000000e4da569a33192b0f34a60e639388af1949","0x00000000000000000000000000000000002720b1e293b455df30130dcb761d96","0x0000000000000000000000000000000d515847713bc8321831a2011e963168e1","0x00000000000000000000000000000000002495fd85225afe88486bb32f391098","0x000000000000000000000000000000c4b0dca57e3054a5893a52fe82ed4c93a2","0x00000000000000000000000000000000001c03460a61021bafdf445365a24df2","0x000000000000000000000000000000fcd88c79df4143bf1e2ef7330b754715a0","0x0000000000000000000000000000000000039e73207196125e19f984267e6f9f","0x0000000000000000000000000000003b1a38b3e3cbef9b9178ad9190c30b3305","0x0000000000000000000000000000000000031be7550873e5c0d5da000782245e","0x000000000000000000000000000000fd471a9be2dd684881221caf4e3b032485","0x00000000000000000000000000000000002356514cfea29ef16d326f72f3cbca","0x000000000000000000000000000000b309f2a708d312c2144dc3ed2caed7f896","0x00000000000000000000000000000000000d7654fd948210647749189d77b08d","0x000000000000000000000000000000ac02cfeb0524a06ab88c4ccbe8ff9d4969","0x00000000000000000000000000000000000aac8197ec9a97a8a9e1c2423f3d4a","0x00000000000000000000000000000082bb2aadf7e3c3bc6b19f09ef980368226","0x000000000000000000000000000000000013ed6a9e3f475f83e7a1a6387efcba","0x0000000000000000000000000000002ddb0e371241a4e1949b9d10c3462de0d6","0x00000000000000000000000000000000002aeeb919361d025ee0cb2c3abacc70","0x0000000000000000000000000000000bf1e340a4e1f229af8a97aa284de78b95","0x000000000000000000000000000000000012f1eb993e6d434ee173893e06ce96","0x000000000000000000000000000000a330fe60e3bbf2f5b5a01dae93f332b4d8","0x0000000000000000000000000000000000006c08e445017084fd8a0b46e9b27d","0x000000000000000000000000000000479765744601fac59a58772fd8a16ccf44","0x00000000000000000000000000000000002f431136c5f545a247c7a54f116e4b","0x00000000000000000000000000000091bf2d311966e13423265736ce919b13eb","0x0000000000000000000000000000000000196696384a5f06ee7173acf7a697e8","0x00000000000000000000000000000045f2fb453e2cbde7c3763ba3a41d38c16f","0x0000000000000000000000000000000000103a777e3faace3c31cf1927576171","0x0000000000000000000000000000007ee098d6ddc852108fb4261b35ed31e8d9","0x00000000000000000000000000000000000bb2c5e278cf60370c4619232c78db","0x000000000000000000000000000000d85502b4d88c5589a84c4c93dc4706d3be","0x000000000000000000000000000000000002b27ffbbf09ce26ab3ef89b63aa97","0x000000000000000000000000000000691d8fd24730d11e8c4c672921ca86c1ce","0x00000000000000000000000000000000001597533e7f45d0d9272f911f6cbe8e","0x000000000000000000000000000000ac21d466f8422e1faa4f4f4e749a814651","0x000000000000000000000000000000000007a4cd311030c8e81b55cc5d1e2dde","0x000000000000000000000000000000fb6cd0af81e46eec9948fb3157bf71ffaa","0x000000000000000000000000000000000001f98d476af8dd99cb473e1eeb1ae6","0x000000000000000000000000000000abbf216bc97645fbe37a38cf60e8d76239","0x00000000000000000000000000000000001d0a487ad1bb296bbea426a8c48dd6","0x0000000000000000000000000000003883ddd61bd888c488f77fa3b3a9ae6beb","0x00000000000000000000000000000000000997dc425cf28e1d0f043aefa0d516","0x000000000000000000000000000000d5e78350147140e21fcadaa488b9c8ea8c","0x00000000000000000000000000000000002037861df990935fcd0e814b1bf739","0x0000000000000000000000000000004ecaa8998f76b6a4c09931fac7d6bc6749","0x000000000000000000000000000000000000687cbfcc8894c68f64a7cadeff9e","0x000000000000000000000000000000b6943ac8fa6ca1fd68b1a515dcdbb098c8","0x00000000000000000000000000000000000eff396de321039968df4870793740","0x000000000000000000000000000000da259e3cacabd225d773fbdef73a932041","0x000000000000000000000000000000000003f707759d8897bebaac9e6735f8ac","0x0000000000000000000000000000005fb25786c9a783258a264f0faff5f9982f","0x0000000000000000000000000000000000137e46ca8d6e2410f6bcb7cfe0d384","0x000000000000000000000000000000f5461779b573530e6bd7707fdc988113ad","0x00000000000000000000000000000000000da742eaed8a069b8c6b3c2a2880e2","0x0000000000000000000000000000005dea508ac832b3f8c7b4409d431107c69d","0x00000000000000000000000000000000001dc51b8af23809bad551c8653cd85f","0x0000000000000000000000000000002cde78913467c67dade61b479c25eb03d4","0x00000000000000000000000000000000002c9abdbb053781a89175609efbcaf7","0x00000000000000000000000000000056171db0722852ba449ca7ab03759ea4c0","0x000000000000000000000000000000000008b7075ae38656f84b2952dd8a7b7f","0x00000000000000000000000000000062eab404e33638dc79b321c47387e79866","0x00000000000000000000000000000000000eb3281c7badbbe070ac0a85228b10","0x000000000000000000000000000000b104d0d85d2c474f75ce151e18af9feba4","0x00000000000000000000000000000000001794fce82e9bd15cab7bc32fc7227d","0x000000000000000000000000000000de57d1ae8a024e3b4e698c5b4f0d88da43","0x00000000000000000000000000000000000635441a35af7980ad297c1cebbac4","0x00000000000000000000000000000036631fd011a8c26dac05c4c18beb459e5e","0x0000000000000000000000000000000000211ab89ee5f6206261ed14c7003ad7","0x00000000000000000000000000000085ddbb5359d7299988b147431fd326c5de","0x000000000000000000000000000000000020e1e230152899ddb07c71ab6a5beb","0x0000000000000000000000000000000712ccf2921e57da19c9fca055bece5704","0x000000000000000000000000000000000029ffcc382cfcfbed7748bb5a391a3e","0x0000000000000000000000000000008964c2abb496834356ab11fe15d7f5ef05","0x000000000000000000000000000000000020df9f5bfe49004af6581c4866afab","0x00000000000000000000000000000027787ea9ee7c33dfa37b6d6f4b77abb7d7","0x00000000000000000000000000000000001efd7dca7c8e6f4f6d73ccd7b42008","0x0000000000000000000000000000000000000000000000000000000000000001","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000002","0x0000000000000000000000000000000000000000000000000000000000000000","0x000000000000000000000000000000fce5f6e4c22ec9f684c4750f74e4c5f4e6","0x000000000000000000000000000000000028d57f17c825507ce364c1b8c6e27e","0x00000000000000000000000000000061e59e023cb3a2310baa326a86430956e2","0x00000000000000000000000000000000001286fdc058c2d00c990e76a17c96b2"] \ No newline at end of file diff --git a/packages/tokens/src/confidential/circuits/withdraw/src/main.nr b/packages/tokens/src/confidential/circuits/withdraw/src/main.nr index 5af565b88..778c9bfcc 100644 --- a/packages/tokens/src/confidential/circuits/withdraw/src/main.nr +++ b/packages/tokens/src/confidential/circuits/withdraw/src/main.nr @@ -62,7 +62,9 @@ mod tests; // lets the auditor track // the full Pedersen // opening of C_spend -// forward. +// forward, which the +// Clawback circuit's CB1 +// witness requires. // // Point-validation doctrine (Section 10.8) // ---------------------------------------- diff --git a/packages/tokens/src/confidential/compliance/mod.rs b/packages/tokens/src/confidential/compliance/mod.rs index 172b4516f..e7673b0ce 100644 --- a/packages/tokens/src/confidential/compliance/mod.rs +++ b/packages/tokens/src/confidential/compliance/mod.rs @@ -12,9 +12,12 @@ //! token entry point against the active configuration. Wire as `type Hooks = //! ComplianceHooks;` on a contract that implements [`ConfidentialToken`]. //! 2. [`ConfidentialCompliance`] — the admin-facing trait. -//! 3. [`Policy`] — the cross-contract interface for an external allowlist / +//! 3. [`ConfidentialClawback`] — the opt-in seizure trait. Omitting its impl +//! block is how a deployment ships freeze and policy gating without seizure +//! capability. +//! 4. [`Policy`] — the cross-contract interface for an external allowlist / //! denylist / KYC / sanctions registry. -//! 4. Storage helpers in [`storage`]. +//! 5. Storage helpers in [`storage`]. //! //! Deployments that never write a configuration pay only one instance-storage //! probe per op: [`ComplianceHooks`] short-circuits when @@ -25,8 +28,10 @@ pub mod storage; #[cfg(test)] mod test; -use soroban_sdk::{contractclient, contracterror, contractevent, contracttrait, Address, Env}; -pub use storage::{ComplianceConfig, ComplianceStorageKey}; +use soroban_sdk::{ + contractclient, contracterror, contractevent, contracttrait, Address, Bytes, Env, +}; +pub use storage::{ClawbackData, ComplianceConfig, ComplianceStorageKey}; use crate::confidential::{ ConfidentialToken, Hooks, RegisterPayload, SetSpenderPayload, SpenderTransferPayload, @@ -163,6 +168,129 @@ pub trait ConfidentialCompliance: ConfidentialToken { } } +// ################## CLAWBACK TRAIT ################## + +/// Opt-in seizure interface. A deployment that wants freeze and policy gating +/// but no seizure capability simply omits this impl block — a compile-time +/// choice with no storage, no configuration, and no misconfiguration mode. +/// +/// Both methods follow the [`ConfidentialCompliance`] pattern: no default +/// body, so the contract author must supply the access-control check +/// explicitly. Both require the target to be frozen, and neither consults the +/// [`Hooks`] impl — gating them would be self-defeating, since the freeze gate +/// rejects exactly the accounts these methods exist to act on. +/// +/// # Security Warning +/// +/// **The freeze precondition is only meaningful when the deployment's `Hooks` +/// impl gates on it, and this trait's bounds do not force that.** +/// `ConfidentialClawback: ConfidentialCompliance` obliges the deployment to +/// implement `freeze` / `unfreeze`, but it places no constraint on +/// `::Hooks`. A contract that wires +/// [`NoHooks`](crate::confidential::NoHooks) alongside this impl block gets a +/// `freeze` that writes the flag and an `is_frozen` that returns `true`, so +/// the precondition passes — while every token operation stays ungated and +/// the target spends its balance out before the seizure lands. The freeze is +/// then satisfied and meaningless, and the admin's only signal is an +/// `InvalidProof` once the commitment has moved. +/// +/// Wiring [`ComplianceHooks`], or a custom [`Hooks`] impl that gates the same +/// seven positions (`on_deposit`, `on_merge`, `on_withdraw`, `on_transfer`, +/// `on_spender_transfer`, `on_set_spender`, `on_revoke_spender`), is a +/// **deployment obligation** of this trait. +#[contracttrait] +pub trait ConfidentialClawback: ConfidentialCompliance { + /// Reduces `account`'s confidential claim by `amount` and settles the + /// corresponding underlying according to `destination`. + /// + /// With `None`, no underlying is transferred: the pool is left + /// over-collateralized by `amount`, and extraction is the issuer's own SAC + /// `clawback` against this contract's address — safe in that order, and + /// only in that order (see [`storage::clawback`]). With `Some(d)`, exactly + /// `amount` is transferred to `d` in this invocation and the pool stays in + /// step with the sum of confidential claims. + /// + /// `destination` is bound into the proof, so a proof built for one + /// destination cannot be submitted against another, and `Some` naming this + /// contract's own address is rejected. + /// + /// `account` MUST be frozen: the freeze is what holds `C_spend` and + /// `C_receive` still between proof construction and submission, which is + /// what the proof's bindings rely on. Note that the freeze only + /// immobilizes the target if the deployment's [`Hooks`] impl gates on it — + /// see the trait-level warning. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `account` - The confidential account being seized from. + /// * `amount` - The strictly positive seize amount. + /// * `destination` - Where the underlying settles, or `None` for no + /// on-chain settlement from this contract. + /// * `data` - XDR-encoded [`ClawbackData`]. + /// * `operator` - The address whose authorization gates this operation. + /// + /// # Errors + /// + /// * refer to [`crate::confidential::storage::decode_data`] errors. + /// * refer to [`storage::clawback`] errors. + /// + /// # Events + /// + /// * topics - `["clawback", account: Address]` + /// * data - `[amount: i128, destination: Option
]` + /// + /// # Security Warning + /// + /// Implementations MUST authorize `operator` before calling + /// [`storage::clawback`], which authorizes nobody. The trait cannot + /// provide a default body — see [`ConfidentialCompliance`]'s trait-level + /// docstring for the rationale. + fn clawback( + e: &Env, + account: Address, + amount: i128, + destination: Option
, + data: Bytes, + operator: Address, + ); + + /// Folds the `(account, spender)` delegation's escrowed allowance back + /// into `account`'s spendable balance and deletes the delegation, without + /// the owner's participation. This is what brings escrowed value into the + /// reach of [`ConfidentialClawback::clawback`], which can only see + /// `C_spend` and `C_receive`. + /// + /// Identical fold to the owner's + /// [`revoke_spender`](crate::confidential::ConfidentialToken::revoke_spender); + /// only the authorization gate differs. Works against expired delegations + /// too — expiry blocks spending, never reclamation. + /// + /// `account` MUST be frozen. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `account` - The delegating owner. + /// * `spender` - The delegated spender. + /// * `operator` - The address whose authorization gates this operation. + /// + /// # Errors + /// + /// * refer to [`storage::force_revoke_spender`] errors. + /// + /// # Events + /// + /// * topics - `["revoke_spender", account: Address, spender: Address]` + /// * data - `[a_tilde: BytesN<32>, allowance_salt: BytesN<32>]` + /// + /// # Security Warning + /// + /// Implementations MUST authorize `operator` before calling + /// [`storage::force_revoke_spender`], which authorizes nobody. + fn force_revoke_spender(e: &Env, account: Address, spender: Address, operator: Address); +} + // ################## HOOKS IMPL ################## /// [`Hooks`] implementation that gates every token callback against @@ -314,6 +442,18 @@ pub enum ComplianceError { /// for the target account (only reachable when `sac_passthrough` is /// enabled). NotAuthorizedBySac = 3603, + /// Indicates the clawback target is not frozen. The freeze is what holds + /// the target's commitments still between proof construction and + /// submission. + AccountNotFrozen = 3604, + /// Indicates the seize amount is not strictly positive. Load-bearing + /// beyond diagnostics: it guards the `amount as u128` cast in + /// post-verification, and it is what makes a clawback proof + /// non-replayable. + InvalidClawbackAmount = 3605, + /// Indicates `destination` is `Some` naming this contract's own address, + /// which would create pool surplus while reporting a settlement. + InvalidClawbackDestination = 3606, } // ################## CONSTANTS ################## @@ -350,6 +490,24 @@ pub fn emit_unfrozen(e: &Env, account: &Address) { Unfrozen { account: account.clone() }.publish(e); } +/// Event emitted when a confidential claim is reduced by a compliance +/// seizure. `destination` is `None` when no underlying moved — the contract's +/// pooled balance is then left over-collateralized by `amount` — and `Some(d)` +/// when exactly `amount` was transferred to `d` in the same invocation. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Clawback { + #[topic] + pub account: Address, + pub amount: i128, + pub destination: Option
, +} + +/// Emits a [`Clawback`] event. +pub fn emit_clawback(e: &Env, account: &Address, amount: i128, destination: &Option
) { + Clawback { account: account.clone(), amount, destination: destination.clone() }.publish(e); +} + /// Event emitted when the compliance configuration is set or rotated. #[contractevent] #[derive(Clone, Debug, Eq, PartialEq)] diff --git a/packages/tokens/src/confidential/compliance/storage.rs b/packages/tokens/src/confidential/compliance/storage.rs index 88fef899e..6a02e2375 100644 --- a/packages/tokens/src/confidential/compliance/storage.rs +++ b/packages/tokens/src/confidential/compliance/storage.rs @@ -1,11 +1,17 @@ -use soroban_sdk::{contracttype, panic_with_error, token, Address, Env}; +use soroban_sdk::{contracttype, panic_with_error, token, Address, Bytes, BytesN, Env}; +use stellar_contract_utils::crypto::grumpkin::Grumpkin; use crate::confidential::{ compliance::{ - emit_compliance_config_changed, emit_frozen, emit_unfrozen, ComplianceError, PolicyClient, - FROZEN_EXTEND_AMOUNT, FROZEN_TTL_THRESHOLD, + emit_clawback, emit_compliance_config_changed, emit_frozen, emit_unfrozen, ComplianceError, + PolicyClient, FROZEN_EXTEND_AMOUNT, FROZEN_TTL_THRESHOLD, }, - storage::get_underlying_asset, + storage::{ + address_to_field, append_amount, append_field, append_point, get_account, + get_address_as_field_element, get_underlying_asset, revoke_spender, set_commitments, + verify, + }, + verifier::CircuitType, }; // ################## TYPES ################## @@ -27,6 +33,26 @@ pub struct ComplianceConfig { pub sac_passthrough: bool, } +/// Envelope decoded from the `data: Bytes` argument of +/// [`crate::confidential::compliance::ConfidentialClawback::clawback`]. +/// +/// The other five operations carry a `{ payload, proof }` envelope. Clawback +/// carries the proof alone, because the clawback circuit has no +/// prover-supplied public inputs at all — the Pedersen openings are private +/// witnesses, and every public input is either loaded from trusted state or +/// recomputed from the invocation arguments. There is nothing for a payload +/// to hold, and Soroban has no representation for an empty `#[contracttype]` +/// struct. +/// +/// Keeping the envelope rather than taking `proof: Bytes` directly preserves +/// the uniform `data: Bytes` trait surface and leaves room to add a `payload` +/// field later without changing any signature. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ClawbackData { + pub proof: Bytes, +} + /// Storage keys for the confidential token compliance extension. #[contracttype] pub enum ComplianceStorageKey { @@ -170,6 +196,189 @@ pub fn unfreeze(e: &Env, account: &Address) { emit_unfrozen(e, account); } +/// Reduces `account`'s confidential claim by `amount` and settles the +/// corresponding underlying according to `destination`. +/// +/// The proof establishes three things the contract cannot check itself, since +/// the balances are committed: that the prover knows the Pedersen openings of +/// both `C_spend` and `C_receive` (CB1, CB2), and that the seize is bounded by +/// what those openings hold, `amount <= v_spend + v_receive` (CB3). The +/// witness is producible by anyone holding the openings — the auditor, or the +/// owner — and *not* by the admin, which holds no blinding. That asymmetry is +/// what keeps the two-party separation intact: the compliance authority +/// decides *whether* to seize, the auditor decides *how much* and *where to*. +/// +/// The post-verification update is the [`crate::confidential::storage::merge`] +/// rule plus a public debit — `C_spend <- C_spend + C_receive - amount * G` +/// and `C_receive <- O` — with no +/// fresh randomness anywhere. The new opening is +/// `(v_s + v_r - amount, r_s + r_r)`, which both the owner and the auditor can +/// recompute — **the seized account stays spendable**. Re-randomizing under an +/// admin-chosen blinding instead would leave the owner unable to open its own +/// commitment and brick the account permanently. +/// +/// # Settlement +/// +/// Under `None` no underlying moves: the pool is left over-collateralized by +/// `amount`, inert (nothing in this contract reads its own pooled balance) and +/// extractable only by the underlying's own issuer, through a SAC `clawback` +/// against this contract's address. **That extraction must follow this call, +/// never precede it.** Seize-then-extract passes through surplus, which is +/// harmless; extract-then-seize passes through *deficit*, which is borne by +/// every other holder and becomes permanent if the seize then turns out to be +/// unbuildable. `None` therefore means "no on-chain settlement from this +/// contract", not "the issuer has already extracted". +/// +/// Under `Some(d)`, exactly `amount` is transferred to `d` in this invocation, +/// so the pool and the sum of claims move together and the issuer is not +/// involved. This branch re-inherits the module's exact-transfer assumption +/// and couples the seizure to this contract's own SAC authorization — an +/// issuer that deauthorizes the contract blocks it entirely. A deployment that +/// needs seizures to survive that must use `None`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `account` - The confidential account being seized from. +/// * `amount` - The strictly positive seize amount. +/// * `destination` - Where the underlying settles, or `None`. +/// * `proof` - The raw UltraHonk proof bytes, from the decoded +/// [`ClawbackData`]. +/// +/// # Errors +/// +/// * [`ComplianceError::AccountNotFrozen`] - When `account` is not frozen. +/// * [`ComplianceError::InvalidClawbackAmount`] - When `amount <= 0`. +/// * [`ComplianceError::InvalidClawbackDestination`] - When `destination` is +/// `Some` naming this contract's own address. +/// * refer to [`crate::confidential::storage::get_account`] errors. +/// * [`crate::confidential::ConfidentialTokenError::NonCanonicalEncoding`] - +/// When a stored commitment coordinate is not a canonical `Bn254Fr` value. +/// * [`crate::confidential::ConfidentialTokenError::InvalidProof`] - When the +/// proof fails verification. +/// +/// # Events +/// +/// * topics - `["clawback", account: Address]` +/// * data - `[amount: i128, destination: Option
]` +/// +/// # Notes +/// +/// On the `Some(d)` branch the confidential debit strictly precedes the SEP-41 +/// transfer, matching [`crate::confidential::storage::withdraw`]'s +/// debit-then-transfer order. Soroban reverts the invocation atomically, so +/// the order does not affect atomicity; it keeps one convention across the two +/// functions that both reduce a claim and move the underlying behind it. +/// +/// # Security Warning +/// +/// **IMPORTANT**: This function bypasses authorization checks. The trait entry +/// point [`crate::confidential::compliance::ConfidentialClawback::clawback`] +/// owns the admin gate. +pub fn clawback( + e: &Env, + account: &Address, + amount: i128, + destination: &Option
, + proof: &Bytes, +) { + if !is_frozen(e, account) { + panic_with_error!(e, ComplianceError::AccountNotFrozen); + } + if amount <= 0 { + panic_with_error!(e, ComplianceError::InvalidClawbackAmount); + } + // A seize settling to this contract's own address is a `None` in economic + // effect while reporting a destination, which breaks the per-branch pool + // reconciliation for any indexer. + if destination.as_ref() == Some(&e.current_contract_address()) { + panic_with_error!(e, ComplianceError::InvalidClawbackDestination); + } + + let data = get_account(e, account); + let addr_f = get_address_as_field_element(e); + // `dest_f` is the zero field under `None`. Unambiguous as a sentinel: + // `address_to_field` is a Poseidon2 output, so reaching exactly zero + // would be a preimage attack on the hash. + let dest_f = match destination { + Some(d) => address_to_field(e, d), + None => BytesN::from_array(e, &[0u8; 32]), + }; + + // PI order (DESIGN §7.13): + // C_spend, C_receive, alpha, addr_f, acct_f, dest_f + // + // `addr_f`, `acct_f` and `dest_f` are referenced by no constraint. Their + // membership in the public-input set *is* the binding — UltraHonk absorbs + // every public input into the transcript — following the `register` + // precedent (`_acct_f`). It binds the proof to one contract, one account, + // and one settlement destination. + let mut pi = Bytes::new(e); + append_point(&mut pi, &data.spendable_commitment); + append_point(&mut pi, &data.receiving_commitment); + append_amount(&mut pi, e, amount); + append_field(&mut pi, &addr_f); + append_field(&mut pi, &address_to_field(e, account)); + append_field(&mut pi, &dest_f); + + verify(e, CircuitType::Clawback, &pi, proof); + + // `amount as u128` is safe: `amount > 0` was checked above. + let seized = Grumpkin::mul(e, &Grumpkin::generator(e), amount as u128); + let c_spend_new = Grumpkin::sub( + e, + &Grumpkin::add(e, &data.spendable_commitment, &data.receiving_commitment), + &seized, + ); + set_commitments(e, account, &c_spend_new, &Grumpkin::identity(e)); + + if let Some(d) = destination { + let token = token::TokenClient::new(e, &get_underlying_asset(e)); + token.transfer(&e.current_contract_address(), d, &amount); + } + + emit_clawback(e, account, amount, destination); +} + +/// Folds the `(account, spender)` delegation's escrowed allowance back into +/// `account`'s spendable balance and deletes the delegation, without the +/// owner's participation. +/// +/// Escrowed value is invisible to [`clawback`], which sees only `C_spend` and +/// `C_receive`; this is what moves it into reach. The fold itself is the same +/// proofless primitive the owner's +/// [`revoke_spender`](crate::confidential::ConfidentialToken::revoke_spender) +/// uses — only the authorization gate differs. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `account` - The delegating owner. +/// * `spender` - The delegated spender. +/// +/// # Errors +/// +/// * [`ComplianceError::AccountNotFrozen`] - When `account` is not frozen. +/// * refer to [`crate::confidential::storage::revoke_spender`] errors. +/// +/// # Events +/// +/// * topics - `["revoke_spender", account: Address, spender: Address]` +/// * data - `[a_tilde: BytesN<32>, allowance_salt: BytesN<32>]` +/// +/// # Security Warning +/// +/// **IMPORTANT**: This function bypasses authorization checks. The trait entry +/// point +/// [`crate::confidential::compliance::ConfidentialClawback::force_revoke_spender`] +/// owns the admin gate. +pub fn force_revoke_spender(e: &Env, account: &Address, spender: &Address) { + if !is_frozen(e, account) { + panic_with_error!(e, ComplianceError::AccountNotFrozen); + } + revoke_spender(e, account, spender); +} + // ################## LOW-LEVEL HELPERS ################## /// Asserts that `account` passes every configured compliance gate against the diff --git a/packages/tokens/src/confidential/compliance/test.rs b/packages/tokens/src/confidential/compliance/test.rs index 3ba7479dd..b004a7592 100644 --- a/packages/tokens/src/confidential/compliance/test.rs +++ b/packages/tokens/src/confidential/compliance/test.rs @@ -10,15 +10,21 @@ use soroban_sdk::{ use crate::confidential::{ compliance::{ - storage::{compliance_config, freeze, is_frozen, set_compliance_config, unfreeze}, - ComplianceConfig, ComplianceHooks, ComplianceStorageKey, ConfidentialCompliance, + storage::{ + clawback, compliance_config, force_revoke_spender, freeze, is_frozen, + set_compliance_config, unfreeze, + }, + ClawbackData, ComplianceConfig, ComplianceHooks, ComplianceStorageKey, + ConfidentialClawback, ConfidentialClawbackClient, ConfidentialCompliance, ConfidentialComplianceClient, Policy, }, - storage::{set_address_as_field_element, set_auditor, set_underlying_asset, set_verifier}, + storage::{ + decode_data, set_address_as_field_element, set_auditor, set_underlying_asset, set_verifier, + }, verifier::CircuitType, - ConfidentialAccount, ConfidentialToken, ConfidentialTokenClient, Hooks, RegisterData, - RegisterPayload, SetSpenderPayload, SpenderDelegation, SpenderTransferPayload, TransferPayload, - WithdrawPayload, + ConfidentialAccount, ConfidentialToken, ConfidentialTokenClient, ConfidentialTokenStorageKey, + Hooks, RegisterData, RegisterPayload, SetSpenderPayload, SpenderDelegation, + SpenderTransferPayload, TransferPayload, WithdrawPayload, }; // ################## MOCK CONTRACTS ################## @@ -59,6 +65,27 @@ impl ConfidentialCompliance for TokenHost { } } +#[contractimpl(contracttrait)] +impl ConfidentialClawback for TokenHost { + fn clawback( + e: &Env, + account: Address, + amount: i128, + destination: Option
, + data: Bytes, + admin: Address, + ) { + admin.require_auth(); + let d: ClawbackData = decode_data(e, &data); + clawback(e, &account, amount, &destination, &d.proof); + } + + fn force_revoke_spender(e: &Env, account: Address, spender: Address, admin: Address) { + admin.require_auth(); + force_revoke_spender(e, &account, &spender); + } +} + #[contract] struct AllowPolicy; @@ -244,6 +271,8 @@ fn set_spender_payload(e: &Env) -> SetSpenderPayload { } } +// ################## NO-CONFIG SHORT-CIRCUIT ################## + #[test] fn hooks_short_circuit_without_config() { let h = setup(); @@ -819,6 +848,216 @@ fn storage_keys_isolated_from_token_keys() { }); } +// ################## CLAWBACK (SMOKE) ################## +// +// Contract-layer plumbing only. The verifier is mocked, so nothing here +// exercises CB1-CB3, the seize bound, or the destination binding -- those are +// circuit-side (`circuits/clawback/src/tests.nr`). Full coverage of the replay +// and redirect cases needs a public-input-binding verifier mock. + +fn clawback_data(e: &Env) -> Bytes { + ClawbackData { proof: Bytes::new(e) }.to_xdr(e) +} + +/// Registers `account` with a spendable commitment of `amount * G` and an +/// empty receiving side, then freezes it. +fn frozen_account_with(h: &Harness, account: &Address, amount: u128) { + use stellar_contract_utils::crypto::grumpkin::Grumpkin; + h.e.as_contract(&h.host, || { + let identity = Grumpkin::identity(&h.e); + let acc = ConfidentialAccount { + spending_public_key: identity.clone(), + viewing_public_key: identity.clone(), + spendable_commitment: Grumpkin::mul(&h.e, &Grumpkin::generator(&h.e), amount), + receiving_commitment: identity, + auditor_id: 0, + }; + h.e.storage() + .persistent() + .set(&ConfidentialTokenStorageKey::Account(account.clone()), &acc); + set_compliance_config(&h.e, &base_config()); + freeze(&h.e, account); + }); +} + +#[test] +fn clawback_none_folds_commitments_and_moves_no_underlying() { + let h = setup(); + let alice = Address::generate(&h.e); + frozen_account_with(&h, &alice, 100); + h.sac.mint(&h.host, &1_000); + + let client = ConfidentialClawbackClient::new(&h.e, &h.host); + client.clawback(&alice, &40i128, &None, &clawback_data(&h.e), &h.admin); + + // C_spend <- C_spend + O - 40*G, C_receive <- O. + h.e.as_contract(&h.host, || { + use stellar_contract_utils::crypto::grumpkin::Grumpkin; + let acc = crate::confidential::storage::get_account(&h.e, &alice); + let expected = Grumpkin::mul(&h.e, &Grumpkin::generator(&h.e), 60); + assert_eq!(acc.spendable_commitment, expected); + assert_eq!(acc.receiving_commitment, Grumpkin::identity(&h.e)); + }); + // No underlying moved: the pool is now over-collateralized by 40. + assert_eq!(StellarAssetClient::new(&h.e, &h.sac_addr).balance(&h.host), 1_000); + // The freeze survives the seizure. + h.e.as_contract(&h.host, || assert!(is_frozen(&h.e, &alice))); +} + +#[test] +fn clawback_some_transfers_exactly_the_seized_amount() { + let h = setup(); + let alice = Address::generate(&h.e); + let dest = Address::generate(&h.e); + frozen_account_with(&h, &alice, 100); + h.sac.mint(&h.host, &1_000); + + let client = ConfidentialClawbackClient::new(&h.e, &h.host); + client.clawback(&alice, &40i128, &Some(dest.clone()), &clawback_data(&h.e), &h.admin); + + let sac = StellarAssetClient::new(&h.e, &h.sac_addr); + assert_eq!(sac.balance(&dest), 40); + assert_eq!(sac.balance(&h.host), 960); +} + +#[test] +#[should_panic(expected = "Error(Contract, #3604)")] +fn clawback_unfrozen_panics() { + let h = setup(); + let alice = Address::generate(&h.e); + frozen_account_with(&h, &alice, 100); + h.e.as_contract(&h.host, || unfreeze(&h.e, &alice)); + + ConfidentialClawbackClient::new(&h.e, &h.host).clawback( + &alice, + &1i128, + &None, + &clawback_data(&h.e), + &h.admin, + ); +} + +#[test] +#[should_panic(expected = "Error(Contract, #3604)")] +fn clawback_freeze_check_precedes_registration_check() { + // An address that is neither frozen nor registered yields 3604, not 3501. + let h = setup(); + let nobody = Address::generate(&h.e); + h.e.as_contract(&h.host, || set_compliance_config(&h.e, &base_config())); + + ConfidentialClawbackClient::new(&h.e, &h.host).clawback( + &nobody, + &1i128, + &None, + &clawback_data(&h.e), + &h.admin, + ); +} + +#[test] +#[should_panic(expected = "Error(Contract, #3605)")] +fn clawback_zero_amount_panics() { + let h = setup(); + let alice = Address::generate(&h.e); + frozen_account_with(&h, &alice, 100); + + ConfidentialClawbackClient::new(&h.e, &h.host).clawback( + &alice, + &0i128, + &None, + &clawback_data(&h.e), + &h.admin, + ); +} + +#[test] +#[should_panic(expected = "Error(Contract, #3605)")] +fn clawback_negative_amount_panics() { + let h = setup(); + let alice = Address::generate(&h.e); + frozen_account_with(&h, &alice, 100); + + ConfidentialClawbackClient::new(&h.e, &h.host).clawback( + &alice, + &-1i128, + &None, + &clawback_data(&h.e), + &h.admin, + ); +} + +#[test] +#[should_panic(expected = "Error(Contract, #3606)")] +fn clawback_to_self_panics() { + let h = setup(); + let alice = Address::generate(&h.e); + frozen_account_with(&h, &alice, 100); + + ConfidentialClawbackClient::new(&h.e, &h.host).clawback( + &alice, + &1i128, + &Some(h.host.clone()), + &clawback_data(&h.e), + &h.admin, + ); +} + +#[test] +fn force_revoke_spender_folds_allowance_and_deletes_delegation() { + use stellar_contract_utils::crypto::grumpkin::Grumpkin; + let h = setup(); + let alice = Address::generate(&h.e); + let spender = Address::generate(&h.e); + frozen_account_with(&h, &alice, 100); + h.e.as_contract(&h.host, || { + h.e.storage().persistent().set( + &ConfidentialTokenStorageKey::Delegation(alice.clone(), spender.clone()), + &SpenderDelegation { + allowance_commitment: Grumpkin::mul(&h.e, &Grumpkin::generator(&h.e), 25), + a_tilde: fr(&h.e), + escrowed_dvk: Grumpkin::identity(&h.e), + allowance_salt: fr(&h.e), + live_until_ledger: 1_000, + }, + ); + }); + + ConfidentialClawbackClient::new(&h.e, &h.host).force_revoke_spender(&alice, &spender, &h.admin); + + h.e.as_contract(&h.host, || { + let acc = crate::confidential::storage::get_account(&h.e, &alice); + assert_eq!(acc.spendable_commitment, Grumpkin::mul(&h.e, &Grumpkin::generator(&h.e), 125)); + assert!(!h + .e + .storage() + .persistent() + .has(&ConfidentialTokenStorageKey::Delegation(alice.clone(), spender.clone()))); + }); +} + +#[test] +#[should_panic(expected = "Error(Contract, #3604)")] +fn force_revoke_spender_unfrozen_panics() { + let h = setup(); + let alice = Address::generate(&h.e); + let spender = Address::generate(&h.e); + frozen_account_with(&h, &alice, 100); + h.e.as_contract(&h.host, || unfreeze(&h.e, &alice)); + + ConfidentialClawbackClient::new(&h.e, &h.host).force_revoke_spender(&alice, &spender, &h.admin); +} + +#[test] +#[should_panic(expected = "Error(Contract, #3504)")] +fn force_revoke_unknown_delegation_panics() { + let h = setup(); + let alice = Address::generate(&h.e); + let spender = Address::generate(&h.e); + frozen_account_with(&h, &alice, 100); + + ConfidentialClawbackClient::new(&h.e, &h.host).force_revoke_spender(&alice, &spender, &h.admin); +} + // ################## HELPERS ################## fn register_minimal_account(e: &Env, account: &Address) { diff --git a/packages/tokens/src/confidential/docs/COMPLIANCE.md b/packages/tokens/src/confidential/docs/COMPLIANCE.md index cc24f991f..e1c14e2a0 100644 --- a/packages/tokens/src/confidential/docs/COMPLIANCE.md +++ b/packages/tokens/src/confidential/docs/COMPLIANCE.md @@ -2,7 +2,7 @@ ## Abstract -This document specifies optional, deployer-configurable controls layered on top of the core Confidential Token (see [DESIGN.md](DESIGN.md)). It covers account freezing, SAC authorization passthrough, pluggable authorization policies, customization patterns for the `Hooks` extension surface, and the pooled-custody clawback flow. +This document specifies optional, deployer-configurable controls layered on top of the core Confidential Token (see [DESIGN.md](DESIGN.md)). It covers account freezing, SAC authorization passthrough, pluggable authorization policies, customization patterns for the `Hooks` extension surface, and the freeze / seize / settle clawback flow for pooled custody (§5). All controls are configured at construction time through a single `compliance: Option` entry. A vanilla deployment leaves the entry empty and pays no compliance overhead. Regulated deployments populate the entry once; subsequent state changes (freeze toggles, admin rotation, policy swap) flow through admin-gated entry points. @@ -34,7 +34,7 @@ Deployments that need separation of duties (distinct freeze, policy, and clawbac ## 2. Contract-Level Freeze -The contract maintains a `frozen(account) -> bool` entry per account. Before applying any state change, every state-modifying operation runs `check_not_frozen` against each account it names (sender, recipient). A frozen account cannot send, receive, deposit, or withdraw. The check reverts at the contract boundary. +The contract maintains a `frozen(account) -> bool` entry per account. Before applying any state change, every state-modifying operation runs `check_not_frozen` against each account it names (sender, recipient). A frozen account cannot send, receive, deposit, withdraw, merge, or create, exercise, or revoke a delegation. The check reverts at the contract boundary. The freeze is also the precondition of the clawback flow (§5.3). Full freeze (rather than outbound-only) keeps semantics clean: no further accumulation is possible after the freeze takes effect. @@ -96,7 +96,7 @@ The policy address is rotatable via `set_compliance_config` (§6) under admin au ## 4. Customizing the Hooks Trait -The compliance surface in §§2–3 is delivered as `ComplianceHooks`, a turnkey implementation of the contract's `Hooks` trait. Deployments that need behaviour beyond the default gating — for example, the deposit-side policies sketched below — replace `ComplianceHooks` with a bespoke `Hooks` impl. The custom impl typically delegates to the same primitives the default uses (`storage::gate_account`, `storage::check_policy`, `storage::check_sac`) and only overrides the callbacks that require non-default semantics. +The compliance surface in §§2–3 is delivered as `ComplianceHooks`, a turnkey implementation of the contract's `Hooks` trait. Deployments that need behaviour beyond the default gating — for example, the deposit-side policies sketched below — replace `ComplianceHooks` with a bespoke `Hooks` impl. The custom impl typically delegates to the same primitives the default uses (`storage::gate_account`, `storage::check_policy`, `storage::check_sac`) and only overrides the callbacks that require non-default semantics. A custom impl deployed alongside `ConfidentialClawback` (§5) must keep gating all seven callbacks on the freeze; §5.3 states why. `deposit` is the canonical entry point for customization because it is the only operation where `from` may legitimately be an address that has never registered with the contract (the depositor only needs to hold the underlying SEP-41). The default `ComplianceHooks::on_deposit` gates both `from` and `to` unconditionally, which means every depositor must first register and pass the policy gate. Deployments that need other semantics override `on_deposit`. @@ -171,61 +171,166 @@ impl Hooks for ApprovedAuditorHooks { --- -## 5. Clawback (Outline Only) +## 5. Clawback -This section specifies seizing value from a single confidential account: extracting a bounded amount and settling it to the issuer over a transparent path. It is separate from **freeze** (§2), and the two must not be conflated. +This section specifies seizing value from a single confidential account. Three distinct steps are involved, and the terms are kept apart throughout: -- **Freeze is immediate and unilateral.** It needs no coordination and is the correct response to an urgent order: a frozen account can neither send, receive, deposit, nor withdraw. It can be triggered by the token admin (§2) or — when the base asset is a SAC and `sac_passthrough` is set — by the issuer's own `set_authorized`/freeze surfacing through the SAC `authorized()` check (§2.2, the *transitive compliance* path), with no state mirrored by the token admin. -- **Seize is the multi-step, coordinated flow** specified below. It is normally preceded by a freeze and relies on it: the freeze is what keeps the target's commitments from changing between proof construction and submission (§5.3, *Anti-replay*). +- **Freeze** (§2) is immediate and unilateral. It needs no coordination and is the correct response to an urgent order: a frozen account can neither send, receive, deposit, withdraw, merge, nor create, exercise, or revoke a delegation. It can be triggered by the token admin or — when the base asset is a SAC and `sac_passthrough` is set — by the issuer's own `set_authorized` surfacing through the SAC `authorized()` check (§2.2). The freeze is also the precondition of everything below (§5.3). +- **Seize** reduces the frozen target's confidential claim by a public amount `alpha`, under a proof that `alpha` is bounded by what the target holds. The proof is produced by the auditor and submitted by the admin through the `clawback` entry point (§5.2, §5.4). +- **Settle** is what happens to the underlying that backed the seized claim. It is chosen per invocation through `destination: Option
` and bound into the proof (§5.5). -**Terminology.** This flow is called *clawback* because it mirrors the clawback semantics of Stellar Classic / SAC assets, but it is a distinct mechanism. +**Terminology.** The entry point is called `clawback` because it mirrors the clawback semantics of Stellar Classic / SAC assets, but it is a distinct mechanism, and under `destination: None` it does not by itself move any underlying. ### 5.1 The Pooled-Custody Problem -Once an account deposits into the contract, the underlying SEP-41 ledger lists the token contract as the holder of those funds, not the depositor. An issuer's SAC-level `clawback(token_address, amount)` call would drain the pool, debiting unrelated accounts. The contract therefore does not forward SAC-level clawback to individual confidential accounts; it must instead extract value from a single targeted account's confidential balance and settle that value to the issuer through a transparent path. +Once an account deposits into the contract, the underlying SEP-41 ledger lists the token contract as the holder of those funds, not the depositor. An issuer's SAC-level `clawback(token_address, amount)` call against the contract debits the pool as a whole, and the pool backs every holder's claim, so on its own it would leave unrelated accounts unbacked. The contract therefore never forwards SAC clawback to individual confidential accounts. It reduces the targeted account's confidential claim first; only then is the corresponding underlying either transferred out by the contract itself or left in the pool as surplus for the issuer to extract (§5.5). Reducing the claim first is what makes the second step safe. -The challenge is that the contract does not know the targeted account's balance. The balance is held as a Pedersen commitment whose opening is private to the owner. The clawback amount must be validated against the actual encrypted value without exposing it on-chain and without trusting the admin to choose a value at random. +The difficulty is that the contract does not know the targeted account's balance. Both balances are Pedersen commitments (DESIGN §5), and the contract holds no opening. The seize amount must be validated against the committed value without exposing it on-chain and without trusting the admin to assert it. ### 5.2 Admin + Auditor Coordination Three roles bear on this flow: -- **Token admin** — the access-control authority on the confidential-token contract (§1.1). Authorizes the freeze, the seize entry point, and settlement. -- **Issuer (SAC admin)** — when the base asset is a Stellar Asset Contract, the holder of its standardized admin interface (`mint`, `clawback`, `set_authorized`; CAP-0046-06). Seized value settles to the issuer over the transparent SEP-41 path, and the issuer can freeze independently of the token admin via SAC passthrough (§2.2). -- **Auditor** — holder of the off-chain auditor decryption key bound to the account at registration (`DESIGN_cont.md` §8). Governs visibility of confidential balances; cannot move funds or change contract state. +- **Token admin** — the access-control authority on the confidential-token contract (§1.1). Authorizes the freeze and submits the seize. +- **Issuer (SAC admin)** — when the base asset is a Stellar Asset Contract, the holder of its standardized admin interface (`mint`, `clawback`, `set_authorized`; CAP-0046-06). Extracts the pool surplus a `None`-branch seize creates (§5.5), and can freeze independently of the token admin via SAC passthrough (§2.2). +- **Auditor** — holder of the off-chain auditor decryption key bound to the account at registration (DESIGN_cont §8). Produces the seize witness. Cannot freeze the account, move funds, or change contract state. -The seize itself is carried out by the token admin and the auditor together. The admin authorizes the on-chain action per the role above; the auditor unlocks knowledge of the target's balance. Two halves of the target's confidential position are covered by the two auditor channels (see `DESIGN_cont.md` §8.1, §8.2). The **sender-auditor** decrypts the spendable-balance checkpoint $$\tilde{b}\_{\text{aud,s}}$$ from the target's most recent owner-initiated event, recovering $$v\_s$$. The **recipient-auditor** decrypts the per-transfer pairs $$(v\_{\text{transfer},i}, r\_{\text{transfer},i})$$ from every inbound transfer and spender-transfer since the last merge, recovering the full Pedersen opening $$(v\_r, r\_r)$$ of the target's `receiving_commitment`. The auditor then produces a zero-knowledge proof bounding the clawback amount by $$v\_s + v\_r$$, without revealing either summand. +The auditor holds the full Pedersen openings of both of the target's balance commitments, not only their values. The sender-auditor channel of every checkpoint operation carries the account's new spendable blinding in its third sponge lane (constraints W_a5, T_a9, S_a6; DESIGN §2.5, §7.5–§7.7), and the recipient-auditor channel carries the opening of every inbound transfer (DESIGN_cont §8.1). The auditor tracks `(v_spend, r_spend)` and `(v_receive, r_receive)` forward through merges, revokes, and prior seizures exactly as a wallet does, and verifies both against the on-chain commitments before proving; the update rules are given in DESIGN_cont §8.1. The clawback circuit then asserts knowledge of both openings and bounds `alpha` by `v_spend + v_receive` (§5.4). It carries no auditor key as a public input and no key-ownership constraint: Pedersen binding pins both values, so there is nothing further to pin. -Neither party can act alone: the admin cannot produce the proof, and the auditor cannot freeze the account or move funds. This is the same trust separation present in the core protocol (admin governs state transitions, auditor governs visibility) extended to a write surface. - -**The auditor is not the sole holder of the inbound openings.** Each inbound transfer's originator can reconstruct the opening of the transfer it sent, by recomputing $$r\_e$$ from its own viewing key and the salt published in the event (`DESIGN.md` §5.3, `DESIGN_cont.md` §8.2). This does not weaken the flow below, which sources $$(v\_r, r\_r)$$ from the auditor, and no single sender can open the target's $$C\_{\text{receive}}$$ as a whole — that needs every contributing transfer plus the deposits. It does mean the receiving-side half of the seize witness could in principle be assembled from cooperating senders, so the two-party separation above is a property of how this flow is constructed rather than a cryptographic impossibility. +**Neither party can act alone.** The witness is producible by anyone holding both openings — the auditor, or the owner. The admin holds no blinding and cannot produce it; the auditor holds no authorization and cannot submit it. Because the settlement destination is a public input of the same proof (§5.5), the auditor's consent fixes *where* the value settles as well as *how much* moves: a compromised clawback signer cannot take a witness built for one destination and submit it against another. A colluding owner supplying the witness is harmless, since the owner is the party being seized from and the admin gate still applies. The admin role here is the same access-control surface introduced in §1.1; deployments typically place it under a dedicated `clawback` role in RBAC, separate from the freeze role. -**Auditor routing.** The recipient-auditor and the sender-auditor roles for a single account are served by the same key: each account binds a single `auditor_id` at registration (`DESIGN.md` §6.1) which the contract uses for both the sender-channel ciphertexts on the account's outgoing operations and the recipient-channel ciphertexts on the account's incoming transfers (the two channels are separated by domain tags $$\delta\_{\text{aud\\\_s}}$$ and $$\delta\_{\text{aud\\\_r}}$$, not by distinct keys). Deployments that intend to use clawback therefore need only ensure the off-chain custodian of that key is operationally capable of producing both halves of the witness — the spendable-balance checkpoint decryption and the per-transfer $$r\_{\text{transfer},i}$$ replay — when the admin initiates a seizure. +**Auditor routing.** The recipient-auditor and the sender-auditor roles for a single account are served by the same key: each account binds a single `auditor_id` at registration (DESIGN §6.1) which the contract uses for both the sender-channel ciphertexts on the account's outgoing operations and the recipient-channel ciphertexts on its incoming transfers (the two channels are separated by domain tags, not by distinct keys). Deployments that intend to use clawback need only ensure the off-chain custodian of that key maintains both accumulators and retains every historical key secret (DESIGN_cont §8.3); the durable event archive of `INDEXER.md` is what it replays from. + +### 5.3 The Freeze Precondition + +Both compliance entry points, `clawback` and `force_revoke_spender`, require `is_frozen(account)` and revert with `AccountNotFrozen` otherwise. This check runs first, before the registration check. The freeze is the whole of "phase one": there is no separate isolate entry point and no two-phase handshake. Requiring it makes the anti-replay argument structural — the proof binds `C_spend` and `C_receive` as public inputs, and `ComplianceHooks` gates every operation that could move either of them (`merge`, deposits and transfers on `to`, `withdraw`, `set_spender`, `revoke_spender` on `account`, spender transfers on `from`), so a frozen account's `C_spend`, `C_receive`, and every escrowed `C_a` are immobile between proof construction and submission. The set of value the seize can reach is therefore fixed at freeze time (§5.6). + +**`is_frozen` sees only the contract's own `Frozen` key.** An account the SAC issuer has deauthorized (§2.2) is immobile through every gate but is not `is_frozen`; the admin must still call `freeze` before either compliance entry point accepts it. + +**The precondition is only meaningful when the deployment's `Hooks` impl gates on it.** `ConfidentialClawback: ConfidentialCompliance` obliges the deployment to implement `freeze` and `unfreeze`, but it places no constraint on `type Hooks`. A contract that wires `NoHooks` alongside the clawback impl gets a `freeze` that writes the flag and an `is_frozen` that returns `true`, so the precondition passes — while every token operation stays ungated and the target spends its balance out before the seizure lands. Wiring `ComplianceHooks`, or a custom impl (§4) that gates the same seven positions (`on_deposit`, `on_merge`, `on_withdraw`, `on_transfer`, `on_spender_transfer`, `on_set_spender`, `on_revoke_spender`), is a deployment obligation of the `ConfidentialClawback` trait. + +Neither compliance entry point is itself gated by the `Hooks` impl. Gating them would be self-defeating: both require the target to be frozen, and the freeze gate rejects frozen accounts. The owner's `revoke_spender` remains gated through `on_revoke_spender`, so a frozen owner cannot move a `C_a` on its own. + +### 5.4 The Clawback Circuit + +The `Clawback` circuit is specified normatively in DESIGN §7.13; this section summarizes it. It has three constraints — the prover knows the opening of `C_spend` (CB1), knows the opening of `C_receive` (CB2), and `v_spend`, `v_receive`, `alpha`, and `v_spend + v_receive - alpha` all lie in `[0, 2^127)` (CB3, the seize bound) — over the private witnesses `(v_spend, r_spend, v_receive, r_receive)`. It has two scalar-multiplication sites and is the cheapest circuit in the system after `Register` (DESIGN_cont §10.3). + +**Public inputs.** `C_spend`, `C_receive`, `alpha`, `addr_f`, `acct_f`, `dest_f` — eight field elements. Every one of them is loaded from trusted state or recomputed by the contract from the invocation arguments; the prover supplies none, which satisfies the DESIGN §7.1 trust-boundary rule by construction. `dest_f` is `address_to_field(destination)` under `Some` and the zero field under `None`. `addr_f`, `acct_f`, and `dest_f` are referenced by no constraint; their membership in the public-input set is the binding (DESIGN §7.2 precedent), tying the proof to one contract, one account, and one settlement destination. Because the circuit has no prover-supplied public inputs, the `data: Bytes` argument decodes to `ClawbackData { proof: Bytes }` with no payload. + +**Post-verification.** The contract sets `C_spend <- C_spend + C_receive - alpha * G` and `C_receive <- O`, then settles per §5.5, then emits `Clawback { account, amount, destination }`. This is the DESIGN §7.4 merge rule with a public debit of `alpha`: the new opening is `(v_spend + v_receive - alpha, r_spend + r_receive)`, no fresh randomness enters, and both the owner and the auditor recompute it from their own state. **The seized account stays spendable.** Re-randomizing under an admin-chosen blinding instead would leave the owner unable to open its own commitment and brick the account permanently. + +**Anti-replay.** The proof binds `C_spend` and `C_receive`, both of which change on a successful seize, so a proof never verifies twice. This holds only because `alpha > 0` is enforced (`InvalidClawbackAmount`): the sole state-preserving case is `C_receive = O` together with `alpha = 0`. The same check guards the `amount as u128` cast in post-verification. + +### 5.5 Settlement + +`destination` selects between an out-of-band settlement and a self-contained one. Both branches reduce the target's claim by exactly `alpha`; they differ in what happens to the underlying that backed it. + +| | `None` | `Some(d)` | +|:---|:---|:---| +| SEP-41 call | none | one `transfer` of exactly `alpha` to `d` | +| Pool after | unchanged — over-collateralized by `alpha` | down by `alpha`, in step with the claims | +| Extraction | issuer's own SAC `clawback(contract_address, alpha)` | already done, atomically | +| Needs `AUTH_CLAWBACK_ENABLED` | yes, for the surplus to be reachable | no | +| Non-SAC underlying | surplus stranded | fully supported | +| Exact-transfer assumption (DESIGN §3.4) | not relied on | relied on | +| Survives the issuer deauthorizing *this contract* | yes | no — `transfer` traps, the seizure reverts | + +**Under `None`** no underlying moves. The `alpha` that backed the target's claim stays in the contract's pooled balance; nothing in the contract reads its own pooled balance, so the surplus is inert and cannot be withdrawn by anyone. Extraction is the issuer's own SAC `clawback(contract_address, alpha)` through the standardized admin interface. The powers this grants match the powers the issuer already holds: an issuer whose asset was created without `AUTH_CLAWBACK_ENABLED` cannot extract the surplus, consistent with §2.2's transitive-compliance principle and with the fact that such an issuer cannot claw back from its transparent holders either. + +**Under `Some(d)`** the contract transfers exactly `alpha` to `d` in the same invocation, after the confidential debit, matching `withdraw`'s debit-then-transfer order. The pool and the sum of claims move together and the issuer is not involved, which is what makes the control work over a non-SAC underlying. This branch re-inherits the exact-transfer assumption the `None` branch is free of, and it couples the seizure to the contract's own SAC authorization: an issuer that deauthorizes the contract blocks this branch entirely. A deployment that needs seizures to survive issuer deauthorization uses `None`. `Some` naming this contract's own address is rejected (`InvalidClawbackDestination`): it would be a `None` in economic effect while reporting a settlement, which breaks the per-branch pool reconciliation `INDEXER.md` specifies. + +**Ordering, `None` branch.** A seize followed by the issuer's extraction passes through surplus, which is inert. The reverse — issuer extracts, then the admin seizes — passes through *deficit*: other holders are not frozen during it, withdrawals become first-come-first-served, and the shortfall is socialized. If the seize then proves unbuildable the deficit never closes, since a frozen account emits no ciphertext (§5.9). `None` therefore means "no on-chain settlement from this contract", not "the issuer has already extracted". It does legitimately cover the repair case, where an issuer extracted unilaterally and the admin is closing a deficit that already exists. + +**A compromised clawback signer cannot steal.** It can destroy confidential claims — griefing — and it can settle to whatever destination the auditor consented to, but it cannot choose that destination itself, because `dest_f` is in the proof's public-input set and the signer cannot produce a witness. + +The conservation invariant with the seized sums on both branches is stated in DESIGN_cont §9.3. + +### 5.6 Reaching Escrowed Allowances + +Value sitting in a `SpenderDelegation`'s `allowance_commitment` `C_a` (DESIGN §6.2) is invisible to the seize bound, which covers only `C_spend` and `C_receive`. A frozen target's escrow would be immobilized but unseizable — an evasion route for a control whose purpose is adversarial. Two mechanisms close it. + +**The auditor holds the opening of every `C_a`.** `set_spender` escrows the delegation viewing key `dvk_i` to the owner's auditor (constraint S14, DESIGN §7.7, §7.11), and every spender transfer re-escrows it over the third sponge lane of the owner-auditor channel (constraint O_a9, DESIGN §7.8). Together with the escrowed amount and post-transfer allowance it already reads from its own channel (DESIGN_cont §8.5), the auditor can assemble `(v_a, r_a)` for any live delegation. Because both constraints are unconditional core-circuit constraints, no delegation can exist without them. + +**`force_revoke_spender` folds the escrow back without the owner.** It performs the same proofless fold as the owner's `revoke_spender` — `C_spend <- C_spend + C_a`, delete the delegation, emit `RevokeSpender { account, spender, a_tilde, allowance_salt }` (DESIGN §7.9) — behind the admin gate instead of `account.require_auth()`, and additionally requires the target to be frozen. Revocation needs no proof: nothing is re-randomized and no private value is asserted, exactly as with `merge` (DESIGN §7.4). The event shape is identical on both gates; whether a revoke was forced is recoverable from the account's `Frozen` history and the invoking role. It works against expired delegations too — expiry blocks spending, never reclamation. + +**Seize surface.** Once frozen, the target cannot create new escrows, spend from them, reclaim them, merge, or receive, so the set is fixed at freeze time. + +| Where value can sit | Reachable | By | +|:---|:---|:---| +| `C_spend` | yes | CB1, over the auditor's tracked opening | +| `C_receive` | yes | CB2 | +| `C_a`, per delegation | yes | S14 / O_a9 + `force_revoke_spender`, then CB1 | + +Nowhere else holds value. -### 5.3 New Circuit +### 5.7 Integration Pattern -The clawback proof is a constant-size circuit deployed through the existing Verifier surface. It binds the seize amount $$\alpha$$ by the sum of the spendable and receiving balances of the target account, refreshes the spendable-balance checkpoint, and rewrites `receiving_commitment` to a zero commitment so the seized inbound flow is consumed atomically. +Clawback lives in its own opt-in trait, `ConfidentialClawback: ConfidentialCompliance`, rather than on `ConfidentialCompliance`. Both methods follow the `freeze` / `unfreeze` pattern: no default body, so the contract author supplies the access-control check explicitly. -**Public inputs.** $$C\_{\text{spend}}, C\_{\text{receive}}, K\_{\text{aud,s}}, \tilde{b}\_{\text{aud,s}}^{\text{old}}, R\_e^{\text{old}}, \sigma^{\text{old}}, \alpha, \tilde{b}\_{\text{aud,s}}^{\text{new}}, R\_e^{\text{new}}, \sigma^{\text{new}}, addr\_f$$. +```rust +#[contractimpl(contracttrait)] +impl ConfidentialToken for Token { + type Hooks = ComplianceHooks; // deployment obligation, see §5.3 +} -**Private witnesses.** $$k\_{\text{aud,s}}, v\_s, r\_s, v\_r, r\_r, r\_e^{\text{new}}$$, plus the sponge outputs from old and new auditor-channel sponge calls. The recipient-auditor's secret key does not appear in the witness because the recipient-channel decryption (recovery of $$(v\_r, r\_r)$$ from per-transfer events) is performed off-chain by the auditor; the circuit only re-verifies the resulting Pedersen opening of $$C\_{\text{receive}}$$ (constraint 1). +#[contractimpl(contracttrait)] +impl ConfidentialCompliance for Token { + #[only_role(operator, "freezer")] + fn freeze(e: &Env, account: Address, operator: Address) { + compliance::storage::freeze(e, &account); + } + // unfreeze, set_compliance_config … +} -**Constraints (sketch).** +// Omit this impl block entirely to deploy without seizure capability. +#[contractimpl(contracttrait)] +impl ConfidentialClawback for Token { + #[only_role(operator, "clawback")] + fn clawback( + e: &Env, + account: Address, + amount: i128, + destination: Option
, + data: Bytes, + operator: Address, + ) { + let d: ClawbackData = confidential::storage::decode_data(e, &data); + compliance::storage::clawback(e, &account, amount, &destination, &d.proof); + } -1. **Receiving-balance opening.** $$C\_{\text{receive}} = v\_r \cdot G + r\_r \cdot H$$. The recipient-auditor reconstructs $$(v\_r, r\_r)$$ off-chain from per-transfer events; the proof asserts knowledge of this opening. -2. **Spendable-balance decryption.** $$(m\_{v,s}^{\text{old}}, m\_{b,s}^{\text{old}}) = \text{SpongeSqueeze}\_2(\delta\_{\text{aud\\\_s}}, \text{ECDH}(k\_{\text{aud,s}}, R\_e^{\text{old}}), \sigma^{\text{old}})$$ and $$v\_s = \tilde{b}\_{\text{aud,s}}^{\text{old}} - m\_{b,s}^{\text{old}}$$. The spendable-balance opening $$(v\_s, r\_s)$$ is consistent with $$C\_{\text{spend}} = v\_s \cdot G + r\_s \cdot H$$ where $$r\_s$$ is recovered via the same path the wallet uses for checkpoint recovery (`DESIGN.md` §5.2): $$r\_s = \text{Poseidon}(\delta\_{\text{spend\\\_r}}, vk\_A, \sigma^{\text{old}})$$. Because the clawback circuit does not have access to $$vk\_A$$, the spendable-balance side of the proof binds via the consistency of $$\tilde{b}\_{\text{aud,s}}^{\text{old}}$$ with $$C\_{\text{spend}}$$ at the time of the last owner-initiated proof. The follow-up revision will pin down whether $$r\_s$$ is supplied as a private witness with an auxiliary opening proof or derived in-circuit from a separately escrowed value. -3. **Range and bound.** $$\alpha, v\_s, v\_r \in [0, 2^{127})$$ and $$\alpha \le v\_s + v\_r$$. -4. **Refreshed checkpoint.** $$r\_e^{\text{new}}$$ is **sampled** by the auditor per `DESIGN.md` §2.2, not derived. This is the one operation in the protocol that samples its ephemeral scalar: `DESIGN.md` §5.3 derives $$r\_e$$ from the originator's viewing key, and the originator here is the auditor, which holds no account's viewing key. Then $$R\_e^{\text{new}} = r\_e^{\text{new}} \cdot H$$, $$r\_e^{\text{new}} \neq 0$$, and $$\tilde{b}\_{\text{aud,s}}^{\text{new}} = (v\_s + v\_r - \alpha) + m\_{b,s}^{\text{new}}$$ where $$(m\_{v,s}^{\text{new}}, m\_{b,s}^{\text{new}}) = \text{SpongeSqueeze}\_2(\delta\_{\text{aud\\\_s}}, \text{ECDH}(k\_{\text{aud,s}}, R\_e^{\text{new}}), \sigma^{\text{new}})$$. + #[only_role(operator, "clawback")] + fn force_revoke_spender(e: &Env, account: Address, spender: Address, operator: Address) { + compliance::storage::force_revoke_spender(e, &account, &spender); + } +} +``` + +Opting out is omitting the `ConfidentialClawback` impl — a compile-time choice with no storage, configuration, or runtime cost, and no misconfiguration mode. The auditor's opening capability comes from the core circuits and is present in every deployment regardless of this choice (DESIGN_cont §8.2, §9.4). + +The feature adds no storage key and reshapes no stored type. `ComplianceConfig`, `ComplianceStorageKey`, and `ComplianceHooks` are unchanged; `Account` and `Delegation` entries keep their shape and are written by the seize and the forced revoke. The auditor's knowledge of the openings travels in events, not in contract storage. -**Post-verification.** The contract sets $$C\_{\text{spend}} \leftarrow (v\_s + v\_r - \alpha) \cdot G + r\_s' \cdot H$$ under fresh deterministic randomness $$r\_s'$$ (admin-derived, since $$vk\_A$$ is unavailable), zeroes $$C\_{\text{receive}}$$, transfers $$\alpha$$ of the underlying SEP-41 token to the issuer, and emits an event carrying $$(\tilde{b}\_{\text{aud,s}}^{\text{new}}, R\_e^{\text{new}}, \sigma^{\text{new}})$$ so the sender-auditor sees the new checkpoint. +Preconditions are checked in this order on both entry points: `is_frozen(account)` (`AccountNotFrozen`), then registration (`AccountNotRegistered`). `clawback` additionally requires `amount > 0` (`InvalidClawbackAmount`) and `destination != Some(current_contract_address)` (`InvalidClawbackDestination`), both before public-input assembly; `force_revoke_spender` additionally requires the delegation to exist (`DelegationNotFound`). -**Anti-replay.** The contract consumes $$C\_{\text{spend}}$$ and $$C\_{\text{receive}}$$ as proof public inputs at verification time. If either commitment changes between proof construction and submission (e.g., an inbound transfer arrives), verification fails because the proof was bound to a different $$C\_{\text{receive}}$$. The §2 contract-level freeze applied to the target per §5.2's flow blocks both spending and receiving, so neither $$C\_{\text{spend}}$$ nor $$C\_{\text{receive}}$$ can change between proof construction and submission, and the proof's bindings hold across the isolate-then-settle handshake. +### 5.8 Operational Sequence -**What is no longer needed.** The earlier sketch of an on-chain receiving-side accumulator and a per-transfer compliance hook on `confidential_transfer`, `confidential_transfer_from`, and `deposit` is not required. The recipient-auditor's opening of $$C\_{\text{receive}}$$ is reconstructed entirely off-chain from event scans (`DESIGN_cont.md` §8.1). +1. `freeze(target)` — immediate, unilateral, one transaction. Required even if the SAC issuer has already deauthorized the target (§5.3). +2. `force_revoke_spender(target, spender)` for each delegation to be reached — one transaction each, no proof. +3. Admin and auditor agree the settlement destination. This is a decision, not a formality: it enters the witness and cannot be changed at submission time. +4. The auditor builds the witness off-chain — `(v_spend, r_spend)` and `(v_receive, r_receive)` from its accumulators (DESIGN_cont §8.1), `dest_f` from step 3 — verifies both openings against the on-chain commitments, and proves. This requires the durable event archive of `INDEXER.md`. +5. `clawback(target, alpha, destination, data)` — the confidential claim drops by `alpha`. Under `Some(d)`, `alpha` has reached `d` and the operation is complete. Under `None`, the pool is now over-collateralized by `alpha`. +6. `None` only: the issuer calls SAC `clawback(contract_address, alpha)` to extract the surplus. This step follows step 5 and must not precede it (§5.5). -Detailed encoding, the precise treatment of $$r\_s$$, and the two-phase isolate-then-settle entry-point sequencing are deferred to a follow-up revision of this document. +The freeze stays in place afterwards; unfreezing is a separate admin decision. + +### 5.9 Out of Scope + +- **Policy on which destinations are admissible.** The destination is bound to the auditor's consent but is otherwise unconstrained — no allowlist, no config-pinned address, no `Policy::is_authorized` call on `d`. A deployment needing the destination itself gated checks it in its own `#[only_role]` override, which already receives the argument. +- **Auditor key rotation across a frozen account.** A frozen account emits no further ciphertext of any kind, so an auditor key rotated after the freeze can build the witness only from secrets it retained. The retention obligation of DESIGN_cont §8.3 is the mitigation; a rotation whose old secret is burned strands every account frozen at that moment until it is unfrozen and checkpoints again. +- **Recovering the pool surplus a `None` seize creates over a non-SAC underlying.** Safe degradation — over-collateralization, never insolvency — but unrecoverable unless the underlying's own admin has a path to it. A non-SAC deployment uses `Some(d)`. +- **A `clawback` variant that does not require a prior freeze.** It would reintroduce the race the freeze precondition eliminates. +- **Value already transferred out before the freeze landed.** The recipient is a separate account; freeze and seize it separately. --- @@ -248,14 +353,62 @@ impl Token { // Reads fn compliance_config(e: Env) -> Option; + + // Clawback (§5) — present only when the deployment implements + // ConfidentialClawback. Both require is_frozen(account). + fn clawback(e: Env, account: Address, amount: i128, destination: Option
, + data: Bytes, admin: Address); + fn force_revoke_spender(e: Env, account: Address, spender: Address, admin: Address); } ``` +The library surface behind the two clawback entry points: + +```rust +#[contracttrait] +pub trait ConfidentialClawback: ConfidentialCompliance { + fn clawback(e: &Env, account: Address, amount: i128, destination: Option
, + data: Bytes, operator: Address); // no default body + fn force_revoke_spender(e: &Env, account: Address, spender: Address, + operator: Address); // no default body +} + +// compliance::storage — both skip authorization; the trait override owns the gate. +pub fn clawback(e: &Env, account: &Address, amount: i128, + destination: &Option
, proof: &Bytes); +pub fn force_revoke_spender(e: &Env, account: &Address, spender: &Address); + +/// Decoded from `clawback`'s `data: Bytes`. Proof only — the circuit has no +/// prover-supplied public inputs (§5.4). +#[contracttype] +pub struct ClawbackData { pub proof: Bytes } +``` + +Two core signatures change because revocation is proofless (DESIGN §7.9): `ConfidentialToken::revoke_spender(e, account, spender)` no longer takes `data`, and `Hooks::on_revoke_spender(e, account, spender)` no longer receives a payload. `ComplianceHooks::on_revoke_spender` gates `account` exactly as before. + ### 6.1 Events -| Event | Fields | -|:---|:---| -| `Frozen`, `Unfrozen` | `account` | -| `ComplianceConfigChanged` | `policy`, `sac_passthrough` | +| Event | Topics | Data | +|:---|:---|:---| +| `Frozen`, `Unfrozen` | `account` | — | +| `ComplianceConfigChanged` | — | `policy`, `sac_passthrough` | +| `Clawback` | `account` | `amount: i128`, `destination: Option
` — `None` when no underlying moved, `Some(d)` when exactly `amount` reached `d` | +| `RevokeSpender` (core, DESIGN_cont §11.2) | `account`, `spender` | `a_tilde`, `allowance_salt` — emitted identically by the owner's `revoke_spender` and by `force_revoke_spender` | + +`Clawback` is not a checkpoint event; it carries nothing under the owner's viewing key. Wallets fold it as a merge with a public debit that also resets the receiving accumulator, and it joins `Merge` and `Register` as a replay-window anchor (DESIGN §5.2). An indexer reconciling the pool against the sum of claims tracks cumulative `amount` per settlement branch, since only `None` creates surplus (`INDEXER.md`). + +### 6.2 Errors + +`ComplianceError` occupies 3600–3606. + +| Code | Variant | Raised by | +|:---|:---|:---| +| 3600 | `NotConfigured` | admin entry points when `compliance.is_none()` | +| 3601 | `AccountFrozen` | `ComplianceHooks`, on a frozen named account | +| 3602 | `NotAuthorizedByPolicy` | `ComplianceHooks`, when the policy contract returns `false` | +| 3603 | `NotAuthorizedBySac` | `ComplianceHooks`, when `sac_passthrough` is set and the SAC reports the account unauthorized | +| 3604 | `AccountNotFrozen` | `clawback`, `force_revoke_spender` — the target is not frozen (§5.3) | +| 3605 | `InvalidClawbackAmount` | `clawback` — `amount <= 0` (§5.4) | +| 3606 | `InvalidClawbackDestination` | `clawback` — `destination` names this contract (§5.5) | -Clawback-related events are specified alongside the clawback flow in the follow-up revision. +Core errors reused unchanged: `AccountNotRegistered`, `DelegationNotFound`, `InvalidProof`, `InvalidData`, `NonCanonicalEncoding` (DESIGN_cont §11). diff --git a/packages/tokens/src/confidential/docs/DESIGN.md b/packages/tokens/src/confidential/docs/DESIGN.md index c31beb408..8d6b73d0d 100644 --- a/packages/tokens/src/confidential/docs/DESIGN.md +++ b/packages/tokens/src/confidential/docs/DESIGN.md @@ -49,7 +49,7 @@ The design is built on three interlocking mechanisms: 3. **Proof-less merge.** Incoming funds accumulate in a receiving balance that is separate from the spendable balance. To make received funds spendable, the owner authorizes a merge - no ZK proof is required. Since merge requires owner authorization and incoming transfers touch only the receiving balance, neither the spend path nor the merge path can be front-run by a third party. -Six Noir/UltraHonk circuits cover registration, withdrawal, confidential transfer, spender transfer, spender delegation, and spender revocation. The proof system leverages the Grumpkin–BN254 curve cycle: Grumpkin point arithmetic is native inside Noir circuits (no field emulation), while Soroban natively supports BN254 operations for UltraHonk proof verification. +Six Noir/UltraHonk circuits cover registration, withdrawal, confidential transfer, spender transfer, spender delegation, and compliance clawback (§7.13); merge and spender revocation are proofless homomorphic folds. The proof system leverages the Grumpkin–BN254 curve cycle: Grumpkin point arithmetic is native inside Noir circuits (no field emulation), while Soroban natively supports BN254 operations for UltraHonk proof verification. --- @@ -257,13 +257,13 @@ i.e., the total committed value across all confidential accounts never exceeds t - *Non-rebasing.* The token's balance attributed to the contract address changes only as a result of explicit operations that the contract itself originated. Tokens whose balances change as a function of supply, oracle data, or external triggers break the accounting invariant and are unsupported. - *No fee-on-transfer.* `token.transfer(from, to, amount)` MUST move exactly `amount` units. A fee deducted in transit would leave the contract's confidential accounting larger than its public backing. - *Deterministic revert.* A failed `token.transfer` MUST cause the enclosing contract invocation (`deposit` or `withdraw`) to revert atomically, so confidential state is never updated against a token transfer that did not happen. -- *Underlying clawback / freeze / deauthorization.* These are surfaces of the Stellar Asset Contract (`StellarAssetInterface`), not the generic SEP-41 (`TokenInterface`). If the underlying token is a SAC whose issuer can clawback, freeze, or deauthorize the contract's holdings, confidential accounting at the contract layer may temporarily or permanently exceed the contract's accessible backing. This is an operational risk borne by the deployer's choice of underlying token. The token layer offers its own freeze and per-account clawback flows that operate inside the confidential surface; see [COMPLIANCE.md](./COMPLIANCE.md) §2 (contract-level freeze) and §5 (admin + auditor clawback). [COMPLIANCE.md](./COMPLIANCE.md) §2.2 additionally specifies SAC authorization passthrough, which composes the contract's freeze with the issuer's freeze without requiring the admin to mirror state. +- *Underlying clawback / freeze / deauthorization.* These are surfaces of the Stellar Asset Contract (`StellarAssetInterface`), not the generic SEP-41 (`TokenInterface`). If the underlying token is a SAC whose issuer can clawback, freeze, or deauthorize the contract's holdings, confidential accounting at the contract layer may temporarily or permanently exceed the contract's accessible backing. This is an operational risk borne by the deployer's choice of underlying token. A compliance seizure settled with `destination: None` (§7.13) gives the issuer a legitimate reason to make such a call: the seize leaves the pool over-collateralized by exactly the seized amount, and the issuer's SAC clawback against the contract address is the intended extraction path -- after the seize, never before, since the reverse order passes through a deficit borne by every other holder ([COMPLIANCE.md](./COMPLIANCE.md) §5.5). The token layer offers its own freeze and per-account clawback flows that operate inside the confidential surface; see [COMPLIANCE.md](./COMPLIANCE.md) §2 (contract-level freeze) and §5 (admin + auditor clawback). [COMPLIANCE.md](./COMPLIANCE.md) §2.2 additionally specifies SAC authorization passthrough, which composes the contract's freeze with the issuer's freeze without requiring the admin to mirror state. **Non-negativity check.** The contract's public interface uses `i128` end-to-end, matching SEP-41. Every entrypoint that accepts a public amount (`deposit`, `withdraw`) MUST reject `amount < 0` and revert. The in-circuit range constraint (Section 2.6) bounds the same value at $$2^{127}$$ from above; together they pin the contract's value domain to $$[0, 2^{127}) = [0, \text{i128::MAX}]$$, matching SEP-41 exactly. No conversion at the SEP-41 boundary is needed. ### 3.5 Governance and Upgradeability -The constructor binds the contract to fixed `admin`, `token`, `verifier`, and `auditor` addresses. It additionally computes and stores `addr_f = address_to_field(env.current_contract_address())` (§2.7) in **instance storage** as a single canonical $$\mathbb{F}\_r$$ Field; this is the value every owner-initiated proof references via constraints R2 / W2 / T2 / S2 / V2. The compressed `addr_f` Field is computed once at construction (not recomputed per call) to ensure all proofs across the contract's lifetime bind to the same Field representative of the contract's address. Beyond that, this specification does not prescribe a governance policy for upgrading these components or for rotating per-circuit verification keys. Concrete deployments differ widely in spender structure, regulatory posture, and emergency-response requirements, so these decisions are deliberately left to implementers. +The constructor binds the contract to fixed `admin`, `token`, `verifier`, and `auditor` addresses. It additionally computes and stores `addr_f = address_to_field(env.current_contract_address())` (§2.7) in **instance storage** as a single canonical $$\mathbb{F}\_r$$ Field; this is the value every owner-initiated proof references via constraints R2 / W2 / T2 / S2, and that the clawback proof binds directly (§7.13). The compressed `addr_f` Field is computed once at construction (not recomputed per call) to ensure all proofs across the contract's lifetime bind to the same Field representative of the contract's address. Beyond that, this specification does not prescribe a governance policy for upgrading these components or for rotating per-circuit verification keys. Concrete deployments differ widely in spender structure, regulatory posture, and emergency-response requirements, so these decisions are deliberately left to implementers. Questions an implementer must answer: @@ -361,31 +361,33 @@ $$W\_{\text{receive}} = (v\_r, r\_r) \quad \text{such that} \quad C\_{\text{rece | Merge | $$W\_{\text{spend}} \leftarrow (v\_s + v\_r, \\; r\_s + r\_r)$$; $$W\_{\text{receive}} \leftarrow (0, 0)$$ | | Set spender (escrow amount $$a$$) | Proof outputs new commitment. $$W\_{\text{spend}} \leftarrow (v\_s - a, \\; \text{Poseidon}(\delta\_{\text{spend\\\_r}}, vk, \sigma))$$ | | Revoke spender, by the owner or forced by the compliance module | Proofless fold. $$W\_{\text{spend}} \mathrel{+}= (v\_a, r\_a)$$, the escrow opening recovered from the event per §7.9 | +| Clawback of public amount $$\alpha$$ (§7.13) | $$W\_{\text{spend}} \leftarrow (v\_s + v\_r - \alpha, \\; r\_s + r\_r)$$; $$W\_{\text{receive}} \leftarrow (0, 0)$$ | -The Merge and Revoke spender rows use exact integer addition; $$W\_{\text{spend}}.r$$ is not reduced modulo $$r$$ or $$q$$ as merges accumulate. At proof-construction time the wallet reduces $$W\_{\text{spend}}.r$$ modulo $$q$$ and encodes the canonical $$\mathbb{F}\_q$$ representative as a single $$\mathbb{F}\_r$$ `Field`; the case where that encoding is unavailable is specified in [DESIGN_cont.md](./DESIGN_cont.md) §10.4 *Post-merge witness availability*. +The Merge, Revoke spender, and Clawback rows use exact integer addition; $$W\_{\text{spend}}.r$$ is not reduced modulo $$r$$ or $$q$$ as merges accumulate. At proof-construction time the wallet reduces $$W\_{\text{spend}}.r$$ modulo $$q$$ and encodes the canonical $$\mathbb{F}\_q$$ representative as a single $$\mathbb{F}\_r$$ `Field`; the case where that encoding is unavailable is specified in [DESIGN_cont.md](./DESIGN_cont.md) §10.4 *Post-merge witness availability*. -After every owner-initiated operation that produces a proof, $$r\_s$$ resets to a deterministic value. This is the **normalization** property: the spendable balance's blinding factor is always recoverable from $$(vk, \sigma)$$ at spend boundaries. Together with $$\tilde{b}$$, both emitted in the spend-boundary event, each spend boundary forms a **checkpoint** from which the spendable opening $$(v\_s, r\_s)$$ is recoverable via a single event lookup, with no exhaustive history replay needed for $$W\_{\text{spend}}$$. $$W\_{\text{receive}}$$ has no such anchor: a checkpoint leaves $$C\_{\text{receive}}$$ untouched, which only `Merge` (§7.4) resets, so recovering $$W\_{\text{receive}}$$ requires replaying events emitted after the account's most recent `Merge` or `Clawback` at or before that checkpoint (see Recovery below). +After every owner-initiated operation that produces a proof, $$r\_s$$ resets to a deterministic value. This is the **normalization** property: the spendable balance's blinding factor is always recoverable from $$(vk, \sigma)$$ at spend boundaries. Together with $$\tilde{b}$$, both emitted in the spend-boundary event, each spend boundary forms a **checkpoint** from which the spendable opening $$(v\_s, r\_s)$$ is recoverable via a single event lookup, with no exhaustive history replay needed for $$W\_{\text{spend}}$$. $$W\_{\text{receive}}$$ has no such anchor: a checkpoint leaves $$C\_{\text{receive}}$$ untouched, which only `Merge` (§7.4) and `Clawback` (§7.13) reset, so recovering $$W\_{\text{receive}}$$ requires replaying events emitted after the account's most recent `Merge` or `Clawback` at or before that checkpoint (see Recovery below). **Consistency check.** At any time, the wallet can verify its state: $$C\_{\text{spend}} \stackrel{?}{=} v\_s \cdot G + r\_s \cdot H$$ and $$C\_{\text{receive}} \stackrel{?}{=} v\_r \cdot G + r\_r \cdot H$$, where $$C\_{\text{spend}}$$ and $$C\_{\text{receive}}$$ are read from on-chain state. -**Recovery.** If the wallet loses local state, it recovers from **two anchors**. The spendable side is anchored at the **last checkpoint**: the most recent owner-initiated proof operation (`withdraw`, `confidential_transfer`, or `set_spender`), which emitted both $$\tilde{b}$$ and $$\sigma$$ in its event, from which steps 1-4 recover $$W\_{\text{spend}}$$ in a single event lookup using $$vk$$. The receiving side is anchored at $$T\_0$$: the account's most recent `Merge` event at or before that checkpoint, or its `Register` event if neither occurred before the checkpoint. $$W\_{\text{receive}}$$ restarts at $$(0, 0)$$ as of $$T\_0$$, so the replay window is $$(T\_0, \text{now}]$$ (steps 5-6) rather than the interval after the checkpoint: +**Recovery.** If the wallet loses local state, it recovers from **two anchors**. The spendable side is anchored at the **last checkpoint**: the most recent owner-initiated proof operation (`withdraw`, `confidential_transfer`, or `set_spender`), which emitted both $$\tilde{b}$$ and $$\sigma$$ in its event, from which steps 1-4 recover $$W\_{\text{spend}}$$ in a single event lookup using $$vk$$. The receiving side is anchored at $$T\_0$$: the account's most recent `Merge` or `Clawback` event at or before that checkpoint, or its `Register` event if neither occurred before the checkpoint. $$W\_{\text{receive}}$$ restarts at $$(0, 0)$$ as of $$T\_0$$, so the replay window is $$(T\_0, \text{now}]$$ (steps 5-6) rather than the interval after the checkpoint: -1. Fetch $$(\tilde{b}, \sigma)$$ from the most recent **checkpoint event** for this account, where a checkpoint event is exactly one of `Withdraw`, `Transfer` (where the account is the `from`), or `SetSpender` -- the three event types that carry a proof-bound $$(\tilde{b}, \sigma)$$ for the account's spendable balance. `Deposit`, `Transfer` (where the account is the `to`), `SpenderTransfer` (recipient side), `Merge` and `RevokeSpender` are explicitly **not** checkpoints: they either carry no $$(\tilde{b}, \sigma)$$ at all or carry one that is bound to a different account's spendable balance. `Merge` and `RevokeSpender` do update the spendable balance, but they carry no proof, so consistency between a $$\tilde{b}$$ and the resulting commitment cannot be enforced (§7.4 *Encrypted balance*); their activity is absorbed into the next checkpoint. `Merge` is nonetheless the anchor of the receiving side, since it is the only event that reset $$C\_{\text{receive}}$$ (step 5). **No-checkpoint case:** if the account has no checkpoint event since `Register`, initialize $$W\_{\text{spend}} \leftarrow (0, 0)$$ and skip to step 5 with $$T\_0$$ = the `Register` event. +1. Fetch $$(\tilde{b}, \sigma)$$ from the most recent **checkpoint event** for this account, where a checkpoint event is exactly one of `Withdraw`, `Transfer` (where the account is the `from`), or `SetSpender` -- the three event types that carry a proof-bound $$(\tilde{b}, \sigma)$$ for the account's spendable balance. `Deposit`, `Transfer` (where the account is the `to`), `SpenderTransfer` (recipient side), `Merge`, `RevokeSpender`, and `Clawback` are explicitly **not** checkpoints: they either carry no $$(\tilde{b}, \sigma)$$ at all or carry one that is bound to a different account's spendable balance. `Merge`, `RevokeSpender`, and `Clawback` do update the spendable balance, but they carry no proof, so consistency between a $$\tilde{b}$$ and the resulting commitment cannot be enforced (§7.4 *Encrypted balance*); their activity is absorbed into the next checkpoint. `Merge` and `Clawback` are nonetheless the anchors of the receiving side, since they are the only events that reset $$C\_{\text{receive}}$$ (step 5). **No-checkpoint case:** if the account has no checkpoint event since `Register`, initialize $$W\_{\text{spend}} \leftarrow (0, 0)$$ and skip to step 5 with $$T\_0$$ = the `Register` event. 2. Recover the spendable balance value: $$v\_s = \tilde{b} - \text{Poseidon}(\delta\_{\text{enc\\\_bal}}, vk, \sigma)$$. 3. Recover the spendable balance blinding: $$r\_s = \text{Poseidon}(\delta\_{\text{spend\\\_r}}, vk, \sigma)$$. 4. Set $$W\_{\text{spend}} \leftarrow (v\_s, r\_s)$$. -5. Locate $$T\_0$$: the account's most recent `Merge` event at or before the checkpoint of step 1, or its `Register` event if neither exists. Set $$W\_{\text{receive}} \leftarrow (0, 0)$$ as of $$T\_0$$. +5. Locate $$T\_0$$: the account's most recent `Merge` or `Clawback` event at or before the checkpoint of step 1, or its `Register` event if neither exists. Set $$W\_{\text{receive}} \leftarrow (0, 0)$$ as of $$T\_0$$. 6. Replay every event after $$T\_0$$ in ledger order. For each event: - **Incoming transfer** $$(R\_e, \tilde{v}, \sigma\_{\text{sender}})$$: compute $$s = \text{ECDH}(vk, R\_e)$$, derive $$v\_{\text{transfer}}$$ and $$r\_{\text{transfer}}$$. Accumulate $$W\_{\text{receive}} \mathrel{+}= (v\_{\text{transfer}}, r\_{\text{transfer}})$$. - **Deposit** of amount $$a$$: accumulate $$W\_{\text{receive}} \mathrel{+}= (a, 0)$$. - **Merge**: fold $$W\_{\text{spend}} \leftarrow (W\_{\text{spend}}.v + W\_{\text{receive}}.v, \\; W\_{\text{spend}}.r + W\_{\text{receive}}.r)$$, reset $$W\_{\text{receive}} \leftarrow (0, 0)$$. + - **Clawback** of public amount $$\alpha$$: fold as a merge, then debit: $$W\_{\text{spend}} \leftarrow (W\_{\text{spend}}.v + W\_{\text{receive}}.v - \alpha, \\; W\_{\text{spend}}.r + W\_{\text{receive}}.r)$$, reset $$W\_{\text{receive}} \leftarrow (0, 0)$$. (A `Clawback` inside the window is necessarily after the checkpoint of step 1, by the choice of the anchor.) - **Revoke spender**: if the event is at or before the checkpoint of step 1, skip it -- the checkpoint absorbed it. Otherwise recover the escrow opening $$(v\_a, r\_a)$$ from the event per §7.9 and fold $$W\_{\text{spend}} \mathrel{+}= (v\_a, r\_a)$$. - **Checkpoint event**: skip the spendable side -- step 1 already captured the spendable opening. A **self-transfer** (a `Transfer` whose `from` and `to` are both this account) is simultaneously a checkpoint and an incoming transfer: apply its incoming-transfer rule to $$W\_{\text{receive}}$$ like any other incoming transfer. 7. Verify consistency: $$C\_{\text{spend}} \stackrel{?}{=} W\_{\text{spend}}.v \cdot G + W\_{\text{spend}}.r \cdot H$$ and $$C\_{\text{receive}} \stackrel{?}{=} W\_{\text{receive}}.v \cdot G + W\_{\text{receive}}.r \cdot H$$. Steps 1-4 require $$(\tilde{b}, \sigma)$$ from the latest checkpoint event and $$vk$$; no history replay is needed for $$W\_{\text{spend}}$$. Step 6 replays the window $$(T\_0, \text{now}]$$ and correctly handles any number of interleaved deposits, transfers, and merges. The window's length is set by how recently the account merged before its latest checkpoint, not by how often it spends. What keeps it short in practice is the wallet merge policy ([SDK.md](./SDK.md) §10.8): a wallet merges (§7.4) ahead of a spend its spendable balance alone cannot cover, which places a `Merge` immediately before most checkpoints. In the worst case (funds received but never merged), the window extends back to registration. -**Event durability requirement.** Recovery depends on the wallet being able to locate $$T\_0$$ and retrieve every event after it, the checkpoint event included, in ledger order. Stellar RPC retains event history for a 7-days window only, so a wallet that loses local state after that window cannot recover from RPC alone. The protocol therefore assumes a durable event archive that retains the full per-account history of `Withdraw`, `Transfer` (both directions), `SpenderTransfer` (recipient side), `Deposit`, `Merge`, `SetSpender`, and `RevokeSpender` events forever. The data model, ingestion contract, retention obligations, and recommended API surface for that archive are specified in [INDEXER.md](./INDEXER.md). Wallets and SDKs MUST consume an indexer that meets that contract for recovery. +**Event durability requirement.** Recovery depends on the wallet being able to locate $$T\_0$$ and retrieve every event after it, the checkpoint event included, in ledger order. Stellar RPC retains event history for a 7-days window only, so a wallet that loses local state after that window cannot recover from RPC alone. The protocol therefore assumes a durable event archive that retains the full per-account history of `Withdraw`, `Transfer` (both directions), `SpenderTransfer` (recipient side), `Deposit`, `Merge`, `SetSpender`, `RevokeSpender`, and `Clawback` events forever. The data model, ingestion contract, retention obligations, and recommended API surface for that archive are specified in [INDEXER.md](./INDEXER.md). Wallets and SDKs MUST consume an indexer that meets that contract for recovery. ### 5.3 ECDH-Derived Blinding @@ -404,7 +406,7 @@ where $$v\_{\text{transfer}}$$ is the transfer amount. The transfer commitment i Since $$vk\_B \cdot R\_e = r\_e \cdot \text{PVK}\_B = S$$ by ECDH commutativity, both sender and recipient can independently derive $$r\_{\text{transfer}}$$ and decrypt $$v\_{\text{transfer}} = \tilde{v} - \text{Poseidon}(\delta\_{\text{transfer\\\_amount}}, s, \sigma)$$, provided they know $$\sigma$$ emitted with the event. The auditor decrypts the transfer amount via a separate ECDH channel (Section 8.1). -**Deterministic ephemeral scalar.** $$r\_e$$ is derived from the originator's own viewing key and the operation salt. Every operation that has an ephemeral derives it this way: `Transfer`, `Withdraw`, and `SetSpender` from the owner's viewing key, `SpenderTransfer` from the spender's own (§7.8). No operation derives $$r\_e$$ any other way; `Merge` and `RevokeSpender` have no ephemeral at all. The derivation MUST be re-attempted with a fresh salt in the negligible case that it yields zero. +**Deterministic ephemeral scalar.** $$r\_e$$ is derived from the originator's own viewing key and the operation salt. Every operation that has an ephemeral derives it this way: `Transfer`, `Withdraw`, and `SetSpender` from the owner's viewing key, `SpenderTransfer` from the spender's own (§7.8). No operation derives $$r\_e$$ any other way; `Merge`, `RevokeSpender`, and `Clawback` have no ephemeral at all. The derivation MUST be re-attempted with a fresh salt in the negligible case that it yields zero. Because $$\sigma$$ is published in the event and $$vk$$ is held by the originator, the derivation lets the originator recompute $$r\_e$$ for any past transfer from the event alone, which is what makes sender-side selective disclosure possible with no per-transfer wallet state ([SELECTIVE_DISCLOSURE.md](./SELECTIVE_DISCLOSURE.md) §7). A transfer whose $$r\_e$$ was sampled and not retained is permanently undisclosable by its sender. No circuit constrains $$r\_e$$ beyond $$R\_e = r\_e \cdot H$$ and $$r\_e \neq 0$$. @@ -456,12 +458,12 @@ $$\text{PVK} = vk \cdot H$$. Set once at registration. Used by senders for ECDH **`spendable_commitment`** -The commitment the owner can spend from. Modified by owner-authorized operations -- transfers out, withdrawals, merge, `set_spender`, `revoke_spender`.md](./COMPLIANCE.md) §5). Encoded as a single Grumpkin affine point (64 bytes). +The commitment the owner can spend from. Modified by owner-authorized operations -- transfers out, withdrawals, merge, `set_spender`, `revoke_spender` -- and, on frozen accounts only, by the compliance module's `clawback` (§7.13) and `force_revoke_spender` (§7.9), which act without the owner's authorization ([COMPLIANCE.md](./COMPLIANCE.md) §5). Encoded as a single Grumpkin affine point (64 bytes). **`receiving_commitment`** -Accumulates incoming deposits and transfers via homomorphic addition. The contract adds to this without any proof from the recipient. Reset to $$\mathcal{O}$$ on merge (§7.4). Encoded as a single Grumpkin affine point (64 bytes). +Accumulates incoming deposits and transfers via homomorphic addition. The contract adds to this without any proof from the recipient. Reset to $$\mathcal{O}$$ on merge (§7.4) and on clawback (§7.13). Encoded as a single Grumpkin affine point (64 bytes). **`auditor_id`** @@ -816,10 +818,56 @@ The spender decrypts using $$sk\_{\text{op}}$$. The `set_spender` proof enforces The $$r\_e$$ here is the same scalar S\_a1 commits to ($$R\_e = r\_e \cdot H$$), so the escrow's $$R\_x$$ and the auditor channel's $$R\_e.x$$ are forced equal. +**Auditor-side escrow.** The same proof escrows the delegation viewing key to the owner's auditor as well (S14), keyed by the S\_a2 shared scalar under a tag of its own (Section 13; §5.3 explains why the spender-escrow tag is not reused). `dvk_cipher_aud` is emitted in the `SetSpender` event, and every spender transfer re-escrows the key over lane 2 of the owner-auditor sponge (O\_a9), so an auditor key rotated after `set_spender` regains it at the delegation's next state change. With the key and the per-event allowance ciphertexts (Section 8.5), the auditor holds the opening $$(v\_a, r\_a)$$ of every $$C\_a$$ -- which is what lets it track $$C\_{\text{spend}}$$ through the proofless revoke of §7.9 and what brings escrowed value within reach of clawback ([COMPLIANCE.md](./COMPLIANCE.md) §5). The key does not recover $$vk$$ (§4.4) and is scoped to one spender in one contract. The escrow is unconditional: no delegation can exist without it. + ### 7.12 Expiry and Revert Safety Allowance randomness includes `allowance_salt` to prevent deterministic-randomness reuse after reverted transactions. Delegation storage, expiry, and revocation semantics are specified in §6.2. +### 7.13 Clawback + +The compliance module reduces a frozen account's confidential claim by a public amount $$\alpha$$ without the owner's participation. The preconditions (the account is frozen, $$\alpha > 0$$, the settlement destination is not the contract itself), the authorization model, and the settlement semantics are specified in [COMPLIANCE.md](./COMPLIANCE.md) §5; this section specifies the circuit and the state update. + +**Circuit constraints (Clawback):** + +| # | Constraint | +|:--|:---| +| CB1 | Prover knows opening $$(v\_s, r\_s)$$ of $$C\_{\text{spend}}$$ | +| CB2 | Prover knows opening $$(v\_r, r\_r)$$ of $$C\_{\text{receive}}$$ | +| CB3 | $$v\_s, v\_r, \alpha \in [0, 2^{127})$$ and $$v\_s + v\_r - \alpha \in [0, 2^{127})$$ (seize bound, Section 2.6) | + +The circuit has no key-ownership constraint and no ephemeral scalar: both openings are pinned by Pedersen binding, so there is nothing left to forge and nothing to pin. Both commitments MUST be accepted in the identity encoding `(0, 0)` (§5.1), opened as `(0, 0)`; `C_receive` is the identity whenever the target has nothing pending. + +**Public inputs (8 fields, in this order):** + +| Input | Notes | +|:---|:---| +| `C_spend` | Loaded from `account.spendable_commitment` | +| `C_receive` | Loaded from `account.receiving_commitment` | +| `alpha` | Public seize amount from invocation inputs | +| `addr_f` | Loaded from instance storage; set once at construction (§3.5) | +| `acct_f` | `address_to_field(account)`, computed per-call by the contract (§2.7) | +| `dest_f` | `address_to_field(destination)` under `Some`, the zero field under `None`; computed per-call | + +No public input is prover-supplied. `addr_f`, `acct_f`, and `dest_f` are referenced by no constraint; their membership in the public-input set is the binding, on the §7.2 precedent. The first two cover the degenerate case of two accounts whose commitments are both the identity; the third binds the settlement destination, so a proof built for one destination cannot be submitted against another. The zero field is unambiguous as the `None` sentinel because `address_to_field` is a Poseidon output. + +**Private witnesses:** $$v\_s, r\_s, v\_r, r\_r$$. The witness is producible by anyone holding both openings -- the auditor, from the secret-escrow slot (§2.5) and the receiving-side ciphertexts (Section 8.1), or the owner from $$vk$$ -- and not by the compliance admin, which holds no blinding. That asymmetry is what keeps the seizure a two-party operation ([COMPLIANCE.md](./COMPLIANCE.md) §5.2). + +**Post-verification:** + +``` +C_spend ← C_spend + C_receive − α·G +C_receive ← O +if destination = Some(d): token.transfer(self, d, α) +emit Clawback(account, α, destination) +``` + +The update is the §7.4 merge rule with a public debit and no fresh randomness. The new opening is $$(v\_s + v\_r - \alpha, \\; r\_s + r\_r)$$; the owner recomputes it as it recomputes a merge (§5.2), the auditor from its own accumulator (Section 8.1), and the account stays spendable. Re-randomizing under an admin-chosen blinding instead would leave the owner unable to open its own commitment. + +**Anti-replay.** Both bound commitments change on every successful seize, so a proof never verifies twice. This relies on $$\alpha > 0$$: the only state-preserving case is an identity `C_receive` together with a zero `alpha`, which the contract rejects before assembling the public inputs. + +The circuit has two `multi_scalar_mul` sites (CB1, CB2); cost figures are in Section 10.3. + ---