From ac3e879e677ceb40a061dd994ac92cc407f45b8f Mon Sep 17 00:00:00 2001 From: brozorec <9572072+brozorec@users.noreply.github.com> Date: Fri, 22 May 2026 12:02:02 +0200 Subject: [PATCH 01/15] feat(confidential): per-channel auditor sponge primitives Replaces the per-plaintext encrypt_auditor_tx / encrypt_auditor_bal helpers with the per-channel Poseidon2 sponge described in design doc v0.5 -> v0.6 (Section 2.5, Section 8.1). One absorb of (delta_channel, S.x, sigma) is followed by N squeezes from the same permutation; rate=3 lets every current call serve all squeezes without a second permutation. AUDITOR_TX and AUDITOR_BALANCE domain tags are dropped in favour of AUDITOR_SENDER (delta_aud_s) and AUDITOR_RECIPIENT (delta_aud_r) at the same numeric slots (10, 11). Adds the sponge_squeeze_2 gadget for nargo info accounting and pins the new sponge outputs in testdata/sponge_squeeze_{1,2}.json. The constraints baseline gains one row; no existing primitive's output changed. --- .../src/confidential/circuits/Nargo.toml | 1 + .../circuits/constraints.baseline | 1 + .../gadgets/sponge_squeeze_2/Nargo.toml | 8 + .../gadgets/sponge_squeeze_2/src/main.nr | 11 ++ .../src/confidential/circuits/lib/src/lib.nr | 67 ++++++--- .../confidential/circuits/lib/src/tests.nr | 142 ++++++++++++++---- .../lib/testdata/encrypt_auditor_bal.json | 11 -- .../lib/testdata/encrypt_auditor_tx.json | 11 -- .../lib/testdata/sponge_squeeze_1.json | 11 ++ .../lib/testdata/sponge_squeeze_2.json | 21 +++ 10 files changed, 208 insertions(+), 76 deletions(-) create mode 100644 packages/tokens/src/confidential/circuits/gadgets/sponge_squeeze_2/Nargo.toml create mode 100644 packages/tokens/src/confidential/circuits/gadgets/sponge_squeeze_2/src/main.nr delete mode 100644 packages/tokens/src/confidential/circuits/lib/testdata/encrypt_auditor_bal.json delete mode 100644 packages/tokens/src/confidential/circuits/lib/testdata/encrypt_auditor_tx.json create mode 100644 packages/tokens/src/confidential/circuits/lib/testdata/sponge_squeeze_1.json create mode 100644 packages/tokens/src/confidential/circuits/lib/testdata/sponge_squeeze_2.json diff --git a/packages/tokens/src/confidential/circuits/Nargo.toml b/packages/tokens/src/confidential/circuits/Nargo.toml index 71cabcb62..31245f543 100644 --- a/packages/tokens/src/confidential/circuits/Nargo.toml +++ b/packages/tokens/src/confidential/circuits/Nargo.toml @@ -9,4 +9,5 @@ members = [ "gadgets/encrypt_amount", "gadgets/range_128", "gadgets/poseidon_with_domain", + "gadgets/sponge_squeeze_2", ] diff --git a/packages/tokens/src/confidential/circuits/constraints.baseline b/packages/tokens/src/confidential/circuits/constraints.baseline index 30230740a..7f55cb966 100644 --- a/packages/tokens/src/confidential/circuits/constraints.baseline +++ b/packages/tokens/src/confidential/circuits/constraints.baseline @@ -34,4 +34,5 @@ | gadget_range_128 | directive_integer_quotient | N/A | N/A | 8 | | gadget_range_128 | directive_invert | N/A | N/A | 9 | | gadget_range_128 | main | Bounded { width: 4 } | 14 | 17 | +| gadget_sponge_squeeze_2 | main | Bounded { width: 4 } | 4 | 0 | | gadget_vk_from_sk | main | Bounded { width: 4 } | 4 | 0 | diff --git a/packages/tokens/src/confidential/circuits/gadgets/sponge_squeeze_2/Nargo.toml b/packages/tokens/src/confidential/circuits/gadgets/sponge_squeeze_2/Nargo.toml new file mode 100644 index 000000000..766e33042 --- /dev/null +++ b/packages/tokens/src/confidential/circuits/gadgets/sponge_squeeze_2/Nargo.toml @@ -0,0 +1,8 @@ +[package] +name = "gadget_sponge_squeeze_2" +type = "bin" +authors = ["OpenZeppelin"] +compiler_version = ">=0.30.0" + +[dependencies] +stellar_confidential_lib = { path = "../../lib" } diff --git a/packages/tokens/src/confidential/circuits/gadgets/sponge_squeeze_2/src/main.nr b/packages/tokens/src/confidential/circuits/gadgets/sponge_squeeze_2/src/main.nr new file mode 100644 index 000000000..df766bf48 --- /dev/null +++ b/packages/tokens/src/confidential/circuits/gadgets/sponge_squeeze_2/src/main.nr @@ -0,0 +1,11 @@ +use stellar_confidential_lib::sponge_squeeze_2; + +// Minimal circuit wrapping `sponge_squeeze_2(d, s_x, sigma) -> [Field; 2]`. +// Used by `nargo info` to report the per-call constraint cost of the +// two-squeeze auditor channel sponge (Section 2.5). The one-squeeze case +// shares the `poseidon_with_domain` gadget already in this directory. +fn main(d: pub Field, s_x: pub Field, sigma: pub Field, m0: pub Field, m1: pub Field) { + let out = sponge_squeeze_2(d, s_x, sigma); + assert(out[0] == m0); + assert(out[1] == m1); +} diff --git a/packages/tokens/src/confidential/circuits/lib/src/lib.nr b/packages/tokens/src/confidential/circuits/lib/src/lib.nr index e686aacaf..639d7a9a9 100644 --- a/packages/tokens/src/confidential/circuits/lib/src/lib.nr +++ b/packages/tokens/src/confidential/circuits/lib/src/lib.nr @@ -86,8 +86,8 @@ global POSEIDON2_IV_BASE: Field = 18446744073709551616; // 2^64 /// | `delta_enc_allow` | 7 | `ENCRYPTED_ALLOWANCE` | /// | `delta_allow_r` | 8 | `ALLOWANCE_RANDOMNESS` | /// | `delta_esc_dvk` | 9 | `ESCROWED_DELEGATION_VIEWING_KEY` | -/// | `delta_aud_tx` | 10 | `AUDITOR_TX` | -/// | `delta_aud_bal` | 11 | `AUDITOR_BALANCE` | +/// | `delta_aud_s` | 10 | `AUDITOR_SENDER` | +/// | `delta_aud_r` | 11 | `AUDITOR_RECIPIENT` | mod domain { /// Viewing-key derivation: `vk = Poseidon2(VIEWING_KEY, sk, wrap)`. /// Section 4.2 (`delta_vk`). @@ -120,14 +120,16 @@ mod domain { /// `Poseidon2(ESCROWED_DELEGATION_VIEWING_KEY, s, op_i)`. /// Constraint S12, Section 7.11 (`delta_esc_dvk`). pub(crate) global ESCROWED_DELEGATION_VIEWING_KEY: Field = 9; - /// Auditor mask for transfer amount: - /// `v_tilde_aud = v_tx + Poseidon2(AUDITOR_TX, s_a, sigma)`. - /// Constraints T_a2 / T_a4 / O_a2 / O_a4 / S_a3 / V_a3 (`delta_aud_tx`). - pub(crate) global AUDITOR_TX: Field = 10; - /// Auditor mask for balance / allowance checkpoint: - /// `b_tilde_aud = v_new + Poseidon2(AUDITOR_BALANCE, s_a, sigma)`. - /// Constraints W_a3 / T_a5 / S_a4 / V_a4 / O_a5 (`delta_aud_bal`). - pub(crate) global AUDITOR_BALANCE: Field = 11; + /// Sender or owner-auditor channel tag for Poseidon2 sponge masks + /// (Section 2.5, Section 8.1). Squeeze 1 yields the amount mask (where + /// applicable); squeeze 2 yields the balance/allowance checkpoint mask. + /// Constraints W_a3 / T_a6 / S_a3 / V_a3 / O_a6 (`delta_aud_s`). + pub(crate) global AUDITOR_SENDER: Field = 10; + /// Recipient-auditor channel tag for Poseidon2 sponge masks + /// (Section 2.5, Section 8.1). Squeeze 1 yields the amount mask; squeeze + /// 2 yields the per-transfer Pedersen randomness mask. + /// Constraints T_a2 / O_a2 (`delta_aud_r`). + pub(crate) global AUDITOR_RECIPIENT: Field = 11; } // ################## CORE PRIMITIVES ################## @@ -299,22 +301,39 @@ pub fn encrypt_allowance(v_a: Field, dvk: Field, sigma_a: Field) -> Field { v_a + poseidon_with_domain(domain::ENCRYPTED_ALLOWANCE, [dvk, sigma_a]) } -/// Encrypts the transfer amount under an auditor's ECDH shared secret. -/// Constraints T_a2 / T_a4 / O_a2 / O_a4 / S_a3 / V_a3. -/// `v_tilde_aud = v_tx + Poseidon2(AUD_TX, s_a, sigma)`. -pub fn encrypt_auditor_tx(v_tx: Field, s_a: Field, sigma: Field) -> Field { - v_tx + poseidon_with_domain(domain::AUDITOR_TX, [s_a, sigma]) -} - -/// Encrypts the post-operation balance (or allowance) under an auditor's ECDH -/// shared secret. Constraints W_a3 / T_a5 / S_a4 / V_a4 / O_a5. -/// `b_tilde_aud = v_new + Poseidon2(AUD_BAL, s_a, sigma)`. -pub fn encrypt_auditor_bal(v_new: Field, s_a: Field, sigma: Field) -> Field { - v_new + poseidon_with_domain(domain::AUDITOR_BALANCE, [s_a, sigma]) -} - /// Delegation-key escrow mask for the operator handoff at `set_operator`. Section 7.11. /// `escrowed_dvk = dvk + Poseidon2(ESC_DVK, s, op_i)` where `s = (r_e * Y_op).x`. pub fn encrypt_esc_dvk(dvk: Field, s: Field, op_i: Field) -> Field { dvk + poseidon_with_domain(domain::ESCROWED_DELEGATION_VIEWING_KEY, [s, op_i]) } + +// ################## AUDITOR-CHANNEL SPONGE ################## + +/// Poseidon2 sponge with a single squeeze. Absorbs `(d, s_x, sigma)` and +/// returns the first rate-element of the post-permutation state. +/// +/// Equivalent to `poseidon_with_domain(d, [s_x, sigma])` -- the alias exists so +/// design-doc constraints written as `m = SpongeSqueeze_1(delta, S.x, sigma)` +/// (Section 2.5) translate 1:1 to source. The single-squeeze case is the +/// sender-auditor channel for Withdraw (W_a3), RevokeOperator (V_a3), and +/// SetOperator (S_a3), which need only the balance/allowance mask. +pub fn sponge_squeeze_1(d: Field, s_x: Field, sigma: Field) -> Field { + poseidon_with_domain(d, [s_x, sigma]) +} + +/// Poseidon2 sponge with two squeezes. Absorbs `(d, s_x, sigma)` and returns +/// the first two rate-elements of the post-permutation state in order. +/// +/// Implements `SpongeSqueeze_2(delta, S.x, sigma)` from Section 2.5. The canonical +/// squeeze order is: index 0 = amount mask, index 1 = balance / randomness +/// mask, fixed by Sections 7 and 8. With rate = 3 the absorb fits one block +/// and both outputs are served from the same permutation, so the constraint +/// cost is exactly one Poseidon2 permutation per call. +/// +/// Used by: Transfer (T_a2 recipient-auditor amount + randomness, T_a6 +/// sender-auditor amount + balance) and OperatorTransfer (O_a2, O_a6). +pub fn sponge_squeeze_2(d: Field, s_x: Field, sigma: Field) -> [Field; 2] { + let iv: Field = 3 * POSEIDON2_IV_BASE; + let state = poseidon2_permutation([d, s_x, sigma, iv], 4); + [state[0], state[1]] +} diff --git a/packages/tokens/src/confidential/circuits/lib/src/tests.nr b/packages/tokens/src/confidential/circuits/lib/src/tests.nr index fa5f75c3e..92bca9b01 100644 --- a/packages/tokens/src/confidential/circuits/lib/src/tests.nr +++ b/packages/tokens/src/confidential/circuits/lib/src/tests.nr @@ -1,8 +1,8 @@ use crate::{ assert_on_curve, assert_on_curve_non_identity, assert_range_128, commit, derive_allow_r, derive_spend_r, derive_tx_blind, domain, dvk_from_vk_op, ecdh, encrypt_allowance, - encrypt_amount, encrypt_auditor_bal, encrypt_auditor_tx, encrypt_balance, encrypt_esc_dvk, - G, H, poseidon_with_domain, pvk_from_vk, scalar_mul, vk_from_sk, + encrypt_amount, encrypt_balance, encrypt_esc_dvk, G, H, poseidon_with_domain, pvk_from_vk, + scalar_mul, sponge_squeeze_1, sponge_squeeze_2, vk_from_sk, }; use std::embedded_curve_ops::EmbeddedCurvePoint; use std::hash::{derive_generators, pedersen_commitment}; @@ -185,8 +185,8 @@ fn domain_separation_distinct() { let h_ea = poseidon_with_domain(domain::ENCRYPTED_ALLOWANCE, [a, b]); let h_ar = poseidon_with_domain(domain::ALLOWANCE_RANDOMNESS, [a, b]); let h_ed = poseidon_with_domain(domain::ESCROWED_DELEGATION_VIEWING_KEY, [a, b]); - let h_at = poseidon_with_domain(domain::AUDITOR_TX, [a, b]); - let h_ab = poseidon_with_domain(domain::AUDITOR_BALANCE, [a, b]); + let h_as = poseidon_with_domain(domain::AUDITOR_SENDER, [a, b]); + let h_ar2 = poseidon_with_domain(domain::AUDITOR_RECIPIENT, [a, b]); // Distinct from VK. assert(h_vk != h_dvk); @@ -197,8 +197,13 @@ fn domain_separation_distinct() { assert(h_vk != h_ea); assert(h_vk != h_ar); assert(h_vk != h_ed); - assert(h_vk != h_at); - assert(h_vk != h_ab); + assert(h_vk != h_as); + assert(h_vk != h_ar2); + + // The two auditor channels must yield distinct masks under identical + // inputs -- if they collide, the same shared-secret `S.x` would leak the + // same ciphertext on both channels, defeating channel separation. + assert(h_as != h_ar2); } #[test] @@ -222,19 +227,63 @@ fn encrypt_decrypt_round_trip() { let mask_allow = poseidon_with_domain(domain::ENCRYPTED_ALLOWANCE, [s, sigma]); assert(a_tilde - mask_allow == v); - let v_tilde_aud = encrypt_auditor_tx(v, s, sigma); - let mask_aud_tx = poseidon_with_domain(domain::AUDITOR_TX, [s, sigma]); - assert(v_tilde_aud - mask_aud_tx == v); - - let b_tilde_aud = encrypt_auditor_bal(v, s, sigma); - let mask_aud_bal = poseidon_with_domain(domain::AUDITOR_BALANCE, [s, sigma]); - assert(b_tilde_aud - mask_aud_bal == v); - let dvk_escrowed = encrypt_esc_dvk(v, s, sigma); let mask_esc = poseidon_with_domain(domain::ESCROWED_DELEGATION_VIEWING_KEY, [s, sigma]); assert(dvk_escrowed - mask_esc == v); } +#[test] +fn sponge_squeeze_1_matches_poseidon_with_domain() { + // sponge_squeeze_1 is documented as an alias of poseidon_with_domain on + // a 2-element payload; the canonical-form check here is what lets every + // SpongeSqueeze_1(...) call site in the design doc translate to either + // helper without ambiguity. + let d: Field = domain::AUDITOR_SENDER; + let s_x: Field = 0xfeed; + let sigma: Field = 0xbeef; + assert(sponge_squeeze_1(d, s_x, sigma) == poseidon_with_domain(d, [s_x, sigma])); +} + +#[test] +fn sponge_squeeze_2_first_matches_squeeze_1() { + // SpongeSqueeze_n shares a single absorb (Section 2.5); n=1 and n=2 must + // produce identical first masks. If this diverges, a circuit doing + // SpongeSqueeze_1 cannot be substituted for the first output of + // SpongeSqueeze_2 and vice versa. + let d: Field = domain::AUDITOR_RECIPIENT; + let s_x: Field = 0x12345; + let sigma: Field = 0x6789; + let m2 = sponge_squeeze_2(d, s_x, sigma); + assert(m2[0] == sponge_squeeze_1(d, s_x, sigma)); +} + +#[test] +fn sponge_squeeze_2_outputs_distinct() { + // The two squeezed masks come from different rate positions of the same + // permutation. They must differ -- a collision would let an attacker who + // recovers one mask recover the other, breaking the per-ciphertext + // independence assumption that justifies the dual-mask construction + // (Section 8.1, the recipient-auditor ciphertext pair). + let d: Field = domain::AUDITOR_SENDER; + let s_x: Field = 0x42; + let sigma: Field = 0x07; + let m = sponge_squeeze_2(d, s_x, sigma); + assert(m[0] != m[1]); +} + +#[test] +fn sponge_squeeze_channel_separation() { + // Same (S.x, sigma) under the two channel domains must produce different + // mask pairs. This is the property that makes a leaked sender-channel + // ciphertext useless for decrypting the recipient channel and vice versa. + let s_x: Field = 0xcafe; + let sigma: Field = 0xdead; + let m_s = sponge_squeeze_2(domain::AUDITOR_SENDER, s_x, sigma); + let m_r = sponge_squeeze_2(domain::AUDITOR_RECIPIENT, s_x, sigma); + assert(m_s[0] != m_r[0]); + assert(m_s[1] != m_r[1]); +} + /// One-shot fixture-generation harness. Prints every primitive's output for a /// deterministic input set. Captured into `testdata/*.json` (the cross-language /// contract for the SDK) and mirrored in `fixtures_match_testdata` below. Run @@ -311,18 +360,28 @@ fn print_fixtures() { let a_tilde = encrypt_allowance(v_a, dvk, sigma_a); println(f"encrypt_allowance = {a_tilde}"); - // encrypt_auditor_tx(v_tx, s, sigma) - let v_aud = encrypt_auditor_tx(v_tx, s_ecdh, sigma); - println(f"encrypt_auditor_tx = {v_aud}"); - - // encrypt_auditor_bal(v, s, sigma) - let b_aud = encrypt_auditor_bal(v, s_ecdh, sigma); - println(f"encrypt_auditor_bal = {b_aud}"); - // encrypt_esc_dvk(dvk, s, op_i) let esc = encrypt_esc_dvk(dvk, s_ecdh, op_i); println(f"encrypt_esc_dvk = {esc}"); + // sponge_squeeze_1(AUDITOR_SENDER, s, sigma) + let ss1_s = sponge_squeeze_1(domain::AUDITOR_SENDER, s_ecdh, sigma); + println(f"sponge_squeeze_1_AUDITOR_SENDER = {ss1_s}"); + + // sponge_squeeze_2(AUDITOR_SENDER, s, sigma) -- (amount, balance) masks + let ss2_s = sponge_squeeze_2(domain::AUDITOR_SENDER, s_ecdh, sigma); + let ss2_s0 = ss2_s[0]; + let ss2_s1 = ss2_s[1]; + println(f"sponge_squeeze_2_AUDITOR_SENDER_0 = {ss2_s0}"); + println(f"sponge_squeeze_2_AUDITOR_SENDER_1 = {ss2_s1}"); + + // sponge_squeeze_2(AUDITOR_RECIPIENT, s, sigma) -- (amount, r_tx) masks + let ss2_r = sponge_squeeze_2(domain::AUDITOR_RECIPIENT, s_ecdh, sigma); + let ss2_r0 = ss2_r[0]; + let ss2_r1 = ss2_r[1]; + println(f"sponge_squeeze_2_AUDITOR_RECIPIENT_0 = {ss2_r0}"); + println(f"sponge_squeeze_2_AUDITOR_RECIPIENT_1 = {ss2_r1}"); + // poseidon_with_domain(VK, [sk, wrap]) -- direct funnel access, equals vk_from_sk let pwd = poseidon_with_domain(domain::VIEWING_KEY, [sk, wrap]); println(f"poseidon_with_domain_VK_2 = {pwd}"); @@ -394,23 +453,46 @@ fn fixtures_match_testdata() { encrypt_allowance(v_a, dvk, sigma_a) == 0x235db43a283e4e000337cdb80a99168ef4b718622cc06de4ab98b3d2bd5736d5, ); - assert( - encrypt_auditor_tx(v_tx, s_ecdh, sigma) - == 0x266e76dccd3d7c654ab7a48045cca920c497b1a6758981acfa6f217427ac30d8, - ); - assert( - encrypt_auditor_bal(v, s_ecdh, sigma) - == 0x2787d1e01bbca7828e13e9b2b3fa11cfcfe9c3d2b121b9e17543146822fe23d3, - ); assert( encrypt_esc_dvk(dvk, s_ecdh, op_i) == 0x2468d02775ad21aa5928d049770287249af2a03dbaf4d18e22e3fe71fd292003, ); + // Per-channel auditor sponge (v0.6, Section 2.5). Values pinned below are + // produced by `nargo test print_fixtures` against the same fixture inputs. + assert( + sponge_squeeze_1(domain::AUDITOR_SENDER, s_ecdh, sigma) + == SPONGE_SQUEEZE_1_AUDITOR_SENDER, + ); + let ss2_s = sponge_squeeze_2(domain::AUDITOR_SENDER, s_ecdh, sigma); + assert(ss2_s[0] == SPONGE_SQUEEZE_2_AUDITOR_SENDER_0); + assert(ss2_s[1] == SPONGE_SQUEEZE_2_AUDITOR_SENDER_1); + let ss2_r = sponge_squeeze_2(domain::AUDITOR_RECIPIENT, s_ecdh, sigma); + assert(ss2_r[0] == SPONGE_SQUEEZE_2_AUDITOR_RECIPIENT_0); + assert(ss2_r[1] == SPONGE_SQUEEZE_2_AUDITOR_RECIPIENT_1); + + // Cross-form invariant: SpongeSqueeze_1 must equal the first squeeze of + // SpongeSqueeze_2 on the same absorb (single permutation, same state[0]). + assert(ss2_s[0] == sponge_squeeze_1(domain::AUDITOR_SENDER, s_ecdh, sigma)); + // poseidon_with_domain funnel is the same operation that backs vk_from_sk. assert(poseidon_with_domain(domain::VIEWING_KEY, [sk, wrap]) == vk); } +// Sponge fixture outputs -- pinned by `print_fixtures` and mirrored in +// `testdata/sponge_squeeze_*.json`. Update both sides together if the lib's +// sponge construction or domain constants change. +global SPONGE_SQUEEZE_1_AUDITOR_SENDER: Field = + 0x266e76dccd3d7c654ab7a48045cca920c497b1a6758981acfa6f217427ac3074; +global SPONGE_SQUEEZE_2_AUDITOR_SENDER_0: Field = + 0x266e76dccd3d7c654ab7a48045cca920c497b1a6758981acfa6f217427ac3074; +global SPONGE_SQUEEZE_2_AUDITOR_SENDER_1: Field = + 0x248b757f9fec92895bd3fb91b1a5c9c17467df6f0f55ab574d85e943df8e5cf3; +global SPONGE_SQUEEZE_2_AUDITOR_RECIPIENT_0: Field = + 0x2787d1e01bbca7828e13e9b2b3fa11cfcfe9c3d2b121b9e17543146822fe1feb; +global SPONGE_SQUEEZE_2_AUDITOR_RECIPIENT_1: Field = + 0x27f3739a132c6353cd5af3edac0ac75faf7fc606acb61367774e4f764ec17b77; + #[test] fn derive_helpers_deterministic_and_distinct() { // derive_spend_r, derive_allow_r, derive_tx_blind must each be diff --git a/packages/tokens/src/confidential/circuits/lib/testdata/encrypt_auditor_bal.json b/packages/tokens/src/confidential/circuits/lib/testdata/encrypt_auditor_bal.json deleted file mode 100644 index 22f456547..000000000 --- a/packages/tokens/src/confidential/circuits/lib/testdata/encrypt_auditor_bal.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "primitive": "encrypt_auditor_bal", - "design_doc_refs": ["Constraint W_a3", "Constraint T_a5", "Constraint S_a4", "Constraint V_a4", "Constraint O_a5"], - "description": "Auditor-encrypted balance checkpoint: b_tilde_aud = v_new + Poseidon2(AUD_BAL, s_a, sigma).", - "vectors": [ - { - "inputs": { "v_new": "0x3e8", "s_a": "0x12345", "sigma": "0x01" }, - "output": "0x2787d1e01bbca7828e13e9b2b3fa11cfcfe9c3d2b121b9e17543146822fe23d3" - } - ] -} diff --git a/packages/tokens/src/confidential/circuits/lib/testdata/encrypt_auditor_tx.json b/packages/tokens/src/confidential/circuits/lib/testdata/encrypt_auditor_tx.json deleted file mode 100644 index 33dc4ed7d..000000000 --- a/packages/tokens/src/confidential/circuits/lib/testdata/encrypt_auditor_tx.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "primitive": "encrypt_auditor_tx", - "design_doc_refs": ["Constraint T_a2", "Constraint T_a4", "Constraint O_a2", "Constraint O_a4", "Constraint S_a3", "Constraint V_a3"], - "description": "Auditor-encrypted transfer amount: v_tilde_aud = v_tx + Poseidon2(AUD_TX, s_a, sigma).", - "vectors": [ - { - "inputs": { "v_tx": "0x64", "s_a": "0x12345", "sigma": "0x01" }, - "output": "0x266e76dccd3d7c654ab7a48045cca920c497b1a6758981acfa6f217427ac30d8" - } - ] -} diff --git a/packages/tokens/src/confidential/circuits/lib/testdata/sponge_squeeze_1.json b/packages/tokens/src/confidential/circuits/lib/testdata/sponge_squeeze_1.json new file mode 100644 index 000000000..741608e1d --- /dev/null +++ b/packages/tokens/src/confidential/circuits/lib/testdata/sponge_squeeze_1.json @@ -0,0 +1,11 @@ +{ + "primitive": "sponge_squeeze_1", + "design_doc_refs": ["Section 2.5", "Constraint W_a3", "Constraint S_a3", "Constraint V_a3"], + "description": "Poseidon2 sponge with a single squeeze (one absorb of (d, s_x, sigma), permute, output state[0]). Used by sender-auditor channels that emit only the balance/allowance mask. Equivalent to poseidon_with_domain(d, [s_x, sigma]).", + "vectors": [ + { + "inputs": { "d": "0x0a", "s_x": "0x12345", "sigma": "0x01" }, + "output": "0x266e76dccd3d7c654ab7a48045cca920c497b1a6758981acfa6f217427ac3074" + } + ] +} diff --git a/packages/tokens/src/confidential/circuits/lib/testdata/sponge_squeeze_2.json b/packages/tokens/src/confidential/circuits/lib/testdata/sponge_squeeze_2.json new file mode 100644 index 000000000..d95baa829 --- /dev/null +++ b/packages/tokens/src/confidential/circuits/lib/testdata/sponge_squeeze_2.json @@ -0,0 +1,21 @@ +{ + "primitive": "sponge_squeeze_2", + "design_doc_refs": ["Section 2.5", "Constraint T_a2", "Constraint T_a6", "Constraint O_a2", "Constraint O_a6"], + "description": "Poseidon2 sponge with two squeezes (one absorb of (d, s_x, sigma), permute, output (state[0], state[1])). Canonical squeeze order: index 0 = amount mask, index 1 = balance / per-transfer Pedersen randomness mask, fixed per design doc Sections 7 and 8. The sender-auditor channel uses d = AUDITOR_SENDER (10); the recipient-auditor channel uses d = AUDITOR_RECIPIENT (11). The first squeeze must match sponge_squeeze_1 on the same inputs.", + "vectors": [ + { + "inputs": { "d": "0x0a", "s_x": "0x12345", "sigma": "0x01" }, + "output": [ + "0x266e76dccd3d7c654ab7a48045cca920c497b1a6758981acfa6f217427ac3074", + "0x248b757f9fec92895bd3fb91b1a5c9c17467df6f0f55ab574d85e943df8e5cf3" + ] + }, + { + "inputs": { "d": "0x0b", "s_x": "0x12345", "sigma": "0x01" }, + "output": [ + "0x2787d1e01bbca7828e13e9b2b3fa11cfcfe9c3d2b121b9e17543146822fe1feb", + "0x27f3739a132c6353cd5af3edac0ac75faf7fc606acb61367774e4f764ec17b77" + ] + } + ] +} From c29f874ab1f5fd0b8d46feb9c0dfa6d4a63e43fc Mon Sep 17 00:00:00 2001 From: brozorec <9572072+brozorec@users.noreply.github.com> Date: Fri, 22 May 2026 12:08:56 +0200 Subject: [PATCH 02/15] feat(confidential): withdraw circuit main constraints (W1-W7) Implements the spend-side block of the Withdraw circuit from design doc Section 7.5: owner key ownership (W1), wrapper-bound viewing key (W2), spendable balance opening (W3), range validity on v, a, and v - a (W4), deterministic randomness for the new commitment (W5), the refreshed spendable commitment (W6), and the encrypted balance scalar (W7). The auditor block (W_a1-W_a5, K_aud_s + r_e + R_e + b_tilde_aud_s) lands in a follow-up commit so this one stays focused on balance conservation and key ownership; once that lands the public input signature grows from 10 to the design doc's frozen 15 fields. Tests reuse the lib's pinned (sk, wrap, sigma, v, r) fixtures so any drift in commit / vk_from_sk / encrypt_balance / derive_spend_r is caught by `withdraw_fixtures_match_lib` before the rest of the harness runs. Negative coverage: under-funded withdrawal (W4), v or a out of range, wrong sk (W1), wrong balance opening (W3), wrong wrap (W2/W6/W7 chain), tampered b_tilde (W7), tampered C_spend' (W6). VK extraction script and constraints baseline updated. --- .../src/confidential/circuits/Nargo.toml | 1 + .../circuits/constraints.baseline | 5 + .../circuits/scripts/extract_vks.sh | 1 + .../confidential/circuits/withdraw/Nargo.toml | 8 + .../circuits/withdraw/src/main.nr | 103 ++++++ .../circuits/withdraw/src/tests.nr | 303 ++++++++++++++++++ 6 files changed, 421 insertions(+) create mode 100644 packages/tokens/src/confidential/circuits/withdraw/Nargo.toml create mode 100644 packages/tokens/src/confidential/circuits/withdraw/src/main.nr create mode 100644 packages/tokens/src/confidential/circuits/withdraw/src/tests.nr diff --git a/packages/tokens/src/confidential/circuits/Nargo.toml b/packages/tokens/src/confidential/circuits/Nargo.toml index 31245f543..49acde248 100644 --- a/packages/tokens/src/confidential/circuits/Nargo.toml +++ b/packages/tokens/src/confidential/circuits/Nargo.toml @@ -2,6 +2,7 @@ members = [ "lib", "register", + "withdraw", "gadgets/assert_on_curve", "gadgets/commit", "gadgets/ecdh", diff --git a/packages/tokens/src/confidential/circuits/constraints.baseline b/packages/tokens/src/confidential/circuits/constraints.baseline index 7f55cb966..eadf61687 100644 --- a/packages/tokens/src/confidential/circuits/constraints.baseline +++ b/packages/tokens/src/confidential/circuits/constraints.baseline @@ -22,6 +22,11 @@ | circuit_register | directive_invert | N/A | N/A | 9 | | circuit_register | lte_hint | N/A | N/A | 33 | | circuit_register | main | Bounded { width: 4 } | 33 | 72 | +| circuit_withdraw | decompose_hint | N/A | N/A | 30 | +| circuit_withdraw | directive_integer_quotient | N/A | N/A | 8 | +| circuit_withdraw | directive_invert | N/A | N/A | 9 | +| circuit_withdraw | lte_hint | N/A | N/A | 33 | +| circuit_withdraw | main | Bounded { width: 4 } | 109 | 80 | | gadget_assert_on_curve | main | Bounded { width: 4 } | 2 | 0 | | gadget_commit | decompose_hint | N/A | N/A | 30 | | gadget_commit | lte_hint | N/A | N/A | 33 | diff --git a/packages/tokens/src/confidential/circuits/scripts/extract_vks.sh b/packages/tokens/src/confidential/circuits/scripts/extract_vks.sh index 5244befb9..f9a5fca9e 100755 --- a/packages/tokens/src/confidential/circuits/scripts/extract_vks.sh +++ b/packages/tokens/src/confidential/circuits/scripts/extract_vks.sh @@ -23,6 +23,7 @@ cd "$(dirname "$0")/.." CIRCUITS=( "register" + "withdraw" ) OUT_DIR="vks" diff --git a/packages/tokens/src/confidential/circuits/withdraw/Nargo.toml b/packages/tokens/src/confidential/circuits/withdraw/Nargo.toml new file mode 100644 index 000000000..47b30c97e --- /dev/null +++ b/packages/tokens/src/confidential/circuits/withdraw/Nargo.toml @@ -0,0 +1,8 @@ +[package] +name = "circuit_withdraw" +type = "bin" +authors = ["OpenZeppelin"] +compiler_version = ">=0.30.0" + +[dependencies] +stellar_confidential_lib = { path = "../lib" } diff --git a/packages/tokens/src/confidential/circuits/withdraw/src/main.nr b/packages/tokens/src/confidential/circuits/withdraw/src/main.nr new file mode 100644 index 000000000..ebafd70fa --- /dev/null +++ b/packages/tokens/src/confidential/circuits/withdraw/src/main.nr @@ -0,0 +1,103 @@ +use stellar_confidential_lib::{ + H, assert_range_128, commit, derive_spend_r, encrypt_balance, scalar_mul, vk_from_sk, +}; + +mod tests; + +// Withdraw circuit -- design doc Section 7.5. +// +// Constraints (main block W1-W7; auditor block W_a1-W_a5 follows) +// --------------------------------------------------------------- +// W1 Y = sk * H owner key ownership +// W2 vk = Poseidon2(delta_vk, sk, wrap) wrapper-bound viewing +// key +// W3 C_spend = v * G + r * H opening of current +// spendable balance +// W4 v, a, v - a in [0, 2^128) range validity +// (Section 2.6) +// W5 r' = Poseidon2(delta_spend_r, vk, sigma) deterministic +// randomness for the +// new balance +// W6 C_spend' = (v - a) * G + r' * H new spendable +// commitment +// W7 b_tilde = (v - a) +// + Poseidon2(delta_enc_bal, vk, sigma) +// encrypted balance +// scalar (emitted) +// +// On-curve checks for Y, C_spend, and C_spend' are not needed: all three +// are bound to in-circuit multi_scalar_mul outputs (Y in W1, C_spend in W3, +// C_spend' in W6), which are on-curve by construction. See design doc +// Section 10.8 for the system-wide point-validation doctrine. +// +// Public inputs (this block: 10 fields; the auditor block will add 5 more +// in design-doc canonical order before this is shipped end-to-end) +// --------------------------------------------------------------------- +// Idx Param Symbol Source / Note +// --- ----- ------ --------------------------------------- +// 0 c_spend_x C_spend.x Loaded from `from.spendable_balance`. +// 1 c_spend_y C_spend.y +// 2 y_x Y.x Loaded from `from.spending_key`. +// 3 y_y Y.y +// 4 wrap wrap `env.current_contract_address()`. +// 5 a a Public withdrawal amount; the wrapper +// pre-checks a >= 0 at the entrypoint, +// W4 then closes a < 2^128 in-circuit. +// 6 c_spend_new_x C_spend'.x Prover-supplied; written to +// 7 c_spend_new_y C_spend'.y `from.spendable_balance` on success. +// 8 sigma sigma Prover-supplied random salt; emitted. +// 9 b_tilde b_tilde Prover-supplied encrypted balance +// scalar; emitted. +// +// Private witnesses +// ----------------- +// sk Spending secret scalar. +// v Plaintext spendable-balance value. +// r Plaintext blinding factor for C_spend. + +fn main( + sk: Field, + v: Field, + r: Field, + c_spend_x: pub Field, + c_spend_y: pub Field, + y_x: pub Field, + y_y: pub Field, + wrap: pub Field, + a: pub Field, + c_spend_new_x: pub Field, + c_spend_new_y: pub Field, + sigma: pub Field, + b_tilde: pub Field, +) { + // W1 + let y_derived = scalar_mul(sk, H); + assert(y_derived.x == y_x); + assert(y_derived.y == y_y); + + // W2 + let vk = vk_from_sk(sk, wrap); + + // W3 + let c_spend_derived = commit(v, r); + assert(c_spend_derived.x == c_spend_x); + assert(c_spend_derived.y == c_spend_y); + + // W4 + assert_range_128(v); + assert_range_128(a); + let v_new = v - a; + assert_range_128(v_new); + + // W5 + let r_new = derive_spend_r(vk, sigma); + + // W6 + let c_spend_new_derived = commit(v_new, r_new); + assert(c_spend_new_derived.x == c_spend_new_x); + assert(c_spend_new_derived.y == c_spend_new_y); + + // W7 + let b_tilde_derived = encrypt_balance(v_new, vk, sigma); + assert(b_tilde_derived == b_tilde); +} diff --git a/packages/tokens/src/confidential/circuits/withdraw/src/tests.nr b/packages/tokens/src/confidential/circuits/withdraw/src/tests.nr new file mode 100644 index 000000000..a09d8ed4c --- /dev/null +++ b/packages/tokens/src/confidential/circuits/withdraw/src/tests.nr @@ -0,0 +1,303 @@ +use crate::main; +use stellar_confidential_lib::{commit, derive_spend_r, encrypt_balance, H, scalar_mul, vk_from_sk}; + +// Canonical fixture inputs. +// +// SK / WRAP / SIGMA / V / R reuse the lib's pinned vectors (lib/testdata) +// so that Y, C_spend, vk, r_new, and b_tilde are derivable from -- and +// pinned by -- the lib's `fixtures_match_testdata` test. A is chosen to +// be strictly less than V so the happy path exercises a non-trivial +// `v_new = v - a` while still satisfying W4. +global SK: Field = 0xdead; +global WRAP: Field = 0xbeef; +global SIGMA: Field = 0x01; +global V: Field = 1000; +global R: Field = 42; +global A: Field = 300; +global V_NEW: Field = 700; + +// Y = sk*H (= lib `scalar_mul_Y` fixture). +global Y_X: Field = 0x1b46b003b88a6c34549dc74115f088f4b231a151397526bc10cbf1d15b457646; +global Y_Y: Field = 0x29116280600c10ead1fdbd9ab4b571896030679bf554d7f1ebf681e5147de21b; + +// C_spend = commit(1000, 42) (= lib `commit` fixture). +global C_SPEND_X: Field = + 0x195a5d8ecd032fe1696054b28f0852b5f03a613681991ace823245ab2f97ed05; +global C_SPEND_Y: Field = + 0x0e67489ecfee0e581dce7db22a383c635886cfb616c3b3bb033bff0c46e9789e; + +// C_spend' and b_tilde are pinned by `withdraw_fixtures_match_lib` below, +// which re-derives them from the lib primitives and asserts the values +// declared here -- if either side drifts the test fails immediately. +global C_SPEND_NEW_X: Field = + 0x2fb2d785d71d4c62ce4130675a6c44020c03a236ab21450b6f20282549caadac; +global C_SPEND_NEW_Y: Field = + 0x069a978c07b99e97858a6e94cef5b3d2c49f1df0d2aa5e04dffcefaca9320814; +global B_TILDE: Field = + 0x2b7f92c72c05ae8ecf7207fdaecea32ef748cd4dd43ca9ed6dc6d226865b4283; + +#[test] +fn print_fixtures() { + // One-shot harness: prints C_spend', b_tilde for the chosen (SK, WRAP, + // SIGMA, V, R, A) tuple. Run with + // `nargo test --package circuit_withdraw print_fixtures --show-output` + // and paste the values into the C_SPEND_NEW_* / B_TILDE globals above. + let vk = vk_from_sk(SK, WRAP); + let r_new = derive_spend_r(vk, SIGMA); + let c_new = commit(V_NEW, r_new); + let b_tilde = encrypt_balance(V_NEW, vk, SIGMA); + let cx = c_new.x; + let cy = c_new.y; + println(f"C_SPEND_NEW_X = {cx}"); + println(f"C_SPEND_NEW_Y = {cy}"); + println(f"B_TILDE = {b_tilde}"); +} + +#[test] +fn withdraw_fixtures_match_lib() { + // Cross-check the hard-coded fixture values against the lib's + // primitives. If a lib primitive's output changes the fixture + // assertions break here -- which is exactly when the rest of the + // withdraw tests would silently start using stale public inputs. + let vk = vk_from_sk(SK, WRAP); + let r_new = derive_spend_r(vk, SIGMA); + let c_new = commit(V_NEW, r_new); + let b_tilde = encrypt_balance(V_NEW, vk, SIGMA); + let y = scalar_mul(SK, H); + let c_spend = commit(V, R); + + assert(c_new.x == C_SPEND_NEW_X); + assert(c_new.y == C_SPEND_NEW_Y); + assert(b_tilde == B_TILDE); + assert(y.x == Y_X); + assert(y.y == Y_Y); + assert(c_spend.x == C_SPEND_X); + assert(c_spend.y == C_SPEND_Y); +} + +#[test] +fn matches_fixture() { + main( + SK, + V, + R, + C_SPEND_X, + C_SPEND_Y, + Y_X, + Y_Y, + WRAP, + A, + C_SPEND_NEW_X, + C_SPEND_NEW_Y, + SIGMA, + B_TILDE, + ); +} + +#[test] +fn full_withdrawal() { + // Boundary: a = v, so v_new = 0. C_spend' commits to (0, r_new) which + // is a valid commitment to zero (and is non-identity because r_new is + // a Poseidon output, vanishingly unlikely to equal 0). W6 binds it. + let vk = vk_from_sk(SK, WRAP); + let r_new = derive_spend_r(vk, SIGMA); + let c_new = commit(0, r_new); + let b_tilde = encrypt_balance(0, vk, SIGMA); + main( + SK, + V, + R, + C_SPEND_X, + C_SPEND_Y, + Y_X, + Y_Y, + WRAP, + V, + c_new.x, + c_new.y, + SIGMA, + b_tilde, + ); +} + +#[test(should_fail)] +fn rejects_under_funded_withdrawal() { + // a > v: v - a underflows in the field to a value >= 2^128. W4's + // range check on v_new fires before any downstream constraint, so + // this is exactly the under-funded-withdraw soundness gap closed. + let a_too_large: Field = V + 1; + // C_spend_new is whatever the (necessarily wrong) prover supplies; + // W6 would also fail, but W4 should fail first. Pick C_spend_new for + // some valid v_new derivation just to keep the test focused on W4. + let vk = vk_from_sk(SK, WRAP); + let r_new = derive_spend_r(vk, SIGMA); + let c_new_invalid = commit(V - a_too_large, r_new); + let b_tilde_invalid = encrypt_balance(V - a_too_large, vk, SIGMA); + main( + SK, + V, + R, + C_SPEND_X, + C_SPEND_Y, + Y_X, + Y_Y, + WRAP, + a_too_large, + c_new_invalid.x, + c_new_invalid.y, + SIGMA, + b_tilde_invalid, + ); +} + +#[test(should_fail)] +fn rejects_v_out_of_range() { + // v claimed by the prover is >= 2^128: W4 rejects on the v check + // before W3 even looks at C_spend. + let v_huge: Field = 0x100000000000000000000000000000000; // 2^128 + main( + SK, + v_huge, + R, + C_SPEND_X, + C_SPEND_Y, + Y_X, + Y_Y, + WRAP, + A, + C_SPEND_NEW_X, + C_SPEND_NEW_Y, + SIGMA, + B_TILDE, + ); +} + +#[test(should_fail)] +fn rejects_a_out_of_range() { + // a >= 2^128: W4's range check on a fails. (The wrapper's a >= 0 + // entrypoint check is out-of-scope for the circuit; here we only + // close the upper bound.) + let a_huge: Field = 0x100000000000000000000000000000000; + main( + SK, + V, + R, + C_SPEND_X, + C_SPEND_Y, + Y_X, + Y_Y, + WRAP, + a_huge, + C_SPEND_NEW_X, + C_SPEND_NEW_Y, + SIGMA, + B_TILDE, + ); +} + +#[test(should_fail)] +fn rejects_wrong_sk() { + // sk does not derive the claimed Y: W1 fails. + main( + SK + 1, + V, + R, + C_SPEND_X, + C_SPEND_Y, + Y_X, + Y_Y, + WRAP, + A, + C_SPEND_NEW_X, + C_SPEND_NEW_Y, + SIGMA, + B_TILDE, + ); +} + +#[test(should_fail)] +fn rejects_wrong_balance_opening() { + // (v, r) supplied don't open C_spend: W3 fails. Use a v that still + // passes the W4 range check so the failure is squarely W3. + main( + SK, + V + 1, + R, + C_SPEND_X, + C_SPEND_Y, + Y_X, + Y_Y, + WRAP, + A, + C_SPEND_NEW_X, + C_SPEND_NEW_Y, + SIGMA, + B_TILDE, + ); +} + +#[test(should_fail)] +fn rejects_wrong_wrap() { + // wrap != WRAP under which Y was derived. vk recomputes under the + // new wrap, the new vk produces a different r_new (W5) and a + // different b_tilde (W7). W6 / W7 fire. (W1 still passes because Y + // is the prover-claimed Y and sk*H is recomputed against it without + // using wrap.) + main( + SK, + V, + R, + C_SPEND_X, + C_SPEND_Y, + Y_X, + Y_Y, + 0xcafe, + A, + C_SPEND_NEW_X, + C_SPEND_NEW_Y, + SIGMA, + B_TILDE, + ); +} + +#[test(should_fail)] +fn rejects_tampered_b_tilde() { + // b_tilde mutated by +1: W7 fails because encrypt_balance derives a + // specific scalar that no longer matches. + main( + SK, + V, + R, + C_SPEND_X, + C_SPEND_Y, + Y_X, + Y_Y, + WRAP, + A, + C_SPEND_NEW_X, + C_SPEND_NEW_Y, + SIGMA, + B_TILDE + 1, + ); +} + +#[test(should_fail)] +fn rejects_tampered_c_spend_new() { + // C_spend' supplied with wrong x: W6 fails because the prover's + // claim no longer matches the in-circuit (v - a)*G + r'*H. + main( + SK, + V, + R, + C_SPEND_X, + C_SPEND_Y, + Y_X, + Y_Y, + WRAP, + A, + C_SPEND_NEW_X + 1, + C_SPEND_NEW_Y, + SIGMA, + B_TILDE, + ); +} From 2ee2a6a41efc6d897d61872da27b207bd1e5fee8 Mon Sep 17 00:00:00 2001 From: brozorec <9572072+brozorec@users.noreply.github.com> Date: Fri, 22 May 2026 12:16:18 +0200 Subject: [PATCH 03/15] feat(confidential): withdraw auditor block (W_a1-W_a5) Adds the sender-auditor visibility block to the Withdraw circuit (design doc Section 7.5, Section 8.1): the ephemeral key R_e = r_e * H (W_a1), the sender-auditor ECDH shared secret S_{a,s} = r_e * K_{aud,s} (W_a2), the single-squeeze sender-channel sponge mask m_b (W_a3), the sender-auditor balance checkpoint ciphertext b_tilde_aud_s = (v - a) + m_b (W_a4), and the r_e != 0 non-collapse constraint (W_a5). The public input signature grows from 10 to the design-doc-frozen 15 fields, in canonical order (C_spend, Y, wrap, K_aud_s, a, C_spend', sigma, b_tilde, R_e, b_tilde_aud_s). K_aud_s carries an explicit on-curve + non-identity check at the entrypoint because the verifier doesn't check curve membership and an off-curve K_aud_s would break the soundness of W_a2 (Section 10.8). `encrypt_auditor_sender_balance` lands in the lib as the canonical W_a3+W_a4 composition; the same helper covers RevokeOperator V_a3/V_a5 and SetOperator S_a3/S_a5 when those circuits land. Pinned via testdata and a lib round-trip test that ties it back to sponge_squeeze_1. Auditor-side negative coverage: r_e = 0 (W_a5), wrong r_e (W_a1), off-curve K_aud_s, identity K_aud_s, valid-but-wrong K_aud_s, tampered b_tilde_aud_s (W_a4). Closes the under-funded-withdrawal + tampered- ciphertext criteria from issue #705. --- .../circuits/constraints.baseline | 2 +- .../src/confidential/circuits/lib/src/lib.nr | 12 + .../confidential/circuits/lib/src/tests.nr | 19 +- .../encrypt_auditor_sender_balance.json | 15 + .../circuits/withdraw/src/main.nr | 110 ++++-- .../circuits/withdraw/src/tests.nr | 329 ++++++++++++++++-- 6 files changed, 421 insertions(+), 66 deletions(-) create mode 100644 packages/tokens/src/confidential/circuits/lib/testdata/encrypt_auditor_sender_balance.json diff --git a/packages/tokens/src/confidential/circuits/constraints.baseline b/packages/tokens/src/confidential/circuits/constraints.baseline index eadf61687..512ad4a2c 100644 --- a/packages/tokens/src/confidential/circuits/constraints.baseline +++ b/packages/tokens/src/confidential/circuits/constraints.baseline @@ -26,7 +26,7 @@ | circuit_withdraw | directive_integer_quotient | N/A | N/A | 8 | | circuit_withdraw | directive_invert | N/A | N/A | 9 | | circuit_withdraw | lte_hint | N/A | N/A | 33 | -| circuit_withdraw | main | Bounded { width: 4 } | 109 | 80 | +| circuit_withdraw | main | Bounded { width: 4 } | 130 | 80 | | gadget_assert_on_curve | main | Bounded { width: 4 } | 2 | 0 | | gadget_commit | decompose_hint | N/A | N/A | 30 | | gadget_commit | lte_hint | N/A | N/A | 33 | diff --git a/packages/tokens/src/confidential/circuits/lib/src/lib.nr b/packages/tokens/src/confidential/circuits/lib/src/lib.nr index 639d7a9a9..0317a4d7f 100644 --- a/packages/tokens/src/confidential/circuits/lib/src/lib.nr +++ b/packages/tokens/src/confidential/circuits/lib/src/lib.nr @@ -337,3 +337,15 @@ pub fn sponge_squeeze_2(d: Field, s_x: Field, sigma: Field) -> [Field; 2] { let state = poseidon2_permutation([d, s_x, sigma, iv], 4); [state[0], state[1]] } + +/// Sender-auditor encrypted balance/allowance checkpoint for circuits whose +/// auditor block emits a single ciphertext (Withdraw W_a3/W_a4, RevokeOperator +/// V_a3/V_a5, SetOperator S_a3/S_a5). +/// +/// `b_tilde_aud_s = v_new + SpongeSqueeze_1(AUDITOR_SENDER, s_a_s_x, sigma)`. +/// The two-squeeze sender-channel form (Transfer T_a6-T_a8, OperatorTransfer +/// O_a6-O_a8) is a different composition that callers spell out directly with +/// `sponge_squeeze_2`. +pub fn encrypt_auditor_sender_balance(v_new: Field, s_a_s_x: Field, sigma: Field) -> Field { + v_new + sponge_squeeze_1(domain::AUDITOR_SENDER, s_a_s_x, sigma) +} diff --git a/packages/tokens/src/confidential/circuits/lib/src/tests.nr b/packages/tokens/src/confidential/circuits/lib/src/tests.nr index 92bca9b01..25bf1c701 100644 --- a/packages/tokens/src/confidential/circuits/lib/src/tests.nr +++ b/packages/tokens/src/confidential/circuits/lib/src/tests.nr @@ -1,8 +1,8 @@ use crate::{ assert_on_curve, assert_on_curve_non_identity, assert_range_128, commit, derive_allow_r, derive_spend_r, derive_tx_blind, domain, dvk_from_vk_op, ecdh, encrypt_allowance, - encrypt_amount, encrypt_balance, encrypt_esc_dvk, G, H, poseidon_with_domain, pvk_from_vk, - scalar_mul, sponge_squeeze_1, sponge_squeeze_2, vk_from_sk, + encrypt_amount, encrypt_auditor_sender_balance, encrypt_balance, encrypt_esc_dvk, G, H, + poseidon_with_domain, pvk_from_vk, scalar_mul, sponge_squeeze_1, sponge_squeeze_2, vk_from_sk, }; use std::embedded_curve_ops::EmbeddedCurvePoint; use std::hash::{derive_generators, pedersen_commitment}; @@ -230,6 +230,10 @@ fn encrypt_decrypt_round_trip() { let dvk_escrowed = encrypt_esc_dvk(v, s, sigma); let mask_esc = poseidon_with_domain(domain::ESCROWED_DELEGATION_VIEWING_KEY, [s, sigma]); assert(dvk_escrowed - mask_esc == v); + + let b_tilde_aud_s = encrypt_auditor_sender_balance(v, s, sigma); + let mask_aud_s = sponge_squeeze_1(domain::AUDITOR_SENDER, s, sigma); + assert(b_tilde_aud_s - mask_aud_s == v); } #[test] @@ -368,6 +372,10 @@ fn print_fixtures() { let ss1_s = sponge_squeeze_1(domain::AUDITOR_SENDER, s_ecdh, sigma); println(f"sponge_squeeze_1_AUDITOR_SENDER = {ss1_s}"); + // encrypt_auditor_sender_balance(v, s, sigma) + let b_aud_s = encrypt_auditor_sender_balance(v, s_ecdh, sigma); + println(f"encrypt_auditor_sender_balance = {b_aud_s}"); + // sponge_squeeze_2(AUDITOR_SENDER, s, sigma) -- (amount, balance) masks let ss2_s = sponge_squeeze_2(domain::AUDITOR_SENDER, s_ecdh, sigma); let ss2_s0 = ss2_s[0]; @@ -464,6 +472,13 @@ fn fixtures_match_testdata() { sponge_squeeze_1(domain::AUDITOR_SENDER, s_ecdh, sigma) == SPONGE_SQUEEZE_1_AUDITOR_SENDER, ); + // encrypt_auditor_sender_balance is the canonical composition: + // v + SpongeSqueeze_1(AUDITOR_SENDER, ...) -- pin so the lib helper and + // its raw sponge cousin can't drift apart. + assert( + encrypt_auditor_sender_balance(v, s_ecdh, sigma) + == v + SPONGE_SQUEEZE_1_AUDITOR_SENDER, + ); let ss2_s = sponge_squeeze_2(domain::AUDITOR_SENDER, s_ecdh, sigma); assert(ss2_s[0] == SPONGE_SQUEEZE_2_AUDITOR_SENDER_0); assert(ss2_s[1] == SPONGE_SQUEEZE_2_AUDITOR_SENDER_1); diff --git a/packages/tokens/src/confidential/circuits/lib/testdata/encrypt_auditor_sender_balance.json b/packages/tokens/src/confidential/circuits/lib/testdata/encrypt_auditor_sender_balance.json new file mode 100644 index 000000000..6aecdd011 --- /dev/null +++ b/packages/tokens/src/confidential/circuits/lib/testdata/encrypt_auditor_sender_balance.json @@ -0,0 +1,15 @@ +{ + "primitive": "encrypt_auditor_sender_balance", + "design_doc_refs": ["Constraint W_a3", "Constraint W_a4", "Constraint S_a3", "Constraint S_a5", "Constraint V_a3", "Constraint V_a5"], + "description": "Sender-auditor encrypted balance/allowance checkpoint via the single-squeeze sender channel sponge: b_tilde_aud_s = v_new + SpongeSqueeze_1(AUDITOR_SENDER, s_a_s_x, sigma). Used by Withdraw, RevokeOperator, and SetOperator -- the three sender-side proofs whose auditor block emits only a balance mask.", + "vectors": [ + { + "inputs": { + "v_new": "0x3e8", + "s_a_s_x": "0x12345", + "sigma": "0x01" + }, + "output": "0x266e76dccd3d7c654ab7a48045cca920c497b1a6758981acfa6f217427ac345c" + } + ] +} diff --git a/packages/tokens/src/confidential/circuits/withdraw/src/main.nr b/packages/tokens/src/confidential/circuits/withdraw/src/main.nr index ebafd70fa..f005c0623 100644 --- a/packages/tokens/src/confidential/circuits/withdraw/src/main.nr +++ b/packages/tokens/src/confidential/circuits/withdraw/src/main.nr @@ -1,13 +1,16 @@ use stellar_confidential_lib::{ - H, assert_range_128, commit, derive_spend_r, encrypt_balance, scalar_mul, vk_from_sk, + H, assert_on_curve_non_identity, assert_range_128, commit, derive_spend_r, ecdh, + encrypt_auditor_sender_balance, encrypt_balance, scalar_mul, vk_from_sk, }; +use std::embedded_curve_ops::EmbeddedCurvePoint; mod tests; // Withdraw circuit -- design doc Section 7.5. // -// Constraints (main block W1-W7; auditor block W_a1-W_a5 follows) -// --------------------------------------------------------------- +// Constraints +// ----------- +// Main block (balance conservation + ownership) // W1 Y = sk * H owner key ownership // W2 vk = Poseidon2(delta_vk, sk, wrap) wrapper-bound viewing // key @@ -24,52 +27,91 @@ mod tests; // + Poseidon2(delta_enc_bal, vk, sigma) // encrypted balance // scalar (emitted) +// Auditor block (sender-auditor visibility, Section 8.1) +// W_a1 R_e = r_e * H ephemeral key for +// auditor ECDH +// W_a2 S_{a,s} = r_e * K_{aud,s} sender-auditor ECDH +// shared secret +// W_a3 m_b = SpongeSqueeze_1(delta_aud_s, +// S_{a,s}.x, sigma) sender-channel +// sponge, single squeeze +// W_a4 b_tilde_aud_s = (v - a) + m_b sender-auditor +// encrypted balance +// checkpoint (emitted) +// W_a5 r_e != 0 rules out R_e = O and +// S_{a,s} = O, which +// would collapse m_b to +// a constant function +// of sigma +// +// W_a3 + W_a4 are encapsulated by `encrypt_auditor_sender_balance` in the lib. // -// On-curve checks for Y, C_spend, and C_spend' are not needed: all three -// are bound to in-circuit multi_scalar_mul outputs (Y in W1, C_spend in W3, -// C_spend' in W6), which are on-curve by construction. See design doc -// Section 10.8 for the system-wide point-validation doctrine. +// Point-validation doctrine (Section 10.8) +// ---------------------------------------- +// Y, C_spend, C_spend', and R_e are each bound to in-circuit multi_scalar_mul +// outputs (W1, W3, W6, W_a1) and are therefore on-curve by construction -- +// no explicit check needed. K_aud_s is a public-input key consumed by an +// ECDH multiplication; the verifier doesn't check curve membership, so an +// off-curve K_aud_s would break the soundness of W_a2. This file explicitly +// validates K_aud_s on-curve AND non-identity before W_a2. // -// Public inputs (this block: 10 fields; the auditor block will add 5 more -// in design-doc canonical order before this is shipped end-to-end) -// --------------------------------------------------------------------- -// Idx Param Symbol Source / Note -// --- ----- ------ --------------------------------------- -// 0 c_spend_x C_spend.x Loaded from `from.spendable_balance`. +// Public inputs (15 fields, in design-doc canonical order) +// -------------------------------------------------------- +// Idx Param Symbol Source / Note +// --- ----- ------ ------------------------------------- +// 0 c_spend_x C_spend.x Loaded from `from.spendable_balance`. // 1 c_spend_y C_spend.y -// 2 y_x Y.x Loaded from `from.spending_key`. +// 2 y_x Y.x Loaded from `from.spending_key`. // 3 y_y Y.y -// 4 wrap wrap `env.current_contract_address()`. -// 5 a a Public withdrawal amount; the wrapper -// pre-checks a >= 0 at the entrypoint, -// W4 then closes a < 2^128 in-circuit. -// 6 c_spend_new_x C_spend'.x Prover-supplied; written to -// 7 c_spend_new_y C_spend'.y `from.spendable_balance` on success. -// 8 sigma sigma Prover-supplied random salt; emitted. -// 9 b_tilde b_tilde Prover-supplied encrypted balance -// scalar; emitted. +// 4 wrap wrap `env.current_contract_address()`. +// 5 k_aud_s_x K_aud_s.x Fetched from the auditor contract by +// 6 k_aud_s_y K_aud_s.y `from.auditor_id`. +// 7 a a Public withdrawal amount; the wrapper +// pre-checks a >= 0 at the entrypoint, +// W4 then closes a < 2^128 in-circuit. +// 8 c_spend_new_x C_spend'.x Prover-supplied; written to +// 9 c_spend_new_y C_spend'.y `from.spendable_balance` on success. +// 10 sigma sigma Prover-supplied random salt; emitted. +// 11 b_tilde b_tilde Prover-supplied encrypted balance +// scalar; emitted. +// 12 r_e_x R_e.x Prover-supplied ephemeral key for +// 13 r_e_y R_e.y auditor ECDH; emitted. +// 14 b_tilde_aud_s b_tilde_aud_s Prover-supplied sender-auditor +// encrypted balance checkpoint; emitted. // // Private witnesses // ----------------- -// sk Spending secret scalar. -// v Plaintext spendable-balance value. -// r Plaintext blinding factor for C_spend. +// sk Spending secret scalar. +// v Plaintext spendable-balance value. +// r Plaintext blinding factor for C_spend. +// r_e Ephemeral scalar for the auditor ECDH; must satisfy r_e != 0 +// (W_a5). fn main( sk: Field, v: Field, r: Field, + r_e: Field, c_spend_x: pub Field, c_spend_y: pub Field, y_x: pub Field, y_y: pub Field, wrap: pub Field, + k_aud_s_x: pub Field, + k_aud_s_y: pub Field, a: pub Field, c_spend_new_x: pub Field, c_spend_new_y: pub Field, sigma: pub Field, b_tilde: pub Field, + r_e_x: pub Field, + r_e_y: pub Field, + b_tilde_aud_s: pub Field, ) { + // W_a5 -- runs first so the r_e = 0 attack is rejected before any + // scalar mul against it could quietly produce the identity. + assert(r_e != 0); + // W1 let y_derived = scalar_mul(sk, H); assert(y_derived.x == y_x); @@ -100,4 +142,20 @@ fn main( // W7 let b_tilde_derived = encrypt_balance(v_new, vk, sigma); assert(b_tilde_derived == b_tilde); + + // W_a1 + let r_e_derived = scalar_mul(r_e, H); + assert(r_e_derived.x == r_e_x); + assert(r_e_derived.y == r_e_y); + + // K_aud_s point validation (Section 10.8: public-input key). + let k_aud_s = EmbeddedCurvePoint { x: k_aud_s_x, y: k_aud_s_y, is_infinite: false }; + assert_on_curve_non_identity(k_aud_s); + + // W_a2 (shared secret x-coordinate) + let s_a_s_x = ecdh(r_e, k_aud_s); + + // W_a3 + W_a4 + let b_tilde_aud_s_derived = encrypt_auditor_sender_balance(v_new, s_a_s_x, sigma); + assert(b_tilde_aud_s_derived == b_tilde_aud_s); } diff --git a/packages/tokens/src/confidential/circuits/withdraw/src/tests.nr b/packages/tokens/src/confidential/circuits/withdraw/src/tests.nr index a09d8ed4c..ccdacffd9 100644 --- a/packages/tokens/src/confidential/circuits/withdraw/src/tests.nr +++ b/packages/tokens/src/confidential/circuits/withdraw/src/tests.nr @@ -1,13 +1,18 @@ use crate::main; -use stellar_confidential_lib::{commit, derive_spend_r, encrypt_balance, H, scalar_mul, vk_from_sk}; +use stellar_confidential_lib::{ + commit, derive_spend_r, ecdh, encrypt_auditor_sender_balance, encrypt_balance, H, scalar_mul, + vk_from_sk, +}; +use std::embedded_curve_ops::EmbeddedCurvePoint; // Canonical fixture inputs. // // SK / WRAP / SIGMA / V / R reuse the lib's pinned vectors (lib/testdata) // so that Y, C_spend, vk, r_new, and b_tilde are derivable from -- and -// pinned by -- the lib's `fixtures_match_testdata` test. A is chosen to -// be strictly less than V so the happy path exercises a non-trivial -// `v_new = v - a` while still satisfying W4. +// pinned by -- the lib's `fixtures_match_testdata` test. A is chosen +// strictly less than V so the happy path exercises a non-trivial +// `v_new = v - a` while still satisfying W4. R_E and K_AUD_S are local +// to this circuit (no auditor-side primitives in lib's fixtures). global SK: Field = 0xdead; global WRAP: Field = 0xbeef; global SIGMA: Field = 0x01; @@ -15,6 +20,12 @@ global V: Field = 1000; global R: Field = 42; global A: Field = 300; global V_NEW: Field = 700; +global R_E: Field = 0xfeedface; + +// K_aud_s = `sk_aud * H` for a placeholder auditor secret 0xc0ffee. Any +// non-identity Grumpkin point would do; reusing scalar_mul on a constant +// scalar keeps the value derivable from lib primitives. +global K_AUD_S_SCALAR: Field = 0xc0ffee; // Y = sk*H (= lib `scalar_mul_Y` fixture). global Y_X: Field = 0x1b46b003b88a6c34549dc74115f088f4b231a151397526bc10cbf1d15b457646; @@ -36,29 +47,51 @@ global C_SPEND_NEW_Y: Field = global B_TILDE: Field = 0x2b7f92c72c05ae8ecf7207fdaecea32ef748cd4dd43ca9ed6dc6d226865b4283; +// Auditor-block fixtures. Pinned by `withdraw_auditor_fixtures_match_lib`. +// R_E_X (= (R_E * H).x) matches the lib's `ecdh` fixture (`ecdh(r_e, H)` for +// the same r_e), pinning the cross-circuit relationship between R_e and the +// lib's scalar-mul output. +global K_AUD_S_X: Field = + 0x22502c7b20b64aeaaaa4d4fa5f2f8600f9734e828f7284a8d1431da36b33803a; +global K_AUD_S_Y: Field = + 0x0419089353d24334f03f4a1c9c9b19282e32f6879c5c69b9cdffba6a13a7c5ed; +global R_E_X: Field = 0x114ed4fcf2c57014eb678c577aa02f30ef590b713d7a6a5e87702d1c7f71957f; +global R_E_Y: Field = 0x07a70cf826350d4f438c7a3c5e8761b0ae6cb63de757f0c96815f4057b9205f4; +global B_TILDE_AUD_S: Field = + 0x216ab12ea2182712721d79c9a9222a261452ab06a90309e23efaa6ecb0e20714; + #[test] fn print_fixtures() { - // One-shot harness: prints C_spend', b_tilde for the chosen (SK, WRAP, - // SIGMA, V, R, A) tuple. Run with + // One-shot harness: prints C_spend', b_tilde, K_aud_s, R_e, b_tilde_aud_s + // for the chosen fixture tuple. Run with // `nargo test --package circuit_withdraw print_fixtures --show-output` - // and paste the values into the C_SPEND_NEW_* / B_TILDE globals above. + // and paste the values into the globals above. let vk = vk_from_sk(SK, WRAP); let r_new = derive_spend_r(vk, SIGMA); let c_new = commit(V_NEW, r_new); let b_tilde = encrypt_balance(V_NEW, vk, SIGMA); + let k_aud_s = scalar_mul(K_AUD_S_SCALAR, H); + let r_e_pt = scalar_mul(R_E, H); + let s_a_s_x = ecdh(R_E, k_aud_s); + let b_tilde_aud_s = encrypt_auditor_sender_balance(V_NEW, s_a_s_x, SIGMA); let cx = c_new.x; let cy = c_new.y; + let kx = k_aud_s.x; + let ky = k_aud_s.y; + let rx = r_e_pt.x; + let ry = r_e_pt.y; println(f"C_SPEND_NEW_X = {cx}"); println(f"C_SPEND_NEW_Y = {cy}"); println(f"B_TILDE = {b_tilde}"); + println(f"K_AUD_S_X = {kx}"); + println(f"K_AUD_S_Y = {ky}"); + println(f"R_E_X = {rx}"); + println(f"R_E_Y = {ry}"); + println(f"B_TILDE_AUD_S = {b_tilde_aud_s}"); } #[test] fn withdraw_fixtures_match_lib() { - // Cross-check the hard-coded fixture values against the lib's - // primitives. If a lib primitive's output changes the fixture - // assertions break here -- which is exactly when the rest of the - // withdraw tests would silently start using stale public inputs. let vk = vk_from_sk(SK, WRAP); let r_new = derive_spend_r(vk, SIGMA); let c_new = commit(V_NEW, r_new); @@ -75,229 +108,451 @@ fn withdraw_fixtures_match_lib() { assert(c_spend.y == C_SPEND_Y); } +#[test] +fn withdraw_auditor_fixtures_match_lib() { + let k_aud_s = scalar_mul(K_AUD_S_SCALAR, H); + let r_e_pt = scalar_mul(R_E, H); + let s_a_s_x = ecdh(R_E, k_aud_s); + let b_tilde_aud_s = encrypt_auditor_sender_balance(V_NEW, s_a_s_x, SIGMA); + assert(k_aud_s.x == K_AUD_S_X); + assert(k_aud_s.y == K_AUD_S_Y); + assert(r_e_pt.x == R_E_X); + assert(r_e_pt.y == R_E_Y); + assert(b_tilde_aud_s == B_TILDE_AUD_S); +} + #[test] fn matches_fixture() { main( SK, V, R, + R_E, C_SPEND_X, C_SPEND_Y, Y_X, Y_Y, WRAP, + K_AUD_S_X, + K_AUD_S_Y, A, C_SPEND_NEW_X, C_SPEND_NEW_Y, SIGMA, B_TILDE, + R_E_X, + R_E_Y, + B_TILDE_AUD_S, ); } #[test] fn full_withdrawal() { - // Boundary: a = v, so v_new = 0. C_spend' commits to (0, r_new) which - // is a valid commitment to zero (and is non-identity because r_new is - // a Poseidon output, vanishingly unlikely to equal 0). W6 binds it. + // Boundary: a = v, so v_new = 0. C_spend' commits to (0, r_new) which is + // a valid commitment to zero. b_tilde and b_tilde_aud_s both bind to 0 + // (plus mask) so the auditor sees the post-withdrawal balance reach zero. let vk = vk_from_sk(SK, WRAP); let r_new = derive_spend_r(vk, SIGMA); let c_new = commit(0, r_new); let b_tilde = encrypt_balance(0, vk, SIGMA); + let k_aud_s = EmbeddedCurvePoint { x: K_AUD_S_X, y: K_AUD_S_Y, is_infinite: false }; + let s_a_s_x = ecdh(R_E, k_aud_s); + let b_tilde_aud_s = encrypt_auditor_sender_balance(0, s_a_s_x, SIGMA); main( SK, V, R, + R_E, C_SPEND_X, C_SPEND_Y, Y_X, Y_Y, WRAP, + K_AUD_S_X, + K_AUD_S_Y, V, c_new.x, c_new.y, SIGMA, b_tilde, + R_E_X, + R_E_Y, + b_tilde_aud_s, ); } #[test(should_fail)] fn rejects_under_funded_withdrawal() { - // a > v: v - a underflows in the field to a value >= 2^128. W4's - // range check on v_new fires before any downstream constraint, so - // this is exactly the under-funded-withdraw soundness gap closed. + // a > v: v - a underflows in the field to a value >= 2^128. W4 fires. let a_too_large: Field = V + 1; - // C_spend_new is whatever the (necessarily wrong) prover supplies; - // W6 would also fail, but W4 should fail first. Pick C_spend_new for - // some valid v_new derivation just to keep the test focused on W4. let vk = vk_from_sk(SK, WRAP); let r_new = derive_spend_r(vk, SIGMA); let c_new_invalid = commit(V - a_too_large, r_new); let b_tilde_invalid = encrypt_balance(V - a_too_large, vk, SIGMA); + let k_aud_s = EmbeddedCurvePoint { x: K_AUD_S_X, y: K_AUD_S_Y, is_infinite: false }; + let s_a_s_x = ecdh(R_E, k_aud_s); + let b_tilde_aud_s_invalid = encrypt_auditor_sender_balance(V - a_too_large, s_a_s_x, SIGMA); main( SK, V, R, + R_E, C_SPEND_X, C_SPEND_Y, Y_X, Y_Y, WRAP, + K_AUD_S_X, + K_AUD_S_Y, a_too_large, c_new_invalid.x, c_new_invalid.y, SIGMA, b_tilde_invalid, + R_E_X, + R_E_Y, + b_tilde_aud_s_invalid, ); } #[test(should_fail)] fn rejects_v_out_of_range() { - // v claimed by the prover is >= 2^128: W4 rejects on the v check - // before W3 even looks at C_spend. let v_huge: Field = 0x100000000000000000000000000000000; // 2^128 main( SK, v_huge, R, + R_E, C_SPEND_X, C_SPEND_Y, Y_X, Y_Y, WRAP, + K_AUD_S_X, + K_AUD_S_Y, A, C_SPEND_NEW_X, C_SPEND_NEW_Y, SIGMA, B_TILDE, + R_E_X, + R_E_Y, + B_TILDE_AUD_S, ); } #[test(should_fail)] fn rejects_a_out_of_range() { - // a >= 2^128: W4's range check on a fails. (The wrapper's a >= 0 - // entrypoint check is out-of-scope for the circuit; here we only - // close the upper bound.) let a_huge: Field = 0x100000000000000000000000000000000; main( SK, V, R, + R_E, C_SPEND_X, C_SPEND_Y, Y_X, Y_Y, WRAP, + K_AUD_S_X, + K_AUD_S_Y, a_huge, C_SPEND_NEW_X, C_SPEND_NEW_Y, SIGMA, B_TILDE, + R_E_X, + R_E_Y, + B_TILDE_AUD_S, ); } #[test(should_fail)] fn rejects_wrong_sk() { - // sk does not derive the claimed Y: W1 fails. main( SK + 1, V, R, + R_E, C_SPEND_X, C_SPEND_Y, Y_X, Y_Y, WRAP, + K_AUD_S_X, + K_AUD_S_Y, A, C_SPEND_NEW_X, C_SPEND_NEW_Y, SIGMA, B_TILDE, + R_E_X, + R_E_Y, + B_TILDE_AUD_S, ); } #[test(should_fail)] fn rejects_wrong_balance_opening() { - // (v, r) supplied don't open C_spend: W3 fails. Use a v that still - // passes the W4 range check so the failure is squarely W3. main( SK, V + 1, R, + R_E, C_SPEND_X, C_SPEND_Y, Y_X, Y_Y, WRAP, + K_AUD_S_X, + K_AUD_S_Y, A, C_SPEND_NEW_X, C_SPEND_NEW_Y, SIGMA, B_TILDE, + R_E_X, + R_E_Y, + B_TILDE_AUD_S, ); } #[test(should_fail)] fn rejects_wrong_wrap() { - // wrap != WRAP under which Y was derived. vk recomputes under the - // new wrap, the new vk produces a different r_new (W5) and a - // different b_tilde (W7). W6 / W7 fire. (W1 still passes because Y - // is the prover-claimed Y and sk*H is recomputed against it without - // using wrap.) main( SK, V, R, + R_E, C_SPEND_X, C_SPEND_Y, Y_X, Y_Y, 0xcafe, + K_AUD_S_X, + K_AUD_S_Y, A, C_SPEND_NEW_X, C_SPEND_NEW_Y, SIGMA, B_TILDE, + R_E_X, + R_E_Y, + B_TILDE_AUD_S, ); } #[test(should_fail)] fn rejects_tampered_b_tilde() { - // b_tilde mutated by +1: W7 fails because encrypt_balance derives a - // specific scalar that no longer matches. main( SK, V, R, + R_E, C_SPEND_X, C_SPEND_Y, Y_X, Y_Y, WRAP, + K_AUD_S_X, + K_AUD_S_Y, A, C_SPEND_NEW_X, C_SPEND_NEW_Y, SIGMA, B_TILDE + 1, + R_E_X, + R_E_Y, + B_TILDE_AUD_S, ); } #[test(should_fail)] fn rejects_tampered_c_spend_new() { - // C_spend' supplied with wrong x: W6 fails because the prover's - // claim no longer matches the in-circuit (v - a)*G + r'*H. main( SK, V, R, + R_E, C_SPEND_X, C_SPEND_Y, Y_X, Y_Y, WRAP, + K_AUD_S_X, + K_AUD_S_Y, A, C_SPEND_NEW_X + 1, C_SPEND_NEW_Y, SIGMA, B_TILDE, + R_E_X, + R_E_Y, + B_TILDE_AUD_S, + ); +} + +#[test(should_fail)] +fn rejects_r_e_zero() { + // W_a5 fires: r_e = 0 would force R_e = O (identity) and collapse + // S_a,s to O, making m_b a knowable constant function of sigma. + main( + SK, + V, + R, + 0, + C_SPEND_X, + C_SPEND_Y, + Y_X, + Y_Y, + WRAP, + K_AUD_S_X, + K_AUD_S_Y, + A, + C_SPEND_NEW_X, + C_SPEND_NEW_Y, + SIGMA, + B_TILDE, + R_E_X, + R_E_Y, + B_TILDE_AUD_S, + ); +} + +#[test(should_fail)] +fn rejects_wrong_r_e() { + // r_e mismatches the public R_e: W_a1 fails because scalar_mul(r_e, H) + // no longer equals the claimed (R_e_x, R_e_y). + main( + SK, + V, + R, + R_E + 1, + C_SPEND_X, + C_SPEND_Y, + Y_X, + Y_Y, + WRAP, + K_AUD_S_X, + K_AUD_S_Y, + A, + C_SPEND_NEW_X, + C_SPEND_NEW_Y, + SIGMA, + B_TILDE, + R_E_X, + R_E_Y, + B_TILDE_AUD_S, + ); +} + +#[test(should_fail)] +fn rejects_off_curve_k_aud_s() { + // (1, 2) is off-curve: 4 != 1 - 17. The on-curve check in main() + // fires before W_a2 ever consumes K_aud_s. + main( + SK, + V, + R, + R_E, + C_SPEND_X, + C_SPEND_Y, + Y_X, + Y_Y, + WRAP, + 1, + 2, + A, + C_SPEND_NEW_X, + C_SPEND_NEW_Y, + SIGMA, + B_TILDE, + R_E_X, + R_E_Y, + B_TILDE_AUD_S, + ); +} + +#[test(should_fail)] +fn rejects_identity_k_aud_s() { + // K_aud_s = identity collapses ECDH (S = r_e * O = O), so the on-curve + // check explicitly rejects the identity for keys. + main( + SK, + V, + R, + R_E, + C_SPEND_X, + C_SPEND_Y, + Y_X, + Y_Y, + WRAP, + 0, + 0, + A, + C_SPEND_NEW_X, + C_SPEND_NEW_Y, + SIGMA, + B_TILDE, + R_E_X, + R_E_Y, + B_TILDE_AUD_S, + ); +} + +#[test(should_fail)] +fn rejects_tampered_b_tilde_aud_s() { + // b_tilde_aud_s mutated by +1: W_a3+W_a4 fail because + // encrypt_auditor_sender_balance derives a specific scalar that no + // longer matches the prover's claim. Closes the + // tampered-ciphertext criterion in the issue. + main( + SK, + V, + R, + R_E, + C_SPEND_X, + C_SPEND_Y, + Y_X, + Y_Y, + WRAP, + K_AUD_S_X, + K_AUD_S_Y, + A, + C_SPEND_NEW_X, + C_SPEND_NEW_Y, + SIGMA, + B_TILDE, + R_E_X, + R_E_Y, + B_TILDE_AUD_S + 1, + ); +} + +#[test(should_fail)] +fn rejects_wrong_k_aud_s() { + // K_aud_s replaced by a *valid* but different on-curve key (Y_X, Y_Y is + // sk*H from the fixtures, definitely on-curve and non-identity). The + // shared secret changes, m_b changes, W_a4 fails. + main( + SK, + V, + R, + R_E, + C_SPEND_X, + C_SPEND_Y, + Y_X, + Y_Y, + WRAP, + Y_X, + Y_Y, + A, + C_SPEND_NEW_X, + C_SPEND_NEW_Y, + SIGMA, + B_TILDE, + R_E_X, + R_E_Y, + B_TILDE_AUD_S, ); } From ca2d248635bf0df0488cbfa872950d0f9c9ee388 Mon Sep 17 00:00:00 2001 From: brozorec <9572072+brozorec@users.noreply.github.com> Date: Fri, 22 May 2026 12:17:25 +0200 Subject: [PATCH 04/15] feat(confidential): publish withdraw verification key VK extracted via `bash scripts/extract_vks.sh` after the Withdraw circuit landed in the workspace. Same UltraHonk universal-SRS pipeline as the register VK -- no new trusted-setup contribution required. The committed JSON is the integration contract with the on-chain verifier (#701); CI re-runs the extraction script and diffs against this copy. --- packages/tokens/src/confidential/circuits/vks/withdraw.vk.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 packages/tokens/src/confidential/circuits/vks/withdraw.vk.json diff --git a/packages/tokens/src/confidential/circuits/vks/withdraw.vk.json b/packages/tokens/src/confidential/circuits/vks/withdraw.vk.json new file mode 100644 index 000000000..7eb07aada --- /dev/null +++ b/packages/tokens/src/confidential/circuits/vks/withdraw.vk.json @@ -0,0 +1 @@ +["0x0000000000000000000000000000000000000000000000000000000000008000","0x000000000000000000000000000000000000000000000000000000000000001f","0x0000000000000000000000000000000000000000000000000000000000000001","0x000000000000000000000000000000000000000000000000000000000000000f","0x00000000000000000000000000000089a373ad605ece6a3fe747d2480745e0cc","0x000000000000000000000000000000000005a17fb6eb6073e8d6beeb6c1b9197","0x000000000000000000000000000000904e32c9d3717a6c6d319630fc1fe4b7ae","0x00000000000000000000000000000000002b788c60ecb83b731a2bc623e51bb5","0x0000000000000000000000000000006570ff78b488437ebfa1f4dcbc055d1044","0x00000000000000000000000000000000000f2ab92beb0c1b02472274cebc2541","0x000000000000000000000000000000fb733f474eae795512fec5aa2bded0da8b","0x00000000000000000000000000000000000dbe274e3bab2b97bcc3da51162950","0x000000000000000000000000000000335ae9830c388b0afcca37990478161780","0x000000000000000000000000000000000014790edfe8ec563d36ccc113f79a1b","0x0000000000000000000000000000001964e8eb5b7700b35b381589c55c0feb6a","0x000000000000000000000000000000000026775d2d1fd9d69ec8e262317506c4","0x0000000000000000000000000000003fb155d9788484afa4fe369b3bfa7386fa","0x000000000000000000000000000000000008bc103927def69c43cd94a08efdae","0x00000000000000000000000000000063619d1ad3cde1f50ac8941e8580b8ee08","0x00000000000000000000000000000000002492bee4fc2c6765c990b5079b4b78","0x0000000000000000000000000000005932a81b749fb08e27c389a204a0c9a66c","0x0000000000000000000000000000000000268bd34c8b97d624a3bba40deaf76e","0x00000000000000000000000000000054dd9da135d37ffaa710640414c309c17b","0x00000000000000000000000000000000001a30e0a54ac45727d3a83948960850","0x00000000000000000000000000000072c314a26f9ce5a3818b0168245760e9a2","0x000000000000000000000000000000000002d4d4b4ab3de4b3cd0960b83521f7","0x000000000000000000000000000000d4eaebcabfc8fff8a8677ddcd19142ad44","0x0000000000000000000000000000000000228117aa076f6b0924133e5d887709","0x000000000000000000000000000000176df2b9f9816b99b87dcd27314f248492","0x00000000000000000000000000000000000396fae9cfc50f85ebfe6bf44327d7","0x0000000000000000000000000000000107a790c778e6a2ffbae75e5764ee8cc2","0x00000000000000000000000000000000000e0f2756936ef0c0b2175663320713","0x00000000000000000000000000000023f9d11f3ecfe7f62be9524c863c1dc874","0x00000000000000000000000000000000002da3916ecb67c00f42a962473b0d60","0x0000000000000000000000000000008cbb64648b81e2d88c2e2acd9411de2c04","0x000000000000000000000000000000000003e3716c735f53222476c645f8c1df","0x000000000000000000000000000000c0deabd5bc078f4c2e34347e51dc08665d","0x00000000000000000000000000000000000b1e3794549547ce84cf504ef1f790","0x00000000000000000000000000000017435a1130a8f09b7f008b039f980e497f","0x0000000000000000000000000000000000183d7725e4086d845e7a073c091bed","0x000000000000000000000000000000f203bc6dd4bbf94d23f2c1134d42812ef2","0x00000000000000000000000000000000001c8ae3984efef7332b3e2f8e5bdb11","0x00000000000000000000000000000055523cf125d6c105b01a81e18a93385d73","0x000000000000000000000000000000000027af0adb42d6dfe987a5eda810f1ac","0x00000000000000000000000000000045b2c7e86bebc1bc2cc666e0c899eb8188","0x00000000000000000000000000000000001f955cb230f7dbec9465ee91468b40","0x00000000000000000000000000000057cccf985e9af0dadeef55c723765d87b6","0x0000000000000000000000000000000000197705eab64da334874994ccd8e0e4","0x00000000000000000000000000000068a2c3be28e298ec24ee52aea586306737","0x00000000000000000000000000000000000946c0d07552c2907bf4dbd07f23b3","0x000000000000000000000000000000527b1f269e0f9182b056a4c4f1bad72734","0x0000000000000000000000000000000000123fcf7b03c0f520b4c02284aa7263","0x000000000000000000000000000000aa7318c0a706dc9935f6254f820a290f8c","0x00000000000000000000000000000000002fe7f885bd76d63a52630afb2eb96c","0x0000000000000000000000000000000b2806b41403a451356b2a8ec66f451d10","0x00000000000000000000000000000000000a177c20fe67c97e7e49f73699070d","0x000000000000000000000000000000923eb1e0f63ad4e00142a457137470095c","0x000000000000000000000000000000000024978f1ed1f0abaf316b3e968d35ed","0x000000000000000000000000000000baf6b35de3a424c3557a63eae09eac22f9","0x00000000000000000000000000000000002647f354f0072d3519269dd79fa14b","0x000000000000000000000000000000c2d9700620ae22fd6bdf4d528eef1d35ae","0x000000000000000000000000000000000016eb34c2d447770351e801cc9b004c","0x000000000000000000000000000000f9b479b749dfebf01b58c841109945b9d8","0x0000000000000000000000000000000000135fbaabb54c672d9f580e958f8a2a","0x0000000000000000000000000000000cc5e4dfb7bdeef3d214b54354f9a0adb4","0x000000000000000000000000000000000002d0a67f80ae376ffa582cc0316ea9","0x000000000000000000000000000000258db7eba59c614306d884b705c74f36c5","0x00000000000000000000000000000000002f438da1a6fed88d2cb38b02f67f4b","0x00000000000000000000000000000020809242a456386d49aeb7d2fdee2dc874","0x0000000000000000000000000000000000264d7ba415e912a7e32c327970770a","0x00000000000000000000000000000030e4c165de1305183c731034d89d1df666","0x000000000000000000000000000000000023b6d9eaded11e563905a463b38a18","0x0000000000000000000000000000005bae16c4c2d40aee47692aa29579359e57","0x00000000000000000000000000000000000c64d0f337645b360ac89550b52eab","0x000000000000000000000000000000e447cb5ccd9365e3a14e6f8dd3f45b32d5","0x00000000000000000000000000000000002e23a5d1c317ee92d18515798bfc8d","0x00000000000000000000000000000098ccdc7fcb669a7d2e8558ec750e2fa612","0x00000000000000000000000000000000000c92ae44da6be8e0f691d2c4528e88","0x0000000000000000000000000000007e6dfab12df0cafc95f92ebb4512d23a9b","0x00000000000000000000000000000000002b452643d7967666962604fb48a4b4","0x0000000000000000000000000000002f14092777e6d6fb8684664e7df7cb6a33","0x000000000000000000000000000000000012242645114a2ae75392abb64a0235","0x00000000000000000000000000000074ea6aca418b22396785498539cd785fab","0x000000000000000000000000000000000009988525d643f69d57290850acf393","0x0000000000000000000000000000004f37d2f15db09dae0845148569ddc663fd","0x00000000000000000000000000000000002fc85d102a6c77e4d471e84d0502af","0x0000000000000000000000000000007b7fc7044729e116cb97a297baaf1eba3f","0x0000000000000000000000000000000000138d5b07df3374d865bf8ec55d3ccc","0x000000000000000000000000000000984d80910016772cb83ed3881f90ebcc8c","0x00000000000000000000000000000000001b6540dd28b967d06897d2fe4f4732","0x000000000000000000000000000000b7445646b5160c380130b66e34450aa736","0x00000000000000000000000000000000000ef8897e2c65d0ec75dc09f85edeac","0x00000000000000000000000000000045688ef774d85c8bd41f9665bad2d794c8","0x00000000000000000000000000000000002ac07a74c19ad88489ed6f1e93dc9e","0x000000000000000000000000000000a5a32649850aae3c839626ec285717f399","0x000000000000000000000000000000000010cdf0710877e231d375a2ef4dc3d3","0x000000000000000000000000000000160d4e95b954b96077723f1800f420dc77","0x0000000000000000000000000000000000280ae74a2dae2f5e68706031174b76","0x000000000000000000000000000000440bb5fc5471f6a37dbe12fbef6a37c009","0x0000000000000000000000000000000000106864b3460bfded9d5dc448fe29e8","0x000000000000000000000000000000d29de5d4acc99c6c72fa17e0a80bf88401","0x000000000000000000000000000000000007e83dfa443e984d53e0323523f742","0x0000000000000000000000000000001cfe095fa8e5d91145e1222746c27083ad","0x000000000000000000000000000000000006a7d963d96d78bc035faf0a744f8e","0x0000000000000000000000000000000000000000000000000000000000000001","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000002","0x0000000000000000000000000000000000000000000000000000000000000000","0x000000000000000000000000000000de9e600862f91873e12f0d906fe6fb4b35","0x0000000000000000000000000000000000268e1172734cd620010a0e7bd33318","0x0000000000000000000000000000006222cac69b3a4024afa430991710a6fe53","0x000000000000000000000000000000000018c0b8f57b190c18dcd096aa188b5d"] \ No newline at end of file From eb3dfda71b4f7dcfba19c94a3906d320318a065f Mon Sep 17 00:00:00 2001 From: brozorec <9572072+brozorec@users.noreply.github.com> Date: Fri, 22 May 2026 15:00:09 +0200 Subject: [PATCH 05/15] feat(confidential): tighten withdraw range to [0, 2^127) per design doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aligns W4 with design doc §7.5 / §2.6 / §3.4: the value domain is the SEP-41 non-negative i128 range [0, 2^127), not [0, 2^128). The circuit now uses Noir stdlib's `Field::assert_max_bit_size::<127>()` directly (the 127-bit decomposition spelled out in §2.6) instead of the lib's u128-cast helper. Doc comments for W4 and the public-input table for `a` are restated against the tighter bound and reference §3.4 for the entrypoint-level `a >= 0` check. Test boundaries retarget to 2^127 (the smallest value W4 must reject), not 2^128. Withdraw VK regenerated; constraint count drops from 130 to 92 ACIR opcodes (stdlib's assert_max_bit_size is cheaper than the u128 round-trip). The lib's `assert_range_128` primitive is now unused by Withdraw but stays in place for backwards compat -- a separate cleanup can remove it once no callers reference it. --- .../circuits/constraints.baseline | 3 +-- .../circuits/vks/withdraw.vk.json | 2 +- .../circuits/withdraw/src/main.nr | 27 ++++++++++++------- .../circuits/withdraw/src/tests.nr | 8 ++++-- 4 files changed, 25 insertions(+), 15 deletions(-) diff --git a/packages/tokens/src/confidential/circuits/constraints.baseline b/packages/tokens/src/confidential/circuits/constraints.baseline index 512ad4a2c..37eb2232c 100644 --- a/packages/tokens/src/confidential/circuits/constraints.baseline +++ b/packages/tokens/src/confidential/circuits/constraints.baseline @@ -23,10 +23,9 @@ | circuit_register | lte_hint | N/A | N/A | 33 | | circuit_register | main | Bounded { width: 4 } | 33 | 72 | | circuit_withdraw | decompose_hint | N/A | N/A | 30 | -| circuit_withdraw | directive_integer_quotient | N/A | N/A | 8 | | circuit_withdraw | directive_invert | N/A | N/A | 9 | | circuit_withdraw | lte_hint | N/A | N/A | 33 | -| circuit_withdraw | main | Bounded { width: 4 } | 130 | 80 | +| circuit_withdraw | main | Bounded { width: 4 } | 92 | 72 | | gadget_assert_on_curve | main | Bounded { width: 4 } | 2 | 0 | | gadget_commit | decompose_hint | N/A | N/A | 30 | | gadget_commit | lte_hint | N/A | N/A | 33 | diff --git a/packages/tokens/src/confidential/circuits/vks/withdraw.vk.json b/packages/tokens/src/confidential/circuits/vks/withdraw.vk.json index 7eb07aada..abc61d4a2 100644 --- a/packages/tokens/src/confidential/circuits/vks/withdraw.vk.json +++ b/packages/tokens/src/confidential/circuits/vks/withdraw.vk.json @@ -1 +1 @@ -["0x0000000000000000000000000000000000000000000000000000000000008000","0x000000000000000000000000000000000000000000000000000000000000001f","0x0000000000000000000000000000000000000000000000000000000000000001","0x000000000000000000000000000000000000000000000000000000000000000f","0x00000000000000000000000000000089a373ad605ece6a3fe747d2480745e0cc","0x000000000000000000000000000000000005a17fb6eb6073e8d6beeb6c1b9197","0x000000000000000000000000000000904e32c9d3717a6c6d319630fc1fe4b7ae","0x00000000000000000000000000000000002b788c60ecb83b731a2bc623e51bb5","0x0000000000000000000000000000006570ff78b488437ebfa1f4dcbc055d1044","0x00000000000000000000000000000000000f2ab92beb0c1b02472274cebc2541","0x000000000000000000000000000000fb733f474eae795512fec5aa2bded0da8b","0x00000000000000000000000000000000000dbe274e3bab2b97bcc3da51162950","0x000000000000000000000000000000335ae9830c388b0afcca37990478161780","0x000000000000000000000000000000000014790edfe8ec563d36ccc113f79a1b","0x0000000000000000000000000000001964e8eb5b7700b35b381589c55c0feb6a","0x000000000000000000000000000000000026775d2d1fd9d69ec8e262317506c4","0x0000000000000000000000000000003fb155d9788484afa4fe369b3bfa7386fa","0x000000000000000000000000000000000008bc103927def69c43cd94a08efdae","0x00000000000000000000000000000063619d1ad3cde1f50ac8941e8580b8ee08","0x00000000000000000000000000000000002492bee4fc2c6765c990b5079b4b78","0x0000000000000000000000000000005932a81b749fb08e27c389a204a0c9a66c","0x0000000000000000000000000000000000268bd34c8b97d624a3bba40deaf76e","0x00000000000000000000000000000054dd9da135d37ffaa710640414c309c17b","0x00000000000000000000000000000000001a30e0a54ac45727d3a83948960850","0x00000000000000000000000000000072c314a26f9ce5a3818b0168245760e9a2","0x000000000000000000000000000000000002d4d4b4ab3de4b3cd0960b83521f7","0x000000000000000000000000000000d4eaebcabfc8fff8a8677ddcd19142ad44","0x0000000000000000000000000000000000228117aa076f6b0924133e5d887709","0x000000000000000000000000000000176df2b9f9816b99b87dcd27314f248492","0x00000000000000000000000000000000000396fae9cfc50f85ebfe6bf44327d7","0x0000000000000000000000000000000107a790c778e6a2ffbae75e5764ee8cc2","0x00000000000000000000000000000000000e0f2756936ef0c0b2175663320713","0x00000000000000000000000000000023f9d11f3ecfe7f62be9524c863c1dc874","0x00000000000000000000000000000000002da3916ecb67c00f42a962473b0d60","0x0000000000000000000000000000008cbb64648b81e2d88c2e2acd9411de2c04","0x000000000000000000000000000000000003e3716c735f53222476c645f8c1df","0x000000000000000000000000000000c0deabd5bc078f4c2e34347e51dc08665d","0x00000000000000000000000000000000000b1e3794549547ce84cf504ef1f790","0x00000000000000000000000000000017435a1130a8f09b7f008b039f980e497f","0x0000000000000000000000000000000000183d7725e4086d845e7a073c091bed","0x000000000000000000000000000000f203bc6dd4bbf94d23f2c1134d42812ef2","0x00000000000000000000000000000000001c8ae3984efef7332b3e2f8e5bdb11","0x00000000000000000000000000000055523cf125d6c105b01a81e18a93385d73","0x000000000000000000000000000000000027af0adb42d6dfe987a5eda810f1ac","0x00000000000000000000000000000045b2c7e86bebc1bc2cc666e0c899eb8188","0x00000000000000000000000000000000001f955cb230f7dbec9465ee91468b40","0x00000000000000000000000000000057cccf985e9af0dadeef55c723765d87b6","0x0000000000000000000000000000000000197705eab64da334874994ccd8e0e4","0x00000000000000000000000000000068a2c3be28e298ec24ee52aea586306737","0x00000000000000000000000000000000000946c0d07552c2907bf4dbd07f23b3","0x000000000000000000000000000000527b1f269e0f9182b056a4c4f1bad72734","0x0000000000000000000000000000000000123fcf7b03c0f520b4c02284aa7263","0x000000000000000000000000000000aa7318c0a706dc9935f6254f820a290f8c","0x00000000000000000000000000000000002fe7f885bd76d63a52630afb2eb96c","0x0000000000000000000000000000000b2806b41403a451356b2a8ec66f451d10","0x00000000000000000000000000000000000a177c20fe67c97e7e49f73699070d","0x000000000000000000000000000000923eb1e0f63ad4e00142a457137470095c","0x000000000000000000000000000000000024978f1ed1f0abaf316b3e968d35ed","0x000000000000000000000000000000baf6b35de3a424c3557a63eae09eac22f9","0x00000000000000000000000000000000002647f354f0072d3519269dd79fa14b","0x000000000000000000000000000000c2d9700620ae22fd6bdf4d528eef1d35ae","0x000000000000000000000000000000000016eb34c2d447770351e801cc9b004c","0x000000000000000000000000000000f9b479b749dfebf01b58c841109945b9d8","0x0000000000000000000000000000000000135fbaabb54c672d9f580e958f8a2a","0x0000000000000000000000000000000cc5e4dfb7bdeef3d214b54354f9a0adb4","0x000000000000000000000000000000000002d0a67f80ae376ffa582cc0316ea9","0x000000000000000000000000000000258db7eba59c614306d884b705c74f36c5","0x00000000000000000000000000000000002f438da1a6fed88d2cb38b02f67f4b","0x00000000000000000000000000000020809242a456386d49aeb7d2fdee2dc874","0x0000000000000000000000000000000000264d7ba415e912a7e32c327970770a","0x00000000000000000000000000000030e4c165de1305183c731034d89d1df666","0x000000000000000000000000000000000023b6d9eaded11e563905a463b38a18","0x0000000000000000000000000000005bae16c4c2d40aee47692aa29579359e57","0x00000000000000000000000000000000000c64d0f337645b360ac89550b52eab","0x000000000000000000000000000000e447cb5ccd9365e3a14e6f8dd3f45b32d5","0x00000000000000000000000000000000002e23a5d1c317ee92d18515798bfc8d","0x00000000000000000000000000000098ccdc7fcb669a7d2e8558ec750e2fa612","0x00000000000000000000000000000000000c92ae44da6be8e0f691d2c4528e88","0x0000000000000000000000000000007e6dfab12df0cafc95f92ebb4512d23a9b","0x00000000000000000000000000000000002b452643d7967666962604fb48a4b4","0x0000000000000000000000000000002f14092777e6d6fb8684664e7df7cb6a33","0x000000000000000000000000000000000012242645114a2ae75392abb64a0235","0x00000000000000000000000000000074ea6aca418b22396785498539cd785fab","0x000000000000000000000000000000000009988525d643f69d57290850acf393","0x0000000000000000000000000000004f37d2f15db09dae0845148569ddc663fd","0x00000000000000000000000000000000002fc85d102a6c77e4d471e84d0502af","0x0000000000000000000000000000007b7fc7044729e116cb97a297baaf1eba3f","0x0000000000000000000000000000000000138d5b07df3374d865bf8ec55d3ccc","0x000000000000000000000000000000984d80910016772cb83ed3881f90ebcc8c","0x00000000000000000000000000000000001b6540dd28b967d06897d2fe4f4732","0x000000000000000000000000000000b7445646b5160c380130b66e34450aa736","0x00000000000000000000000000000000000ef8897e2c65d0ec75dc09f85edeac","0x00000000000000000000000000000045688ef774d85c8bd41f9665bad2d794c8","0x00000000000000000000000000000000002ac07a74c19ad88489ed6f1e93dc9e","0x000000000000000000000000000000a5a32649850aae3c839626ec285717f399","0x000000000000000000000000000000000010cdf0710877e231d375a2ef4dc3d3","0x000000000000000000000000000000160d4e95b954b96077723f1800f420dc77","0x0000000000000000000000000000000000280ae74a2dae2f5e68706031174b76","0x000000000000000000000000000000440bb5fc5471f6a37dbe12fbef6a37c009","0x0000000000000000000000000000000000106864b3460bfded9d5dc448fe29e8","0x000000000000000000000000000000d29de5d4acc99c6c72fa17e0a80bf88401","0x000000000000000000000000000000000007e83dfa443e984d53e0323523f742","0x0000000000000000000000000000001cfe095fa8e5d91145e1222746c27083ad","0x000000000000000000000000000000000006a7d963d96d78bc035faf0a744f8e","0x0000000000000000000000000000000000000000000000000000000000000001","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000002","0x0000000000000000000000000000000000000000000000000000000000000000","0x000000000000000000000000000000de9e600862f91873e12f0d906fe6fb4b35","0x0000000000000000000000000000000000268e1172734cd620010a0e7bd33318","0x0000000000000000000000000000006222cac69b3a4024afa430991710a6fe53","0x000000000000000000000000000000000018c0b8f57b190c18dcd096aa188b5d"] \ No newline at end of file +["0x0000000000000000000000000000000000000000000000000000000000008000","0x000000000000000000000000000000000000000000000000000000000000001f","0x0000000000000000000000000000000000000000000000000000000000000001","0x000000000000000000000000000000000000000000000000000000000000000f","0x000000000000000000000000000000eeba595d642c330ff0052a30356aef944d","0x000000000000000000000000000000000010e3400ec4848ce654b0033bd1909c","0x0000000000000000000000000000002b7a9a001dcd869f8a0f57a9463fbda32e","0x00000000000000000000000000000000000a1c18400fcebbffedebc9f1d7957f","0x000000000000000000000000000000037dca156d5c0e70124cea51e227a0146b","0x00000000000000000000000000000000002a4b62dbc27de48f7031e5aab1583e","0x000000000000000000000000000000136c427b7f48da876c44c2adc3a2369e5d","0x000000000000000000000000000000000022e1a1a0c6e0adb02dbed58ed9333b","0x00000000000000000000000000000086be6e47fb05c8a34ee650da276874a16e","0x00000000000000000000000000000000002ffcdf47719489739c0ccd79a094a4","0x0000000000000000000000000000001d83a98627294b99ea6b8b987226bca454","0x0000000000000000000000000000000000112246923b49d0709e410042c4860b","0x00000000000000000000000000000038f6ec2a41271e1da7b722ee6abc79ff09","0x0000000000000000000000000000000000083462567704201deb6dcc33c9cfe4","0x000000000000000000000000000000401aaa7d6ab87aa284f84f8ad30ac71c49","0x00000000000000000000000000000000002249363ebb246b83846ad9ac5b3ed6","0x000000000000000000000000000000bae8ada0ee2188885f5b8effd9e61b02a1","0x0000000000000000000000000000000000286bf8187ddec53c2ec9ac55e9cdd4","0x000000000000000000000000000000c101fd8b4d894b84208bc3720568bdca84","0x00000000000000000000000000000000002b0edb22bc1d39cba3be724c04018d","0x000000000000000000000000000000b06aacb1989ba061c490d4f2ec0857d429","0x00000000000000000000000000000000001df228e6da104ccd52eb0f004112bd","0x000000000000000000000000000000f803b078140485be6e5e6fe54b54758dd6","0x00000000000000000000000000000000002bdae37d3fae349df4b291c8b28987","0x000000000000000000000000000000176df2b9f9816b99b87dcd27314f248492","0x00000000000000000000000000000000000396fae9cfc50f85ebfe6bf44327d7","0x0000000000000000000000000000000107a790c778e6a2ffbae75e5764ee8cc2","0x00000000000000000000000000000000000e0f2756936ef0c0b2175663320713","0x000000000000000000000000000000df66bc3e9f02258d5ab62bdc7217cf2d8d","0x0000000000000000000000000000000000071538f67f71e31ecf217d87ac3038","0x000000000000000000000000000000e36eddb6fd7bbbbe99f71ff10df7ef86fa","0x00000000000000000000000000000000000a42c0ff54a34a80cb110dcb90f724","0x00000000000000000000000000000068bdc47e00f54f7343c34b2c48368ebde6","0x00000000000000000000000000000000001d418813d959151e7f2eba7d7f5c51","0x000000000000000000000000000000cb095b6f7caee08e4e5e30eea991d86920","0x0000000000000000000000000000000000106af79cd3966436c97d57b5b3fc2a","0x000000000000000000000000000000bd02c58e4ee0c2bb494de03af6bc7526ab","0x00000000000000000000000000000000000d6405504013ca3e28a415a7f71669","0x000000000000000000000000000000685cb2ece723d459108a821240d3fc2262","0x000000000000000000000000000000000029244ec479537ad66815d0389fc4d2","0x0000000000000000000000000000004415003ff210bb2ce2f3bef905b903520b","0x00000000000000000000000000000000001542f730190d952f86315d3d29457a","0x00000000000000000000000000000054d142734abe1f06533e7316a0ddd16496","0x00000000000000000000000000000000001873d1a3f4ddbf6e0958e5bb1106af","0x000000000000000000000000000000a98543fb0eb78f681eb4cb0d3e14d2a9dd","0x000000000000000000000000000000000014fcb07f82d6efdd47ca3f7248c61e","0x00000000000000000000000000000005ba5d932d7b77bef91294a42ef37aa1b1","0x00000000000000000000000000000000000b627e9389774df7f4bd5563897614","0x000000000000000000000000000000f01a7d58ea57541ff46b6bfe1193f521c7","0x00000000000000000000000000000000002b084ad4761ae968a05151fed32ac2","0x0000000000000000000000000000009df8752bd4bc40071d8a3821dea722997d","0x000000000000000000000000000000000003cc62c1a745e20ef2af508d94d96a","0x000000000000000000000000000000e87bcab37cd4e308aa243f2f00bbcdb146","0x00000000000000000000000000000000000f5f0933ac6e90a320c03886ce5ef5","0x00000000000000000000000000000010c95aed0ecbe9e328bc0022428fe2b9d1","0x0000000000000000000000000000000000261e1356aa63e9f3b929351ef34520","0x000000000000000000000000000000d0316171b1fcbc63d492e5c921e1d85534","0x0000000000000000000000000000000000135bf1d5004195d5aee992f4e8d2ef","0x000000000000000000000000000000e2f72da69c7cbf9bd22617ef95bc07db46","0x00000000000000000000000000000000000f4c6d60625829f72ad52827157819","0x000000000000000000000000000000aed2ec42f4730788107991640341ecec01","0x00000000000000000000000000000000002ccb40475a3d4394cf306d8e187b46","0x0000000000000000000000000000004fbe69b6da34d7526ca644393f340acb1f","0x00000000000000000000000000000000002055a571e8058e11fe5bd7a70df194","0x0000000000000000000000000000002a827b802fdc3fa2bb96e45de06250fec6","0x0000000000000000000000000000000000283d3c31b996ab705d67d593823e23","0x000000000000000000000000000000bc1001981431c53496a849a2259d03b457","0x000000000000000000000000000000000016245614bd6436e48273825ec30279","0x000000000000000000000000000000d0b237370730e5c12b49202f8eed05caa0","0x000000000000000000000000000000000011834ef1f9a10486f2ca206bc00455","0x000000000000000000000000000000cda55dfbdd2802299831bca66dc058e055","0x0000000000000000000000000000000000207f3ebb920fc90d406d3a612a2daf","0x000000000000000000000000000000e13bb20551a2af5f8ac36ebaac7876a1c4","0x000000000000000000000000000000000028017bb468f2ed75a6887614e5a9b1","0x000000000000000000000000000000b5a4ceab339d24da55f579aa04f192eab2","0x0000000000000000000000000000000000040edbf67c3d6d99fa176b85dae651","0x000000000000000000000000000000cfb60fe92fb07d24b9de89186c75086f1e","0x00000000000000000000000000000000000a4cc323ddffb2bde796024f39bc52","0x0000000000000000000000000000006fb079278fc9ad6c879493fc3bc60da717","0x00000000000000000000000000000000001588a466568529e8a6c5b33fcc26e3","0x000000000000000000000000000000bf3de286969e366510b04f43ee02b06117","0x000000000000000000000000000000000008175e28145dca69106c8791d8a2e5","0x0000000000000000000000000000002c116fa0beca775474a5fe7b78fc2f2bba","0x00000000000000000000000000000000000c999129c3c06772c6fbbc7355c09c","0x000000000000000000000000000000984d80910016772cb83ed3881f90ebcc8c","0x00000000000000000000000000000000001b6540dd28b967d06897d2fe4f4732","0x000000000000000000000000000000b7445646b5160c380130b66e34450aa736","0x00000000000000000000000000000000000ef8897e2c65d0ec75dc09f85edeac","0x00000000000000000000000000000045688ef774d85c8bd41f9665bad2d794c8","0x00000000000000000000000000000000002ac07a74c19ad88489ed6f1e93dc9e","0x000000000000000000000000000000a5a32649850aae3c839626ec285717f399","0x000000000000000000000000000000000010cdf0710877e231d375a2ef4dc3d3","0x000000000000000000000000000000160d4e95b954b96077723f1800f420dc77","0x0000000000000000000000000000000000280ae74a2dae2f5e68706031174b76","0x000000000000000000000000000000440bb5fc5471f6a37dbe12fbef6a37c009","0x0000000000000000000000000000000000106864b3460bfded9d5dc448fe29e8","0x000000000000000000000000000000d29de5d4acc99c6c72fa17e0a80bf88401","0x000000000000000000000000000000000007e83dfa443e984d53e0323523f742","0x0000000000000000000000000000001cfe095fa8e5d91145e1222746c27083ad","0x000000000000000000000000000000000006a7d963d96d78bc035faf0a744f8e","0x0000000000000000000000000000000000000000000000000000000000000001","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000002","0x0000000000000000000000000000000000000000000000000000000000000000","0x000000000000000000000000000000f0632125fa5d1bd312c714447d2ee3f921","0x00000000000000000000000000000000000e3504c34901d8cf39ca6008e672b7","0x00000000000000000000000000000015d3e4e604b8962bdca2dff461051dca20","0x00000000000000000000000000000000002d2b8c20d3ca4c73e2f8b6854b8c67"] \ No newline at end of file diff --git a/packages/tokens/src/confidential/circuits/withdraw/src/main.nr b/packages/tokens/src/confidential/circuits/withdraw/src/main.nr index f005c0623..2e5057e09 100644 --- a/packages/tokens/src/confidential/circuits/withdraw/src/main.nr +++ b/packages/tokens/src/confidential/circuits/withdraw/src/main.nr @@ -1,6 +1,6 @@ use stellar_confidential_lib::{ - H, assert_on_curve_non_identity, assert_range_128, commit, derive_spend_r, ecdh, - encrypt_auditor_sender_balance, encrypt_balance, scalar_mul, vk_from_sk, + H, assert_on_curve_non_identity, commit, derive_spend_r, ecdh, encrypt_auditor_sender_balance, + encrypt_balance, scalar_mul, vk_from_sk, }; use std::embedded_curve_ops::EmbeddedCurvePoint; @@ -16,8 +16,12 @@ mod tests; // key // W3 C_spend = v * G + r * H opening of current // spendable balance -// W4 v, a, v - a in [0, 2^128) range validity -// (Section 2.6) +// W4 v, a, v - a in [0, 2^127) range validity +// (Section 2.6); the +// [0, 2^127) bound is +// the SEP-41 i128 +// non-negative range +// (Section 3.4) // W5 r' = Poseidon2(delta_spend_r, vk, sigma) deterministic // randomness for the // new balance @@ -67,8 +71,9 @@ mod tests; // 5 k_aud_s_x K_aud_s.x Fetched from the auditor contract by // 6 k_aud_s_y K_aud_s.y `from.auditor_id`. // 7 a a Public withdrawal amount; the wrapper -// pre-checks a >= 0 at the entrypoint, -// W4 then closes a < 2^128 in-circuit. +// pre-checks a >= 0 at the entrypoint +// (Section 3.4), W4 then closes +// a < 2^127 in-circuit. // 8 c_spend_new_x C_spend'.x Prover-supplied; written to // 9 c_spend_new_y C_spend'.y `from.spendable_balance` on success. // 10 sigma sigma Prover-supplied random salt; emitted. @@ -125,11 +130,13 @@ fn main( assert(c_spend_derived.x == c_spend_x); assert(c_spend_derived.y == c_spend_y); - // W4 - assert_range_128(v); - assert_range_128(a); + // W4 -- Section 2.6 spells out the 127-bit decomposition / recomposition + // pattern; `assert_max_bit_size` is the Noir stdlib primitive that + // implements it directly. + v.assert_max_bit_size::<127>(); + a.assert_max_bit_size::<127>(); let v_new = v - a; - assert_range_128(v_new); + v_new.assert_max_bit_size::<127>(); // W5 let r_new = derive_spend_r(vk, sigma); diff --git a/packages/tokens/src/confidential/circuits/withdraw/src/tests.nr b/packages/tokens/src/confidential/circuits/withdraw/src/tests.nr index ccdacffd9..e994848ab 100644 --- a/packages/tokens/src/confidential/circuits/withdraw/src/tests.nr +++ b/packages/tokens/src/confidential/circuits/withdraw/src/tests.nr @@ -217,7 +217,10 @@ fn rejects_under_funded_withdrawal() { #[test(should_fail)] fn rejects_v_out_of_range() { - let v_huge: Field = 0x100000000000000000000000000000000; // 2^128 + // 2^127 is exactly the boundary W4 rules out (Section 2.6 / 3.4 -- SEP-41 + // i128 non-negative range is [0, 2^127), so 2^127 is the smallest value + // that must fail). + let v_huge: Field = 0x80000000000000000000000000000000; main( SK, v_huge, @@ -243,7 +246,8 @@ fn rejects_v_out_of_range() { #[test(should_fail)] fn rejects_a_out_of_range() { - let a_huge: Field = 0x100000000000000000000000000000000; + // a = 2^127: W4's range check on a fires at the exact SEP-41 boundary. + let a_huge: Field = 0x80000000000000000000000000000000; main( SK, V, From 6ece1f12e0f206081f6c0187bca8750674c63fed Mon Sep 17 00:00:00 2001 From: brozorec <9572072+brozorec@users.noreply.github.com> Date: Fri, 22 May 2026 16:07:45 +0200 Subject: [PATCH 06/15] docs: fix inline --- .../circuits/withdraw/src/main.nr | 54 +++++++++---------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/packages/tokens/src/confidential/circuits/withdraw/src/main.nr b/packages/tokens/src/confidential/circuits/withdraw/src/main.nr index 2e5057e09..4e041b3e0 100644 --- a/packages/tokens/src/confidential/circuits/withdraw/src/main.nr +++ b/packages/tokens/src/confidential/circuits/withdraw/src/main.nr @@ -11,42 +11,42 @@ mod tests; // Constraints // ----------- // Main block (balance conservation + ownership) -// W1 Y = sk * H owner key ownership -// W2 vk = Poseidon2(delta_vk, sk, wrap) wrapper-bound viewing -// key -// W3 C_spend = v * G + r * H opening of current -// spendable balance -// W4 v, a, v - a in [0, 2^127) range validity +// W1 Y = sk * H Owner key ownership. +// W2 vk = Poseidon2(delta_vk, sk, wrap) Wrapper-bound viewing +// key. +// W3 C_spend = v * G + r * H Opening of current +// spendable balance. +// W4 v, a, v - a in [0, 2^127) Range validity // (Section 2.6); the // [0, 2^127) bound is // the SEP-41 i128 // non-negative range // (Section 3.4) -// W5 r' = Poseidon2(delta_spend_r, vk, sigma) deterministic +// W5 r' = Poseidon2(delta_spend_r, vk, sigma) Deterministic // randomness for the -// new balance -// W6 C_spend' = (v - a) * G + r' * H new spendable -// commitment +// new balance. +// W6 C_spend' = (v - a) * G + r' * H New spendable +// commitment. // W7 b_tilde = (v - a) // + Poseidon2(delta_enc_bal, vk, sigma) -// encrypted balance -// scalar (emitted) -// Auditor block (sender-auditor visibility, Section 8.1) -// W_a1 R_e = r_e * H ephemeral key for -// auditor ECDH -// W_a2 S_{a,s} = r_e * K_{aud,s} sender-auditor ECDH -// shared secret -// W_a3 m_b = SpongeSqueeze_1(delta_aud_s, -// S_{a,s}.x, sigma) sender-channel -// sponge, single squeeze -// W_a4 b_tilde_aud_s = (v - a) + m_b sender-auditor -// encrypted balance -// checkpoint (emitted) -// W_a5 r_e != 0 rules out R_e = O and +// Encrypted balance +// scalar (emitted). +// W8 r_e != 0 Rules out R_e = O and // S_{a,s} = O, which // would collapse m_b to // a constant function -// of sigma +// of sigma. +// Auditor block (sender-auditor visibility, Section 8.1) +// W_a1 R_e = r_e * H Ephemeral key for +// auditor ECDH. +// W_a2 S_{a,s} = r_e * K_{aud,s} Sender-auditor ECDH +// shared secret. +// W_a3 m_b = SpongeSqueeze_1(delta_aud_s, +// S_{a,s}.x, sigma) Sender-channel +// sponge, single squeeze. +// W_a4 b_tilde_aud_s = (v - a) + m_b Sender-auditor +// encrypted balance +// checkpoint (emitted). // // W_a3 + W_a4 are encapsulated by `encrypt_auditor_sender_balance` in the lib. // @@ -90,7 +90,7 @@ mod tests; // v Plaintext spendable-balance value. // r Plaintext blinding factor for C_spend. // r_e Ephemeral scalar for the auditor ECDH; must satisfy r_e != 0 -// (W_a5). +// (W8). fn main( sk: Field, @@ -113,7 +113,7 @@ fn main( r_e_y: pub Field, b_tilde_aud_s: pub Field, ) { - // W_a5 -- runs first so the r_e = 0 attack is rejected before any + // W8 -- runs first so the r_e = 0 attack is rejected before any // scalar mul against it could quietly produce the identity. assert(r_e != 0); From 89e054ab61fc6bb72efc7c459336870144d54749 Mon Sep 17 00:00:00 2001 From: brozorec <9572072+brozorec@users.noreply.github.com> Date: Fri, 22 May 2026 16:20:13 +0200 Subject: [PATCH 07/15] test(confidential): isolate W4 in rejects_v_out_of_range The test previously fired W3 (commit(v_huge, R) != stale C_SPEND) before W4 ever ran, so the range check was never the load-bearing assertion. Recompute C_spend from v_huge so W3 passes and the very first W4 line (v.assert_max_bit_size::<127>()) becomes the failing constraint. rejects_a_out_of_range already isolates W4 (V is unchanged, W3 passes, W4 hits a.assert_max_bit_size::<127>() on a_huge = 2^127) so it is left as-is. --- .../src/confidential/circuits/withdraw/src/tests.nr | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/tokens/src/confidential/circuits/withdraw/src/tests.nr b/packages/tokens/src/confidential/circuits/withdraw/src/tests.nr index e994848ab..32cb0874e 100644 --- a/packages/tokens/src/confidential/circuits/withdraw/src/tests.nr +++ b/packages/tokens/src/confidential/circuits/withdraw/src/tests.nr @@ -219,15 +219,18 @@ fn rejects_under_funded_withdrawal() { fn rejects_v_out_of_range() { // 2^127 is exactly the boundary W4 rules out (Section 2.6 / 3.4 -- SEP-41 // i128 non-negative range is [0, 2^127), so 2^127 is the smallest value - // that must fail). + // that must fail). C_spend must be recomputed against v_huge so W3 + // passes and W4 is the load-bearing assertion; downstream constraints + // (W6/W7/W_a4) are never reached. let v_huge: Field = 0x80000000000000000000000000000000; + let c_spend = commit(v_huge, R); main( SK, v_huge, R, R_E, - C_SPEND_X, - C_SPEND_Y, + c_spend.x, + c_spend.y, Y_X, Y_Y, WRAP, From d719111560dcda29d16b8fb065378833eb169d63 Mon Sep 17 00:00:00 2001 From: brozorec <9572072+brozorec@users.noreply.github.com> Date: Tue, 26 May 2026 10:20:45 +0200 Subject: [PATCH 08/15] =?UTF-8?q?chore(confidential):=20align=20Poseidon?= =?UTF-8?q?=20domain=20tags=20with=20DESIGN.md=20=C2=A713?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The design doc added delta_addr = 1 (§2.7), shifting every other tag up by one. Realign the lib constants, re-pin all Poseidon-derived fixtures and testdata, regenerate VKs, and fix a stale W_a5 label in the withdraw test (now W8). --- .../src/confidential/circuits/lib/src/lib.nr | 50 +++++++++++-------- .../confidential/circuits/lib/src/tests.nr | 34 +++++++------ .../circuits/lib/testdata/derive_allow_r.json | 4 +- .../circuits/lib/testdata/derive_spend_r.json | 4 +- .../lib/testdata/derive_tx_blind.json | 2 +- .../circuits/lib/testdata/dvk_from_vk_op.json | 4 +- .../lib/testdata/encrypt_allowance.json | 4 +- .../circuits/lib/testdata/encrypt_amount.json | 2 +- .../encrypt_auditor_sender_balance.json | 2 +- .../lib/testdata/encrypt_balance.json | 4 +- .../lib/testdata/encrypt_esc_dvk.json | 4 +- .../lib/testdata/poseidon_with_domain.json | 4 +- .../circuits/lib/testdata/pvk_from_vk.json | 6 +-- .../lib/testdata/sponge_squeeze_1.json | 4 +- .../lib/testdata/sponge_squeeze_2.json | 14 +++--- .../circuits/lib/testdata/vk_from_sk.json | 2 +- .../circuits/register/src/tests.nr | 4 +- .../circuits/vks/register.vk.json | 2 +- .../circuits/vks/withdraw.vk.json | 2 +- .../circuits/withdraw/src/tests.nr | 10 ++-- 20 files changed, 85 insertions(+), 77 deletions(-) diff --git a/packages/tokens/src/confidential/circuits/lib/src/lib.nr b/packages/tokens/src/confidential/circuits/lib/src/lib.nr index 0317a4d7f..cbafc8c26 100644 --- a/packages/tokens/src/confidential/circuits/lib/src/lib.nr +++ b/packages/tokens/src/confidential/circuits/lib/src/lib.nr @@ -77,59 +77,65 @@ global POSEIDON2_IV_BASE: Field = 18446744073709551616; // 2^64 /// /// | Design doc | Value | Constant | /// |:------------------|:------|:----------------------------------| -/// | `delta_vk` | 1 | `VIEWING_KEY` | -/// | `delta_dvk` | 2 | `DELEGATION_VIEWING_KEY` | -/// | `delta_spend_r` | 3 | `SPEND_RANDOMNESS` | -/// | `delta_tx_blind` | 4 | `TX_BLINDING` | -/// | `delta_tx_amount` | 5 | `TX_AMOUNT` | -/// | `delta_enc_bal` | 6 | `ENCRYPTED_BALANCE` | -/// | `delta_enc_allow` | 7 | `ENCRYPTED_ALLOWANCE` | -/// | `delta_allow_r` | 8 | `ALLOWANCE_RANDOMNESS` | -/// | `delta_esc_dvk` | 9 | `ESCROWED_DELEGATION_VIEWING_KEY` | -/// | `delta_aud_s` | 10 | `AUDITOR_SENDER` | -/// | `delta_aud_r` | 11 | `AUDITOR_RECIPIENT` | +/// | `delta_addr` | 1 | `ADDRESS` | +/// | `delta_vk` | 2 | `VIEWING_KEY` | +/// | `delta_dvk` | 3 | `DELEGATION_VIEWING_KEY` | +/// | `delta_spend_r` | 4 | `SPEND_RANDOMNESS` | +/// | `delta_tx_blind` | 5 | `TX_BLINDING` | +/// | `delta_tx_amount` | 6 | `TX_AMOUNT` | +/// | `delta_enc_bal` | 7 | `ENCRYPTED_BALANCE` | +/// | `delta_enc_allow` | 8 | `ENCRYPTED_ALLOWANCE` | +/// | `delta_allow_r` | 9 | `ALLOWANCE_RANDOMNESS` | +/// | `delta_esc_dvk` | 10 | `ESCROWED_DELEGATION_VIEWING_KEY` | +/// | `delta_aud_s` | 11 | `AUDITOR_SENDER` | +/// | `delta_aud_r` | 12 | `AUDITOR_RECIPIENT` | mod domain { + /// Soroban Address compression into a single `F_r` Field: + /// `address_to_field(a) = Poseidon2(ADDRESS, lo(a), hi(a))`. + /// Section 2.7 (`delta_addr`). The wrapper computes `wrap` and `op_i` + /// off-circuit via this tag; circuits consume the resulting Field directly. + pub(crate) global ADDRESS: Field = 1; /// Viewing-key derivation: `vk = Poseidon2(VIEWING_KEY, sk, wrap)`. /// Section 4.2 (`delta_vk`). - pub(crate) global VIEWING_KEY: Field = 1; + pub(crate) global VIEWING_KEY: Field = 2; /// Delegation viewing-key derivation: /// `dvk = Poseidon2(DELEGATION_VIEWING_KEY, vk, op_i)`. Section 4.4 (`delta_dvk`). - pub(crate) global DELEGATION_VIEWING_KEY: Field = 2; + pub(crate) global DELEGATION_VIEWING_KEY: Field = 3; /// Deterministic randomness for the new spendable balance: /// `r' = Poseidon2(SPEND_RANDOMNESS, vk, sigma)`. Constraints W5/T10/S9/V6 (`delta_spend_r`). - pub(crate) global SPEND_RANDOMNESS: Field = 3; + pub(crate) global SPEND_RANDOMNESS: Field = 4; /// ECDH-derived blinding for the transfer commitment: /// `r_tx = Poseidon2(TX_BLINDING, s, sigma)`. Constraints T7/O7 (`delta_tx_blind`). - pub(crate) global TX_BLINDING: Field = 4; + pub(crate) global TX_BLINDING: Field = 5; /// Mask for the encrypted transfer amount: /// `v_tilde = v_tx + Poseidon2(TX_AMOUNT, s, sigma)`. Constraints T9/O9 (`delta_tx_amount`). - pub(crate) global TX_AMOUNT: Field = 5; + pub(crate) global TX_AMOUNT: Field = 6; /// Mask for the encrypted balance scalar: /// `b_tilde = v_new + Poseidon2(ENCRYPTED_BALANCE, vk, sigma)`. /// Constraints W7/T12/S11/V8 (`delta_enc_bal`). - pub(crate) global ENCRYPTED_BALANCE: Field = 6; + pub(crate) global ENCRYPTED_BALANCE: Field = 7; /// Mask for the encrypted allowance scalar: /// `a_tilde = v_a + Poseidon2(ENCRYPTED_ALLOWANCE, dvk, sigma_a)`. /// Constraints S8/O12 (`delta_enc_allow`). - pub(crate) global ENCRYPTED_ALLOWANCE: Field = 7; + pub(crate) global ENCRYPTED_ALLOWANCE: Field = 8; /// Deterministic randomness for the operator allowance: /// `r_a = Poseidon2(ALLOWANCE_RANDOMNESS, dvk, sigma_a)`. /// Constraints S6/O3/O10 (`delta_allow_r`). - pub(crate) global ALLOWANCE_RANDOMNESS: Field = 8; + pub(crate) global ALLOWANCE_RANDOMNESS: Field = 9; /// Delegation-key escrow mask (operator ECDH): /// `Poseidon2(ESCROWED_DELEGATION_VIEWING_KEY, s, op_i)`. /// Constraint S12, Section 7.11 (`delta_esc_dvk`). - pub(crate) global ESCROWED_DELEGATION_VIEWING_KEY: Field = 9; + pub(crate) global ESCROWED_DELEGATION_VIEWING_KEY: Field = 10; /// Sender or owner-auditor channel tag for Poseidon2 sponge masks /// (Section 2.5, Section 8.1). Squeeze 1 yields the amount mask (where /// applicable); squeeze 2 yields the balance/allowance checkpoint mask. /// Constraints W_a3 / T_a6 / S_a3 / V_a3 / O_a6 (`delta_aud_s`). - pub(crate) global AUDITOR_SENDER: Field = 10; + pub(crate) global AUDITOR_SENDER: Field = 11; /// Recipient-auditor channel tag for Poseidon2 sponge masks /// (Section 2.5, Section 8.1). Squeeze 1 yields the amount mask; squeeze /// 2 yields the per-transfer Pedersen randomness mask. /// Constraints T_a2 / O_a2 (`delta_aud_r`). - pub(crate) global AUDITOR_RECIPIENT: Field = 11; + pub(crate) global AUDITOR_RECIPIENT: Field = 12; } // ################## CORE PRIMITIVES ################## diff --git a/packages/tokens/src/confidential/circuits/lib/src/tests.nr b/packages/tokens/src/confidential/circuits/lib/src/tests.nr index 25bf1c701..0f1ecf6e4 100644 --- a/packages/tokens/src/confidential/circuits/lib/src/tests.nr +++ b/packages/tokens/src/confidential/circuits/lib/src/tests.nr @@ -176,6 +176,7 @@ fn domain_separation_distinct() { // Touches every domain tag at least once. let a: Field = 42; let b: Field = 99; + let h_addr = poseidon_with_domain(domain::ADDRESS, [a, b]); let h_vk = poseidon_with_domain(domain::VIEWING_KEY, [a, b]); let h_dvk = poseidon_with_domain(domain::DELEGATION_VIEWING_KEY, [a, b]); let h_sr = poseidon_with_domain(domain::SPEND_RANDOMNESS, [a, b]); @@ -189,6 +190,7 @@ fn domain_separation_distinct() { let h_ar2 = poseidon_with_domain(domain::AUDITOR_RECIPIENT, [a, b]); // Distinct from VK. + assert(h_vk != h_addr); assert(h_vk != h_dvk); assert(h_vk != h_sr); assert(h_vk != h_tb); @@ -427,43 +429,43 @@ fn fixtures_match_testdata() { ); let vk = vk_from_sk(sk, wrap); - assert(vk == 0x0ac6ade91aea36f4de35eb5a06a67e1b9c3dab9842479f1cce85caf826194ec2); + assert(vk == 0x208fbdb70d2faacf04f987b54f12aeeaeb432acc29d650c86ce0f6275b958eb8); let pvk = pvk_from_vk(vk); - assert(pvk.x == 0x01de20bfe21ad660e073b14a106feae4afce4e78a020953ca16b767e0615331b); - assert(pvk.y == 0x12cf407b75fccb5c7721c5fdadc3d1a22b5440e36b08fe52658ab885529e92fd); + assert(pvk.x == 0x2e7421c0e86a4c8eed823edf851c364ecee448bf70eae2a95befe3dd9364cc45); + assert(pvk.y == 0x0b060655740aabef84e7305bb886b75da8d258f346381f03925d69645292fe7a); let dvk = dvk_from_vk_op(vk, op_i); - assert(dvk == 0x1dec1da326d325cf918eed33f097cfb854b41aac8ece9e6e8bd54b7a7c448785); + assert(dvk == 0x1a088264ebf7269160bbf34d5a3f94d7dec37efc609ef76c5dbfa8690af3eae9); assert( derive_spend_r(vk, sigma) - == 0x1b9d813c7c14c78969edb37cce24f3bb867f6e64efe73d950cfbbe8ed4c31482, + == 0x0218f93b7ea0735d4934904f2bf26fc20c33dc01fb032a8f00aa67147132ec91, ); assert( derive_allow_r(dvk, sigma_a) - == 0x199b47ff8b24b165411fa44b06d541b9d1b74d0d202fd651712e24c11e05cb35, + == 0x0afda20e062a277afc0c86e31ea22597ee2c094b1b9cd08b8d8a2a212a8af1ee, ); assert( derive_tx_blind(s_ecdh, sigma) - == 0x15388ad70c7a48cfed4fb9cfbd633ff18617c9e52829b5d51eaab938baae33a7, + == 0x274c4f45d57544efca9f6f505e6b29ae098f1d30243de28f7cef62d05484c83d, ); assert( encrypt_amount(v_tx, s_ecdh, sigma) - == 0x274c4f45d57544efca9f6f505e6b29ae098f1d30243de28f7cef62d05484c8a1, + == 0x2a07070eb651b03ecc22453ad1cc69a39567ca27fb4224e8727a5fbc83091526, ); assert( encrypt_balance(v, vk, sigma) - == 0x2b7f92c72c05ae8ecf7207fdaecea32ef748cd4dd43ca9ed6dc6d226865b43af, + == 0x04d1659db899a50a94dcfc54a18b8adaf6e9e8e3046bd11893fbd4a86a7c579c, ); assert( encrypt_allowance(v_a, dvk, sigma_a) - == 0x235db43a283e4e000337cdb80a99168ef4b718622cc06de4ab98b3d2bd5736d5, + == 0x2440e4fcb575aa4433cd53a37a7396d5022f8c287298cacc0cfc0857250ef02a, ); assert( encrypt_esc_dvk(dvk, s_ecdh, op_i) - == 0x2468d02775ad21aa5928d049770287249af2a03dbaf4d18e22e3fe71fd292003, + == 0x15f3b867cbe94f0b6371e46752431be550f907ba8448c6191b7ba1f47c2cb83e, ); // Per-channel auditor sponge (v0.6, Section 2.5). Values pinned below are @@ -498,15 +500,15 @@ fn fixtures_match_testdata() { // `testdata/sponge_squeeze_*.json`. Update both sides together if the lib's // sponge construction or domain constants change. global SPONGE_SQUEEZE_1_AUDITOR_SENDER: Field = - 0x266e76dccd3d7c654ab7a48045cca920c497b1a6758981acfa6f217427ac3074; + 0x2787d1e01bbca7828e13e9b2b3fa11cfcfe9c3d2b121b9e17543146822fe1feb; global SPONGE_SQUEEZE_2_AUDITOR_SENDER_0: Field = - 0x266e76dccd3d7c654ab7a48045cca920c497b1a6758981acfa6f217427ac3074; + 0x2787d1e01bbca7828e13e9b2b3fa11cfcfe9c3d2b121b9e17543146822fe1feb; global SPONGE_SQUEEZE_2_AUDITOR_SENDER_1: Field = - 0x248b757f9fec92895bd3fb91b1a5c9c17467df6f0f55ab574d85e943df8e5cf3; + 0x27f3739a132c6353cd5af3edac0ac75faf7fc606acb61367774e4f764ec17b77; global SPONGE_SQUEEZE_2_AUDITOR_RECIPIENT_0: Field = - 0x2787d1e01bbca7828e13e9b2b3fa11cfcfe9c3d2b121b9e17543146822fe1feb; + 0x0801e6d7184cea11e6225fcfb4e88a90674cb085da8ebc04918c57cc44b9c25d; global SPONGE_SQUEEZE_2_AUDITOR_RECIPIENT_1: Field = - 0x27f3739a132c6353cd5af3edac0ac75faf7fc606acb61367774e4f764ec17b77; + 0x131187e2ac296c1c54c2be2a5cbe614644f01c754108ce25359212d67b36ecc3; #[test] fn derive_helpers_deterministic_and_distinct() { diff --git a/packages/tokens/src/confidential/circuits/lib/testdata/derive_allow_r.json b/packages/tokens/src/confidential/circuits/lib/testdata/derive_allow_r.json index 6c8f0d8fb..ba6dfdbdc 100644 --- a/packages/tokens/src/confidential/circuits/lib/testdata/derive_allow_r.json +++ b/packages/tokens/src/confidential/circuits/lib/testdata/derive_allow_r.json @@ -5,10 +5,10 @@ "vectors": [ { "inputs": { - "dvk": "0x1dec1da326d325cf918eed33f097cfb854b41aac8ece9e6e8bd54b7a7c448785", + "dvk": "0x1a088264ebf7269160bbf34d5a3f94d7dec37efc609ef76c5dbfa8690af3eae9", "sigma_a": "0x02" }, - "output": "0x199b47ff8b24b165411fa44b06d541b9d1b74d0d202fd651712e24c11e05cb35" + "output": "0x0afda20e062a277afc0c86e31ea22597ee2c094b1b9cd08b8d8a2a212a8af1ee" } ] } diff --git a/packages/tokens/src/confidential/circuits/lib/testdata/derive_spend_r.json b/packages/tokens/src/confidential/circuits/lib/testdata/derive_spend_r.json index a47bc553e..64b3f5809 100644 --- a/packages/tokens/src/confidential/circuits/lib/testdata/derive_spend_r.json +++ b/packages/tokens/src/confidential/circuits/lib/testdata/derive_spend_r.json @@ -5,10 +5,10 @@ "vectors": [ { "inputs": { - "vk": "0x0ac6ade91aea36f4de35eb5a06a67e1b9c3dab9842479f1cce85caf826194ec2", + "vk": "0x208fbdb70d2faacf04f987b54f12aeeaeb432acc29d650c86ce0f6275b958eb8", "sigma": "0x01" }, - "output": "0x1b9d813c7c14c78969edb37cce24f3bb867f6e64efe73d950cfbbe8ed4c31482" + "output": "0x0218f93b7ea0735d4934904f2bf26fc20c33dc01fb032a8f00aa67147132ec91" } ] } diff --git a/packages/tokens/src/confidential/circuits/lib/testdata/derive_tx_blind.json b/packages/tokens/src/confidential/circuits/lib/testdata/derive_tx_blind.json index 2f7fa3d57..3ae424005 100644 --- a/packages/tokens/src/confidential/circuits/lib/testdata/derive_tx_blind.json +++ b/packages/tokens/src/confidential/circuits/lib/testdata/derive_tx_blind.json @@ -5,7 +5,7 @@ "vectors": [ { "inputs": { "s": "0x12345", "sigma": "0x01" }, - "output": "0x15388ad70c7a48cfed4fb9cfbd633ff18617c9e52829b5d51eaab938baae33a7" + "output": "0x274c4f45d57544efca9f6f505e6b29ae098f1d30243de28f7cef62d05484c83d" } ] } diff --git a/packages/tokens/src/confidential/circuits/lib/testdata/dvk_from_vk_op.json b/packages/tokens/src/confidential/circuits/lib/testdata/dvk_from_vk_op.json index 43fbdc89c..3c43f6a23 100644 --- a/packages/tokens/src/confidential/circuits/lib/testdata/dvk_from_vk_op.json +++ b/packages/tokens/src/confidential/circuits/lib/testdata/dvk_from_vk_op.json @@ -5,10 +5,10 @@ "vectors": [ { "inputs": { - "vk": "0x0ac6ade91aea36f4de35eb5a06a67e1b9c3dab9842479f1cce85caf826194ec2", + "vk": "0x208fbdb70d2faacf04f987b54f12aeeaeb432acc29d650c86ce0f6275b958eb8", "op_i": "0xabcd" }, - "output": "0x1dec1da326d325cf918eed33f097cfb854b41aac8ece9e6e8bd54b7a7c448785" + "output": "0x1a088264ebf7269160bbf34d5a3f94d7dec37efc609ef76c5dbfa8690af3eae9" } ] } diff --git a/packages/tokens/src/confidential/circuits/lib/testdata/encrypt_allowance.json b/packages/tokens/src/confidential/circuits/lib/testdata/encrypt_allowance.json index 3fe63afa1..5e7d30c3c 100644 --- a/packages/tokens/src/confidential/circuits/lib/testdata/encrypt_allowance.json +++ b/packages/tokens/src/confidential/circuits/lib/testdata/encrypt_allowance.json @@ -6,10 +6,10 @@ { "inputs": { "v_a": "0x1f4", - "dvk": "0x1dec1da326d325cf918eed33f097cfb854b41aac8ece9e6e8bd54b7a7c448785", + "dvk": "0x1a088264ebf7269160bbf34d5a3f94d7dec37efc609ef76c5dbfa8690af3eae9", "sigma_a": "0x02" }, - "output": "0x235db43a283e4e000337cdb80a99168ef4b718622cc06de4ab98b3d2bd5736d5" + "output": "0x2440e4fcb575aa4433cd53a37a7396d5022f8c287298cacc0cfc0857250ef02a" } ] } diff --git a/packages/tokens/src/confidential/circuits/lib/testdata/encrypt_amount.json b/packages/tokens/src/confidential/circuits/lib/testdata/encrypt_amount.json index 788b164b7..3465d75ea 100644 --- a/packages/tokens/src/confidential/circuits/lib/testdata/encrypt_amount.json +++ b/packages/tokens/src/confidential/circuits/lib/testdata/encrypt_amount.json @@ -5,7 +5,7 @@ "vectors": [ { "inputs": { "v_tx": "0x64", "s": "0x12345", "sigma": "0x01" }, - "output": "0x274c4f45d57544efca9f6f505e6b29ae098f1d30243de28f7cef62d05484c8a1" + "output": "0x2a07070eb651b03ecc22453ad1cc69a39567ca27fb4224e8727a5fbc83091526" } ] } diff --git a/packages/tokens/src/confidential/circuits/lib/testdata/encrypt_auditor_sender_balance.json b/packages/tokens/src/confidential/circuits/lib/testdata/encrypt_auditor_sender_balance.json index 6aecdd011..ff24253ab 100644 --- a/packages/tokens/src/confidential/circuits/lib/testdata/encrypt_auditor_sender_balance.json +++ b/packages/tokens/src/confidential/circuits/lib/testdata/encrypt_auditor_sender_balance.json @@ -9,7 +9,7 @@ "s_a_s_x": "0x12345", "sigma": "0x01" }, - "output": "0x266e76dccd3d7c654ab7a48045cca920c497b1a6758981acfa6f217427ac345c" + "output": "0x2787d1e01bbca7828e13e9b2b3fa11cfcfe9c3d2b121b9e17543146822fe23d3" } ] } diff --git a/packages/tokens/src/confidential/circuits/lib/testdata/encrypt_balance.json b/packages/tokens/src/confidential/circuits/lib/testdata/encrypt_balance.json index 78bf2922d..ec4cf51f4 100644 --- a/packages/tokens/src/confidential/circuits/lib/testdata/encrypt_balance.json +++ b/packages/tokens/src/confidential/circuits/lib/testdata/encrypt_balance.json @@ -6,10 +6,10 @@ { "inputs": { "v_new": "0x3e8", - "vk": "0x0ac6ade91aea36f4de35eb5a06a67e1b9c3dab9842479f1cce85caf826194ec2", + "vk": "0x208fbdb70d2faacf04f987b54f12aeeaeb432acc29d650c86ce0f6275b958eb8", "sigma": "0x01" }, - "output": "0x2b7f92c72c05ae8ecf7207fdaecea32ef748cd4dd43ca9ed6dc6d226865b43af" + "output": "0x04d1659db899a50a94dcfc54a18b8adaf6e9e8e3046bd11893fbd4a86a7c579c" } ] } diff --git a/packages/tokens/src/confidential/circuits/lib/testdata/encrypt_esc_dvk.json b/packages/tokens/src/confidential/circuits/lib/testdata/encrypt_esc_dvk.json index ed85fb202..edd5124ac 100644 --- a/packages/tokens/src/confidential/circuits/lib/testdata/encrypt_esc_dvk.json +++ b/packages/tokens/src/confidential/circuits/lib/testdata/encrypt_esc_dvk.json @@ -5,11 +5,11 @@ "vectors": [ { "inputs": { - "dvk": "0x1dec1da326d325cf918eed33f097cfb854b41aac8ece9e6e8bd54b7a7c448785", + "dvk": "0x1a088264ebf7269160bbf34d5a3f94d7dec37efc609ef76c5dbfa8690af3eae9", "s": "0x12345", "op_i": "0xabcd" }, - "output": "0x2468d02775ad21aa5928d049770287249af2a03dbaf4d18e22e3fe71fd292003" + "output": "0x15f3b867cbe94f0b6371e46752431be550f907ba8448c6191b7ba1f47c2cb83e" } ] } diff --git a/packages/tokens/src/confidential/circuits/lib/testdata/poseidon_with_domain.json b/packages/tokens/src/confidential/circuits/lib/testdata/poseidon_with_domain.json index 9139c2ee6..156b45991 100644 --- a/packages/tokens/src/confidential/circuits/lib/testdata/poseidon_with_domain.json +++ b/packages/tokens/src/confidential/circuits/lib/testdata/poseidon_with_domain.json @@ -4,8 +4,8 @@ "description": "The single Poseidon2 entry point. The domain tag is the FIRST absorbed element; the sponge uses rate=3, capacity=1, IV = (input_length+1) * 2^64. Vector below pins the VK-domain hash of [sk, wrap] -- equal to vk_from_sk by construction.", "vectors": [ { - "inputs": { "domain": "0x01", "inputs": ["0xdead", "0xbeef"] }, - "output": "0x0ac6ade91aea36f4de35eb5a06a67e1b9c3dab9842479f1cce85caf826194ec2" + "inputs": { "domain": "0x02", "inputs": ["0xdead", "0xbeef"] }, + "output": "0x208fbdb70d2faacf04f987b54f12aeeaeb432acc29d650c86ce0f6275b958eb8" } ] } diff --git a/packages/tokens/src/confidential/circuits/lib/testdata/pvk_from_vk.json b/packages/tokens/src/confidential/circuits/lib/testdata/pvk_from_vk.json index d44cd2df7..21a97c9b2 100644 --- a/packages/tokens/src/confidential/circuits/lib/testdata/pvk_from_vk.json +++ b/packages/tokens/src/confidential/circuits/lib/testdata/pvk_from_vk.json @@ -4,10 +4,10 @@ "description": "Public viewing key PVK = vk*H. Inputs use the vk produced by vk_from_sk(0xdead, 0xbeef).", "vectors": [ { - "inputs": { "vk": "0x0ac6ade91aea36f4de35eb5a06a67e1b9c3dab9842479f1cce85caf826194ec2" }, + "inputs": { "vk": "0x208fbdb70d2faacf04f987b54f12aeeaeb432acc29d650c86ce0f6275b958eb8" }, "output": { - "x": "0x01de20bfe21ad660e073b14a106feae4afce4e78a020953ca16b767e0615331b", - "y": "0x12cf407b75fccb5c7721c5fdadc3d1a22b5440e36b08fe52658ab885529e92fd" + "x": "0x2e7421c0e86a4c8eed823edf851c364ecee448bf70eae2a95befe3dd9364cc45", + "y": "0x0b060655740aabef84e7305bb886b75da8d258f346381f03925d69645292fe7a" } } ] diff --git a/packages/tokens/src/confidential/circuits/lib/testdata/sponge_squeeze_1.json b/packages/tokens/src/confidential/circuits/lib/testdata/sponge_squeeze_1.json index 741608e1d..ef920defb 100644 --- a/packages/tokens/src/confidential/circuits/lib/testdata/sponge_squeeze_1.json +++ b/packages/tokens/src/confidential/circuits/lib/testdata/sponge_squeeze_1.json @@ -4,8 +4,8 @@ "description": "Poseidon2 sponge with a single squeeze (one absorb of (d, s_x, sigma), permute, output state[0]). Used by sender-auditor channels that emit only the balance/allowance mask. Equivalent to poseidon_with_domain(d, [s_x, sigma]).", "vectors": [ { - "inputs": { "d": "0x0a", "s_x": "0x12345", "sigma": "0x01" }, - "output": "0x266e76dccd3d7c654ab7a48045cca920c497b1a6758981acfa6f217427ac3074" + "inputs": { "d": "0x0b", "s_x": "0x12345", "sigma": "0x01" }, + "output": "0x2787d1e01bbca7828e13e9b2b3fa11cfcfe9c3d2b121b9e17543146822fe1feb" } ] } diff --git a/packages/tokens/src/confidential/circuits/lib/testdata/sponge_squeeze_2.json b/packages/tokens/src/confidential/circuits/lib/testdata/sponge_squeeze_2.json index d95baa829..10774d8cf 100644 --- a/packages/tokens/src/confidential/circuits/lib/testdata/sponge_squeeze_2.json +++ b/packages/tokens/src/confidential/circuits/lib/testdata/sponge_squeeze_2.json @@ -1,20 +1,20 @@ { "primitive": "sponge_squeeze_2", "design_doc_refs": ["Section 2.5", "Constraint T_a2", "Constraint T_a6", "Constraint O_a2", "Constraint O_a6"], - "description": "Poseidon2 sponge with two squeezes (one absorb of (d, s_x, sigma), permute, output (state[0], state[1])). Canonical squeeze order: index 0 = amount mask, index 1 = balance / per-transfer Pedersen randomness mask, fixed per design doc Sections 7 and 8. The sender-auditor channel uses d = AUDITOR_SENDER (10); the recipient-auditor channel uses d = AUDITOR_RECIPIENT (11). The first squeeze must match sponge_squeeze_1 on the same inputs.", + "description": "Poseidon2 sponge with two squeezes (one absorb of (d, s_x, sigma), permute, output (state[0], state[1])). Canonical squeeze order: index 0 = amount mask, index 1 = balance / per-transfer Pedersen randomness mask, fixed per design doc Sections 7 and 8. The sender-auditor channel uses d = AUDITOR_SENDER (11); the recipient-auditor channel uses d = AUDITOR_RECIPIENT (12). The first squeeze must match sponge_squeeze_1 on the same inputs.", "vectors": [ { - "inputs": { "d": "0x0a", "s_x": "0x12345", "sigma": "0x01" }, + "inputs": { "d": "0x0b", "s_x": "0x12345", "sigma": "0x01" }, "output": [ - "0x266e76dccd3d7c654ab7a48045cca920c497b1a6758981acfa6f217427ac3074", - "0x248b757f9fec92895bd3fb91b1a5c9c17467df6f0f55ab574d85e943df8e5cf3" + "0x2787d1e01bbca7828e13e9b2b3fa11cfcfe9c3d2b121b9e17543146822fe1feb", + "0x27f3739a132c6353cd5af3edac0ac75faf7fc606acb61367774e4f764ec17b77" ] }, { - "inputs": { "d": "0x0b", "s_x": "0x12345", "sigma": "0x01" }, + "inputs": { "d": "0x0c", "s_x": "0x12345", "sigma": "0x01" }, "output": [ - "0x2787d1e01bbca7828e13e9b2b3fa11cfcfe9c3d2b121b9e17543146822fe1feb", - "0x27f3739a132c6353cd5af3edac0ac75faf7fc606acb61367774e4f764ec17b77" + "0x0801e6d7184cea11e6225fcfb4e88a90674cb085da8ebc04918c57cc44b9c25d", + "0x131187e2ac296c1c54c2be2a5cbe614644f01c754108ce25359212d67b36ecc3" ] } ] diff --git a/packages/tokens/src/confidential/circuits/lib/testdata/vk_from_sk.json b/packages/tokens/src/confidential/circuits/lib/testdata/vk_from_sk.json index d14589841..93ba86d3f 100644 --- a/packages/tokens/src/confidential/circuits/lib/testdata/vk_from_sk.json +++ b/packages/tokens/src/confidential/circuits/lib/testdata/vk_from_sk.json @@ -5,7 +5,7 @@ "vectors": [ { "inputs": { "sk": "0xdead", "wrap": "0xbeef" }, - "output": "0x0ac6ade91aea36f4de35eb5a06a67e1b9c3dab9842479f1cce85caf826194ec2" + "output": "0x208fbdb70d2faacf04f987b54f12aeeaeb432acc29d650c86ce0f6275b958eb8" } ] } diff --git a/packages/tokens/src/confidential/circuits/register/src/tests.nr b/packages/tokens/src/confidential/circuits/register/src/tests.nr index 873c0e364..273aa95ef 100644 --- a/packages/tokens/src/confidential/circuits/register/src/tests.nr +++ b/packages/tokens/src/confidential/circuits/register/src/tests.nr @@ -8,8 +8,8 @@ global SK: Field = 0xdead; global WRAP: Field = 0xbeef; global Y_X: Field = 0x1b46b003b88a6c34549dc74115f088f4b231a151397526bc10cbf1d15b457646; global Y_Y: Field = 0x29116280600c10ead1fdbd9ab4b571896030679bf554d7f1ebf681e5147de21b; -global PVK_X: Field = 0x01de20bfe21ad660e073b14a106feae4afce4e78a020953ca16b767e0615331b; -global PVK_Y: Field = 0x12cf407b75fccb5c7721c5fdadc3d1a22b5440e36b08fe52658ab885529e92fd; +global PVK_X: Field = 0x2e7421c0e86a4c8eed823edf851c364ecee448bf70eae2a95befe3dd9364cc45; +global PVK_Y: Field = 0x0b060655740aabef84e7305bb886b75da8d258f346381f03925d69645292fe7a; #[test] fn matches_fixture() { diff --git a/packages/tokens/src/confidential/circuits/vks/register.vk.json b/packages/tokens/src/confidential/circuits/vks/register.vk.json index b65f1c014..c11960427 100644 --- a/packages/tokens/src/confidential/circuits/vks/register.vk.json +++ b/packages/tokens/src/confidential/circuits/vks/register.vk.json @@ -1 +1 @@ -["0x0000000000000000000000000000000000000000000000000000000000004000","0x0000000000000000000000000000000000000000000000000000000000000015","0x0000000000000000000000000000000000000000000000000000000000000001","0x0000000000000000000000000000000000000000000000000000000000000005","0x0000000000000000000000000000002cbf39eee2723f9fd840e1be0073c9a0d8","0x00000000000000000000000000000000001fee7af92f1d1fe10d4507fea42c5c","0x00000000000000000000000000000034815c8250548e54c39459c0f35df59516","0x000000000000000000000000000000000014612d716b73f420884f6b65cb2092","0x00000000000000000000000000000084ddf10a4264d5a79b103864954fa1d7dd","0x00000000000000000000000000000000000c725aba00f697419328c8ceda9710","0x00000000000000000000000000000084e78e08219563e2ce1b061c8219833403","0x0000000000000000000000000000000000175f185390e0e0c084fa44ab6a2039","0x00000000000000000000000000000021f39c3408fbba505949cf04721654a138","0x000000000000000000000000000000000018e20c47b723ffc3185c63df4a960a","0x000000000000000000000000000000aa30f41cdaf9bc53efa32cf1c9d90bb299","0x0000000000000000000000000000000000257cdd46a6d1d26398d70eff5dfaf6","0x0000000000000000000000000000007024e0cbe6450e723eefec65d12ae336bf","0x00000000000000000000000000000000000b42731d30961e77fc3ee6b53899bf","0x00000000000000000000000000000051ea98fa660d79e507aa4df62c9acba779","0x00000000000000000000000000000000002ae867d624fe18c0d0b4971588d976","0x0000000000000000000000000000001b7f4bad0a6c8e45276b7814c024f7c9fd","0x00000000000000000000000000000000001bc30b5c66509f1d2964271ef1eb98","0x0000000000000000000000000000002ed2c54aaa10ffde5a628116db02aaab31","0x000000000000000000000000000000000016e5e189f92184662ea20dafab34e2","0x000000000000000000000000000000c4f695d2fad3de645b505e9c3ea13dbe6f","0x00000000000000000000000000000000000a21752b9c6889920f7b05735d1544","0x000000000000000000000000000000446f25b2aba628a48c507d12f2b3143de9","0x000000000000000000000000000000000023beea8c6369e5bc8819b8afb04185","0x0000000000000000000000000000007c95b756ba3f04a9a6236fc1bebd926514","0x00000000000000000000000000000000001a10c0124a906a4cf2c2236344a54b","0x0000000000000000000000000000005213c7017cafc3ceab9dfd97d4fb66d1df","0x000000000000000000000000000000000001e32415cdfbf774e6196ddfbda4de","0x000000000000000000000000000000338676eb0cc763115ecac4dd45bdc17164","0x00000000000000000000000000000000000ce497f32915d01fda667b3c6d799c","0x000000000000000000000000000000e5b4d8e652291809988391f81a6af916a7","0x000000000000000000000000000000000006a9fd0417e9e672dc84984a51b001","0x000000000000000000000000000000c452b4abb0969024ff9f5fb003effe52d0","0x000000000000000000000000000000000026807f50b5c6c81b5643858053666d","0x000000000000000000000000000000c39e2dabfdaffe0978ed8c724fd854f27b","0x000000000000000000000000000000000012adfcf55e199a695c3cdbb6902efc","0x000000000000000000000000000000412414171bd511ac2129f3977337d89cc1","0x00000000000000000000000000000000001232469b084c2d27f258c4d5a0bc1a","0x000000000000000000000000000000ccfe85419d4bdb653815d0fbe16bec5efc","0x000000000000000000000000000000000006eb3430321f2a4af6f2f7202abb46","0x000000000000000000000000000000d0877cbf0a99c90b279f2889be5a9c3d14","0x00000000000000000000000000000000001ab486acd1fd2b40fb4847cb6695e2","0x00000000000000000000000000000027b513136689042c8221316f7c9d5a5a4a","0x00000000000000000000000000000000000d721e1df10a48bb88ccd47413d17e","0x0000000000000000000000000000000d0d790cf23fea38aac3715871c55cacb0","0x0000000000000000000000000000000000126c54e31961c330e06723d0b2208f","0x000000000000000000000000000000b6bb83bf8dada900f753d36df669ad6383","0x000000000000000000000000000000000003c07ae02fc82655e8fc0aee0a76e9","0x00000000000000000000000000000082842a04f62c7139e1efc0cb5270b9a872","0x000000000000000000000000000000000014522d46d2ef48173369eaa47d6343","0x00000000000000000000000000000055b1a94580a80541573ac2dbb50782dd14","0x00000000000000000000000000000000001e552c529eaf605f4b3b241597dc18","0x000000000000000000000000000000515ae87c58f7eb9bdd60b760155d0a409a","0x00000000000000000000000000000000001fcf7f08f01d459c59821d4bf76db6","0x000000000000000000000000000000add18c995ed7eb094a3bc745cf5c30519c","0x00000000000000000000000000000000001f791c8ebe227a23deb44b06ba96af","0x000000000000000000000000000000c1c8289f80b3c171fe5578d3551f8b8d4e","0x00000000000000000000000000000000000d6da4dcdd5465a0d61023cc6b5227","0x000000000000000000000000000000ad23f7c5bfc2c6931a061f64e3c24a48fe","0x00000000000000000000000000000000001eca9d2193d5fbebcc681885b07054","0x00000000000000000000000000000097efd70a12ea08026f082fc11ab509c280","0x000000000000000000000000000000000002b3247eaf66784f054afe75611f82","0x00000000000000000000000000000010b0cd3a669da156eba716595b4a5c4931","0x00000000000000000000000000000000001f171afbf27b15694eb516465565a3","0x000000000000000000000000000000220061473e1e9d9e5aa9537b49f564b634","0x0000000000000000000000000000000000133881339d3be5c2390edfe02b848c","0x0000000000000000000000000000004bb99b2941ee77765d3bc7ed3d9e4d7c9e","0x00000000000000000000000000000000000b678e0d0531eb37f8338d6aeb13f4","0x000000000000000000000000000000f053b8f7b82096e72fcfed3d55876c6c4e","0x00000000000000000000000000000000001562d96fb60ab98c85c36a335a95a4","0x0000000000000000000000000000006cb17d0094e6206e728a4c1aede3bc281e","0x0000000000000000000000000000000000124c1ec8970ec15a72b1b3d716b1a9","0x0000000000000000000000000000007ce29ab1a4f1fbba3c6f4de5d60e69131c","0x00000000000000000000000000000000001196f4343331de2928257b9870b6e3","0x000000000000000000000000000000fd905a34c508e3e8f877bf285ff7ac9886","0x0000000000000000000000000000000000124b5fbc96921997e41d65baa728e8","0x00000000000000000000000000000028c4dccde75229b33bb507fd7202ba6536","0x00000000000000000000000000000000001136c39b4299c9bf077678616fd21f","0x0000000000000000000000000000007b96f1d38a502a7badb64ee24527c38820","0x0000000000000000000000000000000000164e2e9219df91e1eca116e53c6091","0x000000000000000000000000000000d3572867fb40929727b712e10e7db49fd4","0x00000000000000000000000000000000001b842060fea61eaf6109589ce43078","0x0000000000000000000000000000003eeb74a384aeb7e0bbe60b61ad40bfcc68","0x00000000000000000000000000000000000d48c7f2460f21f2a513b08d99a94c","0x0000000000000000000000000000002f66a9f2427c95067caac7d93cd4929e7d","0x00000000000000000000000000000000000066f0bab7045a560d3e725caa9525","0x000000000000000000000000000000fb7362318e8282249a59bf00ada02d4894","0x0000000000000000000000000000000000229a8d83dea8c91787a007c8d696f7","0x000000000000000000000000000000bcada96cae4677ac7116fa48614e16acb2","0x0000000000000000000000000000000000163a4c6dde0c86fbac73aa094d615a","0x0000000000000000000000000000004d06f9b0fe8d6228210c07a1129fd1d155","0x0000000000000000000000000000000000172798c37bcc9250b4bd39284d81c3","0x00000000000000000000000000000042c99d04c29e4be877301f6f3e8c7d2c18","0x0000000000000000000000000000000000006f42f2c23ec38f554f3214626aa9","0x00000000000000000000000000000040bfbb330c9a3785efe5c975f671d8d614","0x00000000000000000000000000000000000d096d6738835b10ce28926833472a","0x0000000000000000000000000000009bd462667dffaa43f0f9c0fec12932acc0","0x00000000000000000000000000000000000c0de5268cdf1735f9718f55cc97de","0x000000000000000000000000000000cccd853a292a87e8486ebb28dbbf688186","0x0000000000000000000000000000000000294abdcae7bddcd7761a18f1d61fdd","0x0000000000000000000000000000000000000000000000000000000000000001","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000002","0x0000000000000000000000000000000000000000000000000000000000000000","0x000000000000000000000000000000861740f11b4cff0861eb3852f8e8c388b3","0x00000000000000000000000000000000001e539cab87b3086755837b966cf562","0x00000000000000000000000000000062d0b8637e66dbd7e971f29af06b93d710","0x000000000000000000000000000000000015621e2ef72fd514bb7f6592490a80"] \ No newline at end of file +["0x0000000000000000000000000000000000000000000000000000000000004000","0x0000000000000000000000000000000000000000000000000000000000000015","0x0000000000000000000000000000000000000000000000000000000000000001","0x0000000000000000000000000000000000000000000000000000000000000005","0x0000000000000000000000000000002cbf39eee2723f9fd840e1be0073c9a0d8","0x00000000000000000000000000000000001fee7af92f1d1fe10d4507fea42c5c","0x00000000000000000000000000000034815c8250548e54c39459c0f35df59516","0x000000000000000000000000000000000014612d716b73f420884f6b65cb2092","0x00000000000000000000000000000042ed3cf1a9bc788fef9e0b52c8a2f11179","0x00000000000000000000000000000000002c375859a69f4cb15e38e98441ee4c","0x0000000000000000000000000000002ad0211f029358945b2b71d177524f921d","0x00000000000000000000000000000000001bfcadc0e39f57896c854c49594c0a","0x00000000000000000000000000000021f39c3408fbba505949cf04721654a138","0x000000000000000000000000000000000018e20c47b723ffc3185c63df4a960a","0x000000000000000000000000000000aa30f41cdaf9bc53efa32cf1c9d90bb299","0x0000000000000000000000000000000000257cdd46a6d1d26398d70eff5dfaf6","0x0000000000000000000000000000007024e0cbe6450e723eefec65d12ae336bf","0x00000000000000000000000000000000000b42731d30961e77fc3ee6b53899bf","0x00000000000000000000000000000051ea98fa660d79e507aa4df62c9acba779","0x00000000000000000000000000000000002ae867d624fe18c0d0b4971588d976","0x0000000000000000000000000000001b7f4bad0a6c8e45276b7814c024f7c9fd","0x00000000000000000000000000000000001bc30b5c66509f1d2964271ef1eb98","0x0000000000000000000000000000002ed2c54aaa10ffde5a628116db02aaab31","0x000000000000000000000000000000000016e5e189f92184662ea20dafab34e2","0x000000000000000000000000000000c4f695d2fad3de645b505e9c3ea13dbe6f","0x00000000000000000000000000000000000a21752b9c6889920f7b05735d1544","0x000000000000000000000000000000446f25b2aba628a48c507d12f2b3143de9","0x000000000000000000000000000000000023beea8c6369e5bc8819b8afb04185","0x0000000000000000000000000000007c95b756ba3f04a9a6236fc1bebd926514","0x00000000000000000000000000000000001a10c0124a906a4cf2c2236344a54b","0x0000000000000000000000000000005213c7017cafc3ceab9dfd97d4fb66d1df","0x000000000000000000000000000000000001e32415cdfbf774e6196ddfbda4de","0x000000000000000000000000000000338676eb0cc763115ecac4dd45bdc17164","0x00000000000000000000000000000000000ce497f32915d01fda667b3c6d799c","0x000000000000000000000000000000e5b4d8e652291809988391f81a6af916a7","0x000000000000000000000000000000000006a9fd0417e9e672dc84984a51b001","0x000000000000000000000000000000c452b4abb0969024ff9f5fb003effe52d0","0x000000000000000000000000000000000026807f50b5c6c81b5643858053666d","0x000000000000000000000000000000c39e2dabfdaffe0978ed8c724fd854f27b","0x000000000000000000000000000000000012adfcf55e199a695c3cdbb6902efc","0x000000000000000000000000000000412414171bd511ac2129f3977337d89cc1","0x00000000000000000000000000000000001232469b084c2d27f258c4d5a0bc1a","0x000000000000000000000000000000ccfe85419d4bdb653815d0fbe16bec5efc","0x000000000000000000000000000000000006eb3430321f2a4af6f2f7202abb46","0x000000000000000000000000000000d0877cbf0a99c90b279f2889be5a9c3d14","0x00000000000000000000000000000000001ab486acd1fd2b40fb4847cb6695e2","0x00000000000000000000000000000027b513136689042c8221316f7c9d5a5a4a","0x00000000000000000000000000000000000d721e1df10a48bb88ccd47413d17e","0x0000000000000000000000000000000d0d790cf23fea38aac3715871c55cacb0","0x0000000000000000000000000000000000126c54e31961c330e06723d0b2208f","0x000000000000000000000000000000b6bb83bf8dada900f753d36df669ad6383","0x000000000000000000000000000000000003c07ae02fc82655e8fc0aee0a76e9","0x00000000000000000000000000000082842a04f62c7139e1efc0cb5270b9a872","0x000000000000000000000000000000000014522d46d2ef48173369eaa47d6343","0x00000000000000000000000000000055b1a94580a80541573ac2dbb50782dd14","0x00000000000000000000000000000000001e552c529eaf605f4b3b241597dc18","0x000000000000000000000000000000515ae87c58f7eb9bdd60b760155d0a409a","0x00000000000000000000000000000000001fcf7f08f01d459c59821d4bf76db6","0x000000000000000000000000000000add18c995ed7eb094a3bc745cf5c30519c","0x00000000000000000000000000000000001f791c8ebe227a23deb44b06ba96af","0x000000000000000000000000000000c1c8289f80b3c171fe5578d3551f8b8d4e","0x00000000000000000000000000000000000d6da4dcdd5465a0d61023cc6b5227","0x000000000000000000000000000000ad23f7c5bfc2c6931a061f64e3c24a48fe","0x00000000000000000000000000000000001eca9d2193d5fbebcc681885b07054","0x00000000000000000000000000000097efd70a12ea08026f082fc11ab509c280","0x000000000000000000000000000000000002b3247eaf66784f054afe75611f82","0x00000000000000000000000000000010b0cd3a669da156eba716595b4a5c4931","0x00000000000000000000000000000000001f171afbf27b15694eb516465565a3","0x000000000000000000000000000000220061473e1e9d9e5aa9537b49f564b634","0x0000000000000000000000000000000000133881339d3be5c2390edfe02b848c","0x0000000000000000000000000000004bb99b2941ee77765d3bc7ed3d9e4d7c9e","0x00000000000000000000000000000000000b678e0d0531eb37f8338d6aeb13f4","0x000000000000000000000000000000f053b8f7b82096e72fcfed3d55876c6c4e","0x00000000000000000000000000000000001562d96fb60ab98c85c36a335a95a4","0x0000000000000000000000000000006cb17d0094e6206e728a4c1aede3bc281e","0x0000000000000000000000000000000000124c1ec8970ec15a72b1b3d716b1a9","0x0000000000000000000000000000007ce29ab1a4f1fbba3c6f4de5d60e69131c","0x00000000000000000000000000000000001196f4343331de2928257b9870b6e3","0x000000000000000000000000000000fd905a34c508e3e8f877bf285ff7ac9886","0x0000000000000000000000000000000000124b5fbc96921997e41d65baa728e8","0x00000000000000000000000000000028c4dccde75229b33bb507fd7202ba6536","0x00000000000000000000000000000000001136c39b4299c9bf077678616fd21f","0x0000000000000000000000000000007b96f1d38a502a7badb64ee24527c38820","0x0000000000000000000000000000000000164e2e9219df91e1eca116e53c6091","0x000000000000000000000000000000d3572867fb40929727b712e10e7db49fd4","0x00000000000000000000000000000000001b842060fea61eaf6109589ce43078","0x0000000000000000000000000000003eeb74a384aeb7e0bbe60b61ad40bfcc68","0x00000000000000000000000000000000000d48c7f2460f21f2a513b08d99a94c","0x0000000000000000000000000000002f66a9f2427c95067caac7d93cd4929e7d","0x00000000000000000000000000000000000066f0bab7045a560d3e725caa9525","0x000000000000000000000000000000fb7362318e8282249a59bf00ada02d4894","0x0000000000000000000000000000000000229a8d83dea8c91787a007c8d696f7","0x000000000000000000000000000000bcada96cae4677ac7116fa48614e16acb2","0x0000000000000000000000000000000000163a4c6dde0c86fbac73aa094d615a","0x0000000000000000000000000000004d06f9b0fe8d6228210c07a1129fd1d155","0x0000000000000000000000000000000000172798c37bcc9250b4bd39284d81c3","0x00000000000000000000000000000042c99d04c29e4be877301f6f3e8c7d2c18","0x0000000000000000000000000000000000006f42f2c23ec38f554f3214626aa9","0x00000000000000000000000000000040bfbb330c9a3785efe5c975f671d8d614","0x00000000000000000000000000000000000d096d6738835b10ce28926833472a","0x0000000000000000000000000000009bd462667dffaa43f0f9c0fec12932acc0","0x00000000000000000000000000000000000c0de5268cdf1735f9718f55cc97de","0x000000000000000000000000000000cccd853a292a87e8486ebb28dbbf688186","0x0000000000000000000000000000000000294abdcae7bddcd7761a18f1d61fdd","0x0000000000000000000000000000000000000000000000000000000000000001","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000002","0x0000000000000000000000000000000000000000000000000000000000000000","0x000000000000000000000000000000861740f11b4cff0861eb3852f8e8c388b3","0x00000000000000000000000000000000001e539cab87b3086755837b966cf562","0x00000000000000000000000000000062d0b8637e66dbd7e971f29af06b93d710","0x000000000000000000000000000000000015621e2ef72fd514bb7f6592490a80"] \ No newline at end of file diff --git a/packages/tokens/src/confidential/circuits/vks/withdraw.vk.json b/packages/tokens/src/confidential/circuits/vks/withdraw.vk.json index abc61d4a2..cbf5d95b8 100644 --- a/packages/tokens/src/confidential/circuits/vks/withdraw.vk.json +++ b/packages/tokens/src/confidential/circuits/vks/withdraw.vk.json @@ -1 +1 @@ -["0x0000000000000000000000000000000000000000000000000000000000008000","0x000000000000000000000000000000000000000000000000000000000000001f","0x0000000000000000000000000000000000000000000000000000000000000001","0x000000000000000000000000000000000000000000000000000000000000000f","0x000000000000000000000000000000eeba595d642c330ff0052a30356aef944d","0x000000000000000000000000000000000010e3400ec4848ce654b0033bd1909c","0x0000000000000000000000000000002b7a9a001dcd869f8a0f57a9463fbda32e","0x00000000000000000000000000000000000a1c18400fcebbffedebc9f1d7957f","0x000000000000000000000000000000037dca156d5c0e70124cea51e227a0146b","0x00000000000000000000000000000000002a4b62dbc27de48f7031e5aab1583e","0x000000000000000000000000000000136c427b7f48da876c44c2adc3a2369e5d","0x000000000000000000000000000000000022e1a1a0c6e0adb02dbed58ed9333b","0x00000000000000000000000000000086be6e47fb05c8a34ee650da276874a16e","0x00000000000000000000000000000000002ffcdf47719489739c0ccd79a094a4","0x0000000000000000000000000000001d83a98627294b99ea6b8b987226bca454","0x0000000000000000000000000000000000112246923b49d0709e410042c4860b","0x00000000000000000000000000000038f6ec2a41271e1da7b722ee6abc79ff09","0x0000000000000000000000000000000000083462567704201deb6dcc33c9cfe4","0x000000000000000000000000000000401aaa7d6ab87aa284f84f8ad30ac71c49","0x00000000000000000000000000000000002249363ebb246b83846ad9ac5b3ed6","0x000000000000000000000000000000bae8ada0ee2188885f5b8effd9e61b02a1","0x0000000000000000000000000000000000286bf8187ddec53c2ec9ac55e9cdd4","0x000000000000000000000000000000c101fd8b4d894b84208bc3720568bdca84","0x00000000000000000000000000000000002b0edb22bc1d39cba3be724c04018d","0x000000000000000000000000000000b06aacb1989ba061c490d4f2ec0857d429","0x00000000000000000000000000000000001df228e6da104ccd52eb0f004112bd","0x000000000000000000000000000000f803b078140485be6e5e6fe54b54758dd6","0x00000000000000000000000000000000002bdae37d3fae349df4b291c8b28987","0x000000000000000000000000000000176df2b9f9816b99b87dcd27314f248492","0x00000000000000000000000000000000000396fae9cfc50f85ebfe6bf44327d7","0x0000000000000000000000000000000107a790c778e6a2ffbae75e5764ee8cc2","0x00000000000000000000000000000000000e0f2756936ef0c0b2175663320713","0x000000000000000000000000000000df66bc3e9f02258d5ab62bdc7217cf2d8d","0x0000000000000000000000000000000000071538f67f71e31ecf217d87ac3038","0x000000000000000000000000000000e36eddb6fd7bbbbe99f71ff10df7ef86fa","0x00000000000000000000000000000000000a42c0ff54a34a80cb110dcb90f724","0x00000000000000000000000000000068bdc47e00f54f7343c34b2c48368ebde6","0x00000000000000000000000000000000001d418813d959151e7f2eba7d7f5c51","0x000000000000000000000000000000cb095b6f7caee08e4e5e30eea991d86920","0x0000000000000000000000000000000000106af79cd3966436c97d57b5b3fc2a","0x000000000000000000000000000000bd02c58e4ee0c2bb494de03af6bc7526ab","0x00000000000000000000000000000000000d6405504013ca3e28a415a7f71669","0x000000000000000000000000000000685cb2ece723d459108a821240d3fc2262","0x000000000000000000000000000000000029244ec479537ad66815d0389fc4d2","0x0000000000000000000000000000004415003ff210bb2ce2f3bef905b903520b","0x00000000000000000000000000000000001542f730190d952f86315d3d29457a","0x00000000000000000000000000000054d142734abe1f06533e7316a0ddd16496","0x00000000000000000000000000000000001873d1a3f4ddbf6e0958e5bb1106af","0x000000000000000000000000000000a98543fb0eb78f681eb4cb0d3e14d2a9dd","0x000000000000000000000000000000000014fcb07f82d6efdd47ca3f7248c61e","0x00000000000000000000000000000005ba5d932d7b77bef91294a42ef37aa1b1","0x00000000000000000000000000000000000b627e9389774df7f4bd5563897614","0x000000000000000000000000000000f01a7d58ea57541ff46b6bfe1193f521c7","0x00000000000000000000000000000000002b084ad4761ae968a05151fed32ac2","0x0000000000000000000000000000009df8752bd4bc40071d8a3821dea722997d","0x000000000000000000000000000000000003cc62c1a745e20ef2af508d94d96a","0x000000000000000000000000000000e87bcab37cd4e308aa243f2f00bbcdb146","0x00000000000000000000000000000000000f5f0933ac6e90a320c03886ce5ef5","0x00000000000000000000000000000010c95aed0ecbe9e328bc0022428fe2b9d1","0x0000000000000000000000000000000000261e1356aa63e9f3b929351ef34520","0x000000000000000000000000000000d0316171b1fcbc63d492e5c921e1d85534","0x0000000000000000000000000000000000135bf1d5004195d5aee992f4e8d2ef","0x000000000000000000000000000000e2f72da69c7cbf9bd22617ef95bc07db46","0x00000000000000000000000000000000000f4c6d60625829f72ad52827157819","0x000000000000000000000000000000aed2ec42f4730788107991640341ecec01","0x00000000000000000000000000000000002ccb40475a3d4394cf306d8e187b46","0x0000000000000000000000000000004fbe69b6da34d7526ca644393f340acb1f","0x00000000000000000000000000000000002055a571e8058e11fe5bd7a70df194","0x0000000000000000000000000000002a827b802fdc3fa2bb96e45de06250fec6","0x0000000000000000000000000000000000283d3c31b996ab705d67d593823e23","0x000000000000000000000000000000bc1001981431c53496a849a2259d03b457","0x000000000000000000000000000000000016245614bd6436e48273825ec30279","0x000000000000000000000000000000d0b237370730e5c12b49202f8eed05caa0","0x000000000000000000000000000000000011834ef1f9a10486f2ca206bc00455","0x000000000000000000000000000000cda55dfbdd2802299831bca66dc058e055","0x0000000000000000000000000000000000207f3ebb920fc90d406d3a612a2daf","0x000000000000000000000000000000e13bb20551a2af5f8ac36ebaac7876a1c4","0x000000000000000000000000000000000028017bb468f2ed75a6887614e5a9b1","0x000000000000000000000000000000b5a4ceab339d24da55f579aa04f192eab2","0x0000000000000000000000000000000000040edbf67c3d6d99fa176b85dae651","0x000000000000000000000000000000cfb60fe92fb07d24b9de89186c75086f1e","0x00000000000000000000000000000000000a4cc323ddffb2bde796024f39bc52","0x0000000000000000000000000000006fb079278fc9ad6c879493fc3bc60da717","0x00000000000000000000000000000000001588a466568529e8a6c5b33fcc26e3","0x000000000000000000000000000000bf3de286969e366510b04f43ee02b06117","0x000000000000000000000000000000000008175e28145dca69106c8791d8a2e5","0x0000000000000000000000000000002c116fa0beca775474a5fe7b78fc2f2bba","0x00000000000000000000000000000000000c999129c3c06772c6fbbc7355c09c","0x000000000000000000000000000000984d80910016772cb83ed3881f90ebcc8c","0x00000000000000000000000000000000001b6540dd28b967d06897d2fe4f4732","0x000000000000000000000000000000b7445646b5160c380130b66e34450aa736","0x00000000000000000000000000000000000ef8897e2c65d0ec75dc09f85edeac","0x00000000000000000000000000000045688ef774d85c8bd41f9665bad2d794c8","0x00000000000000000000000000000000002ac07a74c19ad88489ed6f1e93dc9e","0x000000000000000000000000000000a5a32649850aae3c839626ec285717f399","0x000000000000000000000000000000000010cdf0710877e231d375a2ef4dc3d3","0x000000000000000000000000000000160d4e95b954b96077723f1800f420dc77","0x0000000000000000000000000000000000280ae74a2dae2f5e68706031174b76","0x000000000000000000000000000000440bb5fc5471f6a37dbe12fbef6a37c009","0x0000000000000000000000000000000000106864b3460bfded9d5dc448fe29e8","0x000000000000000000000000000000d29de5d4acc99c6c72fa17e0a80bf88401","0x000000000000000000000000000000000007e83dfa443e984d53e0323523f742","0x0000000000000000000000000000001cfe095fa8e5d91145e1222746c27083ad","0x000000000000000000000000000000000006a7d963d96d78bc035faf0a744f8e","0x0000000000000000000000000000000000000000000000000000000000000001","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000002","0x0000000000000000000000000000000000000000000000000000000000000000","0x000000000000000000000000000000f0632125fa5d1bd312c714447d2ee3f921","0x00000000000000000000000000000000000e3504c34901d8cf39ca6008e672b7","0x00000000000000000000000000000015d3e4e604b8962bdca2dff461051dca20","0x00000000000000000000000000000000002d2b8c20d3ca4c73e2f8b6854b8c67"] \ No newline at end of file +["0x0000000000000000000000000000000000000000000000000000000000008000","0x000000000000000000000000000000000000000000000000000000000000001f","0x0000000000000000000000000000000000000000000000000000000000000001","0x000000000000000000000000000000000000000000000000000000000000000f","0x000000000000000000000000000000eeba595d642c330ff0052a30356aef944d","0x000000000000000000000000000000000010e3400ec4848ce654b0033bd1909c","0x0000000000000000000000000000002b7a9a001dcd869f8a0f57a9463fbda32e","0x00000000000000000000000000000000000a1c18400fcebbffedebc9f1d7957f","0x000000000000000000000000000000cd6295581e4781b58cf58eb5da08908d71","0x00000000000000000000000000000000000a91d2db4017d8061778a2683223c8","0x000000000000000000000000000000141af02cedab14ac75e6dc574fe15c9dbc","0x00000000000000000000000000000000002666cf01eee43af06bb16f2374d0a8","0x00000000000000000000000000000086be6e47fb05c8a34ee650da276874a16e","0x00000000000000000000000000000000002ffcdf47719489739c0ccd79a094a4","0x0000000000000000000000000000001d83a98627294b99ea6b8b987226bca454","0x0000000000000000000000000000000000112246923b49d0709e410042c4860b","0x00000000000000000000000000000038f6ec2a41271e1da7b722ee6abc79ff09","0x0000000000000000000000000000000000083462567704201deb6dcc33c9cfe4","0x000000000000000000000000000000401aaa7d6ab87aa284f84f8ad30ac71c49","0x00000000000000000000000000000000002249363ebb246b83846ad9ac5b3ed6","0x000000000000000000000000000000bae8ada0ee2188885f5b8effd9e61b02a1","0x0000000000000000000000000000000000286bf8187ddec53c2ec9ac55e9cdd4","0x000000000000000000000000000000c101fd8b4d894b84208bc3720568bdca84","0x00000000000000000000000000000000002b0edb22bc1d39cba3be724c04018d","0x000000000000000000000000000000b06aacb1989ba061c490d4f2ec0857d429","0x00000000000000000000000000000000001df228e6da104ccd52eb0f004112bd","0x000000000000000000000000000000f803b078140485be6e5e6fe54b54758dd6","0x00000000000000000000000000000000002bdae37d3fae349df4b291c8b28987","0x000000000000000000000000000000176df2b9f9816b99b87dcd27314f248492","0x00000000000000000000000000000000000396fae9cfc50f85ebfe6bf44327d7","0x0000000000000000000000000000000107a790c778e6a2ffbae75e5764ee8cc2","0x00000000000000000000000000000000000e0f2756936ef0c0b2175663320713","0x000000000000000000000000000000df66bc3e9f02258d5ab62bdc7217cf2d8d","0x0000000000000000000000000000000000071538f67f71e31ecf217d87ac3038","0x000000000000000000000000000000e36eddb6fd7bbbbe99f71ff10df7ef86fa","0x00000000000000000000000000000000000a42c0ff54a34a80cb110dcb90f724","0x00000000000000000000000000000068bdc47e00f54f7343c34b2c48368ebde6","0x00000000000000000000000000000000001d418813d959151e7f2eba7d7f5c51","0x000000000000000000000000000000cb095b6f7caee08e4e5e30eea991d86920","0x0000000000000000000000000000000000106af79cd3966436c97d57b5b3fc2a","0x000000000000000000000000000000bd02c58e4ee0c2bb494de03af6bc7526ab","0x00000000000000000000000000000000000d6405504013ca3e28a415a7f71669","0x000000000000000000000000000000685cb2ece723d459108a821240d3fc2262","0x000000000000000000000000000000000029244ec479537ad66815d0389fc4d2","0x0000000000000000000000000000004415003ff210bb2ce2f3bef905b903520b","0x00000000000000000000000000000000001542f730190d952f86315d3d29457a","0x00000000000000000000000000000054d142734abe1f06533e7316a0ddd16496","0x00000000000000000000000000000000001873d1a3f4ddbf6e0958e5bb1106af","0x000000000000000000000000000000a98543fb0eb78f681eb4cb0d3e14d2a9dd","0x000000000000000000000000000000000014fcb07f82d6efdd47ca3f7248c61e","0x00000000000000000000000000000005ba5d932d7b77bef91294a42ef37aa1b1","0x00000000000000000000000000000000000b627e9389774df7f4bd5563897614","0x000000000000000000000000000000f01a7d58ea57541ff46b6bfe1193f521c7","0x00000000000000000000000000000000002b084ad4761ae968a05151fed32ac2","0x0000000000000000000000000000009df8752bd4bc40071d8a3821dea722997d","0x000000000000000000000000000000000003cc62c1a745e20ef2af508d94d96a","0x000000000000000000000000000000e87bcab37cd4e308aa243f2f00bbcdb146","0x00000000000000000000000000000000000f5f0933ac6e90a320c03886ce5ef5","0x00000000000000000000000000000010c95aed0ecbe9e328bc0022428fe2b9d1","0x0000000000000000000000000000000000261e1356aa63e9f3b929351ef34520","0x000000000000000000000000000000d0316171b1fcbc63d492e5c921e1d85534","0x0000000000000000000000000000000000135bf1d5004195d5aee992f4e8d2ef","0x000000000000000000000000000000e2f72da69c7cbf9bd22617ef95bc07db46","0x00000000000000000000000000000000000f4c6d60625829f72ad52827157819","0x000000000000000000000000000000aed2ec42f4730788107991640341ecec01","0x00000000000000000000000000000000002ccb40475a3d4394cf306d8e187b46","0x0000000000000000000000000000004fbe69b6da34d7526ca644393f340acb1f","0x00000000000000000000000000000000002055a571e8058e11fe5bd7a70df194","0x0000000000000000000000000000002a827b802fdc3fa2bb96e45de06250fec6","0x0000000000000000000000000000000000283d3c31b996ab705d67d593823e23","0x000000000000000000000000000000bc1001981431c53496a849a2259d03b457","0x000000000000000000000000000000000016245614bd6436e48273825ec30279","0x000000000000000000000000000000d0b237370730e5c12b49202f8eed05caa0","0x000000000000000000000000000000000011834ef1f9a10486f2ca206bc00455","0x000000000000000000000000000000cda55dfbdd2802299831bca66dc058e055","0x0000000000000000000000000000000000207f3ebb920fc90d406d3a612a2daf","0x000000000000000000000000000000e13bb20551a2af5f8ac36ebaac7876a1c4","0x000000000000000000000000000000000028017bb468f2ed75a6887614e5a9b1","0x000000000000000000000000000000b5a4ceab339d24da55f579aa04f192eab2","0x0000000000000000000000000000000000040edbf67c3d6d99fa176b85dae651","0x000000000000000000000000000000cfb60fe92fb07d24b9de89186c75086f1e","0x00000000000000000000000000000000000a4cc323ddffb2bde796024f39bc52","0x0000000000000000000000000000006fb079278fc9ad6c879493fc3bc60da717","0x00000000000000000000000000000000001588a466568529e8a6c5b33fcc26e3","0x000000000000000000000000000000bf3de286969e366510b04f43ee02b06117","0x000000000000000000000000000000000008175e28145dca69106c8791d8a2e5","0x0000000000000000000000000000002c116fa0beca775474a5fe7b78fc2f2bba","0x00000000000000000000000000000000000c999129c3c06772c6fbbc7355c09c","0x000000000000000000000000000000984d80910016772cb83ed3881f90ebcc8c","0x00000000000000000000000000000000001b6540dd28b967d06897d2fe4f4732","0x000000000000000000000000000000b7445646b5160c380130b66e34450aa736","0x00000000000000000000000000000000000ef8897e2c65d0ec75dc09f85edeac","0x00000000000000000000000000000045688ef774d85c8bd41f9665bad2d794c8","0x00000000000000000000000000000000002ac07a74c19ad88489ed6f1e93dc9e","0x000000000000000000000000000000a5a32649850aae3c839626ec285717f399","0x000000000000000000000000000000000010cdf0710877e231d375a2ef4dc3d3","0x000000000000000000000000000000160d4e95b954b96077723f1800f420dc77","0x0000000000000000000000000000000000280ae74a2dae2f5e68706031174b76","0x000000000000000000000000000000440bb5fc5471f6a37dbe12fbef6a37c009","0x0000000000000000000000000000000000106864b3460bfded9d5dc448fe29e8","0x000000000000000000000000000000d29de5d4acc99c6c72fa17e0a80bf88401","0x000000000000000000000000000000000007e83dfa443e984d53e0323523f742","0x0000000000000000000000000000001cfe095fa8e5d91145e1222746c27083ad","0x000000000000000000000000000000000006a7d963d96d78bc035faf0a744f8e","0x0000000000000000000000000000000000000000000000000000000000000001","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000002","0x0000000000000000000000000000000000000000000000000000000000000000","0x000000000000000000000000000000f0632125fa5d1bd312c714447d2ee3f921","0x00000000000000000000000000000000000e3504c34901d8cf39ca6008e672b7","0x00000000000000000000000000000015d3e4e604b8962bdca2dff461051dca20","0x00000000000000000000000000000000002d2b8c20d3ca4c73e2f8b6854b8c67"] \ No newline at end of file diff --git a/packages/tokens/src/confidential/circuits/withdraw/src/tests.nr b/packages/tokens/src/confidential/circuits/withdraw/src/tests.nr index 32cb0874e..d481f0619 100644 --- a/packages/tokens/src/confidential/circuits/withdraw/src/tests.nr +++ b/packages/tokens/src/confidential/circuits/withdraw/src/tests.nr @@ -41,11 +41,11 @@ global C_SPEND_Y: Field = // which re-derives them from the lib primitives and asserts the values // declared here -- if either side drifts the test fails immediately. global C_SPEND_NEW_X: Field = - 0x2fb2d785d71d4c62ce4130675a6c44020c03a236ab21450b6f20282549caadac; + 0x22f167015ee0e4b33dcb278cdc824c336c919dc4cd06f391850305d5083336e6; global C_SPEND_NEW_Y: Field = - 0x069a978c07b99e97858a6e94cef5b3d2c49f1df0d2aa5e04dffcefaca9320814; + 0x0be3d0fda4f8f70b04e2d609db4e29b30345da9288bf208c7560572b2bef172d; global B_TILDE: Field = - 0x2b7f92c72c05ae8ecf7207fdaecea32ef748cd4dd43ca9ed6dc6d226865b4283; + 0x04d1659db899a50a94dcfc54a18b8adaf6e9e8e3046bd11893fbd4a86a7c5670; // Auditor-block fixtures. Pinned by `withdraw_auditor_fixtures_match_lib`. // R_E_X (= (R_E * H).x) matches the lib's `ecdh` fixture (`ecdh(r_e, H)` for @@ -58,7 +58,7 @@ global K_AUD_S_Y: Field = global R_E_X: Field = 0x114ed4fcf2c57014eb678c577aa02f30ef590b713d7a6a5e87702d1c7f71957f; global R_E_Y: Field = 0x07a70cf826350d4f438c7a3c5e8761b0ae6cb63de757f0c96815f4057b9205f4; global B_TILDE_AUD_S: Field = - 0x216ab12ea2182712721d79c9a9222a261452ab06a90309e23efaa6ecb0e20714; + 0x0a607feb31e56e89ed94a3e1dc3811d3d23aaf10b2f27874ce670878b9f6f071; #[test] fn print_fixtures() { @@ -401,7 +401,7 @@ fn rejects_tampered_c_spend_new() { #[test(should_fail)] fn rejects_r_e_zero() { - // W_a5 fires: r_e = 0 would force R_e = O (identity) and collapse + // W8 fires: r_e = 0 would force R_e = O (identity) and collapse // S_a,s to O, making m_b a knowable constant function of sigma. main( SK, From a1d04af0175b16ce0f72354520c98ea18d014b94 Mon Sep 17 00:00:00 2001 From: brozorec <9572072+brozorec@users.noreply.github.com> Date: Tue, 26 May 2026 11:56:47 +0200 Subject: [PATCH 09/15] docs: add design and compliance docs --- .../confidential/docs/COMPLIANCE.github.md | 179 +++ .../src/confidential/docs/DESIGN.github.md | 1313 +++++++++++++++++ 2 files changed, 1492 insertions(+) create mode 100644 packages/tokens/src/confidential/docs/COMPLIANCE.github.md create mode 100644 packages/tokens/src/confidential/docs/DESIGN.github.md diff --git a/packages/tokens/src/confidential/docs/COMPLIANCE.github.md b/packages/tokens/src/confidential/docs/COMPLIANCE.github.md new file mode 100644 index 000000000..91ba661a1 --- /dev/null +++ b/packages/tokens/src/confidential/docs/COMPLIANCE.github.md @@ -0,0 +1,179 @@ +# Confidential Token Wrapper: Compliance Extensions + +## Abstract + +This document specifies optional, deployer-configurable controls layered on top of the core Confidential Token Wrapper (see [DESIGN.md](DESIGN.md)). It covers account freezing, SAC authorization passthrough, pluggable authorization policies, unregistered-deposit handling, and the pooled-custody clawback flow. + +All controls are configured at construction time through a single `compliance: Option` entry. A vanilla deployment leaves the entry empty and pays no compliance overhead. Regulated deployments populate the slot once; subsequent state changes (freeze toggles, admin rotation, policy swap) flow through admin-gated entry points. + +--- + +## 1. Configuration + +```rust +struct ComplianceConfig { + policy: Option
, // §3 + sac_passthrough: bool, // §2 + permit_unregistered_deposit: bool, // §4 +} +``` + +| Field | Purpose | +|:---|:---| +| `policy` | Optional external authorization contract (§3). `None` means no policy gate. | +| `sac_passthrough` | When `true`, every state-modifying operation additionally consults the underlying SAC's `authorized()` check (§2). | +| `permit_unregistered_deposit` | When `true`, `deposit` skips policy and freeze checks on an unregistered `from` (§4). | + +The constructor takes `compliance: Option`. When `None`, the wrapper behaves exactly as `DESIGN.md` specifies: no pre-checks run, and the admin-gated entry points in §6 revert with `NotConfigured`. + +### 1.1 Admin Authority + +This document refers to an "admin" as the authority gating freeze, unfreeze, configuration rotation, and clawback. The wrapper does not prescribe how that authority is structured. Implementors can compose with an access-control module from the OpenZeppelin Soroban library (e.g., `ownable` for a single-owner model or `access_control` for role-based separation between freeze, policy, and clawback authorities). Admin-gated entry points invoke the chosen module's check (`only_owner`, `only_role`, etc.) at the top of the function. + +Deployments that need separation of duties (distinct freeze, policy, and clawback signers) reach for RBAC; deployments with a single jurisdictional authority use ownable. The wrapper sees only the result of the access check. + +--- + +## 2. Wrapper-Level Freeze + +The wrapper maintains a `frozen(account) -> bool` entry per account. Before applying any state change, every state-modifying operation runs `check_not_frozen` against each account it names (sender, recipient, operator). A frozen account cannot send, receive, deposit, withdraw, or participate as an operator. The check reverts at the contract boundary. + +Full freeze (rather than outbound-only) keeps semantics clean: no further accumulation is possible after the freeze takes effect. + +### 2.1 Core Interface Additions + +Three functions are added to the core wrapper interface: + +```rust +impl Wrapper { + fn freeze(e: Env, account: Address, admin: Address); // admin auth + fn unfreeze(e: Env, account: Address, admin: Address); // admin auth + fn is_frozen(e: Env, account: Address) -> bool; +} +``` + +`freeze` and `unfreeze` are gated by the implementor's access-control module (§1.1) and revert when `compliance.is_none()`. `is_frozen` is a public read; it returns `false` when compliance is not configured. + +### 2.2 SAC Authorization Passthrough + +When `sac_passthrough = true` and the underlying SEP-41 is a Stellar Asset Contract, every state-modifying operation additionally calls `sac.authorized(account)` for each named account and reverts on `false`. This composes the wrapper's freeze with the issuer's freeze without requiring the admin to mirror state: + +$$\text{permitted}(a) = \neg \text{frozen}(a) \;\land\; \text{policy\\\_ok}(a) \;\land\; (\neg \text{sac\\\_passthrough} \;\lor\; \text{sac.authorized}(a))$$ + +Off by default. Issuer-led deployments using a SAC underlying opt in at construction. The cost is one extra cross-contract invocation per named account per operation. + +--- + +## 3. Policy Contract + +When `compliance.policy = Some(addr)`, every state-modifying operation invokes `policy.is_authorized(account, wrapper) -> bool` on the configured contract for each named account, reverting on `false`. The policy is consulted in addition to the freeze check and (where enabled) the SAC passthrough. + +```rust +trait Policy { + fn is_authorized(e: Env, account: Address, wrapper: Address) -> bool; +} +``` + +This single hook covers the common deployment modes without baking them into the wrapper: + +- **Allowlist:** the policy returns `true` only for listed addresses. +- **Denylist:** the policy returns `true` for everything except listed addresses. +- **KYC / ASP / sanctions screening:** the policy delegates to an identity registry, attestation provider, or sanctions oracle. + +Membership management, list semantics, and identity proofs live entirely inside the policy contract. The wrapper's only contract with the policy is the boolean return value. + +Externalizing the policy also lets a single registry serve multiple wrappers. An issuer running several confidential tokens (different denominations, jurisdictions, or product lines) can point every wrapper at the same KYC or sanctions contract and maintain one source of truth, rather than mirroring lists into each wrapper. The `wrapper` argument to `is_authorized` lets the registry apply per-wrapper rules when needed (e.g., a jurisdiction filter) without giving up the shared baseline. + +The policy address is rotatable via `set_compliance_config` (§6) under admin auth (§1.1). Setting it to `None` disables the gate. The policy is part of the deployment's trust surface. + +--- + +## 4. Unregistered Deposits + +`deposit` is the one operation where `from` may legitimately be an address that has never registered with the wrapper (the depositor only needs to hold the underlying SEP-41). Two cases: + +1. **`permit_unregistered_deposit = false` (default).** Unregistered `from` fails the policy check by default (most policy contracts will not list addresses they have not seen). The operation reverts. +2. **`permit_unregistered_deposit = true`.** When `from` is unregistered, the wrapper skips the policy and freeze checks on `from` and proceeds with the deposit. Checks on `to` (the registered recipient) are unaffected. + +This carve-out lets regulated deployments accept inbound payments from non-listed external counterparties (e.g., an exchange wallet depositing into a payroll pool) while keeping the recipient-side guarantees intact. Deployments that need a stricter posture leave the flag `false` and force every depositor through registration first. + +The flag is set at construction and rotatable via `set_compliance_config` (§6) under admin auth. + +--- + +## 5. Clawback (Outline Only) + +### 5.1 The Pooled-Custody Problem + +Once an account deposits into the wrapper, the underlying SEP-41 ledger lists the wrapper contract as the holder of those funds, not the depositor. An issuer's SAC-level `clawback(wrapper_address, amount)` call would drain the pool, debiting unrelated accounts. The wrapper 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. + +The challenge is that the wrapper 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.2 Admin + Auditor Coordination + +The clawback flow requires cooperation between two roles: + +- **Admin** authorizes the action on-chain: freezes the target, calls the clawback entry point, and settles the recovered amount to the issuer. +- **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.md` §8.1, §8.2). The **sender-auditor** decrypts the spendable-balance checkpoint $\tilde{b}_{\text{aud,s}}$ from the target's most recent owner-initiated event, recovering $v_s$. The **recipient-auditor** decrypts the per-transfer pairs $(v_{\text{tx},i}, r_{\text{tx},i})$ from every inbound transfer and operator-transfer since the last merge, recovering the full Pedersen opening $(v_r, r_r)$ of the target's `receiving_balance`. The auditor then produces a zero-knowledge proof bounding the clawback amount by $v_s + v_r$, without revealing either summand. + +Neither party can act alone: the admin cannot produce the proof, and the auditor cannot freeze the account or move funds. This is the same trust separation present in the core protocol (admin governs state transitions, auditor governs visibility) extended to a write surface. + +The admin role here is the same access-control surface introduced in §1.1; deployments typically place it under a dedicated `clawback` role in RBAC, separate from the freeze role. + +**Auditor routing.** The recipient-auditor and the sender-auditor roles for a single account are served by the same key: each account binds a single `auditor_id` at registration (`DESIGN.md` §6.1) which the wrapper 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{tx},i}$ replay — when the admin initiates a seizure. + +### 5.3 New Circuit + +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_balance` to a zero commitment so the seized inbound flow is consumed atomically. + +**Public inputs.** $C_{\text{spend}}, C_{\text{receive}}, K_{\text{aud,s}}, \tilde{b}_{\text{aud,s}}^{\text{old}}, R_e^{\text{old}}, \sigma^{\text{old}}, \alpha, \tilde{b}_{\text{aud,s}}^{\text{new}}, R_e^{\text{new}}, \sigma^{\text{new}}, \text{wrap}$. + +**Private witnesses.** $k_{\text{aud,s}}, v_s, r_s, v_r, r_r, r_e^{\text{new}}$, plus the sponge outputs from old and new auditor-channel sponge calls. The recipient-auditor's secret key does not appear in the witness because the recipient-channel decryption (recovery of $(v_r, r_r)$ from per-transfer events) is performed off-chain by the auditor; the circuit only re-verifies the resulting Pedersen opening of $C_{\text{receive}}$ (constraint 1). + +**Constraints (sketch).** + +1. **Receiving-balance opening.** $C_{\text{receive}} = v_r \cdot G + r_r \cdot H$. The recipient-auditor reconstructs $(v_r, r_r)$ off-chain from per-transfer events; the proof asserts knowledge of this opening. +2. **Spendable-balance decryption.** $(m_{v,s}^{\text{old}}, m_{b,s}^{\text{old}}) = \text{SpongeSqueeze}_2(\delta_{\text{aud\\\_s}}, (k_{\text{aud,s}} \cdot R_e^{\text{old}}).x, \sigma^{\text{old}})$ and $v_s = \tilde{b}_{\text{aud,s}}^{\text{old}} - m_{b,s}^{\text{old}}$. The spendable-balance opening $(v_s, r_s)$ is consistent with $C_{\text{spend}} = v_s \cdot G + r_s \cdot H$ where $r_s$ is recovered via the same path the wallet uses for checkpoint recovery (`DESIGN.md` §5.2): $r_s = \text{Poseidon}(\delta_{\text{spend\\\_r}}, vk_A, \sigma^{\text{old}})$. Because the clawback circuit does not have access to $vk_A$, the spendable-balance side of the proof binds via the consistency of $\tilde{b}_{\text{aud,s}}^{\text{old}}$ with $C_{\text{spend}}$ at the time of the last owner-initiated proof. The follow-up revision will pin down whether $r_s$ is supplied as a private witness with an auxiliary opening proof or derived in-circuit from a separately escrowed value. +3. **Range and bound.** $\alpha, v_s, v_r \in [0, 2^{127})$ and $\alpha \le v_s + v_r$. +4. **Refreshed checkpoint.** $R_e^{\text{new}} = r_e^{\text{new}} \cdot H$, $r_e^{\text{new}} \neq 0$, and $\tilde{b}_{\text{aud,s}}^{\text{new}} = (v_s + v_r - \alpha) + m_{b,s}^{\text{new}}$ where $(m_{v,s}^{\text{new}}, m_{b,s}^{\text{new}}) = \text{SpongeSqueeze}_2(\delta_{\text{aud\\\_s}}, (k_{\text{aud,s}} \cdot R_e^{\text{new}}).x, \sigma^{\text{new}})$. + +**Post-verification.** The wrapper sets $C_{\text{spend}} \leftarrow (v_s + v_r - \alpha) \cdot G + r_s' \cdot H$ under fresh deterministic randomness $r_s'$ (admin-derived, since $vk_A$ is unavailable), zeroes $C_{\text{receive}}$, transfers $\alpha$ of the underlying SEP-41 token to the issuer, and emits an event carrying $(\tilde{b}_{\text{aud,s}}^{\text{new}}, R_e^{\text{new}}, \sigma^{\text{new}})$ so the sender-auditor sees the new checkpoint. + +**Anti-replay.** The wrapper 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 wrapper-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. + +**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.md` §8.1). + +Detailed encoding, the precise treatment of $r_s$, and the two-phase isolate-then-settle entry-point sequencing are deferred to a follow-up revision of this document. + +--- + +## 6. Interface Summary + +```rust +impl Wrapper { + fn __constructor(e: Env, /* core args */, compliance: Option); + + // Freeze (§2) + fn freeze(e: Env, account: Address, admin: Address); + fn unfreeze(e: Env, account: Address, admin: 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); + + // Reads + fn compliance_config(e: Env) -> Option; +} +``` + +`set_compliance_config` overwrites all three fields atomically. Callers that want to toggle a single field read the current config, modify the relevant field, and pass the updated struct back. This keeps the admin-gated surface to one entry point and avoids per-field rotation helpers. + +### 6.1 Events + +| Event | Fields | +|:---|:---| +| `Frozen`, `Unfrozen` | `account` | +| `ComplianceConfigChanged` | `policy`, `sac_passthrough`, `permit_unregistered_deposit` | + +Clawback-related events are specified alongside the clawback flow in the follow-up revision. diff --git a/packages/tokens/src/confidential/docs/DESIGN.github.md b/packages/tokens/src/confidential/docs/DESIGN.github.md new file mode 100644 index 000000000..90eb3ee21 --- /dev/null +++ b/packages/tokens/src/confidential/docs/DESIGN.github.md @@ -0,0 +1,1313 @@ +# Confidential Token Wrapper + +## Abstract + +We present a confidential token wrapper for Soroban that adds private balances and transfers to any SEP-41 token. Balances are stored as unchunked Pedersen commitments as single elliptic curve points, and updated homomorphically by the contract without decryption. Zero-knowledge proofs (Noir/UltraHonk) accompany each spending operation to prove correctness without revealing amounts. Transfer recipients and auditors recover amounts and blinding factors via per-transfer ephemeral ECDH key agreement over Grumpkin. A dual-balance model (spendable/receiving) prevents griefing: incoming transfers accumulate in a receiving commitment that third parties cannot use to invalidate in-flight spend proofs. A dual-auditor model provides per-account audit visibility: each transfer produces ciphertexts under two auditor keys, giving the recipient's auditor the transfer amount and the sender's auditor the transfer amount plus the sender's post-transfer balance (or post-transfer allowance for operator transfers), enabling real-time auditing. Account owners can delegate spending to time-limited operators via escrowed allowances with derived delegation viewing keys. The system uses 6 Noir circuits, and works seamlessly with Soroban's BN254 host functions (leveraging the recently added CAP-80), and requires approximately 288 bytes of on-chain storage per account. + +--- + +## Project Documents + +This project is composed of the following documents: + +- Confidential Token Wrapper (this document) +- Confidential Token Wrapper: [Compliance Extensions](./COMPLIANCE.md) +- Confidential Token Wrapper: User Flows Overview (to be added) +- Indexing and Off-Chain State Recovery (to be added) +- SDK (to be added) + +--- + +## 1. Introduction + +### 1.1 Background + +Confidential transfers on blockchain require balances and amounts to be hidden from public observers while remaining verifiable by the contract. The standard approach uses additively homomorphic encryption - the contract operates on ciphertexts (adding deposits, subtracting transfers) without learning the underlying values, and zero-knowledge proofs guarantee that operations are valid (sufficient funds, consistent encryption, non-negative balances). + +This document defines a standalone Soroban contract that wraps any SEP-41 token to provide confidential balances and transfers. It is not an extension to the fungible token standard; it is a separate contract that holds tokens on behalf of users and manages encrypted state independently. The wrapper path is chosen over native token integration for three reasons: it works with existing assets, it can evolve independently of the token standard, and it keeps confidentiality complexity separate from the token layer. + +### 1.2 Design Goals + +**Amount and balance confidentiality.** An observer can see that account $A$ transferred to account $B$, and how much each party deposited or withdrew, but not how much moved between them or what their balances are. The system provides confidentiality, not anonymity - sender and recipient addresses remain visible on-chain. + +**Griefing resistance.** A third party must not be able to prevent an account owner from spending by spamming transfers. The balance model must isolate incoming funds from the state that spend proofs reference. + +**No mandatory maintenance operations.** Receiving funds should not require the owner to perform a costly ZK proof before those funds become accessible. The merge operation that makes received funds spendable must be lightweight and non-frontrunnable. + +**Selective auditing.** Each account selects an auditor at registration. Each transfer produces ciphertexts under two auditor keys: the recipient's auditor receives the transfer amount, while the sender's auditor receives the transfer amount and sender's post-transfer balance. This dual-auditor model enables real-time visibility for both parties' auditors without granting access to uninvolved accounts' historical balances. + +**Delegated spending.** Account owners can authorize operators (separate addresses) to spend from escrowed allowances, enabling use cases like automated market makers and custodial services without sharing the owner's secret key. + +### 1.3 Approach + +The design is built on three interlocking mechanisms: + +1. **Pedersen commitments.** Each balance is a single elliptic curve point $C = v \cdot G + r \cdot H$. There is no chunking, no discrete logarithm to solve for decryption, and no overflow from repeated homomorphic additions. The owner maintains the commitment opening $(v, r)$ as local wallet state, updated incrementally from on-chain events. + +2. **ECDH-derived blinding.** When a sender transfers to a recipient, the blinding factor of the transfer commitment is derived from an ephemeral Diffie-Hellman key exchange with the recipient's public viewing key. The circuit enforces correct derivation, ensuring the recipient can always compute the blinding. The same ephemeral scalar is reused for an ECDH exchange with auditors' public key. + +3. **Proof-less merge.** Incoming funds accumulate in a receiving balance that is separate from the spendable balance. To make received funds spendable, the owner authorizes a merge - no ZK proof is required. Since merge requires owner authorization and incoming transfers touch only the receiving balance, neither the spend path nor the merge path can be front-run by a third party. + +Six Noir/UltraHonk circuits cover registration, withdrawal, confidential transfer, operator transfer, operator delegation, and operator revocation. The proof system leverages the Grumpkin–BN254 curve cycle: Grumpkin point arithmetic is native inside Noir circuits (no field emulation), while Soroban natively supports BN254 operations for UltraHonk proof verification. + +--- + +## 2. Preliminaries + +### 2.1 Notation + +| Symbol | Definition | +|:---|:---| +| $\mathbb{G}$ | Grumpkin elliptic curve group (prime order) | +| $\mathbb{F}_r$ | BN254 scalar field $= \mathbb{G}$'s base field | +| $\mathbb{F}_q$ | BN254 base field $= \mathbb{G}$'s scalar field | +| $G, H \in \mathbb{G}$ | Independent generators with no known discrete log relation | +| $\mathcal{O}$ | Identity element (point at infinity), encoded as $(0, 0)$ on-chain | +| $P.x$ | The $x$-coordinate of point $P$, an element of $\mathbb{F}_r$ | +| $\text{Poseidon}(\cdot)$ | Poseidon2 hash function over $\mathbb{F}_r$ (Section 2.5) | +| $\delta_{\ast}$ | Domain separation constants (subscript identifies the domain) | +| $[n]$ | The set $\{0, 1, \ldots, n-1\}$ | + +### 2.2 Grumpkin–BN254 Cycle + +Grumpkin is defined by $y^2 = x^3 - 17$ over $\mathbb{F}_r$. It forms a 2-cycle with BN254: + +$$\text{base}(\mathbb{G}) = \mathbb{F}_r^{\text{BN254}}, \qquad \text{scalar}(\mathbb{G}) = \mathbb{F}_q^{\text{BN254}}$$ + +A Grumpkin point is a pair $(x, y) \in \mathbb{F}_r^2$. Noir's native `Field` type is $\mathbb{F}_r$, so Grumpkin point arithmetic inside UltraHonk circuits incurs no non-native field emulation. On-chain, the Soroban host provides BN254 $\mathbb{F}_r$ arithmetic (`bn254_fr_{add, sub, mul, inv}` via CAP-80), which suffices for Grumpkin affine point operations. + +**Scalar sampling.** Grumpkin scalars live in $\mathbb{F}_q$, which is slightly larger than $\mathbb{F}_r$. All secret scalars in this design ($sk$, $r_e$, $\sigma$, $\sigma_a$) are sampled by the **rejection sampling** procedure, which produces a uniform draw from $\mathbb{F}_r$: + +1. Draw 32 bytes (256 bits) from a CSPRNG. +2. Mask the top 2 bits to zero, yielding a 254-bit candidate $x \in [0, 2^{254})$. +3. If $x \geq r$, reject and return to step 1. +4. If the call site requires $x \neq 0$ and $x = 0$, reject and return to step 1. +5. Output $x$ in its canonical form -- as a Noir `Field` for in-circuit use, or as 32 big-endian bytes (`BytesN<32>`) for storage and event emission. Per §10.8 / §11 this canonical encoding is what the Soroban host's `bn254_fr_*` deserialization accepts; non-canonical values (i.e. $x \geq r$) are rejected at the host boundary. + +### 2.3 Pedersen Commitments + +A Pedersen commitment to a value $v$ with a blinding factor $r$, viewed as scalars in Grumpkin's scalar field $\mathbb{F}_q$, is: + +$$\text{Com}(v, r) = v \cdot G + r \cdot H$$ + +In this design both $v$ and $r$ are drawn from $\mathbb{F}_r \subset \mathbb{F}_q$ (§2.2): $v$ is a non-negative integer below $2^{127}$ (§2.6) and $r$ is a Poseidon2 output or an $\mathbb{F}_r$-sampled CSPRNG draw; the group law operates in $\mathbb{F}_q$. + +**Binding.** Finding $(v', r') \neq (v, r)$ such that $\text{Com}(v, r) = \text{Com}(v', r')$ requires computing $\log_G H$, which is infeasible under the discrete logarithm assumption. + +**Hiding.** For any $v$, the commitment $\text{Com}(v, r)$ with uniformly random $r \in \mathbb{F}_q$ is uniformly distributed over $\mathbb{G}$, revealing nothing about $v$. Sampling $r$ from $\mathbb{F}_r \subset \mathbb{F}_q$ instead of full $\mathbb{F}_q$ (§2.2) makes the commitment distribution **statistically close** to uniform over $\mathbb{G}$, with total-variation distance bounded by $(|\mathbb{F}_q| - |\mathbb{F}_r|)/|\mathbb{F}_q| \approx 2^{-127}$. + +**Homomorphism.** $\text{Com}(v_1, r_1) + \text{Com}(v_2, r_2) = \text{Com}(v_1 + v_2, r_1 + r_2)$. Scalar addition in the commitment relation is over $\mathbb{F}_q^{\text{BN254}}$ -- the scalar field of $\mathbb{G}$, equivalently the order of the Grumpkin group. Since every committed value is bounded by $2^{127}$ (§2.6) and the number of additions across the lifetime of any one commitment is far below $2^{127}$, the value component never wraps in $\mathbb{F}_q$ and the homomorphic relation holds in $\mathbb{Z}$ for values. The blinding component is added in $\mathbb{F}_q$ and may reduce mod $q$ on accumulation; the only place this has operational consequences is the wallet's post-merge spend witness, where the canonical $\mathbb{F}_q$ representative of $r_s + r_r$ can land in $[r, q)$ with probability bounded at $(q-r)/q \approx 2^{-127}$ per merge (see §10.4 *Post-merge witness availability*). + +**Generators.** $G$ and $H$ are inherited from Barretenberg's standard Grumpkin Pedersen instantiation (the same generators that the toolchain's `pedersen_commitment` and `pedersen_hash` primitives use). Their provenance is part of the toolchain's audited surface, so the wrapper inherits both the generators and the soundness assumption that $\log_G H$ is unknown. The Noir circuits import them as `embedded_curve_ops::generator()`. + +### 2.4 Elliptic Curve Diffie-Hellman + +Given a long-term keypair $(a, A = a \cdot H)$ and an ephemeral keypair $(r_e, R_e = r_e \cdot H)$, the ECDH shared secret is: + +$$S = r_e \cdot A = a \cdot R_e = a \cdot r_e \cdot H \in \mathbb{G}$$ + +Both parties compute $S$ independently. We extract the scalar $s = S.x \in \mathbb{F}_r$ for use as a Poseidon input. + +### 2.5 Poseidon2 Hash + +The system uses **Poseidon2**, the algebraic hash function native to Noir's standard library and implemented as a custom gate in Barretenberg. For the complete parameter specification of the Noir/Barretenberg instantiation, see: + +- [Poseidon2 paper](https://eprint.iacr.org/2023/323) (Grassi, Khovratovich, Schofnegger, AFRICACRYPT 2023) - parameter derivation and security analysis +- [Barretenberg `poseidon2_params.hpp`](https://github.com/AztecProtocol/aztec-packages/blob/next/barretenberg/cpp/src/barretenberg/crypto/poseidon2/poseidon2_params.hpp) - concrete round constants and matrix entries +- [HorizenLabs reference implementation](https://github.com/HorizenLabs/poseidon2) - parameter generation script (`poseidon2_rust_params.sage`) +- [Noir stdlib `hash/mod.nr`](https://github.com/noir-lang/noir/blob/master/noir_stdlib/src/hash/mod.nr) - sponge construction wrapping the `Poseidon2Permutation` ACIR opcode + +**Usage in this system:** + +- Key derivation: $vk = \text{Poseidon2}(\delta_{\text{vk}}, sk, \text{wrap})$ +- Randomness derivation: $r = \text{Poseidon2}(\delta_{\text{spend\\\_r}}, vk, \sigma)$ +- Symmetric encryption: $\tilde{v} = v + \text{Poseidon2}(\delta_{\text{tx\\\_amount}}, s, \sigma)$ +- Domain separation: each invocation includes a leading constant $\delta$ to prevent cross-context collisions + +**Sponge mode for auditor channels.** The per-transfer auditor ciphertexts (Section 8) use Poseidon2 in sponge mode. A single absorb of $(\delta_{\text{channel}}, S.x, \sigma)$ is followed by $n$ sequential squeezes producing $(m_1, \ldots, m_n) \in \mathbb{F}_r^n$, denoted $\text{SpongeSqueeze}_n(\delta_{\text{channel}}, S.x, \sigma)$. Two channel tags are used: $\delta_{\text{aud\\\_s}}$ for the sender-auditor channel keyed by $S_{a,s}.x = (r_e \cdot K_{\text{aud,s}}).x$, and $\delta_{\text{aud\\\_r}}$ for the recipient-auditor channel keyed by $S_{a,r}.x = (r_e \cdot K_{\text{aud,r}}).x$. + +Squeeze order is canonical. Where $n = 2$, the first squeezed mask is the amount mask and the second is the balance or randomness mask, fixed per operation by the formulas in Sections 7 and 8. + +All references to "Poseidon" in this document denote this Poseidon2 instantiation. + +### 2.6 Integer Embedding and Range Proofs + +**The problem.** Noir circuits operate over $\mathbb{F}_r$, where every element is a non-negative integer modulo $r \approx 2^{254}$. The statement "$v \geq 0$" is vacuously true for all $v \in \mathbb{F}_r$, and "$v_A \geq v_{\text{tx}}$" is undefined without specifying how integers are embedded in the field. Without explicit range constraints, a prover can claim a balance of 1 and transfer 1,000,000: the "new balance" $1 - 1{,}000{,}000 \equiv r - 999{,}999 \pmod{r}$ is a valid field element, and the commitment equation holds. The attacker has minted 999,999 tokens. + +**Integer embedding.** We define a canonical embedding $\iota: [0, 2^{127}) \to \mathbb{F}_r$ mapping non-negative integers to their natural field representatives. A field element $x \in \mathbb{F}_r$ represents a valid balance or transfer amount if and only if $x < 2^{127}$. + +**Range proof mechanism.** A range proof for $x \in [0, 2^{127})$ is implemented by decomposing $x$ into 127 bits inside the circuit and checking the recomposition: + +$$x = \sum_{i=0}^{126} b_i \cdot 2^i, \qquad b_i \in \{0, 1\} \;\forall\, i$$ + +Each $b_i$ is constrained to be Boolean ($b_i \cdot (b_i - 1) = 0$) and the recomposition is checked against $x$. Noir's standard library exposes this directly: + +```noir +// Range check: [0, 2^127) +value.assert_max_bit_size::<127>(); +``` + +**Sufficiency argument.** If the prover supplies $v_A$ and $v_{\text{tx}}$ such that the circuit verifies: + +1. $v_A \in [0, 2^{127})$ (the opening of $C_{\text{spend}}$) +2. $v_{\text{tx}} \in [0, 2^{127})$ (the transfer amount) +3. $v_A - v_{\text{tx}} \in [0, 2^{127})$ (the new balance) + +then $v_A - v_{\text{tx}}$ is a non-negative integer less than $2^{127}$, which is only possible if the integer subtraction did not underflow. This is because $v_A < 2^{127}$ and $v_{\text{tx}} < 2^{127}$, so if $v_A < v_{\text{tx}}$ as integers, then $v_A - v_{\text{tx}} \pmod{r}$ would be $r - (v_{\text{tx}} - v_A)$, which is at least $r - 2^{127} \gg 2^{127}$, failing constraint (3). + +**Value capacity.** Both balances and transfer amounts are constrained to $[0, 2^{127})$. These bounds are enforced in every circuit that manipulates values. The bound is exactly the SEP-41 non-negative `i128` range, so the wrapper's value domain matches the underlying token's domain by construction. The gap between $2^{127}$ and $|\mathbb{F}_r| \approx 2^{254}$ ensures that modular wrap-around is detectable by the range check. + +**Receiving balance (unproven accumulation).** The receiving balance $C_{\text{receive}}$ is updated by contract-side point addition without any proof from the recipient. Therefore, the receiving balance's committed value $v_r$ is never directly range-checked by any circuit. + +This is safe because $v_r$ is *indirectly* bounded: + +1. Each deposit adds a public `i128` amount validated by the contract ($\ge 0$, hence $< 2^{127}$). +2. Each incoming transfer adds a commitment whose sender circuit proved $v_{\text{tx}} \in [0, 2^{127})$ (constraint T4 / O4). +3. All tokens in the wrapper entered through deposits, so the sum of all committed values is bounded by the underlying token's total supply ($< 2^{127}$). No single account can receive more than the total supply. +4. For the field-arithmetic concern (could $v_r$ reach $r$ and wrap around), that would require $r / 2^{127} > 2^{127}$ incoming transfers, which is computationally infeasible. + +When the owner spends after a merge, the spend proof constrains the full post-merge opening: $v_s + v_r \in [0, 2^{127})$ (via constraint W4 or T4 on the spendable balance). This provides an implicit range check at the next spend boundary. + +### 2.7 Address-to-Field Encoding + +In Soroban, the SDK's `Address` host type covers exactly the two `ScAddressType` variants the wrapper interacts with as actors: `Account` (Stellar ed25519 account) and `Contract` (Soroban contract instance). The protocol encodes those addresses via their **canonical Stellar strkey** (SEP-23) representation: + +$$\text{enc}(a) \;=\; \text{Address::to\\\_string}(a)\text{.to\\\_bytes}() \;\in\; \{\text{ASCII}\}^{56}$$ + +This is the 56-character ASCII strkey produced by the host's `address_to_strkey` function: a 1-byte version tag (`G` = `0x47` for `Account`, `C` = `0x43` for `Contract`), a 32-byte payload (ed25519 public key or contract hash), and a 2-byte CRC16 checksum, all base32-encoded into 56 ASCII characters. The byte string is fixed-length, canonical, and reproducible in every Stellar SDK via the language's stellar-strkey library; the protocol commits to these 56 ASCII bytes. + +The Poseidon-compressed Field encoding splits the 56-byte string into two 28-byte limbs (each $\le 2^{224} \ll r \approx 2^{254}$, hence trivially in $\mathbb{F}_r$): + +$$\text{address\\\_to\\\_field}(a) \;=\; \text{Poseidon2}\big(\delta_{\text{addr}}, \;\text{lo}(a), \;\text{hi}(a)\big)$$ + +where $\text{lo}(a) = \sum_{i=0}^{27} 256^{\,i} \cdot \text{enc}(a)[i]$ and $\text{hi}(a) = \sum_{i=0}^{27} 256^{\,i} \cdot \text{enc}(a)[28 + i]$ interpret the lower and upper 28 bytes of the strkey in little-endian byte order. + +The wrapper, the SDK, the wallet, and any indexer reproduce the same Field value from the same Address by running their language's stellar-strkey encoder over the same `(version, payload)` pair and applying the same limb decomposition. No implementation needs to handle `ScAddress` XDR or the inner `AccountID` / `ContractID` union nesting. + +**Usage sites.** + +| Site | When computed | Storage | +|:---|:---|:---| +| $\text{wrap}$ | Once, by the wrapper's `__constructor` over `env.current_contract_address()` | Stored as a single Field in the wrapper's **instance storage** (§3.5); read on every proof verification | +| $\text{op}_i$ | Per-call, by the wrapper at `set_operator` and `revoke_operator` over the `operator` argument | Not stored; recomputed each call. The circuit binds it via S5 / V3 | + +--- + +## 3. System Model + +### 3.1 Components + +The system comprises three contracts deployed on Soroban: + +**Wrapper contract.** Holds SEP-41 token balances, manages encrypted account state, and delegates proof verification via cross-contract calls. Performs Grumpkin point arithmetic through $\mathbb{F}_r$ host operations for homomorphic balance updates. + +**Verifier contract.** A modified [UltraHonk verifier](https://github.com/indextree/ultrahonk_soroban_contract) storing one verification key per circuit type. Accepts a circuit identifier, serialized public inputs, and a proof blob; returns success or failure. + +**Auditor contract.** Manages auditor encryption keys independently of the wrapper. One auditor contract serves multiple token wrappers. Stores Grumpkin public keys as full affine points $(x, y)$ indexed by `auditor_id`. The contract validates that stored keys are non-identity curve points; a zero or identity key would make ECDH-derived ciphertexts trivially decryptable (since $\sigma$ is public). The wrapper fetches the active auditor key at operation time and passes it as a public input to the relevant circuit. + +### 3.2 Threat Model + +- The contract execution environment is trusted for correctness but not for privacy: all on-chain state and invocation inputs are public. +- Proof verification is sound: a valid proof guarantees the proven statement holds. This depends on the UltraHonk knowledge soundness assumption *and* the integrity of the Structured Reference String (Section 10.6). +- The discrete logarithm problem on Grumpkin is hard. +- Poseidon2 (Section 2.5) is a pseudorandom function (PRF) and is preimage-resistant over $\mathbb{F}_r$ at the parameterized round count ($R_F = 8$, $R_P = 56$, 128-bit security target). +- Third parties may submit arbitrary transactions, including spam transfers to any registered account. + +### 3.3 Trust Assumptions + +The wrapper, verifier, and auditor contracts are trusted code. Users trust that the verification keys embedded in the verifier correspond to the correct circuits and were derived from a honestly generated Structured Reference String (Section 10.6). The auditor is trusted to protect its decryption key and exercise access only upon legitimate regulatory request. + +### 3.4 Underlying Token Assumptions + +The wrapper holds units of an underlying SEP-41 token on behalf of its users. The confidential accounting invariant (Section 9.3) implicitly assumes: + +$$\sum_i v_{\text{committed},i} \;\le\; \text{token.balance}(\text{wrapper})$$ + +i.e., the total committed value across all confidential accounts never exceeds the public token balance held by the wrapper. The deployer's choice of underlying token determines whether that invariant is actually preserved over time. The wrapper itself does not, and cannot, defend against every misbehavior of the wrapped asset. + +**Required properties of the underlying token.** + +- *Non-rebasing.* The token's balance attributed to the wrapper address changes only as a result of explicit operations that the wrapper itself originated. Tokens whose balances change as a function of supply, oracle data, or external triggers break the accounting invariant and are unsupported. +- *No fee-on-transfer.* `token.transfer(from, to, amount)` MUST move exactly `amount` units. A fee deducted in transit would leave the wrapper's confidential accounting larger than its public backing. +- *Deterministic revert.* A failed `token.transfer` MUST cause the enclosing wrapper invocation (`deposit` or `withdraw`) to revert atomically, so confidential state is never updated against a token transfer that did not happen. +- *Underlying clawback / freeze / deauthorization.* If the underlying SEP-41 (especially a Stellar Asset Contract) supports issuer-level clawback or freeze that can reduce or block the wrapper's holdings, confidential accounting at the wrapper layer may temporarily or permanently exceed the wrapper's accessible backing. This is an operational risk borne by the deployer's choice of underlying token. The wrapper layer offers its own freeze and per-account clawback flows that operate inside the confidential surface; see [COMPLIANCE.md](./COMPLIANCE.md) §2 (wrapper-level freeze) and §5 (admin + auditor clawback). [COMPLIANCE.md](./COMPLIANCE.md) §2.2 additionally specifies SAC authorization passthrough, which composes the wrapper's freeze with the issuer's freeze without requiring the admin to mirror state. + +**Non-negativity check.** The wrapper's public interface uses `i128` end-to-end, matching SEP-41. Every entrypoint that accepts a public amount (`deposit`, `withdraw`) MUST reject `amount < 0` and revert. The in-circuit range constraint (Section 2.6) bounds the same value at $2^{127}$ from above; together they pin the wrapper's value domain to $[0, 2^{127}) = [0, \text{i128::MAX}]$, matching SEP-41 exactly. No conversion at the SEP-41 boundary is needed. + +### 3.5 Governance and Upgradeability + +The constructor binds the wrapper to fixed `admin`, `token`, `verifier`, and `auditor` addresses. It additionally computes and stores $\text{wrap} = \text{address\\\_to\\\_field}(\text{env.current\\\_contract\\\_address}())$ (§2.7) in **instance storage** as a single canonical $\mathbb{F}_r$ Field; this is the value every owner-initiated proof references via constraints R2 / W2 / T2 / S2 / V2. The compressed `wrap` Field is computed once at construction (not recomputed per call) to ensure all proofs across the wrapper's lifetime bind to the same Field representative of the wrapper's address. Beyond that, this specification does not prescribe a governance policy for upgrading these components or for rotating per-circuit verification keys. Concrete deployments differ widely in operator structure, regulatory posture, and emergency-response requirements, so these decisions are deliberately left to implementers. + +Questions an implementer must answer: + +- May `admin` replace the `verifier` contract, or the `auditor` contract after deployment? +- Are per-circuit verification keys immutable for the lifetime of the deployment, or may they be updated? +- If any of the above is upgradeable, what authorization (single key, multisig), timelock, and event-emission rules apply? +- How do users independently reproduce a deployed VK from circuit source, toolchain, and SRS (Section 10.6)? + +**Recommendation.** The strongest soundness posture is full immutability: `token`, `verifier`, `auditor`, and per-circuit verification keys all fixed at deployment, with any circuit or verifier change requiring a fresh deployment and an explicit user-side migration. Where operational realities make full immutability impractical (for example, a discovered soundness bug in a circuit or verifier that needs a fast fix), implementers may expose admin-guarded upgrade entrypoints for the `verifier` address or per-circuit VKs. In that case the upgrade path should be gated. + +--- + +## 4. Key Hierarchy + +All keys derive from a single spending secret $sk \in \mathbb{F}_r$. + +### 4.1 Spending Key + +$$Y = sk \cdot H$$ + +The spending public key is stored on-chain at registration. Knowledge of $sk$ is required to authorize transfers, withdrawals, operator delegations, and merges. + +### 4.2 Viewing Key + +$$vk = \text{Poseidon}(\delta_{\text{vk}}, sk, \text{wrap})$$ + +A scalar in $\mathbb{F}_r$, unique per $(sk, \text{wrap})$ pair. Enables balance decryption without spending authority. Cannot recover $sk$ (Poseidon preimage resistance). Because $\text{wrap}$ is bound into the derivation, proofs that constrain $vk$ (R2, W2, T2, S2, V2) are inherently bound to the wrapper contract, eliminating the need for explicit per-circuit context binding. + +### 4.3 Public Viewing Key + +$$\text{PVK} = vk \cdot H$$ + +A Grumpkin point stored on-chain at registration. Serves as the recipient's ECDH public key for incoming transfers. The registration proof constrains $\text{PVK} = vk \cdot H$ where $vk = \text{Poseidon}(\delta_{\text{vk}}, sk, \text{wrap})$ and $Y = sk \cdot H$, preventing a user from registering an unrelated $\text{PVK}$. + +### 4.4 Delegation Viewing Key + +For operator $i$ with address $\text{op}_i$, the owner derives: + +$$dvk_i = \text{Poseidon}(\delta_{\text{dvk}}, vk, \text{op}_i)$$ + +Properties: +- $dvk_i$ reveals only this operator's allowance state in this wrapper context ($vk$ is wrapper-specific, Section 4.2). +- $dvk_i$ cannot recover $vk$ (preimage resistance). +- Different $(vk, \text{op}_i)$ tuples yield independent keys. + +--- + +## 5. Commitment Scheme + +The following symbols are used throughout this section: + +| Symbol | Definition | +|:---|:---| +| $C_{\text{spend}}$ | On-chain spendable balance commitment (Pedersen point) | +| $C_{\text{receive}}$ | On-chain receiving balance commitment (Pedersen point) | +| $C_{\text{tx}}$ | Transfer commitment added to recipient's $C_{\text{receive}}$ | +| $\text{Com}(v, r)$ | Pedersen commitment $v \cdot G + r \cdot H$ | +| $v_s, r_s$ | Value and blinding factor of $C_{\text{spend}}$ (off-chain wallet state) | +| $v_r, r_r$ | Value and blinding factor of $C_{\text{receive}}$ (off-chain wallet state) | +| $v_{\text{tx}}$ | Transfer amount (private) | +| $r_{\text{tx}}$ | ECDH-derived blinding factor for $C_{\text{tx}}$ | +| $W_{\text{spend}}, W_{\text{receive}}$ | Wallet-side accumulators: $(v, r)$ pairs tracking commitment openings | +| $r_e$ | Ephemeral scalar sampled per transfer | +| $R_e$ | Ephemeral public key $r_e \cdot H$ (published in event data) | +| $S$ | ECDH shared secret point $r_e \cdot \text{PVK}_B$ | +| $s$ | Scalar extracted from shared secret: $S.x \in \mathbb{F}_r$ | +| $\tilde{v}$ | Encrypted transfer amount: $v_{\text{tx}} + \text{Poseidon}(\delta_{\text{tx\\\_amount}}, s, \sigma)$ | +| $\tilde{b}$ | Encrypted balance scalar: $v_{\text{new}} + \text{Poseidon}(\delta_{\text{enc\\\_bal}}, vk, \sigma)$ | +| $\sigma$ | Prover-chosen random salt, sampled per operation via the rejection sampling procedure of §2.2; canonical $\mathbb{F}_r$ representative encoded as `BytesN<32>` | + +### 5.1 Balance Commitments + +Each balance is a single Pedersen commitment $C = \text{Com}(v, r) \in \mathbb{G}$, represented on-chain as an uncompressed affine point $(x, y) \in \mathbb{F}_r^2$ (64 bytes). The identity $\mathcal{O}$ is encoded as $(0, 0)$ and handled as a special case in point arithmetic. + +The committed value $v$ can represent the full range of practical balances (up to $2^{127} - 1$, bounded by the SEP-41 `i128` interface) without discrete logarithm concerns, because the owner maintains the commitment opening off-chain (Section 5.2) and the auditor reads an encrypted scalar (Section 5.5). + +### 5.2 Off-Chain Opening Maintenance {#opening-maintenance} + +A Pedersen commitment $C = \text{Com}(v, r)$ hides its opening $(v, r)$. The owner must know this opening to construct spend proofs so they must maintain $(v, r)$ as local wallet state, updated incrementally as balance-modifying events occur. + +**Definition** (Wallet state). The owner's wallet maintains two running accumulators: + +$$W_{\text{spend}} = (v_s, r_s) \quad \text{such that} \quad C_{\text{spend}} = v_s \cdot G + r_s \cdot H$$ +$$W_{\text{receive}} = (v_r, r_r) \quad \text{such that} \quad C_{\text{receive}} = v_r \cdot G + r_r \cdot H$$ + +**Initialization.** At registration, $C_{\text{spend}} = C_{\text{receive}} = \mathcal{O}$. The wallet sets $W_{\text{spend}} = W_{\text{receive}} = (0, 0)$. + +**Update rules.** Each balance-modifying event updates exactly one accumulator: + +| Event | Accumulator update | +|:---|:---| +| Deposit of public amount $a$ to this account | $W_{\text{receive}} \mathrel{+}= (a, 0)$ | +| Incoming transfer with event $(R_e, \tilde{v}, \sigma)$ | Compute $S = vk \cdot R_e$, $s = S.x$; derive $v_{\text{tx}} = \tilde{v} - \text{Poseidon}(\delta_{\text{tx\\\_amount}}, s, \sigma)$ and $r_{\text{tx}} = \text{Poseidon}(\delta_{\text{tx\\\_blind}}, s, \sigma)$. Then $W_{\text{receive}} \mathrel{+}= (v_{\text{tx}}, r_{\text{tx}})$ | +| Outgoing transfer/withdrawal of amount $a$ | Proof outputs new commitment with deterministic randomness. $W_{\text{spend}} \leftarrow (v_s - a, \; \text{Poseidon}(\delta_{\text{spend\\\_r}}, vk, \sigma))$ | +| Merge | $W_{\text{spend}} \leftarrow (v_s + v_r, \; r_s + r_r)$; $W_{\text{receive}} \leftarrow (0, 0)$ | +| Set operator (escrow amount $a$) | Proof outputs new commitment. $W_{\text{spend}} \leftarrow (v_s - a, \; \text{Poseidon}(\delta_{\text{spend\\\_r}}, vk, \sigma))$ | +| Revoke operator (reclaim amount $a$) | Proof outputs new commitment. $W_{\text{spend}} \leftarrow (v_s + a, \; \text{Poseidon}(\delta_{\text{spend\\\_r}}, vk, \sigma))$ | + +The Merge row uses exact integer addition; $W_{\text{spend}}.r$ is not reduced modulo $r$ or $q$ as merges accumulate. At proof-construction time the wallet reduces $W_{\text{spend}}.r$ modulo $q$ and encodes the canonical $\mathbb{F}_q$ representative as a single $\mathbb{F}_r$ `Field`. This encoding succeeds when the representative lies in $[0, r)$, with probability $\geq 1 - 2^{-127}$ per merge; the complementary case is acknowledged in §10.4 *Post-merge witness availability*. + +After every owner-initiated operation that produces a proof, $r_s$ resets to a deterministic value. This is the **normalization** property: the spendable balance's blinding factor is always recoverable from $(vk, \sigma)$ at spend boundaries. Together with $\tilde{b}$, both emitted in the spend-boundary event, each spend boundary forms a **checkpoint** from which the spendable opening $(v_s, r_s)$ is recoverable via a single event lookup, with no exhaustive history replay needed for $W_{\text{spend}}$. Recovering $W_{\text{receive}}$, and folding in any post-checkpoint merges, still requires replaying events emitted after the checkpoint (see Recovery below). + +**Consistency check.** At any time, the wallet can verify its state: $C_{\text{spend}} \stackrel{?}{=} v_s \cdot G + r_s \cdot H$ and $C_{\text{receive}} \stackrel{?}{=} v_r \cdot G + r_r \cdot H$, where $C_{\text{spend}}$ and $C_{\text{receive}}$ are read from on-chain state. + +**Recovery.** If the wallet loses local state, it recovers from the **last checkpoint**: the most recent owner-initiated proof operation (`withdraw`, `confidential_transfer`, `set_operator`, or `revoke_operator`), which emitted both $\tilde{b}$ and $\sigma$ in its event. By construction, only deposits, incoming transfers, and merges can occur after this event; any later owner-initiated proof operation would itself become the new checkpoint. Steps 1-4 recover the spendable balance using $\tilde{b}$, $\sigma$ (both from the event), and $vk$. Event replay (steps 5-6) folds in the bounded post-checkpoint activity: + +1. Fetch $(\tilde{b}, \sigma)$ from the most recent **checkpoint event** for this account, where a checkpoint event is exactly one of `Withdraw`, `Transfer` (where the account is the `from`), `SetOperator`, or `RevokeOperator` -- the four event types that carry a proof-bound $(\tilde{b}, \sigma)$ for the account's spendable balance. `Deposit`, `Transfer` (where the account is the `to`), `OperatorTransfer` (recipient side), and `Merge` are explicitly **not** checkpoints: they either carry no $(\tilde{b}, \sigma)$ at all or carry one that is bound to a different account's spendable balance. **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 the replay window starting at 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)$ and $W_{\text{receive}} \leftarrow (0, 0)$. +5. Replay all events since the checkpoint in ledger order. For each event: + - **Incoming transfer** $(R_e, \tilde{v}, \sigma_{\text{sender}})$: compute $S = vk \cdot R_e$, derive $v_{\text{tx}}$ and $r_{\text{tx}}$. Accumulate $W_{\text{receive}} \mathrel{+}= (v_{\text{tx}}, r_{\text{tx}})$. + - **Deposit** of amount $a$: accumulate $W_{\text{receive}} \mathrel{+}= (a, 0)$. + - **Merge**: fold $W_{\text{spend}} \leftarrow (W_{\text{spend}}.v + W_{\text{receive}}.v, \; W_{\text{spend}}.r + W_{\text{receive}}.r)$, reset $W_{\text{receive}} \leftarrow (0, 0)$. +6. Verify consistency: $C_{\text{spend}} \stackrel{?}{=} W_{\text{spend}}.v \cdot G + W_{\text{spend}}.r \cdot H$ and $C_{\text{receive}} \stackrel{?}{=} W_{\text{receive}}.v \cdot G + W_{\text{receive}}.r \cdot H$. + +Steps 1-3 require $(\tilde{b}, \sigma)$ from the latest owner event and $vk$. No full event replay is needed. Step 5 replays only events since the last checkpoint and correctly handles any number of interleaved deposits, transfers, and merges. A wallet that spends regularly produces frequent checkpoints, bounding the replay window. In the worst case (funds received but never spent), the replay window extends back to registration. + +**Event durability requirement.** Recovery depends on the wallet being able to retrieve every event since the last checkpoint, plus the checkpoint event itself, in ledger order. Stellar RPC retains event history for a 7-days window only, so a wallet that loses local state after that window cannot recover from RPC alone. The protocol therefore assumes a durable event archive that retains the full per-account history of `Withdraw`, `Transfer` (both directions), `OperatorTransfer` (recipient side), `Deposit`, `Merge`, `SetOperator`, and `RevokeOperator` events forever. The data model, ingestion contract, retention obligations, and recommended API surface for that archive are specified in [INDEXER.md](INDEXER.md). Wallets and SDKs MUST consume an indexer that meets that contract for recovery. + +### 5.3 ECDH-Derived Blinding + +When a sender (spending key $sk_A$) transfers to a recipient with public viewing key $\text{PVK}_B$, the transfer commitment uses blinding derived from an ephemeral ECDH exchange. + +**Definition 1** (Transfer blinding derivation). The sender samples $r_e, \sigma \in \mathbb{F}_r$ via the rejection sampling procedure (§2.2), then computes: + +$$R_e = r_e \cdot H$$ +$$S = r_e \cdot \text{PVK}_B$$ +$$s = S.x \in \mathbb{F}_r$$ +$$r_{\text{tx}} = \text{Poseidon}(\delta_{\text{tx\\\_blind}}, s, \sigma)$$ +$$\tilde{v} = v_{\text{tx}} + \text{Poseidon}(\delta_{\text{tx\\\_amount}}, s, \sigma)$$ + +where $v_{\text{tx}}$ is the transfer amount. The transfer commitment is $C_{\text{tx}} = \text{Com}(v_{\text{tx}}, r_{\text{tx}})$. The ephemeral public key $R_e$, encrypted amount $\tilde{v}$, and $\sigma$ are published in the transaction event data so recipients can derive both $v_{\text{tx}}$ and $r_{\text{tx}}$ during replay. + +Since $vk_B \cdot R_e = r_e \cdot \text{PVK}_B = S$ by ECDH commutativity, both sender and recipient can independently derive $r_{\text{tx}}$ and decrypt $v_{\text{tx}} = \tilde{v} - \text{Poseidon}(\delta_{\text{tx\\\_amount}}, s, \sigma)$, provided they know $\sigma$ emitted with the event. The auditor decrypts the transfer amount via a separate ECDH channel (Section 8.1). + +**Note.** Each transfer involves two auditor ECDH exchanges: one with the recipient's auditor key ($S_{a,r} = r_e \cdot K_{\text{aud,r}}$) and one with the sender's auditor key ($S_{a,s} = r_e \cdot K_{\text{aud,s}}$). Both reuse the ephemeral scalar $r_e$, as does the $dvk_i$ escrow ECDH in `set_operator` (§7.11) when one is present. Neither auditor recovers any account's viewing key. + +**Why reusing $r_e$ is safe.** Each ECDH channel keyed from the same $r_e$ produces a distinct shared scalar because the counterparty public keys are distinct ($\text{PVK}_B$, $K_{\text{aud,r}}$, $K_{\text{aud,s}}$, $Y_{\text{op}}$ are independent Grumpkin points, none derivable from one another). Each channel further uses a distinct Poseidon domain tag ($\delta_{\text{tx\\\_blind}}/\delta_{\text{tx\\\_amount}}$ for the recipient channel, $\delta_{\text{aud\\\_r}}$ and $\delta_{\text{aud\\\_s}}$ for the two auditor channels, $\delta_{\text{esc\\\_dvk}}$ for the operator escrow), so masks across channels are independent under the PRF assumption on Poseidon (§3.2). The channel masks are used as one-time pads against fresh per-transfer randomness ($\sigma$ or $\sigma_a$), and each per-channel sponge re-absorbs that nonce, so a given mask is never reused even for the same counterparty across two operations. Together these three properties (distinct shared scalars, distinct domains, fresh per-operation nonce) close the standard ECDH key-reuse attack surface; the wrapper's enumeration of channels in §13 satisfies the domain-distinctness condition. + +### 5.4 Anti-Poisoning Constraint + +The transfer circuit enforces that $C_{\text{tx}}$ was constructed using the ECDH-derived $r_{\text{tx}}$: + +$$C_{\text{tx}} = v_{\text{tx}} \cdot G + r_{\text{tx}} \cdot H \quad \text{where} \quad r_{\text{tx}} = \text{Poseidon}(\delta_{\text{tx\\\_blind}}, s, \sigma)$$ + +This prevents a malicious sender from committing with arbitrary blinding, which would cause the recipient to lose track of their accumulated blinding factor and be unable to spend. + +### 5.5 Encrypted Balance Scalar + +Owner-initiated operations (transfers, withdrawals) produce a new spendable balance commitment with deterministic randomness (Section 7). To enable wallet recovery without full event replay, the proof also outputs an **encrypted balance scalar**: + +$$\tilde{b} = v_{\text{new}} + \text{Poseidon}(\delta_{\text{enc\\\_bal}}, vk, \sigma)$$ + +where $v_{\text{new}}$ is the new spendable balance and $\sigma$ is the prover-chosen random salt. The contract emits $\tilde{b}$ in the operation's event (Section 11.2) rather than storing it on-chain; the contract never reads it after the proof has bound it to $C_{\text{spend}}$. Anyone with $vk$ recovers $v_{\text{new}} = \tilde{b} - \text{Poseidon}(\delta_{\text{enc\\\_bal}}, vk, \sigma)$ from the event. The primary consumer is the owner's wallet for checkpoint recovery (Section 5.2); auditors do not hold $vk$ and instead read balances via per-transfer ECDH ciphertexts (Section 8.1). The circuit enforces consistency between $\tilde{b}$ and the committed value in $C_{\text{spend}}$. + +--- + +## 6. Account State + +### 6.1 Account Data Model + +Each registered account stores a `ConfidentialAccount` in persistent storage, keyed by `Address`: + +```rust +ConfidentialAccount { + spending_key: BytesN<64>, // Y = sk · H + viewing_public_key: BytesN<64>, // PVK = vk · H + spendable_balance: BytesN<64>, // C_spend: single Pedersen commitment + receiving_balance: BytesN<64>, // C_receive: single Pedersen commitment + auditor_id: u32, +} +``` + +**`spending_key`** + +$Y = sk \cdot H$. Set once at registration. Authorizes all spending operations. + +**`viewing_public_key`** + +$\text{PVK} = vk \cdot H$. Set once at registration. Used by senders for ECDH key agreement. The registration proof enforces derivation from the same $sk$ as $Y$. + +**`spendable_balance`** + +The commitment the owner can spend from. Modified only by owner-authorized operations: transfers out, withdrawals, merge, `set_operator`, `revoke_operator`. Encoded as a single Grumpkin affine point (64 bytes). + + +**`receiving_balance`** + +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. Encoded as a single Grumpkin affine point (64 bytes). + +**`auditor_id`** + +Index into the auditor contract's key store. Set once at registration. Used by the wrapper to fetch the correct auditor public key when building transfer public inputs. For incoming transfers, the recipient's `auditor_id` determines the key under which the transfer amount is encrypted. For outgoing transfers (and operator transfers), the sender's (or owner's) `auditor_id` determines the key under which the transfer amount and post-transfer balance (or allowance) are encrypted. + +### 6.2 Operator Delegation + +Operator delegations are stored in persistent storage, keyed by `(owner, operator)`: + +```rust +OperatorDelegation { + allowance_commitment: BytesN<64>, // Single Pedersen commitment + encrypted_allowance: BytesN<32>, // Poseidon-encrypted allowance scalar + escrowed_dvk: BytesN<64>, // ECDH escrow of dvk_i under operator key + allowance_salt: BytesN<32>, + expiration_ledger: u32, +} +``` + +**`allowance_commitment`** + +The operator's remaining escrowed allowance, a single Pedersen commitment: $C_a = \text{Com}(v_a, r_a)$ where $r_a = \text{Poseidon}(\delta_{\text{allow\\\_r}}, dvk_i, \sigma_a)$. One Grumpkin point (64 bytes). + +**`encrypted_allowance`** + +Poseidon-encrypted allowance scalar: $\tilde{a} = v_a + \text{Poseidon}(\delta_{\text{enc\\\_allow}}, dvk_i, \sigma_a)$. Enables the operator (who holds $dvk_i$ via `escrowed_dvk`) to read the current allowance without DLP when constructing an `OperatorTransfer` witness. The owner can also read it via $vk \rightarrow dvk_i$. The auditor does not consume this field; allowance visibility for the auditor is provided by the per-event ciphertexts (Section 8.5). + +**`escrowed_dvk`** + +$dvk_i$ encrypted under the operator's spending key via ECDH. (64 bytes) + +**`allowance_salt`** + +Per-delegation salt for allowance randomness derivation, encoded as `BytesN<32>` (canonical $\mathbb{F}_r$ representative). $\sigma_a$ is sampled by the rejection sampling procedure of §2.2 (same as $\sigma$) and is the sole freshness input to all allowance Poseidon derivations. Set by the owner at `set_operator` and replaced by the operator on every `confidential_transfer_from` (the operator samples a fresh `new_allowance_salt` and that becomes the stored value alongside the updated `allowance_commitment`). The salt is bound to the current commitment: when the commitment changes, the salt changes with it. It is stored on-chain so the owner can decrypt the allowance at revocation without depending on event history. + +**Dual role.** In operator transfers, $\sigma_a$ also serves as the nonce for the recipient ECDH encryption (O7, O9) and the auditor channel sponges (O\_a2 and O\_a6, which absorb $\sigma_a$ alongside the channel shared scalar). This is safe because ECDH confidentiality derives from the shared secret $S.x$ (or $S_{a,r}.x$, $S_{a,s}.x$), not from $\sigma_a$ being secret. However, this couples the allowance salt to the transfer event: the event must emit $\sigma_a$ so that the recipient and auditor can decrypt. Any change to how the salt is stored or exposed must preserve this invariant. + +**`expiration_ledger`** + +Ledger sequence number after which the delegation is no longer valid. Checked on every `confidential_transfer_from`. The delegation persists in storage until explicitly revoked (if it were in temporary storage automatic cleanup would destroy escrowed funds). + +**Single-slot semantics.** The `(owner, operator)` slot holds at most one delegation. `set_operator` (Section 7.7) reverts if a delegation already exists for that pair, regardless of whether the existing delegation is past `expiration_ledger`. Expiry only prevents the operator from spending; the escrowed value persists on-chain until `revoke_operator` (Section 7.9) folds it back into the owner's spendable balance. Re-delegating to the same operator therefore requires the sequence: `revoke_operator` then `set_operator`. This rule is what keeps the balance-conservation invariant (Section 9.3) ranging cleanly over stored delegations: every delegation is either active, expired-pending-revoke, or absent, and the escrowed value is never silently dropped. + +--- + +## 7. Operations + +### 7.1 Public Input Sources + +UltraHonk verifies the relation between a proof and its public-input vector. The verifier sees only field elements -- it has no knowledge of which account, wrapper, or auditor those values are supposed to describe. Binding each public input to the correct provenance is the wrapper's responsibility. If the wrapper takes a value that should come from trusted state (e.g. the sender's `spending_key`) and instead reads it from caller-controlled invocation inputs, a soundly proven statement can verify for the wrong account. + +Each operation below lists, for every public input, where the wrapper loads it from -- persistent account storage, the delegation entry, the wrapper's own contract address, an auditor-contract lookup, an invocation argument, or a prover-supplied value that the circuit binds. + +**Trust-boundary rule.** Public inputs that derive from trusted state (account storage, delegation storage, the current contract address, or auditor-contract lookups) MUST be loaded by the wrapper itself. The wrapper MUST NOT accept these values from the caller's `data` payload. Only invocation arguments (which are bound under `require_auth()` per §11.1) and prover-supplied values (which the circuit binds to its constraints) may originate from the caller. Violating this rule breaks soundness even with a perfectly sound circuit. + +### 7.2 Registration + +An account provides a Grumpkin spending key $Y$, a public viewing key $\text{PVK}$, and a chosen `auditor_id`, accompanied by a proof of key well-formedness. + +**Circuit constraints (Register):** + +| # | Constraint | +|:--|:---| +| R1 | $Y = sk \cdot H$ (spending key well-formed) | +| R2 | $vk = \text{Poseidon}(\delta_{\text{vk}}, sk, \text{wrap})$ (viewing key correctly derived, binds proof to wrapper) | +| R3 | $\text{PVK} = vk \cdot H$ (public viewing key matches $vk$) | +| R4 | $sk \neq 0$ (rules out $Y = \mathcal{O}$) | +| R5 | $vk \neq 0$ (rules out $\text{PVK} = \mathcal{O}$, which would collapse every incoming-transfer ECDH) | + +**Public inputs:** + +| Input | Notes | +|:---|:---| +| $Y$, $\text{PVK}$ | Prover-supplied; written to `account.spending_key` and `account.viewing_public_key` on success | +| $\text{wrap}$ | Loaded from instance storage; set once at construction (§3.5) | + +**Private witnesses:** $sk$. + +**Post-verification state:** The contract validates that `auditor_id` exists in the auditor contract and points to a valid key, then stores `spending_key`, `viewing_public_key`, `auditor_id`, and initializes `spendable_balance = receiving_balance = ` $\mathcal{O}$. + +### 7.3 Deposit + +Transparent tokens flow from the depositor to the wrapper via `token.transfer(from, self, amount)`. The amount $a$ is public and typed as `i128`. The wrapper checks $a \ge 0$ at the entrypoint and reverts on violation (Section 3.4). The contract then computes the deposit commitment with zero blinding: + +$$C_{\text{dep}} = a \cdot G + 0 \cdot H = a \cdot G$$ + +and adds it to the recipient's receiving balance: + +$$C_{\text{receive}} \leftarrow C_{\text{receive}} + C_{\text{dep}}$$ + +No proof required. The recipient `to` **must** be registered: the receiving-balance update writes into `to`'s `ConfidentialAccount` slot and the wrapper reverts if no slot exists. The depositor `from` does **not** need a registered confidential account; only the SEP-41 `token.transfer(from, self, a)` authorization is required. The recipient's off-chain state updates: $v_{\text{receive}} \mathrel{+}= a$, $r_{\text{receive}} \mathrel{+}= 0$. + +### 7.4 Merge + +The owner folds the receiving balance into the spendable balance. + +**Contract logic (no proof):** + +``` +require account.require_auth() +C_spend ← C_spend + C_receive +C_receive ← O +``` + +**Proposition 1** (Merge correctness). If $C_{\text{spend}} = \text{Com}(v_s, r_s)$ and $C_{\text{receive}} = \text{Com}(v_r, r_r)$ before merge, then after merge $C_{\text{spend}} = \text{Com}(v_s + v_r, r_s + r_r)$ and $C_{\text{receive}} = \mathcal{O} = \text{Com}(0, 0)$. + +*Proof.* By the homomorphic property of Pedersen commitments: +$$C_{\text{spend}} + C_{\text{receive}} = (v_s \cdot G + r_s \cdot H) + (v_r \cdot G + r_r \cdot H) = (v_s + v_r) \cdot G + (r_s + r_r) \cdot H = \text{Com}(v_s + v_r, r_s + r_r)$$ +No value is created or destroyed. $\square$ + +**Owner state update.** The owner knows the opening of the post-merge commitment: $v_{\text{spend}}' = v_s + v_r$, $r_{\text{spend}}' = r_s + r_r$. The owner knows $v_r$ and $r_r$ from processing incoming transfer and deposit events into $W_{\text{receive}}$ (Section 5.2, *Update rules*; the per-transfer derivation is Definition 1 in Section 5.3). The values $v_s$ and $r_s$ are known from the owner's last proof output. + +**Griefing analysis.** Merge requires `account.require_auth()`. No third party can invoke it. Incoming transfers that arrive between proof construction and submission modify only $C_{\text{receive}}$, which is not referenced by spend proofs. Therefore merge is not front-runnable and incoming transfers cannot invalidate spend proofs (Proposition 2, Section 9.1). + +**Encrypted balance.** Merge emits no $\tilde{b}$ (there is no proof to enforce consistency between $\tilde{b}$ and the post-merge $C_{\text{spend}}$). The next owner-initiated proof operation issues a fresh checkpoint. The auditor tracks incoming amounts independently from transfer events. + +### 7.5 Withdrawal + +The owner withdraws a public amount $a$ (typed `i128`) from their spendable balance. The W4 range constraint bounds $a$ at $2^{127}$ in-circuit; the wrapper additionally checks $a \ge 0$ at the entrypoint (Section 3.4). + +**Circuit constraints (Withdraw):** + +| # | Constraint | +|:--|:---| +| W1 | $Y = sk \cdot H$ (owner key ownership) | +| W2 | $vk = \text{Poseidon}(\delta_{\text{vk}}, sk, \text{wrap})$ (binds proof to wrapper) | +| W3 | The prover knows the opening $(v, r)$ of $C_{\text{spend}}$: $C_{\text{spend}} = v \cdot G + r \cdot H$ | +| W4 | $v \in [0, 2^{127})$, $a \in [0, 2^{127})$, $v - a \in [0, 2^{127})$ (range validity, Section 2.6) | +| W5 | $r' = \text{Poseidon}(\delta_{\text{spend\\\_r}}, vk, \sigma)$ (deterministic randomness for new balance) | +| W6 | $C_{\text{spend}}' = (v - a) \cdot G + r' \cdot H$ (new spendable commitment) | +| W7 | $\tilde{b} = (v - a) + \text{Poseidon}(\delta_{\text{enc\\\_bal}}, vk, \sigma)$ (encrypted balance scalar) | +| W8 | $r_e \neq 0$ (rules out $R_e = \mathcal{O}$ and $S_{a,s} = \mathcal{O}$, which would reduce $m_b$ to a constant function of $\sigma$) | +| W\_a1 | $R_e = r_e \cdot H$ (ephemeral key for auditor ECDH) | +| W\_a2 | $S_{a,s} = r_e \cdot K_{\text{aud,s}}$ (sender-auditor ECDH shared secret) | +| W\_a3 | $m_b = \text{SpongeSqueeze}_1(\delta_{\text{aud\\\_s}}, S_{a,s}.x, \sigma)$ (sender-auditor channel sponge, single squeeze) | +| W\_a4 | $\tilde{b}_{\text{aud,s}} = (v - a) + m_b$ (sender-auditor encrypted balance checkpoint) | + +**Public inputs (15 fields):** + +| Input | Notes | +|:---|:---| +| $C_{\text{spend}}$ | Loaded from `from.spendable_balance` | +| $Y$ | Loaded from `from.spending_key` | +| $\text{wrap}$ | Loaded from instance storage; set once at construction (§3.5) | +| $K_{\text{aud,s}}$ | Fetched from the auditor contract using `from.auditor_id` | +| $a$ | Public withdrawal amount from invocation inputs | +| $C_{\text{spend}}'$, $\sigma$, $\tilde{b}$, $R_e$, $\tilde{b}_{\text{aud,s}}$ | Prover-supplied; $C_{\text{spend}}'$ written to `from.spendable_balance`, the rest emitted in event | + +$\text{to}$ is bound under `from.require_auth()` and does not appear in the proof. + +**Private witnesses:** $sk$, $vk$, $v$, $r$, $r_e$. + +**Post-verification:** The contract verifies the proof, sets `from`.`spendable_balance` $= C_{\text{spend}}'$, and calls `token.transfer(self, to, a)`. Emits event with $(R_e, \sigma, \tilde{b}, \tilde{b}_{\text{aud,s}})$. + +### 7.6 Confidential Transfer + +The sender (account $A$, spending key $sk_A$) transfers a hidden amount $v_{\text{tx}}$ to recipient $B$ (public viewing key $\text{PVK}_B$). + +**Sender computation:** + +1. Sample ephemeral scalar $r_e \in \mathbb{F}_r$ via the rejection sampling procedure (§2.2); sample $\sigma \in \mathbb{F}_r$ via the same procedure +2. Compute $R_e = r_e \cdot H$ +3. Compute $S = r_e \cdot \text{PVK}_B$, extract $s = S.x$ +4. Derive transfer blinding: $r_{\text{tx}} = \text{Poseidon}(\delta_{\text{tx\\\_blind}}, s, \sigma)$ +5. Derive encrypted amount: $\tilde{v} = v_{\text{tx}} + \text{Poseidon}(\delta_{\text{tx\\\_amount}}, s, \sigma)$ +6. Compute transfer commitment: $C_{\text{tx}} = v_{\text{tx}} \cdot G + r_{\text{tx}} \cdot H$ +7. Compute new spendable commitment with deterministic randomness: + - $r_A' = \text{Poseidon}(\delta_{\text{spend\\\_r}}, vk_A, \sigma)$ + - $C_{\text{spend}}' = (v_A - v_{\text{tx}}) \cdot G + r_A' \cdot H$ +8. Compute encrypted balance scalar: $\tilde{b} = (v_A - v_{\text{tx}}) + \text{Poseidon}(\delta_{\text{enc\\\_bal}}, vk_A, \sigma)$ +9. Compute recipient-auditor ECDH shared secret: $S_{a,r} = r_e \cdot K_{\text{aud,r}}$, extract $s_{a,r} = S_{a,r}.x$ +10. Squeeze recipient-auditor channel masks: $(m_{v,r}, m_{r,r}) = \text{SpongeSqueeze}_2(\delta_{\text{aud\\\_r}}, s_{a,r}, \sigma)$ +11. Compute recipient-auditor ciphertexts: $\tilde{v}_{\text{aud,r}} = v_{\text{tx}} + m_{v,r}$ and $\tilde{r}_{\text{aud,r}} = r_{\text{tx}} + m_{r,r}$ +12. Compute sender-auditor ECDH shared secret: $S_{a,s} = r_e \cdot K_{\text{aud,s}}$, extract $s_{a,s} = S_{a,s}.x$ +13. Squeeze sender-auditor channel masks: $(m_{v,s}, m_{b,s}) = \text{SpongeSqueeze}_2(\delta_{\text{aud\\\_s}}, s_{a,s}, \sigma)$ +14. Compute sender-auditor ciphertexts: $\tilde{v}_{\text{aud,s}} = v_{\text{tx}} + m_{v,s}$ and $\tilde{b}_{\text{aud,s}} = (v_A - v_{\text{tx}}) + m_{b,s}$ + +**Circuit constraints (Transfer):** + +| # | Constraint | +|:--|:---| +| T1 | $Y_A = sk_A \cdot H$ (sender key ownership) | +| T2 | $vk_A = \text{Poseidon}(\delta_{\text{vk}}, sk_A, \text{wrap})$ (binds proof to wrapper) | +| T3 | Prover knows opening $(v_A, r_A)$ of $C_{\text{spend}}^A$ | +| T4 | $v_A \in [0, 2^{127})$, $v_{\text{tx}} \in [0, 2^{127})$, $v_A - v_{\text{tx}} \in [0, 2^{127})$ (range validity, Section 2.6) | +| T5 | $S = r_e \cdot \text{PVK}_B$ (ECDH correctly computed) | +| T6 | $R_e = r_e \cdot H$ (ephemeral key well-formed) | +| T7 | $r_{\text{tx}} = \text{Poseidon}(\delta_{\text{tx\\\_blind}}, S.x, \sigma)$ (blinding correctly derived) | +| T8 | $C_{\text{tx}} = v_{\text{tx}} \cdot G + r_{\text{tx}} \cdot H$ (transfer commitment well-formed) | +| T9 | $\tilde{v} = v_{\text{tx}} + \text{Poseidon}(\delta_{\text{tx\\\_amount}}, S.x, \sigma)$ (encrypted amount correct) | +| T10 | $r_A' = \text{Poseidon}(\delta_{\text{spend\\\_r}}, vk_A, \sigma)$ (deterministic randomness) | +| T11 | $C_{\text{spend}}' = (v_A - v_{\text{tx}}) \cdot G + r_A' \cdot H$ (new sender balance) | +| T12 | $\tilde{b} = (v_A - v_{\text{tx}}) + \text{Poseidon}(\delta_{\text{enc\\\_bal}}, vk_A, \sigma)$ (encrypted balance scalar) | +| T13 | $r_e \neq 0$ (rules out $R_e = \mathcal{O}$ and $S, S_{a,r}, S_{a,s} = \mathcal{O}$; otherwise every ECDH mask in this transfer collapses to a constant function of $\sigma$) | +| T\_a1 | $S_{a,r} = r_e \cdot K_{\text{aud,r}}$ (recipient-auditor ECDH shared secret, reuses ephemeral scalar) | +| T\_a2 | $(m_{v,r}, m_{r,r}) = \text{SpongeSqueeze}_2(\delta_{\text{aud\\\_r}}, S_{a,r}.x, \sigma)$ (recipient-auditor channel masks) | +| T\_a3 | $\tilde{v}_{\text{aud,r}} = v_{\text{tx}} + m_{v,r}$ (recipient-auditor encrypted transfer amount) | +| T\_a4 | $\tilde{r}_{\text{aud,r}} = r_{\text{tx}} + m_{r,r}$ (recipient-auditor encrypted transfer randomness, enables Pedersen-opening reconstruction of $C_{\text{receive}}$, see Section 8.1) | +| T\_a5 | $S_{a,s} = r_e \cdot K_{\text{aud,s}}$ (sender-auditor ECDH shared secret, reuses ephemeral scalar) | +| T\_a6 | $(m_{v,s}, m_{b,s}) = \text{SpongeSqueeze}_2(\delta_{\text{aud\\\_s}}, S_{a,s}.x, \sigma)$ (sender-auditor channel masks) | +| T\_a7 | $\tilde{v}_{\text{aud,s}} = v_{\text{tx}} + m_{v,s}$ (sender-auditor encrypted transfer amount) | +| T\_a8 | $\tilde{b}_{\text{aud,s}} = (v_A - v_{\text{tx}}) + m_{b,s}$ (sender-auditor encrypted balance checkpoint) | + +**Public inputs (24 fields, counting each Grumpkin point as two $\mathbb{F}_r$ coordinates):** + +| Input | Notes | +|:---|:---| +| $C_{\text{spend}}^A$ | Loaded from sender's `spendable_balance` | +| $Y_A$ | Loaded from sender's `spending_key` | +| $\text{PVK}_B$ | Loaded from recipient's `viewing_public_key`. Recipient must be registered. | +| $\text{wrap}$ | Loaded from instance storage; set once at construction (§3.5) | +| $K_{\text{aud,r}}$ | Fetched from the auditor contract using recipient's `auditor_id` | +| $K_{\text{aud,s}}$ | Fetched from the auditor contract using sender's `auditor_id` | +| $C_{\text{spend}}'$, $C_{\text{tx}}$, $R_e$, $\tilde{v}$, $\tilde{b}$, $\sigma$, $\tilde{v}_{\text{aud,r}}$, $\tilde{r}_{\text{aud,r}}$, $\tilde{v}_{\text{aud,s}}$, $\tilde{b}_{\text{aud,s}}$ | Prover-supplied; $C_{\text{spend}}'$ written to sender's `spendable_balance`, $C_{\text{tx}}$ added to recipient's `receiving_balance`, the rest emitted in event | + +**Private witnesses:** $sk_A$, $vk_A$, $v_A$, $r_A$, $v_{\text{tx}}$, $r_e$. + +**Post-verification:** The contract verifies the proof, then: +- Sets $A$`.spendable_balance` $= C_{\text{spend}}'$ +- Adds to recipient: $B$`.receiving_balance` $\mathrel{+}= C_{\text{tx}}$ +- Emits event with $(R_e, \tilde{v}, \sigma, \tilde{b}, \tilde{v}_{\text{aud,r}}, \tilde{r}_{\text{aud,r}}, \tilde{v}_{\text{aud,s}}, \tilde{b}_{\text{aud,s}})$ + +**Recipient processing.** Upon observing the event, the recipient computes $S = vk \cdot R_e$, derives amount and blinding. The decryption flow is independent of whether the sender was the owner or an operator. + +### 7.7 Set Operator + +The owner locks funds from their spendable balance into a per-operator escrow. The operator must be a registered account in the wrapper, so that $Y_{\text{op}}$ (needed for $dvk_i$ escrow) can be looked up from the operator's stored `spending_key`. + +**Circuit constraints (SetOperator):** + +| # | Constraint | +|:--|:---| +| S1 | $Y = sk \cdot H$ (owner key ownership) | +| S2 | $vk = \text{Poseidon}(\delta_{\text{vk}}, sk, \text{wrap})$ (binds proof to wrapper) | +| S3 | Prover knows opening $(v, r)$ of $C_{\text{spend}}$ | +| S4 | $v \in [0, 2^{127})$, $v_a \in [0, 2^{127})$, $v - v_a \in [0, 2^{127})$ (range validity, Section 2.6) | +| S5 | $dvk_i = \text{Poseidon}(\delta_{\text{dvk}}, vk, \text{op}_i)$ (delegation key derivation; wrapper-bound via $vk$) | +| S6 | $r_a = \text{Poseidon}(\delta_{\text{allow\\\_r}}, dvk_i, \sigma_a)$ (allowance blinding) | +| S7 | $C_a = v_a \cdot G + r_a \cdot H$ (allowance commitment) | +| S8 | $\tilde{a} = v_a + \text{Poseidon}(\delta_{\text{enc\\\_allow}}, dvk_i, \sigma_a)$ (encrypted allowance) | +| S9 | $r' = \text{Poseidon}(\delta_{\text{spend\\\_r}}, vk, \sigma)$ (new balance randomness) | +| S10 | $C_{\text{spend}}' = (v - v_a) \cdot G + r' \cdot H$ (new spendable balance) | +| S11 | $\tilde{b} = (v - v_a) + \text{Poseidon}(\delta_{\text{enc\\\_bal}}, vk, \sigma)$ (encrypted balance) | +| S12 | Escrowed $dvk_i$ correctly encrypts under $Y_{\text{op}}$ via ECDH | +| S13 | $r_e \neq 0$ (rules out $R_e = \mathcal{O}$ and $S_{a,s} = \mathcal{O}$; the same $r_e$ is reused for the $dvk_i$ escrow ECDH in Section 7.11, so this also rules out a trivial escrow shared secret) | +| S\_a1 | $R_e = r_e \cdot H$ (ephemeral key for auditor ECDH) | +| S\_a2 | $S_{a,s} = r_e \cdot K_{\text{aud,s}}$ (owner-auditor ECDH shared secret) | +| S\_a3 | $(m_v, m_b) = \text{SpongeSqueeze}_2(\delta_{\text{aud\\\_s}}, S_{a,s}.x, \sigma)$ (owner-auditor channel masks) | +| S\_a4 | $\tilde{v}_{\text{aud,s}} = v_a + m_v$ (owner-auditor encrypted escrow amount) | +| S\_a5 | $\tilde{b}_{\text{aud,s}} = (v - v_a) + m_b$ (owner-auditor encrypted balance checkpoint) | + +**Public inputs (24 fields):** + +| Input | Notes | +|:---|:---| +| $C_{\text{spend}}$ | Loaded from owner's `spendable_balance` | +| $Y$ | Loaded from owner's `spending_key` | +| $Y_{\text{op}}$ | Loaded from operator account's `spending_key`. Operator must be registered. | +| $\text{op}_i$ | $\text{address\\\_to\\\_field}$(`operator` argument), computed per-call by the wrapper (§2.7) | +| $\text{wrap}$ | Loaded from instance storage; set once at construction (§3.5) | +| $K_{\text{aud,s}}$ | Fetched from the auditor contract using owner's `auditor_id` | +| $C_{\text{spend}}'$, $C_a$, escrowed\_dvk, $\tilde{b}$, $\tilde{a}$, $\sigma$, $\sigma_a$, $R_e$, $\tilde{v}_{\text{aud,s}}$, $\tilde{b}_{\text{aud,s}}$ | Prover-supplied; $C_{\text{spend}}'$ written to owner's `spendable_balance`, the delegation fields written to storage, the rest emitted in event | + +**Private witnesses:** $sk$, $vk$, $v$, $r$, $v_a$, $r_e$. + +**Post-verification:** The contract verifies the proof, sets `spendable_balance` $= C_{\text{spend}}'$ and stores the `OperatorDelegation`. Emits event with $(R_e, \sigma, \tilde{b}, \tilde{v}_{\text{aud,s}}, \tilde{b}_{\text{aud,s}})$. + +### 7.8 Operator Transfer + +The operator transfers from the owner's escrowed allowance to a recipient. + +**Circuit constraints (OperatorTransfer):** + +| # | Constraint | +|:--|:---| +| O1 | $Y_{\text{op}} = sk_{\text{op}} \cdot H$ (operator key ownership) | +| O2 | Prover knows $dvk_i$ and the opening $(v_a, r_a)$ of $C_a$ | +| O3 | $r_a = \text{Poseidon}(\delta_{\text{allow\\\_r}}, dvk_i, \sigma_a)$ (allowance randomness matches stored state) | +| O4 | $v_a \in [0, 2^{127})$, $v_{\text{tx}} \in [0, 2^{127})$, $v_a - v_{\text{tx}} \in [0, 2^{127})$ (range validity, Section 2.6) | +| O5 | $S = r_e \cdot \text{PVK}_{\text{recipient}}$ (ECDH for recipient) | +| O6 | $R_e = r_e \cdot H$ | +| O7 | $r_{\text{tx}} = \text{Poseidon}(\delta_{\text{tx\\\_blind}}, S.x, \sigma_a)$ (transfer blinding) | +| O8 | $C_{\text{tx}} = v_{\text{tx}} \cdot G + r_{\text{tx}} \cdot H$ | +| O9 | $\tilde{v} = v_{\text{tx}} + \text{Poseidon}(\delta_{\text{tx\\\_amount}}, S.x, \sigma_a)$ (encrypted amount) | +| O10 | $r_a' = \text{Poseidon}(\delta_{\text{allow\\\_r}}, dvk_i, \sigma_a')$ (new allowance randomness) | +| O11 | $C_a' = (v_a - v_{\text{tx}}) \cdot G + r_a' \cdot H$ (new allowance) | +| O12 | $\tilde{a}' = (v_a - v_{\text{tx}}) + \text{Poseidon}(\delta_{\text{enc\\\_allow}}, dvk_i, \sigma_a')$ (encrypted allowance) | +| O13 | $r_e \neq 0$ (rules out $R_e = \mathcal{O}$ and $S, S_{a,r}, S_{a,s} = \mathcal{O}$; otherwise every ECDH mask in this transfer collapses to a constant function of $\sigma_a$) | +| O\_a1 | $S_{a,r} = r_e \cdot K_{\text{aud,r}}$ (recipient-auditor ECDH shared secret, reuses ephemeral scalar) | +| O\_a2 | $(m_{v,r}, m_{r,r}) = \text{SpongeSqueeze}_2(\delta_{\text{aud\\\_r}}, S_{a,r}.x, \sigma_a)$ (recipient-auditor channel masks) | +| O\_a3 | $\tilde{v}_{\text{aud,r}} = v_{\text{tx}} + m_{v,r}$ (recipient-auditor encrypted transfer amount) | +| O\_a4 | $\tilde{r}_{\text{aud,r}} = r_{\text{tx}} + m_{r,r}$ (recipient-auditor encrypted transfer randomness, enables Pedersen-opening reconstruction of $C_{\text{receive}}$, see Section 8.1) | +| O\_a5 | $S_{a,s} = r_e \cdot K_{\text{aud,s}}$ (owner-auditor ECDH shared secret, reuses ephemeral scalar) | +| O\_a6 | $(m_{v,s}, m_{a,s}) = \text{SpongeSqueeze}_2(\delta_{\text{aud\\\_s}}, S_{a,s}.x, \sigma_a)$ (owner-auditor channel masks) | +| O\_a7 | $\tilde{v}_{\text{aud,s}} = v_{\text{tx}} + m_{v,s}$ (owner-auditor encrypted transfer amount) | +| O\_a8 | $\tilde{a}_{\text{aud,s}} = (v_a - v_{\text{tx}}) + m_{a,s}$ (owner-auditor encrypted post-transfer allowance) | + +**Public inputs (24 fields):** + +| Input | Notes | +|:---|:---| +| $C_a$, $\sigma_a$ | Loaded from the `(from, operator)` delegation entry | +| $Y_{\text{op}}$ | Loaded from operator's `spending_key`; matches the auth principal | +| $\text{PVK}_{\text{recipient}}$ | Loaded from recipient's `viewing_public_key` | +| $K_{\text{aud,r}}$ | Fetched from the auditor contract using recipient's `auditor_id` | +| $K_{\text{aud,s}}$ | Fetched from the auditor contract using **owner's** `auditor_id`, not operator's. The visibility model points balance- and allowance-checkpoint ciphertexts at the funds' owner. | +| $C_a'$, $C_{\text{tx}}$, $R_e$, $\tilde{v}$, $\tilde{a}'$, $\sigma_a'$, $\tilde{v}_{\text{aud,r}}$, $\tilde{r}_{\text{aud,r}}$, $\tilde{v}_{\text{aud,s}}$, $\tilde{a}_{\text{aud,s}}$ | Prover-supplied; allowance fields written to delegation storage, $C_{\text{tx}}$ added to recipient's `receiving_balance`, the rest emitted in event | + +**Private witnesses:** $sk_{\text{op}}$, $dvk_i$, $v_a$, $r_a$ (single-limb $\mathbb{F}_r$; pinned by O3 to $\text{Poseidon}(\delta_{\text{allow\\\_r}}, dvk_i, \sigma_a)$), $v_{\text{tx}}$, $r_e$. + +**Post-verification:** The contract checks `ledger.sequence() <= expiration_ledger`, updates `allowance_commitment`, `encrypted_allowance`, stores `new_allowance_salt`, and adds $C_{\text{tx}}$ to the recipient's `receiving_balance`. Emits event with $(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}})$. + +**Recipient uniformity.** The recipient processes the incoming transfer identically to a direct transfer: compute $S = vk \cdot R_e$, derive amount and blinding. The decryption flow is independent of whether the sender was the owner or an operator. + +**Wrapper binding.** Unlike owner-initiated circuits, the OperatorTransfer circuit does not constrain the $vk$ derivation (the operator has no access to the owner's $sk$). Wrapper binding is instead inherited indirectly through the allowance commitment chain: the SetOperator circuit derives $dvk_i$ from the wrapper-specific $vk$ (S2, S5), which determines $r_a$ (S6) and thus $C_a$ (S7). The OperatorTransfer circuit verifies $dvk_i$ against $C_a$ via $\sigma_a$ (O3). Since $C_a$ is a public input and was constructed with wrapper-specific randomness, a proof generated against one wrapper's $C_a$ cannot verify against another's. + +### 7.9 Revoke Operator + +The owner reclaims the remaining escrowed allowance. + +**Circuit constraints (RevokeOperator):** + +| # | Constraint | +|:--|:---| +| V1 | $Y = sk \cdot H$ (owner key ownership) | +| V2 | $vk = \text{Poseidon}(\delta_{\text{vk}}, sk, \text{wrap})$ (binds proof to wrapper) | +| V3 | $dvk_i = \text{Poseidon}(\delta_{\text{dvk}}, vk, \text{op}_i)$ | +| V4 | Prover knows opening $(v_a, r_a)$ of $C_a$, with $r_a = \text{Poseidon}(\delta_{\text{allow\\\_r}}, dvk_i, \sigma_a)$ (allowance randomness matches stored state, mirrors O3) | +| V5 | Prover knows opening $(v_s, r_s)$ of $C_{\text{spend}}$ | +| V6 | $r' = \text{Poseidon}(\delta_{\text{spend\\\_r}}, vk, \sigma)$ | +| V7 | $C_{\text{spend}}' = (v_s + v_a) \cdot G + r' \cdot H$ | +| V8 | $\tilde{b} = (v_s + v_a) + \text{Poseidon}(\delta_{\text{enc\\\_bal}}, vk, \sigma)$ | +| V9 | $v_s \in [0, 2^{127})$, $v_a \in [0, 2^{127})$, $v_s + v_a \in [0, 2^{127})$ (range validity, Section 2.6) | +| V10 | $r_e \neq 0$ (rules out $R_e = \mathcal{O}$ and $S_{a,s} = \mathcal{O}$, which would reduce $m_v$ and $m_b$ to constant functions of $\sigma$) | +| V\_a1 | $R_e = r_e \cdot H$ (ephemeral key for auditor ECDH) | +| V\_a2 | $S_{a,s} = r_e \cdot K_{\text{aud,s}}$ (owner-auditor ECDH shared secret) | +| V\_a3 | $(m_v, m_b) = \text{SpongeSqueeze}_2(\delta_{\text{aud\\\_s}}, S_{a,s}.x, \sigma)$ (owner-auditor channel masks) | +| V\_a4 | $\tilde{v}_{\text{aud,s}} = v_a + m_v$ (owner-auditor encrypted reclaimed amount) | +| V\_a5 | $\tilde{b}_{\text{aud,s}} = (v_s + v_a) + m_b$ (owner-auditor encrypted balance checkpoint) | + +**Public inputs (19 fields):** + +| Input | Notes | +|:---|:---| +| $C_{\text{spend}}$ | Loaded from owner's `spendable_balance` | +| $C_a$, $\sigma_a$ | Loaded from the `(account, operator)` delegation entry | +| $Y$ | Loaded from owner's `spending_key` | +| $\text{op}_i$ | $\text{address\\\_to\\\_field}$(`operator` argument), computed per-call by the wrapper (§2.7) | +| $\text{wrap}$ | Loaded from instance storage; set once at construction (§3.5) | +| $K_{\text{aud,s}}$ | Fetched from the auditor contract using owner's `auditor_id` | +| $C_{\text{spend}}'$, $\tilde{b}$, $\sigma$, $R_e$, $\tilde{v}_{\text{aud,s}}$, $\tilde{b}_{\text{aud,s}}$ | Prover-supplied; $C_{\text{spend}}'$ written to owner's `spendable_balance`, delegation entry deleted, the rest emitted in event | + +**Private witnesses:** $sk$, $vk$, $dvk_i$, $v_a$, $r_a$, $v_s$, $r_s$ (input spendable-balance blinding, encoded as a single $\mathbb{F}_r$ `Field`; see §10.4 *Post-merge witness availability* for the acknowledged $2^{-127}$-per-merge case affecting $r_s$), $r_e$. + +**Post-verification:** The contract verifies the proof, sets `spendable_balance` $= C_{\text{spend}}'$ and deletes the delegation. Emits event with $(R_e, \sigma, \tilde{b}, \tilde{v}_{\text{aud,s}}, \tilde{b}_{\text{aud,s}})$. + +### 7.10 Owner Operations with Active Operators + +Owner transfers, withdrawals, and merges proceed identically to the no-operator case. Operator allowances are independently escrowed - no synchronization is needed. The owner's spendable balance and operator allowances are fully isolated. + +### 7.11 Delegation Key Escrow + +At `set_operator`, the owner escrows $dvk_i$ to the operator on-chain via ECDH, eliminating off-chain key sharing: + +1. Owner picks ephemeral $r_e$ (reused from the `set_operator` proof's outer ECDH; see §5.3 Why reusing $r_e$ is safe) and computes $R = r_e \cdot H$. +2. Shared secret: $s = (r_e \cdot Y_{\text{op}}).x$ +3. Escrowed key: $\text{escrowed\\\_dvk} = (R.x, \; \text{Poseidon}(\delta_{\text{esc\\\_dvk}}, s, \text{op}_i) + dvk_i)$ + +**Encoding.** `escrowed_dvk` is a `BytesN<64>` consisting of two 32-byte $\mathbb{F}_r$ representatives: `R_x` (the $x$-coordinate of $R$) followed by `dvk_cipher` (the masked $dvk_i$). $R.y$ is **not** stored. This is sound because ECDH on Grumpkin recovers only the $x$-coordinate of the shared secret: $\pm R$ both have $x = R.x$, and $sk_{\text{op}} \cdot R$ and $sk_{\text{op}} \cdot (-R)$ are inverse points with the same $x$-coordinate. The operator reconstructs the curve point by solving $y^2 = R.x^3 - 17$ in $\mathbb{F}_r$, picks either root, and proceeds; both choices produce the same $s = (sk_{\text{op}} \cdot R).x$ and therefore the same Poseidon mask. + +The operator decrypts using $sk_{\text{op}}$. The `set_operator` proof enforces escrow correctness via constraint S12, which expands to three sub-constraints over the prover-supplied `escrowed_dvk = (R_x, dvk_cipher)`: + +- $R_x = (r_e \cdot H).x$ +- $s_{\text{esc}} = (r_e \cdot Y_{\text{op}}).x$ +- $\text{dvk\\\_cipher} = \text{Poseidon}(\delta_{\text{esc\\\_dvk}}, s_{\text{esc}}, \text{op}_i) + dvk_i$ + +The $r_e$ here is the same scalar S\_a1 commits to ($R_e = r_e \cdot H$), so the escrow's $R_x$ and the auditor channel's $R_e.x$ are forced equal. + +### 7.12 Expiry and Revert Safety + +Delegations use persistent storage and persist until explicitly revoked. `expiration_ledger` is checked on every operator transfer. Allowance randomness includes `allowance_salt` to prevent deterministic-randomness reuse after reverted transactions. + +--- + +## 8. Auditing + +### 8.1 Per-Transfer Auditor Ciphertexts + +Each confidential transfer produces ciphertexts under two auditor keys via ECDH, using the same ephemeral scalar $r_e$ used for recipient ECDH. Each auditor channel runs Poseidon2 in sponge mode (Section 2.5), absorbing the channel's domain tag, the ECDH shared scalar, and $\sigma$, and squeezing two masks per call. + +**Recipient's auditor** ($K_{\text{aud,r}}$, from the recipient's `auditor_id`) receives the transfer amount and the per-transfer Pedersen randomness: + +$$S_{a,r} = r_e \cdot K_{\text{aud,r}}, \qquad s_{a,r} = S_{a,r}.x$$ +$$(m_{v,r}, m_{r,r}) = \text{SpongeSqueeze}_2(\delta_{\text{aud\\\_r}}, s_{a,r}, \sigma)$$ +$$\tilde{v}_{\text{aud,r}} = v_{\text{tx}} + m_{v,r}, \qquad \tilde{r}_{\text{aud,r}} = r_{\text{tx}} + m_{r,r}$$ + +**Sender's auditor** ($K_{\text{aud,s}}$, from the sender's `auditor_id`) receives the transfer amount and the sender's post-transfer balance: + +$$S_{a,s} = r_e \cdot K_{\text{aud,s}}, \qquad s_{a,s} = S_{a,s}.x$$ +$$(m_{v,s}, m_{b,s}) = \text{SpongeSqueeze}_2(\delta_{\text{aud\\\_s}}, s_{a,s}, \sigma)$$ +$$\tilde{v}_{\text{aud,s}} = v_{\text{tx}} + m_{v,s}, \qquad \tilde{b}_{\text{aud,s}} = (v_A - v_{\text{tx}}) + m_{b,s}$$ + +The transfer circuit (constraints T\_a1--T\_a8) enforces correct computation. The wrapper fetches both auditor keys from the auditor contract using the respective account `auditor_id` fields; neither the sender nor the recipient can substitute a different key. + +Each auditor decrypts using their secret key $k$. For example, the sender's auditor: + +$$S_{a,s} = k \cdot R_e, \qquad s_{a,s} = S_{a,s}.x$$ +$$(m_{v,s}, m_{b,s}) = \text{SpongeSqueeze}_2(\delta_{\text{aud\\\_s}}, s_{a,s}, \sigma)$$ +$$v_{\text{tx}} = \tilde{v}_{\text{aud,s}} - m_{v,s}, \qquad v_{\text{new}} = \tilde{b}_{\text{aud,s}} - m_{b,s}$$ + +where $R_e$ and $\sigma$ are published in the Transfer event. The recipient's auditor follows the same pattern with $\delta_{\text{aud\\\_r}}$ to recover the pair $(v_{\text{tx}}, r_{\text{tx}})$. + +**Recipient-auditor opening capability.** Because the recipient-auditor recovers $r_{\text{tx}}$ for every inbound transfer, and because deposits add to `receiving_balance` with $r = 0$ (Section 7.3), the recipient-auditor can reconstruct the full Pedersen opening of $C_{\text{receive}}$ between merges: + +$$v_r = \sum_i v_{\text{tx},i} + \sum_j a_j, \qquad r_r = \sum_i r_{\text{tx},i}$$ + +where $i$ ranges over inbound transfers and operator-transfers since the last merge and $j$ ranges over deposits. This is a full Pedersen *opening* of $C_{\text{receive}}$: both the value and the blinding are reconstructed by the auditor. + +The opening capability does not extend to $C_{\text{spend}}$. The auditor knows the *value* $v_s$ at every spend boundary via $\tilde{b}_{\text{aud,s}}$ (Section 5.5), and can extend that with the known $v_r$ contribution at each merge. This bounded opening is what enables the clawback flow specified in [COMPLIANCE.md](./COMPLIANCE.md) §5: the recipient-auditor is the seize-enabling party for inbound flows while $C_{\text{receive}}$ has not yet been merged. After merge the auditor still tracks $C_{\text{spend}}$'s value via $\tilde{b}_{\text{aud,s}}$ updates. + +### 8.2 Auditor Visibility Properties + +**Transfer amounts.** Both auditors see the transfer amount in real time. The recipient's auditor decrypts $v_{\text{tx}}$ from $\tilde{v}_{\text{aud,r}}$; the sender's auditor decrypts it from $\tilde{v}_{\text{aud,s}}$. + +**Balance checkpoints.** The sender's auditor receives an encrypted balance checkpoint at every owner-initiated operation that produces a proof: + +- **Outgoing transfer**: auditor decrypts post-transfer balance $(v_A - v_{\text{tx}})$ from $\tilde{b}_{\text{aud,s}}$ (constraints T\_a5--T\_a8). +- **Withdrawal**: auditor decrypts post-withdrawal balance $(v - a)$ from $\tilde{b}_{\text{aud,s}}$ (constraints W\_a1--W\_a4). The withdrawal amount $a$ is also visible as a public input. +- **Set operator**: auditor decrypts escrowed amount $v_a$ from $\tilde{v}_{\text{aud,s}}$ and post-escrow balance $(v - v_a)$ from $\tilde{b}_{\text{aud,s}}$ (constraints S\_a1--S\_a5). +- **Revoke operator**: auditor decrypts reclaimed amount $v_a$ from $\tilde{v}_{\text{aud,s}}$ and post-reclaim balance $(v_s + v_a)$ from $\tilde{b}_{\text{aud,s}}$ (constraints V\_a1--V\_a5). + +The recipient's auditor does not see the sender's balance in any of these operations. + +**Per-transfer Pedersen randomness (recipient-auditor only).** Beyond the transfer amount, the recipient's auditor also decrypts the per-transfer Pedersen blinding $r_{\text{tx}}$ from $\tilde{r}_{\text{aud,r}}$ on every confidential transfer and operator-transfer (constraints T\_a4 and O\_a4). Combined with $v_{\text{tx}}$ this is a full Pedersen opening of each $C_{\text{tx},i}$ and, by homomorphism, of the recipient's `receiving_balance` $C_{\text{receive}}$ between merges (Section 8.1). The sender's auditor does not see $r_{\text{tx}}$. + +**Key rotation.** When the auditor contract sets a new key under the account's `auditor_id` (§8.3), the new key sees the balance checkpoint at the next owner-initiated operation, with no event replay or bootstrapping required. The balance checkpoint is self-contained: it depends only on the auditor's ECDH secret key and the published $(R_e, \sigma)$. Note that `auditor_id` itself is immutable per account (§6.1); only the key under that `auditor_id` rotates. + +**No viewing-key escrow on the sender side.** The sender-auditor does not hold any account's viewing key, and compromise of a sender-auditor key exposes only per-operation amounts and balance checkpoints from operations that occurred while the compromised key was active. Historical balances under prior keys, and the recipient's `spendable_balance` (whose blinding derives from $vk_A$, not from any auditor channel), remain opaque. + +**Recipient-side opening capability.** The recipient-auditor additionally learns the per-transfer Pedersen randomness $r_{\text{tx}}$ for every inbound transfer and operator-transfer. This is capability-equivalent to holding the opening of every $C_{\text{tx},i}$ and, by summation, of $C_{\text{receive}}$. The capability is bounded in two ways: + +- **Forward-only.** Only events emitted while the auditor key was active are decryptable. +- **Receiving-side only (opening).** The full $(v_r, r_r)$ opening covers `receiving_balance`. It does not extend to a full opening of `spendable_balance`, whose blinding $r_s = \text{Poseidon}(\delta_{\text{spend\\\_r}}, vk_A, \sigma)$ depends on $vk_A$ and is not derivable from any auditor key. The auditor still knows the *value* $v_s$ of `spendable_balance` at every spend boundary via $\tilde{b}_{\text{aud,s}}$. + +This is the trust position that supports the clawback flow in [COMPLIANCE.md](./COMPLIANCE.md) §5: the recipient-auditor is the seize-enabling party for inbound flows, while the sender-auditor remains the seize-enabling party for the spendable-balance side via $\tilde{b}_{\text{aud,s}}$. + +### 8.3 Auditor Key Management and Rotation + +The auditor contract stores Grumpkin public keys as full affine points $(x, y)$ indexed by `auditor_id`. The contract validates that every inserted key is canonical, on-curve ($y^2 \equiv x^3 - 17 \pmod{r}$), and non-identity at insertion time (Section 3.1, Section 10.8). Each `auditor_id` MAY maintain a sequence of versions, each carrying its activation ledger. Rotation appends a new entry rather than overwriting the previous one. + +When building public inputs for any operation that produces auditor ciphertexts (transfers, withdrawals, set/revoke operator), the wrapper fetches the relevant auditor keys for the recipient's and/or sender's `auditor_id`. The wrapper passes the full Grumpkin point as a public input; the circuit constrains the ECDH ciphertexts against that exact point. The wrapper and the circuit are version-agnostic: they verify against whichever key the auditor contract currently exposes. + +**In-flight proofs across rotation.** A proof constructed against version $v$ becomes unverifiable the instant the auditor contract activates version $v+1$. The $K_{\text{aud}}$ public input the wrapper fetches at verification no longer matches the value the prover committed to, so UltraHonk verification fails and the invocation **reverts at the proof-verification boundary**. The caller (sender, owner, or operator) reconstructs the proof against the new $K_{\text{aud}}$ and resubmits. The rejection is benign: the wrapper's spendable balance, receiving balance, and delegation state are unchanged by the reverted call, $\sigma$ is freshly sampled on retry (Section 9.6), and an observer cannot correlate the rejected attempt with the resubmission. + +**Auditor's off-chain obligation.** The auditor MUST retain the secret key for every historical version it has issued. To decrypt an event at ledger $L$, the auditor queries the auditor contract for the version of its `auditor_id` whose activation ledger is the largest value not exceeding $L$, then uses the corresponding off-chain secret key against the $R_e$ and $\sigma$ (or $\sigma_a$) emitted in the event. + +### 8.4 Operator Transfer Auditing + +Each operator transfer produces auditor ciphertexts under two keys (constraints O\_a1--O\_a8), following the same dual-auditor sponge model as owner transfers. The recipient's auditor decrypts the transfer amount and the per-transfer Pedersen randomness: + +$$(m_{v,r}, m_{r,r}) = \text{SpongeSqueeze}_2(\delta_{\text{aud\\\_r}}, s_{a,r}, \sigma_a)$$ +$$v_{\text{tx}} = \tilde{v}_{\text{aud,r}} - m_{v,r}, \qquad r_{\text{tx}} = \tilde{r}_{\text{aud,r}} - m_{r,r}$$ + +The owner's auditor decrypts the transfer amount and post-transfer allowance: + +$$(m_{v,s}, m_{a,s}) = \text{SpongeSqueeze}_2(\delta_{\text{aud\\\_s}}, s_{a,s}, \sigma_a)$$ +$$v_{\text{tx}} = \tilde{v}_{\text{aud,s}} - m_{v,s}, \qquad v_a' = \tilde{a}_{\text{aud,s}} - m_{a,s}$$ + +where $s_{a,r}$, $s_{a,s}$, and $\sigma_a$ are recovered from the event as in Section 8.1. The recipient-auditor opening capability stated in Section 8.1 extends to operator-transfer inbound flows: $r_{\text{tx}}$ from operator-transfers contributes to $r_r$ in $C_{\text{receive}}$ identically to owner-transfer inbound flows. + +### 8.5 Operator Allowance Auditing + +The auditor tracks each allowance's current value through the per-event ciphertexts produced at every state-changing operation: `set_operator` reveals the escrowed amount $v_a$ (Section 8.2), `confidential_transfer_from` reveals the transfer amount and post-transfer allowance $v_a'$ (Section 8.4), and `revoke_operator` reveals the reclaimed amount (Section 8.2). + +**Key rotation.** Visibility is forward-only at the event level, matching the spendable-balance model (§8.2). A new key under the account's existing `auditor_id` sees an allowance at the next state-changing operation, when a fresh ciphertext is produced under the new key. + +--- + +## 9. Security Analysis + +### 9.1 Griefing Resistance + +**Proposition 2** (Spend-proof stability). *No third party can invalidate an honest owner's in-flight spend proof.* + +*Proof.* A spend proof (Section 7.6) references $C_{\text{spend}}^A$ as a public input. The contract modifies $C_{\text{spend}}^A$ only through: +1. Owner-initiated operations (transfer, withdrawal, `set_operator`, `revoke_operator`) - all require `account.require_auth()`. +2. Merge - requires `account.require_auth()`. + +Incoming transfers modify only $C_{\text{receive}}^A$, which does not appear in the spend proof's public inputs. Therefore, between proof construction and submission, no third-party action can alter $C_{\text{spend}}^A$. The proof remains valid. $\square$ + +**Corollary.** There is no counter cap on incoming transfers. An account can receive an unbounded number of transfers without any mandatory owner action. The receiving balance is a single point whose committed value grows monotonically; there is no chunk overflow because Pedersen commitments operate over the full scalar field ($|\mathbb{F}_q| \approx 2^{254}$). + +### 9.2 Merge Safety + +**Proposition 3** (Merge cannot be weaponized). *A third party cannot invoke merge on another account.* + +*Proof.* The `merge()` function requires `account.require_auth()`. Only the account holder can authorize it. $\square$ + +**Proposition 4** (Merge does not create or destroy value). *Follows directly from Proposition 1 and the homomorphic property of Pedersen commitments.* + +### 9.3 Balance Conservation + +**Invariant.** For any account at any time: + +$$\sum_{j} d_j - \sum_{k} w_k = v_{\text{spend}} + v_{\text{receive}} + \sum_{i} v_{\text{allowance}_i}$$ + +where $d_j$ are deposits, $w_k$ are withdrawals, and the right-hand side sums committed values across the spendable balance, the receiving balance, and every stored (not-yet-revoked) operator allowance. Expired-but-not-revoked allowances are included: expiration prevents the operator from spending the allowance, but the escrowed value still resides on-chain in $C_a$ until `revoke_operator` reclaims it (Section 6.2, Single-slot semantics). + +This invariant is maintained by: +- **Deposits** increase $v_{\text{receive}}$ by $d_j$ (Section 7.3). +- **Withdrawals** decrease $v_{\text{spend}}$ by $w_k$, enforced by circuit constraint W4. +- **Transfers** decrease sender's $v_{\text{spend}}$ and increase recipient's $v_{\text{receive}}$ by the same $v_{\text{tx}}$, enforced by circuit constraints T3–T8. +- **Merge** moves value from $v_{\text{receive}}$ to $v_{\text{spend}}$ (Proposition 1); the sum is unchanged. +- **Set operator** moves value from $v_{\text{spend}}$ to $v_{\text{allowance}_i}$; enforced by S3–S7. +- **Operator transfer** decreases $v_{\text{allowance}_i}$ and increases recipient's $v_{\text{receive}}$ by $v_{\text{tx}}$; enforced by O2–O8. +- **Revoke** moves remaining $v_{\text{allowance}_i}$ back to $v_{\text{spend}}$; enforced by V4–V7. + +### 9.4 Privacy Properties + +**Amount confidentiality.** Transfer amounts are hidden inside Pedersen commitments (computationally hiding under DL). The encrypted amount $\tilde{v}$ is masked by $\text{Poseidon}(\delta_{\text{tx\\\_amount}}, s, \sigma)$, which is pseudorandom to anyone who does not know $s$ (the ECDH shared secret). + +**Balance confidentiality.** The spendable balance commitment hides both value and blinding. The encrypted balance scalar $\tilde{b}$ emitted in spend-boundary events is masked by $\text{Poseidon}(\delta_{\text{enc\\\_bal}}, vk, \sigma)$, pseudorandom without $vk$. + +**Sender-recipient linkage.** Sender and recipient addresses are visible on-chain. The system provides amount and balance confidentiality, not anonymity. + +**Viewing key compromise.** Since $vk$ is wrapper-specific (Section 4.2), compromise of one wrapper's viewing key does not affect the owner's accounts in other wrappers. Within the compromised wrapper, the attacker can: read all spendable balance snapshots (via $\tilde{b}$ emitted in spend-boundary events), decrypt all incoming transfer amounts (via ECDH with $R_e$ from events), and derive all $dvk_i$ to read operator allowances. The attacker **cannot** authorize any spending operation (requires $sk$, and $vk$ cannot recover $sk$ by Poseidon preimage resistance). + +**Auditor key compromise.** If a sender's auditor key is compromised, the attacker can decrypt amounts and balance checkpoints ($\tilde{b}_{\text{aud,s}}$) for all operations (transfers, withdrawals, set/revoke operator) from accounts that used the compromised key, but cannot construct openings of any commitment. If a recipient's auditor key is compromised, the attacker recovers both the transfer amount and the per-transfer Pedersen randomness ($\tilde{v}_{\text{aud,r}}$, $\tilde{r}_{\text{aud,r}}$) for every incoming transfer to accounts that used the compromised key. This is capability-equivalent to holding the opening of every $C_{\text{tx},i}$ and, by summation, of the receiving-balance commitment $C_{\text{receive}}$; see Section 8.2 for the bounded scope (forward-only, receiving-side only). 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 recipient-auditor's $r_r$ knowledge does not extend to a post-merge opening of $C_{\text{spend}}$: $r_s$ depends on $vk_A$ and is not derivable from any auditor key. In neither case can the attacker recover viewing keys, post-merge spendable-balance openings, historical data from before the key was active, or authorize any spending. After key rotation, new operations are protected by the new key. + +### 9.5 State Recovery + +The recovery model is built around **checkpoints**: each owner-initiated operation that produces a proof emits $(\tilde{b}, \sigma)$ in its event, creating a point from which the full spendable balance opening is recoverable using $\tilde{b}$, $\sigma$, and $vk$. Event replay is bounded to the window between the most recent checkpoint and the current ledger. + +A checkpoint is concretely an event of type `Withdraw`, `Transfer` (as sender), `SetOperator`, or `RevokeOperator`: exactly the events that carry $(\tilde{b}, \sigma)$ for the owner's spendable balance. `Deposit`, incoming `Transfer`, and `OperatorTransfer` do not touch the owner's spendable balance and are therefore not checkpoints. `Merge` does update the spendable balance ($C_{\text{spend}} \leftarrow C_{\text{spend}} + C_{\text{receive}}$, Section 7.4) but is not a checkpoint either: it carries no proof and emits no $(\tilde{b}, \sigma)$, so consistency between $\tilde{b}$ and the post-merge commitment cannot be enforced. Any merge activity is absorbed into the next owner-initiated proof operation, which issues a fresh checkpoint. + +**Checkpoint recovery (one event lookup).** At every spend boundary, the spendable balance has deterministic randomness: $r = \text{Poseidon}(\delta_{\text{spend\\\_r}}, vk, \sigma)$. The owner fetches $(\tilde{b}, \sigma)$ from the most recent checkpoint event for their account (`Withdraw`, sender-side `Transfer`, `SetOperator`, or `RevokeOperator`), then recovers $v = \tilde{b} - \text{Poseidon}(\delta_{\text{enc\\\_bal}}, vk, \sigma)$. Consistency is verifiable: $C_{\text{spend}} \stackrel{?}{=} v \cdot G + r \cdot H$. + +**Post-checkpoint recovery (bounded event replay).** Between a checkpoint and the next spend, the receiving balance may have accumulated incoming transfers and deposits, and a merge may have folded them into the spendable balance. The owner reconstructs the current state by replaying only events since the last checkpoint: + +1. Start from the checkpoint: set $W_{\text{spend}} \leftarrow (v_n, r_n)$ recovered from $(\tilde{b}, \sigma)$ in the latest checkpoint event and the deterministic blinding derivation. Set $W_{\text{receive}} \leftarrow (0, 0)$. +2. Replay all events since the checkpoint in ledger order. For each: incoming transfers and deposits accumulate into $W_{\text{receive}}$; merge events fold $W_{\text{receive}}$ into $W_{\text{spend}}$ and reset $W_{\text{receive}} \leftarrow (0, 0)$. This correctly handles any number of interleaved events. +3. Verify: $C_{\text{spend}} \stackrel{?}{=} W_{\text{spend}}.v \cdot G + W_{\text{spend}}.r \cdot H$ and $C_{\text{receive}} \stackrel{?}{=} W_{\text{receive}}.v \cdot G + W_{\text{receive}}.r \cdot H$. + +The replay window is bounded by the owner's spending frequency. An account that spends or withdraws regularly produces frequent checkpoints, keeping the replay window short. In the worst case (funds received but never spent), the window extends back to registration. + +**Data-availability dependency.** Recovery from seed alone (i.e., after the wallet's local cache is destroyed) requires access to the full event history since the last checkpoint, which Stellar RPC does not guarantee. The protocol therefore requires a durable indexer; the data model, retention obligations, and recommended API are specified in [INDEXER.md](INDEXER.md). Without such an indexer a user can still see that their funds exist on-chain (the commitment remains), but cannot reconstruct the opening required to spend. + +**Incoming-transfer spam.** A third party can spam an account with confidential transfers (including zero-value transfers, see Section 9.1 Corollary) without invalidating the recipient's spend proofs. The cost to the spammer is the Soroban transaction fee per transfer, which bounds the rate. The cost to the recipient is per-event indexer storage and wallet replay work. Both costs are linear in the number of incoming transfers and bounded by the replay window; neither breaks correctness. + +### 9.6 Revert Safety + +Because $\sigma$ is sampled fresh via CSPRNG for every operation, a retry after a reverted transaction naturally uses a different $\sigma$. This means the deterministic randomness $r = \text{Poseidon}(\delta_{\text{spend\\\_r}}, vk, \sigma)$ is always fresh, and an observer cannot correlate reverted and retried commitments. + +**Retry procedure.** On revert, the wallet simply picks a new random $\sigma$ and recomputes the proof. No special-case logic is needed. The $\sigma$ is a public input and emitted in events so the auditor and owner can reconstruct randomness. + +### 9.7 Replay Protection + +**Proposition 5** (Proof non-replayability). *A valid proof cannot be replayed to execute the same operation twice.* + +*Proof.* Every spending proof includes the current on-chain commitment ($C_{\text{spend}}$ or $C_a$) as a public input. Upon successful verification, the contract replaces this commitment with the proof's output commitment ($C_{\text{spend}}'$ or $C_a'$). A replayed proof references the old commitment, which no longer matches the stored state, so verification fails. The same argument applies to operator transfers via $C_a$. $\square$ + +**Corollary.** No explicit nullifier or nonce is needed. State binding through commitment chaining provides replay protection as an inherent property of the protocol. + +--- + +## 10. Proof System + +### 10.1 Circuits + +All proof logic is written in Noir, compiled to UltraHonk circuits. The system uses 6 circuits: + +```rust +#[contracttype] +#[repr(u32)] +pub enum CircuitType { + Register = 0, + Withdraw = 1, + Transfer = 2, + OperatorTransfer = 3, + SetOperator = 4, + RevokeOperator = 5, +} +``` + +### 10.2 Circuit Summary + +| Circuit | What it proves | +|:---|:---| +| `Register` | Spending key well-formedness; wrapper-bound viewing key derivation from $sk$; public viewing key consistency with the derived $vk$ | +| `Withdraw` | Balance sufficiency; new spendable commitment with deterministic randomness; encrypted balance scalar; sender-auditor ECDH ciphertext (balance checkpoint); owner key ownership | +| `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); deterministic randomness for new sender balance; encrypted balance scalar; sender key ownership; range validity (balance $\in [0, 2^{127})$, amount $\in [0, 2^{127})$) | +| `OperatorTransfer` | 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); deterministic randomness for new allowance; encrypted allowance scalar; operator key ownership; wrapper-bound indirectly via $C_a$ chain (Section 7.8) | +| `SetOperator` | Balance split; $dvk_i$ derivation; ECDH escrow of $dvk_i$; allowance commitment with deterministic randomness; encrypted balance and allowance scalars; owner-auditor ECDH ciphertexts (escrow amount + balance checkpoint); owner key ownership; wrapper-bound via $vk$ derivation | +| `RevokeOperator` | Allowance decryption via $dvk_i$; balance merge; deterministic randomness for new balance; encrypted balance scalar; owner-auditor ECDH ciphertexts (reclaimed amount + balance checkpoint); owner key ownership; wrapper-bound via $vk$ derivation | + +### 10.3 Circuit Cost Analysis + +The dominant cost in Noir circuits is elliptic curve scalar multiplication. With Barretenberg's native Grumpkin support via `multi_scalar_mul`, each scalar multiplication costs approximately 64 UltraPlonk-equivalent constraints (with ECC VM) or 4,700–6,250 without. + +The Transfer circuit requires approximately 7 scalar multiplications: spending key verification, spendable balance opening, recipient ECDH shared secret, ephemeral key derivation, transfer commitment construction, recipient-auditor ECDH shared secret, and sender-auditor ECDH shared secret. The OperatorTransfer circuit requires approximately 7 scalar multiplications: operator key verification, allowance commitment opening, recipient ECDH shared secret, ephemeral key derivation, transfer commitment construction, recipient-auditor ECDH shared secret, and owner-auditor ECDH shared secret. The Withdraw, SetOperator, and RevokeOperator circuits each require 2 additional scalar multiplications for auditor ECDH (ephemeral key derivation + auditor shared secret), bringing their totals to approximately 4, 6, and 5 respectively. 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). + +### 10.4 Noir Primitives + +```rust +use std::embedded_curve_ops::{EmbeddedCurvePoint, EmbeddedCurveScalar, multi_scalar_mul}; + +/// Barretenberg's Pedersen generator at index 0 of the "DEFAULT_DOMAIN_SEPARATOR" +/// domain. Equivalent to `derive_generators("DEFAULT_DOMAIN_SEPARATOR", 0)[0]`. +global G: EmbeddedCurvePoint = EmbeddedCurvePoint { + x: 0x083e7911d835097629f0067531fc15cafd79a89beecb39903f69572c636f4a5a, + y: 0x1a7f5efaad7f315c25a918f30cc8d7333fccab7ad7c90f14de81bcc528f9935d, + is_infinite: false, +}; + +/// Barretenberg's Pedersen generator at index 1 of the "DEFAULT_DOMAIN_SEPARATOR" +/// domain. Equivalent to `derive_generators("DEFAULT_DOMAIN_SEPARATOR", 0)[1]`. +/// No known discrete-log relation to G (each is the output of Barretenberg's +/// hash-to-curve on the domain separator + index). +global H: EmbeddedCurvePoint = EmbeddedCurvePoint { + x: 0x054aa86a73cb8a34525e5bbed6e43ba1198e860f5f3950268f71df4591bde402, + y: 0x209dcfbf2cfb57f9f6046f44d71ac6faf87254afc7407c04eb621a6287cac126, + is_infinite: false, +}; + +/// Pedersen commitment, used uniformly for every opening witnessed in any +/// circuit (input or output). Both scalars are encoded as single-limb F_r +/// `Field` values: Poseidon outputs or rejection-sampled CSPRNG draws for fresh +/// blindings, and (for the spend-side input opening of C_spend in W3/T3/S3/V5) +/// the canonical F_q reduction of the wallet's post-merge integer blinding, +/// which lies in F_r with probability >= 1 - 2^-127 per merge. The complementary +/// case is acknowledged below in *Post-merge witness availability*. +fn commit(value: Field, randomness: Field) -> EmbeddedCurvePoint { + multi_scalar_mul( + [G, H], + [EmbeddedCurveScalar::from_field(value), + EmbeddedCurveScalar::from_field(randomness)] + ) +} + +/// ECDH: scalar * point. All ECDH scalars in this protocol are F_r-sampled +/// (sk, vk, r_e), so single-limb conversion is sound. +fn ecdh(scalar: Field, point: EmbeddedCurvePoint) -> EmbeddedCurvePoint { + multi_scalar_mul( + [point], + [EmbeddedCurveScalar::from_field(scalar)] + ) +} +``` + +**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 `Merge` (§7.4), the spendable-balance blinding is $r_s + r_r$ over $\mathbb{F}_q$. 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 merge (§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-operator / revoke-operator 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 operator 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. + +### 10.5 Verification Flow + +1. Wrapper reads on-chain state (commitments, public keys) +2. Encodes state as public inputs: Grumpkin point coordinates as 32-byte $\mathbb{F}_r$ values +3. Cross-contract call: `verifier.verify_proof(circuit_type, public_inputs, proof)` +4. Verifier deserializes stored VK, runs UltraHonk verification (BN254 G1/G2 pairings, Fiat-Shamir, sumcheck) +5. Wrapper applies homomorphic balance updates (Grumpkin point arithmetic via $\mathbb{F}_r$ ops) + +### 10.6 Structured Reference String {#srs} + +UltraHonk is a PLONK-family proving system. Its knowledge soundness guarantee depends on a **Structured Reference String (SRS)** -- a sequence of BN254 G1 and G2 points derived from a secret scalar $\tau$ (the "toxic waste"): + +$$\text{SRS} = \bigl([1]_1, [\tau]_1, [\tau^2]_1, \ldots, [\tau^{N-1}]_1, \; [1]_2, [\tau]_2\bigr)$$ + +where $[x]_1 = x \cdot G_1$ and $[x]_2 = x \cdot G_2$ are BN254 group elements. The SRS is **universal**: a single SRS supports any circuit up to size $N$, and circuit-specific verification keys are derived from it deterministically. The SRS is used during both proof generation (client-side) and verification key derivation (one-time setup). + +**Security requirement.** If $\tau$ is known to an attacker, they can forge proofs for arbitrary false statements: minting tokens, draining accounts, bypassing all circuit constraints. The knowledge soundness of the entire system reduces to the assumption that $\tau$ was destroyed after SRS generation. + +**Multi-party ceremony.** The standard mitigation is a multi-party computation (MPC) ceremony in which $N$ participants each contribute randomness. The resulting SRS is secure if *at least one* participant honestly destroyed their contribution. + +**SRS used in this system.** The Noir/Barretenberg toolchain uses the **Aztec Ignition SRS** by default. Barretenberg downloads the required SRS points from a public transcript on first use. The Ignition ceremony transcript, participant attestations, and verification code are publicly available. The SRS supports circuits up to $2^{28}$ gates, well above the expected circuit sizes for this system ($< 2^{20}$). + +**Deployment considerations.** The verifier contract does not store or reference the full SRS. Circuit-specific verification keys are derived offline from the SRS during circuit compilation and embedded in the verifier contract at deployment. The correctness of these VKs can be independently verified by anyone with access to the circuit source code and the public SRS transcript. + +**Risk assessment.** The Ignition ceremony had 176 independent participants across multiple jurisdictions, hardware platforms, and operating systems. Compromise requires collusion of *all* 176 participants. + +### 10.7 Dependency: CAP-80 + +[CAP-80](https://github.com/stellar/stellar-protocol/blob/master/core/cap-0080.md) introduces host functions required for efficient UltraHonk verification and on-chain Grumpkin point arithmetic: + +- `bn254_g1_msm`: Batched scalar-point multiplication on BN254 G1. +- `bn254_fr_{add, sub, mul, inv, pow}`: $\mathbb{F}_r$ scalar arithmetic. + +### 10.8 On-Chain Point Arithmetic + +The wrapper performs Grumpkin affine point addition and subtraction for homomorphic balance updates. Since Grumpkin coordinates are $\mathbb{F}_r^{\text{BN254}}$ elements, these reduce to Fr field operations. + +**Curve coefficients.** Grumpkin $y^2 = x^3 - 17$ (Section 2.2) is in short Weierstrass form $y^2 = x^3 + a x + b$ with $a = 0$ and $b = -17$. Only $a$ enters the point arithmetic slope formulas below; $b$ enters only the on-curve check. + +The contract distinguishes the following cases when computing $P_3 = P_1 + P_2$: + +| Case | Condition | Result | +|:--|:--|:--| +| Left identity | $P_1 = \mathcal{O}$ | $P_3 = P_2$ | +| Right identity | $P_2 = \mathcal{O}$ | $P_3 = P_1$ | +| Inverse | $P_1, P_2 \neq \mathcal{O}$, $x_1 = x_2$, $y_1 = -y_2 \bmod r$ | $P_3 = \mathcal{O}$ | +| Doubling | $P_1, P_2 \neq \mathcal{O}$, $P_1 = P_2$ (so $y_1 \neq 0$) | slope formula with $\lambda_{\text{dbl}}$ below | +| Generic | $P_1, P_2 \neq \mathcal{O}$, $x_1 \neq x_2$ | slope formula with $\lambda_{\text{add}}$ below | + +The inverse case must be detected and short-circuited before the generic slope formula, because $x_1 - x_2 = 0$ would otherwise force a division by zero in $\mathbb{F}_r$. + +**Slope.** + +$$\lambda_{\text{add}} = (y_2 - y_1)(x_2 - x_1)^{-1} \pmod{r}$$ + +$$\lambda_{\text{dbl}} = (3 x_1^2 + a)(2 y_1)^{-1} = 3 x_1^2 \cdot (2 y_1)^{-1} \pmod{r} \qquad (a = 0 \text{ for Grumpkin})$$ + +**Resulting coordinates.** With $\lambda$ selected per the case above: + +$$x_3 = \lambda^2 - x_1 - x_2 \pmod{r}$$ +$$y_3 = \lambda (x_1 - x_3) - y_1 \pmod{r}$$ + +Requires `bn254_fr_{add, sub, mul, inv}` host calls (CAP-80, Section 10.7). + +**Point subtraction** $P_3 = P_1 - P_2$: if $P_2 = \mathcal{O}$ set $-P_2 = \mathcal{O}$, else $-P_2 = (x_2, -y_2 \bmod r)$; then apply the addition cases above. Subtraction of a point from itself yields $\mathcal{O}$ via the inverse case, never the doubling branch. + +**Point validation.** Grumpkin points enter the system through three boundaries; on-curve and non-identity checks live at the boundary that owns each one. The wrapper itself performs no per-call on-curve check. + +1. **Proof-constrained points (the dominant case).** Every public input that the corresponding circuit also derives via `multi_scalar_mul` is on-curve by construction -- Noir's embedded-curve operations cannot produce an off-curve Grumpkin point. This covers $Y$ (R1), $\text{PVK}$ (R3), $R_e$ (T6, O6, W_a1, S_a1, V_a1), $C_{\text{tx}}$ (T8, O8), $C_{\text{spend}}'$ (T11, W6, S10, V7), $C_a$ / $C_a'$ (S7, O11), and the ECDH shared secrets. Non-identity is enforced *in-circuit* by explicit nonzero-scalar constraints: $sk \neq 0$ and $vk \neq 0$ at registration (R4, R5), and $r_e \neq 0$ in every circuit that produces an ephemeral key (W8, T13, S13, O13, V10). Without these constraints an adversary could publish $Y = \mathcal{O}$, $\text{PVK} = \mathcal{O}$, or $R_e = \mathcal{O}$ and collapse ECDH (every shared secret becomes $\mathcal{O}$, every Poseidon mask becomes a constant function of $\sigma$, every ciphertext becomes trivially decryptable). +2. **Points read from prior on-chain state.** $C_{\text{spend}}$, $C_{\text{receive}}$, stored $Y$ / $\text{PVK}$, and allowance commitments were validated through path (1) when first written. The wrapper trusts them on subsequent reads. +3. **Auditor keys (the only proof-less entry point).** $K_{\text{aud}}$ is registered in the auditor contract by the auditor itself, with no accompanying proof. The auditor contract performs canonical encoding, on-curve ($y^2 \equiv x^3 - 17 \pmod{r}$), and non-identity checks at insertion (Section 3.1); the wrapper trusts the fetched value. + +**Canonical encoding** ($x, y \in [0, r)$ as 32-byte representatives) is enforced at the XDR / Soroban host boundary when bytes are deserialized into `BnScalar`; no additional check is needed inside the wrapper. + +--- + +## 11. Interface + +Based on [EIP-7984](https://eips.ethereum.org/EIPS/eip-7984), adapted for Soroban. The `data: Bytes` parameter carries XDR-encoded proof payloads. + +**Canonical encoding.** The `data` payloads are `#[contracttype]` structs and enums declared in the wrapper crate. Their on-chain byte representation is fixed by Soroban's XDR rules, which are canonical: every value has exactly one valid byte encoding. Named struct fields are serialised as an `ScMap` in declaration order; unnamed fields and tuple-enum variants as an `ScVec` in declaration order; map keys are host-enforced into a canonical sorted form. As a consequence, independent implementations that compile against the same `#[contracttype]` definitions produce byte-identical `data` payloads. The authoritative schemas for the underlying `ScVal`, `ScMap`, `ScVec` live in the [stellar/stellar-xdr](https://github.com/stellar/stellar-xdr) repository; the encoding rules are summarised in the [Stellar XDR documentation](https://developers.stellar.org/docs/learn/fundamentals/data-format/xdr) and the [`#[contracttype]` mapping reference](https://developers.stellar.org/docs/learn/fundamentals/contract-development/types/custom-types). + +```rust +trait ConfidentialTokenWrapper { + fn __constructor(e: Env, admin: Address, token: Address, + verifier: Address, auditor: Address); + + fn register(e: Env, account: Address, auditor_id: u32, data: Bytes); + + fn deposit(e: Env, from: Address, to: Address, amount: i128); + + fn merge(e: Env, account: Address); + + fn withdraw(e: Env, from: Address, to: Address, amount: i128, data: Bytes); + + fn confidential_transfer(e: Env, from: Address, to: Address, data: Bytes); + + fn confidential_transfer_from(e: Env, operator: Address, + from: Address, to: Address, data: Bytes); + + fn set_operator(e: Env, account: Address, operator: Address, + expiration_ledger: u32, data: Bytes); + + fn revoke_operator(e: Env, account: Address, operator: Address, + data: Bytes); + + fn confidential_balance(e: Env, account: Address) -> Bytes; + + fn is_operator(e: Env, account: Address, operator: Address) -> bool; + + fn get_operator(e: Env, account: Address, operator: Address) -> Bytes; +} +``` + +This table is authoritative: every entry is exactly the set of prover-supplied public inputs from the corresponding Section 7 operation (the wrapper loads the remaining public inputs from trusted state per §7.1), plus the `proof` blob. Names map directly to the Section 7 symbols. + +| Operation | `data` contents | +|:---|:---| +| `register` | $Y$, $\text{PVK}$, `proof` | +| `withdraw` | $C_{\text{spend}}'$, $\tilde{b}$, $R_e$, $\sigma$, $\tilde{b}_{\text{aud,s}}$, `proof` | +| `confidential_transfer` | $C_{\text{spend}}'$, $C_{\text{tx}}$, $R_e$, $\tilde{v}$, $\tilde{b}$, $\sigma$, $\tilde{v}_{\text{aud,r}}$, $\tilde{r}_{\text{aud,r}}$, $\tilde{v}_{\text{aud,s}}$, $\tilde{b}_{\text{aud,s}}$, `proof` | +| `confidential_transfer_from` | $C_a'$, $C_{\text{tx}}$, $R_e$, $\tilde{v}$, $\tilde{a}'$, $\sigma_a'$, $\tilde{v}_{\text{aud,r}}$, $\tilde{r}_{\text{aud,r}}$, $\tilde{v}_{\text{aud,s}}$, $\tilde{a}_{\text{aud,s}}$, `proof` | +| `set_operator` | $C_{\text{spend}}'$, $C_a$, $\text{escrowed\\\_dvk}$, $\tilde{b}$, $\tilde{a}$, $R_e$, $\sigma$, $\sigma_a$, $\tilde{v}_{\text{aud,s}}$, $\tilde{b}_{\text{aud,s}}$, `proof` | +| `revoke_operator` | $C_{\text{spend}}'$, $\tilde{b}$, $R_e$, $\sigma$, $\tilde{v}_{\text{aud,s}}$, $\tilde{b}_{\text{aud,s}}$, `proof` | + +For `confidential_transfer_from`, the stored allowance salt $\sigma_a$ is **not** carried in `data`: the wrapper loads it from the `(from, operator)` delegation entry (§7.8 public-input table). Only the prover-chosen replacement $\sigma_a'$ travels in `data`, gets bound by constraint O10, and is then written back to the delegation entry as the new `allowance_salt` (§6.2). This keeps the trust-boundary rule of §7.1 intact: caller-controlled bytes never overwrite the live $\sigma_a$ used to verify the proof. `set_operator`, by contrast, has no prior delegation entry to load from, so its $\sigma_a$ is prover-supplied and bound by S6. + +### 11.1 Authorization Model + +Soroban `address.require_auth()` proves that the named principal authorized the current invocation; it binds the full invocation (function name and all arguments) by default. ZK proof verification proves that the prover knows a witness satisfying the circuit's constraints over public inputs the wrapper itself supplies. The two are complementary: every state-changing operation requires **both** the appropriate `require_auth()` and (where applicable) a valid proof. + +| Operation | `require_auth()` principal | +|:---|:---| +| `register(account, auditor_id, data)` | `account` | +| `deposit(from, to, amount)` | `from` | +| `merge(account)` | `account` | +| `withdraw(from, to, amount, data)` | `from` | +| `confidential_transfer(from, to, data)` | `from` | +| `confidential_transfer_from(operator, from, to, data)` | `operator` (not `from`) | +| `set_operator(account, operator, expiration_ledger, data)` | `account` | +| `revoke_operator(account, operator, data)` | `account` | +| `confidential_balance`, `is_operator`, `get_operator` | none (read-only) | + +**`register` is single-use.** It reverts if `account` is already registered. Combined with `account.require_auth()`, this prevents a third party from binding attacker-controlled $(Y, \text{PVK})$ to `account`'s `ConfidentialAccount` slot. + +**`set_operator` rejects replacement.** It reverts if a non-revoked delegation already exists for `(account, operator)` -- see §6.2. + +**`confidential_transfer_from` is operator-authorized.** The owner's authorization was granted out-of-band at `set_operator` and persists in the on-chain delegation entry until expiry or revocation. The operator's `require_auth()` binds `from`, `to`, and `data`. + +### 11.2 Event Schema + +Each state-modifying operation emits a structured event. Events carry the data needed for recipient decryption, auditor decryption, and wallet recovery. + +| Event | Fields | +|:---|:---| +| `Register` | `account`, `auditor_id` | +| `Deposit` | `from`, `to`, `amount` | +| `Merge` | `account` | +| `Withdraw` | `from`, `to`, `amount`, $R_e$, $\sigma$, $\tilde{b}$, $\tilde{b}_{\text{aud,s}}$ | +| `Transfer` | `from`, `to`, $R_e$, $\tilde{v}$, $\sigma$, $\tilde{b}$, $\tilde{v}_{\text{aud,r}}$, $\tilde{r}_{\text{aud,r}}$, $\tilde{v}_{\text{aud,s}}$, $\tilde{b}_{\text{aud,s}}$ | +| `OperatorTransfer` | `operator`, `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}}$ | +| `SetOperator` | `account`, `operator`, `expiration_ledger`, $R_e$, $\sigma$, $\tilde{b}$, $\tilde{v}_{\text{aud,s}}$, $\tilde{b}_{\text{aud,s}}$ | +| `RevokeOperator` | `account`, `operator`, $R_e$, $\sigma$, $\tilde{b}$, $\tilde{v}_{\text{aud,s}}$, $\tilde{b}_{\text{aud,s}}$ | + +Amount fields in `Deposit` and `Withdraw` are typed `i128`, matching SEP-41. + +**Usage by consumers:** + +- **Recipient wallet**: processes `Transfer` and `OperatorTransfer` events using $(R_e, \tilde{v}, \sigma)$ to derive $v_{\text{tx}}$ and $r_{\text{tx}}$ (Section 5.3). +- **Owner wallet**: processes all events for recovery (Section 5.2). The $(\tilde{b}, \sigma)$ pair from the most recent owner-initiated event forms a checkpoint. +- **Auditor**: processes events containing $R_e$ to compute ECDH shared secrets and decrypt amounts and balance checkpoints (Section 8.1, 8.2). + +### 11.3 Read Methods + +**`confidential_balance(account) -> Bytes`.** Returns the XDR-serialized `ConfidentialAccount` struct for the given account (§6.1), i.e. the tuple `(spending_key, viewing_public_key, spendable_balance, receiving_balance, auditor_id)`. Reverts if `account` is not registered. Wallets bootstrap from this call (single round-trip to obtain both Pedersen commitments plus the keys needed to identify the account and its bound auditor); indexers use it to verify consistency between their replayed accumulators and on-chain state (§5.2 "Consistency check"). + +**`is_operator(account, operator) -> bool`.** Returns `true` iff a delegation entry exists for `(account, operator)` **and** `ledger.sequence() <= expiration_ledger`. Returns `false` for: + +- pairs with no delegation entry, +- pairs whose entry has `ledger.sequence() > expiration_ledger` (expired-but-not-yet-revoked: the escrowed value still resides on-chain in $C_a$ until `revoke_operator` reclaims it -- §6.2 *Single-slot semantics* -- but the operator can no longer spend), +- pairs whose entry was revoked (deleted) by `revoke_operator`. + +The function returns the *spending-authority* state, not the *escrow-existence* state. Consumers that need to distinguish "no delegation" from "expired delegation" inspect `get_operator` (below) or replay `SetOperator` / `RevokeOperator` events. + +**`get_operator(account, operator) -> Bytes`.** Returns the XDR-serialized `OperatorDelegation` struct (§6.2) for the `(account, operator)` pair, i.e. `(allowance_commitment, encrypted_allowance, escrowed_dvk, allowance_salt, expiration_ledger)`. Reverts if no delegation entry exists for the pair. Unlike `is_operator`, this surfaces the raw on-chain delegation state without applying the expiry filter, so callers can distinguish "no delegation" (revert) from "active delegation" (`ledger.sequence() <= expiration_ledger`) from "expired-but-not-yet-revoked delegation" (`ledger.sequence() > expiration_ledger`, escrowed value still pending reclaim). Primary consumers: + +- **Operator wallet:** fetches `allowance_commitment`, `encrypted_allowance`, `escrowed_dvk`, and `allowance_salt` to recover $dvk_i$ via §7.11 decryption, then reads the current allowance via $\tilde{a} = v_a + \text{Poseidon}(\delta_{\text{enc\\\_allow}}, dvk_i, \sigma_a)$ to construct the next `confidential_transfer_from` witness. +- **Owner wallet:** reads the same fields after losing local state, or before calling `revoke_operator`, to confirm the on-chain entry matches its records. +- **Indexers:** verify their replayed delegation state against the live commitment, in the same way `confidential_balance` is used for account state (§5.2 "Consistency check"). + +The auditor's allowance tracking does **not** use this method: per-event allowance ciphertexts (§8.5) are the auditor's data path; `encrypted_allowance` is keyed to $dvk_i$ and is unreadable without it. + +--- + +## 12. Dependencies + +| Dependency | Status | Impact | +|:---|:---|:---| +| **Protocol 25** (BN254 native support) | Available | `bn254.g1_add()`, `g1_mul()`, `pairing_check()`, `BnScalar` Fr arithmetic | +| **CAP-80** (BN254 host functions) | Available | Required for efficient UltraHonk verification and Grumpkin point arithmetic | +| **Modified UltraHonk verifier** | To be built | Multi-VK support (one per circuit type) | +| **Noir circuits** | To be built | 6 circuits using `std::embedded_curve_ops` for Grumpkin | +| **Grumpkin point arithmetic library** | To be built | On-chain point add/sub using BN254 Fr ops, identity handling | +| **Auditor contract** | To be built | Independent key management contract | +| **Nargo / Barretenberg** | Available (`nargo 1.0.0-beta.11`, `bb v0.87.0`) | Off-chain proof generation | +| **Client library** | To be built | ECDH key agreement, Poseidon-based amount encryption/decryption, event processing, off-chain balance tracking | + +--- + +## 13. Domain Separation Constants + +Each $\delta$ is a small positive integer in $\mathbb{F}_r$, fixed for the protocol version and used as a Poseidon2 leading-input domain tag. Numeric values are assigned sequentially from 1; the protocol version is implicit in the deployment, and any change to a circuit's constraint that uses these tags requires a new deployment with a fresh verification key (§3.5, §10.6). + +| $\delta$ | Value | Context | +|:---|:---:|:---| +| $\delta_{\text{addr}}$ | 1 | Soroban Address compression into a single $\mathbb{F}_r$ Field (§2.7) | +| $\delta_{\text{vk}}$ | 2 | Viewing key derivation from spending key and wrapper address (§4.2) | +| $\delta_{\text{dvk}}$ | 3 | Delegation viewing key derivation (§4.4) | +| $\delta_{\text{spend\\\_r}}$ | 4 | Deterministic randomness for spendable balance commitments (§5.2 *Update rules*) | +| $\delta_{\text{tx\\\_blind}}$ | 5 | ECDH-derived transfer blinding factor (§5.3 Definition 1) | +| $\delta_{\text{tx\\\_amount}}$ | 6 | ECDH-derived transfer amount encryption (§5.3 Definition 1) | +| $\delta_{\text{enc\\\_bal}}$ | 7 | Encrypted balance scalar masking (§5.5) | +| $\delta_{\text{enc\\\_allow}}$ | 8 | Encrypted allowance scalar masking (§6.2 *encrypted\_allowance*) | +| $\delta_{\text{allow\\\_r}}$ | 9 | Deterministic randomness for operator allowance commitments (§6.2 *allowance\_commitment*) | +| $\delta_{\text{esc\\\_dvk}}$ | 10 | Delegation key escrow (operator ECDH) (§7.11) | +| $\delta_{\text{aud\\\_s}}$ | 11 | Sender / owner-auditor channel sponge (§2.5, §8.1) | +| $\delta_{\text{aud\\\_r}}$ | 12 | Recipient-auditor channel sponge (§2.5, §8.1) | + +**Provenance.** Sequential small integers are the simplest assignment that satisfies the requirement of *distinctness* across all Poseidon2 invocations in this protocol -- Poseidon2 is collision-resistant under the assumption of §3.2, so any two distinct leading inputs (independent of size) produce independent outputs. The values themselves carry no semantic meaning; the binding is purely positional and the table is the only authoritative source. Implementations MUST hardcode these exact numeric values; deviations break cross-implementation derivation of $vk$, $dvk_i$, $\tilde{v}$, $\tilde{b}$, $\tilde{a}$, $r_{\text{tx}}$, $r_a$, and all auditor masks. + +**Cross-protocol collision.** Future protocols that share Grumpkin / BN254 / Poseidon2 with this wrapper -- e.g. an unrelated payments protocol that uses small-integer Poseidon2 domains -- could in principle pick the same numeric values for unrelated purposes. The protocol assumes that the surrounding inputs to Poseidon2 (key material, structural witnesses) sufficiently disambiguate even in such a case; no Poseidon2 invocation in this protocol is keyed solely on a $\delta$ value. If stronger isolation is desired, implementers may instead use the alternate scheme $\delta_X = \text{Poseidon2}(0, \text{ASCII}(\text{"openzeppelin/confidential-token-wrapper/v1:X"}))$, but this is a deployment-time choice that must be applied uniformly and disclosed in the deployment's circuit-binding documentation. From 1400304e638b7638ae9d2251febd1e5bbc84c016 Mon Sep 17 00:00:00 2001 From: brozorec <9572072+brozorec@users.noreply.github.com> Date: Wed, 27 May 2026 09:15:07 +0200 Subject: [PATCH 10/15] fix: domains visibility --- .../src/confidential/circuits/lib/src/lib.nr | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/tokens/src/confidential/circuits/lib/src/lib.nr b/packages/tokens/src/confidential/circuits/lib/src/lib.nr index cbafc8c26..beb16401c 100644 --- a/packages/tokens/src/confidential/circuits/lib/src/lib.nr +++ b/packages/tokens/src/confidential/circuits/lib/src/lib.nr @@ -89,53 +89,53 @@ global POSEIDON2_IV_BASE: Field = 18446744073709551616; // 2^64 /// | `delta_esc_dvk` | 10 | `ESCROWED_DELEGATION_VIEWING_KEY` | /// | `delta_aud_s` | 11 | `AUDITOR_SENDER` | /// | `delta_aud_r` | 12 | `AUDITOR_RECIPIENT` | -mod domain { +pub mod domain { /// Soroban Address compression into a single `F_r` Field: /// `address_to_field(a) = Poseidon2(ADDRESS, lo(a), hi(a))`. /// Section 2.7 (`delta_addr`). The wrapper computes `wrap` and `op_i` /// off-circuit via this tag; circuits consume the resulting Field directly. - pub(crate) global ADDRESS: Field = 1; + pub global ADDRESS: Field = 1; /// Viewing-key derivation: `vk = Poseidon2(VIEWING_KEY, sk, wrap)`. /// Section 4.2 (`delta_vk`). - pub(crate) global VIEWING_KEY: Field = 2; + pub global VIEWING_KEY: Field = 2; /// Delegation viewing-key derivation: /// `dvk = Poseidon2(DELEGATION_VIEWING_KEY, vk, op_i)`. Section 4.4 (`delta_dvk`). - pub(crate) global DELEGATION_VIEWING_KEY: Field = 3; + pub global DELEGATION_VIEWING_KEY: Field = 3; /// Deterministic randomness for the new spendable balance: /// `r' = Poseidon2(SPEND_RANDOMNESS, vk, sigma)`. Constraints W5/T10/S9/V6 (`delta_spend_r`). - pub(crate) global SPEND_RANDOMNESS: Field = 4; + pub global SPEND_RANDOMNESS: Field = 4; /// ECDH-derived blinding for the transfer commitment: /// `r_tx = Poseidon2(TX_BLINDING, s, sigma)`. Constraints T7/O7 (`delta_tx_blind`). - pub(crate) global TX_BLINDING: Field = 5; + pub global TX_BLINDING: Field = 5; /// Mask for the encrypted transfer amount: /// `v_tilde = v_tx + Poseidon2(TX_AMOUNT, s, sigma)`. Constraints T9/O9 (`delta_tx_amount`). - pub(crate) global TX_AMOUNT: Field = 6; + pub global TX_AMOUNT: Field = 6; /// Mask for the encrypted balance scalar: /// `b_tilde = v_new + Poseidon2(ENCRYPTED_BALANCE, vk, sigma)`. /// Constraints W7/T12/S11/V8 (`delta_enc_bal`). - pub(crate) global ENCRYPTED_BALANCE: Field = 7; + pub global ENCRYPTED_BALANCE: Field = 7; /// Mask for the encrypted allowance scalar: /// `a_tilde = v_a + Poseidon2(ENCRYPTED_ALLOWANCE, dvk, sigma_a)`. /// Constraints S8/O12 (`delta_enc_allow`). - pub(crate) global ENCRYPTED_ALLOWANCE: Field = 8; + pub global ENCRYPTED_ALLOWANCE: Field = 8; /// Deterministic randomness for the operator allowance: /// `r_a = Poseidon2(ALLOWANCE_RANDOMNESS, dvk, sigma_a)`. /// Constraints S6/O3/O10 (`delta_allow_r`). - pub(crate) global ALLOWANCE_RANDOMNESS: Field = 9; + pub global ALLOWANCE_RANDOMNESS: Field = 9; /// Delegation-key escrow mask (operator ECDH): /// `Poseidon2(ESCROWED_DELEGATION_VIEWING_KEY, s, op_i)`. /// Constraint S12, Section 7.11 (`delta_esc_dvk`). - pub(crate) global ESCROWED_DELEGATION_VIEWING_KEY: Field = 10; + pub global ESCROWED_DELEGATION_VIEWING_KEY: Field = 10; /// Sender or owner-auditor channel tag for Poseidon2 sponge masks /// (Section 2.5, Section 8.1). Squeeze 1 yields the amount mask (where /// applicable); squeeze 2 yields the balance/allowance checkpoint mask. /// Constraints W_a3 / T_a6 / S_a3 / V_a3 / O_a6 (`delta_aud_s`). - pub(crate) global AUDITOR_SENDER: Field = 11; + pub global AUDITOR_SENDER: Field = 11; /// Recipient-auditor channel tag for Poseidon2 sponge masks /// (Section 2.5, Section 8.1). Squeeze 1 yields the amount mask; squeeze /// 2 yields the per-transfer Pedersen randomness mask. /// Constraints T_a2 / O_a2 (`delta_aud_r`). - pub(crate) global AUDITOR_RECIPIENT: Field = 12; + pub global AUDITOR_RECIPIENT: Field = 12; } // ################## CORE PRIMITIVES ################## From 6833adaf0eae9a484d7492f3ee4ae0f2e76e99a8 Mon Sep 17 00:00:00 2001 From: brozorec <9572072+brozorec@users.noreply.github.com> Date: Wed, 27 May 2026 11:05:27 +0200 Subject: [PATCH 11/15] feat(confidential): set_operator circuit (S1-S13, S_a1-S_a5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the SetOperator circuit per design doc §7.7 (issue #707): owner balance split (S1-S4, S9-S11), delegation + allowance escrow with the dvk-handoff ECDH expanded into its three §7.11 sub-constraints (S5-S8, S12, S13), and the owner-auditor block (S_a1-S_a5). 24 public inputs in the design-doc canonical order. Tests cover the happy path, the v_a = v boundary, S4 underflow / range overflow, the wrong op_i case, wrong Y_op (S12(b) via the cipher), every tampered ciphertext including both escrowed_dvk limbs, r_e = 0 (S13), and on-curve / non-identity rejection for K_aud_s. --- .../src/confidential/circuits/Nargo.toml | 1 + .../circuits/scripts/extract_vks.sh | 1 + .../circuits/set_operator/Nargo.toml | 8 + .../circuits/set_operator/src/main.nr | 274 ++++ .../circuits/set_operator/src/tests.nr | 1249 +++++++++++++++++ 5 files changed, 1533 insertions(+) create mode 100644 packages/tokens/src/confidential/circuits/set_operator/Nargo.toml create mode 100644 packages/tokens/src/confidential/circuits/set_operator/src/main.nr create mode 100644 packages/tokens/src/confidential/circuits/set_operator/src/tests.nr diff --git a/packages/tokens/src/confidential/circuits/Nargo.toml b/packages/tokens/src/confidential/circuits/Nargo.toml index 49acde248..bc3c96f99 100644 --- a/packages/tokens/src/confidential/circuits/Nargo.toml +++ b/packages/tokens/src/confidential/circuits/Nargo.toml @@ -3,6 +3,7 @@ members = [ "lib", "register", "withdraw", + "set_operator", "gadgets/assert_on_curve", "gadgets/commit", "gadgets/ecdh", diff --git a/packages/tokens/src/confidential/circuits/scripts/extract_vks.sh b/packages/tokens/src/confidential/circuits/scripts/extract_vks.sh index f9a5fca9e..afc68b67a 100755 --- a/packages/tokens/src/confidential/circuits/scripts/extract_vks.sh +++ b/packages/tokens/src/confidential/circuits/scripts/extract_vks.sh @@ -24,6 +24,7 @@ cd "$(dirname "$0")/.." CIRCUITS=( "register" "withdraw" + "set_operator" ) OUT_DIR="vks" diff --git a/packages/tokens/src/confidential/circuits/set_operator/Nargo.toml b/packages/tokens/src/confidential/circuits/set_operator/Nargo.toml new file mode 100644 index 000000000..9907d429a --- /dev/null +++ b/packages/tokens/src/confidential/circuits/set_operator/Nargo.toml @@ -0,0 +1,8 @@ +[package] +name = "circuit_set_operator" +type = "bin" +authors = ["OpenZeppelin"] +compiler_version = ">=0.30.0" + +[dependencies] +stellar_confidential_lib = { path = "../lib" } diff --git a/packages/tokens/src/confidential/circuits/set_operator/src/main.nr b/packages/tokens/src/confidential/circuits/set_operator/src/main.nr new file mode 100644 index 000000000..dcfd7fc50 --- /dev/null +++ b/packages/tokens/src/confidential/circuits/set_operator/src/main.nr @@ -0,0 +1,274 @@ +use stellar_confidential_lib::{ + H, assert_on_curve_non_identity, commit, derive_allow_r, derive_spend_r, domain, + dvk_from_vk_op, ecdh, encrypt_allowance, encrypt_balance, encrypt_esc_dvk, scalar_mul, + sponge_squeeze_2, vk_from_sk, +}; +use std::embedded_curve_ops::EmbeddedCurvePoint; + +mod tests; + +// SetOperator circuit -- design doc Section 7.7. +// +// Constraints +// ----------- +// Owner balance split (ownership + new spendable balance) +// S1 Y = sk * H Owner key +// ownership. +// S2 vk = Poseidon2(delta_vk, sk, wrap) Wrapper-bound +// viewing key. +// S3 C_spend = v * G + r * H Opening of current +// spendable balance. +// S4 v, v_a, v - v_a in [0, 2^127) Range validity +// (Section 2.6); the +// [0, 2^127) bound is +// the SEP-41 i128 +// non-negative range +// (Section 3.4). +// S9 r' = Poseidon2(delta_spend_r, vk, sigma) Deterministic +// randomness for the +// new spendable +// balance. +// S10 C_spend' = (v - v_a) * G + r' * H New spendable +// commitment. +// S11 b_tilde = (v - v_a) +// + Poseidon2(delta_enc_bal, vk, sigma) +// Encrypted balance +// scalar (emitted). +// Delegation + allowance (per-operator escrow) +// S5 dvk_i = Poseidon2(delta_dvk, vk, op_i) Delegation viewing +// key; wrapper-bound +// via vk. +// S6 r_a = Poseidon2(delta_allow_r, dvk_i, +// sigma_a) Deterministic +// allowance blinding. +// S7 C_a = v_a * G + r_a * H Allowance commitment. +// S8 a_tilde = v_a +// + Poseidon2(delta_enc_allow, dvk_i, sigma_a) +// Encrypted allowance +// scalar (emitted). +// S12 Escrowed dvk_i correctly encrypts under Y_op via ECDH. +// Expanded per Section 7.11 into three sub-constraints over the +// prover-supplied escrowed_dvk = (R_x, dvk_cipher): +// (a) R_x = R_e.x Same r_e as the +// auditor block +// (S_a1), forced +// equal here. +// (b) s_esc = (r_e * Y_op).x Operator-handoff +// ECDH shared secret. +// (c) dvk_cipher = dvk_i +// + Poseidon2(delta_esc_dvk, s_esc, op_i) +// Masked dvk_i for the +// operator. +// S13 r_e != 0 Rules out R_e = O, +// S_{a,s} = O, and a +// trivial escrow +// shared secret +// (same r_e is reused +// for the dvk escrow +// ECDH; Section 7.11). +// Auditor block (owner-auditor visibility, Section 8.1) +// S_a1 R_e = r_e * H Ephemeral key for +// auditor ECDH (also +// the escrow ephemeral +// key; see S12(a)). +// S_a2 S_{a,s} = r_e * K_aud_s Owner-auditor ECDH +// shared secret. +// S_a3 (m_v, m_b) = SpongeSqueeze_2(delta_aud_s, +// S_{a,s}.x, sigma) Owner-channel +// sponge: two masks. +// S_a4 v_tilde_aud_s = v_a + m_v Owner-auditor +// encrypted escrow +// amount. +// S_a5 b_tilde_aud_s = (v - v_a) + m_b Owner-auditor +// encrypted balance +// checkpoint. +// +// Point-validation doctrine (Section 10.8) +// ---------------------------------------- +// Y, C_spend, C_spend', C_a, R_e, and S_{a,s} are bound to in-circuit +// multi_scalar_mul outputs (S1, S3, S10, S7, S_a1, S_a2) and are therefore +// on-curve by construction -- no explicit check needed. Y_op was constrained +// on-curve when the operator registered (R1, Section 7.2) and is loaded by +// the wrapper from trusted storage, so it is path (2) of Section 10.8 and the +// wrapper trusts it on read. K_aud_s is a public-input key consumed by an +// ECDH multiplication (path (3)); the verifier doesn't check curve membership +// and an off-curve K_aud_s would break the soundness of S_a2, so this file +// explicitly validates it on-curve AND non-identity before the auditor block +// runs. +// +// Public inputs (24 fields, in design-doc canonical order) +// -------------------------------------------------------- +// Idx Param Symbol Source / Note +// --- ----- ------ ----------------------------- +// 0 c_spend_x C_spend.x Loaded from +// 1 c_spend_y C_spend.y `from.spendable_balance`. +// 2 y_x Y.x Loaded from +// 3 y_y Y.y `from.spending_key`. +// 4 y_op_x Y_op.x Loaded from operator +// 5 y_op_y Y_op.y account's `spending_key`. +// Operator must be registered. +// 6 op_i op_i address_to_field(`operator`), +// computed off-circuit by the +// wrapper (Section 2.7). +// 7 wrap wrap `env.current_contract_address()`. +// 8 k_aud_s_x K_aud_s.x Fetched from the auditor +// 9 k_aud_s_y K_aud_s.y contract by `from.auditor_id`. +// 10 c_spend_new_x C_spend'.x Prover-supplied; written to +// 11 c_spend_new_y C_spend'.y `from.spendable_balance`. +// 12 c_a_x C_a.x Prover-supplied; written to +// 13 c_a_y C_a.y `(from, op).allowance`. +// 14 escrowed_dvk_r_x R_x Prover-supplied; the first +// 32-byte limb of the on-chain +// escrowed_dvk encoding +// (Section 7.11); forced equal +// to R_e.x by S12(a). +// 15 escrowed_dvk_cipher dvk_cipher Prover-supplied; second limb +// of escrowed_dvk; stored. +// 16 b_tilde b_tilde Prover-supplied encrypted +// balance scalar; emitted. +// 17 a_tilde a_tilde Prover-supplied encrypted +// allowance scalar; emitted. +// 18 sigma sigma Prover-supplied owner-channel +// salt; emitted. +// 19 sigma_a sigma_a Prover-supplied allowance +// salt; stored. +// 20 r_e_x R_e.x Prover-supplied ephemeral key +// 21 r_e_y R_e.y for auditor ECDH; emitted. +// 22 v_tilde_aud_s v_tilde_aud_s Prover-supplied owner-auditor +// encrypted escrow amount; +// emitted. +// 23 b_tilde_aud_s b_tilde_aud_s Prover-supplied owner-auditor +// encrypted balance checkpoint; +// emitted. +// +// Private witnesses +// ----------------- +// sk Owner spending secret scalar. +// v Plaintext spendable-balance value. +// r Plaintext blinding factor for C_spend. +// v_a Plaintext per-operator allowance. +// r_e Ephemeral scalar for the auditor + escrow ECDH; must satisfy +// r_e != 0 (S13). + +fn main( + sk: Field, + v: Field, + r: Field, + v_a: Field, + r_e: Field, + c_spend_x: pub Field, + c_spend_y: pub Field, + y_x: pub Field, + y_y: pub Field, + y_op_x: pub Field, + y_op_y: pub Field, + op_i: pub Field, + wrap: pub Field, + k_aud_s_x: pub Field, + k_aud_s_y: pub Field, + c_spend_new_x: pub Field, + c_spend_new_y: pub Field, + c_a_x: pub Field, + c_a_y: pub Field, + escrowed_dvk_r_x: pub Field, + escrowed_dvk_cipher: pub Field, + b_tilde: pub Field, + a_tilde: pub Field, + sigma: pub Field, + sigma_a: pub Field, + r_e_x: pub Field, + r_e_y: pub Field, + v_tilde_aud_s: pub Field, + b_tilde_aud_s: pub Field, +) { + // S13 -- runs first so the r_e = 0 attack is rejected before any + // scalar mul against it could quietly produce the identity (R_e, S_{a,s}, + // and the escrow shared secret all collapse together). + assert(r_e != 0); + + // S1 + let y_derived = scalar_mul(sk, H); + assert(y_derived.x == y_x); + assert(y_derived.y == y_y); + + // S2 + let vk = vk_from_sk(sk, wrap); + + // S3 + let c_spend_derived = commit(v, r); + assert(c_spend_derived.x == c_spend_x); + assert(c_spend_derived.y == c_spend_y); + + // S4 -- Section 2.6 spells out the 127-bit decomposition / recomposition + // pattern; `assert_max_bit_size` is the Noir stdlib primitive that + // implements it directly. + v.assert_max_bit_size::<127>(); + v_a.assert_max_bit_size::<127>(); + let v_new = v - v_a; + v_new.assert_max_bit_size::<127>(); + + // S5 + let dvk = dvk_from_vk_op(vk, op_i); + + // S6 + let r_a = derive_allow_r(dvk, sigma_a); + + // S7 + let c_a_derived = commit(v_a, r_a); + assert(c_a_derived.x == c_a_x); + assert(c_a_derived.y == c_a_y); + + // S8 + let a_tilde_derived = encrypt_allowance(v_a, dvk, sigma_a); + assert(a_tilde_derived == a_tilde); + + // S9 + let r_new = derive_spend_r(vk, sigma); + + // S10 + let c_spend_new_derived = commit(v_new, r_new); + assert(c_spend_new_derived.x == c_spend_new_x); + assert(c_spend_new_derived.y == c_spend_new_y); + + // S11 + let b_tilde_derived = encrypt_balance(v_new, vk, sigma); + assert(b_tilde_derived == b_tilde); + + // S_a1 + let r_e_derived = scalar_mul(r_e, H); + assert(r_e_derived.x == r_e_x); + assert(r_e_derived.y == r_e_y); + + // S12(a) -- escrowed_dvk's R_x limb is forced equal to R_e.x, which is + // the auditor-block ephemeral. Section 7.11: "the escrow's R_x and the + // auditor channel's R_e.x are forced equal" -- this constraint is what + // forces it. + assert(escrowed_dvk_r_x == r_e_x); + + // Y_op is path (2) of Section 10.8: bound to scalar_mul(sk_op, H) when + // the operator registered (R1), trusted on read from the wrapper. + let y_op = EmbeddedCurvePoint { x: y_op_x, y: y_op_y, is_infinite: false }; + + // S12(b) + let s_esc = ecdh(r_e, y_op); + + // S12(c) + let escrowed_dvk_cipher_derived = encrypt_esc_dvk(dvk, s_esc, op_i); + assert(escrowed_dvk_cipher_derived == escrowed_dvk_cipher); + + // K_aud_s point validation (Section 10.8: public-input key). + let k_aud_s = EmbeddedCurvePoint { x: k_aud_s_x, y: k_aud_s_y, is_infinite: false }; + assert_on_curve_non_identity(k_aud_s); + + // S_a2 (owner-auditor shared secret x-coordinate) + let s_a_s_x = ecdh(r_e, k_aud_s); + + // S_a3 (owner-channel masks: amount, then balance) + let m_s = sponge_squeeze_2(domain::AUDITOR_SENDER, s_a_s_x, sigma); + + // S_a4 + assert(v_a + m_s[0] == v_tilde_aud_s); + + // S_a5 + assert(v_new + m_s[1] == b_tilde_aud_s); +} diff --git a/packages/tokens/src/confidential/circuits/set_operator/src/tests.nr b/packages/tokens/src/confidential/circuits/set_operator/src/tests.nr new file mode 100644 index 000000000..9ffe3c2fb --- /dev/null +++ b/packages/tokens/src/confidential/circuits/set_operator/src/tests.nr @@ -0,0 +1,1249 @@ +use crate::main; +use stellar_confidential_lib::{ + commit, derive_allow_r, derive_spend_r, domain, dvk_from_vk_op, ecdh, encrypt_allowance, + encrypt_balance, encrypt_esc_dvk, H, scalar_mul, sponge_squeeze_2, vk_from_sk, +}; +use std::embedded_curve_ops::EmbeddedCurvePoint; + +// Canonical fixture inputs. +// +// SK / WRAP / SIGMA / V / R / R_E reuse the lib's pinned vectors (lib/testdata) +// so that Y, C_spend, vk, r_new, b_tilde, R_e, and the owner-channel masks +// are all derivable from -- and pinned by -- the lib's `fixtures_match_testdata` +// test. V_A = 300 keeps the happy path on a non-trivial v - v_a while still +// satisfying S4 (matches the withdraw fixture's A so the owner-block balance +// math is identical across the two owner-initiated circuits). SIGMA_A is a +// distinct salt so the allowance channel is independent of the owner channel. +// SK_OP / K_AUD_S_SCALAR are placeholder secret scalars used to materialize +// on-curve Y_op / K_aud_s; any non-zero scalar would do, these are pinned by +// `set_operator_fixtures_match_lib`. K_AUD_S_SCALAR matches the withdraw +// fixture's auditor scalar so the two circuits share the same owner-auditor +// key in cross-circuit tooling. OP_I is a placeholder address-as-field; the +// wrapper computes it off-circuit via `address_to_field` (Section 2.7). +global SK: Field = 0xdead; +global WRAP: Field = 0xbeef; +global SIGMA: Field = 0x01; +global SIGMA_A: Field = 0x02; +global V: Field = 1000; +global R: Field = 42; +global V_A: Field = 300; +global V_NEW: Field = 700; +global R_E: Field = 0xfeedface; +global OP_I: Field = 0xfee1; + +// Placeholder operator secret scalar; Y_op = SK_OP * H. +global SK_OP: Field = 0xbabe; +// Placeholder owner-auditor secret scalar; K_aud_s = scalar * H. Matches the +// withdraw fixture so cross-circuit tooling sees the same K_aud_s. +global K_AUD_S_SCALAR: Field = 0xc0ffee; + +// Y = sk*H (= lib `scalar_mul_Y` fixture). +global Y_X: Field = 0x1b46b003b88a6c34549dc74115f088f4b231a151397526bc10cbf1d15b457646; +global Y_Y: Field = 0x29116280600c10ead1fdbd9ab4b571896030679bf554d7f1ebf681e5147de21b; + +// C_spend = commit(1000, 42) (= lib `commit` fixture). +global C_SPEND_X: Field = + 0x195a5d8ecd032fe1696054b28f0852b5f03a613681991ace823245ab2f97ed05; +global C_SPEND_Y: Field = + 0x0e67489ecfee0e581dce7db22a383c635886cfb616c3b3bb033bff0c46e9789e; + +// K_aud_s = K_AUD_S_SCALAR * H. R_e = R_E * H. Both pinned by the withdraw +// fixture (`withdraw_auditor_fixtures_match_lib`) since they reuse the same +// scalars. +global K_AUD_S_X: Field = + 0x22502c7b20b64aeaaaa4d4fa5f2f8600f9734e828f7284a8d1431da36b33803a; +global K_AUD_S_Y: Field = + 0x0419089353d24334f03f4a1c9c9b19282e32f6879c5c69b9cdffba6a13a7c5ed; +global R_E_X: Field = 0x114ed4fcf2c57014eb678c577aa02f30ef590b713d7a6a5e87702d1c7f71957f; +global R_E_Y: Field = 0x07a70cf826350d4f438c7a3c5e8761b0ae6cb63de757f0c96815f4057b9205f4; + +// C_spend' and b_tilde reuse the withdraw fixture: V_A = A = 300 here, and +// the owner-block randomness (vk, sigma) is identical, so both spendable- +// balance ciphertexts collapse to the withdraw values. Pinned by +// `withdraw_fixtures_match_lib` and re-asserted by +// `set_operator_fixtures_match_lib`. +global C_SPEND_NEW_X: Field = + 0x22f167015ee0e4b33dcb278cdc824c336c919dc4cd06f391850305d5083336e6; +global C_SPEND_NEW_Y: Field = + 0x0be3d0fda4f8f70b04e2d609db4e29b30345da9288bf208c7560572b2bef172d; +global B_TILDE: Field = + 0x04d1659db899a50a94dcfc54a18b8adaf6e9e8e3046bd11893fbd4a86a7c5670; + +// New values pinned by `set_operator_fixtures_match_lib` and +// `set_operator_auditor_fixtures_match_lib` (re-derived from the lib +// primitives -- if either side drifts the test fails immediately). Emitted by +// `print_fixtures`; the placeholder zeros below are filled in by running +// `nargo test --package circuit_set_operator print_fixtures --show-output`. +global Y_OP_X: Field = 0x0; +global Y_OP_Y: Field = 0x0; +global C_A_X: Field = 0x0; +global C_A_Y: Field = 0x0; +global A_TILDE: Field = 0x0; +global ESCROWED_DVK_CIPHER: Field = 0x0; +global V_TILDE_AUD_S: Field = 0x0; +global B_TILDE_AUD_S: Field = 0x0; + +#[test] +fn print_fixtures() { + // One-shot harness: prints every derived public input for the chosen + // fixture tuple. Run with + // `nargo test --package circuit_set_operator print_fixtures --show-output` + // and paste the values into the globals above. + let vk = vk_from_sk(SK, WRAP); + let dvk = dvk_from_vk_op(vk, OP_I); + let r_a = derive_allow_r(dvk, SIGMA_A); + let c_a = commit(V_A, r_a); + let a_tilde = encrypt_allowance(V_A, dvk, SIGMA_A); + + let r_new = derive_spend_r(vk, SIGMA); + let c_spend_new = commit(V_NEW, r_new); + let b_tilde = encrypt_balance(V_NEW, vk, SIGMA); + + let y_op = scalar_mul(SK_OP, H); + let s_esc = ecdh(R_E, y_op); + let escrowed_dvk_cipher = encrypt_esc_dvk(dvk, s_esc, OP_I); + + let k_aud_s = scalar_mul(K_AUD_S_SCALAR, H); + let r_e_pt = scalar_mul(R_E, H); + let s_a_s_x = ecdh(R_E, k_aud_s); + let m_s = sponge_squeeze_2(domain::AUDITOR_SENDER, s_a_s_x, SIGMA); + let v_tilde_aud_s = V_A + m_s[0]; + let b_tilde_aud_s = V_NEW + m_s[1]; + + let yox = y_op.x; + let yoy = y_op.y; + let cax = c_a.x; + let cay = c_a.y; + let csnx = c_spend_new.x; + let csny = c_spend_new.y; + let kx = k_aud_s.x; + let ky = k_aud_s.y; + let rex = r_e_pt.x; + let rey = r_e_pt.y; + println(f"Y_OP_X = {yox}"); + println(f"Y_OP_Y = {yoy}"); + println(f"C_A_X = {cax}"); + println(f"C_A_Y = {cay}"); + println(f"A_TILDE = {a_tilde}"); + println(f"C_SPEND_NEW_X = {csnx}"); + println(f"C_SPEND_NEW_Y = {csny}"); + println(f"B_TILDE = {b_tilde}"); + println(f"ESCROWED_DVK_CIPHER = {escrowed_dvk_cipher}"); + println(f"K_AUD_S_X = {kx}"); + println(f"K_AUD_S_Y = {ky}"); + println(f"R_E_X = {rex}"); + println(f"R_E_Y = {rey}"); + println(f"V_TILDE_AUD_S = {v_tilde_aud_s}"); + println(f"B_TILDE_AUD_S = {b_tilde_aud_s}"); +} + +#[test] +fn set_operator_fixtures_match_lib() { + let vk = vk_from_sk(SK, WRAP); + let dvk = dvk_from_vk_op(vk, OP_I); + let r_a = derive_allow_r(dvk, SIGMA_A); + let c_a = commit(V_A, r_a); + let a_tilde = encrypt_allowance(V_A, dvk, SIGMA_A); + let r_new = derive_spend_r(vk, SIGMA); + let c_new = commit(V_NEW, r_new); + let b_tilde = encrypt_balance(V_NEW, vk, SIGMA); + let y = scalar_mul(SK, H); + let c_spend = commit(V, R); + let y_op = scalar_mul(SK_OP, H); + + assert(y.x == Y_X); + assert(y.y == Y_Y); + assert(c_spend.x == C_SPEND_X); + assert(c_spend.y == C_SPEND_Y); + assert(y_op.x == Y_OP_X); + assert(y_op.y == Y_OP_Y); + assert(c_a.x == C_A_X); + assert(c_a.y == C_A_Y); + assert(a_tilde == A_TILDE); + assert(c_new.x == C_SPEND_NEW_X); + assert(c_new.y == C_SPEND_NEW_Y); + assert(b_tilde == B_TILDE); +} + +#[test] +fn set_operator_auditor_fixtures_match_lib() { + let vk = vk_from_sk(SK, WRAP); + let dvk = dvk_from_vk_op(vk, OP_I); + let y_op = scalar_mul(SK_OP, H); + let s_esc = ecdh(R_E, y_op); + let escrowed_dvk_cipher = encrypt_esc_dvk(dvk, s_esc, OP_I); + let k_aud_s = scalar_mul(K_AUD_S_SCALAR, H); + let r_e_pt = scalar_mul(R_E, H); + let s_a_s_x = ecdh(R_E, k_aud_s); + let m_s = sponge_squeeze_2(domain::AUDITOR_SENDER, s_a_s_x, SIGMA); + + assert(k_aud_s.x == K_AUD_S_X); + assert(k_aud_s.y == K_AUD_S_Y); + assert(r_e_pt.x == R_E_X); + assert(r_e_pt.y == R_E_Y); + assert(escrowed_dvk_cipher == ESCROWED_DVK_CIPHER); + assert(V_A + m_s[0] == V_TILDE_AUD_S); + assert(V_NEW + m_s[1] == B_TILDE_AUD_S); +} + +// `run_main` accepts all 24 public inputs as parameters -- nothing baked in. +// Tests that target a specific constraint construct the public tuple so only +// that constraint can fail: tamper the witness or public input under test +// AND recompute every downstream public that depends on it, so no cascaded +// mismatch coincidentally lights up the same `should_fail`. +fn run_main( + sk_in: Field, + v_in: Field, + r_in: Field, + v_a_in: Field, + r_e_in: Field, + c_spend_x_in: Field, + c_spend_y_in: Field, + y_x_in: Field, + y_y_in: Field, + y_op_x_in: Field, + y_op_y_in: Field, + op_i_in: Field, + wrap_in: Field, + k_aud_s_x_in: Field, + k_aud_s_y_in: Field, + c_spend_new_x_in: Field, + c_spend_new_y_in: Field, + c_a_x_in: Field, + c_a_y_in: Field, + escrowed_dvk_r_x_in: Field, + escrowed_dvk_cipher_in: Field, + b_tilde_in: Field, + a_tilde_in: Field, + sigma_in: Field, + sigma_a_in: Field, + r_e_x_in: Field, + r_e_y_in: Field, + v_tilde_aud_s_in: Field, + b_tilde_aud_s_in: Field, +) { + main( + sk_in, + v_in, + r_in, + v_a_in, + r_e_in, + c_spend_x_in, + c_spend_y_in, + y_x_in, + y_y_in, + y_op_x_in, + y_op_y_in, + op_i_in, + wrap_in, + k_aud_s_x_in, + k_aud_s_y_in, + c_spend_new_x_in, + c_spend_new_y_in, + c_a_x_in, + c_a_y_in, + escrowed_dvk_r_x_in, + escrowed_dvk_cipher_in, + b_tilde_in, + a_tilde_in, + sigma_in, + sigma_a_in, + r_e_x_in, + r_e_y_in, + v_tilde_aud_s_in, + b_tilde_aud_s_in, + ); +} + +fn run_fixture() { + run_main( + SK, + V, + R, + V_A, + R_E, + C_SPEND_X, + C_SPEND_Y, + Y_X, + Y_Y, + Y_OP_X, + Y_OP_Y, + OP_I, + WRAP, + K_AUD_S_X, + K_AUD_S_Y, + C_SPEND_NEW_X, + C_SPEND_NEW_Y, + C_A_X, + C_A_Y, + R_E_X, + ESCROWED_DVK_CIPHER, + B_TILDE, + A_TILDE, + SIGMA, + SIGMA_A, + R_E_X, + R_E_Y, + V_TILDE_AUD_S, + B_TILDE_AUD_S, + ); +} + +#[test] +fn matches_fixture() { + run_fixture(); +} + +#[test] +fn full_allowance_escrow() { + // Boundary: v_a = v, so v_new = 0. C_spend' commits to (0, r_new) which + // is a valid commitment to zero. b_tilde and b_tilde_aud_s both bind to 0 + // (plus mask) so the owner's auditor sees the post-escrow spendable + // balance reach zero. C_a binds to the full v. + let vk = vk_from_sk(SK, WRAP); + let dvk = dvk_from_vk_op(vk, OP_I); + let r_a = derive_allow_r(dvk, SIGMA_A); + let c_a = commit(V, r_a); + let a_tilde = encrypt_allowance(V, dvk, SIGMA_A); + let r_new = derive_spend_r(vk, SIGMA); + let c_new = commit(0, r_new); + let b_tilde = encrypt_balance(0, vk, SIGMA); + let y_op = EmbeddedCurvePoint { x: Y_OP_X, y: Y_OP_Y, is_infinite: false }; + let s_esc = ecdh(R_E, y_op); + let escrowed_dvk_cipher = encrypt_esc_dvk(dvk, s_esc, OP_I); + let k_aud_s = EmbeddedCurvePoint { x: K_AUD_S_X, y: K_AUD_S_Y, is_infinite: false }; + let s_a_s_x = ecdh(R_E, k_aud_s); + let m_s = sponge_squeeze_2(domain::AUDITOR_SENDER, s_a_s_x, SIGMA); + run_main( + SK, + V, + R, + V, + R_E, + C_SPEND_X, + C_SPEND_Y, + Y_X, + Y_Y, + Y_OP_X, + Y_OP_Y, + OP_I, + WRAP, + K_AUD_S_X, + K_AUD_S_Y, + c_new.x, + c_new.y, + c_a.x, + c_a.y, + R_E_X, + escrowed_dvk_cipher, + b_tilde, + a_tilde, + SIGMA, + SIGMA_A, + R_E_X, + R_E_Y, + V + m_s[0], + 0 + m_s[1], + ); +} + +#[test(should_fail)] +fn rejects_under_funded_escrow() { + // v_a > v: v - v_a underflows in the field to a value >= 2^128. S4 fires. + // Every downstream public is recomputed against v_a_too_large so the + // single firing constraint is S4's v_new range check. + let v_a_too_large: Field = V + 1; + let vk = vk_from_sk(SK, WRAP); + let dvk = dvk_from_vk_op(vk, OP_I); + let r_a = derive_allow_r(dvk, SIGMA_A); + let c_a_invalid = commit(v_a_too_large, r_a); + let a_tilde_invalid = encrypt_allowance(v_a_too_large, dvk, SIGMA_A); + let r_new = derive_spend_r(vk, SIGMA); + let c_new_invalid = commit(V - v_a_too_large, r_new); + let b_tilde_invalid = encrypt_balance(V - v_a_too_large, vk, SIGMA); + let y_op = EmbeddedCurvePoint { x: Y_OP_X, y: Y_OP_Y, is_infinite: false }; + let s_esc = ecdh(R_E, y_op); + let escrowed_dvk_cipher = encrypt_esc_dvk(dvk, s_esc, OP_I); + let k_aud_s = EmbeddedCurvePoint { x: K_AUD_S_X, y: K_AUD_S_Y, is_infinite: false }; + let s_a_s_x = ecdh(R_E, k_aud_s); + let m_s = sponge_squeeze_2(domain::AUDITOR_SENDER, s_a_s_x, SIGMA); + run_main( + SK, + V, + R, + v_a_too_large, + R_E, + C_SPEND_X, + C_SPEND_Y, + Y_X, + Y_Y, + Y_OP_X, + Y_OP_Y, + OP_I, + WRAP, + K_AUD_S_X, + K_AUD_S_Y, + c_new_invalid.x, + c_new_invalid.y, + c_a_invalid.x, + c_a_invalid.y, + R_E_X, + escrowed_dvk_cipher, + b_tilde_invalid, + a_tilde_invalid, + SIGMA, + SIGMA_A, + R_E_X, + R_E_Y, + v_a_too_large + m_s[0], + (V - v_a_too_large) + m_s[1], + ); +} + +#[test(should_fail)] +fn rejects_v_out_of_range() { + // 2^127 is exactly the boundary S4 rules out (Section 2.6 / 3.4 -- SEP-41 + // i128 non-negative range is [0, 2^127), so 2^127 is the smallest value + // that must fail). With v_huge - V_A = 2^127 - 300 still in [0, 2^127), + // the v_new range check passes; only the `v.assert_max_bit_size<127>()` + // sub-check of S4 fires. C_spend, c_spend_new, b_tilde, and b_tilde_aud_s + // are recomputed against v_huge / v_new_huge so S3 / S10 / S11 / S_a5 + // cannot coincidentally fire. + let v_huge: Field = 0x80000000000000000000000000000000; + let v_new_huge: Field = v_huge - V_A; + let vk = vk_from_sk(SK, WRAP); + let c_spend = commit(v_huge, R); + let r_new = derive_spend_r(vk, SIGMA); + let c_new = commit(v_new_huge, r_new); + let b_tilde = encrypt_balance(v_new_huge, vk, SIGMA); + let k_aud_s = EmbeddedCurvePoint { x: K_AUD_S_X, y: K_AUD_S_Y, is_infinite: false }; + let s_a_s_x = ecdh(R_E, k_aud_s); + let m_s = sponge_squeeze_2(domain::AUDITOR_SENDER, s_a_s_x, SIGMA); + run_main( + SK, + v_huge, + R, + V_A, + R_E, + c_spend.x, + c_spend.y, + Y_X, + Y_Y, + Y_OP_X, + Y_OP_Y, + OP_I, + WRAP, + K_AUD_S_X, + K_AUD_S_Y, + c_new.x, + c_new.y, + C_A_X, + C_A_Y, + R_E_X, + ESCROWED_DVK_CIPHER, + b_tilde, + A_TILDE, + SIGMA, + SIGMA_A, + R_E_X, + R_E_Y, + V_TILDE_AUD_S, + v_new_huge + m_s[1], + ); +} + +#[test(should_fail)] +fn rejects_v_a_out_of_range() { + // v_a = 2^127: S4's range check on v_a fires at the exact SEP-41 + // boundary. v_new = V - 2^127 field-underflows so the v_new range check + // would also fire, but both belong to S4. Every downstream public that + // depends on v_a (C_a, a_tilde, v_tilde_aud_s) and on v_new (c_spend_new, + // b_tilde, b_tilde_aud_s) is recomputed so the only constraints that fail + // are S4's two range sub-checks. + let v_a_huge: Field = 0x80000000000000000000000000000000; + let v_new_huge: Field = V - v_a_huge; + let vk = vk_from_sk(SK, WRAP); + let dvk = dvk_from_vk_op(vk, OP_I); + let r_a = derive_allow_r(dvk, SIGMA_A); + let c_a = commit(v_a_huge, r_a); + let a_tilde = encrypt_allowance(v_a_huge, dvk, SIGMA_A); + let r_new = derive_spend_r(vk, SIGMA); + let c_new = commit(v_new_huge, r_new); + let b_tilde = encrypt_balance(v_new_huge, vk, SIGMA); + let y_op = EmbeddedCurvePoint { x: Y_OP_X, y: Y_OP_Y, is_infinite: false }; + let s_esc = ecdh(R_E, y_op); + let escrowed_dvk_cipher = encrypt_esc_dvk(dvk, s_esc, OP_I); + let k_aud_s = EmbeddedCurvePoint { x: K_AUD_S_X, y: K_AUD_S_Y, is_infinite: false }; + let s_a_s_x = ecdh(R_E, k_aud_s); + let m_s = sponge_squeeze_2(domain::AUDITOR_SENDER, s_a_s_x, SIGMA); + run_main( + SK, + V, + R, + v_a_huge, + R_E, + C_SPEND_X, + C_SPEND_Y, + Y_X, + Y_Y, + Y_OP_X, + Y_OP_Y, + OP_I, + WRAP, + K_AUD_S_X, + K_AUD_S_Y, + c_new.x, + c_new.y, + c_a.x, + c_a.y, + R_E_X, + escrowed_dvk_cipher, + b_tilde, + a_tilde, + SIGMA, + SIGMA_A, + R_E_X, + R_E_Y, + v_a_huge + m_s[0], + v_new_huge + m_s[1], + ); +} + +#[test(should_fail)] +fn rejects_wrong_sk() { + // sk + 1 != sk: S1 fails because scalar_mul(sk+1, H) no longer matches Y. + // vk is rebound to (sk+1, wrap) and every vk-dependent public + // (c_spend_new, b_tilde, c_a, a_tilde, escrowed_dvk_cipher) is recomputed + // under the new vk so S10 / S11 / S7 / S8 / S12(c) cannot fire alongside + // S1. The auditor block does not depend on vk so its canonical values + // still satisfy S_a4 / S_a5. + let vk_bad = vk_from_sk(SK + 1, WRAP); + let dvk_bad = dvk_from_vk_op(vk_bad, OP_I); + let r_a_bad = derive_allow_r(dvk_bad, SIGMA_A); + let c_a_bad = commit(V_A, r_a_bad); + let a_tilde_bad = encrypt_allowance(V_A, dvk_bad, SIGMA_A); + let r_new_bad = derive_spend_r(vk_bad, SIGMA); + let c_new_bad = commit(V_NEW, r_new_bad); + let b_tilde_bad = encrypt_balance(V_NEW, vk_bad, SIGMA); + let y_op = EmbeddedCurvePoint { x: Y_OP_X, y: Y_OP_Y, is_infinite: false }; + let s_esc = ecdh(R_E, y_op); + let escrowed_dvk_cipher_bad = encrypt_esc_dvk(dvk_bad, s_esc, OP_I); + run_main( + SK + 1, + V, + R, + V_A, + R_E, + C_SPEND_X, + C_SPEND_Y, + Y_X, + Y_Y, + Y_OP_X, + Y_OP_Y, + OP_I, + WRAP, + K_AUD_S_X, + K_AUD_S_Y, + c_new_bad.x, + c_new_bad.y, + c_a_bad.x, + c_a_bad.y, + R_E_X, + escrowed_dvk_cipher_bad, + b_tilde_bad, + a_tilde_bad, + SIGMA, + SIGMA_A, + R_E_X, + R_E_Y, + V_TILDE_AUD_S, + B_TILDE_AUD_S, + ); +} + +#[test(should_fail)] +fn rejects_wrong_balance_opening() { + // S3 fires: commit(V+1, R) != C_spend. v_new = (V+1) - V_A cascades into + // S10 / S11 / S_a5; v_a is unchanged so S7 / S8 / S_a4 stay canonical. + // Recompute c_spend_new, b_tilde, and b_tilde_aud_s against v_new = 701 + // so only S3 fires. + let v_bad: Field = V + 1; + let v_new_bad: Field = v_bad - V_A; + let vk = vk_from_sk(SK, WRAP); + let r_new = derive_spend_r(vk, SIGMA); + let c_new_bad = commit(v_new_bad, r_new); + let b_tilde_bad = encrypt_balance(v_new_bad, vk, SIGMA); + let k_aud_s = EmbeddedCurvePoint { x: K_AUD_S_X, y: K_AUD_S_Y, is_infinite: false }; + let s_a_s_x = ecdh(R_E, k_aud_s); + let m_s = sponge_squeeze_2(domain::AUDITOR_SENDER, s_a_s_x, SIGMA); + run_main( + SK, + v_bad, + R, + V_A, + R_E, + C_SPEND_X, + C_SPEND_Y, + Y_X, + Y_Y, + Y_OP_X, + Y_OP_Y, + OP_I, + WRAP, + K_AUD_S_X, + K_AUD_S_Y, + c_new_bad.x, + c_new_bad.y, + C_A_X, + C_A_Y, + R_E_X, + ESCROWED_DVK_CIPHER, + b_tilde_bad, + A_TILDE, + SIGMA, + SIGMA_A, + R_E_X, + R_E_Y, + V_TILDE_AUD_S, + v_new_bad + m_s[1], + ); +} + +#[test(should_fail)] +fn rejects_wrong_wrap() { + // The circuit binds vk to wrap via S2 but doesn't assert wrap directly; + // the binding is visible only through vk-derived publics (S10, S11, S7, + // S8, S12(c)). With wrap_bad, recompute c_spend_new and c_a from the new + // vk_bad so S10 / S7 pass -- leaving b_tilde canonical, where the + // wrap-binding catches the mismatch via S11. (Recomputing everything, + // as in `rejects_wrong_sk`, would make the proof verify: S1 is the + // load-bearing wrap-independent check there, and no such constraint + // exists here.) + let wrap_bad: Field = 0xcafe; + let vk_bad = vk_from_sk(SK, wrap_bad); + let dvk_bad = dvk_from_vk_op(vk_bad, OP_I); + let r_a_bad = derive_allow_r(dvk_bad, SIGMA_A); + let c_a_bad = commit(V_A, r_a_bad); + let r_new_bad = derive_spend_r(vk_bad, SIGMA); + let c_new_bad = commit(V_NEW, r_new_bad); + run_main( + SK, + V, + R, + V_A, + R_E, + C_SPEND_X, + C_SPEND_Y, + Y_X, + Y_Y, + Y_OP_X, + Y_OP_Y, + OP_I, + wrap_bad, + K_AUD_S_X, + K_AUD_S_Y, + c_new_bad.x, + c_new_bad.y, + c_a_bad.x, + c_a_bad.y, + R_E_X, + ESCROWED_DVK_CIPHER, + B_TILDE, + A_TILDE, + SIGMA, + SIGMA_A, + R_E_X, + R_E_Y, + V_TILDE_AUD_S, + B_TILDE_AUD_S, + ); +} + +#[test(should_fail)] +fn rejects_wrong_op_i() { + // op_i changes the derived dvk (S5), which cascades into r_a (S6), C_a + // (S7), a_tilde (S8), and the escrow cipher (S12(c)). Keeping the + // canonical C_A / A_TILDE / ESCROWED_DVK_CIPHER public inputs forces all + // three of S7 / S8 / S12(c) to mismatch -- any one is sufficient to + // catch the wrong op_i. + let op_i_bad: Field = OP_I + 1; + run_main( + SK, + V, + R, + V_A, + R_E, + C_SPEND_X, + C_SPEND_Y, + Y_X, + Y_Y, + Y_OP_X, + Y_OP_Y, + op_i_bad, + WRAP, + K_AUD_S_X, + K_AUD_S_Y, + C_SPEND_NEW_X, + C_SPEND_NEW_Y, + C_A_X, + C_A_Y, + R_E_X, + ESCROWED_DVK_CIPHER, + B_TILDE, + A_TILDE, + SIGMA, + SIGMA_A, + R_E_X, + R_E_Y, + V_TILDE_AUD_S, + B_TILDE_AUD_S, + ); +} + +#[test(should_fail)] +fn rejects_tampered_c_a() { + // S7 fires: C_a + 1 no longer matches commit(v_a, r_a). + run_main( + SK, + V, + R, + V_A, + R_E, + C_SPEND_X, + C_SPEND_Y, + Y_X, + Y_Y, + Y_OP_X, + Y_OP_Y, + OP_I, + WRAP, + K_AUD_S_X, + K_AUD_S_Y, + C_SPEND_NEW_X, + C_SPEND_NEW_Y, + C_A_X + 1, + C_A_Y, + R_E_X, + ESCROWED_DVK_CIPHER, + B_TILDE, + A_TILDE, + SIGMA, + SIGMA_A, + R_E_X, + R_E_Y, + V_TILDE_AUD_S, + B_TILDE_AUD_S, + ); +} + +#[test(should_fail)] +fn rejects_tampered_a_tilde() { + // S8 fires: a_tilde + 1 no longer matches + // v_a + Poseidon2(ENC_ALLOW, dvk, sigma_a). + run_main( + SK, + V, + R, + V_A, + R_E, + C_SPEND_X, + C_SPEND_Y, + Y_X, + Y_Y, + Y_OP_X, + Y_OP_Y, + OP_I, + WRAP, + K_AUD_S_X, + K_AUD_S_Y, + C_SPEND_NEW_X, + C_SPEND_NEW_Y, + C_A_X, + C_A_Y, + R_E_X, + ESCROWED_DVK_CIPHER, + B_TILDE, + A_TILDE + 1, + SIGMA, + SIGMA_A, + R_E_X, + R_E_Y, + V_TILDE_AUD_S, + B_TILDE_AUD_S, + ); +} + +#[test(should_fail)] +fn rejects_tampered_c_spend_new() { + // S10 fires. + run_main( + SK, + V, + R, + V_A, + R_E, + C_SPEND_X, + C_SPEND_Y, + Y_X, + Y_Y, + Y_OP_X, + Y_OP_Y, + OP_I, + WRAP, + K_AUD_S_X, + K_AUD_S_Y, + C_SPEND_NEW_X + 1, + C_SPEND_NEW_Y, + C_A_X, + C_A_Y, + R_E_X, + ESCROWED_DVK_CIPHER, + B_TILDE, + A_TILDE, + SIGMA, + SIGMA_A, + R_E_X, + R_E_Y, + V_TILDE_AUD_S, + B_TILDE_AUD_S, + ); +} + +#[test(should_fail)] +fn rejects_tampered_b_tilde() { + // S11 fires. + run_main( + SK, + V, + R, + V_A, + R_E, + C_SPEND_X, + C_SPEND_Y, + Y_X, + Y_Y, + Y_OP_X, + Y_OP_Y, + OP_I, + WRAP, + K_AUD_S_X, + K_AUD_S_Y, + C_SPEND_NEW_X, + C_SPEND_NEW_Y, + C_A_X, + C_A_Y, + R_E_X, + ESCROWED_DVK_CIPHER, + B_TILDE + 1, + A_TILDE, + SIGMA, + SIGMA_A, + R_E_X, + R_E_Y, + V_TILDE_AUD_S, + B_TILDE_AUD_S, + ); +} + +#[test(should_fail)] +fn rejects_tampered_escrowed_dvk_r_x() { + // S12(a) fires: escrowed_dvk_r_x must equal R_e.x. Tamper the R_x limb + // by +1 while keeping the canonical R_e (r_e_x / r_e_y from S_a1 + // unchanged) so the auditor block proceeds normally; only S12(a) catches + // the mismatch. + run_main( + SK, + V, + R, + V_A, + R_E, + C_SPEND_X, + C_SPEND_Y, + Y_X, + Y_Y, + Y_OP_X, + Y_OP_Y, + OP_I, + WRAP, + K_AUD_S_X, + K_AUD_S_Y, + C_SPEND_NEW_X, + C_SPEND_NEW_Y, + C_A_X, + C_A_Y, + R_E_X + 1, + ESCROWED_DVK_CIPHER, + B_TILDE, + A_TILDE, + SIGMA, + SIGMA_A, + R_E_X, + R_E_Y, + V_TILDE_AUD_S, + B_TILDE_AUD_S, + ); +} + +#[test(should_fail)] +fn rejects_tampered_escrowed_dvk_cipher() { + // S12(c) fires: cipher + 1 no longer matches dvk + Poseidon2(ESC_DVK, + // s_esc, op_i). + run_main( + SK, + V, + R, + V_A, + R_E, + C_SPEND_X, + C_SPEND_Y, + Y_X, + Y_Y, + Y_OP_X, + Y_OP_Y, + OP_I, + WRAP, + K_AUD_S_X, + K_AUD_S_Y, + C_SPEND_NEW_X, + C_SPEND_NEW_Y, + C_A_X, + C_A_Y, + R_E_X, + ESCROWED_DVK_CIPHER + 1, + B_TILDE, + A_TILDE, + SIGMA, + SIGMA_A, + R_E_X, + R_E_Y, + V_TILDE_AUD_S, + B_TILDE_AUD_S, + ); +} + +#[test(should_fail)] +fn rejects_wrong_y_op() { + // Y_op replaced by a different on-curve key (Y_X, Y_Y -- the owner's + // own Y, which is valid on-curve). s_esc = r_e * Y_op changes, so + // escrowed_dvk_cipher under the canonical dvk no longer matches and + // S12(c) fails. (Y_op is path (2) of Section 10.8: trusted on read from + // the wrapper, so the in-circuit consequence of swapping it is purely + // through the escrow-cipher mask.) + run_main( + SK, + V, + R, + V_A, + R_E, + C_SPEND_X, + C_SPEND_Y, + Y_X, + Y_Y, + Y_X, + Y_Y, + OP_I, + WRAP, + K_AUD_S_X, + K_AUD_S_Y, + C_SPEND_NEW_X, + C_SPEND_NEW_Y, + C_A_X, + C_A_Y, + R_E_X, + ESCROWED_DVK_CIPHER, + B_TILDE, + A_TILDE, + SIGMA, + SIGMA_A, + R_E_X, + R_E_Y, + V_TILDE_AUD_S, + B_TILDE_AUD_S, + ); +} + +#[test(should_fail)] +fn rejects_r_e_zero() { + // S13 fires: r_e = 0 would force R_e = O (identity), collapse S_{a,s} to + // O, and collapse the escrow shared secret s_esc to 0 -- making every + // mask a knowable constant function of sigma / op_i. Every r_e-dependent + // public (R_e = (0, 0) identity-encoding, escrowed_dvk_cipher derived + // against s_esc = 0, and the auditor channel ciphertexts derived against + // s_a_s_x = 0) is recomputed so the `assert(r_e != 0)` check is the only + // constraint that can catch this -- removing S13 would let the proof + // verify. + let vk = vk_from_sk(SK, WRAP); + let dvk = dvk_from_vk_op(vk, OP_I); + let y_op = EmbeddedCurvePoint { x: Y_OP_X, y: Y_OP_Y, is_infinite: false }; + let k_aud_s = EmbeddedCurvePoint { x: K_AUD_S_X, y: K_AUD_S_Y, is_infinite: false }; + let s_esc_zero = ecdh(0, y_op); + let escrowed_dvk_cipher_zero = encrypt_esc_dvk(dvk, s_esc_zero, OP_I); + let s_a_s_x_zero = ecdh(0, k_aud_s); + let m_s_zero = sponge_squeeze_2(domain::AUDITOR_SENDER, s_a_s_x_zero, SIGMA); + run_main( + SK, + V, + R, + V_A, + 0, + C_SPEND_X, + C_SPEND_Y, + Y_X, + Y_Y, + Y_OP_X, + Y_OP_Y, + OP_I, + WRAP, + K_AUD_S_X, + K_AUD_S_Y, + C_SPEND_NEW_X, + C_SPEND_NEW_Y, + C_A_X, + C_A_Y, + 0, + escrowed_dvk_cipher_zero, + B_TILDE, + A_TILDE, + SIGMA, + SIGMA_A, + 0, + 0, + V_A + m_s_zero[0], + V_NEW + m_s_zero[1], + ); +} + +#[test(should_fail)] +fn rejects_wrong_r_e() { + // r_e mismatches the public R_e: S_a1 fails because scalar_mul(r_e+1, H) + // no longer equals the canonical (R_E_X, R_E_Y). R_e itself is left + // canonical (so S_a1 catches the mismatch) but every other r_e-dependent + // public (escrowed_dvk_cipher, both auditor channel ciphertexts) is + // recomputed against r_e+1 so S12(c) / S_a4 / S_a5 cannot coincidentally + // fire. escrowed_dvk_r_x is also left canonical at R_E_X so S12(a) keeps + // matching the public R_e -- the only catchable mismatch is S_a1's + // derived point. + let r_e_bad: Field = R_E + 1; + let vk = vk_from_sk(SK, WRAP); + let dvk = dvk_from_vk_op(vk, OP_I); + let y_op = EmbeddedCurvePoint { x: Y_OP_X, y: Y_OP_Y, is_infinite: false }; + let k_aud_s = EmbeddedCurvePoint { x: K_AUD_S_X, y: K_AUD_S_Y, is_infinite: false }; + let s_esc_bad = ecdh(r_e_bad, y_op); + let escrowed_dvk_cipher_bad = encrypt_esc_dvk(dvk, s_esc_bad, OP_I); + let s_a_s_x_bad = ecdh(r_e_bad, k_aud_s); + let m_s_bad = sponge_squeeze_2(domain::AUDITOR_SENDER, s_a_s_x_bad, SIGMA); + run_main( + SK, + V, + R, + V_A, + r_e_bad, + C_SPEND_X, + C_SPEND_Y, + Y_X, + Y_Y, + Y_OP_X, + Y_OP_Y, + OP_I, + WRAP, + K_AUD_S_X, + K_AUD_S_Y, + C_SPEND_NEW_X, + C_SPEND_NEW_Y, + C_A_X, + C_A_Y, + R_E_X, + escrowed_dvk_cipher_bad, + B_TILDE, + A_TILDE, + SIGMA, + SIGMA_A, + R_E_X, + R_E_Y, + V_A + m_s_bad[0], + V_NEW + m_s_bad[1], + ); +} + +#[test(should_fail)] +fn rejects_off_curve_k_aud_s() { + // (1, 2) is off-curve. The on-curve check on K_aud_s fires before S_a2 + // ever consumes it. + run_main( + SK, + V, + R, + V_A, + R_E, + C_SPEND_X, + C_SPEND_Y, + Y_X, + Y_Y, + Y_OP_X, + Y_OP_Y, + OP_I, + WRAP, + 1, + 2, + C_SPEND_NEW_X, + C_SPEND_NEW_Y, + C_A_X, + C_A_Y, + R_E_X, + ESCROWED_DVK_CIPHER, + B_TILDE, + A_TILDE, + SIGMA, + SIGMA_A, + R_E_X, + R_E_Y, + V_TILDE_AUD_S, + B_TILDE_AUD_S, + ); +} + +#[test(should_fail)] +fn rejects_identity_k_aud_s() { + // K_aud_s = identity collapses ECDH; the on-curve check rejects identity + // for keys. + run_main( + SK, + V, + R, + V_A, + R_E, + C_SPEND_X, + C_SPEND_Y, + Y_X, + Y_Y, + Y_OP_X, + Y_OP_Y, + OP_I, + WRAP, + 0, + 0, + C_SPEND_NEW_X, + C_SPEND_NEW_Y, + C_A_X, + C_A_Y, + R_E_X, + ESCROWED_DVK_CIPHER, + B_TILDE, + A_TILDE, + SIGMA, + SIGMA_A, + R_E_X, + R_E_Y, + V_TILDE_AUD_S, + B_TILDE_AUD_S, + ); +} + +#[test(should_fail)] +fn rejects_wrong_k_aud_s() { + // K_aud_s replaced by a valid but different on-curve key (Y_X, Y_Y). + // Shared secret changes, owner-channel masks change, S_a4 fails. + run_main( + SK, + V, + R, + V_A, + R_E, + C_SPEND_X, + C_SPEND_Y, + Y_X, + Y_Y, + Y_OP_X, + Y_OP_Y, + OP_I, + WRAP, + Y_X, + Y_Y, + C_SPEND_NEW_X, + C_SPEND_NEW_Y, + C_A_X, + C_A_Y, + R_E_X, + ESCROWED_DVK_CIPHER, + B_TILDE, + A_TILDE, + SIGMA, + SIGMA_A, + R_E_X, + R_E_Y, + V_TILDE_AUD_S, + B_TILDE_AUD_S, + ); +} + +#[test(should_fail)] +fn rejects_tampered_v_tilde_aud_s() { + // S_a4 fires. + run_main( + SK, + V, + R, + V_A, + R_E, + C_SPEND_X, + C_SPEND_Y, + Y_X, + Y_Y, + Y_OP_X, + Y_OP_Y, + OP_I, + WRAP, + K_AUD_S_X, + K_AUD_S_Y, + C_SPEND_NEW_X, + C_SPEND_NEW_Y, + C_A_X, + C_A_Y, + R_E_X, + ESCROWED_DVK_CIPHER, + B_TILDE, + A_TILDE, + SIGMA, + SIGMA_A, + R_E_X, + R_E_Y, + V_TILDE_AUD_S + 1, + B_TILDE_AUD_S, + ); +} + +#[test(should_fail)] +fn rejects_tampered_b_tilde_aud_s() { + // S_a5 fires. + run_main( + SK, + V, + R, + V_A, + R_E, + C_SPEND_X, + C_SPEND_Y, + Y_X, + Y_Y, + Y_OP_X, + Y_OP_Y, + OP_I, + WRAP, + K_AUD_S_X, + K_AUD_S_Y, + C_SPEND_NEW_X, + C_SPEND_NEW_Y, + C_A_X, + C_A_Y, + R_E_X, + ESCROWED_DVK_CIPHER, + B_TILDE, + A_TILDE, + SIGMA, + SIGMA_A, + R_E_X, + R_E_Y, + V_TILDE_AUD_S, + B_TILDE_AUD_S + 1, + ); +} From c528cae941c6d28791de505fdba2fb7d7cc24169 Mon Sep 17 00:00:00 2001 From: brozorec <9572072+brozorec@users.noreply.github.com> Date: Wed, 27 May 2026 11:10:11 +0200 Subject: [PATCH 12/15] chore(confidential): pin set_operator fixtures and constraint baseline Pastes the values emitted by `print_fixtures` into the tests.nr globals and adds circuit_set_operator's row to constraints.baseline (128 ACIR opcodes, 72 Brillig). `set_operator_fixtures_match_lib` and `set_operator_auditor_fixtures_match_lib` re-derive the same values from the lib primitives so a future lib drift fails the test immediately. --- .../circuits/constraints.baseline | 4 ++++ .../circuits/set_operator/src/tests.nr | 23 +++++++++++-------- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/packages/tokens/src/confidential/circuits/constraints.baseline b/packages/tokens/src/confidential/circuits/constraints.baseline index 37eb2232c..3e06466bb 100644 --- a/packages/tokens/src/confidential/circuits/constraints.baseline +++ b/packages/tokens/src/confidential/circuits/constraints.baseline @@ -22,6 +22,10 @@ | circuit_register | directive_invert | N/A | N/A | 9 | | circuit_register | lte_hint | N/A | N/A | 33 | | circuit_register | main | Bounded { width: 4 } | 33 | 72 | +| circuit_set_operator | decompose_hint | N/A | N/A | 30 | +| circuit_set_operator | directive_invert | N/A | N/A | 9 | +| circuit_set_operator | lte_hint | N/A | N/A | 33 | +| circuit_set_operator | main | Bounded { width: 4 } | 128 | 72 | | circuit_withdraw | decompose_hint | N/A | N/A | 30 | | circuit_withdraw | directive_invert | N/A | N/A | 9 | | circuit_withdraw | lte_hint | N/A | N/A | 33 | diff --git a/packages/tokens/src/confidential/circuits/set_operator/src/tests.nr b/packages/tokens/src/confidential/circuits/set_operator/src/tests.nr index 9ffe3c2fb..6c7cd84d8 100644 --- a/packages/tokens/src/confidential/circuits/set_operator/src/tests.nr +++ b/packages/tokens/src/confidential/circuits/set_operator/src/tests.nr @@ -72,16 +72,19 @@ global B_TILDE: Field = // New values pinned by `set_operator_fixtures_match_lib` and // `set_operator_auditor_fixtures_match_lib` (re-derived from the lib // primitives -- if either side drifts the test fails immediately). Emitted by -// `print_fixtures`; the placeholder zeros below are filled in by running -// `nargo test --package circuit_set_operator print_fixtures --show-output`. -global Y_OP_X: Field = 0x0; -global Y_OP_Y: Field = 0x0; -global C_A_X: Field = 0x0; -global C_A_Y: Field = 0x0; -global A_TILDE: Field = 0x0; -global ESCROWED_DVK_CIPHER: Field = 0x0; -global V_TILDE_AUD_S: Field = 0x0; -global B_TILDE_AUD_S: Field = 0x0; +// `print_fixtures`. +global Y_OP_X: Field = 0x08a500f1d05ebcba6cdbe9c246475e406f28ee2285fdfa4ed9781013ebb1571e; +global Y_OP_Y: Field = 0x2264cba150d089d7678ee412307b118826efb9e4c0f1b4ae10a851d63e83c632; +global C_A_X: Field = 0x1c673e9370f2dcf333e4c63d6faf3957e5a667017406540713b248583c5ae7d7; +global C_A_Y: Field = 0x1f9f86c84bed1f091c282300a063e4ff38082a63aeec17aef054d5ece0c3ebcc; +global A_TILDE: Field = + 0x0c4647bbcd2819afdcd6facf2037861da37e68008f3a40ec2f8db6487e1355c1; +global ESCROWED_DVK_CIPHER: Field = + 0x0d9a3beebbda646b71859a11420a74f8b3fe645d9f235f862733554e1d928405; +global V_TILDE_AUD_S: Field = + 0x0a607feb31e56e89ed94a3e1dc3811d3d23aaf10b2f27874ce670878b9f6eee1; +global B_TILDE_AUD_S: Field = + 0x0b586defafdf4583d0f338855fdf8391102adaac4634a3b80594aea6f788e103; #[test] fn print_fixtures() { From 29b1327107cdc9bd86af25714b9910a03be70c58 Mon Sep 17 00:00:00 2001 From: brozorec <9572072+brozorec@users.noreply.github.com> Date: Wed, 27 May 2026 11:10:11 +0200 Subject: [PATCH 13/15] feat(confidential): publish set_operator verification key VK extracted via `bash scripts/extract_vks.sh` after the SetOperator circuit landed in the workspace. Same UltraHonk universal-SRS pipeline as the register / withdraw / transfer VKs -- no new trusted-setup contribution required. The committed JSON is the integration contract with the on-chain verifier (#701); CI re-runs the extraction script and diffs against this copy. --- .../tokens/src/confidential/circuits/vks/set_operator.vk.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 packages/tokens/src/confidential/circuits/vks/set_operator.vk.json diff --git a/packages/tokens/src/confidential/circuits/vks/set_operator.vk.json b/packages/tokens/src/confidential/circuits/vks/set_operator.vk.json new file mode 100644 index 000000000..486d96cd8 --- /dev/null +++ b/packages/tokens/src/confidential/circuits/vks/set_operator.vk.json @@ -0,0 +1 @@ +["0x0000000000000000000000000000000000000000000000000000000000008000","0x0000000000000000000000000000000000000000000000000000000000000028","0x0000000000000000000000000000000000000000000000000000000000000001","0x0000000000000000000000000000000000000000000000000000000000000018","0x000000000000000000000000000000af741bbc6a67e952c6d36e7f155f2c5d62","0x0000000000000000000000000000000000282f11933bfa855033758665cfcf2e","0x000000000000000000000000000000bee0f10a961a22f16bc2647af867a8fea7","0x000000000000000000000000000000000008ece09d1c43e4a181f781db4a2ef0","0x00000000000000000000000000000030365887618dc69bfb76ce0e6875317282","0x0000000000000000000000000000000000211e193ab33aa08af64bc18a5af1ff","0x000000000000000000000000000000d5d927e5c40e3ceeaeefa34be1728c2af4","0x00000000000000000000000000000000002014f55fbdd42c8648d23d20a2992a","0x000000000000000000000000000000bed1fb17366cf057671bfa3a3163cbc8ee","0x00000000000000000000000000000000000d28b5f777452b4771f33127f5141b","0x0000000000000000000000000000009aedcf0f24f6bee77974be0725e93dbb0b","0x000000000000000000000000000000000001fb7969ea25b130416a5be29dfb4e","0x00000000000000000000000000000065e649f7147528599123d87191d266645a","0x00000000000000000000000000000000002b328eb1b18b4ad48a653c7fd6ae58","0x000000000000000000000000000000382f9d91347bd46d20cc4548c2d520d3c9","0x000000000000000000000000000000000003ecc7f63f645d9c7745589b2e49ba","0x000000000000000000000000000000f06d0426ed1ccc60aa55ea8fae49810141","0x000000000000000000000000000000000017310115dc8e996e3b81db7adb2bfb","0x000000000000000000000000000000de50b2d5d86b66b110fe464f719baf0659","0x0000000000000000000000000000000000021479f83761f88133f85450a49b7d","0x00000000000000000000000000000025734b7bf5acc189e2d4de6dd99bfd3324","0x00000000000000000000000000000000002fbd888b08a201e65f32c88b306f47","0x000000000000000000000000000000c7f7fd7c1416bb1f9d1fa7ab3c14772112","0x00000000000000000000000000000000001d9130fe22f8c15d5959ecd73ee46b","0x0000000000000000000000000000005738f222d113ad96d990e7d4a004ec2e57","0x000000000000000000000000000000000014e35bf3517c8beabf7ec116bd4c00","0x000000000000000000000000000000d89185b10a6c32362672867293fa6e87a6","0x00000000000000000000000000000000002cbca48853bc69fafe318ba9e1f9cc","0x0000000000000000000000000000000f00793f87b5c1d5b242345ab296149c78","0x000000000000000000000000000000000011796e2890542c47dd2878d5064348","0x000000000000000000000000000000967cc2b3b71056e18a6b2a0a5f475416aa","0x00000000000000000000000000000000002b786c6572fa473179e1a0d0859119","0x0000000000000000000000000000004bbfac405f6fba3e9203daabba8c8d50dc","0x00000000000000000000000000000000000a2b0d71e6501bf80141d4d54ecfa8","0x00000000000000000000000000000043aa328e5b93ef1b6a49708e17f70e57d9","0x00000000000000000000000000000000000905b7025feb236c1391dfb7f1d3d6","0x0000000000000000000000000000007467fa5cd63eb877038357784611bcbf96","0x0000000000000000000000000000000000062cd5671d9ff5b762533a875aef7e","0x000000000000000000000000000000e5051ead1beb62b390cb9452f60648228f","0x00000000000000000000000000000000001f5476797c313c0c0e04004a18b0c2","0x000000000000000000000000000000494c73f4bd803d7c7cd5e33743c803b5e1","0x000000000000000000000000000000000024e9c0b88577e352fed15dc5ebc12f","0x000000000000000000000000000000826ecc75e25e193a958064ca4a6c911416","0x00000000000000000000000000000000001920a77c2b6c370c8a8f6f3dc431c0","0x000000000000000000000000000000a5abcf419507b69df9f52bd4afd94cd31c","0x00000000000000000000000000000000001d944b8998e2814fb148cb55ef2e45","0x0000000000000000000000000000004763c759ea33aa289f20ccb853c322c035","0x0000000000000000000000000000000000202cb5630d6984a4e0736056e2d566","0x00000000000000000000000000000037b43ac733272d38959d94e86b2899b9c8","0x00000000000000000000000000000000002e31746e1b51b34140f88bf5f52b45","0x00000000000000000000000000000084e882ef445d12237d96fec58951bdfd71","0x00000000000000000000000000000000002293b99fe6f2c5c7de5a5b0e125759","0x0000000000000000000000000000005c7dbfbc3e9ef02d417577f506c63f852f","0x00000000000000000000000000000000001257dc481e3d07b30718d024add5da","0x000000000000000000000000000000bcfdee6187db5c810aa4033f866fd249d0","0x000000000000000000000000000000000022d298c62c1ee88e83bd5fa17a55f5","0x000000000000000000000000000000484d841b7afa15bec83e58632aa9efebf7","0x00000000000000000000000000000000001fe93a498410be5a6394a6d4fd13fd","0x00000000000000000000000000000064bf260c4879d3a2d37acb41dac7f61903","0x000000000000000000000000000000000002af639b1b55bc406fa80e09cc199c","0x000000000000000000000000000000598f821e2351f22e3bd2cade51593fe02b","0x00000000000000000000000000000000001297e789101a8fd168f6e3f3a27ebc","0x00000000000000000000000000000055001992954d5bba254ff615c285147159","0x00000000000000000000000000000000002d9bbb9e8f14f94a86c6487521a031","0x000000000000000000000000000000ce22e88f1905ec5d0af3ae3b673a203686","0x00000000000000000000000000000000002c0efd2faeccf8b4369bcda4cc4c92","0x0000000000000000000000000000000f68fa5f9023d05175db668737c7224b69","0x00000000000000000000000000000000000018f9559d1a4ad4cf03d0c0a60763","0x0000000000000000000000000000003978108d8eaa0a95383e51bf5ee7f67fe0","0x00000000000000000000000000000000000e7063b1c5f2b64f65704ccb7a403d","0x00000000000000000000000000000028f169d2343e35af2e9d55086d9aeed28e","0x00000000000000000000000000000000002d2cc50f61fb11f6ae63d82b0f0d4a","0x000000000000000000000000000000947ba048d9f8128a4a46f71add6a08a377","0x00000000000000000000000000000000002747cd2e8300fd25c57d3f12506bf4","0x0000000000000000000000000000000b0107b76dd648cf7641a25935f9491500","0x0000000000000000000000000000000000151691fcb29cf546758bc4437a3428","0x000000000000000000000000000000bcc729a93d82a1a0e5fc20e84b2ed99ed2","0x000000000000000000000000000000000029d7dbfee0f5bc7b5d262890d95ebb","0x000000000000000000000000000000fc957c8baaa583984b20fbe37988dede18","0x0000000000000000000000000000000000255ba5659322161023c9b1645fcbb7","0x0000000000000000000000000000000749ae19418d7b1784271b8c136fda8e70","0x00000000000000000000000000000000001828f167b539d23a2772f108369249","0x0000000000000000000000000000005eec000a5b56a2200314d5d7c0f0e792f9","0x00000000000000000000000000000000002ff5ffea5f5f8ed3e43a47c414d3d8","0x000000000000000000000000000000ffad1d95a8ee7ccd2287a489375de526f6","0x00000000000000000000000000000000002549709cd2124b19800eed4f003e52","0x00000000000000000000000000000043c4b44d03e114c03c1689f77c5789643c","0x00000000000000000000000000000000000621edcb5a9eab5ae5675b4da9d9a2","0x000000000000000000000000000000d0492104db91551b307d94f2ab08cc79f4","0x000000000000000000000000000000000025f2f754a0726238075d9def0babb9","0x000000000000000000000000000000cb74279f288c14712784e2fa6490e4536b","0x00000000000000000000000000000000001cb622d0b3b58b69ee8edce2674300","0x000000000000000000000000000000e2295164532fcce82a3c013e597c594ef8","0x00000000000000000000000000000000001189d45faed739fc4e16a3dd3f9b7b","0x00000000000000000000000000000019a9d0d71575acbc5f5960590bd2f40a84","0x00000000000000000000000000000000000c0d0b7c8a283361906d364db189df","0x000000000000000000000000000000ffec2c04e68f5bbfb7a031a8dfe73f8bc6","0x00000000000000000000000000000000000a3ebe2336d65cff7e94ebb4a6f2df","0x0000000000000000000000000000000e2e091047811681c2716df0d71df042c8","0x00000000000000000000000000000000001ced77a29b750aec5c8fa1e3c63876","0x0000000000000000000000000000000000000000000000000000000000000001","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000002","0x0000000000000000000000000000000000000000000000000000000000000000","0x000000000000000000000000000000316c235e48b672f047686b9dccdb3488bf","0x000000000000000000000000000000000011d1ddaa314c817931850bf4cd1719","0x000000000000000000000000000000840079fa058847628841ff923fc99e3182","0x00000000000000000000000000000000002b4e33687739e87be590d132c113f9"] \ No newline at end of file From 1f70ddd269ebb178d53ed6984a6bee2559b8e35e Mon Sep 17 00:00:00 2001 From: brozorec <9572072+brozorec@users.noreply.github.com> Date: Wed, 27 May 2026 11:29:30 +0200 Subject: [PATCH 14/15] chore(confidential): rename print_fixtures local to dodge typos linter `yoy` (Y_op.y in print_fixtures) trips the workflow-pinned typos ruleset, which flags it as a misspelling of `you`. Rename `yox` / `yoy` to `yopx` / `yopy` to match the pattern (Y_op-x, Y_op-y) and avoid the substring match. Test-only change; no circuit semantics affected. --- .../src/confidential/circuits/set_operator/src/tests.nr | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/tokens/src/confidential/circuits/set_operator/src/tests.nr b/packages/tokens/src/confidential/circuits/set_operator/src/tests.nr index 6c7cd84d8..897ee522b 100644 --- a/packages/tokens/src/confidential/circuits/set_operator/src/tests.nr +++ b/packages/tokens/src/confidential/circuits/set_operator/src/tests.nr @@ -113,8 +113,8 @@ fn print_fixtures() { let v_tilde_aud_s = V_A + m_s[0]; let b_tilde_aud_s = V_NEW + m_s[1]; - let yox = y_op.x; - let yoy = y_op.y; + let yopx = y_op.x; + let yopy = y_op.y; let cax = c_a.x; let cay = c_a.y; let csnx = c_spend_new.x; @@ -123,8 +123,8 @@ fn print_fixtures() { let ky = k_aud_s.y; let rex = r_e_pt.x; let rey = r_e_pt.y; - println(f"Y_OP_X = {yox}"); - println(f"Y_OP_Y = {yoy}"); + println(f"Y_OP_X = {yopx}"); + println(f"Y_OP_Y = {yopy}"); println(f"C_A_X = {cax}"); println(f"C_A_Y = {cay}"); println(f"A_TILDE = {a_tilde}"); From 5ee2ea1c6c74e6b3a2b2020e78475db00268d392 Mon Sep 17 00:00:00 2001 From: brozorec <9572072+brozorec@users.noreply.github.com> Date: Fri, 29 May 2026 11:38:10 +0200 Subject: [PATCH 15/15] ci(confidential): sort constraints baseline by LC_ALL=C byte order CI runs LC_ALL=C sort to normalize nargo info output; the baseline rows were in insertion order instead of sort order, so check-test-info failed after the set_operator circuit was added. --- .../confidential/circuits/constraints.baseline | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/tokens/src/confidential/circuits/constraints.baseline b/packages/tokens/src/confidential/circuits/constraints.baseline index 6e8dfbf72..8363906f9 100644 --- a/packages/tokens/src/confidential/circuits/constraints.baseline +++ b/packages/tokens/src/confidential/circuits/constraints.baseline @@ -22,18 +22,18 @@ | circuit_register | directive_invert | N/A | N/A | 9 | | circuit_register | lte_hint | N/A | N/A | 33 | | circuit_register | main | Bounded { width: 4 } | 33 | 72 | -| circuit_withdraw | decompose_hint | N/A | N/A | 30 | -| circuit_withdraw | directive_invert | N/A | N/A | 9 | -| circuit_withdraw | lte_hint | N/A | N/A | 33 | -| circuit_withdraw | main | Bounded { width: 4 } | 92 | 72 | -| circuit_transfer | decompose_hint | N/A | N/A | 30 | -| circuit_transfer | directive_invert | N/A | N/A | 9 | -| circuit_transfer | lte_hint | N/A | N/A | 33 | -| circuit_transfer | main | Bounded { width: 4 } | 129 | 72 | | circuit_set_operator | decompose_hint | N/A | N/A | 30 | | circuit_set_operator | directive_invert | N/A | N/A | 9 | | circuit_set_operator | lte_hint | N/A | N/A | 33 | | circuit_set_operator | main | Bounded { width: 4 } | 128 | 72 | +| circuit_transfer | decompose_hint | N/A | N/A | 30 | +| circuit_transfer | directive_invert | N/A | N/A | 9 | +| circuit_transfer | lte_hint | N/A | N/A | 33 | +| circuit_transfer | main | Bounded { width: 4 } | 129 | 72 | +| circuit_withdraw | decompose_hint | N/A | N/A | 30 | +| circuit_withdraw | directive_invert | N/A | N/A | 9 | +| circuit_withdraw | lte_hint | N/A | N/A | 33 | +| circuit_withdraw | main | Bounded { width: 4 } | 92 | 72 | | gadget_assert_on_curve | main | Bounded { width: 4 } | 2 | 0 | | gadget_commit | decompose_hint | N/A | N/A | 30 | | gadget_commit | lte_hint | N/A | N/A | 33 |