diff --git a/packages/tokens/src/confidential/CLAUDE.md b/packages/tokens/src/confidential/CLAUDE.md index 305a056bb..3104b3b5f 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 (`clawback`, `force_revoke_spender`) | | `circuits/` | Noir workspace, compiled by `nargo`, not `cargo` | | `docs/` | The protocol specification (see below) | @@ -25,13 +25,13 @@ Balances are Pedersen commitments on Grumpkin. Every operation that opens or re- ## Canonical encoding is a security boundary -The public-input blob is a positional concatenation of 32-byte big-endian `Bn254Fr` representatives, in the order given by each circuit's table in DESIGN §7. Grumpkin points contribute two limbs (`x` then `y`). +The public-input blob is a positional concatenation of 32-byte big-endian `Bn254Fr` representatives, in the order given by each circuit's table in DESIGN §7 (COMPLIANCE §5.3 for the clawback circuit). Grumpkin points contribute two limbs (`x` then `y`). Soroban's host silently reduces values `≥ r` modulo `r` rather than rejecting them, so `x` and `x + r` deserialise to the same field element. Every caller-supplied scalar and coordinate must therefore reach `verify_proof` through `append_field` / `append_point`, which call `Grumpkin::is_canonical_field` / `is_canonical_point`. Bypassing those helpers breaks byte-uniqueness of stored state and emitted events even though proofs still verify. ## Code cites the spec by section number -Rust carries roughly sixteen `DESIGN §N` / `DESIGN_cont §N` references in doc comments. Renumbering a spec section silently invalidates them — nothing checks. Before renumbering, grep the module for the old number. +Rust carries roughly two dozen `DESIGN §N` / `DESIGN_cont §N` / `COMPLIANCE §N` references in doc comments, and `circuits/clawback/src/main.nr` cites `COMPLIANCE.md` by section too. Renumbering a spec section silently invalidates them — nothing checks. Before renumbering, grep the module for the old number. ## Tests @@ -55,7 +55,7 @@ Everything else defers by citation: `SDK.md`, `SELECTIVE_DISCLOSURE.md`, `INDEXE ### Duplicated tables that drift -Five things exist in more than one file. Changing the normative copy means grepping for every other one: +Six things exist in more than one file. Changing the normative copy means grepping for every other one: | Content | Normative source | Copies live in | |:---|:---|:---| @@ -63,7 +63,8 @@ Five things exist in more than one file. Changing the normative copy means grepp | Sponge lane assignment | `DESIGN.md` §2.5 | `SDK.md` §4.3 and §11 | | Per-circuit scalar-multiplication counts | `DESIGN_cont.md` §10.3 | `OVERVIEW.md` | | Checkpoint event set | `DESIGN.md` §5.2 | `INDEXER.md`, `SDK.md` | -| Replay-window anchor `T₀` | `DESIGN.md` §5.2 | `INDEXER.md`, `OVERVIEW.md` | +| Replay-window anchor `T₀` (`Register`, `Merge`, `Clawback`) | `DESIGN.md` §5.2 | `INDEXER.md`, `OVERVIEW.md`, `SDK.md`, `COMPLIANCE.md` §5.7 | +| ACIR opcode counts | `circuits/constraints.baseline` | `DESIGN_cont.md` §10.3, `circuits/CLAUDE.md` | The tags are a cross-language wire contract. `DESIGN_cont.md` §13 is their only authoritative source: it assigns every value, and it states which subset `circuits/lib/src/lib.nr` implements and why the remainder are absent. Changing any assigned value is a new deployment, not an upgrade. @@ -71,7 +72,7 @@ The tags are a cross-language wire contract. `DESIGN_cont.md` §13 is their only Every sentence is a maintenance liability: a claim written twice has to be fixed twice, and the second copy is the one that goes stale. Doc work here trends net-negative in lines. -- **One owning section per claim.** Every fact has exactly one home; everywhere else writes `§N`. When tempted to summarise a neighbouring section for the reader's convenience, cite it instead; +- **One owning section per claim.** Every fact has exactly one home; everywhere else writes `§N`. When tempted to summarise a neighbouring section for the reader's convenience, cite it instead. - **Say it once, then move on.** No second-register restatement, no paragraph-closing punchline, no recap of the section's own argument in its last sentence. If a paragraph's content survives deleting it, delete it. - **Gloss a symbol at its definition site only.** Re-glossing `s` or `r_e` in each section that uses them is three more places to update when a name changes. - **No pre-stating.** A `Note:` or lead-in that previews what the next paragraph spells out in full is a duplicate. Fold it into the argument. diff --git a/packages/tokens/src/confidential/circuits/CLAUDE.md b/packages/tokens/src/confidential/circuits/CLAUDE.md index 577fc5a3f..303608311 100644 --- a/packages/tokens/src/confidential/circuits/CLAUDE.md +++ b/packages/tokens/src/confidential/circuits/CLAUDE.md @@ -22,11 +22,11 @@ There is no `nargo fmt` step in CI and no formatter config — Noir formatting i ### `compiler_version = "=1.0.0"` is deliberate -It appears in all thirteen package `Nargo.toml` files — the workspace manifest `circuits/Nargo.toml` carries only `[workspace]` — and looks like a mistake. Nargo requirements cannot name prereleases, so the real toolchain — **nargo 1.0.0-beta.11 + bb 0.87.0**, pinned in `.github/workflows/noir.yml` — is documented in the comment above it instead. Do not "correct" this to `=1.0.0-beta.11`. +It appears in all fourteen package `Nargo.toml` files — the workspace manifest `circuits/Nargo.toml` carries only `[workspace]` — and looks like a mistake. Nargo requirements cannot name prereleases, so the real toolchain — **nargo 1.0.0-beta.11 + bb 0.87.0**, pinned in `.github/workflows/noir.yml` — is documented in the comment above it instead. Do not "correct" this to `=1.0.0-beta.11`. ### Do not prune unused public inputs -`_acct_f` in `register/src/main.nr` is referenced by no gate and looks like dead code. It is the replay binding: UltraHonk absorbs every public input into the transcript, so a proof produced for one account fails when the contract assembles the blob for another. Removing it lets anyone replay a legitimate registration's published proof and payload to mint duplicate-key accounts. Each operation circuit declares its exact public-input count in a header comment — withdraw 16, transfer / spender_transfer 25, set_spender 26 — and the count is part of the contract with the on-chain assembler. +`_acct_f` in `register/src/main.nr` is referenced by no gate and looks like dead code. It is the replay binding: UltraHonk absorbs every public input into the transcript, so a proof produced for one account fails when the contract assembles the blob for another. Removing it lets anyone replay a legitimate registration's published proof and payload to mint duplicate-key accounts. `clawback/src/main.nr` carries three such bindings — `addr_f`, `_acct_f`, `_dest_f` — and removing `_dest_f` lets a compromised clawback signer settle a witness to a destination of its own choosing. Each operation circuit declares its exact public-input count in a header comment — clawback 8, withdraw 16, transfer / spender_transfer 25, set_spender 26 — and the count is part of the contract with the on-chain assembler. ### Package names are load-bearing @@ -60,7 +60,7 @@ LC_ALL=C nargo info | grep '^|' | LC_ALL=C sort > constraints.baseline `LC_ALL=C` is mandatory on **both** sides of the pipe — byte order is the only ordering stable between macOS and the Ubuntu runner. The redirect overwrites the file's header comments; re-paste them, because CI's failure message asks for them. -Two non-obvious consequences: adding or removing a **gadget** changes the baseline even when no circuit logic changed, and the ACIR opcode counts are quoted in prose at `../docs/DESIGN_cont.md` §10.3 (Register 33, Withdraw 95, Transfer 134, SetSpender 135, SpenderTransfer 136). Nothing enforces that second copy — update it in the same PR. +Two non-obvious consequences: adding or removing a **gadget** changes the baseline even when no circuit logic changed, and the ACIR opcode counts are quoted in prose at `../docs/DESIGN_cont.md` §10.3 (Register 33, Clawback 51, Withdraw 95, Transfer 134, SetSpender 135, SpenderTransfer 136). Nothing enforces that second copy — update it in the same PR. ### `vks/` @@ -88,4 +88,4 @@ Note the standing obligation on the future TS SDK (`../docs/SDK.md` §6.1): its ## Version bumps -`.github/workflows/noir.yml` is the source of truth for `NARGO_VERSION` and `BB_VERSION`, and the two must be bumped together — the VK pipeline is byte-sensitive to bb. The string `1.0.0-beta.11` is duplicated across all thirteen package `Nargo.toml` files, `constraints.baseline`, `vks/README.md` (which also restates the bb version), and this file. A bump touches all of them and requires regenerating both the baseline and the VKs. The installer scripts in CI are pinned to git commits with SHA256 verification; bump URL and hash together. +`.github/workflows/noir.yml` is the source of truth for `NARGO_VERSION` and `BB_VERSION`, and the two must be bumped together — the VK pipeline is byte-sensitive to bb. The string `1.0.0-beta.11` is duplicated across all fourteen package `Nargo.toml` files, `constraints.baseline`, `vks/README.md` (which also restates the bb version), and this file. A bump touches all of them and requires regenerating both the baseline and the VKs. The installer scripts in CI are pinned to git commits with SHA256 verification; bump URL and hash together. 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..2f1f6e6b9 --- /dev/null +++ b/packages/tokens/src/confidential/circuits/clawback/src/main.nr @@ -0,0 +1,101 @@ +use stellar_confidential_lib::commit; + +mod tests; + +// Clawback circuit -- COMPLIANCE.md Section 5.3. +// +// Proves that a compliance seizure of the public amount `alpha` is bounded by +// what the target actually holds, without revealing either balance. It is the +// only circuit in the system with no key-ownership constraint and no `r_e`: +// both openings are pinned by Pedersen binding directly. +// +// Constraints +// ----------- +// CB1 C_spend = v_s * G + r_s * H Prover knows the opening of the +// target's spendable commitment. +// CB2 C_receive = v_r * G + r_r * H Prover knows the opening of the +// target's receiving commitment. +// CB3 v_s, v_r, alpha, and +// v_s + v_r - alpha in [0, 2^127) The seize bound (Section 2.6). +// The final term establishes +// alpha <= v_s + v_r. +// +// Identity encoding +// ----------------- +// The circuit MUST accept (0, 0) as the identity for either commitment, opened +// as (0, 0). `C_receive = O` whenever the target has nothing pending. +// `commit(0, 0)` returns the identity point, whose `EmbeddedCurvePoint` +// encoding has x = y = 0, so the equality checks below hold without a special +// case. +// +// Point-validation +// ---------------------------------------- +// C_spend and C_receive are the only points, and both are bound to in-circuit +// multi_scalar_mul outputs (CB1, CB2), so they are on-curve by construction. +// +// Public inputs (8 fields, in COMPLIANCE.md Section 5.3 canonical order) +// ---------------------------------------------------------------------- +// Idx Param Symbol Source / Note +// --- ----- ------ ---------------------------------------- +// 0 c_spend_x C_spend.x Loaded from the target's +// 1 c_spend_y C_spend.y `spendable_commitment`. +// 2 c_receive_x C_receive.x Loaded from the target's +// 3 c_receive_y C_receive.y `receiving_commitment`. +// 4 alpha alpha Public seize amount, from the invocation. +// The contract pre-checks alpha > 0 before +// assembling the blob; CB3 closes +// alpha < 2^127 in-circuit. +// 5 addr_f addr_f Loaded from instance storage. +// 6 _acct_f acct_f `address_to_field(account)`, computed +// per-call. +// 7 _dest_f dest_f `address_to_field(destination)` under +// `Some`, the zero field under `None`; +// computed per-call. +// +// `addr_f`, `_acct_f`, and `_dest_f` are referenced by no gate and look like +// dead parameters: their presence in the public-input set is the binding, on +// the `register/_acct_f` precedent. +// +// Private witnesses +// ----------------- +// v_s Plaintext spendable-balance value. +// r_s Plaintext blinding factor for C_spend. +// v_r Plaintext receiving-balance value. +// r_r Plaintext blinding factor for C_receive. + +fn main( + v_s: Field, + r_s: Field, + v_r: Field, + r_r: Field, + c_spend_x: pub Field, + c_spend_y: pub Field, + c_receive_x: pub Field, + c_receive_y: pub Field, + alpha: pub Field, + addr_f: pub Field, + _acct_f: pub Field, + _dest_f: pub Field, +) { + // addr_f is a binding-only input like _acct_f and _dest_f; naming it + // without a leading underscore would warn, so consume it explicitly. + let _ = addr_f; + + // CB1 + let c_spend_derived = commit(v_s, r_s); + assert(c_spend_derived.x == c_spend_x); + assert(c_spend_derived.y == c_spend_y); + + // CB2 + let c_receive_derived = commit(v_r, r_r); + assert(c_receive_derived.x == c_receive_x); + assert(c_receive_derived.y == c_receive_y); + + // CB3 -- Section 2.6's 127-bit decomposition / recomposition pattern. + // Range on v_s and v_r alone does not bound v_s + v_r - alpha. + v_s.assert_max_bit_size::<127>(); + v_r.assert_max_bit_size::<127>(); + alpha.assert_max_bit_size::<127>(); + let v_new = v_s + v_r - alpha; + v_new.assert_max_bit_size::<127>(); +} 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/tests.nr b/packages/tokens/src/confidential/circuits/lib/src/tests.nr index b19f40491..41592bed3 100644 --- a/packages/tokens/src/confidential/circuits/lib/src/tests.nr +++ b/packages/tokens/src/confidential/circuits/lib/src/tests.nr @@ -238,8 +238,8 @@ fn sponge_squeeze_3_agrees_with_squeeze_2_on_shared_lanes() { // The widening from two lanes to three must be observationally identical // on lane[0] and lane[1] for every domain tag: both squeezes read the same // post-permutation state, and the absorb occupies a single rate-3 block. - // This is what lets `delta_aud_s` be widened everywhere it appears - // without renumbering a single existing constraint. + // It lets `delta_aud_s` be widened everywhere it appears without + // renumbering a single existing constraint. let s: Field = 0x12345; let sigma: Field = 0x6789; for d in [domain::AUDITOR_SENDER, domain::AUDITOR_RECIPIENT] { @@ -270,8 +270,8 @@ fn esc_allow_r_auditor_is_domain_separated_from_the_spender_escrow() { // additive pads over the same shape, so a shared tag would let a // coincidence of inputs collapse one onto the other. Reusing one tag for // both would be secure in practice, since the shared scalars differ (Y_op - // and K_aud_s are independent points), but the separation is what keeps - // Section 5.3's r_e-reuse argument whole, so it must be observable here. + // and K_aud_s are independent points), but the separation keeps Section + // 5.3's r_e-reuse argument whole, so it must be observable here. let x: Field = 0xd0d0; let s: Field = 0x12345; let op_i: Field = 0xabcd; 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/tests.nr b/packages/tokens/src/confidential/circuits/set_spender/src/tests.nr index 2596c0919..3e10e4190 100644 --- a/packages/tokens/src/confidential/circuits/set_spender/src/tests.nr +++ b/packages/tokens/src/confidential/circuits/set_spender/src/tests.nr @@ -1477,7 +1477,7 @@ fn rejects_escrow_of_blinding_for_the_wrong_sigma_a() { // correctly tagged, derived from the right dvk_i -- while every other // public stays canonical at SIGMA_A. S14 fires. // - // This is what makes the escrow useful: the auditor opens the on-chain C_a + // That pinning makes the escrow useful: the auditor opens the on-chain C_a // with what it decrypts. An escrow that floated free of sigma_a would let // a prover hand the auditor an opening of some other allowance state, and // the mismatch would only surface when the auditor checked the commitment. 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/compliance/mod.rs b/packages/tokens/src/confidential/compliance/mod.rs index 172b4516f..1b78b64cc 100644 --- a/packages/tokens/src/confidential/compliance/mod.rs +++ b/packages/tokens/src/confidential/compliance/mod.rs @@ -12,9 +12,11 @@ //! 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. Omit its impl block +//! to ship 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 +27,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 +167,124 @@ pub trait ConfidentialCompliance: ConfidentialToken { } } +// ################## CLAWBACK TRAIT ################## + +/// Opt-in seizure interface. A deployment that wants freeze and policy gating +/// but no seizure capability omits this impl block. +/// +/// 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, whose freeze gate would reject exactly the accounts these +/// methods 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 admin's +/// only signal is an `InvalidProof` once the commitment has moved. +/// +/// Wiring [`ComplianceHooks`], or a custom [`Hooks`] impl that gates the token +/// positions (`on_withdraw`, `on_transfer`, etc) , 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. 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 holds `C_spend` and `C_receive` + /// still between proof construction and submission, which 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`]. 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. It brings escrowed value into the reach of + /// [`ConfidentialClawback::clawback`]. + /// + /// Identical fold to the owner's + /// [`revoke_spender`](crate::confidential::ConfidentialToken::revoke_spender); + /// only the authorization gate differs. + /// + /// `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`]. The trait cannot provide a default + /// body — see [`ConfidentialCompliance`]'s trait-level docstring for the + /// rationale. + fn force_revoke_spender(e: &Env, account: Address, spender: Address, operator: Address); +} + // ################## HOOKS IMPL ################## /// [`Hooks`] implementation that gates every token callback against @@ -314,6 +436,12 @@ pub enum ComplianceError { /// for the target account (only reachable when `sac_passthrough` is /// enabled). NotAuthorizedBySac = 3603, + /// Indicates the clawback target is not frozen. + AccountNotFrozen = 3604, + /// Indicates the seize amount is not strictly positive. + InvalidClawbackAmount = 3605, + /// Indicates `destination` is `Some` naming this contract's own address. + InvalidClawbackDestination = 3606, } // ################## CONSTANTS ################## @@ -350,6 +478,23 @@ 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, 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..98a176951 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,16 @@ pub struct ComplianceConfig { pub sac_passthrough: bool, } +/// Envelope decoded from the `data: Bytes` argument of +/// [`crate::confidential::compliance::ConfidentialClawback::clawback`]. +/// Carries the proof alone: the clawback circuit has no prover-supplied +/// public inputs, so there is no payload to accompany it. +#[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 +186,159 @@ 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 what the contract cannot check against committed +/// balances: that the prover knows the Pedersen openings of `C_spend` and +/// `C_receive` (CB1, CB2), and that `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. +/// +/// 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. The new opening is +/// `(v_s + v_r - amount, r_s + r_r)`, which both the owner and the auditor +/// recompute, so the seized account stays spendable. +/// +/// # 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 +/// +/// Under `None` no underlying moves: the pool is left over-collateralized by +/// `amount`, extractable only by the underlying's own issuer through a SAC +/// `clawback` against this contract's address. That extraction must follow +/// this call. +/// +/// Under `Some(d)`, exactly `amount` is transferred to `d` in this invocation, +/// so the pool and the sum of claims move together. +/// +/// # Security Warning +/// +/// **IMPORTANT**: This function bypasses authorization checks. The trait entry +/// point [`crate::confidential::compliance::ConfidentialClawback::clawback`] +/// is responsible for authorizing `operator`. +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); + } + 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); + // Zero is an unambiguous `None` sentinel: `address_to_field` is a + // Poseidon2 output. + let dest_f = match destination { + Some(d) => address_to_field(e, d), + None => BytesN::from_array(e, &[0u8; 32]), + }; + + // PI order (COMPLIANCE §5.3): + // 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 binds the proof to one contract, one + // account, and one settlement destination, on the `register` precedent. + 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); + + 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 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`] +/// is responsible for authorizing `operator`. +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 ada2866bf..c5185e431 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; @@ -821,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 5d225b8e7..d4d101d8a 100644 --- a/packages/tokens/src/confidential/docs/COMPLIANCE.md +++ b/packages/tokens/src/confidential/docs/COMPLIANCE.md @@ -46,8 +46,8 @@ Three functions are added to the core contract interface: ```rust impl Token { - fn freeze(e: Env, account: Address, admin: Address); // admin auth - fn unfreeze(e: Env, account: Address, admin: Address); // admin auth + fn freeze(e: Env, account: Address, operator: Address); // admin auth + fn unfreeze(e: Env, account: Address, operator: Address); // admin auth fn is_frozen(e: Env, account: Address) -> bool; } ``` @@ -171,65 +171,89 @@ 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 frozen confidential account: reducing its committed claim by a public amount, bounded by what the account holds, and settling the corresponding underlying over a transparent path. The flow is coordinated rather than unilateral, and it presupposes a freeze (§2), which keeps the target's commitments from changing between proof construction and submission (§5.6). -- **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*). - -**Terminology.** This flow is called *clawback* because it mirrors the clawback semantics of Stellar Classic / SAC assets, but it is a distinct mechanism. +**Terminology.** This flow is called *clawback* because it mirrors the clawback semantics of Stellar Classic / SAC assets, but it is a distinct mechanism. It is delivered by the opt-in `ConfidentialClawback` trait, whose two entry points are `clawback` and `force_revoke_spender` (§6); a deployment that omits that impl block ships freeze and policy gating with no seizure capability. ### 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)` drains the pool as a whole and debits every holder. Seizing from one account therefore has to happen at the confidential layer: reduce that account's committed claim, then reconcile the pool. + +The contract does not know the targeted account's balance. `C_spend` and `C_receive` are Pedersen commitments whose openings are private, so the seize amount must be validated against the committed values without exposing them on-chain and without trusting the admin's word for them. + +### 5.2 Roles and Separation + +- **Token admin** — the access-control authority on the confidential-token contract (§1.1). Authorizes the freeze and the two seizure entry points; decides *whether* to seize. +- **Witness holder** — whoever holds the Pedersen openings of the target's `C_spend` and `C_receive`. Produces the proof and thereby decides *how much* and *where to*, both being bound into it (§5.3). +- **Issuer (SAC admin)** — when the base asset is a Stellar Asset Contract, the holder of its standardized admin interface (CAP-0046-06). Extracts the pool surplus a `None` settlement leaves behind (§5.4) and can freeze independently of the token admin via SAC passthrough (§2.2). + +In practice the witness comes from the **auditor**; the admin holds no blinding and cannot produce it. The **owner** can derive both openings from `vk` (`DESIGN.md` §5.2), but is the party being seized from and is not expected to cooperate. The auditor holds both openings by the standing capability of `DESIGN_cont.md` §8.1, advanced across `Clawback` per §5.7. One key serves both channels for an account (`DESIGN.md` §6.1), so a deployment that intends to use clawback need only ensure that key's custodian can assemble both halves. + +Neither party can act alone: the admin cannot produce the proof, and the witness holder cannot pass the admin gate. Deployments typically place the seizure authority under a dedicated role, separate from the freeze role (§1.1). -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. +### 5.3 Circuit -### 5.2 Admin + Auditor Coordination +The clawback circuit proves that a public seize amount is bounded by the target's committed total without revealing either balance. It is the only circuit with no key-ownership constraint and no ephemeral scalar: both openings are pinned by Pedersen binding. -Three roles bear on this flow: +**Circuit constraints:** -- **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. +| # | Constraint | +|:--|:---| +| CB1 | `C_spend = Com(v_s, r_s)` (prover knows the spendable opening) | +| CB2 | `C_receive = Com(v_r, r_r)` (prover knows the receiving opening) | +| CB3 | `v_s, v_r, alpha, v_s + v_r - alpha ∈ [0, 2^127)` (range validity, `DESIGN.md` §2.6) | -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 checkpoint event, recovering $$v\_s$$, and folds in every proofless operation the account performed since (§5.3 constraint 2). 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. +Range on `v_s` and `v_r` alone does not bound their sum against `alpha`, and an over-seize would drive the committed value negative mod `r` — a commitment the owner can still open but never again satisfy under W4 / T4. -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. +**Public inputs (8 fields):** -**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. +| Input | Notes | +|:---|:---| +| `C_spend`, `C_receive` | Loaded from the target's `spendable_commitment` and `receiving_commitment`, in this order | +| `alpha` | Public seize amount from invocation inputs | +| `addr_f` | Loaded from instance storage (`DESIGN.md` §2.7) | +| `acct_f` | Binds the proof to the target account | +| `dest_f` | `address_to_field(destination)` under `Some`, the zero field under `None` | + +No public input is prover-supplied (`DESIGN.md` §7.1). `addr_f`, `acct_f`, and `dest_f` are referenced by no gate; their membership in the public-input set is the binding, on the `register` / `acct_f` precedent (`DESIGN.md` §7.2). For `dest_f` that binding is what stops a compromised clawback signer from settling a witness built for one destination to an address of its own choosing; its zero sentinel for `None` is unambiguous because `address_to_field` is a Poseidon2 output. -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. +**Private witnesses:** `v_s`, `r_s`, `v_r`, `r_r` — the openings of the two commitments. -**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. +### 5.4 Contract Flow -### 5.3 New Circuit +`clawback(account, amount, destination, data, operator)` runs, after the deployment's access-control check on `operator`: -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. +1. **Preconditions.** `is_frozen(account)`, else `AccountNotFrozen`; `amount > 0`, else `InvalidClawbackAmount`; `destination` is not `Some` naming this contract, else `InvalidClawbackDestination` — such a seize is a `None` in effect while reporting a settlement, which breaks per-branch pool reconciliation for any indexer. +2. **Public inputs.** Assembled in the §5.3 order through `append_point` / `append_field` / `append_amount`, so every coordinate and scalar passes the canonicality guards. `data` decodes to `ClawbackData { proof }`. +3. **Verification** against `CircuitType::Clawback`. +4. **State update.** `C_spend <- C_spend + C_receive - alpha·G` and `C_receive <- O`: the `Merge` rule (`DESIGN.md` §7.4) followed by a public debit, with no fresh randomness. The new opening is `(v_s + v_r - alpha, r_s + r_r)`, which the owner and the auditor recompute from the event's `amount` alone, so the seized account stays spendable. Re-randomizing under a prover-chosen blinding instead would leave the owner unable to open its own commitment. +5. **Settlement**, by `destination`: + - `None` — no underlying moves. + - `Some(d)` — exactly `amount` is transferred to `d` in the same invocation. +6. **Event.** `Clawback { account, amount, destination }` (§6.1). -**Public inputs.** $$C\_{\text{spend}}, C\_{\text{receive}}, K\_{\text{aud,s}}, \tilde{b}\_{\text{aud,s}}^{\text{old}}, \tilde{r}\_{\text{aud,s}}^{\text{old}}, R\_e^{\text{old}}, \sigma^{\text{old}}, \alpha, C\_{\text{spend}}^{\text{new}}, \tilde{b}\_{\text{aud,s}}^{\text{new}}, \tilde{r}\_{\text{aud,s}}^{\text{new}}, R\_e^{\text{new}}, \sigma^{\text{new}}, addr\_f$$. +**Extraction order.** The seize and the issuer's extraction are separate invocations by different parties, and Soroban admits one per transaction, so the pool sits mismatched for at least a ledger between them. Nothing in the contract enforces which comes first; under a `None` settlement the extraction must follow the seize: -**Private witnesses.** $$k\_{\text{aud,s}}, v\_s, r\_s, v\_r, r\_r, r\_s', 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). +- **Seize, then extract.** Between the two the pool holds `amount` more than the claims against it. No holder is affected, and the extraction returns it to exact collateralization. +- **Extract, then seize.** Between the two the pool holds `amount` less than the claims against it, so it cannot honour them all: the shortfall lands on whichever holders withdraw last, none of whom is the target. It becomes permanent if the seize then turns out to be unbuildable — no witness holder produces the proof (§5.2). -**Constraints (sketch).** +The confidential debit strictly precedes the SEP-41 transfer, matching `withdraw`. Clawback is the one operation that reduces the sum of claims without a `Withdraw` (`DESIGN_cont.md` §9.3), and it invokes no `Hooks` callback: the freeze gate would reject exactly the account it acts on (`DESIGN_cont.md` §11). -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}}, m\_{r,s}^{\text{old}}) = \text{SpongeSqueeze}\_3(\delta\_{\text{aud\\\_s}}, \text{ECDH}(k\_{\text{aud,s}}, R\_e^{\text{old}}), \sigma^{\text{old}})$$, then $$v\_s = \tilde{b}\_{\text{aud,s}}^{\text{old}} - m\_{b,s}^{\text{old}}$$ and $$r\_s = \tilde{r}\_{\text{aud,s}}^{\text{old}} - m\_{r,s}^{\text{old}}$$, and $$C\_{\text{spend}} = v\_s \cdot G + r\_s \cdot H$$. The `lane[2]` escrow (`DESIGN.md` W\_a5 / T\_a9 / S\_a6) is what makes this a closed derivation: the circuit recovers the blinding from the auditor channel itself. The old checkpoint referenced here is the account's most recent `lane[2]` escrow, which the proofless operations do not produce — neither `merge` (`DESIGN.md` §7.4) nor `revoke_spender` (`DESIGN.md` §7.9) publishes one. Anchoring on an earlier checkpoint requires folding: $$C\_{\text{spend}}$$ is a public input the contract loads from live storage, so the equality above holds only once every fold performed since that checkpoint has been added into $$(v\_s, r\_s)$$. +### 5.5 Forced Revocation - **Precondition.** The seize-enabling auditor MUST hold the opening of every addend folded since the checkpoint it anchors on — `DESIGN_cont.md` §8.3 states when a key does. An auditor that does not, having rotated in after an addend's event or missed it from its archive, cannot assemble this witness. Nor can the target be made to re-anchor it: every operation that issues a fresh checkpoint is gated on the account being unfrozen, and §5.2's flow requires the freeze. A deployment that intends to use clawback therefore owes the same archive completeness §8.5 *Archive dependence* already requires of the auditor. +Escrowed allowances are invisible to `clawback`, which sees only `C_spend` and `C_receive`. `force_revoke_spender(account, spender, operator)` moves them into reach: with `account` frozen (`AccountNotFrozen` otherwise), it performs the owner's `revoke_spender` fold (`DESIGN.md` §7.9) under the admin gate in place of the owner's authorization and emits the same `RevokeSpender` event, carrying `a_tilde` and `allowance_salt`. No proof is involved, and expired delegations are revocable: expiry blocks spending but not reclamation. Like `clawback`, it invokes no `Hooks` callback. -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, with $$(m\_{v,s}^{\text{new}}, m\_{b,s}^{\text{new}}, m\_{r,s}^{\text{new}}) = \text{SpongeSqueeze}\_3(\delta\_{\text{aud\\\_s}}, \text{ECDH}(k\_{\text{aud,s}}, R\_e^{\text{new}}), \sigma^{\text{new}})$$, both $$\tilde{b}\_{\text{aud,s}}^{\text{new}} = (v\_s + v\_r - \alpha) + m\_{b,s}^{\text{new}}$$ and $$\tilde{r}\_{\text{aud,s}}^{\text{new}} = r\_s' + m\_{r,s}^{\text{new}}$$. -5. **New spendable commitment.** $$C\_{\text{spend}}^{\text{new}} = (v\_s + v\_r - \alpha) \cdot G + r\_s' \cdot H$$. The commitment is a public input, since $$v\_s$$, $$v\_r$$, and $$r\_s'$$ are all private witnesses. +The owner and the auditor fold the event as they would an owner-initiated one (`DESIGN.md` §5.2; `DESIGN_cont.md` §8.5). For the auditor the archive dependence of `DESIGN_cont.md` §8.5 applies unchanged: `r_a` is not derivable from `a_tilde` and `σ_a`, so its standing opening of `C_spend` survives the fold only if it observed the delegation's last `SetSpender` / `SpenderTransfer` event. -**Post-verification.** The contract stores the prover-supplied $$C\_{\text{spend}}^{\text{new}}$$, 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}}, \tilde{r}\_{\text{aud,s}}^{\text{new}}, R\_e^{\text{new}}, \sigma^{\text{new}})$$ so the auditor sees the new checkpoint and its blinding. +### 5.6 Anti-Replay and the Freeze -**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. +`C_spend` and `C_receive` are public inputs, so any change to either between proof construction and submission — an inbound transfer, a merge, a revoke — fails verification with `InvalidProof`. The freeze holds the commitments still. `ConfidentialClawback: ConfidentialCompliance` forces a `freeze` / `unfreeze` implementation but constrains nothing about the deployment's `Hooks`. Wiring `NoHooks` next to a `ConfidentialClawback` impl yields a `freeze` that writes the flag and an `is_frozen` that returns `true` while every token operation stays ungated, so the target spends out before the seizure lands and the admin's only signal is an `InvalidProof` once the commitments have moved. A deployment that enables clawback MUST wire `ComplianceHooks`, or a custom `Hooks` impl that gates the same seven positions (§4). -**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). +### 5.7 Wallet and Auditor Consequences -Detailed encoding and the two-phase isolate-then-settle entry-point sequencing are deferred to a follow-up revision of this document. +`Clawback` is a receiving-side reset and a `T_0` anchor, alongside `Merge` (`DESIGN.md` §5.2 *Recovery*; `INDEXER.md` §2). It is not a checkpoint: it carries no `b_tilde`, so its effect on the spendable side is absorbed into the next owner-initiated proof operation. The owner applies the `Merge` row of `DESIGN.md` §5.2 followed by `W_spend.v -= amount`; the auditor advances its standing opening identically (`DESIGN_cont.md` §8.1 *Sender-auditor opening capability*). A `RevokeSpender` emitted by `force_revoke_spender` is indistinguishable from an owner-initiated one for both consumers. --- @@ -242,24 +266,41 @@ impl Token { auditor: Address, compliance: Option); // Freeze (§2) - fn freeze(e: Env, account: Address, admin: Address); - fn unfreeze(e: Env, account: Address, admin: Address); + fn freeze(e: Env, account: Address, operator: Address); + fn unfreeze(e: Env, account: Address, operator: Address); fn is_frozen(e: Env, account: Address) -> bool; // Config rotation (admin auth per §1.1, reverts when compliance.is_none()) // Replaces the entire ComplianceConfig in one call. - fn set_compliance_config(e: Env, config: ComplianceConfig, admin: Address); + fn set_compliance_config(e: Env, config: ComplianceConfig, operator: Address); // Reads fn compliance_config(e: Env) -> Option; + + // Clawback (§5), opt-in via ConfidentialClawback + fn clawback(e: Env, account: Address, amount: i128, destination: Option
, + data: Bytes /* XDR ClawbackData { proof } */, operator: Address); + fn force_revoke_spender(e: Env, account: Address, spender: Address, operator: Address); } ``` +`operator` is the address whose authorization the deployment's access-control module checks (§1.1). + ### 6.1 Events | Event | Fields | |:---|:---| | `Frozen`, `Unfrozen` | `account` | | `ComplianceConfigChanged` | `policy`, `sac_passthrough` | +| `Clawback` | `account` (topic), `amount`, `destination` | +| `RevokeSpender` | as `DESIGN_cont.md` §11.2, when emitted by `force_revoke_spender` | + +### 6.2 Errors + +The seizure entry points add three variants to `ComplianceError`: -Clawback-related events are specified alongside the clawback flow in the follow-up revision. +| Error | Code | Raised when | +|:---|:--|:---| +| `AccountNotFrozen` | 3604 | the target of `clawback` or `force_revoke_spender` is not frozen | +| `InvalidClawbackAmount` | 3605 | `amount <= 0` | +| `InvalidClawbackDestination` | 3606 | `destination` is `Some` naming the contract's own address | diff --git a/packages/tokens/src/confidential/docs/DESIGN.md b/packages/tokens/src/confidential/docs/DESIGN.md index 9a2d669ac..0a2175d7b 100644 --- a/packages/tokens/src/confidential/docs/DESIGN.md +++ b/packages/tokens/src/confidential/docs/DESIGN.md @@ -361,20 +361,21 @@ $$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 | Proofless fold. $$W\_{\text{spend}} \mathrel{+}= (v\_a, r\_a)$$, the escrow opening recovered from the event per §7.9 | +| Clawback (compliance extension) | The Merge row, then the event's public `amount` is subtracted from the spendable value ([COMPLIANCE.md](./COMPLIANCE.md) §5.4) | -The Merge and Revoke spender rows use exact integer addition; $$W\_{\text{spend}}.r$$ is not reduced modulo $$r$$ or $$q$$ as folds 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 fold openings the wallet already holds, leaving $$W\_{\text{spend}}.r$$ a sum; [DESIGN_cont.md](./DESIGN_cont.md) §10.4 *Post-merge witness availability* covers the case where no circuit can witness it. -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)$$ *as of that boundary* is recoverable via a single event lookup, with no replay of the spend history preceding it. $$W\_{\text{receive}}$$ has no such anchor: a checkpoint leaves $$C\_{\text{receive}}$$ untouched, which only `Merge` (§7.4) resets, so recovering it requires a replay (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)$$ *as of that boundary* is recoverable via a single event lookup, with no replay of the spend history preceding it. $$W\_{\text{receive}}$$ has no such anchor: a checkpoint leaves $$C\_{\text{receive}}$$ untouched, which only `Merge` (§7.4) and `Clawback` reset, so recovering it requires a replay (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 account's last **checkpoint** for the spendable side (steps 1-4), and $$T\_0$$ -- its most recent `Merge` at or before that checkpoint -- for the receiving side (step 5). Since $$W\_{\text{receive}}$$ restarts at $$(0, 0)$$ as of $$T\_0$$, one replay window $$(T\_0, \text{now}]$$ serves both: it rebuilds the receiving side and carries the post-checkpoint proofless folds onto the spendable one (step 6). [DESIGN_cont.md](./DESIGN_cont.md) §9.5 *Why the two anchors compose* gives the argument that they do: +**Recovery.** If the wallet loses local state, it recovers from **two anchors**: the account's last **checkpoint** for the spendable side (steps 1-4), and $$T\_0$$ -- its most recent `Merge` or `Clawback` at or before that checkpoint -- for the receiving side (step 5). Since $$W\_{\text{receive}}$$ restarts at $$(0, 0)$$ as of $$T\_0$$, one replay window $$(T\_0, \text{now}]$$ serves both: it rebuilds the receiving side and carries the post-checkpoint proofless folds onto the spendable one (step 6). [DESIGN_cont.md](./DESIGN_cont.md) §9.5 *Why the two anchors compose* gives the argument that they do: -1. Fetch $$(\tilde{b}, \sigma)$$ from the most recent **checkpoint event** for this account: exactly one of `Withdraw`, `Transfer` (where the account is the `from`), or `SetSpender` -- the three event types carrying a proof-bound $$(\tilde{b}, \sigma)$$ for this account's spendable balance. `Merge` and `RevokeSpender` do change $$C\_{\text{spend}}$$ but are not checkpoints: carrying no proof, they cannot bind a $$\tilde{b}$$ to the resulting commitment (§7.4 *Encrypted balance*, §7.9 *Encrypted balance*), and their effect is absorbed into the next checkpoint. **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: exactly one of `Withdraw`, `Transfer` (where the account is the `from`), or `SetSpender` -- the three event types carrying a proof-bound $$(\tilde{b}, \sigma)$$ for this account's spendable balance. `Merge`, `RevokeSpender`, and `Clawback` do change $$C\_{\text{spend}}$$ but are not checkpoints: none binds a $$\tilde{b}$$ to the resulting commitment (§7.4 *Encrypted balance*, §7.9 *Encrypted balance*, [COMPLIANCE.md](./COMPLIANCE.md) §5.7), and their effect is absorbed into the next checkpoint. **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 no such `Merge` 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, applying the *Update rules* above with three replay-specific amendments: - **Checkpoint event**: skip the spendable side -- step 1 already captured it. - **Revoke spender**: skip if at or before the checkpoint of step 1, which absorbed it; otherwise fold as the table specifies. @@ -452,12 +453,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 and, in a deployment that enables the optional compliance extension, by its clawback ([COMPLIANCE.md](./COMPLIANCE.md) §5, outline only). Encoded as a single Grumpkin affine point (64 bytes). +The commitment the owner can spend from. Modified by owner-authorized operations and, in a deployment that enables the optional compliance extension, by its clawback ([COMPLIANCE.md](./COMPLIANCE.md) §5.4). 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 ([COMPLIANCE.md](./COMPLIANCE.md) §5.4). Encoded as a single Grumpkin affine point (64 bytes). **`auditor_id`** diff --git a/packages/tokens/src/confidential/docs/DESIGN_cont.md b/packages/tokens/src/confidential/docs/DESIGN_cont.md index ec670114d..35d8a044a 100644 --- a/packages/tokens/src/confidential/docs/DESIGN_cont.md +++ b/packages/tokens/src/confidential/docs/DESIGN_cont.md @@ -49,7 +49,7 @@ The capability is bounded in three ways: - **Forward-only.** Only events emitted while the auditor key was active are decryptable. - **Recipient-channel only.** The reconstruction above yields an opening of `receiving_commitment`: the spend-side blinding $$r\_s = \text{Poseidon}(\delta\_{\text{spend\\\_r}}, vk\_A, \sigma)$$ depends on $$vk\_A$$ and is not derivable from the recipient channel. -- **Reset by merge.** Merge folds $$r\_r$$ into the spendable-balance randomness ($$r\_{\text{spend}}' = r\_s + r\_r$$, Section 7.4) and emits no checkpoint, so the reconstruction above restarts from the next inbound flow. +- **Reset by merge.** Merge folds $$r\_r$$ into the spendable-balance randomness ($$r\_{\text{spend}}' = r\_s + r\_r$$, Section 7.4) and emits no checkpoint, so the reconstruction above restarts from the next inbound flow. A clawback resets it the same way ([COMPLIANCE.md](./COMPLIANCE.md) §5.7). **Sender-auditor opening capability.** The `lane[2]` escrow hands the sender-auditor the *blinding* of the account's post-operation spendable balance directly, without $$vk\_A$$: together with the value in $$\tilde{b}\_{\text{aud,s}}$$ it is a full Pedersen opening of $$C\_{\text{spend}}'$$. It is available at the three checkpoint operations that escrow the spendable blinding -- withdrawal (W\_a5), outgoing transfer (T\_a9), and `set_spender` (S\_a6) -- and is likewise bounded: @@ -58,7 +58,7 @@ The capability is bounded in three ways: - **Carried through `revoke_spender`.** The revoke is a public homomorphic fold, $$C\_{\text{spend}}' = C\_{\text{spend}} + C\_a$$, emitting no new checkpoint (Section 7.9). An auditor that decrypted the delegation's last state-changing event holds the opening of the $$C\_a$$ being folded (Section 8.5) and advances its own opening by the same addition the contract performs. - **Rotation-scoped.** Both folds above depend on the auditor having decrypted the event that produced the addend; Section 8.3 *Decryption capability across rotation* states what a rotated key can and cannot open. -These bounded openings are what the clawback flow specified in [COMPLIANCE.md](./COMPLIANCE.md) §5 draws its witness from. +The clawback flow specified in [COMPLIANCE.md](./COMPLIANCE.md) §5 draws its witness from these bounded openings. ### 8.2 Auditor Visibility Properties @@ -165,6 +165,7 @@ This invariant is maintained by: - **Set spender** moves value from $$v\_{\text{spend}}$$ to $$v\_{\text{allowance}\_i}$$; enforced by S3–S7. - **Spender transfer** decreases $$v\_{\text{allowance}\_i}$$ and increases recipient's $$v\_{\text{receive}}$$ by $$v\_{\text{transfer}}$$; enforced by O2–O8. - **Revoke** moves remaining $$v\_{\text{allowance}\_i}$$ back to $$v\_{\text{spend}}$$ (Proposition 1); the sum is unchanged. +- **Clawback** (compliance extension) removes a public amount from $$v\_{\text{spend}} + v\_{\text{receive}}$$ without a withdrawal, enforced by CB1–CB3; in a deployment that enables it, seized amounts count alongside $$w\_k$$ on the left-hand side ([COMPLIANCE.md](./COMPLIANCE.md) §5.4). ### 9.4 Privacy Properties @@ -186,9 +187,9 @@ As with $$vk$$ above, the openings are self-verifying artifacts a holder can han The recovery procedure, the definition of the **checkpoint** it is built around, the replay window it runs over, and the durable-archive requirement it depends on are all specified in [DESIGN.md](./DESIGN.md) §5.2 *Recovery*. This section states the security properties that follow from it. -**Why the two anchors compose.** No `Merge` falls strictly between $$T\_0$$ and the checkpoint, by construction of $$T\_0$$ as the last merge at or before it. Within $$(T\_0, \text{checkpoint}]$$ the only spendable-affecting events are therefore checkpoint events and `RevokeSpender` folds, and the latest checkpoint's $$(\tilde{b}, \sigma)$$ supersedes both — which is why the replay skips them ([DESIGN.md](./DESIGN.md) §5.2 *Recovery*, step 6). Any `Merge` the replay encounters necessarily sits after the checkpoint and is folded normally, against the receiving opening it actually consumed. +**Why the two anchors compose.** No `Merge` or `Clawback` falls strictly between $$T\_0$$ and the checkpoint, by construction of $$T\_0$$ as the last of either at or before it. Within $$(T\_0, \text{checkpoint}]$$ the only spendable-affecting events are therefore checkpoint events and `RevokeSpender` folds, and the latest checkpoint's $$(\tilde{b}, \sigma)$$ supersedes both — which is why the replay skips them ([DESIGN.md](./DESIGN.md) §5.2 *Recovery*, step 6). Any `Merge` the replay encounters necessarily sits after the checkpoint and is folded normally, against the receiving opening it actually consumed. -**Recoverability.** An owner who holds $$vk$$ reconstructs both openings deterministically from the account's event history, so the loss of local wallet state is not a loss of funds. The single-lookup shortcut on the spendable side is sound rather than merely convenient: each checkpoint's $$\tilde{b}$$ is bound to the $$C\_{\text{spend}}$$ it certifies by the emitting operation's own proof (W7, T12, S11), so a checkpoint cannot misreport the balance the wallet restarts from. Beyond the event history and the on-chain commitments, recovery requires nothing from the auditor, from counterparties, or from the contract admin. +**Recoverability.** An owner who holds $$vk$$ reconstructs both openings deterministically from the account's event history, so the loss of local wallet state is not a loss of funds. The single-lookup shortcut on the spendable side is sound: each checkpoint's $$\tilde{b}$$ is bound to the $$C\_{\text{spend}}$$ it certifies by the emitting operation's own proof (W7, T12, S11), so a checkpoint cannot misreport the balance the wallet restarts from. Beyond the event history and the on-chain commitments, recovery requires nothing from the auditor, from counterparties, or from the contract admin. **Integrity fails closed.** The event archive is trusted for availability, not for integrity. Recovery terminates in a consistency check against the on-chain commitments ([DESIGN.md](./DESIGN.md) §5.2 *Recovery*, step 7) and Pedersen commitments are binding (Section 2.3), so a tampered or truncated history cannot yield an opening that verifies against a balance the account does not hold — it yields a detectable mismatch. The residual risk is therefore denial of recovery, a liveness failure, rather than silent corruption of the reconstructed balance. [INDEXER.md](./INDEXER.md) §7 specifies the archive's trust model and the client-side checks that follow from it. @@ -214,7 +215,7 @@ Because $$\sigma$$ is sampled fresh via CSPRNG for every operation, a retry afte ### 10.1 Circuits -All proof logic is written in Noir, compiled to UltraHonk circuits. The system uses 5 circuits: +All proof logic is written in Noir, compiled to UltraHonk circuits. The system uses six circuits, the last belonging to the optional compliance extension: ```rust #[contracttype] @@ -225,10 +226,11 @@ pub enum CircuitType { Transfer = 2, SpenderTransfer = 3, SetSpender = 4, + Clawback = 6, } ``` -`deposit`, `merge`, and `revoke_spender` carry no proof and have no circuit (Sections 7.3, 7.4, 7.9). The discriminants are part of the on-chain interface and are never renumbered. +`deposit`, `merge`, and `revoke_spender` carry no proof and have no circuit (Sections 7.3, 7.4, 7.9). The discriminants are part of the on-chain interface and are never renumbered; 5 is retired — it was `RevokeSpender` before revocation became proofless — and is never reassigned. ### 10.2 Circuit Summary @@ -239,6 +241,7 @@ pub enum CircuitType { | `Transfer` | Balance conservation; ECDH-derived blinding and encrypted amount for recipient; dual-auditor channel sponges (recipient auditor: amount + per-transfer Pedersen randomness; sender auditor: amount + balance + `lane[2]` escrow of the new spendable blinding); deterministic randomness for new sender balance; encrypted balance scalar; sender key ownership; range validity (balance $$\in [0, 2^{127})$$, amount $$\in [0, 2^{127})$$) | | `SpenderTransfer` | Allowance sufficiency; ECDH-derived blinding and encrypted amount for recipient; dual-auditor channel sponges (recipient auditor: amount + per-transfer Pedersen randomness; owner auditor: amount + allowance + `lane[2]` escrow of the new allowance blinding); deterministic randomness for new allowance; encrypted allowance scalar; spender key ownership; contract-bound indirectly via $$C\_a$$ chain (Section 7.8) | | `SetSpender` | Balance split; $$dvk\_i$$ derivation; ECDH escrow of $$dvk\_i$$ to the spender and of the allowance blinding to the owner's auditor; allowance commitment with deterministic randomness; encrypted balance and allowance scalars; owner-auditor ECDH ciphertexts (escrow amount + balance checkpoint + `lane[2]` escrow of the new spendable blinding); owner key ownership; contract-bound via $$vk$$ derivation | +| `Clawback` | Knowledge of the openings of the target's $$C\_{\text{spend}}$$ and $$C\_{\text{receive}}$$; public seize amount bounded by their sum, remainder in range; no key ownership, no ephemeral ([COMPLIANCE.md](./COMPLIANCE.md) §5.3) | ### 10.3 Circuit Cost Analysis @@ -253,8 +256,9 @@ The dominant cost in Noir circuits is elliptic curve scalar multiplication. With | `Transfer` | 8 | $$Y\_A$$ (T1), $$C\_{\text{spend}}^A$$ opening (T3), recipient ECDH (T5), $$R\_e$$ (T6), $$C\_{\text{transfer}}$$ (T8), $$C\_{\text{spend}}'$$ (T11), recipient-auditor ECDH (T\_a1), sender-auditor ECDH (T\_a5) | | `SpenderTransfer` | 8 | $$Y\_{\text{op}}$$ (O1), $$C\_a$$ opening (O2), recipient ECDH (O5), $$R\_e$$ (O6), $$C\_{\text{transfer}}$$ (O8), $$C\_a'$$ (O11), recipient-auditor ECDH (O\_a1), owner-auditor ECDH (O\_a5) | | `SetSpender` | 7 | $$Y$$ (S1), $$C\_{\text{spend}}$$ opening (S3), $$C\_a$$ (S7), $$C\_{\text{spend}}'$$ (S10), $$R\_e$$ (S\_a1), $$dvk\_i$$ escrow ECDH (S12, §7.11), owner-auditor ECDH (S\_a2) | +| `Clawback` | 2 | $$C\_{\text{spend}}$$ opening (CB1), $$C\_{\text{receive}}$$ opening (CB2) | -`SetSpender` is the one circuit with a third ECDH beyond the auditor channel: the $$dvk\_i$$ handoff of §7.11 reuses $$r\_e$$ but multiplies it against $$Y\_{\text{op}}$$, so it is a separate call, not a reuse of the S\_a2 shared secret. The auditor-side escrow of the allowance blinding $$r\_a$$ (S14) reuses the S\_a2 shared scalar and adds one Poseidon evaluation. The `lane[2]` escrows (W\_a5, T\_a9, S\_a6, O\_a9) read `lane[2]` of a permutation each circuit already computes and cost one field addition apiece. The ordering these totals imply is consistent with the committed ACIR opcode counts in `circuits/constraints.baseline`: `Register` 33, `Withdraw` 95, `Transfer` 134, `SetSpender` 135, `SpenderTransfer` 136. +`SetSpender` is the one circuit with a third ECDH beyond the auditor channel: the $$dvk\_i$$ handoff of §7.11 reuses $$r\_e$$ but multiplies it against $$Y\_{\text{op}}$$, so it is a separate call, not a reuse of the S\_a2 shared secret. The auditor-side escrow of the allowance blinding $$r\_a$$ (S14) reuses the S\_a2 shared scalar and adds one Poseidon evaluation. The `lane[2]` escrows (W\_a5, T\_a9, S\_a6, O\_a9) read `lane[2]` of a permutation each circuit already computes and cost one field addition apiece. The ordering these totals imply is consistent with the committed ACIR opcode counts in `circuits/constraints.baseline`: `Register` 33, `Clawback` 51, `Withdraw` 95, `Transfer` 134, `SetSpender` 135, `SpenderTransfer` 136. The ECDH computations add scalar multiplications compared to a random-blinding scheme, but the unchunked design eliminates all per-chunk constraints (which, in a chunked scheme, would involve 8+ scalar multiplications for balance chunks and per-chunk range proofs). @@ -311,7 +315,7 @@ fn ecdh(scalar: Field, point: EmbeddedCurvePoint) -> Field { } ``` -**Post-merge witness availability.** Every opening witnessed in any circuit is encoded as a single $$\mathbb{F}\_r$$ `Field` via the same `commit` primitive. After a proofless fold -- `Merge` (§7.4) or `revoke_spender` (§7.9) -- the spendable-balance blinding is a sum over $$\mathbb{F}\_q$$: $$r\_s + r\_r$$ for a merge, $$r\_s + r\_a$$ for a revoke. Its canonical $$\mathbb{F}\_q$$ representative lies in $$[0, r)$$ -- representable as a Noir `Field` -- with probability $$\geq 1 - (q - r)/q \approx 1 - 2^{-127}$$ per fold (§2.3). With the complementary probability $$\approx 2^{-127}$$ it lies in $$[r, q)$$. In that case the on-chain state remains well-formed (the commitment is a valid Grumpkin point), but the wallet's local opening witness is unencodable as a `Field`, so no spend / transfer / set-spender proof can be constructed against the affected $$C\_{\text{spend}}$$ until further accumulation shifts the blinding back into $$\mathbb{F}\_r$$. +**Post-merge witness availability.** Every opening witnessed in any circuit is encoded as a single $$\mathbb{F}\_r$$ `Field` via the same `commit` primitive. After a proofless fold -- `Merge` (§7.4), `revoke_spender` (§7.9), or `clawback` ([COMPLIANCE.md](./COMPLIANCE.md) §5.4, where the compliance extension is enabled) -- the spendable-balance blinding is a sum over $$\mathbb{F}\_q$$: $$r\_s + r\_r$$ for a merge or a clawback, $$r\_s + r\_a$$ for a revoke. Its canonical $$\mathbb{F}\_q$$ representative lies in $$[0, r)$$ -- representable as a Noir `Field` -- with probability $$\geq 1 - (q - r)/q \approx 1 - 2^{-127}$$ per fold (§2.3). With the complementary probability $$\approx 2^{-127}$$ it lies in $$[r, q)$$. In that case the on-chain state remains well-formed (the commitment is a valid Grumpkin point), but the wallet's local opening witness is unencodable as a `Field`, so no spend / transfer / set-spender proof can be constructed against the affected $$C\_{\text{spend}}$$ until further accumulation shifts the blinding back into $$\mathbb{F}\_r$$. **Soft recovery.** Every subsequent inbound confidential transfer or spender transfer, once merged, adds a fresh $$\mathbb{F}\_r$$-derived blinding. For each transfer-derived addend, the new canonical $$\mathbb{F}\_q$$ representative falls in $$[0, r)$$ with probability $$\geq 1 - 2^{-127}$$ regardless of the current stuck value (worst case: the current value sits at the lower edge of $$[r, q)$$, requiring the new $$\mathbb{F}\_r$$ addend to cross the mod $$q$$ boundary; the probability of failing to do so is bounded by $$(q - r)/r \approx 2^{-127}$$). For accounts that continue to receive confidential transfers, the unspendable window is self-resolving at the next merge with overwhelming probability; accounts whose only inflows are deposits remain stuck until a confidential transfer arrives. @@ -430,9 +434,9 @@ trait ConfidentialToken { } ``` -A deployment that enables the optional compliance extension adds `compliance: Option` to this constructor and the freeze, configuration-rotation, and read entry points of [COMPLIANCE.md](./COMPLIANCE.md) §6 to this interface. The admin-gated ones are authorized by the deployment's own access-control module. +A deployment that enables the optional compliance extension adds `compliance: Option` to this constructor and the freeze, configuration-rotation, read, and — when it opts into `ConfidentialClawback` — seizure entry points of [COMPLIANCE.md](./COMPLIANCE.md) §6 to this interface. The admin-gated ones are authorized by the deployment's own access-control module. -**Hooks.** Every entry point invokes the deployment's `Hooks` implementation after authorization and payload decoding and before the storage-layer operation. Hooks for operations that carry `data` receive the decoded payload by reference; the proofless `on_deposit`, `on_merge`, and `on_revoke_spender` receive none. The two compliance entry points invoke no hook, since their precondition — a frozen target — is exactly what a gating hook rejects ([COMPLIANCE.md](./COMPLIANCE.md) §5). +**Hooks.** Every entry point invokes the deployment's `Hooks` implementation after authorization and payload decoding and before the storage-layer operation. Hooks for operations that carry `data` receive the decoded payload by reference; the proofless `on_deposit`, `on_merge`, and `on_revoke_spender` receive none. The two seizure entry points, `clawback` and `force_revoke_spender`, invoke no hook, since a gating hook rejects exactly their precondition — a frozen target ([COMPLIANCE.md](./COMPLIANCE.md) §5). This table is authoritative: every entry is exactly the set of prover-supplied public inputs from the corresponding Section 7 operation (the contract loads the remaining public inputs from trusted state per §7.1), plus the `proof` blob. Names map directly to the Section 7 symbols. @@ -482,14 +486,17 @@ Each state-modifying operation emits a structured event. Events carry the data n | `SpenderTransfer` | `spender`, `from`, `to`, $$R\_e$$, $$\tilde{v}$$, $$\sigma\_a'$$, $$\tilde{v}\_{\text{aud,r}}$$, $$\tilde{r}\_{\text{aud,r}}$$, $$\tilde{v}\_{\text{aud,s}}$$, $$\tilde{a}\_{\text{aud,s}}$$, $$\tilde{r}\_{\text{aud,s}}$$ | | `SetSpender` | `account`, `spender`, `live_until_ledger`, $$R\_e$$, $$\sigma$$, $$\tilde{b}$$, $$\tilde{v}\_{\text{aud,s}}$$, $$\tilde{b}\_{\text{aud,s}}$$, $$\tilde{r}\_{\text{aud,s}}$$, $$\tilde{r}\_{a,\text{aud,s}}$$ | | `RevokeSpender` | `account`, `spender`, $$\tilde{a}$$, $$\sigma\_a$$ | +| `Clawback` | `account`, `amount`, `destination` | -Amount fields in `Deposit` and `Withdraw` are typed `i128`, matching SEP-41. +Amount fields in `Deposit`, `Withdraw`, and `Clawback` are typed `i128`, matching SEP-41. + +`Clawback` is emitted only by deployments that enable the optional compliance extension ([COMPLIANCE.md](./COMPLIANCE.md) §5). **Usage by consumers:** - **Recipient wallet**: processes `Transfer` and `SpenderTransfer` events using $$(R\_e, \tilde{v}, \sigma)$$ -- with $$\sigma\_a'$$ in place of $$\sigma$$ for `SpenderTransfer` -- to derive $$v\_{\text{transfer}}$$ and $$r\_{\text{transfer}}$$ (Section 5.3). -- **Owner wallet**: processes all events for recovery (Section 5.2). The $$(\tilde{b}, \sigma)$$ pair from the most recent checkpoint event (`Withdraw`, `Transfer` as sender, `SetSpender`) anchors $$W\_{\text{spend}}$$; `Merge` and `RevokeSpender` are folded into it during replay. -- **Auditor**: processes events containing $$R\_e$$ to compute ECDH shared secrets and decrypt amounts, balance checkpoints, and the escrowed blindings (Sections 8.1, 8.2, 8.5); folds `Merge` and `RevokeSpender` into its accumulators as Section 8.1 specifies. +- **Owner wallet**: processes all events for recovery (Section 5.2). The $$(\tilde{b}, \sigma)$$ pair from the most recent checkpoint event (`Withdraw`, `Transfer` as sender, `SetSpender`) anchors $$W\_{\text{spend}}$$; `Merge`, `RevokeSpender`, and `Clawback` are folded into it during replay. +- **Auditor**: processes events containing $$R\_e$$ to compute ECDH shared secrets and decrypt amounts, balance checkpoints, and the escrowed blindings (Sections 8.1, 8.2, 8.5); folds `Merge`, `RevokeSpender`, and `Clawback` into its accumulators as Section 8.1 and [COMPLIANCE.md](./COMPLIANCE.md) §5.7 specify. - **Indexer**: reconciles the pooled balance against the sum of claims (Section 9.3, [INDEXER.md](./INDEXER.md)). ### 11.3 Read Methods diff --git a/packages/tokens/src/confidential/docs/INDEXER.md b/packages/tokens/src/confidential/docs/INDEXER.md index 35dff767a..7e9f82286 100644 --- a/packages/tokens/src/confidential/docs/INDEXER.md +++ b/packages/tokens/src/confidential/docs/INDEXER.md @@ -9,7 +9,7 @@ The key words MUST, MUST NOT, SHOULD, and MAY are to be interpreted as in RFC 21 ## 1. Why the Indexer Is Load-Bearing -Confidential balances are Pedersen commitments; the on-chain entry alone does not reveal the opening `(v, r)` needed to spend. A wallet that loses its local cache reconstructs the opening deterministically from the master secret plus the account's event history: the latest *checkpoint* event supplies `(b_tilde, sigma)` from which the spendable opening as of that checkpoint is derived (DESIGN §5.2), while the receiving-side opening is rebuilt by replaying deposits and incoming transfers back to the account's last `Merge` at or before that checkpoint, or to registration if it has not merged before it (§2). That same replay carries the post-checkpoint proofless folds — `Merge` and `RevokeSpender` — onto the spendable side. +Confidential balances are Pedersen commitments; the on-chain entry alone does not reveal the opening `(v, r)` needed to spend. A wallet that loses its local cache reconstructs the opening deterministically from the master secret plus the account's event history: the latest *checkpoint* event supplies `(b_tilde, sigma)` from which the spendable opening as of that checkpoint is derived (DESIGN §5.2), while the receiving-side opening is rebuilt by replaying deposits and incoming transfers back to the account's last `Merge` or `Clawback` at or before that checkpoint, or to registration if there is none before it (§2). That same replay carries the post-checkpoint folds — `Merge`, `RevokeSpender`, and `Clawback` — onto the spendable side. Stellar RPC retains events for a **7-day window** only. A wallet that loses local state after that window can still see that its funds exist (the commitment remains on-chain) but cannot reconstruct the opening required to spend them — unless a durable archive holds the missing events. That archive is this document's subject. Without a conforming indexer, recovery from seed is not guaranteed, and deployments MUST treat wallet-local state as unrecoverable after the RPC window. @@ -18,7 +18,7 @@ Stellar RPC retains events for a **7-day window** only. A wallet that loses loca ## 2. Terminology - **Checkpoint** — an owner-initiated proof-carrying event that publishes `(b_tilde, sigma)` for the owner's spendable balance (DESIGN §5.2; §3.2 lists the qualifying event types in ingestion scope). -- **Replay window** — the range from `T_0` to the current ledger, where `T_0` is the account's most recent `Merge` at or before its latest checkpoint, or its `Register` event if it has not merged before that checkpoint (DESIGN §5.2 *Recovery*). One window serves both sides: the checkpoint supplies the spendable opening as of the checkpoint, the window carries the `Merge` and `RevokeSpender` folds that follow it onto that opening, and the receiving opening is rebuilt across the window. Anchoring the receiving side at the account's last `Merge` overall is not sufficient — a `Merge` after the checkpoint reconstructs the current receiving opening correctly but leaves the spendable opening short by the amount that merge folded in, the checkpoint predating it. +- **Replay window** — the range from `T_0` to the current ledger, where `T_0` is the account's most recent `Merge` or `Clawback` at or before its latest checkpoint, or its `Register` event if there is none before that checkpoint (DESIGN §5.2 *Recovery*). One window serves both sides: the checkpoint supplies the spendable opening as of the checkpoint, the window carries the `Merge`, `RevokeSpender`, and `Clawback` folds that follow it onto that opening, and the receiving opening is rebuilt across the window. Anchoring the receiving side at the account's last `Merge` overall is not sufficient — a `Merge` after the checkpoint reconstructs the current receiving opening correctly but leaves the spendable opening short by the amount that merge folded in, the checkpoint predating it. - **Event id** — the triple `(ledger_seq, tx_hash, event_index)`, unique per emitted event: `tx_hash` is globally unique, and `event_index` is unambiguous because a Soroban transaction carries a single operation. The same event MUST carry the same id whether served from the archive or from RPC, so a hybrid client can deduplicate across the seam. The id does not by itself encode position within a ledger — `tx_hash` conveys no ordering — so the canonical total order is instead `(ledger_seq, tx_application_order, event_index)`, where `tx_application_order` is persisted as its own field (§3.1) and drives §3.4. - **Seam** — in a hybrid client (§1) that reads the recent tail from Stellar RPC and older history from the archive, the ledger at which it switches sources. A client sets the seam above the RPC retention floor (`getHealth().oldestLedger`) so the RPC side is always served from live retention, and requires the archive's ingested-through ledger (§6 C4) to reach the seam. @@ -51,8 +51,9 @@ All events emitted by the confidential token (DESIGN_cont §11.2) with the follo | `Transfer` (recipient side) | Receiving-side replay: carries the recipient-channel ciphertexts for `(v_transfer, r_transfer)`. | | `SpenderTransfer` (recipient side) | Receiving-side replay, as above. | | `Merge` | Anchor: folds the receiving opening into the spendable opening; resets the receiving side. | +| `Clawback` | Anchor, as `Merge`, less the public `amount` on the spendable side; not a checkpoint. Emitted only by deployments that enable the compliance extension (COMPLIANCE §5.7). | | `Withdraw`, `Transfer` (sender side), `SetSpender` | **Checkpoints**: publish `(b_tilde, sigma)` for the owner's spendable balance. `SetSpender` is in scope as an owner checkpoint only — a spender recovers allowance state from the on-chain delegation entry (`allowance_commitment`, `a_tilde`, `escrowed_dvk`, `allowance_salt`), not from the archive. | -| `RevokeSpender` | Spendable-side fold, not a checkpoint: the owner adds the reclaimed allowance opening to the spendable opening, deriving it from the event's `a_tilde` and `allowance_salt` (DESIGN §7.9). | +| `RevokeSpender` | Spendable-side fold, not a checkpoint: the owner adds the reclaimed allowance opening to the spendable opening, deriving it from the event's `a_tilde` and `allowance_salt` (DESIGN §7.9). May be emitted by the compliance extension's `force_revoke_spender` without an owner-signed transaction; the fold is the same (COMPLIANCE §5.5). | A self-transfer — a `Transfer` whose `from` and `to` are the same account — carries both roles at once: it is a sender-side checkpoint and a recipient-side replay event, and recovery applies both (DESIGN §5.2). @@ -62,7 +63,7 @@ Configuration events (`UnderlyingAssetSet`, `VerifierSet`, `AuditorSet`, `Addres ### 3.3 Account attribution -Recovery is per-account, and an event belongs to **each** account address appearing in its topics — a `Transfer` to both the sender's and the recipient's history, a `SpenderTransfer` to the owner's, recipient's, and spender's, a `RevokeSpender` to the owner's and the spender's alone. Attribution MUST come from the event topics, never from the transaction source account. +Recovery is per-account, and an event belongs to **each** account address appearing in its topics — a `Transfer` to both the sender's and the recipient's history, a `SpenderTransfer` to the owner's, recipient's, and spender's, a `RevokeSpender` to the owner's and the spender's alone, a `Clawback` to the target's alone. Attribution MUST come from the event topics, never from the transaction source account. The indexer MAY apply this attribution server-side (per-account queries, §6 C2) or serve the whole per-contract stream and leave the client to select the events touching its account; both conform, since attribution is a pure function of the topics. Server-side per-account filtering is RECOMMENDED for high-volume contracts, where downloading the full contract history to every wallet does not scale. @@ -83,14 +84,14 @@ The indexer MUST preserve and expose the total order `(ledger_seq, tx_applicatio The indexer MUST retain the full per-account history of every in-scope event **indefinitely**. No pruning horizon is safe in general: - The spendable opening is taken from the account's latest checkpoint, which is arbitrarily old for a dormant account. -- The receiving side is replayed from the last `Merge` at or before that checkpoint, which for an account that receives but never merges is its registration. +- The receiving side is replayed from the last `Merge` or `Clawback` at or before that checkpoint, which for an account that receives but never merges is its registration. - A `RevokeSpender` after the latest checkpoint is the only surviving record of the addend it folded: the delegation entry carrying `a_tilde` and `allowance_salt` is deleted in the same invocation (DESIGN §7.9). ## 6. API Surface C2–C4 below are normative; C1 is RECOMMENDED. The REST shape is RECOMMENDED — any transport exposing the same capabilities conforms. -- **C1 — Latest checkpoint (RECOMMENDED).** Return the most recent checkpoint event for `(contract_id, account)` at or before a given ledger. This is an optimization, not a correctness requirement: each checkpoint carries a self-contained `(b_tilde, sigma)` that re-derives the spendable opening as of that checkpoint, so a client can also obtain the latest checkpoint by scanning the ordered history (C2). Exposing C1 lets a dormant account with a long history skip transferring that history. A client additionally needs `T_0`, the last `Merge` at or before that checkpoint (§2), which C1 does not return and which is obtainable from C2's ordered history. +- **C1 — Latest checkpoint (RECOMMENDED).** Return the most recent checkpoint event for `(contract_id, account)` at or before a given ledger. This is an optimization, not a correctness requirement: each checkpoint carries a self-contained `(b_tilde, sigma)` that re-derives the spendable opening as of that checkpoint, so a client can also obtain the latest checkpoint by scanning the ordered history (C2). Exposing C1 lets a dormant account with a long history skip transferring that history. A client additionally needs `T_0`, the last `Merge` or `Clawback` at or before that checkpoint (§2), which C1 does not return and which is obtainable from C2's ordered history. - **C2 — Ordered history.** Return all in-scope events for `(contract_id, account)` within a ledger range, in the total order of §3.4, paginated, each carrying its event id and payload (§3.1). An indexer that serves only the per-contract stream (§3.3) satisfies C2 by delivering that stream in order for client-side attribution. - **C3 — Completeness signal.** Every response MUST state whether the served range is complete (`complete: true` only when the indexer holds a gap-free history for the whole requested range). - **C4 — Ingestion status.** Expose the latest fully-ingested ledger so clients can bound staleness. A hybrid client (§1) compares it against the seam it derives from the RPC retention floor; if the archive has not ingested through the seam, the client MUST treat the crossing range as incomplete (C3). diff --git a/packages/tokens/src/confidential/docs/OVERVIEW.md b/packages/tokens/src/confidential/docs/OVERVIEW.md index c1bd88fb6..2a7040f9a 100644 --- a/packages/tokens/src/confidential/docs/OVERVIEW.md +++ b/packages/tokens/src/confidential/docs/OVERVIEW.md @@ -145,6 +145,7 @@ Each auditor decrypts its ciphertexts by running the channel sponge (recipient-a - **Dual-auditor ciphertexts.** The ciphertexts each operation produces are enforced by its zero-knowledge proof, so they cannot be omitted or malformed, and no extra action is needed from users. - **Per-account scope.** Auditing one account reveals nothing about any other account. - **Recipient-side opening capability.** The recipient's auditor holds the per-transfer Pedersen blinding $r_{\text{transfer}}$, hence the full Pedersen opening of the recipient's receiving balance between merges, which is what enables the seizure/clawback flow specified in `COMPLIANCE.md` §5; the capability and its bounds are specified in `DESIGN_cont.md` §8.1. +- **Seizure.** A deployment that opts into the compliance extension can reduce a frozen account's balance by a public amount against a proof that the amount does not exceed what the account holds; the proof is produced by the auditor or the owner, never by the admin alone, and the account stays spendable afterwards (`COMPLIANCE.md` §5). - **Sender-side opening capability.** The sender's auditor holds the opening of the account's spendable balance as of each withdrawal, outgoing transfer, and spender setup, because those operations also encrypt the post-operation blinding factor to it. Because one key serves both of an account's auditor channels, the capability survives merges; its bounds are specified in `DESIGN_cont.md` §8.1. - **Seamless auditor rotation.** When an auditor key is rotated, the new key immediately receives ciphertexts on subsequent operations. For the sender's auditor, the balance checkpoint at the next owner-initiated proof operation (transfer, withdrawal, or set spender) provides the current balance with no event replay; `DESIGN_cont.md` §8.3 bounds what a rotated key can open beyond that checkpoint. - **Spender visibility.** The owner's auditor sees spender transfer amounts and post-transfer allowances via the same dual-auditor mechanism, and additionally sees the escrowed amount at `set_spender`. @@ -171,7 +172,7 @@ The wallet must: - **Generate and store keys** - derive the full key hierarchy (spending key, viewing key, public viewing key, delegation viewing keys) from a single master secret. - **Produce zero-knowledge proofs** - the heaviest client-side computation. Proof generation time depends on the circuit complexity but targets single-digit seconds on modern hardware. The Transfer circuit involves 8 elliptic-curve scalar multiplications (including two auditor ECDH exchanges); the Register circuit needs 2. `DESIGN_cont.md` §10.3 lists the per-circuit totals. - **Track local state** - maintain running commitment openings (value and blinding factor pairs) for the spendable and receiving balances by processing on-chain events. This is comparable to wallet sync in UTXO-based privacy systems (Zcash, Monero). -- **Handle recovery** - if local state is lost, reconstruct balances from on-chain data using the viewing key: fetch the encrypted balance scalar and salt from the most recent spend-boundary event, derive the deterministic blinding factor, and replay the events emitted after the last merge preceding that spend boundary, since only a merge clears the receiving balance and since merges and spender revocations in that window fold into the spendable balance too. Recovery requires the master secret plus access to a durable event archive (Stellar RPC retains only 7 days of history); the indexer this archive must satisfy is specified in the companion [Indexing and Off-Chain State Recovery](./INDEXER.md) document. +- **Handle recovery** - if local state is lost, reconstruct balances from on-chain data using the viewing key: fetch the encrypted balance scalar and salt from the most recent spend-boundary event, derive the deterministic blinding factor, and replay the events emitted after the last merge (or compliance clawback) preceding that spend boundary, since only those clear the receiving balance and since merges, spender revocations, and clawbacks in that window fold into the spendable balance too. Recovery requires the master secret plus access to a durable event archive (Stellar RPC retains only 7 days of history); the indexer this archive must satisfy is specified in the companion [Indexing and Off-Chain State Recovery](./INDEXER.md) document. ### For Developers (Integration) @@ -180,7 +181,7 @@ The wallet must: | **Token contract** | The main Soroban contract that holds SEP-41 tokens and manages encrypted account state. | | **Verifier contract** | Validates zero-knowledge proofs on-chain. Stores one verification key per operation type. | | **Auditor contract** | Manages auditor public keys. Shared across tokens. | -| **Noir circuits** | Five proof circuits (register, withdraw, transfer, spender transfer, set spender). Written in Noir, compiled to UltraHonk. | +| **Noir circuits** | Six proof circuits (register, withdraw, transfer, spender transfer, set spender, and the compliance extension's clawback). Written in Noir, compiled to UltraHonk. | | **Client library** | SDK for wallets: key management, proof generation, event processing, balance tracking, encryption/decryption. Specified in [SDK.md](./SDK.md). | --- @@ -206,7 +207,7 @@ If the wallet is lost or reinstalled on a new device: 1. The account holder restores from the master secret (seed phrase or equivalent). 2. The wallet re-derives the full key hierarchy. -3. The wallet fetches the latest spend-boundary event for the account, reads the encrypted balance scalar and salt from it, recovers the spendable balance opening as of that boundary using the viewing key, then replays the deposits, incoming transfers, merges, and spender revocations emitted after the last merge preceding that spend boundary — rebuilding the receiving balance and folding the merges and revocations onto the spendable one. A spend boundary does not clear the receiving balance — only a merge does — so the replay starts at that merge rather than at the spend boundary. +3. The wallet fetches the latest spend-boundary event for the account, reads the encrypted balance scalar and salt from it, recovers the spendable balance opening as of that boundary using the viewing key, then replays the deposits, incoming transfers, merges, spender revocations, and compliance clawbacks emitted after the last merge or clawback preceding that spend boundary — rebuilding the receiving balance and folding the merges, revocations, and clawbacks onto the spendable one. A spend boundary does not clear the receiving balance — only a merge or a clawback does — so the replay starts there rather than at the spend boundary. The recovery process is fully deterministic given the master secret and access to the account's event history from that merge onward. Because Stellar RPC retains only the last 7 days of events, recovery from seed alone depends on a durable indexer ([INDEXER.md](./INDEXER.md)) that retains the per-account event log; without one, the on-chain commitments remain visible but their openings cannot be reconstructed. diff --git a/packages/tokens/src/confidential/docs/SDK.md b/packages/tokens/src/confidential/docs/SDK.md index a434d0abd..2998e2b81 100644 --- a/packages/tokens/src/confidential/docs/SDK.md +++ b/packages/tokens/src/confidential/docs/SDK.md @@ -119,15 +119,19 @@ $$\text{ECDH}(a, B) = \text{poseidon\\\_with\\\_domain}(\delta_{\text{ecdh}}, [S The derivation MUST fail rather than proceed if $$S$$ is the identity: with $$\sigma$$ public, an identity shared secret makes every derived ciphertext trivially decryptable, which is why the circuits carry explicit nonzero-scalar constraints (DESIGN_cont.md §10.8). -### 4.6 Blinding accumulation — mod $$q$$, never mod $$r$$ +### 4.6 Blinding accumulation -Commitment blinding factors accumulate under homomorphic point addition, so they accumulate in the **Grumpkin scalar field** $$\mathbb{F}_q$$ (§4.1): +Commitment blinding factors compose under homomorphic point addition, so their arithmetic is that of the **Grumpkin scalar field** $$\mathbb{F}_q$$ (§4.1): $$\text{Com}(v_1, r_1) + \text{Com}(v_2, r_2) = \text{Com}(v_1 + v_2, \\, (r_1 + r_2) \bmod q)$$ -Reducing modulo $$r$$ instead yields an opening that is off by $$q - r$$ and no longer matches the on-chain point, and for two full-size blindings the integer sum crosses $$q$$ roughly half the time. Implementations MUST provide distinct, clearly named addition operations for the two moduli and MUST use the $$\mathbb{F}_q$$ one for every blinding accumulation: merge (DESIGN.md §7.4) and receiving-balance credit (DESIGN.md §5.2 *Update rules*). +Reducing modulo $$r$$ instead yields an opening that is off by $$q - r$$ and no longer matches the on-chain point, and for two full-size blindings the integer sum crosses $$q$$ roughly half the time. Implementations MUST provide distinct, clearly named reduction operations for the two moduli. -Committed **values** accumulate as exact integers and MUST NOT be reduced by either modulus; DESIGN.md §2.3 establishes that they never wrap. +**One reduction point.** An implementation MUST fold blindings with unbounded integer addition and MUST NOT reduce by either modulus as folds compose. This governs merge (DESIGN.md §7.4), the `RevokeSpender` and `Clawback` folds, and receiving-balance credit (DESIGN.md §5.2 *Update rules*) alike. + +An implementation MUST reduce modulo $$q$$ to the canonical representative only where a blinding leaves an accumulator for the curve: a proof witness, or the recommit of the §10.6 consistency check. §10.7 tests for encodability at that same point. + +Committed **values** accumulate as exact integers and are never reduced; DESIGN.md §2.3 establishes that they never wrap. ### 4.7 Scalar sampling @@ -371,7 +375,7 @@ Separating the first two matters most: they present as the same opaque failure o ### 10.1 State model -The wallet maintains the two accumulators of DESIGN.md §5.2 — $$W_{\text{spend}}$$ and $$W_{\text{receive}}$$ — plus a sync position and any in-flight projection (§10.3). Values accumulate as exact integers; blindings accumulate modulo $$q$$ (§4.6). +The wallet maintains the two accumulators of DESIGN.md §5.2 — $$W_{\text{spend}}$$ and $$W_{\text{receive}}$$ — plus a sync position and any in-flight projection (§10.3). Values and blindings alike accumulate as exact integers; a blinding is reduced modulo $$q$$ only where it leaves an accumulator for the curve (§4.6). Persistence MUST be pluggable, since the same core serves environments with very different storage. With RPC-only event access, discarding persisted state loses the receiving-side openings permanently (§10.9), so it MUST NOT be treated as an evictable cache. @@ -387,6 +391,7 @@ Application rules are DESIGN.md §5.2's update table and are not restated here. - `Withdraw`, sender-side `Transfer`, and `SetSpender` **overwrite** $$W_{\text{spend}}$$ from the event's $$(\tilde{b}, \sigma)$$ rather than adjusting it, so a wallet that missed intervening events still converges on the spendable side. - `RevokeSpender` is not a checkpoint — it carries no $$\tilde{b}$$ — and is **folded**, like `Merge`. `RevokeSpender` adds the reclaimed allowance opening to $$W_{\text{spend}}$$, with $$v_a$$ and $$r_a$$ recovered from the event's $$\tilde{a}$$ and `allowance_salt` under the owner-derived $$dvk_i$$ (DESIGN.md §7.9). A wallet that misses either event diverges until the next checkpoint and MUST rely on §10.6 to detect that. +- `Clawback` is likewise not a checkpoint and is **folded**: the `Merge` rule, then the event's public `amount` subtracted from $$W_{\text{spend}}$$'s value (COMPLIANCE.md §5.7). It occurs only in deployments that enable the compliance extension. - A self-transfer — a `Transfer` whose `from` and `to` are the same account — MUST be applied in both roles: the sender side overwrites $$W_{\text{spend}}$$ from the event's $$(\tilde{b}, \sigma)$$, and the recipient side credits $$W_{\text{receive}}$$ from the same event's recipient-channel ciphertexts. The two roles act on different accumulators, so their relative order does not affect the result; applying only one loses the other accumulator's update. ### 10.3 In-flight operations @@ -413,8 +418,6 @@ $$r_e = \text{poseidon\\\_with\\\_domain}(\delta_{\text{eph}}, [vk, \sigma_E])$$ where $$vk$$ is the originator's viewing key and $$\sigma_E$$ the operation's salt. DESIGN.md §5.3 is the normative source: it fixes which viewing key and salt each operation derives from, and the retry rule for the negligible case that the derivation yields zero. -**Scope.** The clawback circuit is the one operation outside the rule, its ephemeral belonging to the auditor: an implementation that constructs clawback witnesses obtains that scalar by the §4.7 procedure, no viewing key being available there (COMPLIANCE.md §5.3). - **Three consequences an implementation MUST handle.** First, **$$vk$$ carries more authority than balance decryption.** Recomputing $$r_e$$ yields the recipient shared scalar, hence $$r_{\text{transfer}}$$, hence a full Pedersen opening of every transfer commitment the account created — retroactively, and reaching commitments that sit inside recipients' receiving balances (DESIGN_cont.md §9.4, §8.2). An implementation MUST treat $$vk$$ accordingly in §13 and MUST NOT export it as a read-only credential without stating this. @@ -431,11 +434,11 @@ Implementations MUST report which accumulator diverged, since the two have diffe ### 10.7 The unspendable-blinding case -A post-merge spendable blinding can land outside the range a Noir `Field` encodes, leaving no constructible proof against the affected commitment while on-chain state stays well-formed and §10.6's check still passes (DESIGN_cont.md §10.4 *Post-merge witness availability*). +$$W_{\text{spend}}.r$$ is an exact integer, but a proof witnesses it as a single $$\mathbb{F}_r$$ `Field`. After any of the three proofless folds — `Merge`, `RevokeSpender`, `Clawback` — its canonical $$\mathbb{F}_q$$ representative can land in $$[r, q)$$, which no `Field` encodes, so no proof can be constructed against that commitment. The state itself is sound: the commitment is a well-formed Grumpkin point, and §10.6's check recommits from the same representative and still passes (DESIGN_cont.md §10.4 *Post-merge witness availability*). -An implementation MUST detect this condition and surface it as a distinct, named state rather than as a generic proof-construction failure. +An implementation MUST test the reduction rather than the accumulator: before attempting a proof, reduce $$W_{\text{spend}}.r$$ modulo $$q$$ at §4.6's reduction point and compare the representative against $$r$$. The unreduced accumulator carries no signal: after a few folds an integer sum past $$r$$ is the ordinary case. -It MUST also surface the recovery path: the condition resolves at the next merge that folds in an inbound confidential transfer, and an account whose only inflows are deposits stays affected until one arrives (DESIGN_cont.md §10.4 *Soft recovery*). +A failed comparison MUST surface as a distinct, named state rather than as a generic proof-construction failure. The recovery path MUST surface with it: the condition resolves at the next merge that folds in an inbound confidential transfer, and an account whose only inflows are deposits stays affected until one arrives (DESIGN_cont.md §10.4 *Soft recovery*). ### 10.8 Merge policy @@ -445,7 +448,7 @@ Merge is proof-less and owner-authorized, and neither a merge nor an in-flight s ### 10.9 Recovery -Recovery follows the procedure of DESIGN.md §5.2 *Recovery*, with the reconstructed state verified per §10.6. Its two anchors differ: the latest checkpoint event pins $$W_{\text{spend}}$$ as of that checkpoint in one lookup, with the `Merge` and `RevokeSpender` folds that follow it applied over the replay window per §10.2, while $$W_{\text{receive}}$$ restarts at $$T_0$$ — the account's last `Merge` at or before that checkpoint — from which that window runs. +Recovery follows the procedure of DESIGN.md §5.2 *Recovery*, with the reconstructed state verified per §10.6. Its two anchors differ: the latest checkpoint event pins $$W_{\text{spend}}$$ as of that checkpoint in one lookup, with the `Merge`, `RevokeSpender`, and `Clawback` folds that follow it applied over the replay window per §10.2, while $$W_{\text{receive}}$$ restarts at $$T_0$$ — the account's last `Merge` or `Clawback` at or before that checkpoint — from which that window runs. Two further obligations follow from data availability: @@ -481,6 +484,8 @@ An implementation MUST squeeze the sender / owner channel three-wide and MUST NO **Scope MUST be represented, not implied.** The recipient-channel capability is forward-only, reset by merge, and yields no opening of the spendable commitment (DESIGN_cont.md §8.1). The `lane[2]` opening of the sender channel is forward-only and **standing**: it opens the spendable commitment as of the checkpoint that escrowed it and stays valid through merges provided the client folded in every inbound flow since the previous merge, one account key serving both channels (DESIGN_cont.md §8.1 *Sender-auditor opening capability*). Maintaining it is the client's job: an implementation MUST add the `lane[0]` amount and `lane[1]` $$r_{\text{transfer}}$$ of every inbound `Transfer` and `SpenderTransfer` to its stored $$(v, r)$$, and MUST treat each `Deposit` as $$(\text{amount}, 0)$$ (DESIGN.md §7.3). On a `RevokeSpender` event it MUST add the $$(v_a, r_a)$$ it recorded for that delegation, the fold being public and the addend already escrowed (DESIGN_cont.md §8.5); where it holds no such record it MUST mark the opening unavailable until the next `lane[2]` escrow rather than carry a stale one. Across a key rotation, what an implementation may carry forward and what it MUST treat as unopened are fixed by DESIGN_cont.md §8.3. +**Clawback witness.** In a deployment that enables seizure (COMPLIANCE.md §5), the clawback witness is the pair of openings the client already maintains — the standing sender-channel opening of $$C_{\text{spend}}$$ and the recipient-channel opening of $$C_{\text{receive}}$$ (COMPLIANCE.md §5.2, §5.3). An implementation MUST verify both against the on-chain commitments before proving, and on a `Clawback` event MUST advance the standing opening by the `Merge` rule and the event's public `amount` (COMPLIANCE.md §5.7). + An auditor facade MUST NOT be able to construct a spending witness. It can open the spendable balance past a merge — the inbound $$r_{\text{transfer}}$$ reaches the same key on the recipient channel — so a facade that exposes openings exposes them for the account's whole history under the active key. --- @@ -539,7 +544,7 @@ $$vk$$ MUST NOT be presented as a safely-shareable read-only credential. It expo **Proving latency.** Single-digit seconds on contemporary hardware is the design target (OVERVIEW.md). Implementations MUST treat it as user-visible. -**Sync and replay bounds.** The replay window runs from the account's last `Merge` at or before its latest checkpoint, or from registration for an account that has not merged before that checkpoint (DESIGN.md §5.2 *Recovery*), which is unbounded in age. Implementations MUST NOT assume a bounded window, and SHOULD use the archive's checkpoint lookup where available (INDEXER.md §6, C1) so that a dormant account is not obliged to transfer its entire history. +**Sync and replay bounds.** The replay window runs from the account's last `Merge` or `Clawback` at or before its latest checkpoint, or from registration if there is none before that checkpoint (DESIGN.md §5.2 *Recovery*), which is unbounded in age. Implementations MUST NOT assume a bounded window, and SHOULD use the archive's checkpoint lookup where available (INDEXER.md §6, C1) so that a dormant account is not obliged to transfer its entire history. **Storage growth.** Per-account event volume is linear in inbound transfers and unbounded by design, since incoming-transfer spam is rate-limited only by transaction fees (DESIGN_cont.md §9.5). Implementations MUST NOT size local storage on the assumption that inbound volume tracks the user's own activity. diff --git a/packages/tokens/src/confidential/mod.rs b/packages/tokens/src/confidential/mod.rs index f7f23084e..78e1dc36b 100644 --- a/packages/tokens/src/confidential/mod.rs +++ b/packages/tokens/src/confidential/mod.rs @@ -87,9 +87,11 @@ //! `amount` units between the two accounts, with no fees deducted in transit //! and no rebasing applied. [`storage::deposit`] credits the //! confidential receiving balance with `amount · G` after the SEP-41 -//! transfer, and [`storage::withdraw`] debits the confidential -//! spendable balance by `amount` before transferring the same amount out; -//! neither call re-measures the contract's own balance. With a +//! transfer, [`storage::withdraw`] debits the confidential +//! spendable balance by `amount` before transferring the same amount out, +//! and [`compliance::storage::clawback`] does the same on its +//! `Some(destination)` branch; none of the three re-measures the contract's +//! own balance. With a //! fee-on-transfer, rebasing, or otherwise malicious token implementation, //! the confidential ledger would drift from the on-chain reserves — //! credit a higher amount than was actually received, or pay out less than @@ -125,6 +127,17 @@ //! clawback empties the pool, leaving all holders' confidential balances //! unbacked and unwithdrawable. Issuer-led SAC deployments must weigh both //! powers explicitly alongside the exact-transfer assumption. +//! +//! The compliance extension's +//! [`clawback`](compliance::ConfidentialClawback::clawback) gives the issuer a +//! *legitimate* reason to make such a call. A seizure with `destination: None` +//! reduces the target's confidential claim without moving any underlying, so +//! the pool ends up over-collateralized by exactly that amount, which the +//! issuer's own SAC clawback against this contract's address then extracts. +//! The order matters: seizing first passes through surplus, while extracting +//! first passes through a deficit borne by every other holder. An issuer +//! extracting more than the accumulated surplus creates the shortfall +//! described above. pub mod auditor; pub mod compliance; @@ -838,7 +851,9 @@ pub fn emit_set_spender( } /// Event emitted when a delegation's escrowed allowance is folded back into -/// the owner's spendable balance and the delegation is deleted. +/// the owner's spendable balance and the delegation deleted — by the owner +/// through [`ConfidentialToken::revoke_spender`], or by the compliance module +/// through `ConfidentialClawback::force_revoke_spender`. #[contractevent] #[derive(Clone, Debug, Eq, PartialEq)] pub struct RevokeSpender { diff --git a/packages/tokens/src/confidential/storage.rs b/packages/tokens/src/confidential/storage.rs index 663098f9c..fdf68894a 100644 --- a/packages/tokens/src/confidential/storage.rs +++ b/packages/tokens/src/confidential/storage.rs @@ -1127,6 +1127,35 @@ pub fn set_address_as_field_element(e: &Env) { emit_address_as_field_set(e, &computed); } +/// Overwrites both balance commitments of `account`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `account` - The owner address. +/// * `c_spend` - The new spendable commitment. +/// * `c_receive` - The new receiving commitment. +/// +/// # Errors +/// +/// * [`ConfidentialTokenError::AccountNotRegistered`] - When `account` is not +/// registered. +/// +/// # Security Warning +/// +/// **IMPORTANT**: This function performs no authorization check, consumes no +/// proof, and verifies no conservation property. Establishing that the write +/// preserves balance conservation (DESIGN_cont §9.3) is the caller's +/// obligation. It exists for the compliance module's clawback flow +/// ([`crate::confidential::compliance::storage::clawback`]), which discharges +/// that obligation in-circuit before calling. +pub(crate) fn set_commitments(e: &Env, account: &Address, c_spend: &Point, c_receive: &Point) { + let mut data = get_account(e, account); + data.spendable_commitment = c_spend.clone(); + data.receiving_commitment = c_receive.clone(); + e.storage().persistent().set(&ConfidentialTokenStorageKey::Account(account.clone()), &data); +} + // ################## LOW-LEVEL HELPERS ################## /// Overwrites `account.spendable_commitment` with `c_spend_new`. @@ -1245,7 +1274,7 @@ where /// Cross-contract proof verification through [`ConfidentialVerifierClient`]. /// Panics with [`ConfidentialTokenError::InvalidProof`] if the verifier returns /// `false`. -fn verify(e: &Env, circuit_type: CircuitType, public_inputs: &Bytes, proof: &Bytes) { +pub(crate) fn verify(e: &Env, circuit_type: CircuitType, public_inputs: &Bytes, proof: &Bytes) { let verifier = ConfidentialVerifierClient::new(e, &get_verifier(e)); if !verifier.verify_proof(&circuit_type, public_inputs, proof) { panic_with_error!(e, ConfidentialTokenError::InvalidProof); @@ -1266,7 +1295,7 @@ fn verify(e: &Env, circuit_type: CircuitType, public_inputs: &Bytes, proof: &Byt /// /// * [`ConfidentialTokenError::NonCanonicalEncoding`] - When either coordinate /// is `≥ r`. -fn append_point(pi: &mut Bytes, p: &Point) { +pub(crate) fn append_point(pi: &mut Bytes, p: &Point) { if !Grumpkin::is_canonical_point(p) { panic_with_error!(pi.env(), ConfidentialTokenError::NonCanonicalEncoding); } @@ -1281,7 +1310,7 @@ fn append_point(pi: &mut Bytes, p: &Point) { /// /// * [`ConfidentialTokenError::NonCanonicalEncoding`] - When the value is `≥ /// r`. -fn append_field(pi: &mut Bytes, f: &BytesN<32>) { +pub(crate) fn append_field(pi: &mut Bytes, f: &BytesN<32>) { if !Grumpkin::is_canonical_field(f) { panic_with_error!(pi.env(), ConfidentialTokenError::NonCanonicalEncoding); } @@ -1290,7 +1319,7 @@ fn append_field(pi: &mut Bytes, f: &BytesN<32>) { /// Appends a non-negative `i128` amount as a canonical 32-byte big-endian /// `Bn254Fr` representative. -fn append_amount(pi: &mut Bytes, e: &Env, amount: i128) { +pub(crate) fn append_amount(pi: &mut Bytes, e: &Env, amount: i128) { let mut buf = [0u8; 32]; buf[16..].copy_from_slice(&amount.to_be_bytes()); pi.append(&Bytes::from_array(e, &buf)); diff --git a/packages/tokens/src/confidential/test.rs b/packages/tokens/src/confidential/test.rs index 6c5cf2363..c25a99372 100644 --- a/packages/tokens/src/confidential/test.rs +++ b/packages/tokens/src/confidential/test.rs @@ -860,9 +860,8 @@ fn auditor_key_rotation_rescopes_the_escrowed_allowance_opening() { let before = h.token.get_spender_delegation(&alice, &spender); // Step 3 -- the first post-rotation state change re-anchors K2. O_a9's - // escrow of r_a' is verified against K2, so this single event is what - // gives the rotated-in key an opening of the allowance it did not see - // created. + // escrow of r_a' is verified against K2, so this single event gives the + // rotated-in key an opening of the allowance it did not see created. h.token.confidential_transfer_from(&spender, &alice, &bob, &spender_transfer_data(&h.e)); let xfer_events = h.e.events().all(); assert_eq!( @@ -886,7 +885,7 @@ fn auditor_key_rotation_rescopes_the_escrowed_allowance_opening() { // The delegation moved to a new state, so the opening K2 just received is // an opening of the CURRENT C_a, not of the one K1 saw. (The commitments // themselves are the same canonical fixture point under a mocked verifier; - // the salt is what distinguishes the two states here.) + // the salt distinguishes the two states here.) let after = h.token.get_spender_delegation(&alice, &spender); assert_ne!(before.allowance_salt, after.allowance_salt); diff --git a/packages/tokens/src/confidential/verifier/mod.rs b/packages/tokens/src/confidential/verifier/mod.rs index a36be83b6..037ef73d8 100644 --- a/packages/tokens/src/confidential/verifier/mod.rs +++ b/packages/tokens/src/confidential/verifier/mod.rs @@ -92,7 +92,11 @@ pub use storage::{ /// Identifier of a zero-knowledge circuit whose verification key is stored in /// the registry. The numeric values are part of the on-chain interface and -/// MUST NOT change. +/// MUST NOT change. Discriminant 5 is retired — it belonged to a +/// `RevokeSpender` circuit before revocation became proofless — and is never +/// reassigned: a registry that still holds a key under it would otherwise hand +/// that key to a new circuit, and nothing in this module can detect the +/// mismatch (see the module-level warning on wrong keys). #[contracttype] #[derive(Copy, Clone, Debug, Eq, PartialEq)] #[repr(u32)] @@ -102,6 +106,7 @@ pub enum CircuitType { Transfer = 2, SpenderTransfer = 3, SetSpender = 4, + Clawback = 6, } /// Trait for managing UltraHonk verification keys used by the confidential diff --git a/packages/tokens/src/confidential/verifier/test.rs b/packages/tokens/src/confidential/verifier/test.rs index bd33daae4..5809a33bf 100644 --- a/packages/tokens/src/confidential/verifier/test.rs +++ b/packages/tokens/src/confidential/verifier/test.rs @@ -44,6 +44,7 @@ fn register_each_circuit_round_trips() { CircuitType::Transfer, CircuitType::SpenderTransfer, CircuitType::SetSpender, + CircuitType::Clawback, ] .into_iter() .enumerate() @@ -129,12 +130,12 @@ fn storage_key_round_trip() { e.as_contract(&address, || { let verification_key = verification_key_bytes(&e, 0x77); - register_verification_key(&e, CircuitType::SetSpender, &verification_key); + register_verification_key(&e, CircuitType::Clawback, &verification_key); let stored: Bytes = e .storage() .instance() - .get(&VerifierStorageKey::VerificationKey(CircuitType::SetSpender)) + .get(&VerifierStorageKey::VerificationKey(CircuitType::Clawback)) .unwrap(); assert_eq!(stored, verification_key); });