-
Notifications
You must be signed in to change notification settings - Fork 57
feat(confidential): Noir operator_transfer circuit #731
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
18 commits
Select commit
Hold shift + click to select a range
ac3e879
feat(confidential): per-channel auditor sponge primitives
brozorec c29f874
feat(confidential): withdraw circuit main constraints (W1-W7)
brozorec 2ee2a6a
feat(confidential): withdraw auditor block (W_a1-W_a5)
brozorec ca2d248
feat(confidential): publish withdraw verification key
brozorec eb3dfda
feat(confidential): tighten withdraw range to [0, 2^127) per design doc
brozorec 6ece1f1
docs: fix inline
brozorec 89e054a
test(confidential): isolate W4 in rejects_v_out_of_range
brozorec d719111
chore(confidential): align Poseidon domain tags with DESIGN.md §13
brozorec a1d04af
docs: add design and compliance docs
brozorec 1400304
fix: domains visibility
brozorec 66e9903
feat(confidential): operator-transfer circuit (O1-O13, O_a1-O_a8)
brozorec cfb1e12
feat(confidential): publish operator-transfer verification key
brozorec 77902da
chore: add baseline
brozorec 245bd7f
merge main
brozorec f73f251
fix(confidential): align operator-transfer PVK doctrine with transfer
brozorec 8314a44
chore(confidential): restore constraints.baseline header
brozorec b92ce4c
feat: add sigmas inequality constraint
brozorec b527099
merge main
brozorec File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
8 changes: 8 additions & 0 deletions
8
packages/tokens/src/confidential/circuits/operator_transfer/Nargo.toml
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| [package] | ||
| name = "circuit_operator_transfer" | ||
| type = "bin" | ||
| authors = ["OpenZeppelin"] | ||
| compiler_version = ">=0.30.0" | ||
|
|
||
| [dependencies] | ||
| stellar_confidential_lib = { path = "../lib" } |
318 changes: 318 additions & 0 deletions
318
packages/tokens/src/confidential/circuits/operator_transfer/src/main.nr
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,318 @@ | ||
| use stellar_confidential_lib::{ | ||
| H, assert_on_curve_non_identity, commit, derive_allow_r, derive_tx_blind, domain, ecdh, | ||
| encrypt_allowance, encrypt_amount, scalar_mul, sponge_squeeze_2, | ||
| }; | ||
| use std::embedded_curve_ops::EmbeddedCurvePoint; | ||
|
|
||
| mod tests; | ||
|
|
||
| // OperatorTransfer circuit -- design doc Section 7.8. | ||
| // | ||
| // Constraints | ||
| // ----------- | ||
| // Operator key ownership | ||
| // O1 Y_op = sk_op * H Operator key | ||
| // ownership. | ||
| // Allowance state (current) | ||
| // O2 C_a = v_a * G + r_a * H Opening of the | ||
| // stored allowance | ||
| // commitment. | ||
| // O3 r_a = Poseidon2(delta_allow_r, dvk_i, | ||
| // sigma_a) Allowance | ||
| // randomness matches | ||
| // stored state (the | ||
| // wrapper-binding | ||
| // anchor, see below). | ||
| // O4 v_a, v_tx, v_a - v_tx 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). | ||
| // Recipient ECDH + transfer commitment (Section 5.3, 5.4) | ||
| // O5 S = r_e * PVK_recipient Recipient ECDH | ||
| // shared secret. | ||
| // O6 R_e = r_e * H Ephemeral public | ||
| // key. | ||
| // O7 r_tx = Poseidon2(delta_tx_blind, S.x, | ||
| // sigma_a) Anti-poisoning | ||
| // binding for the | ||
| // transfer blinding. | ||
| // O8 C_tx = v_tx * G + r_tx * H Transfer | ||
| // commitment. | ||
| // O9 v_tilde = v_tx | ||
| // + Poseidon2(delta_tx_amount, S.x, | ||
| // sigma_a) Encrypted amount | ||
| // (emitted). | ||
| // Allowance state (new) | ||
| // -- sigma_a' != sigma_a Salt rotation | ||
| // (implementation | ||
| // hardening; not in | ||
| // Section 7.8). | ||
| // O10 r_a' = Poseidon2(delta_allow_r, dvk_i, | ||
| // sigma_a') New allowance | ||
| // randomness. | ||
| // O11 C_a' = (v_a - v_tx) * G + r_a' * H New allowance | ||
| // commitment. | ||
| // O12 a_tilde' = (v_a - v_tx) | ||
| // + Poseidon2(delta_enc_allow, dvk_i, | ||
| // sigma_a') Encrypted new | ||
| // allowance scalar | ||
| // (emitted). | ||
| // Nonzero ephemeral | ||
| // O13 r_e != 0 Rules out R_e = O | ||
| // and S, S_{a,r}, | ||
| // S_{a,s} = O. | ||
| // Auditor block (dual-channel visibility, Section 8.1 + Section 8.4) | ||
| // O_a1 S_{a,r} = r_e * K_aud_r Recipient-auditor | ||
| // ECDH shared secret | ||
| // (reuses r_e). | ||
| // O_a2 (m_v_r, m_r_r) | ||
| // = SpongeSqueeze_2(delta_aud_r, | ||
| // S_{a,r}.x, sigma_a) Recipient-channel | ||
| // sponge: two masks. | ||
| // O_a3 v_tilde_aud_r = v_tx + m_v_r Recipient-auditor | ||
| // encrypted amount. | ||
| // O_a4 r_tilde_aud_r = r_tx + m_r_r Recipient-auditor | ||
| // encrypted transfer | ||
| // randomness. | ||
| // O_a5 S_{a,s} = r_e * K_aud_s Owner-auditor ECDH | ||
| // shared secret | ||
| // (reuses r_e); the | ||
| // sender-channel | ||
| // visibility points | ||
| // at the funds' | ||
| // OWNER, not the | ||
| // operator (Section | ||
| // 8.4). | ||
| // O_a6 (m_v_s, m_a_s) | ||
| // = SpongeSqueeze_2(delta_aud_s, | ||
| // S_{a,s}.x, sigma_a) Owner-channel | ||
| // sponge: two masks. | ||
| // O_a7 v_tilde_aud_s = v_tx + m_v_s Owner-auditor | ||
| // encrypted amount. | ||
| // O_a8 a_tilde_aud_s = (v_a - v_tx) + m_a_s Owner-auditor | ||
| // encrypted | ||
| // post-transfer | ||
| // allowance. | ||
| // | ||
| // Wrapper binding (Section 7.8 final paragraph) | ||
| // --------------------------------------------- | ||
| // Unlike owner-initiated circuits, OperatorTransfer does NOT constrain the vk | ||
| // derivation -- the operator has no access to the owner's sk. Wrapper binding | ||
| // is inherited indirectly through the allowance commitment chain: SetOperator | ||
| // derived `dvk_i` from the wrapper-specific `vk` (S2, S5), which determined | ||
| // `r_a` (S6) and thus `C_a` (S7). Here O3 verifies the operator's claimed | ||
| // `dvk_i` against the on-chain `C_a` via `sigma_a`. Because `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. | ||
| // | ||
| // Channel-nonce reuse (Section 6.2 *Dual role*) | ||
| // --------------------------------------------- | ||
| // `sigma_a` serves as the freshness nonce for THREE distinct uses in this | ||
| // circuit: the allowance-randomness Poseidon (O3), the recipient ECDH chain | ||
| // (O7 / O9), and both auditor-channel sponges (O_a2 / O_a6). Soundness derives | ||
| // from ECDH shared-secret unpredictability (S.x is unknown to anyone but the | ||
| // recipient and the prover); `sigma_a` itself is public and need not be | ||
| // secret. Only the new-allowance constraints O10 and O12 use the fresh | ||
| // `sigma_a'`. | ||
| // | ||
| // Point-validation doctrine (Section 10.8) | ||
| // ---------------------------------------- | ||
| // Y_op, C_a, C_a', C_tx, R_e, S, S_{a,r}, and S_{a,s} are bound to in-circuit | ||
| // multi_scalar_mul outputs (O1, O2, O11, O8, O6, O5, O_a1, O_a5) and are | ||
| // therefore on-curve by construction -- no explicit check needed. | ||
| // PVK_recipient was constrained on-curve when the recipient registered (R3, | ||
| // Section 7.2) and is loaded by the wrapper from trusted storage, so it is | ||
| // path (2) of Section 10.8 and the circuit consumes it without re-checking. | ||
| // K_aud_r and K_aud_s are public-input keys with no proof constraint at | ||
| // insertion (path (3)) -- the verifier doesn't check curve membership and | ||
| // an off-curve key would break the soundness of O_a1 / O_a5. This file | ||
| // explicitly validates both on-curve AND non-identity before the | ||
| // corresponding ECDH consumes them. | ||
| // | ||
| // Public inputs (24 fields, in design-doc canonical order) | ||
| // -------------------------------------------------------- | ||
| // Idx Param Symbol Source / Note | ||
| // --- ----- ------ ------------------------------- | ||
| // 0 c_a_x C_a.x Loaded from the | ||
| // 1 c_a_y C_a.y `(from, operator)` delegation | ||
| // entry's allowance_commitment. | ||
| // 2 sigma_a sigma_a Loaded from the delegation | ||
| // entry's allowance_salt. | ||
| // 3 y_op_x Y_op.x Loaded from | ||
| // 4 y_op_y Y_op.y `operator.spending_key`; | ||
| // matches the auth principal. | ||
| // 5 pvk_recipient_x PVK_recipient.x Loaded from | ||
| // 6 pvk_recipient_y PVK_recipient.y `to.viewing_public_key`. | ||
| // Recipient must be registered. | ||
| // 7 k_aud_r_x K_aud_r.x Fetched from the auditor | ||
| // 8 k_aud_r_y K_aud_r.y contract by `to.auditor_id`. | ||
| // 9 k_aud_s_x K_aud_s.x Fetched from the auditor | ||
| // 10 k_aud_s_y K_aud_s.y contract by the OWNER's | ||
| // `auditor_id`, not the | ||
| // operator's (Section 7.8). | ||
| // 11 c_a_new_x C_a'.x Prover-supplied; written to | ||
| // 12 c_a_new_y C_a'.y the delegation entry's new | ||
| // allowance_commitment. | ||
| // 13 c_tx_x C_tx.x Prover-supplied; added to | ||
| // 14 c_tx_y C_tx.y `to.receiving_balance`. | ||
| // 15 r_e_x R_e.x Prover-supplied ephemeral key; | ||
| // 16 r_e_y R_e.y emitted. | ||
| // 17 v_tilde v_tilde Prover-supplied encrypted | ||
| // transfer amount; emitted. | ||
| // 18 a_tilde_new a_tilde' Prover-supplied encrypted new | ||
| // allowance scalar; stored. | ||
| // 19 sigma_a_new sigma_a' Prover-supplied fresh salt; | ||
| // written to allowance_salt. | ||
| // 20 v_tilde_aud_r v_tilde_aud_r Prover-supplied recipient- | ||
| // auditor encrypted amount; | ||
| // emitted. | ||
| // 21 r_tilde_aud_r r_tilde_aud_r Prover-supplied recipient- | ||
| // auditor encrypted transfer | ||
| // randomness; emitted. | ||
| // 22 v_tilde_aud_s v_tilde_aud_s Prover-supplied owner-auditor | ||
| // encrypted amount; emitted. | ||
| // 23 a_tilde_aud_s a_tilde_aud_s Prover-supplied owner-auditor | ||
| // encrypted post-transfer | ||
| // allowance; emitted. | ||
| // | ||
| // Private witnesses | ||
| // ----------------- | ||
| // sk_op Operator's spending secret scalar. | ||
| // dvk_i Delegation viewing key for this (owner, operator) pair, pinned by | ||
| // O3 to Poseidon2(delta_allow_r, dvk_i, sigma_a) = r_a. | ||
| // v_a Plaintext current allowance value. | ||
| // r_a Plaintext blinding factor for C_a (single-limb F_r; pinned by O3). | ||
| // v_tx Plaintext transfer amount. | ||
| // r_e Ephemeral scalar for recipient + auditor ECDH; must satisfy | ||
| // r_e != 0 (O13). | ||
|
|
||
| fn main( | ||
| sk_op: Field, | ||
| dvk_i: Field, | ||
| v_a: Field, | ||
| r_a: Field, | ||
| v_tx: Field, | ||
| r_e: Field, | ||
| c_a_x: pub Field, | ||
| c_a_y: pub Field, | ||
| sigma_a: pub Field, | ||
| y_op_x: pub Field, | ||
| y_op_y: pub Field, | ||
| pvk_recipient_x: pub Field, | ||
| pvk_recipient_y: pub Field, | ||
| k_aud_r_x: pub Field, | ||
| k_aud_r_y: pub Field, | ||
| k_aud_s_x: pub Field, | ||
| k_aud_s_y: pub Field, | ||
| c_a_new_x: pub Field, | ||
| c_a_new_y: pub Field, | ||
| c_tx_x: pub Field, | ||
| c_tx_y: pub Field, | ||
| r_e_x: pub Field, | ||
| r_e_y: pub Field, | ||
| v_tilde: pub Field, | ||
| a_tilde_new: pub Field, | ||
| sigma_a_new: pub Field, | ||
| v_tilde_aud_r: pub Field, | ||
| r_tilde_aud_r: pub Field, | ||
| v_tilde_aud_s: pub Field, | ||
| a_tilde_aud_s: pub Field, | ||
| ) { | ||
| // O13 -- 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); | ||
|
|
||
| // O1 | ||
| let y_op_derived = scalar_mul(sk_op, H); | ||
| assert(y_op_derived.x == y_op_x); | ||
| assert(y_op_derived.y == y_op_y); | ||
|
|
||
| // O3 -- runs before O2 so the wrapper-binding check (r_a is pinned to | ||
| // dvk_i, sigma_a) is in scope before O2 uses r_a as the blinding under | ||
| // the Pedersen commitment. | ||
| let r_a_derived = derive_allow_r(dvk_i, sigma_a); | ||
| assert(r_a_derived == r_a); | ||
|
|
||
| // O2 | ||
| 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); | ||
|
|
||
| // O4 -- 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_a.assert_max_bit_size::<127>(); | ||
| v_tx.assert_max_bit_size::<127>(); | ||
| let v_a_new = v_a - v_tx; | ||
| v_a_new.assert_max_bit_size::<127>(); | ||
|
|
||
| // O6 | ||
| 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); | ||
|
|
||
| // O5 -- PVK_recipient is path (2) of Section 10.8 (proof-constrained at | ||
| // the recipient's registration via R3, trusted on read). | ||
| let pvk_recipient = | ||
| EmbeddedCurvePoint { x: pvk_recipient_x, y: pvk_recipient_y, is_infinite: false }; | ||
| let s_x = ecdh(r_e, pvk_recipient); | ||
|
|
||
| // O7 (anti-poisoning, Section 5.4) | ||
| let r_tx = derive_tx_blind(s_x, sigma_a); | ||
|
|
||
| // O8 | ||
| let c_tx_derived = commit(v_tx, r_tx); | ||
| assert(c_tx_derived.x == c_tx_x); | ||
| assert(c_tx_derived.y == c_tx_y); | ||
|
|
||
| // O9 | ||
| let v_tilde_derived = encrypt_amount(v_tx, s_x, sigma_a); | ||
| assert(v_tilde_derived == v_tilde); | ||
|
|
||
| // Salt rotation (implementation hardening). | ||
| assert(sigma_a_new != sigma_a); | ||
|
|
||
| // O10 | ||
| let r_a_new = derive_allow_r(dvk_i, sigma_a_new); | ||
|
|
||
| // O11 | ||
| let c_a_new_derived = commit(v_a_new, r_a_new); | ||
| assert(c_a_new_derived.x == c_a_new_x); | ||
| assert(c_a_new_derived.y == c_a_new_y); | ||
|
|
||
| // O12 | ||
| let a_tilde_new_derived = encrypt_allowance(v_a_new, dvk_i, sigma_a_new); | ||
| assert(a_tilde_new_derived == a_tilde_new); | ||
|
|
||
| // K_aud_r / K_aud_s point validation (Section 10.8: public-input keys). | ||
| let k_aud_r = EmbeddedCurvePoint { x: k_aud_r_x, y: k_aud_r_y, is_infinite: false }; | ||
| assert_on_curve_non_identity(k_aud_r); | ||
| 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); | ||
|
|
||
| // O_a1 (recipient-auditor shared secret x-coordinate) | ||
| let s_a_r_x = ecdh(r_e, k_aud_r); | ||
|
|
||
| // O_a2 (recipient-channel masks: amount, then r_tx) | ||
| let m_r = sponge_squeeze_2(domain::AUDITOR_RECIPIENT, s_a_r_x, sigma_a); | ||
|
|
||
| // O_a3 | ||
| assert(v_tx + m_r[0] == v_tilde_aud_r); | ||
|
|
||
| // O_a4 | ||
| assert(r_tx + m_r[1] == r_tilde_aud_r); | ||
|
|
||
| // O_a5 (owner-auditor shared secret x-coordinate) | ||
| let s_a_s_x = ecdh(r_e, k_aud_s); | ||
|
|
||
| // O_a6 (owner-channel masks: amount, then post-transfer allowance) | ||
| let m_s = sponge_squeeze_2(domain::AUDITOR_SENDER, s_a_s_x, sigma_a); | ||
|
|
||
| // O_a7 | ||
| assert(v_tx + m_s[0] == v_tilde_aud_s); | ||
|
|
||
| // O_a8 | ||
| assert(v_a_new + m_s[1] == a_tilde_aud_s); | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.