From 6ea3075d44de51ea2a0d26fa45cbee6d385731b5 Mon Sep 17 00:00:00 2001 From: "yigitcan.org" <71771733+yigitcangokmen@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:34:17 +0300 Subject: [PATCH 01/17] fix(accounts): cap spending history at the reachable ledger entry limit (#848) * test: pin the ledger-entry ceiling that makes MAX_HISTORY_ENTRIES unreachable MAX_HISTORY_ENTRIES is documented as preventing storage DoS by capping the history vector, but it cannot be reached. A SpendingLimitData holding 816 entries serializes to 65,592 bytes, past the mainnet contractDataEntrySizeBytes limit of 65,536, so enforce fails with an untyped host budget error and HistoryCapacityExceeded is never returned. The measured ceiling is 815. This is visible in the existing suite: enforce_history_capacity_exceeded reaches 1000 only because it calls disable_resource_limits(). That call is a normal idiom here, used in eight places across four files for tests that deliberately exceed mainnet limits, so this is not a claim that the test is wrong. It does mean no test currently covers the interaction between the constant and the platform limit. Adds two tests that pin both sides of the boundary under mainnet defaults: filling to 815 succeeds and leaves the guard untouched, and 816 fails with Error(Budget, ExceededLimit). Documents the ceiling on the constant. The constant itself is left at 1000. Lowering it would be a behavioural change for accounts currently holding more than the new value in-window, and the right value is a judgement about headroom that belongs to the maintainers. The open question is in the linked issue. Note the second test asserts a host budget error rather than a contract error code, so it does not use the usual Error(Contract, #NNNN) form. stellar-accounts: 183 passed. * fix: cap spending history at the reachable ledger entry limit MAX_HISTORY_ENTRIES was 1000, but a SpendingLimitData holding 816 entries serializes to 65,592 bytes, past the mainnet contractDataEntrySizeBytes limit of 65,536. The guard could therefore never fire: enforce failed with an untyped host budget error before HistoryCapacityExceeded could be returned. Set the cap to 815, the largest history that fits in one ledger entry, so the guard is reachable and callers get the typed error. enforce_history_capacity_exceeded was only passing because it disabled resource limits, which is why the unreachable cap went unnoticed; it now runs under mainnet defaults. A new test writes one entry past the cap straight to storage so the ceiling is pinned by a test rather than a comment. --- .../accounts/src/policies/spending_limit.rs | 13 ++++++++- .../src/policies/test/spending_limit.rs | 28 ++++++++++++++++++- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/packages/accounts/src/policies/spending_limit.rs b/packages/accounts/src/policies/spending_limit.rs index c0065259c..b95e04f00 100644 --- a/packages/accounts/src/policies/spending_limit.rs +++ b/packages/accounts/src/policies/spending_limit.rs @@ -155,7 +155,18 @@ pub const SPENDING_LIMIT_TTL_THRESHOLD: u32 = SPENDING_LIMIT_EXTEND_AMOUNT - DAY /// Maximum number of spending entries to keep in history. /// This prevents storage DoS by capping the vector size. -pub const MAX_HISTORY_ENTRIES: u32 = 1000; +/// +/// # Notes +/// +/// The cap is the largest history that still fits in a single ledger entry. A +/// `SpendingLimitData` holding 816 entries serializes to 65,592 bytes, past the +/// mainnet `contractDataEntrySizeBytes` limit of 65,536, so a higher cap could +/// never be enforced: [`enforce`] would fail with an untyped host budget error +/// before [`SpendingLimitError::HistoryCapacityExceeded`] could be returned. +/// Raising this value therefore requires a smaller entry or a larger network +/// limit, which +/// `spending_history_past_the_cap_exceeds_the_ledger_entry_limit` pins. +pub const MAX_HISTORY_ENTRIES: u32 = 815; // ################## QUERY STATE ################## diff --git a/packages/accounts/src/policies/test/spending_limit.rs b/packages/accounts/src/policies/test/spending_limit.rs index 291ab4154..f3cee72fb 100644 --- a/packages/accounts/src/policies/test/spending_limit.rs +++ b/packages/accounts/src/policies/test/spending_limit.rs @@ -710,7 +710,6 @@ fn enforce_negative_amount_errors() { #[should_panic(expected = "Error(Contract, #3224)")] fn enforce_history_capacity_exceeded() { let e = Env::default(); - e.cost_estimate().disable_resource_limits(); let address = e.register(MockContract, ()); let smart_account = Address::generate(&e); let context_rule = create_context_rule(&e); @@ -906,3 +905,30 @@ fn enforce_create_contract_context_with_signers_errors() { enforce(&e, &context, &context_rule.signers, &context_rule, &smart_account); }); } + +#[test] +#[should_panic(expected = "Error(Budget, ExceededLimit)")] +fn spending_history_past_the_cap_exceeds_the_ledger_entry_limit() { + let e = Env::default(); + let address = e.register(MockContract, ()); + let smart_account = Address::generate(&e); + + // Bypass `enforce` and its capacity guard, writing a history one entry past + // the cap straight to storage. The write exceeds + // `contractDataEntrySizeBytes` and the host rejects it, which is why + // MAX_HISTORY_ENTRIES cannot be raised on its own. + e.as_contract(&address, || { + let mut spending_history = Vec::new(&e); + for i in 0..=MAX_HISTORY_ENTRIES { + spending_history.push_back(SpendingEntry { amount: 1, ledger_sequence: 1000 + i }); + } + let data = SpendingLimitData { + spending_limit: i128::MAX, + period_ledgers: 1_000_000, + spending_history, + cached_total_spent: i128::from(MAX_HISTORY_ENTRIES) + 1, + }; + let key = SpendingLimitStorageKey::AccountContext(smart_account.clone(), 1); + e.storage().persistent().set(&key, &data); + }); +} From 035da1a8c77ca7af5f6aa9d27b4fe071d966354e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96zg=C3=BCn=20=C3=96zerk?= Date: Wed, 9 Sep 2026 13:50:05 +0300 Subject: [PATCH 02/17] carry freeze status on 0 balance recovery (#871) --- packages/tokens/src/rwa/mod.rs | 11 +++++++++++ packages/tokens/src/rwa/storage.rs | 25 ++++++++++++++++--------- packages/tokens/src/rwa/test.rs | 30 ++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 9 deletions(-) diff --git a/packages/tokens/src/rwa/mod.rs b/packages/tokens/src/rwa/mod.rs index faf515844..01c387281 100644 --- a/packages/tokens/src/rwa/mod.rs +++ b/packages/tokens/src/rwa/mod.rs @@ -244,6 +244,12 @@ pub trait RWAToken: Pausable + FungibleToken { /// privileges. RBAC checks are expected to be enforced on the /// `operator`. /// + /// Balances and address freezes are stored per token, so after the + /// identity is recovered in the registry, this function should be called on + /// each token separately. A token where the old account holds nothing + /// still needs the call when the account is frozen there, otherwise the + /// freeze status is not carried over to the new account. + /// /// # Arguments /// /// * `e` - Access to the Soroban environment. @@ -258,6 +264,11 @@ pub trait RWAToken: Pausable + FungibleToken { /// /// # Events /// + /// Emitted when the old account is fully frozen: + /// * topics - `["address_frozen", new_account: Address, is_frozen: bool]` + /// * data - `[]` + /// + /// Emitted only when tokens are moved: /// * topics - `["transfer", old_account: Address, new_account: Address]` /// * data - `[amount: i128]` /// * topics - `["recovery_success", old_account: Address, new_account: diff --git a/packages/tokens/src/rwa/storage.rs b/packages/tokens/src/rwa/storage.rs index 5b169991e..fb8b17a7e 100644 --- a/packages/tokens/src/rwa/storage.rs +++ b/packages/tokens/src/rwa/storage.rs @@ -432,7 +432,7 @@ impl RWA { /// Recovery function used to force transfer tokens from a old account to a /// new account. This function transfers all tokens and preserves the frozen /// status from the old account to the new account. Returns `true` if - /// recovery was successful, `false` if no tokens to recover. + /// tokens were moved, `false` if the old account held no tokens. /// /// # Arguments /// @@ -449,6 +449,11 @@ impl RWA { /// /// # Events /// + /// Emitted when the old account is fully frozen: + /// * topics - `["address_frozen", new_account: Address, is_frozen: bool]` + /// * data - `[]` + /// + /// Emitted only when tokens are moved: /// * topics - `["transfer", old_account: Address, new_account: Address]` /// * data - `[amount: i128]` /// * topics - `["recovery_success", old_account: Address, new_account: @@ -459,7 +464,11 @@ impl RWA { /// /// This function preserves the frozen status (both partial and full) from /// the old account and applies it to the new account, maintaining - /// regulatory compliance. + /// regulatory compliance. The full-address freeze is a wallet-level + /// restriction, so it follows the investor to the new account even when the + /// old account holds no tokens. The partially frozen amount is bounded by + /// the balance, so there is nothing to migrate in that case. + /// /// /// State owned by this contract (the frozen status) is migrated directly /// by this function. State held by compliance modules is handled through @@ -495,6 +504,10 @@ impl RWA { panic_with_error!(e, RWAError::IdentityMismatch); } + if Self::is_frozen(e, old_account) { + Self::set_address_frozen(e, new_account, true); + } + // Get the balance of the old account, if there is nothing to transfer, // return false let lost_balance = Base::balance(e, old_account); @@ -502,9 +515,8 @@ impl RWA { return false; } - // Store frozen status before transfer + // Store the partially frozen amount before the transfer unfreezes it let frozen_tokens = Self::get_frozen_tokens(e, old_account); - let is_address_frozen = Self::is_frozen(e, old_account); // Move all tokens with the shared privileged mechanics (handles // unfreezing as needed), reporting the movement as a recovery so @@ -523,11 +535,6 @@ impl RWA { Self::freeze_partial_tokens(e, new_account, frozen_tokens); } - // Preserve address frozen status on the new account if it was frozen - if is_address_frozen { - Self::set_address_frozen(e, new_account, true); - } - emit_recovery_success(e, old_account, new_account); true diff --git a/packages/tokens/src/rwa/test.rs b/packages/tokens/src/rwa/test.rs index 5ef2f45cc..3b243372f 100644 --- a/packages/tokens/src/rwa/test.rs +++ b/packages/tokens/src/rwa/test.rs @@ -578,6 +578,7 @@ fn recovery_with_zero_balance_returns_false() { // No tokens in old account let success = RWA::recover_balance(&e, &old_account, &new_account); assert!(!success); // Should return false for zero balance + assert!(!RWA::is_frozen(&e, &new_account)); }); } @@ -939,6 +940,35 @@ fn recover_balance_with_both_frozen_tokens_and_address() { }); } +#[test] +fn recover_balance_with_zero_balance_preserves_frozen_address() { + let e = Env::default(); + let address = e.register(MockRWAContract, ()); + let old_account = Address::generate(&e); + let new_account = Address::generate(&e); + + e.as_contract(&address, || { + let identity_verifier = set_and_return_identity_verifier(&e); + let _ = set_and_return_compliance(&e); + + // Set recovery target in the identity verifier contract's storage + e.as_contract(&identity_verifier, || { + e.storage().persistent().set(&symbol_short!("recovery"), &new_account); + }); + + // Freeze the address while it holds no tokens + RWA::set_address_frozen(&e, &old_account, true); + assert!(RWA::is_frozen(&e, &old_account)); + + // Nothing to move, but the address freeze must still follow + let success = RWA::recover_balance(&e, &old_account, &new_account); + assert!(!success); + + assert_eq!(RWA::balance(&e, &new_account), 0); + assert!(RWA::is_frozen(&e, &new_account)); + }); +} + #[test] #[should_panic(expected = "Error(Contract, #313)")] fn recover_balance_without_recovery_target_fails() { From 1db8925a4835d69706ebd8f32c583c6132559755 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96zg=C3=BCn=20=C3=96zerk?= Date: Thu, 3 Sep 2026 12:03:53 +0300 Subject: [PATCH 03/17] 512 bit support (#851) --- packages/contract-utils/README.md | 43 + .../src/math/i256_fixed_point.rs | 412 ++++++- packages/contract-utils/src/math/mod.rs | 63 +- .../src/math/test/i256_fixed_point.rs | 1038 ++++++++++++++++- packages/contract-utils/src/math/test/wad.rs | 68 ++ packages/contract-utils/src/math/wad.rs | 127 +- 6 files changed, 1686 insertions(+), 65 deletions(-) diff --git a/packages/contract-utils/README.md b/packages/contract-utils/README.md index 462fe7812..27eff3aaf 100644 --- a/packages/contract-utils/README.md +++ b/packages/contract-utils/README.md @@ -146,6 +146,49 @@ The `merkle_distributor` module implements a Merkle-based claim distribution sys - **Flexible Leaf Structure**: Support for custom claim data structures - **Use Cases**: Token airdrops, NFT distributions, allowlists, snapshot voting +### Math + +The `math` module provides fixed-point arithmetic: a `Wad` decimal type with 18 decimal places, and free functions for `x * y / denominator` on both `i128` and `I256` with an explicit rounding direction. + +Each operation comes in a panicking and a checked variant. The panicking variants raise `SorobanFixedPointError::Overflow` (1500) when the result overflows; the checked variants return `None`. `Wad`'s operators (`+`, `-`, `*`, `/`) work directly on `i128` and panic with a native arithmetic error instead (see the `# Overflow` section on `Wad`). + +#### Usage Examples + +```rust +use soroban_sdk::{Env, I256}; +use stellar_contract_utils::math::{i256_fixed_point, i128_fixed_point, wad::Wad, Rounding}; + +// Share conversion, rounding in the vault's favour. +pub fn to_shares(e: &Env, assets: i128, total_shares: i128, total_assets: i128) -> i128 { + i128_fixed_point::mul_div_with_rounding(e, assets, total_shares, total_assets, Rounding::Floor) +} + +// The same computation at 256-bit width. `I256` carries its own `Env`, +// so these signatures take no `&Env`. +pub fn to_shares_256(assets: &I256, total_shares: &I256, total_assets: &I256) -> I256 { + i256_fixed_point::mul_div_floor(assets, total_shares, total_assets) +} + +// 18-decimal fixed point, where 1.0 is 10^18. +pub fn apply_rate(e: &Env, principal: i128) -> Option { + let rate = Wad::from_raw(1_050_000_000_000_000_000); // 1.05 + Wad::from_integer(e, principal).checked_mul(e, rate).map(|w| w.to_integer()) +} +``` + +#### Phantom Overflow + +Both widths recover from an intermediate `x * y` that overflows while the final result is representable, a case known as *phantom overflow*. + +- **`i128`**: the operands are promoted to `I256`, and the result is scaled back if it fits `i128`. +- **`I256`**: there is no wider type to promote to, so the operands are split by the denominator and the division is distributed, which is an exact identity rather than an approximation. This holds for every denominator with `|denominator| <= 2^128` (roughly `3.4e38`, far above the fixed-point scales in practical use: `10^18`, `10^27`, `2^96`, `10^38`). Within that domain the result is bit-for-bit what a 512-bit intermediate would produce. + +Above `2^128` the operation rejects rather than returning an incorrect value, and rejection is permitted rather than guaranteed: a large denominator whose remainders happen to be small still succeeds. + +Phantom overflow handling does not extend to `Wad`'s operator implementations (`+`, `-`, `*`, `/`), because an operator cannot reach an `Env` to build the intermediate. Their narrower bounds are tabulated in the `# Overflow` section of `Wad`'s documentation, and `checked_mul` / `checked_div` should be preferred wherever an operand can plausibly exceed them. + +Zero denominators and `MIN / -1` are left to the platform rather than mapped to a contract error, since these are plain arithmetic operations; the checked variants return `None` for them. The `math` module documentation details which error each failure surfaces as. + ## Installation Add this to your `Cargo.toml`: diff --git a/packages/contract-utils/src/math/i256_fixed_point.rs b/packages/contract-utils/src/math/i256_fixed_point.rs index df454c16e..0d72a63ba 100644 --- a/packages/contract-utils/src/math/i256_fixed_point.rs +++ b/packages/contract-utils/src/math/i256_fixed_point.rs @@ -1,27 +1,34 @@ // Based on the Soroban fixed-point mathematics library // Original implementation: https://github.com/script3/soroban-fixed-point-math -// NOTE: Unlike the i128 variants, phantom overflow is NOT resolved here. -// The i128 helpers promote to I256 so that an intermediate `x * y` which -// overflows i128 can still produce a correct result; doing the same for I256 -// would require a custom I512 type. As a consequence, the `checked_*` functions -// return `None` when `x * y` overflows I256 (even if `x * y / denominator` -// would fit), and the unchecked functions panic in that case. Overflowing two -// large I256 values is considered rare enough in practice that this trade-off -// is acceptable. +// NOTE: Phantom overflow IS resolved here, without a wider intermediate type. +// When `x * y` overflows I256, both operands are split by the denominator and +// the division is distributed, which is an exact identity rather than an +// approximation: +// +// |x| = q1*D + r1 |y| = q2*D + r2 (D = |denominator|, 0 <= r1, r2 < D) +// floor(|x*y|/D) = q1*q2*D + q1*r2 + r1*q2 + floor(r1*r2/D) -use soroban_sdk::I256; +use soroban_sdk::{panic_with_error, Env, I256, U256}; -use crate::math::Rounding; +use crate::math::{Rounding, SorobanFixedPointError}; /// Calculates `x * y / denominator` following the specified rounding direction. /// +/// When the intermediate `x * y` overflows `I256`, the result is recovered by +/// remainder decomposition instead of failing; refer to the module +/// documentation for the domain. +/// /// # Arguments /// /// * `x` - The first operand. /// * `y` - The second operand. /// * `denominator` - The divisor. /// * `rounding` - The rounding direction to use. +/// +/// # Errors +/// +/// * refer to the errors of [`mul_div`] pub fn mul_div_with_rounding(x: I256, y: I256, denominator: I256, rounding: Rounding) -> I256 { match rounding { Rounding::Floor => mul_div_floor(&x, &y, &denominator), @@ -32,9 +39,10 @@ pub fn mul_div_with_rounding(x: I256, y: I256, denominator: I256, rounding: Roun /// Checked version of [`mul_div_with_rounding`]. /// -/// Calculates `x * y / denominator`, returning `None` instead of panicking -/// when the intermediate `x * y` overflows `I256`, when `denominator` is zero, -/// or when the division overflows. +/// Calculates `x * y / denominator`, returning `None` instead of panicking when +/// the result cannot be represented in `I256`, when `denominator` is zero, or +/// when the intermediate `x * y` overflows `I256` and `denominator` is too +/// large for the fallback to recover it. /// /// # Arguments /// @@ -57,44 +65,89 @@ pub fn checked_mul_div_with_rounding( /// Calculates floor(x * y / denominator). /// +/// When the intermediate `x * y` overflows `I256`, the result is recovered by +/// remainder decomposition instead of failing; refer to the module +/// documentation for the domain. +/// /// # Arguments /// /// * `x` - The first operand. /// * `y` - The multiplicand. /// * `denominator` - The divisor. +/// +/// # Errors +/// +/// * refer to the errors of [`mul_div`] pub fn mul_div_floor(x: &I256, y: &I256, denominator: &I256) -> I256 { - let r = x.mul(y); - div_floor(&r, denominator) + let e = x.env(); + match x.checked_mul(y) { + Some(r) => div_floor(&r, denominator), + // Reaching `None` means either the true result does not fit `I256`, or + // `|denominator|` is above the `2^128` ceiling described in the module docs. + None => checked_mul_div_decomposed(x, y, denominator, Rounding::Floor) + .unwrap_or_else(|| panic_with_error!(e, SorobanFixedPointError::Overflow)), + } } /// Calculates ceil(x * y / denominator). /// +/// When the intermediate `x * y` overflows `I256`, the result is recovered by +/// remainder decomposition instead of failing; refer to the module +/// documentation for the domain. +/// /// # Arguments /// /// * `x` - The first operand. /// * `y` - The multiplicand. /// * `denominator` - The divisor. +/// +/// # Errors +/// +/// * refer to the errors of [`mul_div`] pub fn mul_div_ceil(x: &I256, y: &I256, denominator: &I256) -> I256 { - let r = x.mul(y); - div_ceil(&r, denominator) + let e = x.env(); + match x.checked_mul(y) { + Some(r) => div_ceil(&r, denominator), + None => checked_mul_div_decomposed(x, y, denominator, Rounding::Ceil) + .unwrap_or_else(|| panic_with_error!(e, SorobanFixedPointError::Overflow)), + } } /// Calculates `x * y / denominator` (truncated toward zero). /// +/// When the intermediate `x * y` overflows `I256`, the result is recovered by +/// remainder decomposition instead of failing; refer to the module +/// documentation for the domain. +/// /// # Arguments /// /// * `x` - The first operand. /// * `y` - The multiplicand. /// * `denominator` - The divisor. +/// +/// # Errors +/// +/// * [`SorobanFixedPointError::Overflow`] - when `x * y` overflows `I256` and +/// the result still cannot be recovered, either because it does not fit +/// `I256` or because `|denominator|` exceeds `2^128`. +/// +/// # Notes +/// +/// Domain errors (a zero `denominator`, `I256::MIN / -1`) are left to the +/// platform; refer to the module documentation for how each failure surfaces. +/// [`checked_mul_div`] returns `None` for all of them. pub fn mul_div(x: &I256, y: &I256, denominator: &I256) -> I256 { - let r = x.mul(y); - r.div(denominator) + let e = x.env(); + match x.checked_mul(y) { + Some(r) => r.div(denominator), + None => checked_mul_div_decomposed(x, y, denominator, Rounding::Truncate) + .unwrap_or_else(|| panic_with_error!(e, SorobanFixedPointError::Overflow)), + } } /// Calculates floor(x * y / denominator). /// -/// Returns `None` if the intermediate `x * y` overflows `I256`, if -/// `denominator` is zero, or if the division overflows. +/// Returns `None` under the same conditions as [`checked_mul_div`]. /// /// # Arguments /// @@ -102,14 +155,15 @@ pub fn mul_div(x: &I256, y: &I256, denominator: &I256) -> I256 { /// * `y` - The multiplicand. /// * `denominator` - The divisor. pub fn checked_mul_div_floor(x: &I256, y: &I256, denominator: &I256) -> Option { - let r = x.checked_mul(y)?; - checked_div_floor(&r, denominator) + match x.checked_mul(y) { + Some(r) => checked_div_floor(&r, denominator), + None => checked_mul_div_decomposed(x, y, denominator, Rounding::Floor), + } } /// Calculates ceil(x * y / denominator). /// -/// Returns `None` if the intermediate `x * y` overflows `I256`, if -/// `denominator` is zero, or if the division overflows. +/// Returns `None` under the same conditions as [`checked_mul_div`]. /// /// # Arguments /// @@ -117,14 +171,18 @@ pub fn checked_mul_div_floor(x: &I256, y: &I256, denominator: &I256) -> Option Option { - let r = x.checked_mul(y)?; - checked_div_ceil(&r, denominator) + match x.checked_mul(y) { + Some(r) => checked_div_ceil(&r, denominator), + None => checked_mul_div_decomposed(x, y, denominator, Rounding::Ceil), + } } /// Calculates `x * y / denominator` (truncated toward zero). /// -/// Returns `None` if the intermediate `x * y` overflows `I256`, if -/// `denominator` is zero, or if the division overflows. +/// Returns `None` if `denominator` is zero, if the result does not fit `I256`, +/// or if `x * y` overflows `I256` and `|denominator|` exceeds `2^128`. An +/// intermediate `x * y` that overflows `I256` is no longer a failure by itself; +/// refer to the module documentation for the domain. /// /// # Arguments /// @@ -132,12 +190,304 @@ pub fn checked_mul_div_ceil(x: &I256, y: &I256, denominator: &I256) -> Option Option { - let r = x.checked_mul(y)?; - r.checked_div(denominator) + match x.checked_mul(y) { + Some(r) => r.checked_div(denominator), + None => checked_mul_div_decomposed(x, y, denominator, Rounding::Truncate), + } } // ###################### HELPERS ###################### +/// Returns the absolute value of `v` as an unsigned `U256` magnitude. +/// +/// [`checked_mul_div_decomposed`] holds the reasoning: why the decomposition +/// works on absolute values, why they have to be unsigned, and why the +/// conversion goes through bytes. +fn to_magnitude(v: &I256) -> U256 { + let e = v.env(); + let u = U256::from_be_bytes(e, &v.to_be_bytes()); + if *v < I256::from_i32(e, 0) { + // Two's-complement negation, normally `!u + 1`, done without bitwise + // operations because the SDK has none. + // + // Inverting every bit is the same as subtracting from all-ones. Every + // bit of `U256::MAX` is 1, and at each position `1 - bit` + // yields the flipped bit, so no position ever borrows from its + // neighbour and the subtraction is a bit-for-bit inversion: + // `U256::MAX - u == !u` exactly. Therefore `-u == (U256::MAX - + // u) + 1`. + // + // In an 8-bit analogue: negating 5 gives `255 - 5 + 1 == 0xFB`, which + // read as an i8 is -5; negating 128 (`|i8::MIN|`, which has no + // positive i8 form) gives `0x80`, i.e. -128. The same + // arithmetic at 256 bits is how `|I256::MIN|`, which is + // `2^255`, survives the round trip. + // + // Neither the subtraction nor the addition can leave `U256` here: `v < + // 0` reinterprets to `u >= 2^255`, so `U256::MAX - u <= 2^255 - + // 1`, and adding one to that stays below `2^256`. + U256::max_value(e).sub(&u).add(&U256::from_u32(e, 1)) + } else { + u + } +} + +/// Converts a `U256` magnitude back to a signed `I256`, applying `negative`, +/// and returns `None` when the signed value does not fit. +/// +/// The inverse of [`to_magnitude`], and the point where leaving `I256`'s range +/// is caught. Also returns `None`, rather than `Some(0)`, for a zero magnitude +/// with `negative` set; [`checked_mul_div_decomposed`] covers why that cannot +/// arise from the fallback. +fn from_magnitude(e: &Env, mag: &U256, negative: bool) -> Option { + if negative { + // The largest magnitude any `I256` has is `2^255`, which sits well + // inside `U256`. Coming back is different. `U256` reaches + // `2^256 - 1`, while `I256` stops at `2^255 - 1` going up and + // `-2^255` going down, so most of the upper half of `U256` has + // no signed counterpart. The magnitude arriving here was + // produced by the summation, which is free to exceed the signed range, + // so it has to be bounds-checked before it is reinterpreted. + // + // The limit differs by sign, because the signed range is not symmetric. + // A negative result may use the whole `2^255`, since + // `I256::MIN` is `-2^255`. + // + // `from_parts` takes four 64-bit limbs, most significant first, so the + // constant below is `0x8000_0000_0000_0000 * 2^192`, which is + // `2^63 * 2^192`, which is `2^255`. + if *mag > U256::from_parts(e, 0x8000_0000_0000_0000, 0, 0, 0) { + return None; + } + // The same `(U256::MAX - v) + 1` negation as in `to_magnitude`, run the + // other way. `checked_add` rather than `add` is what handles a + // zero magnitude; see the note on this function for why that + // case cannot arrive from the fallback. + let negated = U256::max_value(e).sub(mag).checked_add(&U256::from_u32(e, 1))?; + // The bytes are unchanged; only their interpretation goes from unsigned + // to signed. + Some(I256::from_be_bytes(e, &negated.to_be_bytes())) + } else { + // The positive end stops one short of the negative end, at + // `I256::MAX == 2^255 - 1`, so this bound is one lower than the one + // above. In limbs, `(2^63 - 1) * 2^192` plus all-ones in the + // remaining three sums to exactly `2^255 - 1`. + if *mag > U256::from_parts(e, 0x7fff_ffff_ffff_ffff, u64::MAX, u64::MAX, u64::MAX) { + return None; + } + Some(I256::from_be_bytes(e, &mag.to_be_bytes())) + } +} + +/// Computes `x * y / denominator` by remainder decomposition. +/// +/// Returns `None` when `denominator` is zero, when the result does not fit +/// `I256`, or when `|denominator|` is large enough that the `r1 * r2` term +/// overflows `U256`. +/// +/// # Notes +/// +/// The fast path in each entry point multiplies first and divides second, so it +/// gives up whenever `x * y` leaves `I256`, even when `x * y / denominator` +/// would fit easily. This reaches the same answer without ever forming that +/// product. +/// +/// The long path (phantom overflow path) is to divide before multiplying. +/// Splitting a value by the denominator gives a whole part and a leftover, +/// and every value has exactly one such split: +/// +/// ```text +/// abs_x = q1 * abs_d + r1 with 0 <= r1 < abs_d +/// abs_y = q2 * abs_d + r2 with 0 <= r2 < abs_d +/// ``` +/// +/// The operands here are absolute values, with the sign reapplied at the very +/// end; why they must be absolute is explained after the identity. +/// +/// Substituting both splits into the product and expanding gives four terms: +/// +/// ```text +/// abs_x * abs_y == (q1*abs_d + r1) * (q2*abs_d + r2) +/// == q1*q2*abs_d*abs_d + q1*r2*abs_d + r1*q2*abs_d + r1*r2 +/// ``` +/// +/// Every term except `r1*r2` carries a factor of `abs_d`, so dividing the whole +/// line by `abs_d` cancels that factor exactly and leaves `r1*r2` as the only +/// term still under a division: +/// +/// ```text +/// abs_x * abs_y / abs_d +/// == q1*q2*abs_d + q1*r2 + r1*q2 + rem_product / abs_d +/// ``` +/// +/// where `rem_product` is `r1 * r2`. That last line is the identity the +/// function computes, and its point is that the right-hand side never mentions +/// `abs_x * abs_y`. All four terms are built only from `q1`, `r1`, `q2`, `r2` +/// and `abs_d`. +/// +/// A small case to fix the shape, `7 * 5 / 3`, whose answer is `11`: +/// +/// ```text +/// 7 = 2*3 + 1 -> q1 = 2, r1 = 1 +/// 5 = 1*3 + 2 -> q2 = 1, r2 = 2 +/// +/// q1*q2*3 + q1*r2 + r1*q2 + (r1*r2)/3 +/// 6 + 4 + 1 + 0 == 11 +/// ``` +/// +/// The two split equations, `abs_x = q1 * abs_d + r1` and `abs_y = q2 * abs_d + +/// r2`, have to hold exactly. If either is off, the expansion above starts from +/// a false premise and the four terms add up to the wrong number. That +/// requirement is what forces absolute values, because `q1` and `r1` come from +/// two separate SDK calls: +/// +/// ```text +/// q1 = abs_x.div(abs_d) truncates toward zero +/// r1 = abs_x.rem_euclid(abs_d) never negative, always in 0 .. abs_d +/// ``` +/// +/// On a non-negative numerator `div` and `rem_euclid` agree, because truncating +/// and rounding down are the same thing there. On a negative numerator they do +/// not. Splitting `-7` by `3` is the smallest case that shows it, and there are +/// two ways to do it, both correct arithmetic: +/// +/// ```text +/// truncate toward zero: -7 / 3 = -2.33 -> -2 -7 = (-2 * 3) + (-1) +/// quotient -2, remainder -1 +/// +/// round down: -7 / 3 = -2.33 -> -3 -7 = (-3 * 3) + 2 +/// quotient -3, remainder 2 +/// ``` +/// +/// `div` truncates, so it drops the fraction of `-2.33` and returns the first +/// row's quotient, `-2`. `rem_euclid` is defined never to return a negative +/// value, whatever the signs of its operands, so it reports the second row's +/// remainder, `2`. Both calls are right on their own; they simply describe +/// different divisions, and the SDK offers no truncating `rem` that would +/// return the `-1` belonging to `-2`. Calling `div` and `rem_euclid` together +/// therefore pairs the truncating row's quotient with the rounding-down row's +/// remainder: +/// +/// ```text +/// q1 * abs_d + r1 == (-2 * 3) + 2 == -4 but the numerator was -7 +/// ``` +/// +/// Off by exactly one `abs_d`. A false split does not raise an error, it +/// silently changes the answer. For `x = -7`, `y = 5`, `denominator = 3`, where +/// the true truncated result is `-11`: +/// +/// ```text +/// signed split: q1 = -2, r1 = 2, q2 = 1, r2 = 2 +/// terms -6 + (-4) + 2 + 1 = -7 wrong, and returned as success +/// +/// magnitudes: q1 = 2, r1 = 1, q2 = 1, r2 = 2 +/// terms 6 + 4 + 1 + 0 = 11 sign reapplied -> -11 +/// ``` +/// +/// Signed operands are not impossible here, but they would have to derive the +/// remainder themselves as `x - q * d` rather than call `rem_euclid`. +/// +/// The magnitudes are unsigned rather than a signed `abs`, because a signed +/// absolute value does not always exist. `I256::MIN` is `-2^255` while +/// `I256::MAX` stops one short of `2^255`, so `|I256::MIN|` is larger than +/// anything the type holds, and `I256::MIN` does reach this function, for +/// example through `mul_div(I256::MIN, 3, 3)`. Converting in and +/// out goes through the big-endian byte encoding because the SDK offers no +/// other bridge between `I256` and `U256`: there is no `to_parts`, no bitwise +/// operations and no wrapping arithmetic. +/// +/// The terms `q1*q2*abs_d`, `q1*r2` and `r1*q2` are whole integers, so the +/// entire fractional part of the quotient sits in `rem_product / abs_d`. That +/// is why the truncated magnitude, `mag`, is just those three terms plus +/// `floor(rem_product / abs_d)`, and why `rem_product % abs_d` alone decides +/// whether the result is exact. +/// +/// What the identity buys, beyond avoiding the product, is what bounds each of +/// its four terms. `q1*q2*abs_d` is at most the answer itself, `q1*r2` is at +/// most `abs_x`, and `r1*q2` is at most `abs_y`, so all three fit whenever the +/// inputs and the answer do. Only `rem_product` is bounded by the denominator +/// alone, at just under `abs_d * abs_d`, with no relation to how big the answer +/// is. That single term is the whole source of the `|denominator| <= 2^128` +/// domain, since `abs_d * abs_d` has to stay below `2^256`. It is also why the +/// magnitudes are `U256`: capped at `I256`, the ceiling would fall to +/// `2^127.5`. With `abs_d` at `2^128 - 1` and both remainders maximal, +/// `rem_product` reaches `2^256 - 2^130 + 4`, twice what `I256` holds and +/// comfortably inside `U256`. +/// +/// One edge: reapplying a negative sign to a zero magnitude is not +/// representable by the negation [`from_magnitude`] uses, so it answers `None` +/// there. No entry point reaches that case, since arriving here requires +/// `x * y` to have overflowed, which puts the quotient magnitude at one or +/// above. Only a direct call with `|x * y| < |denominator|` can hit it. +/// +/// `pub(super)` so the differential test in `crate::math::test` can compare +/// this fallback against the fast path. +pub(super) fn checked_mul_div_decomposed( + x: &I256, + y: &I256, + denominator: &I256, + rounding: Rounding, +) -> Option { + let e = x.env(); + + let abs_x = to_magnitude(x); + let abs_y = to_magnitude(y); + let abs_d = to_magnitude(denominator); + + // Checked rather than plain division: `abs_d` is zero exactly when + // `denominator` is, and returning `None` for that is what keeps the checked + // entry points total. The unchecked ones only reach this function when `x * + // y` overflowed; a zero denominator on the fast path is left to the host. + let q1 = abs_x.checked_div(&abs_d)?; + let r1 = abs_x.checked_rem_euclid(&abs_d)?; + let q2 = abs_y.checked_div(&abs_d)?; + let r2 = abs_y.checked_rem_euclid(&abs_d)?; + + // `r1 * r2` is the only term bounded by `abs_d` alone rather than by an + // input or by the answer, which makes this multiplication the exact + // detector for `|denominator| > 2^128`: it can fail only when `abs_d * + // abs_d` exceeds `2^256`, so nothing inside the documented domain + // reaches the failure and nothing outside it receives a wrong answer. + let rem_product = r1.checked_mul(&r2)?; + + // Every term is non-negative, so each partial sum is bounded by the final + // magnitude. Summation order is therefore irrelevant to overflow: a failure + // here always means the true result is unrepresentable, never that the + // terms were added in an unlucky order. + let mut mag = q1.checked_mul(&q2)?.checked_mul(&abs_d)?; + mag = mag.checked_add(&q1.checked_mul(&r2)?)?; + mag = mag.checked_add(&r1.checked_mul(&q2)?)?; + mag = mag.checked_add(&rem_product.checked_div(&abs_d)?)?; + + // `q1*q2*abs_d`, `q1*r2` and `r1*q2` are integers, so the entire fractional + // part of `|x * y| / abs_d` is carried by `rem_product / abs_d`. Exactness + // is decided there alone, and a non-zero `rem_product` does not imply an + // inexact result. + let exact = rem_product.checked_rem_euclid(&abs_d)? == U256::from_u32(e, 0); + + // The result is negative iff an odd number of the three operands are. That + // matches the sign of the true result only because this function is + // unreachable with a zero operand: a product involving zero never + // overflows, so it stays on the fast path. + let zero = I256::from_i32(e, 0); + let negative = (*x < zero) ^ (*y < zero) ^ (*denominator < zero); + + // `mag` is the truncated magnitude, so in magnitude space all three modes + // reduce to one question: whether to round away from zero. Floor moves away + // from zero only for a negative result, Ceil only for a positive one, and + // Truncate never does. + let round_away_from_zero = match rounding { + Rounding::Truncate => false, + Rounding::Floor => negative && !exact, + Rounding::Ceil => !negative && !exact, + }; + if round_away_from_zero { + mag = mag.checked_add(&U256::from_u32(e, 1))?; + } + + from_magnitude(e, &mag, negative) +} + /// Performs checked floor(r / z) fn checked_div_floor(r: &I256, z: &I256) -> Option { let env = r.env(); diff --git a/packages/contract-utils/src/math/mod.rs b/packages/contract-utils/src/math/mod.rs index 12ef0b05e..fb317920e 100644 --- a/packages/contract-utils/src/math/mod.rs +++ b/packages/contract-utils/src/math/mod.rs @@ -9,12 +9,11 @@ //! multiplication and division, in both panicking and checked variants: //! //! - **Panicking variants** (e.g. [`i128_fixed_point::mul_div_with_rounding`]): -//! panic with [`SorobanFixedPointError::Overflow`] when the result overflows, -//! and with a native arithmetic panic when dividing by zero. +//! panic with [`SorobanFixedPointError::Overflow`] when the result overflows. //! - **Checked variants** (e.g. //! [`i128_fixed_point::checked_mul_div_with_rounding`]): return `None` on //! error for graceful handling, including when the intermediate `x * y` -//! multiplication overflows. +//! multiplication overflows and the result cannot be recovered. //! //! ### Phantom Overflow Handling //! @@ -23,9 +22,61 @@ //! using `I256` as an intermediate type and scaled back to `i128` if the final //! result fits. This is called *phantom overflow handling*. //! -//! `I256` operations do **not** apply phantom overflow handling — doing so -//! would require a custom `I512` type. Overflowing two large `I256` values is -//! considered rare enough in practice that this trade-off is acceptable. +//! `I256` operations apply it too, without a wider intermediate type. When +//! `x * y` overflows `I256`, both operands are split by the denominator and the +//! division is distributed, which is an exact identity: +//! +//! ```text +//! |x| = q1*D + r1 |y| = q2*D + r2 (D = |denominator|, 0 <= r1, r2 < D) +//! floor(|x*y|/D) = q1*q2*D + q1*r2 + r1*q2 + floor(r1*r2/D) +//! ``` +//! +//! The decomposition runs on magnitudes; the result's sign is reapplied before +//! the rounding direction is. +//! +//! Three of the four terms are bounded by the answer or by an input, so they +//! fit whenever the inputs and the result do. Only `r1*r2` is bounded by `D` +//! alone, which gives the single condition `|denominator| <= 2^128` (roughly +//! `3.4e38`, far above every fixed-point scale in practical use: `10^18`, +//! `10^27`, `2^96`, `10^38`). Within that domain the result is bit-for-bit what +//! a 512-bit intermediate would produce. +//! +//! Beyond it the operation rejects rather than returning an incorrect value, +//! and the rejection is permitted rather than guaranteed: a large denominator +//! whose remainders happen to be small still succeeds. +//! +//! Phantom overflow handling covers the free functions in +//! [`i128_fixed_point`] and [`i256_fixed_point`], and the `checked_*` methods +//! on [`wad::Wad`]. It does **not** cover `Wad`'s operator implementations +//! (`+`, `-`, `*`, `/`), which work directly on `i128` and panic on overflow, +//! because an operator cannot reach an `Env` to construct the `I256` +//! intermediate. The resulting bounds are tabulated in the `# Overflow` +//! section on [`wad::Wad`]. +//! +//! ### Error Reporting +//! +//! Domain errors (a zero `denominator`, `MIN / -1`) are left to the platform +//! rather than mapped to contract errors, since these are plain arithmetic +//! operations. What the platform raises depends on the width: +//! +//! - **`i128`**: native Rust arithmetic, whose panics surface on-chain as a +//! generic wasm trap (`Error(WasmVm, InvalidAction)`). +//! - **`I256`**: host calls, for which the host raises a single arithmetic +//! error, `Error(Object, ArithDomain)`, identical for overflow, division by +//! zero and `MIN / -1`. +//! +//! The `I256` phantom-overflow fallback runs on checked operations, so every +//! failure inside it, a zero `denominator` included, collapses into a single +//! `None` and surfaces as [`SorobanFixedPointError::Overflow`], consistent +//! with the one host error covering all these faults. +//! +//! The platform therefore never distinguishes a division by zero from an +//! overflow; a caller that needs a distinct division-by-zero signal has to +//! validate the denominator up front. That is what the higher-level helpers +//! such as [`wad::Wad::from_ratio`] do with +//! [`SorobanFixedPointError::DivisionByZero`]. +//! +//! The checked variants return `None` for every failure above. //! //! ## Structure //! diff --git a/packages/contract-utils/src/math/test/i256_fixed_point.rs b/packages/contract-utils/src/math/test/i256_fixed_point.rs index a6c4ce7ab..5ab98f94d 100644 --- a/packages/contract-utils/src/math/test/i256_fixed_point.rs +++ b/packages/contract-utils/src/math/test/i256_fixed_point.rs @@ -2,18 +2,21 @@ extern crate std; -use soroban_sdk::{Env, I256}; +use std::panic::{catch_unwind, AssertUnwindSafe}; + +use proptest::prelude::*; +use soroban_sdk::{Bytes, Env, I256}; use crate::math::{ i256_fixed_point::{ - checked_mul_div, checked_mul_div_ceil, checked_mul_div_floor, + checked_mul_div, checked_mul_div_ceil, checked_mul_div_decomposed, checked_mul_div_floor, checked_mul_div_with_rounding, mul_div, mul_div_ceil, mul_div_floor, mul_div_with_rounding, }, Rounding, }; #[test] -#[should_panic] +#[should_panic(expected = "Error(Object, ArithDomain)")] fn test_mul_div_zero_denominator() { let env = Env::default(); let x: I256 = I256::from_i128(&env, 100); @@ -91,16 +94,22 @@ fn test_mul_div_floor_large_number() { } #[test] -#[should_panic(expected = "overflow")] -fn test_mul_div_floor_phantom_overflow() { +fn test_mul_div_floor_phantom_overflow_resolves() { let env = Env::default(); let x: I256 = I256::from_i128(&env, i128::MAX); - // 256 bit max ~= 5.8e76, 128 bit max ~= 1.7e38, need to multiply by at - // least 10^39 - let y: I256 = I256::from_i128(&env, 10i128.pow(39)); + // x * 10^39 overflows the I256 intermediate (max ~5.8e76), so this + // exercises the fallback. The quotient is 10^21 * x, which fits, so it + // is recovered rather than failing. + // + // 10^39 is the multiplier we need, but it exceeds i128::MAX (~1.7e38), so + // it cannot be written as `10i128.pow(39)`. It is built in I256 space + // instead. + let y: I256 = I256::from_i128(&env, 10i128.pow(20)).mul(&I256::from_i128(&env, 10i128.pow(19))); let denominator: I256 = I256::from_i128(&env, 10i128.pow(18)); - mul_div_floor(&x, &y, &denominator); + let result = mul_div_floor(&x, &y, &denominator); + + assert_eq!(result, x.mul(&I256::from_i128(&env, 10i128.pow(21)))); } #[test] @@ -141,20 +150,24 @@ fn test_mul_div_ceil_large_number() { } #[test] -#[should_panic(expected = "overflow")] -fn test_mul_div_ceil_phantom_overflow() { +fn test_mul_div_ceil_phantom_overflow_resolves() { let env = Env::default(); let x: I256 = I256::from_i128(&env, i128::MAX); - // 256 bit max ~= 5.8e76, 128 bit max ~= 1.7e38, need to multiply by at - // least 10^39 - let y: I256 = I256::from_i128(&env, 10i128.pow(39)); + // Same input as the floor case. The division is exact, so ceil returns the + // same value. + // + // 10^39 exceeds i128::MAX (~1.7e38), so it is built in I256 space rather + // than as `10i128.pow(39)`. + let y: I256 = I256::from_i128(&env, 10i128.pow(20)).mul(&I256::from_i128(&env, 10i128.pow(19))); let denominator: I256 = I256::from_i128(&env, 10i128.pow(18)); - mul_div_ceil(&x, &y, &denominator); + let result = mul_div_ceil(&x, &y, &denominator); + + assert_eq!(result, x.mul(&I256::from_i128(&env, 10i128.pow(21)))); } #[test] -#[should_panic] +#[should_panic(expected = "Error(Object, ArithDomain)")] fn test_mul_div_floor_zero_denominator() { let env = Env::default(); let x: I256 = I256::from_i128(&env, 100); @@ -165,7 +178,7 @@ fn test_mul_div_floor_zero_denominator() { } #[test] -#[should_panic] +#[should_panic(expected = "Error(Object, ArithDomain)")] fn test_mul_div_ceil_zero_denominator() { let env = Env::default(); let x: I256 = I256::from_i128(&env, 100); @@ -538,26 +551,65 @@ fn test_checked_mul_div_floor_negative_with_remainder() { } #[test] -fn test_checked_mul_div_mul_overflow_returns_none() { +fn test_checked_mul_div_mul_overflow_resolves() { let env = Env::default(); - // x * y = I256::MAX * 2 overflows I256, so the checked variants must return - // None rather than panicking like their unchecked counterparts. + // x * y = I256::MAX * 2 overflows I256, but the quotient is exactly + // I256::MAX. The remainders are both zero, so the decomposition + // recovers the answer and the checked variants return `Some` where they + // used to return `None`. let x: I256 = I256::max_value(&env); let y: I256 = I256::from_i128(&env, 2); let denominator: I256 = I256::from_i128(&env, 2); + let expected: I256 = I256::max_value(&env); - assert_eq!(checked_mul_div(&x, &y, &denominator), None); - assert_eq!(checked_mul_div_floor(&x, &y, &denominator), None); - assert_eq!(checked_mul_div_ceil(&x, &y, &denominator), None); + assert_eq!(checked_mul_div(&x, &y, &denominator), Some(expected.clone())); + assert_eq!(checked_mul_div_floor(&x, &y, &denominator), Some(expected.clone())); + assert_eq!(checked_mul_div_ceil(&x, &y, &denominator), Some(expected.clone())); assert_eq!( checked_mul_div_with_rounding(x.clone(), y.clone(), denominator.clone(), Rounding::Floor), - None + Some(expected.clone()) ); assert_eq!( checked_mul_div_with_rounding(x.clone(), y.clone(), denominator.clone(), Rounding::Ceil), - None + Some(expected.clone()) ); - assert_eq!(checked_mul_div_with_rounding(x, y, denominator, Rounding::Truncate), None); + assert_eq!( + checked_mul_div_with_rounding(x, y, denominator, Rounding::Truncate), + Some(expected) + ); +} + +#[test] +#[should_panic(expected = "Error(Object, ArithDomain)")] +fn test_mul_div_min_by_negative_one_panics() { + let env = Env::default(); + // `I256::MIN / -1` is `2^255`, which has no `I256` representation. + // `checked_mul` succeeds and the overflow happens inside the unchecked + // division, so this is the one failure the module reports as the host's + // arithmetic error rather than a contract error code. Pinned deliberately + // rather than fixed: the i128 sibling has the identical trap, and routing + // the fast path through the checked helper would re-price every + // non-overflowing call. + let x: I256 = I256::min_value(&env); + let y: I256 = I256::from_i128(&env, 1); + let denominator: I256 = I256::from_i128(&env, -1); + + mul_div_floor(&x, &y, &denominator); +} + +#[test] +fn test_checked_mul_div_min_by_negative_one_returns_none() { + let env = Env::default(); + // The checked counterpart of the case above. Both fail, which is what keeps + // the two families in agreement on their domain; only the reporting + // differs. + let x: I256 = I256::min_value(&env); + let y: I256 = I256::from_i128(&env, 1); + let denominator: I256 = I256::from_i128(&env, -1); + + assert_eq!(checked_mul_div_floor(&x, &y, &denominator), None); + assert_eq!(checked_mul_div_ceil(&x, &y, &denominator), None); + assert_eq!(checked_mul_div(&x, &y, &denominator), None); } // ################## MULDIV TESTS ################## @@ -635,3 +687,937 @@ fn test_checked_muldiv_truncate_success() { assert_eq!(result, Some(I256::from_i128(&env, 483_5313675))); } + +// ################## FALLBACK HELPERS ################## + +/// Shorthand for the small `I256` literals the fallback tests are built from. +fn i(e: &Env, v: i128) -> I256 { + I256::from_i128(e, v) +} + +/// `2^n` as an `I256`. +fn two_pow(e: &Env, n: u32) -> I256 { + I256::from_i32(e, 2).pow(n) +} + +/// `-v`. The SDK exposes no `Neg` impl, and nothing below negates `I256::MIN`. +fn negate(v: &I256) -> I256 { + I256::from_i32(v.env(), 0).sub(v) +} + +/// The unchecked half of one family pair, held as a function pointer so all +/// three rounding modes can be driven from one table. +type UncheckedMulDiv = fn(&I256, &I256, &I256) -> I256; + +/// Reads 32 bytes as an `I256`, the route the property tests use because `I256` +/// has no `Arbitrary` impl. +fn from_be(e: &Env, bytes: &[u8; 32]) -> I256 { + I256::from_be_bytes(e, &Bytes::from_array(e, bytes)) +} + +/// Builds a signed `I256` from the low `significant` bytes of `bytes`, so the +/// property tests can bound operand size without rejecting draws. +fn bounded(e: &Env, bytes: &[u8; 32], significant: usize, negative: bool) -> I256 { + let mut buf = [0u8; 32]; + buf[32 - significant..].copy_from_slice(&bytes[32 - significant..]); + let value = from_be(e, &buf); + if negative { + negate(&value) + } else { + value + } +} + +/// A property-test operand, with magnitude in `2^151 ..= 2^152 - 1`. +/// +/// Both bounds matter. The floor is forced so that two operands always multiply +/// past `2^255`, which puts every draw on the fallback rather than most of +/// them; each property test asserts that the fast path did give up. The ceiling +/// keeps the quotient representable, and keeps `I256::MIN` out of range, so +/// negating an operand or a result is always defined. +fn operand(e: &Env, bytes: &[u8; 32], negative: bool) -> I256 { + let mut buf = *bytes; + buf[13] |= 0x80; + bounded(e, &buf, 19, negative) +} + +/// A property-test denominator, with magnitude in `2^127 ..= 2^128 - 1`. +/// +/// The high bit is forced so that no draw is small enough to push the quotient +/// out of `I256`, and the ceiling keeps every draw inside the guaranteed +/// domain. +fn denominator(e: &Env, bytes: &[u8; 32], negative: bool) -> I256 { + let mut buf = *bytes; + buf[16] |= 0x80; + bounded(e, &buf, 16, negative) +} + +/// Drives one magnitude shape through all eight sign combinations and all three +/// rounding modes. +/// +/// `magnitude` is the truncated magnitude of the exact quotient and `inexact` +/// says whether anything was dropped. Together they fix every expected value: +/// truncation keeps the magnitude, floor moves away from zero only for a +/// negative result, ceil only for a positive one, and neither moves when the +/// quotient is exact. +fn assert_rounding_matrix( + e: &Env, + abs_x: &I256, + abs_y: &I256, + abs_d: &I256, + magnitude: &I256, + inexact: bool, +) { + let zero = i(e, 0); + let step = if inexact { i(e, 1) } else { zero.clone() }; + + for signs in [ + (false, false, false), + (true, false, false), + (false, true, false), + (false, false, true), + (true, true, false), + (true, false, true), + (false, true, true), + (true, true, true), + ] { + let (neg_x, neg_y, neg_d) = signs; + let x = if neg_x { negate(abs_x) } else { abs_x.clone() }; + let y = if neg_y { negate(abs_y) } else { abs_y.clone() }; + let d = if neg_d { negate(abs_d) } else { abs_d.clone() }; + let negative = neg_x ^ neg_y ^ neg_d; + + assert_eq!(x.checked_mul(&y), None, "signs {signs:?} must reach the fallback"); + + let (floor, ceil) = if negative { + (negate(&magnitude.add(&step)), negate(magnitude)) + } else { + (magnitude.clone(), magnitude.add(&step)) + }; + let truncate = if negative { negate(magnitude) } else { magnitude.clone() }; + + assert_eq!(mul_div_floor(&x, &y, &d), floor, "floor, signs {signs:?}"); + assert_eq!(mul_div_ceil(&x, &y, &d), ceil, "ceil, signs {signs:?}"); + assert_eq!(mul_div(&x, &y, &d), truncate, "truncate, signs {signs:?}"); + // The sign of the result, asserted on its own rather than read off the + // magnitudes above. + assert_eq!(truncate < zero, negative, "sign, signs {signs:?}"); + } +} + +// ################## FALLBACK: DIFFERENTIAL ################## + +#[test] +fn decomposition_matches_fast_path_works() { + let e = Env::default(); + // Thousands of metered host calls in one `Env` exceed the default budget, + // and the resulting `Error(Budget, ExceededLimit)` names no test logic + // at all. + e.cost_estimate().budget().reset_unlimited(); + + let mut compared = 0u32; + for x in -13i128..=13 { + for y in -13i128..=13 { + for d in -6i128..=6 { + // Production reaches the fallback only with `|x * y| >= 2^255 + // >= |d|`, so the result magnitude is at least + // one. Calling the core directly outside that + // band asks `from_magnitude` for a negatively signed + // zero, which it answers `None` to by design. + if d == 0 || x == 0 || y == 0 || x.abs() * y.abs() < d.abs() { + continue; + } + let (xi, yi, di) = (i(&e, x), i(&e, y), i(&e, d)); + for (rounding, fast) in [ + (Rounding::Floor, mul_div_floor(&xi, &yi, &di)), + (Rounding::Ceil, mul_div_ceil(&xi, &yi, &di)), + (Rounding::Truncate, mul_div(&xi, &yi, &di)), + ] { + let slow = checked_mul_div_decomposed(&xi, &yi, &di, rounding) + .expect("small operands are inside the fallback's domain"); + assert_eq!(slow, fast, "x={x} y={y} d={d}"); + compared += 1; + } + } + } + } + + assert!(compared > 3_000, "the grid degenerated to {compared} comparisons"); +} + +#[test] +fn prop_decomposition_matches_fast_path() { + let e = Env::default(); + e.cost_estimate().budget().reset_unlimited(); + + proptest!(|(x: i128, y: i128, d: i128)| { + // `|i128 * i128| <= 2^254`, so the fast path is defined for every draw here + // and the two routes are directly comparable. + prop_assume!(x != 0 && y != 0 && d != 0); + // Same lower bound on the result magnitude as the grid above. + let in_band = match x.unsigned_abs().checked_mul(y.unsigned_abs()) { + Some(product) => product >= d.unsigned_abs(), + // A product past `u128` exceeds every possible `|d|`. + None => true, + }; + prop_assume!(in_band); + + let (xi, yi, di) = (i(&e, x), i(&e, y), i(&e, d)); + prop_assert_eq!( + checked_mul_div_decomposed(&xi, &yi, &di, Rounding::Floor), + Some(mul_div_floor(&xi, &yi, &di)) + ); + prop_assert_eq!( + checked_mul_div_decomposed(&xi, &yi, &di, Rounding::Ceil), + Some(mul_div_ceil(&xi, &yi, &di)) + ); + prop_assert_eq!( + checked_mul_div_decomposed(&xi, &yi, &di, Rounding::Truncate), + Some(mul_div(&xi, &yi, &di)) + ); + }) +} + +// ################## FALLBACK: DOMAIN BOUNDARY ################## + +#[test] +fn mul_div_at_denominator_two_pow_128_works() { + let e = Env::default(); + let d = two_pow(&e, 128); + let x = d.clone(); + let y = d.add(&i(&e, 1)); + + // `x * y` is `2^256 + 2^128`, so the fast path gives up. + assert_eq!(x.checked_mul(&y), None); + + // `2^128 * (2^128 + 1) / 2^128` is `2^128 + 1` exactly, so all three modes + // agree. + let expected = d.add(&i(&e, 1)); + assert_eq!(mul_div_floor(&x, &y, &d), expected); + assert_eq!(mul_div_ceil(&x, &y, &d), expected); + assert_eq!(mul_div(&x, &y, &d), expected); +} + +#[test] +fn mul_div_at_maximal_remainders_works() { + let e = Env::default(); + // The `2^128` domain rests entirely on `r1 * r2` staying inside `U256`, and + // boundary coverage built from zero remainders never exercises that term at + // all. Here both remainders are maximal. With `n = 2^128`, `d = n - 1` and + // `x = y = 2n - 3`, the split is `q1 = q2 = 1` and `r1 = r2 = n - 2`, so + // `r1 * r2 = n^2 - 4n + 4`, which is `2^256 - 2^130 + 4`: twice what `I256` + // holds and just inside `U256`. + // + // The expected value comes from polynomial division rather than from the + // decomposition, so the oracle owes nothing to the code under test: + // + // ```text + // x * y = 4n^2 - 12n + 9 = (n - 1)(4n - 8) + 1 + // ``` + // + // The quotient is `4n - 8`, which is `2^130 - 8`, and the remainder is 1, + // so the result is inexact. + let n = two_pow(&e, 128); + let d = n.sub(&i(&e, 1)); + let x = n.mul(&i(&e, 2)).sub(&i(&e, 3)); + + assert_eq!(x.checked_mul(&x), None); + + let floor = two_pow(&e, 130).sub(&i(&e, 8)); + assert_eq!(mul_div_floor(&x, &x, &d), floor); + assert_eq!(mul_div(&x, &x, &d), floor); + assert_eq!(mul_div_ceil(&x, &x, &d), floor.add(&i(&e, 1))); +} + +#[test] +fn checked_mul_div_above_two_pow_128_returns_none() { + let e = Env::default(); + // One past the domain, with the remainders that make the term overflow: + // `r1 = r2 = 2^128`, so `r1 * r2` is `2^256` and the exact detector fires. + let x = two_pow(&e, 128); + let d = x.add(&i(&e, 1)); + + assert_eq!(x.checked_mul(&x), None); + assert_eq!(checked_mul_div_floor(&x, &x, &d), None); + assert_eq!(checked_mul_div_ceil(&x, &x, &d), None); + assert_eq!(checked_mul_div(&x, &x, &d), None); + + // The rejection is not confined to answers that would not fit. Here the + // true answer is `2^60`, which fits with room to spare, and the + // operation rejects anyway because `|denominator|` is past the ceiling. + // That is the false rejection the `2^128` condition buys. + let wide = two_pow(&e, 130); + let wide_d = two_pow(&e, 200); + + assert_eq!(wide.checked_mul(&wide), None); + assert_eq!(checked_mul_div_floor(&wide, &wide, &wide_d), None); + assert_eq!(checked_mul_div_ceil(&wide, &wide, &wide_d), None); + assert_eq!(checked_mul_div(&wide, &wide, &wide_d), None); +} + +#[test] +#[should_panic(expected = "Error(Contract, #1500)")] // SorobanFixedPointError::Overflow +fn mul_div_above_two_pow_128_panics() { + let e = Env::default(); + let x = two_pow(&e, 128); + let d = x.add(&i(&e, 1)); + + mul_div_floor(&x, &x, &d); +} + +#[test] +fn mul_div_at_practical_scales_works() { + let e = Env::default(); + // Every fixed-point scale in practical use, each with a product that + // overflows. Taking `y` as the denominator makes the exact answer `x` + // with no remainder, an expected value that owes nothing to the + // decomposition. + let x = two_pow(&e, 200); + for d in [ + i(&e, 10i128.pow(18)), // WAD + i(&e, 10i128.pow(27)), // RAY + two_pow(&e, 96), // Q64.96 + i(&e, 10i128.pow(38)), + ] { + assert_eq!(x.checked_mul(&d), None, "the scale must overflow the fast path"); + assert_eq!(mul_div_floor(&x, &d, &d), x); + assert_eq!(mul_div_ceil(&x, &d, &d), x); + assert_eq!(mul_div(&x, &d, &d), x); + } +} + +#[test] +fn mul_div_inexact_at_wad_scale_works() { + let e = Env::default(); + // The WAD scale with a remainder. Taking `y = d + 1` makes the exact + // quotient `x + x/d`, so the expected value is computable on the fast + // path, where `2^200` and `10^18` both fit comfortably. + let d = i(&e, 10i128.pow(18)); + let x = two_pow(&e, 200); + let y = d.add(&i(&e, 1)); + + assert_eq!(x.checked_mul(&y), None); + // `10^18` carries a factor of `5^18`, so it cannot divide a power of two. + assert!(x.rem_euclid(&d) > i(&e, 0)); + + let floor = x.add(&x.div(&d)); + assert_eq!(mul_div_floor(&x, &y, &d), floor); + assert_eq!(mul_div(&x, &y, &d), floor); + assert_eq!(mul_div_ceil(&x, &y, &d), floor.add(&i(&e, 1))); +} + +#[test] +fn mul_div_beyond_guaranteed_domain_works() { + let e = Env::default(); + // `2^128` is a sufficient domain, not a necessary one: a denominator far + // above it still succeeds when the remainders stay small. Here `|d|` is + // `2^255`, the largest magnitude an `I256` has, both quotients are + // zero, and `r1 * r2` is only `2^255`. + // + // It is also the smallest result the fallback can produce. Entering it + // requires `|x * y| >= 2^255`, and no `|denominator|` exceeds `2^255`, + // so the magnitude is at least one. This input sits exactly on that + // floor and carries a negative sign, the pairing `from_magnitude` + // cannot represent for a zero magnitude. + let x = two_pow(&e, 128); + let y = two_pow(&e, 127); + let d = I256::min_value(&e); + + assert_eq!(x.checked_mul(&y), None); + + let expected = i(&e, -1); + assert_eq!(mul_div_floor(&x, &y, &d), expected); + assert_eq!(mul_div_ceil(&x, &y, &d), expected); + assert_eq!(mul_div(&x, &y, &d), expected); +} + +#[test] +fn mul_div_at_denominator_max_works() { + let e = Env::default(); + // The input the design's failure taxonomy cites to show that `2^128` is a + // sufficient domain rather than a necessary one. `|denominator|` is `2^255 + // - 1` here, far above the ceiling, but the split leaves `r1 = r2 = 0`, + // so `r1 * r2` never grows and the exact answer comes back. + // + // A different term profile from `mul_div_beyond_guaranteed_domain_works`, + // where both quotients are zero and the whole answer comes from the + // fractional term. Here `q1 = q2 = 1` and both remainders are zero, so + // it all comes from `q1 * q2 * abs_d`. + let max = I256::max_value(&e); + + assert_eq!(max.checked_mul(&max), None); + + assert_eq!(mul_div_floor(&max, &max, &max), max); + assert_eq!(mul_div_ceil(&max, &max, &max), max); + assert_eq!(mul_div(&max, &max, &max), max); + assert_eq!(checked_mul_div_floor(&max, &max, &max), Some(max.clone())); + assert_eq!(checked_mul_div_ceil(&max, &max, &max), Some(max.clone())); + assert_eq!(checked_mul_div(&max, &max, &max), Some(max.clone())); +} + +// ################## FALLBACK: ROUNDING, EXACTNESS, SIGN ################## + +#[test] +fn rounding_matrix_in_fallback_domain_works() { + let e = Env::default(); + e.cost_estimate().budget().reset_unlimited(); + + // Inexact shape. With `d = 3`, `a = 2^127`, `b = 2^126`: + // + // ```text + // x = 3a + 1, y = 3b + 2 -> x * y = 9ab + 6a + 3b + 2 + // x * y / 3 = 3ab + 2a + b + 2/3 + // ``` + // + // so the truncated magnitude is `3ab + 2a + b` and two thirds are dropped. + let a = two_pow(&e, 127); + let b = two_pow(&e, 126); + let three = i(&e, 3); + let abs_x = three.mul(&a).add(&i(&e, 1)); + let abs_y = three.mul(&b).add(&i(&e, 2)); + let magnitude = three.mul(&a).mul(&b).add(&i(&e, 2).mul(&a)).add(&b); + assert_rounding_matrix(&e, &abs_x, &abs_y, &three, &magnitude, true); + + // Exact shape, chosen so that neither remainder is zero. With `d = 6`, `r1 + // = 2` and `r2 = 3`, `r1 * r2` is a non-zero exact multiple of `d`: + // + // ```text + // x = 6a + 2, y = 6b + 3 -> x * y = 36ab + 18a + 12b + 6 + // x * y / 6 = 6ab + 3a + 2b + 1, with nothing dropped + // ``` + let a = two_pow(&e, 124); + let b = two_pow(&e, 127); + let six = i(&e, 6); + let abs_x = six.mul(&a).add(&i(&e, 2)); + let abs_y = six.mul(&b).add(&i(&e, 3)); + let magnitude = + six.mul(&a).mul(&b).add(&i(&e, 3).mul(&a)).add(&i(&e, 2).mul(&b)).add(&i(&e, 1)); + assert_rounding_matrix(&e, &abs_x, &abs_y, &six, &magnitude, false); +} + +#[test] +fn exactness_with_non_zero_remainders_works() { + let e = Env::default(); + // The case that separates "exact" from "no remainders", and the one a suite + // whose exact cases all set `r1 = 0` or `r2 = 0` never reaches. With `d = + // 6`, `r1 = 2` and `r2 = 3`, `r1 * r2` is 6, so the quotient is exact even + // though neither remainder is zero. An implementation reading `r1 * r2 != + // 0` as inexact makes ceil round up by one here. + let a = two_pow(&e, 125); + let b = two_pow(&e, 126); + let six = i(&e, 6); + let x = six.mul(&a).add(&i(&e, 2)); + let y = six.mul(&b).add(&i(&e, 3)); + + assert_eq!(x.checked_mul(&y), None); + + // `x * y / 6 = 6ab + 3a + 2b + 1` + let expected = six.mul(&a).mul(&b).add(&i(&e, 3).mul(&a)).add(&i(&e, 2).mul(&b)).add(&i(&e, 1)); + + assert_eq!(mul_div_floor(&x, &y, &six), expected); + assert_eq!(mul_div(&x, &y, &six), expected); + assert_eq!(mul_div_ceil(&x, &y, &six), expected, "an exact quotient must not round up"); +} + +#[test] +fn mul_div_negative_inexact_rounds_away_from_zero_works() { + let e = Env::default(); + // With `d = 7`, `a = 2^126`, `x = -(7a + 3)` and `y = 7a + 5`: + // + // ```text + // |x * y| = 49a^2 + 56a + 15 + // |x * y| / 7 = 7a^2 + 8a + 2 + 1/7 + // ``` + // + // so the magnitude is `7a^2 + 8a + 2` and the result is negative and + // inexact. Floor is the mode that moves away from zero here, which is + // the direction that leaks value in a vault or fee path when it is + // missed. + let a = two_pow(&e, 126); + let seven = i(&e, 7); + let x = negate(&seven.mul(&a).add(&i(&e, 3))); + let y = seven.mul(&a).add(&i(&e, 5)); + + assert_eq!(x.checked_mul(&y), None); + + let truncate = negate(&seven.mul(&a).mul(&a).add(&i(&e, 8).mul(&a)).add(&i(&e, 2))); + assert_eq!(mul_div(&x, &y, &seven), truncate); + assert_eq!(mul_div_ceil(&x, &y, &seven), truncate); + assert_eq!(mul_div_floor(&x, &y, &seven), truncate.sub(&i(&e, 1))); +} + +#[test] +fn checked_mul_div_rounds_in_fallback_domain_works() { + let e = Env::default(); + // Each checked function passes its own rounding mode into the fallback, and + // nothing else here pins those three arguments to a value. The rejection + // cases return `None` in every mode, the exact cases make all three + // modes agree, and the dispatcher test compares a dispatcher against + // the leaf it delegates to, so a leaf and its dispatcher move together + // when the leaf is wrong. An inexact fallback quotient is the input + // that separates the modes. + // + // With `d = 5` and `a = 2^126`: + // + // ```text + // x = 5a + 3, y = 5a + 4 -> x * y = 25a^2 + 35a + 12 + // x * y / 5 = 5a^2 + 7a + 2 + 2/5 + // ``` + let a = two_pow(&e, 126); + let five = i(&e, 5); + let x = five.mul(&a).add(&i(&e, 3)); + let y = five.mul(&a).add(&i(&e, 4)); + + assert_eq!(x.checked_mul(&y), None); + + let magnitude = five.mul(&a).mul(&a).add(&i(&e, 7).mul(&a)).add(&i(&e, 2)); + + assert_eq!(checked_mul_div_floor(&x, &y, &five), Some(magnitude.clone())); + assert_eq!(checked_mul_div(&x, &y, &five), Some(magnitude.clone())); + assert_eq!(checked_mul_div_ceil(&x, &y, &five), Some(magnitude.add(&i(&e, 1)))); + + // Negated, where floor is the mode that moves away from zero. This half is + // what separates floor from truncate; on a positive result the two + // agree. + let neg_x = negate(&x); + let truncate = negate(&magnitude); + + assert_eq!(checked_mul_div(&neg_x, &y, &five), Some(truncate.clone())); + assert_eq!(checked_mul_div_ceil(&neg_x, &y, &five), Some(truncate.clone())); + assert_eq!(checked_mul_div_floor(&neg_x, &y, &five), Some(truncate.sub(&i(&e, 1)))); +} + +#[test] +fn mul_div_with_zero_operand_stays_on_fast_path_works() { + let e = Env::default(); + // The fallback's sign rule is an XOR over the three operands, which matches + // the sign of the true result only because a product involving zero + // never overflows. A negative denominator is what would expose a + // reliance on it: the XOR alone calls these results negative. + let zero = i(&e, 0); + let max = I256::max_value(&e); + let neg_one = i(&e, -1); + + assert_eq!(zero.checked_mul(&max), Some(zero.clone())); + + for (x, y) in [(zero.clone(), max.clone()), (max.clone(), zero.clone())] { + assert_eq!(mul_div_floor(&x, &y, &neg_one), zero); + assert_eq!(mul_div_ceil(&x, &y, &neg_one), zero); + assert_eq!(mul_div(&x, &y, &neg_one), zero); + assert_eq!(checked_mul_div_floor(&x, &y, &neg_one), Some(zero.clone())); + assert_eq!(checked_mul_div_ceil(&x, &y, &neg_one), Some(zero.clone())); + assert_eq!(checked_mul_div(&x, &y, &neg_one), Some(zero.clone())); + } +} + +// ################## FALLBACK: REPRESENTABILITY ################## + +#[test] +fn mul_div_with_min_operand_works() { + let e = Env::default(); + // `|I256::MIN|` is `2^255`, which the type itself cannot hold, so the + // operand reaches the core as an unsigned magnitude and has to come + // back through the negation intact. `MIN * 3 / 3` puts `2^255` on both + // ends of that round trip: the split is `q1 = (2^255 - 2)/3` and `r1 = + // 2`, since `2^255 % 3 == 2`, and the terms sum back to `3*q1 + 2`, + // which is `2^255` exactly. + let min = I256::min_value(&e); + let three = i(&e, 3); + + assert_eq!(min.checked_mul(&three), None); + + assert_eq!(mul_div_floor(&min, &three, &three), min); + assert_eq!(mul_div_ceil(&min, &three, &three), min); + assert_eq!(mul_div(&min, &three, &three), min); +} + +#[test] +fn mul_div_result_at_i256_min_works() { + let e = Env::default(); + // `I256`'s range is asymmetric, so the largest representable magnitude + // depends on the sign: `2^255` is one past `I256::MAX` going up, but + // exactly `I256::MIN` going down. `2^128 * 2^128 / -2` lands on it from + // the negative side. + // + // The product has to be `2^256` rather than `2^255` for the fast path to + // give up. A negative product of `2^255` is `I256::MIN`, which + // `checked_mul` accepts. + let x = two_pow(&e, 128); + let d = i(&e, -2); + + assert_eq!(x.checked_mul(&x), None); + + let min = I256::min_value(&e); + assert_eq!(mul_div_floor(&x, &x, &d), min); + assert_eq!(mul_div_ceil(&x, &x, &d), min); + assert_eq!(mul_div(&x, &x, &d), min); +} + +#[test] +fn checked_mul_div_result_above_i256_max_returns_none() { + let e = Env::default(); + // The positive half of the case above, same magnitude and same denominator + // up to sign. `2^255` has no positive representation, so the upper + // bound rejects it one short of where the negative bound accepts. + let x = two_pow(&e, 128); + let d = i(&e, 2); + + assert_eq!(checked_mul_div_floor(&x, &x, &d), None); + assert_eq!(checked_mul_div_ceil(&x, &x, &d), None); + assert_eq!(checked_mul_div(&x, &x, &d), None); +} + +#[test] +fn checked_mul_div_result_outside_range_returns_none() { + let e = Env::default(); + // A magnitude of `3 * 2^254`, past `I256` in both directions but well + // inside `U256`, so the summation itself succeeds and the rejection + // comes from the sign-specific bound rather than from a checked + // multiplication. + let x = two_pow(&e, 128); + let y = i(&e, 3).mul(&two_pow(&e, 126)); + let one = i(&e, 1); + + assert_eq!(x.checked_mul(&y), None); + assert_eq!(checked_mul_div(&x, &y, &one), None); + assert_eq!(checked_mul_div(&negate(&x), &y, &one), None); + assert_eq!(checked_mul_div_floor(&negate(&x), &y, &one), None); + assert_eq!(checked_mul_div_ceil(&negate(&x), &y, &one), None); +} + +#[test] +fn mul_div_with_all_terms_non_zero_works() { + let e = Env::default(); + // Every term of the decomposition live at once, including the + // dropped-fraction term that the cases above leave at zero. With `d = + // 5` and `a = 2^126`: + // + // ```text + // x = 5a + 3, y = 5a + 4 -> x * y = 25a^2 + 35a + 12 + // x * y / 5 = 5a^2 + 7a + 2 + 2/5 + // ``` + // + // so `q1*q2*d`, `q1*r2`, `r1*q2` and `floor(r1*r2/d)` are `5a^2`, `4a`, + // `3a` and 2, none of them zero. + let a = two_pow(&e, 126); + let five = i(&e, 5); + let x = five.mul(&a).add(&i(&e, 3)); + let y = five.mul(&a).add(&i(&e, 4)); + + assert_eq!(x.checked_mul(&y), None); + + let floor = five.mul(&a).mul(&a).add(&i(&e, 7).mul(&a)).add(&i(&e, 2)); + assert_eq!(mul_div_floor(&x, &y, &five), floor); + assert_eq!(mul_div(&x, &y, &five), floor); + assert_eq!(mul_div_ceil(&x, &y, &five), floor.add(&i(&e, 1))); +} + +// ################## FALLBACK: TOTALITY AND FAILURE REPORTING +// ################## + +#[test] +fn checked_family_never_panics_over_extremes() { + let e = Env::default(); + e.cost_estimate().budget().reset_unlimited(); + + let values = [ + I256::min_value(&e), + I256::min_value(&e).add(&i(&e, 1)), + i(&e, -1), + i(&e, 0), + i(&e, 1), + I256::max_value(&e).sub(&i(&e, 1)), + I256::max_value(&e), + ]; + + let mut triples = 0u32; + for x in &values { + for y in &values { + for d in &values { + // Returning at all is the assertion. Zero denominators, `MIN / + // -1` and unrepresentable results are all in + // this grid. + let _ = checked_mul_div_floor(x, y, d); + let _ = checked_mul_div_ceil(x, y, d); + let _ = checked_mul_div(x, y, d); + triples += 1; + } + } + } + + assert_eq!(triples, 343); +} + +#[test] +fn prop_checked_family_never_panics() { + let e = Env::default(); + e.cost_estimate().budget().reset_unlimited(); + + // Unconstrained triples over the whole type, including `MIN`, which the + // byte route reaches and `from_i128` cannot. + proptest!(|(xb: [u8; 32], yb: [u8; 32], db: [u8; 32])| { + let x = from_be(&e, &xb); + let y = from_be(&e, &yb); + let d = from_be(&e, &db); + + let _ = checked_mul_div_floor(&x, &y, &d); + let _ = checked_mul_div_ceil(&x, &y, &d); + let _ = checked_mul_div(&x, &y, &d); + }) +} + +#[test] +fn checked_mul_div_zero_denominator_with_overflowing_product_returns_none() { + let e = Env::default(); + // The product overflows and the denominator is zero, so the fallback runs + // and divides by a zero magnitude. This is the case that keeps the + // core's divisions checked rather than bare: the checked family owes a + // `None` here, not a trap. + let x = I256::max_value(&e); + let y = i(&e, 2); + let zero = i(&e, 0); + + assert_eq!(x.checked_mul(&y), None); + assert_eq!(checked_mul_div_floor(&x, &y, &zero), None); + assert_eq!(checked_mul_div_ceil(&x, &y, &zero), None); + assert_eq!(checked_mul_div(&x, &y, &zero), None); +} + +#[test] +#[should_panic(expected = "Error(Contract, #1500)")] // SorobanFixedPointError::Overflow +fn mul_div_zero_denominator_via_fallback_panics() { + let e = Env::default(); + // A zero denominator is reported two ways depending on operand size, a + // documented consequence of leaving domain faults to the platform. On the + // fast path the host's own arithmetic error surfaces, which the + // zero-denominator tests above pin; through the fallback it is `#1500`. + let x = I256::max_value(&e); + let y = i(&e, 2); + let zero = i(&e, 0); + + mul_div_floor(&x, &y, &zero); +} + +#[test] +#[should_panic(expected = "Error(Contract, #1500)")] // SorobanFixedPointError::Overflow +fn mul_div_ceil_unrepresentable_result_panics() { + let e = Env::default(); + // The third unchecked entry point's own `#1500`. The two tests around this + // one pin the code for `mul_div_floor` and `mul_div`; without this one, + // a wrong error variant in `mul_div_ceil`'s fallback arm alone goes + // unnoticed, because the family-agreement test only observes that an + // abort happened, not which. + let max = I256::max_value(&e); + let one = i(&e, 1); + + mul_div_ceil(&max, &max, &one); +} + +#[test] +#[should_panic(expected = "Error(Contract, #1500)")] // SorobanFixedPointError::Overflow +fn mul_div_unrepresentable_result_panics() { + let e = Env::default(); + // The other route to `#1500`: the decomposition runs and the true answer, + // `MAX * MAX`, does not fit `I256`. + let max = I256::max_value(&e); + let one = i(&e, 1); + + mul_div(&max, &max, &one); +} + +// ################## FALLBACK: FAMILY AND DISPATCHER AGREEMENT +// ################## + +#[test] +fn checked_and_unchecked_families_agree_on_domain() { + let e = Env::default(); + e.cost_estimate().budget().reset_unlimited(); + + // The two families may report a rejection differently, `None` against a + // panic, but they must never disagree about which inputs are + // acceptable. Divergence is the realistic outcome of changing one + // family and forgetting the other. + // + // A curated list rather than a sweep: every rejected case unwinds a real + // host panic, and the host writes its event log on the way out. + let two_128 = two_pow(&e, 128); + let inputs = [ + (i(&e, 100), i(&e, 50), i(&e, 10)), // fast path, succeeds + (i(&e, 0), I256::max_value(&e), i(&e, -1)), // zero operand + (I256::max_value(&e), i(&e, 2), i(&e, 2)), // fallback, succeeds + (I256::min_value(&e), i(&e, 3), i(&e, 3)), // `|MIN|` has no signed form + (two_128.clone(), two_128.add(&i(&e, 1)), two_128.clone()), // `|d|` is `2^128` + (negate(&two_128), two_pow(&e, 127), i(&e, 1)), // result is `I256::MIN` + (i(&e, 100), i(&e, 50), i(&e, 0)), // zero denominator, fast path + (I256::max_value(&e), i(&e, 2), i(&e, 0)), // zero denominator, fallback + (I256::min_value(&e), i(&e, 1), i(&e, -1)), // `MIN / -1` + (I256::max_value(&e), I256::max_value(&e), i(&e, 1)), // result too large + (two_128.clone(), two_128.clone(), two_128.add(&i(&e, 1))), // `|d|` past the domain + (two_128.clone(), two_pow(&e, 127), i(&e, 1)), // one past `I256::MAX` + ]; + + let mut rejections = 0u32; + for (index, (x, y, d)) in inputs.iter().enumerate() { + let pairs: [(Option, UncheckedMulDiv); 3] = [ + (checked_mul_div_floor(x, y, d), mul_div_floor), + (checked_mul_div_ceil(x, y, d), mul_div_ceil), + (checked_mul_div(x, y, d), mul_div), + ]; + + for (mode, (checked, unchecked)) in pairs.into_iter().enumerate() { + let panicked = catch_unwind(AssertUnwindSafe(|| unchecked(x, y, d))).is_err(); + assert_eq!( + checked.is_none(), + panicked, + "input {index}, mode {mode}: checked and unchecked disagree" + ); + if panicked { + rejections += 1; + } + } + } + + // Six of the twelve inputs are rejected, in all three modes. Without this + // the test would pass on a list that happened to contain no failures at + // all. + assert_eq!(rejections, 18, "the rejected half of the input list stopped rejecting"); +} + +#[test] +fn dispatchers_match_leaf_functions_in_fallback_domain_works() { + let e = Env::default(); + // The dispatchers stay pure delegation, so the fallback lives in the six + // leaf functions and there is exactly one place per behaviour. + let a = two_pow(&e, 126); + let five = i(&e, 5); + let x = five.mul(&a).add(&i(&e, 3)); + let y = five.mul(&a).add(&i(&e, 4)); + + assert_eq!(x.checked_mul(&y), None); + + assert_eq!( + mul_div_with_rounding(x.clone(), y.clone(), five.clone(), Rounding::Floor), + mul_div_floor(&x, &y, &five) + ); + assert_eq!( + mul_div_with_rounding(x.clone(), y.clone(), five.clone(), Rounding::Ceil), + mul_div_ceil(&x, &y, &five) + ); + assert_eq!( + mul_div_with_rounding(x.clone(), y.clone(), five.clone(), Rounding::Truncate), + mul_div(&x, &y, &five) + ); + assert_eq!( + checked_mul_div_with_rounding(x.clone(), y.clone(), five.clone(), Rounding::Floor), + checked_mul_div_floor(&x, &y, &five) + ); + assert_eq!( + checked_mul_div_with_rounding(x.clone(), y.clone(), five.clone(), Rounding::Ceil), + checked_mul_div_ceil(&x, &y, &five) + ); + assert_eq!( + checked_mul_div_with_rounding(x.clone(), y.clone(), five.clone(), Rounding::Truncate), + checked_mul_div(&x, &y, &five) + ); +} + +// ################## FALLBACK: PROPERTIES ################## + +#[test] +fn prop_cross_mode_consistency() { + let e = Env::default(); + e.cost_estimate().budget().reset_unlimited(); + + // Relates the three modes to each other, so it needs no reference + // implementation and still catches a wrong exactness flag or a wrong sign. + proptest!(|(xb: [u8; 32], yb: [u8; 32], db: [u8; 32], sx: bool, sy: bool, sd: bool)| { + let x = operand(&e, &xb, sx); + let y = operand(&e, &yb, sy); + let d = denominator(&e, &db, sd); + prop_assert_eq!(x.checked_mul(&y), None); + let zero = i(&e, 0); + + let floor = mul_div_floor(&x, &y, &d); + let ceil = mul_div_ceil(&x, &y, &d); + let truncate = mul_div(&x, &y, &d); + + prop_assert!(floor <= truncate); + prop_assert!(truncate <= ceil); + + let gap = ceil.sub(&floor); + prop_assert!(gap == zero || gap == i(&e, 1)); + + // Truncation follows the sign of the exact quotient, which the operand signs + // give directly. Reading the sign off `truncate` instead would be wrong for a + // quotient in `-1 .. 0`, where truncation lands on zero. + if (x < zero) ^ (y < zero) ^ (d < zero) { + prop_assert_eq!(&truncate, &ceil); + } else { + prop_assert_eq!(&truncate, &floor); + } + }) +} + +#[test] +fn prop_fallback_is_commutative_in_x_and_y() { + let e = Env::default(); + e.cost_estimate().budget().reset_unlimited(); + + // The four-term form is symmetric in `x` and `y` by construction, which was + // a stated reason for choosing it over the cheaper three-term form. + proptest!(|(xb: [u8; 32], yb: [u8; 32], db: [u8; 32], sx: bool, sy: bool, sd: bool)| { + let x = operand(&e, &xb, sx); + let y = operand(&e, &yb, sy); + let d = denominator(&e, &db, sd); + prop_assert_eq!(x.checked_mul(&y), None); + + prop_assert_eq!(mul_div_floor(&x, &y, &d), mul_div_floor(&y, &x, &d)); + prop_assert_eq!(mul_div_ceil(&x, &y, &d), mul_div_ceil(&y, &x, &d)); + prop_assert_eq!(mul_div(&x, &y, &d), mul_div(&y, &x, &d)); + }) +} + +#[test] +fn prop_negating_x_swaps_floor_and_ceil() { + let e = Env::default(); + e.cost_estimate().budget().reset_unlimited(); + + // `floor(-a) == -ceil(a)`, and truncation is odd. `I256::MIN` never reaches + // a negated position: the generator caps operand magnitudes below + // `2^152`, so negation is defined for every draw rather than filtered + // afterwards. + proptest!(|(xb: [u8; 32], yb: [u8; 32], db: [u8; 32], sx: bool, sy: bool, sd: bool)| { + let x = operand(&e, &xb, sx); + let y = operand(&e, &yb, sy); + let d = denominator(&e, &db, sd); + prop_assert_eq!(x.checked_mul(&y), None); + let neg_x = negate(&x); + + prop_assert_eq!(mul_div_floor(&neg_x, &y, &d), negate(&mul_div_ceil(&x, &y, &d))); + prop_assert_eq!(mul_div_ceil(&neg_x, &y, &d), negate(&mul_div_floor(&x, &y, &d))); + prop_assert_eq!(mul_div(&neg_x, &y, &d), negate(&mul_div(&x, &y, &d))); + }) +} + +#[test] +fn prop_negating_two_operands_preserves_result() { + let e = Env::default(); + e.cost_estimate().budget().reset_unlimited(); + + // Negating two of the three operands preserves both sign and magnitude. + proptest!(|(xb: [u8; 32], yb: [u8; 32], db: [u8; 32], sx: bool, sy: bool, sd: bool)| { + let x = operand(&e, &xb, sx); + let y = operand(&e, &yb, sy); + let d = denominator(&e, &db, sd); + prop_assert_eq!(x.checked_mul(&y), None); + let (neg_x, neg_y, neg_d) = (negate(&x), negate(&y), negate(&d)); + + prop_assert_eq!(mul_div_floor(&neg_x, &neg_y, &d), mul_div_floor(&x, &y, &d)); + prop_assert_eq!(mul_div_ceil(&neg_x, &neg_y, &d), mul_div_ceil(&x, &y, &d)); + prop_assert_eq!(mul_div(&neg_x, &neg_y, &d), mul_div(&x, &y, &d)); + + prop_assert_eq!(mul_div_floor(&neg_x, &y, &neg_d), mul_div_floor(&x, &y, &d)); + prop_assert_eq!(mul_div_ceil(&neg_x, &y, &neg_d), mul_div_ceil(&x, &y, &d)); + prop_assert_eq!(mul_div(&neg_x, &y, &neg_d), mul_div(&x, &y, &d)); + }) +} diff --git a/packages/contract-utils/src/math/test/wad.rs b/packages/contract-utils/src/math/test/wad.rs index a87b365e3..c4627adb7 100644 --- a/packages/contract-utils/src/math/test/wad.rs +++ b/packages/contract-utils/src/math/test/wad.rs @@ -205,6 +205,57 @@ fn test_mul_overflow() { let _ = a * b; } +/// The `*` operator's limit is on the *product* of the two values, not on +/// either operand. Products at or below 170 survive regardless of how they +/// are split between the operands. +#[test] +fn test_mul_operator_product_bound() { + let e = Env::default(); + for (a, b) in [(170, 1), (85, 2), (34, 5), (17, 10), (1, 170), (4, 4)] { + let product = Wad::from_integer(&e, a) * Wad::from_integer(&e, b); + assert_eq!(product, Wad::from_integer(&e, a * b), "{a} * {b}"); + } +} + +#[test] +#[should_panic] +fn test_mul_operator_product_bound_exceeded() { + let e = Env::default(); + // Product 171 exceeds `i128::MAX / WAD_SCALE^2` ~= 170.1411, even though + // both operands are tiny. + let _ = Wad::from_integer(&e, 171) * Wad::from_integer(&e, 1); +} + +/// The `/` operator's limit is on the dividend alone; the divisor never +/// affects it. +#[test] +fn test_div_operator_dividend_bound() { + let e = Env::default(); + for divisor in [1, 2, 1000] { + let quotient = Wad::from_integer(&e, 170) / Wad::from_integer(&e, divisor); + assert_eq!(quotient, Wad::from_ratio(&e, 170, divisor), "170 / {divisor}"); + } +} + +#[test] +#[should_panic] +fn test_div_operator_dividend_bound_exceeded() { + let e = Env::default(); + // A dividend above `i128::MAX / WAD_SCALE^2` ~= 170.1411 overflows even + // when dividing by one. + let _ = Wad::from_integer(&e, 171) / Wad::from_integer(&e, 1); +} + +/// `checked_mul` promotes to `I256`, so it succeeds well past the point where +/// the `*` operator overflows. +#[test] +fn test_checked_mul_exceeds_operator_bound() { + let e = Env::default(); + let a = Wad::from_integer(&e, 171); + let b = Wad::from_integer(&e, 1); + assert_eq!(a.checked_mul(&e, b), Some(Wad::from_integer(&e, 171))); +} + #[test] #[should_panic] fn test_div_by_zero() { @@ -452,6 +503,15 @@ fn test_checked_div_int_overflow() { assert_eq!(result, None); } +#[test] +#[should_panic(expected = "attempt to divide with overflow")] +fn test_div_int_min_by_negative_one_panics() { + // The operator sibling of the case above. `i128` division overflow is + // checked in every build profile, so this panic is unconditional. + let a = Wad::from_raw(i128::MIN); + let _ = a / -1i128; +} + #[test] fn test_neg_positive() { let e = Env::default(); @@ -475,6 +535,14 @@ fn test_neg_zero() { assert_eq!(neg_zero, Wad::from_raw(0)); } +#[test] +#[should_panic(expected = "attempt to negate with overflow")] +fn test_neg_min_panics() { + // `|i128::MIN|` has no `i128` representation. + let a = Wad::from_raw(i128::MIN); + let _ = -a; +} + #[test] fn test_abs_positive() { let e = Env::default(); diff --git a/packages/contract-utils/src/math/wad.rs b/packages/contract-utils/src/math/wad.rs index d8c6048c3..3aba1c6f9 100644 --- a/packages/contract-utils/src/math/wad.rs +++ b/packages/contract-utils/src/math/wad.rs @@ -41,6 +41,41 @@ use crate::math::{ /// **Typical precision loss:** ~10^-15 to 10^-16 in relative terms, which is /// negligible when converting to typical token precision (6-8 decimals). /// +/// # Overflow +/// +/// The operator implementations (`+`, `-`, `*`, `/`, unary `-`) work directly +/// on the raw `i128` and panic on arithmetic overflow. They do **not** apply +/// the phantom overflow handling described in the [module +/// docs](crate::math#phantom-overflow-handling), because an operator cannot +/// reach an [`Env`] to promote the intermediate to `I256`. Their domains are +/// therefore narrower than those of the `checked_*` methods: +/// +/// | expression | panics once | +/// |------------|-------------| +/// | `a + b`, `a - b` | the result's magnitude passes `i128::MAX / WAD_SCALE` ~= `1.7014 * 10^20` | +/// | `a * b` | the **product** `a * b` passes `i128::MAX / WAD_SCALE^2` ~= `170.1412` | +/// | `a / b` | **`a`** alone passes `i128::MAX / WAD_SCALE^2` ~= `170.1412`, whatever `b` is | +/// | `a * n`, `n * a` (`i128`) | `a.raw() * n` leaves `i128` | +/// | `a / n` (`i128`) | `n == 0`, or `a.raw() == i128::MIN && n == -1` | +/// | `-a` | `a.raw() == i128::MIN` | +/// +/// These panics assume `overflow-checks = true`, which the standard Stellar +/// release profile sets and `stellar contract build` enforces. In a build +/// without it, every row except `a / n` wraps silently instead of panicking; +/// division checks are the only ones rustc emits unconditionally. +/// +/// Two properties of the `*` bound are worth noting. It constrains the +/// product rather than either operand, so `Wad(1) * Wad(170)` and +/// `Wad(17) * Wad(10)` both succeed while `Wad(171) * Wad(1)` does not. And +/// because it constrains the product, values below 1 carry roughly 20 decimal +/// orders of headroom, which is the range `Wad` is primarily intended for. +/// The bound bites when a large magnitude meets a small one, for example a +/// price of `2000` scaled by a quantity, since `2000 > 170.1412`. +/// +/// [`Wad::checked_mul`] and [`Wad::checked_div`] succeed over the full range +/// in which the result is representable, and should be preferred wherever an +/// operand can plausibly exceed these bounds. +/// /// # Examples /// /// ```ignore @@ -688,6 +723,18 @@ impl Wad { impl Add for Wad { type Output = Wad; + /// Addition. + /// + /// # Errors + /// + /// * Panics with a native arithmetic overflow when `self.raw() + rhs.raw()` + /// leaves `i128`, i.e. once the result's magnitude passes `i128::MAX / + /// WAD_SCALE` ~= `1.7014 * 10^20`. + /// + /// # Notes + /// + /// [`Wad::checked_add`] returns `None` instead of panicking. The i128 + /// range is the hard limit for both, so no wider intermediate applies. fn add(self, rhs: Wad) -> Wad { Wad(self.0 + rhs.0) } @@ -697,26 +744,68 @@ impl Add for Wad { impl Sub for Wad { type Output = Wad; + /// Subtraction. + /// + /// # Errors + /// + /// * Panics with a native arithmetic overflow when `self.raw() - rhs.raw()` + /// leaves `i128`, i.e. once the result's magnitude passes `i128::MAX / + /// WAD_SCALE` ~= `1.7014 * 10^20`. + /// + /// # Notes + /// + /// refer to the notes of [`Wad::add`]. fn sub(self, rhs: Wad) -> Wad { Wad(self.0 - rhs.0) } } // Wad * Wad: fixed-point multiplication (a * b) / WAD_SCALE -// Result is truncated toward zero. impl Mul for Wad { type Output = Wad; + /// Fixed-point multiplication `(a * b) / WAD_SCALE`, truncated toward + /// zero. + /// + /// # Errors + /// + /// * Panics with a native arithmetic overflow when the raw product + /// `self.raw() * rhs.raw()` leaves `i128`, i.e. once the product `self * + /// rhs` passes `i128::MAX / WAD_SCALE^2` ~= `170.1412`. The bound + /// constrains the product and not either operand, so operands below 1 + /// carry roughly 20 decimal orders of headroom. + /// + /// # Notes + /// + /// No phantom overflow handling is applied here, because an operator + /// cannot reach an [`Env`] to promote the intermediate to `I256`. + /// [`Wad::checked_mul`] succeeds over the full range in which the result + /// is representable and should be preferred wherever the product can + /// plausibly exceed the bound above. See also the `# Overflow` section on + /// [`Wad`]. fn mul(self, rhs: Wad) -> Wad { Wad((self.0 * rhs.0) / WAD_SCALE) } } // Wad / Wad: fixed-point division (a * WAD_SCALE) / b -// Result is truncated toward zero. impl Div for Wad { type Output = Wad; + /// Fixed-point division `(a * WAD_SCALE) / b`, truncated toward zero. + /// + /// # Errors + /// + /// * Panics with a native arithmetic overflow when the raw numerator + /// `self.raw() * WAD_SCALE` leaves `i128`, i.e. once `self` alone passes + /// `i128::MAX / WAD_SCALE^2` ~= `170.1412`. The divisor plays no part in + /// this bound: `Wad(171) / Wad(1)` overflows. + /// * Panics with a native division by zero when `rhs` is zero. + /// + /// # Notes + /// + /// refer to the notes of [`Wad::mul`], substituting + /// [`Wad::checked_div`]. fn div(self, rhs: Wad) -> Wad { Wad((self.0 * WAD_SCALE) / rhs.0) } @@ -726,6 +815,12 @@ impl Div for Wad { impl Neg for Wad { type Output = Wad; + /// Negation. + /// + /// # Errors + /// + /// * Panics with a native arithmetic overflow when `self.raw() == + /// i128::MIN`, whose negation is not representable. fn neg(self) -> Wad { Wad(-self.0) } @@ -735,6 +830,17 @@ impl Neg for Wad { impl Mul for Wad { type Output = Wad; + /// Multiplication by an integer. No WAD scaling is applied, so the raw + /// value is scaled directly. + /// + /// # Errors + /// + /// * Panics with a native arithmetic overflow when `self.raw() * rhs` + /// leaves `i128`. + /// + /// # Notes + /// + /// [`Wad::checked_mul_int`] returns `None` instead of panicking. fn mul(self, rhs: i128) -> Wad { Wad(self.0 * rhs) } @@ -744,6 +850,11 @@ impl Mul for Wad { impl Mul for i128 { type Output = Wad; + /// Multiplication by an integer, with the operands reversed. + /// + /// # Errors + /// + /// * refer to the errors of [`Wad::mul`] for `Mul`. fn mul(self, rhs: Wad) -> Wad { Wad(self * rhs.0) } @@ -753,6 +864,18 @@ impl Mul for i128 { impl Div for Wad { type Output = Wad; + /// Division by an integer. No WAD scaling is applied, so the raw value is + /// divided directly. + /// + /// # Errors + /// + /// * Panics with a native division by zero when `rhs` is zero. + /// * Panics with a native arithmetic overflow when `self.raw() == + /// i128::MIN` and `rhs == -1`. + /// + /// # Notes + /// + /// [`Wad::checked_div_int`] returns `None` instead of panicking. fn div(self, rhs: i128) -> Wad { Wad(self.0 / rhs) } From f80d8e945052620d148423dc92cf0b9a73cf22be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96zg=C3=BCn=20=C3=96zerk?= Date: Thu, 3 Sep 2026 12:11:21 +0300 Subject: [PATCH 04/17] Rwa batch (#841) * test(rwa): add cost benchmark for batch functions (#767) * feat(rwa): batch variants of the operator-gated entry points (#767) * test(rwa): measure the shipped batch_transfer in the bench * reorganization of storage --- Cargo.toml | 1 + bench/rwa-batch/Cargo.lock | 2157 +++++++++++++++++ bench/rwa-batch/Cargo.toml | 22 + bench/rwa-batch/README.md | 101 + bench/rwa-batch/tests/batch_cost.rs | 1003 ++++++++ .../rwa/compliance-country-allow/src/test.rs | 10 + .../compliance-country-restrict/src/test.rs | 10 + .../rwa/compliance-max-balance/src/test.rs | 10 + .../src/test.rs | 10 + .../rwa/identity-registry/src/contract.rs | 24 + examples/rwa/token/src/contract.rs | 51 + .../compliance/modules/country_allow/test.rs | 10 + .../modules/country_restrict/test.rs | 10 + .../compliance/modules/max_balance/test.rs | 10 + .../tokens/src/rwa/compliance/modules/test.rs | 10 + .../modules/time_transfers_limits/test.rs | 10 + .../identity_registry_storage/mod.rs | 61 +- .../identity_registry_storage/storage.rs | 63 + .../identity_registry_storage/test.rs | 124 +- packages/tokens/src/rwa/mod.rs | 271 ++- packages/tokens/src/rwa/storage.rs | 629 ++++- packages/tokens/src/rwa/test.rs | 453 +++- 22 files changed, 4939 insertions(+), 111 deletions(-) create mode 100644 bench/rwa-batch/Cargo.lock create mode 100644 bench/rwa-batch/Cargo.toml create mode 100644 bench/rwa-batch/README.md create mode 100644 bench/rwa-batch/tests/batch_cost.rs diff --git a/Cargo.toml b/Cargo.toml index d88c336ee..24c61e381 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,7 @@ members = [ exclude = [ "examples/upgradeable/testdata", "examples/rwa/sign-claim", # native CLI tool, not a WASM contract + "bench/rwa-batch", # cost benchmark, run on demand only ] [workspace.package] diff --git a/bench/rwa-batch/Cargo.lock b/bench/rwa-batch/Cargo.lock new file mode 100644 index 000000000..fe349e04e --- /dev/null +++ b/bench/rwa-batch/Cargo.lock @@ -0,0 +1,2157 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "arbitrary" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5a26814d8dcb93b0e5a0ff3c6d80a8843bafb21b39e8e18a6f05471870e110" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "ark-bls12-381" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3df4dcc01ff89867cd86b0da835f23c3f02738353aaee7dde7495af71363b8d5" +dependencies = [ + "ark-ec", + "ark-ff", + "ark-serialize", + "ark-std", +] + +[[package]] +name = "ark-bn254" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d69eab57e8d2663efa5c63135b2af4f396d66424f88954c21104125ab6b3e6bc" +dependencies = [ + "ark-ec", + "ark-ff", + "ark-std", +] + +[[package]] +name = "ark-ec" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d68f2d516162846c1238e755a7c4d131b892b70cc70c471a8e3ca3ed818fce" +dependencies = [ + "ahash", + "ark-ff", + "ark-poly", + "ark-serialize", + "ark-std", + "educe", + "fnv", + "hashbrown 0.15.5", + "itertools", + "num-bigint", + "num-integer", + "num-traits", + "zeroize", +] + +[[package]] +name = "ark-ff" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a177aba0ed1e0fbb62aa9f6d0502e9b46dad8c2eab04c14258a1212d2557ea70" +dependencies = [ + "ark-ff-asm", + "ark-ff-macros", + "ark-serialize", + "ark-std", + "arrayvec", + "digest 0.10.7", + "educe", + "itertools", + "num-bigint", + "num-traits", + "paste", + "zeroize", +] + +[[package]] +name = "ark-ff-asm" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62945a2f7e6de02a31fe400aa489f0e0f5b2502e69f95f853adb82a96c7a6b60" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ark-ff-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09be120733ee33f7693ceaa202ca41accd5653b779563608f1234f78ae07c4b3" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ark-poly" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "579305839da207f02b89cd1679e50e67b4331e2f9294a57693e5051b7703fe27" +dependencies = [ + "ahash", + "ark-ff", + "ark-serialize", + "ark-std", + "educe", + "fnv", + "hashbrown 0.15.5", +] + +[[package]] +name = "ark-serialize" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f4d068aaf107ebcd7dfb52bc748f8030e0fc930ac8e360146ca54c1203088f7" +dependencies = [ + "ark-serialize-derive", + "ark-std", + "arrayvec", + "digest 0.10.7", + "num-bigint", +] + +[[package]] +name = "ark-serialize-derive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "213888f660fddcca0d257e88e54ac05bca01885f258ccdf695bafd77031bb69d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ark-std" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "246a225cc6131e9ee4f24619af0f19d67761fff15d7ccc22e42b80846e69449a" +dependencies = [ + "num-traits", + "rand", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes-lit" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b04f2b1d34cb428043f14aa4c853d14294532e8bbde3b6a3bc2faaaae31a1dd" +dependencies = [ + "num-bigint", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "cc" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_eval" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45565fc9416b9896014f5732ac776f810ee53a66730c17e4020c3ec064a8f88f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crate-git-revision" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c521bf1f43d31ed2f73441775ed31935d77901cb3451e44b38a1c1612fcbaf98" +dependencies = [ + "serde", + "serde_derive", + "serde_json", +] + +[[package]] +name = "crate-git-revision" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54851b5b3f24621804b1cded2820975623c205e3055d2d44031cdb1237339ac8" +dependencies = [ + "serde", + "serde_derive", + "serde_json", +] + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctor" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67773048316103656a637612c4a62477603b777d91d9c62ff2290f9cde178fdb" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2931af7e13dc045d8e9d26afccc6fa115d64e115c9c84b1166288b46f6782c2" + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto 0.2.9", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "curve25519-dalek-derive", + "digest 0.11.3", + "fiat-crypto 0.3.0", + "rustc_version", + "subtle", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.20", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derive_arbitrary" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67e77553c4162a157adbf834ebae5b415acbecbeafc7a74b0e886657506a7611" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "const-oid", + "crypto-common 0.1.6", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dtor" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "404d02eeb088a82cfd873006cb713fe411306c7d182c344905e101fb1167d301" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek 4.1.3", + "ed25519", + "rand_core", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "educe" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "enum-ordinalize" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89dd01549b09589510cf0647475075d12071456586d70f5c75c98ae2a5537677" +dependencies = [ + "enum-ordinalize-derive", +] + +[[package]] +name = "enum-ordinalize-derive" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a65863d15a4ce2888bd2f0f543cc963d3879c3a022c8ee43f6141d479a3ac815" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "escape-bytes" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bfcf67fea2815c2fc3b90873fae90957be12ff417335dfadc7f52927feb03b2" + +[[package]] +name = "ethnum" +version = "1.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40404c3f5f511ec4da6fe866ddf6a717c309fdbb69fbbad7b0f3edab8f2e835f" + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +dependencies = [ + "serde", +] + +[[package]] +name = "hex-literal" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "indexmap-nostd" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e04e2fd2b8188ea827b32ef11de88377086d690286ab35747ef7f9bf3ccb590" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if", + "ecdsa", + "elliptic-curve", + "sha2", +] + +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures 0.2.17", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "macro-string" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b27834086c65ec3f9387b096d66e99f221cf081c2b738042aa252bcd41204e3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "rwa-batch-bench" +version = "0.7.1" +dependencies = [ + "ed25519-dalek", + "soroban-sdk", + "stellar-contract-utils", + "stellar-tokens", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "subtle", + "zeroize", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_with" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" +dependencies = [ + "base64", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "jiff", + "schemars 0.8.22", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest 0.10.7", + "keccak", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "soroban-builtin-sdk-macros" +version = "27.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b77bc93d930032c487cb1506b6ed166b2af49db76d52678ec4887ac621ecce01" +dependencies = [ + "itertools", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "soroban-env-common" +version = "27.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b22e9981cdd444f3aa6734bc58d76195bf7eca3ccf1dd432b875af5d02da068" +dependencies = [ + "arbitrary", + "crate-git-revision 0.0.6", + "ethnum", + "num-derive", + "num-traits", + "serde", + "soroban-env-macros", + "soroban-wasmi", + "static_assertions", + "stellar-xdr", + "wasmparser", +] + +[[package]] +name = "soroban-env-guest" +version = "27.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6072f99ca6bf8e8d5b04e05d083dac785e5357d9c0f36a6658f819c2fd7d67" +dependencies = [ + "soroban-env-common", + "static_assertions", +] + +[[package]] +name = "soroban-env-host" +version = "27.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c06afd7c75ce150ce53e4d77a77645b18e3fb61856a0ddc42bfcecdc39fa3b9" +dependencies = [ + "ark-bls12-381", + "ark-bn254", + "ark-ec", + "ark-ff", + "ark-serialize", + "curve25519-dalek 5.0.0", + "ecdsa", + "ed25519-dalek", + "elliptic-curve", + "generic-array", + "getrandom", + "hex-literal", + "hmac", + "k256", + "num-derive", + "num-integer", + "num-traits", + "p256", + "rand", + "rand_chacha", + "sec1", + "sha2", + "sha3", + "soroban-builtin-sdk-macros", + "soroban-env-common", + "soroban-wasmi", + "static_assertions", + "stellar-strkey 0.0.13", + "wasmparser", +] + +[[package]] +name = "soroban-env-macros" +version = "27.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "647811bdd28a3ec40296987f6635781e5e1141c8f5affbbd53ba12b6295b7bb6" +dependencies = [ + "itertools", + "proc-macro2", + "quote", + "serde", + "serde_json", + "stellar-xdr", + "syn 2.0.119", +] + +[[package]] +name = "soroban-ledger-snapshot" +version = "27.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b59883d8bd0d1aed8d57579a9974ab88eaf787dd0a1af104f6881b5707450558" +dependencies = [ + "serde", + "serde_json", + "serde_with", + "soroban-env-common", + "soroban-env-host", + "thiserror 1.0.69", +] + +[[package]] +name = "soroban-poseidon" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b241822373adc51c23d5412e412b3b91b0d6b6bba146584946391aa32667820d" +dependencies = [ + "soroban-sdk", +] + +[[package]] +name = "soroban-sdk" +version = "27.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cfa78af0110f0830d3af9db0361b6cdb50507846cedb8725189318134218b80" +dependencies = [ + "arbitrary", + "bytes-lit", + "crate-git-revision 0.0.9", + "ctor", + "derive_arbitrary", + "ed25519-dalek", + "rand", + "rustc_version", + "serde", + "serde_json", + "soroban-env-guest", + "soroban-env-host", + "soroban-ledger-snapshot", + "soroban-sdk-macros", + "stellar-strkey 0.0.16", + "visibility", +] + +[[package]] +name = "soroban-sdk-macros" +version = "27.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bd4a847273d749807fe2eb52e2b9c1917ee482cd6a39465cad5c389548996ad" +dependencies = [ + "darling 0.20.11", + "heck", + "itertools", + "macro-string", + "proc-macro2", + "quote", + "sha2", + "soroban-env-common", + "soroban-spec", + "soroban-spec-rust", + "stellar-xdr", + "syn 2.0.119", +] + +[[package]] +name = "soroban-spec" +version = "27.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473404322827b285cbcd87517f365986bd63af7842c78b2a86ee061715fda61e" +dependencies = [ + "base64", + "sha2", + "stellar-xdr", + "thiserror 1.0.69", + "wasmparser", +] + +[[package]] +name = "soroban-spec-rust" +version = "27.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f25698b6ce2125850a9ef075cf9ba1e8d25b4cfa0c46aca42dadd80cc29d881" +dependencies = [ + "prettyplease", + "proc-macro2", + "quote", + "sha2", + "soroban-spec", + "stellar-xdr", + "syn 2.0.119", + "thiserror 1.0.69", +] + +[[package]] +name = "soroban-wasmi" +version = "0.31.1-soroban.20.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710403de32d0e0c35375518cb995d4fc056d0d48966f2e56ea471b8cb8fc9719" +dependencies = [ + "smallvec", + "spin", + "wasmi_arena", + "wasmi_core", + "wasmparser-nostd", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "stellar-contract-utils" +version = "0.7.1" +dependencies = [ + "soroban-sdk", +] + +[[package]] +name = "stellar-governance" +version = "0.7.1" +dependencies = [ + "soroban-sdk", +] + +[[package]] +name = "stellar-strkey" +version = "0.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee1832fb50c651ad10f734aaf5d31ca5acdfb197a6ecda64d93fcdb8885af913" +dependencies = [ + "crate-git-revision 0.0.6", + "data-encoding", +] + +[[package]] +name = "stellar-strkey" +version = "0.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "084afcb0d458c3d5d5baa2d294b18f881e62cc258ef539d8fdf68be7dbe45520" +dependencies = [ + "crate-git-revision 0.0.6", + "data-encoding", + "heapless", +] + +[[package]] +name = "stellar-tokens" +version = "0.7.1" +dependencies = [ + "soroban-poseidon", + "soroban-sdk", + "stellar-contract-utils", + "stellar-governance", +] + +[[package]] +name = "stellar-xdr" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05ff843326969bdf1ef673dcdba94c08f4a3c8f1e58d6e6ef39b1bd4f749179a" +dependencies = [ + "arbitrary", + "base64", + "cfg_eval", + "crate-git-revision 0.0.6", + "escape-bytes", + "ethnum", + "hex", + "serde", + "serde_with", + "sha2", + "stellar-strkey 0.0.13", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "visibility" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d674d135b4a8c1d7e813e2f8d1c9a58308aee4a680323066025e53132218bd91" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasmi_arena" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "104a7f73be44570cac297b3035d76b169d6599637631cf37a1703326a0727073" + +[[package]] +name = "wasmi_core" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcf1a7db34bff95b85c261002720c00c3a6168256dcb93041d3fa2054d19856a" +dependencies = [ + "downcast-rs", + "libm", + "num-traits", + "paste", +] + +[[package]] +name = "wasmparser" +version = "0.116.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a58e28b80dd8340cb07b8242ae654756161f6fc8d0038123d679b7b99964fa50" +dependencies = [ + "indexmap 2.14.0", + "semver", +] + +[[package]] +name = "wasmparser-nostd" +version = "0.100.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5a015fe95f3504a94bb1462c717aae75253e39b9dd6c3fb1062c934535c64aa" +dependencies = [ + "indexmap-nostd", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/bench/rwa-batch/Cargo.toml b/bench/rwa-batch/Cargo.toml new file mode 100644 index 000000000..9c9c17408 --- /dev/null +++ b/bench/rwa-batch/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "rwa-batch-bench" +edition = "2021" +license = "MIT" +repository = "https://github.com/OpenZeppelin/stellar-contracts" +publish = false +authors = ["OpenZeppelin"] +version = "0.7.1" + +# Deliberately excluded from the root workspace (see its `workspace.exclude`): +# the harness measures cost curves rather than asserting behaviour, takes ~2 +# minutes to run, and would otherwise be pulled into `cargo test --workspace` +# and counted by `cargo llvm-cov --workspace --fail-under-lines 90`. + +[dependencies] +soroban-sdk = { version = "27.0.2", features = [ + "testutils", + "experimental_spec_shaking_v2", +] } +stellar-contract-utils = { path = "../../packages/contract-utils", version = "0.7.1" } +stellar-tokens = { path = "../../packages/tokens", version = "0.7.1" } +ed25519-dalek = "2.1.1" diff --git a/bench/rwa-batch/README.md b/bench/rwa-batch/README.md new file mode 100644 index 000000000..abf1384d0 --- /dev/null +++ b/bench/rwa-batch/README.md @@ -0,0 +1,101 @@ +# RWA batch cost benchmark + +Measurement harness behind the design decisions for the RWA batch functions +(issue #767). It is not a test suite: it prints cost curves rather than +asserting behaviour, and it takes roughly two minutes to run. That is why it +sits outside the root workspace (listed in `workspace.exclude`), so +`cargo test --workspace` and `cargo llvm-cov --workspace` leave it alone. + +```bash +cargo test --manifest-path bench/rwa-batch/Cargo.toml -- --nocapture +``` + +## What it registers + +The real library stack, not mocks: + +```text +BenchToken ──> BenchCompliance ──> BenchSupplyLimit (storage only) + │ └──> BenchMaxBalance ──> BenchIrs + └───────> BenchVerifier ──┬──> BenchIrs (wallet -> identity) + ├──> BenchCti (required topics) + └──> BenchIdentity ──> BenchIssuer (ed25519 verify) +``` + +Access control is omitted from the bench contracts: an RBAC check is a +per-call constant that a batch amortises by construction, so it cannot move +the naive-versus-hoisted comparison. + +## Results (one required claim topic unless noted) + +`bench/rwa-batch/Cargo.lock` pins `soroban-sdk` to the same 27.0.2 the +workspace lock pins, so these figures are measured against the build the +library ships. The crate is workspace-`exclude`d and CI never rebuilds it, so +that pin has to be maintained by hand: re-run `cargo update -p soroban-sdk +--precise ` here whenever the workspace SDK moves, and re-measure +before trusting the numbers below. + +Hoisting loop-invariant work out of the per-item body: + +| batch | hoisted work | saving | +| --- | --- | --- | +| `mint` | `paused` + two instance address reads | 0.8% | +| `transfer` | sender identity verification | 28% (32% with two claim topics) | + +That is why `batch_mint` ships as a plain loop and `batch_transfer` hoists. + +The `transfer SHIPPED` row measures `RWA::batch_transfer` itself, which hoists +only the sender's identity verification: the cheap `paused` and +`is_frozen(from)` checks stay in the loop, because leaving them there costs +under 1% and keeps the set of assumptions the hoist rests on as small as +possible. + +| n | naive (1 topic) | shipped (1 topic) | saving | saving, 2 topics | +| --- | --- | --- | --- | --- | +| 1 | 1,960,401 | 1,960,504 | 0% | 0% | +| 2 | 3,883,461 | 3,208,635 | 17% | 20% | +| 5 | 10,077,882 | 7,336,557 | 27% | 31% | +| 10 | 21,600,751 | 15,360,997 | 29% | 33% | +| 20 | 49,029,314 | 35,561,762 | 27% | 31% | + +A batch of one costs the same as a single transfer, since the one sender +verification happens either way. + +Isolated probes explaining the mint result: an instance read costs ~4,900 CPU +and a repeated write to an already-written key ~12,900 CPU, against a per-item +mint cost of ~1.2M. A repeated write to one key never costs more than one +write entry in the footprint. + +## Batch ceilings + +Against the **live** mainnet config settings, fetched with +`stellar network settings --rpc-url https://mainnet.sorobanrpc.com` +(`tx_max_footprint_entries` 400, `tx_max_write_ledger_entries` 200, +`tx_max_instructions` 400M, `tx_max_contract_events_size_bytes` 16,384). The +SDK's `InvocationResourceLimits::mainnet()` is a stale snapshot (footprint +100, writes 50, instructions 600M) and is deliberately not used. + +| function | largest batch | binding limit | +| --- | --- | --- | +| `batch_mint` | 38 | footprint | +| `batch_transfer` | 37 | footprint | +| `batch_add_identity` | 37 | events size | +| `batch_freeze_partial_tokens` | 99 | events size | + +Footprint per minted recipient is 8 distinct keys, attributed by removing one +contributor at a time: 4 for the token and registry (`Balance` read-write, +`AddressFrozen`, `FrozenTokens`, the IRS `Identity` mapping), 3 for the +investor's own identity contract (instance, claim-ids-by-topic, the claim), +and 1 for the max-balance module's per-identity aggregate. Each additional +required claim topic adds about 2. + +Caveats, all pushing the real ceiling down rather than up: + +- The footprint figures use the host's in-test accounting, which sums read and + write entries and so counts every read-write key twice. Under the literal + reading (each footprint key once) mint and transfer both reach 47. +- Native test contracts are not charged for wasm code entries; mainnet adds one + per distinct wasm, about 9 for this stack. +- Native test contracts do not meter guest wasm instructions. At the ceiling, + mint uses 78.6M of the 400M instruction budget and transfer 108M, so a real + wasm build could make instructions the binding limit instead of footprint. diff --git a/bench/rwa-batch/tests/batch_cost.rs b/bench/rwa-batch/tests/batch_cost.rs new file mode 100644 index 000000000..2781d03f4 --- /dev/null +++ b/bench/rwa-batch/tests/batch_cost.rs @@ -0,0 +1,1003 @@ +//! Scratch benchmark for issue #767 (RWA batch functions). +//! +//! Measures naive-loop vs. hoisted batch implementations against the real +//! library stack: RWA token -> compliance (2 modules, one of which calls back +//! into the IRS) and identity verifier -> IRS + claim topics and issuers + +//! per-investor identity contract + claim issuer (with a real ed25519 check). +//! +//! Access control is omitted from the bench contracts: an RBAC check is a +//! per-call constant that a batch amortises by construction, so it cannot +//! move the naive-vs-hoisted comparison. +//! +//! Measurement harness, not library code: it prints cost curves rather than +//! asserting behaviour. Lives outside the root workspace so `cargo test +//! --workspace` and `cargo llvm-cov --workspace` skip it. Run it with +//! `cargo test --manifest-path bench/rwa-batch/Cargo.toml -- --nocapture`. +//! Results are recorded in the README next to this file. + +use ed25519_dalek::{Signer, SigningKey}; +use soroban_sdk::{ + contract, contractimpl, panic_with_error, symbol_short, + testutils::{Address as _, EnvTestConfig, Ledger as _}, + vec, Address, Bytes, BytesN, Env, Map, MuxedAddress, String, Vec, +}; +use stellar_contract_utils::pausable::{paused, PausableError}; +use stellar_tokens::{ + fungible::{emit_transfer, Base, FungibleToken}, + rwa::{ + claim_topics_and_issuers::{storage as cti, ClaimTopicsAndIssuers}, + compliance::{ + self as compliance, + modules::{ + max_balance::storage as max_balance, storage as module_storage, + supply_limit::storage as supply_limit, ComplianceModule, + }, + AccountSnapshot, Compliance, ComplianceClient, ComplianceHook, TransferKind, + }, + emit_mint, + identity_claims::{self as claims, Claim, IdentityClaims}, + identity_verification::{ + claim_issuer::ClaimIssuer, + identity_registry_storage::{ + self as identity_storage, CountryData, CountryRelation, IdentityRegistryStorage, + IdentityType, IndividualCountryRelation, + }, + }, + identity_verifier::{storage as identity_verifier, IdentityVerifier}, + utils::token_binder::{self as binder, TokenBinder}, + IdentityVerifierClient, RWAError, FromVerification, RWA, + }, +}; + +// ################## TOKEN ################## + +#[contract] +pub struct BenchToken; + +#[contractimpl] +impl BenchToken { + pub fn __constructor(e: &Env, compliance: Address, identity_verifier: Address) { + Base::set_metadata(e, 7, String::from_str(e, "Bench"), String::from_str(e, "BNC")); + RWA::set_compliance(e, &compliance); + RWA::set_identity_verifier(e, &identity_verifier); + } + + // ---------- mint ---------- + + /// Naive loop: the full singular body, N times. + pub fn batch_mint_naive(e: &Env, to_list: Vec
, amounts: Vec) { + for (to, amount) in to_list.iter().zip(amounts.iter()) { + RWA::mint(e, &to, amount); + } + } + + /// Hoisted: `paused`, the identity-verifier address and the compliance + /// address are read once for the whole batch. + pub fn batch_mint_hoisted(e: &Env, to_list: Vec
, amounts: Vec) { + if paused(e) { + panic_with_error!(e, PausableError::EnforcedPause); + } + let verifier = IdentityVerifierClient::new(e, &RWA::identity_verifier(e)); + let compliance_client = ComplianceClient::new(e, &RWA::compliance(e)); + let token = e.current_contract_address(); + + for (to, amount) in to_list.iter().zip(amounts.iter()) { + if RWA::is_frozen(e, &to) { + panic_with_error!(e, RWAError::AddressFrozen); + } + verifier.verify_identity(&to); + let to_snapshot = RWA::account_snapshot(e, &to); + Base::update(e, None, Some(&to), amount); + compliance_client.created(&to_snapshot, &amount, &token); + emit_mint(e, &to, amount); + } + } + + // ---------- transfer ---------- + + /// Naive loop: the singular body minus `require_auth`, N times. Verifies + /// the sender's identity on every item. + pub fn batch_transfer_naive(e: &Env, from: Address, to_list: Vec
, amounts: Vec) { + from.require_auth(); + for (to, amount) in to_list.iter().zip(amounts.iter()) { + let from_snapshot = RWA::account_snapshot(e, &from); + let to_snapshot = RWA::account_snapshot(e, &to); + RWA::validate_transfer( + e, + &from_snapshot, + &to_snapshot, + amount, + FromVerification::Required, + ); + Base::update(e, Some(&from), Some(&to), amount); + ComplianceClient::new(e, &RWA::compliance(e)).transferred( + &from_snapshot, + &to_snapshot, + &amount, + &TransferKind::Standard, + &e.current_contract_address(), + ); + emit_transfer(e, &from, &to, None, amount); + } + } + + /// Hoisted: sender identity, `paused` and `is_frozen(from)` checked once. + pub fn batch_transfer_hoisted( + e: &Env, + from: Address, + to_list: Vec
, + amounts: Vec, + ) { + Self::batch_transfer_hoisted_inner(e, from, to_list, amounts, false); + } + + /// Hoisted plus an end-of-batch sender re-verification, the variant that + /// was considered and dropped: it would have made a mid-batch revocation + /// revert the whole call, at the cost of one extra verification. + pub fn batch_transfer_recheck( + e: &Env, + from: Address, + to_list: Vec
, + amounts: Vec, + ) { + Self::batch_transfer_hoisted_inner(e, from, to_list, amounts, true); + } + + fn batch_transfer_hoisted_inner( + e: &Env, + from: Address, + to_list: Vec
, + amounts: Vec, + recheck: bool, + ) { + from.require_auth(); + + if paused(e) { + panic_with_error!(e, PausableError::EnforcedPause); + } + if RWA::is_frozen(e, &from) { + panic_with_error!(e, RWAError::AddressFrozen); + } + let verifier = IdentityVerifierClient::new(e, &RWA::identity_verifier(e)); + verifier.verify_identity(&from); + let compliance_client = ComplianceClient::new(e, &RWA::compliance(e)); + let token = e.current_contract_address(); + + for (to, amount) in to_list.iter().zip(amounts.iter()) { + let from_snapshot = RWA::account_snapshot(e, &from); + let to_snapshot = RWA::account_snapshot(e, &to); + + if RWA::is_frozen(e, &to) { + panic_with_error!(e, RWAError::AddressFrozen); + } + if from_snapshot.balance - from_snapshot.frozen < amount { + panic_with_error!(e, RWAError::InsufficientFreeTokens); + } + verifier.verify_identity(&to); + + Base::update(e, Some(&from), Some(&to), amount); + compliance_client.transferred( + &from_snapshot, + &to_snapshot, + &amount, + &TransferKind::Standard, + &token, + ); + emit_transfer(e, &from, &to, None, amount); + } + + if recheck { + verifier.verify_identity(&from); + } + } + + /// The shipped `RWA::batch_transfer`: hoists only the sender's identity + /// verification, leaving the cheap `paused` and `is_frozen(from)` checks in + /// the loop. + pub fn batch_transfer_shipped( + e: &Env, + from: Address, + to_list: Vec
, + amounts: Vec, + ) { + RWA::batch_transfer(e, &from, &to_list, &amounts); + } + + // ---------- freeze family (no invariants; measured for the ceiling) ---------- + + pub fn batch_freeze_naive(e: &Env, user_addresses: Vec
, amounts: Vec) { + for (user, amount) in user_addresses.iter().zip(amounts.iter()) { + RWA::freeze_partial_tokens(e, &user, amount); + } + } + + // ---------- storage churn probes ---------- + + /// N writes to one instance key (what `Base::update` does to + /// `TotalSupply`). + pub fn churn_instance(e: &Env, n: u32) { + for i in 0..n { + e.storage().instance().set(&symbol_short!("churn"), &(i as i128)); + } + } + + /// N writes to one persistent key (what a batch does to a sender balance). + pub fn churn_persistent(e: &Env, n: u32) { + for i in 0..n { + e.storage().persistent().set(&symbol_short!("churn"), &(i as i128)); + } + } + + /// N writes to N distinct persistent keys, for comparison. + pub fn churn_distinct(e: &Env, n: u32) { + for i in 0..n { + e.storage().persistent().set(&i, &(i as i128)); + } + } + + /// N reads of one persistent key. + pub fn churn_read(e: &Env, n: u32) { + for _ in 0..n { + let _: Option = e.storage().persistent().get(&symbol_short!("churn")); + } + } + + /// N reads of one instance key (the shape of a hoistable address read). + pub fn churn_read_instance(e: &Env, n: u32) { + for _ in 0..n { + let _ = RWA::compliance(e); + } + } +} + +#[contractimpl(contracttrait)] +impl FungibleToken for BenchToken { + type ContractType = RWA; +} + +// ################## COMPLIANCE ################## + +#[contract] +pub struct BenchCompliance; + +#[contractimpl] +impl BenchCompliance { + pub fn bind(e: &Env, token: Address) { + binder::bind_token(e, &token); + } +} + +#[contractimpl(contracttrait)] +impl TokenBinder for BenchCompliance { + fn bind_token(e: &Env, token: Address, _operator: Address) { + binder::bind_token(e, &token); + } + + fn unbind_token(e: &Env, token: Address, _operator: Address) { + binder::unbind_token(e, &token); + } +} + +#[contractimpl(contracttrait)] +impl Compliance for BenchCompliance { + fn add_module_to(e: &Env, hook: ComplianceHook, module: Address, _operator: Address) { + compliance::storage::add_module_to(e, hook, module); + } + + fn remove_module_from(e: &Env, hook: ComplianceHook, module: Address, _operator: Address) { + compliance::storage::remove_module_from(e, hook, module); + } +} + +// ################## MODULES ################## + +#[contract] +pub struct BenchSupplyLimit; + +#[contractimpl] +impl BenchSupplyLimit { + pub fn setup(e: &Env, token: Address, compliance: Address, limit: i128) { + module_storage::set_compliance_address(e, &token, &compliance); + supply_limit::set_supply_limit(e, &token, limit); + } +} + +#[contractimpl(contracttrait)] +impl ComplianceModule for BenchSupplyLimit { + fn on_transfer( + _e: &Env, + _from: AccountSnapshot, + _to: AccountSnapshot, + _amount: i128, + _kind: TransferKind, + _token: Address, + ) { + } + + fn on_created(e: &Env, to: AccountSnapshot, amount: i128, token: Address) { + module_storage::get_compliance_address(e, &token).require_auth(); + supply_limit::on_created(e, &to.address, amount, &token); + } + + fn on_destroyed(e: &Env, from: AccountSnapshot, amount: i128, token: Address) { + module_storage::get_compliance_address(e, &token).require_auth(); + supply_limit::on_destroyed(e, &from.address, amount, &token); + } + + fn name(e: &Env) -> String { + String::from_str(e, "SupplyLimit") + } + + fn set_compliance_address(e: &Env, token: Address, compliance: Address, _operator: Address) { + module_storage::set_compliance_address(e, &token, &compliance); + } +} + +#[contract] +pub struct BenchMaxBalance; + +#[contractimpl] +impl BenchMaxBalance { + pub fn setup(e: &Env, token: Address, compliance: Address, irs: Address, max: i128) { + module_storage::set_compliance_address(e, &token, &compliance); + module_storage::set_irs_address(e, &token, &irs); + max_balance::set_max_balance(e, &token, max); + } +} + +#[contractimpl(contracttrait)] +impl ComplianceModule for BenchMaxBalance { + fn on_transfer( + e: &Env, + from: AccountSnapshot, + to: AccountSnapshot, + amount: i128, + kind: TransferKind, + token: Address, + ) { + module_storage::get_compliance_address(e, &token).require_auth(); + max_balance::on_transfer(e, &from.address, &to.address, amount, &kind, &token); + } + + fn on_created(e: &Env, to: AccountSnapshot, amount: i128, token: Address) { + module_storage::get_compliance_address(e, &token).require_auth(); + max_balance::on_created(e, &to.address, amount, &token); + } + + fn on_destroyed(e: &Env, from: AccountSnapshot, amount: i128, token: Address) { + module_storage::get_compliance_address(e, &token).require_auth(); + max_balance::on_destroyed(e, &from.address, amount, &token); + } + + fn name(e: &Env) -> String { + String::from_str(e, "MaxBalance") + } + + fn set_compliance_address(e: &Env, token: Address, compliance: Address, _operator: Address) { + module_storage::set_compliance_address(e, &token, &compliance); + } +} + +// ################## IDENTITY VERIFIER ################## + +#[contract] +pub struct BenchVerifier; + +#[contractimpl] +impl BenchVerifier { + pub fn __constructor(e: &Env, irs: Address, cti: Address) { + identity_verifier::set_identity_registry_storage(e, &irs); + identity_verifier::set_claim_topics_and_issuers(e, &cti); + } +} + +#[contractimpl(contracttrait)] +impl IdentityVerifier for BenchVerifier { + fn verify_identity(e: &Env, account: &Address) { + identity_verifier::verify_identity(e, account); + } + + fn recovery_target(e: &Env, old_account: &Address) -> Option
{ + identity_verifier::recovery_target(e, old_account) + } + + fn set_claim_topics_and_issuers(e: &Env, claim_topics_and_issuers: Address, _op: Address) { + identity_verifier::set_claim_topics_and_issuers(e, &claim_topics_and_issuers); + } +} + +// ################## IDENTITY REGISTRY STORAGE ################## + +#[contract] +pub struct BenchIrs; + +#[contractimpl] +impl BenchIrs { + pub fn register(e: &Env, account: Address, identity: Address, country: u32) { + let data = CountryData { + country: CountryRelation::Individual(IndividualCountryRelation::Residence(country)), + metadata: None, + }; + identity_storage::add_identity( + e, + &account, + &identity, + IdentityType::Individual, + &vec![e, data], + ); + } + + /// Naive loop over the singular helper: nothing here is loop-invariant. + pub fn batch_register(e: &Env, accounts: Vec
, identities: Vec
) { + for (account, identity) in accounts.iter().zip(identities.iter()) { + let data = CountryData { + country: CountryRelation::Individual(IndividualCountryRelation::Residence(250)), + metadata: None, + }; + identity_storage::add_identity( + e, + &account, + &identity, + IdentityType::Individual, + &vec![e, data], + ); + } + } +} + +#[contractimpl(contracttrait)] +impl TokenBinder for BenchIrs { + fn bind_token(e: &Env, token: Address, _operator: Address) { + binder::bind_token(e, &token); + } + + fn unbind_token(e: &Env, token: Address, _operator: Address) { + binder::unbind_token(e, &token); + } +} + +#[contractimpl(contracttrait)] +impl IdentityRegistryStorage for BenchIrs { + fn add_identity( + e: &Env, + account: Address, + identity: Address, + _country_data_list: Vec, + _operator: Address, + ) { + let data = CountryData { + country: CountryRelation::Individual(IndividualCountryRelation::Residence(250)), + metadata: None, + }; + identity_storage::add_identity( + e, + &account, + &identity, + IdentityType::Individual, + &vec![e, data], + ); + } + + fn batch_add_identity( + _e: &Env, + _accounts: Vec
, + _identities: Vec
, + _country_data_lists: Vec>, + _operator: Address, + ) { + unreachable!("the bench registers identities through the inherent helper"); + } + + fn remove_identity(e: &Env, account: Address, _operator: Address) { + identity_storage::remove_identity(e, &account); + } + + fn recover_identity(e: &Env, old_account: Address, new_account: Address, _operator: Address) { + identity_storage::recover_identity(e, &old_account, &new_account); + } +} + +// ################## CLAIM TOPICS AND ISSUERS ################## + +#[contract] +pub struct BenchCti; + +#[contractimpl(contracttrait)] +impl ClaimTopicsAndIssuers for BenchCti { + fn add_claim_topic(e: &Env, claim_topic: u32, _operator: Address) { + cti::add_claim_topic(e, claim_topic); + } + + fn remove_claim_topic(e: &Env, claim_topic: u32, _operator: Address) { + cti::remove_claim_topic(e, claim_topic); + } + + fn add_trusted_issuer( + e: &Env, + trusted_issuer: Address, + claim_topics: Vec, + _operator: Address, + ) { + cti::add_trusted_issuer(e, &trusted_issuer, &claim_topics); + } + + fn remove_trusted_issuer(e: &Env, trusted_issuer: Address, _operator: Address) { + cti::remove_trusted_issuer(e, &trusted_issuer); + } + + fn update_issuer_claim_topics( + e: &Env, + trusted_issuer: Address, + claim_topics: Vec, + _operator: Address, + ) { + cti::update_issuer_claim_topics(e, &trusted_issuer, &claim_topics); + } +} + +// ################## INVESTOR IDENTITY ################## + +#[contract] +pub struct BenchIdentity; + +#[contractimpl(contracttrait)] +impl IdentityClaims for BenchIdentity { + fn add_claim( + e: &Env, + topic: u32, + scheme: u32, + issuer: Address, + signature: Bytes, + data: Bytes, + uri: String, + ) -> BytesN<32> { + claims::add_claim(e, topic, scheme, &issuer, &signature, &data, &uri) + } +} + +// ################## CLAIM ISSUER ################## + +#[contract] +pub struct BenchIssuer; + +#[contractimpl] +impl BenchIssuer { + pub fn setup(e: &Env, public_key: BytesN<32>, message: Bytes, signature: BytesN<64>) { + e.storage().instance().set(&symbol_short!("pk"), &public_key); + e.storage().instance().set(&symbol_short!("msg"), &message); + e.storage().instance().set(&symbol_short!("sig"), &signature); + } +} + +#[contractimpl] +impl ClaimIssuer for BenchIssuer { + /// A representative live validity check: one storage read plus one + /// ed25519 verification, which is the shape of the example issuer. + fn is_claim_valid( + e: &Env, + _identity: Address, + _claim_topic: u32, + _scheme: u32, + _sig_data: Bytes, + _claim_data: Bytes, + ) { + let pk: BytesN<32> = e.storage().instance().get(&symbol_short!("pk")).unwrap(); + let msg: Bytes = e.storage().instance().get(&symbol_short!("msg")).unwrap(); + let sig: BytesN<64> = e.storage().instance().get(&symbol_short!("sig")).unwrap(); + e.crypto().ed25519_verify(&pk, &msg, &sig); + } +} + +// ################## LIVE MAINNET LIMITS ################## + +/// Live mainnet config settings, fetched with +/// `stellar network settings --rpc-url https://mainnet.sorobanrpc.com`. +/// The SDK's `InvocationResourceLimits::mainnet()` is a stale snapshot +/// (footprint 100, writes 50, instructions 600M), so it is not used here. +const TX_MAX_FOOTPRINT_ENTRIES: u32 = 400; +const TX_MAX_WRITE_LEDGER_ENTRIES: u32 = 200; +const TX_MAX_DISK_READ_ENTRIES: u32 = 200; +const TX_MAX_INSTRUCTIONS: i64 = 400_000_000; +const TX_MEMORY_LIMIT: i64 = 41_943_040; +const TX_MAX_WRITE_BYTES: u32 = 132_096; +const TX_MAX_EVENTS_SIZE_BYTES: u32 = 16_384; + +// Resource figures from the last measured invocation, so the reporting line can +// print them alongside the verdict. +std::thread_local! { + static LAST_KEYS: std::cell::Cell = const { std::cell::Cell::new(0) }; + static LAST_HOST: std::cell::Cell = const { std::cell::Cell::new(0) }; + static LAST_WR: std::cell::Cell = const { std::cell::Cell::new(0) }; + static LAST_EV: std::cell::Cell = const { std::cell::Cell::new(0) }; + static LAST_IN: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Returns the live-mainnet limits that the last invocation exceeded. +/// +/// Two readings of the footprint are reported, because the host's in-test check +/// sums read entries and write entries (double counting every read-write entry) +/// while a transaction footprint lists each key exactly once. +fn live_limit_breaches(e: &Env) -> std::vec::Vec { + let r = e.cost_estimate().resources(); + let distinct_keys = r.disk_read_entries + r.memory_read_entries; + let host_style = distinct_keys + r.write_entries; + LAST_KEYS.with(|c| c.set(distinct_keys)); + LAST_HOST.with(|c| c.set(host_style)); + LAST_WR.with(|c| c.set(r.write_entries)); + LAST_EV.with(|c| c.set(r.contract_events_size_bytes)); + LAST_IN.with(|c| c.set(r.instructions)); + let mut out = std::vec::Vec::new(); + if distinct_keys > TX_MAX_FOOTPRINT_ENTRIES { + out.push(std::format!( + "footprint(distinct keys) {distinct_keys} > {TX_MAX_FOOTPRINT_ENTRIES}" + )); + } + if host_style > TX_MAX_FOOTPRINT_ENTRIES { + out.push(std::format!( + "footprint(host-style rd+wr) {host_style} > {TX_MAX_FOOTPRINT_ENTRIES}" + )); + } + if r.write_entries > TX_MAX_WRITE_LEDGER_ENTRIES { + out.push(std::format!("write entries {} > {TX_MAX_WRITE_LEDGER_ENTRIES}", r.write_entries)); + } + if r.disk_read_entries > TX_MAX_DISK_READ_ENTRIES { + out.push(std::format!("disk reads {} > {TX_MAX_DISK_READ_ENTRIES}", r.disk_read_entries)); + } + if r.instructions > TX_MAX_INSTRUCTIONS { + out.push(std::format!("instructions {} > {TX_MAX_INSTRUCTIONS}", r.instructions)); + } + if r.mem_bytes > TX_MEMORY_LIMIT { + out.push(std::format!("mem {} > {TX_MEMORY_LIMIT}", r.mem_bytes)); + } + if r.write_bytes > TX_MAX_WRITE_BYTES { + out.push(std::format!("write bytes {} > {TX_MAX_WRITE_BYTES}", r.write_bytes)); + } + if r.contract_events_size_bytes > TX_MAX_EVENTS_SIZE_BYTES { + out.push(std::format!( + "events {} > {TX_MAX_EVENTS_SIZE_BYTES}", + r.contract_events_size_bytes + )); + } + out +} + +// ################## HARNESS ################## + +struct Stack<'a> { + e: Env, + token: BenchTokenClient<'a>, + investors: std::vec::Vec
, +} + +/// Registers the whole stack with `topics` required claim topics and +/// `investors` registered wallets (one identity contract each). +fn setup(topics: u32, investors: u32) -> Stack<'static> { + setup_cfg(topics, investors, 2, false) +} + +/// `modules`: how many compliance modules are registered (0, 1 = supply limit +/// only, 2 = supply limit + max balance, the latter calling back into the IRS). +/// `shared_identity`: all wallets point at one identity contract instead of one +/// each. +fn setup_cfg(topics: u32, investors: u32, modules: u32, shared_identity: bool) -> Stack<'static> { + // Snapshot capture at drop meters the recorded auth tree in shadow mode and + // blows the shadow budget on larger batches, so turn it off. + let e = Env::new_with_config(EnvTestConfig { capture_snapshot_at_drop: false }); + e.mock_all_auths(); + e.cost_estimate().disable_resource_limits(); + // Per-invocation resets zero the counters but keep the limits, so lifting + // them once here is enough to measure past the mainnet ceiling. + e.cost_estimate().budget().reset_unlimited(); + e.ledger().with_mut(|l| l.sequence_number = 1000); + + // Claim issuer with a real signature to verify. + let sk = SigningKey::from_bytes(&[7u8; 32]); + let pk = BytesN::from_array(&e, &sk.verifying_key().to_bytes()); + let msg_bytes = [42u8; 64]; + let sig = BytesN::from_array(&e, &sk.sign(&msg_bytes).to_bytes()); + let msg = Bytes::from_array(&e, &msg_bytes); + + let issuer = e.register(BenchIssuer, ()); + BenchIssuerClient::new(&e, &issuer).setup(&pk, &msg, &sig); + + let cti = e.register(BenchCti, ()); + let cti_client = BenchCtiClient::new(&e, &cti); + let operator = Address::generate(&e); + let mut topic_list = Vec::new(&e); + for t in 1..=topics { + cti_client.add_claim_topic(&t, &operator); + topic_list.push_back(t); + } + cti_client.add_trusted_issuer(&issuer, &topic_list, &operator); + + let irs = e.register(BenchIrs, ()); + let verifier = e.register(BenchVerifier, (irs.clone(), cti.clone())); + let compliance = e.register(BenchCompliance, ()); + let token = e.register(BenchToken, (compliance.clone(), verifier.clone())); + + // Bind the token to the compliance contract and register the modules. + let compliance_client = BenchComplianceClient::new(&e, &compliance); + compliance_client.bind(&token); + + let supply = e.register(BenchSupplyLimit, ()); + BenchSupplyLimitClient::new(&e, &supply).setup(&token, &compliance, &i128::MAX); + let maxbal = e.register(BenchMaxBalance, ()); + BenchMaxBalanceClient::new(&e, &maxbal).setup(&token, &compliance, &irs, &i128::MAX); + + if modules >= 1 { + compliance_client.add_module_to(&ComplianceHook::Created, &supply, &operator); + } + if modules >= 2 { + compliance_client.add_module_to(&ComplianceHook::Created, &maxbal, &operator); + compliance_client.add_module_to(&ComplianceHook::Transferred, &maxbal, &operator); + } + + // Register investors: one identity contract each, one claim per topic. + let irs_client = BenchIrsClient::new(&e, &irs); + let mut investor_list = std::vec::Vec::new(); + let mut shared: Option
= None; + for _ in 0..investors { + let account = Address::generate(&e); + let identity = match (shared_identity, shared.clone()) { + (true, Some(id)) => id, + _ => { + let identity = e.register(BenchIdentity, ()); + let identity_client = BenchIdentityClient::new(&e, &identity); + for t in 1..=topics { + identity_client.add_claim( + &t, + &101u32, + &issuer, + &Bytes::from_array(&e, &[1u8; 8]), + &Bytes::from_array(&e, &[2u8; 8]), + &String::from_str(&e, "uri"), + ); + } + if shared_identity { + shared = Some(identity.clone()); + } + identity + } + }; + irs_client.register(&account, &identity, &250u32); + investor_list.push(account); + } + + let token_client = BenchTokenClient::new(&e, &token); + Stack { e, token: token_client, investors: investor_list } +} + +fn report(e: &Env, label: &str, n: usize) { + let r = e.cost_estimate().resources(); + let cpu = e.cost_estimate().budget().cpu_instruction_cost(); + println!( + "{label:<28} n={n:<3} cpu={cpu:>11} insns={:>11} mem={:>9} rd_entries={:>3} \ + wr_entries={:>3} rd_bytes={:>6} wr_bytes={:>6} events={:>5}", + r.instructions, + r.mem_bytes, + r.disk_read_entries + r.memory_read_entries, + r.write_entries, + r.disk_read_bytes, + r.write_bytes, + r.contract_events_size_bytes, + ); +} + +// ################## MEASUREMENTS ################## + +#[test] +fn bench_mint_naive_vs_hoisted() { + println!("\n--- batch_mint: naive vs hoisted (1 claim topic) ---"); + for n in [1usize, 2, 5, 10, 20] { + let s = setup(1, n as u32); + let tos = Vec::from_iter(&s.e, s.investors.iter().cloned()); + let amounts = Vec::from_iter(&s.e, (0..n).map(|_| 100i128)); + s.token.batch_mint_naive(&tos, &amounts); + report(&s.e, "mint naive", n); + + let s = setup(1, n as u32); + let tos = Vec::from_iter(&s.e, s.investors.iter().cloned()); + let amounts = Vec::from_iter(&s.e, (0..n).map(|_| 100i128)); + s.token.batch_mint_hoisted(&tos, &amounts); + report(&s.e, "mint hoisted", n); + } +} + +#[test] +fn bench_transfer_naive_vs_hoisted() { + for topics in [1u32, 2] { + println!("\n--- batch_transfer: naive vs hoisted ({topics} claim topic(s)) ---"); + for n in [1usize, 2, 5, 10, 20] { + for variant in 0..4 { + let s = setup(topics, n as u32 + 1); + let from = s.investors[0].clone(); + s.token.batch_mint_naive(&vec![&s.e, from.clone()], &vec![&s.e, 1_000_000i128]); + let tos = Vec::from_iter(&s.e, s.investors[1..].iter().cloned()); + let amounts = Vec::from_iter(&s.e, (0..n).map(|_| 10i128)); + match variant { + 0 => { + s.token.batch_transfer_naive(&from, &tos, &amounts); + report(&s.e, "transfer naive", n); + } + 1 => { + s.token.batch_transfer_hoisted(&from, &tos, &amounts); + report(&s.e, "transfer hoisted", n); + } + 2 => { + s.token.batch_transfer_recheck(&from, &tos, &amounts); + report(&s.e, "transfer hoisted+recheck", n); + } + _ => { + s.token.batch_transfer_shipped(&from, &tos, &amounts); + report(&s.e, "transfer SHIPPED", n); + } + } + } + } + } +} + +#[test] +fn bench_storage_churn() { + println!("\n--- storage churn: N writes to one key vs N keys ---"); + let s = setup(1, 1); + for n in [1u32, 5, 10, 20, 40] { + s.token.churn_instance(&n); + report(&s.e, "instance same key", n as usize); + s.token.churn_persistent(&n); + report(&s.e, "persistent same key", n as usize); + s.token.churn_read(&n); + report(&s.e, "persistent read same key", n as usize); + s.token.churn_read_instance(&n); + report(&s.e, "instance read (address)", n as usize); + if n <= 40 { + s.token.churn_distinct(&n); + report(&s.e, "persistent distinct keys", n as usize); + } + } +} + +#[test] +fn bench_ceiling() { + println!("\n--- raw resource curve (compare against live limits in bench_ceiling_live) ---"); + for n in [10usize, 20, 30, 40, 50] { + let s = setup(1, n as u32); + let tos = Vec::from_iter(&s.e, s.investors.iter().cloned()); + let amounts = Vec::from_iter(&s.e, (0..n).map(|_| 100i128)); + s.token.batch_mint_naive(&tos, &amounts); + report(&s.e, "mint naive", n); + } + for n in [10usize, 20, 40, 60, 80] { + let s = setup(1, n as u32); + let tos = Vec::from_iter(&s.e, s.investors.iter().cloned()); + let amounts = Vec::from_iter(&s.e, (0..n).map(|_| 0i128)); + s.token.batch_freeze_naive(&tos, &amounts); + report(&s.e, "freeze naive", n); + } +} + +/// Ceiling for the IRS batch. The identity address is only stored, never +/// called, so a registration does not drag the identity contract into the +/// footprint. +#[test] +fn bench_irs_ceiling() { + println!("\n--- batch_add_identity: footprint per registration ---"); + for n in [1usize, 10, 20, 30, 40, 50, 60] { + let e = Env::new_with_config(EnvTestConfig { capture_snapshot_at_drop: false }); + e.mock_all_auths(); + e.cost_estimate().disable_resource_limits(); + e.cost_estimate().budget().reset_unlimited(); + let irs = e.register(BenchIrs, ()); + let client = BenchIrsClient::new(&e, &irs); + let mut accounts = Vec::new(&e); + let mut identities = Vec::new(&e); + for _ in 0..n { + accounts.push_back(Address::generate(&e)); + identities.push_back(Address::generate(&e)); + } + client.batch_register(&accounts, &identities); + report(&e, "irs batch_register", n); + } +} + +/// Attributes the per-recipient footprint of `batch_mint` to its sources by +/// removing one contributor at a time. Per-item cost is the n=10 minus n=5 +/// delta, divided by 5. +#[test] +fn bench_footprint_decomposition() { + println!("\n--- batch_mint: per-recipient footprint, by stack variant ---"); + let variants: [(&str, u32, u32, bool); 5] = [ + ("2 modules, own identity", 1, 2, false), + ("1 module, own identity", 1, 1, false), + ("0 modules, own identity", 1, 0, false), + ("2 modules, shared identity", 1, 2, true), + ("0 modules, shared identity", 1, 0, true), + ]; + for (label, topics, modules, shared) in variants { + let mut pts = std::vec::Vec::new(); + for n in [5usize, 10] { + let s = setup_cfg(topics, n as u32, modules, shared); + let tos = Vec::from_iter(&s.e, s.investors.iter().cloned()); + let amounts = Vec::from_iter(&s.e, (0..n).map(|_| 100i128)); + s.token.batch_mint_naive(&tos, &amounts); + let r = s.e.cost_estimate().resources(); + pts.push((r.disk_read_entries + r.memory_read_entries, r.write_entries)); + } + let (rd5, wr5) = pts[0]; + let (rd10, wr10) = pts[1]; + println!( + "{label:<28} reads/item={:<5.1} writes/item={:<4.1} distinct-keys/item={:<4.1}", + (rd10 - rd5) as f64 / 5.0, + (wr10 - wr5) as f64 / 5.0, + (rd10 - rd5) as f64 / 5.0, + ); + } +} + +/// Largest batch that fits under the LIVE mainnet limits. +#[test] +fn bench_ceiling_live() { + println!("\n--- live mainnet limits: footprint 400, writes 200, 400M insns, 16KB events ---"); + for (label, ns) in [ + ("mint", std::vec![36usize, 38, 39, 46, 47, 48]), + ("transfer", std::vec![29, 30, 31, 36, 37, 38]), + ("freeze", std::vec![98, 99, 100, 132, 133, 134]), + ("irs", std::vec![36, 37, 38]), + ] { + for n in ns { + let breaches = if label == "irs" { + let e = Env::new_with_config(EnvTestConfig { capture_snapshot_at_drop: false }); + e.mock_all_auths(); + e.cost_estimate().disable_resource_limits(); + e.cost_estimate().budget().reset_unlimited(); + let irs = e.register(BenchIrs, ()); + let client = BenchIrsClient::new(&e, &irs); + let mut accounts = Vec::new(&e); + let mut identities = Vec::new(&e); + for _ in 0..n { + accounts.push_back(Address::generate(&e)); + identities.push_back(Address::generate(&e)); + } + client.batch_register(&accounts, &identities); + live_limit_breaches(&e) + } else { + let s = setup(1, n as u32 + 1); + match label { + "mint" => { + let tos = Vec::from_iter(&s.e, s.investors[..n].iter().cloned()); + let amounts = Vec::from_iter(&s.e, (0..n).map(|_| 100i128)); + s.token.batch_mint_naive(&tos, &amounts); + } + "transfer" => { + let from = s.investors[0].clone(); + s.token.batch_mint_naive( + &vec![&s.e, from.clone()], + &vec![&s.e, 10_000_000i128], + ); + let tos = Vec::from_iter(&s.e, s.investors[1..=n].iter().cloned()); + let amounts = Vec::from_iter(&s.e, (0..n).map(|_| 10i128)); + s.token.batch_transfer_naive(&from, &tos, &amounts); + } + _ => { + let tos = Vec::from_iter(&s.e, s.investors[..n].iter().cloned()); + let amounts = Vec::from_iter(&s.e, (0..n).map(|_| 0i128)); + s.token.batch_freeze_naive(&tos, &amounts); + } + } + live_limit_breaches(&s.e) + }; + println!( + "{label:<9} n={n:<4} {:<7} keys={:<4} rd+wr={:<4} wr={:<4} events={:<6} insns={}", + if breaches.is_empty() { "fits" } else { "EXCEEDS" }, + LAST_KEYS.with(|c| c.get()), + LAST_HOST.with(|c| c.get()), + LAST_WR.with(|c| c.get()), + LAST_EV.with(|c| c.get()), + LAST_IN.with(|c| c.get()), + ); + if !breaches.is_empty() { + println!(" {}", breaches.join("; ")); + } + } + } +} diff --git a/examples/rwa/compliance-country-allow/src/test.rs b/examples/rwa/compliance-country-allow/src/test.rs index 5972aa092..d584332ff 100644 --- a/examples/rwa/compliance-country-allow/src/test.rs +++ b/examples/rwa/compliance-country-allow/src/test.rs @@ -66,6 +66,16 @@ impl IdentityRegistryStorage for MockIRSContract { unreachable!("add_identity is not used in these tests"); } + fn batch_add_identity( + _e: &Env, + _accounts: Vec
, + _identities: Vec
, + _country_data_lists: Vec>, + _operator: Address, + ) { + unreachable!("batch_add_identity is not used in these tests"); + } + fn remove_identity(_e: &Env, _account: Address, _operator: Address) { unreachable!("remove_identity is not used in these tests"); } diff --git a/examples/rwa/compliance-country-restrict/src/test.rs b/examples/rwa/compliance-country-restrict/src/test.rs index d1911437f..921a2d78b 100644 --- a/examples/rwa/compliance-country-restrict/src/test.rs +++ b/examples/rwa/compliance-country-restrict/src/test.rs @@ -66,6 +66,16 @@ impl IdentityRegistryStorage for MockIRSContract { unreachable!("add_identity is not used in these tests"); } + fn batch_add_identity( + _e: &Env, + _accounts: Vec
, + _identities: Vec
, + _country_data_lists: Vec>, + _operator: Address, + ) { + unreachable!("batch_add_identity is not used in these tests"); + } + fn remove_identity(_e: &Env, _account: Address, _operator: Address) { unreachable!("remove_identity is not used in these tests"); } diff --git a/examples/rwa/compliance-max-balance/src/test.rs b/examples/rwa/compliance-max-balance/src/test.rs index bfa40c197..d2e5bc69b 100644 --- a/examples/rwa/compliance-max-balance/src/test.rs +++ b/examples/rwa/compliance-max-balance/src/test.rs @@ -59,6 +59,16 @@ impl IdentityRegistryStorage for MockIRSContract { unreachable!("add_identity is not used in these tests"); } + fn batch_add_identity( + _e: &Env, + _accounts: Vec
, + _identities: Vec
, + _country_data_lists: Vec>, + _operator: Address, + ) { + unreachable!("batch_add_identity is not used in these tests"); + } + fn remove_identity(_e: &Env, _account: Address, _operator: Address) { unreachable!("remove_identity is not used in these tests"); } diff --git a/examples/rwa/compliance-time-transfers-limits/src/test.rs b/examples/rwa/compliance-time-transfers-limits/src/test.rs index b01f1cd5c..f83d736f0 100644 --- a/examples/rwa/compliance-time-transfers-limits/src/test.rs +++ b/examples/rwa/compliance-time-transfers-limits/src/test.rs @@ -66,6 +66,16 @@ impl IdentityRegistryStorage for MockIRSContract { unreachable!("add_identity is not used in these tests"); } + fn batch_add_identity( + _e: &Env, + _accounts: Vec
, + _identities: Vec
, + _country_data_lists: Vec>, + _operator: Address, + ) { + unreachable!("batch_add_identity is not used in these tests"); + } + fn remove_identity(_e: &Env, _account: Address, _operator: Address) { unreachable!("remove_identity is not used in these tests"); } diff --git a/examples/rwa/identity-registry/src/contract.rs b/examples/rwa/identity-registry/src/contract.rs index c3bfe92fa..b54fe1773 100644 --- a/examples/rwa/identity-registry/src/contract.rs +++ b/examples/rwa/identity-registry/src/contract.rs @@ -59,6 +59,30 @@ impl IdentityRegistryStorage for IdentityRegistryContract { ); } + #[only_role(operator, "manager")] + fn batch_add_identity( + e: &Env, + accounts: Vec
, + identities: Vec
, + initial_profiles: Vec>, + operator: Address, + ) { + let country_data_lists = Vec::from_iter( + e, + initial_profiles.iter().map(|profiles| { + Vec::from_iter(e, profiles.iter().map(|p| CountryData::from_val(e, &p))) + }), + ); + + identity_storage::batch_add_identity( + e, + &accounts, + &identities, + IdentityType::Individual, + &country_data_lists, + ); + } + #[only_role(operator, "manager")] fn remove_identity(e: &Env, account: Address, operator: Address) { identity_storage::remove_identity(e, &account); diff --git a/examples/rwa/token/src/contract.rs b/examples/rwa/token/src/contract.rs index 610334064..1d456a192 100644 --- a/examples/rwa/token/src/contract.rs +++ b/examples/rwa/token/src/contract.rs @@ -99,6 +99,57 @@ impl RWAToken for RWATokenContract { RWA::unfreeze_partial_tokens(e, &user_address, amount); } + #[only_role(operator, "manager")] + fn batch_forced_transfer( + e: &Env, + from_list: Vec
, + to_list: Vec
, + amounts: Vec, + operator: Address, + ) { + RWA::batch_forced_transfer(e, &from_list, &to_list, &amounts); + } + + #[only_role(operator, "manager")] + fn batch_mint(e: &Env, to_list: Vec
, amounts: Vec, operator: Address) { + RWA::batch_mint(e, &to_list, &amounts); + } + + #[only_role(operator, "manager")] + fn batch_burn(e: &Env, user_addresses: Vec
, amounts: Vec, operator: Address) { + RWA::batch_burn(e, &user_addresses, &amounts); + } + + #[only_role(operator, "manager")] + fn batch_set_address_frozen( + e: &Env, + user_addresses: Vec
, + freeze_list: Vec, + operator: Address, + ) { + RWA::batch_set_address_frozen(e, &user_addresses, &freeze_list); + } + + #[only_role(operator, "manager")] + fn batch_freeze_partial_tokens( + e: &Env, + user_addresses: Vec
, + amounts: Vec, + operator: Address, + ) { + RWA::batch_freeze_partial_tokens(e, &user_addresses, &amounts); + } + + #[only_role(operator, "manager")] + fn batch_unfreeze_partial_tokens( + e: &Env, + user_addresses: Vec
, + amounts: Vec, + operator: Address, + ) { + RWA::batch_unfreeze_partial_tokens(e, &user_addresses, &amounts); + } + #[only_role(operator, "manager")] fn set_compliance(e: &Env, compliance: Address, operator: Address) { RWA::set_compliance(e, &compliance); diff --git a/packages/tokens/src/rwa/compliance/modules/country_allow/test.rs b/packages/tokens/src/rwa/compliance/modules/country_allow/test.rs index e98b2f9d5..9c485146b 100644 --- a/packages/tokens/src/rwa/compliance/modules/country_allow/test.rs +++ b/packages/tokens/src/rwa/compliance/modules/country_allow/test.rs @@ -61,6 +61,16 @@ impl IdentityRegistryStorage for MockIRSContract { unreachable!("add_identity is not used in these tests"); } + fn batch_add_identity( + _e: &Env, + _accounts: Vec
, + _identities: Vec
, + _country_data_lists: Vec>, + _operator: Address, + ) { + unreachable!("batch_add_identity is not used in these tests"); + } + fn remove_identity(_e: &Env, _account: Address, _operator: Address) { unreachable!("remove_identity is not used in these tests"); } diff --git a/packages/tokens/src/rwa/compliance/modules/country_restrict/test.rs b/packages/tokens/src/rwa/compliance/modules/country_restrict/test.rs index b1b6ed8eb..31e890c48 100644 --- a/packages/tokens/src/rwa/compliance/modules/country_restrict/test.rs +++ b/packages/tokens/src/rwa/compliance/modules/country_restrict/test.rs @@ -61,6 +61,16 @@ impl IdentityRegistryStorage for MockIRSContract { unreachable!("add_identity is not used in these tests"); } + fn batch_add_identity( + _e: &Env, + _accounts: Vec
, + _identities: Vec
, + _country_data_lists: Vec>, + _operator: Address, + ) { + unreachable!("batch_add_identity is not used in these tests"); + } + fn remove_identity(_e: &Env, _account: Address, _operator: Address) { unreachable!("remove_identity is not used in these tests"); } diff --git a/packages/tokens/src/rwa/compliance/modules/max_balance/test.rs b/packages/tokens/src/rwa/compliance/modules/max_balance/test.rs index 56c4e6a58..3792f56d7 100644 --- a/packages/tokens/src/rwa/compliance/modules/max_balance/test.rs +++ b/packages/tokens/src/rwa/compliance/modules/max_balance/test.rs @@ -60,6 +60,16 @@ impl IdentityRegistryStorage for MockIRSContract { unreachable!("add_identity is not used in these tests"); } + fn batch_add_identity( + _e: &Env, + _accounts: Vec
, + _identities: Vec
, + _country_data_lists: Vec>, + _operator: Address, + ) { + unreachable!("batch_add_identity is not used in these tests"); + } + fn remove_identity(_e: &Env, _account: Address, _operator: Address) { unreachable!("remove_identity is not used in these tests"); } diff --git a/packages/tokens/src/rwa/compliance/modules/test.rs b/packages/tokens/src/rwa/compliance/modules/test.rs index ff4c82712..d8549ba1f 100644 --- a/packages/tokens/src/rwa/compliance/modules/test.rs +++ b/packages/tokens/src/rwa/compliance/modules/test.rs @@ -54,6 +54,16 @@ impl IdentityRegistryStorage for MockIRSContract { unreachable!("add_identity is not used in these tests"); } + fn batch_add_identity( + _e: &Env, + _accounts: Vec
, + _identities: Vec
, + _country_data_lists: Vec>, + _operator: Address, + ) { + unreachable!("batch_add_identity is not used in these tests"); + } + fn remove_identity(_e: &Env, _account: Address, _operator: Address) { unreachable!("remove_identity is not used in these tests"); } diff --git a/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/test.rs b/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/test.rs index cd4af54fd..f59f719d5 100644 --- a/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/test.rs +++ b/packages/tokens/src/rwa/compliance/modules/time_transfers_limits/test.rs @@ -62,6 +62,16 @@ impl IdentityRegistryStorage for MockIRSContract { unreachable!("add_identity is not used in these tests"); } + fn batch_add_identity( + _e: &Env, + _accounts: Vec
, + _identities: Vec
, + _country_data_lists: Vec>, + _operator: Address, + ) { + unreachable!("batch_add_identity is not used in these tests"); + } + fn remove_identity(_e: &Env, _account: Address, _operator: Address) { unreachable!("remove_identity is not used in these tests"); } diff --git a/packages/tokens/src/rwa/identity_verification/identity_registry_storage/mod.rs b/packages/tokens/src/rwa/identity_verification/identity_registry_storage/mod.rs index dbd251d8e..7500951be 100644 --- a/packages/tokens/src/rwa/identity_verification/identity_registry_storage/mod.rs +++ b/packages/tokens/src/rwa/identity_verification/identity_registry_storage/mod.rs @@ -356,10 +356,10 @@ mod test; use soroban_sdk::{contracterror, contractevent, contracttrait, Address, Env, IntoVal, Val, Vec}; pub use storage::{ - add_country_data_entries, add_identity, delete_country_data, get_country_data, - get_country_data_entries, get_identity_profile, get_recovered_to, modify_country_data, - recover_identity, remove_identity, stored_identity, validate_country_data, CountryData, - CountryRelation, IdentityProfile, IdentityType, IndividualCountryRelation, + add_country_data_entries, add_identity, batch_add_identity, delete_country_data, + get_country_data, get_country_data_entries, get_identity_profile, get_recovered_to, + modify_country_data, recover_identity, remove_identity, stored_identity, validate_country_data, + CountryData, CountryRelation, IdentityProfile, IdentityType, IndividualCountryRelation, OrganizationCountryRelation, }; @@ -402,6 +402,57 @@ pub trait IdentityRegistryStorage: TokenBinder { operator: Address, ); + /// Stores several identities in one invocation, associating `accounts[i]` + /// with `identities[i]` and `country_data_lists[i]`. The batched form of + /// [`IdentityRegistryStorage::add_identity`], and the counterpart of + /// ERC-3643's `batchRegisterIdentity`. + /// + /// # Arguments + /// + /// * `e` - The Soroban environment. + /// * `accounts` - The account addresses to associate with the identities. + /// * `identities` - The identity addresses to store. + /// * `country_data_lists` - The initial country data entries for each + /// account. + /// * `operator` - The address authorizing the invocation. + /// + /// # Errors + /// + /// * [`IRSError::BatchSizeMismatch`] - When the three arrays do not all + /// have the same length. + /// * refer to [`batch_add_identity`] errors. + /// + /// # Events + /// + /// For each registration: + /// * topics - `["identity_stored", account: Address, identity: Address]` + /// * data - `[]` + /// + /// And for each of its country data entries: + /// * topics - `["country_data_added", account: Address]` + /// * data - `[country_data: Val]` + /// + /// # Notes + /// + /// No default implementation is provided because this is a privileged + /// operation that requires custom access control. Access control should be + /// enforced on `operator` before calling [`batch_add_identity`] for the + /// implementation, which validates that the three arrays line up. + /// + /// The identity type is not part of the interface, exactly as in + /// [`IdentityRegistryStorage::add_identity`]: the implementation decides + /// it. + /// + /// Refer to the batch operations section of the + /// [RWA module documentation](crate::rwa) for how to size a batch. + fn batch_add_identity( + e: &Env, + accounts: Vec
, + identities: Vec
, + country_data_lists: Vec>, + operator: Address, + ); + /// Removes an identity and all associated country data entries. /// /// # Arguments @@ -619,6 +670,8 @@ pub enum IRSError { MetadataStringTooLong = 327, /// The account still holds a balance in a linked token. AccountHasBalance = 328, + /// The parallel arrays of a batch call have different lengths. + BatchSizeMismatch = 329, } // ################## CONSTANTS ################## diff --git a/packages/tokens/src/rwa/identity_verification/identity_registry_storage/storage.rs b/packages/tokens/src/rwa/identity_verification/identity_registry_storage/storage.rs index 79596cef9..ccc6a0e56 100644 --- a/packages/tokens/src/rwa/identity_verification/identity_registry_storage/storage.rs +++ b/packages/tokens/src/rwa/identity_verification/identity_registry_storage/storage.rs @@ -387,6 +387,69 @@ pub fn add_identity( } } +/// Stores several identities in one invocation, associating `accounts[i]` with +/// `identities[i]` and `country_data_lists[i]`. +/// +/// The batch is atomic: an item that fails reverts every item before it. +/// +/// # Arguments +/// +/// * `e` - The Soroban environment. +/// * `accounts` - The account addresses to associate with the identities. +/// * `identities` - The identity addresses to store. +/// * `identity_type` - Whether the batch registers individuals or +/// organizations. +/// * `country_data_lists` - The initial country data entries for each account. +/// +/// # Errors +/// +/// * [`IRSError::BatchSizeMismatch`] - When the three arrays do not all have +/// the same length. +/// * refer to [`add_identity`] errors. +/// +/// # Events +/// +/// For each registration: +/// * topics - `["identity_stored", account: Address, identity: Address]` +/// * data - `[]` +/// +/// And for each of its country data entries: +/// * topics - `["country_data_added", account: Address]` +/// * data - `[country_data: Val]` +/// +/// # Notes +/// +/// One `identity_type` applies to the whole batch, so a cohort that mixes +/// individuals and organizations takes one call per type. +/// +/// An account repeated across the batch fails on its second occurrence with +/// [`IRSError::IdentityOverwrite`], which reverts the whole batch. +/// +/// The caller is responsible for sizing the batch to fit the per-transaction +/// network limits. Refer to the batch operations section of the +/// [RWA module documentation](crate::rwa). +/// +/// # Security Warning +/// +/// This helper performs no authorization checks. +pub fn batch_add_identity( + e: &Env, + accounts: &Vec
, + identities: &Vec
, + identity_type: IdentityType, + country_data_lists: &Vec>, +) { + if accounts.len() != identities.len() || accounts.len() != country_data_lists.len() { + panic_with_error!(e, IRSError::BatchSizeMismatch); + } + + for ((account, identity), countries) in + accounts.iter().zip(identities.iter()).zip(country_data_lists.iter()) + { + add_identity(e, &account, &identity, identity_type.clone(), &countries); + } +} + /// Removes an identity and all associated country data. /// /// The account must not hold a balance in any linked token. Compliance diff --git a/packages/tokens/src/rwa/identity_verification/identity_registry_storage/test.rs b/packages/tokens/src/rwa/identity_verification/identity_registry_storage/test.rs index f65e38df7..24be9a1ff 100644 --- a/packages/tokens/src/rwa/identity_verification/identity_registry_storage/test.rs +++ b/packages/tokens/src/rwa/identity_verification/identity_registry_storage/test.rs @@ -9,10 +9,11 @@ use soroban_sdk::{ use crate::rwa::{ identity_verification::identity_registry_storage::{ storage::{ - add_country_data_entries, add_identity, delete_country_data, get_country_data, - get_country_data_entries, get_identity_profile, get_recovered_to, modify_country_data, - recover_identity, remove_identity, stored_identity, validate_country_data, CountryData, - CountryRelation, IdentityType, IndividualCountryRelation, OrganizationCountryRelation, + add_country_data_entries, add_identity, batch_add_identity, delete_country_data, + get_country_data, get_country_data_entries, get_identity_profile, get_recovered_to, + modify_country_data, recover_identity, remove_identity, stored_identity, + validate_country_data, CountryData, CountryRelation, IdentityType, + IndividualCountryRelation, OrganizationCountryRelation, }, MAX_COUNTRY_ENTRIES, MAX_METADATA_ENTRIES, MAX_METADATA_STRING_LEN, }, @@ -1206,3 +1207,118 @@ fn validate_country_data_catches_invalid_metadata_value_types() { validate_country_data(&e, &country_data); }); } + +// ################## BATCH OPERATIONS ################## + +fn residence(country: u32) -> CountryRelation { + CountryRelation::Individual(IndividualCountryRelation::Residence(country)) +} + +#[test] +fn batch_add_identity_stores_every_registration() { + let e = Env::default(); + let contract_id = e.register(MockContract, ()); + + e.as_contract(&contract_id, || { + let first_account = Address::generate(&e); + let second_account = Address::generate(&e); + let first_identity = Address::generate(&e); + let second_identity = Address::generate(&e); + + let usa = CountryData { country: residence(840), metadata: None }; + let france = CountryData { country: residence(250), metadata: None }; + + batch_add_identity( + &e, + &vec![&e, first_account.clone(), second_account.clone()], + &vec![&e, first_identity.clone(), second_identity.clone()], + IdentityType::Individual, + &vec![&e, vec![&e, usa.clone()], vec![&e, france.clone()]], + ); + + assert_eq!(stored_identity(&e, &first_account), first_identity); + assert_eq!(stored_identity(&e, &second_account), second_identity); + + let profile = get_identity_profile(&e, &second_account); + assert_eq!(profile.identity_type, IdentityType::Individual); + assert_eq!(profile.countries, vec![&e, france]); + }); +} + +#[test] +#[should_panic(expected = "Error(Contract, #329)")] +fn batch_add_identity_rejects_mismatched_identities() { + let e = Env::default(); + let contract_id = e.register(MockContract, ()); + + e.as_contract(&contract_id, || { + let country = CountryData { country: residence(840), metadata: None }; + + batch_add_identity( + &e, + &vec![&e, Address::generate(&e), Address::generate(&e)], + &vec![&e, Address::generate(&e)], + IdentityType::Individual, + &vec![&e, vec![&e, country.clone()], vec![&e, country]], + ); + }); +} + +#[test] +#[should_panic(expected = "Error(Contract, #329)")] +fn batch_add_identity_rejects_mismatched_country_lists() { + let e = Env::default(); + let contract_id = e.register(MockContract, ()); + + e.as_contract(&contract_id, || { + let country = CountryData { country: residence(840), metadata: None }; + + batch_add_identity( + &e, + &vec![&e, Address::generate(&e)], + &vec![&e, Address::generate(&e)], + IdentityType::Individual, + &vec![&e, vec![&e, country.clone()], vec![&e, country]], + ); + }); +} + +/// An account repeated inside one batch hits the overwrite guard on its second +/// occurrence, which aborts the whole call. +#[test] +#[should_panic(expected = "Error(Contract, #320)")] +fn batch_add_identity_rejects_a_repeated_account() { + let e = Env::default(); + let contract_id = e.register(MockContract, ()); + + e.as_contract(&contract_id, || { + let account = Address::generate(&e); + let country = CountryData { country: residence(840), metadata: None }; + + batch_add_identity( + &e, + &vec![&e, account.clone(), account.clone()], + &vec![&e, Address::generate(&e), Address::generate(&e)], + IdentityType::Individual, + &vec![&e, vec![&e, country.clone()], vec![&e, country]], + ); + }); +} + +#[test] +fn batch_add_identity_accepts_an_empty_batch() { + let e = Env::default(); + let contract_id = e.register(MockContract, ()); + + e.as_contract(&contract_id, || { + batch_add_identity( + &e, + &Vec::new(&e), + &Vec::new(&e), + IdentityType::Individual, + &Vec::new(&e), + ); + + assert_eq!(e.events().all().events().len(), 0); + }); +} diff --git a/packages/tokens/src/rwa/mod.rs b/packages/tokens/src/rwa/mod.rs index 01c387281..8af6d6918 100644 --- a/packages/tokens/src/rwa/mod.rs +++ b/packages/tokens/src/rwa/mod.rs @@ -84,6 +84,25 @@ //! below. These modules act as the default implementations for the most common //! needs. //! +//! ## Batch Operations +//! +//! The operator-gated entry points have `batch_` siblings that apply the same +//! operation across a list of accounts in a single invocation, mirroring the +//! batch functions of the ERC-3643 standard. A batch is atomic: an item that +//! fails reverts the whole call, and every item emits the same event its +//! single-account counterpart would. +//! +//! Batch size is bounded by the per-transaction network limits rather than by +//! the library, which cannot know how many compliance modules are registered +//! or how far an identity check has to walk. Measured against the default +//! stack (two compliance modules, one required claim topic), the footprint +//! limit is reached at roughly 35 accounts for [`RWA::batch_mint`] and +//! [`RWA::batch_transfer`], and the event size limit at roughly 35 for +//! identity registration and 99 for the freezing functions. Every additional +//! required claim topic or compliance module lowers those numbers, so a +//! deployment is expected to measure against its own configuration. Current +//! limits are listed at . +//! //! ## Modules //! //! - **Identity Verification**: Claim issuers, claim topic registries, identity @@ -108,9 +127,9 @@ pub use identity_verification::{ #[cfg(test)] mod test; -use soroban_sdk::{contracterror, contractevent, contracttrait, Address, Env, String}; +use soroban_sdk::{contracterror, contractevent, contracttrait, Address, Env, String, Vec}; use stellar_contract_utils::pausable::Pausable; -pub use storage::{RWAStorageKey, RWA}; +pub use storage::{FromVerification, RWAStorageKey, RWA}; use crate::fungible::FungibleToken; @@ -132,6 +151,38 @@ use crate::fungible::FungibleToken; pub trait RWAToken: Pausable + FungibleToken { // ################## CORE TOKEN FUNCTIONS ################## + /// Transfers tokens to several recipients in a single call, moving + /// `amounts[i]` to `recipients[i]`. The batched form of + /// [`FungibleToken::transfer`], authorized once by `from`. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `from` - The address of the sender. + /// * `recipients` - The addresses of the receivers. + /// * `amounts` - The number of tokens to transfer to each recipient. + /// + /// # Errors + /// + /// * refer to [`RWA::batch_transfer`] errors. + /// + /// # Events + /// + /// For each recipient: + /// * topics - `["transfer", from: Address, to: Address]` + /// * data - `[amount: i128]` + /// + /// # Notes + /// + /// The sender's identity is verified once for the whole batch instead of + /// once per recipient, which is where the saving over repeated single + /// transfers comes from. Refer to [`RWA::batch_transfer`] for the mechanics + /// and the assumption that rests on, and to the batch operations section of + /// the [module documentation](crate::rwa) for how to size a batch. + fn batch_transfer(e: &Env, from: Address, recipients: Vec
, amounts: Vec) { + RWA::batch_transfer(e, &from, &recipients, &amounts); + } + /// Forces a transfer of tokens from `from` to `to`. /// This function can only be called by the operator with necessary /// privileges. RBAC checks are expected to be enforced on the @@ -172,6 +223,46 @@ pub trait RWAToken: Pausable + FungibleToken { /// implementation. fn forced_transfer(e: &Env, from: Address, to: Address, amount: i128, operator: Address); + /// Forces a transfer for each `(from_list[i], to_list[i], amounts[i])` + /// triple in a single call. The batched form of + /// [`RWAToken::forced_transfer`], with the same privileges and the same + /// exemption from `from` verification. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `from_list` - The addresses of the senders. + /// * `to_list` - The addresses of the receivers. + /// * `amounts` - The number of tokens to transfer for each pair. + /// * `operator` - The address of the operator. + /// + /// # Errors + /// + /// * refer to [`RWA::batch_forced_transfer`] errors. + /// + /// # Events + /// + /// For each triple: + /// * topics - `["transfer", from: Address, to: Address]` + /// * data - `[amount: i128]` + /// + /// # Notes + /// + /// No default implementation is provided because this is a privileged + /// operation that requires custom access control. Access control should be + /// enforced on `operator` before calling [`RWA::batch_forced_transfer`] for + /// the implementation. + /// + /// Refer to the batch operations section of the + /// [module documentation](crate::rwa) for how to size a batch. + fn batch_forced_transfer( + e: &Env, + from_list: Vec
, + to_list: Vec
, + amounts: Vec, + operator: Address, + ); + /// Mints tokens to a wallet. Tokens can only be minted to verified /// addresses. This function can only be called by the operator with /// necessary privileges. RBAC checks are expected to be enforced on the @@ -206,6 +297,38 @@ pub trait RWAToken: Pausable + FungibleToken { /// checks). fn mint(e: &Env, to: Address, amount: i128, operator: Address); + /// Mints `amounts[i]` tokens to `to_list[i]` in a single call. The batched + /// form of [`RWAToken::mint`], with the same privileges and the same + /// identity and compliance checks per recipient. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `to_list` - The addresses to mint the tokens to. + /// * `amounts` - The amount of tokens to mint to each address. + /// * `operator` - The address of the operator. + /// + /// # Errors + /// + /// * refer to [`RWA::batch_mint`] errors. + /// + /// # Events + /// + /// For each address: + /// * topics - `["mint", to: Address]` + /// * data - `[amount: i128]` + /// + /// # Notes + /// + /// No default implementation is provided because this is a privileged + /// operation that requires custom access control. Access control should be + /// enforced on `operator` before calling [`RWA::batch_mint`] for the + /// implementation. + /// + /// Refer to the batch operations section of the + /// [module documentation](crate::rwa) for how to size a batch. + fn batch_mint(e: &Env, to_list: Vec
, amounts: Vec, operator: Address); + /// Burns tokens from a wallet. /// This function can only be called by the operator with necessary /// privileges. RBAC checks are expected to be enforced on the @@ -238,6 +361,37 @@ pub trait RWAToken: Pausable + FungibleToken { /// implementation. fn burn(e: &Env, user_address: Address, amount: i128, operator: Address); + /// Burns `amounts[i]` tokens from `user_addresses[i]` in a single call. The + /// batched form of [`RWAToken::burn`], with the same privileges. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `user_addresses` - The addresses to burn the tokens from. + /// * `amounts` - The amount of tokens to burn from each address. + /// * `operator` - The address of the operator. + /// + /// # Errors + /// + /// * refer to [`RWA::batch_burn`] errors. + /// + /// # Events + /// + /// For each address: + /// * topics - `["burn", user_address: Address]` + /// * data - `[amount: i128]` + /// + /// # Notes + /// + /// No default implementation is provided because this is a privileged + /// operation that requires custom access control. Access control should be + /// enforced on `operator` before calling [`RWA::batch_burn`] for the + /// implementation. + /// + /// Refer to the batch operations section of the + /// [module documentation](crate::rwa) for how to size a batch. + fn batch_burn(e: &Env, user_addresses: Vec
, amounts: Vec, operator: Address); + /// Recovery function used to force transfer tokens from a old account /// to a new account for an investor. /// This function can only be called by the operator with necessary @@ -314,6 +468,43 @@ pub trait RWAToken: Pausable + FungibleToken { /// the implementation. fn set_address_frozen(e: &Env, user_address: Address, freeze: bool, operator: Address); + /// Sets the frozen status of `user_addresses[i]` to `freeze_list[i]` in a + /// single call. The batched form of [`RWAToken::set_address_frozen`], with + /// the same privileges. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `user_addresses` - The addresses for which to update frozen status. + /// * `freeze_list` - The frozen status to apply to each address. + /// * `operator` - The address of the operator. + /// + /// # Errors + /// + /// * refer to [`RWA::batch_set_address_frozen`] errors. + /// + /// # Events + /// + /// For each address: + /// * topics - `["address_frozen", user_address: Address, is_frozen: bool]` + /// * data - `[]` + /// + /// # Notes + /// + /// No default implementation is provided because this is a privileged + /// operation that requires custom access control. Access control should be + /// enforced on `operator` before calling + /// [`RWA::batch_set_address_frozen`] for the implementation. + /// + /// Refer to the batch operations section of the + /// [module documentation](crate::rwa) for how to size a batch. + fn batch_set_address_frozen( + e: &Env, + user_addresses: Vec
, + freeze_list: Vec, + operator: Address, + ); + /// Freezes a specified amount of tokens for a given address. /// This function can only be called by the operator with necessary /// privileges. RBAC checks are expected to be enforced on the @@ -345,6 +536,43 @@ pub trait RWAToken: Pausable + FungibleToken { /// for the implementation. fn freeze_partial_tokens(e: &Env, user_address: Address, amount: i128, operator: Address); + /// Freezes `amounts[i]` tokens on `user_addresses[i]` in a single call. The + /// batched form of [`RWAToken::freeze_partial_tokens`], with the same + /// privileges. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `user_addresses` - The addresses for which to freeze tokens. + /// * `amounts` - The amount of tokens to freeze on each address. + /// * `operator` - The address of the operator. + /// + /// # Errors + /// + /// * refer to [`RWA::batch_freeze_partial_tokens`] errors. + /// + /// # Events + /// + /// For each address: + /// * topics - `["tokens_frozen", user_address: Address]` + /// * data - `[amount: i128]` + /// + /// # Notes + /// + /// No default implementation is provided because this is a privileged + /// operation that requires custom access control. Access control should be + /// enforced on `operator` before calling + /// [`RWA::batch_freeze_partial_tokens`] for the implementation. + /// + /// Refer to the batch operations section of the + /// [module documentation](crate::rwa) for how to size a batch. + fn batch_freeze_partial_tokens( + e: &Env, + user_addresses: Vec
, + amounts: Vec, + operator: Address, + ); + /// Unfreezes a specified amount of tokens for a given address. /// This function can only be called by the operator with necessary /// privileges. RBAC checks are expected to be enforced on the @@ -376,6 +604,43 @@ pub trait RWAToken: Pausable + FungibleToken { /// for the implementation. fn unfreeze_partial_tokens(e: &Env, user_address: Address, amount: i128, operator: Address); + /// Unfreezes `amounts[i]` tokens on `user_addresses[i]` in a single call. + /// The batched form of [`RWAToken::unfreeze_partial_tokens`], with the same + /// privileges. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `user_addresses` - The addresses for which to unfreeze tokens. + /// * `amounts` - The amount of tokens to unfreeze on each address. + /// * `operator` - The address of the operator. + /// + /// # Errors + /// + /// * refer to [`RWA::batch_unfreeze_partial_tokens`] errors. + /// + /// # Events + /// + /// For each address: + /// * topics - `["tokens_unfrozen", user_address: Address]` + /// * data - `[amount: i128]` + /// + /// # Notes + /// + /// No default implementation is provided because this is a privileged + /// operation that requires custom access control. Access control should be + /// enforced on `operator` before calling + /// [`RWA::batch_unfreeze_partial_tokens`] for the implementation. + /// + /// Refer to the batch operations section of the + /// [module documentation](crate::rwa) for how to size a batch. + fn batch_unfreeze_partial_tokens( + e: &Env, + user_addresses: Vec
, + amounts: Vec, + operator: Address, + ); + /// Returns the freezing status of a wallet. /// /// # Arguments @@ -520,6 +785,8 @@ pub enum RWAError { IdentityVerifierNotSet = 312, /// Indicates the old account and new account have different identities. IdentityMismatch = 313, + /// Indicates the parallel arrays of a batch call have different lengths. + BatchSizeMismatch = 314, } // ################## CONSTANTS ################## diff --git a/packages/tokens/src/rwa/storage.rs b/packages/tokens/src/rwa/storage.rs index fb8b17a7e..204424175 100644 --- a/packages/tokens/src/rwa/storage.rs +++ b/packages/tokens/src/rwa/storage.rs @@ -1,4 +1,4 @@ -use soroban_sdk::{contracttype, panic_with_error, Address, Env, MuxedAddress, String}; +use soroban_sdk::{contracttype, panic_with_error, Address, Env, MuxedAddress, String, Vec}; use stellar_contract_utils::pausable::{paused, PausableError}; use crate::{ @@ -31,6 +31,21 @@ pub enum RWAStorageKey { pub struct RWA; +/// Who verifies the identity of `from` in [`RWA::validate_transfer`]. +/// +/// Every item of a batch transfer shares one `from`, so verifying it per item +/// repeats the same walk through the identity stack. This lets the batch lift +/// that one check out of its loop while every other check still runs per item, +/// and while all three transfer entry points keep sharing one validator. +pub enum FromVerification { + /// Verify `from` as part of this call. What the single-transfer entry + /// points pass. + Required, + /// Skip the check on `from`, because the caller has already verified it + /// once for a batch of transfers that all share it. + HoistedByCaller, +} + impl ContractOverrides for RWA { fn transfer(e: &Env, from: &Address, to: &MuxedAddress, amount: i128) { RWA::transfer(e, from, &to.address(), amount); @@ -234,60 +249,6 @@ impl RWA { Self::privileged_transfer(e, from, to, amount, &TransferKind::Forced); } - /// Shared mechanics behind [`Self::forced_transfer`] and - /// [`Self::recover_balance`]: moves `amount` from `from` to `to`, - /// bypassing paused state and freezing restrictions (unfreezing as - /// needed), and reports the movement to the compliance contract as - /// `kind`. The two public entry points differ only in the kind they - /// report, which is what lets compliance modules tell a seizure from a - /// wallet migration. - /// - /// Performs no identity verification; each caller verifies the recipient - /// before delegating here, so the check runs exactly once per invocation. - fn privileged_transfer( - e: &Env, - from: &Address, - to: &Address, - amount: i128, - kind: &TransferKind, - ) { - // Snapshot before any unfreeze/balance mutation so the hook observes - // the pre-operation state. A privileged transfer has no spender: it is - // an administrative pull, not a delegated move. - let from_snapshot = Self::account_snapshot(e, from); - let to_snapshot = Self::account_snapshot(e, to); - - if from_snapshot.balance < amount { - panic_with_error!(e, RWAError::InsufficientBalance); - } - - // Check if we need to unfreeze tokens to complete the transfer. The - // snapshot is still current: nothing has mutated the balance or - // frozen amount since it was captured. - let free_tokens = from_snapshot.balance - from_snapshot.frozen; - if free_tokens < amount { - let tokens_to_unfreeze = amount - free_tokens; - let new_frozen = from_snapshot.frozen - tokens_to_unfreeze; - - e.storage().persistent().set(&RWAStorageKey::FrozenTokens(from.clone()), &new_frozen); - emit_tokens_unfrozen(e, from, tokens_to_unfreeze); - } - - Base::update(e, Some(from), Some(to), amount); - - let compliance_addr = Self::compliance(e); - let compliance_client = ComplianceClient::new(e, &compliance_addr); - compliance_client.transferred( - &from_snapshot, - &to_snapshot, - &amount, - kind, - &e.current_contract_address(), - ); - - emit_transfer(e, from, to, None, amount); - } - /// Mints `amount` tokens to `to`. /// /// # Arguments @@ -716,48 +677,6 @@ impl RWA { emit_identity_verifier_set(e, identity_verifier); } - /// This function performs all the checks that are required - /// for a transfer but does not require authorization. It is used by - /// [`Self::transfer`] and [`Self::transfer_from`] overrides. - /// - /// # Arguments - /// - /// * `e` - Access to the Soroban environment. - /// * `from` - Snapshot of the sender, as of before the transfer. - /// * `to` - Snapshot of the receiver, as of before the transfer. - /// * `amount` - The amount of tokens to transfer. - /// - /// # Errors - /// - /// * [`PausableError::EnforcedPause`] - If the contract is paused. - /// * [`RWAError::AddressFrozen`] - If either the sender or receiver is - /// frozen. - /// * [`RWAError::InsufficientFreeTokens`] - If the sender does not have - /// enough free tokens. - /// * refer to [`Self::identity_verifier`] errors. - /// * refer to [`IdentityVerifierClient::verify_identity`] errors. - pub fn validate_transfer(e: &Env, from: &AccountSnapshot, to: &AccountSnapshot, amount: i128) { - // Check if contract is paused - if paused(e) { - panic_with_error!(e, PausableError::EnforcedPause); - } - - // Check if addresses are frozen - if Self::is_frozen(e, &from.address) || Self::is_frozen(e, &to.address) { - panic_with_error!(e, RWAError::AddressFrozen); - } - - // Check if there are enough free tokens (not frozen) - if from.balance - from.frozen < amount { - panic_with_error!(e, RWAError::InsufficientFreeTokens); - } - - let identity_verifier_addr = Self::identity_verifier(e); - let identity_verifier_client = IdentityVerifierClient::new(e, &identity_verifier_addr); - identity_verifier_client.verify_identity(&from.address); - identity_verifier_client.verify_identity(&to.address); - } - // ################## OVERRIDDEN FUNCTIONS ################## /// `transfer` override with added compliance and identity verification @@ -786,7 +705,13 @@ impl RWA { let from_snapshot = Self::account_snapshot(e, from); let to_snapshot = Self::account_snapshot(e, to); - Self::validate_transfer(e, &from_snapshot, &to_snapshot, amount); + Self::validate_transfer( + e, + &from_snapshot, + &to_snapshot, + amount, + FromVerification::Required, + ); Base::update(e, Some(from), Some(to), amount); @@ -827,7 +752,13 @@ impl RWA { let from_snapshot = Self::account_snapshot(e, from); let to_snapshot = Self::account_snapshot(e, to); - Self::validate_transfer(e, &from_snapshot, &to_snapshot, amount); + Self::validate_transfer( + e, + &from_snapshot, + &to_snapshot, + amount, + FromVerification::Required, + ); Base::spend_allowance(e, from, spender, amount); @@ -843,4 +774,504 @@ impl RWA { ); emit_transfer(e, from, to, None, amount); } + + // ################## BATCH FUNCTIONS ################## + + /// Transfers to several recipients in one invocation, moving + /// `amounts[i]` from `from` to `recipients[i]`. The sender authorizes the + /// whole batch once. + /// + /// The batch is atomic: an item that fails reverts every item before it. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `from` - The address holding the tokens. + /// * `recipients` - The addresses receiving the tokens. + /// * `amounts` - The amount to transfer to each recipient. + /// + /// # Errors + /// + /// * [`RWAError::BatchSizeMismatch`] - When `recipients` and `amounts` have + /// different lengths. + /// * refer to [`Self::validate_transfer`] errors, which every item runs. + /// * refer to [`Base::update`] errors. + /// + /// # Events + /// + /// For each recipient: + /// * topics - `["transfer", from: Address, to: Address]` + /// * data - `[amount: i128]` + /// + /// # Notes + /// + /// Recipients are processed in order, and the sender's free balance is + /// re-evaluated for every item, so a batch that overspends fails on the + /// item that runs out rather than up front. + /// + /// The sender's identity is verified once for the whole batch rather than + /// once per recipient, which is where the saving over repeated single + /// transfers comes from. This assumes nothing invalidates the sender + /// part-way through: the compliance modules that run between items hold no + /// authority over the identity stack, and re-entering this token is + /// impossible in Soroban, so no module in reach can revoke the sender it + /// was just handed. A module granted write access to the identity stack + /// would break that assumption. + /// + /// The paused state is checked once up front so that a paused token is + /// rejected before any identity work and regardless of batch size, and + /// again per item along with both wallets' freezing status, since those + /// reads are cheap enough that skipping them would buy nothing. + /// + /// The caller is responsible for sizing the batch to fit the + /// per-transaction network limits. Refer to the batch operations section of + /// the [module documentation](crate::rwa) for measured figures. + pub fn batch_transfer(e: &Env, from: &Address, recipients: &Vec
, amounts: &Vec) { + from.require_auth(); + + Self::require_equal_lengths(e, recipients.len(), amounts.len()); + + if paused(e) { + panic_with_error!(e, PausableError::EnforcedPause); + } + + // Every item shares one `from`, so the walk through the identity stack + // that verifies it runs once for the batch instead of once per + // recipient. Every other check stays inside the loop. + IdentityVerifierClient::new(e, &Self::identity_verifier(e)).verify_identity(from); + + let compliance_client = ComplianceClient::new(e, &Self::compliance(e)); + let token = e.current_contract_address(); + + for (to, amount) in recipients.iter().zip(amounts.iter()) { + // Snapshot before the update so the hook observes the pre-transfer + // state. The sender's balance shrinks as the batch progresses, so + // this is re-read for every item. + let from_snapshot = Self::account_snapshot(e, from); + let to_snapshot = Self::account_snapshot(e, &to); + + Self::validate_transfer( + e, + &from_snapshot, + &to_snapshot, + amount, + FromVerification::HoistedByCaller, + ); + + Base::update(e, Some(from), Some(&to), amount); + + compliance_client.transferred( + &from_snapshot, + &to_snapshot, + &amount, + &TransferKind::Standard, + &token, + ); + emit_transfer(e, from, &to, None, amount); + } + } + + /// Forces a transfer for each `(from_list[i], to_list[i], amounts[i])` + /// triple in one invocation. + /// + /// The batch is atomic: an item that fails reverts every item before it. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `from_list` - The addresses holding the tokens. + /// * `to_list` - The addresses receiving the tokens. + /// * `amounts` - The amount to move for each pair. + /// + /// # Errors + /// + /// * [`RWAError::BatchSizeMismatch`] - When the three arrays do not all + /// have the same length. + /// * refer to [`Self::forced_transfer`] errors. + /// + /// # Events + /// + /// For each triple: + /// * topics - `["transfer", from: Address, to: Address]` + /// * data - `[amount: i128]` + /// + /// # Notes + /// + /// The caller is responsible for sizing the batch to fit the + /// per-transaction network limits. Refer to the batch operations section of + /// the [module documentation](crate::rwa). + /// + /// # Security Warning + /// + /// **IMPORTANT**: This function bypasses authorization and freezing checks, + /// exactly like [`Self::forced_transfer`]. Refer to its security warning. + pub fn batch_forced_transfer( + e: &Env, + from_list: &Vec
, + to_list: &Vec
, + amounts: &Vec, + ) { + Self::require_equal_lengths(e, from_list.len(), to_list.len()); + Self::require_equal_lengths(e, to_list.len(), amounts.len()); + + for ((from, to), amount) in from_list.iter().zip(to_list.iter()).zip(amounts.iter()) { + Self::forced_transfer(e, &from, &to, amount); + } + } + + /// Mints `amounts[i]` tokens to `to_list[i]` in one invocation. + /// + /// The batch is atomic: an item that fails reverts every item before it. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `to_list` - The addresses receiving the new tokens. + /// * `amounts` - The amount to mint to each address. + /// + /// # Errors + /// + /// * [`RWAError::BatchSizeMismatch`] - When `to_list` and `amounts` have + /// different lengths. + /// * refer to [`Self::mint`] errors. + /// + /// # Events + /// + /// For each address: + /// * topics - `["mint", to: Address]` + /// * data - `[amount: i128]` + /// + /// # Notes + /// + /// The caller is responsible for sizing the batch to fit the + /// per-transaction network limits. Refer to the batch operations section of + /// the [module documentation](crate::rwa). + /// + /// # Security Warning + /// + /// ⚠️ SECURITY RISK: This function has NO AUTHORIZATION CONTROLS ⚠️ + /// + /// Refer to the security warning on [`Self::mint`]. + pub fn batch_mint(e: &Env, to_list: &Vec
, amounts: &Vec) { + Self::require_equal_lengths(e, to_list.len(), amounts.len()); + + for (to, amount) in to_list.iter().zip(amounts.iter()) { + Self::mint(e, &to, amount); + } + } + + /// Burns `amounts[i]` tokens from `user_addresses[i]` in one invocation. + /// + /// The batch is atomic: an item that fails reverts every item before it. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `user_addresses` - The addresses to burn tokens from. + /// * `amounts` - The amount to burn from each address. + /// + /// # Errors + /// + /// * [`RWAError::BatchSizeMismatch`] - When `user_addresses` and `amounts` + /// have different lengths. + /// * refer to [`Self::burn`] errors. + /// + /// # Events + /// + /// For each address: + /// * topics - `["burn", user_address: Address]` + /// * data - `[amount: i128]` + /// + /// # Notes + /// + /// The caller is responsible for sizing the batch to fit the + /// per-transaction network limits. Refer to the batch operations section of + /// the [module documentation](crate::rwa). + /// + /// # Security Warning + /// + /// **IMPORTANT**: This function bypasses authorization checks, exactly like + /// [`Self::burn`]. Refer to its security warning. + pub fn batch_burn(e: &Env, user_addresses: &Vec
, amounts: &Vec) { + Self::require_equal_lengths(e, user_addresses.len(), amounts.len()); + + for (user_address, amount) in user_addresses.iter().zip(amounts.iter()) { + Self::burn(e, &user_address, amount); + } + } + + /// Sets the frozen status of `user_addresses[i]` to `freeze_list[i]` in one + /// invocation. + /// + /// The batch is atomic: an item that fails reverts every item before it. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `user_addresses` - The addresses to freeze or unfreeze. + /// * `freeze_list` - The frozen status to apply to each address. + /// + /// # Errors + /// + /// * [`RWAError::BatchSizeMismatch`] - When `user_addresses` and + /// `freeze_list` have different lengths. + /// + /// # Events + /// + /// For each address: + /// * topics - `["address_frozen", user_address: Address, is_frozen: bool]` + /// * data - `[]` + /// + /// # Notes + /// + /// An address repeated in `user_addresses` ends up with the status of its + /// last occurrence, and each occurrence emits its own event. + /// + /// The caller is responsible for sizing the batch to fit the + /// per-transaction network limits. Refer to the batch operations section of + /// the [module documentation](crate::rwa). + /// + /// # Security Warning + /// + /// **IMPORTANT**: This function bypasses authorization checks, exactly like + /// [`Self::set_address_frozen`]. Refer to its security warning. + pub fn batch_set_address_frozen( + e: &Env, + user_addresses: &Vec
, + freeze_list: &Vec, + ) { + Self::require_equal_lengths(e, user_addresses.len(), freeze_list.len()); + + for (user_address, freeze) in user_addresses.iter().zip(freeze_list.iter()) { + Self::set_address_frozen(e, &user_address, freeze); + } + } + + /// Freezes `amounts[i]` tokens on `user_addresses[i]` in one invocation. + /// + /// The batch is atomic: an item that fails reverts every item before it. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `user_addresses` - The addresses for which to freeze tokens. + /// * `amounts` - The amount to freeze on each address. + /// + /// # Errors + /// + /// * [`RWAError::BatchSizeMismatch`] - When `user_addresses` and `amounts` + /// have different lengths. + /// * refer to [`Self::freeze_partial_tokens`] errors. + /// + /// # Events + /// + /// For each address: + /// * topics - `["tokens_frozen", user_address: Address]` + /// * data - `[amount: i128]` + /// + /// # Notes + /// + /// Amounts accumulate, so an address repeated in `user_addresses` has every + /// occurrence added to its frozen balance. + /// + /// The caller is responsible for sizing the batch to fit the + /// per-transaction network limits. Refer to the batch operations section of + /// the [module documentation](crate::rwa). + /// + /// # Security Warning + /// + /// **IMPORTANT**: This function bypasses authorization checks, exactly like + /// [`Self::freeze_partial_tokens`]. Refer to its security warning. + pub fn batch_freeze_partial_tokens( + e: &Env, + user_addresses: &Vec
, + amounts: &Vec, + ) { + Self::require_equal_lengths(e, user_addresses.len(), amounts.len()); + + for (user_address, amount) in user_addresses.iter().zip(amounts.iter()) { + Self::freeze_partial_tokens(e, &user_address, amount); + } + } + + /// Unfreezes `amounts[i]` tokens on `user_addresses[i]` in one invocation. + /// + /// The batch is atomic: an item that fails reverts every item before it. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `user_addresses` - The addresses for which to unfreeze tokens. + /// * `amounts` - The amount to unfreeze on each address. + /// + /// # Errors + /// + /// * [`RWAError::BatchSizeMismatch`] - When `user_addresses` and `amounts` + /// have different lengths. + /// * refer to [`Self::unfreeze_partial_tokens`] errors. + /// + /// # Events + /// + /// For each address: + /// * topics - `["tokens_unfrozen", user_address: Address]` + /// * data - `[amount: i128]` + /// + /// # Notes + /// + /// Amounts accumulate, so an address repeated in `user_addresses` has every + /// occurrence subtracted from its frozen balance. + /// + /// The caller is responsible for sizing the batch to fit the + /// per-transaction network limits. Refer to the batch operations section of + /// the [module documentation](crate::rwa). + /// + /// # Security Warning + /// + /// **IMPORTANT**: This function bypasses authorization checks, exactly like + /// [`Self::unfreeze_partial_tokens`]. Refer to its security warning. + pub fn batch_unfreeze_partial_tokens( + e: &Env, + user_addresses: &Vec
, + amounts: &Vec, + ) { + Self::require_equal_lengths(e, user_addresses.len(), amounts.len()); + + for (user_address, amount) in user_addresses.iter().zip(amounts.iter()) { + Self::unfreeze_partial_tokens(e, &user_address, amount); + } + } + + // ################## LOW-LEVEL HELPERS ################## + + /// Performs every check a transfer requires, without requiring + /// authorization or moving anything: paused state, the freezing status of + /// both wallets, the sender's free balance, and the identity of both + /// parties. Shared by [`Self::transfer`], [`Self::transfer_from`] and every + /// item of [`Self::batch_transfer`], so no entry point can drift away from + /// the others on what it checks. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `from` - Snapshot of the sender, as of before the transfer. + /// * `to` - Snapshot of the receiver, as of before the transfer. + /// * `amount` - The amount of tokens to transfer. + /// * `from_verification` - Who verifies the identity of `from`, refer to + /// [`FromVerification`]. + /// + /// # Errors + /// + /// * [`PausableError::EnforcedPause`] - If the contract is paused. + /// * [`RWAError::AddressFrozen`] - If either the sender or receiver is + /// frozen. + /// * [`RWAError::InsufficientFreeTokens`] - If the sender does not have + /// enough free tokens. + /// * refer to [`Self::identity_verifier`] errors. + /// * refer to [`IdentityVerifierClient::verify_identity`] errors. + /// + /// # Security Warning + /// + /// **IMPORTANT**: Passing [`FromVerification::HoistedByCaller`] skips the + /// sender's identity check, and is only sound when the caller has verified + /// that same sender earlier in the invocation (i.e. batched operations). + pub fn validate_transfer( + e: &Env, + from: &AccountSnapshot, + to: &AccountSnapshot, + amount: i128, + from_verification: FromVerification, + ) { + // Check if contract is paused + if paused(e) { + panic_with_error!(e, PausableError::EnforcedPause); + } + + // Check if addresses are frozen + if Self::is_frozen(e, &from.address) || Self::is_frozen(e, &to.address) { + panic_with_error!(e, RWAError::AddressFrozen); + } + + // Check if there are enough free tokens (not frozen) + if from.balance - from.frozen < amount { + panic_with_error!(e, RWAError::InsufficientFreeTokens); + } + + let identity_verifier_addr = Self::identity_verifier(e); + let identity_verifier_client = IdentityVerifierClient::new(e, &identity_verifier_addr); + if matches!(from_verification, FromVerification::Required) { + identity_verifier_client.verify_identity(&from.address); + } + identity_verifier_client.verify_identity(&to.address); + } + + /// Shared mechanics behind [`Self::forced_transfer`] and + /// [`Self::recover_balance`]: moves `amount` from `from` to `to`, + /// bypassing paused state and freezing restrictions (unfreezing as + /// needed), and reports the movement to the compliance contract as + /// `kind`. The two public entry points differ only in the kind they + /// report, which is what lets compliance modules tell a seizure from a + /// wallet migration. + /// + /// Performs no identity verification; each caller verifies the recipient + /// before delegating here, so the check runs exactly once per invocation. + fn privileged_transfer( + e: &Env, + from: &Address, + to: &Address, + amount: i128, + kind: &TransferKind, + ) { + // Snapshot before any unfreeze/balance mutation so the hook observes + // the pre-operation state. A privileged transfer has no spender: it is + // an administrative pull, not a delegated move. + let from_snapshot = Self::account_snapshot(e, from); + let to_snapshot = Self::account_snapshot(e, to); + + if from_snapshot.balance < amount { + panic_with_error!(e, RWAError::InsufficientBalance); + } + + // Check if we need to unfreeze tokens to complete the transfer. The + // snapshot is still current: nothing has mutated the balance or + // frozen amount since it was captured. + let free_tokens = from_snapshot.balance - from_snapshot.frozen; + if free_tokens < amount { + let tokens_to_unfreeze = amount - free_tokens; + let new_frozen = from_snapshot.frozen - tokens_to_unfreeze; + + e.storage().persistent().set(&RWAStorageKey::FrozenTokens(from.clone()), &new_frozen); + emit_tokens_unfrozen(e, from, tokens_to_unfreeze); + } + + Base::update(e, Some(from), Some(to), amount); + + let compliance_addr = Self::compliance(e); + let compliance_client = ComplianceClient::new(e, &compliance_addr); + compliance_client.transferred( + &from_snapshot, + &to_snapshot, + &amount, + kind, + &e.current_contract_address(), + ); + + emit_transfer(e, from, to, None, amount); + } + + /// Rejects a batch whose parallel arrays do not line up. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `left` - Length of the first array. + /// * `right` - Length of the array that must match it. + /// + /// # Errors + /// + /// * [`RWAError::BatchSizeMismatch`] - When the two lengths differ. + fn require_equal_lengths(e: &Env, left: u32, right: u32) { + if left != right { + panic_with_error!(e, RWAError::BatchSizeMismatch); + } + } } diff --git a/packages/tokens/src/rwa/test.rs b/packages/tokens/src/rwa/test.rs index 3b243372f..314902e82 100644 --- a/packages/tokens/src/rwa/test.rs +++ b/packages/tokens/src/rwa/test.rs @@ -3,7 +3,7 @@ extern crate std; use soroban_sdk::{ contract, contractimpl, panic_with_error, symbol_short, testutils::{Address as _, Events}, - Address, Env, Event, String, + vec, Address, Env, Event, String, Vec, }; use stellar_contract_utils::pausable; @@ -12,7 +12,8 @@ use crate::{ rwa::{ compliance::{AccountSnapshot, TransferKind}, storage::RWAStorageKey, - IdentityVerifier, RWAError, RecoverySuccess, TokensUnfrozen, RWA, + AddressFrozen, Burn, IdentityVerifier, Mint, RWAError, RecoverySuccess, TokensFrozen, + TokensUnfrozen, RWA, }, }; @@ -22,6 +23,12 @@ pub struct MockIdentityVerifier; #[contractimpl] impl IdentityVerifier for MockIdentityVerifier { fn verify_identity(e: &Env, account: &Address) { + // Record how many times each address is verified, so batch tests can + // assert that the sender check is lifted out of the loop. + let key = (symbol_short!("vcount"), account.clone()); + let count: u32 = e.storage().persistent().get(&key).unwrap_or(0); + e.storage().persistent().set(&key, &(count + 1)); + // Per-address denial: if `id_deny` is set and matches, this address // fails verification while others still pass. Lets tests exercise // the asymmetry between `from` and `to` in privileged operations. @@ -1168,3 +1175,445 @@ fn transfer_fails_when_not_compliant() { RWA::transfer(&e, &from, &to, 50); }); } + +// ################## BATCH OPERATIONS ################## + +/// Reads the per-address verification counter kept by +/// [`MockIdentityVerifier`]. Must be called outside the token's contract +/// context. +fn verification_count(e: &Env, verifier: &Address, account: &Address) -> u32 { + e.as_contract(verifier, || { + e.storage().persistent().get(&(symbol_short!("vcount"), account.clone())).unwrap_or(0) + }) +} + +#[test] +fn batch_mint_credits_every_recipient() { + let e = Env::default(); + let address = e.register(MockRWAContract, ()); + let first = Address::generate(&e); + let second = Address::generate(&e); + + e.as_contract(&address, || { + setup_all_contracts(&e); + + RWA::batch_mint(&e, &vec![&e, first.clone(), second.clone()], &vec![&e, 100, 250]); + + assert_eq!(RWA::balance(&e, &first), 100); + assert_eq!(RWA::balance(&e, &second), 250); + + // 1 IdentityVerifierSet + 1 ComplianceSet + 1 Mint per recipient + let events = e.events().all(); + assert_eq!(events.events().len(), 4); + assert_eq!( + events.events().get(2).unwrap(), + &Mint { to: first.clone(), amount: 100 }.to_xdr(&e, &address) + ); + assert_eq!( + events.events().get(3).unwrap(), + &Mint { to: second.clone(), amount: 250 }.to_xdr(&e, &address) + ); + }); +} + +#[test] +#[should_panic(expected = "Error(Contract, #314)")] +fn batch_mint_rejects_length_mismatch() { + let e = Env::default(); + let address = e.register(MockRWAContract, ()); + let recipient = Address::generate(&e); + + e.as_contract(&address, || { + setup_all_contracts(&e); + RWA::batch_mint(&e, &vec![&e, recipient.clone()], &vec![&e, 100, 250]); + }); +} + +/// A failing item aborts the call. Rolling back the items that already +/// succeeded is the host's job (the whole invocation reverts), so the +/// assertion here is only that the batch does not carry on past the failure. +#[test] +#[should_panic(expected = "Error(Contract, #302)")] +fn batch_mint_stops_at_a_frozen_recipient() { + let e = Env::default(); + let address = e.register(MockRWAContract, ()); + let first = Address::generate(&e); + let frozen = Address::generate(&e); + + e.as_contract(&address, || { + setup_all_contracts(&e); + RWA::set_address_frozen(&e, &frozen, true); + + RWA::batch_mint(&e, &vec![&e, first.clone(), frozen.clone()], &vec![&e, 100, 100]); + }); +} + +#[test] +fn batch_burn_debits_every_account() { + let e = Env::default(); + let address = e.register(MockRWAContract, ()); + let first = Address::generate(&e); + let second = Address::generate(&e); + + e.as_contract(&address, || { + setup_all_contracts(&e); + RWA::batch_mint(&e, &vec![&e, first.clone(), second.clone()], &vec![&e, 100, 100]); + + RWA::batch_burn(&e, &vec![&e, first.clone(), second.clone()], &vec![&e, 40, 60]); + + assert_eq!(RWA::balance(&e, &first), 60); + assert_eq!(RWA::balance(&e, &second), 40); + + // 1 IdentityVerifierSet + 1 ComplianceSet + 1 Mint and 1 Burn per + // account + let events = e.events().all(); + assert_eq!(events.events().len(), 6); + assert_eq!( + events.events().get(4).unwrap(), + &Burn { from: first.clone(), amount: 40 }.to_xdr(&e, &address) + ); + assert_eq!( + events.events().get(5).unwrap(), + &Burn { from: second.clone(), amount: 60 }.to_xdr(&e, &address) + ); + }); +} + +#[test] +#[should_panic(expected = "Error(Contract, #314)")] +fn batch_burn_rejects_length_mismatch() { + let e = Env::default(); + let address = e.register(MockRWAContract, ()); + let account = Address::generate(&e); + + e.as_contract(&address, || { + setup_all_contracts(&e); + RWA::batch_burn(&e, &vec![&e, account.clone()], &vec![&e]); + }); +} + +#[test] +fn batch_forced_transfer_moves_every_pair() { + let e = Env::default(); + let address = e.register(MockRWAContract, ()); + let first_from = Address::generate(&e); + let second_from = Address::generate(&e); + let first_to = Address::generate(&e); + let second_to = Address::generate(&e); + + e.as_contract(&address, || { + setup_all_contracts(&e); + RWA::batch_mint( + &e, + &vec![&e, first_from.clone(), second_from.clone()], + &vec![&e, 100, 100], + ); + + RWA::batch_forced_transfer( + &e, + &vec![&e, first_from.clone(), second_from.clone()], + &vec![&e, first_to.clone(), second_to.clone()], + &vec![&e, 30, 70], + ); + + assert_eq!(RWA::balance(&e, &first_from), 70); + assert_eq!(RWA::balance(&e, &first_to), 30); + assert_eq!(RWA::balance(&e, &second_from), 30); + assert_eq!(RWA::balance(&e, &second_to), 70); + }); +} + +#[test] +#[should_panic(expected = "Error(Contract, #314)")] +fn batch_forced_transfer_rejects_mismatched_endpoints() { + let e = Env::default(); + let address = e.register(MockRWAContract, ()); + let from = Address::generate(&e); + let to = Address::generate(&e); + + e.as_contract(&address, || { + setup_all_contracts(&e); + RWA::batch_forced_transfer( + &e, + &vec![&e, from.clone(), from.clone()], + &vec![&e, to.clone()], + &vec![&e, 10], + ); + }); +} + +#[test] +#[should_panic(expected = "Error(Contract, #314)")] +fn batch_forced_transfer_rejects_mismatched_amounts() { + let e = Env::default(); + let address = e.register(MockRWAContract, ()); + let from = Address::generate(&e); + let to = Address::generate(&e); + + e.as_contract(&address, || { + setup_all_contracts(&e); + RWA::batch_forced_transfer( + &e, + &vec![&e, from.clone()], + &vec![&e, to.clone()], + &vec![&e, 10, 20], + ); + }); +} + +#[test] +fn batch_set_address_frozen_applies_each_status() { + let e = Env::default(); + let address = e.register(MockRWAContract, ()); + let frozen = Address::generate(&e); + let thawed = Address::generate(&e); + + e.as_contract(&address, || { + RWA::set_address_frozen(&e, &thawed, true); + + RWA::batch_set_address_frozen( + &e, + &vec![&e, frozen.clone(), thawed.clone()], + &vec![&e, true, false], + ); + + assert!(RWA::is_frozen(&e, &frozen)); + assert!(!RWA::is_frozen(&e, &thawed)); + + let events = e.events().all(); + assert_eq!( + events.events().last().unwrap(), + &AddressFrozen { user_address: thawed.clone(), is_frozen: false }.to_xdr(&e, &address) + ); + }); +} + +#[test] +#[should_panic(expected = "Error(Contract, #314)")] +fn batch_set_address_frozen_rejects_length_mismatch() { + let e = Env::default(); + let address = e.register(MockRWAContract, ()); + let account = Address::generate(&e); + + e.as_contract(&address, || { + RWA::batch_set_address_frozen(&e, &vec![&e, account.clone()], &vec![&e, true, false]); + }); +} + +#[test] +fn batch_freeze_and_unfreeze_partial_tokens() { + let e = Env::default(); + let address = e.register(MockRWAContract, ()); + let first = Address::generate(&e); + let second = Address::generate(&e); + + e.as_contract(&address, || { + setup_all_contracts(&e); + RWA::batch_mint(&e, &vec![&e, first.clone(), second.clone()], &vec![&e, 100, 100]); + + RWA::batch_freeze_partial_tokens( + &e, + &vec![&e, first.clone(), second.clone()], + &vec![&e, 30, 80], + ); + assert_eq!(RWA::get_frozen_tokens(&e, &first), 30); + assert_eq!(RWA::get_frozen_tokens(&e, &second), 80); + + let events = e.events().all(); + assert_eq!( + events.events().last().unwrap(), + &TokensFrozen { user_address: second.clone(), amount: 80 }.to_xdr(&e, &address) + ); + + RWA::batch_unfreeze_partial_tokens( + &e, + &vec![&e, first.clone(), second.clone()], + &vec![&e, 10, 80], + ); + assert_eq!(RWA::get_frozen_tokens(&e, &first), 20); + assert_eq!(RWA::get_frozen_tokens(&e, &second), 0); + + let events = e.events().all(); + assert_eq!( + events.events().last().unwrap(), + &TokensUnfrozen { user_address: second.clone(), amount: 80 }.to_xdr(&e, &address) + ); + }); +} + +#[test] +#[should_panic(expected = "Error(Contract, #314)")] +fn batch_freeze_partial_tokens_rejects_length_mismatch() { + let e = Env::default(); + let address = e.register(MockRWAContract, ()); + let account = Address::generate(&e); + + e.as_contract(&address, || { + setup_all_contracts(&e); + RWA::batch_freeze_partial_tokens(&e, &vec![&e, account.clone()], &vec![&e, 10, 20]); + }); +} + +#[test] +#[should_panic(expected = "Error(Contract, #314)")] +fn batch_unfreeze_partial_tokens_rejects_length_mismatch() { + let e = Env::default(); + let address = e.register(MockRWAContract, ()); + let account = Address::generate(&e); + + e.as_contract(&address, || { + setup_all_contracts(&e); + RWA::batch_unfreeze_partial_tokens(&e, &vec![&e, account.clone()], &vec![&e, 10, 20]); + }); +} + +#[test] +fn batch_transfer_credits_every_recipient() { + let e = Env::default(); + e.mock_all_auths(); + let address = e.register(MockRWAContract, ()); + let from = Address::generate(&e); + let first = Address::generate(&e); + let second = Address::generate(&e); + + e.as_contract(&address, || { + setup_all_contracts(&e); + RWA::mint(&e, &from, 100); + + RWA::batch_transfer(&e, &from, &vec![&e, first.clone(), second.clone()], &vec![&e, 20, 30]); + + assert_eq!(RWA::balance(&e, &from), 50); + assert_eq!(RWA::balance(&e, &first), 20); + assert_eq!(RWA::balance(&e, &second), 30); + + // 1 IdentityVerifierSet + 1 ComplianceSet + 1 Mint + 1 Transfer per + // recipient + let events = e.events().all(); + assert_eq!(events.events().len(), 5); + assert_eq!( + events.events().get(3).unwrap(), + &Transfer { from: from.clone(), to: first.clone(), amount: 20 }.to_xdr(&e, &address) + ); + assert_eq!( + events.events().get(4).unwrap(), + &Transfer { from: from.clone(), to: second.clone(), amount: 30 }.to_xdr(&e, &address) + ); + }); +} + +/// The sender's identity is verified once for the whole batch, whatever its +/// size, while each recipient is verified exactly once. +#[test] +fn batch_transfer_verifies_sender_independently_of_size() { + fn sender_verifications(recipient_count: u32) -> u32 { + let e = Env::default(); + e.mock_all_auths(); + let address = e.register(MockRWAContract, ()); + let from = Address::generate(&e); + + let mut recipients = Vec::new(&e); + let mut amounts = Vec::new(&e); + for _ in 0..recipient_count { + recipients.push_back(Address::generate(&e)); + amounts.push_back(1); + } + + let verifier = e.as_contract(&address, || { + let verifier = set_and_return_identity_verifier(&e); + let _ = set_and_return_compliance(&e); + RWA::mint(&e, &from, 100); + RWA::batch_transfer(&e, &from, &recipients, &amounts); + verifier + }); + + for recipient in recipients.iter() { + assert_eq!(verification_count(&e, &verifier, &recipient), 1); + } + + // The single mint above verifies the sender once too, so subtract it. + verification_count(&e, &verifier, &from) - 1 + } + + assert_eq!(sender_verifications(1), 1); + assert_eq!(sender_verifications(2), 1); + assert_eq!(sender_verifications(8), 1); +} + +#[test] +#[should_panic(expected = "Error(Contract, #314)")] +fn batch_transfer_rejects_length_mismatch() { + let e = Env::default(); + e.mock_all_auths(); + let address = e.register(MockRWAContract, ()); + let from = Address::generate(&e); + let to = Address::generate(&e); + + e.as_contract(&address, || { + setup_all_contracts(&e); + RWA::batch_transfer(&e, &from, &vec![&e, to.clone()], &vec![&e, 10, 20]); + }); +} + +/// The sender's free balance is re-evaluated per item, so an overspending +/// batch fails on the item that runs out rather than up front. +#[test] +#[should_panic(expected = "Error(Contract, #303)")] +fn batch_transfer_fails_on_the_item_that_overspends() { + let e = Env::default(); + e.mock_all_auths(); + let address = e.register(MockRWAContract, ()); + let from = Address::generate(&e); + let first = Address::generate(&e); + let second = Address::generate(&e); + + e.as_contract(&address, || { + setup_all_contracts(&e); + RWA::mint(&e, &from, 15); + + RWA::batch_transfer(&e, &from, &vec![&e, first.clone(), second.clone()], &vec![&e, 10, 10]); + }); +} + +/// A paused token is rejected before any identity work, so the reported error +/// is `EnforcedPause` and not whatever the identity stack would have said. +#[test] +#[should_panic(expected = "Error(Contract, #1000)")] +fn batch_transfer_reports_pause_ahead_of_identity() { + let e = Env::default(); + e.mock_all_auths(); + let address = e.register(MockRWAContract, ()); + let from = Address::generate(&e); + let to = Address::generate(&e); + + e.as_contract(&address, || { + let identity_verifier = set_and_return_identity_verifier(&e); + let _ = set_and_return_compliance(&e); + + // Mint while `from` is still verifiable, then take both its identity + // and the token away. + RWA::mint(&e, &from, 100); + e.as_contract(&identity_verifier, || { + e.storage().persistent().set(&symbol_short!("id_deny"), &from); + }); + pausable::pause(&e); + + RWA::batch_transfer(&e, &from, &vec![&e, to.clone()], &vec![&e, 10]); + }); +} + +/// An empty batch is not a way around the pause either. +#[test] +#[should_panic(expected = "Error(Contract, #1000)")] +fn batch_transfer_rejects_an_empty_batch_when_paused() { + let e = Env::default(); + e.mock_all_auths(); + let address = e.register(MockRWAContract, ()); + let from = Address::generate(&e); + + e.as_contract(&address, || { + setup_all_contracts(&e); + pausable::pause(&e); + + RWA::batch_transfer(&e, &from, &Vec::new(&e), &Vec::new(&e)); + }); +} From 99540074bc66740458c2482781a127beea99834c Mon Sep 17 00:00:00 2001 From: Boyan Barakov <9572072+brozorec@users.noreply.github.com> Date: Thu, 3 Sep 2026 17:08:36 +0200 Subject: [PATCH 05/17] feat(confidential): escrow commitment blindings to the sender-auditor (#853) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(confidential): escrow the sender-auditor secret in sponge lane 2 Widens the sender-auditor sponge from two lanes to three and adds an auditor-side escrow of the delegation viewing key. Lane 2 carries the new spendable blinding on the checkpoint operations (W_a5, T_a9, S_a6) and dvk_i on spender transfers (O_a9); S14 escrows dvk_i to the owner's auditor at set_spender under a new domain tag. The auditor can now recover the full Pedersen opening of C_spend and C_a rather than the value alone. * cite instead of copy in CLAUDE.md * docs(confidential): make the sender-auditor channel three-lane throughout DESIGN_cont §8.1/§8.2/§8.4/§8.5 still read the sender channel two-wide and still claimed no auditor can open C_spend; SDK §11 and OVERVIEW's visibility table omitted lane 2 entirely. Scope the spend-side opening to the checkpoint events that escrow it, restore V2 in the vk contract-binding lists, and move the lane-2 semantics out of DESIGN §2.5 into §8.1. * feat(confidential): emit the allowance salts in delegation events `SetSpender` gains `sigma_a` and `SpenderTransfer` gains `sigma_a_new`, both already proof-bound public inputs. Without them the owner's auditor cannot derive `r_a` and open `C_a`, contradicting DESIGN_cont §8.5. Event assertions in the happy-path tests now compare the typed `#[contractevent]` struct rather than counting events. * test(confidential): cover the lane-2 escrow constraints, drop the dead helper Adds the missing negative tests for W_a5, T_a9, S_a6, S14 and O_a9 -- each constraint could be deleted without failing a test. `encrypt_auditor_sender_balance` has no circuit caller since the sender channel went three-lane; `sponge_squeeze_2` is now the prefix of `sponge_squeeze_3` so their agreement is structural rather than test-pinned. Also corrects the RevokeSpender and `op_i` rationales, which did not hold. * docs(confidential): correct the auditor's opening scope and the tag-17 home The lane-2 escrow does not lapse at a merge: one auditor_id serves both of an account's channels, so the same key holds every inbound blinding and carries the opening forward by addition. §8.1, §9.4, SDK §11, OVERVIEW and SELECTIVE_DISCLOSURE said otherwise. Also fills in the event and payload tables with the fields this branch added, specifies the auditor-side dvk escrow in §8.5 (DESIGN.md is over its LaTeX budget), and makes the COMPLIANCE §5.3 clawback sketch consume and re-emit the escrow instead of leaving r_s unresolved. * docs(confidential): state what actually separates tag 17 from tag 11 * feat(confidential): escrow r_a to the owner's auditor instead of dvk_i S14 and O_a9 escrowed the delegation viewing key, which is deterministic and permanent per (owner, spender) and survives revoke-then-re-delegate, so one leaked ciphertext opened every allowance state for that pair, past and future. Both now escrow the blinding of the allowance commitment the operation writes -- r_a at set_spender, r_a' at spender_transfer -- which is what the auditor actually needs to open C_a. Tag 17 becomes ESCROWED_ALLOWANCE_BLINDING_AUDITOR (value unchanged) and lane 2 is uniformly a commitment blinding, never a key. Zero ACIR delta: both blindings were already constrained witnesses in scope. Payload and event fields are renamed, so this is an SDK-visible ABI change. DESIGN_cont §8.5's forward-only and rotation-remediation claims were false and are replaced with an event-scoped claim: a rotated-in key holds no opening until the delegation's next state change, and a retired key that already holds one can still carry it through a public homomorphic fold. * docs(confidential): fix stale auditor-side dvk_i escrow references Seven spots still described S14 and SpenderTransfer lane 2 as escrowing dvk_i. The SELECTIVE_DISCLOSURE §8 note was the substantive one: it ruled out a lane-2 disclosure variant on the grounds that the lane masks a key, which no longer holds now that it carries r_a'. * docs(confidential): drop the restated blast-radius paragraph in §8.5 The leak scope it describes is already stated by the dvk_i comparison paragraph above it, which ends "a leaked r_a ciphertext costs one state". * fix(confidential): key spender-transfer pads to the fresh allowance salt A reverted `confidential_transfer_from` leaves the delegation entry untouched, so the stored `sigma_a` recurred on the retry along with the ephemeral scalar and every channel pad derived from it, O_a9's lane[2] included; a retry that changed the amount published the difference in the clear. O7, O9, O_a2 and O_a6 now absorb the prover-chosen `sigma_a'`, which `SpenderTransfer` emits in place of the stored salt, and `SetSpender` drops its salt since no auditor path reads either. Documents the rotation assert as O14 and regenerates the spender-transfer VK and fixtures. * docs(confidential): write sponge lanes as lane[i] instead of "lane N" Bare cardinals read as ordinals -- "lane 2" invites "second lane" when it means the third slot, index 2. Every specific-lane reference now uses the zero-based index notation, defined normatively in DESIGN.md 2.5, and the ambiguous [0..1] range in the prefix-property formulas is replaced by an explicit per-index equality. --- packages/tokens/src/confidential/CLAUDE.md | 12 +- .../src/confidential/circuits/CLAUDE.md | 14 +- .../circuits/constraints.baseline | 8 +- .../src/confidential/circuits/lib/src/lib.nr | 115 +++++- .../confidential/circuits/lib/src/tests.nr | 113 ++++-- .../encrypt_auditor_sender_balance.json | 15 - .../testdata/encrypt_esc_allow_r_auditor.json | 15 + .../lib/testdata/sponge_squeeze_2.json | 4 +- .../lib/testdata/sponge_squeeze_3.json | 15 + .../circuits/set_spender/src/main.nr | 72 +++- .../circuits/set_spender/src/tests.nr | 330 ++++++++++++++- .../circuits/spender_transfer/src/main.nr | 114 ++++-- .../circuits/spender_transfer/src/tests.nr | 377 ++++++++++++++---- .../circuits/transfer/src/main.nr | 30 +- .../circuits/transfer/src/tests.nr | 98 ++++- .../circuits/vks/set_spender.vk.json | 2 +- .../circuits/vks/spender_transfer.vk.json | 2 +- .../circuits/vks/transfer.vk.json | 2 +- .../circuits/vks/withdraw.vk.json | 2 +- .../circuits/withdraw/src/main.nr | 45 ++- .../circuits/withdraw/src/tests.nr | 77 +++- .../src/confidential/compliance/test.rs | 5 + .../src/confidential/docs/COMPLIANCE.md | 13 +- .../tokens/src/confidential/docs/DESIGN.md | 76 ++-- .../src/confidential/docs/DESIGN_cont.md | 122 ++++-- .../tokens/src/confidential/docs/INDEXER.md | 12 +- .../tokens/src/confidential/docs/OVERVIEW.md | 13 +- packages/tokens/src/confidential/docs/SDK.md | 37 +- .../confidential/docs/SELECTIVE_DISCLOSURE.md | 28 +- packages/tokens/src/confidential/mod.rs | 32 +- packages/tokens/src/confidential/storage.rs | 40 +- packages/tokens/src/confidential/test.rs | 301 +++++++++++++- 32 files changed, 1760 insertions(+), 381 deletions(-) delete mode 100644 packages/tokens/src/confidential/circuits/lib/testdata/encrypt_auditor_sender_balance.json create mode 100644 packages/tokens/src/confidential/circuits/lib/testdata/encrypt_esc_allow_r_auditor.json create mode 100644 packages/tokens/src/confidential/circuits/lib/testdata/sponge_squeeze_3.json diff --git a/packages/tokens/src/confidential/CLAUDE.md b/packages/tokens/src/confidential/CLAUDE.md index f31e0d6d9..5b3b3db38 100644 --- a/packages/tokens/src/confidential/CLAUDE.md +++ b/packages/tokens/src/confidential/CLAUDE.md @@ -51,7 +51,7 @@ Beyond the root guide's conventions: Everything else defers by citation: `SDK.md`, `SELECTIVE_DISCLOSURE.md`, `INDEXER.md`, `COMPLIANCE.md`, and the non-normative `OVERVIEW.md`. Two exceptions run the other way — `circuits/lib/src/lib.nr` outranks the docs wherever they disagree about a primitive (`SDK.md` §4 says so explicitly), and the contract's `#[contracttype]`s are authoritative for their own shape. -`DESIGN.md` is already at roughly 768 expressions, over its own stated budget. Do not add math to §1–§7; put it in `DESIGN_cont.md`. +`DESIGN.md` is already at roughly 778 expressions, over its own stated budget. Do not add math to §1–§7; put it in `DESIGN_cont.md`. ### Duplicated tables that drift @@ -59,17 +59,17 @@ Five things exist in more than one file. Changing the normative copy means grepp | Content | Normative source | Copies live in | |:---|:---|:---| -| The 16 domain-separation tags | `DESIGN_cont.md` §13 | `SDK.md` §4.8, referenced by `SELECTIVE_DISCLOSURE.md` | -| Sponge lane assignment (lane 0 = amount mask, lane 1 = balance/allowance/randomness) | `DESIGN.md` §2.5 | `SDK.md` §4.3 and §11 | +| Domain-separation tag assignments | `DESIGN_cont.md` §13 | `SDK.md` §4.8, referenced by `SELECTIVE_DISCLOSURE.md` | +| Sponge lane assignment | `DESIGN.md` §2.5 | `SDK.md` §4.3 and §11 | | Per-circuit scalar-multiplication counts | `DESIGN_cont.md` §10.3 | `OVERVIEW.md` | -| Checkpoint event set (`Withdraw`, `Transfer` sender side, `SetSpender`, `RevokeSpender`) | `DESIGN.md` §5.2 | `INDEXER.md`, `SDK.md` | +| Checkpoint event set | `DESIGN.md` §5.2 | `INDEXER.md`, `SDK.md` | | Replay-window anchor `T₀` | `DESIGN.md` §5.2 | `INDEXER.md`, `OVERVIEW.md` | -The tags are a cross-language wire contract. `DESIGN_cont.md` §13 assigns all sixteen and no other document may; `circuits/lib/src/lib.nr` implements only 1–13, because 14 is derived off-circuit and 15–16 belong to the off-chain disclosure layer. That gap is intentional. Changing any assigned value is a new deployment, not an upgrade. +The tags are a cross-language wire contract. `DESIGN_cont.md` §13 is their only authoritative source: it assigns every value, and it states which subset `circuits/lib/src/lib.nr` implements and why the remainder are absent. Changing any assigned value is a new deployment, not an upgrade. ### Editing rules - **Cite, do not restate.** Every drift bug in the recent history came from a second copy of something. When tempted to summarise a neighbouring section, write `§N` instead. - **Match the file's math style, not a global one.** `DESIGN*.md` and `SELECTIVE_DISCLOSURE.md` use `$$…$$` with backslash-escaped subscripts (`$$\mathbb{F}\_r$$`); `SDK.md` uses `$$…$$` unescaped; `OVERVIEW.md` uses single `$…$`; `INDEXER.md` and `COMPLIANCE.md` use backticked ASCII and no LaTeX. -- **Symbols are a maintained namespace.** `sk`/`vk`/`dvk_i`/`PVK`/`Y`; `r_e` and `R_e = r_e·H`; `σ` (operation salt) is distinct from `σ_a` (per-delegation allowance salt); tilde means ciphertext; `C_spend` / `C_receive` / `C_transfer` / `C_a`. An audit finding once required renaming the `tx` subscript to `transfer` across the whole module. +- **Symbols are a maintained namespace.** `sk`/`vk`/`dvk_i`/`PVK`/`Y`; `r_e` and `R_e = r_e·H`; `σ` (operation salt) is distinct from `σ_a` (per-delegation allowance salt); tilde means ciphertext; `C_spend` / `C_receive` / `C_transfer` / `C_a`; `r_a` is `C_a`'s blinding and `r_a'` the post-transfer one. An audit finding once required renaming the `tx` subscript to `transfer` across the whole module. - Prose is full-width — no hard wrapping. One paragraph or list item per line. diff --git a/packages/tokens/src/confidential/circuits/CLAUDE.md b/packages/tokens/src/confidential/circuits/CLAUDE.md index 74060ec76..a1363bb9d 100644 --- a/packages/tokens/src/confidential/circuits/CLAUDE.md +++ b/packages/tokens/src/confidential/circuits/CLAUDE.md @@ -26,7 +26,7 @@ It appears in all fourteen `Nargo.toml` files and looks like a mistake. Nargo re ### Do not prune unused public inputs -`_acct_f` in `register/src/main.nr` is referenced by no gate and looks like dead code. It is the replay binding: UltraHonk absorbs every public input into the transcript, so a proof produced for one account fails when the contract assembles the blob for another. Removing it lets anyone replay a legitimate registration's published proof and payload to mint duplicate-key accounts. Each operation circuit declares its exact public-input count in a header comment — withdraw 15, revoke_spender 19, transfer / set_spender / spender_transfer 24 — and the count is part of the contract with the on-chain assembler. +`_acct_f` in `register/src/main.nr` is referenced by no gate and looks like dead code. It is the replay binding: UltraHonk absorbs every public input into the transcript, so a proof produced for one account fails when the contract assembles the blob for another. Removing it lets anyone replay a legitimate registration's published proof and payload to mint duplicate-key accounts. Each operation circuit declares its exact public-input count in a header comment — withdraw 16, revoke_spender 19, transfer / spender_transfer 25, set_spender 26 — and the count is part of the contract with the on-chain assembler. ### Package names are load-bearing @@ -34,9 +34,13 @@ Directory `transfer/` is package `circuit_transfer`; `gadgets/commit/` is `gadge ### Never hash raw -`poseidon_with_domain` is the only Poseidon entry point in `lib/src/lib.nr`; calling the underlying hash directly is a violation of the library contract. The domain tag is always the first absorbed element. The numeric tag values are the cross-language contract with the SDK — see `../CLAUDE.md` and `docs/DESIGN_cont.md` §13, which is their only authoritative source. +`poseidon_with_domain` is the only Poseidon entry point in `lib/src/lib.nr`; calling the underlying hash directly is a violation of the library contract. The domain tag is always the first absorbed element. The numeric tag values are the cross-language contract with the SDK — see `../CLAUDE.md` and `../docs/DESIGN_cont.md` §13, which is their only authoritative source. -Sponge parameters: width 4, rate 3, capacity 1, `iv = len · 2^64`. Empty input still applies the squeeze permutation, matching the on-chain sponge. The two-lane squeeze order is fixed — **index 0 is always an amount mask, index 1 always a balance/allowance/randomness mask** — and `encrypt_auditor_sender_balance` deliberately takes the second lane so a balance checkpoint can never share a pad with an amount ciphertext under `(r_e, σ)` reuse. `sponge_squeeze_2(d,s,σ)[0]` must stay equal to `poseidon_with_domain(d,[s,σ])`. +Sponge parameters, the canonical lane assignment, and the mode-exclusivity rule that follows from a single-block absorb are normative in `../docs/DESIGN.md` §2.5; the Noir sponge must match it exactly. The obligations that section places on this code: `sponge_squeeze_2(d,s,σ)[0]` must stay equal to `poseidon_with_domain(d,[s,σ])`, and `sponge_squeeze_3(d,s,σ)[0..2]` must stay equal to `sponge_squeeze_2(d,s,σ)` — which is why `sponge_squeeze_2` is defined as the prefix of `sponge_squeeze_3` rather than as a second permutation. A divergence in either silently changes every existing mask. + +`AUDITOR_SENDER` is squeezed three-wide by every circuit that escrows `lane[2]` and two-wide only by RevokeSpender (V_a3); `AUDITOR_RECIPIENT` is always two-wide; every other tag goes through `poseidon_with_domain`. Widening or narrowing a channel is a spec change, not a refactor. + +`lane[2]` carries **the blinding of a commitment the operation writes, never a key** — `r'` on W_a5 / T_a9 / S_a6, `r_a'` on O_a9. Tag 17 (`ESCROWED_ALLOWANCE_BLINDING_AUDITOR`) is the same idea off-sponge: SetSpender's `lane[2]` is already taken, so S14 escrows `r_a` under a single-output pad. Do not escrow `dvk_i` here: it is permanent per `(owner, spender)` and survives revoke-then-re-delegate, so one leaked ciphertext would open every allowance state for that pair, past and future (`../docs/DESIGN_cont.md` §8.5). ECDH must absorb both `S.x` and `S.y`; x-only extraction collapses `P` and `-P`. @@ -56,7 +60,7 @@ LC_ALL=C nargo info | grep '^|' | LC_ALL=C sort > constraints.baseline `LC_ALL=C` is mandatory on **both** sides of the pipe — byte order is the only ordering stable between macOS and the Ubuntu runner. The redirect overwrites the file's header comments; re-paste them, because CI's failure message asks for them. -Two non-obvious consequences: adding or removing a **gadget** changes the baseline even when no circuit logic changed, and the ACIR opcode counts are quoted in prose at `../docs/DESIGN_cont.md` §10.3 (Register 33, Withdraw 94, RevokeSpender 123, SetSpender 131, Transfer 133, SpenderTransfer 135). Nothing enforces that second copy — update it in the same PR. +Two non-obvious consequences: adding or removing a **gadget** changes the baseline even when no circuit logic changed, and the ACIR opcode counts are quoted in prose at `../docs/DESIGN_cont.md` §10.3 (Register 33, Withdraw 95, RevokeSpender 123, Transfer 134, SetSpender 135, SpenderTransfer 136). Nothing enforces that second copy — update it in the same PR. ### `vks/` @@ -76,7 +80,7 @@ Fixtures are not auto-generated. Changing a primitive is a three-step lockstep: 2. Update the matching `testdata/*.json` 3. Update the hardcoded expected values in the `fixtures_match_testdata` test in `lib/src/tests.nr` -`fixtures_match_testdata` is the in-Noir guard that fails CI. The sponge vectors are additionally hoisted into `global SPONGE_SQUEEZE_2_*` constants in the same file — a fourth site. +`fixtures_match_testdata` is the in-Noir guard that fails CI. The sponge vectors are additionally hoisted into `global SPONGE_SQUEEZE_2_*` / `SPONGE_SQUEEZE_3_*` constants in the same file — a fourth site. `address_to_field.json` is the exception. That derivation has no Noir implementation at all (circuits take `addr_f` as an opaque public input), so it is the one primitive with two independent implementations. Its guard is the Rust test `address_to_field_matches_testdata_vectors` in `../test.rs`, which **transcribes the hex values as string literals** rather than reading the JSON — update both together or neither. Its inputs are 56-character SEP-23 strkeys, and the lo/hi 28-byte limbs are little-endian. diff --git a/packages/tokens/src/confidential/circuits/constraints.baseline b/packages/tokens/src/confidential/circuits/constraints.baseline index fa1ae7894..0c7af34d5 100644 --- a/packages/tokens/src/confidential/circuits/constraints.baseline +++ b/packages/tokens/src/confidential/circuits/constraints.baseline @@ -29,19 +29,19 @@ | circuit_set_spender | decompose_hint | N/A | N/A | 30 | | circuit_set_spender | directive_invert | N/A | N/A | 9 | | circuit_set_spender | lte_hint | N/A | N/A | 33 | -| circuit_set_spender | main | Bounded { width: 4 } | 131 | 72 | +| circuit_set_spender | main | Bounded { width: 4 } | 135 | 72 | | circuit_spender_transfer | decompose_hint | N/A | N/A | 30 | | circuit_spender_transfer | directive_invert | N/A | N/A | 9 | | circuit_spender_transfer | lte_hint | N/A | N/A | 33 | -| circuit_spender_transfer | main | Bounded { width: 4 } | 135 | 72 | +| circuit_spender_transfer | main | Bounded { width: 4 } | 136 | 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 } | 133 | 72 | +| circuit_transfer | main | Bounded { width: 4 } | 134 | 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 } | 94 | 72 | +| circuit_withdraw | main | Bounded { width: 4 } | 95 | 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/lib/src/lib.nr b/packages/tokens/src/confidential/circuits/lib/src/lib.nr index 2091c9620..2b9240534 100644 --- a/packages/tokens/src/confidential/circuits/lib/src/lib.nr +++ b/packages/tokens/src/confidential/circuits/lib/src/lib.nr @@ -90,6 +90,10 @@ global POSEIDON2_IV_BASE: Field = 18446744073709551616; // 2^64 /// | `delta_aud_s` | 11 | `AUDITOR_SENDER` | /// | `delta_aud_r` | 12 | `AUDITOR_RECIPIENT` | /// | `delta_ecdh` | 13 | `ECDH_SHARED_SECRET` | +/// | `delta_esc_allow_r_aud` | 17 | `ESCROWED_ALLOWANCE_BLINDING_AUDITOR` | +/// +/// Tags 14-16 are deliberately absent here: 14 is derived off-circuit and +/// 15-16 belong to the off-chain disclosure layer. pub mod domain { /// Soroban Address compression into a single `F_r` Field: /// `address_to_field(a) = Poseidon2(ADDRESS, lo(a), hi(a))`. @@ -128,9 +132,21 @@ pub mod domain { /// Constraint S12, Section 7.11 (`delta_esc_dvk`). 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`). + /// (Section 2.5, Section 8.1). **Three lanes**, and the only tag squeezed + /// three-wide: lane[0] is the amount mask (where applicable), lane[1] the + /// balance/allowance checkpoint mask, lane[2] the commitment-blinding + /// escrow slot -- the new spendable blinding on checkpoints, the new + /// allowance blinding on spender transfers. Constraints W_a3 / T_a6 / + /// S_a3 / O_a6 (`delta_aud_s`). + /// + /// RevokeSpender (V_a3) still reads two lanes, so the opening the auditor + /// holds is not renewed there and lapses until the next checkpoint; the + /// two widths agree on every lane they share (see [`sponge_squeeze_3`]). + /// + /// The tag stays in a single sponge mode per Section 2.5: + /// `sponge_squeeze_2(d, s, sigma)[0]` is the same field element as + /// `poseidon_with_domain(d, [s, sigma])`, so a tag used in both modes + /// would collapse one onto the other. 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 @@ -141,6 +157,24 @@ pub mod domain { /// `s = Poseidon2(ECDH_SHARED_SECRET, S.x, S.y)`. /// Section 2.4 (`delta_ecdh`). See [`ecdh`]. pub global ECDH_SHARED_SECRET: Field = 13; + /// Auditor-side allowance-blinding escrow mask (owner-auditor ECDH): + /// `Poseidon2(ESCROWED_ALLOWANCE_BLINDING_AUDITOR, s_a_s, op_i)`. + /// Constraint S14, Section 8.5 (`delta_esc_allow_r_aud`). + /// + /// A separate tag from `ESCROWED_DELEGATION_VIEWING_KEY` = 10 even though + /// reuse would be secure -- the two shared scalars differ, since `Y_op` + /// and `K_aud_s` are independent points. Sharing the tag would drop one + /// of the three legs of Section 5.3's "why reusing r_e is safe" argument; + /// a fresh tag keeps that argument intact. + /// + /// Unlike the tags above it, this one shares its shared scalar with + /// `AUDITOR_SENDER` (both key off `s_a_s`). What separates them is that + /// they are different tags: 17 is absorbed here, 11 there, and the tag is + /// the first absorbed element, so no `(s_a_s, x)` pair yields the same + /// field element under both. Section 2.5 mode exclusivity is a separate, + /// per-tag hygiene rule that each of the two satisfies on its own -- this + /// one is only ever single-output, tag 11 only ever multi-lane. + pub global ESCROWED_ALLOWANCE_BLINDING_AUDITOR: Field = 17; } // ################## CORE PRIMITIVES ################## @@ -304,6 +338,29 @@ 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-side allowance-blinding escrow at `set_spender`. Constraint S14. +/// `r_a_tilde_aud_s = r_a + Poseidon2(ESC_ALLOW_R_AUD, s_a_s, op_i)` where +/// `s_a_s = ecdh(r_e, K_aud_s)` is the *owner-auditor* shared scalar. +/// +/// A single-output Poseidon rather than a further sponge lane, because at +/// `set_spender` lane[2] of the owner-auditor sponge is already taken by S_a6. +/// Costs one Poseidon2 permutation and zero scalar multiplications. `op_i` is +/// a per-`(owner, spender)` constant that separates pads across delegations, +/// not a nonce: the pad's freshness comes solely from `s_a_s`, which is fresh +/// per operation because `r_e` is, and `r_e` is fresh only because the salt is +/// (Section 5.3, Section 9.6). +/// +/// This is what gives the auditor the opening of `C_a`: `r_a` is the blinding +/// S6 derived and S7 committed to, so with the emitted `a_tilde_aud_s` the +/// auditor holds both halves of the allowance opening for the state this +/// operation writes (Section 8.5). The escrowed value is a per-state blinding, +/// not the key that generates every state: an auditor key that never saw this +/// event cannot derive it, and one that saw only this event learns nothing +/// about any other. +pub fn encrypt_esc_allow_r_auditor(r_a: Field, s_a_s: Field, op_i: Field) -> Field { + r_a + poseidon_with_domain(domain::ESCROWED_ALLOWANCE_BLINDING_AUDITOR, [s_a_s, op_i]) +} + // ################## AUDITOR-CHANNEL SPONGE ################## /// Poseidon2 sponge with two squeezes. Absorbs `(d, s, sigma)` -- where @@ -316,27 +373,43 @@ pub fn encrypt_esc_dvk(dvk: Field, s: Field, op_i: Field) -> Field { /// 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 SpenderTransfer (O_a2, O_a6). +/// Used by the recipient-auditor channel of Transfer (T_a2) and +/// SpenderTransfer (O_a2), and by RevokeSpender's sender-auditor channel +/// (V_a3), which escrows nothing in lane[2]. Every other sender-auditor +/// channel reads [`sponge_squeeze_3`]. +/// +/// Defined as the two-lane prefix of [`sponge_squeeze_3`] so the two cannot +/// drift apart: a change to the IV or the absorb applies to both by +/// construction rather than by test. pub fn sponge_squeeze_2(d: Field, s: Field, sigma: Field) -> [Field; 2] { - let iv: Field = 3 * POSEIDON2_IV_BASE; - let state = poseidon2_permutation([d, s, sigma, iv], 4); + let state = sponge_squeeze_3(d, s, sigma); [state[0], state[1]] } -/// Sender-auditor encrypted balance checkpoint for circuits whose auditor -/// block emits a single ciphertext (Withdraw W_a3/W_a4). +/// Poseidon2 sponge with three squeezes. Absorbs `(d, s, sigma)` and returns +/// the first three rate-elements of the post-permutation state in order. +/// +/// Implements `SpongeSqueeze_3(delta, s, sigma)` from Section 2.5. As in +/// [`sponge_squeeze_2`], lane[0] is the amount mask and lane[1] the balance / +/// allowance / randomness mask. **The lane[2] slot is the sender-auditor +/// blinding escrow**: it carries the blinding of a commitment the operation +/// writes, and never a key -- the new spendable blinding on the three +/// checkpoint operations (W_a5 / T_a9 / S_a6), the new allowance blinding on +/// spender transfers (O_a9). /// -/// `b_tilde_aud_s = v_new + sponge_squeeze_2(AUDITOR_SENDER, s_a_s, sigma)[1]`, -/// where `s_a_s` is the sender-auditor ECDH shared-secret scalar produced by -/// [`ecdh`]. -/// The pad is the sponge's *second* squeeze -- the slot every two-ciphertext -/// sender channel (Transfer T_a6-T_a8, SetSpender S_a3-S_a5, RevokeSpender -/// V_a3-V_a5, SpenderTransfer O_a6-O_a8) uses for its balance/allowance mask. -/// The first squeeze is reserved for amount masks, so a balance checkpoint -/// never shares a pad with an amount ciphertext even if a `(r_e, sigma)` pair -/// is mistakenly reused across operations (the pair must be unique per proof; -/// DESIGN Section 2.5). -pub fn encrypt_auditor_sender_balance(v_new: Field, s_a_s: Field, sigma: Field) -> Field { - v_new + sponge_squeeze_2(domain::AUDITOR_SENDER, s_a_s, sigma)[1] +/// `sponge_squeeze_3(d, s, sigma)[0..2] == sponge_squeeze_2(d, s, sigma)`, by +/// construction: the absorb fits one rate-3 block, so all three outputs are +/// served from the same permutation and the two functions differ only in how +/// much of the state they expose. Widening therefore changes no existing mask +/// value and costs no extra constraints. The capacity lane at `state[3]` stays +/// untouched. +/// +/// The blindings lane[2] carries still belong to different commitment roles -- +/// spendable on the checkpoints, allowance on spender transfers -- so the +/// uniformity is documentary, not a claim that a recipient can substitute one +/// for the other. Each is scoped to the operation that emitted it. +pub fn sponge_squeeze_3(d: Field, s: Field, sigma: Field) -> [Field; 3] { + let iv: Field = 3 * POSEIDON2_IV_BASE; + let state = poseidon2_permutation([d, s, sigma, iv], 4); + [state[0], state[1], state[2]] } diff --git a/packages/tokens/src/confidential/circuits/lib/src/tests.nr b/packages/tokens/src/confidential/circuits/lib/src/tests.nr index 91f12440a..1be685b2c 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_non_identity, commit, derive_allow_r, derive_spend_r, derive_transfer_blind, domain, - dvk_from_vk_op, ecdh, encrypt_allowance, encrypt_amount, encrypt_auditor_sender_balance, - encrypt_balance, encrypt_esc_dvk, G, H, poseidon_with_domain, pvk_from_vk, scalar_mul, sponge, - sponge_squeeze_2, vk_from_sk, + dvk_from_vk_op, ecdh, encrypt_allowance, encrypt_amount, + encrypt_balance, encrypt_esc_allow_r_auditor, encrypt_esc_dvk, G, H, poseidon_with_domain, + pvk_from_vk, scalar_mul, sponge, sponge_squeeze_2, sponge_squeeze_3, vk_from_sk, }; use std::embedded_curve_ops::EmbeddedCurvePoint; use std::hash::{derive_generators, pedersen_commitment, poseidon2_permutation}; @@ -207,12 +207,6 @@ 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); - - // The auditor balance checkpoint pads with the sponge's second squeeze - // (the balance slot), not the single-squeeze funnel. - let b_tilde_aud_s = encrypt_auditor_sender_balance(v, s, sigma); - let mask_aud_s = sponge_squeeze_2(domain::AUDITOR_SENDER, s, sigma)[1]; - assert(b_tilde_aud_s - mask_aud_s == v); } #[test] @@ -239,6 +233,55 @@ fn sponge_squeeze_2_first_matches_poseidon_with_domain() { assert(m2[0] == poseidon_with_domain(d, [s, sigma])); } +#[test] +fn sponge_squeeze_3_agrees_with_squeeze_2_on_shared_lanes() { + // The widening from two lanes to three must be observationally identical + // on lane[0] and lane[1] for every domain tag: both squeezes read the same + // post-permutation state, and the absorb occupies a single rate-3 block. + // This is what lets `delta_aud_s` be widened everywhere it appears + // without renumbering a single existing constraint. + let s: Field = 0x12345; + let sigma: Field = 0x6789; + for d in [domain::AUDITOR_SENDER, domain::AUDITOR_RECIPIENT] { + let m2 = sponge_squeeze_2(d, s, sigma); + let m3 = sponge_squeeze_3(d, s, sigma); + assert(m3[0] == m2[0]); + assert(m3[1] == m2[1]); + } +} + +#[test] +fn sponge_squeeze_3_outputs_distinct() { + // The lane[2] mask comes from rate position state[2] and must differ + // from both others. A collision would let an observer who + // recovers the balance mask also recover the secret-escrow pad -- and + // with it the account's spendable blinding, which is exactly what the + // slot exists to keep between the sender and its auditor. + let m = sponge_squeeze_3(domain::AUDITOR_SENDER, 0x42, 0x07); + assert(m[0] != m[1]); + assert(m[0] != m[2]); + assert(m[1] != m[2]); +} + +#[test] +fn esc_allow_r_auditor_is_domain_separated_from_the_spender_escrow() { + // Tags 10 and 17 escrow different plaintexts -- the delegation key to the + // spender, the allowance blinding to the owner's auditor -- but both are + // additive pads over the same shape, so a shared tag would let a + // coincidence of inputs collapse one onto the other. Reusing one tag for + // both would be secure in practice, since the shared scalars differ (Y_op + // and K_aud_s are independent points), but the separation is what keeps + // Section 5.3's r_e-reuse argument whole, so it must be observable here. + let x: Field = 0xd0d0; + let s: Field = 0x12345; + let op_i: Field = 0xabcd; + assert(encrypt_esc_dvk(x, s, op_i) != encrypt_esc_allow_r_auditor(x, s, op_i)); + + // Both are additive one-time-pad encryptions and must round-trip. + let mask = poseidon_with_domain(domain::ESCROWED_ALLOWANCE_BLINDING_AUDITOR, [s, op_i]); + assert(encrypt_esc_allow_r_auditor(x, s, op_i) - mask == x); +} + #[test] fn sponge_squeeze_2_outputs_distinct() { // The two squeezed masks come from different rate positions of the same @@ -346,10 +389,6 @@ fn print_fixtures() { let esc = encrypt_esc_dvk(dvk, s_ecdh, op_i); println(f"encrypt_esc_dvk = {esc}"); - // 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]; @@ -364,6 +403,19 @@ fn print_fixtures() { println(f"sponge_squeeze_2_AUDITOR_RECIPIENT_0 = {ss2_r0}"); println(f"sponge_squeeze_2_AUDITOR_RECIPIENT_1 = {ss2_r1}"); + // sponge_squeeze_3(AUDITOR_SENDER, s, sigma) -- (amount, balance, escrow) + let ss3_s = sponge_squeeze_3(domain::AUDITOR_SENDER, s_ecdh, sigma); + let ss3_s0 = ss3_s[0]; + let ss3_s1 = ss3_s[1]; + let ss3_s2 = ss3_s[2]; + println(f"sponge_squeeze_3_AUDITOR_SENDER_0 = {ss3_s0}"); + println(f"sponge_squeeze_3_AUDITOR_SENDER_1 = {ss3_s1}"); + println(f"sponge_squeeze_3_AUDITOR_SENDER_2 = {ss3_s2}"); + + // encrypt_esc_allow_r_auditor(r_a, s, op_i) + let esc_aud = encrypt_esc_allow_r_auditor(r_a, s_ecdh, op_i); + println(f"encrypt_esc_allow_r_auditor = {esc_aud}"); + // poseidon_with_domain(VK, [sk, addr_f]) -- direct funnel access, equals vk_from_sk let pwd = poseidon_with_domain(domain::VIEWING_KEY, [sk, addr_f]); println(f"poseidon_with_domain_VK_2 = {pwd}"); @@ -449,21 +501,32 @@ fn fixtures_match_testdata() { assert(ss2_r[0] == SPONGE_SQUEEZE_2_AUDITOR_RECIPIENT_0); assert(ss2_r[1] == SPONGE_SQUEEZE_2_AUDITOR_RECIPIENT_1); - // encrypt_auditor_sender_balance pads with the sponge's *second* - // squeeze -- the balance slot, matching the two-ciphertext sender - // channels (Transfer T_a6). The first squeeze stays reserved for amount - // masks so the checkpoint pad can never collide with an amount pad even - // under (r_e, sigma) reuse. Pin against the squeeze_2 vector so the - // helper and the raw sponge can't drift apart. - assert( - encrypt_auditor_sender_balance(v, s_ecdh, sigma) - == v + SPONGE_SQUEEZE_2_AUDITOR_SENDER_1, - ); + // The lane[0] output is the single-squeeze funnel: the absorb fits one + // rate-3 block, so a tag used in both modes would collapse one onto the + // other (Section 2.5 mode exclusivity). assert( poseidon_with_domain(domain::AUDITOR_SENDER, [s_ecdh, sigma]) == SPONGE_SQUEEZE_2_AUDITOR_SENDER_0, ); + // The three-lane squeeze must agree with the two-lane one on lane[0] + // and lane[1]: the absorb fits one rate-3 block, so both are served from + // the same permutation. If this diverges, widening the sender-auditor + // channel silently changed every existing mask and every published + // ciphertext became undecryptable. + let ss3_s = sponge_squeeze_3(domain::AUDITOR_SENDER, s_ecdh, sigma); + assert(ss3_s[0] == SPONGE_SQUEEZE_2_AUDITOR_SENDER_0); + assert(ss3_s[1] == SPONGE_SQUEEZE_2_AUDITOR_SENDER_1); + assert(ss3_s[2] == SPONGE_SQUEEZE_3_AUDITOR_SENDER_2); + let ss3_r = sponge_squeeze_3(domain::AUDITOR_RECIPIENT, s_ecdh, sigma); + assert(ss3_r[0] == SPONGE_SQUEEZE_2_AUDITOR_RECIPIENT_0); + assert(ss3_r[1] == SPONGE_SQUEEZE_2_AUDITOR_RECIPIENT_1); + + assert( + encrypt_esc_allow_r_auditor(derive_allow_r(dvk, sigma_a), s_ecdh, op_i) + == 0x2d97a37cb8d103eb9231907e292a954e22e7fa56165a6b3b9f639252557bf7a2, + ); + // poseidon_with_domain funnel is the same operation that backs vk_from_sk. assert(poseidon_with_domain(domain::VIEWING_KEY, [sk, addr_f]) == vk); } @@ -479,6 +542,10 @@ global SPONGE_SQUEEZE_2_AUDITOR_RECIPIENT_0: Field = 0x0801e6d7184cea11e6225fcfb4e88a90674cb085da8ebc04918c57cc44b9c25d; global SPONGE_SQUEEZE_2_AUDITOR_RECIPIENT_1: Field = 0x131187e2ac296c1c54c2be2a5cbe614644f01c754108ce25359212d67b36ecc3; +// The lane[2] output of the same permutation -- the sender-auditor +// secret-escrow slot. Mirrored in `testdata/sponge_squeeze_3.json`. +global SPONGE_SQUEEZE_3_AUDITOR_SENDER_2: Field = + 0x1c4e67103733b87be6672e0af91fc5416b6d582bf99d5a0f78ea6aaf5fab20b0; #[test] fn derive_helpers_deterministic_and_distinct() { 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 deleted file mode 100644 index 2d98d5ead..000000000 --- a/packages/tokens/src/confidential/circuits/lib/testdata/encrypt_auditor_sender_balance.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "primitive": "encrypt_auditor_sender_balance", - "design_doc_refs": ["Constraint W_a3", "Constraint W_a4"], - "description": "Sender-auditor encrypted balance checkpoint via the second squeeze of the sender channel sponge: b_tilde_aud_s = v_new + SpongeSqueeze_2(AUDITOR_SENDER, s_a_s, sigma)[1]. Used by Withdraw, the only sender-side proof whose auditor block emits a single ciphertext. The second squeeze is the balance slot shared with Transfer/SetSpender/RevokeSpender; the first-squeeze amount slot is left unused so the checkpoint pad never coincides with an amount pad, even under (r_e, sigma) reuse.", - "vectors": [ - { - "inputs": { - "v_new": "0x3e8", - "s_a_s": "0x12345", - "sigma": "0x01" - }, - "output": "0x27f3739a132c6353cd5af3edac0ac75faf7fc606acb61367774e4f764ec17f5f" - } - ] -} diff --git a/packages/tokens/src/confidential/circuits/lib/testdata/encrypt_esc_allow_r_auditor.json b/packages/tokens/src/confidential/circuits/lib/testdata/encrypt_esc_allow_r_auditor.json new file mode 100644 index 000000000..ef158fcd8 --- /dev/null +++ b/packages/tokens/src/confidential/circuits/lib/testdata/encrypt_esc_allow_r_auditor.json @@ -0,0 +1,15 @@ +{ + "primitive": "encrypt_esc_allow_r_auditor", + "design_doc_refs": ["Section 8.5", "Constraint S14"], + "description": "Auditor-side allowance-blinding escrow: r_a_tilde_aud_s = r_a + Poseidon2(ESC_ALLOW_R_AUD, s_a_s, op_i) where s_a_s = ecdh(r_e, K_aud_s) is the owner-auditor shared scalar. Same additive-pad shape as encrypt_esc_dvk but a different plaintext under a different tag (17 rather than 10) and a different shared scalar: this one hands the owner's auditor the blinding of the allowance commitment the operation writes, the other hands the spender the delegation viewing key. Paired with the emitted v_tilde_aud_s it is the full opening of C_a for that one state -- not the key from which every state's opening derives.", + "vectors": [ + { + "inputs": { + "r_a": "0x0afda20e062a277afc0c86e31ea22597ee2c094b1b9cd08b8d8a2a212a8af1ee", + "s_a_s": "0x12345", + "op_i": "0xabcd" + }, + "output": "0x2d97a37cb8d103eb9231907e292a954e22e7fa56165a6b3b9f639252557bf7a2" + } + ] +} 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 e8aeca281..981da5887 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,7 +1,7 @@ { "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, 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 equals poseidon_with_domain(d, [s, sigma]) on the same inputs (single permutation, same state[0]).", + "design_doc_refs": ["Section 2.5", "Constraint T_a2", "Constraint O_a2", "Constraint V_a3"], + "description": "Poseidon2 sponge with two squeezes (one absorb of (d, s, 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 recipient-auditor channel uses d = AUDITOR_RECIPIENT (12) and is always read two-wide; the sender-auditor channel d = AUDITOR_SENDER (11) is read two-wide only by RevokeSpender and three-wide elsewhere (see sponge_squeeze_3), of which these two lanes are the prefix. The first squeeze equals poseidon_with_domain(d, [s, sigma]) on the same inputs (single permutation, same state[0]).", "vectors": [ { "inputs": { "d": "0x0b", "s": "0x12345", "sigma": "0x01" }, diff --git a/packages/tokens/src/confidential/circuits/lib/testdata/sponge_squeeze_3.json b/packages/tokens/src/confidential/circuits/lib/testdata/sponge_squeeze_3.json new file mode 100644 index 000000000..1b8c773dc --- /dev/null +++ b/packages/tokens/src/confidential/circuits/lib/testdata/sponge_squeeze_3.json @@ -0,0 +1,15 @@ +{ + "primitive": "sponge_squeeze_3", + "design_doc_refs": ["Section 2.5", "Constraint W_a3", "Constraint T_a6", "Constraint S_a3", "Constraint O_a6"], + "description": "Poseidon2 sponge with three squeezes (one absorb of (d, s, sigma), permute, output (state[0], state[1], state[2])). Same absorb and same permutation as sponge_squeeze_2, so lanes 0 and 1 are bit-identical to that primitive's outputs on the same inputs -- verify that agreement before trusting an implementation. Canonical lane order: 0 = amount mask, 1 = balance / allowance / per-transfer randomness mask, 2 = the sender-auditor blinding-escrow slot, which carries the blinding of a commitment the operation writes -- the new spendable blinding on the three checkpoint operations (Withdraw, Transfer sender side, SetSpender), the new allowance blinding on spender transfers -- and never a key. Only the sender-auditor channel, d = AUDITOR_SENDER (11), is squeezed three-wide; the recipient-auditor channel, d = AUDITOR_RECIPIENT (12), stays at two lanes.", + "vectors": [ + { + "inputs": { "d": "0x0b", "s": "0x12345", "sigma": "0x01" }, + "output": [ + "0x2787d1e01bbca7828e13e9b2b3fa11cfcfe9c3d2b121b9e17543146822fe1feb", + "0x27f3739a132c6353cd5af3edac0ac75faf7fc606acb61367774e4f764ec17b77", + "0x1c4e67103733b87be6672e0af91fc5416b6d582bf99d5a0f78ea6aaf5fab20b0" + ] + } + ] +} diff --git a/packages/tokens/src/confidential/circuits/set_spender/src/main.nr b/packages/tokens/src/confidential/circuits/set_spender/src/main.nr index 8fec9c36d..8ec7148bb 100644 --- a/packages/tokens/src/confidential/circuits/set_spender/src/main.nr +++ b/packages/tokens/src/confidential/circuits/set_spender/src/main.nr @@ -1,7 +1,7 @@ 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, + dvk_from_vk_op, ecdh, encrypt_allowance, encrypt_balance, encrypt_esc_allow_r_auditor, + encrypt_esc_dvk, scalar_mul, sponge_squeeze_3, vk_from_sk, }; use std::embedded_curve_ops::EmbeddedCurvePoint; @@ -59,6 +59,34 @@ mod tests; // + Poseidon2(delta_esc_dvk, s_esc, op_i) // Masked dvk_i for the // spender. +// S14 r_a_tilde_aud_s = r_a +// + Poseidon2(delta_esc_allow_r_aud, s_{a,s}, op_i) +// Auditor-side escrow +// of the allowance +// blinding S6 derived +// and S7 committed to. +// With v_tilde_aud_s +// (S_a4) this hands the +// owner's auditor the +// opening of the C_a +// this operation writes +// (Section 8.5) -- +// one state, not the +// key that generates +// every state. +// A separate single- +// output Poseidon +// rather than a sponge +// lane, because lane[2] +// of this channel is +// taken by S_a6. Costs +// one Poseidon and zero +// scalar muls; r_a is +// already a witness +// (S6). +// Unconditional -- there +// is no delegation that +// can exist without it. // S13 r_e != 0 Rules out R_e = O, // S_{a,s} = O, and a // trivial escrow @@ -73,15 +101,20 @@ mod tests; // key; see S12(a)). // S_a2 s_{a,s} = ecdh(r_e, K_aud_s) Owner-auditor ECDH // shared scalar. -// S_a3 (m_v, m_b) = SpongeSqueeze_2(delta_aud_s, +// S_a3 (m_v, m_b, m_r) +// = SpongeSqueeze_3(delta_aud_s, // s_{a,s}, sigma) Owner-channel -// sponge: two masks. +// sponge: three 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. +// S_a6 r_tilde_aud_s = r' + m_r Owner-auditor escrow +// of the new spendable +// blinding, over S9's +// r'. // // Point-validation doctrine (Section 10.8) // ---------------------------------------- @@ -96,7 +129,7 @@ mod tests; // explicitly validates it on-curve AND non-identity before the auditor block // runs. // -// Public inputs (24 fields, in design-doc canonical order) +// Public inputs (26 fields, in design-doc canonical order) // -------------------------------------------------------- // Idx Param Symbol Source / Note // --- ----- ------ ----------------------------- @@ -140,6 +173,18 @@ mod tests; // 23 b_tilde_aud_s b_tilde_aud_s Prover-supplied owner-auditor // encrypted balance checkpoint; // emitted. +// 24 r_tilde_aud_s r_tilde_aud_s Prover-supplied owner-auditor +// escrow of the new spendable +// blinding; emitted. +// 25 r_a_tilde_aud_s r_a_tilde_aud_s Prover-supplied owner-auditor +// escrow of the new allowance +// blinding; emitted. Distinct +// from r_tilde_aud_s (index +// 24), which escrows the +// spendable blinding, and from +// escrowed_dvk_cipher, which +// hands the spender the +// delegation key. // // Private witnesses // ----------------- @@ -180,6 +225,8 @@ fn main( r_e_y: pub Field, v_tilde_aud_s: pub Field, b_tilde_aud_s: pub Field, + r_tilde_aud_s: pub Field, + r_a_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}, @@ -263,12 +310,23 @@ fn main( // S_a2 (owner-auditor shared-secret scalar) let s_a_s = 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, sigma); + // S_a3 (owner-channel masks: amount, balance, then the blinding-escrow + // slot) + let m_s = sponge_squeeze_3(domain::AUDITOR_SENDER, s_a_s, 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); + + // S_a6 + assert(r_new + m_s[2] == r_tilde_aud_s); + + // S14 -- reuses s_a_s from S_a2, so no new scalar multiplication. Runs + // after the auditor block so the shared scalar is already in scope. The + // escrowed value is S6's r_a, the very blinding S7 committed under, so the + // auditor's opening matches the C_a this proof writes on-chain. + let r_a_tilde_aud_s_derived = encrypt_esc_allow_r_auditor(r_a, s_a_s, op_i); + assert(r_a_tilde_aud_s_derived == r_a_tilde_aud_s); } diff --git a/packages/tokens/src/confidential/circuits/set_spender/src/tests.nr b/packages/tokens/src/confidential/circuits/set_spender/src/tests.nr index 082a71041..2596c0919 100644 --- a/packages/tokens/src/confidential/circuits/set_spender/src/tests.nr +++ b/packages/tokens/src/confidential/circuits/set_spender/src/tests.nr @@ -1,7 +1,8 @@ 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, + encrypt_balance, encrypt_esc_allow_r_auditor, encrypt_esc_dvk, H, scalar_mul, + sponge_squeeze_3, vk_from_sk, }; use std::embedded_curve_ops::EmbeddedCurvePoint; @@ -83,6 +84,13 @@ global ESCROWED_DVK_CIPHER: Field = 0x1ef89e3bd93ec1afed649d150e9d0ff5df72002759e260cb66b06604a4d7ebe0; global V_TILDE_AUD_S: Field = 0x0d0a3aa77960977737f4355093d4672ea9f95556fabf63bda0913ebef52c4397; +// lane[2] spendable-blinding escrow (S_a6) and the auditor-side allowance- +// blinding escrow (S14). Both are blindings of commitments this operation +// writes -- C_spend' and C_a respectively. +global R_TILDE_AUD_S: Field = + 0x12bae1b1e1b12493e37d427d3ee28b481c37f7f456c4ab0c022b8ba6262ca57f; +global R_A_TILDE_AUD_S: Field = + 0x28991b7df68413693ffe3fd0d47b0793e65f3c6edb6907a7a152fe80d158927f; global B_TILDE_AUD_S: Field = 0x288ff48898f218dbba2a5c9546d0986af3257a4a539e23e4f35c404d6720c07c; @@ -109,9 +117,11 @@ fn print_fixtures() { let k_aud_s = scalar_mul(K_AUD_S_SCALAR, H); let r_e_pt = scalar_mul(R_E, H); let s_a_s = ecdh(R_E, k_aud_s); - let m_s = sponge_squeeze_2(domain::AUDITOR_SENDER, s_a_s, SIGMA); + let m_s = sponge_squeeze_3(domain::AUDITOR_SENDER, s_a_s, SIGMA); let v_tilde_aud_s = V_A + m_s[0]; let b_tilde_aud_s = V_NEW + m_s[1]; + let r_tilde_aud_s = r_new + m_s[2]; + let r_a_tilde_aud_s = encrypt_esc_allow_r_auditor(r_a, s_a_s, OP_I); let yopx = y_op.x; let yopy = y_op.y; @@ -138,6 +148,8 @@ fn print_fixtures() { 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}"); + println(f"R_TILDE_AUD_S = {r_tilde_aud_s}"); + println(f"R_A_TILDE_AUD_S = {r_a_tilde_aud_s}"); } #[test] @@ -172,13 +184,15 @@ fn set_spender_fixtures_match_lib() { fn set_spender_auditor_fixtures_match_lib() { let vk = vk_from_sk(SK, ADDR_F); let dvk = dvk_from_vk_op(vk, OP_I); + let r_a = derive_allow_r(dvk, SIGMA_A); 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 = ecdh(R_E, k_aud_s); - let m_s = sponge_squeeze_2(domain::AUDITOR_SENDER, s_a_s, SIGMA); + let m_s = sponge_squeeze_3(domain::AUDITOR_SENDER, s_a_s, SIGMA); + let r_new = derive_spend_r(vk, SIGMA); assert(k_aud_s.x == K_AUD_S_X); assert(k_aud_s.y == K_AUD_S_Y); @@ -187,9 +201,11 @@ fn set_spender_auditor_fixtures_match_lib() { 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); + assert(r_new + m_s[2] == R_TILDE_AUD_S); + assert(encrypt_esc_allow_r_auditor(r_a, s_a_s, OP_I) == R_A_TILDE_AUD_S); } -// `run_main` accepts all 24 public inputs as parameters -- nothing baked in. +// `run_main` accepts every public input as a parameter -- 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 @@ -224,6 +240,8 @@ fn run_main( r_e_y_in: Field, v_tilde_aud_s_in: Field, b_tilde_aud_s_in: Field, + r_tilde_aud_s_in: Field, + r_a_tilde_aud_s_in: Field, ) { main( sk_in, @@ -255,6 +273,8 @@ fn run_main( r_e_y_in, v_tilde_aud_s_in, b_tilde_aud_s_in, + r_tilde_aud_s_in, + r_a_tilde_aud_s_in, ); } @@ -289,6 +309,8 @@ fn run_fixture() { R_E_Y, V_TILDE_AUD_S, B_TILDE_AUD_S, + R_TILDE_AUD_S, + R_A_TILDE_AUD_S, ); } @@ -316,7 +338,7 @@ fn full_allowance_escrow() { 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 = ecdh(R_E, k_aud_s); - let m_s = sponge_squeeze_2(domain::AUDITOR_SENDER, s_a_s, SIGMA); + let m_s = sponge_squeeze_3(domain::AUDITOR_SENDER, s_a_s, SIGMA); run_main( SK, V, @@ -347,6 +369,8 @@ fn full_allowance_escrow() { R_E_Y, V + m_s[0], 0 + m_s[1], + r_new + m_s[2], + encrypt_esc_allow_r_auditor(r_a, s_a_s, OP_I), ); } @@ -369,7 +393,7 @@ fn rejects_under_funded_escrow() { 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 = ecdh(R_E, k_aud_s); - let m_s = sponge_squeeze_2(domain::AUDITOR_SENDER, s_a_s, SIGMA); + let m_s = sponge_squeeze_3(domain::AUDITOR_SENDER, s_a_s, SIGMA); run_main( SK, V, @@ -400,6 +424,8 @@ fn rejects_under_funded_escrow() { R_E_Y, v_a_too_large + m_s[0], (V - v_a_too_large) + m_s[1], + r_new + m_s[2], + encrypt_esc_allow_r_auditor(r_a, s_a_s, OP_I), ); } @@ -421,7 +447,7 @@ fn rejects_v_out_of_range() { 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 = ecdh(R_E, k_aud_s); - let m_s = sponge_squeeze_2(domain::AUDITOR_SENDER, s_a_s, SIGMA); + let m_s = sponge_squeeze_3(domain::AUDITOR_SENDER, s_a_s, SIGMA); run_main( SK, v_huge, @@ -452,6 +478,8 @@ fn rejects_v_out_of_range() { R_E_Y, V_TILDE_AUD_S, v_new_huge + m_s[1], + r_new + m_s[2], + encrypt_esc_allow_r_auditor(derive_allow_r(dvk_from_vk_op(vk, OP_I), SIGMA_A), s_a_s, OP_I), ); } @@ -478,7 +506,7 @@ fn rejects_v_a_out_of_range() { 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 = ecdh(R_E, k_aud_s); - let m_s = sponge_squeeze_2(domain::AUDITOR_SENDER, s_a_s, SIGMA); + let m_s = sponge_squeeze_3(domain::AUDITOR_SENDER, s_a_s, SIGMA); run_main( SK, V, @@ -509,6 +537,8 @@ fn rejects_v_a_out_of_range() { R_E_Y, v_a_huge + m_s[0], v_new_huge + m_s[1], + r_new + m_s[2], + encrypt_esc_allow_r_auditor(r_a, s_a_s, OP_I), ); } @@ -561,6 +591,8 @@ fn rejects_wrong_sk() { R_E_Y, V_TILDE_AUD_S, B_TILDE_AUD_S, + R_TILDE_AUD_S, + R_A_TILDE_AUD_S, ); } @@ -578,7 +610,7 @@ fn rejects_wrong_balance_opening() { 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 = ecdh(R_E, k_aud_s); - let m_s = sponge_squeeze_2(domain::AUDITOR_SENDER, s_a_s, SIGMA); + let m_s = sponge_squeeze_3(domain::AUDITOR_SENDER, s_a_s, SIGMA); run_main( SK, v_bad, @@ -609,6 +641,8 @@ fn rejects_wrong_balance_opening() { R_E_Y, V_TILDE_AUD_S, v_new_bad + m_s[1], + r_new + m_s[2], + encrypt_esc_allow_r_auditor(derive_allow_r(dvk_from_vk_op(vk, OP_I), SIGMA_A), s_a_s, OP_I), ); } @@ -659,6 +693,8 @@ fn rejects_wrong_addr_f() { R_E_Y, V_TILDE_AUD_S, B_TILDE_AUD_S, + R_TILDE_AUD_S, + R_A_TILDE_AUD_S, ); } @@ -700,6 +736,8 @@ fn rejects_wrong_op_i() { R_E_Y, V_TILDE_AUD_S, B_TILDE_AUD_S, + R_TILDE_AUD_S, + R_A_TILDE_AUD_S, ); } @@ -736,6 +774,8 @@ fn rejects_tampered_c_a() { R_E_Y, V_TILDE_AUD_S, B_TILDE_AUD_S, + R_TILDE_AUD_S, + R_A_TILDE_AUD_S, ); } @@ -773,6 +813,8 @@ fn rejects_tampered_a_tilde() { R_E_Y, V_TILDE_AUD_S, B_TILDE_AUD_S, + R_TILDE_AUD_S, + R_A_TILDE_AUD_S, ); } @@ -809,6 +851,8 @@ fn rejects_tampered_c_spend_new() { R_E_Y, V_TILDE_AUD_S, B_TILDE_AUD_S, + R_TILDE_AUD_S, + R_A_TILDE_AUD_S, ); } @@ -845,6 +889,8 @@ fn rejects_tampered_b_tilde() { R_E_Y, V_TILDE_AUD_S, B_TILDE_AUD_S, + R_TILDE_AUD_S, + R_A_TILDE_AUD_S, ); } @@ -884,6 +930,8 @@ fn rejects_tampered_escrowed_dvk_r_x() { R_E_Y, V_TILDE_AUD_S, B_TILDE_AUD_S, + R_TILDE_AUD_S, + R_A_TILDE_AUD_S, ); } @@ -921,6 +969,8 @@ fn rejects_tampered_escrowed_dvk_cipher() { R_E_Y, V_TILDE_AUD_S, B_TILDE_AUD_S, + R_TILDE_AUD_S, + R_A_TILDE_AUD_S, ); } @@ -962,6 +1012,8 @@ fn rejects_wrong_y_op() { R_E_Y, V_TILDE_AUD_S, B_TILDE_AUD_S, + R_TILDE_AUD_S, + R_A_TILDE_AUD_S, ); } @@ -983,7 +1035,8 @@ fn rejects_r_e_zero() { 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_zero = ecdh(0, k_aud_s); - let m_s_zero = sponge_squeeze_2(domain::AUDITOR_SENDER, s_a_s_zero, SIGMA); + let m_s_zero = sponge_squeeze_3(domain::AUDITOR_SENDER, s_a_s_zero, SIGMA); + let r_new = derive_spend_r(vk, SIGMA); run_main( SK, V, @@ -1014,6 +1067,8 @@ fn rejects_r_e_zero() { 0, V_A + m_s_zero[0], V_NEW + m_s_zero[1], + r_new + m_s_zero[2], + encrypt_esc_allow_r_auditor(derive_allow_r(dvk, SIGMA_A), s_a_s_zero, OP_I), ); } @@ -1035,7 +1090,8 @@ fn rejects_wrong_r_e() { 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_bad = ecdh(r_e_bad, k_aud_s); - let m_s_bad = sponge_squeeze_2(domain::AUDITOR_SENDER, s_a_s_bad, SIGMA); + let m_s_bad = sponge_squeeze_3(domain::AUDITOR_SENDER, s_a_s_bad, SIGMA); + let r_new = derive_spend_r(vk, SIGMA); run_main( SK, V, @@ -1066,6 +1122,8 @@ fn rejects_wrong_r_e() { R_E_Y, V_A + m_s_bad[0], V_NEW + m_s_bad[1], + r_new + m_s_bad[2], + encrypt_esc_allow_r_auditor(derive_allow_r(dvk, SIGMA_A), s_a_s_bad, OP_I), ); } @@ -1103,6 +1161,8 @@ fn rejects_off_curve_k_aud_s() { R_E_Y, V_TILDE_AUD_S, B_TILDE_AUD_S, + R_TILDE_AUD_S, + R_A_TILDE_AUD_S, ); } @@ -1140,6 +1200,8 @@ fn rejects_identity_k_aud_s() { R_E_Y, V_TILDE_AUD_S, B_TILDE_AUD_S, + R_TILDE_AUD_S, + R_A_TILDE_AUD_S, ); } @@ -1177,6 +1239,8 @@ fn rejects_wrong_k_aud_s() { R_E_Y, V_TILDE_AUD_S, B_TILDE_AUD_S, + R_TILDE_AUD_S, + R_A_TILDE_AUD_S, ); } @@ -1213,6 +1277,8 @@ fn rejects_tampered_v_tilde_aud_s() { R_E_Y, V_TILDE_AUD_S + 1, B_TILDE_AUD_S, + R_TILDE_AUD_S, + R_A_TILDE_AUD_S, ); } @@ -1249,5 +1315,247 @@ fn rejects_tampered_b_tilde_aud_s() { R_E_Y, V_TILDE_AUD_S, B_TILDE_AUD_S + 1, + R_TILDE_AUD_S, + R_A_TILDE_AUD_S, + ); +} + +#[test(should_fail)] +fn rejects_tampered_r_a_tilde_aud_s() { + // r_a_tilde_aud_s mutated by +1: S14 fails because the auditor-side escrow + // is pinned to r_a + Poseidon(delta_esc_allow_r_aud, s_a_s, op_i), which + // the prover's claim no longer matches. + 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, + ADDR_F, + 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, + R_TILDE_AUD_S, + R_A_TILDE_AUD_S + 1, + ); +} + +// Salt and ephemeral reuse (characterization) +// ------------------------------------------- +// Nothing in this circuit constrains `sigma`, `sigma_a`, or `r_e` to be fresh +// -- all three are prover-supplied and unchecked. The tests below pin what +// actually happens under reuse rather than asserting that it is prevented. If +// a future change starts enforcing uniqueness, they have to be updated +// deliberately. + +#[test] +fn repeated_sigma_a_republishes_the_same_allowance_state() { + // Two delegations to the same spender under the same sigma_a and the same + // v_a write a byte-identical C_a and a_tilde, even with a different owner + // balance, a different sigma, and a fresh r_e -- r_a and a_tilde are pure + // functions of (dvk_i, sigma_a) and v_a. An observer learns the allowance + // was reset to a value it has seen before, and no more: equal ciphertexts + // carry no differential. + let v_2: Field = 900; + let r_2: Field = 43; + let v_new_2: Field = v_2 - V_A; + let sigma_2: Field = 0x11; + let r_e_2: Field = R_E + 7; + + let vk = vk_from_sk(SK, ADDR_F); + 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); + assert(c_a.x == C_A_X); + assert(c_a.y == C_A_Y); + assert(a_tilde == A_TILDE); + + let c_spend_2 = commit(v_2, r_2); + let r_new_2 = derive_spend_r(vk, sigma_2); + let c_spend_new_2 = commit(v_new_2, r_new_2); + let b_tilde_2 = encrypt_balance(v_new_2, vk, sigma_2); + let y_op = EmbeddedCurvePoint { x: Y_OP_X, y: Y_OP_Y, is_infinite: false }; + let escrowed_dvk_cipher_2 = encrypt_esc_dvk(dvk, ecdh(r_e_2, y_op), OP_I); + let k_aud_s = EmbeddedCurvePoint { x: K_AUD_S_X, y: K_AUD_S_Y, is_infinite: false }; + let s_a_s_2 = ecdh(r_e_2, k_aud_s); + let m_s_2 = sponge_squeeze_3(domain::AUDITOR_SENDER, s_a_s_2, sigma_2); + let r_e_2_pt = scalar_mul(r_e_2, H); + + run_main( + SK, + v_2, + r_2, + V_A, + r_e_2, + c_spend_2.x, + c_spend_2.y, + Y_X, + Y_Y, + Y_OP_X, + Y_OP_Y, + OP_I, + ADDR_F, + K_AUD_S_X, + K_AUD_S_Y, + c_spend_new_2.x, + c_spend_new_2.y, + C_A_X, + C_A_Y, + r_e_2_pt.x, + escrowed_dvk_cipher_2, + b_tilde_2, + A_TILDE, + sigma_2, + SIGMA_A, + r_e_2_pt.x, + r_e_2_pt.y, + V_A + m_s_2[0], + v_new_2 + m_s_2[1], + r_new_2 + m_s_2[2], + encrypt_esc_allow_r_auditor(r_a, s_a_s_2, OP_I), + ); +} + +#[test] +fn repeated_r_e_and_sigma_repeat_every_owner_channel_pad() { + // Reusing (r_e, sigma) repeats s_a_s and therefore all three sponge lanes + // and S14's pad. Re-escrowing the same plaintexts under them republishes + // byte-identical ciphertexts -- no differential. + let vk = vk_from_sk(SK, ADDR_F); + let dvk = dvk_from_vk_op(vk, OP_I); + let r_a = derive_allow_r(dvk, SIGMA_A); + let r_new = derive_spend_r(vk, SIGMA); + let k_aud_s = EmbeddedCurvePoint { x: K_AUD_S_X, y: K_AUD_S_Y, is_infinite: false }; + let s_a_s = ecdh(R_E, k_aud_s); + let m_s = sponge_squeeze_3(domain::AUDITOR_SENDER, s_a_s, SIGMA); + assert(V_A + m_s[0] == V_TILDE_AUD_S); + assert(V_NEW + m_s[1] == B_TILDE_AUD_S); + assert(r_new + m_s[2] == R_TILDE_AUD_S); + assert(encrypt_esc_allow_r_auditor(r_a, s_a_s, OP_I) == R_A_TILDE_AUD_S); + + // Change a plaintext under the repeated pad and the difference survives + // the encryption in the clear. This is the leak the per-operation + // freshness of r_e exists to prevent (Section 5.3); the circuit cannot + // catch it, because a reused r_e is a perfectly well-formed witness. + let v_a_2: Field = V_A + 25; + assert((v_a_2 + m_s[0]) - V_TILDE_AUD_S == 25); + + // The two allowance escrows do NOT leak the same way, and not because the + // pad is any fresher: r_a is a Poseidon image, so a change of v_a or + // sigma_a moves it unpredictably rather than by a known offset. What + // repeats byte-for-byte is only the case above, where nothing changed. + let r_a_2 = derive_allow_r(dvk, SIGMA_A + 1); + assert(encrypt_esc_allow_r_auditor(r_a_2, s_a_s, OP_I) != R_A_TILDE_AUD_S); +} + +#[test(should_fail)] +fn rejects_escrow_of_blinding_for_the_wrong_sigma_a() { + // The escrowed blinding must be the one S6 derived and S7 committed under, + // not merely *a* well-formed allowance blinding for this delegation. Here + // the prover escrows derive_allow_r(dvk, SIGMA_A + 1) -- correctly padded, + // correctly tagged, derived from the right dvk_i -- while every other + // public stays canonical at SIGMA_A. S14 fires. + // + // This is what makes the escrow useful: the auditor opens the on-chain C_a + // with what it decrypts. An escrow that floated free of sigma_a would let + // a prover hand the auditor an opening of some other allowance state, and + // the mismatch would only surface when the auditor checked the commitment. + let vk = vk_from_sk(SK, ADDR_F); + let dvk = dvk_from_vk_op(vk, OP_I); + let k_aud_s = EmbeddedCurvePoint { x: K_AUD_S_X, y: K_AUD_S_Y, is_infinite: false }; + let s_a_s = ecdh(R_E, k_aud_s); + 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, + ADDR_F, + 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, + R_TILDE_AUD_S, + encrypt_esc_allow_r_auditor(derive_allow_r(dvk, SIGMA_A + 1), s_a_s, OP_I), + ); +} + +#[test(should_fail)] +fn rejects_tampered_r_tilde_aud_s() { + // r_tilde_aud_s mutated by +1: S_a6 fails because the lane[2] escrow is + // pinned to r' = derive_spend_r(vk, sigma), which the prover's claim no + // longer matches. + 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, + ADDR_F, + 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, + R_TILDE_AUD_S + 1, + R_A_TILDE_AUD_S, ); } diff --git a/packages/tokens/src/confidential/circuits/spender_transfer/src/main.nr b/packages/tokens/src/confidential/circuits/spender_transfer/src/main.nr index d419818a9..a5cb8187a 100644 --- a/packages/tokens/src/confidential/circuits/spender_transfer/src/main.nr +++ b/packages/tokens/src/confidential/circuits/spender_transfer/src/main.nr @@ -1,6 +1,6 @@ use stellar_confidential_lib::{ H, assert_on_curve_non_identity, commit, derive_allow_r, derive_transfer_blind, domain, ecdh, - encrypt_allowance, encrypt_amount, scalar_mul, sponge_squeeze_2, + encrypt_allowance, encrypt_amount, scalar_mul, sponge_squeeze_2, sponge_squeeze_3, }; use std::embedded_curve_ops::EmbeddedCurvePoint; @@ -36,20 +36,23 @@ mod tests; // O6 R_e = r_e * H Ephemeral public // key. // O7 r_transfer = Poseidon2(delta_transfer_blind, s, -// sigma_a) Anti-poisoning +// sigma_a') Anti-poisoning // binding for the // transfer blinding. // O8 C_transfer = v_transfer * G + r_transfer * H Transfer // commitment. // O9 v_tilde = v_transfer // + Poseidon2(delta_transfer_amount, s, -// sigma_a) Encrypted amount +// sigma_a') Encrypted amount // (emitted). // Allowance state (new) -// -- sigma_a' != sigma_a Salt rotation -// (implementation -// hardening; not in -// Section 7.8). +// O14 sigma_a' != sigma_a Nonce rotation: +// rejects keying the +// pads to the salt the +// previous transfer +// already used +// (Section 6.2 +// *Transfer nonce*). // O10 r_a' = Poseidon2(delta_allow_r, dvk_i, // sigma_a') New allowance // randomness. @@ -63,14 +66,18 @@ mod tests; // Nonzero ephemeral // O13 r_e != 0 Rules out R_e = O // and S, S_{a,r}, -// S_{a,s} = O. +// S_{a,s} = O; every +// pad would collapse +// to a constant +// function of +// sigma_a'. // Auditor block (dual-channel visibility, Section 8.1 + Section 8.4) // O_a1 s_{a,r} = ecdh(r_e, K_aud_r) Recipient-auditor // ECDH shared scalar // (reuses r_e). // O_a2 (m_v_r, m_r_r) // = SpongeSqueeze_2(delta_aud_r, -// s_{a,r}, sigma_a) Recipient-channel +// s_{a,r}, sigma_a') Recipient-channel // sponge: two masks. // O_a3 v_tilde_aud_r = v_transfer + m_v_r Recipient-auditor // encrypted amount. @@ -86,16 +93,49 @@ mod tests; // OWNER, not the // spender (Section // 8.4). -// O_a6 (m_v_s, m_a_s) -// = SpongeSqueeze_2(delta_aud_s, -// s_{a,s}, sigma_a) Owner-channel -// sponge: two masks. +// O_a6 (m_v_s, m_a_s, m_r_s) +// = SpongeSqueeze_3(delta_aud_s, +// s_{a,s}, sigma_a') Owner-channel +// sponge: three masks. // O_a7 v_tilde_aud_s = v_transfer + m_v_s Owner-auditor // encrypted amount. // O_a8 a_tilde_aud_s = (v_a - v_transfer) + m_a_s Owner-auditor // encrypted // post-transfer // allowance. +// O_a9 r_tilde_aud_s = r_a' + m_r_s Owner-auditor +// escrow of the NEW +// allowance blinding +// -- O10's r_a', +// which O11 commits +// under. Already a +// witness, so this is +// one field addition. +// With a_tilde_aud_s +// (O_a8) it hands the +// owner's auditor the +// opening of the C_a' +// this operation +// writes. Pad and +// plaintext both key +// off sigma_a', under +// different domain +// tags and different +// secrets (s_{a,s} +// vs dvk_i). +// Buys one-operation +// convergence for a +// live delegation +// whose auditor key +// rotated after +// set_spender -- the +// same property every +// other auditor +// ciphertext has. It +// does nothing for a +// frozen account, +// which emits no +// ciphertext at all. // // Contract binding (Section 7.8 final paragraph) // --------------------------------------------- @@ -108,15 +148,19 @@ mod tests; // input and was constructed with contract-specific randomness, a proof // generated against one contract'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 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'`. +// Channel nonce (Section 6.2 *Transfer nonce*) +// -------------------------------------------- +// The stored `sigma_a` opens the current allowance (O3) and nothing else. +// Every pad this circuit derives absorbs the prover-chosen replacement +// `sigma_a'` instead: the recipient ECDH chain (O7 / O9), both auditor-channel +// sponges (O_a2 / O_a6, hence O_a9's lane[2] pad), and the new-allowance +// derivations (O10 / O12). A reverted call leaves the delegation entry +// untouched, so `sigma_a` is forced on the retry; keying the pads to it would +// repeat r_e and every mask, and a retry that changed the amount would publish +// the difference in the clear. `sigma_a'` is constrained only against +// prover-supplied state, so a retry re-samples it freely. O14 rejects the one +// adjacent collision; global non-repetition of `sigma_a'` over the +// delegation's lifetime is a client obligation (SDK.md Section 10.4). // // Point-validation doctrine (Section 10.8) // ---------------------------------------- @@ -132,7 +176,7 @@ mod tests; // explicitly validates both on-curve AND non-identity before the // corresponding ECDH consumes them. // -// Public inputs (24 fields, in design-doc canonical order) +// Public inputs (25 fields, in design-doc canonical order) // -------------------------------------------------------- // Idx Param Symbol Source / Note // --- ----- ------ ------------------------------- @@ -165,6 +209,7 @@ mod tests; // 18 a_tilde_new a_tilde' Prover-supplied encrypted new // allowance scalar; stored. // 19 sigma_a_new sigma_a' Prover-supplied fresh salt; +// channel nonce for every pad; // written to allowance_salt. // 20 v_tilde_aud_r v_tilde_aud_r Prover-supplied recipient- // auditor encrypted amount; @@ -177,6 +222,9 @@ mod tests; // 23 a_tilde_aud_s a_tilde_aud_s Prover-supplied owner-auditor // encrypted post-transfer // allowance; emitted. +// 24 r_tilde_aud_s r_tilde_aud_s Prover-supplied owner-auditor +// escrow of the new allowance +// blinding r_a'; emitted. // // Private witnesses // ----------------- @@ -220,6 +268,7 @@ fn main( r_tilde_aud_r: pub Field, v_tilde_aud_s: pub Field, a_tilde_aud_s: pub Field, + r_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. @@ -261,7 +310,7 @@ fn main( let s = ecdh(r_e, pvk_recipient); // O7 (anti-poisoning, Section 5.4) - let r_transfer = derive_transfer_blind(s, sigma_a); + let r_transfer = derive_transfer_blind(s, sigma_a_new); // O8 let c_transfer_derived = commit(v_transfer, r_transfer); @@ -269,10 +318,10 @@ fn main( assert(c_transfer_derived.y == c_transfer_y); // O9 - let v_tilde_derived = encrypt_amount(v_transfer, s, sigma_a); + let v_tilde_derived = encrypt_amount(v_transfer, s, sigma_a_new); assert(v_tilde_derived == v_tilde); - // Salt rotation (implementation hardening). + // O14 assert(sigma_a_new != sigma_a); // O10 @@ -297,7 +346,7 @@ fn main( let s_a_r = ecdh(r_e, k_aud_r); // O_a2 (recipient-channel masks: amount, then r_transfer) - let m_r = sponge_squeeze_2(domain::AUDITOR_RECIPIENT, s_a_r, sigma_a); + let m_r = sponge_squeeze_2(domain::AUDITOR_RECIPIENT, s_a_r, sigma_a_new); // O_a3 assert(v_transfer + m_r[0] == v_tilde_aud_r); @@ -308,12 +357,19 @@ fn main( // O_a5 (owner-auditor shared-secret scalar) let s_a_s = 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, sigma_a); + // O_a6 (owner-channel masks: amount, post-transfer allowance, then the + // blinding-escrow slot) + let m_s = sponge_squeeze_3(domain::AUDITOR_SENDER, s_a_s, sigma_a_new); // O_a7 assert(v_transfer + m_s[0] == v_tilde_aud_s); // O_a8 assert(v_a_new + m_s[1] == a_tilde_aud_s); + + // O_a9 -- r_a_new is already constrained by O10 and committed by O11, so + // this is one field addition: zero scalar muls, no new domain tag. The + // escrowed blinding is the one the operation WRITES, not the one it + // consumes, so the auditor's opening matches the C_a' left on-chain. + assert(r_a_new + m_s[2] == r_tilde_aud_s); } diff --git a/packages/tokens/src/confidential/circuits/spender_transfer/src/tests.nr b/packages/tokens/src/confidential/circuits/spender_transfer/src/tests.nr index 6337933ab..efcda52cd 100644 --- a/packages/tokens/src/confidential/circuits/spender_transfer/src/tests.nr +++ b/packages/tokens/src/confidential/circuits/spender_transfer/src/tests.nr @@ -1,17 +1,18 @@ use crate::main; use stellar_confidential_lib::{ commit, derive_allow_r, derive_transfer_blind, domain, ecdh, encrypt_allowance, encrypt_amount, H, - scalar_mul, sponge_squeeze_2, + scalar_mul, sponge_squeeze_2, sponge_squeeze_3, }; use std::embedded_curve_ops::EmbeddedCurvePoint; // Canonical fixture inputs. // -// SIGMA_A is the channel nonce reused across the allowance Poseidon (O3), -// the recipient ECDH chain (O7 / O9), and both auditor sponges (O_a2 / O_a6) -// -- see main.nr "Channel-nonce reuse". SIGMA_A_NEW is the fresh salt for the -// post-transfer allowance derivations only (O10, O12). V_A / V_TRANSFER / V_A_NEW -// give a non-trivial `v_a - v_transfer` so O4 is exercised on a real subtraction. +// SIGMA_A is the stored allowance salt and opens C_a (O3) and nothing else. +// SIGMA_A_NEW is the channel nonce: the recipient ECDH chain (O7 / O9), both +// auditor sponges (O_a2 / O_a6), and the post-transfer allowance derivations +// (O10, O12) all absorb it -- see main.nr "Channel nonce". V_A / V_TRANSFER / +// V_A_NEW give a non-trivial `v_a - v_transfer` so O4 is exercised on a real +// subtraction. // // Y_OP_X / Y_OP_Y and the auditor / recipient keys share fixture values with // withdraw/transfer (sk_op = 0xdead, K_aud_s/r and pvk scalars identical to @@ -57,21 +58,26 @@ global K_AUD_S_X: Field = 0x22502c7b20b64aeaaaa4d4fa5f2f8600f9734e828f7284a8d143 global K_AUD_S_Y: Field = 0x0419089353d24334f03f4a1c9c9b19282e32f6879c5c69b9cdffba6a13a7c5ed; global C_A_NEW_X: Field = 0x2d19aa5933ba41ebadb28d4c784ca4e405a1a6efcb5cf15e2c8d5e9a0552f178; global C_A_NEW_Y: Field = 0x0214719083236d5ecfc551fbc2b6d04fa8c95b8986fcfc7bbb8c0ff3cecd3dd1; -global C_TRANSFER_X: Field = 0x26677e8f24cbbc929b8be4a8d470d4a0e54a3c8a351ceef295e6b99b2898ed1d; -global C_TRANSFER_Y: Field = 0x089153eeedb04e49b206f7121341fdcb842a6ca19fb0f938167834dd10d42a97; +global C_TRANSFER_X: Field = 0x278bb372b27575f08ab88b27d967546c867287e9e64b58a482b4f6433ddf8bdd; +global C_TRANSFER_Y: Field = 0x01021c5c2d8c3d736a8d87555ddd4cef3abc8297657ae139ab0968ff9cfebbfd; global R_E_X: Field = 0x114ed4fcf2c57014eb678c577aa02f30ef590b713d7a6a5e87702d1c7f71957f; global R_E_Y: Field = 0x07a70cf826350d4f438c7a3c5e8761b0ae6cb63de757f0c96815f4057b9205f4; -global V_TILDE: Field = 0x0b3b7be1cd27249ec6b32b4ecb840079e0354b8675e94aade6519e5428473ffa; +global V_TILDE: Field = 0x06656a0ee0a408f201f01030e6a6941a55cb2829549b5f5dd8ed233eb61a7880; global A_TILDE_NEW: Field = 0x1d2e286a8d510a7c4164d0142ceea3202a957d49248426435f311a348f681147; global V_TILDE_AUD_R: Field = - 0x1150b1568e3047a5d263b9c24b00f49ca3971880e2fd09e9e6448207136bbb41; + 0x274f7ec87c29f7e31d3240257d563a77373efd3f976bb197f0505fc3eff581dd; global R_TILDE_AUD_R: Field = - 0x16cb31d142cf3005f8bd95ab3f59c3bda0da0de7e1d2ac16b4d038548a02b364; + 0x11504bfdaa7e3dfb4b38918d6ae2ba379abfda0a53ebc00fd1b561e805407c96; global V_TILDE_AUD_S: Field = - 0x0d0a3aa77960977737f4355093d4672ea9f95556fabf63bda0913ebef52c42cf; + 0x08ad26221dfe94a593dae37d3efdbe93a503a3cfe35bd10c3ab60061267c20fc; +// lane[2] blinding-escrow slot (O_a9): the NEW allowance blinding r_a', the +// one O11 commits C_a' under, escrowed to the owner's auditor on every +// spender transfer. Pad and plaintext both key off SIGMA_A_NEW. +global R_TILDE_AUD_S: Field = + 0x19b2c06c2ec9cacaa9f36876d6b988d186ad82b2f47cff17dbe6e4632d79b4d7; global A_TILDE_AUD_S: Field = - 0x288ff48898f218dbba2a5c9546d0986af3257a4a539e23e4f35c404d6720c144; + 0x29499e6f15cad25108f6b3931d8f3cd70cd0c0000ca03921509c5466feb773bb; #[test] fn print_fixtures() { @@ -89,23 +95,24 @@ fn print_fixtures() { let r_e_pt = scalar_mul(R_E, H); let s = ecdh(R_E, pvk_recipient); - let r_transfer = derive_transfer_blind(s, SIGMA_A); + let r_transfer = derive_transfer_blind(s, SIGMA_A_NEW); let c_transfer = commit(V_TRANSFER, r_transfer); - let v_tilde = encrypt_amount(V_TRANSFER, s, SIGMA_A); + let v_tilde = encrypt_amount(V_TRANSFER, s, SIGMA_A_NEW); let r_a_new = derive_allow_r(DVK_I, SIGMA_A_NEW); let c_a_new = commit(V_A_NEW, r_a_new); let a_tilde_new = encrypt_allowance(V_A_NEW, DVK_I, SIGMA_A_NEW); let s_a_r = ecdh(R_E, k_aud_r); - let m_r = sponge_squeeze_2(domain::AUDITOR_RECIPIENT, s_a_r, SIGMA_A); + let m_r = sponge_squeeze_2(domain::AUDITOR_RECIPIENT, s_a_r, SIGMA_A_NEW); let v_tilde_aud_r = V_TRANSFER + m_r[0]; let r_tilde_aud_r = r_transfer + m_r[1]; let s_a_s = ecdh(R_E, k_aud_s); - let m_s = sponge_squeeze_2(domain::AUDITOR_SENDER, s_a_s, SIGMA_A); + let m_s = sponge_squeeze_3(domain::AUDITOR_SENDER, s_a_s, SIGMA_A_NEW); let v_tilde_aud_s = V_TRANSFER + m_s[0]; let a_tilde_aud_s = V_A_NEW + m_s[1]; + let r_tilde_aud_s = r_a_new + m_s[2]; let yopx = y_op.x; let yopy = y_op.y; @@ -146,6 +153,7 @@ fn print_fixtures() { println(f"R_TILDE_AUD_R = {r_tilde_aud_r}"); println(f"V_TILDE_AUD_S = {v_tilde_aud_s}"); println(f"A_TILDE_AUD_S = {a_tilde_aud_s}"); + println(f"R_TILDE_AUD_S = {r_tilde_aud_s}"); } #[test] @@ -155,9 +163,9 @@ fn spender_transfer_fixtures_match_lib() { let c_a = commit(V_A, r_a); let pvk_recipient = scalar_mul(VK_RECIPIENT, H); let s = ecdh(R_E, pvk_recipient); - let r_transfer = derive_transfer_blind(s, SIGMA_A); + let r_transfer = derive_transfer_blind(s, SIGMA_A_NEW); let c_transfer = commit(V_TRANSFER, r_transfer); - let v_tilde = encrypt_amount(V_TRANSFER, s, SIGMA_A); + let v_tilde = encrypt_amount(V_TRANSFER, s, SIGMA_A_NEW); let r_a_new = derive_allow_r(DVK_I, SIGMA_A_NEW); let c_a_new = commit(V_A_NEW, r_a_new); let a_tilde_new = encrypt_allowance(V_A_NEW, DVK_I, SIGMA_A_NEW); @@ -184,11 +192,11 @@ fn spender_transfer_auditor_fixtures_match_lib() { let r_e_pt = scalar_mul(R_E, H); let pvk_recipient = scalar_mul(VK_RECIPIENT, H); let s = ecdh(R_E, pvk_recipient); - let r_transfer = derive_transfer_blind(s, SIGMA_A); + let r_transfer = derive_transfer_blind(s, SIGMA_A_NEW); let s_a_r = ecdh(R_E, k_aud_r); - let m_r = sponge_squeeze_2(domain::AUDITOR_RECIPIENT, s_a_r, SIGMA_A); + let m_r = sponge_squeeze_2(domain::AUDITOR_RECIPIENT, s_a_r, SIGMA_A_NEW); let s_a_s = ecdh(R_E, k_aud_s); - let m_s = sponge_squeeze_2(domain::AUDITOR_SENDER, s_a_s, SIGMA_A); + let m_s = sponge_squeeze_3(domain::AUDITOR_SENDER, s_a_s, SIGMA_A_NEW); assert(k_aud_r.x == K_AUD_R_X); assert(k_aud_r.y == K_AUD_R_Y); @@ -200,6 +208,7 @@ fn spender_transfer_auditor_fixtures_match_lib() { assert(r_transfer + m_r[1] == R_TILDE_AUD_R); assert(V_TRANSFER + m_s[0] == V_TILDE_AUD_S); assert(V_A_NEW + m_s[1] == A_TILDE_AUD_S); + assert(derive_allow_r(DVK_I, SIGMA_A_NEW) + m_s[2] == R_TILDE_AUD_S); } // `run_main` accepts every public input as a parameter -- nothing baked in. @@ -238,6 +247,7 @@ fn run_main( r_tilde_aud_r_in: Field, v_tilde_aud_s_in: Field, a_tilde_aud_s_in: Field, + r_tilde_aud_s_in: Field, ) { main( sk_op_in, @@ -270,6 +280,7 @@ fn run_main( r_tilde_aud_r_in, v_tilde_aud_s_in, a_tilde_aud_s_in, + r_tilde_aud_s_in, ); } @@ -305,6 +316,7 @@ fn run_fixture() { R_TILDE_AUD_R, V_TILDE_AUD_S, A_TILDE_AUD_S, + R_TILDE_AUD_S, ); } @@ -316,11 +328,11 @@ fn matches_fixture() { #[test] fn second_transfer_against_same_allowance() { // Second happy-path point: same C_a (= same dvk_i, same v_a, same - // sigma_a) with a different v_transfer. Exercises the case where the spender - // hasn't yet rotated the allowance commitment but produces a fresh - // sigma_a_new and a fresh r_e on every call. The transfer-side ECDH - // chain (O5..O9) is fully rederived; only O11/O12 share dvk_i + the new - // sigma_a_new with the canonical fixture. + // sigma_a) with a different v_transfer. This is the retry / second-attempt + // shape: the stored sigma_a is forced, so a fresh sigma_a_new is the only + // thing that can make the pads fresh. Every pad here is keyed to + // sigma_a_new_2 and r_e_2, so none of the canonical fixture's ciphertexts + // recur even though C_a, v_a and sigma_a are identical. let v_transfer_2: Field = 250; let v_a_new_2: Field = V_A - v_transfer_2; // 750 let sigma_a_new_2: Field = 0x03; @@ -332,17 +344,17 @@ fn second_transfer_against_same_allowance() { let k_aud_s = EmbeddedCurvePoint { x: K_AUD_S_X, y: K_AUD_S_Y, is_infinite: false }; let s = ecdh(r_e_2, pvk_recipient); - let r_transfer = derive_transfer_blind(s, SIGMA_A); + let r_transfer = derive_transfer_blind(s, sigma_a_new_2); let c_transfer = commit(v_transfer_2, r_transfer); - let v_tilde_2 = encrypt_amount(v_transfer_2, s, SIGMA_A); + let v_tilde_2 = encrypt_amount(v_transfer_2, s, sigma_a_new_2); let r_a_new_2 = derive_allow_r(DVK_I, sigma_a_new_2); let c_a_new_2 = commit(v_a_new_2, r_a_new_2); let a_tilde_new_2 = encrypt_allowance(v_a_new_2, DVK_I, sigma_a_new_2); let r_e_2_pt = scalar_mul(r_e_2, H); let s_a_r = ecdh(r_e_2, k_aud_r); - let m_r = sponge_squeeze_2(domain::AUDITOR_RECIPIENT, s_a_r, SIGMA_A); + let m_r = sponge_squeeze_2(domain::AUDITOR_RECIPIENT, s_a_r, sigma_a_new_2); let s_a_s = ecdh(r_e_2, k_aud_s); - let m_s = sponge_squeeze_2(domain::AUDITOR_SENDER, s_a_s, SIGMA_A); + let m_s = sponge_squeeze_3(domain::AUDITOR_SENDER, s_a_s, sigma_a_new_2); run_main( SK_OP, @@ -375,6 +387,7 @@ fn second_transfer_against_same_allowance() { r_transfer + m_r[1], v_transfer_2 + m_s[0], v_a_new_2 + m_s[1], + r_a_new_2 + m_s[2], ); } @@ -390,16 +403,16 @@ fn full_allowance_transfer() { let k_aud_r = EmbeddedCurvePoint { x: K_AUD_R_X, y: K_AUD_R_Y, is_infinite: false }; let k_aud_s = EmbeddedCurvePoint { x: K_AUD_S_X, y: K_AUD_S_Y, is_infinite: false }; let s = ecdh(R_E, pvk_recipient); - let r_transfer = derive_transfer_blind(s, SIGMA_A); + let r_transfer = derive_transfer_blind(s, SIGMA_A_NEW); let c_transfer = commit(V_A, r_transfer); - let v_tilde = encrypt_amount(V_A, s, SIGMA_A); + let v_tilde = encrypt_amount(V_A, s, SIGMA_A_NEW); let r_a_new = derive_allow_r(DVK_I, SIGMA_A_NEW); let c_a_new = commit(0, r_a_new); let a_tilde_new = encrypt_allowance(0, DVK_I, SIGMA_A_NEW); let s_a_r = ecdh(R_E, k_aud_r); - let m_r = sponge_squeeze_2(domain::AUDITOR_RECIPIENT, s_a_r, SIGMA_A); + let m_r = sponge_squeeze_2(domain::AUDITOR_RECIPIENT, s_a_r, SIGMA_A_NEW); let s_a_s = ecdh(R_E, k_aud_s); - let m_s = sponge_squeeze_2(domain::AUDITOR_SENDER, s_a_s, SIGMA_A); + let m_s = sponge_squeeze_3(domain::AUDITOR_SENDER, s_a_s, SIGMA_A_NEW); run_main( SK_OP, @@ -432,6 +445,7 @@ fn full_allowance_transfer() { r_transfer + m_r[1], V_A + m_s[0], 0 + m_s[1], + r_a_new + m_s[2], ); } @@ -469,6 +483,7 @@ fn rejects_wrong_sk_op() { R_TILDE_AUD_R, V_TILDE_AUD_S, A_TILDE_AUD_S, + R_TILDE_AUD_S, ); } @@ -542,6 +557,7 @@ fn rejects_wrong_dvk_against_contract() { R_TILDE_AUD_R, V_TILDE_AUD_S, A_TILDE_AUD_S, + R_TILDE_AUD_S, ); } @@ -557,7 +573,7 @@ fn rejects_wrong_allowance_opening() { let c_a_new_bad = commit(v_a_new_bad, r_a_new); let a_tilde_new_bad = encrypt_allowance(v_a_new_bad, DVK_I, SIGMA_A_NEW); let s_a_s = ecdh(R_E, k_aud_s); - let m_s = sponge_squeeze_2(domain::AUDITOR_SENDER, s_a_s, SIGMA_A); + let m_s = sponge_squeeze_3(domain::AUDITOR_SENDER, s_a_s, SIGMA_A_NEW); run_main( SK_OP, @@ -590,6 +606,7 @@ fn rejects_wrong_allowance_opening() { R_TILDE_AUD_R, V_TILDE_AUD_S, v_a_new_bad + m_s[1], + r_a_new + m_s[2], ); } @@ -606,16 +623,16 @@ fn rejects_v_a_new_out_of_range() { let k_aud_r = EmbeddedCurvePoint { x: K_AUD_R_X, y: K_AUD_R_Y, is_infinite: false }; let k_aud_s = EmbeddedCurvePoint { x: K_AUD_S_X, y: K_AUD_S_Y, is_infinite: false }; let s = ecdh(R_E, pvk_recipient); - let r_transfer = derive_transfer_blind(s, SIGMA_A); + let r_transfer = derive_transfer_blind(s, SIGMA_A_NEW); let c_transfer = commit(v_transfer_too_large, r_transfer); - let v_tilde = encrypt_amount(v_transfer_too_large, s, SIGMA_A); + let v_tilde = encrypt_amount(v_transfer_too_large, s, SIGMA_A_NEW); let r_a_new = derive_allow_r(DVK_I, SIGMA_A_NEW); let c_a_new_invalid = commit(V_A - v_transfer_too_large, r_a_new); let a_tilde_new_invalid = encrypt_allowance(V_A - v_transfer_too_large, DVK_I, SIGMA_A_NEW); let s_a_r = ecdh(R_E, k_aud_r); - let m_r = sponge_squeeze_2(domain::AUDITOR_RECIPIENT, s_a_r, SIGMA_A); + let m_r = sponge_squeeze_2(domain::AUDITOR_RECIPIENT, s_a_r, SIGMA_A_NEW); let s_a_s = ecdh(R_E, k_aud_s); - let m_s = sponge_squeeze_2(domain::AUDITOR_SENDER, s_a_s, SIGMA_A); + let m_s = sponge_squeeze_3(domain::AUDITOR_SENDER, s_a_s, SIGMA_A_NEW); run_main( SK_OP, @@ -648,6 +665,7 @@ fn rejects_v_a_new_out_of_range() { r_transfer + m_r[1], v_transfer_too_large + m_s[0], (V_A - v_transfer_too_large) + m_s[1], + r_a_new + m_s[2], ); } @@ -669,7 +687,7 @@ fn rejects_v_a_out_of_range() { let c_a_new = commit(v_a_new_huge, r_a_new); let a_tilde_new = encrypt_allowance(v_a_new_huge, DVK_I, SIGMA_A_NEW); let s_a_s = ecdh(R_E, k_aud_s); - let m_s = sponge_squeeze_2(domain::AUDITOR_SENDER, s_a_s, SIGMA_A); + let m_s = sponge_squeeze_3(domain::AUDITOR_SENDER, s_a_s, SIGMA_A_NEW); run_main( SK_OP, @@ -702,6 +720,7 @@ fn rejects_v_a_out_of_range() { R_TILDE_AUD_R, V_TILDE_AUD_S, v_a_new_huge + m_s[1], + r_a_new + m_s[2], ); } @@ -718,16 +737,16 @@ fn rejects_v_transfer_out_of_range() { let k_aud_r = EmbeddedCurvePoint { x: K_AUD_R_X, y: K_AUD_R_Y, is_infinite: false }; let k_aud_s = EmbeddedCurvePoint { x: K_AUD_S_X, y: K_AUD_S_Y, is_infinite: false }; let s = ecdh(R_E, pvk_recipient); - let r_transfer = derive_transfer_blind(s, SIGMA_A); + let r_transfer = derive_transfer_blind(s, SIGMA_A_NEW); let c_transfer = commit(v_transfer_huge, r_transfer); - let v_tilde = encrypt_amount(v_transfer_huge, s, SIGMA_A); + let v_tilde = encrypt_amount(v_transfer_huge, s, SIGMA_A_NEW); let r_a_new = derive_allow_r(DVK_I, SIGMA_A_NEW); let c_a_new = commit(v_a_new_huge, r_a_new); let a_tilde_new = encrypt_allowance(v_a_new_huge, DVK_I, SIGMA_A_NEW); let s_a_r = ecdh(R_E, k_aud_r); - let m_r = sponge_squeeze_2(domain::AUDITOR_RECIPIENT, s_a_r, SIGMA_A); + let m_r = sponge_squeeze_2(domain::AUDITOR_RECIPIENT, s_a_r, SIGMA_A_NEW); let s_a_s = ecdh(R_E, k_aud_s); - let m_s = sponge_squeeze_2(domain::AUDITOR_SENDER, s_a_s, SIGMA_A); + let m_s = sponge_squeeze_3(domain::AUDITOR_SENDER, s_a_s, SIGMA_A_NEW); run_main( SK_OP, @@ -760,6 +779,7 @@ fn rejects_v_transfer_out_of_range() { r_transfer + m_r[1], v_transfer_huge + m_s[0], v_a_new_huge + m_s[1], + r_a_new + m_s[2], ); } @@ -800,6 +820,7 @@ fn rejects_wrong_recipient_pvk() { R_TILDE_AUD_R, V_TILDE_AUD_S, A_TILDE_AUD_S, + R_TILDE_AUD_S, ); } @@ -808,12 +829,12 @@ fn rejects_poisoned_c_transfer() { // Anti-poisoning (Section 5.4): a prover that commits C_transfer with arbitrary // blinding -- here r_transfer + 1 -- desynchronizes the recipient's accumulated // blinding. O7 + O8 together close this attack: r_transfer is uniquely - // determined by Poseidon2(delta_transfer_blind, s, sigma_a), so any C_transfer not + // determined by Poseidon2(delta_transfer_blind, s, sigma_a'), so any C_transfer not // built from that exact r_transfer fails O8. let pvk_recipient = EmbeddedCurvePoint { x: PVK_RECIPIENT_X, y: PVK_RECIPIENT_Y, is_infinite: false }; let s = ecdh(R_E, pvk_recipient); - let r_transfer_poisoned = derive_transfer_blind(s, SIGMA_A) + 1; + let r_transfer_poisoned = derive_transfer_blind(s, SIGMA_A_NEW) + 1; let c_transfer_poisoned = commit(V_TRANSFER, r_transfer_poisoned); run_main( @@ -847,6 +868,7 @@ fn rejects_poisoned_c_transfer() { R_TILDE_AUD_R, V_TILDE_AUD_S, A_TILDE_AUD_S, + R_TILDE_AUD_S, ); } @@ -884,6 +906,7 @@ fn rejects_tampered_v_tilde() { R_TILDE_AUD_R, V_TILDE_AUD_S, A_TILDE_AUD_S, + R_TILDE_AUD_S, ); } @@ -929,6 +952,7 @@ fn rejects_tampered_a_tilde_new() { R_TILDE_AUD_R, V_TILDE_AUD_S, A_TILDE_AUD_S, + R_TILDE_AUD_S, ); } @@ -966,22 +990,36 @@ fn rejects_tampered_c_a_new() { R_TILDE_AUD_R, V_TILDE_AUD_S, A_TILDE_AUD_S, + R_TILDE_AUD_S, ); } #[test(should_fail)] fn rejects_sigma_a_new_equal_to_sigma_a() { - // Salt-rotation hardening: the circuit rejects `sigma_a' == sigma_a` - // even though Section 7.8 does not require it. With `sigma_a' = sigma_a`: + // O14: the circuit rejects `sigma_a' == sigma_a`, which would key every + // pad to the salt the previous transfer already used. With + // `sigma_a' = sigma_a`: // r_a_new = derive_allow_r(dvk_i, sigma_a_new) = r_a // c_a_new = (V_A - V_TRANSFER) * G + r_a * H // a_tilde_new = encrypt_allowance(V_A - V_TRANSFER, dvk_i, sigma_a_new) - // All downstream publics are recomputed consistently so the only - // firing constraint is `assert(sigma_a_new != sigma_a)`. + // and every channel pad absorbs SIGMA_A. All downstream publics are + // recomputed consistently so the only firing constraint is + // `assert(sigma_a_new != sigma_a)`. let r_a_new = derive_allow_r(DVK_I, SIGMA_A); let c_a_new = commit(V_A_NEW, r_a_new); let a_tilde_new = encrypt_allowance(V_A_NEW, DVK_I, SIGMA_A); + let pvk_recipient = + EmbeddedCurvePoint { x: PVK_RECIPIENT_X, y: PVK_RECIPIENT_Y, is_infinite: false }; + let k_aud_r = EmbeddedCurvePoint { x: K_AUD_R_X, y: K_AUD_R_Y, is_infinite: false }; + let k_aud_s = EmbeddedCurvePoint { x: K_AUD_S_X, y: K_AUD_S_Y, is_infinite: false }; + let s = ecdh(R_E, pvk_recipient); + let r_transfer = derive_transfer_blind(s, SIGMA_A); + let c_transfer = commit(V_TRANSFER, r_transfer); + let v_tilde = encrypt_amount(V_TRANSFER, s, SIGMA_A); + let m_r = sponge_squeeze_2(domain::AUDITOR_RECIPIENT, ecdh(R_E, k_aud_r), SIGMA_A); + let m_s = sponge_squeeze_3(domain::AUDITOR_SENDER, ecdh(R_E, k_aud_s), SIGMA_A); + run_main( SK_OP, DVK_I, @@ -1002,17 +1040,18 @@ fn rejects_sigma_a_new_equal_to_sigma_a() { K_AUD_S_Y, c_a_new.x, c_a_new.y, - C_TRANSFER_X, - C_TRANSFER_Y, + c_transfer.x, + c_transfer.y, R_E_X, R_E_Y, - V_TILDE, + v_tilde, a_tilde_new, SIGMA_A, - V_TILDE_AUD_R, - R_TILDE_AUD_R, - V_TILDE_AUD_S, - A_TILDE_AUD_S, + V_TRANSFER + m_r[0], + r_transfer + m_r[1], + V_TRANSFER + m_s[0], + V_A_NEW + m_s[1], + r_a_new + m_s[2], ); } @@ -1022,7 +1061,7 @@ fn rejects_r_e_zero() { // shared point in this transfer (S, S_{a,r}, S_{a,s} all become O), so // each ecdh(0, .) returns the ECDH-domain hash of the identity point: // s_zero, s_a_r_zero, and s_a_s_zero are identity-derived constants, - // making every mask a knowable constant function of sigma_a. Every + // making every mask a knowable constant function of sigma_a'. Every // r_e-dependent public (R_e = (0, 0) identity-encoding, C_transfer / // v_tilde derived against s_zero, and both auditor channels' ciphertexts // derived against s_a_r_zero / s_a_s_zero) is recomputed so the @@ -1033,13 +1072,13 @@ fn rejects_r_e_zero() { let k_aud_r = EmbeddedCurvePoint { x: K_AUD_R_X, y: K_AUD_R_Y, is_infinite: false }; let k_aud_s = EmbeddedCurvePoint { x: K_AUD_S_X, y: K_AUD_S_Y, is_infinite: false }; let s_zero = ecdh(0, pvk_recipient); - let r_transfer_zero = derive_transfer_blind(s_zero, SIGMA_A); + let r_transfer_zero = derive_transfer_blind(s_zero, SIGMA_A_NEW); let c_transfer_zero = commit(V_TRANSFER, r_transfer_zero); - let v_tilde_zero = encrypt_amount(V_TRANSFER, s_zero, SIGMA_A); + let v_tilde_zero = encrypt_amount(V_TRANSFER, s_zero, SIGMA_A_NEW); let s_a_r_zero = ecdh(0, k_aud_r); - let m_r_zero = sponge_squeeze_2(domain::AUDITOR_RECIPIENT, s_a_r_zero, SIGMA_A); + let m_r_zero = sponge_squeeze_2(domain::AUDITOR_RECIPIENT, s_a_r_zero, SIGMA_A_NEW); let s_a_s_zero = ecdh(0, k_aud_s); - let m_s_zero = sponge_squeeze_2(domain::AUDITOR_SENDER, s_a_s_zero, SIGMA_A); + let m_s_zero = sponge_squeeze_3(domain::AUDITOR_SENDER, s_a_s_zero, SIGMA_A_NEW); run_main( SK_OP, @@ -1072,6 +1111,7 @@ fn rejects_r_e_zero() { r_transfer_zero + m_r_zero[1], V_TRANSFER + m_s_zero[0], V_A_NEW + m_s_zero[1], + derive_allow_r(DVK_I, SIGMA_A_NEW) + m_s_zero[2], ); } @@ -1089,13 +1129,13 @@ fn rejects_wrong_r_e() { let k_aud_r = EmbeddedCurvePoint { x: K_AUD_R_X, y: K_AUD_R_Y, is_infinite: false }; let k_aud_s = EmbeddedCurvePoint { x: K_AUD_S_X, y: K_AUD_S_Y, is_infinite: false }; let s_bad = ecdh(r_e_bad, pvk_recipient); - let r_transfer_bad = derive_transfer_blind(s_bad, SIGMA_A); + let r_transfer_bad = derive_transfer_blind(s_bad, SIGMA_A_NEW); let c_transfer_bad = commit(V_TRANSFER, r_transfer_bad); - let v_tilde_bad = encrypt_amount(V_TRANSFER, s_bad, SIGMA_A); + let v_tilde_bad = encrypt_amount(V_TRANSFER, s_bad, SIGMA_A_NEW); let s_a_r_bad = ecdh(r_e_bad, k_aud_r); - let m_r_bad = sponge_squeeze_2(domain::AUDITOR_RECIPIENT, s_a_r_bad, SIGMA_A); + let m_r_bad = sponge_squeeze_2(domain::AUDITOR_RECIPIENT, s_a_r_bad, SIGMA_A_NEW); let s_a_s_bad = ecdh(r_e_bad, k_aud_s); - let m_s_bad = sponge_squeeze_2(domain::AUDITOR_SENDER, s_a_s_bad, SIGMA_A); + let m_s_bad = sponge_squeeze_3(domain::AUDITOR_SENDER, s_a_s_bad, SIGMA_A_NEW); run_main( SK_OP, @@ -1128,6 +1168,7 @@ fn rejects_wrong_r_e() { r_transfer_bad + m_r_bad[1], V_TRANSFER + m_s_bad[0], V_A_NEW + m_s_bad[1], + derive_allow_r(DVK_I, SIGMA_A_NEW) + m_s_bad[2], ); } @@ -1166,6 +1207,7 @@ fn rejects_off_curve_k_aud_r() { R_TILDE_AUD_R, V_TILDE_AUD_S, A_TILDE_AUD_S, + R_TILDE_AUD_S, ); } @@ -1204,6 +1246,7 @@ fn rejects_identity_k_aud_r() { R_TILDE_AUD_R, V_TILDE_AUD_S, A_TILDE_AUD_S, + R_TILDE_AUD_S, ); } @@ -1240,6 +1283,7 @@ fn rejects_off_curve_k_aud_s() { R_TILDE_AUD_R, V_TILDE_AUD_S, A_TILDE_AUD_S, + R_TILDE_AUD_S, ); } @@ -1276,6 +1320,7 @@ fn rejects_identity_k_aud_s() { R_TILDE_AUD_R, V_TILDE_AUD_S, A_TILDE_AUD_S, + R_TILDE_AUD_S, ); } @@ -1313,6 +1358,7 @@ fn rejects_tampered_v_tilde_aud_r() { R_TILDE_AUD_R, V_TILDE_AUD_S, A_TILDE_AUD_S, + R_TILDE_AUD_S, ); } @@ -1350,6 +1396,7 @@ fn rejects_tampered_r_tilde_aud_r() { R_TILDE_AUD_R + 1, V_TILDE_AUD_S, A_TILDE_AUD_S, + R_TILDE_AUD_S, ); } @@ -1387,6 +1434,7 @@ fn rejects_tampered_v_tilde_aud_s() { R_TILDE_AUD_R, V_TILDE_AUD_S + 1, A_TILDE_AUD_S, + R_TILDE_AUD_S, ); } @@ -1424,6 +1472,199 @@ fn rejects_tampered_a_tilde_aud_s() { R_TILDE_AUD_R, V_TILDE_AUD_S, A_TILDE_AUD_S + 1, + R_TILDE_AUD_S, + ); +} + +// Salt and ephemeral reuse (characterization) +// ------------------------------------------- +// The only freshness this circuit enforces is `sigma_a' != sigma_a`, and only +// against the immediate predecessor; `r_e` is unchecked. The tests below pin +// what happens under reuse rather than asserting it is prevented. + +#[test] +fn alternating_sigma_a_reproduces_an_earlier_allowance_blinding() { + // A -> B -> A is accepted: the salt-rotation check only rules out the + // state the transfer consumes. Since r_a is a pure function of + // (dvk_i, sigma_a), returning to salt A reproduces salt A's blinding + // exactly -- R_A, the blinding of the very first allowance commitment. The + // consequence is linkability, not unsoundness: if the value also + // coincided, C_a would be byte-identical to a commitment already on-chain. + // Here the value differs, so only the blinding repeats. + let v_transfer_2: Field = 400; + let v_a_new_2: Field = V_A_NEW - v_transfer_2; // 500 + let r_e_2: Field = R_E + 3; + + // Back to SIGMA_A after one hop through SIGMA_A_NEW; the pads follow the + // written salt, so they key off SIGMA_A too. + let r_a_new_2 = derive_allow_r(DVK_I, SIGMA_A); + assert(r_a_new_2 == R_A); + + let pvk_recipient = + EmbeddedCurvePoint { x: PVK_RECIPIENT_X, y: PVK_RECIPIENT_Y, is_infinite: false }; + let k_aud_r = EmbeddedCurvePoint { x: K_AUD_R_X, y: K_AUD_R_Y, is_infinite: false }; + let k_aud_s = EmbeddedCurvePoint { x: K_AUD_S_X, y: K_AUD_S_Y, is_infinite: false }; + let s = ecdh(r_e_2, pvk_recipient); + let r_transfer_2 = derive_transfer_blind(s, SIGMA_A); + let c_transfer_2 = commit(v_transfer_2, r_transfer_2); + let v_tilde_2 = encrypt_amount(v_transfer_2, s, SIGMA_A); + let c_a_new_2 = commit(v_a_new_2, r_a_new_2); + let a_tilde_new_2 = encrypt_allowance(v_a_new_2, DVK_I, SIGMA_A); + let r_e_2_pt = scalar_mul(r_e_2, H); + let s_a_r_2 = ecdh(r_e_2, k_aud_r); + let m_r_2 = sponge_squeeze_2(domain::AUDITOR_RECIPIENT, s_a_r_2, SIGMA_A); + let s_a_s_2 = ecdh(r_e_2, k_aud_s); + let m_s_2 = sponge_squeeze_3(domain::AUDITOR_SENDER, s_a_s_2, SIGMA_A); + + run_main( + SK_OP, + DVK_I, + V_A_NEW, + derive_allow_r(DVK_I, SIGMA_A_NEW), + v_transfer_2, + r_e_2, + C_A_NEW_X, + C_A_NEW_Y, + SIGMA_A_NEW, + Y_OP_X, + Y_OP_Y, + PVK_RECIPIENT_X, + PVK_RECIPIENT_Y, + K_AUD_R_X, + K_AUD_R_Y, + K_AUD_S_X, + K_AUD_S_Y, + c_a_new_2.x, + c_a_new_2.y, + c_transfer_2.x, + c_transfer_2.y, + r_e_2_pt.x, + r_e_2_pt.y, + v_tilde_2, + a_tilde_new_2, + SIGMA_A, + v_transfer_2 + m_r_2[0], + r_transfer_2 + m_r_2[1], + v_transfer_2 + m_s_2[0], + v_a_new_2 + m_s_2[1], + r_a_new_2 + m_s_2[2], + ); +} + +#[test] +fn repeated_r_e_and_sigma_a_new_repeat_every_channel_pad() { + // Reusing (r_e, sigma_a') repeats s, s_{a,r}, and s_{a,s}, and with them + // every mask on all three channels. Re-encrypting the same plaintexts + // under them republishes byte-identical ciphertexts -- no differential. + let pvk_recipient = + EmbeddedCurvePoint { x: PVK_RECIPIENT_X, y: PVK_RECIPIENT_Y, is_infinite: false }; + let k_aud_r = EmbeddedCurvePoint { x: K_AUD_R_X, y: K_AUD_R_Y, is_infinite: false }; + let k_aud_s = EmbeddedCurvePoint { x: K_AUD_S_X, y: K_AUD_S_Y, is_infinite: false }; + let s = ecdh(R_E, pvk_recipient); + let m_r = sponge_squeeze_2(domain::AUDITOR_RECIPIENT, ecdh(R_E, k_aud_r), SIGMA_A_NEW); + let m_s = sponge_squeeze_3(domain::AUDITOR_SENDER, ecdh(R_E, k_aud_s), SIGMA_A_NEW); + assert(encrypt_amount(V_TRANSFER, s, SIGMA_A_NEW) == V_TILDE); + assert(V_TRANSFER + m_r[0] == V_TILDE_AUD_R); + assert(V_TRANSFER + m_s[0] == V_TILDE_AUD_S); + assert(V_A_NEW + m_s[1] == A_TILDE_AUD_S); + assert(derive_allow_r(DVK_I, SIGMA_A_NEW) + m_s[2] == R_TILDE_AUD_S); + + // Change a plaintext under the repeated pad and the difference survives in + // the clear. Two transfers of different sizes under one (r_e, sigma_a') + // publish v_tilde values whose difference is the amount delta. The circuit + // cannot catch this -- a reused r_e is a well-formed witness and O14 sees + // only the adjacent salt -- so it is the honest client's obligation + // (Section 5.3, Section 9.6, SDK.md Section 10.4). + let v_transfer_2: Field = V_TRANSFER + 40; + assert((v_transfer_2 + m_s[0]) - V_TILDE_AUD_S == 40); +} + +#[test(should_fail)] +fn rejects_escrow_of_the_consumed_allowance_blinding() { + // The prover escrows R_A -- the blinding of the allowance this transfer + // CONSUMES -- instead of O10's r_a', the blinding of the allowance it + // writes. Correctly padded under the canonical lane[2] mask, so only O_a9 + // can catch it, and it does. + // + // The distinction matters because the auditor's job is to track live + // state: a_tilde_aud_s (O_a8) already carries the post-transfer value, so + // pairing it with the pre-transfer blinding yields an opening of nothing + // on-chain. The auditor already knew the consumed opening -- it decrypted + // it from the previous operation's event. + let k_aud_s = EmbeddedCurvePoint { x: K_AUD_S_X, y: K_AUD_S_Y, is_infinite: false }; + let s_a_s = ecdh(R_E, k_aud_s); + let m_s = sponge_squeeze_3(domain::AUDITOR_SENDER, s_a_s, SIGMA_A_NEW); + run_main( + SK_OP, + DVK_I, + V_A, + R_A, + V_TRANSFER, + R_E, + C_A_X, + C_A_Y, + SIGMA_A, + Y_OP_X, + Y_OP_Y, + PVK_RECIPIENT_X, + PVK_RECIPIENT_Y, + K_AUD_R_X, + K_AUD_R_Y, + K_AUD_S_X, + K_AUD_S_Y, + C_A_NEW_X, + C_A_NEW_Y, + C_TRANSFER_X, + C_TRANSFER_Y, + R_E_X, + R_E_Y, + V_TILDE, + A_TILDE_NEW, + SIGMA_A_NEW, + V_TILDE_AUD_R, + R_TILDE_AUD_R, + V_TILDE_AUD_S, + A_TILDE_AUD_S, + R_A + m_s[2], + ); +} + +#[test(should_fail)] +fn rejects_tampered_r_tilde_aud_s() { + // r_tilde_aud_s mutated by +1: O_a9 fails because the lane[2] escrow is + // pinned to O10's r_a', which the prover's claim no longer matches. + run_main( + SK_OP, + DVK_I, + V_A, + R_A, + V_TRANSFER, + R_E, + C_A_X, + C_A_Y, + SIGMA_A, + Y_OP_X, + Y_OP_Y, + PVK_RECIPIENT_X, + PVK_RECIPIENT_Y, + K_AUD_R_X, + K_AUD_R_Y, + K_AUD_S_X, + K_AUD_S_Y, + C_A_NEW_X, + C_A_NEW_Y, + C_TRANSFER_X, + C_TRANSFER_Y, + R_E_X, + R_E_Y, + V_TILDE, + A_TILDE_NEW, + SIGMA_A_NEW, + V_TILDE_AUD_R, + R_TILDE_AUD_R, + V_TILDE_AUD_S, + A_TILDE_AUD_S, + R_TILDE_AUD_S + 1, ); } @@ -1462,6 +1703,7 @@ fn rejects_wrong_k_aud_r() { R_TILDE_AUD_R, V_TILDE_AUD_S, A_TILDE_AUD_S, + R_TILDE_AUD_S, ); } @@ -1500,5 +1742,6 @@ fn rejects_wrong_k_aud_s() { R_TILDE_AUD_R, V_TILDE_AUD_S, A_TILDE_AUD_S, + R_TILDE_AUD_S, ); } diff --git a/packages/tokens/src/confidential/circuits/transfer/src/main.nr b/packages/tokens/src/confidential/circuits/transfer/src/main.nr index e7dbedb49..2574c68f9 100644 --- a/packages/tokens/src/confidential/circuits/transfer/src/main.nr +++ b/packages/tokens/src/confidential/circuits/transfer/src/main.nr @@ -1,6 +1,6 @@ use stellar_confidential_lib::{ H, assert_on_curve_non_identity, commit, derive_spend_r, derive_transfer_blind, domain, ecdh, - encrypt_amount, encrypt_balance, scalar_mul, sponge_squeeze_2, vk_from_sk, + encrypt_amount, encrypt_balance, scalar_mul, sponge_squeeze_2, sponge_squeeze_3, vk_from_sk, }; use std::embedded_curve_ops::EmbeddedCurvePoint; @@ -68,15 +68,23 @@ mod tests; // T_a5 s_{a,s} = ecdh(r_e, K_aud_s) Sender-auditor ECDH // shared scalar // (reuses r_e). -// T_a6 (m_v_s, m_b_s) -// = SpongeSqueeze_2(delta_aud_s, +// T_a6 (m_v_s, m_b_s, m_r_s) +// = SpongeSqueeze_3(delta_aud_s, // s_{a,s}, sigma) Sender-channel -// sponge: two masks. +// sponge: three masks. // T_a7 v_tilde_aud_s = v_transfer + m_v_s Sender-auditor // encrypted amount. // T_a8 b_tilde_aud_s = (v_A - v_transfer) + m_b_s Sender-auditor // encrypted balance // checkpoint. +// T_a9 r_tilde_aud_s = r_A' + m_r_s Sender-auditor +// escrow of the new +// spendable blinding, +// over T10's r_A'. +// Lets the auditor +// track the full +// opening of C_spend +// forward. // // Point-validation doctrine (Section 10.8) // ---------------------------------------- @@ -91,7 +99,7 @@ mod tests; // T_a1 / T_a5, so this file explicitly validates both on-curve AND // non-identity before the auditor block runs. // -// Public inputs (24 fields, in design-doc canonical order) +// Public inputs (25 fields, in design-doc canonical order) // -------------------------------------------------------- // Idx Param Symbol Source / Note // --- ----- ------ ----------------------------------- @@ -127,6 +135,9 @@ mod tests; // 23 b_tilde_aud_s b_tilde_aud_s Prover-supplied sender-auditor // encrypted balance checkpoint; // emitted. +// 24 r_tilde_aud_s r_tilde_aud_s Prover-supplied sender-auditor +// escrow of the new spendable +// blinding; emitted. // // Private witnesses // ----------------- @@ -167,6 +178,7 @@ fn main( r_tilde_aud_r: pub Field, v_tilde_aud_s: pub Field, b_tilde_aud_s: pub Field, + r_tilde_aud_s: pub Field, ) { // T13 -- runs first so the r_e = 0 attack is rejected before any // scalar mul against it could quietly produce the identity. @@ -248,12 +260,16 @@ fn main( // T_a5 (sender-auditor shared-secret scalar) let s_a_s = ecdh(r_e, k_aud_s); - // T_a6 (sender-channel masks: amount, then balance) - let m_s = sponge_squeeze_2(domain::AUDITOR_SENDER, s_a_s, sigma); + // T_a6 (sender-channel masks: amount, balance, then the secret-escrow + // slot) + let m_s = sponge_squeeze_3(domain::AUDITOR_SENDER, s_a_s, sigma); // T_a7 assert(v_transfer + m_s[0] == v_tilde_aud_s); // T_a8 assert(v_new + m_s[1] == b_tilde_aud_s); + + // T_a9 + assert(r_new + m_s[2] == r_tilde_aud_s); } diff --git a/packages/tokens/src/confidential/circuits/transfer/src/tests.nr b/packages/tokens/src/confidential/circuits/transfer/src/tests.nr index 5ff281fbd..3003de492 100644 --- a/packages/tokens/src/confidential/circuits/transfer/src/tests.nr +++ b/packages/tokens/src/confidential/circuits/transfer/src/tests.nr @@ -1,7 +1,7 @@ use crate::main; use stellar_confidential_lib::{ commit, derive_spend_r, derive_transfer_blind, domain, ecdh, encrypt_amount, encrypt_balance, H, - scalar_mul, sponge_squeeze_2, vk_from_sk, + scalar_mul, sponge_squeeze_2, sponge_squeeze_3, vk_from_sk, }; use std::embedded_curve_ops::EmbeddedCurvePoint; @@ -71,6 +71,9 @@ global V_TILDE_AUD_S: Field = 0x0d0a3aa77960977737f4355093d4672ea9f95556fabf63bda0913ebef52c42cf; global B_TILDE_AUD_S: Field = 0x288ff48898f218dbba2a5c9546d0986af3257a4a539e23e4f35c404d6720c144; +// lane[2] secret-escrow slot: r_tilde_aud_s = r_A' + m_r_s, over T10's r_A'. +global R_TILDE_AUD_S: Field = + 0x12bae1b1e1b12493e37d427d3ee28b481c37f7f456c4ab0c022b8ba6262ca57f; #[test] fn print_fixtures() { @@ -99,9 +102,10 @@ fn print_fixtures() { let r_tilde_aud_r = r_transfer + m_r[1]; let s_a_s = ecdh(R_E, k_aud_s); - let m_s = sponge_squeeze_2(domain::AUDITOR_SENDER, s_a_s, SIGMA); + let m_s = sponge_squeeze_3(domain::AUDITOR_SENDER, s_a_s, SIGMA); let v_tilde_aud_s = V_TRANSFER + m_s[0]; let b_tilde_aud_s = V_NEW + m_s[1]; + let r_tilde_aud_s = r_new + m_s[2]; let pvkx = pvk_b.x; let pvky = pvk_b.y; @@ -133,6 +137,7 @@ fn print_fixtures() { println(f"R_TILDE_AUD_R = {r_tilde_aud_r}"); println(f"V_TILDE_AUD_S = {v_tilde_aud_s}"); println(f"B_TILDE_AUD_S = {b_tilde_aud_s}"); + println(f"R_TILDE_AUD_S = {r_tilde_aud_s}"); } #[test] @@ -174,7 +179,9 @@ fn transfer_auditor_fixtures_match_lib() { let s_a_r = ecdh(R_E, k_aud_r); let m_r = sponge_squeeze_2(domain::AUDITOR_RECIPIENT, s_a_r, SIGMA); let s_a_s = ecdh(R_E, k_aud_s); - let m_s = sponge_squeeze_2(domain::AUDITOR_SENDER, s_a_s, SIGMA); + let m_s = sponge_squeeze_3(domain::AUDITOR_SENDER, s_a_s, SIGMA); + let vk = vk_from_sk(SK, ADDR_F); + let r_new = derive_spend_r(vk, SIGMA); assert(k_aud_r.x == K_AUD_R_X); assert(k_aud_r.y == K_AUD_R_Y); @@ -186,9 +193,10 @@ fn transfer_auditor_fixtures_match_lib() { assert(r_transfer + m_r[1] == R_TILDE_AUD_R); assert(V_TRANSFER + m_s[0] == V_TILDE_AUD_S); assert(V_NEW + m_s[1] == B_TILDE_AUD_S); + assert(r_new + m_s[2] == R_TILDE_AUD_S); } -// `run_main` accepts all 24 public inputs as parameters -- nothing baked in. +// `run_main` accepts every public input as a parameter -- 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 @@ -223,6 +231,7 @@ fn run_main( r_tilde_aud_r_in: Field, v_tilde_aud_s_in: Field, b_tilde_aud_s_in: Field, + r_tilde_aud_s_in: Field, ) { main( sk_in, @@ -254,6 +263,7 @@ fn run_main( r_tilde_aud_r_in, v_tilde_aud_s_in, b_tilde_aud_s_in, + r_tilde_aud_s_in, ); } @@ -288,6 +298,7 @@ fn run_fixture() { R_TILDE_AUD_R, V_TILDE_AUD_S, B_TILDE_AUD_S, + R_TILDE_AUD_S, ); } @@ -316,7 +327,7 @@ fn full_balance_transfer() { let s_a_r = ecdh(R_E, k_aud_r); let m_r = sponge_squeeze_2(domain::AUDITOR_RECIPIENT, s_a_r, SIGMA); let s_a_s = ecdh(R_E, k_aud_s); - let m_s = sponge_squeeze_2(domain::AUDITOR_SENDER, s_a_s, SIGMA); + let m_s = sponge_squeeze_3(domain::AUDITOR_SENDER, s_a_s, SIGMA); run_main( SK, V, @@ -347,6 +358,7 @@ fn full_balance_transfer() { r_transfer + m_r[1], V + m_s[0], 0 + m_s[1], + r_new + m_s[2], ); } @@ -370,7 +382,7 @@ fn rejects_under_funded_transfer() { let s_a_r = ecdh(R_E, k_aud_r); let m_r = sponge_squeeze_2(domain::AUDITOR_RECIPIENT, s_a_r, SIGMA); let s_a_s = ecdh(R_E, k_aud_s); - let m_s = sponge_squeeze_2(domain::AUDITOR_SENDER, s_a_s, SIGMA); + let m_s = sponge_squeeze_3(domain::AUDITOR_SENDER, s_a_s, SIGMA); run_main( SK, V, @@ -401,6 +413,7 @@ fn rejects_under_funded_transfer() { r_transfer + m_r[1], v_transfer_too_large + m_s[0], (V - v_transfer_too_large) + m_s[1], + r_new + m_s[2], ); } @@ -422,7 +435,7 @@ fn rejects_v_out_of_range() { let c_new = commit(v_new_huge, r_new); let b_tilde = encrypt_balance(v_new_huge, vk, SIGMA); let s_a_s = ecdh(R_E, k_aud_s); - let m_s = sponge_squeeze_2(domain::AUDITOR_SENDER, s_a_s, SIGMA); + let m_s = sponge_squeeze_3(domain::AUDITOR_SENDER, s_a_s, SIGMA); run_main( SK, v_huge, @@ -453,6 +466,7 @@ fn rejects_v_out_of_range() { R_TILDE_AUD_R, V_TILDE_AUD_S, v_new_huge + m_s[1], + r_new + m_s[2], ); } @@ -480,7 +494,7 @@ fn rejects_v_transfer_out_of_range() { let s_a_r = ecdh(R_E, k_aud_r); let m_r = sponge_squeeze_2(domain::AUDITOR_RECIPIENT, s_a_r, SIGMA); let s_a_s = ecdh(R_E, k_aud_s); - let m_s = sponge_squeeze_2(domain::AUDITOR_SENDER, s_a_s, SIGMA); + let m_s = sponge_squeeze_3(domain::AUDITOR_SENDER, s_a_s, SIGMA); run_main( SK, V, @@ -511,6 +525,7 @@ fn rejects_v_transfer_out_of_range() { r_transfer + m_r[1], v_transfer_huge + m_s[0], v_new_huge + m_s[1], + r_new + m_s[2], ); } @@ -555,6 +570,7 @@ fn rejects_wrong_sk() { R_TILDE_AUD_R, V_TILDE_AUD_S, B_TILDE_AUD_S, + R_TILDE_AUD_S, ); } @@ -571,7 +587,7 @@ fn rejects_wrong_balance_opening() { let c_new_bad = commit(v_new_bad, r_new); let b_tilde_bad = encrypt_balance(v_new_bad, vk, SIGMA); let s_a_s = ecdh(R_E, k_aud_s); - let m_s = sponge_squeeze_2(domain::AUDITOR_SENDER, s_a_s, SIGMA); + let m_s = sponge_squeeze_3(domain::AUDITOR_SENDER, s_a_s, SIGMA); run_main( SK, v_bad, @@ -602,6 +618,7 @@ fn rejects_wrong_balance_opening() { R_TILDE_AUD_R, V_TILDE_AUD_S, v_new_bad + m_s[1], + r_new + m_s[2], ); } @@ -648,6 +665,7 @@ fn rejects_wrong_addr_f() { R_TILDE_AUD_R, V_TILDE_AUD_S, B_TILDE_AUD_S, + R_TILDE_AUD_S, ); } @@ -686,6 +704,7 @@ fn rejects_wrong_recipient_pvk() { R_TILDE_AUD_R, V_TILDE_AUD_S, B_TILDE_AUD_S, + R_TILDE_AUD_S, ); } @@ -730,6 +749,7 @@ fn rejects_poisoned_c_transfer() { R_TILDE_AUD_R, V_TILDE_AUD_S, B_TILDE_AUD_S, + R_TILDE_AUD_S, ); } @@ -766,6 +786,7 @@ fn rejects_tampered_v_tilde() { R_TILDE_AUD_R, V_TILDE_AUD_S, B_TILDE_AUD_S, + R_TILDE_AUD_S, ); } @@ -802,6 +823,7 @@ fn rejects_tampered_b_tilde() { R_TILDE_AUD_R, V_TILDE_AUD_S, B_TILDE_AUD_S, + R_TILDE_AUD_S, ); } @@ -838,6 +860,7 @@ fn rejects_tampered_c_spend_new() { R_TILDE_AUD_R, V_TILDE_AUD_S, B_TILDE_AUD_S, + R_TILDE_AUD_S, ); } @@ -863,7 +886,8 @@ fn rejects_r_e_zero() { let s_a_r_zero = ecdh(0, k_aud_r); let m_r_zero = sponge_squeeze_2(domain::AUDITOR_RECIPIENT, s_a_r_zero, SIGMA); let s_a_s_zero = ecdh(0, k_aud_s); - let m_s_zero = sponge_squeeze_2(domain::AUDITOR_SENDER, s_a_s_zero, SIGMA); + let m_s_zero = sponge_squeeze_3(domain::AUDITOR_SENDER, s_a_s_zero, SIGMA); + let r_new = derive_spend_r(vk_from_sk(SK, ADDR_F), SIGMA); run_main( SK, V, @@ -894,6 +918,7 @@ fn rejects_r_e_zero() { r_transfer_zero + m_r_zero[1], V_TRANSFER + m_s_zero[0], V_NEW + m_s_zero[1], + r_new + m_s_zero[2], ); } @@ -915,7 +940,8 @@ fn rejects_wrong_r_e() { let s_a_r_bad = ecdh(r_e_bad, k_aud_r); let m_r_bad = sponge_squeeze_2(domain::AUDITOR_RECIPIENT, s_a_r_bad, SIGMA); let s_a_s_bad = ecdh(r_e_bad, k_aud_s); - let m_s_bad = sponge_squeeze_2(domain::AUDITOR_SENDER, s_a_s_bad, SIGMA); + let m_s_bad = sponge_squeeze_3(domain::AUDITOR_SENDER, s_a_s_bad, SIGMA); + let r_new = derive_spend_r(vk_from_sk(SK, ADDR_F), SIGMA); run_main( SK, V, @@ -946,6 +972,7 @@ fn rejects_wrong_r_e() { r_transfer_bad + m_r_bad[1], V_TRANSFER + m_s_bad[0], V_NEW + m_s_bad[1], + r_new + m_s_bad[2], ); } @@ -983,6 +1010,7 @@ fn rejects_off_curve_k_aud_r() { R_TILDE_AUD_R, V_TILDE_AUD_S, B_TILDE_AUD_S, + R_TILDE_AUD_S, ); } @@ -1020,6 +1048,7 @@ fn rejects_identity_k_aud_r() { R_TILDE_AUD_R, V_TILDE_AUD_S, B_TILDE_AUD_S, + R_TILDE_AUD_S, ); } @@ -1055,6 +1084,7 @@ fn rejects_off_curve_k_aud_s() { R_TILDE_AUD_R, V_TILDE_AUD_S, B_TILDE_AUD_S, + R_TILDE_AUD_S, ); } @@ -1090,6 +1120,7 @@ fn rejects_identity_k_aud_s() { R_TILDE_AUD_R, V_TILDE_AUD_S, B_TILDE_AUD_S, + R_TILDE_AUD_S, ); } @@ -1127,6 +1158,7 @@ fn rejects_wrong_k_aud_r() { R_TILDE_AUD_R, V_TILDE_AUD_S, B_TILDE_AUD_S, + R_TILDE_AUD_S, ); } @@ -1164,6 +1196,7 @@ fn rejects_wrong_k_aud_s() { R_TILDE_AUD_R, V_TILDE_AUD_S, B_TILDE_AUD_S, + R_TILDE_AUD_S, ); } @@ -1200,6 +1233,7 @@ fn rejects_tampered_v_tilde_aud_r() { R_TILDE_AUD_R, V_TILDE_AUD_S, B_TILDE_AUD_S, + R_TILDE_AUD_S, ); } @@ -1236,6 +1270,7 @@ fn rejects_tampered_r_tilde_aud_r() { R_TILDE_AUD_R + 1, V_TILDE_AUD_S, B_TILDE_AUD_S, + R_TILDE_AUD_S, ); } @@ -1272,6 +1307,7 @@ fn rejects_tampered_v_tilde_aud_s() { R_TILDE_AUD_R, V_TILDE_AUD_S + 1, B_TILDE_AUD_S, + R_TILDE_AUD_S, ); } @@ -1308,5 +1344,45 @@ fn rejects_tampered_b_tilde_aud_s() { R_TILDE_AUD_R, V_TILDE_AUD_S, B_TILDE_AUD_S + 1, + R_TILDE_AUD_S, + ); +} + +#[test(should_fail)] +fn rejects_tampered_r_tilde_aud_s() { + // r_tilde_aud_s mutated by +1: T_a9 fails because the lane[2] escrow is + // pinned to r_A' = derive_spend_r(vk, sigma), which the prover's claim no + // longer matches. + run_main( + SK, + V, + R, + V_TRANSFER, + R_E, + C_SPEND_X, + C_SPEND_Y, + Y_X, + Y_Y, + ADDR_F, + PVK_B_X, + PVK_B_Y, + K_AUD_R_X, + K_AUD_R_Y, + K_AUD_S_X, + K_AUD_S_Y, + C_SPEND_NEW_X, + C_SPEND_NEW_Y, + C_TRANSFER_X, + C_TRANSFER_Y, + R_E_X, + R_E_Y, + V_TILDE, + B_TILDE, + SIGMA, + V_TILDE_AUD_R, + R_TILDE_AUD_R, + V_TILDE_AUD_S, + B_TILDE_AUD_S, + R_TILDE_AUD_S + 1, ); } diff --git a/packages/tokens/src/confidential/circuits/vks/set_spender.vk.json b/packages/tokens/src/confidential/circuits/vks/set_spender.vk.json index ad7189e2e..04e179c65 100644 --- a/packages/tokens/src/confidential/circuits/vks/set_spender.vk.json +++ b/packages/tokens/src/confidential/circuits/vks/set_spender.vk.json @@ -1 +1 @@ -["0x0000000000000000000000000000000000000000000000000000000000008000","0x0000000000000000000000000000000000000000000000000000000000000028","0x0000000000000000000000000000000000000000000000000000000000000001","0x0000000000000000000000000000000000000000000000000000000000000018","0x0000000000000000000000000000001b0bcac9dce30ac04720972a66a06045ea","0x00000000000000000000000000000000001005336c4f381202589b3506ea8f40","0x000000000000000000000000000000de74ae5a9880b088bd173f1253ef36b0f1","0x000000000000000000000000000000000022c52e6340dce6f64c0656851171d7","0x0000000000000000000000000000001697d4f845b0f618397a0859f76b87db3c","0x00000000000000000000000000000000000adff912596c6d0e9127a7a9346265","0x0000000000000000000000000000007a9b82f177aaf60304dd06235035b4141c","0x00000000000000000000000000000000000f0a488a0b134d5d6602bfe3edb649","0x000000000000000000000000000000aa3d3ba88ee808b0f52fbeb71b6326471c","0x00000000000000000000000000000000002dac6cc6570585ab23727b26e0790e","0x00000000000000000000000000000044ff7cf6231dc58f2afa332937fd32f2b6","0x000000000000000000000000000000000000161b6466c09a362b884277791a8d","0x000000000000000000000000000000599dac4cde8b4e76a62a8c0f8106d69b71","0x000000000000000000000000000000000004030c61ba95aa7b622819252ec92a","0x000000000000000000000000000000f19d4ef66d26b5f1ecdcb5d78fda68f9f0","0x0000000000000000000000000000000000226f614c0287897e67e8a52f4b2392","0x0000000000000000000000000000006bc680d3a2fe67c82c091b942fcffd6aea","0x00000000000000000000000000000000001ebd8c4548c120f831a800e7847359","0x0000000000000000000000000000008ba19baf8afa354c14261b6a5665dc8e36","0x00000000000000000000000000000000002e357a2f9d6d16047650a3723501af","0x000000000000000000000000000000f103296c7c0ee57e40360bece45c447647","0x000000000000000000000000000000000004be12eeccc78897157fac581ed031","0x00000000000000000000000000000097033d659d46e14032695d33c195a5ed39","0x00000000000000000000000000000000001034e051dd92a38cf67180452968b5","0x0000000000000000000000000000005738f222d113ad96d990e7d4a004ec2e57","0x000000000000000000000000000000000014e35bf3517c8beabf7ec116bd4c00","0x000000000000000000000000000000d89185b10a6c32362672867293fa6e87a6","0x00000000000000000000000000000000002cbca48853bc69fafe318ba9e1f9cc","0x000000000000000000000000000000aba4586d2fb72d76d0392998c8c56f2fc0","0x00000000000000000000000000000000001e8b35596e134832bfbae1a9ddf0eb","0x000000000000000000000000000000a28c3c027e9a472c1663729c83a9c9f61f","0x000000000000000000000000000000000003f5630f2c0e0c6ed17957ca93cc1c","0x000000000000000000000000000000a9b3c578d9cd2f1b1ec4cd74cbb47e588c","0x00000000000000000000000000000000002588344fb20402b460766f9a5e62d7","0x000000000000000000000000000000f6dac0c80d62ca550666d7d5651ca8bedf","0x00000000000000000000000000000000000e40b92ee16be807f7576e7bbf36fa","0x00000000000000000000000000000033fd31cd667d95e8815f8ce997fcf355f7","0x00000000000000000000000000000000001343b3298cd02f5e168eba6d326761","0x000000000000000000000000000000e70069899227e4a42f1a37c20db25ad0bd","0x000000000000000000000000000000000022161b052ea12746192f6ef2c11086","0x000000000000000000000000000000c6ab2981c28c889cc89b97ce23c63e50d2","0x0000000000000000000000000000000000181e22ec1ba42eb1dfac60647880bd","0x000000000000000000000000000000ad3dfcdd8bcc37294862278a1bf1dd376c","0x000000000000000000000000000000000020320840931d4a9f2c030d8aadd1b4","0x000000000000000000000000000000f2e71144323f7b8f1c4bf834b57f905a10","0x00000000000000000000000000000000001e362c6a6547a01dec544d44850762","0x0000000000000000000000000000001e6bd3938c4d6ebfc1a7baebccf8889c2f","0x00000000000000000000000000000000001378d134ae3b553e13c7e875fc8896","0x0000000000000000000000000000003c83eda777391195746d9756b6d1acf5b0","0x0000000000000000000000000000000000297221eab510af95e082f8063670be","0x000000000000000000000000000000d0b7d7279191ed16bbf861d8bca47ae618","0x00000000000000000000000000000000001937c9db207067e0818b6aff991f71","0x0000000000000000000000000000006d484eca98129ae65415906f90a2ff48dd","0x00000000000000000000000000000000000e44731657c7a04b3803a79b0f8486","0x000000000000000000000000000000d62fdd8b978d19f1c710c42200f654c21a","0x00000000000000000000000000000000000e763c5b7f4495c87247d3e13c4e13","0x000000000000000000000000000000c6e6c07d5a41fe47f8d18d134f7e808800","0x000000000000000000000000000000000016e81a27c47bf738979584678dae77","0x00000000000000000000000000000001ae5dfec41320fbc1e7b95db2838c680a","0x000000000000000000000000000000000024cde7dab07610ea93412f11453a32","0x0000000000000000000000000000001ddb85dc8eb1c6edc0045b51a0ee0f48dc","0x00000000000000000000000000000000002eee1fd4872d69bd436afb60791f62","0x000000000000000000000000000000b76f302a35cabe286feef1b2b87807a59e","0x00000000000000000000000000000000002cb81803dea497d27050884f9c57fc","0x000000000000000000000000000000a95e23fe3ad0ed90661fd062bdcbdca094","0x00000000000000000000000000000000000b714b2bd9892df5778ab317918a54","0x0000000000000000000000000000005a60aae5b608307a16b720f25c29de8e6e","0x00000000000000000000000000000000001a319eb1e6d0bccdd75582d6e9f377","0x0000000000000000000000000000000c3ad607a1c36e9f7b4fdb7cb1a7a1b2fd","0x00000000000000000000000000000000001b156a2d46be70bf538ffdbc013ce8","0x000000000000000000000000000000f26cb23c8a10506a2463163c391a289ebd","0x00000000000000000000000000000000000ca204c0a8832cd57c8b878af3df85","0x000000000000000000000000000000ff68879b815fb6861f7627c8ec2114e88d","0x00000000000000000000000000000000000c686cf57a1ed1e0c3b2182623d953","0x000000000000000000000000000000663f00e180b27021afb0215176b19b3d3b","0x00000000000000000000000000000000000bf7230dbfc8df3477fd73d53b98b4","0x00000000000000000000000000000085bd8af700fb001391fc21293e8b549a02","0x0000000000000000000000000000000000068fbe20a938fa7fa34f58cbb64241","0x0000000000000000000000000000009b8b4ef3bff9f8b8d73e4bb08877e38ae9","0x000000000000000000000000000000000010ebddcc02b3858b88870e1c13cd34","0x00000000000000000000000000000022916750324a78c8e6dc452965815fc85a","0x00000000000000000000000000000000000dc88aee00f84c70bea95184b92b5c","0x000000000000000000000000000000b478e858150b16f1b2121e096caa90001b","0x000000000000000000000000000000000010105d3a704f56969fbb1ca64a2754","0x000000000000000000000000000000ffad1d95a8ee7ccd2287a489375de526f6","0x00000000000000000000000000000000002549709cd2124b19800eed4f003e52","0x00000000000000000000000000000043c4b44d03e114c03c1689f77c5789643c","0x00000000000000000000000000000000000621edcb5a9eab5ae5675b4da9d9a2","0x000000000000000000000000000000d0492104db91551b307d94f2ab08cc79f4","0x000000000000000000000000000000000025f2f754a0726238075d9def0babb9","0x000000000000000000000000000000cb74279f288c14712784e2fa6490e4536b","0x00000000000000000000000000000000001cb622d0b3b58b69ee8edce2674300","0x000000000000000000000000000000e2295164532fcce82a3c013e597c594ef8","0x00000000000000000000000000000000001189d45faed739fc4e16a3dd3f9b7b","0x00000000000000000000000000000019a9d0d71575acbc5f5960590bd2f40a84","0x00000000000000000000000000000000000c0d0b7c8a283361906d364db189df","0x000000000000000000000000000000ffec2c04e68f5bbfb7a031a8dfe73f8bc6","0x00000000000000000000000000000000000a3ebe2336d65cff7e94ebb4a6f2df","0x0000000000000000000000000000000e2e091047811681c2716df0d71df042c8","0x00000000000000000000000000000000001ced77a29b750aec5c8fa1e3c63876","0x0000000000000000000000000000000000000000000000000000000000000001","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000002","0x0000000000000000000000000000000000000000000000000000000000000000","0x000000000000000000000000000000ffec6ec615ca63e74c4d735795decbb02c","0x0000000000000000000000000000000000211ababdad6ea01bfd11d305f2860c","0x000000000000000000000000000000830e6b43237b7bff3b6cccd19f8fafeae8","0x0000000000000000000000000000000000184a72052579e9ff70b59493d34fb2"] \ No newline at end of file +["0x0000000000000000000000000000000000000000000000000000000000008000","0x000000000000000000000000000000000000000000000000000000000000002a","0x0000000000000000000000000000000000000000000000000000000000000001","0x000000000000000000000000000000000000000000000000000000000000001a","0x0000000000000000000000000000001ee8326ff6fa4d9eaa698e6140f07f937b","0x0000000000000000000000000000000000106a0a88936a807b905fd25fa6eae5","0x0000000000000000000000000000007c8a46d26563134c136509e706b746f1c4","0x00000000000000000000000000000000000590438230856dc4e29b2b487f82a6","0x0000000000000000000000000000008fda4fa9a21711c3439d8c7bdc8706a35a","0x000000000000000000000000000000000026ee93cae09f66482049b8ab9f18ee","0x000000000000000000000000000000fa6519a25edb2d71cde10a7979794e21ff","0x00000000000000000000000000000000000783403486a30c30781b1336b06de0","0x0000000000000000000000000000002c88f0e429c3aa1b48f7f93509eb30bc9a","0x00000000000000000000000000000000000d417179f4c315c350cc1107b9cfc9","0x000000000000000000000000000000b0b25c47633b74d1a2bf1cd00ffce7b12c","0x000000000000000000000000000000000007ce4d696b72e2d8452d52a7caf9de","0x0000000000000000000000000000002d12248226512f020e334317dae6eaf0c8","0x000000000000000000000000000000000018cccf723238de695bcde25904b27d","0x000000000000000000000000000000f88387a18ddc16904307058f7d7a883e4a","0x00000000000000000000000000000000001d06b3270da56a3607007ee7ad0f1e","0x000000000000000000000000000000ee1a79ad9afc1d02b840e27c5283e74499","0x000000000000000000000000000000000028d1b1924fe7cf03f605e701eccd20","0x000000000000000000000000000000a2e8413b26a238c1274c1c4bc1bf3fa366","0x000000000000000000000000000000000028554b198c6912b267e8a6bf0987da","0x000000000000000000000000000000725ddea9830a058064e593ae1150357563","0x00000000000000000000000000000000000be21052e0ca4bbc01af225ab441ef","0x00000000000000000000000000000039b1667579c4ac66601d7962273b2bd832","0x00000000000000000000000000000000001e82560f75396646bb960ee9ec317b","0x000000000000000000000000000000933174c97d07b3e2e562d13bd83ed809d0","0x00000000000000000000000000000000002c183f1ad7406f0e39afd5ae8a4d66","0x000000000000000000000000000000cf09eb98da45a5b7fd4fc995638326c2a7","0x00000000000000000000000000000000000465c7d5dfc1c170aa4885b2933dfa","0x0000000000000000000000000000004488817604cfb82b60e35ade6c2bfbbde4","0x000000000000000000000000000000000025df503deb0f9d455fd7fdbc35501d","0x0000000000000000000000000000005b0af382eaa78b050823e07021a2596730","0x000000000000000000000000000000000013296f3f21d5be36f6c3de4f7e12bb","0x00000000000000000000000000000013e4981e445ea861b4d191c94874786ced","0x000000000000000000000000000000000009f2a00d23e559fc77b790f178d644","0x0000000000000000000000000000005a04ff9ac166a13efe0bbb42af5a9370a2","0x00000000000000000000000000000000001d488faa8537a3184ed1838cfa8da9","0x0000000000000000000000000000006ebe4f355ba6a3fc1d7e57f657359fcf7e","0x00000000000000000000000000000000001970bb0ec1ae1d702597e0504befc0","0x000000000000000000000000000000ff7fa0375289b0c62312d5c20c6e9b9d82","0x0000000000000000000000000000000000242ff04c2fb35a5b4940794a5e0c9b","0x000000000000000000000000000000e24c5739a33f3016deeb3150ca6cd333ed","0x0000000000000000000000000000000000168f59e1c5afdc0b6f4351e098969d","0x0000000000000000000000000000008fabee483e7aa0b20e40295d2fee6e79da","0x0000000000000000000000000000000000121880c763fa3fd1bdfc39945bf18a","0x000000000000000000000000000000df647f1842d3f41f64ad17f3fef8cad92a","0x00000000000000000000000000000000000baa779152f64c30acd67fb6a96532","0x00000000000000000000000000000040611399572dd13dfc9e5711b25a957117","0x00000000000000000000000000000000000233a10ee44b41e3123d589039619e","0x000000000000000000000000000000ed142ab059b436c2440f0567e5985e038e","0x0000000000000000000000000000000000107423e6071c6594e7960804836a87","0x0000000000000000000000000000008c0b459dfd4818f2ce5d4622d460ac4b3b","0x000000000000000000000000000000000027f96010efb256b54377ea2cf2203b","0x0000000000000000000000000000009fc85e0c56770304c47b796b55ac95f26e","0x00000000000000000000000000000000000c9f12f0b2dc198d1d714a54c15f07","0x000000000000000000000000000000f25195b7b261832155e87a05dcf0c0910b","0x00000000000000000000000000000000002ec2de20c7e0b55f993be81484ab72","0x000000000000000000000000000000785648e012fdba3ea8946ba29cd6b6289d","0x0000000000000000000000000000000000054fa9f89a5fdb8cc9a1cd7ec7167f","0x000000000000000000000000000000dcc51423d919d1b391abf47e023905742f","0x00000000000000000000000000000000001d15d476416457f487b97af414ea1a","0x000000000000000000000000000000a248122c42f86368af6a0c74a9a25d5f9c","0x000000000000000000000000000000000000ee8f7378796672556adecf8f6c63","0x000000000000000000000000000000e8c233c18d70dfd6675ae5dd7df5e29271","0x00000000000000000000000000000000002c56c41a07a3006c2830ac414004e7","0x000000000000000000000000000000c675e97962a8230f86a1093d714e9f0f05","0x00000000000000000000000000000000001b125d3316c14b62c532f6b8b2e4fe","0x00000000000000000000000000000089bd1899f66cf8f75b58a2b60a9de48cf7","0x00000000000000000000000000000000002706f62382cc7e17c7e531832ff966","0x000000000000000000000000000000d26a37225805d4e0170474e9e02188fb21","0x00000000000000000000000000000000001e50e6e7ad7be4d8dacbf29e940af4","0x000000000000000000000000000000efba449cb7d241c0dd520375104721726d","0x00000000000000000000000000000000001b095bb696a673bd0c5a116e27b404","0x0000000000000000000000000000006dba6a66fc696e4d1e69e3c6a93317ec77","0x000000000000000000000000000000000019ec4850bfd37b9ff6c6c37ee18ea0","0x000000000000000000000000000000e2f05188e6cf778a0e83cbd802df2137dc","0x00000000000000000000000000000000000648fd3d1938953473fbf1992a1a0d","0x0000000000000000000000000000004b8a4408e4550a88712539ac23123da4d9","0x00000000000000000000000000000000002d42bdc43b945b84bb790ce832b4fa","0x0000000000000000000000000000001c3b96a17adf9ede03ef8631fb3dd882e9","0x000000000000000000000000000000000002042c33e5a67a0466ab51daa1cd58","0x000000000000000000000000000000c718f214b89a0282b726a88d928a48a5e1","0x00000000000000000000000000000000001adfcfe9e07545d3f43ca4491dd2ef","0x000000000000000000000000000000b01dbb08a6e78d278fb9777e55f5062e2c","0x0000000000000000000000000000000000122a27907479081d47ce05e12a9bc2","0x0000000000000000000000000000008273c63c240753dd46d3ebe5905c877ceb","0x000000000000000000000000000000000029bf117651e308871fd01200cd3d97","0x000000000000000000000000000000e7c89b48a1f264687e29c5bb6a3b29b0ea","0x000000000000000000000000000000000018c2c4ecff68ed36990b085ae6eeb0","0x0000000000000000000000000000005206f07655d92b74b8f70b49b3c0d20b6d","0x000000000000000000000000000000000014e1b95e20c0363366a7d450ddb471","0x000000000000000000000000000000c3188cd7a04d6c8a8803f89c399e9bc958","0x00000000000000000000000000000000001480c491cfda58b06ac1de20233d25","0x00000000000000000000000000000017515b2bd236e2f9c584a7ce552dc8fcc2","0x000000000000000000000000000000000010cc2efa8f18c2b3612e01a8f94503","0x0000000000000000000000000000001f0a3f81411b1e06edf45c3394c8c296e2","0x0000000000000000000000000000000000093763194f12aae29dcb9f7b74552a","0x00000000000000000000000000000022eadbf390242527d6646603cb2ba8fb18","0x0000000000000000000000000000000000040bb072ad71de805dc3d35901d8fd","0x0000000000000000000000000000009969e938d09ae8a3e0b2e88252474433f1","0x000000000000000000000000000000000001a0c3834a281427a0bd40fbfad2c0","0x0000000000000000000000000000000000000000000000000000000000000001","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000002","0x0000000000000000000000000000000000000000000000000000000000000000","0x000000000000000000000000000000f3f3adcb44841356ca07ede3ffdaa4e56f","0x00000000000000000000000000000000001ffaeed8a0fd4e93e92f31906d07db","0x000000000000000000000000000000947443af422077ce83dc603affb9d07832","0x0000000000000000000000000000000000247c8888f27a2569a7c50eafb17f6b"] \ No newline at end of file diff --git a/packages/tokens/src/confidential/circuits/vks/spender_transfer.vk.json b/packages/tokens/src/confidential/circuits/vks/spender_transfer.vk.json index 4f047cf19..f81ac038c 100644 --- a/packages/tokens/src/confidential/circuits/vks/spender_transfer.vk.json +++ b/packages/tokens/src/confidential/circuits/vks/spender_transfer.vk.json @@ -1 +1 @@ -["0x0000000000000000000000000000000000000000000000000000000000008000","0x0000000000000000000000000000000000000000000000000000000000000028","0x0000000000000000000000000000000000000000000000000000000000000001","0x0000000000000000000000000000000000000000000000000000000000000018","0x000000000000000000000000000000a173185a6c66bbbf8844688be7ac5b05ba","0x0000000000000000000000000000000000178d5026a2292c295bacc6839d146c","0x000000000000000000000000000000251fbdee5ec057add298a568ebfccdaa3c","0x00000000000000000000000000000000000fcea813296807774dac67504290f0","0x0000000000000000000000000000006c7743cf4bc8503bd574b9c7b042a1b68d","0x000000000000000000000000000000000027dee1df21902824e3fb2a07d21db4","0x0000000000000000000000000000003b8d04d07881ea4472156051067e9d33d3","0x00000000000000000000000000000000002efb7c99cbaf72320bdac23789b559","0x00000000000000000000000000000019f8887663e824b08e215a41790a2ec7a6","0x00000000000000000000000000000000001cc6ea2f5fc3b0120e75b4cbaf2861","0x000000000000000000000000000000165b607efeda4ffc649af0b694b6a32c26","0x000000000000000000000000000000000012ade532f1db76e5584869d990a2fc","0x0000000000000000000000000000009467b47a84166e9e2d04ab3aae32ab9c15","0x0000000000000000000000000000000000098a67f56e850a274e2fd7d9bb44de","0x000000000000000000000000000000f9647c52015aa4311f503490baa5e873ff","0x0000000000000000000000000000000000278197a2e23df63ac913b9b617427e","0x0000000000000000000000000000001d6c6dd79dbc9867c840f99a32d23e51d1","0x00000000000000000000000000000000001da7bb1e031b6a663f1bceceaf7bd3","0x000000000000000000000000000000c13e098ec0c7ef662dd1983657e77bdea7","0x000000000000000000000000000000000000ec8e935a8bd17321ee190d54b6f9","0x000000000000000000000000000000f1f89ce288dfec299c4ea104b2dd5b88c1","0x00000000000000000000000000000000002c543d8f601164e5d5facba816c37f","0x000000000000000000000000000000d4cfd490b180059e6b6ed708de819f4aa1","0x00000000000000000000000000000000000f12ea1c37d54e5283e3f4f4d6e79a","0x0000000000000000000000000000005738f222d113ad96d990e7d4a004ec2e57","0x000000000000000000000000000000000014e35bf3517c8beabf7ec116bd4c00","0x000000000000000000000000000000d89185b10a6c32362672867293fa6e87a6","0x00000000000000000000000000000000002cbca48853bc69fafe318ba9e1f9cc","0x0000000000000000000000000000002f2a2f928122d8521c561b495e7e1857f5","0x00000000000000000000000000000000001ef0e3e9173c235ec2b1c0b91281c4","0x00000000000000000000000000000065181a2e3847dd0d30a22c9b2eb0422b4a","0x0000000000000000000000000000000000172776113a217eb5cf32ccaf0c95c8","0x0000000000000000000000000000001d411cd7d552ebaef417157ea93cc846df","0x00000000000000000000000000000000000420bb60da34cbbfa5bc9a410e5765","0x000000000000000000000000000000a397dbb24a08eff9d676f0749459e71c88","0x0000000000000000000000000000000000082a13f1f83d1a894640ccb07f9c3d","0x00000000000000000000000000000058c4e00e4c5e324009ead3ecf212577819","0x00000000000000000000000000000000000f3381ec4cc7c75ca4ee3e1d594234","0x0000000000000000000000000000008f4aeb1bd095fbcfd120926369f2264d72","0x00000000000000000000000000000000001869edafbb233d9fc8c2376ef49866","0x00000000000000000000000000000051807132e87254a64a7d37e681a280a72b","0x00000000000000000000000000000000000ca61bee2d3897a477e8b3e5370b9f","0x000000000000000000000000000000a051f66e31acd00649bfcc30cc17a57559","0x00000000000000000000000000000000000682ad983b1e1483f2e6fcf4af40a0","0x000000000000000000000000000000d766209beee813fbfbf6cb84ef8ddfa216","0x0000000000000000000000000000000000246ec1ff20b9106a42885b97157afd","0x0000000000000000000000000000003d61f572e4a04f3eb184ddfcf57e4cd1f6","0x00000000000000000000000000000000000155a8d254c90b62bd0da6bed49121","0x0000000000000000000000000000004e3a7e8ff0058f7cef545a45aca6604ead","0x0000000000000000000000000000000000181ba86db74a39162abf8850c70d80","0x000000000000000000000000000000ccfc6208c78202a6a5db64bb0dea3d05a0","0x00000000000000000000000000000000003029cc67f5222913abce8b7f6bfc33","0x000000000000000000000000000000f47a548e30eb6664e7da4fee0fe8fd5cc8","0x00000000000000000000000000000000001110a66e7db8c7d275aead5e44a2e5","0x000000000000000000000000000000e0a2f05efe775db5fa6e9356129adfd308","0x00000000000000000000000000000000002f655e46cd96550b177cab30f78557","0x000000000000000000000000000000eb7004471c4ca9e986bc42b14fa712b9bc","0x000000000000000000000000000000000012ebd5509439bc031e4fa3658c603d","0x000000000000000000000000000000fcde1a8d022d92236c7e08695be0353fc4","0x00000000000000000000000000000000002dad713f031c9987fa3f12d4541941","0x000000000000000000000000000000afc918b76e32490ec37f5b095b3f14b2bc","0x000000000000000000000000000000000023f616db2c19d173c8f0756bdab2a4","0x000000000000000000000000000000976b02396f55456aae29523e685dd9f935","0x00000000000000000000000000000000001627baa0461616fec69ea4934e656f","0x000000000000000000000000000000584805b0c65b1d33ef7763a7b0ffcc2736","0x000000000000000000000000000000000025dbe6974ca7111098ca63c21db3b7","0x00000000000000000000000000000009a61c7809ebdf49649f6d13b0f0f2fa33","0x000000000000000000000000000000000021d6a57b5260ea6c8e3740022e76a0","0x000000000000000000000000000000337d27788cbe9b4e1a895a73b9e8dcea15","0x0000000000000000000000000000000000017a55101bd2c03b1336b37df158c7","0x00000000000000000000000000000010aa17f60dfbdc5bb37fd16e798958b040","0x00000000000000000000000000000000000e862f3cfd0004a863fdc3ef7355c0","0x000000000000000000000000000000e4e18e522f28a916bbabab2b7a70f9fbc6","0x0000000000000000000000000000000000015f5961f9651dc930ce8206f964b4","0x000000000000000000000000000000a4908a5d585331c8cc1cbfa0b5dc2d1e1a","0x0000000000000000000000000000000000061168b4c58069364aafecc59ccb98","0x0000000000000000000000000000001b63cd11e8e1b438d1793ac621f0c1c26c","0x00000000000000000000000000000000000f487b28c699f3932e7c94abfdee20","0x00000000000000000000000000000010aeff6fa851db928f235dd4fbc17cfcf2","0x000000000000000000000000000000000012cf1027e8f15d9dd21b68032a9641","0x000000000000000000000000000000aa14cb32534ccfb68f4c4560f301d4c3cf","0x00000000000000000000000000000000000cee8cb1cf07d59b0be8e95d33a844","0x000000000000000000000000000000356c4714a52f5c1e03cccf3dea2d266989","0x00000000000000000000000000000000000355a55d816040412d356ec7d77a12","0x000000000000000000000000000000ffad1d95a8ee7ccd2287a489375de526f6","0x00000000000000000000000000000000002549709cd2124b19800eed4f003e52","0x00000000000000000000000000000043c4b44d03e114c03c1689f77c5789643c","0x00000000000000000000000000000000000621edcb5a9eab5ae5675b4da9d9a2","0x000000000000000000000000000000d0492104db91551b307d94f2ab08cc79f4","0x000000000000000000000000000000000025f2f754a0726238075d9def0babb9","0x000000000000000000000000000000cb74279f288c14712784e2fa6490e4536b","0x00000000000000000000000000000000001cb622d0b3b58b69ee8edce2674300","0x000000000000000000000000000000e2295164532fcce82a3c013e597c594ef8","0x00000000000000000000000000000000001189d45faed739fc4e16a3dd3f9b7b","0x00000000000000000000000000000019a9d0d71575acbc5f5960590bd2f40a84","0x00000000000000000000000000000000000c0d0b7c8a283361906d364db189df","0x000000000000000000000000000000ffec2c04e68f5bbfb7a031a8dfe73f8bc6","0x00000000000000000000000000000000000a3ebe2336d65cff7e94ebb4a6f2df","0x0000000000000000000000000000000e2e091047811681c2716df0d71df042c8","0x00000000000000000000000000000000001ced77a29b750aec5c8fa1e3c63876","0x0000000000000000000000000000000000000000000000000000000000000001","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000002","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000005a1ab0e0419a29211c7a50a0054e9ef2a7","0x00000000000000000000000000000000001998ccc89e4b3f4434109ca924791a","0x000000000000000000000000000000a1f76296454097f832ec1dc41c05248d8c","0x00000000000000000000000000000000002557d1b101e196a9be90c395e7ff18"] \ No newline at end of file +["0x0000000000000000000000000000000000000000000000000000000000008000","0x0000000000000000000000000000000000000000000000000000000000000029","0x0000000000000000000000000000000000000000000000000000000000000001","0x0000000000000000000000000000000000000000000000000000000000000019","0x000000000000000000000000000000b1e2f541998a2755e5213d3e971e91df06","0x00000000000000000000000000000000001c85aa14f04fe5cd6ba340f1e36f30","0x000000000000000000000000000000dd17a4a404747a820741028f49190e3643","0x00000000000000000000000000000000001c56196f530a939d9d97a3cc03ab0b","0x000000000000000000000000000000e138bb15582e87af9d51fbc7ed6e0e7695","0x00000000000000000000000000000000001e7f60c2349b166c96ad0d379b4ca3","0x000000000000000000000000000000b98deafa29f053f7ceb11d0d32de87dc7c","0x0000000000000000000000000000000000002ce030b942f99071c3b7b51a20c3","0x000000000000000000000000000000528a55b629a204f061da96f9f06561d4f9","0x000000000000000000000000000000000023792b0987721b9128e0cf41d9b32c","0x0000000000000000000000000000008a430926dbdf33c991898be570409a110a","0x0000000000000000000000000000000000219cb1f43da57ea5d7d298bd0e7b49","0x00000000000000000000000000000092e74077b65ff0e232758cf9429592aaf5","0x00000000000000000000000000000000001282528955f7796f0a65e209114dd2","0x00000000000000000000000000000029590dd636d3648667c797ca389e874332","0x0000000000000000000000000000000000236ef8ad2e3fc5ce9dc9143315fb86","0x000000000000000000000000000000c8a6c7a4920d3415fab8e852d34e731f3d","0x0000000000000000000000000000000000169f38558aa5d5fbc8ccfc308ece82","0x0000000000000000000000000000007ab272b3b30d2aa5a04ab9ff8b03761300","0x0000000000000000000000000000000000048cbf034592a047b3f743cc06a9b3","0x00000000000000000000000000000092f9818005d01ae6aa95051e81a4d8fd7c","0x000000000000000000000000000000000019f5135e35bb7fd0bb0db2984503d7","0x0000000000000000000000000000001a8a976ae62ab0d4ebdf0f026a25320094","0x0000000000000000000000000000000000237782b32e0dd4f3385e0e58707578","0x0000000000000000000000000000007d23bc9c11fe98e0188a8228651e8c8612","0x00000000000000000000000000000000001824dc044196d321168c8386ebc647","0x000000000000000000000000000000fd200b620e62036d89553e3b1def7ec8b4","0x000000000000000000000000000000000025ca5d6f72d2a5c0539cef16bc5772","0x00000000000000000000000000000083f8426178a4adb8759e565672d48d57b2","0x00000000000000000000000000000000000d2162b8663e51626b63b613f38d10","0x0000000000000000000000000000005abf0765b4ec2d9008d3a0b0e4507d896d","0x00000000000000000000000000000000001c9eb82857d4ed7141bbdd48792bf6","0x00000000000000000000000000000053644e331267af096b7fa639d567af46d5","0x0000000000000000000000000000000000169b62f18a62c1dd259c0e4c1e6ed7","0x000000000000000000000000000000e0c1b58069ea333eef6ab3adfdebfbff1b","0x0000000000000000000000000000000000194f5f1fb476a26932b74e9b3b0076","0x0000000000000000000000000000007f16b72a7cd37b7fad520beb587385af0c","0x00000000000000000000000000000000000b7c2b90171c94d593ce8c018f9d9f","0x0000000000000000000000000000006b97f4bcd4ffbb3f43a44c9b54da291227","0x000000000000000000000000000000000011e40fb9bb5d8e48416a30e2fbec52","0x00000000000000000000000000000045fe00b3b7a774a6f24792e2232d7cff7e","0x00000000000000000000000000000000001931e0d13d1f24ae08944ddf7477e9","0x000000000000000000000000000000a03c16e18ed20dc8b0359b35f488a561b5","0x00000000000000000000000000000000001e367c248e85a81becee8094786fa5","0x000000000000000000000000000000a9e97de75737843e2a615ddfcf184170b3","0x000000000000000000000000000000000012af86eb66bebd2a4d37c160d27a23","0x0000000000000000000000000000000be148149805efb06f7487733e2ca3f78d","0x000000000000000000000000000000000001623d48ab894fd1c64ab90c49fc39","0x000000000000000000000000000000c0e17eccf6222f6f5e80382b856c9069ca","0x0000000000000000000000000000000000049669495d53f7e25db94054b7bf66","0x000000000000000000000000000000063bf54ae8896ffcfcd5f483bce7ffbd2c","0x000000000000000000000000000000000011e1d2550ae5e4aa5f37048b93c3ac","0x000000000000000000000000000000c893d71e03cdbbcccba83e3686748261fa","0x00000000000000000000000000000000002a7b4f7de7010bbf608cb8b59223c0","0x0000000000000000000000000000005e3974920dd9781e8b9b2e81540233392d","0x0000000000000000000000000000000000084df620d5d72a1ec67897952cd34b","0x000000000000000000000000000000c92cf866aee5a0ea4648909ef6d68fd296","0x00000000000000000000000000000000001fca1e1587e296c15350f05e90d9c8","0x000000000000000000000000000000a2005b5d09a1823e4ee2a0351a9efff114","0x000000000000000000000000000000000007c766a5124ce00865594065e092bf","0x000000000000000000000000000000229e8d446d2172bca1bde608f9b7af6940","0x00000000000000000000000000000000000f8f294047a4fe4f38bb3d32986e65","0x0000000000000000000000000000003547cd41239103ff65ed2aa52e662d1fa7","0x000000000000000000000000000000000029d698f9e85044e11bc2fa37409c12","0x0000000000000000000000000000003449790c59fc7a4349e1e55768a7066fd4","0x0000000000000000000000000000000000075204d5ba12a18730f9cca06c9d14","0x000000000000000000000000000000d74e3abd35dea7d5c3864b37381dacb1dd","0x0000000000000000000000000000000000105a75d044776abb9e403442460c86","0x00000000000000000000000000000050c32559396a96a2c975b2387cf9b0f0a8","0x00000000000000000000000000000000001560274dd48a2b5d24b6091ca1e9d5","0x000000000000000000000000000000926bdc48c50dff3424d82c0478e4d1a5ee","0x000000000000000000000000000000000008bf7f0695b740da94ed59263850ee","0x000000000000000000000000000000f78443226cf26cb8bd04a73b09d33c6481","0x00000000000000000000000000000000001e4f8f021bcc5df9de12fb67a01b94","0x000000000000000000000000000000704e51d181f28de21c8a8fca2978a3cef2","0x000000000000000000000000000000000018be339c2f9f1df76ce2b201b70385","0x000000000000000000000000000000df174b538c33c37f6aae1f5376293eb040","0x00000000000000000000000000000000002e8e391362e10fcc3e73988410b62e","0x00000000000000000000000000000043520aea0acff78687103480b09c01a8f6","0x00000000000000000000000000000000002a69cc75b104ac7855fd7f75f184a3","0x000000000000000000000000000000267c9488653d674ef240ba580f33176340","0x000000000000000000000000000000000019e6a90b94d863a6d4c4e39e80e6c0","0x0000000000000000000000000000009c7b961d4157b3b26b9156a3932361c86e","0x0000000000000000000000000000000000275212059032c11bb72f6a50581c87","0x0000000000000000000000000000008e59b97dd6154e534733ad92ad3a68a5d1","0x0000000000000000000000000000000000273947962d9e45c3ffe701e8dfb3d2","0x00000000000000000000000000000094d4ac37423f4543d43c6d532fba449ef1","0x000000000000000000000000000000000017ed9d63172a2eb4131c9f16db109a","0x00000000000000000000000000000072cb7beeabccc6c0373d512fc2a1fcd7e0","0x000000000000000000000000000000000001d67d4a0eb2e248680352726f0375","0x00000000000000000000000000000063ff686ee61e643ec81d1c8943337798b6","0x0000000000000000000000000000000000223b3eb65e0e3b442921ef14846d6a","0x000000000000000000000000000000a4c5c09617703654854f4cb71647d3ea68","0x000000000000000000000000000000000030451a0765f5e203bdedf2cfca899b","0x000000000000000000000000000000a71843a019f1987c83e489f228f4d22c33","0x00000000000000000000000000000000002eb0772b27bdffc2103f1878526700","0x000000000000000000000000000000c6b6d92eb8c0c7b04720ca665ef4e83f9a","0x00000000000000000000000000000000002edd5fa4e5172459005ff4f8a17593","0x0000000000000000000000000000009013a1c81bffd29f7d6ae91954fac52e3d","0x00000000000000000000000000000000001009ca6d2b80745a813790fc281bb5","0x0000000000000000000000000000000000000000000000000000000000000001","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000002","0x0000000000000000000000000000000000000000000000000000000000000000","0x000000000000000000000000000000d678f1a0bcc54fa74011eefbef799f4a0d","0x000000000000000000000000000000000030439c58cae8bce4e184e2b8bda865","0x000000000000000000000000000000c8f80f131a32e400e35d3dad34964738b9","0x00000000000000000000000000000000000fcd9a67a0311379f8a005c79daec1"] \ No newline at end of file diff --git a/packages/tokens/src/confidential/circuits/vks/transfer.vk.json b/packages/tokens/src/confidential/circuits/vks/transfer.vk.json index 932c7350a..79ac1df9e 100644 --- a/packages/tokens/src/confidential/circuits/vks/transfer.vk.json +++ b/packages/tokens/src/confidential/circuits/vks/transfer.vk.json @@ -1 +1 @@ -["0x0000000000000000000000000000000000000000000000000000000000008000","0x0000000000000000000000000000000000000000000000000000000000000028","0x0000000000000000000000000000000000000000000000000000000000000001","0x0000000000000000000000000000000000000000000000000000000000000018","0x00000000000000000000000000000004413d24f9ef99a13491acc3aa55395d62","0x00000000000000000000000000000000000b9cbec0ba4ade4c074a49116d168f","0x0000000000000000000000000000001c647836b8994b8842561aabc00fcc67c2","0x000000000000000000000000000000000027ec474a1c645c0b33a9001aa66079","0x00000000000000000000000000000008fc94084b14bb94fcec2e8eb2aa6d6338","0x000000000000000000000000000000000000dc8867233b315171148598a1cd3f","0x000000000000000000000000000000b279cd48ac75427503ed3352c9c9a5334a","0x000000000000000000000000000000000012c9697469839cf1f3dd53c4d1309c","0x000000000000000000000000000000f9f9d31e4e345760912032b27368b64c3c","0x000000000000000000000000000000000001d2b60c1be711bbc4fe20283aec42","0x0000000000000000000000000000008bad4e8849f784170c2c0efee3bcd96f13","0x000000000000000000000000000000000017d032a52b15c7764e65077b005953","0x0000000000000000000000000000001df0203a9392db9f72c54363e298885359","0x0000000000000000000000000000000000102827f901378f6b56b63238ca4f61","0x000000000000000000000000000000f9a38ce984a2739067d3f5530069acd5b2","0x0000000000000000000000000000000000299fee9e9ac6a881667f79562984f6","0x000000000000000000000000000000b1df6fbc084945ad009a91a6406832e706","0x000000000000000000000000000000000023d8ced027973fe412a0a8a8ae562d","0x000000000000000000000000000000d1c1cd6d0c1f8aca7ba576a278ff3a2851","0x0000000000000000000000000000000000097e71727da99a2a4f8f5674525595","0x000000000000000000000000000000cb99e5e8a724031a8844c9e77e87de8d54","0x0000000000000000000000000000000000031c3791e5e36df9e8641da31e8556","0x000000000000000000000000000000cbb525c67113f08c10c149e587ad41a4b1","0x00000000000000000000000000000000000723274327f1c28920cf374bf42b2b","0x0000000000000000000000000000005738f222d113ad96d990e7d4a004ec2e57","0x000000000000000000000000000000000014e35bf3517c8beabf7ec116bd4c00","0x000000000000000000000000000000d89185b10a6c32362672867293fa6e87a6","0x00000000000000000000000000000000002cbca48853bc69fafe318ba9e1f9cc","0x00000000000000000000000000000090be9e0f4ad68be758dc91464100d964f4","0x000000000000000000000000000000000009e8536a046d743505b08104ea028f","0x000000000000000000000000000000cb4fd5227fe827fec053677babcb2ebda7","0x00000000000000000000000000000000001f0fbf6631cee0ffcfb86e249aadf5","0x000000000000000000000000000000d3efb3a64db75d1b3d967e32bbf21cd6c9","0x000000000000000000000000000000000025c52c5e5f67d9aa920c536ea6a599","0x0000000000000000000000000000007028a22cfc51631a8537ede3e2d07d3ca1","0x000000000000000000000000000000000015b337967fc201c9744d5f6be5196d","0x00000000000000000000000000000027466a58010816fba3923c274d63f62f81","0x0000000000000000000000000000000000136e6df8fbaa4970d742e35f737054","0x0000000000000000000000000000008ec1ab7bcb285a743a72bc725e617463f0","0x00000000000000000000000000000000002c6cf860d994aed7991185d6b43238","0x000000000000000000000000000000c5eeb9738e1051a201c65dda1fb7cd1e76","0x00000000000000000000000000000000001cc137bf0f6d056c73b3e65254afac","0x000000000000000000000000000000cc30c6c27e8bf2fdb653c1d4beb23bc87e","0x00000000000000000000000000000000001372ba0a7e82cab73226f2366b26fc","0x000000000000000000000000000000b6335b2a4b071d351828c4337e7a9e8c4a","0x00000000000000000000000000000000001dc9e72c2fdb64cc3c0d25057a8d24","0x0000000000000000000000000000009a6e568feac9da1616acf6de081c1968e6","0x00000000000000000000000000000000002ca76fc9d813030d3a0fbf2c17cd72","0x000000000000000000000000000000ef40ad028444c52560905e3578c8d0ae55","0x0000000000000000000000000000000000263ade3e29bdc96854cab29124430d","0x000000000000000000000000000000a5e98ec9faa0203b948564a5931a6230f7","0x000000000000000000000000000000000020062a3319ec0a4f1c4ac674110721","0x000000000000000000000000000000c8b7dfed18f3e5898b56dfdc1865110c7b","0x00000000000000000000000000000000001527146e79e49f249a186cfe7c15eb","0x00000000000000000000000000000085ec7fa4c3be15fa392abb598d434f32ea","0x00000000000000000000000000000000001dcef54518d6b45a568d745d4465db","0x000000000000000000000000000000e82f8e8258b24217bde2dd08bd2d623acf","0x00000000000000000000000000000000001719c66f3e93abadebb5593ac9b041","0x00000000000000000000000000000017bebabc00e318a6daa37132677f86fd21","0x00000000000000000000000000000000001175d817775004346c6555f836556d","0x0000000000000000000000000000008269ab0a36bae1c6c7ef80f675500a1412","0x000000000000000000000000000000000004a143d6fa4b117034bc918cf2e543","0x00000000000000000000000000000076b98a6f23d948831c387bcc10a10a349e","0x00000000000000000000000000000000001940ab63da158a6a4dbf77a2456545","0x0000000000000000000000000000005cdf03320c85129a300ab51777cb3a73a7","0x00000000000000000000000000000000000661cdf5d6eeb598d5e33aebf877a0","0x000000000000000000000000000000e8a716b26c32928c1babd9c1b0353ad8e6","0x00000000000000000000000000000000000b51d04c0213555e9bf3f673e3e6f8","0x000000000000000000000000000000d92aa4f9cbf2a9e94daa3f73029201942c","0x000000000000000000000000000000000002b5f46fe7494b6922ea1de624156f","0x0000000000000000000000000000004fa4be1b794645a5ec1098cbc217657ad5","0x00000000000000000000000000000000000b0ab13f772163323cd412372d194c","0x0000000000000000000000000000009a3429a482019f705358fd897415e23ab0","0x000000000000000000000000000000000026f4957cf960f28e1e257190f1145c","0x000000000000000000000000000000abf5352ecb12764ee23881fc7cc946fbc7","0x000000000000000000000000000000000014d5b06fa1468445a54c04bc2f4def","0x00000000000000000000000000000044cfa260ec1024d9dffea7cfee9abd80f9","0x00000000000000000000000000000000001a288b6b1e1365604897e638698e44","0x000000000000000000000000000000abd3436c788a8c92a1eb840022a99ed2b0","0x000000000000000000000000000000000027dd023c90a794bdbb178dc963eaea","0x000000000000000000000000000000187303d4a4b721cccdcafdeb1a3a5c8aa9","0x000000000000000000000000000000000029a62d8a44d50e98d8d8afd79a2a85","0x0000000000000000000000000000002c05db33d64eff927f702bfec559a0c867","0x000000000000000000000000000000000020867e1ebec347b4648103cf4fff67","0x000000000000000000000000000000ffad1d95a8ee7ccd2287a489375de526f6","0x00000000000000000000000000000000002549709cd2124b19800eed4f003e52","0x00000000000000000000000000000043c4b44d03e114c03c1689f77c5789643c","0x00000000000000000000000000000000000621edcb5a9eab5ae5675b4da9d9a2","0x000000000000000000000000000000d0492104db91551b307d94f2ab08cc79f4","0x000000000000000000000000000000000025f2f754a0726238075d9def0babb9","0x000000000000000000000000000000cb74279f288c14712784e2fa6490e4536b","0x00000000000000000000000000000000001cb622d0b3b58b69ee8edce2674300","0x000000000000000000000000000000e2295164532fcce82a3c013e597c594ef8","0x00000000000000000000000000000000001189d45faed739fc4e16a3dd3f9b7b","0x00000000000000000000000000000019a9d0d71575acbc5f5960590bd2f40a84","0x00000000000000000000000000000000000c0d0b7c8a283361906d364db189df","0x000000000000000000000000000000ffec2c04e68f5bbfb7a031a8dfe73f8bc6","0x00000000000000000000000000000000000a3ebe2336d65cff7e94ebb4a6f2df","0x0000000000000000000000000000000e2e091047811681c2716df0d71df042c8","0x00000000000000000000000000000000001ced77a29b750aec5c8fa1e3c63876","0x0000000000000000000000000000000000000000000000000000000000000001","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000002","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000001de872e949b7932ce49e5508e210397dbb","0x00000000000000000000000000000000001113f4c90761cf8a67d0b888c54886","0x000000000000000000000000000000b5b4d07530ac0dc44948da70e53de01ed7","0x00000000000000000000000000000000001b70a28af537d468ef471dab8d4914"] \ No newline at end of file +["0x0000000000000000000000000000000000000000000000000000000000008000","0x0000000000000000000000000000000000000000000000000000000000000029","0x0000000000000000000000000000000000000000000000000000000000000001","0x0000000000000000000000000000000000000000000000000000000000000019","0x000000000000000000000000000000f5d772a8b2b7bde6a9ecce3933c8ba7506","0x000000000000000000000000000000000005f8d9bec870eabb5428428cec9dbc","0x00000000000000000000000000000092ebb216fc3afa183f8593b7510c46ca76","0x000000000000000000000000000000000007e3e3a47295dfe7d76f593e657e16","0x000000000000000000000000000000640c5d727b698896a855ee57acba0e0499","0x00000000000000000000000000000000001d8a1707073e9fb82a1078ce2975a5","0x000000000000000000000000000000cd9e145bb7dafdcb53af565e81bd724533","0x00000000000000000000000000000000002b3f18887dceacffc34eda46dc10df","0x000000000000000000000000000000562d5f29dc5281a43c645517c310ceb8ff","0x000000000000000000000000000000000025ba00f39e165de83b2c3bda9cb123","0x000000000000000000000000000000502ca1dcf2b6b28d7b4b9dd70f33db9b0c","0x00000000000000000000000000000000001878dff2dbb12392d55453324082ce","0x00000000000000000000000000000003722d0ce1932cb3c4a20ac17a68598d3b","0x000000000000000000000000000000000004353836de7a6e7166870265703c3e","0x000000000000000000000000000000ee08c3b6eb99fa5127802af028a08d199e","0x0000000000000000000000000000000000277df57716804cb66fbe60bbe6d590","0x000000000000000000000000000000a8083855303defc3959dceda342048be9d","0x0000000000000000000000000000000000042bd5e14aaf19b6243d483665cbd3","0x000000000000000000000000000000581575c27408860b85da4bf0ca3903334c","0x00000000000000000000000000000000001c21a68e72092d02e857bef160acce","0x000000000000000000000000000000561e3ea66cad00b2056f7554c84e4172e8","0x0000000000000000000000000000000000060a5a2cba3421ed00e31c508b149e","0x0000000000000000000000000000002995f46a078062cd92b4fac0477462f097","0x00000000000000000000000000000000000dc2d83172aaae19e5ef68e7707656","0x0000000000000000000000000000007d23bc9c11fe98e0188a8228651e8c8612","0x00000000000000000000000000000000001824dc044196d321168c8386ebc647","0x000000000000000000000000000000fd200b620e62036d89553e3b1def7ec8b4","0x000000000000000000000000000000000025ca5d6f72d2a5c0539cef16bc5772","0x000000000000000000000000000000f500b7356c6ab562b9605690eea7b2bd8c","0x000000000000000000000000000000000009058533e5724c9a430ad8a3c98308","0x00000000000000000000000000000048c14b4e47bdf367947798d4dd1b3d27ab","0x000000000000000000000000000000000017878e4241e0c6d3949939c3ab498f","0x000000000000000000000000000000283a030e81778adc0f01680d7c4d3ed5ef","0x000000000000000000000000000000000015d65e0b38f10a425851e973b7e563","0x0000000000000000000000000000005204bf41c8022c119f289b8b7e2559012f","0x00000000000000000000000000000000000f45588264380a850b800e4fc2b277","0x000000000000000000000000000000285143090900edd247a5f97a3e0c936111","0x0000000000000000000000000000000000190b77599be7c3341e633c2207e969","0x000000000000000000000000000000fc195d824e5cb74a4f61070a1fb42c5863","0x00000000000000000000000000000000002a6cb0e5fb6a925ff072ab7440d21e","0x000000000000000000000000000000bff6259607afe2170934377bbd2c3366df","0x000000000000000000000000000000000029d0cb4dcaba401c3f7138dedd8e39","0x000000000000000000000000000000884b33df3e47f9f324df0318436ef65804","0x00000000000000000000000000000000001695ee1d81b1e4d037d42c67498058","0x000000000000000000000000000000cf881c4b8bb60b7f7b05f0e1b18323c545","0x00000000000000000000000000000000000fe9480b17503def7d999ffbe215fa","0x0000000000000000000000000000009b23b561b39075617e24eb348fcf3dea9a","0x000000000000000000000000000000000004d6f14944850a5721c2a99e25b72d","0x0000000000000000000000000000008ad328fe3d5ed1f17440303774a9ffd8dc","0x00000000000000000000000000000000000566f306cd4810e118c58c2af03ca1","0x000000000000000000000000000000e6d58d11e40fb164ae0f24ddb5cb8d2a24","0x000000000000000000000000000000000023270455c2aee0ed8bdd2eae73e4ac","0x00000000000000000000000000000027850ceb032e59656e83337140db0045c4","0x000000000000000000000000000000000009ef4debccbff4c8f4a214b5094cf1","0x0000000000000000000000000000006c008a091acee0c880ba8634d16a846222","0x00000000000000000000000000000000001d5b664647d97ecf2ed8cd42085e27","0x0000000000000000000000000000008c3ecbc123ece174301b1d674ab52261d6","0x00000000000000000000000000000000000d0bd94f74c8e8fd745e178c536511","0x000000000000000000000000000000865e886a1e7ddddc7e597a410b46db647b","0x0000000000000000000000000000000000141339f6cec9d3e576c3ccfe25f472","0x0000000000000000000000000000004eb9e73a8257da2ba22652f29eb038d1b3","0x00000000000000000000000000000000001bcead29ab67995ed99f425fd53b89","0x00000000000000000000000000000019e4f87389f1395ddbefd6b4ff88e823e4","0x0000000000000000000000000000000000015126dcbdfc878382a8d06fde6b8b","0x0000000000000000000000000000006fe8bd2b551f88ea84e4fa8a31a5c88473","0x00000000000000000000000000000000001a4b3e2a2e6ff2b5beadb8041aacaa","0x00000000000000000000000000000001044ec0f270f3c3bc2d8b929fd112b364","0x0000000000000000000000000000000000005bd1120beaa52b62d9d7431d5b2e","0x0000000000000000000000000000005ac02d8ebd8dfadb3f2f6080147450646b","0x00000000000000000000000000000000001a31184f8288965c6c91b6aa215bba","0x000000000000000000000000000000457b0e2bd6402709ef3e9d77f3e445f02e","0x000000000000000000000000000000000011b4f505ab7a15527647419e777c21","0x000000000000000000000000000000577c1eb10946b588313356f4fdd107bfd4","0x000000000000000000000000000000000029dae68696ce656493d3089b673d71","0x0000000000000000000000000000008702bcea38196f4a06d6c6743f83b25e71","0x00000000000000000000000000000000001797c635854e2c02447ff20e101ee3","0x0000000000000000000000000000009ae73d7eddaa8f5b7752166997d7663c43","0x00000000000000000000000000000000002f2f513aa81cea87bdb7ad33211f2f","0x00000000000000000000000000000073bf7f66ca3c357165d811434f0074eadd","0x00000000000000000000000000000000000a0caa960e29beb402c44963504a54","0x000000000000000000000000000000a0864b1140f9bff8333eae8a225e2178e3","0x00000000000000000000000000000000002607530a0f7804828ff3bc81effea1","0x000000000000000000000000000000f165beb0b8dcc2844bfe53fbd72496c7e2","0x00000000000000000000000000000000001b6a7890f4808fd8fb19f6c23127b8","0x0000000000000000000000000000008e59b97dd6154e534733ad92ad3a68a5d1","0x0000000000000000000000000000000000273947962d9e45c3ffe701e8dfb3d2","0x00000000000000000000000000000094d4ac37423f4543d43c6d532fba449ef1","0x000000000000000000000000000000000017ed9d63172a2eb4131c9f16db109a","0x00000000000000000000000000000072cb7beeabccc6c0373d512fc2a1fcd7e0","0x000000000000000000000000000000000001d67d4a0eb2e248680352726f0375","0x00000000000000000000000000000063ff686ee61e643ec81d1c8943337798b6","0x0000000000000000000000000000000000223b3eb65e0e3b442921ef14846d6a","0x000000000000000000000000000000a4c5c09617703654854f4cb71647d3ea68","0x000000000000000000000000000000000030451a0765f5e203bdedf2cfca899b","0x000000000000000000000000000000a71843a019f1987c83e489f228f4d22c33","0x00000000000000000000000000000000002eb0772b27bdffc2103f1878526700","0x000000000000000000000000000000c6b6d92eb8c0c7b04720ca665ef4e83f9a","0x00000000000000000000000000000000002edd5fa4e5172459005ff4f8a17593","0x0000000000000000000000000000009013a1c81bffd29f7d6ae91954fac52e3d","0x00000000000000000000000000000000001009ca6d2b80745a813790fc281bb5","0x0000000000000000000000000000000000000000000000000000000000000001","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000002","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000004a85a0c64e04f69a36a97deb3958c6bbf0","0x0000000000000000000000000000000000199c8bc40ae10ed7f07310b7975cda","0x000000000000000000000000000000ff0c31a2001f2ff66db7afbadc36e96392","0x0000000000000000000000000000000000080e3aa2e0896e4f1a235744ee0d7c"] \ 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 84456fda7..aad7919ba 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","0x000000000000000000000000000000e597d7a43e1658fccdb57ddf48c37e5473","0x00000000000000000000000000000000001193bcf3c4251926ee33d674f84987","0x0000000000000000000000000000000baeee3ec66b08d113bd9b5f2677180418","0x0000000000000000000000000000000000169966bb708e8d372103425d107ebd","0x000000000000000000000000000000af4e0624fbc3fb17c1fc5387dc7d647685","0x0000000000000000000000000000000000173735e4349fe3ccb518a9a54dcb14","0x000000000000000000000000000000bf9dde92a2c32830bab41da0e30ad6bccf","0x0000000000000000000000000000000000086ef65b435baeef94ebc5e94611cb","0x0000000000000000000000000000005d54bbe76b91a1c6e1c341fe11218b7cdf","0x00000000000000000000000000000000001a31367e051e5b12ab9653b83d7fb5","0x000000000000000000000000000000153cc60f1784443c98cc6150b112629fb7","0x000000000000000000000000000000000018d4e69f77fafbee4385aa43522009","0x000000000000000000000000000000cf9f96c32e8dc2db412963d3cd9871e1b4","0x000000000000000000000000000000000001c58ba0e8ccd62b538ba0e7e38790","0x000000000000000000000000000000ccc339f1ae6f9d0e5b1383cda494c1a780","0x000000000000000000000000000000000020fc1c18361ef24f296fcfd6eda535","0x000000000000000000000000000000e2233ee6fb9eb58b252db928e3e89311d7","0x0000000000000000000000000000000000170098446da68cebadd10f03159739","0x00000000000000000000000000000050278a139eb755328f4ab36d3d766ac3c1","0x00000000000000000000000000000000002d40aba8e2a0b3d415a1658ce4a829","0x000000000000000000000000000000cddf8ca7068e0e861dfd8a2d58610a50e7","0x0000000000000000000000000000000000154bf9bb67fd24d2005a9a01d39ca4","0x000000000000000000000000000000fe48c66b4105d57a0dceea39003ce4b8a5","0x00000000000000000000000000000000002bfa809f8f7de18409ad040d1e1390","0x000000000000000000000000000000176df2b9f9816b99b87dcd27314f248492","0x00000000000000000000000000000000000396fae9cfc50f85ebfe6bf44327d7","0x0000000000000000000000000000000107a790c778e6a2ffbae75e5764ee8cc2","0x00000000000000000000000000000000000e0f2756936ef0c0b2175663320713","0x0000000000000000000000000000000750098d0e116f1e93f1288acc950929e4","0x000000000000000000000000000000000029eb04b0dd469b00a82249ab293ebb","0x000000000000000000000000000000ae4184b486d7e33213b5be4c1fd17e373e","0x000000000000000000000000000000000010a740ffa5e0dfeab5dbf9617a8e81","0x00000000000000000000000000000024829bafb32570dc9d762cbffbd515611b","0x000000000000000000000000000000000016ff65fed205274a44f4622f1d5139","0x0000000000000000000000000000009652c7e9c052b125d82c422401fede028a","0x00000000000000000000000000000000001af922f957a23b18cd7b8f59f868cf","0x000000000000000000000000000000905c0c34f8112b8db53aa91e142245a61f","0x00000000000000000000000000000000000c84c6bdac23e9bcbd0ea28ce4847d","0x0000000000000000000000000000000061d74669c77c502b5fe6779f65892eaf","0x00000000000000000000000000000000001693564a3fac5e69d46818e1aa0fe1","0x000000000000000000000000000000e7e7ba36f6691bfc01870d47fd49f532be","0x00000000000000000000000000000000001473f3835ff08ca24236e1339ec3ca","0x0000000000000000000000000000004b068fde313c5b4d5dc8794a669d455257","0x000000000000000000000000000000000009742e7eab970cca765392ccc507b1","0x000000000000000000000000000000ff4675f67809d637b96d8c16337feb9f42","0x00000000000000000000000000000000001990e3e48557be6488ae35f0a51a95","0x00000000000000000000000000000085fcb8baeae055d6aa0bc02f8972f996b3","0x00000000000000000000000000000000001a909e20c4bf8940de1010fdeba8c0","0x00000000000000000000000000000026493d51a261f9d81c876414c0257f16e5","0x00000000000000000000000000000000001c7f30ca9ccde5cccca36703e63e9c","0x000000000000000000000000000000187c6d9276766c4114d4ad373138ac5334","0x000000000000000000000000000000000017cfa853d6882c4db823390713961b","0x0000000000000000000000000000007ca7da7ca56e8d165cd92f693671290235","0x00000000000000000000000000000000002cc308df428e14c4fa51df0a2e4b04","0x000000000000000000000000000000dd06766d59592ee4cdfb39bd81c3e8a81b","0x000000000000000000000000000000000005e13e18e0c2f6e0ddd3e5916cc944","0x0000000000000000000000000000008e0dbdb19560c20ab64e43a8279a8b4256","0x0000000000000000000000000000000000058ab6892e78443ba96b837ade42fc","0x000000000000000000000000000000c7009ef82d611b4dc16fab927433897de5","0x000000000000000000000000000000000018ca6385544e57541b0775b28895c9","0x00000000000000000000000000000029a6c1dfd2f703b9bd2f83b7cf56b5b7ba","0x000000000000000000000000000000000017d7459cbe727aa3c4b26169d0f795","0x0000000000000000000000000000007760e92b68eace4738858a05bdf9ab0c5b","0x00000000000000000000000000000000000304ef1f846e4abd3928cfc3316f12","0x0000000000000000000000000000005a4803b4eea8ed1648680131f6013fb36c","0x0000000000000000000000000000000000233aadd2b997264caa8b7687a5b147","0x00000000000000000000000000000094ff2c242ea80bd05eb5957312d32c2c17","0x000000000000000000000000000000000027032a31ba7b4286afc91524c420f7","0x0000000000000000000000000000007106b178573e9b851ffa1e16134d407a1a","0x000000000000000000000000000000000015c9a22d78f6548deb9a54fa84dc13","0x00000000000000000000000000000022708f1f0aef815b7cf99a66698cee52f9","0x00000000000000000000000000000000000387fe2425c59e75d884f32991fd91","0x00000000000000000000000000000074ce2422329832f9618c74c72ef4740385","0x0000000000000000000000000000000000080fc591f68b66fefe0d6e3b4a7c2c","0x000000000000000000000000000000a1d75292cc2bc1f1eff3cd81f64f6f91a0","0x000000000000000000000000000000000029d52acd302678d419d9e0b1f16807","0x000000000000000000000000000000bf0f9a13faa9252ff93bf1b24c745c3a50","0x000000000000000000000000000000000000c431a1843df444d3008dc20d1fc7","0x0000000000000000000000000000009103b5d371a24e95242f720bd87d5cb6ba","0x000000000000000000000000000000000013b28f89f55e9d0c5a901245936ef9","0x000000000000000000000000000000b11178bfc6c1b9d0581942640655dce0a6","0x00000000000000000000000000000000000845a08e2071080b55c5ccbf7a7fa3","0x000000000000000000000000000000e4a16685a95b9a022eaacfc7916e3422f3","0x000000000000000000000000000000000010dd7a24c1648e2b655803281f4efd","0x000000000000000000000000000000984d80910016772cb83ed3881f90ebcc8c","0x00000000000000000000000000000000001b6540dd28b967d06897d2fe4f4732","0x000000000000000000000000000000b7445646b5160c380130b66e34450aa736","0x00000000000000000000000000000000000ef8897e2c65d0ec75dc09f85edeac","0x00000000000000000000000000000045688ef774d85c8bd41f9665bad2d794c8","0x00000000000000000000000000000000002ac07a74c19ad88489ed6f1e93dc9e","0x000000000000000000000000000000a5a32649850aae3c839626ec285717f399","0x000000000000000000000000000000000010cdf0710877e231d375a2ef4dc3d3","0x000000000000000000000000000000160d4e95b954b96077723f1800f420dc77","0x0000000000000000000000000000000000280ae74a2dae2f5e68706031174b76","0x000000000000000000000000000000440bb5fc5471f6a37dbe12fbef6a37c009","0x0000000000000000000000000000000000106864b3460bfded9d5dc448fe29e8","0x000000000000000000000000000000d29de5d4acc99c6c72fa17e0a80bf88401","0x000000000000000000000000000000000007e83dfa443e984d53e0323523f742","0x0000000000000000000000000000001cfe095fa8e5d91145e1222746c27083ad","0x000000000000000000000000000000000006a7d963d96d78bc035faf0a744f8e","0x0000000000000000000000000000000000000000000000000000000000000001","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000002","0x0000000000000000000000000000000000000000000000000000000000000000","0x00000000000000000000000000000048818b917ab2937e3b04b8a63242c8d685","0x00000000000000000000000000000000000f8f935e6d133cf64a5f2a95b7320c","0x000000000000000000000000000000ea7294c82d95f81a10e62710512933b773","0x000000000000000000000000000000000018b83b936d6bbdf96f12867151464e"] \ No newline at end of file +["0x0000000000000000000000000000000000000000000000000000000000008000","0x0000000000000000000000000000000000000000000000000000000000000020","0x0000000000000000000000000000000000000000000000000000000000000001","0x0000000000000000000000000000000000000000000000000000000000000010","0x0000000000000000000000000000004c442649afc96694fbd11364e67ad38665","0x000000000000000000000000000000000028053d1ff061a2df1452b4d996643e","0x0000000000000000000000000000001582b8d4502c01e5ffdd936e93c193fa97","0x00000000000000000000000000000000001ea7640ca206713afd369069bbd871","0x000000000000000000000000000000ab706e244ff19b91c383680960675e9940","0x000000000000000000000000000000000018f77e0f17928f1209d27f893bbb0e","0x0000000000000000000000000000006062946ceb70f901200642ab8816baff79","0x000000000000000000000000000000000011e6980aab93a7a998040a00d7c70f","0x000000000000000000000000000000fd0957636b6cd60c293e7d603f68a5b6e2","0x00000000000000000000000000000000001f5dbb5ce8342a7e22730401b6aa5f","0x000000000000000000000000000000173181fdcb5c2df435472b2fdb1270f8e9","0x00000000000000000000000000000000000f350011e6f7b73f33ee49baf5eb7e","0x000000000000000000000000000000383619f6dd719ad90bc863da0ab8d72adf","0x000000000000000000000000000000000020c1bc2be87cbd4a7fa36dfdd857a0","0x0000000000000000000000000000007dcb345c387a00d6aa197af9ee697d344e","0x00000000000000000000000000000000001c73fa78aeb84b556ee48f42b00291","0x000000000000000000000000000000c95c5cf30bb32e7e999104b2e787426f7c","0x00000000000000000000000000000000000f1e98707c5742c2fb2046fd405c2a","0x000000000000000000000000000000c9a2d2aff1a043878fe8846d393a094608","0x00000000000000000000000000000000002664a800fe61ea766996ed02064911","0x000000000000000000000000000000952680e1c239306ef5e6af3d4102725f6a","0x000000000000000000000000000000000011e345325c59c658bf5a607968b298","0x0000000000000000000000000000009a6d0c416152a4d75d15f5157aa6db133f","0x000000000000000000000000000000000007e4d4865aafe796f204a900e4afce","0x00000000000000000000000000000092e27aaad5daa09c02bd3d4a06261c6955","0x0000000000000000000000000000000000279d0d683708150813227323363a98","0x000000000000000000000000000000b093a03ff149ad26be672954978aa36c7f","0x000000000000000000000000000000000008ca4e0cdddab1cf17d82fac1ab338","0x0000000000000000000000000000006b138628d74be50bbf305f178f475acc62","0x0000000000000000000000000000000000045e4248bbac34003bfbba4b4db8ae","0x000000000000000000000000000000ea7ccd3292923da76a46553c0fa3e620e1","0x00000000000000000000000000000000001ccd9c4471951bf3ff804c9a303f85","0x00000000000000000000000000000032ffd82879a53a599955e658616d6ee2f2","0x00000000000000000000000000000000002c569129a60c84497390ee7174f9ee","0x00000000000000000000000000000068e4044dcf7fadf62665814a69fca364fa","0x00000000000000000000000000000000000dcfa704637eedbb8e45464a2ef845","0x000000000000000000000000000000b6983b29c27f6a8b5bb026a1e76845d121","0x0000000000000000000000000000000000037758ec0c8d66e25607a554491aab","0x000000000000000000000000000000c85f0f87a4f1fadbe7e0bcb8c75fac8ca8","0x0000000000000000000000000000000000085458e28b36e782ac13a261894f8f","0x000000000000000000000000000000cdfcb6dd05b85366435fdd3db7865ace3a","0x000000000000000000000000000000000021a9cad52c4e49041088163537ebf9","0x00000000000000000000000000000012d65cc124eb2723b65fd696cf9d80a780","0x0000000000000000000000000000000000020845f24f752bbecddbd6bde2e07e","0x000000000000000000000000000000fe833f1ef6649c54762b39d7978680a236","0x000000000000000000000000000000000012c3745781a0664266edd96dea2d82","0x000000000000000000000000000000b3c6ab497730a0d91cb60f47bb7dea8328","0x000000000000000000000000000000000027f5cd7d54e2a7e58c6e06977feb8a","0x0000000000000000000000000000001b8e81967fefbcf14ef9aa07d09b795c10","0x0000000000000000000000000000000000290fcc53bc94e99bc7b6d0c5b6ac68","0x0000000000000000000000000000009f436cb5ec371892fe986aa33b3f528bf9","0x00000000000000000000000000000000001fe0ad95c51ab6cdb54ad28dd874cc","0x000000000000000000000000000000d6c4eb2310e53daf54409c0ad88ae0675f","0x000000000000000000000000000000000009e14b24cce77616b3cc7f9a704529","0x0000000000000000000000000000007669d25c4e452701f6246301fd1f4b6247","0x00000000000000000000000000000000001e98087048b27558482e0ab925ca88","0x0000000000000000000000000000000b8e00279cd27f8e79ddd6f115786a2cb2","0x00000000000000000000000000000000000f11502980c524525557a41ae89ef2","0x0000000000000000000000000000005399536b916f427b858903a1e15cb6493b","0x00000000000000000000000000000000000e3a6547370bac47f42469c4365a22","0x0000000000000000000000000000006817ff88a828dc759d612032b2c4d7d1a5","0x0000000000000000000000000000000000151d74521b7b510ab012a788a44504","0x000000000000000000000000000000022be5646a84a6a3b907e4fca07f227357","0x00000000000000000000000000000000002922887b4d45a45a9f50e4bdd2c9ad","0x0000000000000000000000000000006ca6d0060aee07323c58d157cd592e5aa9","0x00000000000000000000000000000000002768d343b4719ee740d4716eb78762","0x000000000000000000000000000000e6e8d8ca0e237a70530528cc9c2e1f9700","0x00000000000000000000000000000000002d4697f9e03380f07cea7635865fee","0x0000000000000000000000000000000cf58ad6e38f7872eb217bd112b48fd56d","0x0000000000000000000000000000000000253f9f0d5b07a7608b2659b7912fda","0x000000000000000000000000000000e4143d1db51214335ea56a34e70ec20dbe","0x0000000000000000000000000000000000142446512f94cb2392d7c5ad87d080","0x00000000000000000000000000000076f4dcd726dda842133baf7a628ee02f28","0x000000000000000000000000000000000000984210bd7caa2e5387ac673933bc","0x00000000000000000000000000000040436cbcf89d866f03731dcc2d43a0dac6","0x000000000000000000000000000000000010dba246da2fcc8c533e6882c47976","0x000000000000000000000000000000d5ea8a0956143bd21c871bc815a1227b42","0x00000000000000000000000000000000001ec8042c2134addedbc3014cad8e19","0x0000000000000000000000000000004480f4586763d9f91600be77eeff6c387c","0x0000000000000000000000000000000000300c18297df2312b4433b3b3cd24f5","0x000000000000000000000000000000db4acb40bf25a938aae223baba211ead95","0x000000000000000000000000000000000018e953437f1138e83b2ad414da67b0","0x00000000000000000000000000000096e99865d497c4b89ff90e3df7eca2159f","0x000000000000000000000000000000000007694ca87ce7b62f35c42cd904744e","0x000000000000000000000000000000c6d3193628a55a914a1ad75168e7155748","0x000000000000000000000000000000000000f71154026816bf941623343d89d2","0x000000000000000000000000000000691a6116e567ef5f1a576558df94ebbde5","0x000000000000000000000000000000000029d3301c46141dce1cee3552479bf8","0x000000000000000000000000000000ca7ba36583fdf1ae1435c9c977dfcabb24","0x000000000000000000000000000000000018baad696f8f908e5bb1e6683a84bf","0x000000000000000000000000000000bb2016356836c7e3aca49f161906cb86b1","0x000000000000000000000000000000000008d2fb9e407703816dac893b653985","0x00000000000000000000000000000041e30592e5b686ae7c91c0c16bcafce094","0x000000000000000000000000000000000026ef15da093439d27b304bfb2644ff","0x00000000000000000000000000000005e2e27132a1ee599dc1f267dbb6491c6b","0x00000000000000000000000000000000002085801a9ce94286f972b123775fca","0x0000000000000000000000000000006614deb9657f4d2631270f067acfe3b1b4","0x000000000000000000000000000000000012c12492034628a48a6fca591203cf","0x0000000000000000000000000000009674be0b43c0206da4a14d09f00de3a32f","0x000000000000000000000000000000000016b9ededc6771a87c48af38dd80e79","0x0000000000000000000000000000000000000000000000000000000000000001","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000002","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000003716fcb657ff7deb84775a442cdf9b967b","0x00000000000000000000000000000000001778125c0cf94a5433a70b1020b3f7","0x000000000000000000000000000000f1ed4cadcc714972d4558c36c6c943d87b","0x000000000000000000000000000000000023fb4e8a8043c534a75ed4ded83d7a"] \ 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 a2c479d00..bfcd6b985 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, 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, domain, ecdh, encrypt_balance, + scalar_mul, sponge_squeeze_3, vk_from_sk, }; use std::embedded_curve_ops::EmbeddedCurvePoint; @@ -41,20 +41,28 @@ mod tests; // auditor ECDH. // W_a2 s_{a,s} = ecdh(r_e, K_{aud,s}) Sender-auditor ECDH // shared scalar (2.4). -// W_a3 m_b = SpongeSqueeze_2(delta_aud_s, +// W_a3 (m_b, m_r) = SpongeSqueeze_3(delta_aud_s, // s_{a,s}, -// sigma)[1] Sender-channel sponge, -// second squeeze (the -// balance slot; the -// first-squeeze amount +// sigma)[1], [2] Sender-channel sponge, +// lane[1] and lane[2]. +// The lane[0] amount // slot is unused -- the // withdrawal amount is -// public). +// public -- and skipping +// it keeps the checkpoint +// pad distinct from every +// amount pad. // 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. +// W_a5 r_tilde_aud_s = r' + m_r Sender-auditor escrow +// of the new spendable +// blinding, over W5's r' +// (emitted). This is what +// lets the auditor track +// the full Pedersen +// opening of C_spend +// forward. // // Point-validation doctrine (Section 10.8) // ---------------------------------------- @@ -65,7 +73,7 @@ mod tests; // 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 (15 fields, in design-doc canonical order) +// Public inputs (16 fields, in design-doc canonical order) // -------------------------------------------------------- // Idx Param Symbol Source / Note // --- ----- ------ ------------------------------------- @@ -89,6 +97,8 @@ mod tests; // 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. +// 15 r_tilde_aud_s r_tilde_aud_s Prover-supplied sender-auditor escrow +// of the new spendable blinding; emitted. // // Private witnesses // ----------------- @@ -118,6 +128,7 @@ fn main( r_e_x: pub Field, r_e_y: pub Field, b_tilde_aud_s: pub Field, + r_tilde_aud_s: pub Field, ) { // W8 -- runs first so the r_e = 0 attack is rejected before any // scalar mul against it could quietly produce the identity. @@ -168,7 +179,13 @@ fn main( // W_a2 (shared-secret scalar) let s_a_s = 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, sigma); - assert(b_tilde_aud_s_derived == b_tilde_aud_s); + // W_a3 (sender-channel masks: lane[0] unused, then balance, then the + // secret-escrow slot) + let m_s = sponge_squeeze_3(domain::AUDITOR_SENDER, s_a_s, sigma); + + // W_a4 + assert(v_new + m_s[1] == b_tilde_aud_s); + + // W_a5 + assert(r_new + m_s[2] == r_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 1683537e6..a9911e7dd 100644 --- a/packages/tokens/src/confidential/circuits/withdraw/src/tests.nr +++ b/packages/tokens/src/confidential/circuits/withdraw/src/tests.nr @@ -1,7 +1,7 @@ use crate::main; use stellar_confidential_lib::{ - commit, derive_spend_r, ecdh, encrypt_auditor_sender_balance, encrypt_balance, H, scalar_mul, - vk_from_sk, + commit, derive_spend_r, domain, ecdh, encrypt_balance, H, + scalar_mul, sponge_squeeze_3, vk_from_sk, }; use std::embedded_curve_ops::EmbeddedCurvePoint; @@ -58,6 +58,9 @@ global R_E_X: Field = 0x114ed4fcf2c57014eb678c577aa02f30ef590b713d7a6a5e87702d1c global R_E_Y: Field = 0x07a70cf826350d4f438c7a3c5e8761b0ae6cb63de757f0c96815f4057b9205f4; global B_TILDE_AUD_S: Field = 0x288ff48898f218dbba2a5c9546d0986af3257a4a539e23e4f35c404d6720c07c; +// lane[2] secret-escrow slot: r_tilde_aud_s = r' + m_r, over W5's r'. +global R_TILDE_AUD_S: Field = + 0x12bae1b1e1b12493e37d427d3ee28b481c37f7f456c4ab0c022b8ba6262ca57f; #[test] fn print_fixtures() { @@ -72,7 +75,9 @@ fn print_fixtures() { let k_aud_s = scalar_mul(K_AUD_S_SCALAR, H); let r_e_pt = scalar_mul(R_E, H); let s_a_s = ecdh(R_E, k_aud_s); - let b_tilde_aud_s = encrypt_auditor_sender_balance(V_NEW, s_a_s, SIGMA); + let m_s = sponge_squeeze_3(domain::AUDITOR_SENDER, s_a_s, SIGMA); + let b_tilde_aud_s = V_NEW + m_s[1]; + let r_tilde_aud_s = r_new + m_s[2]; let cx = c_new.x; let cy = c_new.y; let kx = k_aud_s.x; @@ -87,6 +92,7 @@ fn print_fixtures() { println(f"R_E_X = {rx}"); println(f"R_E_Y = {ry}"); println(f"B_TILDE_AUD_S = {b_tilde_aud_s}"); + println(f"R_TILDE_AUD_S = {r_tilde_aud_s}"); } #[test] @@ -112,12 +118,17 @@ 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 = ecdh(R_E, k_aud_s); - let b_tilde_aud_s = encrypt_auditor_sender_balance(V_NEW, s_a_s, SIGMA); + let vk = vk_from_sk(SK, ADDR_F); + let r_new = derive_spend_r(vk, SIGMA); + let m_s = sponge_squeeze_3(domain::AUDITOR_SENDER, s_a_s, SIGMA); + let b_tilde_aud_s = V_NEW + m_s[1]; + let r_tilde_aud_s = r_new + m_s[2]; 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); + assert(r_tilde_aud_s == R_TILDE_AUD_S); } #[test] @@ -142,6 +153,7 @@ fn matches_fixture() { R_E_X, R_E_Y, B_TILDE_AUD_S, + R_TILDE_AUD_S, ); } @@ -156,7 +168,9 @@ fn full_withdrawal() { 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 = ecdh(R_E, k_aud_s); - let b_tilde_aud_s = encrypt_auditor_sender_balance(0, s_a_s, SIGMA); + let m_s = sponge_squeeze_3(domain::AUDITOR_SENDER, s_a_s, SIGMA); + let b_tilde_aud_s = m_s[1]; + let r_tilde_aud_s = r_new + m_s[2]; main( SK, V, @@ -177,6 +191,7 @@ fn full_withdrawal() { R_E_X, R_E_Y, b_tilde_aud_s, + r_tilde_aud_s, ); } @@ -190,7 +205,9 @@ fn rejects_under_funded_withdrawal() { 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 = ecdh(R_E, k_aud_s); - let b_tilde_aud_s_invalid = encrypt_auditor_sender_balance(V - a_too_large, s_a_s, SIGMA); + let m_s = sponge_squeeze_3(domain::AUDITOR_SENDER, s_a_s, SIGMA); + let b_tilde_aud_s_invalid = (V - a_too_large) + m_s[1]; + let r_tilde_aud_s_invalid = r_new + m_s[2]; main( SK, V, @@ -211,6 +228,7 @@ fn rejects_under_funded_withdrawal() { R_E_X, R_E_Y, b_tilde_aud_s_invalid, + r_tilde_aud_s_invalid, ); } @@ -243,6 +261,7 @@ fn rejects_v_out_of_range() { R_E_X, R_E_Y, B_TILDE_AUD_S, + R_TILDE_AUD_S, ); } @@ -270,6 +289,7 @@ fn rejects_a_out_of_range() { R_E_X, R_E_Y, B_TILDE_AUD_S, + R_TILDE_AUD_S, ); } @@ -295,6 +315,7 @@ fn rejects_wrong_sk() { R_E_X, R_E_Y, B_TILDE_AUD_S, + R_TILDE_AUD_S, ); } @@ -320,6 +341,7 @@ fn rejects_wrong_balance_opening() { R_E_X, R_E_Y, B_TILDE_AUD_S, + R_TILDE_AUD_S, ); } @@ -345,6 +367,7 @@ fn rejects_wrong_addr_f() { R_E_X, R_E_Y, B_TILDE_AUD_S, + R_TILDE_AUD_S, ); } @@ -370,6 +393,7 @@ fn rejects_tampered_b_tilde() { R_E_X, R_E_Y, B_TILDE_AUD_S, + R_TILDE_AUD_S, ); } @@ -395,6 +419,7 @@ fn rejects_tampered_c_spend_new() { R_E_X, R_E_Y, B_TILDE_AUD_S, + R_TILDE_AUD_S, ); } @@ -422,6 +447,7 @@ fn rejects_r_e_zero() { R_E_X, R_E_Y, B_TILDE_AUD_S, + R_TILDE_AUD_S, ); } @@ -449,6 +475,7 @@ fn rejects_wrong_r_e() { R_E_X, R_E_Y, B_TILDE_AUD_S, + R_TILDE_AUD_S, ); } @@ -476,6 +503,7 @@ fn rejects_off_curve_k_aud_s() { R_E_X, R_E_Y, B_TILDE_AUD_S, + R_TILDE_AUD_S, ); } @@ -503,14 +531,14 @@ fn rejects_identity_k_aud_s() { R_E_X, R_E_Y, B_TILDE_AUD_S, + R_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 + // b_tilde_aud_s mutated by +1: W_a4 fails because the lane[1] pad derives + // a specific scalar that no longer matches the prover's claim. Closes the // tampered-ciphertext criterion in the issue. main( SK, @@ -532,6 +560,36 @@ fn rejects_tampered_b_tilde_aud_s() { R_E_X, R_E_Y, B_TILDE_AUD_S + 1, + R_TILDE_AUD_S, + ); +} + +#[test(should_fail)] +fn rejects_tampered_r_tilde_aud_s() { + // r_tilde_aud_s mutated by +1: W_a5 fails because the lane[2] escrow is + // pinned to r' = derive_spend_r(vk, sigma), which the prover's claim no + // longer matches. + main( + SK, + V, + R, + R_E, + C_SPEND_X, + C_SPEND_Y, + Y_X, + Y_Y, + ADDR_F, + 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, + R_TILDE_AUD_S + 1, ); } @@ -560,5 +618,6 @@ fn rejects_wrong_k_aud_s() { R_E_X, R_E_Y, B_TILDE_AUD_S, + R_TILDE_AUD_S, ); } diff --git a/packages/tokens/src/confidential/compliance/test.rs b/packages/tokens/src/confidential/compliance/test.rs index 2056b245b..40070b7dd 100644 --- a/packages/tokens/src/confidential/compliance/test.rs +++ b/packages/tokens/src/confidential/compliance/test.rs @@ -191,6 +191,7 @@ fn withdraw_payload(e: &Env) -> WithdrawPayload { r_e_point: pt(e), sigma: fr(e), b_tilde_aud_s: fr(e), + r_tilde_aud_s: fr(e), } } @@ -206,6 +207,7 @@ fn transfer_payload(e: &Env) -> TransferPayload { r_tilde_aud_r: fr(e), v_tilde_aud_s: fr(e), b_tilde_aud_s: fr(e), + r_tilde_aud_s: fr(e), } } @@ -221,6 +223,7 @@ fn spender_transfer_payload(e: &Env) -> SpenderTransferPayload { r_tilde_aud_r: fr(e), v_tilde_aud_s: fr(e), a_tilde_aud_s: fr(e), + r_tilde_aud_s: fr(e), } } @@ -236,6 +239,8 @@ fn set_spender_payload(e: &Env) -> SetSpenderPayload { sigma_a: fr(e), v_tilde_aud_s: fr(e), b_tilde_aud_s: fr(e), + r_tilde_aud_s: fr(e), + r_a_tilde_aud_s: fr(e), } } diff --git a/packages/tokens/src/confidential/docs/COMPLIANCE.md b/packages/tokens/src/confidential/docs/COMPLIANCE.md index cc24f991f..4fa533739 100644 --- a/packages/tokens/src/confidential/docs/COMPLIANCE.md +++ b/packages/tokens/src/confidential/docs/COMPLIANCE.md @@ -208,24 +208,25 @@ The admin role here is the same access-control surface introduced in §1.1; depl The clawback proof is a constant-size circuit deployed through the existing Verifier surface. It binds the seize amount $$\alpha$$ by the sum of the spendable and receiving balances of the target account, refreshes the spendable-balance checkpoint, and rewrites `receiving_commitment` to a zero commitment so the seized inbound flow is consumed atomically. -**Public inputs.** $$C\_{\text{spend}}, C\_{\text{receive}}, K\_{\text{aud,s}}, \tilde{b}\_{\text{aud,s}}^{\text{old}}, R\_e^{\text{old}}, \sigma^{\text{old}}, \alpha, \tilde{b}\_{\text{aud,s}}^{\text{new}}, R\_e^{\text{new}}, \sigma^{\text{new}}, addr\_f$$. +**Public inputs.** $$C\_{\text{spend}}, C\_{\text{receive}}, K\_{\text{aud,s}}, \tilde{b}\_{\text{aud,s}}^{\text{old}}, \tilde{r}\_{\text{aud,s}}^{\text{old}}, R\_e^{\text{old}}, \sigma^{\text{old}}, \alpha, C\_{\text{spend}}^{\text{new}}, \tilde{b}\_{\text{aud,s}}^{\text{new}}, \tilde{r}\_{\text{aud,s}}^{\text{new}}, R\_e^{\text{new}}, \sigma^{\text{new}}, addr\_f$$. -**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). +**Private witnesses.** $$k\_{\text{aud,s}}, v\_s, r\_s, v\_r, r\_r, r\_s', r\_e^{\text{new}}$$, plus the sponge outputs from old and new auditor-channel sponge calls. The recipient-auditor's secret key does not appear in the witness because the recipient-channel decryption (recovery of $$(v\_r, r\_r)$$ from per-transfer events) is performed off-chain by the auditor; the circuit only re-verifies the resulting Pedersen opening of $$C\_{\text{receive}}$$ (constraint 1). **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}}, \text{ECDH}(k\_{\text{aud,s}}, R\_e^{\text{old}}), \sigma^{\text{old}})$$ and $$v\_s = \tilde{b}\_{\text{aud,s}}^{\text{old}} - m\_{b,s}^{\text{old}}$$. The spendable-balance opening $$(v\_s, r\_s)$$ is consistent with $$C\_{\text{spend}} = v\_s \cdot G + r\_s \cdot H$$ where $$r\_s$$ is recovered via the same path the wallet uses for checkpoint recovery (`DESIGN.md` §5.2): $$r\_s = \text{Poseidon}(\delta\_{\text{spend\\\_r}}, vk\_A, \sigma^{\text{old}})$$. Because the clawback circuit does not have access to $$vk\_A$$, the spendable-balance side of the proof binds via the consistency of $$\tilde{b}\_{\text{aud,s}}^{\text{old}}$$ with $$C\_{\text{spend}}$$ at the time of the last owner-initiated proof. The follow-up revision will pin down whether $$r\_s$$ is supplied as a private witness with an auxiliary opening proof or derived in-circuit from a separately escrowed value. +2. **Spendable-balance decryption.** $$(m\_{v,s}^{\text{old}}, m\_{b,s}^{\text{old}}, m\_{r,s}^{\text{old}}) = \text{SpongeSqueeze}\_3(\delta\_{\text{aud\\\_s}}, \text{ECDH}(k\_{\text{aud,s}}, R\_e^{\text{old}}), \sigma^{\text{old}})$$, then $$v\_s = \tilde{b}\_{\text{aud,s}}^{\text{old}} - m\_{b,s}^{\text{old}}$$ and $$r\_s = \tilde{r}\_{\text{aud,s}}^{\text{old}} - m\_{r,s}^{\text{old}}$$, and $$C\_{\text{spend}} = v\_s \cdot G + r\_s \cdot H$$. The `lane[2]` escrow (`DESIGN.md` W\_a5 / T\_a9 / S\_a6) is what makes this a closed derivation: the circuit recovers the blinding from the auditor channel itself rather than from $$vk\_A$$, which it does not hold. The old checkpoint referenced here must be the account's most recent `lane[2]` escrow; `revoke_spender` publishes none (V\_a3), so a target frozen immediately after a revocation must be settled against the checkpoint preceding it. 3. **Range and bound.** $$\alpha, v\_s, v\_r \in [0, 2^{127})$$ and $$\alpha \le v\_s + v\_r$$. -4. **Refreshed checkpoint.** $$r\_e^{\text{new}}$$ is **sampled** by the auditor per `DESIGN.md` §2.2, not derived. This is the one operation in the protocol that samples its ephemeral scalar: `DESIGN.md` §5.3 derives $$r\_e$$ from the originator's viewing key, and the originator here is the auditor, which holds no account's viewing key. Then $$R\_e^{\text{new}} = r\_e^{\text{new}} \cdot H$$, $$r\_e^{\text{new}} \neq 0$$, and $$\tilde{b}\_{\text{aud,s}}^{\text{new}} = (v\_s + v\_r - \alpha) + m\_{b,s}^{\text{new}}$$ where $$(m\_{v,s}^{\text{new}}, m\_{b,s}^{\text{new}}) = \text{SpongeSqueeze}\_2(\delta\_{\text{aud\\\_s}}, \text{ECDH}(k\_{\text{aud,s}}, R\_e^{\text{new}}), \sigma^{\text{new}})$$. +4. **Refreshed checkpoint.** $$r\_e^{\text{new}}$$ is **sampled** by the auditor per `DESIGN.md` §2.2, not derived. This is the one operation in the protocol that samples its ephemeral scalar: `DESIGN.md` §5.3 derives $$r\_e$$ from the originator's viewing key, and the originator here is the auditor, which holds no account's viewing key. Then $$R\_e^{\text{new}} = r\_e^{\text{new}} \cdot H$$, $$r\_e^{\text{new}} \neq 0$$, and, with $$(m\_{v,s}^{\text{new}}, m\_{b,s}^{\text{new}}, m\_{r,s}^{\text{new}}) = \text{SpongeSqueeze}\_3(\delta\_{\text{aud\\\_s}}, \text{ECDH}(k\_{\text{aud,s}}, R\_e^{\text{new}}), \sigma^{\text{new}})$$, both $$\tilde{b}\_{\text{aud,s}}^{\text{new}} = (v\_s + v\_r - \alpha) + m\_{b,s}^{\text{new}}$$ and $$\tilde{r}\_{\text{aud,s}}^{\text{new}} = r\_s' + m\_{r,s}^{\text{new}}$$. Re-escrowing `lane[2]` is what keeps the auditor's standing opening (`DESIGN_cont.md` §8.1) intact across a seizure. +5. **New spendable commitment.** $$C\_{\text{spend}}^{\text{new}} = (v\_s + v\_r - \alpha) \cdot G + r\_s' \cdot H$$. The commitment is a public input rather than a value the contract recomputes, since $$v\_s$$, $$v\_r$$, and $$r\_s'$$ are all private witnesses. -**Post-verification.** The contract sets $$C\_{\text{spend}} \leftarrow (v\_s + v\_r - \alpha) \cdot G + r\_s' \cdot H$$ under fresh deterministic randomness $$r\_s'$$ (admin-derived, since $$vk\_A$$ is unavailable), zeroes $$C\_{\text{receive}}$$, transfers $$\alpha$$ of the underlying SEP-41 token to the issuer, and emits an event carrying $$(\tilde{b}\_{\text{aud,s}}^{\text{new}}, R\_e^{\text{new}}, \sigma^{\text{new}})$$ so the sender-auditor sees the new checkpoint. +**Post-verification.** The contract stores the prover-supplied $$C\_{\text{spend}}^{\text{new}}$$, zeroes $$C\_{\text{receive}}$$, transfers $$\alpha$$ of the underlying SEP-41 token to the issuer, and emits an event carrying $$(\tilde{b}\_{\text{aud,s}}^{\text{new}}, \tilde{r}\_{\text{aud,s}}^{\text{new}}, R\_e^{\text{new}}, \sigma^{\text{new}})$$ so the auditor sees the new checkpoint and its blinding. **Anti-replay.** The contract consumes $$C\_{\text{spend}}$$ and $$C\_{\text{receive}}$$ as proof public inputs at verification time. If either commitment changes between proof construction and submission (e.g., an inbound transfer arrives), verification fails because the proof was bound to a different $$C\_{\text{receive}}$$. The §2 contract-level freeze applied to the target per §5.2's flow blocks both spending and receiving, so neither $$C\_{\text{spend}}$$ nor $$C\_{\text{receive}}$$ can change between proof construction and submission, and the proof's bindings hold across the isolate-then-settle handshake. **What is no longer needed.** The earlier sketch of an on-chain receiving-side accumulator and a per-transfer compliance hook on `confidential_transfer`, `confidential_transfer_from`, and `deposit` is not required. The recipient-auditor's opening of $$C\_{\text{receive}}$$ is reconstructed entirely off-chain from event scans (`DESIGN_cont.md` §8.1). -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. +Detailed encoding and the two-phase isolate-then-settle entry-point sequencing are deferred to a follow-up revision of this document. --- diff --git a/packages/tokens/src/confidential/docs/DESIGN.md b/packages/tokens/src/confidential/docs/DESIGN.md index 2bbbd5dcb..290d85c04 100644 --- a/packages/tokens/src/confidential/docs/DESIGN.md +++ b/packages/tokens/src/confidential/docs/DESIGN.md @@ -77,7 +77,7 @@ $$\text{base}(\mathbb{G}) = \mathbb{F}\_r^{\text{BN254}}, \qquad \text{scalar}(\ 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$$. Every secret scalar in the core protocol that is drawn rather than derived ($$\sigma$$, $$\sigma\_a$$) is produced by the **rejection sampling** procedure, which yields a uniform draw from $$\mathbb{F}\_r$$; the extension layers draw two further scalars the same way, $$r\_{\text{disc}}$$ ([SELECTIVE_DISCLOSURE.md](./SELECTIVE_DISCLOSURE.md) §4) and the auditor-sampled $$r\_e^{\text{new}}$$ ([COMPLIANCE.md](./COMPLIANCE.md) §5.3). +**Scalar sampling.** Grumpkin scalars live in $$\mathbb{F}\_q$$, which is slightly larger than $$\mathbb{F}\_r$$. Every secret scalar in the core protocol that is drawn rather than derived ($$\sigma$$, $$\sigma\_a$$) is produced by the **rejection sampling** procedure, which yields a uniform draw from $$\mathbb{F}\_r$$; the extension layers draw one further scalar the same way, $$r\_{\text{disc}}$$ ([SELECTIVE_DISCLOSURE.md](./SELECTIVE_DISCLOSURE.md) §4). 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})$$. @@ -143,17 +143,17 @@ The domain tag is always the first element absorbed, so $$\text{Poseidon2}(\delt - Symmetric encryption: $$\tilde{v} = v + \text{Poseidon2}(\delta\_{\text{transfer\\\_amount}}, s, \sigma)$$ - Domain separation: each invocation includes a leading constant $$\delta$$ to prevent cross-context collisions -**Two-mask mode for auditor channels.** The per-transfer auditor ciphertexts (Section 8) need two masks from one absorb. Since $$(\delta\_{\text{channel}}, s, \sigma)$$ is exactly one rate-3 block, the two masks are taken as **two lanes of a single permutation output**, not as two sequential squeezes: +**Multi-lane mode for auditor channels.** The per-transfer auditor ciphertexts (Section 8) need several masks from one absorb. Since $$(\delta\_{\text{channel}}, s, \sigma)$$ is exactly one rate-3 block, the masks are taken as **lanes of a single permutation output**, not as sequential squeezes: -$$\text{SpongeSqueeze}\_2(\delta\_{\text{channel}}, s, \sigma) = \bigl(\text{state}[0], \\, \text{state}[1]\bigr), \qquad \text{state} = \text{permute}\bigl([\delta\_{\text{channel}}, \\, s, \\, \sigma, \\, 3 \cdot 2^{64}]\bigr)$$ +$$\text{SpongeSqueeze}\_n(\delta\_{\text{channel}}, s, \sigma) = \bigl(\text{state}[0], \\, \ldots, \\, \text{state}[n-1]\bigr), \qquad \text{state} = \text{permute}\bigl([\delta\_{\text{channel}}, \\, s, \\, \sigma, \\, 3 \cdot 2^{64}]\bigr)$$ -where $$s$$ is the ECDH shared scalar of Section 2.4. Two channel tags are used: $$\delta\_{\text{aud\\\_s}}$$ for the sender-auditor channel keyed by $$s\_{a,s} = \text{ECDH}(r\_e, K\_{\text{aud,s}})$$, and $$\delta\_{\text{aud\\\_r}}$$ for the recipient-auditor channel keyed by $$s\_{a,r} = \text{ECDH}(r\_e, K\_{\text{aud,r}})$$. No other arity is instantiated; $$n = 2$$ everywhere this notation appears. +where $$s$$ is the ECDH shared scalar of Section 2.4 and $$n \in \\{2, 3\\}$$ is the number of rate lanes read; the capacity lane $$\text{state}[3]$$ is never squeezed. Two channel tags are used: $$\delta\_{\text{aud\\\_s}}$$ for the sender-auditor channel keyed by $$s\_{a,s} = \text{ECDH}(r\_e, K\_{\text{aud,s}})$$, squeezed three-wide, and $$\delta\_{\text{aud\\\_r}}$$ for the recipient-auditor channel keyed by $$s\_{a,r} = \text{ECDH}(r\_e, K\_{\text{aud,r}})$$, squeezed two-wide. No other arity is instantiated. Because the absorb is one block, $$\text{SpongeSqueeze}\_3(\delta, s, \sigma)[i] = \text{SpongeSqueeze}\_2(\delta, s, \sigma)[i]$$ for $$i \in \\{0, 1\\}$$: widening a channel adds a lane without changing the value of any existing one. -Squeeze order is canonical. Lane 0 is always an amount mask and lane 1 is always a balance, allowance, or randomness mask, fixed per operation by the formulas in Sections 7 and 8. Single-ciphertext channels (the Withdraw balance checkpoint, W\_a3) take **lane 1** and leave the amount lane unused, so a checkpoint pad can never coincide with an amount pad. +**Lane assignment.** Squeeze order is canonical. Lanes are named by their zero-based index into the squeeze output: `lane[i]` is $$\text{SpongeSqueeze}\_n(\delta, s, \sigma)[i]$$, so a three-wide squeeze yields `lane[0]`, `lane[1]`, and `lane[2]`. `lane[0]` is always an amount mask and `lane[1]` is always a balance, allowance, or randomness mask, fixed per operation by the formulas in Sections 7 and 8. `lane[2]`, present only on the sender-auditor channel, is the **blinding-escrow slot**: it always carries the blinding of a commitment the operation writes, never a key. Which commitment per operation is specified in Section 8.1 *The `lane[2]` slot*. The Withdraw checkpoint (W\_a3, W\_a5) takes `lane[1]` and `lane[2]` and leaves the amount lane unused, so a checkpoint pad can never coincide with an amount pad. -**Mode exclusivity.** Because the absorb occupies a single block, $$\text{SpongeSqueeze}\_2(\delta, s, \sigma)[0]$$ is the same field element as $$\text{Poseidon2}(\delta, s, \sigma)$$. Distinct domain tags (Section 13) are therefore not sufficient on their own: each tag MUST additionally be used in exactly one of the two modes, or the same $$(\delta, s, \sigma)$$ would yield one mode's mask as the other's output. $$\delta\_{\text{aud\\\_s}}$$ and $$\delta\_{\text{aud\\\_r}}$$ are the two-mask tags; every other tag in Section 13 is used only with the single-output form above. +**Mode exclusivity.** Because the absorb occupies a single block, $$\text{SpongeSqueeze}\_n(\delta, s, \sigma)[0]$$ is the same field element as $$\text{Poseidon2}(\delta, s, \sigma)$$. Distinct domain tags (Section 13) are therefore not sufficient on their own: each tag MUST additionally be used in exactly one sponge mode, or the same $$(\delta, s, \sigma)$$ would yield one mode's mask as the other's output. $$\delta\_{\text{aud\\\_s}}$$ and $$\delta\_{\text{aud\\\_r}}$$ are the two multi-lane tags; every other tag in Section 13 is used only with the single-output form above. Reading one multi-lane tag at two different widths is not a mode violation, by the prefix property above: the sender-auditor tag is squeezed three-wide on every operation that escrows `lane[2]` and two-wide on `RevokeSpender` (V\_a3), while the recipient-auditor tag is always two-wide. -The sponge masks are deterministic in $$(s, \sigma)$$, where $$s$$ is the ECDH shared scalar of Section 2.4, so reusing the pair across two operations reuses every pad slot they share, and a slot whose plaintext is known in one operation (e.g. a transfer amount known to its recipient) decrypts the other operation's ciphertext in that slot. The canonical slot assignment above limits the blast radius of such reuse to same-slot pairs, but does not eliminate it; provers and wallets MUST use a fresh $$(r\_e, \sigma)$$ for every proof. Because $$r\_e$$ is derived from the originator's viewing key and the salt rather than drawn independently (§5.3), a fresh salt is the only thing that makes the pair fresh: the salt carries the entire requirement, and Section 9.6's retry rule is what discharges it. +The sponge masks are deterministic in $$(s, \sigma)$$, where $$s$$ is the ECDH shared scalar of Section 2.4, so reusing the pair across two operations reuses every pad slot they share, and a slot whose plaintext is known in one operation (e.g. a transfer amount known to its recipient) decrypts the other operation's ciphertext in that slot. The canonical slot assignment above limits the blast radius of such reuse to same-slot pairs, but does not eliminate it; provers and wallets MUST use a fresh $$(r\_e, \sigma)$$ for every proof. Because $$r\_e$$ is derived from the originator's viewing key and the salt rather than drawn independently (§5.3), a fresh salt is the only thing that makes the pair fresh: the salt carries the entire requirement, and Section 9.6's retry rule is what discharges it. The salt that carries it is whichever one the operation's pads absorb -- $$\sigma$$ for owner-initiated operations, the prover-chosen $$\sigma\_a'$$ for spender transfers (§6.2 *Transfer nonce*) -- never a salt loaded from storage, which a revert leaves unchanged. All references to "Poseidon" in this document denote this Poseidon2 instantiation. @@ -351,7 +351,7 @@ $$W\_{\text{receive}} = (v\_r, r\_r) \quad \text{such that} \quad C\_{\text{rece **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: +**Update rules.** Each balance-modifying event updates the accumulators as follows: | Event | Accumulator update | |:---|:---| @@ -409,7 +409,7 @@ Because $$\sigma$$ is published in the event and $$vk$$ is held by the originato **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_spender` (§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{transfer\\\_blind}}/\delta\_{\text{transfer\\\_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 spender 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 contract's enumeration of channels in §13 satisfies the domain-distinctness condition. +**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{transfer\\\_blind}}/\delta\_{\text{transfer\\\_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 spender escrow), so masks across channels are independent under the PRF assumption on Poseidon (§3.2). The auditor-side allowance-blinding escrow (S14, $$\delta\_{\text{esc\\\_allow\\\_r\\\_aud}}$$) is the one derivation that does *not* open a channel of its own. It reuses the S\_a2 shared scalar, so it drops the distinct-scalar leg, and it absorbs $$\text{op}\_i$$, a per-delegation constant, rather than a per-operation nonce, so it drops the freshness leg too. Its separation therefore rests on the distinct-tag leg alone, plus the freshness of the shared scalar itself, inherited from $$r\_e$$ and hence from the salt. This is the thinnest construction in the system, and [DESIGN_cont.md](./DESIGN_cont.md) §8.5 states what it does and does not buy. The channel masks are used as one-time pads against fresh per-transfer randomness ($$\sigma$$, or $$\sigma\_a'$$ for spender transfers), 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 for every channel; the contract's enumeration of channels in §13 satisfies the domain-distinctness condition. S14 is the single derivation that stands on one of the three, as noted above. ### 5.4 Anti-Poisoning Constraint @@ -494,9 +494,11 @@ $$dvk\_i$$ encrypted under the spender'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_spender` and replaced by the spender on every `confidential_transfer_from` (the spender 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. +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_spender` and replaced by the spender on every `confidential_transfer_from` (the spender samples a fresh $$\sigma\_a'$$ 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 spender 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 scalar $$s$$ (or $$s\_{a,r}$$, $$s\_{a,s}$$, §2.4), 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. +**Transfer nonce.** The stored $$\sigma\_a$$ opens the current allowance (O3) and nothing else. Every pad a spender transfer derives absorbs the prover-chosen replacement $$\sigma\_a'$$ instead: the recipient ECDH encryption (O7, O9), both auditor channel sponges (O\_a2, O\_a6, and with the latter the lane[2] escrow O\_a9), and the ephemeral scalar (§7.8). $$\sigma\_a'$$ is therefore the nonce the `SpenderTransfer` event emits, and the salt it replaces is never emitted. + +The split is what makes a retry safe. A reverted call leaves the delegation entry untouched, so the stored $$\sigma\_a$$ is forced on the retry; keying the pads to it would repeat $$r\_e$$ and every mask, and a retry that changed the amount would publish the difference in the clear (§2.5). $$\sigma\_a'$$ is constrained only against prover-supplied state, so a retry re-samples it freely. Constraint O14 rejects $$\sigma\_a' = \sigma\_a$$, which would key the pads to the salt the previous transfer already used; it cannot see older salts, so **global non-repetition of $$\sigma\_a'$$ across the delegation's lifetime is a client obligation** ([SDK.md](./SDK.md) §10.4), not an enforced property. **`live_until_ledger`** @@ -600,10 +602,11 @@ The owner withdraws a public amount $$a$$ (typed `i128`) from their spendable ba | 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} = \text{ECDH}(r\_e, K\_{\text{aud,s}})$$ (sender-auditor ECDH shared scalar, §2.4) | -| W\_a3 | $$(\cdot, m\_b) = \text{SpongeSqueeze}\_2(\delta\_{\text{aud\\\_s}}, s\_{a,s}, \sigma)$$ (sender-auditor channel sponge; $$m\_b$$ is the second squeeze — the balance slot, matching T\_a6/S\_a3/V\_a3. The first-squeeze amount slot is unused: the withdrawal amount is public, and skipping the slot keeps the checkpoint pad distinct from every amount pad even under $$(r\_e, \sigma)$$ reuse, Section 2.5) | +| W\_a3 | $$(\cdot, m\_b, m\_r) = \text{SpongeSqueeze}\_3(\delta\_{\text{aud\\\_s}}, s\_{a,s}, \sigma)$$ (sender-auditor channel sponge; $$m\_b$$ is `lane[1]` — the balance slot, matching T\_a6/S\_a3 — and $$m\_r$$ is `lane[2]`, the secret-escrow slot. `lane[0]`, the amount slot, is unused: the withdrawal amount is public, and skipping the slot keeps the checkpoint pad distinct from every amount pad even under $$(r\_e, \sigma)$$ reuse, Section 2.5) | | W\_a4 | $$\tilde{b}\_{\text{aud,s}} = (v - a) + m\_b$$ (sender-auditor encrypted balance checkpoint) | +| W\_a5 | $$\tilde{r}\_{\text{aud,s}} = r' + m\_r$$ (sender-auditor escrow of the new spendable blinding, over W5's $$r'$$; with W\_a4 this hands the sender's auditor the opening of $$C\_{\text{spend}}'$$, Section 8.1) | -**Public inputs (15 fields):** +**Public inputs (16 fields):** | Input | Notes | |:---|:---| @@ -612,13 +615,13 @@ The owner withdraws a public amount $$a$$ (typed `i128`) from their spendable ba | $$\text{addr\\\_f}$$ | 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_commitment`, the rest emitted in event | +| $$C\_{\text{spend}}'$$, $$\sigma$$, $$\tilde{b}$$, $$R\_e$$, $$\tilde{b}\_{\text{aud,s}}$$, $$\tilde{r}\_{\text{aud,s}}$$ | Prover-supplied, in this order; $$C\_{\text{spend}}'$$ written to `from.spendable_commitment`, 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_commitment` $$= C\_{\text{spend}}'$$, and calls `token.transfer(self, to, a)`. Emits event with $$(R\_e, \sigma, \tilde{b}, \tilde{b}\_{\text{aud,s}})$$. +**Post-verification:** The contract verifies the proof, sets `from`.`spendable_commitment` $$= C\_{\text{spend}}'$$, and calls `token.transfer(self, to, a)`. Emits event with $$(R\_e, \sigma, \tilde{b}, \tilde{b}\_{\text{aud,s}}, \tilde{r}\_{\text{aud,s}})$$. ### 7.6 Confidential Transfer @@ -648,11 +651,12 @@ The sender (account $$A$$, spending key $$sk\_A$$) transfers a hidden amount $$v | T\_a3 | $$\tilde{v}\_{\text{aud,r}} = v\_{\text{transfer}} + m\_{v,r}$$ (recipient-auditor encrypted transfer amount) | | T\_a4 | $$\tilde{r}\_{\text{aud,r}} = r\_{\text{transfer}} + 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} = \text{ECDH}(r\_e, K\_{\text{aud,s}})$$ (sender-auditor ECDH shared scalar, reuses ephemeral scalar) | -| T\_a6 | $$(m\_{v,s}, m\_{b,s}) = \text{SpongeSqueeze}\_2(\delta\_{\text{aud\\\_s}}, s\_{a,s}, \sigma)$$ (sender-auditor channel masks) | +| T\_a6 | $$(m\_{v,s}, m\_{b,s}, m\_{r,s}) = \text{SpongeSqueeze}\_3(\delta\_{\text{aud\\\_s}}, s\_{a,s}, \sigma)$$ (sender-auditor channel masks) | | T\_a7 | $$\tilde{v}\_{\text{aud,s}} = v\_{\text{transfer}} + m\_{v,s}$$ (sender-auditor encrypted transfer amount) | | T\_a8 | $$\tilde{b}\_{\text{aud,s}} = (v\_A - v\_{\text{transfer}}) + m\_{b,s}$$ (sender-auditor encrypted balance checkpoint) | +| T\_a9 | $$\tilde{r}\_{\text{aud,s}} = r\_A' + m\_{r,s}$$ (sender-auditor escrow of the new spendable blinding, over T10's $$r\_A'$$; with T\_a8 this hands the sender's auditor the opening of $$C\_{\text{spend}}'$$, Section 8.1) | -**Public inputs (24 fields, counting each Grumpkin point as two $$\mathbb{F}\_r$$ coordinates):** +**Public inputs (25 fields, counting each Grumpkin point as two $$\mathbb{F}\_r$$ coordinates):** | Input | Notes | |:---|:---| @@ -662,14 +666,14 @@ The sender (account $$A$$, spending key $$sk\_A$$) transfers a hidden amount $$v | $$\text{addr\\\_f}$$ | 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{transfer}}$$, $$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_commitment`, $$C\_{\text{transfer}}$$ added to recipient's `receiving_commitment`, the rest emitted in event | +| $$C\_{\text{spend}}'$$, $$C\_{\text{transfer}}$$, $$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}}$$, $$\tilde{r}\_{\text{aud,s}}$$ | Prover-supplied, in this order; $$C\_{\text{spend}}'$$ written to sender's `spendable_commitment`, $$C\_{\text{transfer}}$$ added to recipient's `receiving_commitment`, the rest emitted in event | **Private witnesses:** $$sk\_A$$, $$vk\_A$$, $$v\_A$$, $$r\_A$$, $$v\_{\text{transfer}}$$, $$r\_e$$. **Post-verification:** The contract verifies the proof, then: - Sets $$A$$`.spendable_commitment` $$= C\_{\text{spend}}'$$ - Adds to recipient: $$B$$`.receiving_commitment` $$\mathrel{+}= C\_{\text{transfer}}$$ -- 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}})$$ +- 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}}, \tilde{r}\_{\text{aud,s}})$$ **Recipient processing.** Upon observing the event, the recipient computes $$s = \text{ECDH}(vk, R\_e)$$, derives amount and blinding. The decryption flow is independent of whether the sender was the owner or a spender. @@ -694,13 +698,15 @@ The owner locks funds from their spendable balance into a per-spender escrow. Th | 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) | +| S14 | $$\tilde{r}\_{a,\text{aud,s}} = \text{Poseidon}(\delta\_{\text{esc\\\_allow\\\_r\\\_aud}}, s\_{a,s}, \text{op}\_i) + r\_a$$ (auditor-side escrow of S6's allowance blinding over the S\_a2 shared scalar, Section 8.5; the blinding is already a witness by S6, so this is one Poseidon and no new scalar multiplication) | | S\_a1 | $$R\_e = r\_e \cdot H$$ (ephemeral key for auditor ECDH) | | S\_a2 | $$s\_{a,s} = \text{ECDH}(r\_e, K\_{\text{aud,s}})$$ (owner-auditor ECDH shared scalar, §2.4) | -| S\_a3 | $$(m\_v, m\_b) = \text{SpongeSqueeze}\_2(\delta\_{\text{aud\\\_s}}, s\_{a,s}, \sigma)$$ (owner-auditor channel masks) | +| S\_a3 | $$(m\_v, m\_b, m\_r) = \text{SpongeSqueeze}\_3(\delta\_{\text{aud\\\_s}}, s\_{a,s}, \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) | +| S\_a6 | $$\tilde{r}\_{\text{aud,s}} = r' + m\_r$$ (owner-auditor escrow of the new spendable blinding, over S9's $$r'$$; with S\_a5 this hands the owner's auditor the opening of $$C\_{\text{spend}}'$$, Section 8.1) | -**Public inputs (24 fields):** +**Public inputs (26 fields):** | Input | Notes | |:---|:---| @@ -710,11 +716,11 @@ The owner locks funds from their spendable balance into a per-spender escrow. Th | $$\text{op}\_i$$ | $$\text{address\\\_to\\\_field}$$(`spender` argument), computed per-call by the contract (§2.7) | | $$\text{addr\\\_f}$$ | 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_commitment`, the delegation fields written to storage, the rest emitted in event | +| $$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}}$$, $$\tilde{r}\_{\text{aud,s}}$$, $$\tilde{r}\_{a,\text{aud,s}}$$ | Prover-supplied, in this order; $$C\_{\text{spend}}'$$ written to owner's `spendable_commitment`, 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_commitment` $$= C\_{\text{spend}}'$$ and stores the `SpenderDelegation`. Emits event with $$(R\_e, \sigma, \tilde{b}, \tilde{v}\_{\text{aud,s}}, \tilde{b}\_{\text{aud,s}})$$. +**Post-verification:** The contract verifies the proof, sets `spendable_commitment` $$= C\_{\text{spend}}'$$ and stores the `SpenderDelegation`. Emits event with $$(R\_e, \sigma, \tilde{b}, \tilde{v}\_{\text{aud,s}}, \tilde{b}\_{\text{aud,s}}, \tilde{r}\_{\text{aud,s}}, \tilde{r}\_{a,\text{aud,s}})$$. ### 7.8 Spender Transfer @@ -730,23 +736,25 @@ The spender transfers from the owner's escrowed allowance to a recipient. | O4 | $$v\_a \in [0, 2^{127})$$, $$v\_{\text{transfer}} \in [0, 2^{127})$$, $$v\_a - v\_{\text{transfer}} \in [0, 2^{127})$$ (range validity, Section 2.6) | | O5 | $$s = \text{ECDH}(r\_e, \text{PVK}\_{\text{recipient}})$$ (recipient ECDH shared scalar, §2.4) | | O6 | $$R\_e = r\_e \cdot H$$ | -| O7 | $$r\_{\text{transfer}} = \text{Poseidon}(\delta\_{\text{transfer\\\_blind}}, s, \sigma\_a)$$ (transfer blinding) | +| O7 | $$r\_{\text{transfer}} = \text{Poseidon}(\delta\_{\text{transfer\\\_blind}}, s, \sigma\_a')$$ (transfer blinding) | | O8 | $$C\_{\text{transfer}} = v\_{\text{transfer}} \cdot G + r\_{\text{transfer}} \cdot H$$ | -| O9 | $$\tilde{v} = v\_{\text{transfer}} + \text{Poseidon}(\delta\_{\text{transfer\\\_amount}}, s, \sigma\_a)$$ (encrypted amount) | +| O9 | $$\tilde{v} = v\_{\text{transfer}} + \text{Poseidon}(\delta\_{\text{transfer\\\_amount}}, s, \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{transfer}}) \cdot G + r\_a' \cdot H$$ (new allowance) | | O12 | $$\tilde{a}' = (v\_a - v\_{\text{transfer}}) + \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$$) | +| 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'$$) | +| O14 | $$\sigma\_a' \neq \sigma\_a$$ (nonce rotation; rejects the one-step pad reuse, §6.2 *Transfer nonce*) | | O\_a1 | $$s\_{a,r} = \text{ECDH}(r\_e, K\_{\text{aud,r}})$$ (recipient-auditor ECDH shared scalar, reuses ephemeral scalar) | -| O\_a2 | $$(m\_{v,r}, m\_{r,r}) = \text{SpongeSqueeze}\_2(\delta\_{\text{aud\\\_r}}, s\_{a,r}, \sigma\_a)$$ (recipient-auditor channel masks) | +| O\_a2 | $$(m\_{v,r}, m\_{r,r}) = \text{SpongeSqueeze}\_2(\delta\_{\text{aud\\\_r}}, s\_{a,r}, \sigma\_a')$$ (recipient-auditor channel masks) | | O\_a3 | $$\tilde{v}\_{\text{aud,r}} = v\_{\text{transfer}} + m\_{v,r}$$ (recipient-auditor encrypted transfer amount) | | O\_a4 | $$\tilde{r}\_{\text{aud,r}} = r\_{\text{transfer}} + 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} = \text{ECDH}(r\_e, K\_{\text{aud,s}})$$ (owner-auditor ECDH shared scalar, reuses ephemeral scalar) | -| O\_a6 | $$(m\_{v,s}, m\_{a,s}) = \text{SpongeSqueeze}\_2(\delta\_{\text{aud\\\_s}}, s\_{a,s}, \sigma\_a)$$ (owner-auditor channel masks) | +| O\_a6 | $$(m\_{v,s}, m\_{a,s}, m\_{r,s}) = \text{SpongeSqueeze}\_3(\delta\_{\text{aud\\\_s}}, s\_{a,s}, \sigma\_a')$$ (owner-auditor channel masks) | | O\_a7 | $$\tilde{v}\_{\text{aud,s}} = v\_{\text{transfer}} + m\_{v,s}$$ (owner-auditor encrypted transfer amount) | | O\_a8 | $$\tilde{a}\_{\text{aud,s}} = (v\_a - v\_{\text{transfer}}) + m\_{a,s}$$ (owner-auditor encrypted post-transfer allowance) | +| O\_a9 | $$\tilde{r}\_{\text{aud,s}} = r\_a' + m\_{r,s}$$ (owner-auditor escrow of the NEW allowance blinding, the one O11 commits under, Section 8.5; already a witness by O10, so this is one field addition. Pad and plaintext both key off $$\sigma\_a'$$, under distinct domain tags and distinct secrets) | -**Public inputs (24 fields):** +**Public inputs (25 fields):** | Input | Notes | |:---|:---| @@ -755,13 +763,13 @@ The spender transfers from the owner's escrowed allowance to a recipient. | $$\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 spender's. The visibility model points balance- and allowance-checkpoint ciphertexts at the funds' owner. | -| $$C\_a'$$, $$C\_{\text{transfer}}$$, $$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{transfer}}$$ added to recipient's `receiving_commitment`, the rest emitted in event | +| $$C\_a'$$, $$C\_{\text{transfer}}$$, $$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}}$$, $$\tilde{r}\_{\text{aud,s}}$$ | Prover-supplied, in this order; allowance fields written to delegation storage, $$C\_{\text{transfer}}$$ added to recipient's `receiving_commitment`, 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{transfer}}$$, $$r\_e$$. -**Post-verification:** The contract checks `ledger.sequence() <= live_until_ledger`, updates `allowance_commitment`, `a_tilde`, stores `new_allowance_salt`, and adds $$C\_{\text{transfer}}$$ to the recipient's `receiving_commitment`. 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}})$$. +**Post-verification:** The contract checks `ledger.sequence() <= live_until_ledger`, updates `allowance_commitment`, `a_tilde`, stores $$\sigma\_a'$$ as the new `allowance_salt`, and adds $$C\_{\text{transfer}}$$ to the recipient's `receiving_commitment`. 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}}, \tilde{r}\_{\text{aud,s}})$$. -**Ephemeral scalar.** The spender derives $$r\_e = \text{Poseidon}(\delta\_{\text{eph}}, vk\_{\text{op}}, \sigma\_a)$$ (§5.3) from its *own* viewing key rather than the owner's, so that the spender can later disclose it ([SELECTIVE_DISCLOSURE.md](./SELECTIVE_DISCLOSURE.md) §7). The circuit does not constrain the derivation; it does not constrain $$vk\_{\text{op}}$$ at all, per *Contract binding* below. One consequence follows for the owner: since the owner does not hold $$vk\_{\text{op}}$$, the owner cannot recompute $$r\_e$$ for a spender transfer and cannot disclose it without the spender's cooperation ([SELECTIVE_DISCLOSURE.md](./SELECTIVE_DISCLOSURE.md) §7, *Coverage asymmetry*). +**Ephemeral scalar.** The spender derives $$r\_e = \text{Poseidon}(\delta\_{\text{eph}}, vk\_{\text{op}}, \sigma\_a')$$ (§5.3, §6.2 *Transfer nonce*) from its *own* viewing key rather than the owner's, so that the spender can later disclose it ([SELECTIVE_DISCLOSURE.md](./SELECTIVE_DISCLOSURE.md) §7). The circuit does not constrain the derivation; it does not constrain $$vk\_{\text{op}}$$ at all, per *Contract binding* below. One consequence follows for the owner: since the owner does not hold $$vk\_{\text{op}}$$, the owner cannot recompute $$r\_e$$ for a spender transfer and cannot disclose it without the spender's cooperation ([SELECTIVE_DISCLOSURE.md](./SELECTIVE_DISCLOSURE.md) §7, *Coverage asymmetry*). **Recipient uniformity.** The recipient path is identical to the direct-transfer path of §7.6 *Recipient processing*. @@ -829,9 +837,11 @@ The spender decrypts using $$sk\_{\text{op}}$$. The `set_spender` proof enforces The $$r\_e$$ here is the same scalar S\_a1 commits to ($$R\_e = r\_e \cdot H$$), so the escrow's $$R\_x$$ and the auditor channel's $$R\_e.x$$ are forced equal. +The same proof also escrows to the owner's *auditor* (S14), under its own domain tag and over the auditor shared scalar -- but what it escrows is the allowance blinding $$r\_a$$, not a second copy of $$dvk\_i$$. The auditor needs the opening of $$C\_a$$, not the generator of every opening, and the delegation key is permanent per pair -- it survives revocation and re-delegation to the same address -- so escrowing it would make one leaked ciphertext a permanent capability. That construction, its decryption path, and why it is a single-output pad rather than a sponge lane are specified in [DESIGN_cont.md](./DESIGN_cont.md) §8.5. + ### 7.12 Expiry and Revert Safety -Allowance randomness includes `allowance_salt` to prevent deterministic-randomness reuse after reverted transactions. Delegation storage, expiry, and revocation semantics are specified in §6.2. +A reverted transaction leaves the delegation entry unchanged, so the stored `allowance_salt` recurs on the retry. Every value a retry could vary is therefore keyed to the prover-chosen $$\sigma\_a'$$ (§6.2 *Transfer nonce*). Delegation storage, expiry, and revocation semantics are specified in §6.2. --- diff --git a/packages/tokens/src/confidential/docs/DESIGN_cont.md b/packages/tokens/src/confidential/docs/DESIGN_cont.md index afaaeb1a1..9319a4102 100644 --- a/packages/tokens/src/confidential/docs/DESIGN_cont.md +++ b/packages/tokens/src/confidential/docs/DESIGN_cont.md @@ -15,7 +15,7 @@ for §8-§13. ### 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. +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$$; the recipient channel squeezes two masks, the sender channel three (Section 2.5 *Lane assignment*). **Recipient's auditor** ($$K\_{\text{aud,r}}$$, from the recipient's `auditor_id`) receives the transfer amount and the per-transfer Pedersen randomness: @@ -23,22 +23,24 @@ $$s\_{a,r} = \text{ECDH}(r\_e, K\_{\text{aud,r}}) \qquad \text{(DESIGN §2.4)}$$ $$(m\_{v,r}, m\_{r,r}) = \text{SpongeSqueeze}\_2(\delta\_{\text{aud\\\_r}}, s\_{a,r}, \sigma)$$ $$\tilde{v}\_{\text{aud,r}} = v\_{\text{transfer}} + m\_{v,r}, \qquad \tilde{r}\_{\text{aud,r}} = r\_{\text{transfer}} + 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: +**Sender's auditor** ($$K\_{\text{aud,s}}$$, from the sender's `auditor_id`) receives the transfer amount, the sender's post-transfer balance, and -- in the `lane[2]` blinding-escrow slot (DESIGN §2.5 *Lane assignment*) -- the sender's post-transfer spendable blinding $$r\_A'$$: $$s\_{a,s} = \text{ECDH}(r\_e, K\_{\text{aud,s}}) \qquad \text{(DESIGN §2.4)}$$ -$$(m\_{v,s}, m\_{b,s}) = \text{SpongeSqueeze}\_2(\delta\_{\text{aud\\\_s}}, s\_{a,s}, \sigma)$$ -$$\tilde{v}\_{\text{aud,s}} = v\_{\text{transfer}} + m\_{v,s}, \qquad \tilde{b}\_{\text{aud,s}} = (v\_A - v\_{\text{transfer}}) + m\_{b,s}$$ +$$(m\_{v,s}, m\_{b,s}, m\_{r,s}) = \text{SpongeSqueeze}\_3(\delta\_{\text{aud\\\_s}}, s\_{a,s}, \sigma)$$ +$$\tilde{v}\_{\text{aud,s}} = v\_{\text{transfer}} + m\_{v,s}, \qquad \tilde{b}\_{\text{aud,s}} = (v\_A - v\_{\text{transfer}}) + m\_{b,s}, \qquad \tilde{r}\_{\text{aud,s}} = r\_A' + m\_{r,s}$$ -The transfer circuit (constraints T\_a1--T\_a8) enforces correct computation. At operation time, the contract fetches both auditor keys from the auditor contract using the *stored* `auditor_id` field of each account; neither the sender nor the recipient can substitute a different key for the operation being proven. This guarantee is scoped to operation time: *which* auditor an account is bound to is chosen by the account owner at registration (DESIGN §7.2), subject only to existence in the auditor registry unless the deployment gates the selection in its `Hooks::on_register` implementation ([COMPLIANCE.md](./COMPLIANCE.md) §4.3). +The transfer circuit (constraints T\_a1--T\_a9) enforces correct computation. At operation time, the contract fetches both auditor keys from the auditor contract using the *stored* `auditor_id` field of each account; neither the sender nor the recipient can substitute a different key for the operation being proven. This guarantee is scoped to operation time: *which* auditor an account is bound to is chosen by the account owner at registration (DESIGN §7.2), subject only to existence in the auditor registry unless the deployment gates the selection in its `Hooks::on_register` implementation ([COMPLIANCE.md](./COMPLIANCE.md) §4.3). 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} = \text{Poseidon}(\delta\_{\text{ecdh}}, S\_{a,s}.x, S\_{a,s}.y)$$ -$$(m\_{v,s}, m\_{b,s}) = \text{SpongeSqueeze}\_2(\delta\_{\text{aud\\\_s}}, s\_{a,s}, \sigma)$$ -$$v\_{\text{transfer}} = \tilde{v}\_{\text{aud,s}} - m\_{v,s}, \qquad v\_{\text{new}} = \tilde{b}\_{\text{aud,s}} - m\_{b,s}$$ +$$(m\_{v,s}, m\_{b,s}, m\_{r,s}) = \text{SpongeSqueeze}\_3(\delta\_{\text{aud\\\_s}}, s\_{a,s}, \sigma)$$ +$$v\_{\text{transfer}} = \tilde{v}\_{\text{aud,s}} - m\_{v,s}, \qquad v\_{\text{new}} = \tilde{b}\_{\text{aud,s}} - m\_{b,s}, \qquad r\_{\text{new}} = \tilde{r}\_{\text{aud,s}} - m\_{r,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{transfer}}, r\_{\text{transfer}})$$. +**The `lane[2]` slot.** The `lane[2]` output of the sender-auditor channel (DESIGN §2.5 *Lane assignment*) carries the blinding of a commitment the operation writes, and never a key: the new spendable blinding on the three checkpoint operations (W\_a5, T\_a9, S\_a6), and the new allowance blinding $$r\_a'$$ on spender transfers (O\_a9, Section 8.4). The two blindings belong to different commitment roles, so the uniformity is a naming convention, not an interchangeability claim; each is scoped to the operation that emitted it. The pad is fixed by $$(s\_{a,s}, \sigma)$$ or $$(s\_{a,s}, \sigma\_a')$$, both fresh per operation. + **Recipient-auditor opening capability.** Because the recipient-auditor recovers $$r\_{\text{transfer}}$$ for every inbound transfer, and because deposits add to `receiving_commitment` 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{transfer},i} + \sum\_j a\_j, \qquad r\_r = \sum\_i r\_{\text{transfer},i}$$ @@ -48,10 +50,19 @@ where $$i$$ ranges over inbound transfers and spender-transfers since the last m The capability is bounded in three ways: - **Forward-only.** Only events emitted while the auditor key was active are decryptable. -- **Receiving-side only.** The opening covers `receiving_commitment`. It does not extend to $$C\_{\text{spend}}$$, 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 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. +- **Receiving-side only.** The reconstruction above covers `receiving_commitment`. It does not extend to $$C\_{\text{spend}}$$: the recipient-auditor cannot derive the spend-side blinding $$r\_s = \text{Poseidon}(\delta\_{\text{spend\\\_r}}, vk\_A, \sigma)$$, which depends on $$vk\_A$$. It 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. The spend-side opening reaches the *sender*-auditor by a different route -- the `lane[2]` escrow, bounded separately below -- not by this reconstruction. - **Reset by merge.** Merge folds $$r\_r$$ into the spendable-balance randomness ($$r\_{\text{spend}}' = r\_s + r\_r$$, Section 7.4) and emits no checkpoint, so the reconstruction above restarts from the next inbound flow. -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, while the sender-auditor remains the seize-enabling party for the spendable-balance side via $$\tilde{b}\_{\text{aud,s}}$$. +**Sender-auditor opening capability.** The `lane[2]` escrow hands the sender-auditor the *blinding* of the account's post-operation spendable balance directly, without $$vk\_A$$: together with the value in $$\tilde{b}\_{\text{aud,s}}$$ it is a full Pedersen opening of $$C\_{\text{spend}}'$$. It is available at exactly the three checkpoint operations that escrow `lane[2]` -- withdrawal (W\_a5), outgoing transfer (T\_a9), and `set_spender` (S\_a6) -- and is likewise bounded: + +- **Forward-only**, on the same grounds as the recipient side. +- **Maintained across merges.** An account binds a single `auditor_id` (Section 6.1), so the key that decrypts the `lane[2]` escrow is the same key that decrypts the recipient channel of every inbound flow to that account. Merge adds both the values and the blindings ($$v\_{\text{spend}}' = v\_s + v\_r$$, $$r\_{\text{spend}}' = r\_s + r\_r$$, Section 7.4), and the auditor holds each addend: $$(v\_{\text{transfer},i}, r\_{\text{transfer},i})$$ from the recipient-channel reconstruction above, and $$(a\_j, 0)$$ from the public deposits. It therefore carries the escrowed opening forward through every merge by the same addition the contract performs, rather than losing it at one. +- **Not renewed by `revoke_spender`.** V\_a3 stays two-lane (DESIGN §7.9), so a revoke rewrites $$C\_{\text{spend}}$$ under a blinding the auditor never receives and leaves it with the post-reclaim *value* alone. The opening is re-acquired at the next checkpoint (W\_a5, T\_a9, S\_a6). +- **Rotation-scoped.** A newly activated key cannot decrypt escrows published under the previous one (Section 8.3). An auditor that carries its accumulated opening across the rotation keeps it; one that bootstraps from the new key alone re-acquires it at the next checkpoint. + +The recipient-side reconstruction is therefore event-scoped, while the sender-side escrow gives the account's auditor a **standing** opening of $$C\_{\text{spend}}$$, held continuously from its first checkpoint under the active key. + +These bounded openings are what enable 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, while the sender-auditor is the seize-enabling party for the spendable-balance side via $$\tilde{b}\_{\text{aud,s}}$$ and $$\tilde{r}\_{\text{aud,s}}$$. ### 8.2 Auditor Visibility Properties @@ -59,12 +70,12 @@ This bounded opening is what enables the clawback flow specified in [COMPLIANCE. **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{transfer}})$$ 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 spender**: 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 spender**: 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). +- **Outgoing transfer**: auditor decrypts post-transfer balance $$(v\_A - v\_{\text{transfer}})$$ from $$\tilde{b}\_{\text{aud,s}}$$ and the matching blinding $$r\_A'$$ from $$\tilde{r}\_{\text{aud,s}}$$ (constraints T\_a5--T\_a9). +- **Withdrawal**: auditor decrypts post-withdrawal balance $$(v - a)$$ from $$\tilde{b}\_{\text{aud,s}}$$ and the matching blinding $$r'$$ from $$\tilde{r}\_{\text{aud,s}}$$ (constraints W\_a1--W\_a5). The withdrawal amount $$a$$ is also visible as a public input. +- **Set spender**: auditor decrypts escrowed amount $$v\_a$$ from $$\tilde{v}\_{\text{aud,s}}$$, post-escrow balance $$(v - v\_a)$$ from $$\tilde{b}\_{\text{aud,s}}$$, and the matching blinding $$r'$$ from $$\tilde{r}\_{\text{aud,s}}$$ (constraints S\_a1--S\_a6). +- **Revoke spender**: 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). This is the one checkpoint operation that escrows no blinding: V\_a3 stays two-lane. -The recipient's auditor does not see the sender's balance in any of these operations. +The three operations that also escrow the post-operation spendable blinding confer the opening capability bounded in Section 8.1. The recipient's auditor does not see the sender's balance in any of these operations. **Per-transfer Pedersen randomness (recipient-auditor, not sender-auditor).** Beyond the transfer amount, the recipient's auditor also decrypts the per-transfer Pedersen blinding $$r\_{\text{transfer}}$$ from $$\tilde{r}\_{\text{aud,r}}$$ on every confidential transfer and spender-transfer; Section 8.1 states the opening capability this confers and its bounds (forward-only, receiving-side only, reset by merge). The sender's auditor does not see $$r\_{\text{transfer}}$$. The *originating account's own* $$vk$$ holder does, by recomputing $$r\_e$$ and hence $$s$$ (DESIGN.md §5.3); that path lies outside the auditor model and its consequences are stated in §9.4. @@ -78,29 +89,48 @@ The auditor contract stores Grumpkin public keys as full affine points $$(x, y)$ When building public inputs for any operation that produces auditor ciphertexts (transfers, withdrawals, set/revoke spender), the contract fetches the relevant auditor keys for the recipient's and/or sender's `auditor_id`. The contract passes the full Grumpkin point as a public input; the circuit constrains the ECDH ciphertexts against that exact point. The contract 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 contract 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 spender) reconstructs the proof against the new $$K\_{\text{aud}}$$ and resubmits. The rejection is benign: the contract'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. +**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 contract 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 spender) reconstructs the proof against the new $$K\_{\text{aud}}$$ and resubmits. The rejection is benign: the contract's spendable balance, receiving balance, and delegation state are unchanged by the reverted call, the operation's salt 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 resolves the version from its own rotation records (with a versioned activation-ledger registry, the auditor instead 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. +**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 resolves the version from its own rotation records (with a versioned activation-ledger registry, the auditor instead 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 Spender Transfer Auditing -Each spender 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: +Each spender transfer produces auditor ciphertexts under two keys (constraints O\_a1--O\_a9), 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)$$ +$$(m\_{v,r}, m\_{r,r}) = \text{SpongeSqueeze}\_2(\delta\_{\text{aud\\\_r}}, s\_{a,r}, \sigma\_a')$$ $$v\_{\text{transfer}} = \tilde{v}\_{\text{aud,r}} - m\_{v,r}, \qquad r\_{\text{transfer}} = \tilde{r}\_{\text{aud,r}} - m\_{r,r}$$ -The owner's auditor decrypts the transfer amount and post-transfer allowance: +The owner's auditor decrypts the transfer amount, the post-transfer allowance, and -- in the `lane[2]` blinding-escrow slot -- the *new* allowance blinding $$r\_a'$$, the one O11 commits $$C\_a'$$ under (O\_a9): -$$(m\_{v,s}, m\_{a,s}) = \text{SpongeSqueeze}\_2(\delta\_{\text{aud\\\_s}}, s\_{a,s}, \sigma\_a)$$ -$$v\_{\text{transfer}} = \tilde{v}\_{\text{aud,s}} - m\_{v,s}, \qquad v\_a' = \tilde{a}\_{\text{aud,s}} - m\_{a,s}$$ +$$(m\_{v,s}, m\_{a,s}, m\_{r,s}) = \text{SpongeSqueeze}\_3(\delta\_{\text{aud\\\_s}}, s\_{a,s}, \sigma\_a')$$ +$$v\_{\text{transfer}} = \tilde{v}\_{\text{aud,s}} - m\_{v,s}, \qquad v\_a' = \tilde{a}\_{\text{aud,s}} - m\_{a,s}, \qquad r\_a' = \tilde{r}\_{\text{aud,s}} - m\_{r,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 spender-transfer inbound flows: $$r\_{\text{transfer}}$$ from spender-transfers contributes to $$r\_r$$ in $$C\_{\text{receive}}$$ identically to owner-transfer inbound flows. +$$(\tilde{a}\_{\text{aud,s}}, \tilde{r}\_{\text{aud,s}})$$ open the post-transfer $$C\_a'$$, the state left on-chain. + +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 spender-transfer inbound flows: $$r\_{\text{transfer}}$$ from spender-transfers contributes to $$r\_r$$ in $$C\_{\text{receive}}$$ identically to owner-transfer inbound flows. ### 8.5 Spender Allowance Auditing The auditor tracks each allowance's current value through the per-event ciphertexts produced at every state-changing operation: `set_spender` 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_spender` 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. +**Allowance opening.** The owner's auditor also receives the *blinding* of the allowance commitment each event writes: $$r\_a$$ under $$\delta\_{\text{esc\\\_allow\\\_r\\\_aud}}$$ at `set_spender` (S14, below) and $$r\_a'$$ in `lane[2]` at every spender transfer (O\_a9, §8.4). Paired with the value that event already publishes -- $$\tilde{v}\_{\text{aud,s}}$$ on `SetSpender`, $$\tilde{a}\_{\text{aud,s}}$$ on `SpenderTransfer` -- that is the full Pedersen opening of the $$C\_a$$ left on-chain, reconstructed from the event alone with no storage read. Unlike the spendable side there is no merge to fold in, since a delegation's only state transitions are the events themselves. + +What is escrowed is one state's blinding, not the generator of every state. $$dvk\_i = \text{Poseidon}(\delta\_{\text{dvk}}, vk, \text{op}\_i)$$ is deterministic and permanent for a $$(owner, spender)$$ pair -- neither the salt nor any generation counter enters it, so `revoke_spender` followed by re-delegation to the same address regenerates it exactly. A single leaked $$dvk\_i$$ ciphertext would therefore hand its holder every allowance value and blinding for that pair, past and future; a leaked $$r\_a$$ ciphertext costs one state. + +**Auditor-side allowance-blinding escrow.** At `set_spender` the owner escrows $$r\_a$$ -- the blinding S6 derived and S7 committed under -- to its own auditor: + +$$\tilde{r}\_{a,\text{aud,s}} = r\_a + \text{Poseidon}(\delta\_{\text{esc\\\_allow\\\_r\\\_aud}}, s\_{a,s}, \text{op}\_i)$$ + +reusing the S\_a2 shared scalar $$s\_{a,s} = \text{ECDH}(r\_e, K\_{\text{aud,s}})$$ rather than opening a new ECDH channel, so it costs one Poseidon and no scalar multiplication (DESIGN S14). The auditor recovers $$s\_{a,s}$$ from $$k\_{\text{aud,s}}$$ and the event's $$R\_e$$, and $$\text{op}\_i$$ from the event's `spender` topic, then subtracts. Because the escrowed value is pinned to S6, the opening the auditor holds necessarily matches the $$C\_a$$ the same proof wrote. + +A single-output pad rather than a sponge lane, because `lane[2]` of this channel is already taken by the spendable blinding (S\_a6). What separates it from that channel is tag distinctness: the tag is the first absorbed element, so no pair of remaining inputs makes $$\delta\_{\text{esc\\\_allow\\\_r\\\_aud}}$$ and $$\delta\_{\text{aud\\\_s}}$$ agree. DESIGN §2.5 *Mode exclusivity* is a separate per-tag obligation that each satisfies independently -- 17 is only ever single-output, 11 only ever multi-lane. $$\text{op}\_i$$ is a per-$$(owner, spender)$$ constant that separates pads across delegations, not a nonce: the pad's freshness rests entirely on $$s\_{a,s}$$, hence on $$r\_e$$, hence on the salt (DESIGN §5.3, §9.6). Re-delegating to the same spender under a reused salt would republish a byte-identical ciphertext. + +**Key rotation.** Decryption is scoped to events, and only to events. A key can decrypt an allowance ciphertext exactly when it was the active key at the moment that ciphertext was produced. Two consequences, and neither is stronger than that: + +- A freshly rotated-in key holds no opening for a live delegation. It acquires one at that delegation's next state-changing operation, when a fresh escrow is produced under it, and not before -- rotation does not re-anchor anything on its own. +- A retired key does **not** lose track of state it already opened. Proofless folds -- merge (§7.4), and `revoke_spender` where the deployment runs it without a proof -- recompute $$C\_{\text{spend}}' = C\_{\text{spend}} + C\_a$$ publicly, emitting no new encrypted checkpoint, so a holder of an already-known opening carries it forward by the same addition the contract performs. Containment here is **event-scoped, not state-scoped**: rotation bounds what a key can newly decrypt, not what its holder can still compute. + +**Archive dependence.** The auditor must have *observed* the event. There is no state-based fallback: the escrowed blinding lives in the event and nowhere in contract storage, so an auditor that missed a delegation event cannot recover that opening from `a_tilde` and $$\sigma\_a$$ the way a $$dvk\_i$$ holder could. This extends an existing dependency rather than creating one -- DESIGN.md §5.2 already makes a durable event archive normative, and the recipient-side reconstruction, the spendable checkpoint, and the merge carry-forward are all event-scoped already. [INDEXER.md](./INDEXER.md) §7.1 states the operational consequences: a consistency check of each reconstructed opening against the stored `allowance_commitment` is the practical detection mechanism for a missed or reordered event, and delegation-entry TTL bounds how long that check remains possible. --- @@ -155,7 +185,9 @@ This invariant is maintained by: Two properties of that last capability deserve stating plainly. It is **retroactive**: the derivation is deterministic in $$(vk, \sigma)$$ and $$\sigma$$ is published, so a compromise today opens every transfer the account ever originated. And it **reaches outside the account's own state**: the commitments it opens were added to *recipients'* `receiving_commitment` values. The transfer *amounts* were already inferable from $$vk$$ alone, by differencing consecutive balance checkpoints and netting the inbound credits and merges between them, so amount visibility is not what changes; the openings are, and an opening is a self-verifying artifact that its holder can hand to any third party. This is why $$vk$$ cannot be treated as a safely shareable read-only credential ([SDK.md](./SDK.md) §13): a counterparty that needs visibility into an account's outbound transfers receives per-event disclosure proofs bound to it and to a nonce ([SELECTIVE_DISCLOSURE.md](./SELECTIVE_DISCLOSURE.md) §7), not the key. -**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 spender) from accounts that used the compromised key, but cannot construct openings of any commitment. If a recipient's auditor key is compromised, the attacker acquires the opening capability of Section 8.1 over every incoming transfer to accounts that used the compromised key, with the scope bounded there (forward-only, receiving-side only, reset by merge). 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. +**Auditor key compromise.** An account binds one key for both channels (Section 6.1), so a compromise is best read per account rather than per role. For every account that used the compromised key, the attacker acquires: all amounts and balance checkpoints; the standing opening of $$C\_{\text{spend}}$$ from the first post-activation checkpoint, maintained across merges exactly as Section 8.1 describes; the opening of $$C\_{\text{receive}}$$ between merges; and every $$dvk\_i$$, hence the opening of every $$C\_a$$ (Section 8.5). It cannot recover viewing keys, read data from before the key was active, or authorize any spending. After key rotation, new operations are protected by the new key. + +As with $$vk$$ above, what the openings add over the amounts is that an opening is a self-verifying artifact its holder can hand to any third party. An auditor key is therefore custodied at the same level as a viewing key, not at the level of a read-only amount feed ([SDK.md](./SDK.md) §13). ### 9.5 State Recovery @@ -173,7 +205,7 @@ The recovery procedure, the definition of the **checkpoint** it is built around, 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. +**Retry procedure.** On revert, the wallet simply picks a new random salt -- $$\sigma$$, or $$\sigma\_a'$$ for a spender transfer -- and recomputes the proof. No special-case logic is needed. The salt is a public input and emitted in events so the auditor and owner can reconstruct randomness. ### 9.7 Replay Protection @@ -209,10 +241,10 @@ pub enum CircuitType { | Circuit | What it proves | |:---|:---| | `Register` | Spending key well-formedness; contract-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})$$) | -| `SpenderTransfer` | Allowance sufficiency; ECDH-derived blinding and encrypted amount for recipient; dual-auditor channel sponges (recipient auditor: amount + per-transfer Pedersen randomness; owner auditor: amount + allowance); deterministic randomness for new allowance; encrypted allowance scalar; spender key ownership; contract-bound indirectly via $$C\_a$$ chain (Section 7.8) | -| `SetSpender` | Balance split; $$dvk\_i$$ derivation; ECDH escrow of $$dvk\_i$$; allowance commitment with deterministic randomness; encrypted balance and allowance scalars; owner-auditor ECDH ciphertexts (escrow amount + balance checkpoint); owner key ownership; contract-bound via $$vk$$ derivation | +| `Withdraw` | Balance sufficiency; new spendable commitment with deterministic randomness; encrypted balance scalar; sender-auditor ECDH ciphertexts (balance checkpoint + `lane[2]` escrow of the new spendable blinding); 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 + `lane[2]` escrow of the new spendable blinding); deterministic randomness for new sender balance; encrypted balance scalar; sender key ownership; range validity (balance $$\in [0, 2^{127})$$, amount $$\in [0, 2^{127})$$) | +| `SpenderTransfer` | Allowance sufficiency; ECDH-derived blinding and encrypted amount for recipient; dual-auditor channel sponges (recipient auditor: amount + per-transfer Pedersen randomness; owner auditor: amount + allowance + `lane[2]` escrow of the new allowance blinding); deterministic randomness for new allowance; encrypted allowance scalar; spender key ownership; contract-bound indirectly via $$C\_a$$ chain (Section 7.8) | +| `SetSpender` | Balance split; $$dvk\_i$$ derivation; ECDH escrow of $$dvk\_i$$ to the spender and of the allowance blinding to the owner's auditor; allowance commitment with deterministic randomness; encrypted balance and allowance scalars; owner-auditor ECDH ciphertexts (escrow amount + balance checkpoint + `lane[2]` escrow of the new spendable blinding); owner key ownership; contract-bound via $$vk$$ derivation | | `RevokeSpender` | 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; contract-bound via $$vk$$ derivation | ### 10.3 Circuit Cost Analysis @@ -230,7 +262,7 @@ The dominant cost in Noir circuits is elliptic curve scalar multiplication. With | `SetSpender` | 7 | $$Y$$ (S1), $$C\_{\text{spend}}$$ opening (S3), $$C\_a$$ (S7), $$C\_{\text{spend}}'$$ (S10), $$R\_e$$ (S\_a1), $$dvk\_i$$ escrow ECDH (S12, §7.11), owner-auditor ECDH (S\_a2) | | `RevokeSpender` | 6 | $$Y$$ (V1), $$C\_a$$ opening (V4), $$C\_{\text{spend}}$$ opening (V5), $$C\_{\text{spend}}'$$ (V7), $$R\_e$$ (V\_a1), owner-auditor ECDH (V\_a2) | -`SetSpender` is the one circuit with a third ECDH beyond the auditor channel: the $$dvk\_i$$ handoff of §7.11 reuses $$r\_e$$ but multiplies it against $$Y\_{\text{op}}$$, so it is a separate call, not a reuse of the S\_a2 shared secret. The ordering these totals imply is consistent with the committed ACIR opcode counts in `circuits/constraints.baseline`: `Register` 33, `Withdraw` 94, `RevokeSpender` 123, `SetSpender` 131, `Transfer` 133, `SpenderTransfer` 135. +`SetSpender` is the one circuit with a third ECDH beyond the auditor channel: the $$dvk\_i$$ handoff of §7.11 reuses $$r\_e$$ but multiplies it against $$Y\_{\text{op}}$$, so it is a separate call, not a reuse of the S\_a2 shared secret. The auditor-side escrow of the allowance blinding $$r\_a$$ (S14) reuses the S\_a2 shared scalar and adds a Poseidon evaluation, not a call. The `lane[2]` escrows (W\_a5, T\_a9, S\_a6, O\_a9) read `lane[2]` of a permutation each circuit already computes and cost one field addition apiece. The ordering these totals imply is consistent with the committed ACIR opcode counts in `circuits/constraints.baseline`: `Register` 33, `Withdraw` 95, `RevokeSpender` 123, `Transfer` 134, `SetSpender` 135, `SpenderTransfer` 136. The ECDH computations add scalar multiplications compared to a random-blinding scheme, but the unchunked design eliminates all per-chunk constraints (which, in a chunked scheme, would involve 8+ scalar multiplications for balance chunks and per-chunk range proofs). @@ -260,7 +292,8 @@ global H: EmbeddedCurvePoint = EmbeddedCurvePoint { /// 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) +/// blindings, and (for the spend-side input opening of C_spend in W3/T3/S3 and +/// of both balance commitments in CB1/CB2) /// 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*. @@ -358,7 +391,7 @@ Requires `bn254_fr_{add, sub, mul, inv}` host calls (CAP-80, Section 10.7). **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 contract 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{transfer}}$$ (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). +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{transfer}}$$ (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 the operation's salt, 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 contract 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 contract trusts the fetched value. @@ -411,13 +444,13 @@ This table is authoritative: every entry is exactly the set of prover-supplied p | 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{transfer}}$$, $$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{transfer}}$$, $$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_spender` | $$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` | +| `withdraw` | $$C\_{\text{spend}}'$$, $$\tilde{b}$$, $$R\_e$$, $$\sigma$$, $$\tilde{b}\_{\text{aud,s}}$$, $$\tilde{r}\_{\text{aud,s}}$$, `proof` | +| `confidential_transfer` | $$C\_{\text{spend}}'$$, $$C\_{\text{transfer}}$$, $$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}}$$, $$\tilde{r}\_{\text{aud,s}}$$, `proof` | +| `confidential_transfer_from` | $$C\_a'$$, $$C\_{\text{transfer}}$$, $$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}}$$, $$\tilde{r}\_{\text{aud,s}}$$, `proof` | +| `set_spender` | $$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}}$$, $$\tilde{r}\_{\text{aud,s}}$$, $$\tilde{r}\_{a,\text{aud,s}}$$, `proof` | | `revoke_spender` | $$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 contract loads it from the `(from, spender)` 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_spender`, by contrast, has no prior delegation entry to load from, so its $$\sigma\_a$$ is prover-supplied and bound by S6. +For `confidential_transfer_from`, the stored allowance salt $$\sigma\_a$$ is **not** carried in `data`: the contract loads it from the `(from, spender)` delegation entry (§7.8 public-input table). Only the prover-chosen replacement $$\sigma\_a'$$ travels in `data`. It is bound as this transfer's channel nonce (O7, O9, O\_a2, O\_a6), as the new allowance state's salt (O10, O12), and against the salt it replaces (O14); it is then written back to the delegation entry as the new `allowance_salt` and emitted in the event (§6.2 *Transfer nonce*). 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_spender`, 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 @@ -450,17 +483,17 @@ Each state-modifying operation emits a structured event. Events carry the data n | `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}}$$ | -| `SpenderTransfer` | `spender`, `from`, `to`, $$R\_e$$, $$\tilde{v}$$, $$\sigma\_a$$, $$\tilde{v}\_{\text{aud,r}}$$, $$\tilde{r}\_{\text{aud,r}}$$, $$\tilde{v}\_{\text{aud,s}}$$, $$\tilde{a}\_{\text{aud,s}}$$ | -| `SetSpender` | `account`, `spender`, `live_until_ledger`, $$R\_e$$, $$\sigma$$, $$\tilde{b}$$, $$\tilde{v}\_{\text{aud,s}}$$, $$\tilde{b}\_{\text{aud,s}}$$ | +| `Withdraw` | `from`, `to`, `amount`, $$R\_e$$, $$\sigma$$, $$\tilde{b}$$, $$\tilde{b}\_{\text{aud,s}}$$, $$\tilde{r}\_{\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}}$$, $$\tilde{r}\_{\text{aud,s}}$$ | +| `SpenderTransfer` | `spender`, `from`, `to`, $$R\_e$$, $$\tilde{v}$$, $$\sigma\_a'$$, $$\tilde{v}\_{\text{aud,r}}$$, $$\tilde{r}\_{\text{aud,r}}$$, $$\tilde{v}\_{\text{aud,s}}$$, $$\tilde{a}\_{\text{aud,s}}$$, $$\tilde{r}\_{\text{aud,s}}$$ | +| `SetSpender` | `account`, `spender`, `live_until_ledger`, $$R\_e$$, $$\sigma$$, $$\tilde{b}$$, $$\tilde{v}\_{\text{aud,s}}$$, $$\tilde{b}\_{\text{aud,s}}$$, $$\tilde{r}\_{\text{aud,s}}$$, $$\tilde{r}\_{a,\text{aud,s}}$$ | | `RevokeSpender` | `account`, `spender`, $$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 `SpenderTransfer` events using $$(R\_e, \tilde{v}, \sigma)$$ to derive $$v\_{\text{transfer}}$$ and $$r\_{\text{transfer}}$$ (Section 5.3). +- **Recipient wallet**: processes `Transfer` and `SpenderTransfer` events using $$(R\_e, \tilde{v}, \sigma)$$ -- with $$\sigma\_a'$$ in place of $$\sigma$$ for `SpenderTransfer` -- to derive $$v\_{\text{transfer}}$$ and $$r\_{\text{transfer}}$$ (Section 5.3). - **Owner wallet**: processes all events for recovery (Section 5.2). The $$(\tilde{b}, \sigma)$$ pair from the most recent 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). @@ -512,9 +545,10 @@ Each $$\delta$$ is a small positive integer in $$\mathbb{F}\_r$$, fixed for the | $$\delta\_{\text{eph}}$$ | 14 | Deterministic ephemeral-scalar derivation (§5.3) | | $$\delta\_{\text{disc\\\_bind}}$$ | 15 | Disclosure ciphertext to the disclosure recipient, aggregate variant ([SELECTIVE_DISCLOSURE.md](./SELECTIVE_DISCLOSURE.md) §10) | | $$\delta\_{\text{disc}}$$ | 16 | Disclosure ciphertext to the disclosure recipient ([SELECTIVE_DISCLOSURE.md](./SELECTIVE_DISCLOSURE.md) §4) | +| $$\delta\_{\text{esc\\\_allow\\\_r\\\_aud}}$$ | 17 | Allowance-blinding escrow to the owner's auditor (S14, §8.5) | -This table assigns all sixteen values; no other document assigns them. Tags 14–16 are never absorbed inside a core circuit — 14 is derived off-circuit (DESIGN.md §5.3 makes its derivation normative for every operation whose originator holds a viewing key), and 15–16 belong to the off-chain selective-disclosure layer ([SELECTIVE_DISCLOSURE.md](./SELECTIVE_DISCLOSURE.md) §2.2) — so they are not part of the on-chain wire contract. All sixteen values MUST still be distinct and each MUST be confined to a single sponge mode, so a deployment treats them as one namespace. +This table assigns all seventeen values; no other document assigns them. Tags 14–16 are never absorbed inside a core circuit — 14 is derived off-circuit (DESIGN.md §5.3 makes its derivation normative for every operation whose originator holds a viewing key), and 15–16 belong to the off-chain selective-disclosure layer ([SELECTIVE_DISCLOSURE.md](./SELECTIVE_DISCLOSURE.md) §2.2) — so they are not part of the on-chain wire contract; `circuits/lib/src/lib.nr` accordingly implements 1–13 and 17. All seventeen values MUST still be distinct and each MUST be confined to a single sponge mode, so a deployment treats them as one namespace. Tag 17 is assigned out of sequence with its neighbours because it was added after 14–16. It takes its own tag rather than reusing $$\delta\_{\text{esc\\\_dvk}}$$ because the two escrows key off different shared scalars ($$Y\_{\text{op}}$$ vs $$K\_{\text{aud,s}}$$), which is the distinct-domain leg of §5.3's *Why reusing $$r\_e$$ is safe*. It shares its shared scalar with $$\delta\_{\text{aud\\\_s}}$$ instead, and what separates the two is simply that they are different tags: the tag is the first absorbed element, so no pair of remaining inputs makes tag 17 and tag 11 agree. Mode exclusivity is a separate per-tag rule that each satisfies on its own — 17 is only ever single-output, 11 only ever multi-lane (§8.5). -**Provenance.** Sequential small integers are the simplest assignment that satisfies the requirement of *distinctness* across all Poseidon2 invocations in this protocol -- §3.2 models Poseidon2 as a pseudorandom function, so evaluations whose leading input differs are computationally independent. Distinctness alone is not sufficient: each tag must also be confined to a single sponge mode, since the two-mask form of §2.5 shares its first lane with the single-output form on the same inputs. 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. +**Provenance.** Sequential small integers are the simplest assignment that satisfies the requirement of *distinctness* across all Poseidon2 invocations in this protocol -- §3.2 models Poseidon2 as a pseudorandom function, so evaluations whose leading input differs are computationally independent. Distinctness alone is not sufficient: each tag must also be confined to a single sponge mode, since the multi-lane forms of §2.5 share `lane[0]` with the single-output form on the same inputs; $$\delta\_{\text{aud\\\_s}}$$ is the one tag ever squeezed three-wide, and the widths it is read at agree on their shared lanes (§2.5 *Mode exclusivity*). 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. **Cross-protocol collision.** Future protocols that share Grumpkin / BN254 / Poseidon2 with this protocol -- 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/v1:X"}))$$, but this is a deployment-time choice that must be applied uniformly and disclosed in the deployment's circuit-binding documentation. diff --git a/packages/tokens/src/confidential/docs/INDEXER.md b/packages/tokens/src/confidential/docs/INDEXER.md index ab0d7222d..6cfdbceea 100644 --- a/packages/tokens/src/confidential/docs/INDEXER.md +++ b/packages/tokens/src/confidential/docs/INDEXER.md @@ -51,6 +51,7 @@ All events emitted by the confidential token (DESIGN_cont §11.2) with the follo | `Transfer` (recipient side) | Receiving-side replay: carries the recipient-channel ciphertexts for `(v_transfer, r_transfer)`. | | `SpenderTransfer` (recipient side) | Receiving-side replay, as above. | | `Merge` | Folds the receiving opening into the spendable opening; resets the receiving side. | +| `SetSpender`, `SpenderTransfer` (owner side) | **The auditor's only route to an allowance opening.** The escrowed allowance blinding rides these two events (`r_a_tilde_aud_s` on `SetSpender`, `r_tilde_aud_s` on `SpenderTransfer`) and appears nowhere in contract storage, so an auditor that misses one has no way to reconstruct the opening of the `C_a` it wrote — see §7 *Auditor recovery*. | | `Withdraw`, `Transfer` (sender side), `SetSpender`, `RevokeSpender` | **Checkpoints**: publish `(b_tilde, sigma)` for the owner's spendable balance. `SetSpender`/`RevokeSpender` are in scope as owner checkpoints only — a spender recovers allowance state from the on-chain delegation entry (`allowance_commitment`, `a_tilde`, `escrowed_dvk`, `allowance_salt`), not from the archive. The auditor-channel ciphertexts these events also carry are out of scope for wallet recovery. | A self-transfer — a `Transfer` whose `from` and `to` are the same account — carries both roles at once: it is a sender-side checkpoint and a recipient-side replay event, and recovery applies both (DESIGN §5.2). @@ -114,7 +115,16 @@ The indexer is trusted for **availability and completeness only** — never for - **Confidentiality.** Everything the indexer holds is public chain data: commitments, masked ciphertexts, and ECDH ephemerals. A curious indexer learns nothing beyond what any chain observer sees (DESIGN_cont §9). - **Integrity fails closed.** Recovery ends with the wallet checking its reconstructed openings against the **on-chain** commitments (`C_spend =? v·G + r·H`, DESIGN §5.2 step 7). A tampered or incomplete history cannot produce a wrong balance that verifies; it produces a detectable mismatch. -- **Withholding is the residual risk.** A malicious or broken indexer can deny recovery (a liveness failure, not a soundness one). Two structural mitigations: for the recent window the RPC is an independent source of the same events (the hybrid split of §1), so archive withholding bites only the pre-window history; and for that older history wallets SHOULD support multiple independent archive endpoints, with deployments running or contracting at least two. +- **Withholding is the residual risk.** A malicious or broken indexer can deny recovery (a liveness failure, not a soundness one). Two structural mitigations: for the recent window the RPC is an independent source of the same events (the hybrid split of §1), so archive withholding bites only the pre-window history; and for that older history wallets SHOULD support multiple independent archive endpoints, with deployments running or contracting at least two. The auditor's forward-tracked openings (DESIGN_cont §8.1) are a second recovery source in principle, but no specification defines an auditor-to-wallet channel, so they do not relax this recommendation. + +### 7.1 Auditor recovery + +An auditor's allowance tracking is strictly event-scoped and has no state-based fallback. The opening of a delegation's `C_a` is escrowed only in the event that wrote it (DESIGN_cont §8.5), so a missed, reordered, or unarchived `SetSpender` / `SpenderTransfer` leaves the auditor holding a value it cannot open until the delegation's next state-changing operation. This is a stronger dependency on the archive than the spendable side, where the auditor's opening survives merges by homomorphic carry-forward (DESIGN_cont §8.1). + +Two consequences for deployments: + +- **Consistency checking is the practical detection mechanism.** Soroban enforces nothing about archive completeness, ordering, or availability. An auditor client SHOULD verify each reconstructed allowance opening against the stored `allowance_commitment` — `C_a =? v_a·G + r_a·H` — which is the same fails-closed check §7 states for wallets. A mismatch is evidence of a missed, reordered, or pruned event rather than of a wrong balance. +- **Delegation TTL bounds the recovery anchor.** `live_until_ledger` governs spending authority and is independent of the delegation entry's persistent-entry TTL. If the entry itself is archived away, the commitment the auditor checks against is gone, so the consistency check above becomes unavailable exactly when it is most needed. Deployments SHOULD monitor delegation-entry TTL, not only `live_until_ledger`. ## 8. Conformance and Versioning diff --git a/packages/tokens/src/confidential/docs/OVERVIEW.md b/packages/tokens/src/confidential/docs/OVERVIEW.md index 1bbe76f89..693350a75 100644 --- a/packages/tokens/src/confidential/docs/OVERVIEW.md +++ b/packages/tokens/src/confidential/docs/OVERVIEW.md @@ -91,8 +91,8 @@ Merge is the gate between received funds and spendable funds. | Step | Who | What happens | |:-----|:----|:-------------| | 1 | Account holder | Specifies the withdrawal amount in the wallet. This amount will be publicly visible on-chain once the transaction executes. | -| 2 | Wallet | Generates a zero-knowledge proof demonstrating balance sufficiency, correct construction of the new spendable balance commitment with deterministic randomness, and a sender-auditor encrypted balance checkpoint produced via ephemeral ECDH with the sender's auditor key. | -| 3 | Contract | Verifies the proof, replaces the spendable balance commitment, and transfers the corresponding amount of regular tokens from the contract back to the account holder. Emits an event carrying the ephemeral public key, the salt, the encrypted balance scalar (for owner recovery), and the sender-auditor balance ciphertext. | +| 2 | Wallet | Generates a zero-knowledge proof demonstrating balance sufficiency, correct construction of the new spendable balance commitment with deterministic randomness, and a sender-auditor encrypted balance checkpoint — the new balance and its blinding factor — produced via ephemeral ECDH with the sender's auditor key. | +| 3 | Contract | Verifies the proof, replaces the spendable balance commitment, and transfers the corresponding amount of regular tokens from the contract back to the account holder. Emits an event carrying the ephemeral public key, the salt, the encrypted balance scalar (for owner recovery), and the sender-auditor ciphertexts. | --- @@ -104,7 +104,7 @@ Spenders enable use cases like automated trading bots, payment processors, or cu |:-----|:----|:-------------| | **Setup** | | | | 1 | Owner | Specifies the spender address (which must already be a registered account in the contract, so its spending public key can be looked up for delegation key escrow), the allowance amount, and a `live_until_ledger` expiration. | -| 2 | Wallet | Generates a proof that the allowance is correctly carved out of the owner's spendable balance. The proof also covers derivation and ECDH escrow of a delegation viewing key (`dvk`) so the spender can independently track and decrypt its allowance state, and produces ciphertexts for the owner's auditor (escrow amount and post-operation balance checkpoint). | +| 2 | Wallet | Generates a proof that the allowance is correctly carved out of the owner's spendable balance. The proof also covers derivation and ECDH escrow of a delegation viewing key (`dvk`) so the spender can independently track and decrypt its allowance state, and produces ciphertexts for the owner's auditor (escrow amount, post-operation balance checkpoint, and the blinding of the allowance commitment it just wrote). | | 3 | Contract | Verifies the proof, deducts the allowance from the owner's spendable balance commitment, and stores the spender delegation (allowance commitment, encrypted allowance, escrowed `dvk`, allowance salt, expiration). Emits an event with the owner's post-operation balance checkpoint and the owner-auditor ciphertexts. | | **Operation** | | | | 4 | Spender | Initiates a confidential transfer from the escrowed allowance to any registered recipient. A proof accompanies each transfer, covering allowance sufficiency, ECDH-derived encryption for the recipient, and dual-auditor ciphertexts for the recipient's and owner's auditors. | @@ -129,8 +129,14 @@ The system supports **real-time auditing** via a dual-auditor model. Each accoun | Sender's post-transfer balance | No | Yes | | Withdrawal amount | n/a | Yes (publicly visible) | | Post-withdrawal balance | n/a | Yes | +| Post-operation spendable blinding factor | No | Yes, at withdrawal, outgoing transfer, and spender setup (enables opening the sender's spendable balance from that event onward, since the same key also receives every inbound transfer's blinding; not renewed at spender revocation) | | Spender escrow / reclaim amount | n/a | Yes (owner's auditor) | | Post-transfer spender allowance | No (for spender transfers) | Yes (owner's auditor) | +| Post-escrow / post-reclaim balance | n/a | Yes (owner's auditor), at spender setup and revocation respectively | +| Delegation viewing key `dvk` | No | No | +| Allowance blinding $r_a$ | No | Yes (owner's auditor), at spender setup and on every spender transfer — one state's blinding per event, which with the amount opens that event's allowance commitment | + +The table covers every auditor ciphertext the protocol produces; `DESIGN_cont.md` §8.1-§8.5 is the normative account, including the bounds on each opening capability. Each auditor decrypts its ciphertexts by running the channel sponge (recipient-auditor channel for recipients, sender-auditor channel for senders/owners) with its private key, the ephemeral public key, and the per-operation salt published in the operation's event. @@ -140,6 +146,7 @@ Each auditor decrypts its ciphertexts by running the channel sponge (recipient-a - **Dual-auditor ciphertexts.** The ciphertexts each operation produces are enforced by its zero-knowledge proof, so they cannot be omitted or malformed, and no extra action is needed from users. - **Per-account scope.** Auditing one account reveals nothing about any other account. - **Recipient-side opening capability.** The recipient's auditor holds the per-transfer Pedersen blinding $r_{\text{transfer}}$, hence the full Pedersen opening of the recipient's receiving balance between merges, which is what enables the seizure/clawback flow specified in `COMPLIANCE.md` §5; the capability and its bounds are specified in `DESIGN_cont.md` §8.1. +- **Sender-side opening capability.** The sender's auditor holds the opening of the account's spendable balance as of each withdrawal, outgoing transfer, and spender setup, because those operations also encrypt the post-operation blinding factor to it. Because one key serves both of an account's auditor channels, the capability survives merges rather than expiring with the event; its bounds are specified in `DESIGN_cont.md` §8.1. - **Seamless auditor rotation.** When an auditor key is rotated, the new key immediately receives ciphertexts on subsequent operations. For the sender's auditor, the balance checkpoint at the next owner-initiated proof operation (transfer, withdrawal, set spender, or revoke spender) provides the current balance with no event replay or bootstrapping. - **Spender visibility.** The owner's auditor sees spender transfer amounts and post-transfer allowances via the same dual-auditor mechanism, and additionally sees escrowed and reclaimed amounts at `set_spender` and `revoke_spender`. - **Viewing vs. spending separation.** A viewing key cannot move or spend funds. Spending requires the separate spending key, which is never shared. diff --git a/packages/tokens/src/confidential/docs/SDK.md b/packages/tokens/src/confidential/docs/SDK.md index 33cc1d81e..2ee6fb501 100644 --- a/packages/tokens/src/confidential/docs/SDK.md +++ b/packages/tokens/src/confidential/docs/SDK.md @@ -93,15 +93,15 @@ A value is a **canonical** $$\mathbb{F}_r$$ representative iff it is a 32-byte b ### 4.3 Poseidon2 sponge -The sponge construction, its width and rate, the IV placement, the padding rule, and the two-lane form of $$\text{SpongeSqueeze}_2$$ are specified normatively in DESIGN.md §2.5. What follows is what that construction additionally requires of a client. +The sponge construction, its width and rate, the IV placement, the padding rule, and the two- and three-lane forms $$\text{SpongeSqueeze}_2$$ and $$\text{SpongeSqueeze}_3$$ are specified normatively in DESIGN.md §2.5. What follows is what that construction additionally requires of a client. -**Two self-checks are available before any proof is generated.** The absorbed length in $$\text{SpongeSqueeze}_2$$ is always 3, so its IV is fixed at $$3 \cdot 2^{64}$$; and its first lane is identical to $$\text{poseidon\\\_with\\\_domain}(\delta, [s, \sigma])$$ on the same inputs. An implementation that reproduces both has the block layout and the IV lane right. +**Three self-checks are available before any proof is generated.** The absorbed length in both squeeze forms is always 3, so the IV is fixed at $$3 \cdot 2^{64}$$; `lane[0]` is identical to $$\text{poseidon\\\_with\\\_domain}(\delta, [s, \sigma])$$ on the same inputs; and $$\text{SpongeSqueeze}_3(\delta, s, \sigma)[i] = \text{SpongeSqueeze}_2(\delta, s, \sigma)[i]$$ for $$i \in \\{0, 1\\}$$, since the absorb fits one rate-3 block and both forms read the same permutation. An implementation that reproduces all three has the block layout and the IV lane right. The third is pinned by `circuits/lib/testdata/sponge_squeeze_3.json`. **The domain-tagged funnel.** Every Poseidon2 invocation in the protocol routes through one entry point that places the domain tag as the **first absorbed element**: $$\text{poseidon\\\_with\\\_domain}(\delta, [x_1, \ldots, x_n]) = \text{sponge}([\delta, x_1, \ldots, x_n])$$ -Squeeze-slot assignment is canonical and MUST be followed: lane 0 is always an amount mask, lane 1 is always a balance, allowance, or per-transfer-randomness mask. Single-ciphertext channels — the `Withdraw` balance checkpoint (DESIGN.md W_a3/W_a4) — take lane **1** and leave lane 0 unused, so a checkpoint pad can never coincide with an amount pad. +Squeeze-slot assignment is canonical and MUST be followed: `lane[0]` is always an amount mask, `lane[1]` is always a balance, allowance, or per-transfer-randomness mask, and `lane[2]` is always the sender-auditor blinding-escrow slot — the new spendable blinding on `Withdraw`, `Transfer`, and `SetSpender`, the new allowance blinding $$r_a'$$ on `SpenderTransfer` (DESIGN.md §2.5). `lane[2]` never carries a key. Only the sender-auditor channel ($$\delta_{\text{aud\\\_s}}$$) is squeezed three-wide; the recipient channel ($$\delta_{\text{aud\\\_r}}$$) stays at two lanes. `Withdraw`, whose amount is public, takes `lane[1]` and `lane[2]` and leaves `lane[0]` unused (DESIGN.md W_a3–W_a5), so a checkpoint pad can never coincide with an amount pad. ### 4.4 Generators and commitments @@ -157,10 +157,11 @@ Secret scalars — $$\sigma$$, $$\sigma_a$$ — MUST be produced by the rejectio | $$\delta_{\text{eph}}$$ | 14 | No — derived off-circuit (DESIGN.md §5.3) | | $$\delta_{\text{disc\\\_bind}}$$ | 15 | No — off-chain disclosure only | | $$\delta_{\text{disc}}$$ | 16 | No — off-chain disclosure only | +| $$\delta_{\text{esc\\\_allow\\\_r\\\_aud}}$$ | 17 | Yes | -DESIGN_cont.md §13 assigns all sixteen values and is their only source; the right-hand column is this document's addition. $$\delta_{\text{disc\\\_bind}}$$ and $$\delta_{\text{disc}}$$ belong to the off-chain disclosure layer (SELECTIVE_DISCLOSURE.md §2.2). Tag 1 is absorbed by the contract rather than by a circuit — the contract derives $$\text{addr\\\_f}$$ and $$\text{op}_i$$ on-chain and the circuits receive them as opaque public inputs (DESIGN.md §2.7 *Usage sites*) — so it is part of the on-chain wire contract all the same. None of 14–16 is absorbed either in a circuit or on-chain, so none is part of the on-chain wire contract, but all three are part of the cross-client contract because two wallets serving the same account must agree on them (§6.3). +DESIGN_cont.md §13 assigns all seventeen values and is their only source; the right-hand column is this document's addition. $$\delta_{\text{disc\\\_bind}}$$ and $$\delta_{\text{disc}}$$ belong to the off-chain disclosure layer (SELECTIVE_DISCLOSURE.md §2.2). Tag 1 is absorbed by the contract rather than by a circuit — the contract derives $$\text{addr\\\_f}$$ and $$\text{op}_i$$ on-chain and the circuits receive them as opaque public inputs (DESIGN.md §2.7 *Usage sites*) — so it is part of the on-chain wire contract all the same. None of 14–16 is absorbed either in a circuit or on-chain, so none is part of the on-chain wire contract, but all three are part of the cross-client contract because two wallets serving the same account must agree on them (§6.3). -All sixteen values MUST be distinct, and each MUST be used in exactly one sponge mode, per DESIGN.md §2.5 *Mode exclusivity*. Tags 11 and 12 are the two-mask tags; the remaining fourteen, including 1 and 14–16, are single-output tags. +All seventeen values MUST be distinct, and each MUST be used in exactly one sponge mode, per DESIGN.md §2.5 *Mode exclusivity*. Tags 11 and 12 are the multi-lane tags — 11 read three-wide wherever `lane[2]` is escrowed and two-wide on `RevokeSpender`, 12 always two-wide; the remaining fifteen, including 1, 14–16, and 17, are single-output tags. Tag 17 is absorbed only by the `SetSpender` circuit (DESIGN.md S14), which escrows the allowance blinding $$r_a$$ to the owner's auditor under a single-output pad rather than over `lane[2]`, that lane being taken by the spendable blinding; its fixture is `circuits/lib/testdata/encrypt_esc_allow_r_auditor.json`. ### 4.9 Address compression @@ -395,12 +396,14 @@ The projection MUST still be reconciled against the event, and MUST NOT be treat ### 10.4 Salt freshness -A fresh $$\sigma$$ MUST be sampled for every **attempt**, including retries after a reverted or dropped transaction. +A fresh salt MUST be sampled for every **attempt**, including retries after a reverted or dropped transaction. The salt that must be fresh is the one the operation's pads absorb: $$\sigma$$ for owner-initiated operations, and $$\sigma_a'$$ — the replacement allowance salt — for spender transfers (DESIGN.md §6.2 *Transfer nonce*). -DESIGN_cont.md §9.6 motivates this as unlinkability: a fresh $$\sigma$$ prevents an observer correlating a reverted attempt with its retry. It is equally a confidentiality requirement, because the salt is the sole freshness input to every derived pad in the operation, the ephemeral scalar included (DESIGN.md §2.5, §5.3). Reuse therefore repeats the ephemeral key and every channel mask that depends on it. +DESIGN_cont.md §9.6 motivates this as unlinkability: a fresh salt prevents an observer correlating a reverted attempt with its retry. It is equally a confidentiality requirement, because the salt is the sole freshness input to every derived pad in the operation, the ephemeral scalar included (DESIGN.md §2.5, §5.3). Reuse therefore repeats the ephemeral key and every channel mask that depends on it, and two attempts that differ only in amount publish that difference in the clear. An implementation MUST NOT cache or reuse a salt across attempts, and MUST NOT derive it from anything an observer can predict. +**Freshness is not enforced on-chain.** Constraint O14 rejects only $$\sigma_a' = \sigma_a$$, the adjacent collision; a circuit cannot see a delegation's older salts. An implementation MUST therefore treat non-repetition of $$\sigma_a'$$ over the whole life of a delegation as its own obligation, and MUST NOT cycle salts through a bounded set. + ### 10.5 Deterministic ephemeral scalars An implementation MUST derive the ephemeral scalar of every operation the holder or spender originates: @@ -460,22 +463,24 @@ A spender MUST NOT be able to reach the owner's spendable balance through any in ## 11. Auditor Client -An auditor decrypts from the public event and its own secret $$k$$ alone, with no viewing key, holder cooperation, or extra on-chain read. For each channel it computes the shared scalar against the event's ephemeral point, derives the two lane masks (§4.3), and subtracts. +An auditor decrypts from the public event and its own secret $$k$$ alone, with no viewing key, holder cooperation, or extra on-chain read. The allowance opening comes straight out of the event: the blinding of the $$C_a$$ that operation writes is escrowed in the event itself -- tag 17 on `SetSpender`, `lane[2]` on `SpenderTransfer` -- and the matching value is in the sender-channel ciphertext (DESIGN_cont.md §8.5). An auditor that did not observe the event holds no opening for that state and cannot derive one; there is no key from which the openings follow. For each channel it computes the shared scalar against the event's ephemeral point, derives that channel's lane masks (§4.3) — three on the sender / owner channel, two on the recipient channel — and subtracts. The two channels differ in what they yield (DESIGN_cont.md §8.1): -| Channel | Lane 0 | Lane 1 | -|:--|:--|:--| -| Sender / owner ($$\delta_{\text{aud\\\_s}}$$) | Transfer amount, or the escrowed amount for `SetSpender` and the reclaimed amount for `RevokeSpender` | Sender's post-operation balance, or post-operation allowance for a spender transfer | -| Recipient ($$\delta_{\text{aud\\\_r}}$$) | Transfer amount | Per-transfer Pedersen randomness $$r_{\text{transfer}}$$ | +| Channel | `lane[0]` | `lane[1]` | `lane[2]` | +|:--|:--|:--|:--| +| Sender / owner ($$\delta_{\text{aud\\\_s}}$$) | Transfer amount, or the escrowed amount for `SetSpender` and the reclaimed amount for `RevokeSpender` | Sender's post-operation balance, or post-operation allowance for a spender transfer | Post-operation spendable blinding on `Withdraw`, `Transfer`, and `SetSpender`; post-transfer allowance blinding $$r_a'$$ on `SpenderTransfer`; nothing on `RevokeSpender`, which stays two-lane | +| Recipient ($$\delta_{\text{aud\\\_r}}$$) | Transfer amount | Per-transfer Pedersen randomness $$r_{\text{transfer}}$$ | — (channel is two-lane) | + +`Withdraw`, `SetSpender`, and `RevokeSpender` carry a sender-channel balance checkpoint whose pad is `lane[1]`. Only `Withdraw` leaves `lane[0]` unused, its amount being public (DESIGN.md W_a3, §4.3); `SetSpender` and `RevokeSpender` read `lane[0]` as well, for the escrowed and reclaimed amounts respectively (DESIGN.md S_a4, V_a4). -`Withdraw`, `SetSpender`, and `RevokeSpender` carry a sender-channel balance checkpoint whose pad is lane **1**. Only `Withdraw` leaves lane 0 unused, its amount being public (DESIGN.md W_a3, §4.3); `SetSpender` and `RevokeSpender` read lane 0 as well, for the escrowed and reclaimed amounts respectively (DESIGN.md S_a4, V_a4). +An implementation MUST squeeze the sender / owner channel three-wide and MUST NOT widen the recipient channel. Reading `lane[2]` on `RevokeSpender` yields a pad over no ciphertext (V_a3 is two-lane, DESIGN.md §7.9); an implementation MUST treat a `RevokeSpender` event as carrying no escrowed blinding rather than substituting a stale one. Because the first two lanes of $$\text{SpongeSqueeze}_3$$ coincide with $$\text{SpongeSqueeze}_2$$ (§4.3), a client that already reads `lane[0]` and `lane[1]` keeps every value it decrypted before. **Cross-channel agreement.** Where an auditor holds the key for both parties, the amount decrypts independently on each channel and the circuit constrains both to the same value, so the two MUST agree. An implementation SHOULD perform this comparison and treat disagreement as evidence that $$k$$ is not the auditor key for both parties of that event. -**Scope MUST be represented, not implied.** The recipient-channel capability is forward-only, receiving-side only, and reset by merge (DESIGN_cont.md §8.1). Rotation itself needs no replay on the sender side: the next owner-initiated proof operation publishes a fresh balance checkpoint under the new key. +**Scope MUST be represented, not implied.** The recipient-channel capability is forward-only, receiving-side only, and reset by merge (DESIGN_cont.md §8.1). The `lane[2]` opening of the sender channel is forward-only and **standing**: it opens the spendable commitment as of the checkpoint that escrowed it and stays valid through merges, because one account key serves both channels (DESIGN_cont.md §8.1 *Sender-auditor opening capability*). Maintaining it is the client's job, not the protocol's: an implementation MUST add the `lane[0]` amount and `lane[1]` $$r_{\text{transfer}}$$ of every inbound `Transfer` and `SpenderTransfer` to its stored $$(v, r)$$, and MUST treat each `Deposit` as $$(\text{amount}, 0)$$ (DESIGN.md §7.3). It MUST invalidate the stored opening on a `RevokeSpender` event, which rewrites the spendable commitment under a blinding no channel carries, until the next `lane[2]` escrow. Across a key rotation an implementation MUST either carry the accumulated opening forward from the prior key or treat the account as unopened until the next checkpoint under the new key; the value side needs no replay either way, since the next owner-initiated proof publishes a fresh balance checkpoint. -An auditor facade MUST NOT be able to construct a spending witness, and MUST NOT be able to open a post-merge spendable balance, since merge folds the receiving randomness into a blinding that depends on $$vk$$. +An auditor facade MUST NOT be able to construct a spending witness. It *can*, however, open the spendable balance past a merge -- the inbound $$r_{\text{transfer}}$$ reaches the same key on the recipient channel -- so a facade that exposes openings exposes them for the account's whole history under the active key, not for isolated events. --- @@ -513,7 +518,7 @@ RPC and archive compose: the RPC serves the recent tail, the archive everything ## 13. Security Requirements -**Secret handling.** The root, $$sk$$, $$vk$$, $$dvk_i$$, every derived $$r_e$$, and every cached opening are secrets. Implementations MUST keep them within the trust boundary (§2.1), SHOULD zeroize buffers holding them once no longer needed, and MUST NOT transmit them to any remote service except under §8.3's explicit opt-in. +**Secret handling.** The root, $$sk$$, $$vk$$, $$dvk_i$$, every derived $$r_e$$, every cached opening, and — on the auditor side — $$k$$ and the accumulated openings of §11 are secrets. Implementations MUST keep them within the trust boundary (§2.1), SHOULD zeroize buffers holding them once no longer needed, and MUST NOT transmit them to any remote service except under §8.3's explicit opt-in. $$vk$$ MUST NOT be presented as a safely-shareable read-only credential. It exposes every historical balance checkpoint, every incoming amount, every delegation allowance, and — through the ephemeral-scalar derivation of DESIGN.md §5.3 — the opening of every transfer the account originated. Its only guarantee is that it cannot authorize spending. A party that needs outbound visibility is served with D-sender proofs, which are bound to that party and to a nonce (SELECTIVE_DISCLOSURE.md §13.2), never by handing over the key. diff --git a/packages/tokens/src/confidential/docs/SELECTIVE_DISCLOSURE.md b/packages/tokens/src/confidential/docs/SELECTIVE_DISCLOSURE.md index 4ff44587d..6936258ca 100644 --- a/packages/tokens/src/confidential/docs/SELECTIVE_DISCLOSURE.md +++ b/packages/tokens/src/confidential/docs/SELECTIVE_DISCLOSURE.md @@ -74,7 +74,7 @@ This document reuses the notation, key hierarchy, and commitment scheme from DES - $$sk\_A$$, $$vk\_A$$, $$\text{PVK}\_A$$: an account's spending key, viewing key, and public viewing key (DESIGN.md §4). - $$\text{addr\\\_f}$$: the contract's compressed address Field $$\text{address\\\_to\\\_field}(\text{contract})$$, bound into $$vk$$ derivation (DESIGN.md §2.7, §4.2). Stored once at construction in the contract's instance storage (DESIGN.md §3.5). - $$K\_{\text{aud,s}}$$, $$K\_{\text{aud,r}}$$, $$aud\_{sk}$$: the sender-side and recipient-side auditor Grumpkin public keys, and an auditor's secret key (DESIGN_cont.md §8.1, §8.3). Each account selects an `auditor_id` at registration; the same `auditor_id` may resolve to either role depending on the transfer's direction. -- $$(R\_e, \sigma, \tilde{v}, \tilde{b}, \tilde{v}\_{\text{aud,r}}, \tilde{r}\_{\text{aud,r}}, \tilde{v}\_{\text{aud,s}}, \tilde{b}\_{\text{aud,s}})$$: per-transfer event fields (DESIGN.md §7.6, §11.2). For `SpenderTransfer` events the recipient/auditor ECDH nonce is $$\sigma\_a$$ in place of $$\sigma$$, and the sender-auditor channel emits $$\tilde{a}\_{\text{aud,s}}$$ in place of $$\tilde{b}\_{\text{aud,s}}$$ (DESIGN.md §7.8, §11.2). Throughout this document, the symbol $$\sigma\_E$$ refers to the **event ECDH nonce**, equal to $$\sigma$$ for `Transfer` events and to $$\sigma\_a$$ for `SpenderTransfer` events; one circuit handles both families, parameterized by which nonce the disclosing event emitted. +- $$(R\_e, \sigma, \tilde{v}, \tilde{b}, \tilde{v}\_{\text{aud,r}}, \tilde{r}\_{\text{aud,r}}, \tilde{v}\_{\text{aud,s}}, \tilde{b}\_{\text{aud,s}}, \tilde{r}\_{\text{aud,s}})$$: per-transfer event fields (DESIGN.md §7.6, §11.2). For `SpenderTransfer` events the recipient/auditor ECDH nonce is $$\sigma\_a'$$ in place of $$\sigma$$, and the sender-auditor channel emits $$\tilde{a}\_{\text{aud,s}}$$ in place of $$\tilde{b}\_{\text{aud,s}}$$, with `lane[2]`'s $$\tilde{r}\_{\text{aud,s}}$$ carrying the post-transfer allowance blinding $$r\_a'$$ rather than a spendable one (DESIGN.md §7.8, §11.2). Throughout this document, the symbol $$\sigma\_E$$ refers to the **event ECDH nonce**, equal to $$\sigma$$ for `Transfer` events and to $$\sigma\_a'$$ for `SpenderTransfer` events; one circuit handles both families, parameterized by which nonce the disclosing event emitted. - $$H$$: the Grumpkin Pedersen generator used uniformly for key derivation and ECDH (DESIGN.md §2.3, §2.4). ### 2.1 Disclosure Recipient @@ -97,7 +97,7 @@ The disclosure layer inherits the protocol's threat model (DESIGN.md §3.2) and **Holder is the prover for D-recipient and D-sender variants.** The holder is trusted only to produce *correct* proofs about events they choose to disclose. The holder is *not* trusted to be complete: they may withhold events. Recipients that require completeness must obtain it from the auditor (DESIGN_cont.md §8) or from out-of-band evidence. -**Auditor is the prover for D-auditor variants.** The auditor is trusted to disclose accurately when asked. The auditor's existing trust scope (DESIGN.md §3.3) is not enlarged. +**Auditor is the prover for D-auditor variants.** The auditor is trusted to disclose accurately when asked. The auditor's existing trust scope (DESIGN.md §3.3) is not enlarged. That scope already includes the full Pedersen opening of every $$C\_a$$ (DESIGN_cont.md §8.5) and, at each checkpoint event that escrows `lane[2]`, of the account's $$C\_{\text{spend}}$$ as of that event (DESIGN_cont.md §8.1 *Sender-auditor opening capability*, §8.2). The latter is standing rather than event-scoped: the account's single auditor key also decrypts every inbound blinding, so the opening carries across merges, and only `revoke_spender` -- which escrows no blinding -- suspends it until the next checkpoint. The D-auditor variants expose to a disclosure recipient only what the chosen variant states, never the openings themselves. **Disclosure recipient is honest-but-curious.** The recipient correctly verifies proofs and decrypts ciphertexts addressed to their key. The recipient may attempt to replay or rebroadcast proofs; nonce binding prevents reuse against other parties. @@ -173,8 +173,8 @@ The bundle does **not** include the event's payload, the disclosing account's ad Given a bundle for $$(P\_R, \nu)$$ that this verifier previously issued, the recipient MUST perform every step below in order. Each step's failure is a hard reject; the recipient MUST NOT learn $$v\_{\text{transfer}}$$ from a bundle that fails any step. 1. **Resolve the event.** Look up $$\text{ref}\_E$$ via the indexer or via direct RPC of the transaction. The lookup MUST return exactly one event whose contract address equals the deployed confidential-token contract. Extract the event's payload fields verbatim: - - For `Transfer`: `from`, `to`, $$R\_e$$, $$\sigma$$, $$\tilde{v}$$, $$\tilde{b}$$, $$\tilde{v}\_{\text{aud,r}}$$, $$\tilde{r}\_{\text{aud,r}}$$, $$\tilde{v}\_{\text{aud,s}}$$, $$\tilde{b}\_{\text{aud,s}}$$ (DESIGN_cont.md §11.2). - - For `SpenderTransfer`: `spender`, `from`, `to`, $$R\_e$$, $$\sigma\_a$$, $$\tilde{v}$$, $$\tilde{v}\_{\text{aud,r}}$$, $$\tilde{r}\_{\text{aud,r}}$$, $$\tilde{v}\_{\text{aud,s}}$$, $$\tilde{a}\_{\text{aud,s}}$$ (DESIGN_cont.md §11.2). + - For `Transfer`: `from`, `to`, $$R\_e$$, $$\sigma$$, $$\tilde{v}$$, $$\tilde{b}$$, $$\tilde{v}\_{\text{aud,r}}$$, $$\tilde{r}\_{\text{aud,r}}$$, $$\tilde{v}\_{\text{aud,s}}$$, $$\tilde{b}\_{\text{aud,s}}$$, $$\tilde{r}\_{\text{aud,s}}$$ (DESIGN_cont.md §11.2). + - For `SpenderTransfer`: `spender`, `from`, `to`, $$R\_e$$, $$\sigma\_a'$$, $$\tilde{v}$$, $$\tilde{v}\_{\text{aud,r}}$$, $$\tilde{r}\_{\text{aud,r}}$$, $$\tilde{v}\_{\text{aud,s}}$$, $$\tilde{a}\_{\text{aud,s}}$$, $$\tilde{r}\_{\text{aud,s}}$$ (DESIGN_cont.md §11.2). Any other event type, or a `circuit_id` whose constraints reference a field the event does not carry, is rejected here. @@ -210,7 +210,7 @@ Either way the request and the proof are public, which is the deliberate privacy ## 6. Circuit D-recipient: Holder Discloses an Inbound Transfer -The account holder is the recipient of an on-chain confidential transfer (either a `Transfer` to them or a `SpenderTransfer` whose `to` is them) and proves to a third party that the transfer was for amount $$v\_{\text{transfer}}$$. The same circuit covers both event families because the recipient-side ECDH constraint has identical shape in either case; only the value of the event nonce $$\sigma\_E$$ differs ($$\sigma$$ for `Transfer`, $$\sigma\_a$$ for `SpenderTransfer`; see DESIGN.md §7.6 T9, §7.8 O9). +The account holder is the recipient of an on-chain confidential transfer (either a `Transfer` to them or a `SpenderTransfer` whose `to` is them) and proves to a third party that the transfer was for amount $$v\_{\text{transfer}}$$. The same circuit covers both event families because the recipient-side ECDH constraint has identical shape in either case; only the value of the event nonce $$\sigma\_E$$ differs ($$\sigma$$ for `Transfer`, $$\sigma\_a'$$ for `SpenderTransfer`; see DESIGN.md §7.6 T9, §7.8 O9). **Public inputs** @@ -218,7 +218,7 @@ The account holder is the recipient of an on-chain confidential transfer (either |:---|:---| | $$\text{addr\\\_f}$$ | compressed contract-address Field, loaded from instance storage (DESIGN.md §2.7, §3.5) | | $$\text{PVK}\_A$$ | disclosing account's stored `viewing_public_key` (DESIGN.md §6.1); $$A$$ is the address listed as the event's `to` | -| $$R\_e, \sigma\_E, \tilde{v}$$ | from the on-chain event being disclosed (DESIGN_cont.md §11.2). $$\sigma\_E = \sigma$$ for `Transfer`, $$\sigma\_E = \sigma\_a$$ for `SpenderTransfer`. | +| $$R\_e, \sigma\_E, \tilde{v}$$ | from the on-chain event being disclosed (DESIGN_cont.md §11.2). $$\sigma\_E = \sigma$$ for `Transfer`, $$\sigma\_E = \sigma\_a'$$ for `SpenderTransfer`. | | $$P\_R$$ | disclosure recipient's Grumpkin pubkey (§2.1) | | $$\nu$$ | recipient-supplied nonce (§2.1) | | $$R\_{\text{disc}}, \tilde{v}\_{\text{disc}}$$ | disclosure ciphertext to recipient (§4) | @@ -255,7 +255,7 @@ In both cases the prover must supply the ephemeral scalar $$r\_e$$ as a witness: $$r\_e = \text{Poseidon2}(\delta\_{\text{eph}}, vk, \sigma\_E)$$ -where $$vk$$ is the originator's viewing key ($$vk\_A$$ for `Transfer`, $$vk\_{\text{op}}$$ for `SpenderTransfer`) and $$\sigma\_E$$ is the event nonce ($$\sigma$$ or $$\sigma\_a$$). The disclosed amount then follows: +where $$vk$$ is the originator's viewing key ($$vk\_A$$ for `Transfer`, $$vk\_{\text{op}}$$ for `SpenderTransfer`) and $$\sigma\_E$$ is the event nonce ($$\sigma$$ or $$\sigma\_a'$$). The disclosed amount then follows: $$v\_{\text{transfer}} = \tilde{v} - \text{Poseidon}(\delta\_{\text{transfer\\\_amount}}, \text{ECDH}(r\_e, \text{PVK}\_B), \sigma\_E) \qquad \text{(DESIGN.md §2.4)}$$ @@ -263,7 +263,7 @@ with $$\text{PVK}\_B$$ read from the event's `to` address. Both quantities come **What this implies for $$vk$$.** Since $$r\_e$$ is recoverable from $$vk$$, so is a full Pedersen opening of every transfer the account originated. DESIGN_cont.md §9.4 states the capability a compromised $$vk$$ therefore carries, and DESIGN_cont.md §8.2 records that per-transfer openings are not exclusive to the recipient's auditor. The operative consequence for this layer is that a counterparty needing outbound visibility is served with D-sender proofs, which are bound to that counterparty and to a nonce (§13.2) — never by handing over $$vk$$ (SDK.md §13). -In the symbols below, $$A$$ denotes the **originating** address — the holder's address for `Transfer` and the spender's address for `SpenderTransfer`. $$sk\_A$$ is the originator's spending key, $$\text{PVK}\_A$$ is the originator's stored public viewing key, and $$\sigma\_E = \sigma$$ for `Transfer`, $$\sigma\_E = \sigma\_a$$ for `SpenderTransfer`. +In the symbols below, $$A$$ denotes the **originating** address — the holder's address for `Transfer` and the spender's address for `SpenderTransfer`. $$sk\_A$$ is the originator's spending key, $$\text{PVK}\_A$$ is the originator's stored public viewing key, and $$\sigma\_E = \sigma$$ for `Transfer`, $$\sigma\_E = \sigma\_a'$$ for `SpenderTransfer`. **Public inputs** @@ -307,16 +307,16 @@ A D-sender proof for a `SpenderTransfer` proves that the spender (not the owner) The auditor proves to a third party that an on-chain event corresponds to a transfer of amount $$v\_{\text{transfer}}$$ for one of the accounts under the auditor's scope. Used when the holder is uncooperative or when the disclosure recipient requires a guarantee that the auditor (not just the holder) has attested. -**Which auditor.** Every transfer carries ciphertexts under *two* auditor keys (DESIGN_cont.md §8.1): the recipient-side key $$K\_{\text{aud,r}}$$ (channel $$\delta\_{\text{aud\\\_r}}$$, two squeezes yielding masks for $$v\_{\text{transfer}}$$ and $$r\_{\text{transfer}}$$) and the sender-side key $$K\_{\text{aud,s}}$$ (channel $$\delta\_{\text{aud\\\_s}}$$, two squeezes yielding masks for $$v\_{\text{transfer}}$$ and the sender's post-transfer balance). Whichever auditor is disclosing reuses the same shared-secret derivation they perform to read events natively; the circuit additionally encrypts the result to the disclosure recipient. +**Which auditor.** Every transfer carries ciphertexts under *two* auditor keys (DESIGN_cont.md §8.1): the recipient-side key $$K\_{\text{aud,r}}$$ (channel $$\delta\_{\text{aud\\\_r}}$$, two lanes yielding masks for $$v\_{\text{transfer}}$$ and $$r\_{\text{transfer}}$$) and the sender-side key $$K\_{\text{aud,s}}$$ (channel $$\delta\_{\text{aud\\\_s}}$$, three lanes yielding masks for $$v\_{\text{transfer}}$$, the sender's post-transfer balance, and the blinding-escrow slot of DESIGN.md §2.5). Whichever auditor is disclosing reuses the same shared-secret derivation they perform to read events natively; the circuit additionally encrypts the result to the disclosure recipient. -The constraints below parameterize the channel as $$\delta\_{\text{aud}} \in \\{\delta\_{\text{aud\\\_r}}, \delta\_{\text{aud\\\_s}}\\}$$ and the corresponding event ciphertext as $$\tilde{v}\_{\text{aud}} \in \\{\tilde{v}\_{\text{aud,r}}, \tilde{v}\_{\text{aud,s}}\\}$$. In each case the amount mask is the *first* squeeze of the channel's two-squeeze sponge; the second squeeze ($$m\_{r,r}$$ or $$m\_{b,s}$$) is computed and discarded for an amount disclosure, or used in place of the first for the balance/randomness variants noted below. +The constraints below parameterize the channel as $$\delta\_{\text{aud}} \in \\{\delta\_{\text{aud\\\_r}}, \delta\_{\text{aud\\\_s}}\\}$$ and the corresponding event ciphertext as $$\tilde{v}\_{\text{aud}} \in \\{\tilde{v}\_{\text{aud,r}}, \tilde{v}\_{\text{aud,s}}\\}$$. In each case the amount mask is `lane[0]` of the channel's sponge; the remaining lanes ($$m\_{r,r}$$ on the recipient channel; $$m\_{b,s}$$ and $$m\_{r,s}$$ on the sender channel) are computed and discarded for an amount disclosure, or used in place of `lane[0]` for the balance/randomness variants noted below. A3 is written with $$\text{SpongeSqueeze}\_2$$ because its first two lanes coincide with those of $$\text{SpongeSqueeze}\_3$$ on either channel (DESIGN.md §2.5); only a variant reading `lane[2]` squeezes three-wide. **Public inputs** | Symbol | Source | |:---|:---| | $$K\_{\text{aud}}$$ | auditor's on-chain Grumpkin pubkey for the chosen channel ($$K\_{\text{aud,r}}$$ or $$K\_{\text{aud,s}}$$) (DESIGN_cont.md §8.3) | -| $$R\_e, \sigma\_E, \tilde{v}\_{\text{aud}}$$ | from the on-chain event ($$\tilde{v}\_{\text{aud,r}}$$ for the recipient-side channel, $$\tilde{v}\_{\text{aud,s}}$$ for the sender-side channel). $$\sigma\_E = \sigma$$ for `Transfer`, $$\sigma\_E = \sigma\_a$$ for `SpenderTransfer` (DESIGN.md §7.8). | +| $$R\_e, \sigma\_E, \tilde{v}\_{\text{aud}}$$ | from the on-chain event ($$\tilde{v}\_{\text{aud,r}}$$ for the recipient-side channel, $$\tilde{v}\_{\text{aud,s}}$$ for the sender-side channel). $$\sigma\_E = \sigma$$ for `Transfer`, $$\sigma\_E = \sigma\_a'$$ for `SpenderTransfer` (DESIGN.md §7.8). | | $$P\_R, \nu$$ | disclosure recipient pubkey and nonce | | $$R\_{\text{disc}}, \tilde{v}\_{\text{disc}}$$ | disclosure ciphertext | @@ -337,7 +337,7 @@ D-auditor does not bind to an account record; the auditor key already binds the **Verifier flow.** Follow §5.3 with `circuit_id = D-auditor` (or the chosen balance / randomness variant). Step 2 is skipped — no $$\text{PVK}\_A$$ lookup is needed. Step 3 resolves $$K\_{\text{aud}}$$ at the event's ledger: $$K\_{\text{aud,r}}$$ from the `auditor_id` on the event's `to` account when disclosing the recipient-side channel, or $$K\_{\text{aud,s}}$$ from the `auditor_id` on the `from` account when disclosing the sender-side channel. `from` is the funds' owner in both `Transfer` and `SpenderTransfer`, since the sender-auditor channel always tracks the owner (DESIGN.md §7.8). -**Balance / randomness variants.** The second squeeze of each channel carries a distinct datum: $$m\_{b,s}$$ (sender's post-transfer balance checkpoint, channel $$\delta\_{\text{aud\\\_s}}$$, recovered from $$\tilde{b}\_{\text{aud,s}}$$) or $$m\_{r,r}$$ (per-transfer Pedersen randomness, channel $$\delta\_{\text{aud\\\_r}}$$, recovered from $$\tilde{r}\_{\text{aud,r}}$$). A circuit that discloses either of these substitutes the corresponding event ciphertext for $$\tilde{v}\_{\text{aud}}$$ in A4 and reads $$m\_2$$ rather than $$m\_v$$ from the sponge output. Range constraint D5 applies unchanged to a balance disclosure; for a randomness disclosure D5 is dropped since $$r\_{\text{transfer}} \in \mathbb{F}\_r$$ is not range-bounded. These variants are not separately tabulated. +**Balance / randomness variants.** `lane[1]` of each channel carries a distinct datum: $$m\_{b,s}$$ (sender's post-transfer balance checkpoint, channel $$\delta\_{\text{aud\\\_s}}$$, recovered from $$\tilde{b}\_{\text{aud,s}}$$) or $$m\_{r,r}$$ (per-transfer Pedersen randomness, channel $$\delta\_{\text{aud\\\_r}}$$, recovered from $$\tilde{r}\_{\text{aud,r}}$$). A circuit that discloses either of these substitutes the corresponding event ciphertext for $$\tilde{v}\_{\text{aud}}$$ in A4 and reads $$m\_2$$ rather than $$m\_v$$ from the sponge output. Range constraint D5 applies unchanged to a balance disclosure; for a randomness disclosure D5 is dropped since $$r\_{\text{transfer}} \in \mathbb{F}\_r$$ is not range-bounded. The balance variant has a blinding sibling on `lane[2]` of the sender channel: $$m\_{r,s}$$ recovers the sender's post-transfer spendable blinding from $$\tilde{r}\_{\text{aud,s}}$$ (DESIGN.md §7.6 T\_a9), so an auditor can disclose the full opening of the sender's $$C\_{\text{spend}}$$ *as of that transfer* rather than its value alone (DESIGN_cont.md §8.1); A3 then squeezes three-wide and D5 is dropped as for the randomness variant. On a `SpenderTransfer` the same lane carries the post-transfer allowance blinding $$r\_a'$$ (DESIGN.md §7.8 O\_a9), so the same variant discloses the opening of $$C\_a'$$ rather than of $$C\_{\text{spend}}'$$ (DESIGN_cont.md §8.5). These variants are not separately tabulated. --- @@ -401,7 +401,7 @@ For statements of the form "this account received at least $$X$$ from counterpar | Symbol | Source | |:---|:---| | Common: $$\text{addr\\\_f}$$, $$\text{PVK}\_A$$, $$P\_R$$, $$\nu$$, $$R\_{\text{disc}}, \tilde{V}\_{\text{disc}}$$ | as in §6 | -| List: $$(R\_{e,i}, \sigma\_{E,i}, \tilde{v}\_i)$$ for $$i \in [1, n]$$ | from $$n$$ on-chain transfer-family events; $$\sigma\_{E,i} = \sigma$$ if event $$i$$ is a `Transfer`, $$\sigma\_a$$ if `SpenderTransfer`. Each event MUST be identified by a $$\text{ref}\_{E,i}$$ in the proof bundle and resolved per §5.3. | +| List: $$(R\_{e,i}, \sigma\_{E,i}, \tilde{v}\_i)$$ for $$i \in [1, n]$$ | from $$n$$ on-chain transfer-family events; $$\sigma\_{E,i} = \sigma$$ if event $$i$$ is a `Transfer`, $$\sigma\_a'$$ if `SpenderTransfer`. Each event MUST be identified by a $$\text{ref}\_{E,i}$$ in the proof bundle and resolved per §5.3. | | Optional: $$V\_{\text{threshold}}$$ | aggregate threshold | **Private witnesses:** $$sk\_A$$, $$vk\_A$$, $$\\{v\_{\text{transfer},i}\\}\_{i=1}^n$$, $$r\_{\text{disc}}$$. @@ -432,7 +432,7 @@ The confidential-token contract requires no new state-modifying entry points to |:---|:---|:---| | `confidential_balance(account) -> ConfidentialAccount` | Verifier extracts $$\text{PVK}\_A$$ (and $$\text{PVK}\_B$$ for D-sender, $$C\_{\text{spend}}$$ for D-balance) from the returned `ConfidentialAccount` tuple | Already exposed (DESIGN_cont.md §11.3); the struct carries every field this layer reads, so no narrower accessor is required | | Auditor contract's key lookup for `auditor_id` | Verifier looks up $$K\_{\text{aud,r}}$$ or $$K\_{\text{aud,s}}$$ | Already exposed (DESIGN_cont.md §8.3). The auditor contract MAY maintain a sequence of versioned keys per `auditor_id` with activation ledgers; the verifier MUST select the version whose activation ledger is the largest value not exceeding the disclosed event's ledger (DESIGN_cont.md §8.3, *Auditor's off-chain obligation*). | -| Transfer-family events | Verifier reads the per-event fields ($$R\_e$$, $$\sigma$$ or $$\sigma\_a$$, $$\tilde{v}$$, $$\tilde{b}$$, $$\tilde{v}\_{\text{aud,r}}$$, $$\tilde{r}\_{\text{aud,r}}$$, $$\tilde{v}\_{\text{aud,s}}$$, $$\tilde{b}\_{\text{aud,s}}$$ / $$\tilde{a}\_{\text{aud,s}}$$) | Already emitted (DESIGN_cont.md §11.2). `SpenderTransfer` uses $$\sigma\_a$$ in place of $$\sigma$$ and $$\tilde{a}\_{\text{aud,s}}$$ in place of $$\tilde{b}\_{\text{aud,s}}$$. | +| Transfer-family events | Verifier reads the per-event fields ($$R\_e$$, $$\sigma$$ or $$\sigma\_a'$$, $$\tilde{v}$$, $$\tilde{b}$$, $$\tilde{v}\_{\text{aud,r}}$$, $$\tilde{r}\_{\text{aud,r}}$$, $$\tilde{v}\_{\text{aud,s}}$$, $$\tilde{b}\_{\text{aud,s}}$$ / $$\tilde{a}\_{\text{aud,s}}$$, $$\tilde{r}\_{\text{aud,s}}$$) | Already emitted (DESIGN_cont.md §11.2). `SpenderTransfer` uses $$\sigma\_a'$$ in place of $$\sigma$$ and $$\tilde{a}\_{\text{aud,s}}$$ in place of $$\tilde{b}\_{\text{aud,s}}$$. | | Instance storage: $$\text{addr\\\_f}$$ | D-recipient, D-sender, and D-balance bind $$vk$$ derivation to the contract via $$\text{addr\\\_f}$$ | Computed once at construction (DESIGN.md §3.5); the verifier reproduces it from the contract address using the encoding in DESIGN.md §2.7 | These are the only on-chain dependencies. Disclosure proofs are otherwise self-contained off-chain artifacts. diff --git a/packages/tokens/src/confidential/mod.rs b/packages/tokens/src/confidential/mod.rs index 8af6269dc..d7825631a 100644 --- a/packages/tokens/src/confidential/mod.rs +++ b/packages/tokens/src/confidential/mod.rs @@ -342,7 +342,8 @@ pub trait ConfidentialToken { /// /// * topics - `["withdraw", from: Address, to: Address]` /// * data - `[amount: i128, r_e_point: BytesN<64>, sigma: BytesN<32>, - /// b_tilde: BytesN<32>, b_tilde_aud_s: BytesN<32>]` + /// b_tilde: BytesN<32>, b_tilde_aud_s: BytesN<32>, r_tilde_aud_s: + /// BytesN<32>]` fn withdraw(e: &Env, from: Address, to: Address, amount: i128, data: Bytes) { from.require_auth(); @@ -369,7 +370,7 @@ pub trait ConfidentialToken { /// /// * topics - `["transfer", from: Address, to: Address]` /// * data - `[r_e_point, v_tilde, sigma, b_tilde, v_tilde_aud_r, - /// r_tilde_aud_r, v_tilde_aud_s, b_tilde_aud_s]` + /// r_tilde_aud_r, v_tilde_aud_s, b_tilde_aud_s, r_tilde_aud_s]` fn confidential_transfer(e: &Env, from: Address, to: Address, data: Bytes) { from.require_auth(); @@ -400,8 +401,8 @@ pub trait ConfidentialToken { /// /// * topics - `["spender_transfer", spender: Address, from: Address, to: /// Address]` - /// * data - `[r_e_point, v_tilde, sigma_a, v_tilde_aud_r, r_tilde_aud_r, - /// v_tilde_aud_s, a_tilde_aud_s]` + /// * data - `[r_e_point, v_tilde, sigma_a_new, v_tilde_aud_r, + /// r_tilde_aud_r, v_tilde_aud_s, a_tilde_aud_s, r_tilde_aud_s]` fn confidential_transfer_from( e: &Env, spender: Address, @@ -448,7 +449,7 @@ pub trait ConfidentialToken { /// /// * topics - `["set_spender", account: Address, spender: Address]` /// * data - `[live_until_ledger: u32, r_e_point, sigma, b_tilde, - /// v_tilde_aud_s, b_tilde_aud_s]` + /// v_tilde_aud_s, b_tilde_aud_s, r_tilde_aud_s, r_a_tilde_aud_s]` fn set_spender( e: &Env, account: Address, @@ -663,6 +664,7 @@ pub struct Withdraw { pub sigma: BytesN<32>, pub b_tilde: BytesN<32>, pub b_tilde_aud_s: BytesN<32>, + pub r_tilde_aud_s: BytesN<32>, } /// Emits a `Withdraw` event. @@ -676,6 +678,7 @@ pub fn emit_withdraw( sigma: &BytesN<32>, b_tilde: &BytesN<32>, b_tilde_aud_s: &BytesN<32>, + r_tilde_aud_s: &BytesN<32>, ) { Withdraw { from: from.clone(), @@ -685,6 +688,7 @@ pub fn emit_withdraw( sigma: sigma.clone(), b_tilde: b_tilde.clone(), b_tilde_aud_s: b_tilde_aud_s.clone(), + r_tilde_aud_s: r_tilde_aud_s.clone(), } .publish(e); } @@ -705,6 +709,7 @@ pub struct Transfer { pub r_tilde_aud_r: BytesN<32>, pub v_tilde_aud_s: BytesN<32>, pub b_tilde_aud_s: BytesN<32>, + pub r_tilde_aud_s: BytesN<32>, } /// Emits a `Transfer` event. @@ -721,6 +726,7 @@ pub fn emit_transfer( r_tilde_aud_r: &BytesN<32>, v_tilde_aud_s: &BytesN<32>, b_tilde_aud_s: &BytesN<32>, + r_tilde_aud_s: &BytesN<32>, ) { Transfer { from: from.clone(), @@ -733,6 +739,7 @@ pub fn emit_transfer( r_tilde_aud_r: r_tilde_aud_r.clone(), v_tilde_aud_s: v_tilde_aud_s.clone(), b_tilde_aud_s: b_tilde_aud_s.clone(), + r_tilde_aud_s: r_tilde_aud_s.clone(), } .publish(e); } @@ -749,11 +756,12 @@ pub struct SpenderTransfer { pub to: Address, pub r_e_point: BytesN<64>, pub v_tilde: BytesN<32>, - pub sigma_a: BytesN<32>, + pub sigma_a_new: BytesN<32>, pub v_tilde_aud_r: BytesN<32>, pub r_tilde_aud_r: BytesN<32>, pub v_tilde_aud_s: BytesN<32>, pub a_tilde_aud_s: BytesN<32>, + pub r_tilde_aud_s: BytesN<32>, } /// Emits an `SpenderTransfer` event. @@ -765,11 +773,12 @@ pub fn emit_spender_transfer( to: &Address, r_e_point: &BytesN<64>, v_tilde: &BytesN<32>, - sigma_a: &BytesN<32>, + sigma_a_new: &BytesN<32>, v_tilde_aud_r: &BytesN<32>, r_tilde_aud_r: &BytesN<32>, v_tilde_aud_s: &BytesN<32>, a_tilde_aud_s: &BytesN<32>, + r_tilde_aud_s: &BytesN<32>, ) { SpenderTransfer { spender: spender.clone(), @@ -777,11 +786,12 @@ pub fn emit_spender_transfer( to: to.clone(), r_e_point: r_e_point.clone(), v_tilde: v_tilde.clone(), - sigma_a: sigma_a.clone(), + sigma_a_new: sigma_a_new.clone(), v_tilde_aud_r: v_tilde_aud_r.clone(), r_tilde_aud_r: r_tilde_aud_r.clone(), v_tilde_aud_s: v_tilde_aud_s.clone(), a_tilde_aud_s: a_tilde_aud_s.clone(), + r_tilde_aud_s: r_tilde_aud_s.clone(), } .publish(e); } @@ -800,6 +810,8 @@ pub struct SetSpender { pub b_tilde: BytesN<32>, pub v_tilde_aud_s: BytesN<32>, pub b_tilde_aud_s: BytesN<32>, + pub r_tilde_aud_s: BytesN<32>, + pub r_a_tilde_aud_s: BytesN<32>, } /// Emits a `SetSpender` event. @@ -814,6 +826,8 @@ pub fn emit_set_spender( b_tilde: &BytesN<32>, v_tilde_aud_s: &BytesN<32>, b_tilde_aud_s: &BytesN<32>, + r_tilde_aud_s: &BytesN<32>, + r_a_tilde_aud_s: &BytesN<32>, ) { SetSpender { account: account.clone(), @@ -824,6 +838,8 @@ pub fn emit_set_spender( b_tilde: b_tilde.clone(), v_tilde_aud_s: v_tilde_aud_s.clone(), b_tilde_aud_s: b_tilde_aud_s.clone(), + r_tilde_aud_s: r_tilde_aud_s.clone(), + r_a_tilde_aud_s: r_a_tilde_aud_s.clone(), } .publish(e); } diff --git a/packages/tokens/src/confidential/storage.rs b/packages/tokens/src/confidential/storage.rs index 42cd61ae4..9c447f346 100644 --- a/packages/tokens/src/confidential/storage.rs +++ b/packages/tokens/src/confidential/storage.rs @@ -115,6 +115,7 @@ pub struct WithdrawPayload { pub r_e_point: Point, pub sigma: BytesN<32>, pub b_tilde_aud_s: BytesN<32>, + pub r_tilde_aud_s: BytesN<32>, } /// Envelope decoded from the `data: Bytes` argument of @@ -141,6 +142,7 @@ pub struct TransferPayload { pub r_tilde_aud_r: BytesN<32>, pub v_tilde_aud_s: BytesN<32>, pub b_tilde_aud_s: BytesN<32>, + pub r_tilde_aud_s: BytesN<32>, } /// Envelope decoded from the `data: Bytes` argument of @@ -167,6 +169,7 @@ pub struct SpenderTransferPayload { pub r_tilde_aud_r: BytesN<32>, pub v_tilde_aud_s: BytesN<32>, pub a_tilde_aud_s: BytesN<32>, + pub r_tilde_aud_s: BytesN<32>, } /// Envelope decoded from the `data: Bytes` argument of @@ -192,6 +195,8 @@ pub struct SetSpenderPayload { pub sigma_a: BytesN<32>, pub v_tilde_aud_s: BytesN<32>, pub b_tilde_aud_s: BytesN<32>, + pub r_tilde_aud_s: BytesN<32>, + pub r_a_tilde_aud_s: BytesN<32>, } /// Envelope decoded from the `data: Bytes` argument of @@ -574,7 +579,7 @@ pub fn merge(e: &Env, account: &Address) { /// /// * topics - `["withdraw", from: Address, to: Address]` /// * data - `[amount: i128, r_e_point: BytesN<64>, sigma: BytesN<32>, b_tilde: -/// BytesN<32>, b_tilde_aud_s: BytesN<32>]` +/// BytesN<32>, b_tilde_aud_s: BytesN<32>, r_tilde_aud_s: BytesN<32>]` /// /// # Notes /// @@ -610,7 +615,7 @@ pub fn withdraw( // PI order (DESIGN §7.5): // C_spend, Y, addr_f, K_aud_s, a, - // C_spend', sigma, b_tilde, R_e, b_tilde_aud_s + // C_spend', sigma, b_tilde, R_e, b_tilde_aud_s, r_tilde_aud_s let mut pi = Bytes::new(e); append_point(&mut pi, &account.spendable_commitment); append_point(&mut pi, &account.spending_public_key); @@ -622,6 +627,7 @@ pub fn withdraw( append_field(&mut pi, &payload.b_tilde); append_point(&mut pi, &payload.r_e_point); append_field(&mut pi, &payload.b_tilde_aud_s); + append_field(&mut pi, &payload.r_tilde_aud_s); verify(e, CircuitType::Withdraw, &pi, proof); @@ -639,6 +645,7 @@ pub fn withdraw( &payload.sigma, &payload.b_tilde, &payload.b_tilde_aud_s, + &payload.r_tilde_aud_s, ); } @@ -667,7 +674,7 @@ pub fn withdraw( /// /// * topics - `["transfer", from: Address, to: Address]` /// * data - `[r_e_point, v_tilde, sigma, b_tilde, v_tilde_aud_r, r_tilde_aud_r, -/// v_tilde_aud_s, b_tilde_aud_s]` +/// v_tilde_aud_s, b_tilde_aud_s, r_tilde_aud_s]` /// /// # Security Warning /// @@ -690,7 +697,8 @@ pub fn confidential_transfer( // PI order (DESIGN §7.6): // C_spend_A, Y_A, PVK_B, addr_f, K_aud_r, K_aud_s, // C_spend', C_transfer, R_e, v_tilde, b_tilde, sigma, - // v_tilde_aud_r, r_tilde_aud_r, v_tilde_aud_s, b_tilde_aud_s + // v_tilde_aud_r, r_tilde_aud_r, v_tilde_aud_s, b_tilde_aud_s, + // r_tilde_aud_s let mut pi = Bytes::new(e); append_point(&mut pi, &sender.spendable_commitment); append_point(&mut pi, &sender.spending_public_key); @@ -708,6 +716,7 @@ pub fn confidential_transfer( append_field(&mut pi, &payload.r_tilde_aud_r); append_field(&mut pi, &payload.v_tilde_aud_s); append_field(&mut pi, &payload.b_tilde_aud_s); + append_field(&mut pi, &payload.r_tilde_aud_s); verify(e, CircuitType::Transfer, &pi, proof); @@ -726,6 +735,7 @@ pub fn confidential_transfer( &payload.r_tilde_aud_r, &payload.v_tilde_aud_s, &payload.b_tilde_aud_s, + &payload.r_tilde_aud_s, ); } @@ -760,8 +770,8 @@ pub fn confidential_transfer( /// /// * topics - `["spender_transfer", spender: Address, from: Address, to: /// Address]` -/// * data - `[r_e_point, v_tilde, sigma_a, v_tilde_aud_r, r_tilde_aud_r, -/// v_tilde_aud_s, a_tilde_aud_s]` +/// * data - `[r_e_point, v_tilde, sigma_a_new, v_tilde_aud_r, r_tilde_aud_r, +/// v_tilde_aud_s, a_tilde_aud_s, r_tilde_aud_s]` /// /// # Security Warning /// @@ -790,12 +800,11 @@ pub fn confidential_transfer_from( // owner). let k_aud_s = auditor.get_key(&owner.auditor_id); - // Capture it here to emit in the event below. - let sigma_a = delegation.allowance_salt.clone(); // PI order (DESIGN §7.8): // C_a, sigma_a, Y_op, PVK_recipient, K_aud_r, K_aud_s, // C_a', C_transfer, R_e, v_tilde, a_tilde', sigma_a', - // v_tilde_aud_r, r_tilde_aud_r, v_tilde_aud_s, a_tilde_aud_s + // v_tilde_aud_r, r_tilde_aud_r, v_tilde_aud_s, a_tilde_aud_s, + // r_tilde_aud_s let mut pi = Bytes::new(e); append_point(&mut pi, &delegation.allowance_commitment); append_field(&mut pi, &delegation.allowance_salt); @@ -813,6 +822,7 @@ pub fn confidential_transfer_from( append_field(&mut pi, &payload.r_tilde_aud_r); append_field(&mut pi, &payload.v_tilde_aud_s); append_field(&mut pi, &payload.a_tilde_aud_s); + append_field(&mut pi, &payload.r_tilde_aud_s); verify(e, CircuitType::SpenderTransfer, &pi, proof); @@ -833,11 +843,12 @@ pub fn confidential_transfer_from( to, &payload.r_e_point, &payload.v_tilde, - &sigma_a, + &payload.sigma_a_new, &payload.v_tilde_aud_r, &payload.r_tilde_aud_r, &payload.v_tilde_aud_s, &payload.a_tilde_aud_s, + &payload.r_tilde_aud_s, ); } @@ -873,7 +884,7 @@ pub fn confidential_transfer_from( /// /// * topics - `["set_spender", account: Address, spender: Address]` /// * data - `[live_until_ledger: u32, r_e_point, sigma, b_tilde, v_tilde_aud_s, -/// b_tilde_aud_s]` +/// b_tilde_aud_s, r_tilde_aud_s, r_a_tilde_aud_s]` /// /// # Security Warning /// @@ -897,7 +908,8 @@ pub fn set_spender( // PI order (DESIGN §7.7): // C_spend, Y, Y_op, spender_id (op_i), addr_f, K_aud_s, // C_spend', C_a, escrowed_dvk, b_tilde, a_tilde, - // sigma, sigma_a, R_e, v_tilde_aud_s, b_tilde_aud_s + // sigma, sigma_a, R_e, v_tilde_aud_s, b_tilde_aud_s, + // r_tilde_aud_s, r_a_tilde_aud_s let mut pi = Bytes::new(e); append_point(&mut pi, &owner.spendable_commitment); append_point(&mut pi, &owner.spending_public_key); @@ -915,6 +927,8 @@ pub fn set_spender( append_point(&mut pi, &payload.r_e_point); append_field(&mut pi, &payload.v_tilde_aud_s); append_field(&mut pi, &payload.b_tilde_aud_s); + append_field(&mut pi, &payload.r_tilde_aud_s); + append_field(&mut pi, &payload.r_a_tilde_aud_s); verify(e, CircuitType::SetSpender, &pi, proof); @@ -942,6 +956,8 @@ pub fn set_spender( &payload.b_tilde, &payload.v_tilde_aud_s, &payload.b_tilde_aud_s, + &payload.r_tilde_aud_s, + &payload.r_a_tilde_aud_s, ); } diff --git a/packages/tokens/src/confidential/test.rs b/packages/tokens/src/confidential/test.rs index 40013721c..9b27e16cc 100644 --- a/packages/tokens/src/confidential/test.rs +++ b/packages/tokens/src/confidential/test.rs @@ -5,7 +5,7 @@ use soroban_sdk::{ testutils::{Address as _, Events, Ledger}, token::StellarAssetClient, xdr::ToXdr, - Address, Bytes, BytesN, Env, + Address, Bytes, BytesN, Env, Event, }; use crate::confidential::{ @@ -13,13 +13,26 @@ use crate::confidential::{ storage as token_storage, verifier::{CircuitType, ConfidentialVerifier}, ConfidentialAccount, ConfidentialToken, ConfidentialTokenClient, NoHooks, RegisterData, - RegisterPayload, RevokeSpenderData, RevokeSpenderPayload, SetSpenderData, SetSpenderPayload, - SpenderDelegation, SpenderTransferData, SpenderTransferPayload, TransferData, TransferPayload, - WithdrawData, WithdrawPayload, + RegisterPayload, RevokeSpender, RevokeSpenderData, RevokeSpenderPayload, SetSpender, + SetSpenderData, SetSpenderPayload, SpenderDelegation, SpenderTransfer, SpenderTransferData, + SpenderTransferPayload, Transfer, TransferData, TransferPayload, Withdraw, WithdrawData, + WithdrawPayload, }; // ################## TEST FIXTURES ################## +/// `-G = (1, r - Y)`, the negation of [`GRUMPKIN_G_BYTES`]. On-curve and +/// canonical, and distinct from `G`, so it stands in for a rotated-in auditor +/// key wherever a test needs two different key versions. +const GRUMPKIN_NEG_G_BYTES: [u8; 64] = [ + // x = 1 (32-byte big-endian) + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, + // r - y (32-byte big-endian) + 0x30, 0x64, 0x4e, 0x72, 0xe1, 0x31, 0xa0, 0x26, 0xe9, 0x3c, 0xe7, 0x41, 0x7a, 0xdc, 0xfa, 0xf9, + 0xfb, 0x0c, 0xdb, 0x02, 0x88, 0xa1, 0x5d, 0xfc, 0xc0, 0xa2, 0x31, 0x06, 0x6d, 0xc0, 0xd8, 0xd5, +]; + /// Grumpkin generator `G = (1, Y)` with `Y = /// 17631683881184975370165255887551781615748388533673675138860`. Used as a /// canonical on-curve fixture for both auditor keys and account keys in @@ -37,6 +50,10 @@ fn fixture_point(e: &Env) -> BytesN<64> { BytesN::from_array(e, &GRUMPKIN_G_BYTES) } +fn rotated_fixture_point(e: &Env) -> BytesN<64> { + BytesN::from_array(e, &GRUMPKIN_NEG_G_BYTES) +} + fn fixture_field(e: &Env, byte: u8) -> BytesN<32> { let mut bytes = [byte; 32]; // Zero the top byte so the 256-bit value is bounded by 2^248 - 1, @@ -143,6 +160,35 @@ impl ConfidentialVerifier for ReplayGuardVerifier { } } +/// Records the auditor key each proof was verified against. +/// +/// The escrowed allowance opening is produced under whichever auditor key is +/// registered at the moment of the operation, so "which key version can read +/// this event" reduces, at the contract level, to "which `K_aud_s` went into +/// the public-input blob". This mock captures exactly that. `K_aud_s` sits at +/// limb 8 of the SetSpender blob and limb 9 of the SpenderTransfer and +/// RevokeSpender blobs (DESIGN §7.7 - §7.9). +#[contract] +struct KeyRecordingVerifier; + +#[contractimpl(contracttrait)] +impl ConfidentialVerifier for KeyRecordingVerifier { + fn register_verification_key(_e: &Env, _ct: CircuitType, _vk: Bytes, _op: Address) {} + + fn update_verification_key(_e: &Env, _ct: CircuitType, _vk: Bytes, _op: Address) {} + + fn verify_proof(e: &Env, ct: CircuitType, pi: Bytes, _proof: Bytes) -> bool { + let (key, offset) = match ct { + CircuitType::SetSpender => (symbol_short!("k_set"), 8u32 * 32), + CircuitType::SpenderTransfer => (symbol_short!("k_xfer"), 9u32 * 32), + CircuitType::RevokeSpender => (symbol_short!("k_revoke"), 9u32 * 32), + _ => return true, + }; + e.storage().instance().set(&key, &pi.slice(offset..offset + 64)); + true + } +} + #[contract] struct MockAuditor; @@ -166,6 +212,7 @@ struct Harness<'a> { token_admin: Address, sac: StellarAssetClient<'a>, sac_addr: Address, + auditor_addr: Address, } fn setup<'a>() -> Harness<'a> { @@ -200,7 +247,7 @@ fn setup_with_verifier_addr<'a>(e: Env, verifier_addr: Address) -> Harness<'a> { e.register(TokenContract, (sac_addr.clone(), verifier_addr.clone(), auditor_addr.clone())); let token = ConfidentialTokenClient::new(&e, &token_addr); - Harness { e, token, token_addr, token_admin, sac: sac_client, sac_addr } + Harness { e, token, token_addr, token_admin, sac: sac_client, sac_addr, auditor_addr } } fn register_data(e: &Env) -> Bytes { @@ -219,6 +266,7 @@ fn withdraw_data(e: &Env) -> Bytes { r_e_point: fixture_point(e), sigma: fixture_field(e, 0xbb), b_tilde_aud_s: fixture_field(e, 0xcc), + r_tilde_aud_s: fixture_field(e, 0xdd), }, proof: Bytes::new(e), } @@ -238,6 +286,7 @@ fn transfer_data(e: &Env) -> Bytes { r_tilde_aud_r: fixture_field(e, 0x15), v_tilde_aud_s: fixture_field(e, 0x16), b_tilde_aud_s: fixture_field(e, 0x17), + r_tilde_aud_s: fixture_field(e, 0x18), }, proof: Bytes::new(e), } @@ -257,6 +306,8 @@ fn set_spender_data(e: &Env) -> Bytes { sigma_a: fixture_field(e, 0x24), v_tilde_aud_s: fixture_field(e, 0x25), b_tilde_aud_s: fixture_field(e, 0x26), + r_tilde_aud_s: fixture_field(e, 0x27), + r_a_tilde_aud_s: fixture_field(e, 0x28), }, proof: Bytes::new(e), } @@ -276,6 +327,7 @@ fn spender_transfer_data(e: &Env) -> Bytes { r_tilde_aud_r: fixture_field(e, 0x35), v_tilde_aud_s: fixture_field(e, 0x36), a_tilde_aud_s: fixture_field(e, 0x37), + r_tilde_aud_s: fixture_field(e, 0x38), }, proof: Bytes::new(e), } @@ -384,6 +436,7 @@ fn withdraw_non_canonical_scalar_panics() { r_e_point: fixture_point(&h.e), sigma: BytesN::from_array(&h.e, &[0xff; 32]), b_tilde_aud_s: fixture_field(&h.e, 0x12), + r_tilde_aud_s: fixture_field(&h.e, 0x13), }, proof: Bytes::new(&h.e), } @@ -536,7 +589,22 @@ fn withdraw_transfers_tokens_and_updates_spendable() { h.token.withdraw(&alice, &beneficiary, &300i128, &withdraw_data(&h.e)); // 1 SAC transfer event + 1 Withdraw event. - assert_eq!(h.e.events().all().events().len(), 2); + let events = h.e.events().all(); + assert_eq!(events.events().len(), 2); + assert_eq!( + events.events().get(1).unwrap(), + &Withdraw { + from: alice.clone(), + to: beneficiary.clone(), + amount: 300i128, + r_e_point: fixture_point(&h.e), + sigma: fixture_field(&h.e, 0xbb), + b_tilde: fixture_field(&h.e, 0xaa), + b_tilde_aud_s: fixture_field(&h.e, 0xcc), + r_tilde_aud_s: fixture_field(&h.e, 0xdd), + } + .to_xdr(&h.e, &h.token_addr) + ); let token_client = soroban_sdk::token::TokenClient::new(&h.e, &h.sac_addr); assert_eq!(token_client.balance(&beneficiary), 300); @@ -584,7 +652,25 @@ fn confidential_transfer_updates_both_sides() { h.token.merge(&alice); h.token.confidential_transfer(&alice, &bob, &transfer_data(&h.e)); - assert_eq!(h.e.events().all().events().len(), 1); + let events = h.e.events().all(); + assert_eq!(events.events().len(), 1); + assert_eq!( + events.events().first().unwrap(), + &Transfer { + from: alice.clone(), + to: bob.clone(), + r_e_point: fixture_point(&h.e), + v_tilde: fixture_field(&h.e, 0x11), + sigma: fixture_field(&h.e, 0x13), + b_tilde: fixture_field(&h.e, 0x12), + v_tilde_aud_r: fixture_field(&h.e, 0x14), + r_tilde_aud_r: fixture_field(&h.e, 0x15), + v_tilde_aud_s: fixture_field(&h.e, 0x16), + b_tilde_aud_s: fixture_field(&h.e, 0x17), + r_tilde_aud_s: fixture_field(&h.e, 0x18), + } + .to_xdr(&h.e, &h.token_addr) + ); // Sender's spendable balance was overwritten. let alice_acc = h.token.confidential_balance(&alice); @@ -606,7 +692,24 @@ fn set_spender_stores_delegation() { h.token.register(&spender, &1u32, ®ister_data(&h.e)); h.token.set_spender(&alice, &spender, &1_000u32, &set_spender_data(&h.e)); - assert_eq!(h.e.events().all().events().len(), 1); + let events = h.e.events().all(); + assert_eq!(events.events().len(), 1); + assert_eq!( + events.events().first().unwrap(), + &SetSpender { + account: alice.clone(), + spender: spender.clone(), + live_until_ledger: 1_000u32, + r_e_point: fixture_point(&h.e), + sigma: fixture_field(&h.e, 0x23), + b_tilde: fixture_field(&h.e, 0x21), + v_tilde_aud_s: fixture_field(&h.e, 0x25), + b_tilde_aud_s: fixture_field(&h.e, 0x26), + r_tilde_aud_s: fixture_field(&h.e, 0x27), + r_a_tilde_aud_s: fixture_field(&h.e, 0x28), + } + .to_xdr(&h.e, &h.token_addr) + ); let delegation = h.token.get_spender_delegation(&alice, &spender); assert_eq!(delegation.live_until_ledger, 1_000); @@ -661,6 +764,122 @@ fn get_spender_delegation_unknown_panics() { h.token.get_spender_delegation(&alice, &spender); } +// ################## AUDITOR-KEY ROTATION ################## + +#[test] +fn auditor_key_rotation_rescopes_the_escrowed_allowance_opening() { + // The auditor's allowance opening is escrowed per event, not per + // delegation: every state-changing operation re-encrypts it under + // whichever auditor key is registered at that moment. So rotation is + // event-scoped in both directions -- K2 gets nothing retroactively, and + // K1 keeps whatever it already decrypted. + // + // Proofs are mocked here, so what this pins is the contract-level half of + // that claim: which key version each operation's ciphertexts were produced + // for, and which operations produce an allowance escrow at all. + let e = Env::default(); + let verifier_addr = e.register(KeyRecordingVerifier, ()); + let h = setup_with_verifier_addr(e, verifier_addr.clone()); + let alice = Address::generate(&h.e); + let spender = Address::generate(&h.e); + let bob = Address::generate(&h.e); + + let k1 = fixture_point(&h.e); + let k2 = rotated_fixture_point(&h.e); + let recorded = |key: soroban_sdk::Symbol| -> Bytes { + h.e.as_contract(&verifier_addr, || h.e.storage().instance().get::<_, Bytes>(&key).unwrap()) + }; + + h.token.register(&alice, &1u32, ®ister_data(&h.e)); + h.token.register(&spender, &1u32, ®ister_data(&h.e)); + h.token.register(&bob, &1u32, ®ister_data(&h.e)); + + // Step 1 -- delegate under K1. S14's escrow of r_a rides this event. + // The event buffer is scoped to the last top-level invocation, so it has + // to be read before the `as_contract` peek at the verifier's storage. + h.token.set_spender(&alice, &spender, &1_000u32, &set_spender_data(&h.e)); + let set_events = h.e.events().all(); + assert_eq!( + set_events.events().first().unwrap(), + &SetSpender { + account: alice.clone(), + spender: spender.clone(), + live_until_ledger: 1_000u32, + r_e_point: fixture_point(&h.e), + sigma: fixture_field(&h.e, 0x23), + b_tilde: fixture_field(&h.e, 0x21), + v_tilde_aud_s: fixture_field(&h.e, 0x25), + b_tilde_aud_s: fixture_field(&h.e, 0x26), + r_tilde_aud_s: fixture_field(&h.e, 0x27), + r_a_tilde_aud_s: fixture_field(&h.e, 0x28), + } + .to_xdr(&h.e, &h.token_addr) + ); + assert_eq!(recorded(symbol_short!("k_set")), k1.clone().into()); + + // Step 2 -- rotate the owner's auditor key. Nothing about the live + // delegation changes: no event, no new ciphertext, and the on-chain C_a is + // untouched. K2 holds no opening for it yet. + let auditor = + crate::confidential::auditor::ConfidentialAuditorClient::new(&h.e, &h.auditor_addr); + auditor.rotate_key(&1u32, &k2, &Address::generate(&h.e)); + let before = h.token.get_spender_delegation(&alice, &spender); + + // Step 3 -- the first post-rotation state change re-anchors K2. O_a9's + // escrow of r_a' is verified against K2, so this single event is what + // gives the rotated-in key an opening of the allowance it did not see + // created. + h.token.confidential_transfer_from(&spender, &alice, &bob, &spender_transfer_data(&h.e)); + let xfer_events = h.e.events().all(); + assert_eq!( + xfer_events.events().first().unwrap(), + &SpenderTransfer { + spender: spender.clone(), + from: alice.clone(), + to: bob.clone(), + r_e_point: fixture_point(&h.e), + v_tilde: fixture_field(&h.e, 0x31), + sigma_a_new: fixture_field(&h.e, 0x33), + v_tilde_aud_r: fixture_field(&h.e, 0x34), + r_tilde_aud_r: fixture_field(&h.e, 0x35), + v_tilde_aud_s: fixture_field(&h.e, 0x36), + a_tilde_aud_s: fixture_field(&h.e, 0x37), + r_tilde_aud_s: fixture_field(&h.e, 0x38), + } + .to_xdr(&h.e, &h.token_addr) + ); + assert_eq!(recorded(symbol_short!("k_xfer")), k2.clone().into()); + // The delegation moved to a new state, so the opening K2 just received is + // an opening of the CURRENT C_a, not of the one K1 saw. (The commitments + // themselves are the same canonical fixture point under a mocked verifier; + // the salt is what distinguishes the two states here.) + let after = h.token.get_spender_delegation(&alice, &spender); + assert_ne!(before.allowance_salt, after.allowance_salt); + + // Step 4 -- revocation runs under K2 too, but RevokeSpender reads only two + // sponge lanes (V_a3): its event carries no allowance escrow at all. An + // auditor that missed step 3 gets no second chance here -- it has to fold + // the allowance it already holds into C_spend, or wait for the owner's + // next checkpoint. This is the field list, and the absence is the point. + h.token.revoke_spender(&alice, &spender, &revoke_spender_data(&h.e)); + let revoke_events = h.e.events().all(); + assert_eq!( + revoke_events.events().first().unwrap(), + &RevokeSpender { + account: alice.clone(), + spender: spender.clone(), + r_e_point: fixture_point(&h.e), + sigma: fixture_field(&h.e, 0x42), + b_tilde: fixture_field(&h.e, 0x41), + v_tilde_aud_s: fixture_field(&h.e, 0x43), + b_tilde_aud_s: fixture_field(&h.e, 0x44), + } + .to_xdr(&h.e, &h.token_addr) + ); + assert_eq!(recorded(symbol_short!("k_revoke")), k2.into()); + assert!(!h.token.is_spender(&alice, &spender)); +} + // ################## SPENDER TRANSFER ################## #[test] @@ -676,7 +895,27 @@ fn confidential_transfer_from_updates_delegation_and_recipient() { h.token.set_spender(&alice, &spender, &1_000u32, &set_spender_data(&h.e)); h.token.confidential_transfer_from(&spender, &alice, &bob, &spender_transfer_data(&h.e)); - assert_eq!(h.e.events().all().events().len(), 1); + let events = h.e.events().all(); + assert_eq!(events.events().len(), 1); + assert_eq!( + events.events().first().unwrap(), + &SpenderTransfer { + spender: spender.clone(), + from: alice.clone(), + to: bob.clone(), + r_e_point: fixture_point(&h.e), + v_tilde: fixture_field(&h.e, 0x31), + // The payload's sigma_a', not the stored allowance_salt (0x24) it + // replaces: every pad and the ephemeral scalar are keyed to it. + sigma_a_new: fixture_field(&h.e, 0x33), + v_tilde_aud_r: fixture_field(&h.e, 0x34), + r_tilde_aud_r: fixture_field(&h.e, 0x35), + v_tilde_aud_s: fixture_field(&h.e, 0x36), + a_tilde_aud_s: fixture_field(&h.e, 0x37), + r_tilde_aud_s: fixture_field(&h.e, 0x38), + } + .to_xdr(&h.e, &h.token_addr) + ); // Delegation allowance commitment was rotated. let delegation = h.token.get_spender_delegation(&alice, &spender); @@ -686,6 +925,50 @@ fn confidential_transfer_from_updates_delegation_and_recipient() { assert_ne!(bob_acc.receiving_commitment.to_array(), [0u8; 64]); } +/// The `SpenderTransfer` event carries the transfer's channel nonce +/// `sigma_a'`, not the stored `allowance_salt` it replaces (DESIGN §6.2 +/// *Transfer nonce*). Emitting the stored salt would hand the recipient and +/// both auditors a nonce none of the pads absorbed, and would repeat across a +/// retry. +#[test] +fn confidential_transfer_from_emits_new_salt_as_channel_nonce() { + let h = setup(); + let alice = Address::generate(&h.e); + let spender = Address::generate(&h.e); + let bob = Address::generate(&h.e); + + h.token.register(&alice, &1u32, ®ister_data(&h.e)); + h.token.register(&spender, &1u32, ®ister_data(&h.e)); + h.token.register(&bob, &1u32, ®ister_data(&h.e)); + h.token.set_spender(&alice, &spender, &1_000u32, &set_spender_data(&h.e)); + let stored_salt = h.token.get_spender_delegation(&alice, &spender).allowance_salt; + assert_eq!(stored_salt, fixture_field(&h.e, 0x24)); + + h.token.confidential_transfer_from(&spender, &alice, &bob, &spender_transfer_data(&h.e)); + + let events = h.e.events().all(); + assert_eq!(events.events().len(), 1); + assert_eq!( + events.events().first().unwrap(), + &SpenderTransfer { + spender: spender.clone(), + from: alice.clone(), + to: bob.clone(), + r_e_point: fixture_point(&h.e), + v_tilde: fixture_field(&h.e, 0x31), + sigma_a_new: fixture_field(&h.e, 0x33), + v_tilde_aud_r: fixture_field(&h.e, 0x34), + r_tilde_aud_r: fixture_field(&h.e, 0x35), + v_tilde_aud_s: fixture_field(&h.e, 0x36), + a_tilde_aud_s: fixture_field(&h.e, 0x37), + r_tilde_aud_s: fixture_field(&h.e, 0x38), + } + .to_xdr(&h.e, &h.token_addr) + ); + let delegation = h.token.get_spender_delegation(&alice, &spender); + assert_eq!(delegation.allowance_salt, fixture_field(&h.e, 0x33)); +} + #[test] #[should_panic(expected = "Error(Contract, #3505)")] fn confidential_transfer_from_expired_panics() { From 447b3ff9573909934c9c13da17280c5f03034ce8 Mon Sep 17 00:00:00 2001 From: Boyan Barakov <9572072+brozorec@users.noreply.github.com> Date: Thu, 3 Sep 2026 17:08:36 +0200 Subject: [PATCH 06/17] feat(confidential): make revoke_spender proofless (#854) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(confidential)!: make revoke_spender proofless Revocation folds the escrowed allowance commitment back into the owner's spendable commitment by homomorphic addition, exactly like merge (DESIGN §7.4). The escrowed amount was range-proven when the delegation was created and re-bounded on every spender transfer, and the next spend re-bounds the result, so the RevokeSpender circuit proved nothing the protocol did not already know. The auditor stays in sync because the escrowed r_a it holds from the delegation's last state change (S14 / O_a9) opens the folded commitment directly. BREAKING CHANGE: revoke_spender drops its `data` argument, the RevokeSpender event carries `(a_tilde, allowance_salt)` instead of the checkpoint fields, `on_revoke_spender` loses its payload, and CircuitType::RevokeSpender is removed. * docs(confidential): consolidate auditor rotation capability and fix stale revoke references Move the rotation decryption rule into DESIGN_cont §8.3 as its single normative site and reduce §8.1, §8.2 and §8.5 to pointers, adding the fact none of them stated: re-anchoring a rotated-in key is not one-time, since every later fold whose addend predates the rotation returns the holder to unopened. Correct the escrowed value named in revoke_spender's docs (r_a via S14/O_a9, not dvk_i), remove the phantom force_revoke_spender across five docs and storage.rs, drop the deleted V* constraint citations, and state the clawback precondition COMPLIANCE §5.3 constraint 2 needs. Assert the homomorphic fold in tests and collapse revoke_spender's duplicate account read. * test(confidential): cover revoking an expired delegation * docs(confidential): cite instead of restate around the proofless revoke Drops the Clawback references the core recovery text acquired (COMPLIANCE §5 is outline only), lets §9.5 account for RevokeSpender folds inside the anchor window, and trims the restated §7.9/§8.5 rationale to citations. * docs(confidential): cut restated sponge and salt-freshness claims in §2.5 The prefix property was derived twice and the width assignment restated a third time; the pad-freshness paragraph re-glossed $s$ and compressed the revert argument that §6.2 owns. State each once and cite the rest. * docs(confidential): deduplicate §5.2 and fix stale revoke claims §5.2 stated the checkpoint set three times and the per-event update rules twice; step 6 now cites the update table and carries only the replay-specific skip rules. Corrects the spendable-side recovery claims in DESIGN, INDEXER, SDK, and OVERVIEW, which omitted the post-checkpoint RevokeSpender fold. * docs(confidential): fold §5.3 Note into the r_e-reuse argument The Note duplicated DESIGN_cont §8.1's auditor channels and pre-stated the S14 caveat the next paragraph spelled out in full. Cite §8.1, §8.5 and §2.5 instead of restating them. * docs(confidential): fix salt-emission claims, restructure §6.2 transfer nonce Corrects the claim that the consumed allowance salt never reaches events: `SpenderTransfer` emits its replacement as `sigma_a_new`, so the salt a later revoke folds against sits in the preceding transfer's event. Restructures the transfer-nonce subsection motivation-first and trims restated constraint rationale across §7.5-§7.9. * docs(confidential): deduplicate §8 auditor sections Cut restated prose across §8.1-§8.5 in favour of citations: the lane[2] slot description folds into DESIGN §2.5, and the rotation, pad-freshness, mode-exclusivity, and cost claims now cite §8.3, §5.3, §2.5, and §10.3 instead of restating them. Also retargets a stale §5.5 cross-reference for the auditor's encrypted scalar to §8.1. * docs(confidential): prose style * docs(confidential): fix typo flagged by CI `unparseable` -> `unparsable`. --- packages/tokens/src/confidential/CLAUDE.md | 22 +- .../tokens/src/confidential/auditor/mod.rs | 4 +- .../src/confidential/circuits/CLAUDE.md | 14 +- .../src/confidential/circuits/Nargo.toml | 1 - .../circuits/constraints.baseline | 4 - .../src/confidential/circuits/lib/src/lib.nr | 82 +- .../confidential/circuits/lib/src/tests.nr | 2 +- .../circuits/lib/testdata/derive_spend_r.json | 2 +- .../lib/testdata/encrypt_balance.json | 2 +- .../lib/testdata/sponge_squeeze_2.json | 4 +- .../circuits/lib/testdata/vk_from_sk.json | 2 +- .../circuits/revoke_spender/Nargo.toml | 10 - .../circuits/revoke_spender/src/main.nr | 222 ----- .../circuits/revoke_spender/src/tests.nr | 912 ------------------ .../circuits/scripts/extract_vks.sh | 1 - .../circuits/set_spender/src/main.nr | 39 +- .../circuits/spender_transfer/src/main.nr | 46 +- .../circuits/transfer/src/main.nr | 6 +- .../circuits/vks/revoke_spender.vk.json | 1 - .../circuits/withdraw/src/main.nr | 11 +- .../tokens/src/confidential/compliance/mod.rs | 11 +- .../src/confidential/compliance/test.rs | 23 +- .../src/confidential/docs/COMPLIANCE.md | 11 +- .../tokens/src/confidential/docs/DESIGN.md | 126 +-- .../src/confidential/docs/DESIGN_cont.md | 113 ++- .../tokens/src/confidential/docs/INDEXER.md | 30 +- .../tokens/src/confidential/docs/OVERVIEW.md | 25 +- packages/tokens/src/confidential/docs/SDK.md | 29 +- .../confidential/docs/SELECTIVE_DISCLOSURE.md | 8 +- packages/tokens/src/confidential/mod.rs | 66 +- packages/tokens/src/confidential/storage.rs | 98 +- packages/tokens/src/confidential/test.rs | 102 +- .../tokens/src/confidential/verifier/mod.rs | 1 - .../tokens/src/confidential/verifier/test.rs | 5 +- 34 files changed, 337 insertions(+), 1698 deletions(-) delete mode 100644 packages/tokens/src/confidential/circuits/revoke_spender/Nargo.toml delete mode 100644 packages/tokens/src/confidential/circuits/revoke_spender/src/main.nr delete mode 100644 packages/tokens/src/confidential/circuits/revoke_spender/src/tests.nr delete mode 100644 packages/tokens/src/confidential/circuits/vks/revoke_spender.vk.json diff --git a/packages/tokens/src/confidential/CLAUDE.md b/packages/tokens/src/confidential/CLAUDE.md index 5b3b3db38..305a056bb 100644 --- a/packages/tokens/src/confidential/CLAUDE.md +++ b/packages/tokens/src/confidential/CLAUDE.md @@ -8,14 +8,14 @@ The module ships one token contract plus three satellites, each with the standar | Path | Role | |:---|:---| -| `mod.rs`, `storage.rs` | The `ConfidentialToken` trait — eleven entry points — and the storage/orchestration layer | +| `mod.rs`, `storage.rs` | The `ConfidentialToken` trait — eleven entry points, of which `revoke_spender` is proofless — and the storage/orchestration layer | | `verifier/` | Separate contract holding per-circuit UltraHonk verification keys | | `auditor/` | Separate contract holding the auditor key registry | | `compliance/` | `ComplianceHooks` — freeze, SAC passthrough, policy contract, clawback | | `circuits/` | Noir workspace, compiled by `nargo`, not `cargo` | | `docs/` | The protocol specification (see below) | -Balances are Pedersen commitments on Grumpkin; every operation that consumes private state carries a proof the contract forwards to the verifier contract. +Balances are Pedersen commitments on Grumpkin. Every operation that opens or re-randomizes a commitment carries a proof the contract forwards to the verifier contract; `deposit`, `merge`, and `revoke_spender` are proofless homomorphic folds. **Not production ready.** The UltraHonk backend (`rs-soroban-ultrahonk`) is unfinished and unaudited. The `# ⚠️ Not Production Ready` blocks in `mod.rs` and `verifier/mod.rs` are load-bearing — do not remove or soften them. @@ -43,7 +43,7 @@ Beyond the root guide's conventions: ## The documentation set -`docs/` is a specification, not commentary, and it is the single largest maintenance hazard in this module. Nine of the last sixteen commits on this branch were doc-consistency fixes. +`docs/` is a specification, not commentary, and it is the single largest maintenance hazard in this module — doc-consistency fixes outnumber code commits on this branch. ### Normativity @@ -67,9 +67,21 @@ Five things exist in more than one file. Changing the normative copy means grepp The tags are a cross-language wire contract. `DESIGN_cont.md` §13 is their only authoritative source: it assigns every value, and it states which subset `circuits/lib/src/lib.nr` implements and why the remainder are absent. Changing any assigned value is a new deployment, not an upgrade. -### Editing rules +### Economy + +Every sentence is a maintenance liability: a claim written twice has to be fixed twice, and the second copy is the one that goes stale. Doc work here trends net-negative in lines. + +- **One owning section per claim.** Every fact has exactly one home; everywhere else writes `§N`. When tempted to summarise a neighbouring section for the reader's convenience, cite it instead; +- **Say it once, then move on.** No second-register restatement, no paragraph-closing punchline, no recap of the section's own argument in its last sentence. If a paragraph's content survives deleting it, delete it. +- **Gloss a symbol at its definition site only.** Re-glossing `s` or `r_e` in each section that uses them is three more places to update when a name changes. +- **No pre-stating.** A `Note:` or lead-in that previews what the next paragraph spells out in full is a duplicate. Fold it into the argument. +- **Motivation before constraint.** State what a rule protects against, then the rule. Rationale appended after the fact invites a second copy of the rule next to it. +- **Drop concessive asides.** "…though X would not be a violation either" earns nothing and dates fast. +- **Grep before adding.** A new claim is usually a second copy of an existing one. Search the normative pair for the symbol or term before writing a sentence about it. +- **A citation is not a summary.** `§8.1` is the whole reference. `§8.1, which escrows the allowance blinding` re-creates the copy the citation was avoiding — name the target only when the sentence would be unparsable without it. + +### Style -- **Cite, do not restate.** Every drift bug in the recent history came from a second copy of something. When tempted to summarise a neighbouring section, write `§N` instead. - **Match the file's math style, not a global one.** `DESIGN*.md` and `SELECTIVE_DISCLOSURE.md` use `$$…$$` with backslash-escaped subscripts (`$$\mathbb{F}\_r$$`); `SDK.md` uses `$$…$$` unescaped; `OVERVIEW.md` uses single `$…$`; `INDEXER.md` and `COMPLIANCE.md` use backticked ASCII and no LaTeX. - **Symbols are a maintained namespace.** `sk`/`vk`/`dvk_i`/`PVK`/`Y`; `r_e` and `R_e = r_e·H`; `σ` (operation salt) is distinct from `σ_a` (per-delegation allowance salt); tilde means ciphertext; `C_spend` / `C_receive` / `C_transfer` / `C_a`; `r_a` is `C_a`'s blinding and `r_a'` the post-transfer one. An audit finding once required renaming the `tx` subscript to `transfer` across the whole module. - Prose is full-width — no hard wrapping. One paragraph or list item per line. diff --git a/packages/tokens/src/confidential/auditor/mod.rs b/packages/tokens/src/confidential/auditor/mod.rs index be7d4d368..db42d428f 100644 --- a/packages/tokens/src/confidential/auditor/mod.rs +++ b/packages/tokens/src/confidential/auditor/mod.rs @@ -8,7 +8,7 @@ //! //! Auditor keys are referenced by the confidential token on every operation //! that produces an auditor ciphertext (withdraw, transfer, spender transfer, -//! set/revoke spender). Keeping them in a separate registry allows: +//! set spender). Keeping them in a separate registry allows: //! //! - **Reuse**: one registry can serve many confidential tokens; //! - **Lifecycle**: registration and rotation can evolve (e.g. versioned keys, @@ -51,7 +51,7 @@ pub use storage::{get_key, register_key, rotate_key, AuditorStorageKey}; /// /// The confidential token queries [`ConfidentialAuditor::get_key`] on every /// operation that produces an auditor ciphertext (withdraw, transfer, spender -/// transfer, set/revoke spender). [`ConfidentialAuditor::register_key`] and +/// transfer, set spender). [`ConfidentialAuditor::register_key`] and /// [`ConfidentialAuditor::rotate_key`] are privileged operations expected to be /// gated by the implementor's access-control scheme. #[contracttrait] diff --git a/packages/tokens/src/confidential/circuits/CLAUDE.md b/packages/tokens/src/confidential/circuits/CLAUDE.md index a1363bb9d..577fc5a3f 100644 --- a/packages/tokens/src/confidential/circuits/CLAUDE.md +++ b/packages/tokens/src/confidential/circuits/CLAUDE.md @@ -22,11 +22,11 @@ There is no `nargo fmt` step in CI and no formatter config — Noir formatting i ### `compiler_version = "=1.0.0"` is deliberate -It appears in all fourteen `Nargo.toml` files and looks like a mistake. Nargo requirements cannot name prereleases, so the real toolchain — **nargo 1.0.0-beta.11 + bb 0.87.0**, pinned in `.github/workflows/noir.yml` — is documented in the comment above it instead. Do not "correct" this to `=1.0.0-beta.11`. +It appears in all thirteen package `Nargo.toml` files — the workspace manifest `circuits/Nargo.toml` carries only `[workspace]` — and looks like a mistake. Nargo requirements cannot name prereleases, so the real toolchain — **nargo 1.0.0-beta.11 + bb 0.87.0**, pinned in `.github/workflows/noir.yml` — is documented in the comment above it instead. Do not "correct" this to `=1.0.0-beta.11`. ### Do not prune unused public inputs -`_acct_f` in `register/src/main.nr` is referenced by no gate and looks like dead code. It is the replay binding: UltraHonk absorbs every public input into the transcript, so a proof produced for one account fails when the contract assembles the blob for another. Removing it lets anyone replay a legitimate registration's published proof and payload to mint duplicate-key accounts. Each operation circuit declares its exact public-input count in a header comment — withdraw 16, revoke_spender 19, transfer / spender_transfer 25, set_spender 26 — and the count is part of the contract with the on-chain assembler. +`_acct_f` in `register/src/main.nr` is referenced by no gate and looks like dead code. It is the replay binding: UltraHonk absorbs every public input into the transcript, so a proof produced for one account fails when the contract assembles the blob for another. Removing it lets anyone replay a legitimate registration's published proof and payload to mint duplicate-key accounts. Each operation circuit declares its exact public-input count in a header comment — withdraw 16, transfer / spender_transfer 25, set_spender 26 — and the count is part of the contract with the on-chain assembler. ### Package names are load-bearing @@ -36,11 +36,11 @@ Directory `transfer/` is package `circuit_transfer`; `gadgets/commit/` is `gadge `poseidon_with_domain` is the only Poseidon entry point in `lib/src/lib.nr`; calling the underlying hash directly is a violation of the library contract. The domain tag is always the first absorbed element. The numeric tag values are the cross-language contract with the SDK — see `../CLAUDE.md` and `../docs/DESIGN_cont.md` §13, which is their only authoritative source. -Sponge parameters, the canonical lane assignment, and the mode-exclusivity rule that follows from a single-block absorb are normative in `../docs/DESIGN.md` §2.5; the Noir sponge must match it exactly. The obligations that section places on this code: `sponge_squeeze_2(d,s,σ)[0]` must stay equal to `poseidon_with_domain(d,[s,σ])`, and `sponge_squeeze_3(d,s,σ)[0..2]` must stay equal to `sponge_squeeze_2(d,s,σ)` — which is why `sponge_squeeze_2` is defined as the prefix of `sponge_squeeze_3` rather than as a second permutation. A divergence in either silently changes every existing mask. +Sponge parameters, the canonical lane assignment, and the mode-exclusivity rule that follows from a single-block absorb are normative in `../docs/DESIGN.md` §2.5; the Noir sponge must match it exactly. The obligations that section places on this code: `sponge_squeeze_2(d,s,σ)[0]` must stay equal to `poseidon_with_domain(d,[s,σ])`, and `sponge_squeeze_3(d,s,σ)[0..2]` must stay equal to `sponge_squeeze_2(d,s,σ)` — which is why `sponge_squeeze_2` is defined as the prefix of `sponge_squeeze_3`. A divergence in either silently changes every existing mask. -`AUDITOR_SENDER` is squeezed three-wide by every circuit that escrows `lane[2]` and two-wide only by RevokeSpender (V_a3); `AUDITOR_RECIPIENT` is always two-wide; every other tag goes through `poseidon_with_domain`. Widening or narrowing a channel is a spec change, not a refactor. +`AUDITOR_SENDER` is always squeezed three-wide; `AUDITOR_RECIPIENT` is always two-wide; every other tag goes through `poseidon_with_domain`. Widening or narrowing a channel is a spec change. -`lane[2]` carries **the blinding of a commitment the operation writes, never a key** — `r'` on W_a5 / T_a9 / S_a6, `r_a'` on O_a9. Tag 17 (`ESCROWED_ALLOWANCE_BLINDING_AUDITOR`) is the same idea off-sponge: SetSpender's `lane[2]` is already taken, so S14 escrows `r_a` under a single-output pad. Do not escrow `dvk_i` here: it is permanent per `(owner, spender)` and survives revoke-then-re-delegate, so one leaked ciphertext would open every allowance state for that pair, past and future (`../docs/DESIGN_cont.md` §8.5). +`lane[2]` carries the blinding of a commitment the operation writes — `r'` on W_a5 / T_a9 / S_a6, `r_a'` on O_a9. Tag 17 (`ESCROWED_ALLOWANCE_BLINDING_AUDITOR`) escrows `r_a` off-sponge at S14; the construction is specified in `../docs/DESIGN_cont.md` §8.5. ECDH must absorb both `S.x` and `S.y`; x-only extraction collapses `P` and `-P`. @@ -60,7 +60,7 @@ LC_ALL=C nargo info | grep '^|' | LC_ALL=C sort > constraints.baseline `LC_ALL=C` is mandatory on **both** sides of the pipe — byte order is the only ordering stable between macOS and the Ubuntu runner. The redirect overwrites the file's header comments; re-paste them, because CI's failure message asks for them. -Two non-obvious consequences: adding or removing a **gadget** changes the baseline even when no circuit logic changed, and the ACIR opcode counts are quoted in prose at `../docs/DESIGN_cont.md` §10.3 (Register 33, Withdraw 95, RevokeSpender 123, Transfer 134, SetSpender 135, SpenderTransfer 136). Nothing enforces that second copy — update it in the same PR. +Two non-obvious consequences: adding or removing a **gadget** changes the baseline even when no circuit logic changed, and the ACIR opcode counts are quoted in prose at `../docs/DESIGN_cont.md` §10.3 (Register 33, Withdraw 95, Transfer 134, SetSpender 135, SpenderTransfer 136). Nothing enforces that second copy — update it in the same PR. ### `vks/` @@ -88,4 +88,4 @@ Note the standing obligation on the future TS SDK (`../docs/SDK.md` §6.1): its ## Version bumps -`.github/workflows/noir.yml` is the source of truth for `NARGO_VERSION` and `BB_VERSION`, and the two must be bumped together — the VK pipeline is byte-sensitive to bb. The string `1.0.0-beta.11` is duplicated across all fourteen package `Nargo.toml` files, `constraints.baseline`, `vks/README.md` (which also restates the bb version), and this file. A bump touches all of them and requires regenerating both the baseline and the VKs. The installer scripts in CI are pinned to git commits with SHA256 verification; bump URL and hash together. +`.github/workflows/noir.yml` is the source of truth for `NARGO_VERSION` and `BB_VERSION`, and the two must be bumped together — the VK pipeline is byte-sensitive to bb. The string `1.0.0-beta.11` is duplicated across all thirteen package `Nargo.toml` files, `constraints.baseline`, `vks/README.md` (which also restates the bb version), and this file. A bump touches all of them and requires regenerating both the baseline and the VKs. The installer scripts in CI are pinned to git commits with SHA256 verification; bump URL and hash together. diff --git a/packages/tokens/src/confidential/circuits/Nargo.toml b/packages/tokens/src/confidential/circuits/Nargo.toml index 68ebb59b3..e9ff6b457 100644 --- a/packages/tokens/src/confidential/circuits/Nargo.toml +++ b/packages/tokens/src/confidential/circuits/Nargo.toml @@ -6,7 +6,6 @@ members = [ "transfer", "set_spender", "spender_transfer", - "revoke_spender", "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 0c7af34d5..f58e9e6f2 100644 --- a/packages/tokens/src/confidential/circuits/constraints.baseline +++ b/packages/tokens/src/confidential/circuits/constraints.baseline @@ -22,10 +22,6 @@ | 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_revoke_spender | decompose_hint | N/A | N/A | 30 | -| circuit_revoke_spender | directive_invert | N/A | N/A | 9 | -| circuit_revoke_spender | lte_hint | N/A | N/A | 33 | -| circuit_revoke_spender | main | Bounded { width: 4 } | 123 | 72 | | circuit_set_spender | decompose_hint | N/A | N/A | 30 | | circuit_set_spender | directive_invert | N/A | N/A | 9 | | circuit_set_spender | 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 2b9240534..342b12791 100644 --- a/packages/tokens/src/confidential/circuits/lib/src/lib.nr +++ b/packages/tokens/src/confidential/circuits/lib/src/lib.nr @@ -1,8 +1,8 @@ //! # Confidential Token -- Noir Primitives //! -//! Shared cryptographic primitives consumed by the six circuits described in -//! Section 7 of the design document (Register, Withdraw, Transfer, SpenderTransfer, -//! SetSpender, RevokeSpender). +//! Shared cryptographic primitives consumed by the five circuits described in +//! Section 7 of the design document (Register, Withdraw, Transfer, +//! SpenderTransfer, SetSpender). //! //! Every constraint that involves a Pedersen commitment, a Poseidon2 hash with //! domain separation, an ECDH derivation, or a 128-bit range check funnels @@ -92,8 +92,7 @@ global POSEIDON2_IV_BASE: Field = 18446744073709551616; // 2^64 /// | `delta_ecdh` | 13 | `ECDH_SHARED_SECRET` | /// | `delta_esc_allow_r_aud` | 17 | `ESCROWED_ALLOWANCE_BLINDING_AUDITOR` | /// -/// Tags 14-16 are deliberately absent here: 14 is derived off-circuit and -/// 15-16 belong to the off-chain disclosure layer. +/// Tags 14-16 are not implemented here (DESIGN_cont Section 13). pub mod domain { /// Soroban Address compression into a single `F_r` Field: /// `address_to_field(a) = Poseidon2(ADDRESS, lo(a), hi(a))`. @@ -107,7 +106,7 @@ pub mod domain { /// `dvk = Poseidon2(DELEGATION_VIEWING_KEY, vk, op_i)`. Section 4.4 (`delta_dvk`). 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`). + /// `r' = Poseidon2(SPEND_RANDOMNESS, vk, sigma)`. Constraints W5/T10/S9 (`delta_spend_r`). pub global SPEND_RANDOMNESS: Field = 4; /// ECDH-derived blinding for the transfer commitment: /// `r_transfer = Poseidon2(TRANSFER_BLINDING, s, sigma)`. Constraints T7/O7 (`delta_transfer_blind`). @@ -117,7 +116,7 @@ pub mod domain { pub global TRANSFER_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`). + /// Constraints W7/T12/S11 (`delta_enc_bal`). pub global ENCRYPTED_BALANCE: Field = 7; /// Mask for the encrypted allowance scalar: /// `a_tilde = v_a + Poseidon2(ENCRYPTED_ALLOWANCE, dvk, sigma_a)`. @@ -138,15 +137,6 @@ pub mod domain { /// escrow slot -- the new spendable blinding on checkpoints, the new /// allowance blinding on spender transfers. Constraints W_a3 / T_a6 / /// S_a3 / O_a6 (`delta_aud_s`). - /// - /// RevokeSpender (V_a3) still reads two lanes, so the opening the auditor - /// holds is not renewed there and lapses until the next checkpoint; the - /// two widths agree on every lane they share (see [`sponge_squeeze_3`]). - /// - /// The tag stays in a single sponge mode per Section 2.5: - /// `sponge_squeeze_2(d, s, sigma)[0]` is the same field element as - /// `poseidon_with_domain(d, [s, sigma])`, so a tag used in both modes - /// would collapse one onto the other. 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 @@ -160,20 +150,6 @@ pub mod domain { /// Auditor-side allowance-blinding escrow mask (owner-auditor ECDH): /// `Poseidon2(ESCROWED_ALLOWANCE_BLINDING_AUDITOR, s_a_s, op_i)`. /// Constraint S14, Section 8.5 (`delta_esc_allow_r_aud`). - /// - /// A separate tag from `ESCROWED_DELEGATION_VIEWING_KEY` = 10 even though - /// reuse would be secure -- the two shared scalars differ, since `Y_op` - /// and `K_aud_s` are independent points. Sharing the tag would drop one - /// of the three legs of Section 5.3's "why reusing r_e is safe" argument; - /// a fresh tag keeps that argument intact. - /// - /// Unlike the tags above it, this one shares its shared scalar with - /// `AUDITOR_SENDER` (both key off `s_a_s`). What separates them is that - /// they are different tags: 17 is absorbed here, 11 there, and the tag is - /// the first absorbed element, so no `(s_a_s, x)` pair yields the same - /// field element under both. Section 2.5 mode exclusivity is a separate, - /// per-tag hygiene rule that each of the two satisfies on its own -- this - /// one is only ever single-output, tag 11 only ever multi-lane. pub global ESCROWED_ALLOWANCE_BLINDING_AUDITOR: Field = 17; } @@ -275,7 +251,7 @@ pub fn poseidon_with_domain(d: Field, inputs: [Field; N]) -> Field { /// Viewing key. Section 4.2. `vk = Poseidon2(VK, sk, addr_f)`. /// /// The `addr_f` parameter (the contract's address as a `Field`) binds the -/// viewing key -- and therefore every proof that constrains `vk` (R2/W2/T2/S2/V2) +/// viewing key -- and therefore every proof that constrains `vk` (R2/W2/T2/S2) /// -- to a specific deployed contract. Dropping `addr_f` here would let a proof for /// one contract verify against another's verifier. pub fn vk_from_sk(sk: Field, addr_f: Field) -> Field { @@ -294,7 +270,7 @@ pub fn dvk_from_vk_op(vk: Field, op_i: Field) -> Field { } /// Deterministic randomness for the new spendable balance after an -/// owner-initiated proof operation. Constraints W5 / T10 / S9 / V6. +/// owner-initiated proof operation. Constraints W5 / T10 / S9. pub fn derive_spend_r(vk: Field, sigma: Field) -> Field { poseidon_with_domain(domain::SPEND_RANDOMNESS, [vk, sigma]) } @@ -321,7 +297,7 @@ pub fn encrypt_amount(v_transfer: Field, s: Field, sigma: Field) -> Field { } /// Encrypts the new spendable balance under the owner's viewing key. -/// Constraints W7 / T12 / S11 / V8. `b_tilde = v_new + Poseidon2(ENC_BAL, vk, sigma)`. +/// Constraints W7 / T12 / S11. `b_tilde = v_new + Poseidon2(ENC_BAL, vk, sigma)`. pub fn encrypt_balance(v_new: Field, vk: Field, sigma: Field) -> Field { v_new + poseidon_with_domain(domain::ENCRYPTED_BALANCE, [vk, sigma]) } @@ -341,22 +317,7 @@ pub fn encrypt_esc_dvk(dvk: Field, s: Field, op_i: Field) -> Field { /// Auditor-side allowance-blinding escrow at `set_spender`. Constraint S14. /// `r_a_tilde_aud_s = r_a + Poseidon2(ESC_ALLOW_R_AUD, s_a_s, op_i)` where /// `s_a_s = ecdh(r_e, K_aud_s)` is the *owner-auditor* shared scalar. -/// -/// A single-output Poseidon rather than a further sponge lane, because at -/// `set_spender` lane[2] of the owner-auditor sponge is already taken by S_a6. -/// Costs one Poseidon2 permutation and zero scalar multiplications. `op_i` is -/// a per-`(owner, spender)` constant that separates pads across delegations, -/// not a nonce: the pad's freshness comes solely from `s_a_s`, which is fresh -/// per operation because `r_e` is, and `r_e` is fresh only because the salt is -/// (Section 5.3, Section 9.6). -/// -/// This is what gives the auditor the opening of `C_a`: `r_a` is the blinding -/// S6 derived and S7 committed to, so with the emitted `a_tilde_aud_s` the -/// auditor holds both halves of the allowance opening for the state this -/// operation writes (Section 8.5). The escrowed value is a per-state blinding, -/// not the key that generates every state: an auditor key that never saw this -/// event cannot derive it, and one that saw only this event learns nothing -/// about any other. +/// Section 8.5 specifies the construction. pub fn encrypt_esc_allow_r_auditor(r_a: Field, s_a_s: Field, op_i: Field) -> Field { r_a + poseidon_with_domain(domain::ESCROWED_ALLOWANCE_BLINDING_AUDITOR, [s_a_s, op_i]) } @@ -374,13 +335,10 @@ pub fn encrypt_esc_allow_r_auditor(r_a: Field, s_a_s: Field, op_i: Field) -> Fie /// cost is exactly one Poseidon2 permutation per call. /// /// Used by the recipient-auditor channel of Transfer (T_a2) and -/// SpenderTransfer (O_a2), and by RevokeSpender's sender-auditor channel -/// (V_a3), which escrows nothing in lane[2]. Every other sender-auditor -/// channel reads [`sponge_squeeze_3`]. +/// SpenderTransfer (O_a2). Every sender-auditor channel reads +/// [`sponge_squeeze_3`]. /// -/// Defined as the two-lane prefix of [`sponge_squeeze_3`] so the two cannot -/// drift apart: a change to the IV or the absorb applies to both by -/// construction rather than by test. +/// Defined as the two-lane prefix of [`sponge_squeeze_3`]. pub fn sponge_squeeze_2(d: Field, s: Field, sigma: Field) -> [Field; 2] { let state = sponge_squeeze_3(d, s, sigma); [state[0], state[1]] @@ -393,21 +351,15 @@ pub fn sponge_squeeze_2(d: Field, s: Field, sigma: Field) -> [Field; 2] { /// [`sponge_squeeze_2`], lane[0] is the amount mask and lane[1] the balance / /// allowance / randomness mask. **The lane[2] slot is the sender-auditor /// blinding escrow**: it carries the blinding of a commitment the operation -/// writes, and never a key -- the new spendable blinding on the three -/// checkpoint operations (W_a5 / T_a9 / S_a6), the new allowance blinding on -/// spender transfers (O_a9). +/// writes -- the new spendable blinding on the three checkpoint operations +/// (W_a5 / T_a9 / S_a6), the new allowance blinding on spender transfers +/// (O_a9). /// /// `sponge_squeeze_3(d, s, sigma)[0..2] == sponge_squeeze_2(d, s, sigma)`, by /// construction: the absorb fits one rate-3 block, so all three outputs are /// served from the same permutation and the two functions differ only in how -/// much of the state they expose. Widening therefore changes no existing mask -/// value and costs no extra constraints. The capacity lane at `state[3]` stays +/// much of the state they expose. The capacity lane at `state[3]` stays /// untouched. -/// -/// The blindings lane[2] carries still belong to different commitment roles -- -/// spendable on the checkpoints, allowance on spender transfers -- so the -/// uniformity is documentary, not a claim that a recipient can substitute one -/// for the other. Each is scoped to the operation that emitted it. pub fn sponge_squeeze_3(d: Field, s: Field, sigma: Field) -> [Field; 3] { let iv: Field = 3 * POSEIDON2_IV_BASE; let state = poseidon2_permutation([d, s, sigma, iv], 4); diff --git a/packages/tokens/src/confidential/circuits/lib/src/tests.nr b/packages/tokens/src/confidential/circuits/lib/src/tests.nr index 1be685b2c..b19f40491 100644 --- a/packages/tokens/src/confidential/circuits/lib/src/tests.nr +++ b/packages/tokens/src/confidential/circuits/lib/src/tests.nr @@ -115,7 +115,7 @@ fn on_curve_non_identity_rejects_off_curve() { #[test] fn vk_addr_f_binding() { // vk_from_sk(sk, w1) != vk_from_sk(sk, w2). This is the property that - // makes R2/W2/T2/S2/V2 contract-bound -- if it ever fails, a proof made + // makes R2/W2/T2/S2 contract-bound -- if it ever fails, a proof made // against one contract could verify against another. let sk: Field = 0xdeadbeef; let vk_a = vk_from_sk(sk, 0x111); 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 64b3f5809..fe46cea58 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 @@ -1,6 +1,6 @@ { "primitive": "derive_spend_r", - "design_doc_refs": ["Constraint W5", "Constraint T10", "Constraint S9", "Constraint V6"], + "design_doc_refs": ["Constraint W5", "Constraint T10", "Constraint S9"], "description": "Deterministic randomness for the new spendable balance: r' = Poseidon2(SPEND_R, vk, sigma).", "vectors": [ { 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 ec4cf51f4..419f4c6df 100644 --- a/packages/tokens/src/confidential/circuits/lib/testdata/encrypt_balance.json +++ b/packages/tokens/src/confidential/circuits/lib/testdata/encrypt_balance.json @@ -1,6 +1,6 @@ { "primitive": "encrypt_balance", - "design_doc_refs": ["Constraint W7", "Constraint T12", "Constraint S11", "Constraint V8"], + "design_doc_refs": ["Constraint W7", "Constraint T12", "Constraint S11"], "description": "Encrypted balance scalar: b_tilde = v_new + Poseidon2(ENC_BAL, vk, sigma).", "vectors": [ { 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 981da5887..0539c3e20 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,7 +1,7 @@ { "primitive": "sponge_squeeze_2", - "design_doc_refs": ["Section 2.5", "Constraint T_a2", "Constraint O_a2", "Constraint V_a3"], - "description": "Poseidon2 sponge with two squeezes (one absorb of (d, s, 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 recipient-auditor channel uses d = AUDITOR_RECIPIENT (12) and is always read two-wide; the sender-auditor channel d = AUDITOR_SENDER (11) is read two-wide only by RevokeSpender and three-wide elsewhere (see sponge_squeeze_3), of which these two lanes are the prefix. The first squeeze equals poseidon_with_domain(d, [s, sigma]) on the same inputs (single permutation, same state[0]).", + "design_doc_refs": ["Section 2.5", "Constraint T_a2", "Constraint O_a2"], + "description": "Poseidon2 sponge with two squeezes (one absorb of (d, s, 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 recipient-auditor channel uses d = AUDITOR_RECIPIENT (12) and is always read two-wide; the sender-auditor channel d = AUDITOR_SENDER (11) is read three-wide (see sponge_squeeze_3), of which these two lanes are the prefix. The first squeeze equals poseidon_with_domain(d, [s, sigma]) on the same inputs (single permutation, same state[0]).", "vectors": [ { "inputs": { "d": "0x0b", "s": "0x12345", "sigma": "0x01" }, 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 93ba86d3f..19dbc776e 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 @@ -1,7 +1,7 @@ { "primitive": "vk_from_sk", "design_doc_refs": ["Section 4.2"], - "description": "Viewing key derivation: vk = Poseidon2(VK, sk, wrap). The wrap binding makes proofs wrapper-context-bound (R2/W2/T2/S2/V2).", + "description": "Viewing key derivation: vk = Poseidon2(VK, sk, wrap). The wrap binding makes proofs wrapper-context-bound (R2/W2/T2/S2).", "vectors": [ { "inputs": { "sk": "0xdead", "wrap": "0xbeef" }, diff --git a/packages/tokens/src/confidential/circuits/revoke_spender/Nargo.toml b/packages/tokens/src/confidential/circuits/revoke_spender/Nargo.toml deleted file mode 100644 index 8aff11097..000000000 --- a/packages/tokens/src/confidential/circuits/revoke_spender/Nargo.toml +++ /dev/null @@ -1,10 +0,0 @@ -[package] -name = "circuit_revoke_spender" -type = "bin" -authors = ["OpenZeppelin"] -# Nargo requirements cannot name prereleases; the supported toolchain is -# nargo 1.0.0-beta.11 + bb 0.87.0, pinned in .github/workflows/noir.yml. -compiler_version = "=1.0.0" - -[dependencies] -stellar_confidential_lib = { path = "../lib" } diff --git a/packages/tokens/src/confidential/circuits/revoke_spender/src/main.nr b/packages/tokens/src/confidential/circuits/revoke_spender/src/main.nr deleted file mode 100644 index b3308bd1b..000000000 --- a/packages/tokens/src/confidential/circuits/revoke_spender/src/main.nr +++ /dev/null @@ -1,222 +0,0 @@ -use stellar_confidential_lib::{ - H, assert_on_curve_non_identity, commit, derive_allow_r, derive_spend_r, domain, - dvk_from_vk_op, ecdh, encrypt_balance, scalar_mul, sponge_squeeze_2, vk_from_sk, -}; -use std::embedded_curve_ops::EmbeddedCurvePoint; - -mod tests; - -// RevokeSpender circuit -- design doc Section 7.9. -// -// Constraints -// ----------- -// Main block (owner ownership + delegation opening + balance reclaim) -// V1 Y = sk * H Owner key ownership. -// V2 vk = Poseidon2(delta_vk, sk, addr_f) Contract-bound viewing -// key. -// V3 dvk_i = Poseidon2(delta_dvk, vk, op_i) Delegation viewing -// key for this spender. -// V4 C_a = v_a * G + r_a * H, with -// r_a = Poseidon2(delta_allow_r, dvk_i, sigma_a) -// Opening of the -// on-chain allowance -// commitment (mirrors -// O3 in Section 7.8). -// V5 C_spend = v_s * G + r_s * H Opening of current -// spendable balance. -// V9 v_s, v_a, v_s + v_a in [0, 2^127) Range validity -// (Section 2.6); the -// sum check is the -// soundness-critical -// line of defense -// against a addr_f that -// would let the owner -// mint via field -// overflow. -// V6 r' = Poseidon2(delta_spend_r, vk, sigma) Deterministic -// randomness for the -// reclaimed balance. -// V7 C_spend' = (v_s + v_a) * G + r' * H New spendable -// commitment with the -// allowance folded -// back in. -// V8 b_tilde = (v_s + v_a) -// + Poseidon2(delta_enc_bal, vk, sigma) -// Encrypted balance -// scalar (emitted). -// V10 r_e != 0 Rules out R_e = O -// and S_{a,s} = O, -// which would collapse -// m_v and m_b to -// constant functions -// of sigma. -// Auditor block (owner-auditor visibility, Section 8.1 / 8.5) -// V_a1 R_e = r_e * H Ephemeral key for -// auditor ECDH. -// V_a2 s_{a,s} = ecdh(r_e, K_{aud,s}) Owner-auditor ECDH -// shared scalar (2.4). -// V_a3 (m_v, m_b) -// = SpongeSqueeze_2(delta_aud_s, -// s_{a,s}, sigma) Owner-channel sponge, -// two squeezes. -// V_a4 v_tilde_aud_s = v_a + m_v Owner-auditor -// encrypted reclaimed -// amount (Section 8.5). -// V_a5 b_tilde_aud_s = (v_s + v_a) + m_b Owner-auditor -// encrypted post-reclaim -// balance checkpoint -// (Section 8.2). -// -// Point-validation doctrine (Section 10.8) -// ---------------------------------------- -// Y, C_spend, C_a, C_spend', and R_e are each bound to in-circuit -// multi_scalar_mul outputs (V1, V5, V4, V7, V_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 V_a2. -// This file explicitly validates K_aud_s on-curve AND non-identity before -// V_a2 (mirrors withdraw). -// -// Public inputs (19 fields, in design-doc canonical order, Section 7.9) -// --------------------------------------------------------------------- -// Idx Param Symbol Source / Note -// --- ----- ------ ------------------------------------- -// 0 c_spend_x C_spend.x Loaded from owner's -// 1 c_spend_y C_spend.y `spendable_commitment`. -// 2 c_a_x C_a.x Loaded from the (account, spender) -// 3 c_a_y C_a.y delegation entry. -// 4 sigma_a sigma_a Loaded from the delegation entry -// (`allowance_salt`). -// 5 y_x Y.x Loaded from owner's `spending_public_key`. -// 6 y_y Y.y -// 7 op_i op_i `address_to_field(spender)`, -// computed per-call (Section 2.7). -// 8 addr_f addr_f `env.current_contract_address()`. -// 9 k_aud_s_x K_aud_s.x Fetched from the auditor contract -// 10 k_aud_s_y K_aud_s.y by owner's `auditor_id`. -// 11 c_spend_new_x C_spend'.x Prover-supplied; written to -// 12 c_spend_new_y C_spend'.y owner's `spendable_commitment` on -// success. -// 13 b_tilde b_tilde Prover-supplied encrypted balance -// scalar; emitted. -// 14 sigma sigma Prover-supplied random salt; -// emitted. -// 15 r_e_x R_e.x Prover-supplied ephemeral key for -// 16 r_e_y R_e.y auditor ECDH; emitted. -// 17 v_tilde_aud_s v_tilde_aud_s Prover-supplied owner-auditor -// encrypted reclaimed amount; -// emitted. -// 18 b_tilde_aud_s b_tilde_aud_s Prover-supplied owner-auditor -// encrypted balance checkpoint; -// emitted. -// -// Private witnesses -// ----------------- -// sk Spending secret scalar. -// v_a Plaintext escrowed-allowance value (loaded by the prover via dvk_i). -// r_a Plaintext blinding factor for C_a (bound to V4 via derive_allow_r). -// v_s Plaintext spendable-balance value. -// r_s Plaintext blinding factor for C_spend (see Section 10.4 on the -// acknowledged 2^-127-per-merge case affecting r_s). -// r_e Ephemeral scalar for the auditor ECDH; must satisfy r_e != 0 (V10). - -fn main( - sk: Field, - v_a: Field, - r_a: Field, - v_s: Field, - r_s: Field, - r_e: Field, - c_spend_x: pub Field, - c_spend_y: pub Field, - c_a_x: pub Field, - c_a_y: pub Field, - sigma_a: pub Field, - y_x: pub Field, - y_y: pub Field, - op_i: pub Field, - addr_f: 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, - b_tilde: pub Field, - sigma: pub Field, - r_e_x: pub Field, - r_e_y: pub Field, - v_tilde_aud_s: pub Field, - b_tilde_aud_s: pub Field, -) { - // V10 -- 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); - - // V1 - let y_derived = scalar_mul(sk, H); - assert(y_derived.x == y_x); - assert(y_derived.y == y_y); - - // V2 - let vk = vk_from_sk(sk, addr_f); - - // V3 - let dvk_i = dvk_from_vk_op(vk, op_i); - - // V4 -- mirrors O3 (Section 7.8): r_a is bound to (dvk_i, sigma_a), so - // the only valid v_a is the one whose commitment matches the on-chain - // C_a under that derived r_a. - let r_a_derived = derive_allow_r(dvk_i, sigma_a); - assert(r_a_derived == r_a); - 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); - - // V5 - let c_spend_derived = commit(v_s, r_s); - assert(c_spend_derived.x == c_spend_x); - assert(c_spend_derived.y == c_spend_y); - - // V9 -- Section 2.6 spells out the 127-bit decomposition / recomposition - // pattern; `assert_max_bit_size` is the Noir stdlib primitive that - // implements it directly. The sum check `v_s + v_a < 2^127` is the - // soundness-critical step: without it, a prover could choose v_s and v_a - // each individually in range but whose sum wraps mod F_r, effectively - // minting tokens out of nothing on the reclaim path. - v_s.assert_max_bit_size::<127>(); - v_a.assert_max_bit_size::<127>(); - let v_new = v_s + v_a; - v_new.assert_max_bit_size::<127>(); - - // V6 - let r_new = derive_spend_r(vk, sigma); - - // V7 - 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); - - // V8 - let b_tilde_derived = encrypt_balance(v_new, vk, sigma); - assert(b_tilde_derived == b_tilde); - - // V_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); - - // V_a2 (shared-secret scalar) - let s_a_s = ecdh(r_e, k_aud_s); - - // V_a3 (owner-channel masks: reclaimed amount, then balance checkpoint) - let m = sponge_squeeze_2(domain::AUDITOR_SENDER, s_a_s, sigma); - - // V_a4 - assert(v_a + m[0] == v_tilde_aud_s); - - // V_a5 - assert(v_new + m[1] == b_tilde_aud_s); -} diff --git a/packages/tokens/src/confidential/circuits/revoke_spender/src/tests.nr b/packages/tokens/src/confidential/circuits/revoke_spender/src/tests.nr deleted file mode 100644 index 233bc743a..000000000 --- a/packages/tokens/src/confidential/circuits/revoke_spender/src/tests.nr +++ /dev/null @@ -1,912 +0,0 @@ -use crate::main; -use stellar_confidential_lib::{ - commit, derive_allow_r, derive_spend_r, domain, dvk_from_vk_op, ecdh, encrypt_balance, H, - scalar_mul, sponge_squeeze_2, vk_from_sk, -}; -use std::embedded_curve_ops::EmbeddedCurvePoint; - -// Canonical fixture inputs. -// -// SK / ADDR_F / SIGMA / SIGMA_A / OP_I / V_S / R_S / V_A / R_E reuse the lib's -// pinned vectors (lib/testdata) so that Y, C_spend, C_a, vk, dvk_i, r_a, r_new, -// and b_tilde are derivable from -- and pinned by -- the lib's -// `fixtures_match_testdata` test. V_S + V_A = 1500 is well below 2^127 so the -// happy path exercises a non-trivial sum while still satisfying V9. R_E and -// K_AUD_S are local to this circuit (no auditor-side primitives in lib's -// fixtures). -global SK: Field = 0xdead; -global ADDR_F: Field = 0xbeef; -global SIGMA: Field = 0x01; -global SIGMA_A: Field = 0x02; -global OP_I: Field = 0xabcd; -global V_S: Field = 1000; -global R_S: Field = 42; -global V_A: Field = 500; -global V_NEW: Field = 1500; -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; -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; - -// r_a = derive_allow_r(dvk_i, sigma_a) (= lib fixture). -global R_A: Field = 0x0afda20e062a277afc0c86e31ea22597ee2c094b1b9cd08b8d8a2a212a8af1ee; - -// C_a = commit(V_A, R_A). Pinned by `revoke_spender_fixtures_match_lib`. -global C_A_X: Field = - 0x008a13da4a758b5119439c631f9ec3d32936432549b48ec3ccfb9febe3e33cd1; -global C_A_Y: Field = - 0x233c5490e39ce0276b467a503d4216304c901a08ff6af827d12b28d3376f8e06; - -// C_spend' and b_tilde are pinned by `revoke_spender_fixtures_match_lib`, -// 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 = - 0x1a591f0cb6df2ec210dd1319f9b6af34bdee16c4325cf74bd29a14d4d45acb28; -global C_SPEND_NEW_Y: Field = - 0x2d3be2c130a15ece090a6c7ee42fa54880cec3861b9ade4691a23e37d1687d5c; -global B_TILDE: Field = - 0x04d1659db899a50a94dcfc54a18b8adaf6e9e8e3046bd11893fbd4a86a7c5990; - -// Auditor-block fixtures. Pinned by `revoke_spender_auditor_fixtures_match_lib`. -// R_E_X / R_E_Y pin the scalar-mul output R_e = r_e * H for the same r_e as -// the lib's `ecdh` fixture (`ecdh(r_e, H)` hashes this point's coordinates). -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 V_TILDE_AUD_S: Field = - 0x0d0a3aa77960977737f4355093d4672ea9f95556fabf63bda0913ebef52c445f; -global B_TILDE_AUD_S: Field = - 0x288ff48898f218dbba2a5c9546d0986af3257a4a539e23e4f35c404d6720c39c; - -#[test] -fn print_fixtures() { - // One-shot harness: prints C_a, C_spend', b_tilde, K_aud_s, R_e, - // v_tilde_aud_s, b_tilde_aud_s for the chosen fixture tuple. Run with - // `nargo test --package circuit_revoke_spender print_fixtures --show-output` - // and paste the values into the globals above. - let vk = vk_from_sk(SK, ADDR_F); - let dvk_i = dvk_from_vk_op(vk, OP_I); - let r_a = derive_allow_r(dvk_i, SIGMA_A); - let c_a = commit(V_A, r_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 k_aud_s = scalar_mul(K_AUD_S_SCALAR, H); - let r_e_pt = scalar_mul(R_E, H); - let s_a_s = ecdh(R_E, k_aud_s); - let m = sponge_squeeze_2(domain::AUDITOR_SENDER, s_a_s, SIGMA); - let v_tilde_aud_s = V_A + m[0]; - let b_tilde_aud_s = V_NEW + m[1]; - let ax = c_a.x; - let ay = c_a.y; - 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"R_A = {r_a}"); - println(f"C_A_X = {ax}"); - println(f"C_A_Y = {ay}"); - 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"V_TILDE_AUD_S = {v_tilde_aud_s}"); - println(f"B_TILDE_AUD_S = {b_tilde_aud_s}"); -} - -#[test] -fn revoke_spender_fixtures_match_lib() { - // Pins the main-block fixtures (C_a, C_spend', b_tilde, Y, C_spend, R_A) - // to the lib primitives. If lib derivations or fixture values drift, this - // test catches it before `matches_fixture` reports a less informative - // failure deep in `main`. - let vk = vk_from_sk(SK, ADDR_F); - let dvk_i = dvk_from_vk_op(vk, OP_I); - let r_a = derive_allow_r(dvk_i, SIGMA_A); - let c_a = commit(V_A, r_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_S, R_S); - - assert(r_a == R_A); - assert(c_a.x == C_A_X); - assert(c_a.y == C_A_Y); - 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 revoke_spender_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 = ecdh(R_E, k_aud_s); - let m = sponge_squeeze_2(domain::AUDITOR_SENDER, s_a_s, SIGMA); - let v_tilde_aud_s = V_A + m[0]; - let b_tilde_aud_s = V_NEW + m[1]; - 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(v_tilde_aud_s == V_TILDE_AUD_S); - assert(b_tilde_aud_s == B_TILDE_AUD_S); -} - -#[test] -fn matches_fixture() { - main( - SK, - V_A, - R_A, - V_S, - R_S, - R_E, - C_SPEND_X, - C_SPEND_Y, - C_A_X, - C_A_Y, - SIGMA_A, - Y_X, - Y_Y, - OP_I, - ADDR_F, - K_AUD_S_X, - K_AUD_S_Y, - C_SPEND_NEW_X, - C_SPEND_NEW_Y, - B_TILDE, - SIGMA, - R_E_X, - R_E_Y, - V_TILDE_AUD_S, - B_TILDE_AUD_S, - ); -} - -#[test] -fn alternate_opening() { - // Second happy path with a different (V_S, R_S, V_A, SIGMA_A) tuple to - // exercise the full constraint set against an independent witness. All - // intermediate values are re-derived from lib primitives, so this test - // is also a regression guard for any lib drift specific to the - // alternate-opening path. - let v_s_alt: Field = 7777; - let r_s_alt: Field = 0xabc123; - let v_a_alt: Field = 12345; - let v_new_alt: Field = v_s_alt + v_a_alt; - let sigma_a_alt: Field = 0x09; - - let c_spend_alt = commit(v_s_alt, r_s_alt); - let vk = vk_from_sk(SK, ADDR_F); - let dvk_i = dvk_from_vk_op(vk, OP_I); - let r_a_alt = derive_allow_r(dvk_i, sigma_a_alt); - let c_a_alt = commit(v_a_alt, r_a_alt); - let r_new = derive_spend_r(vk, SIGMA); - let c_new = commit(v_new_alt, r_new); - let b_tilde = encrypt_balance(v_new_alt, vk, SIGMA); - let k_aud_s = EmbeddedCurvePoint { x: K_AUD_S_X, y: K_AUD_S_Y, is_infinite: false }; - let s_a_s = ecdh(R_E, k_aud_s); - let m = sponge_squeeze_2(domain::AUDITOR_SENDER, s_a_s, SIGMA); - let v_tilde_aud_s = v_a_alt + m[0]; - let b_tilde_aud_s = v_new_alt + m[1]; - - main( - SK, - v_a_alt, - r_a_alt, - v_s_alt, - r_s_alt, - R_E, - c_spend_alt.x, - c_spend_alt.y, - c_a_alt.x, - c_a_alt.y, - sigma_a_alt, - Y_X, - Y_Y, - OP_I, - ADDR_F, - K_AUD_S_X, - K_AUD_S_Y, - c_new.x, - c_new.y, - b_tilde, - SIGMA, - R_E_X, - R_E_Y, - v_tilde_aud_s, - b_tilde_aud_s, - ); -} - -#[test(should_fail)] -fn rejects_wrong_sk() { - // V1 fires: scalar_mul(sk+1, H) != Y. - main( - SK + 1, - V_A, - R_A, - V_S, - R_S, - R_E, - C_SPEND_X, - C_SPEND_Y, - C_A_X, - C_A_Y, - SIGMA_A, - Y_X, - Y_Y, - OP_I, - ADDR_F, - K_AUD_S_X, - K_AUD_S_Y, - C_SPEND_NEW_X, - C_SPEND_NEW_Y, - B_TILDE, - SIGMA, - R_E_X, - R_E_Y, - V_TILDE_AUD_S, - B_TILDE_AUD_S, - ); -} - -#[test(should_fail)] -fn rejects_wrong_addr_f() { - // V2 binds vk to addr_f. A bogus addr_f derives a different vk, which - // propagates to V6 (r'), V7 (C_spend'), and V8 (b_tilde). The earliest - // assertion that observes the mismatch (C_spend' mismatch) fires. - main( - SK, - V_A, - R_A, - V_S, - R_S, - R_E, - C_SPEND_X, - C_SPEND_Y, - C_A_X, - C_A_Y, - SIGMA_A, - Y_X, - Y_Y, - OP_I, - 0xcafe, - K_AUD_S_X, - K_AUD_S_Y, - C_SPEND_NEW_X, - C_SPEND_NEW_Y, - B_TILDE, - SIGMA, - R_E_X, - R_E_Y, - V_TILDE_AUD_S, - B_TILDE_AUD_S, - ); -} - -#[test(should_fail)] -fn rejects_wrong_dvk_op_i() { - // V3 binds dvk_i to op_i. A bogus op_i derives a different dvk_i, which - // makes r_a_derived != R_A in V4 and the assertion fires. - main( - SK, - V_A, - R_A, - V_S, - R_S, - R_E, - C_SPEND_X, - C_SPEND_Y, - C_A_X, - C_A_Y, - SIGMA_A, - Y_X, - Y_Y, - OP_I + 1, - ADDR_F, - K_AUD_S_X, - K_AUD_S_Y, - C_SPEND_NEW_X, - C_SPEND_NEW_Y, - B_TILDE, - SIGMA, - R_E_X, - R_E_Y, - V_TILDE_AUD_S, - B_TILDE_AUD_S, - ); -} - -#[test(should_fail)] -fn rejects_bad_allowance_opening_value() { - // V4 fires: v_a + 1 is not a valid opening of the on-chain C_a under the - // bound r_a (which is itself bound by derive_allow_r to dvk_i, sigma_a). - main( - SK, - V_A + 1, - R_A, - V_S, - R_S, - R_E, - C_SPEND_X, - C_SPEND_Y, - C_A_X, - C_A_Y, - SIGMA_A, - Y_X, - Y_Y, - OP_I, - ADDR_F, - K_AUD_S_X, - K_AUD_S_Y, - C_SPEND_NEW_X, - C_SPEND_NEW_Y, - B_TILDE, - SIGMA, - R_E_X, - R_E_Y, - V_TILDE_AUD_S, - B_TILDE_AUD_S, - ); -} - -#[test(should_fail)] -fn rejects_bad_allowance_opening_randomness() { - // V4 fires first via r_a_derived != r_a: the prover-supplied r_a no - // longer matches Poseidon2(delta_allow_r, dvk_i, sigma_a). - main( - SK, - V_A, - R_A + 1, - V_S, - R_S, - R_E, - C_SPEND_X, - C_SPEND_Y, - C_A_X, - C_A_Y, - SIGMA_A, - Y_X, - Y_Y, - OP_I, - ADDR_F, - K_AUD_S_X, - K_AUD_S_Y, - C_SPEND_NEW_X, - C_SPEND_NEW_Y, - B_TILDE, - SIGMA, - R_E_X, - R_E_Y, - V_TILDE_AUD_S, - B_TILDE_AUD_S, - ); -} - -#[test(should_fail)] -fn rejects_wrong_sigma_a() { - // V4 fires: a different sigma_a re-derives r_a_derived which no longer - // matches the supplied R_A. - main( - SK, - V_A, - R_A, - V_S, - R_S, - R_E, - C_SPEND_X, - C_SPEND_Y, - C_A_X, - C_A_Y, - SIGMA_A + 1, - Y_X, - Y_Y, - OP_I, - ADDR_F, - K_AUD_S_X, - K_AUD_S_Y, - C_SPEND_NEW_X, - C_SPEND_NEW_Y, - B_TILDE, - SIGMA, - R_E_X, - R_E_Y, - V_TILDE_AUD_S, - B_TILDE_AUD_S, - ); -} - -#[test(should_fail)] -fn rejects_bad_spend_opening() { - // V5 fires: V_S + 1 with the original R_S no longer recommits to - // (C_SPEND_X, C_SPEND_Y). - main( - SK, - V_A, - R_A, - V_S + 1, - R_S, - R_E, - C_SPEND_X, - C_SPEND_Y, - C_A_X, - C_A_Y, - SIGMA_A, - Y_X, - Y_Y, - OP_I, - ADDR_F, - K_AUD_S_X, - K_AUD_S_Y, - C_SPEND_NEW_X, - C_SPEND_NEW_Y, - B_TILDE, - SIGMA, - R_E_X, - R_E_Y, - V_TILDE_AUD_S, - B_TILDE_AUD_S, - ); -} - -#[test(should_fail)] -fn rejects_v_s_out_of_range() { - // V9 fires: 2^127 is the smallest value outside [0, 2^127) (SEP-41 i128 - // non-negative range, Section 2.6 / 3.4). C_spend must be recomputed - // against v_s_huge so V5 passes and V9's first range check is the - // load-bearing assertion. - let v_s_huge: Field = 0x80000000000000000000000000000000; - let c_spend = commit(v_s_huge, R_S); - main( - SK, - V_A, - R_A, - v_s_huge, - R_S, - R_E, - c_spend.x, - c_spend.y, - C_A_X, - C_A_Y, - SIGMA_A, - Y_X, - Y_Y, - OP_I, - ADDR_F, - K_AUD_S_X, - K_AUD_S_Y, - C_SPEND_NEW_X, - C_SPEND_NEW_Y, - B_TILDE, - SIGMA, - R_E_X, - R_E_Y, - V_TILDE_AUD_S, - B_TILDE_AUD_S, - ); -} - -#[test(should_fail)] -fn rejects_v_a_out_of_range() { - // V9 fires on the v_a range check. C_a is recomputed against v_a_huge so - // V4 passes (r_a still binds via derive_allow_r) and V9 is the - // load-bearing assertion. - let v_a_huge: Field = 0x80000000000000000000000000000000; - let c_a = commit(v_a_huge, R_A); - main( - SK, - v_a_huge, - R_A, - V_S, - R_S, - R_E, - C_SPEND_X, - C_SPEND_Y, - c_a.x, - c_a.y, - SIGMA_A, - Y_X, - Y_Y, - OP_I, - ADDR_F, - K_AUD_S_X, - K_AUD_S_Y, - C_SPEND_NEW_X, - C_SPEND_NEW_Y, - B_TILDE, - SIGMA, - R_E_X, - R_E_Y, - V_TILDE_AUD_S, - B_TILDE_AUD_S, - ); -} - -#[test(should_fail)] -fn rejects_v_sum_overflow() { - // SOUNDNESS-CRITICAL: the only line of defense against a addr_f that lets - // the owner mint tokens out of nothing on the reclaim path. - // - // Witnesses chosen so that the individual range checks on v_s and v_a - // each pass (both are < 2^127), but the sum v_s + v_a = 2^127 sits at - // the exact SEP-41 i128 boundary: - // - // v_s = 2^127 - 1 (largest value passing the 127-bit check) - // v_a = 1 - // v_s + v_a = 2^127 (smallest value failing the 127-bit check) - // - // All downstream public inputs (C_spend, C_spend', C_a, b_tilde, the - // auditor ciphertexts) are recomputed *consistently* with the prover's - // claimed (v_s, v_a, v_new = v_s + v_a) values, so V1-V8 and V_a1-V_a5 - // all succeed individually. The failure is unambiguously the - // `(v_s + v_a).assert_max_bit_size::<127>()` line in V9. If this guard - // ever regressed, the proof would verify and the owner would mint - // 2^127 - existing_spend tokens on revoke. Treat as load-bearing. - let v_s_huge: Field = 0x7fffffffffffffffffffffffffffffff; // 2^127 - 1 - let v_a_one: Field = 1; - let v_new_overflow: Field = v_s_huge + v_a_one; // 2^127 - let c_spend = commit(v_s_huge, R_S); - let c_a = commit(v_a_one, R_A); - let vk = vk_from_sk(SK, ADDR_F); - let r_new = derive_spend_r(vk, SIGMA); - let c_new = commit(v_new_overflow, r_new); - let b_tilde = encrypt_balance(v_new_overflow, vk, SIGMA); - let k_aud_s = EmbeddedCurvePoint { x: K_AUD_S_X, y: K_AUD_S_Y, is_infinite: false }; - let s_a_s = ecdh(R_E, k_aud_s); - let m = sponge_squeeze_2(domain::AUDITOR_SENDER, s_a_s, SIGMA); - let v_tilde_aud_s = v_a_one + m[0]; - let b_tilde_aud_s = v_new_overflow + m[1]; - main( - SK, - v_a_one, - R_A, - v_s_huge, - R_S, - R_E, - c_spend.x, - c_spend.y, - c_a.x, - c_a.y, - SIGMA_A, - Y_X, - Y_Y, - OP_I, - ADDR_F, - K_AUD_S_X, - K_AUD_S_Y, - c_new.x, - c_new.y, - b_tilde, - SIGMA, - R_E_X, - R_E_Y, - v_tilde_aud_s, - b_tilde_aud_s, - ); -} - -#[test(should_fail)] -fn rejects_r_e_zero() { - // V10 fires: r_e = 0 would force R_e = O (identity) and collapse - // S_{a,s} to O, making (m_v, m_b) knowable constant functions of sigma. - main( - SK, - V_A, - R_A, - V_S, - R_S, - 0, - C_SPEND_X, - C_SPEND_Y, - C_A_X, - C_A_Y, - SIGMA_A, - Y_X, - Y_Y, - OP_I, - ADDR_F, - K_AUD_S_X, - K_AUD_S_Y, - C_SPEND_NEW_X, - C_SPEND_NEW_Y, - B_TILDE, - SIGMA, - R_E_X, - R_E_Y, - V_TILDE_AUD_S, - B_TILDE_AUD_S, - ); -} - -#[test(should_fail)] -fn rejects_wrong_r_e() { - // V_a1 fires: scalar_mul(r_e + 1, H) no longer equals the claimed - // (R_e_x, R_e_y). - main( - SK, - V_A, - R_A, - V_S, - R_S, - R_E + 1, - C_SPEND_X, - C_SPEND_Y, - C_A_X, - C_A_Y, - SIGMA_A, - Y_X, - Y_Y, - OP_I, - ADDR_F, - K_AUD_S_X, - K_AUD_S_Y, - C_SPEND_NEW_X, - C_SPEND_NEW_Y, - B_TILDE, - SIGMA, - R_E_X, - R_E_Y, - V_TILDE_AUD_S, - 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 V_a2 ever consumes K_aud_s. - main( - SK, - V_A, - R_A, - V_S, - R_S, - R_E, - C_SPEND_X, - C_SPEND_Y, - C_A_X, - C_A_Y, - SIGMA_A, - Y_X, - Y_Y, - OP_I, - ADDR_F, - 1, - 2, - C_SPEND_NEW_X, - C_SPEND_NEW_Y, - B_TILDE, - SIGMA, - 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 (S = r_e * O = O), so the on-curve - // check explicitly rejects the identity for keys. - main( - SK, - V_A, - R_A, - V_S, - R_S, - R_E, - C_SPEND_X, - C_SPEND_Y, - C_A_X, - C_A_Y, - SIGMA_A, - Y_X, - Y_Y, - OP_I, - ADDR_F, - 0, - 0, - C_SPEND_NEW_X, - C_SPEND_NEW_Y, - B_TILDE, - SIGMA, - R_E_X, - R_E_Y, - V_TILDE_AUD_S, - B_TILDE_AUD_S, - ); -} - -#[test(should_fail)] -fn rejects_tampered_b_tilde() { - // V8 fires: encrypt_balance(v_new, vk, sigma) no longer matches the - // tampered b_tilde. - main( - SK, - V_A, - R_A, - V_S, - R_S, - R_E, - C_SPEND_X, - C_SPEND_Y, - C_A_X, - C_A_Y, - SIGMA_A, - Y_X, - Y_Y, - OP_I, - ADDR_F, - K_AUD_S_X, - K_AUD_S_Y, - C_SPEND_NEW_X, - C_SPEND_NEW_Y, - B_TILDE + 1, - SIGMA, - R_E_X, - R_E_Y, - V_TILDE_AUD_S, - B_TILDE_AUD_S, - ); -} - -#[test(should_fail)] -fn rejects_tampered_c_spend_new() { - // V7 fires: (v_s + v_a) * G + r' * H no longer equals the tampered - // (C_spend'.x + 1, C_spend'.y). - main( - SK, - V_A, - R_A, - V_S, - R_S, - R_E, - C_SPEND_X, - C_SPEND_Y, - C_A_X, - C_A_Y, - SIGMA_A, - Y_X, - Y_Y, - OP_I, - ADDR_F, - K_AUD_S_X, - K_AUD_S_Y, - C_SPEND_NEW_X + 1, - C_SPEND_NEW_Y, - B_TILDE, - SIGMA, - R_E_X, - R_E_Y, - V_TILDE_AUD_S, - B_TILDE_AUD_S, - ); -} - -#[test(should_fail)] -fn rejects_tampered_v_tilde_aud_s() { - // V_a4 fires: v_a + m_v no longer equals the tampered v_tilde_aud_s. - // Closes the auditor-amount tampered-ciphertext criterion. - main( - SK, - V_A, - R_A, - V_S, - R_S, - R_E, - C_SPEND_X, - C_SPEND_Y, - C_A_X, - C_A_Y, - SIGMA_A, - Y_X, - Y_Y, - OP_I, - ADDR_F, - K_AUD_S_X, - K_AUD_S_Y, - C_SPEND_NEW_X, - C_SPEND_NEW_Y, - B_TILDE, - SIGMA, - 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() { - // V_a5 fires: (v_s + v_a) + m_b no longer equals the tampered - // b_tilde_aud_s. Closes the auditor-balance tampered-ciphertext - // criterion. - main( - SK, - V_A, - R_A, - V_S, - R_S, - R_E, - C_SPEND_X, - C_SPEND_Y, - C_A_X, - C_A_Y, - SIGMA_A, - Y_X, - Y_Y, - OP_I, - ADDR_F, - K_AUD_S_X, - K_AUD_S_Y, - C_SPEND_NEW_X, - C_SPEND_NEW_Y, - B_TILDE, - SIGMA, - R_E_X, - R_E_Y, - V_TILDE_AUD_S, - 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_v, m_b) change, V_a4 fails first. - main( - SK, - V_A, - R_A, - V_S, - R_S, - R_E, - C_SPEND_X, - C_SPEND_Y, - C_A_X, - C_A_Y, - SIGMA_A, - Y_X, - Y_Y, - OP_I, - ADDR_F, - Y_X, - Y_Y, - C_SPEND_NEW_X, - C_SPEND_NEW_Y, - B_TILDE, - SIGMA, - R_E_X, - R_E_Y, - V_TILDE_AUD_S, - B_TILDE_AUD_S, - ); -} diff --git a/packages/tokens/src/confidential/circuits/scripts/extract_vks.sh b/packages/tokens/src/confidential/circuits/scripts/extract_vks.sh index 59c0aad43..325e0a3c1 100755 --- a/packages/tokens/src/confidential/circuits/scripts/extract_vks.sh +++ b/packages/tokens/src/confidential/circuits/scripts/extract_vks.sh @@ -27,7 +27,6 @@ CIRCUITS=( "transfer" "set_spender" "spender_transfer" - "revoke_spender" ) OUT_DIR="vks" diff --git a/packages/tokens/src/confidential/circuits/set_spender/src/main.nr b/packages/tokens/src/confidential/circuits/set_spender/src/main.nr index 8ec7148bb..ec74eea28 100644 --- a/packages/tokens/src/confidential/circuits/set_spender/src/main.nr +++ b/packages/tokens/src/confidential/circuits/set_spender/src/main.nr @@ -64,29 +64,7 @@ mod tests; // Auditor-side escrow // of the allowance // blinding S6 derived -// and S7 committed to. -// With v_tilde_aud_s -// (S_a4) this hands the -// owner's auditor the -// opening of the C_a -// this operation writes -// (Section 8.5) -- -// one state, not the -// key that generates -// every state. -// A separate single- -// output Poseidon -// rather than a sponge -// lane, because lane[2] -// of this channel is -// taken by S_a6. Costs -// one Poseidon and zero -// scalar muls; r_a is -// already a witness -// (S6). -// Unconditional -- there -// is no delegation that -// can exist without it. +// (Section 8.5). // S13 r_e != 0 Rules out R_e = O, // S_{a,s} = O, and a // trivial escrow @@ -177,14 +155,8 @@ mod tests; // escrow of the new spendable // blinding; emitted. // 25 r_a_tilde_aud_s r_a_tilde_aud_s Prover-supplied owner-auditor -// escrow of the new allowance -// blinding; emitted. Distinct -// from r_tilde_aud_s (index -// 24), which escrows the -// spendable blinding, and from -// escrowed_dvk_cipher, which -// hands the spender the -// delegation key. +// escrow of the allowance +// blinding; emitted. // // Private witnesses // ----------------- @@ -323,10 +295,7 @@ fn main( // S_a6 assert(r_new + m_s[2] == r_tilde_aud_s); - // S14 -- reuses s_a_s from S_a2, so no new scalar multiplication. Runs - // after the auditor block so the shared scalar is already in scope. The - // escrowed value is S6's r_a, the very blinding S7 committed under, so the - // auditor's opening matches the C_a this proof writes on-chain. + // S14 (reuses s_a_s from S_a2) let r_a_tilde_aud_s_derived = encrypt_esc_allow_r_auditor(r_a, s_a_s, op_i); assert(r_a_tilde_aud_s_derived == r_a_tilde_aud_s); } diff --git a/packages/tokens/src/confidential/circuits/spender_transfer/src/main.nr b/packages/tokens/src/confidential/circuits/spender_transfer/src/main.nr index a5cb8187a..d7b6c0953 100644 --- a/packages/tokens/src/confidential/circuits/spender_transfer/src/main.nr +++ b/packages/tokens/src/confidential/circuits/spender_transfer/src/main.nr @@ -104,38 +104,9 @@ mod tests; // post-transfer // allowance. // O_a9 r_tilde_aud_s = r_a' + m_r_s Owner-auditor -// escrow of the NEW -// allowance blinding -// -- O10's r_a', -// which O11 commits -// under. Already a -// witness, so this is -// one field addition. -// With a_tilde_aud_s -// (O_a8) it hands the -// owner's auditor the -// opening of the C_a' -// this operation -// writes. Pad and -// plaintext both key -// off sigma_a', under -// different domain -// tags and different -// secrets (s_{a,s} -// vs dvk_i). -// Buys one-operation -// convergence for a -// live delegation -// whose auditor key -// rotated after -// set_spender -- the -// same property every -// other auditor -// ciphertext has. It -// does nothing for a -// frozen account, -// which emits no -// ciphertext at all. +// escrow of the new +// allowance blinding, +// over O10's r_a'. // // Contract binding (Section 7.8 final paragraph) // --------------------------------------------- @@ -154,10 +125,8 @@ mod tests; // Every pad this circuit derives absorbs the prover-chosen replacement // `sigma_a'` instead: the recipient ECDH chain (O7 / O9), both auditor-channel // sponges (O_a2 / O_a6, hence O_a9's lane[2] pad), and the new-allowance -// derivations (O10 / O12). A reverted call leaves the delegation entry -// untouched, so `sigma_a` is forced on the retry; keying the pads to it would -// repeat r_e and every mask, and a retry that changed the amount would publish -// the difference in the clear. `sigma_a'` is constrained only against +// derivations (O10 / O12). Section 6.2 *Transfer nonce* states why the +// stored salt cannot key the pads. `sigma_a'` is constrained only against // prover-supplied state, so a retry re-samples it freely. O14 rejects the one // adjacent collision; global non-repetition of `sigma_a'` over the // delegation's lifetime is a client obligation (SDK.md Section 10.4). @@ -367,9 +336,6 @@ fn main( // O_a8 assert(v_a_new + m_s[1] == a_tilde_aud_s); - // O_a9 -- r_a_new is already constrained by O10 and committed by O11, so - // this is one field addition: zero scalar muls, no new domain tag. The - // escrowed blinding is the one the operation WRITES, not the one it - // consumes, so the auditor's opening matches the C_a' left on-chain. + // O_a9 assert(r_a_new + m_s[2] == r_tilde_aud_s); } diff --git a/packages/tokens/src/confidential/circuits/transfer/src/main.nr b/packages/tokens/src/confidential/circuits/transfer/src/main.nr index 2574c68f9..32687eb77 100644 --- a/packages/tokens/src/confidential/circuits/transfer/src/main.nr +++ b/packages/tokens/src/confidential/circuits/transfer/src/main.nr @@ -81,10 +81,6 @@ mod tests; // escrow of the new // spendable blinding, // over T10's r_A'. -// Lets the auditor -// track the full -// opening of C_spend -// forward. // // Point-validation doctrine (Section 10.8) // ---------------------------------------- @@ -260,7 +256,7 @@ fn main( // T_a5 (sender-auditor shared-secret scalar) let s_a_s = ecdh(r_e, k_aud_s); - // T_a6 (sender-channel masks: amount, balance, then the secret-escrow + // T_a6 (sender-channel masks: amount, balance, then the blinding-escrow // slot) let m_s = sponge_squeeze_3(domain::AUDITOR_SENDER, s_a_s, sigma); diff --git a/packages/tokens/src/confidential/circuits/vks/revoke_spender.vk.json b/packages/tokens/src/confidential/circuits/vks/revoke_spender.vk.json deleted file mode 100644 index 2ab0d054a..000000000 --- a/packages/tokens/src/confidential/circuits/vks/revoke_spender.vk.json +++ /dev/null @@ -1 +0,0 @@ -["0x0000000000000000000000000000000000000000000000000000000000008000","0x0000000000000000000000000000000000000000000000000000000000000023","0x0000000000000000000000000000000000000000000000000000000000000001","0x0000000000000000000000000000000000000000000000000000000000000013","0x0000000000000000000000000000002c014d302ecbfa62f8f75f125a20405f23","0x000000000000000000000000000000000012a92886ebf809838d43ce63a1872d","0x0000000000000000000000000000001e923f4764bdb98010c0d42e15c52b7954","0x00000000000000000000000000000000002d0422bd87ac3d4b3d634353f4b549","0x000000000000000000000000000000b46b0e4b4715facf416506fcfa423a5a11","0x000000000000000000000000000000000000b04c304e4a71b213286dc018c1f9","0x000000000000000000000000000000005ddaee7eec4748a016c0e7eb70bfe469","0x00000000000000000000000000000000001675ee58bfe17caa21b87313104317","0x0000000000000000000000000000003e3059a331afea28221fcebbb5ee90b898","0x00000000000000000000000000000000001d84f0683d41359f54f6643ed5ac03","0x000000000000000000000000000000c0350370f8bddb85c68d296dfb1271390b","0x000000000000000000000000000000000024b44a7148db36aa9931d86a4e96e1","0x0000000000000000000000000000009db7868029b09b759b52f84fd8c743bab8","0x000000000000000000000000000000000005b5d467d27ac775ab6ff1010d356b","0x000000000000000000000000000000c1efa3f8c8daa6dcc9da798af518ff06a9","0x000000000000000000000000000000000028b623b93b1514cedb4ad6532c19a5","0x0000000000000000000000000000008324179e660d01d7601b76536e44db2dcd","0x0000000000000000000000000000000000141a6f6bbfd388f992bfbae4b86a8a","0x0000000000000000000000000000004553b681af2c43e02f959c79b7bba3ccce","0x000000000000000000000000000000000021115f0736db946a9bcaaf8ca12e20","0x000000000000000000000000000000d95e752afed5393d52f67843946ebd1d66","0x0000000000000000000000000000000000024b01191687b93ac492798166779d","0x000000000000000000000000000000cd81becf713a7fe7b8208e62d7502e09a1","0x00000000000000000000000000000000001b5c862c3ce08494cc38a09383267b","0x00000000000000000000000000000033764803e8da2316add7d58e858a6906b5","0x000000000000000000000000000000000001c2466ae090a98cf3a2e4d1cf3a89","0x0000000000000000000000000000002d6862135b4c06f02948c03f5ce5beb3de","0x00000000000000000000000000000000002ec54daf2602acae6dff3ba7cfae9d","0x000000000000000000000000000000c3eae438accb0e906615adf2d1d1cdcc35","0x000000000000000000000000000000000009c6be1b91ed16844eed869f78e5a2","0x00000000000000000000000000000039109a7ff0cba8c1c7147e08401ebba9c7","0x00000000000000000000000000000000001f38549722a5d4e0428c442f24dd0b","0x000000000000000000000000000000b9a759394d7b323bcea2661ca3da5c1c1b","0x000000000000000000000000000000000002cd46527027d5b3acfb095aa13fb8","0x000000000000000000000000000000e03df9555c8a735f17bd72fcd4783371b5","0x000000000000000000000000000000000014b0b7f96db3ced3dfb7b6d167203c","0x000000000000000000000000000000ecc9f5de5f5fdbbf35386ca86cbdf41ea5","0x0000000000000000000000000000000000140f275463862272cbd7a4dc5b2b3a","0x0000000000000000000000000000003abfde11b1b8daf0645b2d22af2aaf1666","0x000000000000000000000000000000000016971ba5d7354da6de30e49514d2e7","0x000000000000000000000000000000a37609579c86071a2bd570baa96b6a9769","0x00000000000000000000000000000000002db5a8133c5d6707e9f4cb0649bc29","0x00000000000000000000000000000092ff9954f478c3b0a5c12f0aeeea18a976","0x00000000000000000000000000000000001bbc33e3963b31278f108cf0b649a6","0x000000000000000000000000000000de100adba731498e3386c4a327709c18a9","0x000000000000000000000000000000000021a31b58559d94f99633446859488b","0x000000000000000000000000000000d26898661506e582f7fe3959710bcfac73","0x00000000000000000000000000000000001f84ded745f0c258497f6f25542c01","0x000000000000000000000000000000fc97120dddae9520d7c96cec74c622b242","0x0000000000000000000000000000000000212b30f1e639625e4071af809e7a16","0x000000000000000000000000000000fafd19a947306c5676dee7b15a5c9063c2","0x000000000000000000000000000000000026b71eec68aeb3047d5c51e73ab2ac","0x000000000000000000000000000000d700b1149ac1a35ab6a8f752c488055f21","0x000000000000000000000000000000000013a600208b2b426eba8082132f07d4","0x000000000000000000000000000000f244aaa56ac63e03023488c1df17427c7e","0x000000000000000000000000000000000020411c221c22e056f98b1a11f533fe","0x0000000000000000000000000000002aab1004265d01ac5491ba133a84b0a557","0x00000000000000000000000000000000000013560784d8e4748a5b6e6d6218d0","0x000000000000000000000000000000d1d3a595fc4c359745e19cc6d648cc86cf","0x000000000000000000000000000000000009ef75e93795c0f2ddfe085506a75d","0x000000000000000000000000000000b1d2691924edf28676efdd20edae08bbb7","0x00000000000000000000000000000000001f2fc947e468c9e255753d3d13df1c","0x0000000000000000000000000000004907560a23ce8f969d658c555cf15582f8","0x0000000000000000000000000000000000151056031d567737ed6ff2ec167c32","0x00000000000000000000000000000048a874e0430bde66666b3eb55323f3ccdb","0x00000000000000000000000000000000001410417b388fd9e07fdecf3589f56a","0x000000000000000000000000000000260071879c72979789ff4f8e6ad9e6ce3b","0x000000000000000000000000000000000010e5230342139f360191198088277b","0x0000000000000000000000000000003d25f59f3ba3effb5a37bef8164b8c9832","0x00000000000000000000000000000000001cd184057003aefd247315af637600","0x000000000000000000000000000000b2ac955b83c13ed987e5aa061823a2c0ee","0x00000000000000000000000000000000000989d0a57502ef69f40d59f1727bdb","0x000000000000000000000000000000f936aabeaf8d3e95537b649c1dc23ddec9","0x00000000000000000000000000000000002384a0c255fab312b526531057f3a7","0x000000000000000000000000000000ecf53112acb450559cc9559017e63e08e8","0x00000000000000000000000000000000001dd09c15bc78567d5b266444c7c4be","0x000000000000000000000000000000e72dec9cbe626e9435768821939ca0919b","0x00000000000000000000000000000000002519dfd3365d922a4456b068578ec4","0x000000000000000000000000000000437544a92330bce391e9ea6fccd0563441","0x000000000000000000000000000000000007aa9861c7e912230eb34ab655f237","0x000000000000000000000000000000c85859c53fa2ea360d9dc68aa4363e1373","0x00000000000000000000000000000000001c81f4064d3fce36eca7b9907bfea6","0x0000000000000000000000000000007d29b3d98629ad26a754b45eb98abc26c9","0x000000000000000000000000000000000002732624cedbdff97bc88a8313d450","0x000000000000000000000000000000a82973c2e53a1bcfc35003c82faedfd1aa","0x0000000000000000000000000000000000202338a0de88ae0bc467d9a99a1584","0x000000000000000000000000000000d922fdbb27fce6bfffb6c3c1885d6dafaf","0x000000000000000000000000000000000009fa6853d7d2bb9448ae40d6f7e857","0x0000000000000000000000000000002e3226099177992d3397f31355878535ac","0x000000000000000000000000000000000015305411e270cd6049db948d2e758e","0x000000000000000000000000000000597269ee7172a82715081abbcfd2382a29","0x000000000000000000000000000000000000b19f3819e39282630ec3b45c9091","0x000000000000000000000000000000e2d7988cb3982a86047138d682dea2fb76","0x000000000000000000000000000000000025390e72a03d4754bb915ff608702e","0x0000000000000000000000000000009378cfb720da039c8c234668693b946bdf","0x00000000000000000000000000000000002cfb2d48b3b99e8fb86f3b4b49e518","0x000000000000000000000000000000970f25562407770de6a22a373205a51604","0x00000000000000000000000000000000000d8d44443422a0368a85ba3aba4fe3","0x0000000000000000000000000000002c5073e5641af016a6847ab564c9d7aed9","0x0000000000000000000000000000000000285b08bb8a27746d59f6f19b43dc6f","0x0000000000000000000000000000000000000000000000000000000000000001","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000002","0x0000000000000000000000000000000000000000000000000000000000000000","0x000000000000000000000000000000f2fb82ce748cf2dd0211646f8345161e59","0x00000000000000000000000000000000002a31a07286e98e21fd26d5ff4a335b","0x00000000000000000000000000000009d18add233ea581d981dc4034d5a588d4","0x000000000000000000000000000000000026433e304755431adeb2b301d4f24b"] \ 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 bfcd6b985..ce44bce42 100644 --- a/packages/tokens/src/confidential/circuits/withdraw/src/main.nr +++ b/packages/tokens/src/confidential/circuits/withdraw/src/main.nr @@ -48,21 +48,14 @@ mod tests; // The lane[0] amount // slot is unused -- the // withdrawal amount is -// public -- and skipping -// it keeps the checkpoint -// pad distinct from every -// amount pad. +// public. // W_a4 b_tilde_aud_s = (v - a) + m_b Sender-auditor // encrypted balance // checkpoint (emitted). // W_a5 r_tilde_aud_s = r' + m_r Sender-auditor escrow // of the new spendable // blinding, over W5's r' -// (emitted). This is what -// lets the auditor track -// the full Pedersen -// opening of C_spend -// forward. +// (emitted). // // Point-validation doctrine (Section 10.8) // ---------------------------------------- diff --git a/packages/tokens/src/confidential/compliance/mod.rs b/packages/tokens/src/confidential/compliance/mod.rs index 5a3b935fb..172b4516f 100644 --- a/packages/tokens/src/confidential/compliance/mod.rs +++ b/packages/tokens/src/confidential/compliance/mod.rs @@ -29,8 +29,8 @@ use soroban_sdk::{contractclient, contracterror, contractevent, contracttrait, A pub use storage::{ComplianceConfig, ComplianceStorageKey}; use crate::confidential::{ - ConfidentialToken, Hooks, RegisterPayload, RevokeSpenderPayload, SetSpenderPayload, - SpenderTransferPayload, TransferPayload, WithdrawPayload, + ConfidentialToken, Hooks, RegisterPayload, SetSpenderPayload, SpenderTransferPayload, + TransferPayload, WithdrawPayload, }; // ################## POLICY ################## @@ -288,12 +288,7 @@ impl Hooks for ComplianceHooks { storage::check_policy(e, spender, &config); } - fn on_revoke_spender( - e: &Env, - account: &Address, - _spender: &Address, - _payload: &RevokeSpenderPayload, - ) { + fn on_revoke_spender(e: &Env, account: &Address, _spender: &Address) { let Some(config) = storage::compliance_config(e) else { return; }; diff --git a/packages/tokens/src/confidential/compliance/test.rs b/packages/tokens/src/confidential/compliance/test.rs index 40070b7dd..ada2866bf 100644 --- a/packages/tokens/src/confidential/compliance/test.rs +++ b/packages/tokens/src/confidential/compliance/test.rs @@ -17,8 +17,8 @@ use crate::confidential::{ storage::{set_address_as_field_element, set_auditor, set_underlying_asset, set_verifier}, verifier::CircuitType, ConfidentialAccount, ConfidentialToken, ConfidentialTokenClient, Hooks, RegisterData, - RegisterPayload, RevokeSpenderPayload, SetSpenderPayload, SpenderDelegation, - SpenderTransferPayload, TransferPayload, WithdrawPayload, + RegisterPayload, SetSpenderPayload, SpenderDelegation, SpenderTransferPayload, TransferPayload, + WithdrawPayload, }; // ################## MOCK CONTRACTS ################## @@ -244,17 +244,6 @@ fn set_spender_payload(e: &Env) -> SetSpenderPayload { } } -fn revoke_spender_payload(e: &Env) -> RevokeSpenderPayload { - RevokeSpenderPayload { - c_spend_new: pt(e), - b_tilde: fr(e), - r_e_point: pt(e), - sigma: fr(e), - v_tilde_aud_s: fr(e), - b_tilde_aud_s: fr(e), - } -} - // ################## NO-CONFIG SHORT-CIRCUIT ################## #[test] @@ -278,7 +267,7 @@ fn hooks_short_circuit_without_config() { &spender_transfer_payload(&h.e), ); ComplianceHooks::on_set_spender(&h.e, &alice, &op, 0, &set_spender_payload(&h.e)); - ComplianceHooks::on_revoke_spender(&h.e, &alice, &op, &revoke_spender_payload(&h.e)); + ComplianceHooks::on_revoke_spender(&h.e, &alice, &op); assert!(compliance_config(&h.e).is_none()); assert!(!is_frozen(&h.e, &alice)); }); @@ -457,7 +446,7 @@ fn on_revoke_spender_panics_when_account_frozen() { h.e.as_contract(&h.host, || { set_compliance_config(&h.e, &base_config()); freeze(&h.e, &alice); - ComplianceHooks::on_revoke_spender(&h.e, &alice, &op, &revoke_spender_payload(&h.e)); + ComplianceHooks::on_revoke_spender(&h.e, &alice, &op); }); } @@ -469,7 +458,7 @@ fn on_revoke_spender_when_spender_frozen() { h.e.as_contract(&h.host, || { set_compliance_config(&h.e, &base_config()); freeze(&h.e, &op); - ComplianceHooks::on_revoke_spender(&h.e, &alice, &op, &revoke_spender_payload(&h.e)); + ComplianceHooks::on_revoke_spender(&h.e, &alice, &op); }); } @@ -570,7 +559,7 @@ fn on_revoke_spender_allows_policy_denied_spender() { let policy = h.e.register(DenyOnePolicy, (op.clone(),)); h.e.as_contract(&h.host, || { set_compliance_config(&h.e, &ComplianceConfig { policy: Some(policy), ..base_config() }); - ComplianceHooks::on_revoke_spender(&h.e, &alice, &op, &revoke_spender_payload(&h.e)); + ComplianceHooks::on_revoke_spender(&h.e, &alice, &op); }); } diff --git a/packages/tokens/src/confidential/docs/COMPLIANCE.md b/packages/tokens/src/confidential/docs/COMPLIANCE.md index 4fa533739..5d225b8e7 100644 --- a/packages/tokens/src/confidential/docs/COMPLIANCE.md +++ b/packages/tokens/src/confidential/docs/COMPLIANCE.md @@ -194,7 +194,7 @@ Three roles bear on this flow: - **Issuer (SAC admin)** — when the base asset is a Stellar Asset Contract, the holder of its standardized admin interface (`mint`, `clawback`, `set_authorized`; CAP-0046-06). Seized value settles to the issuer over the transparent SEP-41 path, and the issuer can freeze independently of the token admin via SAC passthrough (§2.2). - **Auditor** — holder of the off-chain auditor decryption key bound to the account at registration (`DESIGN_cont.md` §8). Governs visibility of confidential balances; cannot move funds or change contract state. -The seize itself is carried out by the token admin and the auditor together. The admin authorizes the on-chain action per the role above; the auditor unlocks knowledge of the target's balance. Two halves of the target's confidential position are covered by the two auditor channels (see `DESIGN_cont.md` §8.1, §8.2). The **sender-auditor** decrypts the spendable-balance checkpoint $$\tilde{b}\_{\text{aud,s}}$$ from the target's most recent owner-initiated event, recovering $$v\_s$$. The **recipient-auditor** decrypts the per-transfer pairs $$(v\_{\text{transfer},i}, r\_{\text{transfer},i})$$ from every inbound transfer and spender-transfer since the last merge, recovering the full Pedersen opening $$(v\_r, r\_r)$$ of the target's `receiving_commitment`. The auditor then produces a zero-knowledge proof bounding the clawback amount by $$v\_s + v\_r$$, without revealing either summand. +The seize itself is carried out by the token admin and the auditor together. The admin authorizes the on-chain action per the role above; the auditor unlocks knowledge of the target's balance. Two halves of the target's confidential position are covered by the two auditor channels (see `DESIGN_cont.md` §8.1, §8.2). The **sender-auditor** decrypts the spendable-balance checkpoint $$\tilde{b}\_{\text{aud,s}}$$ from the target's most recent checkpoint event, recovering $$v\_s$$, and folds in every proofless operation the account performed since (§5.3 constraint 2). The **recipient-auditor** decrypts the per-transfer pairs $$(v\_{\text{transfer},i}, r\_{\text{transfer},i})$$ from every inbound transfer and spender-transfer since the last merge, recovering the full Pedersen opening $$(v\_r, r\_r)$$ of the target's `receiving_commitment`. The auditor then produces a zero-knowledge proof bounding the clawback amount by $$v\_s + v\_r$$, without revealing either summand. 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. @@ -215,10 +215,13 @@ The clawback proof is a constant-size circuit deployed through the existing Veri **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}}, m\_{r,s}^{\text{old}}) = \text{SpongeSqueeze}\_3(\delta\_{\text{aud\\\_s}}, \text{ECDH}(k\_{\text{aud,s}}, R\_e^{\text{old}}), \sigma^{\text{old}})$$, then $$v\_s = \tilde{b}\_{\text{aud,s}}^{\text{old}} - m\_{b,s}^{\text{old}}$$ and $$r\_s = \tilde{r}\_{\text{aud,s}}^{\text{old}} - m\_{r,s}^{\text{old}}$$, and $$C\_{\text{spend}} = v\_s \cdot G + r\_s \cdot H$$. The `lane[2]` escrow (`DESIGN.md` W\_a5 / T\_a9 / S\_a6) is what makes this a closed derivation: the circuit recovers the blinding from the auditor channel itself rather than from $$vk\_A$$, which it does not hold. The old checkpoint referenced here must be the account's most recent `lane[2]` escrow; `revoke_spender` publishes none (V\_a3), so a target frozen immediately after a revocation must be settled against the checkpoint preceding it. +2. **Spendable-balance decryption.** $$(m\_{v,s}^{\text{old}}, m\_{b,s}^{\text{old}}, m\_{r,s}^{\text{old}}) = \text{SpongeSqueeze}\_3(\delta\_{\text{aud\\\_s}}, \text{ECDH}(k\_{\text{aud,s}}, R\_e^{\text{old}}), \sigma^{\text{old}})$$, then $$v\_s = \tilde{b}\_{\text{aud,s}}^{\text{old}} - m\_{b,s}^{\text{old}}$$ and $$r\_s = \tilde{r}\_{\text{aud,s}}^{\text{old}} - m\_{r,s}^{\text{old}}$$, and $$C\_{\text{spend}} = v\_s \cdot G + r\_s \cdot H$$. The `lane[2]` escrow (`DESIGN.md` W\_a5 / T\_a9 / S\_a6) is what makes this a closed derivation: the circuit recovers the blinding from the auditor channel itself. The old checkpoint referenced here is the account's most recent `lane[2]` escrow, which the proofless operations do not produce — neither `merge` (`DESIGN.md` §7.4) nor `revoke_spender` (`DESIGN.md` §7.9) publishes one. Anchoring on an earlier checkpoint requires folding: $$C\_{\text{spend}}$$ is a public input the contract loads from live storage, so the equality above holds only once every fold performed since that checkpoint has been added into $$(v\_s, r\_s)$$. + + **Precondition.** The seize-enabling auditor MUST hold the opening of every addend folded since the checkpoint it anchors on — `DESIGN_cont.md` §8.3 states when a key does. An auditor that does not, having rotated in after an addend's event or missed it from its archive, cannot assemble this witness. Nor can the target be made to re-anchor it: every operation that issues a fresh checkpoint is gated on the account being unfrozen, and §5.2's flow requires the freeze. A deployment that intends to use clawback therefore owes the same archive completeness §8.5 *Archive dependence* already requires of the auditor. + 3. **Range and bound.** $$\alpha, v\_s, v\_r \in [0, 2^{127})$$ and $$\alpha \le v\_s + v\_r$$. -4. **Refreshed checkpoint.** $$r\_e^{\text{new}}$$ is **sampled** by the auditor per `DESIGN.md` §2.2, not derived. This is the one operation in the protocol that samples its ephemeral scalar: `DESIGN.md` §5.3 derives $$r\_e$$ from the originator's viewing key, and the originator here is the auditor, which holds no account's viewing key. Then $$R\_e^{\text{new}} = r\_e^{\text{new}} \cdot H$$, $$r\_e^{\text{new}} \neq 0$$, and, with $$(m\_{v,s}^{\text{new}}, m\_{b,s}^{\text{new}}, m\_{r,s}^{\text{new}}) = \text{SpongeSqueeze}\_3(\delta\_{\text{aud\\\_s}}, \text{ECDH}(k\_{\text{aud,s}}, R\_e^{\text{new}}), \sigma^{\text{new}})$$, both $$\tilde{b}\_{\text{aud,s}}^{\text{new}} = (v\_s + v\_r - \alpha) + m\_{b,s}^{\text{new}}$$ and $$\tilde{r}\_{\text{aud,s}}^{\text{new}} = r\_s' + m\_{r,s}^{\text{new}}$$. Re-escrowing `lane[2]` is what keeps the auditor's standing opening (`DESIGN_cont.md` §8.1) intact across a seizure. -5. **New spendable commitment.** $$C\_{\text{spend}}^{\text{new}} = (v\_s + v\_r - \alpha) \cdot G + r\_s' \cdot H$$. The commitment is a public input rather than a value the contract recomputes, since $$v\_s$$, $$v\_r$$, and $$r\_s'$$ are all private witnesses. +4. **Refreshed checkpoint.** $$r\_e^{\text{new}}$$ is **sampled** by the auditor per `DESIGN.md` §2.2, not derived. This is the one operation in the protocol that samples its ephemeral scalar: `DESIGN.md` §5.3 derives $$r\_e$$ from the originator's viewing key, and the originator here is the auditor, which holds no account's viewing key. Then $$R\_e^{\text{new}} = r\_e^{\text{new}} \cdot H$$, $$r\_e^{\text{new}} \neq 0$$, and, with $$(m\_{v,s}^{\text{new}}, m\_{b,s}^{\text{new}}, m\_{r,s}^{\text{new}}) = \text{SpongeSqueeze}\_3(\delta\_{\text{aud\\\_s}}, \text{ECDH}(k\_{\text{aud,s}}, R\_e^{\text{new}}), \sigma^{\text{new}})$$, both $$\tilde{b}\_{\text{aud,s}}^{\text{new}} = (v\_s + v\_r - \alpha) + m\_{b,s}^{\text{new}}$$ and $$\tilde{r}\_{\text{aud,s}}^{\text{new}} = r\_s' + m\_{r,s}^{\text{new}}$$. +5. **New spendable commitment.** $$C\_{\text{spend}}^{\text{new}} = (v\_s + v\_r - \alpha) \cdot G + r\_s' \cdot H$$. The commitment is a public input, since $$v\_s$$, $$v\_r$$, and $$r\_s'$$ are all private witnesses. **Post-verification.** The contract stores the prover-supplied $$C\_{\text{spend}}^{\text{new}}$$, zeroes $$C\_{\text{receive}}$$, transfers $$\alpha$$ of the underlying SEP-41 token to the issuer, and emits an event carrying $$(\tilde{b}\_{\text{aud,s}}^{\text{new}}, \tilde{r}\_{\text{aud,s}}^{\text{new}}, R\_e^{\text{new}}, \sigma^{\text{new}})$$ so the auditor sees the new checkpoint and its blinding. diff --git a/packages/tokens/src/confidential/docs/DESIGN.md b/packages/tokens/src/confidential/docs/DESIGN.md index 290d85c04..9a2d669ac 100644 --- a/packages/tokens/src/confidential/docs/DESIGN.md +++ b/packages/tokens/src/confidential/docs/DESIGN.md @@ -49,7 +49,7 @@ The design is built on three interlocking mechanisms: 3. **Proof-less merge.** Incoming funds accumulate in a receiving balance that is separate from the spendable balance. To make received funds spendable, the owner authorizes a merge - no ZK proof is required. Since merge requires owner authorization and incoming transfers touch only the receiving balance, neither the spend path nor the merge path can be front-run by a third party. -Six Noir/UltraHonk circuits cover registration, withdrawal, confidential transfer, spender transfer, spender delegation, and spender revocation. The proof system leverages the Grumpkin–BN254 curve cycle: Grumpkin point arithmetic is native inside Noir circuits (no field emulation), while Soroban natively supports BN254 operations for UltraHonk proof verification. +Five Noir/UltraHonk circuits cover registration, withdrawal, confidential transfer, spender transfer, and spender delegation. 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. --- @@ -147,13 +147,13 @@ The domain tag is always the first element absorbed, so $$\text{Poseidon2}(\delt $$\text{SpongeSqueeze}\_n(\delta\_{\text{channel}}, s, \sigma) = \bigl(\text{state}[0], \\, \ldots, \\, \text{state}[n-1]\bigr), \qquad \text{state} = \text{permute}\bigl([\delta\_{\text{channel}}, \\, s, \\, \sigma, \\, 3 \cdot 2^{64}]\bigr)$$ -where $$s$$ is the ECDH shared scalar of Section 2.4 and $$n \in \\{2, 3\\}$$ is the number of rate lanes read; the capacity lane $$\text{state}[3]$$ is never squeezed. Two channel tags are used: $$\delta\_{\text{aud\\\_s}}$$ for the sender-auditor channel keyed by $$s\_{a,s} = \text{ECDH}(r\_e, K\_{\text{aud,s}})$$, squeezed three-wide, and $$\delta\_{\text{aud\\\_r}}$$ for the recipient-auditor channel keyed by $$s\_{a,r} = \text{ECDH}(r\_e, K\_{\text{aud,r}})$$, squeezed two-wide. No other arity is instantiated. Because the absorb is one block, $$\text{SpongeSqueeze}\_3(\delta, s, \sigma)[i] = \text{SpongeSqueeze}\_2(\delta, s, \sigma)[i]$$ for $$i \in \\{0, 1\\}$$: widening a channel adds a lane without changing the value of any existing one. +where $$s$$ is the ECDH shared scalar of Section 2.4 and $$n \in \\{2, 3\\}$$ is the number of rate lanes read; the capacity lane $$\text{state}[3]$$ is never squeezed. Two channel tags are used: $$\delta\_{\text{aud\\\_s}}$$ for the sender-auditor channel keyed by $$s\_{a,s} = \text{ECDH}(r\_e, K\_{\text{aud,s}})$$, squeezed three-wide, and $$\delta\_{\text{aud\\\_r}}$$ for the recipient-auditor channel keyed by $$s\_{a,r} = \text{ECDH}(r\_e, K\_{\text{aud,r}})$$, squeezed two-wide. No other arity is instantiated. Because the absorb is one block, every width reads a prefix of one permutation output: $$\text{SpongeSqueeze}\_3(\delta, s, \sigma)[i] = \text{SpongeSqueeze}\_2(\delta, s, \sigma)[i]$$ for $$i \in \\{0, 1\\}$$, and $$\text{SpongeSqueeze}\_n(\delta, s, \sigma)[0] = \text{Poseidon2}(\delta, s, \sigma)$$. -**Lane assignment.** Squeeze order is canonical. Lanes are named by their zero-based index into the squeeze output: `lane[i]` is $$\text{SpongeSqueeze}\_n(\delta, s, \sigma)[i]$$, so a three-wide squeeze yields `lane[0]`, `lane[1]`, and `lane[2]`. `lane[0]` is always an amount mask and `lane[1]` is always a balance, allowance, or randomness mask, fixed per operation by the formulas in Sections 7 and 8. `lane[2]`, present only on the sender-auditor channel, is the **blinding-escrow slot**: it always carries the blinding of a commitment the operation writes, never a key. Which commitment per operation is specified in Section 8.1 *The `lane[2]` slot*. The Withdraw checkpoint (W\_a3, W\_a5) takes `lane[1]` and `lane[2]` and leaves the amount lane unused, so a checkpoint pad can never coincide with an amount pad. +**Lane assignment.** Squeeze order is canonical. Lanes are named by their zero-based index into the squeeze output: `lane[i]` is $$\text{SpongeSqueeze}\_n(\delta, s, \sigma)[i]$$, so a three-wide squeeze yields `lane[0]`, `lane[1]`, and `lane[2]`. `lane[0]` is always an amount mask and `lane[1]` is always a balance, allowance, or randomness mask, fixed per operation by the formulas in Sections 7 and 8. `lane[2]`, present only on the sender-auditor channel, is the **blinding-escrow slot**. It carries the new spendable blinding on the three checkpoint operations (W\_a5, T\_a9, S\_a6) and the new allowance blinding on spender transfers (O\_a9). The Withdraw checkpoint (W\_a3, W\_a5) takes `lane[1]` and `lane[2]` and leaves the amount lane unused, so a checkpoint pad can never coincide with an amount pad. -**Mode exclusivity.** Because the absorb occupies a single block, $$\text{SpongeSqueeze}\_n(\delta, s, \sigma)[0]$$ is the same field element as $$\text{Poseidon2}(\delta, s, \sigma)$$. Distinct domain tags (Section 13) are therefore not sufficient on their own: each tag MUST additionally be used in exactly one sponge mode, or the same $$(\delta, s, \sigma)$$ would yield one mode's mask as the other's output. $$\delta\_{\text{aud\\\_s}}$$ and $$\delta\_{\text{aud\\\_r}}$$ are the two multi-lane tags; every other tag in Section 13 is used only with the single-output form above. Reading one multi-lane tag at two different widths is not a mode violation, by the prefix property above: the sender-auditor tag is squeezed three-wide on every operation that escrows `lane[2]` and two-wide on `RevokeSpender` (V\_a3), while the recipient-auditor tag is always two-wide. +**Mode exclusivity.** By the prefix property, the single-output form is `lane[0]` of the multi-lane one, so distinct domain tags (Section 13) are not sufficient on their own: each tag MUST additionally be used in exactly one sponge mode, or the same $$(\delta, s, \sigma)$$ would yield one mode's mask as the other's output. $$\delta\_{\text{aud\\\_s}}$$ and $$\delta\_{\text{aud\\\_r}}$$ are the two multi-lane tags; every other tag in Section 13 is used only with the single-output form above. -The sponge masks are deterministic in $$(s, \sigma)$$, where $$s$$ is the ECDH shared scalar of Section 2.4, so reusing the pair across two operations reuses every pad slot they share, and a slot whose plaintext is known in one operation (e.g. a transfer amount known to its recipient) decrypts the other operation's ciphertext in that slot. The canonical slot assignment above limits the blast radius of such reuse to same-slot pairs, but does not eliminate it; provers and wallets MUST use a fresh $$(r\_e, \sigma)$$ for every proof. Because $$r\_e$$ is derived from the originator's viewing key and the salt rather than drawn independently (§5.3), a fresh salt is the only thing that makes the pair fresh: the salt carries the entire requirement, and Section 9.6's retry rule is what discharges it. The salt that carries it is whichever one the operation's pads absorb -- $$\sigma$$ for owner-initiated operations, the prover-chosen $$\sigma\_a'$$ for spender transfers (§6.2 *Transfer nonce*) -- never a salt loaded from storage, which a revert leaves unchanged. +**Pad freshness.** The sponge masks are deterministic in $$(s, \sigma)$$, so reusing the pair across two operations reuses every pad slot they share, and a slot whose plaintext is known in one operation (e.g. a transfer amount known to its recipient) decrypts the other operation's ciphertext in that slot. The canonical slot assignment above limits the blast radius of such reuse to same-slot pairs, but does not eliminate it; provers and wallets MUST use a fresh $$(r\_e, \sigma)$$ for every proof. Because $$r\_e$$ is derived from the originator's viewing key and the salt rather than drawn independently (§5.3), a fresh salt is the only thing that makes the pair fresh, and Section 9.6's retry rule is what discharges the requirement. Freshness applies to whichever salt the operation's pads absorb -- $$\sigma$$ for owner-initiated operations, the prover-chosen $$\sigma\_a'$$ for spender transfers (§6.2 *Transfer nonce*). All references to "Poseidon" in this document denote this Poseidon2 instantiation. @@ -216,7 +216,7 @@ The contract, the SDK, the wallet, and any indexer reproduce the same Field valu | Site | When computed | Storage | |:---|:---|:---| | $$\text{addr\\\_f}$$ | Once, by the contract's `__constructor` over `env.current_contract_address()` | Stored as a single Field in the contract's **instance storage** (§3.5); read on every proof verification | -| $$\text{op}\_i$$ | Per-call, by the contract at `set_spender` and `revoke_spender` over the `spender` argument | Not stored; recomputed each call. The circuit binds it via S5 / V3 | +| $$\text{op}\_i$$ | Per-call, by the contract at `set_spender` over the `spender` argument | Not stored; recomputed each call. The circuit binds it via S5 and absorbs it as the S12 / S14 escrow nonce. | --- @@ -263,7 +263,7 @@ i.e., the total committed value across all confidential accounts never exceeds t ### 3.5 Governance and Upgradeability -The constructor binds the contract to fixed `admin`, `token`, `verifier`, and `auditor` addresses. It additionally computes and stores `addr_f = address_to_field(env.current_contract_address())` (§2.7) in **instance storage** as a single canonical $$\mathbb{F}\_r$$ Field; this is the value every owner-initiated proof references via constraints R2 / W2 / T2 / S2 / V2. The compressed `addr_f` Field is computed once at construction (not recomputed per call) to ensure all proofs across the contract's lifetime bind to the same Field representative of the contract's address. Beyond that, this specification does not prescribe a governance policy for upgrading these components or for rotating per-circuit verification keys. Concrete deployments differ widely in spender structure, regulatory posture, and emergency-response requirements, so these decisions are deliberately left to implementers. +The constructor binds the contract to fixed `admin`, `token`, `verifier`, and `auditor` addresses. It additionally computes and stores `addr_f = address_to_field(env.current_contract_address())` (§2.7) in **instance storage** as a single canonical $$\mathbb{F}\_r$$ Field; this is the value every owner-initiated proof references via constraints R2 / W2 / T2 / S2. The compressed `addr_f` Field is computed once at construction (not recomputed per call) to ensure all proofs across the contract's lifetime bind to the same Field representative of the contract's address. Beyond that, this specification does not prescribe a governance policy for upgrading these components or for rotating per-circuit verification keys. Concrete deployments differ widely in spender structure, regulatory posture, and emergency-response requirements, so these decisions are deliberately left to implementers. Questions an implementer must answer: @@ -290,7 +290,7 @@ The spending public key is stored on-chain at registration. Knowledge of $$sk$$ $$vk = \text{Poseidon}(\delta\_{\text{vk}}, sk, \text{addr\\\_f})$$ -A scalar in $$\mathbb{F}\_r$$, unique per $$(sk, \text{addr\\\_f})$$ pair. Enables balance decryption without spending authority, and — through the ephemeral-scalar derivation of §5.3 — reconstruction of the Pedersen openings of transfers the account originated; §9.4 states the full capability of a compromised $$vk$$. Cannot recover $$sk$$ (Poseidon preimage resistance). Because $$\text{addr\\\_f}$$ is bound into the derivation, proofs that constrain $$vk$$ (R2, W2, T2, S2, V2) are inherently bound to the contract, eliminating the need for explicit per-circuit context binding. +A scalar in $$\mathbb{F}\_r$$, unique per $$(sk, \text{addr\\\_f})$$ pair. Enables balance decryption without spending authority, and — through the ephemeral-scalar derivation of §5.3 — reconstruction of the Pedersen openings of transfers the account originated; §9.4 states the full capability of a compromised $$vk$$. Cannot recover $$sk$$ (Poseidon preimage resistance). Because $$\text{addr\\\_f}$$ is bound into the derivation, proofs that constrain $$vk$$ (R2, W2, T2, S2) are inherently bound to the contract, eliminating the need for explicit per-circuit context binding. ### 4.3 Public Viewing Key @@ -338,7 +338,7 @@ The following symbols are used throughout this section: 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). +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 8.1). ### 5.2 Off-Chain Opening Maintenance @@ -360,31 +360,30 @@ $$W\_{\text{receive}} = (v\_r, r\_r) \quad \text{such that} \quad C\_{\text{rece | 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 spender (escrow amount $$a$$) | Proof outputs new commitment. $$W\_{\text{spend}} \leftarrow (v\_s - a, \\; \text{Poseidon}(\delta\_{\text{spend\\\_r}}, vk, \sigma))$$ | -| Revoke spender (reclaim amount $$a$$) | Proof outputs new commitment. $$W\_{\text{spend}} \leftarrow (v\_s + a, \\; \text{Poseidon}(\delta\_{\text{spend\\\_r}}, vk, \sigma))$$ | +| Revoke spender | Proofless fold. $$W\_{\text{spend}} \mathrel{+}= (v\_a, r\_a)$$, the escrow opening recovered from the event per §7.9 | -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`; the case where that encoding is unavailable is specified in [DESIGN_cont.md](./DESIGN_cont.md) §10.4 *Post-merge witness availability*. +The Merge and Revoke spender rows use exact integer addition; $$W\_{\text{spend}}.r$$ is not reduced modulo $$r$$ or $$q$$ as folds accumulate. At proof-construction time the wallet reduces $$W\_{\text{spend}}.r$$ modulo $$q$$ and encodes the canonical $$\mathbb{F}\_q$$ representative as a single $$\mathbb{F}\_r$$ `Field`; the case where that encoding is unavailable is specified in [DESIGN_cont.md](./DESIGN_cont.md) §10.4 *Post-merge witness availability*. -After every owner-initiated operation that produces a proof, $$r\_s$$ resets to a deterministic value. This is the **normalization** property: the spendable balance's blinding factor is always recoverable from $$(vk, \sigma)$$ at spend boundaries. Together with $$\tilde{b}$$, both emitted in the spend-boundary event, each spend boundary forms a **checkpoint** from which the spendable opening $$(v\_s, r\_s)$$ is recoverable via a single event lookup, with no exhaustive history replay needed for $$W\_{\text{spend}}$$. $$W\_{\text{receive}}$$ has no such anchor: a checkpoint leaves $$C\_{\text{receive}}$$ untouched, which only `Merge` resets (§7.4), so recovering $$W\_{\text{receive}}$$ requires replaying events emitted after the account's most recent `Merge` at or before that checkpoint (see Recovery below). +After every owner-initiated operation that produces a proof, $$r\_s$$ resets to a deterministic value. This is the **normalization** property: the spendable balance's blinding factor is always recoverable from $$(vk, \sigma)$$ at spend boundaries. Together with $$\tilde{b}$$, both emitted in the spend-boundary event, each spend boundary forms a **checkpoint** from which the spendable opening $$(v\_s, r\_s)$$ *as of that boundary* is recoverable via a single event lookup, with no replay of the spend history preceding it. $$W\_{\text{receive}}$$ has no such anchor: a checkpoint leaves $$C\_{\text{receive}}$$ untouched, which only `Merge` (§7.4) resets, so recovering it requires a replay (see Recovery below). **Consistency check.** At any time, the wallet can verify its state: $$C\_{\text{spend}} \stackrel{?}{=} v\_s \cdot G + r\_s \cdot H$$ and $$C\_{\text{receive}} \stackrel{?}{=} v\_r \cdot G + r\_r \cdot H$$, where $$C\_{\text{spend}}$$ and $$C\_{\text{receive}}$$ are read from on-chain state. -**Recovery.** If the wallet loses local state, it recovers from **two anchors**. The spendable side is anchored at the **last checkpoint**: the most recent owner-initiated proof operation (`withdraw`, `confidential_transfer`, `set_spender`, or `revoke_spender`), which emitted both $$\tilde{b}$$ and $$\sigma$$ in its event, from which steps 1-4 recover $$W\_{\text{spend}}$$ in a single event lookup using $$vk$$. The receiving side is anchored at $$T\_0$$: the account's most recent `Merge` event at or before that checkpoint, or its `Register` event if it has not merged before the checkpoint. $$W\_{\text{receive}}$$ restarts at $$(0, 0)$$ as of $$T\_0$$, so the replay window is $$(T\_0, \text{now}]$$ (steps 5-6) rather than the interval after the checkpoint: +**Recovery.** If the wallet loses local state, it recovers from **two anchors**: the account's last **checkpoint** for the spendable side (steps 1-4), and $$T\_0$$ -- its most recent `Merge` at or before that checkpoint -- for the receiving side (step 5). Since $$W\_{\text{receive}}$$ restarts at $$(0, 0)$$ as of $$T\_0$$, one replay window $$(T\_0, \text{now}]$$ serves both: it rebuilds the receiving side and carries the post-checkpoint proofless folds onto the spendable one (step 6). [DESIGN_cont.md](./DESIGN_cont.md) §9.5 *Why the two anchors compose* gives the argument that they do: -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`), `SetSpender`, or `RevokeSpender` -- 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`), `SpenderTransfer` (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. `Merge` does update the spendable balance, but it carries no proof, so consistency between a $$\tilde{b}$$ and the post-merge commitment cannot be enforced (§7.4 *Encrypted balance*); merge activity is absorbed into the next checkpoint. `Merge` is nonetheless the anchor of the receiving side, since it is the only event that resets $$C\_{\text{receive}}$$ (step 5). **No-checkpoint case:** if the account has no checkpoint event since `Register`, initialize $$W\_{\text{spend}} \leftarrow (0, 0)$$ and skip to step 5 with $$T\_0$$ = the `Register` event. +1. Fetch $$(\tilde{b}, \sigma)$$ from the most recent **checkpoint event** for this account: exactly one of `Withdraw`, `Transfer` (where the account is the `from`), or `SetSpender` -- the three event types carrying a proof-bound $$(\tilde{b}, \sigma)$$ for this account's spendable balance. `Merge` and `RevokeSpender` do change $$C\_{\text{spend}}$$ but are not checkpoints: carrying no proof, they cannot bind a $$\tilde{b}$$ to the resulting commitment (§7.4 *Encrypted balance*, §7.9 *Encrypted balance*), and their effect is absorbed into the next checkpoint. **No-checkpoint case:** if the account has no checkpoint event since `Register`, initialize $$W\_{\text{spend}} \leftarrow (0, 0)$$ and skip to step 5 with $$T\_0$$ = the `Register` event. 2. Recover the spendable balance value: $$v\_s = \tilde{b} - \text{Poseidon}(\delta\_{\text{enc\\\_bal}}, vk, \sigma)$$. 3. Recover the spendable balance blinding: $$r\_s = \text{Poseidon}(\delta\_{\text{spend\\\_r}}, vk, \sigma)$$. 4. Set $$W\_{\text{spend}} \leftarrow (v\_s, r\_s)$$. 5. Locate $$T\_0$$: the account's most recent `Merge` event at or before the checkpoint of step 1, or its `Register` event if no such `Merge` exists. Set $$W\_{\text{receive}} \leftarrow (0, 0)$$ as of $$T\_0$$. -6. Replay every event after $$T\_0$$ in ledger order. For each event: - - **Incoming transfer** $$(R\_e, \tilde{v}, \sigma\_{\text{sender}})$$: compute $$s = \text{ECDH}(vk, R\_e)$$, derive $$v\_{\text{transfer}}$$ and $$r\_{\text{transfer}}$$. Accumulate $$W\_{\text{receive}} \mathrel{+}= (v\_{\text{transfer}}, r\_{\text{transfer}})$$. - - **Deposit** of amount $$a$$: accumulate $$W\_{\text{receive}} \mathrel{+}= (a, 0)$$. - - **Merge**: fold $$W\_{\text{spend}} \leftarrow (W\_{\text{spend}}.v + W\_{\text{receive}}.v, \\; W\_{\text{spend}}.r + W\_{\text{receive}}.r)$$, reset $$W\_{\text{receive}} \leftarrow (0, 0)$$. - - **Checkpoint event**: skip the spendable side -- step 1 already captured the spendable opening. A **self-transfer** (a `Transfer` whose `from` and `to` are both this account) is simultaneously a checkpoint and an incoming transfer: apply its incoming-transfer rule to $$W\_{\text{receive}}$$ like any other incoming transfer. +6. Replay every event after $$T\_0$$ in ledger order, applying the *Update rules* above with three replay-specific amendments: + - **Checkpoint event**: skip the spendable side -- step 1 already captured it. + - **Revoke spender**: skip if at or before the checkpoint of step 1, which absorbed it; otherwise fold as the table specifies. + - **Self-transfer** (a `Transfer` whose `from` and `to` are both this account): simultaneously a checkpoint and an incoming transfer -- apply the incoming-transfer rule to $$W\_{\text{receive}}$$ as for any other. 7. Verify consistency: $$C\_{\text{spend}} \stackrel{?}{=} W\_{\text{spend}}.v \cdot G + W\_{\text{spend}}.r \cdot H$$ and $$C\_{\text{receive}} \stackrel{?}{=} W\_{\text{receive}}.v \cdot G + W\_{\text{receive}}.r \cdot H$$. -Steps 1-4 require $$(\tilde{b}, \sigma)$$ from the latest checkpoint event and $$vk$$; no history replay is needed for $$W\_{\text{spend}}$$. Step 6 replays the window $$(T\_0, \text{now}]$$ and correctly handles any number of interleaved deposits, transfers, and merges. The window's length is set by how recently the account merged before its latest checkpoint, not by how often it spends. What keeps it short in practice is the wallet merge policy ([SDK.md](./SDK.md) §10.8): a wallet merges (§7.4) ahead of a spend its spendable balance alone cannot cover, which places a `Merge` immediately before most checkpoints. In the worst case (funds received but never merged), the window extends back to registration. +The window's length is set by how recently the account merged before its latest checkpoint, not by how often it spends; the wallet merge policy that keeps it short in practice is [SDK.md](./SDK.md) §10.8's. In the worst case (funds received but never merged), it extends back to registration. -**Event durability requirement.** Recovery depends on the wallet being able to locate $$T\_0$$ and retrieve every event after it, the checkpoint event included, in ledger order. Stellar RPC retains event history for a 7-days window only, so a wallet that loses local state after that window cannot recover from RPC alone. The protocol therefore assumes a durable event archive that retains the full per-account history of `Withdraw`, `Transfer` (both directions), `SpenderTransfer` (recipient side), `Deposit`, `Merge`, `SetSpender`, and `RevokeSpender` events forever. The data model, ingestion contract, retention obligations, and recommended API surface for that archive are specified in [INDEXER.md](./INDEXER.md). Wallets and SDKs MUST consume an indexer that meets that contract for recovery. +**Event durability requirement.** Recovery depends on the wallet being able to locate $$T\_0$$ and retrieve every event after it, the checkpoint event included, in ledger order. Stellar RPC retains event history for a 7-days window only, so a wallet that loses local state after that window cannot recover from RPC alone. The protocol therefore assumes a durable event archive retaining, forever, the full per-account history of every event these rules read. That event set, along with the data model, ingestion contract, retention obligations, and recommended API surface for the archive, is specified in [INDEXER.md](./INDEXER.md) (§3.2 for the set itself). Wallets and SDKs MUST consume an indexer that meets that contract for recovery. ### 5.3 ECDH-Derived Blinding @@ -403,13 +402,11 @@ where $$v\_{\text{transfer}}$$ is the transfer amount. The transfer commitment i Since $$vk\_B \cdot R\_e = r\_e \cdot \text{PVK}\_B = S$$ by ECDH commutativity, both sender and recipient can independently derive $$r\_{\text{transfer}}$$ and decrypt $$v\_{\text{transfer}} = \tilde{v} - \text{Poseidon}(\delta\_{\text{transfer\\\_amount}}, s, \sigma)$$, provided they know $$\sigma$$ emitted with the event. The auditor decrypts the transfer amount via a separate ECDH channel (Section 8.1). -**Deterministic ephemeral scalar.** $$r\_e$$ is derived from the originator's own viewing key and the operation salt. Every operation whose originator holds a viewing key derives it this way: `Transfer`, `Withdraw`, `SetSpender`, and `RevokeSpender` from the owner's viewing key, `SpenderTransfer` from the spender's own (§7.8). The clawback circuit of [COMPLIANCE.md](./COMPLIANCE.md) §5.3 is the sole exception, its ephemeral belonging to the auditor, which holds no account's viewing key. The derivation MUST be re-attempted with a fresh salt in the negligible case that it yields zero. +**Deterministic ephemeral scalar.** $$r\_e$$ is derived from the originator's own viewing key and the operation salt. Every operation that has an ephemeral derives it this way: `Transfer`, `Withdraw`, and `SetSpender` from the owner's viewing key, `SpenderTransfer` from the spender's own (§7.8). The derivation MUST be re-attempted with a fresh salt in the negligible case that it yields zero. Because $$\sigma$$ is published in the event and $$vk$$ is held by the originator, the derivation lets the originator recompute $$r\_e$$ for any past transfer from the event alone, which is what makes sender-side selective disclosure possible with no per-transfer wallet state ([SELECTIVE_DISCLOSURE.md](./SELECTIVE_DISCLOSURE.md) §7). A transfer whose $$r\_e$$ was sampled and not retained is permanently undisclosable by its sender. No circuit constrains $$r\_e$$ beyond $$R\_e = r\_e \cdot H$$ and $$r\_e \neq 0$$. -**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_spender` (§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{transfer\\\_blind}}/\delta\_{\text{transfer\\\_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 spender escrow), so masks across channels are independent under the PRF assumption on Poseidon (§3.2). The auditor-side allowance-blinding escrow (S14, $$\delta\_{\text{esc\\\_allow\\\_r\\\_aud}}$$) is the one derivation that does *not* open a channel of its own. It reuses the S\_a2 shared scalar, so it drops the distinct-scalar leg, and it absorbs $$\text{op}\_i$$, a per-delegation constant, rather than a per-operation nonce, so it drops the freshness leg too. Its separation therefore rests on the distinct-tag leg alone, plus the freshness of the shared scalar itself, inherited from $$r\_e$$ and hence from the salt. This is the thinnest construction in the system, and [DESIGN_cont.md](./DESIGN_cont.md) §8.5 states what it does and does not buy. The channel masks are used as one-time pads against fresh per-transfer randomness ($$\sigma$$, or $$\sigma\_a'$$ for spender transfers), 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 for every channel; the contract's enumeration of channels in §13 satisfies the domain-distinctness condition. S14 is the single derivation that stands on one of the three, as noted above. +**Why reusing $$r\_e$$ is safe.** A single $$r\_e$$ keys every ECDH channel an operation opens: the recipient's ($$\text{PVK}\_B$$), both auditors' ($$K\_{\text{aud,r}}$$ and $$K\_{\text{aud,s}}$$, [DESIGN_cont.md](./DESIGN_cont.md) §8.1), and, at `set_spender`, the $$dvk\_i$$ escrow to the spender ($$Y\_{\text{op}}$$, §7.11). Three properties separate them. *Distinct shared scalars*: those four counterparty points are independent Grumpkin points, none derivable from another. *Distinct domains*: each channel absorbs its own tag ([DESIGN_cont.md](./DESIGN_cont.md) §13 enumerates them), so masks across channels are independent under the PRF assumption on Poseidon (§3.2). *Fresh per-operation nonce*: every channel sponge re-absorbs the operation salt, which §2.5 *Pad freshness* requires to be fresh. Together they close the standard ECDH key-reuse attack surface. One derivation stands outside the argument: the auditor-side allowance-blinding escrow (S14, $$\delta\_{\text{esc\\\_allow\\\_r\\\_aud}}$$) opens no channel of its own, reusing the sender-auditor shared scalar and absorbing the per-delegation constant $$\text{op}\_i$$ rather than a nonce. Only the distinct-domain leg holds there in its own right; its pad freshness is inherited from $$r\_e$$, hence from the salt ([DESIGN_cont.md](./DESIGN_cont.md) §8.5). ### 5.4 Anti-Poisoning Constraint @@ -455,12 +452,12 @@ $$\text{PVK} = vk \cdot H$$. Set once at registration. Used by senders for ECDH **`spendable_commitment`** -The commitment the owner can spend from. Modified only by owner-authorized operations: transfers out, withdrawals, merge, `set_spender`, `revoke_spender`. Encoded as a single Grumpkin affine point (64 bytes). +The commitment the owner can spend from. Modified by owner-authorized operations and, in a deployment that enables the optional compliance extension, by its clawback ([COMPLIANCE.md](./COMPLIANCE.md) §5, outline only). Encoded as a single Grumpkin affine point (64 bytes). **`receiving_commitment`** -Accumulates incoming deposits and transfers via homomorphic addition. The contract adds to this without any proof from the recipient. Reset to $$\mathcal{O}$$ on merge. Encoded as a single Grumpkin affine point (64 bytes). +Accumulates incoming deposits and transfers via homomorphic addition. The contract adds to this without any proof from the recipient. Reset to $$\mathcal{O}$$ on merge (§7.4). Encoded as a single Grumpkin affine point (64 bytes). **`auditor_id`** @@ -486,7 +483,7 @@ The spender's remaining escrowed allowance, a single Pedersen commitment: $$C\_a **`a_tilde`** -Poseidon-encrypted allowance scalar: $$\tilde{a} = v\_a + \text{Poseidon}(\delta\_{\text{enc\\\_allow}}, dvk\_i, \sigma\_a)$$. Enables the spender (who holds $$dvk\_i$$ via `escrowed_dvk`) to read the current allowance without DLP when constructing an `SpenderTransfer` 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). +Poseidon-encrypted allowance scalar: $$\tilde{a} = v\_a + \text{Poseidon}(\delta\_{\text{enc\\\_allow}}, dvk\_i, \sigma\_a)$$. Enables the spender (who holds $$dvk\_i$$ via `escrowed_dvk`) to read the current allowance without DLP when constructing an `SpenderTransfer` witness. The owner can also read it via $$vk \rightarrow dvk\_i$$. It is emitted alongside `allowance_salt` in the `RevokeSpender` event (§7.9). **`escrowed_dvk`** @@ -494,11 +491,11 @@ $$dvk\_i$$ encrypted under the spender'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_spender` and replaced by the spender on every `confidential_transfer_from` (the spender samples a fresh $$\sigma\_a'$$ 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. +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 it opens the current allowance: the stored `allowance_commitment` and `a_tilde` are both derived under it. It is not the freshness input for a spender transfer's pads -- its replacement is (*Transfer nonce* below). Set by the owner at `set_spender` and replaced by the spender on every `confidential_transfer_from`. 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, and it is emitted in the `RevokeSpender` event (§7.9). -**Transfer nonce.** The stored $$\sigma\_a$$ opens the current allowance (O3) and nothing else. Every pad a spender transfer derives absorbs the prover-chosen replacement $$\sigma\_a'$$ instead: the recipient ECDH encryption (O7, O9), both auditor channel sponges (O\_a2, O\_a6, and with the latter the lane[2] escrow O\_a9), and the ephemeral scalar (§7.8). $$\sigma\_a'$$ is therefore the nonce the `SpenderTransfer` event emits, and the salt it replaces is never emitted. +**Transfer nonce.** Unlike $$\sigma$$, which an owner samples afresh for every operation, $$\sigma\_a$$ is stored state: a reverted transfer leaves the delegation entry untouched, so the retry is forced to reuse it. Pads keyed to it would repeat $$r\_e$$ and every mask across the two attempts, and a retry that changed the amount would publish the difference in the clear (§2.5). -The split is what makes a retry safe. A reverted call leaves the delegation entry untouched, so the stored $$\sigma\_a$$ is forced on the retry; keying the pads to it would repeat $$r\_e$$ and every mask, and a retry that changed the amount would publish the difference in the clear (§2.5). $$\sigma\_a'$$ is constrained only against prover-supplied state, so a retry re-samples it freely. Constraint O14 rejects $$\sigma\_a' = \sigma\_a$$, which would key the pads to the salt the previous transfer already used; it cannot see older salts, so **global non-repetition of $$\sigma\_a'$$ across the delegation's lifetime is a client obligation** ([SDK.md](./SDK.md) §10.4), not an enforced property. +A spender transfer avoids this by splitting the salt's two jobs: opening the stored allowance must be deterministic, since the value has to reproduce the randomness the commitment on-chain was built under, while keying pads must be fresh on every attempt. The stored $$\sigma\_a$$ does only the first (O3); every pad the transfer derives, and its ephemeral scalar, absorbs a prover-chosen replacement $$\sigma\_a'$$ instead (§7.8). **`live_until_ledger`** @@ -602,9 +599,9 @@ The owner withdraws a public amount $$a$$ (typed `i128`) from their spendable ba | 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} = \text{ECDH}(r\_e, K\_{\text{aud,s}})$$ (sender-auditor ECDH shared scalar, §2.4) | -| W\_a3 | $$(\cdot, m\_b, m\_r) = \text{SpongeSqueeze}\_3(\delta\_{\text{aud\\\_s}}, s\_{a,s}, \sigma)$$ (sender-auditor channel sponge; $$m\_b$$ is `lane[1]` — the balance slot, matching T\_a6/S\_a3 — and $$m\_r$$ is `lane[2]`, the secret-escrow slot. `lane[0]`, the amount slot, is unused: the withdrawal amount is public, and skipping the slot keeps the checkpoint pad distinct from every amount pad even under $$(r\_e, \sigma)$$ reuse, Section 2.5) | +| W\_a3 | $$(\cdot, m\_b, m\_r) = \text{SpongeSqueeze}\_3(\delta\_{\text{aud\\\_s}}, s\_{a,s}, \sigma)$$ (sender-auditor channel masks; `lane[0]`, the amount slot, is unused) | | W\_a4 | $$\tilde{b}\_{\text{aud,s}} = (v - a) + m\_b$$ (sender-auditor encrypted balance checkpoint) | -| W\_a5 | $$\tilde{r}\_{\text{aud,s}} = r' + m\_r$$ (sender-auditor escrow of the new spendable blinding, over W5's $$r'$$; with W\_a4 this hands the sender's auditor the opening of $$C\_{\text{spend}}'$$, Section 8.1) | +| W\_a5 | $$\tilde{r}\_{\text{aud,s}} = r' + m\_r$$ (sender-auditor escrow of the new spendable blinding, over W5's $$r'$$) | **Public inputs (16 fields):** @@ -654,7 +651,7 @@ The sender (account $$A$$, spending key $$sk\_A$$) transfers a hidden amount $$v | T\_a6 | $$(m\_{v,s}, m\_{b,s}, m\_{r,s}) = \text{SpongeSqueeze}\_3(\delta\_{\text{aud\\\_s}}, s\_{a,s}, \sigma)$$ (sender-auditor channel masks) | | T\_a7 | $$\tilde{v}\_{\text{aud,s}} = v\_{\text{transfer}} + m\_{v,s}$$ (sender-auditor encrypted transfer amount) | | T\_a8 | $$\tilde{b}\_{\text{aud,s}} = (v\_A - v\_{\text{transfer}}) + m\_{b,s}$$ (sender-auditor encrypted balance checkpoint) | -| T\_a9 | $$\tilde{r}\_{\text{aud,s}} = r\_A' + m\_{r,s}$$ (sender-auditor escrow of the new spendable blinding, over T10's $$r\_A'$$; with T\_a8 this hands the sender's auditor the opening of $$C\_{\text{spend}}'$$, Section 8.1) | +| T\_a9 | $$\tilde{r}\_{\text{aud,s}} = r\_A' + m\_{r,s}$$ (sender-auditor escrow of the new spendable blinding, over T10's $$r\_A'$$) | **Public inputs (25 fields, counting each Grumpkin point as two $$\mathbb{F}\_r$$ coordinates):** @@ -698,13 +695,13 @@ The owner locks funds from their spendable balance into a per-spender escrow. Th | 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) | -| S14 | $$\tilde{r}\_{a,\text{aud,s}} = \text{Poseidon}(\delta\_{\text{esc\\\_allow\\\_r\\\_aud}}, s\_{a,s}, \text{op}\_i) + r\_a$$ (auditor-side escrow of S6's allowance blinding over the S\_a2 shared scalar, Section 8.5; the blinding is already a witness by S6, so this is one Poseidon and no new scalar multiplication) | +| S14 | $$\tilde{r}\_{a,\text{aud,s}} = \text{Poseidon}(\delta\_{\text{esc\\\_allow\\\_r\\\_aud}}, s\_{a,s}, \text{op}\_i) + r\_a$$ | | S\_a1 | $$R\_e = r\_e \cdot H$$ (ephemeral key for auditor ECDH) | | S\_a2 | $$s\_{a,s} = \text{ECDH}(r\_e, K\_{\text{aud,s}})$$ (owner-auditor ECDH shared scalar, §2.4) | | S\_a3 | $$(m\_v, m\_b, m\_r) = \text{SpongeSqueeze}\_3(\delta\_{\text{aud\\\_s}}, s\_{a,s}, \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) | -| S\_a6 | $$\tilde{r}\_{\text{aud,s}} = r' + m\_r$$ (owner-auditor escrow of the new spendable blinding, over S9's $$r'$$; with S\_a5 this hands the owner's auditor the opening of $$C\_{\text{spend}}'$$, Section 8.1) | +| S\_a6 | $$\tilde{r}\_{\text{aud,s}} = r' + m\_r$$ (owner-auditor escrow of the new spendable blinding, over S9's $$r'$$) | **Public inputs (26 fields):** @@ -742,8 +739,8 @@ The spender transfers from the owner's escrowed allowance to a recipient. | O10 | $$r\_a' = \text{Poseidon}(\delta\_{\text{allow\\\_r}}, dvk\_i, \sigma\_a')$$ (new allowance randomness) | | O11 | $$C\_a' = (v\_a - v\_{\text{transfer}}) \cdot G + r\_a' \cdot H$$ (new allowance) | | O12 | $$\tilde{a}' = (v\_a - v\_{\text{transfer}}) + \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'$$) | -| O14 | $$\sigma\_a' \neq \sigma\_a$$ (nonce rotation; rejects the one-step pad reuse, §6.2 *Transfer nonce*) | +| O13 | $$r\_e \neq 0$$ (rules out $$R\_e = \mathcal{O}$$ and $$S, S\_{a,r}, S\_{a,s} = \mathcal{O}$$) | +| O14 | $$\sigma\_a' \neq \sigma\_a$$ (nonce rotation) | | O\_a1 | $$s\_{a,r} = \text{ECDH}(r\_e, K\_{\text{aud,r}})$$ (recipient-auditor ECDH shared scalar, reuses ephemeral scalar) | | O\_a2 | $$(m\_{v,r}, m\_{r,r}) = \text{SpongeSqueeze}\_2(\delta\_{\text{aud\\\_r}}, s\_{a,r}, \sigma\_a')$$ (recipient-auditor channel masks) | | O\_a3 | $$\tilde{v}\_{\text{aud,r}} = v\_{\text{transfer}} + m\_{v,r}$$ (recipient-auditor encrypted transfer amount) | @@ -752,7 +749,7 @@ The spender transfers from the owner's escrowed allowance to a recipient. | O\_a6 | $$(m\_{v,s}, m\_{a,s}, m\_{r,s}) = \text{SpongeSqueeze}\_3(\delta\_{\text{aud\\\_s}}, s\_{a,s}, \sigma\_a')$$ (owner-auditor channel masks) | | O\_a7 | $$\tilde{v}\_{\text{aud,s}} = v\_{\text{transfer}} + m\_{v,s}$$ (owner-auditor encrypted transfer amount) | | O\_a8 | $$\tilde{a}\_{\text{aud,s}} = (v\_a - v\_{\text{transfer}}) + m\_{a,s}$$ (owner-auditor encrypted post-transfer allowance) | -| O\_a9 | $$\tilde{r}\_{\text{aud,s}} = r\_a' + m\_{r,s}$$ (owner-auditor escrow of the NEW allowance blinding, the one O11 commits under, Section 8.5; already a witness by O10, so this is one field addition. Pad and plaintext both key off $$\sigma\_a'$$, under distinct domain tags and distinct secrets) | +| O\_a9 | $$\tilde{r}\_{\text{aud,s}} = r\_a' + m\_{r,s}$$ (owner-auditor escrow of the new allowance blinding, over O10's $$r\_a'$$) | **Public inputs (25 fields):** @@ -777,43 +774,24 @@ The spender transfers from the owner's escrowed allowance to a recipient. ### 7.9 Revoke Spender -The owner reclaims the remaining escrowed allowance. +The owner reclaims the remaining escrowed allowance. Like merge (§7.4), revocation is a homomorphic fold and carries no proof: nothing is re-randomized and no private value is asserted. The escrowed value was range-proven at S4 and re-bounded by O4 on every spender transfer, and the next spend re-bounds the result under W4 / T4 -- the posture Section 2.6 already accepts for the receiving balance. -**Circuit constraints (RevokeSpender):** +**Contract logic (no proof):** -| # | Constraint | -|:--|:---| -| V1 | $$Y = sk \cdot H$$ (owner key ownership) | -| V2 | $$vk = \text{Poseidon}(\delta\_{\text{vk}}, sk, \text{addr\\\_f})$$ (binds proof to contract) | -| 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} = \text{ECDH}(r\_e, K\_{\text{aud,s}})$$ (owner-auditor ECDH shared scalar, §2.4) | -| V\_a3 | $$(m\_v, m\_b) = \text{SpongeSqueeze}\_2(\delta\_{\text{aud\\\_s}}, s\_{a,s}, \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):** +``` +require account.require_auth() +C_spend ← C_spend + C_a +delete Delegation(account, spender) +emit RevokeSpender(account, spender, a_tilde, allowance_salt) +``` -| Input | Notes | -|:---|:---| -| $$C\_{\text{spend}}$$ | Loaded from owner's `spendable_commitment` | -| $$C\_a$$, $$\sigma\_a$$ | Loaded from the `(account, spender)` delegation entry | -| $$Y$$ | Loaded from owner's `spending_public_key` | -| $$\text{op}\_i$$ | $$\text{address\\\_to\\\_field}$$(`spender` argument), computed per-call by the contract (§2.7) | -| $$\text{addr\\\_f}$$ | 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_commitment`, delegation entry deleted, the rest emitted in event | +The fold works for both active and expired-but-not-revoked delegations (§6.2). + +**Owner state update.** By Proposition 1 applied to the allowance commitment, the post-revoke opening is $$(v\_s + v\_a, \\; r\_s + r\_a)$$. The owner derives $$dvk\_i$$ from $$vk$$ (§4.4), recovers $$v\_a = \tilde{a} - \text{Poseidon}(\delta\_{\text{enc\\\_allow}}, dvk\_i, \sigma\_a)$$ and $$r\_a = \text{Poseidon}(\delta\_{\text{allow\\\_r}}, dvk\_i, \sigma\_a)$$ from the event, and applies $$W\_{\text{spend}} \mathrel{+}= (v\_a, r\_a)$$ (§5.2). -**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 [DESIGN_cont.md](./DESIGN_cont.md) §10.4 *Post-merge witness availability*), $$r\_e$$. +**Why the event carries $$\tilde{a}$$ and $$\sigma\_a$$.** The fold deletes the delegation entry in the same invocation, so neither field is readable from storage afterwards. `SpenderTransfer` writes its $$\tilde{a}'$$ to storage only. $$\sigma\_a$$ reaches events only through `SpenderTransfer`, which emits the replacement $$\sigma\_a'$$ it writes and not the salt it consumed (§6.2 *Transfer nonce*): the salt this fold consumes is therefore the `sigma_a_new` of the most recent `SpenderTransfer`, recoverable only by scanning back to it, and for a delegation revoked without ever being spent from it is published nowhere, since `SetSpender` emits the owner's spendable salt $$\sigma$$ and no allowance salt. -**Post-verification:** The contract verifies the proof, sets `spendable_commitment` $$= 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}})$$. +**Encrypted balance.** Revocation emits no $$\tilde{b}$$ and is not a checkpoint (§5.2); the next owner-initiated proof operation issues a fresh checkpoint. The auditor carries its opening of $$C\_{\text{spend}}$$ through the fold as Section 8.1 specifies, using the opening of $$C\_a$$ it holds from the delegation's last state change (Section 8.5). ### 7.10 Owner Operations with Active Spenders @@ -837,11 +815,7 @@ The spender decrypts using $$sk\_{\text{op}}$$. The `set_spender` proof enforces The $$r\_e$$ here is the same scalar S\_a1 commits to ($$R\_e = r\_e \cdot H$$), so the escrow's $$R\_x$$ and the auditor channel's $$R\_e.x$$ are forced equal. -The same proof also escrows to the owner's *auditor* (S14), under its own domain tag and over the auditor shared scalar -- but what it escrows is the allowance blinding $$r\_a$$, not a second copy of $$dvk\_i$$. The auditor needs the opening of $$C\_a$$, not the generator of every opening, and the delegation key is permanent per pair -- it survives revocation and re-delegation to the same address -- so escrowing it would make one leaked ciphertext a permanent capability. That construction, its decryption path, and why it is a single-output pad rather than a sponge lane are specified in [DESIGN_cont.md](./DESIGN_cont.md) §8.5. - -### 7.12 Expiry and Revert Safety - -A reverted transaction leaves the delegation entry unchanged, so the stored `allowance_salt` recurs on the retry. Every value a retry could vary is therefore keyed to the prover-chosen $$\sigma\_a'$$ (§6.2 *Transfer nonce*). Delegation storage, expiry, and revocation semantics are specified in §6.2. +The same proof also escrows the allowance blinding $$r\_a$$ to the owner's auditor (S14), under its own domain tag over the auditor shared scalar; the construction and its decryption path are specified in [DESIGN_cont.md](./DESIGN_cont.md) §8.5. --- diff --git a/packages/tokens/src/confidential/docs/DESIGN_cont.md b/packages/tokens/src/confidential/docs/DESIGN_cont.md index 9319a4102..ec670114d 100644 --- a/packages/tokens/src/confidential/docs/DESIGN_cont.md +++ b/packages/tokens/src/confidential/docs/DESIGN_cont.md @@ -23,13 +23,13 @@ $$s\_{a,r} = \text{ECDH}(r\_e, K\_{\text{aud,r}}) \qquad \text{(DESIGN §2.4)}$$ $$(m\_{v,r}, m\_{r,r}) = \text{SpongeSqueeze}\_2(\delta\_{\text{aud\\\_r}}, s\_{a,r}, \sigma)$$ $$\tilde{v}\_{\text{aud,r}} = v\_{\text{transfer}} + m\_{v,r}, \qquad \tilde{r}\_{\text{aud,r}} = r\_{\text{transfer}} + m\_{r,r}$$ -**Sender's auditor** ($$K\_{\text{aud,s}}$$, from the sender's `auditor_id`) receives the transfer amount, the sender's post-transfer balance, and -- in the `lane[2]` blinding-escrow slot (DESIGN §2.5 *Lane assignment*) -- the sender's post-transfer spendable blinding $$r\_A'$$: +**Sender's auditor** ($$K\_{\text{aud,s}}$$, from the sender's `auditor_id`) receives the transfer amount, the sender's post-transfer balance, and the sender's post-transfer spendable blinding $$r\_A'$$: $$s\_{a,s} = \text{ECDH}(r\_e, K\_{\text{aud,s}}) \qquad \text{(DESIGN §2.4)}$$ $$(m\_{v,s}, m\_{b,s}, m\_{r,s}) = \text{SpongeSqueeze}\_3(\delta\_{\text{aud\\\_s}}, s\_{a,s}, \sigma)$$ $$\tilde{v}\_{\text{aud,s}} = v\_{\text{transfer}} + m\_{v,s}, \qquad \tilde{b}\_{\text{aud,s}} = (v\_A - v\_{\text{transfer}}) + m\_{b,s}, \qquad \tilde{r}\_{\text{aud,s}} = r\_A' + m\_{r,s}$$ -The transfer circuit (constraints T\_a1--T\_a9) enforces correct computation. At operation time, the contract fetches both auditor keys from the auditor contract using the *stored* `auditor_id` field of each account; neither the sender nor the recipient can substitute a different key for the operation being proven. This guarantee is scoped to operation time: *which* auditor an account is bound to is chosen by the account owner at registration (DESIGN §7.2), subject only to existence in the auditor registry unless the deployment gates the selection in its `Hooks::on_register` implementation ([COMPLIANCE.md](./COMPLIANCE.md) §4.3). +The transfer circuit (constraints T\_a1--T\_a9) enforces correct computation. Which auditor an account is bound to is chosen by the account owner at registration (DESIGN §7.2), subject only to existence in the auditor registry unless the deployment gates the selection in its `Hooks::on_register` implementation ([COMPLIANCE.md](./COMPLIANCE.md) §4.3). Each auditor decrypts using their secret key $$k$$. For example, the sender's auditor: @@ -39,8 +39,6 @@ $$v\_{\text{transfer}} = \tilde{v}\_{\text{aud,s}} - m\_{v,s}, \qquad v\_{\text{ 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{transfer}}, r\_{\text{transfer}})$$. -**The `lane[2]` slot.** The `lane[2]` output of the sender-auditor channel (DESIGN §2.5 *Lane assignment*) carries the blinding of a commitment the operation writes, and never a key: the new spendable blinding on the three checkpoint operations (W\_a5, T\_a9, S\_a6), and the new allowance blinding $$r\_a'$$ on spender transfers (O\_a9, Section 8.4). The two blindings belong to different commitment roles, so the uniformity is a naming convention, not an interchangeability claim; each is scoped to the operation that emitted it. The pad is fixed by $$(s\_{a,s}, \sigma)$$ or $$(s\_{a,s}, \sigma\_a')$$, both fresh per operation. - **Recipient-auditor opening capability.** Because the recipient-auditor recovers $$r\_{\text{transfer}}$$ for every inbound transfer, and because deposits add to `receiving_commitment` 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{transfer},i} + \sum\_j a\_j, \qquad r\_r = \sum\_i r\_{\text{transfer},i}$$ @@ -50,19 +48,17 @@ where $$i$$ ranges over inbound transfers and spender-transfers since the last m The capability is bounded in three ways: - **Forward-only.** Only events emitted while the auditor key was active are decryptable. -- **Receiving-side only.** The reconstruction above covers `receiving_commitment`. It does not extend to $$C\_{\text{spend}}$$: the recipient-auditor cannot derive the spend-side blinding $$r\_s = \text{Poseidon}(\delta\_{\text{spend\\\_r}}, vk\_A, \sigma)$$, which depends on $$vk\_A$$. It 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. The spend-side opening reaches the *sender*-auditor by a different route -- the `lane[2]` escrow, bounded separately below -- not by this reconstruction. +- **Recipient-channel only.** The reconstruction above yields an opening of `receiving_commitment`: the spend-side blinding $$r\_s = \text{Poseidon}(\delta\_{\text{spend\\\_r}}, vk\_A, \sigma)$$ depends on $$vk\_A$$ and is not derivable from the recipient channel. - **Reset by merge.** Merge folds $$r\_r$$ into the spendable-balance randomness ($$r\_{\text{spend}}' = r\_s + r\_r$$, Section 7.4) and emits no checkpoint, so the reconstruction above restarts from the next inbound flow. -**Sender-auditor opening capability.** The `lane[2]` escrow hands the sender-auditor the *blinding* of the account's post-operation spendable balance directly, without $$vk\_A$$: together with the value in $$\tilde{b}\_{\text{aud,s}}$$ it is a full Pedersen opening of $$C\_{\text{spend}}'$$. It is available at exactly the three checkpoint operations that escrow `lane[2]` -- withdrawal (W\_a5), outgoing transfer (T\_a9), and `set_spender` (S\_a6) -- and is likewise bounded: +**Sender-auditor opening capability.** The `lane[2]` escrow hands the sender-auditor the *blinding* of the account's post-operation spendable balance directly, without $$vk\_A$$: together with the value in $$\tilde{b}\_{\text{aud,s}}$$ it is a full Pedersen opening of $$C\_{\text{spend}}'$$. It is available at the three checkpoint operations that escrow the spendable blinding -- withdrawal (W\_a5), outgoing transfer (T\_a9), and `set_spender` (S\_a6) -- and is likewise bounded: - **Forward-only**, on the same grounds as the recipient side. -- **Maintained across merges.** An account binds a single `auditor_id` (Section 6.1), so the key that decrypts the `lane[2]` escrow is the same key that decrypts the recipient channel of every inbound flow to that account. Merge adds both the values and the blindings ($$v\_{\text{spend}}' = v\_s + v\_r$$, $$r\_{\text{spend}}' = r\_s + r\_r$$, Section 7.4), and the auditor holds each addend: $$(v\_{\text{transfer},i}, r\_{\text{transfer},i})$$ from the recipient-channel reconstruction above, and $$(a\_j, 0)$$ from the public deposits. It therefore carries the escrowed opening forward through every merge by the same addition the contract performs, rather than losing it at one. -- **Not renewed by `revoke_spender`.** V\_a3 stays two-lane (DESIGN §7.9), so a revoke rewrites $$C\_{\text{spend}}$$ under a blinding the auditor never receives and leaves it with the post-reclaim *value* alone. The opening is re-acquired at the next checkpoint (W\_a5, T\_a9, S\_a6). -- **Rotation-scoped.** A newly activated key cannot decrypt escrows published under the previous one (Section 8.3). An auditor that carries its accumulated opening across the rotation keeps it; one that bootstraps from the new key alone re-acquires it at the next checkpoint. - -The recipient-side reconstruction is therefore event-scoped, while the sender-side escrow gives the account's auditor a **standing** opening of $$C\_{\text{spend}}$$, held continuously from its first checkpoint under the active key. +- **Maintained across merges.** An account binds a single `auditor_id` (Section 6.1), so the key that decrypts the `lane[2]` escrow is the same key that decrypts the recipient channel of every inbound flow to that account. Merge adds both the values and the blindings ($$v\_{\text{spend}}' = v\_s + v\_r$$, $$r\_{\text{spend}}' = r\_s + r\_r$$, Section 7.4). An auditor that decrypted the inbound flows since the previous merge holds each addend -- $$(v\_{\text{transfer},i}, r\_{\text{transfer},i})$$ from the recipient-channel reconstruction above, and $$(a\_j, 0)$$ from the public deposits -- and carries the escrowed opening forward by the same addition the contract performs. +- **Carried through `revoke_spender`.** The revoke is a public homomorphic fold, $$C\_{\text{spend}}' = C\_{\text{spend}} + C\_a$$, emitting no new checkpoint (Section 7.9). An auditor that decrypted the delegation's last state-changing event holds the opening of the $$C\_a$$ being folded (Section 8.5) and advances its own opening by the same addition the contract performs. +- **Rotation-scoped.** Both folds above depend on the auditor having decrypted the event that produced the addend; Section 8.3 *Decryption capability across rotation* states what a rotated key can and cannot open. -These bounded openings are what enable 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, while the sender-auditor is the seize-enabling party for the spendable-balance side via $$\tilde{b}\_{\text{aud,s}}$$ and $$\tilde{r}\_{\text{aud,s}}$$. +These bounded openings are what the clawback flow specified in [COMPLIANCE.md](./COMPLIANCE.md) §5 draws its witness from. ### 8.2 Auditor Visibility Properties @@ -73,26 +69,30 @@ These bounded openings are what enable the clawback flow specified in [COMPLIANC - **Outgoing transfer**: auditor decrypts post-transfer balance $$(v\_A - v\_{\text{transfer}})$$ from $$\tilde{b}\_{\text{aud,s}}$$ and the matching blinding $$r\_A'$$ from $$\tilde{r}\_{\text{aud,s}}$$ (constraints T\_a5--T\_a9). - **Withdrawal**: auditor decrypts post-withdrawal balance $$(v - a)$$ from $$\tilde{b}\_{\text{aud,s}}$$ and the matching blinding $$r'$$ from $$\tilde{r}\_{\text{aud,s}}$$ (constraints W\_a1--W\_a5). The withdrawal amount $$a$$ is also visible as a public input. - **Set spender**: auditor decrypts escrowed amount $$v\_a$$ from $$\tilde{v}\_{\text{aud,s}}$$, post-escrow balance $$(v - v\_a)$$ from $$\tilde{b}\_{\text{aud,s}}$$, and the matching blinding $$r'$$ from $$\tilde{r}\_{\text{aud,s}}$$ (constraints S\_a1--S\_a6). -- **Revoke spender**: 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). This is the one checkpoint operation that escrows no blinding: V\_a3 stays two-lane. -The three operations that also escrow the post-operation spendable blinding confer the opening capability bounded in Section 8.1. The recipient's auditor does not see the sender's balance in any of these operations. +The recipient's auditor does not see the sender's balance in any of these operations. -**Per-transfer Pedersen randomness (recipient-auditor, not sender-auditor).** Beyond the transfer amount, the recipient's auditor also decrypts the per-transfer Pedersen blinding $$r\_{\text{transfer}}$$ from $$\tilde{r}\_{\text{aud,r}}$$ on every confidential transfer and spender-transfer; Section 8.1 states the opening capability this confers and its bounds (forward-only, receiving-side only, reset by merge). The sender's auditor does not see $$r\_{\text{transfer}}$$. The *originating account's own* $$vk$$ holder does, by recomputing $$r\_e$$ and hence $$s$$ (DESIGN.md §5.3); that path lies outside the auditor model and its consequences are stated in §9.4. +**Per-transfer Pedersen randomness (recipient-auditor).** Beyond the transfer amount, the recipient's auditor also decrypts the per-transfer Pedersen blinding $$r\_{\text{transfer}}$$ from $$\tilde{r}\_{\text{aud,r}}$$ on every confidential transfer and spender-transfer. The sender's auditor does not see $$r\_{\text{transfer}}$$. -**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. - -**Not exclusive to the recipient-auditor.** Because $$r\_e$$ is derived from the originator's viewing key and the operation salt (DESIGN.md §5.3), each transfer's originator can recompute $$r\_e$$, hence $$s$$, hence $$r\_{\text{transfer}}$$ — so the opening of any single $$C\_{\text{transfer},i}$$ is held both by the recipient's auditor and by the originating account's $$vk$$ holder. +**Key rotation.** When the auditor contract sets a new key under the account's `auditor_id` (§8.3), the new key reads the balance checkpoint at the next owner-initiated operation with no event replay: the checkpoint is self-contained, depending only on the auditor's ECDH secret key and the published $$(R\_e, \sigma)$$. Reading one checkpoint is not the same as holding the account's accumulated opening; Section 8.3 states what a rotated key can open. Note that `auditor_id` itself is immutable per account (§6.1); only the key under that `auditor_id` rotates. ### 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, in which case rotation appends a new entry rather than overwriting the previous one. The reference registry shipped in this repository takes the simpler form: it keeps a single current key per `auditor_id`, which `rotate_key` overwrites in place; a versioned, activation-ledger registry is an optional production target. -When building public inputs for any operation that produces auditor ciphertexts (transfers, withdrawals, set/revoke spender), the contract fetches the relevant auditor keys for the recipient's and/or sender's `auditor_id`. The contract passes the full Grumpkin point as a public input; the circuit constrains the ECDH ciphertexts against that exact point. The contract and the circuit are version-agnostic: they verify against whichever key the auditor contract currently exposes. +When building public inputs for any operation that produces auditor ciphertexts (transfers, withdrawals, `set_spender`), the contract fetches the relevant auditor keys for the recipient's and/or sender's `auditor_id`. The contract passes the full Grumpkin point as a public input; the circuit constrains the ECDH ciphertexts against that exact point. The contract 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 contract 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 spender) reconstructs the proof against the new $$K\_{\text{aud}}$$ and resubmits. The rejection is benign: the contract's spendable balance, receiving balance, and delegation state are unchanged by the reverted call, the operation's salt 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 resolves the version from its own rotation records (with a versioned activation-ledger registry, the auditor instead 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. +**Decryption capability across rotation.** A key decrypts an auditor ciphertext exactly when it was the active key at the moment that ciphertext was produced. Rotation therefore affects the two holders asymmetrically: + +- **Retired key.** Its holder keeps what it already opened. Rotation does not revoke a held opening, and the proofless folds advance it exactly as Section 8.1 specifies. +- **New key.** Its holder starts with nothing and re-anchors per commitment: at the next checkpoint operation for $$C\_{\text{spend}}$$ (Section 8.2), and at the delegation's next state-changing operation for $$C\_a$$ (Section 8.5). + +Each proofless fold consumes an addend produced by an earlier event, and a holder that bootstrapped from the new key alone cannot open an addend that predates the rotation. Such a holder loses its opening at every fold that consumes one, and re-anchors only at the next checkpoint operation following it. A merge's addends reach back only as far as the previous merge; a delegation's last state change is bounded only by its entry TTL. + ### 8.4 Spender Transfer Auditing Each spender transfer produces auditor ciphertexts under two keys (constraints O\_a1--O\_a9), following the same dual-auditor sponge model as owner transfers. The recipient's auditor decrypts the transfer amount and the per-transfer Pedersen randomness: @@ -100,37 +100,30 @@ Each spender transfer produces auditor ciphertexts under two keys (constraints O $$(m\_{v,r}, m\_{r,r}) = \text{SpongeSqueeze}\_2(\delta\_{\text{aud\\\_r}}, s\_{a,r}, \sigma\_a')$$ $$v\_{\text{transfer}} = \tilde{v}\_{\text{aud,r}} - m\_{v,r}, \qquad r\_{\text{transfer}} = \tilde{r}\_{\text{aud,r}} - m\_{r,r}$$ -The owner's auditor decrypts the transfer amount, the post-transfer allowance, and -- in the `lane[2]` blinding-escrow slot -- the *new* allowance blinding $$r\_a'$$, the one O11 commits $$C\_a'$$ under (O\_a9): +The owner's auditor decrypts the transfer amount, the post-transfer allowance, and the *new* allowance blinding $$r\_a'$$, the one O11 commits $$C\_a'$$ under (O\_a9): $$(m\_{v,s}, m\_{a,s}, m\_{r,s}) = \text{SpongeSqueeze}\_3(\delta\_{\text{aud\\\_s}}, s\_{a,s}, \sigma\_a')$$ $$v\_{\text{transfer}} = \tilde{v}\_{\text{aud,s}} - m\_{v,s}, \qquad v\_a' = \tilde{a}\_{\text{aud,s}} - m\_{a,s}, \qquad r\_a' = \tilde{r}\_{\text{aud,s}} - m\_{r,s}$$ -$$(\tilde{a}\_{\text{aud,s}}, \tilde{r}\_{\text{aud,s}})$$ open the post-transfer $$C\_a'$$, the state left on-chain. - 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 spender-transfer inbound flows: $$r\_{\text{transfer}}$$ from spender-transfers contributes to $$r\_r$$ in $$C\_{\text{receive}}$$ identically to owner-transfer inbound flows. -### 8.5 Spender Allowance Auditing - -The auditor tracks each allowance's current value through the per-event ciphertexts produced at every state-changing operation: `set_spender` 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_spender` reveals the reclaimed amount (Section 8.2). - -**Allowance opening.** The owner's auditor also receives the *blinding* of the allowance commitment each event writes: $$r\_a$$ under $$\delta\_{\text{esc\\\_allow\\\_r\\\_aud}}$$ at `set_spender` (S14, below) and $$r\_a'$$ in `lane[2]` at every spender transfer (O\_a9, §8.4). Paired with the value that event already publishes -- $$\tilde{v}\_{\text{aud,s}}$$ on `SetSpender`, $$\tilde{a}\_{\text{aud,s}}$$ on `SpenderTransfer` -- that is the full Pedersen opening of the $$C\_a$$ left on-chain, reconstructed from the event alone with no storage read. Unlike the spendable side there is no merge to fold in, since a delegation's only state transitions are the events themselves. +$$(\tilde{a}\_{\text{aud,s}}, \tilde{r}\_{\text{aud,s}})$$ open the post-transfer $$C\_a'$$, the state left on-chain. -What is escrowed is one state's blinding, not the generator of every state. $$dvk\_i = \text{Poseidon}(\delta\_{\text{dvk}}, vk, \text{op}\_i)$$ is deterministic and permanent for a $$(owner, spender)$$ pair -- neither the salt nor any generation counter enters it, so `revoke_spender` followed by re-delegation to the same address regenerates it exactly. A single leaked $$dvk\_i$$ ciphertext would therefore hand its holder every allowance value and blinding for that pair, past and future; a leaked $$r\_a$$ ciphertext costs one state. +### 8.5 Spender Allowance Auditing -**Auditor-side allowance-blinding escrow.** At `set_spender` the owner escrows $$r\_a$$ -- the blinding S6 derived and S7 committed under -- to its own auditor: +The auditor tracks each allowance's current value from the per-event ciphertexts of its two state-changing operations, `set_spender` (Section 8.2) and `confidential_transfer_from` (Section 8.4). -$$\tilde{r}\_{a,\text{aud,s}} = r\_a + \text{Poseidon}(\delta\_{\text{esc\\\_allow\\\_r\\\_aud}}, s\_{a,s}, \text{op}\_i)$$ +**Allowance opening.** The owner's auditor also receives the *blinding* of the allowance commitment each event writes: $$r\_a$$ under $$\delta\_{\text{esc\\\_allow\\\_r\\\_aud}}$$ at `set_spender` (S14, below) and $$r\_a'$$ in `lane[2]` at every spender transfer (O\_a9, §8.4). Paired with the value that event already publishes -- $$\tilde{v}\_{\text{aud,s}}$$ on `SetSpender`, $$\tilde{a}\_{\text{aud,s}}$$ on `SpenderTransfer` -- that is the full Pedersen opening of the $$C\_a$$ left on-chain, reconstructed from the event alone with no storage read. Unlike the spendable side there is no merge to fold in, since a delegation's only state transitions are the events themselves. This is what `revoke_spender` folds against: it publishes no ciphertext of its own (§7.9), and the auditor opens the $$C\_a$$ from whichever event last wrote it. -reusing the S\_a2 shared scalar $$s\_{a,s} = \text{ECDH}(r\_e, K\_{\text{aud,s}})$$ rather than opening a new ECDH channel, so it costs one Poseidon and no scalar multiplication (DESIGN S14). The auditor recovers $$s\_{a,s}$$ from $$k\_{\text{aud,s}}$$ and the event's $$R\_e$$, and $$\text{op}\_i$$ from the event's `spender` topic, then subtracts. Because the escrowed value is pinned to S6, the opening the auditor holds necessarily matches the $$C\_a$$ the same proof wrote. +**Auditor-side allowance-blinding escrow.** At `set_spender` the owner escrows $$r\_a$$ to its own auditor. A blinding is per state, so one leaked escrow ciphertext opens one allowance state. -A single-output pad rather than a sponge lane, because `lane[2]` of this channel is already taken by the spendable blinding (S\_a6). What separates it from that channel is tag distinctness: the tag is the first absorbed element, so no pair of remaining inputs makes $$\delta\_{\text{esc\\\_allow\\\_r\\\_aud}}$$ and $$\delta\_{\text{aud\\\_s}}$$ agree. DESIGN §2.5 *Mode exclusivity* is a separate per-tag obligation that each satisfies independently -- 17 is only ever single-output, 11 only ever multi-lane. $$\text{op}\_i$$ is a per-$$(owner, spender)$$ constant that separates pads across delegations, not a nonce: the pad's freshness rests entirely on $$s\_{a,s}$$, hence on $$r\_e$$, hence on the salt (DESIGN §5.3, §9.6). Re-delegating to the same spender under a reused salt would republish a byte-identical ciphertext. +$$\tilde{r}\_{a,\text{aud,s}} = \text{Poseidon}(\delta\_{\text{esc\\\_allow\\\_r\\\_aud}}, s\_{a,s}, \text{op}\_i) + r\_a$$ -**Key rotation.** Decryption is scoped to events, and only to events. A key can decrypt an allowance ciphertext exactly when it was the active key at the moment that ciphertext was produced. Two consequences, and neither is stronger than that: +reusing the S\_a2 shared scalar $$s\_{a,s} = \text{ECDH}(r\_e, K\_{\text{aud,s}})$$ rather than opening a new ECDH channel; §10.3 gives the cost. The auditor recovers $$s\_{a,s}$$ from $$k\_{\text{aud,s}}$$ and the event's $$R\_e$$, and $$\text{op}\_i$$ from the event's `spender` topic, then subtracts. Because the escrowed value is pinned to S6, the opening the auditor holds necessarily matches the $$C\_a$$ the same proof wrote. -- A freshly rotated-in key holds no opening for a live delegation. It acquires one at that delegation's next state-changing operation, when a fresh escrow is produced under it, and not before -- rotation does not re-anchor anything on its own. -- A retired key does **not** lose track of state it already opened. Proofless folds -- merge (§7.4), and `revoke_spender` where the deployment runs it without a proof -- recompute $$C\_{\text{spend}}' = C\_{\text{spend}} + C\_a$$ publicly, emitting no new encrypted checkpoint, so a holder of an already-known opening carries it forward by the same addition the contract performs. Containment here is **event-scoped, not state-scoped**: rotation bounds what a key can newly decrypt, not what its holder can still compute. +It is a single-output pad because `lane[2]` of this channel is taken by the spendable blinding (S\_a6). Distinct tags separate the two despite the shared scalar, each confined to one sponge mode as DESIGN §2.5 *Mode exclusivity* requires. Its pad freshness is that of $$r\_e$$ (DESIGN §5.3 *Why reusing $$r\_e$$ is safe*). -**Archive dependence.** The auditor must have *observed* the event. There is no state-based fallback: the escrowed blinding lives in the event and nowhere in contract storage, so an auditor that missed a delegation event cannot recover that opening from `a_tilde` and $$\sigma\_a$$ the way a $$dvk\_i$$ holder could. This extends an existing dependency rather than creating one -- DESIGN.md §5.2 already makes a durable event archive normative, and the recipient-side reconstruction, the spendable checkpoint, and the merge carry-forward are all event-scoped already. [INDEXER.md](./INDEXER.md) §7.1 states the operational consequences: a consistency check of each reconstructed opening against the stored `allowance_commitment` is the practical detection mechanism for a missed or reordered event, and delegation-entry TTL bounds how long that check remains possible. +**Archive dependence.** The auditor must have *observed* the event: the escrowed blinding lives nowhere in contract storage, so an auditor that missed a delegation event cannot recover that opening from `a_tilde` and $$\sigma\_a$$ the way a $$dvk\_i$$ holder could. DESIGN.md §5.2 already makes a durable event archive normative; [INDEXER.md](./INDEXER.md) §7.1 states the operational consequences for auditor clients. --- @@ -171,7 +164,7 @@ This invariant is maintained by: - **Merge** moves value from $$v\_{\text{receive}}$$ to $$v\_{\text{spend}}$$ (Proposition 1); the sum is unchanged. - **Set spender** moves value from $$v\_{\text{spend}}$$ to $$v\_{\text{allowance}\_i}$$; enforced by S3–S7. - **Spender transfer** decreases $$v\_{\text{allowance}\_i}$$ and increases recipient's $$v\_{\text{receive}}$$ by $$v\_{\text{transfer}}$$; enforced by O2–O8. -- **Revoke** moves remaining $$v\_{\text{allowance}\_i}$$ back to $$v\_{\text{spend}}$$; enforced by V4–V7. +- **Revoke** moves remaining $$v\_{\text{allowance}\_i}$$ back to $$v\_{\text{spend}}$$ (Proposition 1); the sum is unchanged. ### 9.4 Privacy Properties @@ -185,17 +178,17 @@ This invariant is maintained by: Two properties of that last capability deserve stating plainly. It is **retroactive**: the derivation is deterministic in $$(vk, \sigma)$$ and $$\sigma$$ is published, so a compromise today opens every transfer the account ever originated. And it **reaches outside the account's own state**: the commitments it opens were added to *recipients'* `receiving_commitment` values. The transfer *amounts* were already inferable from $$vk$$ alone, by differencing consecutive balance checkpoints and netting the inbound credits and merges between them, so amount visibility is not what changes; the openings are, and an opening is a self-verifying artifact that its holder can hand to any third party. This is why $$vk$$ cannot be treated as a safely shareable read-only credential ([SDK.md](./SDK.md) §13): a counterparty that needs visibility into an account's outbound transfers receives per-event disclosure proofs bound to it and to a nonce ([SELECTIVE_DISCLOSURE.md](./SELECTIVE_DISCLOSURE.md) §7), not the key. -**Auditor key compromise.** An account binds one key for both channels (Section 6.1), so a compromise is best read per account rather than per role. For every account that used the compromised key, the attacker acquires: all amounts and balance checkpoints; the standing opening of $$C\_{\text{spend}}$$ from the first post-activation checkpoint, maintained across merges exactly as Section 8.1 describes; the opening of $$C\_{\text{receive}}$$ between merges; and every $$dvk\_i$$, hence the opening of every $$C\_a$$ (Section 8.5). It cannot recover viewing keys, read data from before the key was active, or authorize any spending. After key rotation, new operations are protected by the new key. +**Auditor key compromise.** An account binds one key for both channels (Section 6.1). For every account that used the compromised key, the attacker acquires: all amounts and balance checkpoints; the standing opening of $$C\_{\text{spend}}$$ from the first post-activation checkpoint, maintained across merges exactly as Section 8.1 describes; the opening of $$C\_{\text{receive}}$$ between merges; and the escrowed allowance blinding $$r\_a$$ from every delegation event it observed, hence the opening of each $$C\_a$$ those events wrote (Section 8.5). It cannot recover viewing keys, read data from before the key was active, or authorize any spending. After key rotation, new operations are protected by the new key. -As with $$vk$$ above, what the openings add over the amounts is that an opening is a self-verifying artifact its holder can hand to any third party. An auditor key is therefore custodied at the same level as a viewing key, not at the level of a read-only amount feed ([SDK.md](./SDK.md) §13). +As with $$vk$$ above, the openings are self-verifying artifacts a holder can hand to any third party, so an auditor key is custodied at the same level as a viewing key ([SDK.md](./SDK.md) §13). ### 9.5 State Recovery The recovery procedure, the definition of the **checkpoint** it is built around, the replay window it runs over, and the durable-archive requirement it depends on are all specified in [DESIGN.md](./DESIGN.md) §5.2 *Recovery*. This section states the security properties that follow from it. -**Why the two anchors compose.** No `Merge` falls strictly between $$T\_0$$ and the checkpoint, by construction of $$T\_0$$ as the last merge at or before it. Within $$(T\_0, \text{checkpoint}]$$ the only spendable-affecting events are therefore checkpoint events, and the latest checkpoint's $$(\tilde{b}, \sigma)$$ is the final word on $$W\_{\text{spend}}$$ regardless of them — which is why the replay skips them ([DESIGN.md](./DESIGN.md) §5.2 *Recovery*, step 6). Any `Merge` the replay encounters necessarily sits after the checkpoint and is folded normally, against the receiving opening it actually consumed. +**Why the two anchors compose.** No `Merge` falls strictly between $$T\_0$$ and the checkpoint, by construction of $$T\_0$$ as the last merge at or before it. Within $$(T\_0, \text{checkpoint}]$$ the only spendable-affecting events are therefore checkpoint events and `RevokeSpender` folds, and the latest checkpoint's $$(\tilde{b}, \sigma)$$ supersedes both — which is why the replay skips them ([DESIGN.md](./DESIGN.md) §5.2 *Recovery*, step 6). Any `Merge` the replay encounters necessarily sits after the checkpoint and is folded normally, against the receiving opening it actually consumed. -**Recoverability.** An owner who holds $$vk$$ reconstructs both openings deterministically from the account's event history, so the loss of local wallet state is not a loss of funds. The single-lookup shortcut on the spendable side is sound rather than merely convenient: each checkpoint's $$\tilde{b}$$ is bound to the $$C\_{\text{spend}}$$ it certifies by the emitting operation's own proof (W7, T12, S11, V8), so a checkpoint cannot misreport the balance the wallet restarts from. Beyond the event history and the on-chain commitments, recovery requires nothing from the auditor, from counterparties, or from the contract admin. +**Recoverability.** An owner who holds $$vk$$ reconstructs both openings deterministically from the account's event history, so the loss of local wallet state is not a loss of funds. The single-lookup shortcut on the spendable side is sound rather than merely convenient: each checkpoint's $$\tilde{b}$$ is bound to the $$C\_{\text{spend}}$$ it certifies by the emitting operation's own proof (W7, T12, S11), so a checkpoint cannot misreport the balance the wallet restarts from. Beyond the event history and the on-chain commitments, recovery requires nothing from the auditor, from counterparties, or from the contract admin. **Integrity fails closed.** The event archive is trusted for availability, not for integrity. Recovery terminates in a consistency check against the on-chain commitments ([DESIGN.md](./DESIGN.md) §5.2 *Recovery*, step 7) and Pedersen commitments are binding (Section 2.3), so a tampered or truncated history cannot yield an opening that verifies against a balance the account does not hold — it yields a detectable mismatch. The residual risk is therefore denial of recovery, a liveness failure, rather than silent corruption of the reconstructed balance. [INDEXER.md](./INDEXER.md) §7 specifies the archive's trust model and the client-side checks that follow from it. @@ -205,7 +198,7 @@ The recovery procedure, the definition of the **checkpoint** it is built around, 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 salt -- $$\sigma$$, or $$\sigma\_a'$$ for a spender transfer -- and recomputes the proof. No special-case logic is needed. The salt is a public input and emitted in events so the auditor and owner can reconstruct randomness. +**Retry procedure.** On revert, the wallet picks a new random salt -- $$\sigma$$, or $$\sigma\_a'$$ for a spender transfer -- and recomputes the proof. No special-case logic is needed. The salt is a public input and emitted in events so the auditor and owner can reconstruct randomness. ### 9.7 Replay Protection @@ -221,7 +214,7 @@ Because $$\sigma$$ is sampled fresh via CSPRNG for every operation, a retry afte ### 10.1 Circuits -All proof logic is written in Noir, compiled to UltraHonk circuits. The system uses 6 circuits: +All proof logic is written in Noir, compiled to UltraHonk circuits. The system uses 5 circuits: ```rust #[contracttype] @@ -232,10 +225,11 @@ pub enum CircuitType { Transfer = 2, SpenderTransfer = 3, SetSpender = 4, - RevokeSpender = 5, } ``` +`deposit`, `merge`, and `revoke_spender` carry no proof and have no circuit (Sections 7.3, 7.4, 7.9). The discriminants are part of the on-chain interface and are never renumbered. + ### 10.2 Circuit Summary | Circuit | What it proves | @@ -245,7 +239,6 @@ pub enum CircuitType { | `Transfer` | Balance conservation; ECDH-derived blinding and encrypted amount for recipient; dual-auditor channel sponges (recipient auditor: amount + per-transfer Pedersen randomness; sender auditor: amount + balance + `lane[2]` escrow of the new spendable blinding); deterministic randomness for new sender balance; encrypted balance scalar; sender key ownership; range validity (balance $$\in [0, 2^{127})$$, amount $$\in [0, 2^{127})$$) | | `SpenderTransfer` | Allowance sufficiency; ECDH-derived blinding and encrypted amount for recipient; dual-auditor channel sponges (recipient auditor: amount + per-transfer Pedersen randomness; owner auditor: amount + allowance + `lane[2]` escrow of the new allowance blinding); deterministic randomness for new allowance; encrypted allowance scalar; spender key ownership; contract-bound indirectly via $$C\_a$$ chain (Section 7.8) | | `SetSpender` | Balance split; $$dvk\_i$$ derivation; ECDH escrow of $$dvk\_i$$ to the spender and of the allowance blinding to the owner's auditor; allowance commitment with deterministic randomness; encrypted balance and allowance scalars; owner-auditor ECDH ciphertexts (escrow amount + balance checkpoint + `lane[2]` escrow of the new spendable blinding); owner key ownership; contract-bound via $$vk$$ derivation | -| `RevokeSpender` | 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; contract-bound via $$vk$$ derivation | ### 10.3 Circuit Cost Analysis @@ -260,9 +253,8 @@ The dominant cost in Noir circuits is elliptic curve scalar multiplication. With | `Transfer` | 8 | $$Y\_A$$ (T1), $$C\_{\text{spend}}^A$$ opening (T3), recipient ECDH (T5), $$R\_e$$ (T6), $$C\_{\text{transfer}}$$ (T8), $$C\_{\text{spend}}'$$ (T11), recipient-auditor ECDH (T\_a1), sender-auditor ECDH (T\_a5) | | `SpenderTransfer` | 8 | $$Y\_{\text{op}}$$ (O1), $$C\_a$$ opening (O2), recipient ECDH (O5), $$R\_e$$ (O6), $$C\_{\text{transfer}}$$ (O8), $$C\_a'$$ (O11), recipient-auditor ECDH (O\_a1), owner-auditor ECDH (O\_a5) | | `SetSpender` | 7 | $$Y$$ (S1), $$C\_{\text{spend}}$$ opening (S3), $$C\_a$$ (S7), $$C\_{\text{spend}}'$$ (S10), $$R\_e$$ (S\_a1), $$dvk\_i$$ escrow ECDH (S12, §7.11), owner-auditor ECDH (S\_a2) | -| `RevokeSpender` | 6 | $$Y$$ (V1), $$C\_a$$ opening (V4), $$C\_{\text{spend}}$$ opening (V5), $$C\_{\text{spend}}'$$ (V7), $$R\_e$$ (V\_a1), owner-auditor ECDH (V\_a2) | -`SetSpender` is the one circuit with a third ECDH beyond the auditor channel: the $$dvk\_i$$ handoff of §7.11 reuses $$r\_e$$ but multiplies it against $$Y\_{\text{op}}$$, so it is a separate call, not a reuse of the S\_a2 shared secret. The auditor-side escrow of the allowance blinding $$r\_a$$ (S14) reuses the S\_a2 shared scalar and adds a Poseidon evaluation, not a call. The `lane[2]` escrows (W\_a5, T\_a9, S\_a6, O\_a9) read `lane[2]` of a permutation each circuit already computes and cost one field addition apiece. The ordering these totals imply is consistent with the committed ACIR opcode counts in `circuits/constraints.baseline`: `Register` 33, `Withdraw` 95, `RevokeSpender` 123, `Transfer` 134, `SetSpender` 135, `SpenderTransfer` 136. +`SetSpender` is the one circuit with a third ECDH beyond the auditor channel: the $$dvk\_i$$ handoff of §7.11 reuses $$r\_e$$ but multiplies it against $$Y\_{\text{op}}$$, so it is a separate call, not a reuse of the S\_a2 shared secret. The auditor-side escrow of the allowance blinding $$r\_a$$ (S14) reuses the S\_a2 shared scalar and adds one Poseidon evaluation. The `lane[2]` escrows (W\_a5, T\_a9, S\_a6, O\_a9) read `lane[2]` of a permutation each circuit already computes and cost one field addition apiece. The ordering these totals imply is consistent with the committed ACIR opcode counts in `circuits/constraints.baseline`: `Register` 33, `Withdraw` 95, `Transfer` 134, `SetSpender` 135, `SpenderTransfer` 136. The ECDH computations add scalar multiplications compared to a random-blinding scheme, but the unchunked design eliminates all per-chunk constraints (which, in a chunked scheme, would involve 8+ scalar multiplications for balance chunks and per-chunk range proofs). @@ -319,7 +311,7 @@ fn ecdh(scalar: Field, point: EmbeddedCurvePoint) -> Field { } ``` -**Post-merge witness availability.** Every opening witnessed in any circuit is encoded as a single $$\mathbb{F}\_r$$ `Field` via the same `commit` primitive. After a `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-spender / revoke-spender proof can be constructed against the affected $$C\_{\text{spend}}$$ until further accumulation shifts the blinding back into $$\mathbb{F}\_r$$. +**Post-merge witness availability.** Every opening witnessed in any circuit is encoded as a single $$\mathbb{F}\_r$$ `Field` via the same `commit` primitive. After a proofless fold -- `Merge` (§7.4) or `revoke_spender` (§7.9) -- the spendable-balance blinding is a sum over $$\mathbb{F}\_q$$: $$r\_s + r\_r$$ for a merge, $$r\_s + r\_a$$ for a revoke. Its canonical $$\mathbb{F}\_q$$ representative lies in $$[0, r)$$ -- representable as a Noir `Field` -- with probability $$\geq 1 - (q - r)/q \approx 1 - 2^{-127}$$ per fold (§2.3). With the complementary probability $$\approx 2^{-127}$$ it lies in $$[r, q)$$. In that case the on-chain state remains well-formed (the commitment is a valid Grumpkin point), but the wallet's local opening witness is unencodable as a `Field`, so no spend / transfer / set-spender proof can be constructed against the affected $$C\_{\text{spend}}$$ until further accumulation shifts the blinding back into $$\mathbb{F}\_r$$. **Soft recovery.** Every subsequent inbound confidential transfer or spender transfer, once merged, adds a fresh $$\mathbb{F}\_r$$-derived blinding. For each transfer-derived addend, the new canonical $$\mathbb{F}\_q$$ representative falls in $$[0, r)$$ with probability $$\geq 1 - 2^{-127}$$ regardless of the current stuck value (worst case: the current value sits at the lower edge of $$[r, q)$$, requiring the new $$\mathbb{F}\_r$$ addend to cross the mod $$q$$ boundary; the probability of failing to do so is bounded by $$(q - r)/r \approx 2^{-127}$$). For accounts that continue to receive confidential transfers, the unspendable window is self-resolving at the next merge with overwhelming probability; accounts whose only inflows are deposits remain stuck until a confidential transfer arrives. @@ -331,6 +323,8 @@ fn ecdh(scalar: Field, point: EmbeddedCurvePoint) -> Field { 4. Verifier deserializes stored VK, runs UltraHonk verification (BN254 G1/G2 pairings, Fiat-Shamir, sumcheck) 5. Contract applies homomorphic balance updates (Grumpkin point arithmetic via $$\mathbb{F}\_r$$ ops) +The proofless operations (`deposit`, `merge`, `revoke_spender`) perform step 5 only. + ### 10.6 Structured Reference String 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"): @@ -391,7 +385,7 @@ Requires `bn254_fr_{add, sub, mul, inv}` host calls (CAP-80, Section 10.7). **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 contract 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{transfer}}$$ (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 the operation's salt, every ciphertext becomes trivially decryptable). +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), $$C\_{\text{transfer}}$$ (T8, O8), $$C\_{\text{spend}}'$$ (T11, W6, S10), $$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). 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 the operation's salt, 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 contract 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 contract trusts the fetched value. @@ -426,8 +420,7 @@ trait ConfidentialToken { fn set_spender(e: Env, account: Address, spender: Address, live_until_ledger: u32, data: Bytes); - fn revoke_spender(e: Env, account: Address, spender: Address, - data: Bytes); + fn revoke_spender(e: Env, account: Address, spender: Address); fn confidential_balance(e: Env, account: Address) -> ConfidentialAccount; @@ -437,7 +430,9 @@ trait ConfidentialToken { } ``` -A deployment that enables the optional compliance extension adds `compliance: Option` to this constructor and the freeze, configuration-rotation, and read entry points of [COMPLIANCE.md](./COMPLIANCE.md) §6 to this interface; the admin-gated ones are authorized by the deployment's own access-control module. +A deployment that enables the optional compliance extension adds `compliance: Option` to this constructor and the freeze, configuration-rotation, and read entry points of [COMPLIANCE.md](./COMPLIANCE.md) §6 to this interface. The admin-gated ones are authorized by the deployment's own access-control module. + +**Hooks.** Every entry point invokes the deployment's `Hooks` implementation after authorization and payload decoding and before the storage-layer operation. Hooks for operations that carry `data` receive the decoded payload by reference; the proofless `on_deposit`, `on_merge`, and `on_revoke_spender` receive none. The two compliance entry points invoke no hook, since their precondition — a frozen target — is exactly what a gating hook rejects ([COMPLIANCE.md](./COMPLIANCE.md) §5). This table is authoritative: every entry is exactly the set of prover-supplied public inputs from the corresponding Section 7 operation (the contract loads the remaining public inputs from trusted state per §7.1), plus the `proof` blob. Names map directly to the Section 7 symbols. @@ -448,7 +443,6 @@ This table is authoritative: every entry is exactly the set of prover-supplied p | `confidential_transfer` | $$C\_{\text{spend}}'$$, $$C\_{\text{transfer}}$$, $$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}}$$, $$\tilde{r}\_{\text{aud,s}}$$, `proof` | | `confidential_transfer_from` | $$C\_a'$$, $$C\_{\text{transfer}}$$, $$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}}$$, $$\tilde{r}\_{\text{aud,s}}$$, `proof` | | `set_spender` | $$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}}$$, $$\tilde{r}\_{\text{aud,s}}$$, $$\tilde{r}\_{a,\text{aud,s}}$$, `proof` | -| `revoke_spender` | $$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 contract loads it from the `(from, spender)` delegation entry (§7.8 public-input table). Only the prover-chosen replacement $$\sigma\_a'$$ travels in `data`. It is bound as this transfer's channel nonce (O7, O9, O\_a2, O\_a6), as the new allowance state's salt (O10, O12), and against the salt it replaces (O14); it is then written back to the delegation entry as the new `allowance_salt` and emitted in the event (§6.2 *Transfer nonce*). 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_spender`, by contrast, has no prior delegation entry to load from, so its $$\sigma\_a$$ is prover-supplied and bound by S6. @@ -465,7 +459,7 @@ Soroban `address.require_auth()` proves that the named principal authorized the | `confidential_transfer(from, to, data)` | `from` | | `confidential_transfer_from(spender, from, to, data)` | `spender` (not `from`) | | `set_spender(account, spender, live_until_ledger, data)` | `account` | -| `revoke_spender(account, spender, data)` | `account` | +| `revoke_spender(account, spender)` | `account` | | `confidential_balance`, `is_spender`, `get_spender_delegation` | 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` storage entry. @@ -487,15 +481,16 @@ Each state-modifying operation emits a structured event. Events carry the data n | `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}}$$, $$\tilde{r}\_{\text{aud,s}}$$ | | `SpenderTransfer` | `spender`, `from`, `to`, $$R\_e$$, $$\tilde{v}$$, $$\sigma\_a'$$, $$\tilde{v}\_{\text{aud,r}}$$, $$\tilde{r}\_{\text{aud,r}}$$, $$\tilde{v}\_{\text{aud,s}}$$, $$\tilde{a}\_{\text{aud,s}}$$, $$\tilde{r}\_{\text{aud,s}}$$ | | `SetSpender` | `account`, `spender`, `live_until_ledger`, $$R\_e$$, $$\sigma$$, $$\tilde{b}$$, $$\tilde{v}\_{\text{aud,s}}$$, $$\tilde{b}\_{\text{aud,s}}$$, $$\tilde{r}\_{\text{aud,s}}$$, $$\tilde{r}\_{a,\text{aud,s}}$$ | -| `RevokeSpender` | `account`, `spender`, $$R\_e$$, $$\sigma$$, $$\tilde{b}$$, $$\tilde{v}\_{\text{aud,s}}$$, $$\tilde{b}\_{\text{aud,s}}$$ | +| `RevokeSpender` | `account`, `spender`, $$\tilde{a}$$, $$\sigma\_a$$ | Amount fields in `Deposit` and `Withdraw` are typed `i128`, matching SEP-41. **Usage by consumers:** - **Recipient wallet**: processes `Transfer` and `SpenderTransfer` events using $$(R\_e, \tilde{v}, \sigma)$$ -- with $$\sigma\_a'$$ in place of $$\sigma$$ for `SpenderTransfer` -- to derive $$v\_{\text{transfer}}$$ and $$r\_{\text{transfer}}$$ (Section 5.3). -- **Owner wallet**: processes all events for recovery (Section 5.2). The $$(\tilde{b}, \sigma)$$ pair from the most recent 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). +- **Owner wallet**: processes all events for recovery (Section 5.2). The $$(\tilde{b}, \sigma)$$ pair from the most recent checkpoint event (`Withdraw`, `Transfer` as sender, `SetSpender`) anchors $$W\_{\text{spend}}$$; `Merge` and `RevokeSpender` are folded into it during replay. +- **Auditor**: processes events containing $$R\_e$$ to compute ECDH shared secrets and decrypt amounts, balance checkpoints, and the escrowed blindings (Sections 8.1, 8.2, 8.5); folds `Merge` and `RevokeSpender` into its accumulators as Section 8.1 specifies. +- **Indexer**: reconciles the pooled balance against the sum of claims (Section 9.3, [INDEXER.md](./INDEXER.md)). ### 11.3 Read Methods @@ -515,7 +510,7 @@ The function returns the *spending-authority* state, not the *escrow-existence* - **Owner wallet:** reads the same fields after losing local state, or before calling `revoke_spender`, 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; `a_tilde` is keyed to $$dvk\_i$$ and is unreadable without it. +The auditor's allowance tracking does **not** use this method: per-event allowance ciphertexts (§8.5) are the auditor's data path. `a_tilde` is keyed to $$dvk\_i$$ and is unreadable without it, the owner's auditor included (§8.5). --- @@ -547,7 +542,7 @@ Each $$\delta$$ is a small positive integer in $$\mathbb{F}\_r$$, fixed for the | $$\delta\_{\text{disc}}$$ | 16 | Disclosure ciphertext to the disclosure recipient ([SELECTIVE_DISCLOSURE.md](./SELECTIVE_DISCLOSURE.md) §4) | | $$\delta\_{\text{esc\\\_allow\\\_r\\\_aud}}$$ | 17 | Allowance-blinding escrow to the owner's auditor (S14, §8.5) | -This table assigns all seventeen values; no other document assigns them. Tags 14–16 are never absorbed inside a core circuit — 14 is derived off-circuit (DESIGN.md §5.3 makes its derivation normative for every operation whose originator holds a viewing key), and 15–16 belong to the off-chain selective-disclosure layer ([SELECTIVE_DISCLOSURE.md](./SELECTIVE_DISCLOSURE.md) §2.2) — so they are not part of the on-chain wire contract; `circuits/lib/src/lib.nr` accordingly implements 1–13 and 17. All seventeen values MUST still be distinct and each MUST be confined to a single sponge mode, so a deployment treats them as one namespace. Tag 17 is assigned out of sequence with its neighbours because it was added after 14–16. It takes its own tag rather than reusing $$\delta\_{\text{esc\\\_dvk}}$$ because the two escrows key off different shared scalars ($$Y\_{\text{op}}$$ vs $$K\_{\text{aud,s}}$$), which is the distinct-domain leg of §5.3's *Why reusing $$r\_e$$ is safe*. It shares its shared scalar with $$\delta\_{\text{aud\\\_s}}$$ instead, and what separates the two is simply that they are different tags: the tag is the first absorbed element, so no pair of remaining inputs makes tag 17 and tag 11 agree. Mode exclusivity is a separate per-tag rule that each satisfies on its own — 17 is only ever single-output, 11 only ever multi-lane (§8.5). +This table assigns all seventeen values; no other document assigns them. Tags 14–16 are never absorbed inside a core circuit — 14 is derived off-circuit (DESIGN.md §5.3 makes its derivation normative for every operation whose originator holds a viewing key), and 15–16 belong to the off-chain selective-disclosure layer ([SELECTIVE_DISCLOSURE.md](./SELECTIVE_DISCLOSURE.md) §2.2) — so they are not part of the on-chain wire contract; `circuits/lib/src/lib.nr` accordingly implements 1–13 and 17. All seventeen values MUST still be distinct and each MUST be confined to a single sponge mode, so a deployment treats them as one namespace. Tag 17's construction is specified in §8.5. **Provenance.** Sequential small integers are the simplest assignment that satisfies the requirement of *distinctness* across all Poseidon2 invocations in this protocol -- §3.2 models Poseidon2 as a pseudorandom function, so evaluations whose leading input differs are computationally independent. Distinctness alone is not sufficient: each tag must also be confined to a single sponge mode, since the multi-lane forms of §2.5 share `lane[0]` with the single-output form on the same inputs; $$\delta\_{\text{aud\\\_s}}$$ is the one tag ever squeezed three-wide, and the widths it is read at agree on their shared lanes (§2.5 *Mode exclusivity*). 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. diff --git a/packages/tokens/src/confidential/docs/INDEXER.md b/packages/tokens/src/confidential/docs/INDEXER.md index 6cfdbceea..35dff767a 100644 --- a/packages/tokens/src/confidential/docs/INDEXER.md +++ b/packages/tokens/src/confidential/docs/INDEXER.md @@ -9,7 +9,7 @@ The key words MUST, MUST NOT, SHOULD, and MAY are to be interpreted as in RFC 21 ## 1. Why the Indexer Is Load-Bearing -Confidential balances are Pedersen commitments; the on-chain entry alone does not reveal the opening `(v, r)` needed to spend. A wallet that loses its local cache reconstructs the opening deterministically from the master secret plus the account's event history: the latest *checkpoint* event supplies `(b_tilde, sigma)` from which the spendable opening is derived (DESIGN §5.2), while the receiving-side opening is rebuilt by replaying deposits and incoming transfers back to the account's last `Merge` at or before that checkpoint, or to registration if it has not merged before it (§2). +Confidential balances are Pedersen commitments; the on-chain entry alone does not reveal the opening `(v, r)` needed to spend. A wallet that loses its local cache reconstructs the opening deterministically from the master secret plus the account's event history: the latest *checkpoint* event supplies `(b_tilde, sigma)` from which the spendable opening as of that checkpoint is derived (DESIGN §5.2), while the receiving-side opening is rebuilt by replaying deposits and incoming transfers back to the account's last `Merge` at or before that checkpoint, or to registration if it has not merged before it (§2). That same replay carries the post-checkpoint proofless folds — `Merge` and `RevokeSpender` — onto the spendable side. Stellar RPC retains events for a **7-day window** only. A wallet that loses local state after that window can still see that its funds exist (the commitment remains on-chain) but cannot reconstruct the opening required to spend them — unless a durable archive holds the missing events. That archive is this document's subject. Without a conforming indexer, recovery from seed is not guaranteed, and deployments MUST treat wallet-local state as unrecoverable after the RPC window. @@ -18,7 +18,7 @@ Stellar RPC retains events for a **7-day window** only. A wallet that loses loca ## 2. Terminology - **Checkpoint** — an owner-initiated proof-carrying event that publishes `(b_tilde, sigma)` for the owner's spendable balance (DESIGN §5.2; §3.2 lists the qualifying event types in ingestion scope). -- **Replay window** — the range from `T_0` to the current ledger, where `T_0` is the account's most recent `Merge` at or before its latest checkpoint, or its `Register` event if it has not merged before that checkpoint (DESIGN §5.2 *Recovery*). One window serves both sides: the checkpoint supplies the spendable opening directly, and the receiving opening is rebuilt across the window. Anchoring the receiving side at the account's last `Merge` overall is not sufficient — a `Merge` after the checkpoint reconstructs the current receiving opening correctly but leaves the spendable opening short by the amount that merge folded in, the checkpoint predating it. +- **Replay window** — the range from `T_0` to the current ledger, where `T_0` is the account's most recent `Merge` at or before its latest checkpoint, or its `Register` event if it has not merged before that checkpoint (DESIGN §5.2 *Recovery*). One window serves both sides: the checkpoint supplies the spendable opening as of the checkpoint, the window carries the `Merge` and `RevokeSpender` folds that follow it onto that opening, and the receiving opening is rebuilt across the window. Anchoring the receiving side at the account's last `Merge` overall is not sufficient — a `Merge` after the checkpoint reconstructs the current receiving opening correctly but leaves the spendable opening short by the amount that merge folded in, the checkpoint predating it. - **Event id** — the triple `(ledger_seq, tx_hash, event_index)`, unique per emitted event: `tx_hash` is globally unique, and `event_index` is unambiguous because a Soroban transaction carries a single operation. The same event MUST carry the same id whether served from the archive or from RPC, so a hybrid client can deduplicate across the seam. The id does not by itself encode position within a ledger — `tx_hash` conveys no ordering — so the canonical total order is instead `(ledger_seq, tx_application_order, event_index)`, where `tx_application_order` is persisted as its own field (§3.1) and drives §3.4. - **Seam** — in a hybrid client (§1) that reads the recent tail from Stellar RPC and older history from the archive, the ledger at which it switches sources. A client sets the seam above the RPC retention floor (`getHealth().oldestLedger`) so the RPC side is always served from live retention, and requires the archive's ingested-through ledger (§6 C4) to reach the seam. @@ -50,23 +50,25 @@ All events emitted by the confidential token (DESIGN_cont §11.2) with the follo | `Deposit` | Receiving-side replay: accumulates `(amount, 0)` into the receiving opening. | | `Transfer` (recipient side) | Receiving-side replay: carries the recipient-channel ciphertexts for `(v_transfer, r_transfer)`. | | `SpenderTransfer` (recipient side) | Receiving-side replay, as above. | -| `Merge` | Folds the receiving opening into the spendable opening; resets the receiving side. | -| `SetSpender`, `SpenderTransfer` (owner side) | **The auditor's only route to an allowance opening.** The escrowed allowance blinding rides these two events (`r_a_tilde_aud_s` on `SetSpender`, `r_tilde_aud_s` on `SpenderTransfer`) and appears nowhere in contract storage, so an auditor that misses one has no way to reconstruct the opening of the `C_a` it wrote — see §7 *Auditor recovery*. | -| `Withdraw`, `Transfer` (sender side), `SetSpender`, `RevokeSpender` | **Checkpoints**: publish `(b_tilde, sigma)` for the owner's spendable balance. `SetSpender`/`RevokeSpender` are in scope as owner checkpoints only — a spender recovers allowance state from the on-chain delegation entry (`allowance_commitment`, `a_tilde`, `escrowed_dvk`, `allowance_salt`), not from the archive. The auditor-channel ciphertexts these events also carry are out of scope for wallet recovery. | +| `Merge` | Anchor: folds the receiving opening into the spendable opening; resets the receiving side. | +| `Withdraw`, `Transfer` (sender side), `SetSpender` | **Checkpoints**: publish `(b_tilde, sigma)` for the owner's spendable balance. `SetSpender` is in scope as an owner checkpoint only — a spender recovers allowance state from the on-chain delegation entry (`allowance_commitment`, `a_tilde`, `escrowed_dvk`, `allowance_salt`), not from the archive. | +| `RevokeSpender` | Spendable-side fold, not a checkpoint: the owner adds the reclaimed allowance opening to the spendable opening, deriving it from the event's `a_tilde` and `allowance_salt` (DESIGN §7.9). | A self-transfer — a `Transfer` whose `from` and `to` are the same account — carries both roles at once: it is a sender-side checkpoint and a recipient-side replay event, and recovery applies both (DESIGN §5.2). -Configuration events (`UnderlyingAssetSet`, `VerifierSet`, `AuditorSet`, `AddressAsFieldSet`, verification-key events) are not needed for balance recovery; indexers SHOULD archive them anyway — they are low-volume and useful for deployment forensics. +The auditor-channel fields these events carry (`v_tilde_aud_*`, `b_tilde_aud_s`, `r_tilde_aud_s`, `a_tilde_aud_s`, `r_a_tilde_aud_s`) are out of scope for wallet recovery; for the auditor they are the only route to an allowance opening, since the escrowed blinding (`r_a_tilde_aud_s` on `SetSpender`, `r_tilde_aud_s` on `SpenderTransfer`) appears nowhere in contract storage (DESIGN_cont §8.5 *Archive dependence*; §7.1). They are archived regardless, since the record is the verbatim event (§3.1). Event shapes are normative in DESIGN_cont §11.2. + +Configuration events (`UnderlyingAssetSet`, `VerifierSet`, `AuditorSet`, `AddressAsFieldSet`, verification-key events) and the compliance events `Frozen`, `Unfrozen`, and `ComplianceConfigChanged` are not needed for balance recovery; indexers SHOULD archive them anyway — they are low-volume and useful for deployment forensics. ### 3.3 Account attribution -Recovery is per-account, and an event belongs to **each** account address appearing in its topics — a `Transfer` to both the sender's and the recipient's history, a `SpenderTransfer` to the owner's, recipient's, and spender's. Attribution MUST come from the event topics, never from the transaction source account. +Recovery is per-account, and an event belongs to **each** account address appearing in its topics — a `Transfer` to both the sender's and the recipient's history, a `SpenderTransfer` to the owner's, recipient's, and spender's, a `RevokeSpender` to the owner's and the spender's alone. Attribution MUST come from the event topics, never from the transaction source account. The indexer MAY apply this attribution server-side (per-account queries, §6 C2) or serve the whole per-contract stream and leave the client to select the events touching its account; both conform, since attribution is a pure function of the topics. Server-side per-account filtering is RECOMMENDED for high-volume contracts, where downloading the full contract history to every wallet does not scale. ### 3.4 Ordering -The indexer MUST preserve and expose the total order `(ledger_seq, tx_application_order, event_index)` — all three components are persisted per §3.1. Replay correctness depends on it: interleaved deposits, transfers, and merges only reconstruct the right openings when applied in emission order (DESIGN §5.2 step 6). +The indexer MUST preserve and expose the total order `(ledger_seq, tx_application_order, event_index)` — all three components are persisted per §3.1. Replay correctness depends on it: interleaved deposits, transfers, merges, and revokes only reconstruct the right openings when applied in emission order (DESIGN §5.2 step 6). ## 4. Ingestion Contract @@ -82,12 +84,13 @@ The indexer MUST retain the full per-account history of every in-scope event **i - The spendable opening is taken from the account's latest checkpoint, which is arbitrarily old for a dormant account. - The receiving side is replayed from the last `Merge` at or before that checkpoint, which for an account that receives but never merges is its registration. +- A `RevokeSpender` after the latest checkpoint is the only surviving record of the addend it folded: the delegation entry carrying `a_tilde` and `allowance_salt` is deleted in the same invocation (DESIGN §7.9). ## 6. API Surface C2–C4 below are normative; C1 is RECOMMENDED. The REST shape is RECOMMENDED — any transport exposing the same capabilities conforms. -- **C1 — Latest checkpoint (RECOMMENDED).** Return the most recent checkpoint event for `(contract_id, account)` at or before a given ledger. This is an optimization, not a correctness requirement: each checkpoint carries a self-contained `(b_tilde, sigma)` that fully re-derives the spendable opening, so a client can also obtain the latest checkpoint by scanning the ordered history (C2). Exposing C1 lets a dormant account with a long history skip transferring that history. A client additionally needs `T_0`, the last `Merge` at or before that checkpoint (§2), which C1 does not return and which is obtainable from C2's ordered history. +- **C1 — Latest checkpoint (RECOMMENDED).** Return the most recent checkpoint event for `(contract_id, account)` at or before a given ledger. This is an optimization, not a correctness requirement: each checkpoint carries a self-contained `(b_tilde, sigma)` that re-derives the spendable opening as of that checkpoint, so a client can also obtain the latest checkpoint by scanning the ordered history (C2). Exposing C1 lets a dormant account with a long history skip transferring that history. A client additionally needs `T_0`, the last `Merge` at or before that checkpoint (§2), which C1 does not return and which is obtainable from C2's ordered history. - **C2 — Ordered history.** Return all in-scope events for `(contract_id, account)` within a ledger range, in the total order of §3.4, paginated, each carrying its event id and payload (§3.1). An indexer that serves only the per-contract stream (§3.3) satisfies C2 by delivering that stream in order for client-side attribution. - **C3 — Completeness signal.** Every response MUST state whether the served range is complete (`complete: true` only when the indexer holds a gap-free history for the whole requested range). - **C4 — Ingestion status.** Expose the latest fully-ingested ledger so clients can bound staleness. A hybrid client (§1) compares it against the seam it derives from the RPC retention floor; if the archive has not ingested through the seam, the client MUST treat the crossing range as incomplete (C3). @@ -115,16 +118,11 @@ The indexer is trusted for **availability and completeness only** — never for - **Confidentiality.** Everything the indexer holds is public chain data: commitments, masked ciphertexts, and ECDH ephemerals. A curious indexer learns nothing beyond what any chain observer sees (DESIGN_cont §9). - **Integrity fails closed.** Recovery ends with the wallet checking its reconstructed openings against the **on-chain** commitments (`C_spend =? v·G + r·H`, DESIGN §5.2 step 7). A tampered or incomplete history cannot produce a wrong balance that verifies; it produces a detectable mismatch. -- **Withholding is the residual risk.** A malicious or broken indexer can deny recovery (a liveness failure, not a soundness one). Two structural mitigations: for the recent window the RPC is an independent source of the same events (the hybrid split of §1), so archive withholding bites only the pre-window history; and for that older history wallets SHOULD support multiple independent archive endpoints, with deployments running or contracting at least two. The auditor's forward-tracked openings (DESIGN_cont §8.1) are a second recovery source in principle, but no specification defines an auditor-to-wallet channel, so they do not relax this recommendation. +- **Withholding is the residual risk.** A malicious or broken indexer can deny recovery (a liveness failure, not a soundness one). Two structural mitigations: for the recent window the RPC is an independent source of the same events (the hybrid split of §1), so archive withholding bites only the pre-window history; and for that older history wallets SHOULD support multiple independent archive endpoints, with deployments running or contracting at least two. ### 7.1 Auditor recovery -An auditor's allowance tracking is strictly event-scoped and has no state-based fallback. The opening of a delegation's `C_a` is escrowed only in the event that wrote it (DESIGN_cont §8.5), so a missed, reordered, or unarchived `SetSpender` / `SpenderTransfer` leaves the auditor holding a value it cannot open until the delegation's next state-changing operation. This is a stronger dependency on the archive than the spendable side, where the auditor's opening survives merges by homomorphic carry-forward (DESIGN_cont §8.1). - -Two consequences for deployments: - -- **Consistency checking is the practical detection mechanism.** Soroban enforces nothing about archive completeness, ordering, or availability. An auditor client SHOULD verify each reconstructed allowance opening against the stored `allowance_commitment` — `C_a =? v_a·G + r_a·H` — which is the same fails-closed check §7 states for wallets. A mismatch is evidence of a missed, reordered, or pruned event rather than of a wrong balance. -- **Delegation TTL bounds the recovery anchor.** `live_until_ledger` governs spending authority and is independent of the delegation entry's persistent-entry TTL. If the entry itself is archived away, the commitment the auditor checks against is gone, so the consistency check above becomes unavailable exactly when it is most needed. Deployments SHOULD monitor delegation-entry TTL, not only `live_until_ledger`. +An auditor's allowance tracking is strictly event-scoped and has no state-based fallback. The opening of a delegation's `C_a` is escrowed only in the event that wrote it (DESIGN_cont §8.5), so a missed, reordered, or unarchived `SetSpender` / `SpenderTransfer` leaves the auditor without that opening (DESIGN_cont §8.3 *Decryption capability across rotation*). An auditor client SHOULD verify each reconstructed allowance opening against the stored `allowance_commitment` — `C_a =? v_a·G + r_a·H` — which is the same fails-closed check §7 states for wallets. A mismatch is evidence of a missed, reordered, or pruned event rather than of a wrong balance. `live_until_ledger` governs spending authority and is independent of the delegation entry's persistent-entry TTL. ## 8. Conformance and Versioning diff --git a/packages/tokens/src/confidential/docs/OVERVIEW.md b/packages/tokens/src/confidential/docs/OVERVIEW.md index 693350a75..c1bd88fb6 100644 --- a/packages/tokens/src/confidential/docs/OVERVIEW.md +++ b/packages/tokens/src/confidential/docs/OVERVIEW.md @@ -110,7 +110,7 @@ Spenders enable use cases like automated trading bots, payment processors, or cu | 4 | Spender | Initiates a confidential transfer from the escrowed allowance to any registered recipient. A proof accompanies each transfer, covering allowance sufficiency, ECDH-derived encryption for the recipient, and dual-auditor ciphertexts for the recipient's and owner's auditors. | | 5 | Contract | Verifies the proof, updates the allowance commitment, and adds the transfer commitment to the recipient's receiving balance. The owner's spendable balance is not involved. Emits an event with the ephemeral public key, the salt, and the auditor ciphertexts. | | **Revocation** | | | -| 6 | Owner | Revokes the delegation at any time via a proof. The remaining escrowed allowance is folded back into the owner's spendable balance, and the proof produces ciphertexts for the owner's auditor (reclaimed amount and post-revocation balance checkpoint). The contract emits an event carrying these ciphertexts alongside the owner's balance checkpoint. | +| 6 | Owner | Revokes the delegation at any time. No proof is required: the remaining escrowed allowance is folded back into the owner's spendable balance by public homomorphic addition. The contract emits an event carrying the delegation's allowance ciphertext and salt, from which the owner opens its new spendable balance. | Spenders never access the owner's spendable balance directly. Exposure from a compromised or malicious spender is bounded by the granted allowance amount. @@ -120,7 +120,7 @@ Spenders never access the owner's spendable balance directly. Exposure from a co ### How Auditing Works -The system supports **real-time auditing** via a dual-auditor model. Each account selects an auditor at registration. Every confidential transfer produces encrypted ciphertexts for both the sender's and recipient's auditors. Withdrawals, spender setup, and spender revocation also produce ciphertexts for the sender's (or owner's) auditor. All ciphertexts are enforced by the zero-knowledge proof of each operation. +The system supports **real-time auditing** via a dual-auditor model. Each account selects an auditor at registration. Every confidential transfer produces encrypted ciphertexts for both the sender's and recipient's auditors. Withdrawals and spender setup also produce ciphertexts for the sender's (or owner's) auditor. All ciphertexts are enforced by the zero-knowledge proof of each operation. | What | Recipient's auditor sees | Sender's auditor sees | |:-----|:------------------------|:---------------------| @@ -129,12 +129,11 @@ The system supports **real-time auditing** via a dual-auditor model. Each accoun | Sender's post-transfer balance | No | Yes | | Withdrawal amount | n/a | Yes (publicly visible) | | Post-withdrawal balance | n/a | Yes | -| Post-operation spendable blinding factor | No | Yes, at withdrawal, outgoing transfer, and spender setup (enables opening the sender's spendable balance from that event onward, since the same key also receives every inbound transfer's blinding; not renewed at spender revocation) | -| Spender escrow / reclaim amount | n/a | Yes (owner's auditor) | +| Post-operation spendable blinding factor | No | Yes, at withdrawal, outgoing transfer, and spender setup | +| Spender escrow amount | n/a | Yes (owner's auditor) | | Post-transfer spender allowance | No (for spender transfers) | Yes (owner's auditor) | -| Post-escrow / post-reclaim balance | n/a | Yes (owner's auditor), at spender setup and revocation respectively | -| Delegation viewing key `dvk` | No | No | -| Allowance blinding $r_a$ | No | Yes (owner's auditor), at spender setup and on every spender transfer — one state's blinding per event, which with the amount opens that event's allowance commitment | +| Post-escrow balance | n/a | Yes (owner's auditor), at spender setup | +| Allowance blinding $r_a$ | No | Yes (owner's auditor), at spender setup and every spender transfer | The table covers every auditor ciphertext the protocol produces; `DESIGN_cont.md` §8.1-§8.5 is the normative account, including the bounds on each opening capability. @@ -146,9 +145,9 @@ Each auditor decrypts its ciphertexts by running the channel sponge (recipient-a - **Dual-auditor ciphertexts.** The ciphertexts each operation produces are enforced by its zero-knowledge proof, so they cannot be omitted or malformed, and no extra action is needed from users. - **Per-account scope.** Auditing one account reveals nothing about any other account. - **Recipient-side opening capability.** The recipient's auditor holds the per-transfer Pedersen blinding $r_{\text{transfer}}$, hence the full Pedersen opening of the recipient's receiving balance between merges, which is what enables the seizure/clawback flow specified in `COMPLIANCE.md` §5; the capability and its bounds are specified in `DESIGN_cont.md` §8.1. -- **Sender-side opening capability.** The sender's auditor holds the opening of the account's spendable balance as of each withdrawal, outgoing transfer, and spender setup, because those operations also encrypt the post-operation blinding factor to it. Because one key serves both of an account's auditor channels, the capability survives merges rather than expiring with the event; its bounds are specified in `DESIGN_cont.md` §8.1. -- **Seamless auditor rotation.** When an auditor key is rotated, the new key immediately receives ciphertexts on subsequent operations. For the sender's auditor, the balance checkpoint at the next owner-initiated proof operation (transfer, withdrawal, set spender, or revoke spender) provides the current balance with no event replay or bootstrapping. -- **Spender visibility.** The owner's auditor sees spender transfer amounts and post-transfer allowances via the same dual-auditor mechanism, and additionally sees escrowed and reclaimed amounts at `set_spender` and `revoke_spender`. +- **Sender-side opening capability.** The sender's auditor holds the opening of the account's spendable balance as of each withdrawal, outgoing transfer, and spender setup, because those operations also encrypt the post-operation blinding factor to it. Because one key serves both of an account's auditor channels, the capability survives merges; its bounds are specified in `DESIGN_cont.md` §8.1. +- **Seamless auditor rotation.** When an auditor key is rotated, the new key immediately receives ciphertexts on subsequent operations. For the sender's auditor, the balance checkpoint at the next owner-initiated proof operation (transfer, withdrawal, or set spender) provides the current balance with no event replay; `DESIGN_cont.md` §8.3 bounds what a rotated key can open beyond that checkpoint. +- **Spender visibility.** The owner's auditor sees spender transfer amounts and post-transfer allowances via the same dual-auditor mechanism, and additionally sees the escrowed amount at `set_spender`. - **Viewing vs. spending separation.** A viewing key cannot move or spend funds. Spending requires the separate spending key, which is never shared. ### Auditor Configuration @@ -172,7 +171,7 @@ The wallet must: - **Generate and store keys** - derive the full key hierarchy (spending key, viewing key, public viewing key, delegation viewing keys) from a single master secret. - **Produce zero-knowledge proofs** - the heaviest client-side computation. Proof generation time depends on the circuit complexity but targets single-digit seconds on modern hardware. The Transfer circuit involves 8 elliptic-curve scalar multiplications (including two auditor ECDH exchanges); the Register circuit needs 2. `DESIGN_cont.md` §10.3 lists the per-circuit totals. - **Track local state** - maintain running commitment openings (value and blinding factor pairs) for the spendable and receiving balances by processing on-chain events. This is comparable to wallet sync in UTXO-based privacy systems (Zcash, Monero). -- **Handle recovery** - if local state is lost, reconstruct balances from on-chain data using the viewing key: fetch the encrypted balance scalar and salt from the most recent spend-boundary event, derive the deterministic blinding factor, and replay the events emitted after the last merge preceding that spend boundary, since only a merge clears the receiving balance. Recovery requires the master secret plus access to a durable event archive (Stellar RPC retains only 7 days of history); the indexer this archive must satisfy is specified in the companion [Indexing and Off-Chain State Recovery](./INDEXER.md) document. +- **Handle recovery** - if local state is lost, reconstruct balances from on-chain data using the viewing key: fetch the encrypted balance scalar and salt from the most recent spend-boundary event, derive the deterministic blinding factor, and replay the events emitted after the last merge preceding that spend boundary, since only a merge clears the receiving balance and since merges and spender revocations in that window fold into the spendable balance too. Recovery requires the master secret plus access to a durable event archive (Stellar RPC retains only 7 days of history); the indexer this archive must satisfy is specified in the companion [Indexing and Off-Chain State Recovery](./INDEXER.md) document. ### For Developers (Integration) @@ -181,7 +180,7 @@ The wallet must: | **Token contract** | The main Soroban contract that holds SEP-41 tokens and manages encrypted account state. | | **Verifier contract** | Validates zero-knowledge proofs on-chain. Stores one verification key per operation type. | | **Auditor contract** | Manages auditor public keys. Shared across tokens. | -| **Noir circuits** | Six proof circuits (register, withdraw, transfer, spender transfer, set spender, revoke spender). Written in Noir, compiled to UltraHonk. | +| **Noir circuits** | Five proof circuits (register, withdraw, transfer, spender transfer, set spender). Written in Noir, compiled to UltraHonk. | | **Client library** | SDK for wallets: key management, proof generation, event processing, balance tracking, encryption/decryption. Specified in [SDK.md](./SDK.md). | --- @@ -207,7 +206,7 @@ If the wallet is lost or reinstalled on a new device: 1. The account holder restores from the master secret (seed phrase or equivalent). 2. The wallet re-derives the full key hierarchy. -3. The wallet fetches the latest spend-boundary event for the account, reads the encrypted balance scalar and salt from it, recovers the spendable balance opening using the viewing key, then reconstructs the receiving balance by replaying the deposits, incoming transfers, and merges emitted after the last merge preceding that spend boundary. A spend boundary does not clear the receiving balance — only a merge does — so the replay starts at that merge rather than at the spend boundary. +3. The wallet fetches the latest spend-boundary event for the account, reads the encrypted balance scalar and salt from it, recovers the spendable balance opening as of that boundary using the viewing key, then replays the deposits, incoming transfers, merges, and spender revocations emitted after the last merge preceding that spend boundary — rebuilding the receiving balance and folding the merges and revocations onto the spendable one. A spend boundary does not clear the receiving balance — only a merge does — so the replay starts at that merge rather than at the spend boundary. The recovery process is fully deterministic given the master secret and access to the account's event history from that merge onward. Because Stellar RPC retains only the last 7 days of events, recovery from seed alone depends on a durable indexer ([INDEXER.md](./INDEXER.md)) that retains the per-account event log; without one, the on-chain commitments remain visible but their openings cannot be reconstructed. diff --git a/packages/tokens/src/confidential/docs/SDK.md b/packages/tokens/src/confidential/docs/SDK.md index 2ee6fb501..a434d0abd 100644 --- a/packages/tokens/src/confidential/docs/SDK.md +++ b/packages/tokens/src/confidential/docs/SDK.md @@ -101,7 +101,7 @@ The sponge construction, its width and rate, the IV placement, the padding rule, $$\text{poseidon\\\_with\\\_domain}(\delta, [x_1, \ldots, x_n]) = \text{sponge}([\delta, x_1, \ldots, x_n])$$ -Squeeze-slot assignment is canonical and MUST be followed: `lane[0]` is always an amount mask, `lane[1]` is always a balance, allowance, or per-transfer-randomness mask, and `lane[2]` is always the sender-auditor blinding-escrow slot — the new spendable blinding on `Withdraw`, `Transfer`, and `SetSpender`, the new allowance blinding $$r_a'$$ on `SpenderTransfer` (DESIGN.md §2.5). `lane[2]` never carries a key. Only the sender-auditor channel ($$\delta_{\text{aud\\\_s}}$$) is squeezed three-wide; the recipient channel ($$\delta_{\text{aud\\\_r}}$$) stays at two lanes. `Withdraw`, whose amount is public, takes `lane[1]` and `lane[2]` and leaves `lane[0]` unused (DESIGN.md W_a3–W_a5), so a checkpoint pad can never coincide with an amount pad. +Squeeze-slot assignment is canonical and MUST be followed: `lane[0]` is always an amount mask, `lane[1]` is always a balance, allowance, or per-transfer-randomness mask, and `lane[2]` is always the sender-auditor blinding-escrow slot — the new spendable blinding on `Withdraw`, `Transfer`, and `SetSpender`, the new allowance blinding $$r_a'$$ on `SpenderTransfer` (DESIGN.md §2.5). Only the sender-auditor channel ($$\delta_{\text{aud\\\_s}}$$) is squeezed three-wide; the recipient channel ($$\delta_{\text{aud\\\_r}}$$) stays at two lanes. `Withdraw`, whose amount is public, takes `lane[1]` and `lane[2]` and leaves `lane[0]` unused (DESIGN.md W_a3–W_a5), so a checkpoint pad can never coincide with an amount pad. ### 4.4 Generators and commitments @@ -161,7 +161,7 @@ Secret scalars — $$\sigma$$, $$\sigma_a$$ — MUST be produced by the rejectio DESIGN_cont.md §13 assigns all seventeen values and is their only source; the right-hand column is this document's addition. $$\delta_{\text{disc\\\_bind}}$$ and $$\delta_{\text{disc}}$$ belong to the off-chain disclosure layer (SELECTIVE_DISCLOSURE.md §2.2). Tag 1 is absorbed by the contract rather than by a circuit — the contract derives $$\text{addr\\\_f}$$ and $$\text{op}_i$$ on-chain and the circuits receive them as opaque public inputs (DESIGN.md §2.7 *Usage sites*) — so it is part of the on-chain wire contract all the same. None of 14–16 is absorbed either in a circuit or on-chain, so none is part of the on-chain wire contract, but all three are part of the cross-client contract because two wallets serving the same account must agree on them (§6.3). -All seventeen values MUST be distinct, and each MUST be used in exactly one sponge mode, per DESIGN.md §2.5 *Mode exclusivity*. Tags 11 and 12 are the multi-lane tags — 11 read three-wide wherever `lane[2]` is escrowed and two-wide on `RevokeSpender`, 12 always two-wide; the remaining fifteen, including 1, 14–16, and 17, are single-output tags. Tag 17 is absorbed only by the `SetSpender` circuit (DESIGN.md S14), which escrows the allowance blinding $$r_a$$ to the owner's auditor under a single-output pad rather than over `lane[2]`, that lane being taken by the spendable blinding; its fixture is `circuits/lib/testdata/encrypt_esc_allow_r_auditor.json`. +All seventeen values MUST be distinct, and each MUST be used in exactly one sponge mode, per DESIGN.md §2.5 *Mode exclusivity*. Tags 11 and 12 are the multi-lane tags — 11 read three-wide wherever the sender / owner channel is instantiated, 12 always two-wide; the remaining fifteen, including 1, 14–16, and 17, are single-output tags. Tag 17 is absorbed only by the `SetSpender` circuit (DESIGN.md S14, DESIGN_cont.md §8.5); its fixture is `circuits/lib/testdata/encrypt_esc_allow_r_auditor.json`. ### 4.9 Address compression @@ -291,7 +291,7 @@ Three derivations this document specifies or relies on are computed outside the ## 7. Witness Assembly -An implementation MUST provide witness assembly for each circuit it supports, covering the six core circuits of DESIGN_cont.md §10.1: `Register`, `Withdraw`, `Transfer`, `SpenderTransfer`, `SetSpender`, `RevokeSpender`. +An implementation MUST provide witness assembly for each circuit it supports, covering the five circuits of DESIGN_cont.md §10.1: `Register`, `Withdraw`, `Transfer`, `SpenderTransfer`, `SetSpender`. **Public-input order is a wire contract.** The verifier sees an ordered vector of field elements with no knowledge of what they denote (DESIGN.md §7.1), so a permutation of two same-typed inputs produces a well-formed vector that verifies a different statement. Each builder MUST assemble public inputs in exactly the order the contract assembles them, and MUST cite the contract function it mirrors at the site of the ordering. The per-operation public-input tables in DESIGN.md §7.2–§7.9 are authoritative for *membership*; the contract's assembly is authoritative for *order*. @@ -385,7 +385,8 @@ Event application MUST be ordered, deduplicated, and idempotent in combination, Application rules are DESIGN.md §5.2's update table and are not restated here. Two properties worth making explicit: -- `Withdraw`, sender-side `Transfer`, `SetSpender`, and `RevokeSpender` **overwrite** $$W_{\text{spend}}$$ from the event's $$(\tilde{b}, \sigma)$$ rather than adjusting it, so a wallet that missed intervening events still converges on the spendable side. +- `Withdraw`, sender-side `Transfer`, and `SetSpender` **overwrite** $$W_{\text{spend}}$$ from the event's $$(\tilde{b}, \sigma)$$ rather than adjusting it, so a wallet that missed intervening events still converges on the spendable side. +- `RevokeSpender` is not a checkpoint — it carries no $$\tilde{b}$$ — and is **folded**, like `Merge`. `RevokeSpender` adds the reclaimed allowance opening to $$W_{\text{spend}}$$, with $$v_a$$ and $$r_a$$ recovered from the event's $$\tilde{a}$$ and `allowance_salt` under the owner-derived $$dvk_i$$ (DESIGN.md §7.9). A wallet that misses either event diverges until the next checkpoint and MUST rely on §10.6 to detect that. - A self-transfer — a `Transfer` whose `from` and `to` are the same account — MUST be applied in both roles: the sender side overwrites $$W_{\text{spend}}$$ from the event's $$(\tilde{b}, \sigma)$$, and the recipient side credits $$W_{\text{receive}}$$ from the same event's recipient-channel ciphertexts. The two roles act on different accumulators, so their relative order does not affect the result; applying only one loses the other accumulator's update. ### 10.3 In-flight operations @@ -398,7 +399,7 @@ The projection MUST still be reconciled against the event, and MUST NOT be treat A fresh salt MUST be sampled for every **attempt**, including retries after a reverted or dropped transaction. The salt that must be fresh is the one the operation's pads absorb: $$\sigma$$ for owner-initiated operations, and $$\sigma_a'$$ — the replacement allowance salt — for spender transfers (DESIGN.md §6.2 *Transfer nonce*). -DESIGN_cont.md §9.6 motivates this as unlinkability: a fresh salt prevents an observer correlating a reverted attempt with its retry. It is equally a confidentiality requirement, because the salt is the sole freshness input to every derived pad in the operation, the ephemeral scalar included (DESIGN.md §2.5, §5.3). Reuse therefore repeats the ephemeral key and every channel mask that depends on it, and two attempts that differ only in amount publish that difference in the clear. +DESIGN_cont.md §9.6 motivates this as unlinkability: a fresh salt prevents an observer correlating a reverted attempt with its retry. It is equally a confidentiality requirement, because the salt is the sole freshness input to every derived pad in the operation, the ephemeral scalar included (DESIGN.md §2.5, §5.3). Reuse therefore repeats the ephemeral key and every channel mask that depends on it. An implementation MUST NOT cache or reuse a salt across attempts, and MUST NOT derive it from anything an observer can predict. @@ -444,18 +445,18 @@ Merge is proof-less and owner-authorized, and neither a merge nor an in-flight s ### 10.9 Recovery -Recovery follows the procedure of DESIGN.md §5.2 *Recovery*, with the reconstructed state verified per §10.6. Its two anchors differ: the latest checkpoint event pins $$W_{\text{spend}}$$ in one lookup, while $$W_{\text{receive}}$$ restarts at $$T_0$$ — the account's last `Merge` at or before that checkpoint — from which the replay window runs. +Recovery follows the procedure of DESIGN.md §5.2 *Recovery*, with the reconstructed state verified per §10.6. Its two anchors differ: the latest checkpoint event pins $$W_{\text{spend}}$$ as of that checkpoint in one lookup, with the `Merge` and `RevokeSpender` folds that follow it applied over the replay window per §10.2, while $$W_{\text{receive}}$$ restarts at $$T_0$$ — the account's last `Merge` at or before that checkpoint — from which that window runs. Two further obligations follow from data availability: - Recovery from a root alone depends on a conforming indexer (INDEXER.md). Without one, a client can see that funds exist but cannot reconstruct the opening needed to spend them. -- **With RPC-only event access, a client MUST sync at least once per RPC retention window**, and MUST warn when it has not. The spendable side is robust, since each checkpoint is self-contained, but the receiving side is a running sum from $$T_0$$, so a crediting event that ages out before it is applied takes its opening with it permanently. +- **With RPC-only event access, a client MUST sync at least once per RPC retention window**, and MUST warn when it has not. Any event that ages out before it is applied takes its opening with it permanently: a crediting event on the receiving side, which is a running sum from $$T_0$$, and a `RevokeSpender` after the latest checkpoint, whose $$\tilde{a}$$ and `allowance_salt` survive nowhere else once the delegation entry is deleted (DESIGN.md §7.9). Only the spend history preceding the checkpoint is expendable, each checkpoint being self-contained. ### 10.10 Spender-side wallet A spender reconstructs its allowance state from the on-chain delegation entry rather than from event replay: it recovers $$dvk_i$$ from the escrowed value by ECDH (DESIGN.md §7.11), then reads the current allowance from the entry's encrypted allowance and salt (DESIGN_cont.md §11.3). -Implementations MUST surface the delegation's expiry ledger and SHOULD warn ahead of it. They MUST represent expired-but-unrevoked delegations as still holding escrowed value (DESIGN.md §6.2). +Implementations MUST surface the delegation's expiry ledger and SHOULD warn ahead of it. They MUST represent expired-but-unrevoked delegations as still holding escrowed value (DESIGN.md §6.2). A `RevokeSpender` event with the entry gone means the owner folded the delegation back. A spender MUST NOT be able to reach the owner's spendable balance through any interface (§3). @@ -463,24 +464,24 @@ A spender MUST NOT be able to reach the owner's spendable balance through any in ## 11. Auditor Client -An auditor decrypts from the public event and its own secret $$k$$ alone, with no viewing key, holder cooperation, or extra on-chain read. The allowance opening comes straight out of the event: the blinding of the $$C_a$$ that operation writes is escrowed in the event itself -- tag 17 on `SetSpender`, `lane[2]` on `SpenderTransfer` -- and the matching value is in the sender-channel ciphertext (DESIGN_cont.md §8.5). An auditor that did not observe the event holds no opening for that state and cannot derive one; there is no key from which the openings follow. For each channel it computes the shared scalar against the event's ephemeral point, derives that channel's lane masks (§4.3) — three on the sender / owner channel, two on the recipient channel — and subtracts. +An auditor decrypts from the public event and its own secret $$k$$ alone, with no viewing key, holder cooperation, or extra on-chain read. For each channel it computes the shared scalar against the event's ephemeral point, derives that channel's lane masks (§4.3) — three on the sender / owner channel, two on the recipient channel — and subtracts. The allowance opening comes straight out of the event: the blinding of the $$C_a$$ that operation writes is escrowed in the event itself — tag 17 on `SetSpender`, `lane[2]` on `SpenderTransfer` — and the matching value is in the sender-channel ciphertext (DESIGN_cont.md §8.5). An auditor that did not observe the event holds no opening for that state. The two channels differ in what they yield (DESIGN_cont.md §8.1): | Channel | `lane[0]` | `lane[1]` | `lane[2]` | |:--|:--|:--|:--| -| Sender / owner ($$\delta_{\text{aud\\\_s}}$$) | Transfer amount, or the escrowed amount for `SetSpender` and the reclaimed amount for `RevokeSpender` | Sender's post-operation balance, or post-operation allowance for a spender transfer | Post-operation spendable blinding on `Withdraw`, `Transfer`, and `SetSpender`; post-transfer allowance blinding $$r_a'$$ on `SpenderTransfer`; nothing on `RevokeSpender`, which stays two-lane | +| Sender / owner ($$\delta_{\text{aud\\\_s}}$$) | Transfer amount, or the escrowed amount for `SetSpender` | Sender's post-operation balance, or post-operation allowance for a spender transfer | Post-operation spendable blinding on `Withdraw`, `Transfer`, and `SetSpender`; post-transfer allowance blinding $$r_a'$$ on `SpenderTransfer` | | Recipient ($$\delta_{\text{aud\\\_r}}$$) | Transfer amount | Per-transfer Pedersen randomness $$r_{\text{transfer}}$$ | — (channel is two-lane) | -`Withdraw`, `SetSpender`, and `RevokeSpender` carry a sender-channel balance checkpoint whose pad is `lane[1]`. Only `Withdraw` leaves `lane[0]` unused, its amount being public (DESIGN.md W_a3, §4.3); `SetSpender` and `RevokeSpender` read `lane[0]` as well, for the escrowed and reclaimed amounts respectively (DESIGN.md S_a4, V_a4). +`Withdraw` and `SetSpender` carry a sender-channel balance checkpoint whose pad is `lane[1]`. Only `Withdraw` leaves `lane[0]` unused, its amount being public (DESIGN.md W_a3, §4.3); `SetSpender` reads `lane[0]` as well, for the escrowed amount (DESIGN.md S_a4). -An implementation MUST squeeze the sender / owner channel three-wide and MUST NOT widen the recipient channel. Reading `lane[2]` on `RevokeSpender` yields a pad over no ciphertext (V_a3 is two-lane, DESIGN.md §7.9); an implementation MUST treat a `RevokeSpender` event as carrying no escrowed blinding rather than substituting a stale one. Because the first two lanes of $$\text{SpongeSqueeze}_3$$ coincide with $$\text{SpongeSqueeze}_2$$ (§4.3), a client that already reads `lane[0]` and `lane[1]` keeps every value it decrypted before. +An implementation MUST squeeze the sender / owner channel three-wide and MUST NOT widen the recipient channel. `RevokeSpender` opens no auditor channel, the fold being proofless (DESIGN.md §7.9); an implementation MUST treat that event as carrying no escrowed blinding rather than substituting a stale one. **Cross-channel agreement.** Where an auditor holds the key for both parties, the amount decrypts independently on each channel and the circuit constrains both to the same value, so the two MUST agree. An implementation SHOULD perform this comparison and treat disagreement as evidence that $$k$$ is not the auditor key for both parties of that event. -**Scope MUST be represented, not implied.** The recipient-channel capability is forward-only, receiving-side only, and reset by merge (DESIGN_cont.md §8.1). The `lane[2]` opening of the sender channel is forward-only and **standing**: it opens the spendable commitment as of the checkpoint that escrowed it and stays valid through merges, because one account key serves both channels (DESIGN_cont.md §8.1 *Sender-auditor opening capability*). Maintaining it is the client's job, not the protocol's: an implementation MUST add the `lane[0]` amount and `lane[1]` $$r_{\text{transfer}}$$ of every inbound `Transfer` and `SpenderTransfer` to its stored $$(v, r)$$, and MUST treat each `Deposit` as $$(\text{amount}, 0)$$ (DESIGN.md §7.3). It MUST invalidate the stored opening on a `RevokeSpender` event, which rewrites the spendable commitment under a blinding no channel carries, until the next `lane[2]` escrow. Across a key rotation an implementation MUST either carry the accumulated opening forward from the prior key or treat the account as unopened until the next checkpoint under the new key; the value side needs no replay either way, since the next owner-initiated proof publishes a fresh balance checkpoint. +**Scope MUST be represented, not implied.** The recipient-channel capability is forward-only, reset by merge, and yields no opening of the spendable commitment (DESIGN_cont.md §8.1). The `lane[2]` opening of the sender channel is forward-only and **standing**: it opens the spendable commitment as of the checkpoint that escrowed it and stays valid through merges provided the client folded in every inbound flow since the previous merge, one account key serving both channels (DESIGN_cont.md §8.1 *Sender-auditor opening capability*). Maintaining it is the client's job: an implementation MUST add the `lane[0]` amount and `lane[1]` $$r_{\text{transfer}}$$ of every inbound `Transfer` and `SpenderTransfer` to its stored $$(v, r)$$, and MUST treat each `Deposit` as $$(\text{amount}, 0)$$ (DESIGN.md §7.3). On a `RevokeSpender` event it MUST add the $$(v_a, r_a)$$ it recorded for that delegation, the fold being public and the addend already escrowed (DESIGN_cont.md §8.5); where it holds no such record it MUST mark the opening unavailable until the next `lane[2]` escrow rather than carry a stale one. Across a key rotation, what an implementation may carry forward and what it MUST treat as unopened are fixed by DESIGN_cont.md §8.3. -An auditor facade MUST NOT be able to construct a spending witness. It *can*, however, open the spendable balance past a merge -- the inbound $$r_{\text{transfer}}$$ reaches the same key on the recipient channel -- so a facade that exposes openings exposes them for the account's whole history under the active key, not for isolated events. +An auditor facade MUST NOT be able to construct a spending witness. It can open the spendable balance past a merge — the inbound $$r_{\text{transfer}}$$ reaches the same key on the recipient channel — so a facade that exposes openings exposes them for the account's whole history under the active key. --- diff --git a/packages/tokens/src/confidential/docs/SELECTIVE_DISCLOSURE.md b/packages/tokens/src/confidential/docs/SELECTIVE_DISCLOSURE.md index 6936258ca..3dab1edd2 100644 --- a/packages/tokens/src/confidential/docs/SELECTIVE_DISCLOSURE.md +++ b/packages/tokens/src/confidential/docs/SELECTIVE_DISCLOSURE.md @@ -97,7 +97,7 @@ The disclosure layer inherits the protocol's threat model (DESIGN.md §3.2) and **Holder is the prover for D-recipient and D-sender variants.** The holder is trusted only to produce *correct* proofs about events they choose to disclose. The holder is *not* trusted to be complete: they may withhold events. Recipients that require completeness must obtain it from the auditor (DESIGN_cont.md §8) or from out-of-band evidence. -**Auditor is the prover for D-auditor variants.** The auditor is trusted to disclose accurately when asked. The auditor's existing trust scope (DESIGN.md §3.3) is not enlarged. That scope already includes the full Pedersen opening of every $$C\_a$$ (DESIGN_cont.md §8.5) and, at each checkpoint event that escrows `lane[2]`, of the account's $$C\_{\text{spend}}$$ as of that event (DESIGN_cont.md §8.1 *Sender-auditor opening capability*, §8.2). The latter is standing rather than event-scoped: the account's single auditor key also decrypts every inbound blinding, so the opening carries across merges, and only `revoke_spender` -- which escrows no blinding -- suspends it until the next checkpoint. The D-auditor variants expose to a disclosure recipient only what the chosen variant states, never the openings themselves. +**Auditor is the prover for D-auditor variants.** The auditor is trusted to disclose accurately when asked. The auditor's existing trust scope (DESIGN.md §3.3) is not enlarged. That scope includes the full Pedersen opening of every $$C\_a$$ (DESIGN_cont.md §8.5) and, at each checkpoint event that escrows `lane[2]`, of the account's $$C\_{\text{spend}}$$ as of that event (DESIGN_cont.md §8.1 *Sender-auditor opening capability*, §8.2). The latter is standing rather than event-scoped: it carries across merges and across `revoke_spender` (DESIGN_cont.md §8.1). The D-auditor variants expose to a disclosure recipient only what the chosen variant states. **Disclosure recipient is honest-but-curious.** The recipient correctly verifies proofs and decrypts ciphertexts addressed to their key. The recipient may attempt to replay or rebroadcast proofs; nonce binding prevents reuse against other parties. @@ -229,7 +229,7 @@ The account holder is the recipient of an on-chain confidential transfer (either | # | Constraint | |:--|:---| -| D1 | $$vk\_A = \text{Poseidon}(\delta\_{\text{vk}}, sk\_A, \text{addr\\\_f})$$ (viewing key correctly derived, binds proof to contract; mirrors DESIGN.md R2/T2/W2/S2/V2) | +| D1 | $$vk\_A = \text{Poseidon}(\delta\_{\text{vk}}, sk\_A, \text{addr\\\_f})$$ (viewing key correctly derived, binds proof to contract; mirrors DESIGN.md R2/T2/W2/S2) | | D2 | $$\text{PVK}\_A = vk\_A \cdot H$$ (binds proof to on-chain account) | | D3 | $$s = \text{ECDH}(vk\_A, R\_e)$$ (recipient-side ECDH shared scalar, DESIGN.md §2.4) | | D4 | $$v\_{\text{transfer}} = \tilde{v} - \text{Poseidon}(\delta\_{\text{transfer\\\_amount}}, s, \sigma\_E)$$ (correct decryption of event amount; matches DESIGN.md T9 for `Transfer` and O9 for `SpenderTransfer`) | @@ -375,7 +375,7 @@ Two `circuit_id` shapes are exposed: a **predicate-only** form (`disclose_balanc D1, D2 bind the proof to the disclosing account. DB3 forces the witnessed $$v\_s$$ to be the value the on-chain commitment opens to — by Pedersen binding (DESIGN.md §2.3), no alternative opening exists with non-negligible probability. D5 prevents the predicate from being satisfied by a wrapped-negative $$v\_s$$ that doesn't represent any real balance. -**Distinguishing from D-auditor balance variant (§8).** §8's balance variant decrypts the sender's *post-transfer* balance from $$\tilde{b}\_{\text{aud,s}}$$ of a specific transfer event — event-anchored, historical, requires auditor cooperation. D-balance is holder-side, reflects *current* on-chain state, and supports predicate-only disclosure that §8's variant does not. The two are complementary: a recipient that needs a backstop with auditor attestation uses D-auditor; a recipient that needs predicate-only disclosure or that wants to avoid involving the auditor uses D-balance. +**Distinguishing from D-auditor balance variant (§8).** §8's balance variant decrypts the sender's *post-transfer* balance, and with its `lane[2]` sibling the full opening, from a specific transfer event — event-anchored, historical, requires auditor cooperation. D-balance is holder-side, reflects *current* on-chain state, and supports predicate-only disclosure that §8's variant does not. Capability does not distinguish them: the auditor tracks the current opening $$(v\_s, r\_s)$$ of every scoped account's $$C\_{\text{spend}}$$ forward through checkpoints, merges, and revokes (DESIGN_cont.md §8.1, §8.2), so it could satisfy DB3 against the live commitment as readily as the holder. What distinguishes D-balance is who is bound: D1 and D2 tie the proof to the holder's $$sk\_A$$, making it the holder's own attestation, whereas an auditor-produced current-balance proof would bind through auditor key ownership (A1) instead. No such variant is specified here; a recipient that needs a backstop with auditor attestation uses D-auditor over a recent event, and a recipient that needs predicate-only disclosure, or a statement the holder rather than the auditor stands behind, uses D-balance. **Verifier flow.** D-balance has no on-chain event to reference, so the bundle is: @@ -388,7 +388,7 @@ where `account` is the disclosing address (agreed during the request, not blindl 3. **Construct the public-input vector.** Combine the resolved on-chain state, the recipient's $$(P\_R, \nu)$$, the agreed $$V\_{\text{threshold}}$$ (predicate variants), and the bundle's $$(R\_{\text{disc}}, \tilde{v}\_{\text{disc}})$$. As in §5.2 the verifier MUST NOT accept $$\text{PVK}\_A$$, $$C\_{\text{spend}}$$, or $$V\_{\text{threshold}}$$ from the bundle. 4. **Verify proof and decrypt** as in §5.3 steps 5–6 (decryption applies only to the value-revealing variant). -The recipient and prover MUST agree on $$V\_{\text{threshold}}$$ during the request — otherwise the holder could pick a threshold the recipient never authorized and produce a proof against it. The freshness of the disclosure is the ledger at which the recipient read $$C\_{\text{spend}}$$: if the holder transferred between proving and verification, the on-chain $$C\_{\text{spend}}$$ has changed and verification fails naturally; the prover then re-runs against the new commitment. +The recipient and prover MUST agree on $$V\_{\text{threshold}}$$ during the request — otherwise the holder could pick a threshold the recipient never authorized and produce a proof against it. The freshness of the disclosure is the ledger at which the recipient read $$C\_{\text{spend}}$$: if $$C\_{\text{spend}}$$ changed between proving and verification — by the holder's own operation, or without the holder's participation through a compliance clawback (COMPLIANCE.md §5) — verification fails naturally; the prover then re-runs against the new commitment. --- diff --git a/packages/tokens/src/confidential/mod.rs b/packages/tokens/src/confidential/mod.rs index d7825631a..f7f23084e 100644 --- a/packages/tokens/src/confidential/mod.rs +++ b/packages/tokens/src/confidential/mod.rs @@ -26,8 +26,8 @@ //! free functions in [`storage`]. //! - The on-chain account state types [`ConfidentialAccount`] and //! [`SpenderDelegation`] (DESIGN §6). -//! - The six XDR payload types carried in the `data: Bytes` parameter (DESIGN -//! §11). +//! - The five XDR payload types carried in the `data: Bytes` parameter (DESIGN +//! §11). `revoke_spender` is proofless and carries none. //! - Storage helpers and operation-level orchestration under [`storage`]. //! //! ## Public-Input Encoding @@ -63,7 +63,7 @@ //! The check is by deliberate design **not** an on-curve check. Prover- //! supplied points are constrained on-curve in-circuit by their //! `multi_scalar_mul` derivation and pinned to `≠ O` by explicit -//! nonzero-scalar constraints (DESIGN §10.8: R4/R5, W8, T13, S13, O13, V10). +//! nonzero-scalar constraints (DESIGN §10.8: R4/R5, W8, T13, S13, O13). //! The only proof-less Grumpkin entry point is the auditor key, which the //! auditor contract validates canonical-encoding, on-curve, and non-identity //! at insertion via [`auditor::storage::validate_point`]. @@ -137,9 +137,8 @@ mod test; use soroban_sdk::{contracterror, contractevent, contracttrait, Address, Bytes, BytesN, Env}; pub use storage::{ ConfidentialAccount, ConfidentialTokenStorageKey, RegisterData, RegisterPayload, - RevokeSpenderData, RevokeSpenderPayload, SetSpenderData, SetSpenderPayload, SpenderDelegation, - SpenderTransferData, SpenderTransferPayload, TransferData, TransferPayload, WithdrawData, - WithdrawPayload, + SetSpenderData, SetSpenderPayload, SpenderDelegation, SpenderTransferData, + SpenderTransferPayload, TransferData, TransferPayload, WithdrawData, WithdrawPayload, }; /// Lifecycle hooks invoked by [`ConfidentialToken`] at each @@ -155,8 +154,8 @@ pub use storage::{ /// For ops that carry a `data: Bytes` argument, the last parameter /// `payload` on the matching hook is a reference to the decoded operation /// payload (e.g. [`TransferPayload`] for [`Self::on_transfer`]). The proof -/// is not forwarded. The proofless ops [`Self::on_deposit`] and -/// [`Self::on_merge`] receive no `payload`. +/// is not forwarded. The proofless ops [`Self::on_deposit`], +/// [`Self::on_merge`] and [`Self::on_revoke_spender`] receive no `payload`. /// /// Default bodies are empty no-ops — overriding only the methods relevant /// to a given deployment is the expected pattern. @@ -204,14 +203,8 @@ pub trait Hooks { ) { } - /// Invoked after `revoke_spender`'s auth and decode. - fn on_revoke_spender( - e: &Env, - account: &Address, - spender: &Address, - payload: &RevokeSpenderPayload, - ) { - } + /// Invoked after `revoke_spender`'s auth, before the allowance fold. + fn on_revoke_spender(e: &Env, account: &Address, spender: &Address) {} } /// Zero-cost [`Hooks`] implementation whose every callback is an empty @@ -437,7 +430,7 @@ pub trait ConfidentialToken { /// * `live_until_ledger` - The ledger number at which the delegation /// expires. Spending is authorized while `ledger.sequence() <= /// live_until_ledger`. The escrowed value persists until - /// `revoke_spender`. + /// [`ConfidentialToken::revoke_spender`]. /// * `data` - XDR-encoded [`SetSpenderData`]. /// /// # Errors @@ -475,28 +468,28 @@ pub trait ConfidentialToken { /// remaining escrowed allowance back into `account`'s spendable balance. /// Works for both active and expired-but-not-revoked delegations. /// + /// No proof is required; correctness follows from the homomorphic + /// property of Pedersen commitments. + /// /// # Arguments /// /// * `e` - Access to the Soroban environment. /// * `account` - The owner reclaiming the allowance. /// * `spender` - The previously-delegated spender. - /// * `data` - XDR-encoded [`RevokeSpenderData`]. /// /// # Errors /// - /// * refer to [`storage::decode_data`] errors. /// * refer to [`storage::revoke_spender`] errors. /// /// # Events /// /// * topics - `["revoke_spender", account: Address, spender: Address]` - /// * data - `[r_e_point, sigma, b_tilde, v_tilde_aud_s, b_tilde_aud_s]` - fn revoke_spender(e: &Env, account: Address, spender: Address, data: Bytes) { + /// * data - `[a_tilde: BytesN<32>, allowance_salt: BytesN<32>]` + fn revoke_spender(e: &Env, account: Address, spender: Address) { account.require_auth(); - let decoded: RevokeSpenderData = storage::decode_data(e, &data); - Self::Hooks::on_revoke_spender(e, &account, &spender, &decoded.payload); - storage::revoke_spender(e, &account, &spender, &decoded.payload, &decoded.proof); + Self::Hooks::on_revoke_spender(e, &account, &spender); + storage::revoke_spender(e, &account, &spender); } /// Returns the [`ConfidentialAccount`] stored under `account`. @@ -844,7 +837,8 @@ pub fn emit_set_spender( .publish(e); } -/// Event emitted when an spender is revoked. +/// Event emitted when a delegation's escrowed allowance is folded back into +/// the owner's spendable balance and the delegation is deleted. #[contractevent] #[derive(Clone, Debug, Eq, PartialEq)] pub struct RevokeSpender { @@ -852,33 +846,23 @@ pub struct RevokeSpender { pub account: Address, #[topic] pub spender: Address, - pub r_e_point: BytesN<64>, - pub sigma: BytesN<32>, - pub b_tilde: BytesN<32>, - pub v_tilde_aud_s: BytesN<32>, - pub b_tilde_aud_s: BytesN<32>, + pub a_tilde: BytesN<32>, + pub allowance_salt: BytesN<32>, } /// Emits a `RevokeSpender` event. -#[allow(clippy::too_many_arguments)] pub fn emit_revoke_spender( e: &Env, account: &Address, spender: &Address, - r_e_point: &BytesN<64>, - sigma: &BytesN<32>, - b_tilde: &BytesN<32>, - v_tilde_aud_s: &BytesN<32>, - b_tilde_aud_s: &BytesN<32>, + a_tilde: &BytesN<32>, + allowance_salt: &BytesN<32>, ) { RevokeSpender { account: account.clone(), spender: spender.clone(), - r_e_point: r_e_point.clone(), - sigma: sigma.clone(), - b_tilde: b_tilde.clone(), - v_tilde_aud_s: v_tilde_aud_s.clone(), - b_tilde_aud_s: b_tilde_aud_s.clone(), + a_tilde: a_tilde.clone(), + allowance_salt: allowance_salt.clone(), } .publish(e); } diff --git a/packages/tokens/src/confidential/storage.rs b/packages/tokens/src/confidential/storage.rs index 9c447f346..663098f9c 100644 --- a/packages/tokens/src/confidential/storage.rs +++ b/packages/tokens/src/confidential/storage.rs @@ -208,27 +208,6 @@ pub struct SetSpenderData { pub proof: Bytes, } -/// Payload for [`crate::confidential::ConfidentialToken::revoke_spender`]. -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct RevokeSpenderPayload { - pub c_spend_new: Point, - pub b_tilde: BytesN<32>, - pub r_e_point: Point, - pub sigma: BytesN<32>, - pub v_tilde_aud_s: BytesN<32>, - pub b_tilde_aud_s: BytesN<32>, -} - -/// Envelope decoded from the `data: Bytes` argument of -/// [`crate::confidential::ConfidentialToken::revoke_spender`]. -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct RevokeSpenderData { - pub payload: RevokeSpenderPayload, - pub proof: Bytes, -} - // ################## QUERY STATE ################## /// Returns the SEP-41 token address bound at construction. @@ -961,87 +940,44 @@ pub fn set_spender( ); } -/// Revokes the `(account, spender)` delegation and folds the remaining -/// escrowed allowance back into `account`'s spendable balance. -/// Works for both active and expired-but-not-revoked delegations. +/// Revokes the `(owner, spender)` delegation: folds the escrowed allowance +/// commitment `C_a` back into `owner`'s spendable commitment and deletes the +/// delegation entry. Works for both active and expired-but-not-revoked +/// delegations. The fold and the openings it yields to the owner and the +/// auditor are specified in DESIGN §7.9 and DESIGN_cont §8.5. /// /// # Arguments /// /// * `e` - Access to the Soroban environment. -/// * `account` - The owner reclaiming the allowance. +/// * `owner` - The owner whose allowance is being reclaimed. /// * `spender` - The previously-delegated spender. -/// * `payload` - The decoded [`RevokeSpenderPayload`]. -/// * `proof` - The raw UltraHonk proof bytes. /// /// # Errors /// -/// * [`ConfidentialTokenError::AccountNotRegistered`] - When `account` is not +/// * [`ConfidentialTokenError::AccountNotRegistered`] - When `owner` is not /// registered. -/// * [`ConfidentialTokenError::DelegationNotFound`] - When `(account, spender)` +/// * [`ConfidentialTokenError::DelegationNotFound`] - When `(owner, spender)` /// has no delegation. -/// * [`ConfidentialTokenError::NonCanonicalEncoding`] - When point coordinates -/// or fields from the proof are non-canonical. -/// * [`ConfidentialTokenError::InvalidProof`] - When the proof fails -/// verification. -/// * refer to [`crate::confidential::auditor::ConfidentialAuditor::get_key`] -/// errors. /// /// # Events /// /// * topics - `["revoke_spender", account: Address, spender: Address]` -/// * data - `[r_e_point, sigma, b_tilde, v_tilde_aud_s, b_tilde_aud_s]` +/// * data - `[a_tilde: BytesN<32>, allowance_salt: BytesN<32>]` /// /// # Security Warning /// /// **IMPORTANT**: This function bypasses authorization checks. The trait /// entry point is responsible for calling `account.require_auth()`. -pub fn revoke_spender( - e: &Env, - account: &Address, - spender: &Address, - payload: &RevokeSpenderPayload, - proof: &Bytes, -) { - let owner = get_account(e, account); - let delegation = get_spender_delegation(e, account, spender); - let auditor = ConfidentialAuditorClient::new(e, &get_auditor(e)); - let k_aud_s = auditor.get_key(&owner.auditor_id); - let addr_f = get_address_as_field_element(e); - let spender_id = address_to_field(e, spender); - - // PI order (DESIGN §7.9): - // C_spend, C_a, sigma_a, Y, spender_id (op_i), addr_f, K_aud_s, - // C_spend', b_tilde, sigma, R_e, v_tilde_aud_s, b_tilde_aud_s - let mut pi = Bytes::new(e); - append_point(&mut pi, &owner.spendable_commitment); - append_point(&mut pi, &delegation.allowance_commitment); - append_field(&mut pi, &delegation.allowance_salt); - append_point(&mut pi, &owner.spending_public_key); - append_field(&mut pi, &spender_id); - append_field(&mut pi, &addr_f); - append_point(&mut pi, &k_aud_s); - append_point(&mut pi, &payload.c_spend_new); - append_field(&mut pi, &payload.b_tilde); - append_field(&mut pi, &payload.sigma); - append_point(&mut pi, &payload.r_e_point); - append_field(&mut pi, &payload.v_tilde_aud_s); - append_field(&mut pi, &payload.b_tilde_aud_s); +pub fn revoke_spender(e: &Env, owner: &Address, spender: &Address) { + let mut account = get_account(e, owner); + let delegation = get_spender_delegation(e, owner, spender); - verify(e, CircuitType::RevokeSpender, &pi, proof); + account.spendable_commitment = + Grumpkin::add(e, &account.spendable_commitment, &delegation.allowance_commitment); + e.storage().persistent().set(&ConfidentialTokenStorageKey::Account(owner.clone()), &account); + delete_delegation(e, owner, spender); - set_spendable(e, account, &payload.c_spend_new); - delete_delegation(e, account, spender); - - emit_revoke_spender( - e, - account, - spender, - &payload.r_e_point, - &payload.sigma, - &payload.b_tilde, - &payload.v_tilde_aud_s, - &payload.b_tilde_aud_s, - ); + emit_revoke_spender(e, owner, spender, &delegation.a_tilde, &delegation.allowance_salt); } /// Sets the SEP-41 token address. diff --git a/packages/tokens/src/confidential/test.rs b/packages/tokens/src/confidential/test.rs index 9b27e16cc..6c5cf2363 100644 --- a/packages/tokens/src/confidential/test.rs +++ b/packages/tokens/src/confidential/test.rs @@ -7,16 +7,16 @@ use soroban_sdk::{ xdr::ToXdr, Address, Bytes, BytesN, Env, Event, }; +use stellar_contract_utils::crypto::grumpkin::Grumpkin; use crate::confidential::{ auditor::{storage as auditor_storage, ConfidentialAuditor}, storage as token_storage, verifier::{CircuitType, ConfidentialVerifier}, ConfidentialAccount, ConfidentialToken, ConfidentialTokenClient, NoHooks, RegisterData, - RegisterPayload, RevokeSpender, RevokeSpenderData, RevokeSpenderPayload, SetSpender, - SetSpenderData, SetSpenderPayload, SpenderDelegation, SpenderTransfer, SpenderTransferData, - SpenderTransferPayload, Transfer, TransferData, TransferPayload, Withdraw, WithdrawData, - WithdrawPayload, + RegisterPayload, RevokeSpender, SetSpender, SetSpenderData, SetSpenderPayload, + SpenderDelegation, SpenderTransfer, SpenderTransferData, SpenderTransferPayload, Transfer, + TransferData, TransferPayload, Withdraw, WithdrawData, WithdrawPayload, }; // ################## TEST FIXTURES ################## @@ -166,8 +166,8 @@ impl ConfidentialVerifier for ReplayGuardVerifier { /// registered at the moment of the operation, so "which key version can read /// this event" reduces, at the contract level, to "which `K_aud_s` went into /// the public-input blob". This mock captures exactly that. `K_aud_s` sits at -/// limb 8 of the SetSpender blob and limb 9 of the SpenderTransfer and -/// RevokeSpender blobs (DESIGN §7.7 - §7.9). +/// limb 8 of the SetSpender blob and limb 9 of the SpenderTransfer blob +/// (DESIGN §7.7 - §7.8). #[contract] struct KeyRecordingVerifier; @@ -181,7 +181,6 @@ impl ConfidentialVerifier for KeyRecordingVerifier { let (key, offset) = match ct { CircuitType::SetSpender => (symbol_short!("k_set"), 8u32 * 32), CircuitType::SpenderTransfer => (symbol_short!("k_xfer"), 9u32 * 32), - CircuitType::RevokeSpender => (symbol_short!("k_revoke"), 9u32 * 32), _ => return true, }; e.storage().instance().set(&key, &pi.slice(offset..offset + 64)); @@ -334,21 +333,6 @@ fn spender_transfer_data(e: &Env) -> Bytes { .to_xdr(e) } -fn revoke_spender_data(e: &Env) -> Bytes { - RevokeSpenderData { - payload: RevokeSpenderPayload { - c_spend_new: fixture_point(e), - b_tilde: fixture_field(e, 0x41), - r_e_point: fixture_point(e), - sigma: fixture_field(e, 0x42), - v_tilde_aud_s: fixture_field(e, 0x43), - b_tilde_aud_s: fixture_field(e, 0x44), - }, - proof: Bytes::new(e), - } - .to_xdr(e) -} - // ################## REGISTER ################## #[test] @@ -739,12 +723,62 @@ fn revoke_spender_deletes_delegation() { h.token.register(&spender, &1u32, ®ister_data(&h.e)); h.token.set_spender(&alice, &spender, &1_000u32, &set_spender_data(&h.e)); - h.token.revoke_spender(&alice, &spender, &revoke_spender_data(&h.e)); + h.token.revoke_spender(&alice, &spender); assert_eq!(h.e.events().all().events().len(), 1); assert!(!h.token.is_spender(&alice, &spender)); } +#[test] +fn revoke_spender_folds_allowance_into_spendable() { + let h = setup(); + let alice = Address::generate(&h.e); + let spender = Address::generate(&h.e); + + h.token.register(&alice, &1u32, ®ister_data(&h.e)); + h.token.register(&spender, &1u32, ®ister_data(&h.e)); + h.token.set_spender(&alice, &spender, &1_000u32, &set_spender_data(&h.e)); + + let pre = h.token.confidential_balance(&alice); + let delegation = h.token.get_spender_delegation(&alice, &spender); + let expected = h.e.as_contract(&h.token.address, || { + Grumpkin::add(&h.e, &pre.spendable_commitment, &delegation.allowance_commitment) + }); + + h.token.revoke_spender(&alice, &spender); + + let post = h.token.confidential_balance(&alice); + assert_eq!(post.spendable_commitment, expected); + assert_eq!(post.receiving_commitment, pre.receiving_commitment); +} + +#[test] +fn revoke_spender_after_expiry_folds_allowance() { + let h = setup(); + let alice = Address::generate(&h.e); + let spender = Address::generate(&h.e); + + h.token.register(&alice, &1u32, ®ister_data(&h.e)); + h.token.register(&spender, &1u32, ®ister_data(&h.e)); + h.token.set_spender(&alice, &spender, &10u32, &set_spender_data(&h.e)); + + let pre = h.token.confidential_balance(&alice); + let delegation = h.token.get_spender_delegation(&alice, &spender); + let expected = h.e.as_contract(&h.token.address, || { + Grumpkin::add(&h.e, &pre.spendable_commitment, &delegation.allowance_commitment) + }); + + h.e.ledger().set_sequence_number(100); + assert!(!h.token.is_spender(&alice, &spender)); + + h.token.revoke_spender(&alice, &spender); + + assert_eq!(h.token.confidential_balance(&alice).spendable_commitment, expected); + assert!(!h.e.as_contract(&h.token.address, || token_storage::delegation_exists( + &h.e, &alice, &spender + ))); +} + #[test] #[should_panic(expected = "Error(Contract, #3504)")] fn revoke_unknown_spender_panics() { @@ -752,7 +786,7 @@ fn revoke_unknown_spender_panics() { let alice = Address::generate(&h.e); let spender = Address::generate(&h.e); h.token.register(&alice, &1u32, ®ister_data(&h.e)); - h.token.revoke_spender(&alice, &spender, &revoke_spender_data(&h.e)); + h.token.revoke_spender(&alice, &spender); } #[test] @@ -856,27 +890,23 @@ fn auditor_key_rotation_rescopes_the_escrowed_allowance_opening() { let after = h.token.get_spender_delegation(&alice, &spender); assert_ne!(before.allowance_salt, after.allowance_salt); - // Step 4 -- revocation runs under K2 too, but RevokeSpender reads only two - // sponge lanes (V_a3): its event carries no allowance escrow at all. An - // auditor that missed step 3 gets no second chance here -- it has to fold - // the allowance it already holds into C_spend, or wait for the owner's - // next checkpoint. This is the field list, and the absence is the point. - h.token.revoke_spender(&alice, &spender, &revoke_spender_data(&h.e)); + // Step 4 -- revocation is a proofless fold (§7.9), so it opens no auditor + // channel: the event republishes the two delegation fields the fold + // deletes and nothing else. An auditor that missed step 3 folds the + // allowance it already holds into C_spend, or waits for the owner's next + // checkpoint. + h.token.revoke_spender(&alice, &spender); let revoke_events = h.e.events().all(); assert_eq!( revoke_events.events().first().unwrap(), &RevokeSpender { account: alice.clone(), spender: spender.clone(), - r_e_point: fixture_point(&h.e), - sigma: fixture_field(&h.e, 0x42), - b_tilde: fixture_field(&h.e, 0x41), - v_tilde_aud_s: fixture_field(&h.e, 0x43), - b_tilde_aud_s: fixture_field(&h.e, 0x44), + a_tilde: after.a_tilde.clone(), + allowance_salt: after.allowance_salt.clone(), } .to_xdr(&h.e, &h.token_addr) ); - assert_eq!(recorded(symbol_short!("k_revoke")), k2.into()); assert!(!h.token.is_spender(&alice, &spender)); } diff --git a/packages/tokens/src/confidential/verifier/mod.rs b/packages/tokens/src/confidential/verifier/mod.rs index d23486fb5..a36be83b6 100644 --- a/packages/tokens/src/confidential/verifier/mod.rs +++ b/packages/tokens/src/confidential/verifier/mod.rs @@ -102,7 +102,6 @@ pub enum CircuitType { Transfer = 2, SpenderTransfer = 3, SetSpender = 4, - RevokeSpender = 5, } /// Trait for managing UltraHonk verification keys used by the confidential diff --git a/packages/tokens/src/confidential/verifier/test.rs b/packages/tokens/src/confidential/verifier/test.rs index aca870426..bd33daae4 100644 --- a/packages/tokens/src/confidential/verifier/test.rs +++ b/packages/tokens/src/confidential/verifier/test.rs @@ -44,7 +44,6 @@ fn register_each_circuit_round_trips() { CircuitType::Transfer, CircuitType::SpenderTransfer, CircuitType::SetSpender, - CircuitType::RevokeSpender, ] .into_iter() .enumerate() @@ -130,12 +129,12 @@ fn storage_key_round_trip() { e.as_contract(&address, || { let verification_key = verification_key_bytes(&e, 0x77); - register_verification_key(&e, CircuitType::RevokeSpender, &verification_key); + register_verification_key(&e, CircuitType::SetSpender, &verification_key); let stored: Bytes = e .storage() .instance() - .get(&VerifierStorageKey::VerificationKey(CircuitType::RevokeSpender)) + .get(&VerifierStorageKey::VerificationKey(CircuitType::SetSpender)) .unwrap(); assert_eq!(stored, verification_key); }); From 3656391c8c6f01a60272bbff202fa25e5c831f9b Mon Sep 17 00:00:00 2001 From: Boyan Barakov <9572072+brozorec@users.noreply.github.com> Date: Thu, 3 Sep 2026 17:08:37 +0200 Subject: [PATCH 07/17] feat(confidential): add compliance clawback (#855) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(confidential): add compliance clawback Adds the opt-in `ConfidentialClawback` trait (`clawback`, `force_revoke_spender`), the clawback Noir circuit with `CircuitType::Clawback = 6`, and its specification in COMPLIANCE.md §5 written against the `r_a`-escrow auditor model. * docs(confidential): review prose * docs(confidential): specify blinding accumulation in the SDK DESIGN.md and SDK.md disagreed on whether W_spend.r is reduced per fold; SDK.md §4.6, §10.1 and §10.7 now own the rule, requiring exact-integer accumulation with a single mod-q reduction at the curve boundary and testing encodability on the reduction rather than the accumulator. DESIGN.md §5.2 keeps only the cross-reference, and DESIGN_cont.md §10.4 gains clawback in its fold list. * docs(confidential): add Clawback event in the table * refactor(confidential): number the Clawback circuit at 100 Core circuits keep 0..=4; circuits shipped by optional extensions start at 100. Drops the retired-discriminant note. * docs(confidential): tighten compliance module docs Name what each sentence refers to instead of "gates on it"/"that", spell out `destination` in the None/Some(d) settlement cases, and drop idioms and a duplicated inline comment, per review on #855. --- packages/tokens/src/confidential/CLAUDE.md | 13 +- .../src/confidential/circuits/CLAUDE.md | 8 +- .../src/confidential/circuits/Nargo.toml | 1 + .../confidential/circuits/clawback/Nargo.toml | 10 + .../circuits/clawback/src/main.nr | 101 ++++++ .../circuits/clawback/src/tests.nr | 203 ++++++++++++ .../circuits/constraints.baseline | 3 + .../confidential/circuits/lib/src/tests.nr | 8 +- .../circuits/scripts/extract_vks.sh | 1 + .../circuits/set_spender/src/tests.nr | 2 +- .../circuits/vks/clawback.vk.json | 1 + .../tokens/src/confidential/compliance/mod.rs | 292 +++++++++++++----- .../src/confidential/compliance/storage.rs | 177 ++++++++++- .../src/confidential/compliance/test.rs | 249 ++++++++++++++- .../src/confidential/docs/COMPLIANCE.md | 119 ++++--- .../tokens/src/confidential/docs/DESIGN.md | 15 +- .../src/confidential/docs/DESIGN_cont.md | 33 +- .../tokens/src/confidential/docs/INDEXER.md | 13 +- .../tokens/src/confidential/docs/OVERVIEW.md | 7 +- packages/tokens/src/confidential/docs/SDK.md | 29 +- packages/tokens/src/confidential/mod.rs | 23 +- packages/tokens/src/confidential/storage.rs | 37 ++- packages/tokens/src/confidential/test.rs | 7 +- .../tokens/src/confidential/verifier/mod.rs | 4 +- .../tokens/src/confidential/verifier/test.rs | 5 +- 25 files changed, 1166 insertions(+), 195 deletions(-) create mode 100644 packages/tokens/src/confidential/circuits/clawback/Nargo.toml create mode 100644 packages/tokens/src/confidential/circuits/clawback/src/main.nr create mode 100644 packages/tokens/src/confidential/circuits/clawback/src/tests.nr create mode 100644 packages/tokens/src/confidential/circuits/vks/clawback.vk.json diff --git a/packages/tokens/src/confidential/CLAUDE.md b/packages/tokens/src/confidential/CLAUDE.md index 305a056bb..3104b3b5f 100644 --- a/packages/tokens/src/confidential/CLAUDE.md +++ b/packages/tokens/src/confidential/CLAUDE.md @@ -11,7 +11,7 @@ The module ships one token contract plus three satellites, each with the standar | `mod.rs`, `storage.rs` | The `ConfidentialToken` trait — eleven entry points, of which `revoke_spender` is proofless — and the storage/orchestration layer | | `verifier/` | Separate contract holding per-circuit UltraHonk verification keys | | `auditor/` | Separate contract holding the auditor key registry | -| `compliance/` | `ComplianceHooks` — freeze, SAC passthrough, policy contract, clawback | +| `compliance/` | `ComplianceHooks` — freeze, SAC passthrough, policy contract — plus the opt-in `ConfidentialClawback` trait (`clawback`, `force_revoke_spender`) | | `circuits/` | Noir workspace, compiled by `nargo`, not `cargo` | | `docs/` | The protocol specification (see below) | @@ -25,13 +25,13 @@ Balances are Pedersen commitments on Grumpkin. Every operation that opens or re- ## Canonical encoding is a security boundary -The public-input blob is a positional concatenation of 32-byte big-endian `Bn254Fr` representatives, in the order given by each circuit's table in DESIGN §7. Grumpkin points contribute two limbs (`x` then `y`). +The public-input blob is a positional concatenation of 32-byte big-endian `Bn254Fr` representatives, in the order given by each circuit's table in DESIGN §7 (COMPLIANCE §5.3 for the clawback circuit). Grumpkin points contribute two limbs (`x` then `y`). Soroban's host silently reduces values `≥ r` modulo `r` rather than rejecting them, so `x` and `x + r` deserialise to the same field element. Every caller-supplied scalar and coordinate must therefore reach `verify_proof` through `append_field` / `append_point`, which call `Grumpkin::is_canonical_field` / `is_canonical_point`. Bypassing those helpers breaks byte-uniqueness of stored state and emitted events even though proofs still verify. ## Code cites the spec by section number -Rust carries roughly sixteen `DESIGN §N` / `DESIGN_cont §N` references in doc comments. Renumbering a spec section silently invalidates them — nothing checks. Before renumbering, grep the module for the old number. +Rust carries roughly two dozen `DESIGN §N` / `DESIGN_cont §N` / `COMPLIANCE §N` references in doc comments, and `circuits/clawback/src/main.nr` cites `COMPLIANCE.md` by section too. Renumbering a spec section silently invalidates them — nothing checks. Before renumbering, grep the module for the old number. ## Tests @@ -55,7 +55,7 @@ Everything else defers by citation: `SDK.md`, `SELECTIVE_DISCLOSURE.md`, `INDEXE ### Duplicated tables that drift -Five things exist in more than one file. Changing the normative copy means grepping for every other one: +Six things exist in more than one file. Changing the normative copy means grepping for every other one: | Content | Normative source | Copies live in | |:---|:---|:---| @@ -63,7 +63,8 @@ Five things exist in more than one file. Changing the normative copy means grepp | Sponge lane assignment | `DESIGN.md` §2.5 | `SDK.md` §4.3 and §11 | | Per-circuit scalar-multiplication counts | `DESIGN_cont.md` §10.3 | `OVERVIEW.md` | | Checkpoint event set | `DESIGN.md` §5.2 | `INDEXER.md`, `SDK.md` | -| Replay-window anchor `T₀` | `DESIGN.md` §5.2 | `INDEXER.md`, `OVERVIEW.md` | +| Replay-window anchor `T₀` (`Register`, `Merge`, `Clawback`) | `DESIGN.md` §5.2 | `INDEXER.md`, `OVERVIEW.md`, `SDK.md`, `COMPLIANCE.md` §5.7 | +| ACIR opcode counts | `circuits/constraints.baseline` | `DESIGN_cont.md` §10.3, `circuits/CLAUDE.md` | The tags are a cross-language wire contract. `DESIGN_cont.md` §13 is their only authoritative source: it assigns every value, and it states which subset `circuits/lib/src/lib.nr` implements and why the remainder are absent. Changing any assigned value is a new deployment, not an upgrade. @@ -71,7 +72,7 @@ The tags are a cross-language wire contract. `DESIGN_cont.md` §13 is their only Every sentence is a maintenance liability: a claim written twice has to be fixed twice, and the second copy is the one that goes stale. Doc work here trends net-negative in lines. -- **One owning section per claim.** Every fact has exactly one home; everywhere else writes `§N`. When tempted to summarise a neighbouring section for the reader's convenience, cite it instead; +- **One owning section per claim.** Every fact has exactly one home; everywhere else writes `§N`. When tempted to summarise a neighbouring section for the reader's convenience, cite it instead. - **Say it once, then move on.** No second-register restatement, no paragraph-closing punchline, no recap of the section's own argument in its last sentence. If a paragraph's content survives deleting it, delete it. - **Gloss a symbol at its definition site only.** Re-glossing `s` or `r_e` in each section that uses them is three more places to update when a name changes. - **No pre-stating.** A `Note:` or lead-in that previews what the next paragraph spells out in full is a duplicate. Fold it into the argument. diff --git a/packages/tokens/src/confidential/circuits/CLAUDE.md b/packages/tokens/src/confidential/circuits/CLAUDE.md index 577fc5a3f..303608311 100644 --- a/packages/tokens/src/confidential/circuits/CLAUDE.md +++ b/packages/tokens/src/confidential/circuits/CLAUDE.md @@ -22,11 +22,11 @@ There is no `nargo fmt` step in CI and no formatter config — Noir formatting i ### `compiler_version = "=1.0.0"` is deliberate -It appears in all thirteen package `Nargo.toml` files — the workspace manifest `circuits/Nargo.toml` carries only `[workspace]` — and looks like a mistake. Nargo requirements cannot name prereleases, so the real toolchain — **nargo 1.0.0-beta.11 + bb 0.87.0**, pinned in `.github/workflows/noir.yml` — is documented in the comment above it instead. Do not "correct" this to `=1.0.0-beta.11`. +It appears in all fourteen package `Nargo.toml` files — the workspace manifest `circuits/Nargo.toml` carries only `[workspace]` — and looks like a mistake. Nargo requirements cannot name prereleases, so the real toolchain — **nargo 1.0.0-beta.11 + bb 0.87.0**, pinned in `.github/workflows/noir.yml` — is documented in the comment above it instead. Do not "correct" this to `=1.0.0-beta.11`. ### Do not prune unused public inputs -`_acct_f` in `register/src/main.nr` is referenced by no gate and looks like dead code. It is the replay binding: UltraHonk absorbs every public input into the transcript, so a proof produced for one account fails when the contract assembles the blob for another. Removing it lets anyone replay a legitimate registration's published proof and payload to mint duplicate-key accounts. Each operation circuit declares its exact public-input count in a header comment — withdraw 16, transfer / spender_transfer 25, set_spender 26 — and the count is part of the contract with the on-chain assembler. +`_acct_f` in `register/src/main.nr` is referenced by no gate and looks like dead code. It is the replay binding: UltraHonk absorbs every public input into the transcript, so a proof produced for one account fails when the contract assembles the blob for another. Removing it lets anyone replay a legitimate registration's published proof and payload to mint duplicate-key accounts. `clawback/src/main.nr` carries three such bindings — `addr_f`, `_acct_f`, `_dest_f` — and removing `_dest_f` lets a compromised clawback signer settle a witness to a destination of its own choosing. Each operation circuit declares its exact public-input count in a header comment — clawback 8, withdraw 16, transfer / spender_transfer 25, set_spender 26 — and the count is part of the contract with the on-chain assembler. ### Package names are load-bearing @@ -60,7 +60,7 @@ LC_ALL=C nargo info | grep '^|' | LC_ALL=C sort > constraints.baseline `LC_ALL=C` is mandatory on **both** sides of the pipe — byte order is the only ordering stable between macOS and the Ubuntu runner. The redirect overwrites the file's header comments; re-paste them, because CI's failure message asks for them. -Two non-obvious consequences: adding or removing a **gadget** changes the baseline even when no circuit logic changed, and the ACIR opcode counts are quoted in prose at `../docs/DESIGN_cont.md` §10.3 (Register 33, Withdraw 95, Transfer 134, SetSpender 135, SpenderTransfer 136). Nothing enforces that second copy — update it in the same PR. +Two non-obvious consequences: adding or removing a **gadget** changes the baseline even when no circuit logic changed, and the ACIR opcode counts are quoted in prose at `../docs/DESIGN_cont.md` §10.3 (Register 33, Clawback 51, Withdraw 95, Transfer 134, SetSpender 135, SpenderTransfer 136). Nothing enforces that second copy — update it in the same PR. ### `vks/` @@ -88,4 +88,4 @@ Note the standing obligation on the future TS SDK (`../docs/SDK.md` §6.1): its ## Version bumps -`.github/workflows/noir.yml` is the source of truth for `NARGO_VERSION` and `BB_VERSION`, and the two must be bumped together — the VK pipeline is byte-sensitive to bb. The string `1.0.0-beta.11` is duplicated across all thirteen package `Nargo.toml` files, `constraints.baseline`, `vks/README.md` (which also restates the bb version), and this file. A bump touches all of them and requires regenerating both the baseline and the VKs. The installer scripts in CI are pinned to git commits with SHA256 verification; bump URL and hash together. +`.github/workflows/noir.yml` is the source of truth for `NARGO_VERSION` and `BB_VERSION`, and the two must be bumped together — the VK pipeline is byte-sensitive to bb. The string `1.0.0-beta.11` is duplicated across all fourteen package `Nargo.toml` files, `constraints.baseline`, `vks/README.md` (which also restates the bb version), and this file. A bump touches all of them and requires regenerating both the baseline and the VKs. The installer scripts in CI are pinned to git commits with SHA256 verification; bump URL and hash together. diff --git a/packages/tokens/src/confidential/circuits/Nargo.toml b/packages/tokens/src/confidential/circuits/Nargo.toml index e9ff6b457..a665460b2 100644 --- a/packages/tokens/src/confidential/circuits/Nargo.toml +++ b/packages/tokens/src/confidential/circuits/Nargo.toml @@ -6,6 +6,7 @@ members = [ "transfer", "set_spender", "spender_transfer", + "clawback", "gadgets/assert_on_curve", "gadgets/commit", "gadgets/ecdh", diff --git a/packages/tokens/src/confidential/circuits/clawback/Nargo.toml b/packages/tokens/src/confidential/circuits/clawback/Nargo.toml new file mode 100644 index 000000000..0582bd93e --- /dev/null +++ b/packages/tokens/src/confidential/circuits/clawback/Nargo.toml @@ -0,0 +1,10 @@ +[package] +name = "circuit_clawback" +type = "bin" +authors = ["OpenZeppelin"] +# Nargo requirements cannot name prereleases; the supported toolchain is +# nargo 1.0.0-beta.11 + bb 0.87.0, pinned in .github/workflows/noir.yml. +compiler_version = "=1.0.0" + +[dependencies] +stellar_confidential_lib = { path = "../lib" } diff --git a/packages/tokens/src/confidential/circuits/clawback/src/main.nr b/packages/tokens/src/confidential/circuits/clawback/src/main.nr new file mode 100644 index 000000000..2f1f6e6b9 --- /dev/null +++ b/packages/tokens/src/confidential/circuits/clawback/src/main.nr @@ -0,0 +1,101 @@ +use stellar_confidential_lib::commit; + +mod tests; + +// Clawback circuit -- COMPLIANCE.md Section 5.3. +// +// Proves that a compliance seizure of the public amount `alpha` is bounded by +// what the target actually holds, without revealing either balance. It is the +// only circuit in the system with no key-ownership constraint and no `r_e`: +// both openings are pinned by Pedersen binding directly. +// +// Constraints +// ----------- +// CB1 C_spend = v_s * G + r_s * H Prover knows the opening of the +// target's spendable commitment. +// CB2 C_receive = v_r * G + r_r * H Prover knows the opening of the +// target's receiving commitment. +// CB3 v_s, v_r, alpha, and +// v_s + v_r - alpha in [0, 2^127) The seize bound (Section 2.6). +// The final term establishes +// alpha <= v_s + v_r. +// +// Identity encoding +// ----------------- +// The circuit MUST accept (0, 0) as the identity for either commitment, opened +// as (0, 0). `C_receive = O` whenever the target has nothing pending. +// `commit(0, 0)` returns the identity point, whose `EmbeddedCurvePoint` +// encoding has x = y = 0, so the equality checks below hold without a special +// case. +// +// Point-validation +// ---------------------------------------- +// C_spend and C_receive are the only points, and both are bound to in-circuit +// multi_scalar_mul outputs (CB1, CB2), so they are on-curve by construction. +// +// Public inputs (8 fields, in COMPLIANCE.md Section 5.3 canonical order) +// ---------------------------------------------------------------------- +// Idx Param Symbol Source / Note +// --- ----- ------ ---------------------------------------- +// 0 c_spend_x C_spend.x Loaded from the target's +// 1 c_spend_y C_spend.y `spendable_commitment`. +// 2 c_receive_x C_receive.x Loaded from the target's +// 3 c_receive_y C_receive.y `receiving_commitment`. +// 4 alpha alpha Public seize amount, from the invocation. +// The contract pre-checks alpha > 0 before +// assembling the blob; CB3 closes +// alpha < 2^127 in-circuit. +// 5 addr_f addr_f Loaded from instance storage. +// 6 _acct_f acct_f `address_to_field(account)`, computed +// per-call. +// 7 _dest_f dest_f `address_to_field(destination)` under +// `Some`, the zero field under `None`; +// computed per-call. +// +// `addr_f`, `_acct_f`, and `_dest_f` are referenced by no gate and look like +// dead parameters: their presence in the public-input set is the binding, on +// the `register/_acct_f` precedent. +// +// Private witnesses +// ----------------- +// v_s Plaintext spendable-balance value. +// r_s Plaintext blinding factor for C_spend. +// v_r Plaintext receiving-balance value. +// r_r Plaintext blinding factor for C_receive. + +fn main( + v_s: Field, + r_s: Field, + v_r: Field, + r_r: Field, + c_spend_x: pub Field, + c_spend_y: pub Field, + c_receive_x: pub Field, + c_receive_y: pub Field, + alpha: pub Field, + addr_f: pub Field, + _acct_f: pub Field, + _dest_f: pub Field, +) { + // addr_f is a binding-only input like _acct_f and _dest_f; naming it + // without a leading underscore would warn, so consume it explicitly. + let _ = addr_f; + + // CB1 + let c_spend_derived = commit(v_s, r_s); + assert(c_spend_derived.x == c_spend_x); + assert(c_spend_derived.y == c_spend_y); + + // CB2 + let c_receive_derived = commit(v_r, r_r); + assert(c_receive_derived.x == c_receive_x); + assert(c_receive_derived.y == c_receive_y); + + // CB3 -- Section 2.6's 127-bit decomposition / recomposition pattern. + // Range on v_s and v_r alone does not bound v_s + v_r - alpha. + v_s.assert_max_bit_size::<127>(); + v_r.assert_max_bit_size::<127>(); + alpha.assert_max_bit_size::<127>(); + let v_new = v_s + v_r - alpha; + v_new.assert_max_bit_size::<127>(); +} diff --git a/packages/tokens/src/confidential/circuits/clawback/src/tests.nr b/packages/tokens/src/confidential/circuits/clawback/src/tests.nr new file mode 100644 index 000000000..a7cd65b73 --- /dev/null +++ b/packages/tokens/src/confidential/circuits/clawback/src/tests.nr @@ -0,0 +1,203 @@ +use crate::main; +use stellar_confidential_lib::commit; + +// Canonical fixture inputs. +// +// V_S / R_S reuse the lib's pinned `commit` vector (lib/testdata), so C_SPEND +// is derivable from -- and pinned by -- the lib's `fixtures_match_testdata` +// test. V_R / R_R and ALPHA are local to this circuit. ALPHA = 700 exceeds +// V_R alone, so the happy path exercises a seize that necessarily draws on +// both commitments rather than one. +global V_S: Field = 1000; +global R_S: Field = 42; +global V_R: Field = 500; +global R_R: Field = 77; +global ALPHA: Field = 700; + +global ADDR_F: Field = 0xbeef; +global ACCT_F: Field = 0xfeed; +global DEST_F: Field = 0xcafe; + +// C_spend = commit(1000, 42) (= lib `commit` fixture). +global C_SPEND_X: Field = 0x195a5d8ecd032fe1696054b28f0852b5f03a613681991ace823245ab2f97ed05; +global C_SPEND_Y: Field = 0x0e67489ecfee0e581dce7db22a383c635886cfb616c3b3bb033bff0c46e9789e; + +// C_receive = commit(500, 77). Pinned by `clawback_fixtures_match_lib`. +global C_RECEIVE_X: Field = 0x13399238a890131dbbe17b7e1d4bf333057906c812fc4c718b2c6efad3e97a08; +global C_RECEIVE_Y: Field = 0x2f5dc4cdf9796114e546848c941e7f7fc09ea5dbf2542ea242005dccffb6b7e8; + +#[test] +fn print_fixtures() { + // One-shot harness: prints C_receive for the chosen fixture tuple. Run + // with `nargo test --package circuit_clawback print_fixtures --show-output` + // and paste the values into the globals above. + let c_receive = commit(V_R, R_R); + let rx = c_receive.x; + let ry = c_receive.y; + println(f"C_RECEIVE_X = {rx}"); + println(f"C_RECEIVE_Y = {ry}"); +} + +#[test] +fn clawback_fixtures_match_lib() { + // Pins the fixtures to the lib primitives, so drift is reported here + // rather than as a less informative failure deep inside `main`. + let c_spend = commit(V_S, R_S); + let c_receive = commit(V_R, R_R); + assert(c_spend.x == C_SPEND_X); + assert(c_spend.y == C_SPEND_Y); + assert(c_receive.x == C_RECEIVE_X); + assert(c_receive.y == C_RECEIVE_Y); +} + +#[test] +fn matches_fixture() { + main( + V_S, + R_S, + V_R, + R_R, + C_SPEND_X, + C_SPEND_Y, + C_RECEIVE_X, + C_RECEIVE_Y, + ALPHA, + ADDR_F, + ACCT_F, + DEST_F, + ); +} + +#[test] +fn empty_receiving_side_is_the_identity() { + // C_receive = O is the common case: the target has nothing pending. The + // circuit must open it as (0, 0) with no special case, and `commit(0, 0)` + // must encode as x = y = 0. + let identity = commit(0, 0); + assert(identity.x == 0); + assert(identity.y == 0); + + main(V_S, R_S, 0, 0, C_SPEND_X, C_SPEND_Y, 0, 0, ALPHA, ADDR_F, ACCT_F, DEST_F); +} + +#[test] +fn seize_of_the_entire_claim_is_admissible() { + // alpha == v_s + v_r leaves the account at zero, which is in range. + main( + V_S, + R_S, + V_R, + R_R, + C_SPEND_X, + C_SPEND_Y, + C_RECEIVE_X, + C_RECEIVE_Y, + V_S + V_R, + ADDR_F, + ACCT_F, + DEST_F, + ); +} + +#[test] +fn none_destination_sentinel_verifies() { + // The contract passes the zero field for `destination: None`. Nothing in + // the circuit distinguishes it -- the point of the test is that the + // sentinel is a legal public input, not a rejected one. + main( + V_S, + R_S, + V_R, + R_R, + C_SPEND_X, + C_SPEND_Y, + C_RECEIVE_X, + C_RECEIVE_Y, + ALPHA, + ADDR_F, + ACCT_F, + 0, + ); +} + +#[test(should_fail)] +fn over_seize_is_rejected() { + // alpha > v_s + v_r makes v_s + v_r - alpha wrap mod F_r, landing far + // above 2^127. This is CB3's soundness-critical case: without the sum + // check the seize would produce a commitment the owner can open but can + // never spend from again. + main( + V_S, + R_S, + V_R, + R_R, + C_SPEND_X, + C_SPEND_Y, + C_RECEIVE_X, + C_RECEIVE_Y, + V_S + V_R + 1, + ADDR_F, + ACCT_F, + DEST_F, + ); +} + +#[test(should_fail)] +fn out_of_range_alpha_is_rejected() { + // alpha >= 2^127 fails CB3's own range check before the sum is formed. + main( + V_S, + R_S, + V_R, + R_R, + C_SPEND_X, + C_SPEND_Y, + C_RECEIVE_X, + C_RECEIVE_Y, + 0x80000000000000000000000000000000, + ADDR_F, + ACCT_F, + DEST_F, + ); +} + +#[test(should_fail)] +fn wrong_spendable_opening_is_rejected() { + // A witness that does not open the on-chain C_spend fails CB1. This is + // what stops the compliance admin -- which holds no blinding -- from + // producing a witness of its own. + main( + V_S + 1, + R_S, + V_R, + R_R, + C_SPEND_X, + C_SPEND_Y, + C_RECEIVE_X, + C_RECEIVE_Y, + ALPHA, + ADDR_F, + ACCT_F, + DEST_F, + ); +} + +#[test(should_fail)] +fn wrong_receiving_opening_is_rejected() { + // Same for CB2: an inflated v_r would otherwise let a prover claim a + // larger seizable total than the account holds. + main( + V_S, + R_S, + V_R + 1, + R_R, + C_SPEND_X, + C_SPEND_Y, + C_RECEIVE_X, + C_RECEIVE_Y, + ALPHA, + ADDR_F, + ACCT_F, + DEST_F, + ); +} diff --git a/packages/tokens/src/confidential/circuits/constraints.baseline b/packages/tokens/src/confidential/circuits/constraints.baseline index f58e9e6f2..4a6d98cae 100644 --- a/packages/tokens/src/confidential/circuits/constraints.baseline +++ b/packages/tokens/src/confidential/circuits/constraints.baseline @@ -18,6 +18,9 @@ # Toolchain: nargo 1.0.0-beta.11 # | Package | Function | Expression Width | ACIR Opcodes | Brillig Opcodes | +| circuit_clawback | decompose_hint | N/A | N/A | 30 | +| circuit_clawback | lte_hint | N/A | N/A | 33 | +| circuit_clawback | main | Bounded { width: 4 } | 51 | 63 | | circuit_register | decompose_hint | N/A | N/A | 30 | | circuit_register | directive_invert | N/A | N/A | 9 | | circuit_register | lte_hint | N/A | N/A | 33 | diff --git a/packages/tokens/src/confidential/circuits/lib/src/tests.nr b/packages/tokens/src/confidential/circuits/lib/src/tests.nr index b19f40491..41592bed3 100644 --- a/packages/tokens/src/confidential/circuits/lib/src/tests.nr +++ b/packages/tokens/src/confidential/circuits/lib/src/tests.nr @@ -238,8 +238,8 @@ fn sponge_squeeze_3_agrees_with_squeeze_2_on_shared_lanes() { // The widening from two lanes to three must be observationally identical // on lane[0] and lane[1] for every domain tag: both squeezes read the same // post-permutation state, and the absorb occupies a single rate-3 block. - // This is what lets `delta_aud_s` be widened everywhere it appears - // without renumbering a single existing constraint. + // It lets `delta_aud_s` be widened everywhere it appears without + // renumbering a single existing constraint. let s: Field = 0x12345; let sigma: Field = 0x6789; for d in [domain::AUDITOR_SENDER, domain::AUDITOR_RECIPIENT] { @@ -270,8 +270,8 @@ fn esc_allow_r_auditor_is_domain_separated_from_the_spender_escrow() { // additive pads over the same shape, so a shared tag would let a // coincidence of inputs collapse one onto the other. Reusing one tag for // both would be secure in practice, since the shared scalars differ (Y_op - // and K_aud_s are independent points), but the separation is what keeps - // Section 5.3's r_e-reuse argument whole, so it must be observable here. + // and K_aud_s are independent points), but the separation keeps Section + // 5.3's r_e-reuse argument whole, so it must be observable here. let x: Field = 0xd0d0; let s: Field = 0x12345; let op_i: Field = 0xabcd; diff --git a/packages/tokens/src/confidential/circuits/scripts/extract_vks.sh b/packages/tokens/src/confidential/circuits/scripts/extract_vks.sh index 325e0a3c1..2840ead05 100755 --- a/packages/tokens/src/confidential/circuits/scripts/extract_vks.sh +++ b/packages/tokens/src/confidential/circuits/scripts/extract_vks.sh @@ -27,6 +27,7 @@ CIRCUITS=( "transfer" "set_spender" "spender_transfer" + "clawback" ) OUT_DIR="vks" diff --git a/packages/tokens/src/confidential/circuits/set_spender/src/tests.nr b/packages/tokens/src/confidential/circuits/set_spender/src/tests.nr index 2596c0919..3e10e4190 100644 --- a/packages/tokens/src/confidential/circuits/set_spender/src/tests.nr +++ b/packages/tokens/src/confidential/circuits/set_spender/src/tests.nr @@ -1477,7 +1477,7 @@ fn rejects_escrow_of_blinding_for_the_wrong_sigma_a() { // correctly tagged, derived from the right dvk_i -- while every other // public stays canonical at SIGMA_A. S14 fires. // - // This is what makes the escrow useful: the auditor opens the on-chain C_a + // That pinning makes the escrow useful: the auditor opens the on-chain C_a // with what it decrypts. An escrow that floated free of sigma_a would let // a prover hand the auditor an opening of some other allowance state, and // the mismatch would only surface when the auditor checked the commitment. diff --git a/packages/tokens/src/confidential/circuits/vks/clawback.vk.json b/packages/tokens/src/confidential/circuits/vks/clawback.vk.json new file mode 100644 index 000000000..3d1b33ae9 --- /dev/null +++ b/packages/tokens/src/confidential/circuits/vks/clawback.vk.json @@ -0,0 +1 @@ +["0x0000000000000000000000000000000000000000000000000000000000008000","0x0000000000000000000000000000000000000000000000000000000000000018","0x0000000000000000000000000000000000000000000000000000000000000001","0x0000000000000000000000000000000000000000000000000000000000000008","0x0000000000000000000000000000003e29e35fa10d1374ee392d84015d55146f","0x00000000000000000000000000000000001aa499cf6c3c66d45cd37fc096dba0","0x000000000000000000000000000000e76665815609031e4aef4b456e5a69e81b","0x00000000000000000000000000000000000275c73958f82ba5d3605894738aee","0x0000000000000000000000000000007e272a70bbb936fdd4f8cce42a4fbb1414","0x000000000000000000000000000000000002c85229ff29f3f8dead1204da9097","0x00000000000000000000000000000017c1ef152e3a105b96bfbcdb3072cca45f","0x000000000000000000000000000000000022710699446a710ca9b4c2ef9d6872","0x0000000000000000000000000000000f0380611fed80283c6556f5ab3c1376f9","0x000000000000000000000000000000000020f6a0cee0c5044f9e331b0d1ec278","0x000000000000000000000000000000cc51082c78adb5636fb36ed6648a9334b5","0x00000000000000000000000000000000001f083692f4552918229d0976f3bdc0","0x0000000000000000000000000000000e023b951ec6139c58a52454903248aa63","0x0000000000000000000000000000000000029558e854db74dcf911661dac1ad0","0x000000000000000000000000000000b412d561055e7b0ff0f640b06bdae6edc9","0x000000000000000000000000000000000024567c57baa70dfd813136f398c174","0x000000000000000000000000000000f850f4dd17afeadb66f7d5af2098a067ae","0x00000000000000000000000000000000000a5fb82a741c2e85ec02218d7d8c6e","0x000000000000000000000000000000bf13d957cf9d3528432c9ce697fb6d2dde","0x00000000000000000000000000000000002b54eaa1f515448224b99a014a9fd7","0x000000000000000000000000000000328b2b2e721066afc8c65415579291cec6","0x00000000000000000000000000000000001551cd1cbfb9c65cbc6eead6511776","0x000000000000000000000000000000e4da569a33192b0f34a60e639388af1949","0x00000000000000000000000000000000002720b1e293b455df30130dcb761d96","0x0000000000000000000000000000000d515847713bc8321831a2011e963168e1","0x00000000000000000000000000000000002495fd85225afe88486bb32f391098","0x000000000000000000000000000000c4b0dca57e3054a5893a52fe82ed4c93a2","0x00000000000000000000000000000000001c03460a61021bafdf445365a24df2","0x000000000000000000000000000000fcd88c79df4143bf1e2ef7330b754715a0","0x0000000000000000000000000000000000039e73207196125e19f984267e6f9f","0x0000000000000000000000000000003b1a38b3e3cbef9b9178ad9190c30b3305","0x0000000000000000000000000000000000031be7550873e5c0d5da000782245e","0x000000000000000000000000000000fd471a9be2dd684881221caf4e3b032485","0x00000000000000000000000000000000002356514cfea29ef16d326f72f3cbca","0x000000000000000000000000000000b309f2a708d312c2144dc3ed2caed7f896","0x00000000000000000000000000000000000d7654fd948210647749189d77b08d","0x000000000000000000000000000000ac02cfeb0524a06ab88c4ccbe8ff9d4969","0x00000000000000000000000000000000000aac8197ec9a97a8a9e1c2423f3d4a","0x00000000000000000000000000000082bb2aadf7e3c3bc6b19f09ef980368226","0x000000000000000000000000000000000013ed6a9e3f475f83e7a1a6387efcba","0x0000000000000000000000000000002ddb0e371241a4e1949b9d10c3462de0d6","0x00000000000000000000000000000000002aeeb919361d025ee0cb2c3abacc70","0x0000000000000000000000000000000bf1e340a4e1f229af8a97aa284de78b95","0x000000000000000000000000000000000012f1eb993e6d434ee173893e06ce96","0x000000000000000000000000000000a330fe60e3bbf2f5b5a01dae93f332b4d8","0x0000000000000000000000000000000000006c08e445017084fd8a0b46e9b27d","0x000000000000000000000000000000479765744601fac59a58772fd8a16ccf44","0x00000000000000000000000000000000002f431136c5f545a247c7a54f116e4b","0x00000000000000000000000000000091bf2d311966e13423265736ce919b13eb","0x0000000000000000000000000000000000196696384a5f06ee7173acf7a697e8","0x00000000000000000000000000000045f2fb453e2cbde7c3763ba3a41d38c16f","0x0000000000000000000000000000000000103a777e3faace3c31cf1927576171","0x0000000000000000000000000000007ee098d6ddc852108fb4261b35ed31e8d9","0x00000000000000000000000000000000000bb2c5e278cf60370c4619232c78db","0x000000000000000000000000000000d85502b4d88c5589a84c4c93dc4706d3be","0x000000000000000000000000000000000002b27ffbbf09ce26ab3ef89b63aa97","0x000000000000000000000000000000691d8fd24730d11e8c4c672921ca86c1ce","0x00000000000000000000000000000000001597533e7f45d0d9272f911f6cbe8e","0x000000000000000000000000000000ac21d466f8422e1faa4f4f4e749a814651","0x000000000000000000000000000000000007a4cd311030c8e81b55cc5d1e2dde","0x000000000000000000000000000000fb6cd0af81e46eec9948fb3157bf71ffaa","0x000000000000000000000000000000000001f98d476af8dd99cb473e1eeb1ae6","0x000000000000000000000000000000abbf216bc97645fbe37a38cf60e8d76239","0x00000000000000000000000000000000001d0a487ad1bb296bbea426a8c48dd6","0x0000000000000000000000000000003883ddd61bd888c488f77fa3b3a9ae6beb","0x00000000000000000000000000000000000997dc425cf28e1d0f043aefa0d516","0x000000000000000000000000000000d5e78350147140e21fcadaa488b9c8ea8c","0x00000000000000000000000000000000002037861df990935fcd0e814b1bf739","0x0000000000000000000000000000004ecaa8998f76b6a4c09931fac7d6bc6749","0x000000000000000000000000000000000000687cbfcc8894c68f64a7cadeff9e","0x000000000000000000000000000000b6943ac8fa6ca1fd68b1a515dcdbb098c8","0x00000000000000000000000000000000000eff396de321039968df4870793740","0x000000000000000000000000000000da259e3cacabd225d773fbdef73a932041","0x000000000000000000000000000000000003f707759d8897bebaac9e6735f8ac","0x0000000000000000000000000000005fb25786c9a783258a264f0faff5f9982f","0x0000000000000000000000000000000000137e46ca8d6e2410f6bcb7cfe0d384","0x000000000000000000000000000000f5461779b573530e6bd7707fdc988113ad","0x00000000000000000000000000000000000da742eaed8a069b8c6b3c2a2880e2","0x0000000000000000000000000000005dea508ac832b3f8c7b4409d431107c69d","0x00000000000000000000000000000000001dc51b8af23809bad551c8653cd85f","0x0000000000000000000000000000002cde78913467c67dade61b479c25eb03d4","0x00000000000000000000000000000000002c9abdbb053781a89175609efbcaf7","0x00000000000000000000000000000056171db0722852ba449ca7ab03759ea4c0","0x000000000000000000000000000000000008b7075ae38656f84b2952dd8a7b7f","0x00000000000000000000000000000062eab404e33638dc79b321c47387e79866","0x00000000000000000000000000000000000eb3281c7badbbe070ac0a85228b10","0x000000000000000000000000000000b104d0d85d2c474f75ce151e18af9feba4","0x00000000000000000000000000000000001794fce82e9bd15cab7bc32fc7227d","0x000000000000000000000000000000de57d1ae8a024e3b4e698c5b4f0d88da43","0x00000000000000000000000000000000000635441a35af7980ad297c1cebbac4","0x00000000000000000000000000000036631fd011a8c26dac05c4c18beb459e5e","0x0000000000000000000000000000000000211ab89ee5f6206261ed14c7003ad7","0x00000000000000000000000000000085ddbb5359d7299988b147431fd326c5de","0x000000000000000000000000000000000020e1e230152899ddb07c71ab6a5beb","0x0000000000000000000000000000000712ccf2921e57da19c9fca055bece5704","0x000000000000000000000000000000000029ffcc382cfcfbed7748bb5a391a3e","0x0000000000000000000000000000008964c2abb496834356ab11fe15d7f5ef05","0x000000000000000000000000000000000020df9f5bfe49004af6581c4866afab","0x00000000000000000000000000000027787ea9ee7c33dfa37b6d6f4b77abb7d7","0x00000000000000000000000000000000001efd7dca7c8e6f4f6d73ccd7b42008","0x0000000000000000000000000000000000000000000000000000000000000001","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000002","0x0000000000000000000000000000000000000000000000000000000000000000","0x000000000000000000000000000000fce5f6e4c22ec9f684c4750f74e4c5f4e6","0x000000000000000000000000000000000028d57f17c825507ce364c1b8c6e27e","0x00000000000000000000000000000061e59e023cb3a2310baa326a86430956e2","0x00000000000000000000000000000000001286fdc058c2d00c990e76a17c96b2"] \ No newline at end of file diff --git a/packages/tokens/src/confidential/compliance/mod.rs b/packages/tokens/src/confidential/compliance/mod.rs index 172b4516f..652e1fb22 100644 --- a/packages/tokens/src/confidential/compliance/mod.rs +++ b/packages/tokens/src/confidential/compliance/mod.rs @@ -1,32 +1,38 @@ //! # Confidential Token Compliance Extension //! -//! Deployer-configurable controls layered on top of the -//! [`ConfidentialToken`]: per-account freezing, -//! SAC `authorized()` passthrough, and a pluggable external -//! authorization policy. See `docs/COMPLIANCE.md` for the +//! Deployer-configurable controls layered on top of the [`ConfidentialToken`]: +//! per-account freezing, SAC `authorized()` passthrough, a pluggable external +//! authorization policy, and opt-in seizure. See `docs/COMPLIANCE.md` for the //! specification. //! //! ## Surface //! -//! 1. [`ComplianceHooks`] — a turnkey [`Hooks`] implementation that gates every -//! token entry point against the active configuration. Wire as `type Hooks = -//! ComplianceHooks;` on a contract that implements [`ConfidentialToken`]. -//! 2. [`ConfidentialCompliance`] — the admin-facing trait. -//! 3. [`Policy`] — the cross-contract interface for an external allowlist / +//! 1. [`ComplianceHooks`] — a ready-made [`Hooks`] implementation that checks +//! every token entry point against the active configuration. Wire it as +//! `type Hooks = ComplianceHooks;` on a contract that implements +//! [`ConfidentialToken`]. +//! 2. [`ConfidentialCompliance`] — the admin-facing trait: freeze, unfreeze, +//! and configuration rotation. +//! 3. [`ConfidentialClawback`] — the opt-in seizure trait. A deployment that +//! does not implement it keeps freeze and policy checks but has no seizure +//! capability. +//! 4. [`Policy`] — the cross-contract interface for an external allowlist / //! denylist / KYC / sanctions registry. -//! 4. Storage helpers in [`storage`]. +//! 5. Storage helpers in [`storage`]. //! -//! Deployments that never write a configuration pay only one instance-storage -//! probe per op: [`ComplianceHooks`] short-circuits when -//! [`storage::compliance_config`] returns `None`. +//! Deployments that never write a configuration pay one instance-storage read +//! per operation: [`ComplianceHooks`] returns early when +//! [`storage::compliance_config`] is `None`. pub mod storage; #[cfg(test)] mod test; -use soroban_sdk::{contractclient, contracterror, contractevent, contracttrait, Address, Env}; -pub use storage::{ComplianceConfig, ComplianceStorageKey}; +use soroban_sdk::{ + contractclient, contracterror, contractevent, contracttrait, Address, Bytes, Env, +}; +pub use storage::{ClawbackData, ComplianceConfig, ComplianceStorageKey}; use crate::confidential::{ ConfidentialToken, Hooks, RegisterPayload, SetSpenderPayload, SpenderTransferPayload, @@ -35,37 +41,36 @@ use crate::confidential::{ // ################## POLICY ################## -/// External authorization policy interface. Contracts implementing this -/// trait become pluggable allowlist / denylist / KYC / sanctions registries. +/// External authorization policy interface. A contract implementing this +/// trait serves as a pluggable allowlist, denylist, KYC, or sanctions +/// registry. /// -/// The token contract passes its own address as `token` so a single registry -/// can serve multiple tokens and apply per-token rules where needed. +/// The token contract passes its own address as `token`, so one registry can +/// serve several tokens and apply per-token rules. #[contractclient(name = "PolicyClient")] pub trait Policy { - /// Returns `true` iff `account` is authorized to interact with + /// Returns `true` if and only if `account` is authorized to interact with /// `token`. fn is_authorized(e: Env, account: Address, token: Address) -> bool; } // ################## COMPLIANCE TRAIT ################## -/// Admin-facing compliance interface layered on top of -/// [`ConfidentialToken`]. Exposes freeze/unfreeze, configuration -/// rotation, and the matching read accessors. +/// Admin-facing compliance interface layered on top of [`ConfidentialToken`]: +/// freeze, unfreeze, configuration rotation, and the matching read accessors. /// /// ## Why the write methods have no default body /// -/// The write methods ([`ConfidentialCompliance::freeze`], -/// [`ConfidentialCompliance::unfreeze`], -/// [`ConfidentialCompliance::set_compliance_config`]) -/// accept an `operator: Address` and intentionally ship without a default -/// implementation. Because the choice of access-control module is the contract -/// author's, the trait forces an explicit override. The override typically: +/// [`ConfidentialCompliance::freeze`], [`ConfidentialCompliance::unfreeze`], +/// and [`ConfidentialCompliance::set_compliance_config`] take an +/// `operator: Address` and have no default implementation. The choice of +/// access-control module belongs to the contract author, so the trait requires +/// an explicit override. The override: /// -/// 1. Performs the authorization check — either via `operator.require_auth()` -/// plus a manual identity check, or by attaching `#[only_owner]` / -/// `#[only_role]` to the override (in which case the `operator` parameter is -/// passed through as the documented caller). +/// 1. Authorizes the call — either with `operator.require_auth()` plus a check +/// that `operator` holds the required role, or with `#[only_owner]` / +/// `#[only_role]` on the override, in which case the macro performs the +/// check and `operator` is informational. /// 2. Delegates to the matching helper in [`storage`]. #[contracttrait] pub trait ConfidentialCompliance: ConfidentialToken { @@ -75,7 +80,7 @@ pub trait ConfidentialCompliance: ConfidentialToken { /// /// * `e` - Access to the Soroban environment. /// * `account` - The address to freeze. - /// * `operator` - The address whose authorization gates this operation. + /// * `operator` - The address that must authorize the call. /// /// # Errors /// @@ -89,8 +94,8 @@ pub trait ConfidentialCompliance: ConfidentialToken { /// # Security Warning /// /// Implementations MUST authorize `operator` before calling - /// [`storage::freeze`]. The trait cannot provide a default body - /// — see the trait-level docstring for the rationale. + /// [`storage::freeze`]. The trait-level docstring explains why the method + /// has no default body. fn freeze(e: &Env, account: Address, operator: Address); /// Clears the frozen flag on `account`. @@ -99,7 +104,7 @@ pub trait ConfidentialCompliance: ConfidentialToken { /// /// * `e` - Access to the Soroban environment. /// * `account` - The address to unfreeze. - /// * `operator` - The address whose authorization gates this operation. + /// * `operator` - The address that must authorize the call. /// /// # Errors /// @@ -113,21 +118,20 @@ pub trait ConfidentialCompliance: ConfidentialToken { /// # Security Warning /// /// Implementations MUST authorize `operator` before calling - /// [`storage::unfreeze`]. The trait cannot provide a default - /// body — see the trait-level docstring for the rationale. + /// [`storage::unfreeze`]. The trait-level docstring explains why the + /// method has no default body. fn unfreeze(e: &Env, account: Address, operator: Address); - /// Atomically replaces the compliance configuration with `config`. The - /// intended deployment-time call is from the contract's - /// `__constructor` (which may invoke - /// [`storage::set_compliance_config`] directly); subsequent - /// rotations flow through this method. + /// Replaces the compliance configuration with `config`. The initial + /// configuration is normally written from the contract's `__constructor`, + /// which may call [`storage::set_compliance_config`] directly; later + /// rotations go through this method. /// /// # Arguments /// /// * `e` - Access to the Soroban environment. /// * `config` - The new [`ComplianceConfig`]. - /// * `operator` - The address whose authorization gates this operation. + /// * `operator` - The address that must authorize the call. /// /// # Events /// @@ -137,12 +141,12 @@ pub trait ConfidentialCompliance: ConfidentialToken { /// # Security Warning /// /// Implementations MUST authorize `operator` before calling - /// [`storage::set_compliance_config`]. The trait cannot provide a - /// default body — see the trait-level docstring for the rationale. + /// [`storage::set_compliance_config`]. The trait-level docstring explains + /// why the method has no default body. fn set_compliance_config(e: &Env, config: ComplianceConfig, operator: Address); - /// Returns whether `account` is currently frozen. Returns `false` when - /// compliance has not been configured. + /// Returns whether `account` is frozen, or `false` when compliance has not + /// been configured. /// /// # Arguments /// @@ -163,21 +167,141 @@ pub trait ConfidentialCompliance: ConfidentialToken { } } +// ################## CLAWBACK TRAIT ################## + +/// Opt-in seizure interface. A deployment that wants freeze and policy checks +/// but no seizure capability does not implement this trait. +/// +/// Both methods follow the [`ConfidentialCompliance`] pattern: no default +/// body, so the contract author supplies the access-control check. Both +/// require `account` to be frozen, and neither runs the [`Hooks`] callbacks: a +/// freeze-aware `Hooks` impl would reject the frozen accounts these methods +/// act on. +/// +/// # Security Warning +/// +/// **The freeze precondition is only meaningful when the deployment's `Hooks` +/// impl rejects operations from frozen accounts, and the trait bounds do not +/// require such an impl.** `ConfidentialClawback: ConfidentialCompliance` +/// obliges the deployment to implement `freeze` / `unfreeze` but places no +/// constraint on `::Hooks`. A contract that wires +/// [`NoHooks`](crate::confidential::NoHooks) alongside this trait gets a +/// `freeze` that writes the flag and an `is_frozen` that returns `true`, so +/// the precondition passes while every token operation remains unrestricted +/// and the target can move its balance before the seizure executes. The only +/// signal to the caller is an `InvalidProof` error once the commitment has +/// moved. +/// +/// Wiring [`ComplianceHooks`], or a custom [`Hooks`] impl that rejects frozen +/// accounts in `on_withdraw`, `on_transfer`, and the other balance-moving +/// callbacks, is a **deployment obligation** of this trait. +#[contracttrait] +pub trait ConfidentialClawback: ConfidentialCompliance { + /// Reduces `account`'s confidential claim by `amount` and settles the + /// corresponding underlying according to `destination`. + /// + /// With `destination = None`, no underlying is transferred: the pool is + /// left over-collateralized by `amount`, and the issuer extracts the + /// surplus with the SAC's own `clawback` against this contract's address. + /// With `destination = Some(d)`, exactly `amount` is transferred to `d` in + /// the same invocation and the pool remains equal to the sum of + /// confidential claims. + /// + /// `destination` is bound into the proof, so a proof built for one + /// destination cannot be submitted with another. `Some(d)` with `d` equal + /// to this contract's own address is rejected. + /// + /// `account` MUST be frozen: the freeze keeps `C_spend` and `C_receive` + /// unchanged between proof construction and submission, which the proof's + /// bindings rely on. The freeze only immobilizes the target if the + /// deployment's [`Hooks`] impl rejects operations from frozen accounts — + /// see the trait-level warning. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `account` - The confidential account whose claim is reduced. + /// * `amount` - The amount to seize; must be strictly positive. + /// * `destination` - The recipient of the seized underlying, or `None` to + /// leave the underlying in the pool. + /// * `data` - XDR-encoded [`ClawbackData`]. + /// * `operator` - The address that must authorize the call. + /// + /// # Errors + /// + /// * refer to [`crate::confidential::storage::decode_data`] errors. + /// * refer to [`storage::clawback`] errors. + /// + /// # Events + /// + /// * topics - `["clawback", account: Address]` + /// * data - `[amount: i128, destination: Option
]` + /// + /// # Security Warning + /// + /// Implementations MUST authorize `operator` before calling + /// [`storage::clawback`]. The [`ConfidentialCompliance`] trait-level + /// docstring explains why the method has no default body. + fn clawback( + e: &Env, + account: Address, + amount: i128, + destination: Option
, + data: Bytes, + operator: Address, + ); + + /// Folds the `(account, spender)` delegation's escrowed allowance back + /// into `account`'s spendable balance and deletes the delegation, without + /// the owner's participation, so the escrowed value becomes reachable by + /// [`ConfidentialClawback::clawback`]. + /// + /// The fold is the same as the owner's + /// [`revoke_spender`](crate::confidential::ConfidentialToken::revoke_spender); + /// only the authorizing party differs. + /// + /// `account` MUST be frozen. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `account` - The delegating owner. + /// * `spender` - The delegated spender. + /// * `operator` - The address that must authorize the call. + /// + /// # Errors + /// + /// * refer to [`storage::force_revoke_spender`] errors. + /// + /// # Events + /// + /// * topics - `["revoke_spender", account: Address, spender: Address]` + /// * data - `[a_tilde: BytesN<32>, allowance_salt: BytesN<32>]` + /// + /// # Security Warning + /// + /// Implementations MUST authorize `operator` before calling + /// [`storage::force_revoke_spender`]. The [`ConfidentialCompliance`] + /// trait-level docstring explains why the method has no default body. + fn force_revoke_spender(e: &Env, account: Address, spender: Address, operator: Address); +} + // ################## HOOKS IMPL ################## -/// [`Hooks`] implementation that gates every token callback against -/// the active [`ComplianceConfig`]. Wire as `type Hooks = ComplianceHooks;` -/// on a contract that implements [`ConfidentialToken`]. +/// [`Hooks`] implementation that checks every token callback against the +/// active [`ComplianceConfig`]. Wire it as `type Hooks = ComplianceHooks;` on +/// a contract that implements [`ConfidentialToken`]. /// -/// Each gated party is checked against up to three gates when a configuration -/// is present: +/// When a configuration is present, each checked address passes up to three +/// gates: /// -/// 1. Reverts [`ComplianceError::AccountFrozen`] if the address is frozen. -/// 2. When `config.policy = Some(p)`, calls `p.is_authorized` and reverts -/// [`ComplianceError::NotAuthorizedByPolicy`] on `false`. -/// 3. When `config.sac_passthrough = true`, calls the underlying SAC's +/// 1. Freeze: fails with [`ComplianceError::AccountFrozen`] if the address is +/// frozen. +/// 2. Policy: when `config.policy = Some(p)`, calls `p.is_authorized` and fails +/// with [`ComplianceError::NotAuthorizedByPolicy`] on `false`. +/// 3. SAC: when `config.sac_passthrough = true`, calls the underlying SAC's /// `authorized` view (a Stellar Asset Contract interface, not SEP-41; see -/// [`storage::check_sac`]) and reverts +/// [`storage::check_sac`]) and fails with /// [`ComplianceError::NotAuthorizedBySac`] on `false`. /// /// Which parties pass which gates: @@ -186,28 +310,26 @@ pub trait ConfidentialCompliance: ConfidentialToken { /// [`on_transfer`](Hooks::on_transfer): `from` and `to` pass all three gates. /// [`on_merge`](Hooks::on_merge): `account` passes all three. /// * [`on_register`](Hooks::on_register): `account` skips the freeze gate -/// (registration predates the account entry) but passes policy and SAC. -/// * [`on_register`](Hooks::on_register) does not restrict the caller-selected -/// `auditor_id`. Deployments that must limit which auditors an account may -/// bind to override it with a custom gate (see `docs/COMPLIANCE.md` §4.3). +/// (registration predates the account entry) but passes policy and SAC. The +/// caller-selected `auditor_id` is not restricted; deployments that must +/// limit which auditors an account may bind to override `on_register` with a +/// custom gate (see `docs/COMPLIANCE.md` §4.3). /// * [`on_spender_transfer`](Hooks::on_spender_transfer): `from` and `to` pass /// all three gates; `spender` passes only the policy gate. /// * [`on_set_spender`](Hooks::on_set_spender): the delegating `account` passes /// all three gates; `spender` passes only the policy gate, so a delegation to /// a policy-denied spender fails at grant time rather than at spend time. /// * [`on_revoke_spender`](Hooks::on_revoke_spender): `account` passes all -/// three gates; `spender` is not gated at all. Revocation is the owner's -/// escape hatch — blocking it once the spender turns non-compliant would -/// entrench the bad delegation. +/// three gates; `spender` passes none. Blocking revocation once the spender +/// becomes non-compliant would trap the owner in the delegation. /// -/// The spender is exempt from the freeze and SAC gates everywhere: both -/// target fund ownership, and the spender holds no funds in this model, -/// mirroring the fungible and rwa allowance models. +/// The spender skips the freeze and SAC gates everywhere: both concern fund +/// ownership, and a spender holds no funds, as in the fungible and rwa +/// allowance models. /// /// Deployments that need additional behaviour (audit mirroring, rate -/// limiting, or alternative deposit semantics — see -/// `docs/COMPLIANCE.md` §4) can write a custom `Hooks` impl that -/// calls the same primitives. +/// limiting, or alternative deposit semantics — see `docs/COMPLIANCE.md` §4) +/// can write a custom `Hooks` impl that calls the same primitives. pub struct ComplianceHooks; impl Hooks for ComplianceHooks { @@ -266,9 +388,6 @@ impl Hooks for ComplianceHooks { let Some(config) = storage::compliance_config(e) else { return; }; - // `from` and `to` pass all three gates; the spender holds no funds, so - // it skips freeze and SAC (consistent with the fungible and rwa - // allowance models) but must still clear the external policy. storage::gate_account(e, from, &config); storage::gate_account(e, to, &config); storage::check_policy(e, spender, &config); @@ -314,6 +433,12 @@ pub enum ComplianceError { /// for the target account (only reachable when `sac_passthrough` is /// enabled). NotAuthorizedBySac = 3603, + /// Indicates the clawback target is not frozen. + AccountNotFrozen = 3604, + /// Indicates the seize amount is not strictly positive. + InvalidClawbackAmount = 3605, + /// Indicates `destination` names this contract's own address. + InvalidClawbackDestination = 3606, } // ################## CONSTANTS ################## @@ -350,6 +475,23 @@ pub fn emit_unfrozen(e: &Env, account: &Address) { Unfrozen { account: account.clone() }.publish(e); } +/// Event emitted when a confidential claim is reduced by a compliance +/// seizure. `destination` is `None` when no underlying moved, and `Some(d)` +/// when exactly `amount` was transferred to `d` in the same invocation. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Clawback { + #[topic] + pub account: Address, + pub amount: i128, + pub destination: Option
, +} + +/// Emits a [`Clawback`] event. +pub fn emit_clawback(e: &Env, account: &Address, amount: i128, destination: &Option
) { + Clawback { account: account.clone(), amount, destination: destination.clone() }.publish(e); +} + /// Event emitted when the compliance configuration is set or rotated. #[contractevent] #[derive(Clone, Debug, Eq, PartialEq)] diff --git a/packages/tokens/src/confidential/compliance/storage.rs b/packages/tokens/src/confidential/compliance/storage.rs index 88fef899e..98a176951 100644 --- a/packages/tokens/src/confidential/compliance/storage.rs +++ b/packages/tokens/src/confidential/compliance/storage.rs @@ -1,11 +1,17 @@ -use soroban_sdk::{contracttype, panic_with_error, token, Address, Env}; +use soroban_sdk::{contracttype, panic_with_error, token, Address, Bytes, BytesN, Env}; +use stellar_contract_utils::crypto::grumpkin::Grumpkin; use crate::confidential::{ compliance::{ - emit_compliance_config_changed, emit_frozen, emit_unfrozen, ComplianceError, PolicyClient, - FROZEN_EXTEND_AMOUNT, FROZEN_TTL_THRESHOLD, + emit_clawback, emit_compliance_config_changed, emit_frozen, emit_unfrozen, ComplianceError, + PolicyClient, FROZEN_EXTEND_AMOUNT, FROZEN_TTL_THRESHOLD, }, - storage::get_underlying_asset, + storage::{ + address_to_field, append_amount, append_field, append_point, get_account, + get_address_as_field_element, get_underlying_asset, revoke_spender, set_commitments, + verify, + }, + verifier::CircuitType, }; // ################## TYPES ################## @@ -27,6 +33,16 @@ pub struct ComplianceConfig { pub sac_passthrough: bool, } +/// Envelope decoded from the `data: Bytes` argument of +/// [`crate::confidential::compliance::ConfidentialClawback::clawback`]. +/// Carries the proof alone: the clawback circuit has no prover-supplied +/// public inputs, so there is no payload to accompany it. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ClawbackData { + pub proof: Bytes, +} + /// Storage keys for the confidential token compliance extension. #[contracttype] pub enum ComplianceStorageKey { @@ -170,6 +186,159 @@ pub fn unfreeze(e: &Env, account: &Address) { emit_unfrozen(e, account); } +/// Reduces `account`'s confidential claim by `amount` and settles the +/// corresponding underlying according to `destination`. +/// +/// The proof establishes what the contract cannot check against committed +/// balances: that the prover knows the Pedersen openings of `C_spend` and +/// `C_receive` (CB1, CB2), and that `amount <= v_spend + v_receive` (CB3). +/// The witness is producible by anyone holding the openings — the auditor, or +/// the owner — and not by the admin, which holds no blinding. +/// +/// The post-verification update is the [`crate::confidential::storage::merge`] +/// rule plus a public debit — `C_spend <- C_spend + C_receive - amount * G` +/// and `C_receive <- O` — with no fresh randomness. The new opening is +/// `(v_s + v_r - amount, r_s + r_r)`, which both the owner and the auditor +/// recompute, so the seized account stays spendable. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `account` - The confidential account being seized from. +/// * `amount` - The strictly positive seize amount. +/// * `destination` - Where the underlying settles, or `None`. +/// * `proof` - The raw UltraHonk proof bytes, from the decoded +/// [`ClawbackData`]. +/// +/// # Errors +/// +/// * [`ComplianceError::AccountNotFrozen`] - When `account` is not frozen. +/// * [`ComplianceError::InvalidClawbackAmount`] - When `amount <= 0`. +/// * [`ComplianceError::InvalidClawbackDestination`] - When `destination` is +/// `Some` naming this contract's own address. +/// * refer to [`crate::confidential::storage::get_account`] errors. +/// * [`crate::confidential::ConfidentialTokenError::NonCanonicalEncoding`] - +/// When a stored commitment coordinate is not a canonical `Bn254Fr` value. +/// * [`crate::confidential::ConfidentialTokenError::InvalidProof`] - When the +/// proof fails verification. +/// +/// # Events +/// +/// * topics - `["clawback", account: Address]` +/// * data - `[amount: i128, destination: Option
]` +/// +/// # Notes +/// +/// Under `None` no underlying moves: the pool is left over-collateralized by +/// `amount`, extractable only by the underlying's own issuer through a SAC +/// `clawback` against this contract's address. That extraction must follow +/// this call. +/// +/// Under `Some(d)`, exactly `amount` is transferred to `d` in this invocation, +/// so the pool and the sum of claims move together. +/// +/// # Security Warning +/// +/// **IMPORTANT**: This function bypasses authorization checks. The trait entry +/// point [`crate::confidential::compliance::ConfidentialClawback::clawback`] +/// is responsible for authorizing `operator`. +pub fn clawback( + e: &Env, + account: &Address, + amount: i128, + destination: &Option
, + proof: &Bytes, +) { + if !is_frozen(e, account) { + panic_with_error!(e, ComplianceError::AccountNotFrozen); + } + if amount <= 0 { + panic_with_error!(e, ComplianceError::InvalidClawbackAmount); + } + if destination.as_ref() == Some(&e.current_contract_address()) { + panic_with_error!(e, ComplianceError::InvalidClawbackDestination); + } + + let data = get_account(e, account); + let addr_f = get_address_as_field_element(e); + // Zero is an unambiguous `None` sentinel: `address_to_field` is a + // Poseidon2 output. + let dest_f = match destination { + Some(d) => address_to_field(e, d), + None => BytesN::from_array(e, &[0u8; 32]), + }; + + // PI order (COMPLIANCE §5.3): + // C_spend, C_receive, alpha, addr_f, acct_f, dest_f + // + // `addr_f`, `acct_f` and `dest_f` are referenced by no constraint; their + // membership in the public-input set binds the proof to one contract, one + // account, and one settlement destination, on the `register` precedent. + let mut pi = Bytes::new(e); + append_point(&mut pi, &data.spendable_commitment); + append_point(&mut pi, &data.receiving_commitment); + append_amount(&mut pi, e, amount); + append_field(&mut pi, &addr_f); + append_field(&mut pi, &address_to_field(e, account)); + append_field(&mut pi, &dest_f); + + verify(e, CircuitType::Clawback, &pi, proof); + + let seized = Grumpkin::mul(e, &Grumpkin::generator(e), amount as u128); + let c_spend_new = Grumpkin::sub( + e, + &Grumpkin::add(e, &data.spendable_commitment, &data.receiving_commitment), + &seized, + ); + set_commitments(e, account, &c_spend_new, &Grumpkin::identity(e)); + + if let Some(d) = destination { + let token = token::TokenClient::new(e, &get_underlying_asset(e)); + token.transfer(&e.current_contract_address(), d, &amount); + } + + emit_clawback(e, account, amount, destination); +} + +/// Folds the `(account, spender)` delegation's escrowed allowance back into +/// `account`'s spendable balance and deletes the delegation, without the +/// owner's participation. +/// +/// Escrowed value is invisible to [`clawback`], which sees only `C_spend` and +/// `C_receive`; this moves it into reach. The fold itself is the same +/// proofless primitive the owner's +/// [`revoke_spender`](crate::confidential::ConfidentialToken::revoke_spender) +/// uses — only the authorization gate differs. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `account` - The delegating owner. +/// * `spender` - The delegated spender. +/// +/// # Errors +/// +/// * [`ComplianceError::AccountNotFrozen`] - When `account` is not frozen. +/// * refer to [`crate::confidential::storage::revoke_spender`] errors. +/// +/// # Events +/// +/// * topics - `["revoke_spender", account: Address, spender: Address]` +/// * data - `[a_tilde: BytesN<32>, allowance_salt: BytesN<32>]` +/// +/// # Security Warning +/// +/// **IMPORTANT**: This function bypasses authorization checks. The trait entry +/// point +/// [`crate::confidential::compliance::ConfidentialClawback::force_revoke_spender`] +/// is responsible for authorizing `operator`. +pub fn force_revoke_spender(e: &Env, account: &Address, spender: &Address) { + if !is_frozen(e, account) { + panic_with_error!(e, ComplianceError::AccountNotFrozen); + } + revoke_spender(e, account, spender); +} + // ################## LOW-LEVEL HELPERS ################## /// Asserts that `account` passes every configured compliance gate against the diff --git a/packages/tokens/src/confidential/compliance/test.rs b/packages/tokens/src/confidential/compliance/test.rs index ada2866bf..c5185e431 100644 --- a/packages/tokens/src/confidential/compliance/test.rs +++ b/packages/tokens/src/confidential/compliance/test.rs @@ -10,15 +10,21 @@ use soroban_sdk::{ use crate::confidential::{ compliance::{ - storage::{compliance_config, freeze, is_frozen, set_compliance_config, unfreeze}, - ComplianceConfig, ComplianceHooks, ComplianceStorageKey, ConfidentialCompliance, + storage::{ + clawback, compliance_config, force_revoke_spender, freeze, is_frozen, + set_compliance_config, unfreeze, + }, + ClawbackData, ComplianceConfig, ComplianceHooks, ComplianceStorageKey, + ConfidentialClawback, ConfidentialClawbackClient, ConfidentialCompliance, ConfidentialComplianceClient, Policy, }, - storage::{set_address_as_field_element, set_auditor, set_underlying_asset, set_verifier}, + storage::{ + decode_data, set_address_as_field_element, set_auditor, set_underlying_asset, set_verifier, + }, verifier::CircuitType, - ConfidentialAccount, ConfidentialToken, ConfidentialTokenClient, Hooks, RegisterData, - RegisterPayload, SetSpenderPayload, SpenderDelegation, SpenderTransferPayload, TransferPayload, - WithdrawPayload, + ConfidentialAccount, ConfidentialToken, ConfidentialTokenClient, ConfidentialTokenStorageKey, + Hooks, RegisterData, RegisterPayload, SetSpenderPayload, SpenderDelegation, + SpenderTransferPayload, TransferPayload, WithdrawPayload, }; // ################## MOCK CONTRACTS ################## @@ -59,6 +65,27 @@ impl ConfidentialCompliance for TokenHost { } } +#[contractimpl(contracttrait)] +impl ConfidentialClawback for TokenHost { + fn clawback( + e: &Env, + account: Address, + amount: i128, + destination: Option
, + data: Bytes, + admin: Address, + ) { + admin.require_auth(); + let d: ClawbackData = decode_data(e, &data); + clawback(e, &account, amount, &destination, &d.proof); + } + + fn force_revoke_spender(e: &Env, account: Address, spender: Address, admin: Address) { + admin.require_auth(); + force_revoke_spender(e, &account, &spender); + } +} + #[contract] struct AllowPolicy; @@ -821,6 +848,216 @@ fn storage_keys_isolated_from_token_keys() { }); } +// ################## CLAWBACK (SMOKE) ################## +// +// Contract-layer plumbing only. The verifier is mocked, so nothing here +// exercises CB1-CB3, the seize bound, or the destination binding -- those are +// circuit-side (`circuits/clawback/src/tests.nr`). Full coverage of the replay +// and redirect cases needs a public-input-binding verifier mock. + +fn clawback_data(e: &Env) -> Bytes { + ClawbackData { proof: Bytes::new(e) }.to_xdr(e) +} + +/// Registers `account` with a spendable commitment of `amount * G` and an +/// empty receiving side, then freezes it. +fn frozen_account_with(h: &Harness, account: &Address, amount: u128) { + use stellar_contract_utils::crypto::grumpkin::Grumpkin; + h.e.as_contract(&h.host, || { + let identity = Grumpkin::identity(&h.e); + let acc = ConfidentialAccount { + spending_public_key: identity.clone(), + viewing_public_key: identity.clone(), + spendable_commitment: Grumpkin::mul(&h.e, &Grumpkin::generator(&h.e), amount), + receiving_commitment: identity, + auditor_id: 0, + }; + h.e.storage() + .persistent() + .set(&ConfidentialTokenStorageKey::Account(account.clone()), &acc); + set_compliance_config(&h.e, &base_config()); + freeze(&h.e, account); + }); +} + +#[test] +fn clawback_none_folds_commitments_and_moves_no_underlying() { + let h = setup(); + let alice = Address::generate(&h.e); + frozen_account_with(&h, &alice, 100); + h.sac.mint(&h.host, &1_000); + + let client = ConfidentialClawbackClient::new(&h.e, &h.host); + client.clawback(&alice, &40i128, &None, &clawback_data(&h.e), &h.admin); + + // C_spend <- C_spend + O - 40*G, C_receive <- O. + h.e.as_contract(&h.host, || { + use stellar_contract_utils::crypto::grumpkin::Grumpkin; + let acc = crate::confidential::storage::get_account(&h.e, &alice); + let expected = Grumpkin::mul(&h.e, &Grumpkin::generator(&h.e), 60); + assert_eq!(acc.spendable_commitment, expected); + assert_eq!(acc.receiving_commitment, Grumpkin::identity(&h.e)); + }); + // No underlying moved: the pool is now over-collateralized by 40. + assert_eq!(StellarAssetClient::new(&h.e, &h.sac_addr).balance(&h.host), 1_000); + // The freeze survives the seizure. + h.e.as_contract(&h.host, || assert!(is_frozen(&h.e, &alice))); +} + +#[test] +fn clawback_some_transfers_exactly_the_seized_amount() { + let h = setup(); + let alice = Address::generate(&h.e); + let dest = Address::generate(&h.e); + frozen_account_with(&h, &alice, 100); + h.sac.mint(&h.host, &1_000); + + let client = ConfidentialClawbackClient::new(&h.e, &h.host); + client.clawback(&alice, &40i128, &Some(dest.clone()), &clawback_data(&h.e), &h.admin); + + let sac = StellarAssetClient::new(&h.e, &h.sac_addr); + assert_eq!(sac.balance(&dest), 40); + assert_eq!(sac.balance(&h.host), 960); +} + +#[test] +#[should_panic(expected = "Error(Contract, #3604)")] +fn clawback_unfrozen_panics() { + let h = setup(); + let alice = Address::generate(&h.e); + frozen_account_with(&h, &alice, 100); + h.e.as_contract(&h.host, || unfreeze(&h.e, &alice)); + + ConfidentialClawbackClient::new(&h.e, &h.host).clawback( + &alice, + &1i128, + &None, + &clawback_data(&h.e), + &h.admin, + ); +} + +#[test] +#[should_panic(expected = "Error(Contract, #3604)")] +fn clawback_freeze_check_precedes_registration_check() { + // An address that is neither frozen nor registered yields 3604, not 3501. + let h = setup(); + let nobody = Address::generate(&h.e); + h.e.as_contract(&h.host, || set_compliance_config(&h.e, &base_config())); + + ConfidentialClawbackClient::new(&h.e, &h.host).clawback( + &nobody, + &1i128, + &None, + &clawback_data(&h.e), + &h.admin, + ); +} + +#[test] +#[should_panic(expected = "Error(Contract, #3605)")] +fn clawback_zero_amount_panics() { + let h = setup(); + let alice = Address::generate(&h.e); + frozen_account_with(&h, &alice, 100); + + ConfidentialClawbackClient::new(&h.e, &h.host).clawback( + &alice, + &0i128, + &None, + &clawback_data(&h.e), + &h.admin, + ); +} + +#[test] +#[should_panic(expected = "Error(Contract, #3605)")] +fn clawback_negative_amount_panics() { + let h = setup(); + let alice = Address::generate(&h.e); + frozen_account_with(&h, &alice, 100); + + ConfidentialClawbackClient::new(&h.e, &h.host).clawback( + &alice, + &-1i128, + &None, + &clawback_data(&h.e), + &h.admin, + ); +} + +#[test] +#[should_panic(expected = "Error(Contract, #3606)")] +fn clawback_to_self_panics() { + let h = setup(); + let alice = Address::generate(&h.e); + frozen_account_with(&h, &alice, 100); + + ConfidentialClawbackClient::new(&h.e, &h.host).clawback( + &alice, + &1i128, + &Some(h.host.clone()), + &clawback_data(&h.e), + &h.admin, + ); +} + +#[test] +fn force_revoke_spender_folds_allowance_and_deletes_delegation() { + use stellar_contract_utils::crypto::grumpkin::Grumpkin; + let h = setup(); + let alice = Address::generate(&h.e); + let spender = Address::generate(&h.e); + frozen_account_with(&h, &alice, 100); + h.e.as_contract(&h.host, || { + h.e.storage().persistent().set( + &ConfidentialTokenStorageKey::Delegation(alice.clone(), spender.clone()), + &SpenderDelegation { + allowance_commitment: Grumpkin::mul(&h.e, &Grumpkin::generator(&h.e), 25), + a_tilde: fr(&h.e), + escrowed_dvk: Grumpkin::identity(&h.e), + allowance_salt: fr(&h.e), + live_until_ledger: 1_000, + }, + ); + }); + + ConfidentialClawbackClient::new(&h.e, &h.host).force_revoke_spender(&alice, &spender, &h.admin); + + h.e.as_contract(&h.host, || { + let acc = crate::confidential::storage::get_account(&h.e, &alice); + assert_eq!(acc.spendable_commitment, Grumpkin::mul(&h.e, &Grumpkin::generator(&h.e), 125)); + assert!(!h + .e + .storage() + .persistent() + .has(&ConfidentialTokenStorageKey::Delegation(alice.clone(), spender.clone()))); + }); +} + +#[test] +#[should_panic(expected = "Error(Contract, #3604)")] +fn force_revoke_spender_unfrozen_panics() { + let h = setup(); + let alice = Address::generate(&h.e); + let spender = Address::generate(&h.e); + frozen_account_with(&h, &alice, 100); + h.e.as_contract(&h.host, || unfreeze(&h.e, &alice)); + + ConfidentialClawbackClient::new(&h.e, &h.host).force_revoke_spender(&alice, &spender, &h.admin); +} + +#[test] +#[should_panic(expected = "Error(Contract, #3504)")] +fn force_revoke_unknown_delegation_panics() { + let h = setup(); + let alice = Address::generate(&h.e); + let spender = Address::generate(&h.e); + frozen_account_with(&h, &alice, 100); + + ConfidentialClawbackClient::new(&h.e, &h.host).force_revoke_spender(&alice, &spender, &h.admin); +} + // ################## HELPERS ################## fn register_minimal_account(e: &Env, account: &Address) { diff --git a/packages/tokens/src/confidential/docs/COMPLIANCE.md b/packages/tokens/src/confidential/docs/COMPLIANCE.md index 5d225b8e7..d4d101d8a 100644 --- a/packages/tokens/src/confidential/docs/COMPLIANCE.md +++ b/packages/tokens/src/confidential/docs/COMPLIANCE.md @@ -46,8 +46,8 @@ Three functions are added to the core contract interface: ```rust impl Token { - fn freeze(e: Env, account: Address, admin: Address); // admin auth - fn unfreeze(e: Env, account: Address, admin: Address); // admin auth + fn freeze(e: Env, account: Address, operator: Address); // admin auth + fn unfreeze(e: Env, account: Address, operator: Address); // admin auth fn is_frozen(e: Env, account: Address) -> bool; } ``` @@ -171,65 +171,89 @@ impl Hooks for ApprovedAuditorHooks { --- -## 5. Clawback (Outline Only) +## 5. Clawback -This section specifies seizing value from a single confidential account: extracting a bounded amount and settling it to the issuer over a transparent path. It is separate from **freeze** (§2), and the two must not be conflated. +This section specifies seizing value from a single frozen confidential account: reducing its committed claim by a public amount, bounded by what the account holds, and settling the corresponding underlying over a transparent path. The flow is coordinated rather than unilateral, and it presupposes a freeze (§2), which keeps the target's commitments from changing between proof construction and submission (§5.6). -- **Freeze is immediate and unilateral.** It needs no coordination and is the correct response to an urgent order: a frozen account can neither send, receive, deposit, nor withdraw. It can be triggered by the token admin (§2) or — when the base asset is a SAC and `sac_passthrough` is set — by the issuer's own `set_authorized`/freeze surfacing through the SAC `authorized()` check (§2.2, the *transitive compliance* path), with no state mirrored by the token admin. -- **Seize is the multi-step, coordinated flow** specified below. It is normally preceded by a freeze and relies on it: the freeze is what keeps the target's commitments from changing between proof construction and submission (§5.3, *Anti-replay*). - -**Terminology.** This flow is called *clawback* because it mirrors the clawback semantics of Stellar Classic / SAC assets, but it is a distinct mechanism. +**Terminology.** This flow is called *clawback* because it mirrors the clawback semantics of Stellar Classic / SAC assets, but it is a distinct mechanism. It is delivered by the opt-in `ConfidentialClawback` trait, whose two entry points are `clawback` and `force_revoke_spender` (§6); a deployment that omits that impl block ships freeze and policy gating with no seizure capability. ### 5.1 The Pooled-Custody Problem -Once an account deposits into the contract, the underlying SEP-41 ledger lists the token contract as the holder of those funds, not the depositor. An issuer's SAC-level `clawback(token_address, amount)` call would drain the pool, debiting unrelated accounts. The contract therefore does not forward SAC-level clawback to individual confidential accounts; it must instead extract value from a single targeted account's confidential balance and settle that value to the issuer through a transparent path. +Once an account deposits into the contract, the underlying SEP-41 ledger lists the token contract as the holder of those funds, not the depositor. An issuer's SAC-level `clawback(token_address, amount)` drains the pool as a whole and debits every holder. Seizing from one account therefore has to happen at the confidential layer: reduce that account's committed claim, then reconcile the pool. + +The contract does not know the targeted account's balance. `C_spend` and `C_receive` are Pedersen commitments whose openings are private, so the seize amount must be validated against the committed values without exposing them on-chain and without trusting the admin's word for them. + +### 5.2 Roles and Separation + +- **Token admin** — the access-control authority on the confidential-token contract (§1.1). Authorizes the freeze and the two seizure entry points; decides *whether* to seize. +- **Witness holder** — whoever holds the Pedersen openings of the target's `C_spend` and `C_receive`. Produces the proof and thereby decides *how much* and *where to*, both being bound into it (§5.3). +- **Issuer (SAC admin)** — when the base asset is a Stellar Asset Contract, the holder of its standardized admin interface (CAP-0046-06). Extracts the pool surplus a `None` settlement leaves behind (§5.4) and can freeze independently of the token admin via SAC passthrough (§2.2). + +In practice the witness comes from the **auditor**; the admin holds no blinding and cannot produce it. The **owner** can derive both openings from `vk` (`DESIGN.md` §5.2), but is the party being seized from and is not expected to cooperate. The auditor holds both openings by the standing capability of `DESIGN_cont.md` §8.1, advanced across `Clawback` per §5.7. One key serves both channels for an account (`DESIGN.md` §6.1), so a deployment that intends to use clawback need only ensure that key's custodian can assemble both halves. + +Neither party can act alone: the admin cannot produce the proof, and the witness holder cannot pass the admin gate. Deployments typically place the seizure authority under a dedicated role, separate from the freeze role (§1.1). -The challenge is that the contract does not know the targeted account's balance. The balance is held as a Pedersen commitment whose opening is private to the owner. The clawback amount must be validated against the actual encrypted value without exposing it on-chain and without trusting the admin to choose a value at random. +### 5.3 Circuit -### 5.2 Admin + Auditor Coordination +The clawback circuit proves that a public seize amount is bounded by the target's committed total without revealing either balance. It is the only circuit with no key-ownership constraint and no ephemeral scalar: both openings are pinned by Pedersen binding. -Three roles bear on this flow: +**Circuit constraints:** -- **Token admin** — the access-control authority on the confidential-token contract (§1.1). Authorizes the freeze, the seize entry point, and settlement. -- **Issuer (SAC admin)** — when the base asset is a Stellar Asset Contract, the holder of its standardized admin interface (`mint`, `clawback`, `set_authorized`; CAP-0046-06). Seized value settles to the issuer over the transparent SEP-41 path, and the issuer can freeze independently of the token admin via SAC passthrough (§2.2). -- **Auditor** — holder of the off-chain auditor decryption key bound to the account at registration (`DESIGN_cont.md` §8). Governs visibility of confidential balances; cannot move funds or change contract state. +| # | Constraint | +|:--|:---| +| CB1 | `C_spend = Com(v_s, r_s)` (prover knows the spendable opening) | +| CB2 | `C_receive = Com(v_r, r_r)` (prover knows the receiving opening) | +| CB3 | `v_s, v_r, alpha, v_s + v_r - alpha ∈ [0, 2^127)` (range validity, `DESIGN.md` §2.6) | -The seize itself is carried out by the token admin and the auditor together. The admin authorizes the on-chain action per the role above; the auditor unlocks knowledge of the target's balance. Two halves of the target's confidential position are covered by the two auditor channels (see `DESIGN_cont.md` §8.1, §8.2). The **sender-auditor** decrypts the spendable-balance checkpoint $$\tilde{b}\_{\text{aud,s}}$$ from the target's most recent checkpoint event, recovering $$v\_s$$, and folds in every proofless operation the account performed since (§5.3 constraint 2). The **recipient-auditor** decrypts the per-transfer pairs $$(v\_{\text{transfer},i}, r\_{\text{transfer},i})$$ from every inbound transfer and spender-transfer since the last merge, recovering the full Pedersen opening $$(v\_r, r\_r)$$ of the target's `receiving_commitment`. The auditor then produces a zero-knowledge proof bounding the clawback amount by $$v\_s + v\_r$$, without revealing either summand. +Range on `v_s` and `v_r` alone does not bound their sum against `alpha`, and an over-seize would drive the committed value negative mod `r` — a commitment the owner can still open but never again satisfy under W4 / T4. -Neither party can act alone: the admin cannot produce the proof, and the auditor cannot freeze the account or move funds. This is the same trust separation present in the core protocol (admin governs state transitions, auditor governs visibility) extended to a write surface. +**Public inputs (8 fields):** -**The auditor is not the sole holder of the inbound openings.** Each inbound transfer's originator can reconstruct the opening of the transfer it sent, by recomputing $$r\_e$$ from its own viewing key and the salt published in the event (`DESIGN.md` §5.3, `DESIGN_cont.md` §8.2). This does not weaken the flow below, which sources $$(v\_r, r\_r)$$ from the auditor, and no single sender can open the target's $$C\_{\text{receive}}$$ as a whole — that needs every contributing transfer plus the deposits. It does mean the receiving-side half of the seize witness could in principle be assembled from cooperating senders, so the two-party separation above is a property of how this flow is constructed rather than a cryptographic impossibility. +| Input | Notes | +|:---|:---| +| `C_spend`, `C_receive` | Loaded from the target's `spendable_commitment` and `receiving_commitment`, in this order | +| `alpha` | Public seize amount from invocation inputs | +| `addr_f` | Loaded from instance storage (`DESIGN.md` §2.7) | +| `acct_f` | Binds the proof to the target account | +| `dest_f` | `address_to_field(destination)` under `Some`, the zero field under `None` | + +No public input is prover-supplied (`DESIGN.md` §7.1). `addr_f`, `acct_f`, and `dest_f` are referenced by no gate; their membership in the public-input set is the binding, on the `register` / `acct_f` precedent (`DESIGN.md` §7.2). For `dest_f` that binding is what stops a compromised clawback signer from settling a witness built for one destination to an address of its own choosing; its zero sentinel for `None` is unambiguous because `address_to_field` is a Poseidon2 output. -The admin role here is the same access-control surface introduced in §1.1; deployments typically place it under a dedicated `clawback` role in RBAC, separate from the freeze role. +**Private witnesses:** `v_s`, `r_s`, `v_r`, `r_r` — the openings of the two commitments. -**Auditor routing.** The recipient-auditor and the sender-auditor roles for a single account are served by the same key: each account binds a single `auditor_id` at registration (`DESIGN.md` §6.1) which the contract uses for both the sender-channel ciphertexts on the account's outgoing operations and the recipient-channel ciphertexts on the account's incoming transfers (the two channels are separated by domain tags $$\delta\_{\text{aud\\\_s}}$$ and $$\delta\_{\text{aud\\\_r}}$$, not by distinct keys). Deployments that intend to use clawback therefore need only ensure the off-chain custodian of that key is operationally capable of producing both halves of the witness — the spendable-balance checkpoint decryption and the per-transfer $$r\_{\text{transfer},i}$$ replay — when the admin initiates a seizure. +### 5.4 Contract Flow -### 5.3 New Circuit +`clawback(account, amount, destination, data, operator)` runs, after the deployment's access-control check on `operator`: -The clawback proof is a constant-size circuit deployed through the existing Verifier surface. It binds the seize amount $$\alpha$$ by the sum of the spendable and receiving balances of the target account, refreshes the spendable-balance checkpoint, and rewrites `receiving_commitment` to a zero commitment so the seized inbound flow is consumed atomically. +1. **Preconditions.** `is_frozen(account)`, else `AccountNotFrozen`; `amount > 0`, else `InvalidClawbackAmount`; `destination` is not `Some` naming this contract, else `InvalidClawbackDestination` — such a seize is a `None` in effect while reporting a settlement, which breaks per-branch pool reconciliation for any indexer. +2. **Public inputs.** Assembled in the §5.3 order through `append_point` / `append_field` / `append_amount`, so every coordinate and scalar passes the canonicality guards. `data` decodes to `ClawbackData { proof }`. +3. **Verification** against `CircuitType::Clawback`. +4. **State update.** `C_spend <- C_spend + C_receive - alpha·G` and `C_receive <- O`: the `Merge` rule (`DESIGN.md` §7.4) followed by a public debit, with no fresh randomness. The new opening is `(v_s + v_r - alpha, r_s + r_r)`, which the owner and the auditor recompute from the event's `amount` alone, so the seized account stays spendable. Re-randomizing under a prover-chosen blinding instead would leave the owner unable to open its own commitment. +5. **Settlement**, by `destination`: + - `None` — no underlying moves. + - `Some(d)` — exactly `amount` is transferred to `d` in the same invocation. +6. **Event.** `Clawback { account, amount, destination }` (§6.1). -**Public inputs.** $$C\_{\text{spend}}, C\_{\text{receive}}, K\_{\text{aud,s}}, \tilde{b}\_{\text{aud,s}}^{\text{old}}, \tilde{r}\_{\text{aud,s}}^{\text{old}}, R\_e^{\text{old}}, \sigma^{\text{old}}, \alpha, C\_{\text{spend}}^{\text{new}}, \tilde{b}\_{\text{aud,s}}^{\text{new}}, \tilde{r}\_{\text{aud,s}}^{\text{new}}, R\_e^{\text{new}}, \sigma^{\text{new}}, addr\_f$$. +**Extraction order.** The seize and the issuer's extraction are separate invocations by different parties, and Soroban admits one per transaction, so the pool sits mismatched for at least a ledger between them. Nothing in the contract enforces which comes first; under a `None` settlement the extraction must follow the seize: -**Private witnesses.** $$k\_{\text{aud,s}}, v\_s, r\_s, v\_r, r\_r, r\_s', r\_e^{\text{new}}$$, plus the sponge outputs from old and new auditor-channel sponge calls. The recipient-auditor's secret key does not appear in the witness because the recipient-channel decryption (recovery of $$(v\_r, r\_r)$$ from per-transfer events) is performed off-chain by the auditor; the circuit only re-verifies the resulting Pedersen opening of $$C\_{\text{receive}}$$ (constraint 1). +- **Seize, then extract.** Between the two the pool holds `amount` more than the claims against it. No holder is affected, and the extraction returns it to exact collateralization. +- **Extract, then seize.** Between the two the pool holds `amount` less than the claims against it, so it cannot honour them all: the shortfall lands on whichever holders withdraw last, none of whom is the target. It becomes permanent if the seize then turns out to be unbuildable — no witness holder produces the proof (§5.2). -**Constraints (sketch).** +The confidential debit strictly precedes the SEP-41 transfer, matching `withdraw`. Clawback is the one operation that reduces the sum of claims without a `Withdraw` (`DESIGN_cont.md` §9.3), and it invokes no `Hooks` callback: the freeze gate would reject exactly the account it acts on (`DESIGN_cont.md` §11). -1. **Receiving-balance opening.** $$C\_{\text{receive}} = v\_r \cdot G + r\_r \cdot H$$. The recipient-auditor reconstructs $$(v\_r, r\_r)$$ off-chain from per-transfer events; the proof asserts knowledge of this opening. -2. **Spendable-balance decryption.** $$(m\_{v,s}^{\text{old}}, m\_{b,s}^{\text{old}}, m\_{r,s}^{\text{old}}) = \text{SpongeSqueeze}\_3(\delta\_{\text{aud\\\_s}}, \text{ECDH}(k\_{\text{aud,s}}, R\_e^{\text{old}}), \sigma^{\text{old}})$$, then $$v\_s = \tilde{b}\_{\text{aud,s}}^{\text{old}} - m\_{b,s}^{\text{old}}$$ and $$r\_s = \tilde{r}\_{\text{aud,s}}^{\text{old}} - m\_{r,s}^{\text{old}}$$, and $$C\_{\text{spend}} = v\_s \cdot G + r\_s \cdot H$$. The `lane[2]` escrow (`DESIGN.md` W\_a5 / T\_a9 / S\_a6) is what makes this a closed derivation: the circuit recovers the blinding from the auditor channel itself. The old checkpoint referenced here is the account's most recent `lane[2]` escrow, which the proofless operations do not produce — neither `merge` (`DESIGN.md` §7.4) nor `revoke_spender` (`DESIGN.md` §7.9) publishes one. Anchoring on an earlier checkpoint requires folding: $$C\_{\text{spend}}$$ is a public input the contract loads from live storage, so the equality above holds only once every fold performed since that checkpoint has been added into $$(v\_s, r\_s)$$. +### 5.5 Forced Revocation - **Precondition.** The seize-enabling auditor MUST hold the opening of every addend folded since the checkpoint it anchors on — `DESIGN_cont.md` §8.3 states when a key does. An auditor that does not, having rotated in after an addend's event or missed it from its archive, cannot assemble this witness. Nor can the target be made to re-anchor it: every operation that issues a fresh checkpoint is gated on the account being unfrozen, and §5.2's flow requires the freeze. A deployment that intends to use clawback therefore owes the same archive completeness §8.5 *Archive dependence* already requires of the auditor. +Escrowed allowances are invisible to `clawback`, which sees only `C_spend` and `C_receive`. `force_revoke_spender(account, spender, operator)` moves them into reach: with `account` frozen (`AccountNotFrozen` otherwise), it performs the owner's `revoke_spender` fold (`DESIGN.md` §7.9) under the admin gate in place of the owner's authorization and emits the same `RevokeSpender` event, carrying `a_tilde` and `allowance_salt`. No proof is involved, and expired delegations are revocable: expiry blocks spending but not reclamation. Like `clawback`, it invokes no `Hooks` callback. -3. **Range and bound.** $$\alpha, v\_s, v\_r \in [0, 2^{127})$$ and $$\alpha \le v\_s + v\_r$$. -4. **Refreshed checkpoint.** $$r\_e^{\text{new}}$$ is **sampled** by the auditor per `DESIGN.md` §2.2, not derived. This is the one operation in the protocol that samples its ephemeral scalar: `DESIGN.md` §5.3 derives $$r\_e$$ from the originator's viewing key, and the originator here is the auditor, which holds no account's viewing key. Then $$R\_e^{\text{new}} = r\_e^{\text{new}} \cdot H$$, $$r\_e^{\text{new}} \neq 0$$, and, with $$(m\_{v,s}^{\text{new}}, m\_{b,s}^{\text{new}}, m\_{r,s}^{\text{new}}) = \text{SpongeSqueeze}\_3(\delta\_{\text{aud\\\_s}}, \text{ECDH}(k\_{\text{aud,s}}, R\_e^{\text{new}}), \sigma^{\text{new}})$$, both $$\tilde{b}\_{\text{aud,s}}^{\text{new}} = (v\_s + v\_r - \alpha) + m\_{b,s}^{\text{new}}$$ and $$\tilde{r}\_{\text{aud,s}}^{\text{new}} = r\_s' + m\_{r,s}^{\text{new}}$$. -5. **New spendable commitment.** $$C\_{\text{spend}}^{\text{new}} = (v\_s + v\_r - \alpha) \cdot G + r\_s' \cdot H$$. The commitment is a public input, since $$v\_s$$, $$v\_r$$, and $$r\_s'$$ are all private witnesses. +The owner and the auditor fold the event as they would an owner-initiated one (`DESIGN.md` §5.2; `DESIGN_cont.md` §8.5). For the auditor the archive dependence of `DESIGN_cont.md` §8.5 applies unchanged: `r_a` is not derivable from `a_tilde` and `σ_a`, so its standing opening of `C_spend` survives the fold only if it observed the delegation's last `SetSpender` / `SpenderTransfer` event. -**Post-verification.** The contract stores the prover-supplied $$C\_{\text{spend}}^{\text{new}}$$, zeroes $$C\_{\text{receive}}$$, transfers $$\alpha$$ of the underlying SEP-41 token to the issuer, and emits an event carrying $$(\tilde{b}\_{\text{aud,s}}^{\text{new}}, \tilde{r}\_{\text{aud,s}}^{\text{new}}, R\_e^{\text{new}}, \sigma^{\text{new}})$$ so the auditor sees the new checkpoint and its blinding. +### 5.6 Anti-Replay and the Freeze -**Anti-replay.** The contract consumes $$C\_{\text{spend}}$$ and $$C\_{\text{receive}}$$ as proof public inputs at verification time. If either commitment changes between proof construction and submission (e.g., an inbound transfer arrives), verification fails because the proof was bound to a different $$C\_{\text{receive}}$$. The §2 contract-level freeze applied to the target per §5.2's flow blocks both spending and receiving, so neither $$C\_{\text{spend}}$$ nor $$C\_{\text{receive}}$$ can change between proof construction and submission, and the proof's bindings hold across the isolate-then-settle handshake. +`C_spend` and `C_receive` are public inputs, so any change to either between proof construction and submission — an inbound transfer, a merge, a revoke — fails verification with `InvalidProof`. The freeze holds the commitments still. `ConfidentialClawback: ConfidentialCompliance` forces a `freeze` / `unfreeze` implementation but constrains nothing about the deployment's `Hooks`. Wiring `NoHooks` next to a `ConfidentialClawback` impl yields a `freeze` that writes the flag and an `is_frozen` that returns `true` while every token operation stays ungated, so the target spends out before the seizure lands and the admin's only signal is an `InvalidProof` once the commitments have moved. A deployment that enables clawback MUST wire `ComplianceHooks`, or a custom `Hooks` impl that gates the same seven positions (§4). -**What is no longer needed.** The earlier sketch of an on-chain receiving-side accumulator and a per-transfer compliance hook on `confidential_transfer`, `confidential_transfer_from`, and `deposit` is not required. The recipient-auditor's opening of $$C\_{\text{receive}}$$ is reconstructed entirely off-chain from event scans (`DESIGN_cont.md` §8.1). +### 5.7 Wallet and Auditor Consequences -Detailed encoding and the two-phase isolate-then-settle entry-point sequencing are deferred to a follow-up revision of this document. +`Clawback` is a receiving-side reset and a `T_0` anchor, alongside `Merge` (`DESIGN.md` §5.2 *Recovery*; `INDEXER.md` §2). It is not a checkpoint: it carries no `b_tilde`, so its effect on the spendable side is absorbed into the next owner-initiated proof operation. The owner applies the `Merge` row of `DESIGN.md` §5.2 followed by `W_spend.v -= amount`; the auditor advances its standing opening identically (`DESIGN_cont.md` §8.1 *Sender-auditor opening capability*). A `RevokeSpender` emitted by `force_revoke_spender` is indistinguishable from an owner-initiated one for both consumers. --- @@ -242,24 +266,41 @@ impl Token { auditor: Address, compliance: Option); // Freeze (§2) - fn freeze(e: Env, account: Address, admin: Address); - fn unfreeze(e: Env, account: Address, admin: Address); + fn freeze(e: Env, account: Address, operator: Address); + fn unfreeze(e: Env, account: Address, operator: Address); fn is_frozen(e: Env, account: Address) -> bool; // Config rotation (admin auth per §1.1, reverts when compliance.is_none()) // Replaces the entire ComplianceConfig in one call. - fn set_compliance_config(e: Env, config: ComplianceConfig, admin: Address); + fn set_compliance_config(e: Env, config: ComplianceConfig, operator: Address); // Reads fn compliance_config(e: Env) -> Option; + + // Clawback (§5), opt-in via ConfidentialClawback + fn clawback(e: Env, account: Address, amount: i128, destination: Option
, + data: Bytes /* XDR ClawbackData { proof } */, operator: Address); + fn force_revoke_spender(e: Env, account: Address, spender: Address, operator: Address); } ``` +`operator` is the address whose authorization the deployment's access-control module checks (§1.1). + ### 6.1 Events | Event | Fields | |:---|:---| | `Frozen`, `Unfrozen` | `account` | | `ComplianceConfigChanged` | `policy`, `sac_passthrough` | +| `Clawback` | `account` (topic), `amount`, `destination` | +| `RevokeSpender` | as `DESIGN_cont.md` §11.2, when emitted by `force_revoke_spender` | + +### 6.2 Errors + +The seizure entry points add three variants to `ComplianceError`: -Clawback-related events are specified alongside the clawback flow in the follow-up revision. +| Error | Code | Raised when | +|:---|:--|:---| +| `AccountNotFrozen` | 3604 | the target of `clawback` or `force_revoke_spender` is not frozen | +| `InvalidClawbackAmount` | 3605 | `amount <= 0` | +| `InvalidClawbackDestination` | 3606 | `destination` is `Some` naming the contract's own address | diff --git a/packages/tokens/src/confidential/docs/DESIGN.md b/packages/tokens/src/confidential/docs/DESIGN.md index 9a2d669ac..0a2175d7b 100644 --- a/packages/tokens/src/confidential/docs/DESIGN.md +++ b/packages/tokens/src/confidential/docs/DESIGN.md @@ -361,20 +361,21 @@ $$W\_{\text{receive}} = (v\_r, r\_r) \quad \text{such that} \quad C\_{\text{rece | Merge | $$W\_{\text{spend}} \leftarrow (v\_s + v\_r, \\; r\_s + r\_r)$$; $$W\_{\text{receive}} \leftarrow (0, 0)$$ | | Set spender (escrow amount $$a$$) | Proof outputs new commitment. $$W\_{\text{spend}} \leftarrow (v\_s - a, \\; \text{Poseidon}(\delta\_{\text{spend\\\_r}}, vk, \sigma))$$ | | Revoke spender | Proofless fold. $$W\_{\text{spend}} \mathrel{+}= (v\_a, r\_a)$$, the escrow opening recovered from the event per §7.9 | +| Clawback (compliance extension) | The Merge row, then the event's public `amount` is subtracted from the spendable value ([COMPLIANCE.md](./COMPLIANCE.md) §5.4) | -The Merge and Revoke spender rows use exact integer addition; $$W\_{\text{spend}}.r$$ is not reduced modulo $$r$$ or $$q$$ as folds accumulate. At proof-construction time the wallet reduces $$W\_{\text{spend}}.r$$ modulo $$q$$ and encodes the canonical $$\mathbb{F}\_q$$ representative as a single $$\mathbb{F}\_r$$ `Field`; the case where that encoding is unavailable is specified in [DESIGN_cont.md](./DESIGN_cont.md) §10.4 *Post-merge witness availability*. +The Merge, Revoke spender, and Clawback rows fold openings the wallet already holds, leaving $$W\_{\text{spend}}.r$$ a sum; [DESIGN_cont.md](./DESIGN_cont.md) §10.4 *Post-merge witness availability* covers the case where no circuit can witness it. -After every owner-initiated operation that produces a proof, $$r\_s$$ resets to a deterministic value. This is the **normalization** property: the spendable balance's blinding factor is always recoverable from $$(vk, \sigma)$$ at spend boundaries. Together with $$\tilde{b}$$, both emitted in the spend-boundary event, each spend boundary forms a **checkpoint** from which the spendable opening $$(v\_s, r\_s)$$ *as of that boundary* is recoverable via a single event lookup, with no replay of the spend history preceding it. $$W\_{\text{receive}}$$ has no such anchor: a checkpoint leaves $$C\_{\text{receive}}$$ untouched, which only `Merge` (§7.4) resets, so recovering it requires a replay (see Recovery below). +After every owner-initiated operation that produces a proof, $$r\_s$$ resets to a deterministic value. This is the **normalization** property: the spendable balance's blinding factor is always recoverable from $$(vk, \sigma)$$ at spend boundaries. Together with $$\tilde{b}$$, both emitted in the spend-boundary event, each spend boundary forms a **checkpoint** from which the spendable opening $$(v\_s, r\_s)$$ *as of that boundary* is recoverable via a single event lookup, with no replay of the spend history preceding it. $$W\_{\text{receive}}$$ has no such anchor: a checkpoint leaves $$C\_{\text{receive}}$$ untouched, which only `Merge` (§7.4) and `Clawback` reset, so recovering it requires a replay (see Recovery below). **Consistency check.** At any time, the wallet can verify its state: $$C\_{\text{spend}} \stackrel{?}{=} v\_s \cdot G + r\_s \cdot H$$ and $$C\_{\text{receive}} \stackrel{?}{=} v\_r \cdot G + r\_r \cdot H$$, where $$C\_{\text{spend}}$$ and $$C\_{\text{receive}}$$ are read from on-chain state. -**Recovery.** If the wallet loses local state, it recovers from **two anchors**: the account's last **checkpoint** for the spendable side (steps 1-4), and $$T\_0$$ -- its most recent `Merge` at or before that checkpoint -- for the receiving side (step 5). Since $$W\_{\text{receive}}$$ restarts at $$(0, 0)$$ as of $$T\_0$$, one replay window $$(T\_0, \text{now}]$$ serves both: it rebuilds the receiving side and carries the post-checkpoint proofless folds onto the spendable one (step 6). [DESIGN_cont.md](./DESIGN_cont.md) §9.5 *Why the two anchors compose* gives the argument that they do: +**Recovery.** If the wallet loses local state, it recovers from **two anchors**: the account's last **checkpoint** for the spendable side (steps 1-4), and $$T\_0$$ -- its most recent `Merge` or `Clawback` at or before that checkpoint -- for the receiving side (step 5). Since $$W\_{\text{receive}}$$ restarts at $$(0, 0)$$ as of $$T\_0$$, one replay window $$(T\_0, \text{now}]$$ serves both: it rebuilds the receiving side and carries the post-checkpoint proofless folds onto the spendable one (step 6). [DESIGN_cont.md](./DESIGN_cont.md) §9.5 *Why the two anchors compose* gives the argument that they do: -1. Fetch $$(\tilde{b}, \sigma)$$ from the most recent **checkpoint event** for this account: exactly one of `Withdraw`, `Transfer` (where the account is the `from`), or `SetSpender` -- the three event types carrying a proof-bound $$(\tilde{b}, \sigma)$$ for this account's spendable balance. `Merge` and `RevokeSpender` do change $$C\_{\text{spend}}$$ but are not checkpoints: carrying no proof, they cannot bind a $$\tilde{b}$$ to the resulting commitment (§7.4 *Encrypted balance*, §7.9 *Encrypted balance*), and their effect is absorbed into the next checkpoint. **No-checkpoint case:** if the account has no checkpoint event since `Register`, initialize $$W\_{\text{spend}} \leftarrow (0, 0)$$ and skip to step 5 with $$T\_0$$ = the `Register` event. +1. Fetch $$(\tilde{b}, \sigma)$$ from the most recent **checkpoint event** for this account: exactly one of `Withdraw`, `Transfer` (where the account is the `from`), or `SetSpender` -- the three event types carrying a proof-bound $$(\tilde{b}, \sigma)$$ for this account's spendable balance. `Merge`, `RevokeSpender`, and `Clawback` do change $$C\_{\text{spend}}$$ but are not checkpoints: none binds a $$\tilde{b}$$ to the resulting commitment (§7.4 *Encrypted balance*, §7.9 *Encrypted balance*, [COMPLIANCE.md](./COMPLIANCE.md) §5.7), and their effect is absorbed into the next checkpoint. **No-checkpoint case:** if the account has no checkpoint event since `Register`, initialize $$W\_{\text{spend}} \leftarrow (0, 0)$$ and skip to step 5 with $$T\_0$$ = the `Register` event. 2. Recover the spendable balance value: $$v\_s = \tilde{b} - \text{Poseidon}(\delta\_{\text{enc\\\_bal}}, vk, \sigma)$$. 3. Recover the spendable balance blinding: $$r\_s = \text{Poseidon}(\delta\_{\text{spend\\\_r}}, vk, \sigma)$$. 4. Set $$W\_{\text{spend}} \leftarrow (v\_s, r\_s)$$. -5. Locate $$T\_0$$: the account's most recent `Merge` event at or before the checkpoint of step 1, or its `Register` event if no such `Merge` exists. Set $$W\_{\text{receive}} \leftarrow (0, 0)$$ as of $$T\_0$$. +5. Locate $$T\_0$$: the account's most recent `Merge` or `Clawback` event at or before the checkpoint of step 1, or its `Register` event if neither exists. Set $$W\_{\text{receive}} \leftarrow (0, 0)$$ as of $$T\_0$$. 6. Replay every event after $$T\_0$$ in ledger order, applying the *Update rules* above with three replay-specific amendments: - **Checkpoint event**: skip the spendable side -- step 1 already captured it. - **Revoke spender**: skip if at or before the checkpoint of step 1, which absorbed it; otherwise fold as the table specifies. @@ -452,12 +453,12 @@ $$\text{PVK} = vk \cdot H$$. Set once at registration. Used by senders for ECDH **`spendable_commitment`** -The commitment the owner can spend from. Modified by owner-authorized operations and, in a deployment that enables the optional compliance extension, by its clawback ([COMPLIANCE.md](./COMPLIANCE.md) §5, outline only). Encoded as a single Grumpkin affine point (64 bytes). +The commitment the owner can spend from. Modified by owner-authorized operations and, in a deployment that enables the optional compliance extension, by its clawback ([COMPLIANCE.md](./COMPLIANCE.md) §5.4). Encoded as a single Grumpkin affine point (64 bytes). **`receiving_commitment`** -Accumulates incoming deposits and transfers via homomorphic addition. The contract adds to this without any proof from the recipient. Reset to $$\mathcal{O}$$ on merge (§7.4). Encoded as a single Grumpkin affine point (64 bytes). +Accumulates incoming deposits and transfers via homomorphic addition. The contract adds to this without any proof from the recipient. Reset to $$\mathcal{O}$$ on merge (§7.4) and on clawback ([COMPLIANCE.md](./COMPLIANCE.md) §5.4). Encoded as a single Grumpkin affine point (64 bytes). **`auditor_id`** diff --git a/packages/tokens/src/confidential/docs/DESIGN_cont.md b/packages/tokens/src/confidential/docs/DESIGN_cont.md index ec670114d..730f99bf5 100644 --- a/packages/tokens/src/confidential/docs/DESIGN_cont.md +++ b/packages/tokens/src/confidential/docs/DESIGN_cont.md @@ -49,7 +49,7 @@ The capability is bounded in three ways: - **Forward-only.** Only events emitted while the auditor key was active are decryptable. - **Recipient-channel only.** The reconstruction above yields an opening of `receiving_commitment`: the spend-side blinding $$r\_s = \text{Poseidon}(\delta\_{\text{spend\\\_r}}, vk\_A, \sigma)$$ depends on $$vk\_A$$ and is not derivable from the recipient channel. -- **Reset by merge.** Merge folds $$r\_r$$ into the spendable-balance randomness ($$r\_{\text{spend}}' = r\_s + r\_r$$, Section 7.4) and emits no checkpoint, so the reconstruction above restarts from the next inbound flow. +- **Reset by merge.** Merge folds $$r\_r$$ into the spendable-balance randomness ($$r\_{\text{spend}}' = r\_s + r\_r$$, Section 7.4) and emits no checkpoint, so the reconstruction above restarts from the next inbound flow. A clawback resets it the same way ([COMPLIANCE.md](./COMPLIANCE.md) §5.7). **Sender-auditor opening capability.** The `lane[2]` escrow hands the sender-auditor the *blinding* of the account's post-operation spendable balance directly, without $$vk\_A$$: together with the value in $$\tilde{b}\_{\text{aud,s}}$$ it is a full Pedersen opening of $$C\_{\text{spend}}'$$. It is available at the three checkpoint operations that escrow the spendable blinding -- withdrawal (W\_a5), outgoing transfer (T\_a9), and `set_spender` (S\_a6) -- and is likewise bounded: @@ -58,7 +58,7 @@ The capability is bounded in three ways: - **Carried through `revoke_spender`.** The revoke is a public homomorphic fold, $$C\_{\text{spend}}' = C\_{\text{spend}} + C\_a$$, emitting no new checkpoint (Section 7.9). An auditor that decrypted the delegation's last state-changing event holds the opening of the $$C\_a$$ being folded (Section 8.5) and advances its own opening by the same addition the contract performs. - **Rotation-scoped.** Both folds above depend on the auditor having decrypted the event that produced the addend; Section 8.3 *Decryption capability across rotation* states what a rotated key can and cannot open. -These bounded openings are what the clawback flow specified in [COMPLIANCE.md](./COMPLIANCE.md) §5 draws its witness from. +The clawback flow specified in [COMPLIANCE.md](./COMPLIANCE.md) §5 draws its witness from these bounded openings. ### 8.2 Auditor Visibility Properties @@ -165,6 +165,7 @@ This invariant is maintained by: - **Set spender** moves value from $$v\_{\text{spend}}$$ to $$v\_{\text{allowance}\_i}$$; enforced by S3–S7. - **Spender transfer** decreases $$v\_{\text{allowance}\_i}$$ and increases recipient's $$v\_{\text{receive}}$$ by $$v\_{\text{transfer}}$$; enforced by O2–O8. - **Revoke** moves remaining $$v\_{\text{allowance}\_i}$$ back to $$v\_{\text{spend}}$$ (Proposition 1); the sum is unchanged. +- **Clawback** (compliance extension) removes a public amount from $$v\_{\text{spend}} + v\_{\text{receive}}$$ without a withdrawal, enforced by CB1–CB3; in a deployment that enables it, seized amounts count alongside $$w\_k$$ on the left-hand side ([COMPLIANCE.md](./COMPLIANCE.md) §5.4). ### 9.4 Privacy Properties @@ -186,9 +187,9 @@ As with $$vk$$ above, the openings are self-verifying artifacts a holder can han The recovery procedure, the definition of the **checkpoint** it is built around, the replay window it runs over, and the durable-archive requirement it depends on are all specified in [DESIGN.md](./DESIGN.md) §5.2 *Recovery*. This section states the security properties that follow from it. -**Why the two anchors compose.** No `Merge` falls strictly between $$T\_0$$ and the checkpoint, by construction of $$T\_0$$ as the last merge at or before it. Within $$(T\_0, \text{checkpoint}]$$ the only spendable-affecting events are therefore checkpoint events and `RevokeSpender` folds, and the latest checkpoint's $$(\tilde{b}, \sigma)$$ supersedes both — which is why the replay skips them ([DESIGN.md](./DESIGN.md) §5.2 *Recovery*, step 6). Any `Merge` the replay encounters necessarily sits after the checkpoint and is folded normally, against the receiving opening it actually consumed. +**Why the two anchors compose.** No `Merge` or `Clawback` falls strictly between $$T\_0$$ and the checkpoint, by construction of $$T\_0$$ as the last of either at or before it. Within $$(T\_0, \text{checkpoint}]$$ the only spendable-affecting events are therefore checkpoint events and `RevokeSpender` folds, and the latest checkpoint's $$(\tilde{b}, \sigma)$$ supersedes both — which is why the replay skips them ([DESIGN.md](./DESIGN.md) §5.2 *Recovery*, step 6). Any `Merge` the replay encounters necessarily sits after the checkpoint and is folded normally, against the receiving opening it actually consumed. -**Recoverability.** An owner who holds $$vk$$ reconstructs both openings deterministically from the account's event history, so the loss of local wallet state is not a loss of funds. The single-lookup shortcut on the spendable side is sound rather than merely convenient: each checkpoint's $$\tilde{b}$$ is bound to the $$C\_{\text{spend}}$$ it certifies by the emitting operation's own proof (W7, T12, S11), so a checkpoint cannot misreport the balance the wallet restarts from. Beyond the event history and the on-chain commitments, recovery requires nothing from the auditor, from counterparties, or from the contract admin. +**Recoverability.** An owner who holds $$vk$$ reconstructs both openings deterministically from the account's event history, so the loss of local wallet state is not a loss of funds. The single-lookup shortcut on the spendable side is sound: each checkpoint's $$\tilde{b}$$ is bound to the $$C\_{\text{spend}}$$ it certifies by the emitting operation's own proof (W7, T12, S11), so a checkpoint cannot misreport the balance the wallet restarts from. Beyond the event history and the on-chain commitments, recovery requires nothing from the auditor, from counterparties, or from the contract admin. **Integrity fails closed.** The event archive is trusted for availability, not for integrity. Recovery terminates in a consistency check against the on-chain commitments ([DESIGN.md](./DESIGN.md) §5.2 *Recovery*, step 7) and Pedersen commitments are binding (Section 2.3), so a tampered or truncated history cannot yield an opening that verifies against a balance the account does not hold — it yields a detectable mismatch. The residual risk is therefore denial of recovery, a liveness failure, rather than silent corruption of the reconstructed balance. [INDEXER.md](./INDEXER.md) §7 specifies the archive's trust model and the client-side checks that follow from it. @@ -214,7 +215,7 @@ Because $$\sigma$$ is sampled fresh via CSPRNG for every operation, a retry afte ### 10.1 Circuits -All proof logic is written in Noir, compiled to UltraHonk circuits. The system uses 5 circuits: +All proof logic is written in Noir, compiled to UltraHonk circuits. The system uses six circuits, the last belonging to the optional compliance extension: ```rust #[contracttype] @@ -225,10 +226,11 @@ pub enum CircuitType { Transfer = 2, SpenderTransfer = 3, SetSpender = 4, + Clawback = 100, } ``` -`deposit`, `merge`, and `revoke_spender` carry no proof and have no circuit (Sections 7.3, 7.4, 7.9). The discriminants are part of the on-chain interface and are never renumbered. +`deposit`, `merge`, and `revoke_spender` carry no proof and have no circuit (Sections 7.3, 7.4, 7.9). The discriminants are part of the on-chain interface and are never renumbered; core circuits occupy `0..=4` and extension circuits start at `100`. ### 10.2 Circuit Summary @@ -239,6 +241,7 @@ pub enum CircuitType { | `Transfer` | Balance conservation; ECDH-derived blinding and encrypted amount for recipient; dual-auditor channel sponges (recipient auditor: amount + per-transfer Pedersen randomness; sender auditor: amount + balance + `lane[2]` escrow of the new spendable blinding); deterministic randomness for new sender balance; encrypted balance scalar; sender key ownership; range validity (balance $$\in [0, 2^{127})$$, amount $$\in [0, 2^{127})$$) | | `SpenderTransfer` | Allowance sufficiency; ECDH-derived blinding and encrypted amount for recipient; dual-auditor channel sponges (recipient auditor: amount + per-transfer Pedersen randomness; owner auditor: amount + allowance + `lane[2]` escrow of the new allowance blinding); deterministic randomness for new allowance; encrypted allowance scalar; spender key ownership; contract-bound indirectly via $$C\_a$$ chain (Section 7.8) | | `SetSpender` | Balance split; $$dvk\_i$$ derivation; ECDH escrow of $$dvk\_i$$ to the spender and of the allowance blinding to the owner's auditor; allowance commitment with deterministic randomness; encrypted balance and allowance scalars; owner-auditor ECDH ciphertexts (escrow amount + balance checkpoint + `lane[2]` escrow of the new spendable blinding); owner key ownership; contract-bound via $$vk$$ derivation | +| `Clawback` | Knowledge of the openings of the target's $$C\_{\text{spend}}$$ and $$C\_{\text{receive}}$$; public seize amount bounded by their sum, remainder in range; no key ownership, no ephemeral ([COMPLIANCE.md](./COMPLIANCE.md) §5.3) | ### 10.3 Circuit Cost Analysis @@ -253,8 +256,9 @@ The dominant cost in Noir circuits is elliptic curve scalar multiplication. With | `Transfer` | 8 | $$Y\_A$$ (T1), $$C\_{\text{spend}}^A$$ opening (T3), recipient ECDH (T5), $$R\_e$$ (T6), $$C\_{\text{transfer}}$$ (T8), $$C\_{\text{spend}}'$$ (T11), recipient-auditor ECDH (T\_a1), sender-auditor ECDH (T\_a5) | | `SpenderTransfer` | 8 | $$Y\_{\text{op}}$$ (O1), $$C\_a$$ opening (O2), recipient ECDH (O5), $$R\_e$$ (O6), $$C\_{\text{transfer}}$$ (O8), $$C\_a'$$ (O11), recipient-auditor ECDH (O\_a1), owner-auditor ECDH (O\_a5) | | `SetSpender` | 7 | $$Y$$ (S1), $$C\_{\text{spend}}$$ opening (S3), $$C\_a$$ (S7), $$C\_{\text{spend}}'$$ (S10), $$R\_e$$ (S\_a1), $$dvk\_i$$ escrow ECDH (S12, §7.11), owner-auditor ECDH (S\_a2) | +| `Clawback` | 2 | $$C\_{\text{spend}}$$ opening (CB1), $$C\_{\text{receive}}$$ opening (CB2) | -`SetSpender` is the one circuit with a third ECDH beyond the auditor channel: the $$dvk\_i$$ handoff of §7.11 reuses $$r\_e$$ but multiplies it against $$Y\_{\text{op}}$$, so it is a separate call, not a reuse of the S\_a2 shared secret. The auditor-side escrow of the allowance blinding $$r\_a$$ (S14) reuses the S\_a2 shared scalar and adds one Poseidon evaluation. The `lane[2]` escrows (W\_a5, T\_a9, S\_a6, O\_a9) read `lane[2]` of a permutation each circuit already computes and cost one field addition apiece. The ordering these totals imply is consistent with the committed ACIR opcode counts in `circuits/constraints.baseline`: `Register` 33, `Withdraw` 95, `Transfer` 134, `SetSpender` 135, `SpenderTransfer` 136. +`SetSpender` is the one circuit with a third ECDH beyond the auditor channel: the $$dvk\_i$$ handoff of §7.11 reuses $$r\_e$$ but multiplies it against $$Y\_{\text{op}}$$, so it is a separate call, not a reuse of the S\_a2 shared secret. The auditor-side escrow of the allowance blinding $$r\_a$$ (S14) reuses the S\_a2 shared scalar and adds one Poseidon evaluation. The `lane[2]` escrows (W\_a5, T\_a9, S\_a6, O\_a9) read `lane[2]` of a permutation each circuit already computes and cost one field addition apiece. The ordering these totals imply is consistent with the committed ACIR opcode counts in `circuits/constraints.baseline`: `Register` 33, `Clawback` 51, `Withdraw` 95, `Transfer` 134, `SetSpender` 135, `SpenderTransfer` 136. The ECDH computations add scalar multiplications compared to a random-blinding scheme, but the unchunked design eliminates all per-chunk constraints (which, in a chunked scheme, would involve 8+ scalar multiplications for balance chunks and per-chunk range proofs). @@ -311,7 +315,7 @@ fn ecdh(scalar: Field, point: EmbeddedCurvePoint) -> Field { } ``` -**Post-merge witness availability.** Every opening witnessed in any circuit is encoded as a single $$\mathbb{F}\_r$$ `Field` via the same `commit` primitive. After a proofless fold -- `Merge` (§7.4) or `revoke_spender` (§7.9) -- the spendable-balance blinding is a sum over $$\mathbb{F}\_q$$: $$r\_s + r\_r$$ for a merge, $$r\_s + r\_a$$ for a revoke. Its canonical $$\mathbb{F}\_q$$ representative lies in $$[0, r)$$ -- representable as a Noir `Field` -- with probability $$\geq 1 - (q - r)/q \approx 1 - 2^{-127}$$ per fold (§2.3). With the complementary probability $$\approx 2^{-127}$$ it lies in $$[r, q)$$. In that case the on-chain state remains well-formed (the commitment is a valid Grumpkin point), but the wallet's local opening witness is unencodable as a `Field`, so no spend / transfer / set-spender proof can be constructed against the affected $$C\_{\text{spend}}$$ until further accumulation shifts the blinding back into $$\mathbb{F}\_r$$. +**Post-merge witness availability.** Every opening witnessed in any circuit is encoded as a single $$\mathbb{F}\_r$$ `Field` via the same `commit` primitive. After a proofless fold -- `Merge` (§7.4), `revoke_spender` (§7.9), or `clawback` ([COMPLIANCE.md](./COMPLIANCE.md) §5.4, where the compliance extension is enabled) -- the spendable-balance blinding is a sum over $$\mathbb{F}\_q$$: $$r\_s + r\_r$$ for a merge or a clawback, $$r\_s + r\_a$$ for a revoke. Its canonical $$\mathbb{F}\_q$$ representative lies in $$[0, r)$$ -- representable as a Noir `Field` -- with probability $$\geq 1 - (q - r)/q \approx 1 - 2^{-127}$$ per fold (§2.3). With the complementary probability $$\approx 2^{-127}$$ it lies in $$[r, q)$$. In that case the on-chain state remains well-formed (the commitment is a valid Grumpkin point), but the wallet's local opening witness is unencodable as a `Field`, so no spend / transfer / set-spender proof can be constructed against the affected $$C\_{\text{spend}}$$ until further accumulation shifts the blinding back into $$\mathbb{F}\_r$$. **Soft recovery.** Every subsequent inbound confidential transfer or spender transfer, once merged, adds a fresh $$\mathbb{F}\_r$$-derived blinding. For each transfer-derived addend, the new canonical $$\mathbb{F}\_q$$ representative falls in $$[0, r)$$ with probability $$\geq 1 - 2^{-127}$$ regardless of the current stuck value (worst case: the current value sits at the lower edge of $$[r, q)$$, requiring the new $$\mathbb{F}\_r$$ addend to cross the mod $$q$$ boundary; the probability of failing to do so is bounded by $$(q - r)/r \approx 2^{-127}$$). For accounts that continue to receive confidential transfers, the unspendable window is self-resolving at the next merge with overwhelming probability; accounts whose only inflows are deposits remain stuck until a confidential transfer arrives. @@ -430,9 +434,9 @@ trait ConfidentialToken { } ``` -A deployment that enables the optional compliance extension adds `compliance: Option` to this constructor and the freeze, configuration-rotation, and read entry points of [COMPLIANCE.md](./COMPLIANCE.md) §6 to this interface. The admin-gated ones are authorized by the deployment's own access-control module. +A deployment that enables the optional compliance extension adds `compliance: Option` to this constructor and the freeze, configuration-rotation, read, and — when it opts into `ConfidentialClawback` — seizure entry points of [COMPLIANCE.md](./COMPLIANCE.md) §6 to this interface. The admin-gated ones are authorized by the deployment's own access-control module. -**Hooks.** Every entry point invokes the deployment's `Hooks` implementation after authorization and payload decoding and before the storage-layer operation. Hooks for operations that carry `data` receive the decoded payload by reference; the proofless `on_deposit`, `on_merge`, and `on_revoke_spender` receive none. The two compliance entry points invoke no hook, since their precondition — a frozen target — is exactly what a gating hook rejects ([COMPLIANCE.md](./COMPLIANCE.md) §5). +**Hooks.** Every entry point invokes the deployment's `Hooks` implementation after authorization and payload decoding and before the storage-layer operation. Hooks for operations that carry `data` receive the decoded payload by reference; the proofless `on_deposit`, `on_merge`, and `on_revoke_spender` receive none. The two seizure entry points, `clawback` and `force_revoke_spender`, invoke no hook, since a gating hook rejects exactly their precondition — a frozen target ([COMPLIANCE.md](./COMPLIANCE.md) §5). This table is authoritative: every entry is exactly the set of prover-supplied public inputs from the corresponding Section 7 operation (the contract loads the remaining public inputs from trusted state per §7.1), plus the `proof` blob. Names map directly to the Section 7 symbols. @@ -482,14 +486,17 @@ Each state-modifying operation emits a structured event. Events carry the data n | `SpenderTransfer` | `spender`, `from`, `to`, $$R\_e$$, $$\tilde{v}$$, $$\sigma\_a'$$, $$\tilde{v}\_{\text{aud,r}}$$, $$\tilde{r}\_{\text{aud,r}}$$, $$\tilde{v}\_{\text{aud,s}}$$, $$\tilde{a}\_{\text{aud,s}}$$, $$\tilde{r}\_{\text{aud,s}}$$ | | `SetSpender` | `account`, `spender`, `live_until_ledger`, $$R\_e$$, $$\sigma$$, $$\tilde{b}$$, $$\tilde{v}\_{\text{aud,s}}$$, $$\tilde{b}\_{\text{aud,s}}$$, $$\tilde{r}\_{\text{aud,s}}$$, $$\tilde{r}\_{a,\text{aud,s}}$$ | | `RevokeSpender` | `account`, `spender`, $$\tilde{a}$$, $$\sigma\_a$$ | +| `Clawback` | `account`, `amount`, `destination` | -Amount fields in `Deposit` and `Withdraw` are typed `i128`, matching SEP-41. +Amount fields in `Deposit`, `Withdraw`, and `Clawback` are typed `i128`, matching SEP-41. + +`Clawback` is emitted only by deployments that enable the optional compliance extension ([COMPLIANCE.md](./COMPLIANCE.md) §5). **Usage by consumers:** - **Recipient wallet**: processes `Transfer` and `SpenderTransfer` events using $$(R\_e, \tilde{v}, \sigma)$$ -- with $$\sigma\_a'$$ in place of $$\sigma$$ for `SpenderTransfer` -- to derive $$v\_{\text{transfer}}$$ and $$r\_{\text{transfer}}$$ (Section 5.3). -- **Owner wallet**: processes all events for recovery (Section 5.2). The $$(\tilde{b}, \sigma)$$ pair from the most recent checkpoint event (`Withdraw`, `Transfer` as sender, `SetSpender`) anchors $$W\_{\text{spend}}$$; `Merge` and `RevokeSpender` are folded into it during replay. -- **Auditor**: processes events containing $$R\_e$$ to compute ECDH shared secrets and decrypt amounts, balance checkpoints, and the escrowed blindings (Sections 8.1, 8.2, 8.5); folds `Merge` and `RevokeSpender` into its accumulators as Section 8.1 specifies. +- **Owner wallet**: processes all events for recovery (Section 5.2). The $$(\tilde{b}, \sigma)$$ pair from the most recent checkpoint event (`Withdraw`, `Transfer` as sender, `SetSpender`) anchors $$W\_{\text{spend}}$$; `Merge`, `RevokeSpender`, and `Clawback` are folded into it during replay. +- **Auditor**: processes events containing $$R\_e$$ to compute ECDH shared secrets and decrypt amounts, balance checkpoints, and the escrowed blindings (Sections 8.1, 8.2, 8.5); folds `Merge`, `RevokeSpender`, and `Clawback` into its accumulators as Section 8.1 and [COMPLIANCE.md](./COMPLIANCE.md) §5.7 specify. - **Indexer**: reconciles the pooled balance against the sum of claims (Section 9.3, [INDEXER.md](./INDEXER.md)). ### 11.3 Read Methods diff --git a/packages/tokens/src/confidential/docs/INDEXER.md b/packages/tokens/src/confidential/docs/INDEXER.md index 35dff767a..7e9f82286 100644 --- a/packages/tokens/src/confidential/docs/INDEXER.md +++ b/packages/tokens/src/confidential/docs/INDEXER.md @@ -9,7 +9,7 @@ The key words MUST, MUST NOT, SHOULD, and MAY are to be interpreted as in RFC 21 ## 1. Why the Indexer Is Load-Bearing -Confidential balances are Pedersen commitments; the on-chain entry alone does not reveal the opening `(v, r)` needed to spend. A wallet that loses its local cache reconstructs the opening deterministically from the master secret plus the account's event history: the latest *checkpoint* event supplies `(b_tilde, sigma)` from which the spendable opening as of that checkpoint is derived (DESIGN §5.2), while the receiving-side opening is rebuilt by replaying deposits and incoming transfers back to the account's last `Merge` at or before that checkpoint, or to registration if it has not merged before it (§2). That same replay carries the post-checkpoint proofless folds — `Merge` and `RevokeSpender` — onto the spendable side. +Confidential balances are Pedersen commitments; the on-chain entry alone does not reveal the opening `(v, r)` needed to spend. A wallet that loses its local cache reconstructs the opening deterministically from the master secret plus the account's event history: the latest *checkpoint* event supplies `(b_tilde, sigma)` from which the spendable opening as of that checkpoint is derived (DESIGN §5.2), while the receiving-side opening is rebuilt by replaying deposits and incoming transfers back to the account's last `Merge` or `Clawback` at or before that checkpoint, or to registration if there is none before it (§2). That same replay carries the post-checkpoint folds — `Merge`, `RevokeSpender`, and `Clawback` — onto the spendable side. Stellar RPC retains events for a **7-day window** only. A wallet that loses local state after that window can still see that its funds exist (the commitment remains on-chain) but cannot reconstruct the opening required to spend them — unless a durable archive holds the missing events. That archive is this document's subject. Without a conforming indexer, recovery from seed is not guaranteed, and deployments MUST treat wallet-local state as unrecoverable after the RPC window. @@ -18,7 +18,7 @@ Stellar RPC retains events for a **7-day window** only. A wallet that loses loca ## 2. Terminology - **Checkpoint** — an owner-initiated proof-carrying event that publishes `(b_tilde, sigma)` for the owner's spendable balance (DESIGN §5.2; §3.2 lists the qualifying event types in ingestion scope). -- **Replay window** — the range from `T_0` to the current ledger, where `T_0` is the account's most recent `Merge` at or before its latest checkpoint, or its `Register` event if it has not merged before that checkpoint (DESIGN §5.2 *Recovery*). One window serves both sides: the checkpoint supplies the spendable opening as of the checkpoint, the window carries the `Merge` and `RevokeSpender` folds that follow it onto that opening, and the receiving opening is rebuilt across the window. Anchoring the receiving side at the account's last `Merge` overall is not sufficient — a `Merge` after the checkpoint reconstructs the current receiving opening correctly but leaves the spendable opening short by the amount that merge folded in, the checkpoint predating it. +- **Replay window** — the range from `T_0` to the current ledger, where `T_0` is the account's most recent `Merge` or `Clawback` at or before its latest checkpoint, or its `Register` event if there is none before that checkpoint (DESIGN §5.2 *Recovery*). One window serves both sides: the checkpoint supplies the spendable opening as of the checkpoint, the window carries the `Merge`, `RevokeSpender`, and `Clawback` folds that follow it onto that opening, and the receiving opening is rebuilt across the window. Anchoring the receiving side at the account's last `Merge` overall is not sufficient — a `Merge` after the checkpoint reconstructs the current receiving opening correctly but leaves the spendable opening short by the amount that merge folded in, the checkpoint predating it. - **Event id** — the triple `(ledger_seq, tx_hash, event_index)`, unique per emitted event: `tx_hash` is globally unique, and `event_index` is unambiguous because a Soroban transaction carries a single operation. The same event MUST carry the same id whether served from the archive or from RPC, so a hybrid client can deduplicate across the seam. The id does not by itself encode position within a ledger — `tx_hash` conveys no ordering — so the canonical total order is instead `(ledger_seq, tx_application_order, event_index)`, where `tx_application_order` is persisted as its own field (§3.1) and drives §3.4. - **Seam** — in a hybrid client (§1) that reads the recent tail from Stellar RPC and older history from the archive, the ledger at which it switches sources. A client sets the seam above the RPC retention floor (`getHealth().oldestLedger`) so the RPC side is always served from live retention, and requires the archive's ingested-through ledger (§6 C4) to reach the seam. @@ -51,8 +51,9 @@ All events emitted by the confidential token (DESIGN_cont §11.2) with the follo | `Transfer` (recipient side) | Receiving-side replay: carries the recipient-channel ciphertexts for `(v_transfer, r_transfer)`. | | `SpenderTransfer` (recipient side) | Receiving-side replay, as above. | | `Merge` | Anchor: folds the receiving opening into the spendable opening; resets the receiving side. | +| `Clawback` | Anchor, as `Merge`, less the public `amount` on the spendable side; not a checkpoint. Emitted only by deployments that enable the compliance extension (COMPLIANCE §5.7). | | `Withdraw`, `Transfer` (sender side), `SetSpender` | **Checkpoints**: publish `(b_tilde, sigma)` for the owner's spendable balance. `SetSpender` is in scope as an owner checkpoint only — a spender recovers allowance state from the on-chain delegation entry (`allowance_commitment`, `a_tilde`, `escrowed_dvk`, `allowance_salt`), not from the archive. | -| `RevokeSpender` | Spendable-side fold, not a checkpoint: the owner adds the reclaimed allowance opening to the spendable opening, deriving it from the event's `a_tilde` and `allowance_salt` (DESIGN §7.9). | +| `RevokeSpender` | Spendable-side fold, not a checkpoint: the owner adds the reclaimed allowance opening to the spendable opening, deriving it from the event's `a_tilde` and `allowance_salt` (DESIGN §7.9). May be emitted by the compliance extension's `force_revoke_spender` without an owner-signed transaction; the fold is the same (COMPLIANCE §5.5). | A self-transfer — a `Transfer` whose `from` and `to` are the same account — carries both roles at once: it is a sender-side checkpoint and a recipient-side replay event, and recovery applies both (DESIGN §5.2). @@ -62,7 +63,7 @@ Configuration events (`UnderlyingAssetSet`, `VerifierSet`, `AuditorSet`, `Addres ### 3.3 Account attribution -Recovery is per-account, and an event belongs to **each** account address appearing in its topics — a `Transfer` to both the sender's and the recipient's history, a `SpenderTransfer` to the owner's, recipient's, and spender's, a `RevokeSpender` to the owner's and the spender's alone. Attribution MUST come from the event topics, never from the transaction source account. +Recovery is per-account, and an event belongs to **each** account address appearing in its topics — a `Transfer` to both the sender's and the recipient's history, a `SpenderTransfer` to the owner's, recipient's, and spender's, a `RevokeSpender` to the owner's and the spender's alone, a `Clawback` to the target's alone. Attribution MUST come from the event topics, never from the transaction source account. The indexer MAY apply this attribution server-side (per-account queries, §6 C2) or serve the whole per-contract stream and leave the client to select the events touching its account; both conform, since attribution is a pure function of the topics. Server-side per-account filtering is RECOMMENDED for high-volume contracts, where downloading the full contract history to every wallet does not scale. @@ -83,14 +84,14 @@ The indexer MUST preserve and expose the total order `(ledger_seq, tx_applicatio The indexer MUST retain the full per-account history of every in-scope event **indefinitely**. No pruning horizon is safe in general: - The spendable opening is taken from the account's latest checkpoint, which is arbitrarily old for a dormant account. -- The receiving side is replayed from the last `Merge` at or before that checkpoint, which for an account that receives but never merges is its registration. +- The receiving side is replayed from the last `Merge` or `Clawback` at or before that checkpoint, which for an account that receives but never merges is its registration. - A `RevokeSpender` after the latest checkpoint is the only surviving record of the addend it folded: the delegation entry carrying `a_tilde` and `allowance_salt` is deleted in the same invocation (DESIGN §7.9). ## 6. API Surface C2–C4 below are normative; C1 is RECOMMENDED. The REST shape is RECOMMENDED — any transport exposing the same capabilities conforms. -- **C1 — Latest checkpoint (RECOMMENDED).** Return the most recent checkpoint event for `(contract_id, account)` at or before a given ledger. This is an optimization, not a correctness requirement: each checkpoint carries a self-contained `(b_tilde, sigma)` that re-derives the spendable opening as of that checkpoint, so a client can also obtain the latest checkpoint by scanning the ordered history (C2). Exposing C1 lets a dormant account with a long history skip transferring that history. A client additionally needs `T_0`, the last `Merge` at or before that checkpoint (§2), which C1 does not return and which is obtainable from C2's ordered history. +- **C1 — Latest checkpoint (RECOMMENDED).** Return the most recent checkpoint event for `(contract_id, account)` at or before a given ledger. This is an optimization, not a correctness requirement: each checkpoint carries a self-contained `(b_tilde, sigma)` that re-derives the spendable opening as of that checkpoint, so a client can also obtain the latest checkpoint by scanning the ordered history (C2). Exposing C1 lets a dormant account with a long history skip transferring that history. A client additionally needs `T_0`, the last `Merge` or `Clawback` at or before that checkpoint (§2), which C1 does not return and which is obtainable from C2's ordered history. - **C2 — Ordered history.** Return all in-scope events for `(contract_id, account)` within a ledger range, in the total order of §3.4, paginated, each carrying its event id and payload (§3.1). An indexer that serves only the per-contract stream (§3.3) satisfies C2 by delivering that stream in order for client-side attribution. - **C3 — Completeness signal.** Every response MUST state whether the served range is complete (`complete: true` only when the indexer holds a gap-free history for the whole requested range). - **C4 — Ingestion status.** Expose the latest fully-ingested ledger so clients can bound staleness. A hybrid client (§1) compares it against the seam it derives from the RPC retention floor; if the archive has not ingested through the seam, the client MUST treat the crossing range as incomplete (C3). diff --git a/packages/tokens/src/confidential/docs/OVERVIEW.md b/packages/tokens/src/confidential/docs/OVERVIEW.md index c1bd88fb6..2a7040f9a 100644 --- a/packages/tokens/src/confidential/docs/OVERVIEW.md +++ b/packages/tokens/src/confidential/docs/OVERVIEW.md @@ -145,6 +145,7 @@ Each auditor decrypts its ciphertexts by running the channel sponge (recipient-a - **Dual-auditor ciphertexts.** The ciphertexts each operation produces are enforced by its zero-knowledge proof, so they cannot be omitted or malformed, and no extra action is needed from users. - **Per-account scope.** Auditing one account reveals nothing about any other account. - **Recipient-side opening capability.** The recipient's auditor holds the per-transfer Pedersen blinding $r_{\text{transfer}}$, hence the full Pedersen opening of the recipient's receiving balance between merges, which is what enables the seizure/clawback flow specified in `COMPLIANCE.md` §5; the capability and its bounds are specified in `DESIGN_cont.md` §8.1. +- **Seizure.** A deployment that opts into the compliance extension can reduce a frozen account's balance by a public amount against a proof that the amount does not exceed what the account holds; the proof is produced by the auditor or the owner, never by the admin alone, and the account stays spendable afterwards (`COMPLIANCE.md` §5). - **Sender-side opening capability.** The sender's auditor holds the opening of the account's spendable balance as of each withdrawal, outgoing transfer, and spender setup, because those operations also encrypt the post-operation blinding factor to it. Because one key serves both of an account's auditor channels, the capability survives merges; its bounds are specified in `DESIGN_cont.md` §8.1. - **Seamless auditor rotation.** When an auditor key is rotated, the new key immediately receives ciphertexts on subsequent operations. For the sender's auditor, the balance checkpoint at the next owner-initiated proof operation (transfer, withdrawal, or set spender) provides the current balance with no event replay; `DESIGN_cont.md` §8.3 bounds what a rotated key can open beyond that checkpoint. - **Spender visibility.** The owner's auditor sees spender transfer amounts and post-transfer allowances via the same dual-auditor mechanism, and additionally sees the escrowed amount at `set_spender`. @@ -171,7 +172,7 @@ The wallet must: - **Generate and store keys** - derive the full key hierarchy (spending key, viewing key, public viewing key, delegation viewing keys) from a single master secret. - **Produce zero-knowledge proofs** - the heaviest client-side computation. Proof generation time depends on the circuit complexity but targets single-digit seconds on modern hardware. The Transfer circuit involves 8 elliptic-curve scalar multiplications (including two auditor ECDH exchanges); the Register circuit needs 2. `DESIGN_cont.md` §10.3 lists the per-circuit totals. - **Track local state** - maintain running commitment openings (value and blinding factor pairs) for the spendable and receiving balances by processing on-chain events. This is comparable to wallet sync in UTXO-based privacy systems (Zcash, Monero). -- **Handle recovery** - if local state is lost, reconstruct balances from on-chain data using the viewing key: fetch the encrypted balance scalar and salt from the most recent spend-boundary event, derive the deterministic blinding factor, and replay the events emitted after the last merge preceding that spend boundary, since only a merge clears the receiving balance and since merges and spender revocations in that window fold into the spendable balance too. Recovery requires the master secret plus access to a durable event archive (Stellar RPC retains only 7 days of history); the indexer this archive must satisfy is specified in the companion [Indexing and Off-Chain State Recovery](./INDEXER.md) document. +- **Handle recovery** - if local state is lost, reconstruct balances from on-chain data using the viewing key: fetch the encrypted balance scalar and salt from the most recent spend-boundary event, derive the deterministic blinding factor, and replay the events emitted after the last merge (or compliance clawback) preceding that spend boundary, since only those clear the receiving balance and since merges, spender revocations, and clawbacks in that window fold into the spendable balance too. Recovery requires the master secret plus access to a durable event archive (Stellar RPC retains only 7 days of history); the indexer this archive must satisfy is specified in the companion [Indexing and Off-Chain State Recovery](./INDEXER.md) document. ### For Developers (Integration) @@ -180,7 +181,7 @@ The wallet must: | **Token contract** | The main Soroban contract that holds SEP-41 tokens and manages encrypted account state. | | **Verifier contract** | Validates zero-knowledge proofs on-chain. Stores one verification key per operation type. | | **Auditor contract** | Manages auditor public keys. Shared across tokens. | -| **Noir circuits** | Five proof circuits (register, withdraw, transfer, spender transfer, set spender). Written in Noir, compiled to UltraHonk. | +| **Noir circuits** | Six proof circuits (register, withdraw, transfer, spender transfer, set spender, and the compliance extension's clawback). Written in Noir, compiled to UltraHonk. | | **Client library** | SDK for wallets: key management, proof generation, event processing, balance tracking, encryption/decryption. Specified in [SDK.md](./SDK.md). | --- @@ -206,7 +207,7 @@ If the wallet is lost or reinstalled on a new device: 1. The account holder restores from the master secret (seed phrase or equivalent). 2. The wallet re-derives the full key hierarchy. -3. The wallet fetches the latest spend-boundary event for the account, reads the encrypted balance scalar and salt from it, recovers the spendable balance opening as of that boundary using the viewing key, then replays the deposits, incoming transfers, merges, and spender revocations emitted after the last merge preceding that spend boundary — rebuilding the receiving balance and folding the merges and revocations onto the spendable one. A spend boundary does not clear the receiving balance — only a merge does — so the replay starts at that merge rather than at the spend boundary. +3. The wallet fetches the latest spend-boundary event for the account, reads the encrypted balance scalar and salt from it, recovers the spendable balance opening as of that boundary using the viewing key, then replays the deposits, incoming transfers, merges, spender revocations, and compliance clawbacks emitted after the last merge or clawback preceding that spend boundary — rebuilding the receiving balance and folding the merges, revocations, and clawbacks onto the spendable one. A spend boundary does not clear the receiving balance — only a merge or a clawback does — so the replay starts there rather than at the spend boundary. The recovery process is fully deterministic given the master secret and access to the account's event history from that merge onward. Because Stellar RPC retains only the last 7 days of events, recovery from seed alone depends on a durable indexer ([INDEXER.md](./INDEXER.md)) that retains the per-account event log; without one, the on-chain commitments remain visible but their openings cannot be reconstructed. diff --git a/packages/tokens/src/confidential/docs/SDK.md b/packages/tokens/src/confidential/docs/SDK.md index a434d0abd..2998e2b81 100644 --- a/packages/tokens/src/confidential/docs/SDK.md +++ b/packages/tokens/src/confidential/docs/SDK.md @@ -119,15 +119,19 @@ $$\text{ECDH}(a, B) = \text{poseidon\\\_with\\\_domain}(\delta_{\text{ecdh}}, [S The derivation MUST fail rather than proceed if $$S$$ is the identity: with $$\sigma$$ public, an identity shared secret makes every derived ciphertext trivially decryptable, which is why the circuits carry explicit nonzero-scalar constraints (DESIGN_cont.md §10.8). -### 4.6 Blinding accumulation — mod $$q$$, never mod $$r$$ +### 4.6 Blinding accumulation -Commitment blinding factors accumulate under homomorphic point addition, so they accumulate in the **Grumpkin scalar field** $$\mathbb{F}_q$$ (§4.1): +Commitment blinding factors compose under homomorphic point addition, so their arithmetic is that of the **Grumpkin scalar field** $$\mathbb{F}_q$$ (§4.1): $$\text{Com}(v_1, r_1) + \text{Com}(v_2, r_2) = \text{Com}(v_1 + v_2, \\, (r_1 + r_2) \bmod q)$$ -Reducing modulo $$r$$ instead yields an opening that is off by $$q - r$$ and no longer matches the on-chain point, and for two full-size blindings the integer sum crosses $$q$$ roughly half the time. Implementations MUST provide distinct, clearly named addition operations for the two moduli and MUST use the $$\mathbb{F}_q$$ one for every blinding accumulation: merge (DESIGN.md §7.4) and receiving-balance credit (DESIGN.md §5.2 *Update rules*). +Reducing modulo $$r$$ instead yields an opening that is off by $$q - r$$ and no longer matches the on-chain point, and for two full-size blindings the integer sum crosses $$q$$ roughly half the time. Implementations MUST provide distinct, clearly named reduction operations for the two moduli. -Committed **values** accumulate as exact integers and MUST NOT be reduced by either modulus; DESIGN.md §2.3 establishes that they never wrap. +**One reduction point.** An implementation MUST fold blindings with unbounded integer addition and MUST NOT reduce by either modulus as folds compose. This governs merge (DESIGN.md §7.4), the `RevokeSpender` and `Clawback` folds, and receiving-balance credit (DESIGN.md §5.2 *Update rules*) alike. + +An implementation MUST reduce modulo $$q$$ to the canonical representative only where a blinding leaves an accumulator for the curve: a proof witness, or the recommit of the §10.6 consistency check. §10.7 tests for encodability at that same point. + +Committed **values** accumulate as exact integers and are never reduced; DESIGN.md §2.3 establishes that they never wrap. ### 4.7 Scalar sampling @@ -371,7 +375,7 @@ Separating the first two matters most: they present as the same opaque failure o ### 10.1 State model -The wallet maintains the two accumulators of DESIGN.md §5.2 — $$W_{\text{spend}}$$ and $$W_{\text{receive}}$$ — plus a sync position and any in-flight projection (§10.3). Values accumulate as exact integers; blindings accumulate modulo $$q$$ (§4.6). +The wallet maintains the two accumulators of DESIGN.md §5.2 — $$W_{\text{spend}}$$ and $$W_{\text{receive}}$$ — plus a sync position and any in-flight projection (§10.3). Values and blindings alike accumulate as exact integers; a blinding is reduced modulo $$q$$ only where it leaves an accumulator for the curve (§4.6). Persistence MUST be pluggable, since the same core serves environments with very different storage. With RPC-only event access, discarding persisted state loses the receiving-side openings permanently (§10.9), so it MUST NOT be treated as an evictable cache. @@ -387,6 +391,7 @@ Application rules are DESIGN.md §5.2's update table and are not restated here. - `Withdraw`, sender-side `Transfer`, and `SetSpender` **overwrite** $$W_{\text{spend}}$$ from the event's $$(\tilde{b}, \sigma)$$ rather than adjusting it, so a wallet that missed intervening events still converges on the spendable side. - `RevokeSpender` is not a checkpoint — it carries no $$\tilde{b}$$ — and is **folded**, like `Merge`. `RevokeSpender` adds the reclaimed allowance opening to $$W_{\text{spend}}$$, with $$v_a$$ and $$r_a$$ recovered from the event's $$\tilde{a}$$ and `allowance_salt` under the owner-derived $$dvk_i$$ (DESIGN.md §7.9). A wallet that misses either event diverges until the next checkpoint and MUST rely on §10.6 to detect that. +- `Clawback` is likewise not a checkpoint and is **folded**: the `Merge` rule, then the event's public `amount` subtracted from $$W_{\text{spend}}$$'s value (COMPLIANCE.md §5.7). It occurs only in deployments that enable the compliance extension. - A self-transfer — a `Transfer` whose `from` and `to` are the same account — MUST be applied in both roles: the sender side overwrites $$W_{\text{spend}}$$ from the event's $$(\tilde{b}, \sigma)$$, and the recipient side credits $$W_{\text{receive}}$$ from the same event's recipient-channel ciphertexts. The two roles act on different accumulators, so their relative order does not affect the result; applying only one loses the other accumulator's update. ### 10.3 In-flight operations @@ -413,8 +418,6 @@ $$r_e = \text{poseidon\\\_with\\\_domain}(\delta_{\text{eph}}, [vk, \sigma_E])$$ where $$vk$$ is the originator's viewing key and $$\sigma_E$$ the operation's salt. DESIGN.md §5.3 is the normative source: it fixes which viewing key and salt each operation derives from, and the retry rule for the negligible case that the derivation yields zero. -**Scope.** The clawback circuit is the one operation outside the rule, its ephemeral belonging to the auditor: an implementation that constructs clawback witnesses obtains that scalar by the §4.7 procedure, no viewing key being available there (COMPLIANCE.md §5.3). - **Three consequences an implementation MUST handle.** First, **$$vk$$ carries more authority than balance decryption.** Recomputing $$r_e$$ yields the recipient shared scalar, hence $$r_{\text{transfer}}$$, hence a full Pedersen opening of every transfer commitment the account created — retroactively, and reaching commitments that sit inside recipients' receiving balances (DESIGN_cont.md §9.4, §8.2). An implementation MUST treat $$vk$$ accordingly in §13 and MUST NOT export it as a read-only credential without stating this. @@ -431,11 +434,11 @@ Implementations MUST report which accumulator diverged, since the two have diffe ### 10.7 The unspendable-blinding case -A post-merge spendable blinding can land outside the range a Noir `Field` encodes, leaving no constructible proof against the affected commitment while on-chain state stays well-formed and §10.6's check still passes (DESIGN_cont.md §10.4 *Post-merge witness availability*). +$$W_{\text{spend}}.r$$ is an exact integer, but a proof witnesses it as a single $$\mathbb{F}_r$$ `Field`. After any of the three proofless folds — `Merge`, `RevokeSpender`, `Clawback` — its canonical $$\mathbb{F}_q$$ representative can land in $$[r, q)$$, which no `Field` encodes, so no proof can be constructed against that commitment. The state itself is sound: the commitment is a well-formed Grumpkin point, and §10.6's check recommits from the same representative and still passes (DESIGN_cont.md §10.4 *Post-merge witness availability*). -An implementation MUST detect this condition and surface it as a distinct, named state rather than as a generic proof-construction failure. +An implementation MUST test the reduction rather than the accumulator: before attempting a proof, reduce $$W_{\text{spend}}.r$$ modulo $$q$$ at §4.6's reduction point and compare the representative against $$r$$. The unreduced accumulator carries no signal: after a few folds an integer sum past $$r$$ is the ordinary case. -It MUST also surface the recovery path: the condition resolves at the next merge that folds in an inbound confidential transfer, and an account whose only inflows are deposits stays affected until one arrives (DESIGN_cont.md §10.4 *Soft recovery*). +A failed comparison MUST surface as a distinct, named state rather than as a generic proof-construction failure. The recovery path MUST surface with it: the condition resolves at the next merge that folds in an inbound confidential transfer, and an account whose only inflows are deposits stays affected until one arrives (DESIGN_cont.md §10.4 *Soft recovery*). ### 10.8 Merge policy @@ -445,7 +448,7 @@ Merge is proof-less and owner-authorized, and neither a merge nor an in-flight s ### 10.9 Recovery -Recovery follows the procedure of DESIGN.md §5.2 *Recovery*, with the reconstructed state verified per §10.6. Its two anchors differ: the latest checkpoint event pins $$W_{\text{spend}}$$ as of that checkpoint in one lookup, with the `Merge` and `RevokeSpender` folds that follow it applied over the replay window per §10.2, while $$W_{\text{receive}}$$ restarts at $$T_0$$ — the account's last `Merge` at or before that checkpoint — from which that window runs. +Recovery follows the procedure of DESIGN.md §5.2 *Recovery*, with the reconstructed state verified per §10.6. Its two anchors differ: the latest checkpoint event pins $$W_{\text{spend}}$$ as of that checkpoint in one lookup, with the `Merge`, `RevokeSpender`, and `Clawback` folds that follow it applied over the replay window per §10.2, while $$W_{\text{receive}}$$ restarts at $$T_0$$ — the account's last `Merge` or `Clawback` at or before that checkpoint — from which that window runs. Two further obligations follow from data availability: @@ -481,6 +484,8 @@ An implementation MUST squeeze the sender / owner channel three-wide and MUST NO **Scope MUST be represented, not implied.** The recipient-channel capability is forward-only, reset by merge, and yields no opening of the spendable commitment (DESIGN_cont.md §8.1). The `lane[2]` opening of the sender channel is forward-only and **standing**: it opens the spendable commitment as of the checkpoint that escrowed it and stays valid through merges provided the client folded in every inbound flow since the previous merge, one account key serving both channels (DESIGN_cont.md §8.1 *Sender-auditor opening capability*). Maintaining it is the client's job: an implementation MUST add the `lane[0]` amount and `lane[1]` $$r_{\text{transfer}}$$ of every inbound `Transfer` and `SpenderTransfer` to its stored $$(v, r)$$, and MUST treat each `Deposit` as $$(\text{amount}, 0)$$ (DESIGN.md §7.3). On a `RevokeSpender` event it MUST add the $$(v_a, r_a)$$ it recorded for that delegation, the fold being public and the addend already escrowed (DESIGN_cont.md §8.5); where it holds no such record it MUST mark the opening unavailable until the next `lane[2]` escrow rather than carry a stale one. Across a key rotation, what an implementation may carry forward and what it MUST treat as unopened are fixed by DESIGN_cont.md §8.3. +**Clawback witness.** In a deployment that enables seizure (COMPLIANCE.md §5), the clawback witness is the pair of openings the client already maintains — the standing sender-channel opening of $$C_{\text{spend}}$$ and the recipient-channel opening of $$C_{\text{receive}}$$ (COMPLIANCE.md §5.2, §5.3). An implementation MUST verify both against the on-chain commitments before proving, and on a `Clawback` event MUST advance the standing opening by the `Merge` rule and the event's public `amount` (COMPLIANCE.md §5.7). + An auditor facade MUST NOT be able to construct a spending witness. It can open the spendable balance past a merge — the inbound $$r_{\text{transfer}}$$ reaches the same key on the recipient channel — so a facade that exposes openings exposes them for the account's whole history under the active key. --- @@ -539,7 +544,7 @@ $$vk$$ MUST NOT be presented as a safely-shareable read-only credential. It expo **Proving latency.** Single-digit seconds on contemporary hardware is the design target (OVERVIEW.md). Implementations MUST treat it as user-visible. -**Sync and replay bounds.** The replay window runs from the account's last `Merge` at or before its latest checkpoint, or from registration for an account that has not merged before that checkpoint (DESIGN.md §5.2 *Recovery*), which is unbounded in age. Implementations MUST NOT assume a bounded window, and SHOULD use the archive's checkpoint lookup where available (INDEXER.md §6, C1) so that a dormant account is not obliged to transfer its entire history. +**Sync and replay bounds.** The replay window runs from the account's last `Merge` or `Clawback` at or before its latest checkpoint, or from registration if there is none before that checkpoint (DESIGN.md §5.2 *Recovery*), which is unbounded in age. Implementations MUST NOT assume a bounded window, and SHOULD use the archive's checkpoint lookup where available (INDEXER.md §6, C1) so that a dormant account is not obliged to transfer its entire history. **Storage growth.** Per-account event volume is linear in inbound transfers and unbounded by design, since incoming-transfer spam is rate-limited only by transaction fees (DESIGN_cont.md §9.5). Implementations MUST NOT size local storage on the assumption that inbound volume tracks the user's own activity. diff --git a/packages/tokens/src/confidential/mod.rs b/packages/tokens/src/confidential/mod.rs index f7f23084e..78e1dc36b 100644 --- a/packages/tokens/src/confidential/mod.rs +++ b/packages/tokens/src/confidential/mod.rs @@ -87,9 +87,11 @@ //! `amount` units between the two accounts, with no fees deducted in transit //! and no rebasing applied. [`storage::deposit`] credits the //! confidential receiving balance with `amount · G` after the SEP-41 -//! transfer, and [`storage::withdraw`] debits the confidential -//! spendable balance by `amount` before transferring the same amount out; -//! neither call re-measures the contract's own balance. With a +//! transfer, [`storage::withdraw`] debits the confidential +//! spendable balance by `amount` before transferring the same amount out, +//! and [`compliance::storage::clawback`] does the same on its +//! `Some(destination)` branch; none of the three re-measures the contract's +//! own balance. With a //! fee-on-transfer, rebasing, or otherwise malicious token implementation, //! the confidential ledger would drift from the on-chain reserves — //! credit a higher amount than was actually received, or pay out less than @@ -125,6 +127,17 @@ //! clawback empties the pool, leaving all holders' confidential balances //! unbacked and unwithdrawable. Issuer-led SAC deployments must weigh both //! powers explicitly alongside the exact-transfer assumption. +//! +//! The compliance extension's +//! [`clawback`](compliance::ConfidentialClawback::clawback) gives the issuer a +//! *legitimate* reason to make such a call. A seizure with `destination: None` +//! reduces the target's confidential claim without moving any underlying, so +//! the pool ends up over-collateralized by exactly that amount, which the +//! issuer's own SAC clawback against this contract's address then extracts. +//! The order matters: seizing first passes through surplus, while extracting +//! first passes through a deficit borne by every other holder. An issuer +//! extracting more than the accumulated surplus creates the shortfall +//! described above. pub mod auditor; pub mod compliance; @@ -838,7 +851,9 @@ pub fn emit_set_spender( } /// Event emitted when a delegation's escrowed allowance is folded back into -/// the owner's spendable balance and the delegation is deleted. +/// the owner's spendable balance and the delegation deleted — by the owner +/// through [`ConfidentialToken::revoke_spender`], or by the compliance module +/// through `ConfidentialClawback::force_revoke_spender`. #[contractevent] #[derive(Clone, Debug, Eq, PartialEq)] pub struct RevokeSpender { diff --git a/packages/tokens/src/confidential/storage.rs b/packages/tokens/src/confidential/storage.rs index 663098f9c..fdf68894a 100644 --- a/packages/tokens/src/confidential/storage.rs +++ b/packages/tokens/src/confidential/storage.rs @@ -1127,6 +1127,35 @@ pub fn set_address_as_field_element(e: &Env) { emit_address_as_field_set(e, &computed); } +/// Overwrites both balance commitments of `account`. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `account` - The owner address. +/// * `c_spend` - The new spendable commitment. +/// * `c_receive` - The new receiving commitment. +/// +/// # Errors +/// +/// * [`ConfidentialTokenError::AccountNotRegistered`] - When `account` is not +/// registered. +/// +/// # Security Warning +/// +/// **IMPORTANT**: This function performs no authorization check, consumes no +/// proof, and verifies no conservation property. Establishing that the write +/// preserves balance conservation (DESIGN_cont §9.3) is the caller's +/// obligation. It exists for the compliance module's clawback flow +/// ([`crate::confidential::compliance::storage::clawback`]), which discharges +/// that obligation in-circuit before calling. +pub(crate) fn set_commitments(e: &Env, account: &Address, c_spend: &Point, c_receive: &Point) { + let mut data = get_account(e, account); + data.spendable_commitment = c_spend.clone(); + data.receiving_commitment = c_receive.clone(); + e.storage().persistent().set(&ConfidentialTokenStorageKey::Account(account.clone()), &data); +} + // ################## LOW-LEVEL HELPERS ################## /// Overwrites `account.spendable_commitment` with `c_spend_new`. @@ -1245,7 +1274,7 @@ where /// Cross-contract proof verification through [`ConfidentialVerifierClient`]. /// Panics with [`ConfidentialTokenError::InvalidProof`] if the verifier returns /// `false`. -fn verify(e: &Env, circuit_type: CircuitType, public_inputs: &Bytes, proof: &Bytes) { +pub(crate) fn verify(e: &Env, circuit_type: CircuitType, public_inputs: &Bytes, proof: &Bytes) { let verifier = ConfidentialVerifierClient::new(e, &get_verifier(e)); if !verifier.verify_proof(&circuit_type, public_inputs, proof) { panic_with_error!(e, ConfidentialTokenError::InvalidProof); @@ -1266,7 +1295,7 @@ fn verify(e: &Env, circuit_type: CircuitType, public_inputs: &Bytes, proof: &Byt /// /// * [`ConfidentialTokenError::NonCanonicalEncoding`] - When either coordinate /// is `≥ r`. -fn append_point(pi: &mut Bytes, p: &Point) { +pub(crate) fn append_point(pi: &mut Bytes, p: &Point) { if !Grumpkin::is_canonical_point(p) { panic_with_error!(pi.env(), ConfidentialTokenError::NonCanonicalEncoding); } @@ -1281,7 +1310,7 @@ fn append_point(pi: &mut Bytes, p: &Point) { /// /// * [`ConfidentialTokenError::NonCanonicalEncoding`] - When the value is `≥ /// r`. -fn append_field(pi: &mut Bytes, f: &BytesN<32>) { +pub(crate) fn append_field(pi: &mut Bytes, f: &BytesN<32>) { if !Grumpkin::is_canonical_field(f) { panic_with_error!(pi.env(), ConfidentialTokenError::NonCanonicalEncoding); } @@ -1290,7 +1319,7 @@ fn append_field(pi: &mut Bytes, f: &BytesN<32>) { /// Appends a non-negative `i128` amount as a canonical 32-byte big-endian /// `Bn254Fr` representative. -fn append_amount(pi: &mut Bytes, e: &Env, amount: i128) { +pub(crate) fn append_amount(pi: &mut Bytes, e: &Env, amount: i128) { let mut buf = [0u8; 32]; buf[16..].copy_from_slice(&amount.to_be_bytes()); pi.append(&Bytes::from_array(e, &buf)); diff --git a/packages/tokens/src/confidential/test.rs b/packages/tokens/src/confidential/test.rs index 6c5cf2363..c25a99372 100644 --- a/packages/tokens/src/confidential/test.rs +++ b/packages/tokens/src/confidential/test.rs @@ -860,9 +860,8 @@ fn auditor_key_rotation_rescopes_the_escrowed_allowance_opening() { let before = h.token.get_spender_delegation(&alice, &spender); // Step 3 -- the first post-rotation state change re-anchors K2. O_a9's - // escrow of r_a' is verified against K2, so this single event is what - // gives the rotated-in key an opening of the allowance it did not see - // created. + // escrow of r_a' is verified against K2, so this single event gives the + // rotated-in key an opening of the allowance it did not see created. h.token.confidential_transfer_from(&spender, &alice, &bob, &spender_transfer_data(&h.e)); let xfer_events = h.e.events().all(); assert_eq!( @@ -886,7 +885,7 @@ fn auditor_key_rotation_rescopes_the_escrowed_allowance_opening() { // The delegation moved to a new state, so the opening K2 just received is // an opening of the CURRENT C_a, not of the one K1 saw. (The commitments // themselves are the same canonical fixture point under a mocked verifier; - // the salt is what distinguishes the two states here.) + // the salt distinguishes the two states here.) let after = h.token.get_spender_delegation(&alice, &spender); assert_ne!(before.allowance_salt, after.allowance_salt); diff --git a/packages/tokens/src/confidential/verifier/mod.rs b/packages/tokens/src/confidential/verifier/mod.rs index a36be83b6..f61a7747e 100644 --- a/packages/tokens/src/confidential/verifier/mod.rs +++ b/packages/tokens/src/confidential/verifier/mod.rs @@ -92,7 +92,8 @@ pub use storage::{ /// Identifier of a zero-knowledge circuit whose verification key is stored in /// the registry. The numeric values are part of the on-chain interface and -/// MUST NOT change. +/// MUST NOT change. Core token circuits occupy `0..=4`; circuits shipped by +/// optional extensions start at `100`. #[contracttype] #[derive(Copy, Clone, Debug, Eq, PartialEq)] #[repr(u32)] @@ -102,6 +103,7 @@ pub enum CircuitType { Transfer = 2, SpenderTransfer = 3, SetSpender = 4, + Clawback = 100, } /// Trait for managing UltraHonk verification keys used by the confidential diff --git a/packages/tokens/src/confidential/verifier/test.rs b/packages/tokens/src/confidential/verifier/test.rs index bd33daae4..5809a33bf 100644 --- a/packages/tokens/src/confidential/verifier/test.rs +++ b/packages/tokens/src/confidential/verifier/test.rs @@ -44,6 +44,7 @@ fn register_each_circuit_round_trips() { CircuitType::Transfer, CircuitType::SpenderTransfer, CircuitType::SetSpender, + CircuitType::Clawback, ] .into_iter() .enumerate() @@ -129,12 +130,12 @@ fn storage_key_round_trip() { e.as_contract(&address, || { let verification_key = verification_key_bytes(&e, 0x77); - register_verification_key(&e, CircuitType::SetSpender, &verification_key); + register_verification_key(&e, CircuitType::Clawback, &verification_key); let stored: Bytes = e .storage() .instance() - .get(&VerifierStorageKey::VerificationKey(CircuitType::SetSpender)) + .get(&VerifierStorageKey::VerificationKey(CircuitType::Clawback)) .unwrap(); assert_eq!(stored, verification_key); }); From d9fd7d6efd6aa06eee5f6092c825ae0f01b690ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96zg=C3=BCn=20=C3=96zerk?= Date: Mon, 7 Sep 2026 18:34:49 +0300 Subject: [PATCH 08/17] Rwa muxed support (#857) * support rwa muxed address * optimizations Co-authored-by: Yigit Can Gokmen --- examples/rwa/token/src/contract.rs | 34 +++++- packages/tokens/src/rwa/storage.rs | 38 ++++++- packages/tokens/src/rwa/test.rs | 173 +++++++++++++++++++++++++++-- 3 files changed, 228 insertions(+), 17 deletions(-) diff --git a/examples/rwa/token/src/contract.rs b/examples/rwa/token/src/contract.rs index 1d456a192..fcb2108fd 100644 --- a/examples/rwa/token/src/contract.rs +++ b/examples/rwa/token/src/contract.rs @@ -1,14 +1,15 @@ //! RWA Token Example Contract. use soroban_sdk::{ - contract, contractimpl, symbol_short, Address, Env, MuxedAddress, String, Symbol, Vec, + contract, contractimpl, panic_with_error, symbol_short, Address, Env, MuxedAddress, String, + Symbol, Vec, }; use stellar_access::access_control::{self as access_control, AccessControl}; use stellar_contract_utils::pausable::{self as pausable, Pausable}; use stellar_macros::{only_admin, only_role}; use stellar_tokens::{ fungible::{Base, FungibleToken}, - rwa::{RWAToken, RWA}, + rwa::{RWAError, RWAToken, RWA}, }; const MANAGER_ROLE: Symbol = symbol_short!("manager"); @@ -55,6 +56,35 @@ impl Pausable for RWATokenContract { #[contractimpl(contracttrait)] impl FungibleToken for RWATokenContract { type ContractType = RWA; + + /// Showcase: how to opt out of custodial (muxed) destinations. + /// + /// **SHOWCASE ONLY**: this override exists purely to illustrate how the + /// opt-out is written. It is neither a recommendation nor part of the + /// library's behavior, and it is not required for a conforming RWA token. + /// Whether to accept custodial destinations is entirely the implementor's + /// decision, and both answers are legitimate. + /// + /// Background: [`RWA`] accepts a muxed destination. Identity verification + /// and compliance run against the base address, and the muxed ID is + /// recorded in the transfer event so a custodian can attribute the + /// transfer to one of its off-chain sub-accounts. A verified holder may + /// therefore be a custodian holding on behalf of beneficiaries who have no + /// on-chain identity of their own, and the ID itself carries no on-chain + /// verification. That is the intended arrangement for omnibus custody, and + /// it is what the library does by default. + /// + /// An issuer that instead requires beneficial owners on the on-chain + /// register can refuse muxed destinations, which is what the body below + /// does. An issuer content with omnibus custody should simply delete this + /// override; the library default then applies and muxed destinations are + /// accepted. + fn transfer(e: &Env, from: Address, to: MuxedAddress, amount: i128) { + if to.id().is_some() { + panic_with_error!(e, RWAError::IdentityVerificationFailed); + } + RWA::transfer(e, &from, &to, amount); + } } #[contractimpl(contracttrait)] diff --git a/packages/tokens/src/rwa/storage.rs b/packages/tokens/src/rwa/storage.rs index 204424175..0da5c14f7 100644 --- a/packages/tokens/src/rwa/storage.rs +++ b/packages/tokens/src/rwa/storage.rs @@ -48,7 +48,7 @@ pub enum FromVerification { impl ContractOverrides for RWA { fn transfer(e: &Env, from: &Address, to: &MuxedAddress, amount: i128) { - RWA::transfer(e, from, &to.address(), amount); + RWA::transfer(e, from, to, amount); } fn transfer_from(e: &Env, spender: &Address, from: &Address, to: &Address, amount: i128) { @@ -697,13 +697,41 @@ impl RWA { /// /// Please refer to [`Base::update`] and [`Self::validate_transfer`] for the /// inline documentation. - pub fn transfer(e: &Env, from: &Address, to: &Address, amount: i128) { + /// + /// # Events + /// + /// * topics - `["transfer", from: Address, to: Address]` + /// * data - `amount: i128` (when `to` has no muxed ID) + /// * data - `{to_muxed_id: Option, amount: i128}` (when `to` has a + /// muxed ID) + /// + /// # Notes + /// + /// A muxed `to` is narrowed to its base address before any check runs, so + /// identity verification, the compliance hook and the balance update all + /// operate on the base address. The muxed ID reaches the event only, where + /// it lets a custodian attribute the transfer to one of its off-chain + /// sub-accounts. + /// + /// The ID is supplied by the caller and carries no on-chain verification. + /// A verified holder may therefore be a custodian holding on behalf of + /// beneficiaries who have no on-chain identity of their own. + /// + /// Whether to accept custodial destinations is the implementor's decision, + /// not the library's. An issuer that requires beneficial owners on the + /// on-chain register can override + /// [`crate::fungible::FungibleToken::transfer`] and reject a destination + /// whose `id()` is `Some`; `rwa-token-example` carries that pattern as a + /// showcase. + pub fn transfer(e: &Env, from: &Address, to: &MuxedAddress, amount: i128) { from.require_auth(); + let to_address = to.address(); + // Snapshot before the update so the hook observes the pre-transfer // state. let from_snapshot = Self::account_snapshot(e, from); - let to_snapshot = Self::account_snapshot(e, to); + let to_snapshot = Self::account_snapshot(e, &to_address); Self::validate_transfer( e, @@ -713,7 +741,7 @@ impl RWA { FromVerification::Required, ); - Base::update(e, Some(from), Some(to), amount); + Base::update(e, Some(from), Some(&to_address), amount); let compliance_client = ComplianceClient::new(e, &Self::compliance(e)); compliance_client.transferred( @@ -723,7 +751,7 @@ impl RWA { &TransferKind::Standard, &e.current_contract_address(), ); - emit_transfer(e, from, to, None, amount); + emit_transfer(e, from, &to_address, to.id(), amount); } /// `transfer_from` override with added compliance and identity verification diff --git a/packages/tokens/src/rwa/test.rs b/packages/tokens/src/rwa/test.rs index 314902e82..d05b7c428 100644 --- a/packages/tokens/src/rwa/test.rs +++ b/packages/tokens/src/rwa/test.rs @@ -2,13 +2,13 @@ extern crate std; use soroban_sdk::{ contract, contractimpl, panic_with_error, symbol_short, - testutils::{Address as _, Events}, - vec, Address, Env, Event, String, Vec, + testutils::{Address as _, Events, MuxedAddress as _}, + vec, Address, Env, Event, MuxedAddress, String, Vec, }; use stellar_contract_utils::pausable; use crate::{ - fungible::{ContractOverrides, Transfer}, + fungible::{ContractOverrides, MuxedTransfer, Transfer}, rwa::{ compliance::{AccountSnapshot, TransferKind}, storage::RWAStorageKey, @@ -616,7 +616,7 @@ fn transfer_with_compliance_and_identity_checks() { // Mint and transfer RWA::mint(&e, &from, 100); - RWA::transfer(&e, &from, &to, 50); + RWA::transfer(&e, &from, &MuxedAddress::from(to.clone()), 50); assert_eq!(RWA::balance(&e, &from), 50); assert_eq!(RWA::balance(&e, &to), 50); @@ -637,13 +637,166 @@ fn contract_overrides_transfer() { RWA::mint(&e, &from, 100); // Test ContractOverrides::transfer calls RWA::transfer - RWA::transfer(&e, &from, &to, 30); + RWA::transfer(&e, &from, &MuxedAddress::from(to.clone()), 30); assert_eq!(RWA::balance(&e, &from), 70); assert_eq!(RWA::balance(&e, &to), 30); }); } +#[test] +fn contract_overrides_transfer_preserves_muxed_id() { + let e = Env::default(); + e.mock_all_auths(); + let address = e.register(MockRWAContract, ()); + let from = Address::generate(&e); + let base = MuxedAddress::generate(&e); + let to = base.address(); + + e.as_contract(&address, || { + setup_all_contracts(&e); + + RWA::mint(&e, &from, 100); + + ::transfer(&e, &from, &MuxedAddress::new(base.clone(), 42), 30); + + // Balances settle on the base address, exactly as for a plain + // transfer. + assert_eq!(RWA::balance(&e, &from), 70); + assert_eq!(RWA::balance(&e, &to), 30); + + // The muxed ID must survive into the event, so that a custodian can + // attribute the transfer to the right off-chain sub-account. + // + // 1 IdentityVerifierSet + 1 ComplianceSet + 1 Minted + 1 MuxedTransfer + let events = e.events().all(); + assert_eq!(events.events().len(), 4); + assert_eq!( + events.events().get(3).unwrap(), + &MuxedTransfer { + from: from.clone(), + to: to.clone(), + to_muxed_id: Some(42), + amount: 30 + } + .to_xdr(&e, &address), + ); + }); +} + +#[test] +fn muxed_ids_do_not_fragment_balance() { + let e = Env::default(); + e.mock_all_auths(); + let address = e.register(MockRWAContract, ()); + let from = Address::generate(&e); + let base = MuxedAddress::generate(&e); + let to = base.address(); + + e.as_contract(&address, || { + setup_all_contracts(&e); + RWA::mint(&e, &from, 100); + // Separate frames: `from.require_auth()` may only run once per + // invocation. + RWA::transfer(&e, &from, &MuxedAddress::new(base.clone(), 1), 30); + }); + e.as_contract(&address, || { + RWA::transfer(&e, &from, &MuxedAddress::new(base.clone(), 999), 20); + }); + e.as_contract(&address, || { + // Two sub-accounts of the same base, one aggregate on-chain balance. + assert_eq!(RWA::balance(&e, &to), 50); + assert_eq!(RWA::balance(&e, &from), 50); + assert_eq!(RWA::total_supply(&e), 100); + }); +} + +#[test] +#[should_panic(expected = "Error(Contract, #302)")] +fn muxed_destination_cannot_bypass_freeze() { + let e = Env::default(); + e.mock_all_auths(); + let address = e.register(MockRWAContract, ()); + let from = Address::generate(&e); + let base = MuxedAddress::generate(&e); + + e.as_contract(&address, || { + setup_all_contracts(&e); + RWA::mint(&e, &from, 100); + // Freeze the base address, then try to route around it with a muxed + // ID. + RWA::set_address_frozen(&e, &base.address(), true); + RWA::transfer(&e, &from, &MuxedAddress::new(base.clone(), 42), 30); + }); +} + +#[test] +#[should_panic(expected = "Error(Contract, #304)")] +fn muxed_destination_cannot_bypass_identity_verification() { + let e = Env::default(); + e.mock_all_auths(); + let address = e.register(MockRWAContract, ()); + let from = Address::generate(&e); + let base = MuxedAddress::generate(&e); + + let verifier = e.as_contract(&address, || { + let verifier = set_and_return_identity_verifier(&e); + let _ = set_and_return_compliance(&e); + verifier + }); + // Deny the base address at the identity verifier. + e.as_contract(&verifier, || { + e.storage().persistent().set(&symbol_short!("id_deny"), &base.address()); + }); + + e.as_contract(&address, || { + RWA::mint(&e, &from, 100); + RWA::transfer(&e, &from, &MuxedAddress::new(base.clone(), 42), 30); + }); +} + +/// An issuer that requires beneficial owners on the on-chain register, and so +/// refuses custodial sub-account destinations. Mirrors the showcase override +/// in `rwa-token-example`; the library itself accepts muxed destinations, and +/// the choice belongs to the implementor. +#[contract] +struct NoOmnibusToken; + +#[contractimpl(contracttrait)] +impl crate::fungible::FungibleToken for NoOmnibusToken { + type ContractType = RWA; + + fn transfer(e: &Env, from: Address, to: MuxedAddress, amount: i128) { + if to.id().is_some() { + panic_with_error!(e, RWAError::IdentityVerificationFailed); + } + RWA::transfer(e, &from, &to, amount); + } +} + +#[test] +fn issuer_can_refuse_muxed_destinations() { + let e = Env::default(); + e.mock_all_auths(); + let token = e.register(NoOmnibusToken, ()); + let holder = Address::generate(&e); + e.as_contract(&token, || { + setup_all_contracts(&e); + RWA::mint(&e, &holder, 100); + }); + let client = NoOmnibusTokenClient::new(&e, &token); + + // A plain destination is accepted. + let plain = Address::generate(&e); + client.transfer(&holder, MuxedAddress::from(plain.clone()), &10); + assert_eq!(client.balance(&plain), 10); + + // A custodial sub-account destination is refused. + let custodian = MuxedAddress::generate(&e); + assert!(client.try_transfer(&holder, MuxedAddress::new(custodian.clone(), 57), &10).is_err()); + assert_eq!(client.balance(&custodian.address()), 0); +} + #[test] fn contract_overrides_transfer_from() { let e = Env::default(); @@ -1080,7 +1233,7 @@ fn transfer_fails_when_from_address_frozen() { RWA::set_address_frozen(&e, &from, true); // Try to transfer - should fail with AddressFrozen error - RWA::transfer(&e, &from, &to, 50); + RWA::transfer(&e, &from, &MuxedAddress::from(to.clone()), 50); }); } @@ -1102,7 +1255,7 @@ fn transfer_fails_when_to_address_frozen() { RWA::set_address_frozen(&e, &to, true); // Try to transfer - should fail with AddressFrozen error - RWA::transfer(&e, &from, &to, 50); + RWA::transfer(&e, &from, &MuxedAddress::from(to.clone()), 50); }); } @@ -1125,7 +1278,7 @@ fn transfer_fails_when_insufficient_free_tokens() { assert_eq!(RWA::get_free_tokens(&e, &from), 20); // Try to transfer 50 tokens (more than free) - should fail - RWA::transfer(&e, &from, &to, 50); + RWA::transfer(&e, &from, &MuxedAddress::from(to.clone()), 50); }); } @@ -1147,7 +1300,7 @@ fn transfer_fails_when_contract_paused() { pausable::pause(&e); // Try to transfer - should fail with EnforcedPause error - RWA::transfer(&e, &from, &to, 50); + RWA::transfer(&e, &from, &MuxedAddress::from(to.clone()), 50); }); } @@ -1172,7 +1325,7 @@ fn transfer_fails_when_not_compliant() { }); // Try to transfer - should fail with TransferNotCompliant error - RWA::transfer(&e, &from, &to, 50); + RWA::transfer(&e, &from, &MuxedAddress::from(to.clone()), 50); }); } From c775c2f4ee2cbb1fe497b7f710430383c84f1346 Mon Sep 17 00:00:00 2001 From: Boyan Barakov <9572072+brozorec@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:15:46 +0200 Subject: [PATCH 09/17] Confidential Token: Clawback proof bindings (#872) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(confidential): bind auditor key in clawback * fix(confidential): bind a per-account nonce into clawback proofs A seizure's public inputs were fully restorable — a deposit and merge of `alpha` return the commitments to their pre-seizure values — so an approved proof could execute again. The account's clawback nonce is now a public input and advances on every seizure. * docs(confidential): note that the clawback destination is not gated COMPLIANCE.md §5.4 owns the claim; the two clawback docstrings state it and cite the section. --- packages/tokens/src/confidential/CLAUDE.md | 2 +- .../src/confidential/circuits/CLAUDE.md | 4 +- .../circuits/clawback/src/main.nr | 72 +++++--- .../circuits/clawback/src/tests.nr | 160 ++++++++++++++++-- .../circuits/constraints.baseline | 2 +- .../circuits/vks/clawback.vk.json | 2 +- .../tokens/src/confidential/compliance/mod.rs | 23 ++- .../src/confidential/compliance/storage.rs | 71 ++++++-- .../src/confidential/compliance/test.rs | 137 +++++++++++++-- .../src/confidential/docs/COMPLIANCE.md | 39 +++-- .../src/confidential/docs/DESIGN_cont.md | 6 +- packages/tokens/src/confidential/docs/SDK.md | 2 +- 12 files changed, 436 insertions(+), 84 deletions(-) diff --git a/packages/tokens/src/confidential/CLAUDE.md b/packages/tokens/src/confidential/CLAUDE.md index 3104b3b5f..03ca2e064 100644 --- a/packages/tokens/src/confidential/CLAUDE.md +++ b/packages/tokens/src/confidential/CLAUDE.md @@ -39,7 +39,7 @@ Beyond the root guide's conventions: - Proof verification is mocked. `MockVerifier` / `MockAuditor` in `test.rs` stand in for the real contracts, and proofs are empty `Bytes::new(e)`. Do not attempt to generate real proofs in Rust tests. - Use `fixture_point` / `fixture_field`, not arbitrary bytes. The fixtures are canonical and on-curve; random values fail the canonicality guards before reaching any logic under test. -- One mock models proof semantics on purpose: the register mock binds the first `acct_f` it sees, standing in for UltraHonk's absorption of public inputs, so replay tests are meaningful. Keep that behaviour if the register flow changes. +- Two mocks model proof semantics on purpose: the register mock in `test.rs` binds the first `acct_f` it sees, and `ClawbackReplayGuardVerifier` in `compliance/test.rs` binds the first whole blob, both standing in for UltraHonk's absorption of public inputs, so replay tests are meaningful. Keep that behaviour if either flow changes. ## The documentation set diff --git a/packages/tokens/src/confidential/circuits/CLAUDE.md b/packages/tokens/src/confidential/circuits/CLAUDE.md index 303608311..e4241aaab 100644 --- a/packages/tokens/src/confidential/circuits/CLAUDE.md +++ b/packages/tokens/src/confidential/circuits/CLAUDE.md @@ -26,7 +26,7 @@ It appears in all fourteen package `Nargo.toml` files — the workspace manifest ### Do not prune unused public inputs -`_acct_f` in `register/src/main.nr` is referenced by no gate and looks like dead code. It is the replay binding: UltraHonk absorbs every public input into the transcript, so a proof produced for one account fails when the contract assembles the blob for another. Removing it lets anyone replay a legitimate registration's published proof and payload to mint duplicate-key accounts. `clawback/src/main.nr` carries three such bindings — `addr_f`, `_acct_f`, `_dest_f` — and removing `_dest_f` lets a compromised clawback signer settle a witness to a destination of its own choosing. Each operation circuit declares its exact public-input count in a header comment — clawback 8, withdraw 16, transfer / spender_transfer 25, set_spender 26 — and the count is part of the contract with the on-chain assembler. +`_acct_f` in `register/src/main.nr` is referenced by no gate and looks like dead code. It is the replay binding: UltraHonk absorbs every public input into the transcript, so a proof produced for one account fails when the contract assembles the blob for another. Removing it lets anyone replay a legitimate registration's published proof and payload to mint duplicate-key accounts. `clawback/src/main.nr` carries four such bindings — `addr_f`, `_acct_f`, `_dest_f`, `_nonce` — removing `_dest_f` lets a compromised clawback signer settle a witness to a destination of its own choosing, and removing `_nonce` lets an approved proof execute again once the commitments are restored. Each operation circuit declares its exact public-input count in a header comment — clawback 11, withdraw 16, transfer / spender_transfer 25, set_spender 26 — and the count is part of the contract with the on-chain assembler. ### Package names are load-bearing @@ -60,7 +60,7 @@ LC_ALL=C nargo info | grep '^|' | LC_ALL=C sort > constraints.baseline `LC_ALL=C` is mandatory on **both** sides of the pipe — byte order is the only ordering stable between macOS and the Ubuntu runner. The redirect overwrites the file's header comments; re-paste them, because CI's failure message asks for them. -Two non-obvious consequences: adding or removing a **gadget** changes the baseline even when no circuit logic changed, and the ACIR opcode counts are quoted in prose at `../docs/DESIGN_cont.md` §10.3 (Register 33, Clawback 51, Withdraw 95, Transfer 134, SetSpender 135, SpenderTransfer 136). Nothing enforces that second copy — update it in the same PR. +Two non-obvious consequences: adding or removing a **gadget** changes the baseline even when no circuit logic changed, and the ACIR opcode counts are quoted in prose at `../docs/DESIGN_cont.md` §10.3 (Register 33, Clawback 64, Withdraw 95, Transfer 134, SetSpender 135, SpenderTransfer 136). Nothing enforces that second copy — update it in the same PR. ### `vks/` diff --git a/packages/tokens/src/confidential/circuits/clawback/src/main.nr b/packages/tokens/src/confidential/circuits/clawback/src/main.nr index 2f1f6e6b9..db6b4d9b0 100644 --- a/packages/tokens/src/confidential/circuits/clawback/src/main.nr +++ b/packages/tokens/src/confidential/circuits/clawback/src/main.nr @@ -1,21 +1,29 @@ -use stellar_confidential_lib::commit; +use stellar_confidential_lib::{commit, scalar_mul, H}; mod tests; // Clawback circuit -- COMPLIANCE.md Section 5.3. // // Proves that a compliance seizure of the public amount `alpha` is bounded by -// what the target actually holds, without revealing either balance. It is the -// only circuit in the system with no key-ownership constraint and no `r_e`: -// both openings are pinned by Pedersen binding directly. +// what the target actually holds, without revealing either balance, and that +// the prover is the auditor the target's account is bound to. It is the only +// circuit in the system with no `r_e`, and the only one whose key-ownership +// constraint is over an auditor key rather than an owner's. // // Constraints // ----------- -// CB1 C_spend = v_s * G + r_s * H Prover knows the opening of the +// CB1 K_aud = k_aud * H Auditor key ownership. The +// openings alone are not a secret: +// registration, deposits, and +// merges leave them public, so +// without CB1 a clawback signer +// could prove a seizure of any +// such account by itself. +// CB2 C_spend = v_s * G + r_s * H Prover knows the opening of the // target's spendable commitment. -// CB2 C_receive = v_r * G + r_r * H Prover knows the opening of the +// CB3 C_receive = v_r * G + r_r * H Prover knows the opening of the // target's receiving commitment. -// CB3 v_s, v_r, alpha, and +// CB4 v_s, v_r, alpha, and // v_s + v_r - alpha in [0, 2^127) The seize bound (Section 2.6). // The final term establishes // alpha <= v_s + v_r. @@ -30,40 +38,50 @@ mod tests; // // Point-validation // ---------------------------------------- -// C_spend and C_receive are the only points, and both are bound to in-circuit -// multi_scalar_mul outputs (CB1, CB2), so they are on-curve by construction. +// K_aud, C_spend and C_receive are the only points, and all three are bound to +// in-circuit multi_scalar_mul outputs (CB1, CB2, CB3), so they are on-curve by +// construction. // -// Public inputs (8 fields, in COMPLIANCE.md Section 5.3 canonical order) -// ---------------------------------------------------------------------- +// Public inputs (11 fields, in COMPLIANCE.md Section 5.3 canonical order) +// ----------------------------------------------------------------------- // Idx Param Symbol Source / Note // --- ----- ------ ---------------------------------------- // 0 c_spend_x C_spend.x Loaded from the target's // 1 c_spend_y C_spend.y `spendable_commitment`. // 2 c_receive_x C_receive.x Loaded from the target's // 3 c_receive_y C_receive.y `receiving_commitment`. -// 4 alpha alpha Public seize amount, from the invocation. +// 4 k_aud_x K_aud.x Fetched from the auditor registry under +// 5 k_aud_y K_aud.y the target's `auditor_id`. +// 6 alpha alpha Public seize amount, from the invocation. // The contract pre-checks alpha > 0 before -// assembling the blob; CB3 closes +// assembling the blob; CB4 closes // alpha < 2^127 in-circuit. -// 5 addr_f addr_f Loaded from instance storage. -// 6 _acct_f acct_f `address_to_field(account)`, computed +// 7 addr_f addr_f Loaded from instance storage. +// 8 _acct_f acct_f `address_to_field(account)`, computed // per-call. -// 7 _dest_f dest_f `address_to_field(destination)` under +// 9 _dest_f dest_f `address_to_field(destination)` under // `Some`, the zero field under `None`; // computed per-call. +// 10 _nonce nonce The target's clawback nonce, loaded from +// storage; the contract increments it on +// every seizure (Section 5.4). // -// `addr_f`, `_acct_f`, and `_dest_f` are referenced by no gate and look like -// dead parameters: their presence in the public-input set is the binding, on -// the `register/_acct_f` precedent. +// `addr_f`, `_acct_f`, `_dest_f`, and `_nonce` are referenced by no gate and +// look like dead parameters: their presence in the public-input set is the +// binding, on the `register/_acct_f` precedent. `_nonce` is what makes a proof +// single-use: the commitments alone can be restored to an earlier value +// (Section 5.6). // // Private witnesses // ----------------- +// k_aud Auditor secret scalar for the target's `auditor_id`. // v_s Plaintext spendable-balance value. // r_s Plaintext blinding factor for C_spend. // v_r Plaintext receiving-balance value. // r_r Plaintext blinding factor for C_receive. fn main( + k_aud: Field, v_s: Field, r_s: Field, v_r: Field, @@ -72,26 +90,34 @@ fn main( c_spend_y: pub Field, c_receive_x: pub Field, c_receive_y: pub Field, + k_aud_x: pub Field, + k_aud_y: pub Field, alpha: pub Field, addr_f: pub Field, _acct_f: pub Field, _dest_f: pub Field, + _nonce: pub Field, ) { - // addr_f is a binding-only input like _acct_f and _dest_f; naming it - // without a leading underscore would warn, so consume it explicitly. + // addr_f is a binding-only input like _acct_f, _dest_f, and _nonce; naming + // it without a leading underscore would warn, so consume it explicitly. let _ = addr_f; // CB1 + let k_aud_derived = scalar_mul(k_aud, H); + assert(k_aud_derived.x == k_aud_x); + assert(k_aud_derived.y == k_aud_y); + + // CB2 let c_spend_derived = commit(v_s, r_s); assert(c_spend_derived.x == c_spend_x); assert(c_spend_derived.y == c_spend_y); - // CB2 + // CB3 let c_receive_derived = commit(v_r, r_r); assert(c_receive_derived.x == c_receive_x); assert(c_receive_derived.y == c_receive_y); - // CB3 -- Section 2.6's 127-bit decomposition / recomposition pattern. + // CB4 -- Section 2.6's 127-bit decomposition / recomposition pattern. // Range on v_s and v_r alone does not bound v_s + v_r - alpha. v_s.assert_max_bit_size::<127>(); v_r.assert_max_bit_size::<127>(); diff --git a/packages/tokens/src/confidential/circuits/clawback/src/tests.nr b/packages/tokens/src/confidential/circuits/clawback/src/tests.nr index a7cd65b73..70aa85d0a 100644 --- a/packages/tokens/src/confidential/circuits/clawback/src/tests.nr +++ b/packages/tokens/src/confidential/circuits/clawback/src/tests.nr @@ -1,5 +1,5 @@ use crate::main; -use stellar_confidential_lib::commit; +use stellar_confidential_lib::{commit, scalar_mul, H}; // Canonical fixture inputs. // @@ -7,7 +7,10 @@ use stellar_confidential_lib::commit; // is derivable from -- and pinned by -- the lib's `fixtures_match_testdata` // test. V_R / R_R and ALPHA are local to this circuit. ALPHA = 700 exceeds // V_R alone, so the happy path exercises a seize that necessarily draws on -// both commitments rather than one. +// both commitments rather than one. K_AUD_SCALAR is the same placeholder +// auditor secret the withdraw fixtures use, so K_AUD is the withdraw +// `K_AUD_S` point. +global K_AUD_SCALAR: Field = 0xc0ffee; global V_S: Field = 1000; global R_S: Field = 42; global V_R: Field = 500; @@ -17,6 +20,7 @@ global ALPHA: Field = 700; global ADDR_F: Field = 0xbeef; global ACCT_F: Field = 0xfeed; global DEST_F: Field = 0xcafe; +global NONCE: Field = 3; // C_spend = commit(1000, 42) (= lib `commit` fixture). global C_SPEND_X: Field = 0x195a5d8ecd032fe1696054b28f0852b5f03a613681991ace823245ab2f97ed05; @@ -26,16 +30,26 @@ global C_SPEND_Y: Field = 0x0e67489ecfee0e581dce7db22a383c635886cfb616c3b3bb033b global C_RECEIVE_X: Field = 0x13399238a890131dbbe17b7e1d4bf333057906c812fc4c718b2c6efad3e97a08; global C_RECEIVE_Y: Field = 0x2f5dc4cdf9796114e546848c941e7f7fc09ea5dbf2542ea242005dccffb6b7e8; +// K_aud = K_AUD_SCALAR * H. Pinned by `clawback_fixtures_match_lib`. +global K_AUD_X: Field = 0x22502c7b20b64aeaaaa4d4fa5f2f8600f9734e828f7284a8d1431da36b33803a; +global K_AUD_Y: Field = 0x0419089353d24334f03f4a1c9c9b19282e32f6879c5c69b9cdffba6a13a7c5ed; + #[test] fn print_fixtures() { - // One-shot harness: prints C_receive for the chosen fixture tuple. Run - // with `nargo test --package circuit_clawback print_fixtures --show-output` + // One-shot harness: prints C_receive and K_aud for the chosen fixture + // tuple. Run with + // `nargo test --package circuit_clawback print_fixtures --show-output` // and paste the values into the globals above. let c_receive = commit(V_R, R_R); let rx = c_receive.x; let ry = c_receive.y; println(f"C_RECEIVE_X = {rx}"); println(f"C_RECEIVE_Y = {ry}"); + let k_aud = scalar_mul(K_AUD_SCALAR, H); + let kx = k_aud.x; + let ky = k_aud.y; + println(f"K_AUD_X = {kx}"); + println(f"K_AUD_Y = {ky}"); } #[test] @@ -44,15 +58,19 @@ fn clawback_fixtures_match_lib() { // rather than as a less informative failure deep inside `main`. let c_spend = commit(V_S, R_S); let c_receive = commit(V_R, R_R); + let k_aud = scalar_mul(K_AUD_SCALAR, H); assert(c_spend.x == C_SPEND_X); assert(c_spend.y == C_SPEND_Y); assert(c_receive.x == C_RECEIVE_X); assert(c_receive.y == C_RECEIVE_Y); + assert(k_aud.x == K_AUD_X); + assert(k_aud.y == K_AUD_Y); } #[test] fn matches_fixture() { main( + K_AUD_SCALAR, V_S, R_S, V_R, @@ -61,10 +79,13 @@ fn matches_fixture() { C_SPEND_Y, C_RECEIVE_X, C_RECEIVE_Y, + K_AUD_X, + K_AUD_Y, ALPHA, ADDR_F, ACCT_F, DEST_F, + NONCE, ); } @@ -77,13 +98,57 @@ fn empty_receiving_side_is_the_identity() { assert(identity.x == 0); assert(identity.y == 0); - main(V_S, R_S, 0, 0, C_SPEND_X, C_SPEND_Y, 0, 0, ALPHA, ADDR_F, ACCT_F, DEST_F); + main( + K_AUD_SCALAR, + V_S, + R_S, + 0, + 0, + C_SPEND_X, + C_SPEND_Y, + 0, + 0, + K_AUD_X, + K_AUD_Y, + ALPHA, + ADDR_F, + ACCT_F, + DEST_F, + NONCE, + ); +} + +#[test] +fn public_openings_verify_under_the_auditor_key() { + // An account whose history is register + deposit has C_spend = O and + // C_receive = a * G, both opened with zero blinding. The auditor can + // still seize it: CB1 is the only constraint that needs a secret. + let c_receive = commit(100, 0); + main( + K_AUD_SCALAR, + 0, + 0, + 100, + 0, + 0, + 0, + c_receive.x, + c_receive.y, + K_AUD_X, + K_AUD_Y, + 100, + ADDR_F, + ACCT_F, + DEST_F, + NONCE, + ); } #[test] fn seize_of_the_entire_claim_is_admissible() { // alpha == v_s + v_r leaves the account at zero, which is in range. main( + K_AUD_SCALAR, V_S, R_S, V_R, @@ -92,10 +157,13 @@ fn seize_of_the_entire_claim_is_admissible() { C_SPEND_Y, C_RECEIVE_X, C_RECEIVE_Y, + K_AUD_X, + K_AUD_Y, V_S + V_R, ADDR_F, ACCT_F, DEST_F, + NONCE, ); } @@ -105,6 +173,7 @@ fn none_destination_sentinel_verifies() { // the circuit distinguishes it -- the point of the test is that the // sentinel is a legal public input, not a rejected one. main( + K_AUD_SCALAR, V_S, R_S, V_R, @@ -113,20 +182,75 @@ fn none_destination_sentinel_verifies() { C_SPEND_Y, C_RECEIVE_X, C_RECEIVE_Y, + K_AUD_X, + K_AUD_Y, ALPHA, ADDR_F, ACCT_F, 0, + NONCE, + ); +} + +#[test(should_fail)] +fn public_openings_without_the_auditor_key_are_rejected() { + // The same register + deposit account as + // `public_openings_verify_under_the_auditor_key`, proved by a party that + // reconstructed the public openings but does not hold k_aud. This is the + // clawback signer acting alone; CB1 is what rejects it. + let c_receive = commit(100, 0); + main( + 0xbad, + 0, + 0, + 100, + 0, + 0, + 0, + c_receive.x, + c_receive.y, + K_AUD_X, + K_AUD_Y, + 100, + ADDR_F, + ACCT_F, + DEST_F, + NONCE, + ); +} + +#[test(should_fail)] +fn wrong_auditor_key_is_rejected() { + // A prover holding both openings but a different auditor secret fails + // CB1: the witness is bound to the auditor the account registered under. + main( + K_AUD_SCALAR + 1, + V_S, + R_S, + V_R, + R_R, + C_SPEND_X, + C_SPEND_Y, + C_RECEIVE_X, + C_RECEIVE_Y, + K_AUD_X, + K_AUD_Y, + ALPHA, + ADDR_F, + ACCT_F, + DEST_F, + NONCE, ); } #[test(should_fail)] fn over_seize_is_rejected() { // alpha > v_s + v_r makes v_s + v_r - alpha wrap mod F_r, landing far - // above 2^127. This is CB3's soundness-critical case: without the sum + // above 2^127. This is CB4's soundness-critical case: without the sum // check the seize would produce a commitment the owner can open but can // never spend from again. main( + K_AUD_SCALAR, V_S, R_S, V_R, @@ -135,17 +259,21 @@ fn over_seize_is_rejected() { C_SPEND_Y, C_RECEIVE_X, C_RECEIVE_Y, + K_AUD_X, + K_AUD_Y, V_S + V_R + 1, ADDR_F, ACCT_F, DEST_F, + NONCE, ); } #[test(should_fail)] fn out_of_range_alpha_is_rejected() { - // alpha >= 2^127 fails CB3's own range check before the sum is formed. + // alpha >= 2^127 fails CB4's own range check before the sum is formed. main( + K_AUD_SCALAR, V_S, R_S, V_R, @@ -154,19 +282,22 @@ fn out_of_range_alpha_is_rejected() { C_SPEND_Y, C_RECEIVE_X, C_RECEIVE_Y, + K_AUD_X, + K_AUD_Y, 0x80000000000000000000000000000000, ADDR_F, ACCT_F, DEST_F, + NONCE, ); } #[test(should_fail)] fn wrong_spendable_opening_is_rejected() { - // A witness that does not open the on-chain C_spend fails CB1. This is - // what stops the compliance admin -- which holds no blinding -- from - // producing a witness of its own. + // A witness that does not open the on-chain C_spend fails CB2. This is + // what keeps the seize bounded by what the account actually holds. main( + K_AUD_SCALAR, V_S + 1, R_S, V_R, @@ -175,18 +306,22 @@ fn wrong_spendable_opening_is_rejected() { C_SPEND_Y, C_RECEIVE_X, C_RECEIVE_Y, + K_AUD_X, + K_AUD_Y, ALPHA, ADDR_F, ACCT_F, DEST_F, + NONCE, ); } #[test(should_fail)] fn wrong_receiving_opening_is_rejected() { - // Same for CB2: an inflated v_r would otherwise let a prover claim a + // Same for CB3: an inflated v_r would otherwise let a prover claim a // larger seizable total than the account holds. main( + K_AUD_SCALAR, V_S, R_S, V_R + 1, @@ -195,9 +330,12 @@ fn wrong_receiving_opening_is_rejected() { C_SPEND_Y, C_RECEIVE_X, C_RECEIVE_Y, + K_AUD_X, + K_AUD_Y, ALPHA, ADDR_F, ACCT_F, DEST_F, + NONCE, ); } diff --git a/packages/tokens/src/confidential/circuits/constraints.baseline b/packages/tokens/src/confidential/circuits/constraints.baseline index 4a6d98cae..e83702d48 100644 --- a/packages/tokens/src/confidential/circuits/constraints.baseline +++ b/packages/tokens/src/confidential/circuits/constraints.baseline @@ -20,7 +20,7 @@ | Package | Function | Expression Width | ACIR Opcodes | Brillig Opcodes | | circuit_clawback | decompose_hint | N/A | N/A | 30 | | circuit_clawback | lte_hint | N/A | N/A | 33 | -| circuit_clawback | main | Bounded { width: 4 } | 51 | 63 | +| circuit_clawback | main | Bounded { width: 4 } | 64 | 63 | | circuit_register | decompose_hint | N/A | N/A | 30 | | circuit_register | directive_invert | N/A | N/A | 9 | | circuit_register | lte_hint | N/A | N/A | 33 | diff --git a/packages/tokens/src/confidential/circuits/vks/clawback.vk.json b/packages/tokens/src/confidential/circuits/vks/clawback.vk.json index 3d1b33ae9..f9c9780ca 100644 --- a/packages/tokens/src/confidential/circuits/vks/clawback.vk.json +++ b/packages/tokens/src/confidential/circuits/vks/clawback.vk.json @@ -1 +1 @@ -["0x0000000000000000000000000000000000000000000000000000000000008000","0x0000000000000000000000000000000000000000000000000000000000000018","0x0000000000000000000000000000000000000000000000000000000000000001","0x0000000000000000000000000000000000000000000000000000000000000008","0x0000000000000000000000000000003e29e35fa10d1374ee392d84015d55146f","0x00000000000000000000000000000000001aa499cf6c3c66d45cd37fc096dba0","0x000000000000000000000000000000e76665815609031e4aef4b456e5a69e81b","0x00000000000000000000000000000000000275c73958f82ba5d3605894738aee","0x0000000000000000000000000000007e272a70bbb936fdd4f8cce42a4fbb1414","0x000000000000000000000000000000000002c85229ff29f3f8dead1204da9097","0x00000000000000000000000000000017c1ef152e3a105b96bfbcdb3072cca45f","0x000000000000000000000000000000000022710699446a710ca9b4c2ef9d6872","0x0000000000000000000000000000000f0380611fed80283c6556f5ab3c1376f9","0x000000000000000000000000000000000020f6a0cee0c5044f9e331b0d1ec278","0x000000000000000000000000000000cc51082c78adb5636fb36ed6648a9334b5","0x00000000000000000000000000000000001f083692f4552918229d0976f3bdc0","0x0000000000000000000000000000000e023b951ec6139c58a52454903248aa63","0x0000000000000000000000000000000000029558e854db74dcf911661dac1ad0","0x000000000000000000000000000000b412d561055e7b0ff0f640b06bdae6edc9","0x000000000000000000000000000000000024567c57baa70dfd813136f398c174","0x000000000000000000000000000000f850f4dd17afeadb66f7d5af2098a067ae","0x00000000000000000000000000000000000a5fb82a741c2e85ec02218d7d8c6e","0x000000000000000000000000000000bf13d957cf9d3528432c9ce697fb6d2dde","0x00000000000000000000000000000000002b54eaa1f515448224b99a014a9fd7","0x000000000000000000000000000000328b2b2e721066afc8c65415579291cec6","0x00000000000000000000000000000000001551cd1cbfb9c65cbc6eead6511776","0x000000000000000000000000000000e4da569a33192b0f34a60e639388af1949","0x00000000000000000000000000000000002720b1e293b455df30130dcb761d96","0x0000000000000000000000000000000d515847713bc8321831a2011e963168e1","0x00000000000000000000000000000000002495fd85225afe88486bb32f391098","0x000000000000000000000000000000c4b0dca57e3054a5893a52fe82ed4c93a2","0x00000000000000000000000000000000001c03460a61021bafdf445365a24df2","0x000000000000000000000000000000fcd88c79df4143bf1e2ef7330b754715a0","0x0000000000000000000000000000000000039e73207196125e19f984267e6f9f","0x0000000000000000000000000000003b1a38b3e3cbef9b9178ad9190c30b3305","0x0000000000000000000000000000000000031be7550873e5c0d5da000782245e","0x000000000000000000000000000000fd471a9be2dd684881221caf4e3b032485","0x00000000000000000000000000000000002356514cfea29ef16d326f72f3cbca","0x000000000000000000000000000000b309f2a708d312c2144dc3ed2caed7f896","0x00000000000000000000000000000000000d7654fd948210647749189d77b08d","0x000000000000000000000000000000ac02cfeb0524a06ab88c4ccbe8ff9d4969","0x00000000000000000000000000000000000aac8197ec9a97a8a9e1c2423f3d4a","0x00000000000000000000000000000082bb2aadf7e3c3bc6b19f09ef980368226","0x000000000000000000000000000000000013ed6a9e3f475f83e7a1a6387efcba","0x0000000000000000000000000000002ddb0e371241a4e1949b9d10c3462de0d6","0x00000000000000000000000000000000002aeeb919361d025ee0cb2c3abacc70","0x0000000000000000000000000000000bf1e340a4e1f229af8a97aa284de78b95","0x000000000000000000000000000000000012f1eb993e6d434ee173893e06ce96","0x000000000000000000000000000000a330fe60e3bbf2f5b5a01dae93f332b4d8","0x0000000000000000000000000000000000006c08e445017084fd8a0b46e9b27d","0x000000000000000000000000000000479765744601fac59a58772fd8a16ccf44","0x00000000000000000000000000000000002f431136c5f545a247c7a54f116e4b","0x00000000000000000000000000000091bf2d311966e13423265736ce919b13eb","0x0000000000000000000000000000000000196696384a5f06ee7173acf7a697e8","0x00000000000000000000000000000045f2fb453e2cbde7c3763ba3a41d38c16f","0x0000000000000000000000000000000000103a777e3faace3c31cf1927576171","0x0000000000000000000000000000007ee098d6ddc852108fb4261b35ed31e8d9","0x00000000000000000000000000000000000bb2c5e278cf60370c4619232c78db","0x000000000000000000000000000000d85502b4d88c5589a84c4c93dc4706d3be","0x000000000000000000000000000000000002b27ffbbf09ce26ab3ef89b63aa97","0x000000000000000000000000000000691d8fd24730d11e8c4c672921ca86c1ce","0x00000000000000000000000000000000001597533e7f45d0d9272f911f6cbe8e","0x000000000000000000000000000000ac21d466f8422e1faa4f4f4e749a814651","0x000000000000000000000000000000000007a4cd311030c8e81b55cc5d1e2dde","0x000000000000000000000000000000fb6cd0af81e46eec9948fb3157bf71ffaa","0x000000000000000000000000000000000001f98d476af8dd99cb473e1eeb1ae6","0x000000000000000000000000000000abbf216bc97645fbe37a38cf60e8d76239","0x00000000000000000000000000000000001d0a487ad1bb296bbea426a8c48dd6","0x0000000000000000000000000000003883ddd61bd888c488f77fa3b3a9ae6beb","0x00000000000000000000000000000000000997dc425cf28e1d0f043aefa0d516","0x000000000000000000000000000000d5e78350147140e21fcadaa488b9c8ea8c","0x00000000000000000000000000000000002037861df990935fcd0e814b1bf739","0x0000000000000000000000000000004ecaa8998f76b6a4c09931fac7d6bc6749","0x000000000000000000000000000000000000687cbfcc8894c68f64a7cadeff9e","0x000000000000000000000000000000b6943ac8fa6ca1fd68b1a515dcdbb098c8","0x00000000000000000000000000000000000eff396de321039968df4870793740","0x000000000000000000000000000000da259e3cacabd225d773fbdef73a932041","0x000000000000000000000000000000000003f707759d8897bebaac9e6735f8ac","0x0000000000000000000000000000005fb25786c9a783258a264f0faff5f9982f","0x0000000000000000000000000000000000137e46ca8d6e2410f6bcb7cfe0d384","0x000000000000000000000000000000f5461779b573530e6bd7707fdc988113ad","0x00000000000000000000000000000000000da742eaed8a069b8c6b3c2a2880e2","0x0000000000000000000000000000005dea508ac832b3f8c7b4409d431107c69d","0x00000000000000000000000000000000001dc51b8af23809bad551c8653cd85f","0x0000000000000000000000000000002cde78913467c67dade61b479c25eb03d4","0x00000000000000000000000000000000002c9abdbb053781a89175609efbcaf7","0x00000000000000000000000000000056171db0722852ba449ca7ab03759ea4c0","0x000000000000000000000000000000000008b7075ae38656f84b2952dd8a7b7f","0x00000000000000000000000000000062eab404e33638dc79b321c47387e79866","0x00000000000000000000000000000000000eb3281c7badbbe070ac0a85228b10","0x000000000000000000000000000000b104d0d85d2c474f75ce151e18af9feba4","0x00000000000000000000000000000000001794fce82e9bd15cab7bc32fc7227d","0x000000000000000000000000000000de57d1ae8a024e3b4e698c5b4f0d88da43","0x00000000000000000000000000000000000635441a35af7980ad297c1cebbac4","0x00000000000000000000000000000036631fd011a8c26dac05c4c18beb459e5e","0x0000000000000000000000000000000000211ab89ee5f6206261ed14c7003ad7","0x00000000000000000000000000000085ddbb5359d7299988b147431fd326c5de","0x000000000000000000000000000000000020e1e230152899ddb07c71ab6a5beb","0x0000000000000000000000000000000712ccf2921e57da19c9fca055bece5704","0x000000000000000000000000000000000029ffcc382cfcfbed7748bb5a391a3e","0x0000000000000000000000000000008964c2abb496834356ab11fe15d7f5ef05","0x000000000000000000000000000000000020df9f5bfe49004af6581c4866afab","0x00000000000000000000000000000027787ea9ee7c33dfa37b6d6f4b77abb7d7","0x00000000000000000000000000000000001efd7dca7c8e6f4f6d73ccd7b42008","0x0000000000000000000000000000000000000000000000000000000000000001","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000002","0x0000000000000000000000000000000000000000000000000000000000000000","0x000000000000000000000000000000fce5f6e4c22ec9f684c4750f74e4c5f4e6","0x000000000000000000000000000000000028d57f17c825507ce364c1b8c6e27e","0x00000000000000000000000000000061e59e023cb3a2310baa326a86430956e2","0x00000000000000000000000000000000001286fdc058c2d00c990e76a17c96b2"] \ No newline at end of file +["0x0000000000000000000000000000000000000000000000000000000000008000","0x000000000000000000000000000000000000000000000000000000000000001b","0x0000000000000000000000000000000000000000000000000000000000000001","0x000000000000000000000000000000000000000000000000000000000000000b","0x000000000000000000000000000000a830c1c14552bba8341989d058dde549c3","0x0000000000000000000000000000000000049b63a8146c611b63ae4c8ff7cbba","0x0000000000000000000000000000006c4f4af370f99bafae0816ce88c633ab62","0x000000000000000000000000000000000026d68799be672e3b0a50f8c6e9f534","0x000000000000000000000000000000d26c4165ccc183148d05524649964116d3","0x0000000000000000000000000000000000007862d45dff827373579dc0028a86","0x0000000000000000000000000000004b36360bf68260c124ece039ab1281d347","0x000000000000000000000000000000000011e066676a777551e86768da98d28d","0x000000000000000000000000000000650647acdb9319effbce6ff1666e6c3159","0x000000000000000000000000000000000026a7bb39fe76a19e52667572bdcf9d","0x000000000000000000000000000000fa853a0137cf88e5650023675497f292b1","0x00000000000000000000000000000000002e6524877dd2a7bd07476e5c4efe98","0x00000000000000000000000000000047556ae230ef62ccd4df33d50fe88e9f82","0x00000000000000000000000000000000000368a54a6fdc418b9abafaadce0bef","0x000000000000000000000000000000ae6c86d535ebbb997fdbb8036f2ff2c359","0x0000000000000000000000000000000000087afca608730102d2914bf49693d3","0x0000000000000000000000000000005650a3cc44a43d85b88231e8505057eb81","0x00000000000000000000000000000000002297418c2f946b535eec0eb14b9489","0x000000000000000000000000000000a745954c22e57e31d560ade206d380cace","0x00000000000000000000000000000000000664b99dfcf39b90879ca0b45f7720","0x00000000000000000000000000000040ad8e9680666ccb1b6f5bf25737fe2b4d","0x00000000000000000000000000000000001c02f2358ac46cfae0f2c5b8ed7695","0x0000000000000000000000000000009b84425ba5f74a00e49714de77b5cd7e60","0x0000000000000000000000000000000000253dd47162c61763370b6d5144d4b0","0x000000000000000000000000000000a0be2f4cb72ba2f31c9b57d1a5b2cc41db","0x00000000000000000000000000000000002a94c0fe2d93933566e72643e9653c","0x000000000000000000000000000000882087d05493afd017b8813e817af63820","0x00000000000000000000000000000000002ea245a5a6c75e8c2e488f3102732c","0x000000000000000000000000000000eff45f8bc604151e0531d72252bb153b6d","0x000000000000000000000000000000000022abfd833a03446c658924ec3a75ac","0x0000000000000000000000000000003dd19a70a9e8aed9c3ec6da6cd142938b3","0x000000000000000000000000000000000001621d1d3182f72a19538e607bc24f","0x000000000000000000000000000000ec9d3cf27869dd2c3a2ae63f48b8acb18c","0x000000000000000000000000000000000015aa9c327dcf18a4c2df425a27a085","0x00000000000000000000000000000019276473d2fa0c20dd9ce7015cffd0fa58","0x0000000000000000000000000000000000237a84f2cc76f50647ef6c993bba58","0x0000000000000000000000000000005fd48b0faaddb4c827dbf4202fd458f51c","0x00000000000000000000000000000000001802f40931f7810af8d04a72bfa4fb","0x0000000000000000000000000000007e9ba9c43872af32d7571cf0bc67f0ac13","0x00000000000000000000000000000000001c789195f0e2b6227eab2fa3258bb5","0x0000000000000000000000000000000ce6217ff4bfde5018e67947ee6c525594","0x0000000000000000000000000000000000172b64e786d31c69e109efbe54eefb","0x000000000000000000000000000000ced4a6ecc399eb8534e4be09acc7307dad","0x00000000000000000000000000000000002316674db571a46ad1674be2da8f21","0x000000000000000000000000000000aca653c7f0c18d0ee7a5bdbb8056fbb218","0x00000000000000000000000000000000002fb5ef9a89e0d65322d330c1bfd84b","0x000000000000000000000000000000e4af6f59112f35826ba64a8fdf5283a98a","0x00000000000000000000000000000000002eee2f7b8d127f14fa5eae92639cba","0x00000000000000000000000000000000c240d384fe0ee1741a783a44e2a6570a","0x000000000000000000000000000000000001c92f03140e750bea3e138d75e9c8","0x00000000000000000000000000000098c06e3aa37aa68cac40030e02a2360dd5","0x00000000000000000000000000000000000b9a454121b6aabee61ebd759023f1","0x000000000000000000000000000000b388c1ec9654b528c8b6d233a8858b3cbd","0x000000000000000000000000000000000017a0290f135974df0e5a7b44b7493f","0x0000000000000000000000000000000565978fdb83637ef0d300f192f35b57e4","0x00000000000000000000000000000000002c65e566de72771b25a76b3f51619b","0x000000000000000000000000000000f55b03c9f6e3c8647a36cf0cd900ef2f9b","0x00000000000000000000000000000000001440efb07d2c86bd35f3009e04a74a","0x000000000000000000000000000000c48d7f36bd17c5760deeba0c8789f5c99f","0x0000000000000000000000000000000000050f15eff16a4debd4db185c1d7996","0x0000000000000000000000000000004d93a8e0b31a1a52a93ade80a6c0473708","0x000000000000000000000000000000000025d7fca565314b8435ac7b6ae883a0","0x0000000000000000000000000000001df013712621d06d53b9a6bd7c28deb854","0x00000000000000000000000000000000002abe3a549f57fcfa7d444c70a735c8","0x000000000000000000000000000000d567edd3726c597eab71accc1e3c9cc36e","0x00000000000000000000000000000000002d90a532568192ffec379c410a444e","0x00000000000000000000000000000026a9bb99a1dbc242c0f580c5ec94f34476","0x000000000000000000000000000000000009b9fffa960808090df2deafdb4442","0x00000000000000000000000000000002450b6665bf0bc1a95a18747019a2979e","0x00000000000000000000000000000000001a8d21d78abee6e48ad2d12c7dc1cb","0x0000000000000000000000000000002e32c39c40b6c2f903fe6992ca0e4e5e37","0x000000000000000000000000000000000007bc87a70717e7d4677e985bbe1a3e","0x000000000000000000000000000000a5284385cc61178877fa2021a8e78cdc49","0x0000000000000000000000000000000000018b2201f0e8c562c20098373b9b62","0x000000000000000000000000000000857026685cb8f4b090692ee424e4afc733","0x000000000000000000000000000000000011621c3aa5260461063c5d7fd420e6","0x000000000000000000000000000000f0701199b58b5e9b69e25d3abd19627fb3","0x000000000000000000000000000000000028671547aa18183f6ee4e6ecf30471","0x000000000000000000000000000000015f5a8481da45912d197f8e5f0c69538a","0x000000000000000000000000000000000015caeac2e9dc0b11aab005bc46b118","0x0000000000000000000000000000000be2ef8d32709f9b825287ff9393c42be6","0x00000000000000000000000000000000002434f9fd88bd8fa6fb18a6dfc03c94","0x0000000000000000000000000000004080ec319a6db76a48e13e31948b2902cf","0x000000000000000000000000000000000023c15ef62fd92be53d26cc83f74f05","0x000000000000000000000000000000f79618f03ec1dd475b291a386b3479e308","0x00000000000000000000000000000000002a9eb8c207cbd0b42c4a4766357eed","0x0000000000000000000000000000002d07227d3a13317f74d682a5a26fc1a68c","0x00000000000000000000000000000000001a50db9c3304a995bb826a540e052e","0x000000000000000000000000000000c4cbf21998756f4197200561b23928fe52","0x000000000000000000000000000000000025e9da624df8f723dc7cc6b9edc879","0x0000000000000000000000000000001d1190bfaaf85b4c70ad460c99a44e5355","0x00000000000000000000000000000000002ffbef27b291f2c6132ce5ccb69c2c","0x0000000000000000000000000000001773b012d61d109cb6e6588c114f11981e","0x00000000000000000000000000000000001de6e1c0e5f3b633ba16fde6b8ce9f","0x000000000000000000000000000000fc5bd83ad9e7d6f11dfb737e10949cc552","0x000000000000000000000000000000000025ea1fa35b68c47ca6d27641abebc1","0x000000000000000000000000000000fc93be420537551e4000b3b4fbe19478d5","0x000000000000000000000000000000000000983abc4fa60d331e40f0ec883988","0x0000000000000000000000000000001b495d644ede89b197d520ed8fc2668e89","0x000000000000000000000000000000000001d8120399b0c88438272529ba9529","0x0000000000000000000000000000000000000000000000000000000000000001","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000002","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000001c65bf19e02d6fe2f49163bdb9794f6666","0x000000000000000000000000000000000020f5ec27ce6dd322c3e82cf6307ca7","0x000000000000000000000000000000617267107169b7e8c53adefa0622cdd733","0x0000000000000000000000000000000000280fab1ce7975b96a19b959442d97d"] \ No newline at end of file diff --git a/packages/tokens/src/confidential/compliance/mod.rs b/packages/tokens/src/confidential/compliance/mod.rs index 652e1fb22..c89c995a2 100644 --- a/packages/tokens/src/confidential/compliance/mod.rs +++ b/packages/tokens/src/confidential/compliance/mod.rs @@ -207,9 +207,13 @@ pub trait ConfidentialClawback: ConfidentialCompliance { /// the same invocation and the pool remains equal to the sum of /// confidential claims. /// - /// `destination` is bound into the proof, so a proof built for one - /// destination cannot be submitted with another. `Some(d)` with `d` equal - /// to this contract's own address is rejected. + /// The proof is producible only by the auditor `account` is bound to, and + /// `destination` is bound into it, so a proof built for one destination + /// cannot be submitted with another. `Some(d)` with `d` equal to this + /// contract's own address is rejected; any other `d` is accepted without + /// passing the freeze, policy, or SAC gates. The account's clawback nonce + /// is bound into it as well and advances on every seizure, so a proof + /// executes at most once. /// /// `account` MUST be frozen: the freeze keeps `C_spend` and `C_receive` /// unchanged between proof construction and submission, which the proof's @@ -284,6 +288,17 @@ pub trait ConfidentialClawback: ConfidentialCompliance { /// [`storage::force_revoke_spender`]. The [`ConfidentialCompliance`] /// trait-level docstring explains why the method has no default body. fn force_revoke_spender(e: &Env, account: Address, spender: Address, operator: Address); + + /// Returns the number of seizures executed against `account`, which the + /// auditor binds into the next clawback proof. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `account` - The confidential account to query. + fn clawback_nonce(e: &Env, account: Address) -> u32 { + storage::clawback_nonce(e, &account) + } } // ################## HOOKS IMPL ################## @@ -446,6 +461,8 @@ pub enum ComplianceError { const DAY_IN_LEDGERS: u32 = 17280; pub const FROZEN_EXTEND_AMOUNT: u32 = 30 * DAY_IN_LEDGERS; pub const FROZEN_TTL_THRESHOLD: u32 = FROZEN_EXTEND_AMOUNT - DAY_IN_LEDGERS; +pub const CLAWBACK_NONCE_EXTEND_AMOUNT: u32 = 30 * DAY_IN_LEDGERS; +pub const CLAWBACK_NONCE_TTL_THRESHOLD: u32 = CLAWBACK_NONCE_EXTEND_AMOUNT - DAY_IN_LEDGERS; // ################## EVENTS ################## diff --git a/packages/tokens/src/confidential/compliance/storage.rs b/packages/tokens/src/confidential/compliance/storage.rs index 98a176951..b7db94519 100644 --- a/packages/tokens/src/confidential/compliance/storage.rs +++ b/packages/tokens/src/confidential/compliance/storage.rs @@ -2,14 +2,16 @@ use soroban_sdk::{contracttype, panic_with_error, token, Address, Bytes, BytesN, use stellar_contract_utils::crypto::grumpkin::Grumpkin; use crate::confidential::{ + auditor::ConfidentialAuditorClient, compliance::{ emit_clawback, emit_compliance_config_changed, emit_frozen, emit_unfrozen, ComplianceError, - PolicyClient, FROZEN_EXTEND_AMOUNT, FROZEN_TTL_THRESHOLD, + PolicyClient, CLAWBACK_NONCE_EXTEND_AMOUNT, CLAWBACK_NONCE_TTL_THRESHOLD, + FROZEN_EXTEND_AMOUNT, FROZEN_TTL_THRESHOLD, }, storage::{ address_to_field, append_amount, append_field, append_point, get_account, - get_address_as_field_element, get_underlying_asset, revoke_spender, set_commitments, - verify, + get_address_as_field_element, get_auditor, get_underlying_asset, revoke_spender, + set_commitments, verify, }, verifier::CircuitType, }; @@ -51,6 +53,9 @@ pub enum ComplianceStorageKey { /// Per-account frozen flag. Persistent storage; only set when an account /// is frozen and removed on unfreeze. Frozen(Address), + /// Number of seizures executed against the account. Persistent storage; + /// absent until the first seizure. + ClawbackNonce(Address), } // ################## QUERY STATE ################## @@ -87,6 +92,28 @@ pub fn is_frozen(e: &Env, account: &Address) -> bool { } } +/// Returns the number of seizures executed against `account`, or `0` when it +/// has never been seized from. The value is a public input of the next +/// clawback proof (COMPLIANCE §5.3). +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `account` - The address to query. +pub fn clawback_nonce(e: &Env, account: &Address) -> u32 { + let key = ComplianceStorageKey::ClawbackNonce(account.clone()); + if let Some(nonce) = e.storage().persistent().get::<_, u32>(&key) { + e.storage().persistent().extend_ttl( + &key, + CLAWBACK_NONCE_TTL_THRESHOLD, + CLAWBACK_NONCE_EXTEND_AMOUNT, + ); + nonce + } else { + 0 + } +} + // ################## CHANGE STATE ################## /// Writes `config` into instance storage, overwriting any prior value. The @@ -190,10 +217,11 @@ pub fn unfreeze(e: &Env, account: &Address) { /// corresponding underlying according to `destination`. /// /// The proof establishes what the contract cannot check against committed -/// balances: that the prover knows the Pedersen openings of `C_spend` and -/// `C_receive` (CB1, CB2), and that `amount <= v_spend + v_receive` (CB3). -/// The witness is producible by anyone holding the openings — the auditor, or -/// the owner — and not by the admin, which holds no blinding. +/// balances: that the prover holds the secret key of the auditor `account` is +/// bound to (CB1), that it knows the Pedersen openings of `C_spend` and +/// `C_receive` (CB2, CB3), and that `amount <= v_spend + v_receive` (CB4). +/// The openings alone are not a secret — registration, deposits, and merges +/// leave them public — so the witness is producible only by the auditor. /// /// The post-verification update is the [`crate::confidential::storage::merge`] /// rule plus a public debit — `C_spend <- C_spend + C_receive - amount * G` @@ -201,6 +229,10 @@ pub fn unfreeze(e: &Env, account: &Address) { /// `(v_s + v_r - amount, r_s + r_r)`, which both the owner and the auditor /// recompute, so the seized account stays spendable. /// +/// The account's clawback nonce is a public input and advances on every +/// seizure, so a proof executes at most once even if the commitments are +/// later restored to the values it was built against (COMPLIANCE §5.6). +/// /// # Arguments /// /// * `e` - Access to the Soroban environment. @@ -217,8 +249,11 @@ pub fn unfreeze(e: &Env, account: &Address) { /// * [`ComplianceError::InvalidClawbackDestination`] - When `destination` is /// `Some` naming this contract's own address. /// * refer to [`crate::confidential::storage::get_account`] errors. +/// * refer to [`crate::confidential::auditor::ConfidentialAuditor::get_key`] +/// errors. /// * [`crate::confidential::ConfidentialTokenError::NonCanonicalEncoding`] - -/// When a stored commitment coordinate is not a canonical `Bn254Fr` value. +/// When a stored commitment or auditor key coordinate is not a canonical +/// `Bn254Fr` value. /// * [`crate::confidential::ConfidentialTokenError::InvalidProof`] - When the /// proof fails verification. /// @@ -235,7 +270,8 @@ pub fn unfreeze(e: &Env, account: &Address) { /// this call. /// /// Under `Some(d)`, exactly `amount` is transferred to `d` in this invocation, -/// so the pool and the sum of claims move together. +/// so the pool and the sum of claims move together. `d` passes no compliance +/// gate (COMPLIANCE §5.4). /// /// # Security Warning /// @@ -260,6 +296,8 @@ pub fn clawback( } let data = get_account(e, account); + let auditor = ConfidentialAuditorClient::new(e, &get_auditor(e)); + let k_aud = auditor.get_key(&data.auditor_id); let addr_f = get_address_as_field_element(e); // Zero is an unambiguous `None` sentinel: `address_to_field` is a // Poseidon2 output. @@ -267,20 +305,24 @@ pub fn clawback( Some(d) => address_to_field(e, d), None => BytesN::from_array(e, &[0u8; 32]), }; + let nonce = clawback_nonce(e, account); // PI order (COMPLIANCE §5.3): - // C_spend, C_receive, alpha, addr_f, acct_f, dest_f + // C_spend, C_receive, K_aud, alpha, addr_f, acct_f, dest_f, nonce // - // `addr_f`, `acct_f` and `dest_f` are referenced by no constraint; their - // membership in the public-input set binds the proof to one contract, one - // account, and one settlement destination, on the `register` precedent. + // `addr_f`, `acct_f`, `dest_f` and `nonce` are referenced by no + // constraint; their membership in the public-input set binds the proof to + // one contract, one account, one settlement destination, and one + // execution, on the `register` precedent. let mut pi = Bytes::new(e); append_point(&mut pi, &data.spendable_commitment); append_point(&mut pi, &data.receiving_commitment); + append_point(&mut pi, &k_aud); append_amount(&mut pi, e, amount); append_field(&mut pi, &addr_f); append_field(&mut pi, &address_to_field(e, account)); append_field(&mut pi, &dest_f); + append_amount(&mut pi, e, i128::from(nonce)); verify(e, CircuitType::Clawback, &pi, proof); @@ -291,6 +333,9 @@ pub fn clawback( &seized, ); set_commitments(e, account, &c_spend_new, &Grumpkin::identity(e)); + e.storage() + .persistent() + .set(&ComplianceStorageKey::ClawbackNonce(account.clone()), &(nonce + 1)); if let Some(d) = destination { let token = token::TokenClient::new(e, &get_underlying_asset(e)); diff --git a/packages/tokens/src/confidential/compliance/test.rs b/packages/tokens/src/confidential/compliance/test.rs index c5185e431..319acfc07 100644 --- a/packages/tokens/src/confidential/compliance/test.rs +++ b/packages/tokens/src/confidential/compliance/test.rs @@ -1,12 +1,13 @@ extern crate std; use soroban_sdk::{ - contract, contractimpl, + contract, contractimpl, symbol_short, testutils::{Address as _, Events}, token::StellarAssetClient, xdr::{AccountFlags, ToXdr}, Address, Bytes, BytesN, Env, }; +use stellar_contract_utils::crypto::grumpkin::Grumpkin; use crate::confidential::{ compliance::{ @@ -155,12 +156,41 @@ impl crate::confidential::verifier::ConfidentialVerifier for MockVerifier { } } +/// Models the clawback proof's binding to its public inputs for replay tests. +/// +/// A real UltraHonk proof absorbs every public input into its transcript, so +/// it verifies only against the blob it was produced for. The first blob this +/// mock accepts becomes that statement; every later verification succeeds +/// only when the contract assembles the identical blob. +#[contract] +struct ClawbackReplayGuardVerifier; + +#[contractimpl(contracttrait)] +impl crate::confidential::verifier::ConfidentialVerifier for ClawbackReplayGuardVerifier { + fn register_verification_key(_e: &Env, _ct: CircuitType, _vk: Bytes, _op: Address) {} + + fn update_verification_key(_e: &Env, _ct: CircuitType, _vk: Bytes, _op: Address) {} + + fn verify_proof(e: &Env, _ct: CircuitType, pi: Bytes, _proof: Bytes) -> bool { + let key = symbol_short!("bound"); + match e.storage().instance().get::<_, Bytes>(&key) { + None => { + e.storage().instance().set(&key, &pi); + true + } + Some(bound) => bound == pi, + } + } +} + #[contract] struct MockAuditor; #[contractimpl(contracttrait)] impl crate::confidential::auditor::ConfidentialAuditor for MockAuditor { - fn register_key(_e: &Env, _auditor_id: u32, _point: BytesN<64>, _operator: Address) {} + fn register_key(e: &Env, auditor_id: u32, point: BytesN<64>, _operator: Address) { + crate::confidential::auditor::storage::register_key(e, auditor_id, &point); + } fn rotate_key(_e: &Env, _auditor_id: u32, _new_point: BytesN<64>, _operator: Address) {} } @@ -176,6 +206,10 @@ struct Harness<'a> { } fn setup<'a>() -> Harness<'a> { + setup_with(|e| e.register(MockVerifier, ())) +} + +fn setup_with<'a>(register_verifier: impl FnOnce(&Env) -> Address) -> Harness<'a> { let e = Env::default(); e.mock_all_auths(); @@ -187,10 +221,17 @@ fn setup<'a>() -> Harness<'a> { sac.issuer().set_flag(AccountFlags::RevocableFlag); let sac_client = StellarAssetClient::new(&e, &sac_addr); - let verifier = e.register(MockVerifier, ()); + let verifier = register_verifier(&e); let auditor = e.register(MockAuditor, ()); - let host = e.register(TokenHost, (sac_addr.clone(), verifier, auditor)); let admin = Address::generate(&e); + // Auditor 0 is the `auditor_id` every account in this suite binds to; + // `clawback` fetches its key for the public-input blob. + crate::confidential::auditor::ConfidentialAuditorClient::new(&e, &auditor).register_key( + &0, + &Grumpkin::generator(&e), + &admin, + ); + let host = e.register(TokenHost, (sac_addr.clone(), verifier, auditor)); Harness { e, host, sac_addr, sac: sac_client, admin } } @@ -852,8 +893,9 @@ fn storage_keys_isolated_from_token_keys() { // // Contract-layer plumbing only. The verifier is mocked, so nothing here // exercises CB1-CB3, the seize bound, or the destination binding -- those are -// circuit-side (`circuits/clawback/src/tests.nr`). Full coverage of the replay -// and redirect cases needs a public-input-binding verifier mock. +// circuit-side (`circuits/clawback/src/tests.nr`). The replay case runs +// against `ClawbackReplayGuardVerifier`, which binds the first public-input +// blob it accepts; the redirect case would need the same treatment. fn clawback_data(e: &Env) -> Bytes { ClawbackData { proof: Bytes::new(e) }.to_xdr(e) @@ -862,7 +904,6 @@ fn clawback_data(e: &Env) -> Bytes { /// Registers `account` with a spendable commitment of `amount * G` and an /// empty receiving side, then freezes it. fn frozen_account_with(h: &Harness, account: &Address, amount: u128) { - use stellar_contract_utils::crypto::grumpkin::Grumpkin; h.e.as_contract(&h.host, || { let identity = Grumpkin::identity(&h.e); let acc = ConfidentialAccount { @@ -892,7 +933,6 @@ fn clawback_none_folds_commitments_and_moves_no_underlying() { // C_spend <- C_spend + O - 40*G, C_receive <- O. h.e.as_contract(&h.host, || { - use stellar_contract_utils::crypto::grumpkin::Grumpkin; let acc = crate::confidential::storage::get_account(&h.e, &alice); let expected = Grumpkin::mul(&h.e, &Grumpkin::generator(&h.e), 60); assert_eq!(acc.spendable_commitment, expected); @@ -920,6 +960,55 @@ fn clawback_some_transfers_exactly_the_seized_amount() { assert_eq!(sac.balance(&h.host), 960); } +#[test] +fn clawback_advances_the_nonce() { + let h = setup(); + let alice = Address::generate(&h.e); + frozen_account_with(&h, &alice, 100); + h.sac.mint(&h.host, &1_000); + + let client = ConfidentialClawbackClient::new(&h.e, &h.host); + assert_eq!(client.clawback_nonce(&alice), 0); + client.clawback(&alice, &40i128, &None, &clawback_data(&h.e), &h.admin); + assert_eq!(client.clawback_nonce(&alice), 1); + client.clawback(&alice, &10i128, &None, &clawback_data(&h.e), &h.admin); + assert_eq!(client.clawback_nonce(&alice), 2); +} + +#[test] +#[should_panic(expected = "Error(Contract, #3506)")] +fn clawback_replay_after_restoring_commitments_panics() { + // The M-01 scenario: seize 20 from (100G, O), unfreeze, credit 20 with + // zero blinding (deposit + merge) so the commitments return to (100G, O), + // refreeze, and resubmit the original proof. Every public input except + // the nonce is byte-identical to the first submission. + let h = setup_with(|e| e.register(ClawbackReplayGuardVerifier, ())); + let alice = Address::generate(&h.e); + frozen_account_with(&h, &alice, 100); + h.sac.mint(&h.host, &1_000); + h.sac.mint(&alice, &20); + + let clawback = ConfidentialClawbackClient::new(&h.e, &h.host); + let compliance = ConfidentialComplianceClient::new(&h.e, &h.host); + let token = ConfidentialTokenClient::new(&h.e, &h.host); + let proof = clawback_data(&h.e); + + clawback.clawback(&alice, &20i128, &None, &proof, &h.admin); + + compliance.unfreeze(&alice, &h.admin); + token.deposit(&alice, &alice, &20); + token.merge(&alice); + compliance.freeze(&alice, &h.admin); + + h.e.as_contract(&h.host, || { + let acc = crate::confidential::storage::get_account(&h.e, &alice); + assert_eq!(acc.spendable_commitment, Grumpkin::mul(&h.e, &Grumpkin::generator(&h.e), 100)); + assert_eq!(acc.receiving_commitment, Grumpkin::identity(&h.e)); + }); + + clawback.clawback(&alice, &20i128, &None, &proof, &h.admin); +} + #[test] #[should_panic(expected = "Error(Contract, #3604)")] fn clawback_unfrozen_panics() { @@ -1002,9 +1091,38 @@ fn clawback_to_self_panics() { ); } +#[test] +#[should_panic(expected = "Error(Contract, #3301)")] +fn clawback_unregistered_auditor_panics() { + // The public-input blob carries the target's auditor key, so an account + // whose `auditor_id` has no registered key cannot be seized. + let h = setup(); + let alice = Address::generate(&h.e); + h.e.as_contract(&h.host, || { + let identity = Grumpkin::identity(&h.e); + let acc = ConfidentialAccount { + spending_public_key: identity.clone(), + viewing_public_key: identity.clone(), + spendable_commitment: Grumpkin::mul(&h.e, &Grumpkin::generator(&h.e), 100), + receiving_commitment: identity, + auditor_id: 7, + }; + h.e.storage().persistent().set(&ConfidentialTokenStorageKey::Account(alice.clone()), &acc); + set_compliance_config(&h.e, &base_config()); + freeze(&h.e, &alice); + }); + + ConfidentialClawbackClient::new(&h.e, &h.host).clawback( + &alice, + &1i128, + &None, + &clawback_data(&h.e), + &h.admin, + ); +} + #[test] fn force_revoke_spender_folds_allowance_and_deletes_delegation() { - use stellar_contract_utils::crypto::grumpkin::Grumpkin; let h = setup(); let alice = Address::generate(&h.e); let spender = Address::generate(&h.e); @@ -1063,7 +1181,6 @@ fn force_revoke_unknown_delegation_panics() { fn register_minimal_account(e: &Env, account: &Address) { // Bypass proof verification: the unregistered-deposit tests only need // `account_exists` to return true for selected addresses. - use stellar_contract_utils::crypto::grumpkin::Grumpkin; use crate::confidential::{ConfidentialAccount, ConfidentialTokenStorageKey}; let identity = Grumpkin::identity(e); diff --git a/packages/tokens/src/confidential/docs/COMPLIANCE.md b/packages/tokens/src/confidential/docs/COMPLIANCE.md index d4d101d8a..ae1a5faeb 100644 --- a/packages/tokens/src/confidential/docs/COMPLIANCE.md +++ b/packages/tokens/src/confidential/docs/COMPLIANCE.md @@ -186,40 +186,43 @@ The contract does not know the targeted account's balance. `C_spend` and `C_rece ### 5.2 Roles and Separation - **Token admin** — the access-control authority on the confidential-token contract (§1.1). Authorizes the freeze and the two seizure entry points; decides *whether* to seize. -- **Witness holder** — whoever holds the Pedersen openings of the target's `C_spend` and `C_receive`. Produces the proof and thereby decides *how much* and *where to*, both being bound into it (§5.3). +- **Auditor** — the holder of the secret key behind the auditor the target's account is bound to (`auditor_id`, `DESIGN.md` §6.1) and of the Pedersen openings of the target's `C_spend` and `C_receive`. Produces the proof and thereby decides *how much* and *where to*, both being bound into it (§5.3). - **Issuer (SAC admin)** — when the base asset is a Stellar Asset Contract, the holder of its standardized admin interface (CAP-0046-06). Extracts the pool surplus a `None` settlement leaves behind (§5.4) and can freeze independently of the token admin via SAC passthrough (§2.2). -In practice the witness comes from the **auditor**; the admin holds no blinding and cannot produce it. The **owner** can derive both openings from `vk` (`DESIGN.md` §5.2), but is the party being seized from and is not expected to cooperate. The auditor holds both openings by the standing capability of `DESIGN_cont.md` §8.1, advanced across `Clawback` per §5.7. One key serves both channels for an account (`DESIGN.md` §6.1), so a deployment that intends to use clawback need only ensure that key's custodian can assemble both halves. +The openings alone do not single out the auditor. An account whose history is registration, public deposits, and merges carries zero blinding on both commitments (`DESIGN.md` §7.2–§7.4), so anyone — the admin included — reconstructs its openings from events. The circuit therefore also binds the witness to the auditor's secret key (CB1, §5.3). The auditor holds both openings by the standing capability of `DESIGN_cont.md` §8.1, advanced across `Clawback` per §5.7. -Neither party can act alone: the admin cannot produce the proof, and the witness holder cannot pass the admin gate. Deployments typically place the seizure authority under a dedicated role, separate from the freeze role (§1.1). +Neither party can act alone: the admin cannot produce the proof, and the auditor cannot pass the admin gate. Deployments typically place the seizure authority under a dedicated role, separate from the freeze role (§1.1). ### 5.3 Circuit -The clawback circuit proves that a public seize amount is bounded by the target's committed total without revealing either balance. It is the only circuit with no key-ownership constraint and no ephemeral scalar: both openings are pinned by Pedersen binding. +The clawback circuit proves that a public seize amount is bounded by the target's committed total without revealing either balance, and that the prover is the target's auditor. It is the only circuit with no ephemeral scalar, and the only one whose key-ownership constraint is over an auditor key rather than an owner's: both openings are pinned by Pedersen binding, and the witness is pinned to the account's `auditor_id`. **Circuit constraints:** | # | Constraint | |:--|:---| -| CB1 | `C_spend = Com(v_s, r_s)` (prover knows the spendable opening) | -| CB2 | `C_receive = Com(v_r, r_r)` (prover knows the receiving opening) | -| CB3 | `v_s, v_r, alpha, v_s + v_r - alpha ∈ [0, 2^127)` (range validity, `DESIGN.md` §2.6) | +| CB1 | `K_aud = k_aud · H` (auditor key ownership) | +| CB2 | `C_spend = Com(v_s, r_s)` (prover knows the spendable opening) | +| CB3 | `C_receive = Com(v_r, r_r)` (prover knows the receiving opening) | +| CB4 | `v_s, v_r, alpha, v_s + v_r - alpha ∈ [0, 2^127)` (range validity, `DESIGN.md` §2.6) | -Range on `v_s` and `v_r` alone does not bound their sum against `alpha`, and an over-seize would drive the committed value negative mod `r` — a commitment the owner can still open but never again satisfy under W4 / T4. +CB1 is what makes the witness an approval rather than a computation: without it, the public openings of a zero-blinding account (§5.2) would let the clawback signer prove — and settle to itself — a seizure of everything such an account holds. Range on `v_s` and `v_r` alone does not bound their sum against `alpha`, and an over-seize would drive the committed value negative mod `r` — a commitment the owner can still open but never again satisfy under W4 / T4. -**Public inputs (8 fields):** +**Public inputs (11 fields):** | Input | Notes | |:---|:---| | `C_spend`, `C_receive` | Loaded from the target's `spendable_commitment` and `receiving_commitment`, in this order | +| `K_aud` | Fetched from the auditor registry under the target's `auditor_id` (`DESIGN_cont.md` §8.3) | | `alpha` | Public seize amount from invocation inputs | | `addr_f` | Loaded from instance storage (`DESIGN.md` §2.7) | | `acct_f` | Binds the proof to the target account | | `dest_f` | `address_to_field(destination)` under `Some`, the zero field under `None` | +| `nonce` | The account's clawback nonce, loaded from storage (§5.4) | -No public input is prover-supplied (`DESIGN.md` §7.1). `addr_f`, `acct_f`, and `dest_f` are referenced by no gate; their membership in the public-input set is the binding, on the `register` / `acct_f` precedent (`DESIGN.md` §7.2). For `dest_f` that binding is what stops a compromised clawback signer from settling a witness built for one destination to an address of its own choosing; its zero sentinel for `None` is unambiguous because `address_to_field` is a Poseidon2 output. +No public input is prover-supplied (`DESIGN.md` §7.1). `addr_f`, `acct_f`, `dest_f`, and `nonce` are referenced by no gate; their membership in the public-input set is the binding, on the `register` / `acct_f` precedent (`DESIGN.md` §7.2). For `dest_f` that binding is what stops a compromised clawback signer from settling a witness built for one destination to an address of its own choosing; its zero sentinel for `None` is unambiguous because `address_to_field` is a Poseidon2 output. For `nonce` it is what makes a proof single-use (§5.6). -**Private witnesses:** `v_s`, `r_s`, `v_r`, `r_r` — the openings of the two commitments. +**Private witnesses:** `k_aud`, `v_s`, `r_s`, `v_r`, `r_r` — the auditor secret and the openings of the two commitments. ### 5.4 Contract Flow @@ -229,10 +232,11 @@ No public input is prover-supplied (`DESIGN.md` §7.1). `addr_f`, `acct_f`, and 2. **Public inputs.** Assembled in the §5.3 order through `append_point` / `append_field` / `append_amount`, so every coordinate and scalar passes the canonicality guards. `data` decodes to `ClawbackData { proof }`. 3. **Verification** against `CircuitType::Clawback`. 4. **State update.** `C_spend <- C_spend + C_receive - alpha·G` and `C_receive <- O`: the `Merge` rule (`DESIGN.md` §7.4) followed by a public debit, with no fresh randomness. The new opening is `(v_s + v_r - alpha, r_s + r_r)`, which the owner and the auditor recompute from the event's `amount` alone, so the seized account stays spendable. Re-randomizing under a prover-chosen blinding instead would leave the owner unable to open its own commitment. -5. **Settlement**, by `destination`: +5. **Nonce.** `ClawbackNonce(account) <- nonce + 1`, where `nonce` is the value step 2 read into the public inputs. The entry is absent until the first seizure and reads as `0`; `clawback_nonce(account)` exposes it (§6). +6. **Settlement**, by `destination`: - `None` — no underlying moves. - - `Some(d)` — exactly `amount` is transferred to `d` in the same invocation. -6. **Event.** `Clawback { account, amount, destination }` (§6.1). + - `Some(d)` — exactly `amount` is transferred to `d` in the same invocation. `d` passes no compliance gate (§2, §3): the auditor chooses it when building the proof (§5.3) and the operator confirms it as an argument. +7. **Event.** `Clawback { account, amount, destination }` (§6.1). **Extraction order.** The seize and the issuer's extraction are separate invocations by different parties, and Soroban admits one per transaction, so the pool sits mismatched for at least a ledger between them. Nothing in the contract enforces which comes first; under a `None` settlement the extraction must follow the seize: @@ -249,7 +253,11 @@ The owner and the auditor fold the event as they would an owner-initiated one (` ### 5.6 Anti-Replay and the Freeze -`C_spend` and `C_receive` are public inputs, so any change to either between proof construction and submission — an inbound transfer, a merge, a revoke — fails verification with `InvalidProof`. The freeze holds the commitments still. `ConfidentialClawback: ConfidentialCompliance` forces a `freeze` / `unfreeze` implementation but constrains nothing about the deployment's `Hooks`. Wiring `NoHooks` next to a `ConfidentialClawback` impl yields a `freeze` that writes the flag and an `is_frozen` that returns `true` while every token operation stays ungated, so the target spends out before the seizure lands and the admin's only signal is an `InvalidProof` once the commitments have moved. A deployment that enables clawback MUST wire `ComplianceHooks`, or a custom `Hooks` impl that gates the same seven positions (§4). +`C_spend` and `C_receive` are public inputs, so any change to either between proof construction and submission — an inbound transfer, a merge, a revoke — fails verification with `InvalidProof`. The freeze holds the commitments still. An auditor key rotation in the same window fails the same way (`DESIGN_cont.md` §8.3). + +Commitment equality is not freshness. A seizure of `alpha` from `(C, O)` leaves `(C - alpha·G, O)`, and a zero-blinding credit of `alpha` restores `(C, O)` exactly — a public deposit followed by a merge once the account is unfrozen, or a `force_revoke_spender` fold of an allowance committed as `Com(alpha, 0)` while it is still frozen — with every other public input unchanged. The `nonce` input advances on every seizure (§5.4) and never returns to an earlier value, so the retained proof cannot execute a second seizure the auditor did not approve. + +`ConfidentialClawback: ConfidentialCompliance` forces a `freeze` / `unfreeze` implementation but constrains nothing about the deployment's `Hooks`. Wiring `NoHooks` next to a `ConfidentialClawback` impl yields a `freeze` that writes the flag and an `is_frozen` that returns `true` while every token operation stays ungated, so the target spends out before the seizure lands and the admin's only signal is an `InvalidProof` once the commitments have moved. A deployment that enables clawback MUST wire `ComplianceHooks`, or a custom `Hooks` impl that gates the same seven positions (§4). ### 5.7 Wallet and Auditor Consequences @@ -281,6 +289,7 @@ impl Token { fn clawback(e: Env, account: Address, amount: i128, destination: Option
, data: Bytes /* XDR ClawbackData { proof } */, operator: Address); fn force_revoke_spender(e: Env, account: Address, spender: Address, operator: Address); + fn clawback_nonce(e: Env, account: Address) -> u32; } ``` diff --git a/packages/tokens/src/confidential/docs/DESIGN_cont.md b/packages/tokens/src/confidential/docs/DESIGN_cont.md index 730f99bf5..879c63f91 100644 --- a/packages/tokens/src/confidential/docs/DESIGN_cont.md +++ b/packages/tokens/src/confidential/docs/DESIGN_cont.md @@ -241,7 +241,7 @@ pub enum CircuitType { | `Transfer` | Balance conservation; ECDH-derived blinding and encrypted amount for recipient; dual-auditor channel sponges (recipient auditor: amount + per-transfer Pedersen randomness; sender auditor: amount + balance + `lane[2]` escrow of the new spendable blinding); deterministic randomness for new sender balance; encrypted balance scalar; sender key ownership; range validity (balance $$\in [0, 2^{127})$$, amount $$\in [0, 2^{127})$$) | | `SpenderTransfer` | Allowance sufficiency; ECDH-derived blinding and encrypted amount for recipient; dual-auditor channel sponges (recipient auditor: amount + per-transfer Pedersen randomness; owner auditor: amount + allowance + `lane[2]` escrow of the new allowance blinding); deterministic randomness for new allowance; encrypted allowance scalar; spender key ownership; contract-bound indirectly via $$C\_a$$ chain (Section 7.8) | | `SetSpender` | Balance split; $$dvk\_i$$ derivation; ECDH escrow of $$dvk\_i$$ to the spender and of the allowance blinding to the owner's auditor; allowance commitment with deterministic randomness; encrypted balance and allowance scalars; owner-auditor ECDH ciphertexts (escrow amount + balance checkpoint + `lane[2]` escrow of the new spendable blinding); owner key ownership; contract-bound via $$vk$$ derivation | -| `Clawback` | Knowledge of the openings of the target's $$C\_{\text{spend}}$$ and $$C\_{\text{receive}}$$; public seize amount bounded by their sum, remainder in range; no key ownership, no ephemeral ([COMPLIANCE.md](./COMPLIANCE.md) §5.3) | +| `Clawback` | Knowledge of the openings of the target's $$C\_{\text{spend}}$$ and $$C\_{\text{receive}}$$; public seize amount bounded by their sum, remainder in range; auditor key ownership; no ephemeral ([COMPLIANCE.md](./COMPLIANCE.md) §5.3) | ### 10.3 Circuit Cost Analysis @@ -256,9 +256,9 @@ The dominant cost in Noir circuits is elliptic curve scalar multiplication. With | `Transfer` | 8 | $$Y\_A$$ (T1), $$C\_{\text{spend}}^A$$ opening (T3), recipient ECDH (T5), $$R\_e$$ (T6), $$C\_{\text{transfer}}$$ (T8), $$C\_{\text{spend}}'$$ (T11), recipient-auditor ECDH (T\_a1), sender-auditor ECDH (T\_a5) | | `SpenderTransfer` | 8 | $$Y\_{\text{op}}$$ (O1), $$C\_a$$ opening (O2), recipient ECDH (O5), $$R\_e$$ (O6), $$C\_{\text{transfer}}$$ (O8), $$C\_a'$$ (O11), recipient-auditor ECDH (O\_a1), owner-auditor ECDH (O\_a5) | | `SetSpender` | 7 | $$Y$$ (S1), $$C\_{\text{spend}}$$ opening (S3), $$C\_a$$ (S7), $$C\_{\text{spend}}'$$ (S10), $$R\_e$$ (S\_a1), $$dvk\_i$$ escrow ECDH (S12, §7.11), owner-auditor ECDH (S\_a2) | -| `Clawback` | 2 | $$C\_{\text{spend}}$$ opening (CB1), $$C\_{\text{receive}}$$ opening (CB2) | +| `Clawback` | 3 | $$K\_{\text{aud}}$$ (CB1), $$C\_{\text{spend}}$$ opening (CB2), $$C\_{\text{receive}}$$ opening (CB3) | -`SetSpender` is the one circuit with a third ECDH beyond the auditor channel: the $$dvk\_i$$ handoff of §7.11 reuses $$r\_e$$ but multiplies it against $$Y\_{\text{op}}$$, so it is a separate call, not a reuse of the S\_a2 shared secret. The auditor-side escrow of the allowance blinding $$r\_a$$ (S14) reuses the S\_a2 shared scalar and adds one Poseidon evaluation. The `lane[2]` escrows (W\_a5, T\_a9, S\_a6, O\_a9) read `lane[2]` of a permutation each circuit already computes and cost one field addition apiece. The ordering these totals imply is consistent with the committed ACIR opcode counts in `circuits/constraints.baseline`: `Register` 33, `Clawback` 51, `Withdraw` 95, `Transfer` 134, `SetSpender` 135, `SpenderTransfer` 136. +`SetSpender` is the one circuit with a third ECDH beyond the auditor channel: the $$dvk\_i$$ handoff of §7.11 reuses $$r\_e$$ but multiplies it against $$Y\_{\text{op}}$$, so it is a separate call, not a reuse of the S\_a2 shared secret. The auditor-side escrow of the allowance blinding $$r\_a$$ (S14) reuses the S\_a2 shared scalar and adds one Poseidon evaluation. The `lane[2]` escrows (W\_a5, T\_a9, S\_a6, O\_a9) read `lane[2]` of a permutation each circuit already computes and cost one field addition apiece. The ordering these totals imply is consistent with the committed ACIR opcode counts in `circuits/constraints.baseline`: `Register` 33, `Clawback` 64, `Withdraw` 95, `Transfer` 134, `SetSpender` 135, `SpenderTransfer` 136. The ECDH computations add scalar multiplications compared to a random-blinding scheme, but the unchunked design eliminates all per-chunk constraints (which, in a chunked scheme, would involve 8+ scalar multiplications for balance chunks and per-chunk range proofs). diff --git a/packages/tokens/src/confidential/docs/SDK.md b/packages/tokens/src/confidential/docs/SDK.md index 2998e2b81..adace6844 100644 --- a/packages/tokens/src/confidential/docs/SDK.md +++ b/packages/tokens/src/confidential/docs/SDK.md @@ -484,7 +484,7 @@ An implementation MUST squeeze the sender / owner channel three-wide and MUST NO **Scope MUST be represented, not implied.** The recipient-channel capability is forward-only, reset by merge, and yields no opening of the spendable commitment (DESIGN_cont.md §8.1). The `lane[2]` opening of the sender channel is forward-only and **standing**: it opens the spendable commitment as of the checkpoint that escrowed it and stays valid through merges provided the client folded in every inbound flow since the previous merge, one account key serving both channels (DESIGN_cont.md §8.1 *Sender-auditor opening capability*). Maintaining it is the client's job: an implementation MUST add the `lane[0]` amount and `lane[1]` $$r_{\text{transfer}}$$ of every inbound `Transfer` and `SpenderTransfer` to its stored $$(v, r)$$, and MUST treat each `Deposit` as $$(\text{amount}, 0)$$ (DESIGN.md §7.3). On a `RevokeSpender` event it MUST add the $$(v_a, r_a)$$ it recorded for that delegation, the fold being public and the addend already escrowed (DESIGN_cont.md §8.5); where it holds no such record it MUST mark the opening unavailable until the next `lane[2]` escrow rather than carry a stale one. Across a key rotation, what an implementation may carry forward and what it MUST treat as unopened are fixed by DESIGN_cont.md §8.3. -**Clawback witness.** In a deployment that enables seizure (COMPLIANCE.md §5), the clawback witness is the pair of openings the client already maintains — the standing sender-channel opening of $$C_{\text{spend}}$$ and the recipient-channel opening of $$C_{\text{receive}}$$ (COMPLIANCE.md §5.2, §5.3). An implementation MUST verify both against the on-chain commitments before proving, and on a `Clawback` event MUST advance the standing opening by the `Merge` rule and the event's public `amount` (COMPLIANCE.md §5.7). +**Clawback witness.** In a deployment that enables seizure (COMPLIANCE.md §5), the clawback witness is the auditor secret key together with the pair of openings the client already maintains — the standing sender-channel opening of $$C_{\text{spend}}$$ and the recipient-channel opening of $$C_{\text{receive}}$$ (COMPLIANCE.md §5.2, §5.3). An implementation MUST verify both against the on-chain commitments before proving, MUST read the account's current `clawback_nonce` into the public inputs (COMPLIANCE.md §5.3), and on a `Clawback` event MUST advance the standing opening by the `Merge` rule and the event's public `amount` (COMPLIANCE.md §5.7). An auditor facade MUST NOT be able to construct a spending witness. It can open the spendable balance past a merge — the inbound $$r_{\text{transfer}}$$ reaches the same key on the recipient channel — so a facade that exposes openings exposes them for the account's whole history under the active key. From 89d19fafb28ae1637336be8e9a0063efc1bd545a Mon Sep 17 00:00:00 2001 From: Boyan Barakov <9572072+brozorec@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:52:04 +0200 Subject: [PATCH 10/17] fee-abstraction: consume the whole eager approval (#873) * fix(fee-abstraction): consume the whole eager approval in collect_fee Eager mode approved max_fee_amount but spent only fee_amount, so the unspent allowance survived into the arbitrary target invocation and could be drained by a later forward. Pull max_fee_amount, pay the fee and refund the remainder so no allowance is left behind. * tests: events Co-authored-by: knQzx <75641500+knQzx@users.noreply.github.com> --- .../src/contract.rs | 6 +- .../fee-forwarder-permissionless/src/test.rs | 2 + packages/fee-abstraction/src/lib.rs | 3 +- packages/fee-abstraction/src/storage.rs | 45 ++-- packages/fee-abstraction/src/test.rs | 213 +++++++++++++++++- 5 files changed, 239 insertions(+), 30 deletions(-) diff --git a/examples/fee-forwarder-permissionless/src/contract.rs b/examples/fee-forwarder-permissionless/src/contract.rs index 14e5bcf3c..b9a750272 100644 --- a/examples/fee-forwarder-permissionless/src/contract.rs +++ b/examples/fee-forwarder-permissionless/src/contract.rs @@ -45,8 +45,10 @@ //! //! 6. **Contract executes atomically**: //! - Validates both user and relayer authorizations -//! - User approves contract to spend up to `max_fee_amount` tokens -//! - Contract transfers exactly `fee_amount` tokens from user to itself +//! - User approves contract to spend `max_fee_amount` tokens +//! - Contract pulls `max_fee_amount` tokens from the user, pays `fee_amount` +//! to the relayer and returns the remainder to the user, leaving no +//! allowance behind //! - Contract forwards call to `target_contract.target_fn(target_args)` //! - If any step fails, entire transaction reverts (including token //! transfer) diff --git a/examples/fee-forwarder-permissionless/src/test.rs b/examples/fee-forwarder-permissionless/src/test.rs index ecff0f123..6a7e1945a 100644 --- a/examples/fee-forwarder-permissionless/src/test.rs +++ b/examples/fee-forwarder-permissionless/src/test.rs @@ -143,6 +143,8 @@ fn forward_basic() { assert_eq!(token.balance(&user), initial_user_balance - fee_amount); assert_eq!(token.balance(&relayer), initial_relayer_balance + fee_amount); + assert_eq!(token.balance(&fee_forwarder.address), 0); + assert_eq!(token.allowance(&user, &fee_forwarder.address), 0); } #[test] diff --git a/packages/fee-abstraction/src/lib.rs b/packages/fee-abstraction/src/lib.rs index 1448be512..ff065627c 100644 --- a/packages/fee-abstraction/src/lib.rs +++ b/packages/fee-abstraction/src/lib.rs @@ -13,7 +13,8 @@ //! - **Approval strategies**: utilities for collecting fee from users support //! two approval semantics: //! - [`FeeAbstractionApproval::Eager`]: always approve `max_fee_amount` -//! (overwriting any existing allowance) +//! (overwriting any existing allowance) and consume the whole approval +//! within the collection, leaving no residual allowance //! - [`FeeAbstractionApproval::Lazy`]: only approve if the current allowance //! is less than `max_fee_amount` //! diff --git a/packages/fee-abstraction/src/storage.rs b/packages/fee-abstraction/src/storage.rs index 02a93390f..bb97bab4b 100644 --- a/packages/fee-abstraction/src/storage.rs +++ b/packages/fee-abstraction/src/storage.rs @@ -25,8 +25,11 @@ pub enum FeeAbstractionStorageKey { #[contracttype] pub enum FeeAbstractionApproval { /// Only approve `max_fee_amount` if the existing allowance is insufficient. + /// The unspent remainder stays approved for later collections. Lazy, - /// Always approve `max_fee_amount`, overwriting previous allowances. + /// Always approve `max_fee_amount`, overwriting previous allowances, and + /// consume the whole approval within the same collection so that no + /// allowance remains afterwards. Eager, } @@ -119,9 +122,15 @@ pub fn collect_fee_and_invoke( /// Low-level helper to collect a fee from the user in a given token by checking /// whether the token is allowed when allow list is enabled. /// -/// It can be used with either eager or lazy approval semantics. `Eager` always -/// approves `max_fee_amount` (overwriting any existing allowance); `Lazy` only -/// approves if the current allowance is less than `max_fee_amount`. +/// It can be used with either eager or lazy approval semantics. `Lazy` only +/// approves if the current allowance is less than `max_fee_amount` and leaves +/// the unspent remainder approved for later collections. `Eager` always +/// approves `max_fee_amount` (overwriting any existing allowance) and consumes +/// the whole approval at once: `max_fee_amount` is pulled from the user, the +/// fee is paid to the recipient and the remainder is returned to the user, so +/// no allowance survives the collection. The user therefore needs a spendable +/// balance of at least `max_fee_amount` for an eager collection; the token +/// rejects it otherwise. /// /// # Arguments /// @@ -175,35 +184,33 @@ pub fn collect_fee( validate_fee_bounds(e, fee_amount, max_fee_amount); let token_client = TokenClient::new(e, fee_token); + let contract_address = e.current_contract_address(); match approval { FeeAbstractionApproval::Eager => { - token_client.approve( - user, - &e.current_contract_address(), - &max_fee_amount, - &expiration_ledger, - ); + token_client.approve(user, &contract_address, &max_fee_amount, &expiration_ledger); + token_client.transfer_from(&contract_address, user, &contract_address, &max_fee_amount); + if *fee_recipient != contract_address { + token_client.transfer(&contract_address, fee_recipient, &fee_amount); + } + let remainder = max_fee_amount - fee_amount; + if remainder > 0 { + token_client.transfer(&contract_address, user, &remainder); + } } FeeAbstractionApproval::Lazy => { - let allowance = token_client.allowance(user, &e.current_contract_address()); + let allowance = token_client.allowance(user, &contract_address); if allowance < max_fee_amount { - token_client.approve( - user, - &e.current_contract_address(), - &max_fee_amount, - &expiration_ledger, - ); + token_client.approve(user, &contract_address, &max_fee_amount, &expiration_ledger); } else { // assuming that in the other cases the expiration ledger is // validated in `token.approve()` validate_expiration_ledger(e, expiration_ledger); } + token_client.transfer_from(&contract_address, user, fee_recipient, &fee_amount); } } - token_client.transfer_from(&e.current_contract_address(), user, fee_recipient, &fee_amount); - emit_fee_collected(e, user, fee_recipient, fee_token, fee_amount); } diff --git a/packages/fee-abstraction/src/test.rs b/packages/fee-abstraction/src/test.rs index 4b704da4a..c16e95a79 100644 --- a/packages/fee-abstraction/src/test.rs +++ b/packages/fee-abstraction/src/test.rs @@ -2,14 +2,14 @@ use soroban_sdk::{ contract, contractimpl, testutils::{Address as _, Events, Ledger}, token::TokenClient, - vec, Address, Env, FromVal, MuxedAddress, String, Symbol, Val, Vec, + vec, Address, Env, Event, FromVal, MuxedAddress, String, Symbol, Val, Vec, }; -use stellar_tokens::fungible::{Base, FungibleToken}; +use stellar_tokens::fungible::{Approve, Base, FungibleToken, Transfer}; use crate::{ collect_fee, collect_fee_and_invoke, is_allowed_fee_token, is_fee_token_allowlist_enabled, set_allowed_fee_token, sweep_token, validate_expiration_ledger, validate_fee_bounds, - FeeAbstractionApproval, FeeAbstractionStorageKey, + FeeAbstractionApproval, FeeAbstractionStorageKey, FeeCollected, }; #[contract] @@ -71,14 +71,211 @@ fn collect_fee_with_eager_approval_overwrites_allowance() { }); let events = e.events().all(); - // approval, transfer and collect fee - assert_eq!(events.events().len(), 3); + assert_eq!(events.events().len(), 5); + assert_eq!( + events.events().first().unwrap(), + &Approve { + owner: user.clone(), + spender: contract_address.clone(), + amount: max_fee_amount, + live_until_ledger: 100, + } + .to_xdr(&e, &token_address) + ); + assert_eq!( + events.events().get(1).unwrap(), + &Transfer { from: user.clone(), to: contract_address.clone(), amount: max_fee_amount } + .to_xdr(&e, &token_address) + ); + assert_eq!( + events.events().get(2).unwrap(), + &Transfer { from: contract_address.clone(), to: recipient.clone(), amount: 20 } + .to_xdr(&e, &token_address) + ); + assert_eq!( + events.events().get(3).unwrap(), + &Transfer { from: contract_address.clone(), to: user.clone(), amount: 30 } + .to_xdr(&e, &token_address) + ); + assert_eq!( + events.events().get(4).unwrap(), + &FeeCollected { + user: user.clone(), + recipient: recipient.clone(), + token: token_address.clone(), + amount: 20, + } + .to_xdr(&e, &contract_address) + ); let allowance = token_client.allowance(&user, &contract_address); - assert_eq!(allowance, 30); + assert_eq!(allowance, 0); - let balance = token_client.balance(&recipient); - assert_eq!(balance, 20); + assert_eq!(token_client.balance(&recipient), 20); + assert_eq!(token_client.balance(&user), 980); + assert_eq!(token_client.balance(&contract_address), 0); +} + +#[test] +fn collect_fee_with_eager_approval_no_remainder() { + let e = Env::default(); + e.mock_all_auths_allowing_non_root_auth(); + + let contract_address = e.register(MockContract, ()); + let user = Address::generate(&e); + let token_address = e.register(MockToken, (user.clone(),)); + let recipient = Address::generate(&e); + + let token_client = TokenClient::new(&e, &token_address); + + e.as_contract(&contract_address, || { + // approve 50, spend 50 + collect_fee( + &e, + &token_address, + 50, + 50, + 100, + &user, + &recipient, + FeeAbstractionApproval::Eager, + ); + }); + + let events = e.events().all(); + assert_eq!(events.events().len(), 4); + assert_eq!( + events.events().first().unwrap(), + &Approve { + owner: user.clone(), + spender: contract_address.clone(), + amount: 50, + live_until_ledger: 100, + } + .to_xdr(&e, &token_address) + ); + assert_eq!( + events.events().get(1).unwrap(), + &Transfer { from: user.clone(), to: contract_address.clone(), amount: 50 } + .to_xdr(&e, &token_address) + ); + assert_eq!( + events.events().get(2).unwrap(), + &Transfer { from: contract_address.clone(), to: recipient.clone(), amount: 50 } + .to_xdr(&e, &token_address) + ); + assert_eq!( + events.events().get(3).unwrap(), + &FeeCollected { + user: user.clone(), + recipient: recipient.clone(), + token: token_address.clone(), + amount: 50, + } + .to_xdr(&e, &contract_address) + ); + + let allowance = token_client.allowance(&user, &contract_address); + assert_eq!(allowance, 0); + + assert_eq!(token_client.balance(&recipient), 50); + assert_eq!(token_client.balance(&user), 950); + assert_eq!(token_client.balance(&contract_address), 0); +} + +#[test] +fn collect_fee_with_eager_approval_to_current_contract() { + let e = Env::default(); + e.mock_all_auths_allowing_non_root_auth(); + + let contract_address = e.register(MockContract, ()); + let user = Address::generate(&e); + let token_address = e.register(MockToken, (user.clone(),)); + + let token_client = TokenClient::new(&e, &token_address); + + e.as_contract(&contract_address, || { + // approve 50, spend 20, the fee stays on the contract + collect_fee( + &e, + &token_address, + 20, + 50, + 100, + &user, + &contract_address, + FeeAbstractionApproval::Eager, + ); + }); + + let events = e.events().all(); + assert_eq!(events.events().len(), 4); + assert_eq!( + events.events().first().unwrap(), + &Approve { + owner: user.clone(), + spender: contract_address.clone(), + amount: 50, + live_until_ledger: 100, + } + .to_xdr(&e, &token_address) + ); + assert_eq!( + events.events().get(1).unwrap(), + &Transfer { from: user.clone(), to: contract_address.clone(), amount: 50 } + .to_xdr(&e, &token_address) + ); + assert_eq!( + events.events().get(2).unwrap(), + &Transfer { from: contract_address.clone(), to: user.clone(), amount: 30 } + .to_xdr(&e, &token_address) + ); + assert_eq!( + events.events().get(3).unwrap(), + &FeeCollected { + user: user.clone(), + recipient: contract_address.clone(), + token: token_address.clone(), + amount: 20, + } + .to_xdr(&e, &contract_address) + ); + + let allowance = token_client.allowance(&user, &contract_address); + assert_eq!(allowance, 0); + + assert_eq!(token_client.balance(&contract_address), 20); + assert_eq!(token_client.balance(&user), 980); +} + +#[test] +#[should_panic(expected = "Error(Contract, #100)")] +fn collect_fee_with_eager_approval_requires_max_fee_balance() { + let e = Env::default(); + e.mock_all_auths_allowing_non_root_auth(); + + let contract_address = e.register(MockContract, ()); + let user = Address::generate(&e); + let token_address = e.register(MockToken, (user.clone(),)); + let recipient = Address::generate(&e); + let other = Address::generate(&e); + + let token_client = TokenClient::new(&e, &token_address); + // the user keeps 25, which covers the fee but not the approved maximum + token_client.transfer(&user, &other, &975); + + e.as_contract(&contract_address, || { + collect_fee( + &e, + &token_address, + 20, + 50, + 100, + &user, + &recipient, + FeeAbstractionApproval::Eager, + ); + }); } #[test] From 9357c3a80bcce00c6e7fde63a0adfd08a960aa0e Mon Sep 17 00:00:00 2001 From: Boyan Barakov <9572072+brozorec@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:13:09 +0200 Subject: [PATCH 11/17] Confidential Token: docs refactor (#869) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(confidential): align SDK and SELECTIVE_DISCLOSURE with DESIGN Witness assembly now names all six circuits, drawn-scalar and citation lists match DESIGN, and the D-auditor sponge reads each channel at its fixed width. DESIGN_cont §11 corrects struct-field order to the sorted form the contracttype macro emits. * docs(confidential): split the specification into a topic-per-file tree Mechanical move of DESIGN, DESIGN_cont, SDK, SELECTIVE_DISCLOSURE, COMPLIANCE, INDEXER, and OVERVIEW into docs/{protocol,sdk,selective-disclosure}/ and lower-case single files. Headings are de-numbered and re-levelled and cited bold labels become headings; body text is unchanged. Citations still use the old section numbers and are rewritten in the next commit. * docs(confidential): cite the specification by path and anchor Every section-number citation in the docs, the Rust and Noir comments, and the agent guides becomes a relative path with a GitHub heading anchor. docs/README.md maps the set, and docs/check_links.py, run by the new docs workflow, fails CI on a missing file or heading, a duplicate anchor within a file, or a file over the LaTeX rendering budget. * docs(confidential): give each duplicated fact one home The recovery procedure, checkpoint set, and T0 anchor are stated only in protocol/wallet-state.md; the domain-tag table lives only in protocol/domain-separators.md, now with the layer that absorbs each tag; the per-lane auditor map moves into protocol/auditing.md; the overview and the SDK cite these instead of restating them. ACIR opcode counts are no longer quoted in prose, leaving circuits/constraints.baseline as their only record. * docs(confidential): use one math style Drop the markdown-only backslash escape before subscripts inside math spans, which GitHub renders identically with and without (checked through its markdown API), keep LaTeX's own escaped underscore inside \text{}, and lift overview.md's single-dollar spans to $$. Citations that sat inside \text{} become plain section titles. * ci(confidential): check documentation references with lychee Replace the bespoke checker with lychee-action over the module's Markdown plus a generated link list of the Rust, Noir, and guide citations. The one heading that carried LaTeX now uses a code span, since lychee and GitHub slug math differently. * docs(confidential): give the specification a reading path The split left the index author-facing, overview.md unreachable from any path, and no file pointing to the next one. The index now opens with where to start, every file ends with previous/up/next links, and the operations index lists its operations in order. * docs(confidential): point footers at their map subsection Footers now read "Index" and anchor to the README subsection that lists the file: #protocol for the specification, #companions for the rest. * docs(confidential): escape subscripts so GitHub renders the math GitHub parses a `$$…$$` span's contents with its markdown inline parser before MathJax sees them, so an unescaped `_` flanked by punctuation pairs with the next one in the same paragraph and the resulting `` destroyed both spans. 187 of 1756 spans rendered as literal text; the escape is stripped again on the way to MathJax, so `\_` still arrives as a subscript. * ci(docs): restrict workflow token to contents: read Co-Authored-By: Claude Fable 5.1 * fix: coderabbit suggestions * docs(confidential): tidy protocol README front matter List the protocol files as bullets, rename the third Approach item to "Dual-balance model", and drop the CAP-80 aside from the abstract. Co-Authored-By: Claude Fable 5.1 * docs(confidential): specify the per-role aggregate disclosure circuits The aggregate spec only tabulated the D-recipient shape, so the D-sender aggregate it prescribed could not be assembled (#849). Each role now has per-event inputs, witnesses, and constraints, with per-event keys so an aggregate may span accounts; padding, threshold agreement, the auditor channel, and the decrypt domain are pinned down as well. --- .github/workflows/docs.yml | 42 + packages/tokens/src/confidential/CLAUDE.md | 41 +- packages/tokens/src/confidential/README.md | 30 +- .../src/confidential/circuits/CLAUDE.md | 10 +- .../circuits/clawback/src/main.nr | 12 +- .../gadgets/assert_on_curve/src/main.nr | 2 +- .../gadgets/sponge_squeeze_2/src/main.nr | 2 +- .../src/confidential/circuits/lib/src/lib.nr | 44 +- .../confidential/circuits/lib/src/tests.nr | 8 +- .../circuits/register/src/main.nr | 4 +- .../circuits/set_spender/src/main.nr | 32 +- .../circuits/set_spender/src/tests.nr | 8 +- .../circuits/spender_transfer/src/main.nr | 36 +- .../circuits/spender_transfer/src/tests.nr | 14 +- .../circuits/transfer/src/main.nr | 24 +- .../circuits/transfer/src/tests.nr | 4 +- .../circuits/withdraw/src/main.nr | 16 +- .../circuits/withdraw/src/tests.nr | 2 +- .../tokens/src/confidential/compliance/mod.rs | 9 +- .../src/confidential/compliance/storage.rs | 12 +- .../tokens/src/confidential/docs/DESIGN.md | 828 ------------------ .../src/confidential/docs/DESIGN_cont.md | 556 ------------ .../tokens/src/confidential/docs/README.md | 67 ++ packages/tokens/src/confidential/docs/SDK.md | 570 ------------ .../confidential/docs/SELECTIVE_DISCLOSURE.md | 561 ------------ .../docs/{COMPLIANCE.md => compliance.md} | 140 +-- .../docs/{INDEXER.md => indexer.md} | 94 +- .../docs/{OVERVIEW.md => overview.md} | 46 +- .../src/confidential/docs/protocol/README.md | 63 ++ .../docs/protocol/account-state.md | 81 ++ .../confidential/docs/protocol/auditing.md | 140 +++ .../docs/protocol/domain-separators.md | 33 + .../confidential/docs/protocol/interface.md | 125 +++ .../docs/protocol/keys-and-commitments.md | 110 +++ .../docs/protocol/operations/README.md | 30 + .../docs/protocol/operations/deposit.md | 15 + .../docs/protocol/operations/merge.md | 29 + .../docs/protocol/operations/register.md | 37 + .../protocol/operations/revoke-spender.md | 26 + .../docs/protocol/operations/set-spender.md | 74 ++ .../protocol/operations/spender-transfer.md | 62 ++ .../docs/protocol/operations/transfer.md | 63 ++ .../docs/protocol/operations/withdraw.md | 46 + .../confidential/docs/protocol/primitives.md | 176 ++++ .../docs/protocol/proof-system.md | 191 ++++ .../confidential/docs/protocol/security.md | 73 ++ .../docs/protocol/system-model.md | 57 ++ .../docs/protocol/wallet-state.md | 79 ++ .../src/confidential/docs/sdk/README.md | 71 ++ .../confidential/docs/sdk/auditor-client.md | 19 + .../src/confidential/docs/sdk/clients.md | 33 + .../src/confidential/docs/sdk/conformance.md | 28 + .../src/confidential/docs/sdk/crypto-core.md | 90 ++ .../confidential/docs/sdk/key-derivation.md | 90 ++ .../src/confidential/docs/sdk/proving.md | 81 ++ .../src/confidential/docs/sdk/requirements.md | 52 ++ .../src/confidential/docs/sdk/wallet.md | 95 ++ .../docs/selective-disclosure/README.md | 106 +++ .../circuits/aggregate.md | 58 ++ .../circuits/d-auditor.md | 43 + .../circuits/d-balance.md | 56 ++ .../circuits/d-recipient.md | 39 + .../selective-disclosure/circuits/d-sender.md | 68 ++ .../docs/selective-disclosure/protocol.md | 157 ++++ .../docs/selective-disclosure/security.md | 92 ++ packages/tokens/src/confidential/mod.rs | 32 +- packages/tokens/src/confidential/storage.rs | 26 +- packages/tokens/src/confidential/test.rs | 20 +- .../tokens/src/confidential/verifier/mod.rs | 24 +- .../src/confidential/verifier/storage.rs | 2 +- 70 files changed, 3161 insertions(+), 2845 deletions(-) create mode 100644 .github/workflows/docs.yml delete mode 100644 packages/tokens/src/confidential/docs/DESIGN.md delete mode 100644 packages/tokens/src/confidential/docs/DESIGN_cont.md create mode 100644 packages/tokens/src/confidential/docs/README.md delete mode 100644 packages/tokens/src/confidential/docs/SDK.md delete mode 100644 packages/tokens/src/confidential/docs/SELECTIVE_DISCLOSURE.md rename packages/tokens/src/confidential/docs/{COMPLIANCE.md => compliance.md} (61%) rename packages/tokens/src/confidential/docs/{INDEXER.md => indexer.md} (52%) rename packages/tokens/src/confidential/docs/{OVERVIEW.md => overview.md} (81%) create mode 100644 packages/tokens/src/confidential/docs/protocol/README.md create mode 100644 packages/tokens/src/confidential/docs/protocol/account-state.md create mode 100644 packages/tokens/src/confidential/docs/protocol/auditing.md create mode 100644 packages/tokens/src/confidential/docs/protocol/domain-separators.md create mode 100644 packages/tokens/src/confidential/docs/protocol/interface.md create mode 100644 packages/tokens/src/confidential/docs/protocol/keys-and-commitments.md create mode 100644 packages/tokens/src/confidential/docs/protocol/operations/README.md create mode 100644 packages/tokens/src/confidential/docs/protocol/operations/deposit.md create mode 100644 packages/tokens/src/confidential/docs/protocol/operations/merge.md create mode 100644 packages/tokens/src/confidential/docs/protocol/operations/register.md create mode 100644 packages/tokens/src/confidential/docs/protocol/operations/revoke-spender.md create mode 100644 packages/tokens/src/confidential/docs/protocol/operations/set-spender.md create mode 100644 packages/tokens/src/confidential/docs/protocol/operations/spender-transfer.md create mode 100644 packages/tokens/src/confidential/docs/protocol/operations/transfer.md create mode 100644 packages/tokens/src/confidential/docs/protocol/operations/withdraw.md create mode 100644 packages/tokens/src/confidential/docs/protocol/primitives.md create mode 100644 packages/tokens/src/confidential/docs/protocol/proof-system.md create mode 100644 packages/tokens/src/confidential/docs/protocol/security.md create mode 100644 packages/tokens/src/confidential/docs/protocol/system-model.md create mode 100644 packages/tokens/src/confidential/docs/protocol/wallet-state.md create mode 100644 packages/tokens/src/confidential/docs/sdk/README.md create mode 100644 packages/tokens/src/confidential/docs/sdk/auditor-client.md create mode 100644 packages/tokens/src/confidential/docs/sdk/clients.md create mode 100644 packages/tokens/src/confidential/docs/sdk/conformance.md create mode 100644 packages/tokens/src/confidential/docs/sdk/crypto-core.md create mode 100644 packages/tokens/src/confidential/docs/sdk/key-derivation.md create mode 100644 packages/tokens/src/confidential/docs/sdk/proving.md create mode 100644 packages/tokens/src/confidential/docs/sdk/requirements.md create mode 100644 packages/tokens/src/confidential/docs/sdk/wallet.md create mode 100644 packages/tokens/src/confidential/docs/selective-disclosure/README.md create mode 100644 packages/tokens/src/confidential/docs/selective-disclosure/circuits/aggregate.md create mode 100644 packages/tokens/src/confidential/docs/selective-disclosure/circuits/d-auditor.md create mode 100644 packages/tokens/src/confidential/docs/selective-disclosure/circuits/d-balance.md create mode 100644 packages/tokens/src/confidential/docs/selective-disclosure/circuits/d-recipient.md create mode 100644 packages/tokens/src/confidential/docs/selective-disclosure/circuits/d-sender.md create mode 100644 packages/tokens/src/confidential/docs/selective-disclosure/protocol.md create mode 100644 packages/tokens/src/confidential/docs/selective-disclosure/security.md diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 000000000..0a5ef58c8 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,42 @@ +name: "docs workflow" + +on: + push: + branches: + - main + paths: + - "packages/tokens/src/confidential/**" + - ".github/workflows/docs.yml" + pull_request: + paths: + - "packages/tokens/src/confidential/**" + - ".github/workflows/docs.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + check-links: + runs-on: ubuntu-latest + steps: + - name: git checkout + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: collect code citations + working-directory: packages/tokens/src/confidential + run: | + grep -rhoE '(\.\./)*docs/[A-Za-z0-9_./-]+\.md(#[A-Za-z0-9_-]+)?' --include='*.rs' --include='*.nr' --include='CLAUDE.md' . \ + | sed -E 's|^(\.\./)+||' | sort -u | sed -E 's|.*|[&](/&)|' > "$RUNNER_TEMP/citations.md" + + - name: check documentation references + uses: lycheeverse/lychee-action@v2 + with: + lycheeVersion: v0.24.2 + args: --offline --include-fragments --no-progress --root-dir ${{ github.workspace }}/packages/tokens/src/confidential 'packages/tokens/src/confidential/**/*.md' ${{ runner.temp }}/citations.md diff --git a/packages/tokens/src/confidential/CLAUDE.md b/packages/tokens/src/confidential/CLAUDE.md index 03ca2e064..c20d5d3aa 100644 --- a/packages/tokens/src/confidential/CLAUDE.md +++ b/packages/tokens/src/confidential/CLAUDE.md @@ -13,7 +13,7 @@ The module ships one token contract plus three satellites, each with the standar | `auditor/` | Separate contract holding the auditor key registry | | `compliance/` | `ComplianceHooks` — freeze, SAC passthrough, policy contract — plus the opt-in `ConfidentialClawback` trait (`clawback`, `force_revoke_spender`) | | `circuits/` | Noir workspace, compiled by `nargo`, not `cargo` | -| `docs/` | The protocol specification (see below) | +| `docs/` | The specification set; `docs/README.md` is its map (see below) | Balances are Pedersen commitments on Grumpkin. Every operation that opens or re-randomizes a commitment carries a proof the contract forwards to the verifier contract; `deposit`, `merge`, and `revoke_spender` are proofless homomorphic folds. @@ -25,13 +25,13 @@ Balances are Pedersen commitments on Grumpkin. Every operation that opens or re- ## Canonical encoding is a security boundary -The public-input blob is a positional concatenation of 32-byte big-endian `Bn254Fr` representatives, in the order given by each circuit's table in DESIGN §7 (COMPLIANCE §5.3 for the clawback circuit). Grumpkin points contribute two limbs (`x` then `y`). +The public-input blob is a positional concatenation of 32-byte big-endian `Bn254Fr` representatives, in the order given by each operation's table under `docs/protocol/operations/` (`docs/compliance.md#circuit` for the clawback circuit). Grumpkin points contribute two limbs (`x` then `y`). Soroban's host silently reduces values `≥ r` modulo `r` rather than rejecting them, so `x` and `x + r` deserialise to the same field element. Every caller-supplied scalar and coordinate must therefore reach `verify_proof` through `append_field` / `append_point`, which call `Grumpkin::is_canonical_field` / `is_canonical_point`. Bypassing those helpers breaks byte-uniqueness of stored state and emitted events even though proofs still verify. -## Code cites the spec by section number +## Code cites the spec by path and anchor -Rust carries roughly two dozen `DESIGN §N` / `DESIGN_cont §N` / `COMPLIANCE §N` references in doc comments, and `circuits/clawback/src/main.nr` cites `COMPLIANCE.md` by section too. Renumbering a spec section silently invalidates them — nothing checks. Before renumbering, grep the module for the old number. +Rust and Noir comments cite the docs as `docs/.md#` paths, and the docs cite each other with relative Markdown links. The `check-links` job in `.github/workflows/docs.yml` runs lychee over the Markdown and over a generated list of the code citations, and fails on a missing file or heading. Run its two steps locally (`brew install lychee`) after touching a heading, a file name, or a citation. ## Tests @@ -45,44 +45,37 @@ Beyond the root guide's conventions: `docs/` is a specification, not commentary, and it is the single largest maintenance hazard in this module — doc-consistency fixes outnumber code commits on this branch. -### Normativity +### Layout and normativity -`DESIGN.md` (§1–§7) and `DESIGN_cont.md` (§8–§13) are normative and share **one** numbering space — `DESIGN §9` resolves to `DESIGN_cont.md`. The split exists because GitHub stops rendering LaTeX after roughly 750 expressions per page; it is a rendering budget, not a topical seam. +`docs/README.md` maps the set, states which files are normative, and gives the precedence rules (`circuits/lib/src/lib.nr` outranks the docs on primitives; `#[contracttype]` definitions outrank them on shapes). One topic per file: new material goes into the file that owns its topic, and a new file is warranted only by a new topic. GitHub stops rendering LaTeX after roughly 750 expressions per page; one topic per file keeps every file well inside that. -Everything else defers by citation: `SDK.md`, `SELECTIVE_DISCLOSURE.md`, `INDEXER.md`, `COMPLIANCE.md`, and the non-normative `OVERVIEW.md`. Two exceptions run the other way — `circuits/lib/src/lib.nr` outranks the docs wherever they disagree about a primitive (`SDK.md` §4 says so explicitly), and the contract's `#[contracttype]`s are authoritative for their own shape. +### Facts with more than one home -`DESIGN.md` is already at roughly 778 expressions, over its own stated budget. Do not add math to §1–§7; put it in `DESIGN_cont.md`. +Most facts now have exactly one owning section and are linked from everywhere else. Three still appear in more than one place because the second copy is a different artefact, not prose. Changing the owner means updating the copies in the same PR: -### Duplicated tables that drift - -Six things exist in more than one file. Changing the normative copy means grepping for every other one: - -| Content | Normative source | Copies live in | +| Content | Owner | Copies live in | |:---|:---|:---| -| Domain-separation tag assignments | `DESIGN_cont.md` §13 | `SDK.md` §4.8, referenced by `SELECTIVE_DISCLOSURE.md` | -| Sponge lane assignment | `DESIGN.md` §2.5 | `SDK.md` §4.3 and §11 | -| Per-circuit scalar-multiplication counts | `DESIGN_cont.md` §10.3 | `OVERVIEW.md` | -| Checkpoint event set | `DESIGN.md` §5.2 | `INDEXER.md`, `SDK.md` | -| Replay-window anchor `T₀` (`Register`, `Merge`, `Clawback`) | `DESIGN.md` §5.2 | `INDEXER.md`, `OVERVIEW.md`, `SDK.md`, `COMPLIANCE.md` §5.7 | -| ACIR opcode counts | `circuits/constraints.baseline` | `DESIGN_cont.md` §10.3, `circuits/CLAUDE.md` | +| `Clawback` is a `T₀` anchor alongside `Merge` | `docs/protocol/wallet-state.md#recovery` | `docs/compliance.md#wallet-and-auditor-consequences`, the role column of `docs/indexer.md#events-in-scope` | +| Per-circuit public-input counts | the `Public inputs` heading of each `docs/protocol/operations/*.md` and `docs/compliance.md#circuit` | header comments of `circuits/*/src/main.nr`, `circuits/CLAUDE.md` | +| Domain-separation tag values | `docs/protocol/domain-separators.md` | `circuits/lib/src/lib.nr`, `circuits/lib/testdata/*.json` | -The tags are a cross-language wire contract. `DESIGN_cont.md` §13 is their only authoritative source: it assigns every value, and it states which subset `circuits/lib/src/lib.nr` implements and why the remainder are absent. Changing any assigned value is a new deployment, not an upgrade. +The tags are a cross-language wire contract. `docs/protocol/domain-separators.md` is their only authoritative source: it assigns every value, states which layer absorbs each, and states which subset `circuits/lib/src/lib.nr` implements. Changing any assigned value is a new deployment, not an upgrade. ### Economy Every sentence is a maintenance liability: a claim written twice has to be fixed twice, and the second copy is the one that goes stale. Doc work here trends net-negative in lines. -- **One owning section per claim.** Every fact has exactly one home; everywhere else writes `§N`. When tempted to summarise a neighbouring section for the reader's convenience, cite it instead. +- **One owning section per claim.** Every fact has exactly one home; everywhere else links to it. When tempted to summarise a neighbouring section for the reader's convenience, cite it instead. - **Say it once, then move on.** No second-register restatement, no paragraph-closing punchline, no recap of the section's own argument in its last sentence. If a paragraph's content survives deleting it, delete it. - **Gloss a symbol at its definition site only.** Re-glossing `s` or `r_e` in each section that uses them is three more places to update when a name changes. - **No pre-stating.** A `Note:` or lead-in that previews what the next paragraph spells out in full is a duplicate. Fold it into the argument. - **Motivation before constraint.** State what a rule protects against, then the rule. Rationale appended after the fact invites a second copy of the rule next to it. - **Drop concessive asides.** "…though X would not be a violation either" earns nothing and dates fast. -- **Grep before adding.** A new claim is usually a second copy of an existing one. Search the normative pair for the symbol or term before writing a sentence about it. -- **A citation is not a summary.** `§8.1` is the whole reference. `§8.1, which escrows the allowance blinding` re-creates the copy the citation was avoiding — name the target only when the sentence would be unparsable without it. +- **Grep before adding.** A new claim is usually a second copy of an existing one. Search `docs/protocol/` for the symbol or term before writing a sentence about it. +- **A citation is not a summary.** The link is the whole reference. `[Spender Allowance Auditing](…), which escrows the allowance blinding` re-creates the copy the citation was avoiding — name the target only when the sentence would be unparsable without it. ### Style -- **Match the file's math style, not a global one.** `DESIGN*.md` and `SELECTIVE_DISCLOSURE.md` use `$$…$$` with backslash-escaped subscripts (`$$\mathbb{F}\_r$$`); `SDK.md` uses `$$…$$` unescaped; `OVERVIEW.md` uses single `$…$`; `INDEXER.md` and `COMPLIANCE.md` use backticked ASCII and no LaTeX. +- **One math style.** LaTeX is written as `$$…$$` with every subscript underscore escaped (`$$\mathbb{F}\_r$$`). GitHub runs the span through its markdown inline parser before handing it to MathJax: an unescaped `_` flanked by punctuation pairs with the next one in the same paragraph, table cell, or heading, and the `` that results destroys both spans. The escape is stripped again on the way to MathJax, so `\_` arrives as a subscript. LaTeX's own literal underscore inside `\text{}` is `\\\_`, and `\{`, `\}`, `\|`, `\,`, `\;` double the same way. A span needs whitespace or punctuation on both sides — `D3$$\_i$$` renders as literal text — and no span inside link text renders at all. `indexer.md` and `compliance.md` use backticked ASCII and no LaTeX. - **Symbols are a maintained namespace.** `sk`/`vk`/`dvk_i`/`PVK`/`Y`; `r_e` and `R_e = r_e·H`; `σ` (operation salt) is distinct from `σ_a` (per-delegation allowance salt); tilde means ciphertext; `C_spend` / `C_receive` / `C_transfer` / `C_a`; `r_a` is `C_a`'s blinding and `r_a'` the post-transfer one. An audit finding once required renaming the `tx` subscript to `transfer` across the whole module. - Prose is full-width — no hard wrapping. One paragraph or list item per line. diff --git a/packages/tokens/src/confidential/README.md b/packages/tokens/src/confidential/README.md index e9bec3c49..f9166aefb 100644 --- a/packages/tokens/src/confidential/README.md +++ b/packages/tokens/src/confidential/README.md @@ -29,13 +29,14 @@ confidential/ ├── compliance/ # turnkey ComplianceHooks: freeze, SAC passthrough, external policy ├── circuits/ # Noir/UltraHonk circuits + pinned VKs └── docs/ - ├── DESIGN.md # protocol specification, §1–§7 - ├── DESIGN_cont.md # protocol specification, §8–§13 - ├── COMPLIANCE.md # compliance-extension specification - ├── SELECTIVE_DISCLOSURE.md # off-chain selective-disclosure layer - ├── OVERVIEW.md # user-flows overview - ├── INDEXER.md # indexer specs - └── SDK.md # client SDK specs + ├── README.md # documentation index: where to start, map, citation rules + ├── protocol/ # normative protocol specification, one topic per file + │ └── operations/ # one file per entry point + ├── compliance.md # compliance-extension specification + ├── selective-disclosure/ # off-chain selective-disclosure layer + ├── indexer.md # durable event archive specification + ├── sdk/ # client SDK specification + └── overview.md # non-normative user-flows overview ``` A single deployment is **three contracts** wired together: @@ -86,7 +87,7 @@ A turnkey [`Hooks`] implementation layering deployer-configurable controls on top of the token: per-account freezing, SAC `authorized()` passthrough, and an optional external authorization policy. Wire as `type Hooks = ComplianceHooks;`. See -[`docs/COMPLIANCE.md`](./docs/COMPLIANCE.md) for the specification. +[`docs/compliance.md`](./docs/compliance.md) for the specification. ### `circuits` @@ -98,10 +99,9 @@ pin and re-extraction procedure. ## Documentation -- [`docs/DESIGN.md`](./docs/DESIGN.md) — protocol specification, §1–§7 - (cryptographic preliminaries, account model, operations, public-input - tables). -- [`docs/DESIGN_cont.md`](./docs/DESIGN_cont.md) — protocol specification, - §8–§13 (auditing, security analysis, proof system, interface). -- [`docs/COMPLIANCE.md`](./docs/COMPLIANCE.md) — compliance extension - specification. +Start with [`docs/overview.md`](./docs/overview.md) for the design without +mathematics, then read the protocol specification from +[`docs/protocol/README.md`](./docs/protocol/README.md) onward; each file +links to the next. [`docs/README.md`](./docs/README.md) maps the whole set: +reading paths per role, the compliance, selective-disclosure, indexer, and +SDK companions, and the citation rules that CI enforces with lychee. diff --git a/packages/tokens/src/confidential/circuits/CLAUDE.md b/packages/tokens/src/confidential/circuits/CLAUDE.md index e4241aaab..ecafbbaba 100644 --- a/packages/tokens/src/confidential/circuits/CLAUDE.md +++ b/packages/tokens/src/confidential/circuits/CLAUDE.md @@ -34,13 +34,13 @@ Directory `transfer/` is package `circuit_transfer`; `gadgets/commit/` is `gadge ### Never hash raw -`poseidon_with_domain` is the only Poseidon entry point in `lib/src/lib.nr`; calling the underlying hash directly is a violation of the library contract. The domain tag is always the first absorbed element. The numeric tag values are the cross-language contract with the SDK — see `../CLAUDE.md` and `../docs/DESIGN_cont.md` §13, which is their only authoritative source. +`poseidon_with_domain` is the only Poseidon entry point in `lib/src/lib.nr`; calling the underlying hash directly is a violation of the library contract. The domain tag is always the first absorbed element. The numeric tag values are the cross-language contract with the SDK — see `../CLAUDE.md` and `../docs/protocol/domain-separators.md`, which is their only authoritative source. -Sponge parameters, the canonical lane assignment, and the mode-exclusivity rule that follows from a single-block absorb are normative in `../docs/DESIGN.md` §2.5; the Noir sponge must match it exactly. The obligations that section places on this code: `sponge_squeeze_2(d,s,σ)[0]` must stay equal to `poseidon_with_domain(d,[s,σ])`, and `sponge_squeeze_3(d,s,σ)[0..2]` must stay equal to `sponge_squeeze_2(d,s,σ)` — which is why `sponge_squeeze_2` is defined as the prefix of `sponge_squeeze_3`. A divergence in either silently changes every existing mask. +Sponge parameters, the canonical lane assignment, and the mode-exclusivity rule that follows from a single-block absorb are normative in `../docs/protocol/primitives.md#poseidon2-hash`; the Noir sponge must match it exactly. The obligations that section places on this code: `sponge_squeeze_2(d,s,σ)[0]` must stay equal to `poseidon_with_domain(d,[s,σ])`, and `sponge_squeeze_3(d,s,σ)[0..2]` must stay equal to `sponge_squeeze_2(d,s,σ)` — which is why `sponge_squeeze_2` is defined as the prefix of `sponge_squeeze_3`. A divergence in either silently changes every existing mask. `AUDITOR_SENDER` is always squeezed three-wide; `AUDITOR_RECIPIENT` is always two-wide; every other tag goes through `poseidon_with_domain`. Widening or narrowing a channel is a spec change. -`lane[2]` carries the blinding of a commitment the operation writes — `r'` on W_a5 / T_a9 / S_a6, `r_a'` on O_a9. Tag 17 (`ESCROWED_ALLOWANCE_BLINDING_AUDITOR`) escrows `r_a` off-sponge at S14; the construction is specified in `../docs/DESIGN_cont.md` §8.5. +`lane[2]` carries the blinding of a commitment the operation writes — `r'` on W_a5 / T_a9 / S_a6, `r_a'` on O_a9. Tag 17 (`ESCROWED_ALLOWANCE_BLINDING_AUDITOR`) escrows `r_a` off-sponge at S14; the construction is specified in `../docs/protocol/auditing.md#auditor-side-allowance-blinding-escrow`. ECDH must absorb both `S.x` and `S.y`; x-only extraction collapses `P` and `-P`. @@ -60,7 +60,7 @@ LC_ALL=C nargo info | grep '^|' | LC_ALL=C sort > constraints.baseline `LC_ALL=C` is mandatory on **both** sides of the pipe — byte order is the only ordering stable between macOS and the Ubuntu runner. The redirect overwrites the file's header comments; re-paste them, because CI's failure message asks for them. -Two non-obvious consequences: adding or removing a **gadget** changes the baseline even when no circuit logic changed, and the ACIR opcode counts are quoted in prose at `../docs/DESIGN_cont.md` §10.3 (Register 33, Clawback 64, Withdraw 95, Transfer 134, SetSpender 135, SpenderTransfer 136). Nothing enforces that second copy — update it in the same PR. +One non-obvious consequence: adding or removing a **gadget** changes the baseline even when no circuit logic changed. The baseline is the only place the ACIR opcode counts are recorded; the docs cite it rather than quoting the numbers. ### `vks/` @@ -84,7 +84,7 @@ Fixtures are not auto-generated. Changing a primitive is a three-step lockstep: `address_to_field.json` is the exception. That derivation has no Noir implementation at all (circuits take `addr_f` as an opaque public input), so it is the one primitive with two independent implementations. Its guard is the Rust test `address_to_field_matches_testdata_vectors` in `../test.rs`, which **transcribes the hex values as string literals** rather than reading the JSON — update both together or neither. Its inputs are 56-character SEP-23 strkeys, and the lo/hi 28-byte limbs are little-endian. -Note the standing obligation on the future TS SDK (`../docs/SDK.md` §6.1): its tests must *read* these JSON files rather than transcribe them, so that a change to `print_fixtures` output becomes a test failure instead of a silent divergence. Do not copy the Rust test's transcription pattern into new consumers. +Note the standing obligation on the future TS SDK (`../docs/sdk/conformance.md#primitive-fixtures`): its tests must *read* these JSON files rather than transcribe them, so that a change to `print_fixtures` output becomes a test failure instead of a silent divergence. Do not copy the Rust test's transcription pattern into new consumers. ## Version bumps diff --git a/packages/tokens/src/confidential/circuits/clawback/src/main.nr b/packages/tokens/src/confidential/circuits/clawback/src/main.nr index db6b4d9b0..c74f56faf 100644 --- a/packages/tokens/src/confidential/circuits/clawback/src/main.nr +++ b/packages/tokens/src/confidential/circuits/clawback/src/main.nr @@ -2,7 +2,7 @@ use stellar_confidential_lib::{commit, scalar_mul, H}; mod tests; -// Clawback circuit -- COMPLIANCE.md Section 5.3. +// Clawback circuit -- docs/compliance.md#circuit. // // Proves that a compliance seizure of the public amount `alpha` is bounded by // what the target actually holds, without revealing either balance, and that @@ -24,7 +24,7 @@ mod tests; // CB3 C_receive = v_r * G + r_r * H Prover knows the opening of the // target's receiving commitment. // CB4 v_s, v_r, alpha, and -// v_s + v_r - alpha in [0, 2^127) The seize bound (Section 2.6). +// v_s + v_r - alpha in [0, 2^127) The seize bound (docs/protocol/primitives.md#integer-embedding-and-range-proofs). // The final term establishes // alpha <= v_s + v_r. // @@ -42,7 +42,7 @@ mod tests; // in-circuit multi_scalar_mul outputs (CB1, CB2, CB3), so they are on-curve by // construction. // -// Public inputs (11 fields, in COMPLIANCE.md Section 5.3 canonical order) +// Public inputs (11 fields, in docs/compliance.md#circuit canonical order) // ----------------------------------------------------------------------- // Idx Param Symbol Source / Note // --- ----- ------ ---------------------------------------- @@ -64,13 +64,13 @@ mod tests; // computed per-call. // 10 _nonce nonce The target's clawback nonce, loaded from // storage; the contract increments it on -// every seizure (Section 5.4). +// every seizure (docs/compliance.md#contract-flow). // // `addr_f`, `_acct_f`, `_dest_f`, and `_nonce` are referenced by no gate and // look like dead parameters: their presence in the public-input set is the // binding, on the `register/_acct_f` precedent. `_nonce` is what makes a proof // single-use: the commitments alone can be restored to an earlier value -// (Section 5.6). +// (docs/compliance.md#anti-replay-and-the-freeze). // // Private witnesses // ----------------- @@ -117,7 +117,7 @@ fn main( assert(c_receive_derived.x == c_receive_x); assert(c_receive_derived.y == c_receive_y); - // CB4 -- Section 2.6's 127-bit decomposition / recomposition pattern. + // CB4 -- docs/protocol/primitives.md#integer-embedding-and-range-proofs's 127-bit decomposition / recomposition pattern. // Range on v_s and v_r alone does not bound v_s + v_r - alpha. v_s.assert_max_bit_size::<127>(); v_r.assert_max_bit_size::<127>(); diff --git a/packages/tokens/src/confidential/circuits/gadgets/assert_on_curve/src/main.nr b/packages/tokens/src/confidential/circuits/gadgets/assert_on_curve/src/main.nr index c8fee3f7e..55bdfe3ef 100644 --- a/packages/tokens/src/confidential/circuits/gadgets/assert_on_curve/src/main.nr +++ b/packages/tokens/src/confidential/circuits/gadgets/assert_on_curve/src/main.nr @@ -3,7 +3,7 @@ use stellar_confidential_lib::assert_on_curve_non_identity; // Minimal circuit wrapping `assert_on_curve_non_identity(p)`. Measures the // constraint cost of validating one public-input key (the variant that -// rejects the identity, per design doc Section 7.1 point 4). +// rejects the identity, per docs/protocol/operations/README.md#public-input-sources point 4). fn main(px: pub Field, py: pub Field) { let p = EmbeddedCurvePoint { x: px, y: py, is_infinite: false }; assert_on_curve_non_identity(p); 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 index 0476118dd..e24e34971 100644 --- 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 @@ -2,7 +2,7 @@ use stellar_confidential_lib::sponge_squeeze_2; // Minimal circuit wrapping `sponge_squeeze_2(d, s, 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 +// two-squeeze auditor channel sponge (docs/protocol/primitives.md#poseidon2-hash). The one-squeeze case // shares the `poseidon_with_domain` gadget already in this directory. fn main(d: pub Field, s: pub Field, sigma: pub Field, m0: pub Field, m1: pub Field) { let out = sponge_squeeze_2(d, s, sigma); diff --git a/packages/tokens/src/confidential/circuits/lib/src/lib.nr b/packages/tokens/src/confidential/circuits/lib/src/lib.nr index 342b12791..4fd855bc7 100644 --- a/packages/tokens/src/confidential/circuits/lib/src/lib.nr +++ b/packages/tokens/src/confidential/circuits/lib/src/lib.nr @@ -1,7 +1,7 @@ //! # Confidential Token -- Noir Primitives //! //! Shared cryptographic primitives consumed by the five circuits described in -//! Section 7 of the design document (Register, Withdraw, Transfer, +//! docs/protocol/operations/ (Register, Withdraw, Transfer, //! SpenderTransfer, SetSpender). //! //! Every constraint that involves a Pedersen commitment, a Poseidon2 hash with @@ -69,7 +69,7 @@ pub global H: EmbeddedCurvePoint = EmbeddedCurvePoint { /// `Poseidon2Hasher` and Barretenberg's Poseidon2 sponge. global POSEIDON2_IV_BASE: Field = 18446744073709551616; // 2^64 -/// Domain separation tags (Section 13 of the design document). The integer +/// Domain separation tags (docs/protocol/domain-separators.md). The integer /// values are the cross-language contract -- they appear as the first absorbed /// element in every Poseidon2 call and the SDK must use the exact same numbers. /// The names spell out what the design doc abbreviates; the table below maps @@ -92,18 +92,18 @@ global POSEIDON2_IV_BASE: Field = 18446744073709551616; // 2^64 /// | `delta_ecdh` | 13 | `ECDH_SHARED_SECRET` | /// | `delta_esc_allow_r_aud` | 17 | `ESCROWED_ALLOWANCE_BLINDING_AUDITOR` | /// -/// Tags 14-16 are not implemented here (DESIGN_cont Section 13). +/// Tags 14-16 are not implemented here (docs/protocol/domain-separators.md). 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 contract computes `addr_f` and `op_i` + /// docs/protocol/primitives.md#address-to-field-encoding (`delta_addr`). The contract computes `addr_f` and `op_i` /// off-circuit via this tag; circuits consume the resulting Field directly. pub global ADDRESS: Field = 1; /// Viewing-key derivation: `vk = Poseidon2(VIEWING_KEY, sk, addr_f)`. - /// Section 4.2 (`delta_vk`). + /// docs/protocol/keys-and-commitments.md#viewing-key (`delta_vk`). pub global VIEWING_KEY: Field = 2; /// Delegation viewing-key derivation: - /// `dvk = Poseidon2(DELEGATION_VIEWING_KEY, vk, op_i)`. Section 4.4 (`delta_dvk`). + /// `dvk = Poseidon2(DELEGATION_VIEWING_KEY, vk, op_i)`. docs/protocol/keys-and-commitments.md#delegation-viewing-key (`delta_dvk`). pub global DELEGATION_VIEWING_KEY: Field = 3; /// Deterministic randomness for the new spendable balance: /// `r' = Poseidon2(SPEND_RANDOMNESS, vk, sigma)`. Constraints W5/T10/S9 (`delta_spend_r`). @@ -128,10 +128,10 @@ pub mod domain { pub global ALLOWANCE_RANDOMNESS: Field = 9; /// Delegation-key escrow mask (spender ECDH): /// `Poseidon2(ESCROWED_DELEGATION_VIEWING_KEY, s, op_i)`. - /// Constraint S12, Section 7.11 (`delta_esc_dvk`). + /// Constraint S12, docs/protocol/operations/set-spender.md#delegation-key-escrow (`delta_esc_dvk`). pub global ESCROWED_DELEGATION_VIEWING_KEY: Field = 10; /// Sender or owner-auditor channel tag for Poseidon2 sponge masks - /// (Section 2.5, Section 8.1). **Three lanes**, and the only tag squeezed + /// (docs/protocol/primitives.md#poseidon2-hash, docs/protocol/auditing.md#per-transfer-auditor-ciphertexts). **Three lanes**, and the only tag squeezed /// three-wide: lane[0] is the amount mask (where applicable), lane[1] the /// balance/allowance checkpoint mask, lane[2] the commitment-blinding /// escrow slot -- the new spendable blinding on checkpoints, the new @@ -139,23 +139,23 @@ pub mod domain { /// S_a3 / O_a6 (`delta_aud_s`). 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 + /// (docs/protocol/primitives.md#poseidon2-hash, docs/protocol/auditing.md#per-transfer-auditor-ciphertexts). Squeeze 1 yields the amount mask; squeeze /// 2 yields the per-transfer Pedersen randomness mask. /// Constraints T_a2 / O_a2 (`delta_aud_r`). pub global AUDITOR_RECIPIENT: Field = 12; /// ECDH shared-secret scalar extraction: /// `s = Poseidon2(ECDH_SHARED_SECRET, S.x, S.y)`. - /// Section 2.4 (`delta_ecdh`). See [`ecdh`]. + /// docs/protocol/primitives.md#elliptic-curve-diffie-hellman (`delta_ecdh`). See [`ecdh`]. pub global ECDH_SHARED_SECRET: Field = 13; /// Auditor-side allowance-blinding escrow mask (owner-auditor ECDH): /// `Poseidon2(ESCROWED_ALLOWANCE_BLINDING_AUDITOR, s_a_s, op_i)`. - /// Constraint S14, Section 8.5 (`delta_esc_allow_r_aud`). + /// Constraint S14, docs/protocol/auditing.md#spender-allowance-auditing (`delta_esc_allow_r_aud`). pub global ESCROWED_ALLOWANCE_BLINDING_AUDITOR: Field = 17; } // ################## CORE PRIMITIVES ################## -/// Pedersen commitment `v*G + r*H` on Grumpkin. Section 2.3. +/// Pedersen commitment `v*G + r*H` on Grumpkin. docs/protocol/primitives.md#pedersen-commitments. pub fn commit(value: Field, randomness: Field) -> EmbeddedCurvePoint { multi_scalar_mul( [G, H], @@ -169,7 +169,7 @@ pub fn scalar_mul(scalar: Field, point: EmbeddedCurvePoint) -> EmbeddedCurvePoin multi_scalar_mul([point], [EmbeddedCurveScalar::from_field(scalar)]) } -/// ECDH shared-secret scalar extraction per Section 2.4: computes +/// ECDH shared-secret scalar extraction per docs/protocol/primitives.md#elliptic-curve-diffie-hellman: computes /// `S = scalar * P` and binds both coordinates into the output via the /// Poseidon funnel, `s = Poseidon2(ECDH_SHARED_SECRET, S.x, S.y)`. /// @@ -190,7 +190,7 @@ pub fn ecdh(scalar: Field, point: EmbeddedCurvePoint) -> Field { } /// Asserts `p` is on Grumpkin AND not the identity. Use this for every -/// public-input *key* (Section 7.1 point 4: `R_e`, `Y`, `PVK`, `K_aud` must all +/// public-input *key* (docs/protocol/operations/README.md#public-input-sources point 4: `R_e`, `Y`, `PVK`, `K_aud` must all /// satisfy `P != O`). The identity element collapses ECDH (`S = O` regardless /// of the secret scalar), so accepting it would let an adversary publish a key /// for which every shared secret is known. @@ -248,7 +248,7 @@ pub fn poseidon_with_domain(d: Field, inputs: [Field; N]) -> Field { // ################## KEY DERIVATION ################## -/// Viewing key. Section 4.2. `vk = Poseidon2(VK, sk, addr_f)`. +/// Viewing key. docs/protocol/keys-and-commitments.md#viewing-key. `vk = Poseidon2(VK, sk, addr_f)`. /// /// The `addr_f` parameter (the contract's address as a `Field`) binds the /// viewing key -- and therefore every proof that constrains `vk` (R2/W2/T2/S2) @@ -258,13 +258,13 @@ pub fn vk_from_sk(sk: Field, addr_f: Field) -> Field { poseidon_with_domain(domain::VIEWING_KEY, [sk, addr_f]) } -/// Public viewing key `PVK = vk*H`. Section 4.3. The recipient's ECDH long-term key +/// Public viewing key `PVK = vk*H`. docs/protocol/keys-and-commitments.md#public-viewing-key. The recipient's ECDH long-term key /// for incoming transfers. pub fn pvk_from_vk(vk: Field) -> EmbeddedCurvePoint { scalar_mul(vk, H) } -/// Delegation viewing key. Section 4.4. `dvk_i = Poseidon2(DVK, vk, op_i)`. +/// Delegation viewing key. docs/protocol/keys-and-commitments.md#delegation-viewing-key. `dvk_i = Poseidon2(DVK, vk, op_i)`. pub fn dvk_from_vk_op(vk: Field, op_i: Field) -> Field { poseidon_with_domain(domain::DELEGATION_VIEWING_KEY, [vk, op_i]) } @@ -308,7 +308,7 @@ pub fn encrypt_allowance(v_a: Field, dvk: Field, sigma_a: Field) -> Field { v_a + poseidon_with_domain(domain::ENCRYPTED_ALLOWANCE, [dvk, sigma_a]) } -/// Delegation-key escrow mask for the spender handoff at `set_spender`. Section 7.11. +/// Delegation-key escrow mask for the spender handoff at `set_spender`. docs/protocol/operations/set-spender.md#delegation-key-escrow. /// `escrowed_dvk = dvk + Poseidon2(ESC_DVK, s, op_i)` where `s = ecdh(r_e, Y_op)`. 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]) @@ -317,7 +317,7 @@ pub fn encrypt_esc_dvk(dvk: Field, s: Field, op_i: Field) -> Field { /// Auditor-side allowance-blinding escrow at `set_spender`. Constraint S14. /// `r_a_tilde_aud_s = r_a + Poseidon2(ESC_ALLOW_R_AUD, s_a_s, op_i)` where /// `s_a_s = ecdh(r_e, K_aud_s)` is the *owner-auditor* shared scalar. -/// Section 8.5 specifies the construction. +/// docs/protocol/auditing.md#spender-allowance-auditing specifies the construction. pub fn encrypt_esc_allow_r_auditor(r_a: Field, s_a_s: Field, op_i: Field) -> Field { r_a + poseidon_with_domain(domain::ESCROWED_ALLOWANCE_BLINDING_AUDITOR, [s_a_s, op_i]) } @@ -328,9 +328,9 @@ pub fn encrypt_esc_allow_r_auditor(r_a: Field, s_a_s: Field, op_i: Field) -> Fie /// `s` is the ECDH shared-secret scalar produced by [`ecdh`] -- and returns /// the first two rate-elements of the post-permutation state in order. /// -/// Implements `SpongeSqueeze_2(delta, s, sigma)` from Section 2.5. The canonical +/// Implements `SpongeSqueeze_2(delta, s, sigma)` from docs/protocol/primitives.md#poseidon2-hash. 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 +/// mask, fixed by docs/protocol/operations/ and docs/protocol/auditing.md. 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. /// @@ -347,7 +347,7 @@ pub fn sponge_squeeze_2(d: Field, s: Field, sigma: Field) -> [Field; 2] { /// Poseidon2 sponge with three squeezes. Absorbs `(d, s, sigma)` and returns /// the first three rate-elements of the post-permutation state in order. /// -/// Implements `SpongeSqueeze_3(delta, s, sigma)` from Section 2.5. As in +/// Implements `SpongeSqueeze_3(delta, s, sigma)` from docs/protocol/primitives.md#poseidon2-hash. As in /// [`sponge_squeeze_2`], lane[0] is the amount mask and lane[1] the balance / /// allowance / randomness mask. **The lane[2] slot is the sender-auditor /// blinding escrow**: it carries the blinding of a commitment the operation diff --git a/packages/tokens/src/confidential/circuits/lib/src/tests.nr b/packages/tokens/src/confidential/circuits/lib/src/tests.nr index 41592bed3..3fdff8eeb 100644 --- a/packages/tokens/src/confidential/circuits/lib/src/tests.nr +++ b/packages/tokens/src/confidential/circuits/lib/src/tests.nr @@ -221,7 +221,7 @@ fn sponge_empty_input_applies_squeeze_permutation() { #[test] fn sponge_squeeze_2_first_matches_poseidon_with_domain() { - // SpongeSqueeze_n shares a single absorb (Section 2.5); the single-squeeze + // SpongeSqueeze_n shares a single absorb (docs/protocol/primitives.md#poseidon2-hash); the single-squeeze // form is just `poseidon_with_domain(d, [s, sigma])`, and it must equal // the first output of `sponge_squeeze_2` on the same absorb. If this // diverges, a circuit doing the single-squeeze form cannot be substituted @@ -288,7 +288,7 @@ fn sponge_squeeze_2_outputs_distinct() { // 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). + // (docs/protocol/auditing.md#per-transfer-auditor-ciphertexts, the recipient-auditor ciphertext pair). let d: Field = domain::AUDITOR_SENDER; let s: Field = 0x42; let sigma: Field = 0x07; @@ -492,7 +492,7 @@ fn fixtures_match_testdata() { == 0x15f3b867cbe94f0b6371e46752431be550f907ba8448c6191b7ba1f47c2cb83e, ); - // Per-channel auditor sponge (v0.6, Section 2.5). Values pinned below are + // Per-channel auditor sponge (v0.6, docs/protocol/primitives.md#poseidon2-hash). Values pinned below are // produced by `nargo test print_fixtures` against the same fixture inputs. let ss2_s = sponge_squeeze_2(domain::AUDITOR_SENDER, s_ecdh, sigma); assert(ss2_s[0] == SPONGE_SQUEEZE_2_AUDITOR_SENDER_0); @@ -503,7 +503,7 @@ fn fixtures_match_testdata() { // The lane[0] output is the single-squeeze funnel: the absorb fits one // rate-3 block, so a tag used in both modes would collapse one onto the - // other (Section 2.5 mode exclusivity). + // other (docs/protocol/primitives.md#mode-exclusivity). assert( poseidon_with_domain(domain::AUDITOR_SENDER, [s_ecdh, sigma]) == SPONGE_SQUEEZE_2_AUDITOR_SENDER_0, diff --git a/packages/tokens/src/confidential/circuits/register/src/main.nr b/packages/tokens/src/confidential/circuits/register/src/main.nr index af84eb932..5c1b26caf 100644 --- a/packages/tokens/src/confidential/circuits/register/src/main.nr +++ b/packages/tokens/src/confidential/circuits/register/src/main.nr @@ -2,7 +2,7 @@ use stellar_confidential_lib::{H, pvk_from_vk, scalar_mul, vk_from_sk}; mod tests; -// Register circuit -- design doc Section 7.2. +// Register circuit -- docs/protocol/operations/register.md. // // Constraints // ----------- @@ -17,7 +17,7 @@ mod tests; // On-curve checks for Y and PVK are not needed: both are bound to in-circuit // `scalar_mul` outputs, which are on-curve by construction (Noir's embedded- // curve ops cannot produce an off-curve Grumpkin point).See design doc -// Section 10.8 for the system-wide point-validation doctrine. +// docs/protocol/proof-system.md#on-chain-point-arithmetic for the system-wide point-validation doctrine. // // Public inputs // ------------- diff --git a/packages/tokens/src/confidential/circuits/set_spender/src/main.nr b/packages/tokens/src/confidential/circuits/set_spender/src/main.nr index ec74eea28..e6838fe40 100644 --- a/packages/tokens/src/confidential/circuits/set_spender/src/main.nr +++ b/packages/tokens/src/confidential/circuits/set_spender/src/main.nr @@ -7,7 +7,7 @@ use std::embedded_curve_ops::EmbeddedCurvePoint; mod tests; -// SetSpender circuit -- design doc Section 7.7. +// SetSpender circuit -- docs/protocol/operations/set-spender.md. // // Constraints // ----------- @@ -19,11 +19,11 @@ mod tests; // 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 +// (docs/protocol/primitives.md#integer-embedding-and-range-proofs); the // [0, 2^127) bound is // the SEP-41 i128 // non-negative range -// (Section 3.4). +// (docs/protocol/system-model.md#underlying-token-assumptions). // S9 r' = Poseidon2(delta_spend_r, vk, sigma) Deterministic // randomness for the // new spendable @@ -47,7 +47,7 @@ mod tests; // 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 +// Expanded per docs/protocol/operations/set-spender.md#delegation-key-escrow 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 @@ -64,15 +64,15 @@ mod tests; // Auditor-side escrow // of the allowance // blinding S6 derived -// (Section 8.5). +// (docs/protocol/auditing.md#spender-allowance-auditing). // 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) +// ECDH; docs/protocol/operations/set-spender.md#delegation-key-escrow). +// Auditor block (owner-auditor visibility, docs/protocol/auditing.md#per-transfer-auditor-ciphertexts) // S_a1 R_e = r_e * H Ephemeral key for // auditor ECDH (also // the escrow ephemeral @@ -94,13 +94,13 @@ mod tests; // blinding, over S9's // r'. // -// Point-validation doctrine (Section 10.8) +// Point-validation doctrine (docs/protocol/proof-system.md#on-chain-point-arithmetic) // ---------------------------------------- // 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 spender registered (R1, Section 7.2) and is loaded by -// the contract from trusted storage, so it is path (2) of Section 10.8 and the +// on-curve when the spender registered (R1, docs/protocol/operations/register.md) and is loaded by +// the contract from trusted storage, so it is path (2) of docs/protocol/proof-system.md#on-chain-point-arithmetic and the // contract 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 @@ -120,7 +120,7 @@ mod tests; // Spender must be registered. // 6 op_i op_i address_to_field(`spender`), // computed off-circuit by the -// contract (Section 2.7). +// contract (docs/protocol/primitives.md#address-to-field-encoding). // 7 addr_f addr_f `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`. @@ -131,7 +131,7 @@ mod tests; // 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 +// (docs/protocol/operations/set-spender.md#delegation-key-escrow); forced equal // to R_e.x by S12(a). // 15 escrowed_dvk_cipher dvk_cipher Prover-supplied; second limb // of escrowed_dvk; stored. @@ -218,7 +218,7 @@ fn main( 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 + // S4 -- docs/protocol/primitives.md#integer-embedding-and-range-proofs 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>(); @@ -259,12 +259,12 @@ fn main( 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 + // the auditor-block ephemeral. docs/protocol/operations/set-spender.md#delegation-key-escrow: "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 + // Y_op is path (2) of docs/protocol/proof-system.md#on-chain-point-arithmetic: bound to scalar_mul(sk_op, H) when // the spender registered (R1), trusted on read from the contract. let y_op = EmbeddedCurvePoint { x: y_op_x, y: y_op_y, is_infinite: false }; @@ -275,7 +275,7 @@ fn main( 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). + // K_aud_s point validation (docs/protocol/proof-system.md#on-chain-point-arithmetic: 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); diff --git a/packages/tokens/src/confidential/circuits/set_spender/src/tests.nr b/packages/tokens/src/confidential/circuits/set_spender/src/tests.nr index 3e10e4190..52219edc4 100644 --- a/packages/tokens/src/confidential/circuits/set_spender/src/tests.nr +++ b/packages/tokens/src/confidential/circuits/set_spender/src/tests.nr @@ -20,7 +20,7 @@ use std::embedded_curve_ops::EmbeddedCurvePoint; // `set_spender_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 -// contract computes it off-circuit via `address_to_field` (Section 2.7). +// contract computes it off-circuit via `address_to_field` (docs/protocol/primitives.md#address-to-field-encoding). global SK: Field = 0xdead; global ADDR_F: Field = 0xbeef; global SIGMA: Field = 0x01; @@ -431,7 +431,7 @@ fn rejects_under_funded_escrow() { #[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 + // 2^127 is exactly the boundary S4 rules out (docs/protocol/primitives.md#integer-embedding-and-range-proofs and docs/protocol/system-model.md#underlying-token-assumptions -- 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>()` @@ -979,7 +979,7 @@ 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 + // S12(c) fails. (Y_op is path (2) of docs/protocol/proof-system.md#on-chain-point-arithmetic: trusted on read from // the contract, so the in-circuit consequence of swapping it is purely // through the escrow-cipher mask.) run_main( @@ -1456,7 +1456,7 @@ fn repeated_r_e_and_sigma_repeat_every_owner_channel_pad() { // Change a plaintext under the repeated pad and the difference survives // the encryption in the clear. This is the leak the per-operation - // freshness of r_e exists to prevent (Section 5.3); the circuit cannot + // freshness of r_e exists to prevent (docs/protocol/keys-and-commitments.md#ecdh-derived-blinding); the circuit cannot // catch it, because a reused r_e is a perfectly well-formed witness. let v_a_2: Field = V_A + 25; assert((v_a_2 + m_s[0]) - V_TILDE_AUD_S == 25); diff --git a/packages/tokens/src/confidential/circuits/spender_transfer/src/main.nr b/packages/tokens/src/confidential/circuits/spender_transfer/src/main.nr index d7b6c0953..fe25d95ef 100644 --- a/packages/tokens/src/confidential/circuits/spender_transfer/src/main.nr +++ b/packages/tokens/src/confidential/circuits/spender_transfer/src/main.nr @@ -6,7 +6,7 @@ use std::embedded_curve_ops::EmbeddedCurvePoint; mod tests; -// SpenderTransfer circuit -- design doc Section 7.8. +// SpenderTransfer circuit -- docs/protocol/operations/spender-transfer.md. // // Constraints // ----------- @@ -24,12 +24,12 @@ mod tests; // contract-binding // anchor, see below). // O4 v_a, v_transfer, v_a - v_transfer in [0, 2^127) Range validity -// (Section 2.6); the +// (docs/protocol/primitives.md#integer-embedding-and-range-proofs); 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) +// (docs/protocol/system-model.md#underlying-token-assumptions). +// Recipient ECDH + transfer commitment (docs/protocol/keys-and-commitments.md#ecdh-derived-blinding, docs/protocol/keys-and-commitments.md#anti-poisoning-constraint) // O5 s = ecdh(r_e, PVK_recipient) Recipient ECDH // shared-secret // scalar (2.4). @@ -51,7 +51,7 @@ mod tests; // pads to the salt the // previous transfer // already used -// (Section 6.2 +// (docs/protocol/account-state.md#spender-delegation // *Transfer nonce*). // O10 r_a' = Poseidon2(delta_allow_r, dvk_i, // sigma_a') New allowance @@ -71,7 +71,7 @@ mod tests; // to a constant // function of // sigma_a'. -// Auditor block (dual-channel visibility, Section 8.1 + Section 8.4) +// Auditor block (dual-channel visibility, docs/protocol/auditing.md#per-transfer-auditor-ciphertexts + docs/protocol/auditing.md#spender-transfer-auditing) // O_a1 s_{a,r} = ecdh(r_e, K_aud_r) Recipient-auditor // ECDH shared scalar // (reuses r_e). @@ -108,7 +108,7 @@ mod tests; // allowance blinding, // over O10's r_a'. // -// Contract binding (Section 7.8 final paragraph) +// Contract binding (docs/protocol/operations/spender-transfer.md#contract-binding) // --------------------------------------------- // Unlike owner-initiated circuits, SpenderTransfer does NOT constrain the vk // derivation -- the spender has no access to the owner's sk. Contract binding @@ -119,26 +119,26 @@ mod tests; // input and was constructed with contract-specific randomness, a proof // generated against one contract's `C_a` cannot verify against another's. // -// Channel nonce (Section 6.2 *Transfer nonce*) +// Channel nonce (docs/protocol/account-state.md#transfer-nonce) // -------------------------------------------- // The stored `sigma_a` opens the current allowance (O3) and nothing else. // Every pad this circuit derives absorbs the prover-chosen replacement // `sigma_a'` instead: the recipient ECDH chain (O7 / O9), both auditor-channel // sponges (O_a2 / O_a6, hence O_a9's lane[2] pad), and the new-allowance -// derivations (O10 / O12). Section 6.2 *Transfer nonce* states why the +// derivations (O10 / O12). docs/protocol/account-state.md#transfer-nonce states why the // stored salt cannot key the pads. `sigma_a'` is constrained only against // prover-supplied state, so a retry re-samples it freely. O14 rejects the one // adjacent collision; global non-repetition of `sigma_a'` over the -// delegation's lifetime is a client obligation (SDK.md Section 10.4). +// delegation's lifetime is a client obligation (docs/sdk/wallet.md#salt-freshness). // -// Point-validation doctrine (Section 10.8) +// Point-validation doctrine (docs/protocol/proof-system.md#on-chain-point-arithmetic) // ---------------------------------------- // Y_op, C_a, C_a', C_transfer, 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 contract from trusted storage, so it is -// path (2) of Section 10.8 and the circuit consumes it without re-checking. +// docs/protocol/operations/register.md) and is loaded by the contract from trusted storage, so it is +// path (2) of docs/protocol/proof-system.md#on-chain-point-arithmetic 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 @@ -165,7 +165,7 @@ mod tests; // 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 -// spender's (Section 7.8). +// spender's (docs/protocol/operations/spender-transfer.md). // 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. @@ -259,7 +259,7 @@ fn main( 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 + // O4 -- docs/protocol/primitives.md#integer-embedding-and-range-proofs 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>(); @@ -272,13 +272,13 @@ fn main( 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 + // O5 -- PVK_recipient is path (2) of docs/protocol/proof-system.md#on-chain-point-arithmetic (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 = ecdh(r_e, pvk_recipient); - // O7 (anti-poisoning, Section 5.4) + // O7 (anti-poisoning, docs/protocol/keys-and-commitments.md#anti-poisoning-constraint) let r_transfer = derive_transfer_blind(s, sigma_a_new); // O8 @@ -305,7 +305,7 @@ fn main( 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). + // K_aud_r / K_aud_s point validation (docs/protocol/proof-system.md#on-chain-point-arithmetic: 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 }; diff --git a/packages/tokens/src/confidential/circuits/spender_transfer/src/tests.nr b/packages/tokens/src/confidential/circuits/spender_transfer/src/tests.nr index efcda52cd..d4a2d32ac 100644 --- a/packages/tokens/src/confidential/circuits/spender_transfer/src/tests.nr +++ b/packages/tokens/src/confidential/circuits/spender_transfer/src/tests.nr @@ -19,7 +19,7 @@ use std::embedded_curve_ops::EmbeddedCurvePoint; // transfer) so the three circuits share an ecosystem of pinned curve points // for cross-circuit tooling. DVK_I is a placeholder field; in production it // comes from S5 of SetSpender and the spender decrypts it from -// `escrowed_dvk` (Section 7.11). +// `escrowed_dvk` (docs/protocol/operations/set-spender.md#delegation-key-escrow). global SK_OP: Field = 0xdead; global DVK_I: Field = 0x44767669; // ASCII "Dvki" global SIGMA_A: Field = 0x01; @@ -397,7 +397,7 @@ fn full_allowance_transfer() { // which is a valid commitment to zero. a_tilde' and a_tilde_aud_s both // bind to 0 (plus mask) so the owner's auditor sees the post-transfer // allowance reach zero. The delegation entry isn't deleted (that's - // revoke_spender's job, Section 7.9) -- it now escrows zero value. + // revoke_spender's job, docs/protocol/operations/revoke-spender.md) -- it now escrows zero value. let pvk_recipient = EmbeddedCurvePoint { x: PVK_RECIPIENT_X, y: PVK_RECIPIENT_Y, is_infinite: false }; let k_aud_r = EmbeddedCurvePoint { x: K_AUD_R_X, y: K_AUD_R_Y, is_infinite: false }; @@ -494,7 +494,7 @@ fn rejects_wrong_dvk_against_contract() { // reading might worry that an spender could reuse a proof's witness // across contracts. The defense lives in O3. // - // Mechanism (Section 7.8 final paragraph): + // Mechanism (docs/protocol/operations/spender-transfer.md#contract-binding): // * SetSpender derived dvk_i from a contract-specific vk: S2/S5 chain // vk = Poseidon2(VK, sk_owner, addr_f), then dvk_i = Poseidon2(DVK, vk, // op_i). A different `addr_f` produces a different vk, and therefore a @@ -671,7 +671,7 @@ fn rejects_v_a_new_out_of_range() { #[test(should_fail)] fn rejects_v_a_out_of_range() { - // 2^127 is exactly the boundary O4 rules out (Section 2.6 / 3.4 -- SEP-41 + // 2^127 is exactly the boundary O4 rules out (docs/protocol/primitives.md#integer-embedding-and-range-proofs and docs/protocol/system-model.md#underlying-token-assumptions -- SEP-41 // i128 non-negative range is [0, 2^127), so 2^127 is the smallest value // that must fail). With v_a_huge - V_TRANSFER = 2^127 - 100 still in [0, 2^127), // the v_a_new range check passes; only the `v_a.assert_max_bit_size<127>()` @@ -826,7 +826,7 @@ fn rejects_wrong_recipient_pvk() { #[test(should_fail)] fn rejects_poisoned_c_transfer() { - // Anti-poisoning (Section 5.4): a prover that commits C_transfer with arbitrary + // Anti-poisoning (docs/protocol/keys-and-commitments.md#anti-poisoning-constraint): a prover that commits C_transfer with arbitrary // blinding -- here r_transfer + 1 -- desynchronizes the recipient's accumulated // blinding. O7 + O8 together close this attack: r_transfer is uniquely // determined by Poseidon2(delta_transfer_blind, s, sigma_a'), so any C_transfer not @@ -1574,7 +1574,9 @@ fn repeated_r_e_and_sigma_a_new_repeat_every_channel_pad() { // publish v_tilde values whose difference is the amount delta. The circuit // cannot catch this -- a reused r_e is a well-formed witness and O14 sees // only the adjacent salt -- so it is the honest client's obligation - // (Section 5.3, Section 9.6, SDK.md Section 10.4). + // (docs/protocol/keys-and-commitments.md#ecdh-derived-blinding, + // docs/protocol/security.md#revert-safety, + // docs/sdk/wallet.md#salt-freshness). let v_transfer_2: Field = V_TRANSFER + 40; assert((v_transfer_2 + m_s[0]) - V_TILDE_AUD_S == 40); } diff --git a/packages/tokens/src/confidential/circuits/transfer/src/main.nr b/packages/tokens/src/confidential/circuits/transfer/src/main.nr index 32687eb77..b652748a6 100644 --- a/packages/tokens/src/confidential/circuits/transfer/src/main.nr +++ b/packages/tokens/src/confidential/circuits/transfer/src/main.nr @@ -6,7 +6,7 @@ use std::embedded_curve_ops::EmbeddedCurvePoint; mod tests; -// Transfer circuit -- design doc Section 7.6. +// Transfer circuit -- docs/protocol/operations/transfer.md. // // Constraints // ----------- @@ -19,11 +19,11 @@ mod tests; // sender's current // spendable balance. // T4 v_A, v_transfer, v_A - v_transfer in [0, 2^127) Range validity -// (Section 2.6); the +// (docs/protocol/primitives.md#integer-embedding-and-range-proofs); the // [0, 2^127) bound is // the SEP-41 i128 // non-negative range -// (Section 3.4). +// (docs/protocol/system-model.md#underlying-token-assumptions). // T10 r_A' = Poseidon2(delta_spend_r, vk_A, sigma) Deterministic // randomness for the // new sender balance. @@ -33,7 +33,7 @@ mod tests; // + Poseidon2(delta_enc_bal, vk_A, sigma) // Encrypted balance // scalar (emitted). -// Recipient ECDH + transfer commitment (Section 5.3, 5.4) +// Recipient ECDH + transfer commitment (docs/protocol/keys-and-commitments.md#ecdh-derived-blinding, docs/protocol/keys-and-commitments.md#anti-poisoning-constraint) // T5 s = ecdh(r_e, PVK_B) Recipient ECDH // shared-secret // scalar (2.4). @@ -52,7 +52,7 @@ mod tests; // T13 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) +// Auditor block (dual-channel visibility, docs/protocol/auditing.md#per-transfer-auditor-ciphertexts) // T_a1 s_{a,r} = ecdh(r_e, K_aud_r) Recipient-auditor // ECDH shared scalar // (reuses r_e). @@ -82,13 +82,13 @@ mod tests; // spendable blinding, // over T10's r_A'. // -// Point-validation doctrine (Section 10.8) +// Point-validation doctrine (docs/protocol/proof-system.md#on-chain-point-arithmetic) // ---------------------------------------- // Y_A, C_spend', C_transfer, R_e, S, S_{a,r}, and S_{a,s} are bound to in-circuit // multi_scalar_mul outputs (T1, T11, T8, T6, T5, T_a1, T_a5) and are therefore // on-curve by construction -- no explicit check needed. PVK_B was constrained -// on-curve when the recipient registered (R3, Section 7.2) and is loaded by -// the contract from trusted storage, so it is path (2) of Section 10.8 and the +// on-curve when the recipient registered (R3, docs/protocol/operations/register.md) and is loaded by +// the contract from trusted storage, so it is path (2) of docs/protocol/proof-system.md#on-chain-point-arithmetic and the // contract trusts it on read. K_aud_r and K_aud_s are public-input keys // consumed by ECDH multiplications (path (3)); the verifier doesn't check // curve membership and an off-curve auditor key would break the soundness of @@ -193,7 +193,7 @@ fn main( assert(c_spend_derived.x == c_spend_x); assert(c_spend_derived.y == c_spend_y); - // T4 -- Section 2.6 spells out the 127-bit decomposition / recomposition + // T4 -- docs/protocol/primitives.md#integer-embedding-and-range-proofs 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>(); @@ -206,12 +206,12 @@ fn main( assert(r_e_derived.x == r_e_x); assert(r_e_derived.y == r_e_y); - // T5 -- PVK_B is path (2) of Section 10.8 (proof-constrained at the + // T5 -- PVK_B is path (2) of docs/protocol/proof-system.md#on-chain-point-arithmetic (proof-constrained at the // recipient's registration, trusted on read). let pvk_b = EmbeddedCurvePoint { x: pvk_b_x, y: pvk_b_y, is_infinite: false }; let s = ecdh(r_e, pvk_b); - // T7 (anti-poisoning, Section 5.4) + // T7 (anti-poisoning, docs/protocol/keys-and-commitments.md#anti-poisoning-constraint) let r_transfer = derive_transfer_blind(s, sigma); // T8 @@ -235,7 +235,7 @@ fn main( let b_tilde_derived = encrypt_balance(v_new, vk, sigma); assert(b_tilde_derived == b_tilde); - // K_aud_r / K_aud_s point validation (Section 10.8: public-input keys). + // K_aud_r / K_aud_s point validation (docs/protocol/proof-system.md#on-chain-point-arithmetic: 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 }; diff --git a/packages/tokens/src/confidential/circuits/transfer/src/tests.nr b/packages/tokens/src/confidential/circuits/transfer/src/tests.nr index 3003de492..91b3a3c1c 100644 --- a/packages/tokens/src/confidential/circuits/transfer/src/tests.nr +++ b/packages/tokens/src/confidential/circuits/transfer/src/tests.nr @@ -419,7 +419,7 @@ fn rejects_under_funded_transfer() { #[test(should_fail)] fn rejects_v_out_of_range() { - // 2^127 is exactly the boundary T4 rules out (Section 2.6 / 3.4 -- SEP-41 + // 2^127 is exactly the boundary T4 rules out (docs/protocol/primitives.md#integer-embedding-and-range-proofs and docs/protocol/system-model.md#underlying-token-assumptions -- SEP-41 // i128 non-negative range is [0, 2^127), so 2^127 is the smallest value // that must fail). With v_huge - V_TRANSFER = 2^127 - 100 still in [0, 2^127), // the v_new range check passes; only the `v.assert_max_bit_size<127>()` @@ -710,7 +710,7 @@ fn rejects_wrong_recipient_pvk() { #[test(should_fail)] fn rejects_poisoned_c_transfer() { - // Anti-poisoning (Section 5.4): a sender that commits C_transfer with arbitrary + // Anti-poisoning (docs/protocol/keys-and-commitments.md#anti-poisoning-constraint): a sender that commits C_transfer with arbitrary // blinding -- here r_transfer' = canonical r_transfer + 1 -- desynchronizes the // recipient's accumulated blinding. T7 + T8 together close this attack: // r_transfer is uniquely determined by Poseidon2(delta_transfer_blind, s, sigma), diff --git a/packages/tokens/src/confidential/circuits/withdraw/src/main.nr b/packages/tokens/src/confidential/circuits/withdraw/src/main.nr index ce44bce42..e50b95c11 100644 --- a/packages/tokens/src/confidential/circuits/withdraw/src/main.nr +++ b/packages/tokens/src/confidential/circuits/withdraw/src/main.nr @@ -6,7 +6,7 @@ use std::embedded_curve_ops::EmbeddedCurvePoint; mod tests; -// Withdraw circuit -- design doc Section 7.5. +// Withdraw circuit -- docs/protocol/operations/withdraw.md. // // Constraints // ----------- @@ -17,11 +17,11 @@ mod tests; // 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 +// (docs/protocol/primitives.md#integer-embedding-and-range-proofs); the // [0, 2^127) bound is // the SEP-41 i128 // non-negative range -// (Section 3.4) +// (docs/protocol/system-model.md#underlying-token-assumptions) // W5 r' = Poseidon2(delta_spend_r, vk, sigma) Deterministic // randomness for the // new balance. @@ -36,7 +36,7 @@ mod tests; // would collapse m_b to // a constant function // of sigma. -// Auditor block (sender-auditor visibility, Section 8.1) +// Auditor block (sender-auditor visibility, docs/protocol/auditing.md#per-transfer-auditor-ciphertexts) // W_a1 R_e = r_e * H Ephemeral key for // auditor ECDH. // W_a2 s_{a,s} = ecdh(r_e, K_{aud,s}) Sender-auditor ECDH @@ -57,7 +57,7 @@ mod tests; // blinding, over W5's r' // (emitted). // -// Point-validation doctrine (Section 10.8) +// Point-validation doctrine (docs/protocol/proof-system.md#on-chain-point-arithmetic) // ---------------------------------------- // 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 -- @@ -79,7 +79,7 @@ mod tests; // 6 k_aud_s_y K_aud_s.y `from.auditor_id`. // 7 a a Public withdrawal amount; the contract // pre-checks a >= 0 at the entrypoint -// (Section 3.4), W4 then closes +// (docs/protocol/system-model.md#underlying-token-assumptions), 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_commitment` on success. @@ -140,7 +140,7 @@ fn main( assert(c_spend_derived.x == c_spend_x); assert(c_spend_derived.y == c_spend_y); - // W4 -- Section 2.6 spells out the 127-bit decomposition / recomposition + // W4 -- docs/protocol/primitives.md#integer-embedding-and-range-proofs 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>(); @@ -165,7 +165,7 @@ fn main( 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). + // K_aud_s point validation (docs/protocol/proof-system.md#on-chain-point-arithmetic: 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); diff --git a/packages/tokens/src/confidential/circuits/withdraw/src/tests.nr b/packages/tokens/src/confidential/circuits/withdraw/src/tests.nr index a9911e7dd..6dae4427a 100644 --- a/packages/tokens/src/confidential/circuits/withdraw/src/tests.nr +++ b/packages/tokens/src/confidential/circuits/withdraw/src/tests.nr @@ -234,7 +234,7 @@ fn rejects_under_funded_withdrawal() { #[test(should_fail)] fn rejects_v_out_of_range() { - // 2^127 is exactly the boundary W4 rules out (Section 2.6 / 3.4 -- SEP-41 + // 2^127 is exactly the boundary W4 rules out (docs/protocol/primitives.md#integer-embedding-and-range-proofs and docs/protocol/system-model.md#underlying-token-assumptions -- SEP-41 // i128 non-negative range is [0, 2^127), so 2^127 is the smallest value // that must fail). C_spend must be recomputed against v_huge so W3 // passes and W4 is the load-bearing assertion; downstream constraints diff --git a/packages/tokens/src/confidential/compliance/mod.rs b/packages/tokens/src/confidential/compliance/mod.rs index c89c995a2..51da1fcb5 100644 --- a/packages/tokens/src/confidential/compliance/mod.rs +++ b/packages/tokens/src/confidential/compliance/mod.rs @@ -2,7 +2,7 @@ //! //! Deployer-configurable controls layered on top of the [`ConfidentialToken`]: //! per-account freezing, SAC `authorized()` passthrough, a pluggable external -//! authorization policy, and opt-in seizure. See `docs/COMPLIANCE.md` for the +//! authorization policy, and opt-in seizure. See `docs/compliance.md` for the //! specification. //! //! ## Surface @@ -328,7 +328,7 @@ pub trait ConfidentialClawback: ConfidentialCompliance { /// (registration predates the account entry) but passes policy and SAC. The /// caller-selected `auditor_id` is not restricted; deployments that must /// limit which auditors an account may bind to override `on_register` with a -/// custom gate (see `docs/COMPLIANCE.md` §4.3). +/// custom gate (see `docs/compliance.md#restrict-auditor-selection`). /// * [`on_spender_transfer`](Hooks::on_spender_transfer): `from` and `to` pass /// all three gates; `spender` passes only the policy gate. /// * [`on_set_spender`](Hooks::on_set_spender): the delegating `account` passes @@ -343,8 +343,9 @@ pub trait ConfidentialClawback: ConfidentialCompliance { /// allowance models. /// /// Deployments that need additional behaviour (audit mirroring, rate -/// limiting, or alternative deposit semantics — see `docs/COMPLIANCE.md` §4) -/// can write a custom `Hooks` impl that calls the same primitives. +/// limiting, or alternative deposit semantics — see +/// `docs/compliance.md#customizing-the-hooks-trait`) can write a custom `Hooks` +/// impl that calls the same primitives. pub struct ComplianceHooks; impl Hooks for ComplianceHooks { diff --git a/packages/tokens/src/confidential/compliance/storage.rs b/packages/tokens/src/confidential/compliance/storage.rs index b7db94519..84b14b311 100644 --- a/packages/tokens/src/confidential/compliance/storage.rs +++ b/packages/tokens/src/confidential/compliance/storage.rs @@ -94,7 +94,7 @@ pub fn is_frozen(e: &Env, account: &Address) -> bool { /// Returns the number of seizures executed against `account`, or `0` when it /// has never been seized from. The value is a public input of the next -/// clawback proof (COMPLIANCE §5.3). +/// clawback proof (`docs/compliance.md#circuit`). /// /// # Arguments /// @@ -231,7 +231,8 @@ pub fn unfreeze(e: &Env, account: &Address) { /// /// The account's clawback nonce is a public input and advances on every /// seizure, so a proof executes at most once even if the commitments are -/// later restored to the values it was built against (COMPLIANCE §5.6). +/// later restored to the values it was built against +/// (`docs/compliance.md#anti-replay-and-the-freeze`). /// /// # Arguments /// @@ -271,7 +272,7 @@ pub fn unfreeze(e: &Env, account: &Address) { /// /// Under `Some(d)`, exactly `amount` is transferred to `d` in this invocation, /// so the pool and the sum of claims move together. `d` passes no compliance -/// gate (COMPLIANCE §5.4). +/// gate (`docs/compliance.md#contract-flow`). /// /// # Security Warning /// @@ -307,7 +308,7 @@ pub fn clawback( }; let nonce = clawback_nonce(e, account); - // PI order (COMPLIANCE §5.3): + // PI order (docs/compliance.md#circuit): // C_spend, C_receive, K_aud, alpha, addr_f, acct_f, dest_f, nonce // // `addr_f`, `acct_f`, `dest_f` and `nonce` are referenced by no @@ -435,7 +436,8 @@ pub fn check_policy(e: &Env, account: &Address, config: &ComplianceConfig) { /// for `account`. A no-op when `config.sac_passthrough` is `false`. /// /// The `authorized` view belongs to the Stellar Asset Contract admin -/// interface, not to generic SEP-41 (DESIGN §3.4). Enabling +/// interface, not to generic SEP-41 +/// (`docs/protocol/system-model.md#underlying-token-assumptions`). Enabling /// `sac_passthrough` over a non-SAC underlying (e.g. a plain SEP-41 token) /// makes this call — and with it every gated operation — trap on the /// missing function. diff --git a/packages/tokens/src/confidential/docs/DESIGN.md b/packages/tokens/src/confidential/docs/DESIGN.md deleted file mode 100644 index 0a2175d7b..000000000 --- a/packages/tokens/src/confidential/docs/DESIGN.md +++ /dev/null @@ -1,828 +0,0 @@ -# Confidential Token - -## Abstract - -We present a confidential token 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 spender transfers), enabling real-time auditing. Account owners can delegate spending to time-limited spenders 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 (this document, §1–§7; continued in [DESIGN_cont.md](./DESIGN_cont.md), §8–§13) -- Confidential Token: [Compliance Extensions](./COMPLIANCE.md) -- Confidential Token: [Selective Disclosure](./SELECTIVE_DISCLOSURE.md) -- Confidential Token: [User Flows Overview](./OVERVIEW.md) -- Confidential Token: [Indexing and Off-Chain State Recovery](./INDEXER.md) -- Confidential Token: [SDK](./SDK.md) - ---- - -## 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 Soroban contracts suite that provides confidential balances and transfers features on top of SEP-41 tokens. It is not an extension to the fungible token standard; it defines a separate contract that holds tokens on behalf of users and manages encrypted state independently. The wrapping approach 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 underlying token. - -### 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, so 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 spenders (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. - -Five Noir/UltraHonk circuits cover registration, withdrawal, confidential transfer, spender transfer, and spender delegation. 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$$. Every secret scalar in the core protocol that is drawn rather than derived ($$\sigma$$, $$\sigma\_a$$) is produced by the **rejection sampling** procedure, which yields a uniform draw from $$\mathbb{F}\_r$$; the extension layers draw one further scalar the same way, $$r\_{\text{disc}}$$ ([SELECTIVE_DISCLOSURE.md](./SELECTIVE_DISCLOSURE.md) §4). - -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. - -**Host deserialiser caveat.** The Soroban host's `bn254_fr_from_u256val` (the underlying primitive of `Bn254Fr::from_bytes`) accepts any 32-byte representative and *silently reduces* values $$x \geq r$$ modulo $$r$$ rather than rejecting them. Two distinct byte strings ($$x$$ and $$x + r$$) therefore deserialise to the same $$\mathbb{F}\_r$$ element, which means a verifier alone cannot distinguish canonical from non-canonical inputs. To keep stored state and emitted events byte-unique per logical value, the contract layer enforces canonicality on every prover-supplied $$\mathbb{F}\_r$$ representative *before* the bytes reach the verifier. - -### 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 (see [DESIGN_cont.md](./DESIGN_cont.md) §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 contract inherits both the generators and the soundness assumption that $$\log\_G H$$ is unknown. Concretely they are `derive_generators("DEFAULT_DOMAIN_SEPARATOR")` at indices 0 and 1; the values are listed in [DESIGN_cont.md](./DESIGN_cont.md) §10.4. - -### 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. The scalar shared secret used as a Poseidon input binds both coordinates of $$S$$: - -$$s = \text{Poseidon}(\delta\_{\text{ecdh}}, S.x, S.y) \in \mathbb{F}\_r$$ - -Throughout this document, $$\text{ECDH}(a, B)$$ denotes this derivation with $$S = a \cdot B$$. - -**Why the y-coordinate is bound.** A point and its negation share an x-coordinate ($$-P = (P.x, -P.y)$$), so an x-only extraction $$s = S.x$$ would satisfy $$\text{ECDH}(r\_e, P) = \text{ECDH}(r\_e, -P)$$ for every scalar. The negated key is reachable: $$-\text{PVK} = (-vk) \cdot H$$ is itself a valid, canonical, on-curve registration (with secret $$-vk$$), so an x-only map from registered keys to shared secrets would be two-to-one, collapsing each $$(vk, -vk)$$ pair onto one shared secret. Absorbing $$S.y$$ into the Poseidon funnel binds the full shared point. The y-coordinate is consumed *inside* the derivation and never carried through any downstream formula, event, or storage encoding; the absorb $$(\delta\_{\text{ecdh}}, S.x, S.y)$$ fills exactly one rate-3 block, so the binding costs a single Poseidon2 permutation. - -### 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 - -**Sponge construction.** Every Poseidon2 value in this specification is produced by the following sponge over $$\mathbb{F}\_r$$ at width $$t = 4$$, rate 3, capacity 1. - -1. Let $$M$$ be the number of absorbed field elements, counting the leading domain tag. Set $$\text{iv} = M \cdot 2^{64}$$. -2. Initialise the state to $$[0, 0, 0, \text{iv}]$$, placing the IV in the capacity lane $$\text{state}[3]$$. -3. For each full block of three inputs, **add** them into $$\text{state}[0], \text{state}[1], \text{state}[2]$$ -- addition in $$\mathbb{F}\_r$$, not assignment -- then apply the permutation. -4. If $$M$$ is not a multiple of three, add the remaining inputs into $$\text{state}[0]$$ upward and apply one further permutation. This trailing permutation is also applied when $$M = 0$$, so the empty input hashes to $$\text{permute}([0,0,0,0])[0]$$ rather than to zero. -5. Output $$\text{state}[0]$$. - -The domain tag is always the first element absorbed, so $$\text{Poseidon2}(\delta, x\_1, \ldots, x\_n)$$ throughout this document denotes this sponge applied to $$(\delta, x\_1, \ldots, x\_n)$$. - -**Usage in this system:** - -- Key derivation: $$vk = \text{Poseidon2}(\delta\_{\text{vk}}, sk, \text{addr\\\_f})$$ -- Randomness derivation: $$r = \text{Poseidon2}(\delta\_{\text{spend\\\_r}}, vk, \sigma)$$ -- Symmetric encryption: $$\tilde{v} = v + \text{Poseidon2}(\delta\_{\text{transfer\\\_amount}}, s, \sigma)$$ -- Domain separation: each invocation includes a leading constant $$\delta$$ to prevent cross-context collisions - -**Multi-lane mode for auditor channels.** The per-transfer auditor ciphertexts (Section 8) need several masks from one absorb. Since $$(\delta\_{\text{channel}}, s, \sigma)$$ is exactly one rate-3 block, the masks are taken as **lanes of a single permutation output**, not as sequential squeezes: - -$$\text{SpongeSqueeze}\_n(\delta\_{\text{channel}}, s, \sigma) = \bigl(\text{state}[0], \\, \ldots, \\, \text{state}[n-1]\bigr), \qquad \text{state} = \text{permute}\bigl([\delta\_{\text{channel}}, \\, s, \\, \sigma, \\, 3 \cdot 2^{64}]\bigr)$$ - -where $$s$$ is the ECDH shared scalar of Section 2.4 and $$n \in \\{2, 3\\}$$ is the number of rate lanes read; the capacity lane $$\text{state}[3]$$ is never squeezed. Two channel tags are used: $$\delta\_{\text{aud\\\_s}}$$ for the sender-auditor channel keyed by $$s\_{a,s} = \text{ECDH}(r\_e, K\_{\text{aud,s}})$$, squeezed three-wide, and $$\delta\_{\text{aud\\\_r}}$$ for the recipient-auditor channel keyed by $$s\_{a,r} = \text{ECDH}(r\_e, K\_{\text{aud,r}})$$, squeezed two-wide. No other arity is instantiated. Because the absorb is one block, every width reads a prefix of one permutation output: $$\text{SpongeSqueeze}\_3(\delta, s, \sigma)[i] = \text{SpongeSqueeze}\_2(\delta, s, \sigma)[i]$$ for $$i \in \\{0, 1\\}$$, and $$\text{SpongeSqueeze}\_n(\delta, s, \sigma)[0] = \text{Poseidon2}(\delta, s, \sigma)$$. - -**Lane assignment.** Squeeze order is canonical. Lanes are named by their zero-based index into the squeeze output: `lane[i]` is $$\text{SpongeSqueeze}\_n(\delta, s, \sigma)[i]$$, so a three-wide squeeze yields `lane[0]`, `lane[1]`, and `lane[2]`. `lane[0]` is always an amount mask and `lane[1]` is always a balance, allowance, or randomness mask, fixed per operation by the formulas in Sections 7 and 8. `lane[2]`, present only on the sender-auditor channel, is the **blinding-escrow slot**. It carries the new spendable blinding on the three checkpoint operations (W\_a5, T\_a9, S\_a6) and the new allowance blinding on spender transfers (O\_a9). The Withdraw checkpoint (W\_a3, W\_a5) takes `lane[1]` and `lane[2]` and leaves the amount lane unused, so a checkpoint pad can never coincide with an amount pad. - -**Mode exclusivity.** By the prefix property, the single-output form is `lane[0]` of the multi-lane one, so distinct domain tags (Section 13) are not sufficient on their own: each tag MUST additionally be used in exactly one sponge mode, or the same $$(\delta, s, \sigma)$$ would yield one mode's mask as the other's output. $$\delta\_{\text{aud\\\_s}}$$ and $$\delta\_{\text{aud\\\_r}}$$ are the two multi-lane tags; every other tag in Section 13 is used only with the single-output form above. - -**Pad freshness.** The sponge masks are deterministic in $$(s, \sigma)$$, so reusing the pair across two operations reuses every pad slot they share, and a slot whose plaintext is known in one operation (e.g. a transfer amount known to its recipient) decrypts the other operation's ciphertext in that slot. The canonical slot assignment above limits the blast radius of such reuse to same-slot pairs, but does not eliminate it; provers and wallets MUST use a fresh $$(r\_e, \sigma)$$ for every proof. Because $$r\_e$$ is derived from the originator's viewing key and the salt rather than drawn independently (§5.3), a fresh salt is the only thing that makes the pair fresh, and Section 9.6's retry rule is what discharges the requirement. Freshness applies to whichever salt the operation's pads absorb -- $$\sigma$$ for owner-initiated operations, the prover-chosen $$\sigma\_a'$$ for spender transfers (§6.2 *Transfer nonce*). - -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{transfer}}$$ 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{transfer}}$$ such that the circuit verifies: - -1. $$v\_A \in [0, 2^{127})$$ (the opening of $$C\_{\text{spend}}$$) -2. $$v\_{\text{transfer}} \in [0, 2^{127})$$ (the transfer amount) -3. $$v\_A - v\_{\text{transfer}} \in [0, 2^{127})$$ (the new balance) - -then $$v\_A - v\_{\text{transfer}}$$ 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{transfer}} < 2^{127}$$, so if $$v\_A < v\_{\text{transfer}}$$ as integers, then $$v\_A - v\_{\text{transfer}} \pmod{r}$$ would be $$r - (v\_{\text{transfer}} - 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 contract'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{transfer}} \in [0, 2^{127})$$ (constraint T4 / O4). -3. All tokens in the contract 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 host's `address_to_strkey` function is defined for the two `ScAddressType` variants the contract interacts with as actors -- `Account` (Stellar ed25519 account) and `Contract` (Soroban contract instance) -- and errors on every other variant the SDK's `Address` type can wrap. 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 SEP-23 version byte (`0x30` for `Account`, `0x10` 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 whose leading character is correspondingly `G` (`0x47` in ASCII) or `C` (`0x43`). 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 contract, 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{addr\\\_f}$$ | Once, by the contract's `__constructor` over `env.current_contract_address()` | Stored as a single Field in the contract's **instance storage** (§3.5); read on every proof verification | -| $$\text{op}\_i$$ | Per-call, by the contract at `set_spender` over the `spender` argument | Not stored; recomputed each call. The circuit binds it via S5 and absorbs it as the S12 / S14 escrow nonce. | - ---- - -## 3. System Model - -### 3.1 Components - -The system comprises three contracts deployed on Soroban: - -**Token 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.** An UltraHonk verifier storing one verification key per circuit type. Accepts a circuit identifier, serialized public inputs, and a proof blob; returns success or failure. Builds on top of [NethermindEth/rs-soroban-ultrahonk](https://github.com/NethermindEth/rs-soroban-ultrahonk). - -**Auditor contract.** Manages auditor encryption keys independently of the contract. One auditor contract serves multiple token contracts. 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 contract 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 contract, 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 contract 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{contract})$$ - -i.e., the total committed value across all confidential accounts never exceeds the public token balance held by the contract. The deployer's choice of underlying token determines whether that invariant is actually preserved over time. The contract 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 contract address changes only as a result of explicit operations that the contract itself originated. Tokens whose balances change as a function of supply, oracle data, or external triggers break the accounting invariant and are unsupported. -- *No fee-on-transfer.* `token.transfer(from, to, amount)` MUST move exactly `amount` units. A fee deducted in transit would leave the contract's confidential accounting larger than its public backing. -- *Deterministic revert.* A failed `token.transfer` MUST cause the enclosing contract invocation (`deposit` or `withdraw`) to revert atomically, so confidential state is never updated against a token transfer that did not happen. -- *Underlying clawback / freeze / deauthorization.* These are surfaces of the Stellar Asset Contract (`StellarAssetInterface`), not the generic SEP-41 (`TokenInterface`). If the underlying token is a SAC whose issuer can clawback, freeze, or deauthorize the contract's holdings, confidential accounting at the contract layer may temporarily or permanently exceed the contract's accessible backing. This is an operational risk borne by the deployer's choice of underlying token. The token layer offers its own freeze and per-account clawback flows that operate inside the confidential surface; see [COMPLIANCE.md](./COMPLIANCE.md) §2 (contract-level freeze) and §5 (admin + auditor clawback). [COMPLIANCE.md](./COMPLIANCE.md) §2.2 additionally specifies SAC authorization passthrough, which composes the contract's freeze with the issuer's freeze without requiring the admin to mirror state. - -**Non-negativity check.** The contract's public interface uses `i128` end-to-end, matching SEP-41. Every entrypoint that accepts a public amount (`deposit`, `withdraw`) MUST reject `amount < 0` and revert. The in-circuit range constraint (Section 2.6) bounds the same value at $$2^{127}$$ from above; together they pin the contract's value domain to $$[0, 2^{127}) = [0, \text{i128::MAX}]$$, matching SEP-41 exactly. No conversion at the SEP-41 boundary is needed. - -### 3.5 Governance and Upgradeability - -The constructor binds the contract to fixed `admin`, `token`, `verifier`, and `auditor` addresses. It additionally computes and stores `addr_f = address_to_field(env.current_contract_address())` (§2.7) in **instance storage** as a single canonical $$\mathbb{F}\_r$$ Field; this is the value every owner-initiated proof references via constraints R2 / W2 / T2 / S2. The compressed `addr_f` Field is computed once at construction (not recomputed per call) to ensure all proofs across the contract's lifetime bind to the same Field representative of the contract's address. Beyond that, this specification does not prescribe a governance policy for upgrading these components or for rotating per-circuit verification keys. Concrete deployments differ widely in spender structure, regulatory posture, and emergency-response requirements, so these decisions are deliberately left to implementers. - -Questions an implementer must answer: - -- 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, spender delegations, and merges. - -### 4.2 Viewing Key - -$$vk = \text{Poseidon}(\delta\_{\text{vk}}, sk, \text{addr\\\_f})$$ - -A scalar in $$\mathbb{F}\_r$$, unique per $$(sk, \text{addr\\\_f})$$ pair. Enables balance decryption without spending authority, and — through the ephemeral-scalar derivation of §5.3 — reconstruction of the Pedersen openings of transfers the account originated; §9.4 states the full capability of a compromised $$vk$$. Cannot recover $$sk$$ (Poseidon preimage resistance). Because $$\text{addr\\\_f}$$ is bound into the derivation, proofs that constrain $$vk$$ (R2, W2, T2, S2) are inherently bound to the 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{addr\\\_f})$$ and $$Y = sk \cdot H$$, preventing a user from registering an unrelated $$\text{PVK}$$. - -### 4.4 Delegation Viewing Key - -For spender $$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 spender's allowance state in this contract's context ($$vk$$ is contract-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{transfer}}$$ | 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{transfer}}$$ | Transfer amount (private) | -| $$r\_{\text{transfer}}$$ | ECDH-derived blinding factor for $$C\_{\text{transfer}}$$ | -| $$W\_{\text{spend}}, W\_{\text{receive}}$$ | Wallet-side accumulators: $$(v, r)$$ pairs tracking commitment openings | -| $$r\_e$$ | Ephemeral scalar, derived per operation from the $$vk$$ and the operation salt (§5.3) | -| $$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 shared secret $$\text{ECDH}(r\_e, \text{PVK}\_B) = \text{Poseidon}(\delta\_{\text{ecdh}}, S.x, S.y)$$ (§2.4) | -| $$\tilde{v}$$ | Encrypted transfer amount: $$v\_{\text{transfer}} + \text{Poseidon}(\delta\_{\text{transfer\\\_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 8.1). - -### 5.2 Off-Chain 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 the accumulators as follows: - -| 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 = \text{ECDH}(vk, R\_e)$$ (§2.4); derive $$v\_{\text{transfer}} = \tilde{v} - \text{Poseidon}(\delta\_{\text{transfer\\\_amount}}, s, \sigma)$$ and $$r\_{\text{transfer}} = \text{Poseidon}(\delta\_{\text{transfer\\\_blind}}, s, \sigma)$$. Then $$W\_{\text{receive}} \mathrel{+}= (v\_{\text{transfer}}, r\_{\text{transfer}})$$ | -| 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 spender (escrow amount $$a$$) | Proof outputs new commitment. $$W\_{\text{spend}} \leftarrow (v\_s - a, \\; \text{Poseidon}(\delta\_{\text{spend\\\_r}}, vk, \sigma))$$ | -| Revoke spender | Proofless fold. $$W\_{\text{spend}} \mathrel{+}= (v\_a, r\_a)$$, the escrow opening recovered from the event per §7.9 | -| Clawback (compliance extension) | The Merge row, then the event's public `amount` is subtracted from the spendable value ([COMPLIANCE.md](./COMPLIANCE.md) §5.4) | - -The Merge, Revoke spender, and Clawback rows fold openings the wallet already holds, leaving $$W\_{\text{spend}}.r$$ a sum; [DESIGN_cont.md](./DESIGN_cont.md) §10.4 *Post-merge witness availability* covers the case where no circuit can witness it. - -After every owner-initiated operation that produces a proof, $$r\_s$$ resets to a deterministic value. This is the **normalization** property: the spendable balance's blinding factor is always recoverable from $$(vk, \sigma)$$ at spend boundaries. Together with $$\tilde{b}$$, both emitted in the spend-boundary event, each spend boundary forms a **checkpoint** from which the spendable opening $$(v\_s, r\_s)$$ *as of that boundary* is recoverable via a single event lookup, with no replay of the spend history preceding it. $$W\_{\text{receive}}$$ has no such anchor: a checkpoint leaves $$C\_{\text{receive}}$$ untouched, which only `Merge` (§7.4) and `Clawback` reset, so recovering it requires a replay (see Recovery below). - -**Consistency check.** At any time, the wallet can verify its state: $$C\_{\text{spend}} \stackrel{?}{=} v\_s \cdot G + r\_s \cdot H$$ and $$C\_{\text{receive}} \stackrel{?}{=} v\_r \cdot G + r\_r \cdot H$$, where $$C\_{\text{spend}}$$ and $$C\_{\text{receive}}$$ are read from on-chain state. - -**Recovery.** If the wallet loses local state, it recovers from **two anchors**: the account's last **checkpoint** for the spendable side (steps 1-4), and $$T\_0$$ -- its most recent `Merge` or `Clawback` at or before that checkpoint -- for the receiving side (step 5). Since $$W\_{\text{receive}}$$ restarts at $$(0, 0)$$ as of $$T\_0$$, one replay window $$(T\_0, \text{now}]$$ serves both: it rebuilds the receiving side and carries the post-checkpoint proofless folds onto the spendable one (step 6). [DESIGN_cont.md](./DESIGN_cont.md) §9.5 *Why the two anchors compose* gives the argument that they do: - -1. Fetch $$(\tilde{b}, \sigma)$$ from the most recent **checkpoint event** for this account: exactly one of `Withdraw`, `Transfer` (where the account is the `from`), or `SetSpender` -- the three event types carrying a proof-bound $$(\tilde{b}, \sigma)$$ for this account's spendable balance. `Merge`, `RevokeSpender`, and `Clawback` do change $$C\_{\text{spend}}$$ but are not checkpoints: none binds a $$\tilde{b}$$ to the resulting commitment (§7.4 *Encrypted balance*, §7.9 *Encrypted balance*, [COMPLIANCE.md](./COMPLIANCE.md) §5.7), and their effect is absorbed into the next checkpoint. **No-checkpoint case:** if the account has no checkpoint event since `Register`, initialize $$W\_{\text{spend}} \leftarrow (0, 0)$$ and skip to step 5 with $$T\_0$$ = the `Register` event. -2. Recover the spendable balance value: $$v\_s = \tilde{b} - \text{Poseidon}(\delta\_{\text{enc\\\_bal}}, vk, \sigma)$$. -3. Recover the spendable balance blinding: $$r\_s = \text{Poseidon}(\delta\_{\text{spend\\\_r}}, vk, \sigma)$$. -4. Set $$W\_{\text{spend}} \leftarrow (v\_s, r\_s)$$. -5. Locate $$T\_0$$: the account's most recent `Merge` or `Clawback` event at or before the checkpoint of step 1, or its `Register` event if neither exists. Set $$W\_{\text{receive}} \leftarrow (0, 0)$$ as of $$T\_0$$. -6. Replay every event after $$T\_0$$ in ledger order, applying the *Update rules* above with three replay-specific amendments: - - **Checkpoint event**: skip the spendable side -- step 1 already captured it. - - **Revoke spender**: skip if at or before the checkpoint of step 1, which absorbed it; otherwise fold as the table specifies. - - **Self-transfer** (a `Transfer` whose `from` and `to` are both this account): simultaneously a checkpoint and an incoming transfer -- apply the incoming-transfer rule to $$W\_{\text{receive}}$$ as for any other. -7. Verify consistency: $$C\_{\text{spend}} \stackrel{?}{=} W\_{\text{spend}}.v \cdot G + W\_{\text{spend}}.r \cdot H$$ and $$C\_{\text{receive}} \stackrel{?}{=} W\_{\text{receive}}.v \cdot G + W\_{\text{receive}}.r \cdot H$$. - -The window's length is set by how recently the account merged before its latest checkpoint, not by how often it spends; the wallet merge policy that keeps it short in practice is [SDK.md](./SDK.md) §10.8's. In the worst case (funds received but never merged), it extends back to registration. - -**Event durability requirement.** Recovery depends on the wallet being able to locate $$T\_0$$ and retrieve every event after it, the checkpoint event included, in ledger order. Stellar RPC retains event history for a 7-days window only, so a wallet that loses local state after that window cannot recover from RPC alone. The protocol therefore assumes a durable event archive retaining, forever, the full per-account history of every event these rules read. That event set, along with the data model, ingestion contract, retention obligations, and recommended API surface for the archive, is specified in [INDEXER.md](./INDEXER.md) (§3.2 for the set itself). 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 $$\sigma \in \mathbb{F}\_r$$ via the rejection sampling procedure (§2.2), then computes: - -$$r\_e = \text{Poseidon}(\delta\_{\text{eph}}, vk\_A, \sigma)$$ -$$R\_e = r\_e \cdot H$$ -$$S = r\_e \cdot \text{PVK}\_B$$ -$$s = \text{Poseidon}(\delta\_{\text{ecdh}}, S.x, S.y) \in \mathbb{F}\_r \qquad \text{(§2.4)}$$ -$$r\_{\text{transfer}} = \text{Poseidon}(\delta\_{\text{transfer\\\_blind}}, s, \sigma)$$ -$$\tilde{v} = v\_{\text{transfer}} + \text{Poseidon}(\delta\_{\text{transfer\\\_amount}}, s, \sigma)$$ - -where $$v\_{\text{transfer}}$$ is the transfer amount. The transfer commitment is $$C\_{\text{transfer}} = \text{Com}(v\_{\text{transfer}}, r\_{\text{transfer}})$$. 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{transfer}}$$ and $$r\_{\text{transfer}}$$ 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{transfer}}$$ and decrypt $$v\_{\text{transfer}} = \tilde{v} - \text{Poseidon}(\delta\_{\text{transfer\\\_amount}}, s, \sigma)$$, provided they know $$\sigma$$ emitted with the event. The auditor decrypts the transfer amount via a separate ECDH channel (Section 8.1). - -**Deterministic ephemeral scalar.** $$r\_e$$ is derived from the originator's own viewing key and the operation salt. Every operation that has an ephemeral derives it this way: `Transfer`, `Withdraw`, and `SetSpender` from the owner's viewing key, `SpenderTransfer` from the spender's own (§7.8). The derivation MUST be re-attempted with a fresh salt in the negligible case that it yields zero. - -Because $$\sigma$$ is published in the event and $$vk$$ is held by the originator, the derivation lets the originator recompute $$r\_e$$ for any past transfer from the event alone, which is what makes sender-side selective disclosure possible with no per-transfer wallet state ([SELECTIVE_DISCLOSURE.md](./SELECTIVE_DISCLOSURE.md) §7). A transfer whose $$r\_e$$ was sampled and not retained is permanently undisclosable by its sender. No circuit constrains $$r\_e$$ beyond $$R\_e = r\_e \cdot H$$ and $$r\_e \neq 0$$. - -**Why reusing $$r\_e$$ is safe.** A single $$r\_e$$ keys every ECDH channel an operation opens: the recipient's ($$\text{PVK}\_B$$), both auditors' ($$K\_{\text{aud,r}}$$ and $$K\_{\text{aud,s}}$$, [DESIGN_cont.md](./DESIGN_cont.md) §8.1), and, at `set_spender`, the $$dvk\_i$$ escrow to the spender ($$Y\_{\text{op}}$$, §7.11). Three properties separate them. *Distinct shared scalars*: those four counterparty points are independent Grumpkin points, none derivable from another. *Distinct domains*: each channel absorbs its own tag ([DESIGN_cont.md](./DESIGN_cont.md) §13 enumerates them), so masks across channels are independent under the PRF assumption on Poseidon (§3.2). *Fresh per-operation nonce*: every channel sponge re-absorbs the operation salt, which §2.5 *Pad freshness* requires to be fresh. Together they close the standard ECDH key-reuse attack surface. One derivation stands outside the argument: the auditor-side allowance-blinding escrow (S14, $$\delta\_{\text{esc\\\_allow\\\_r\\\_aud}}$$) opens no channel of its own, reusing the sender-auditor shared scalar and absorbing the per-delegation constant $$\text{op}\_i$$ rather than a nonce. Only the distinct-domain leg holds there in its own right; its pad freshness is inherited from $$r\_e$$, hence from the salt ([DESIGN_cont.md](./DESIGN_cont.md) §8.5). - -### 5.4 Anti-Poisoning Constraint - -The transfer circuit enforces that $$C\_{\text{transfer}}$$ was constructed using the ECDH-derived $$r\_{\text{transfer}}$$: - -$$C\_{\text{transfer}} = v\_{\text{transfer}} \cdot G + r\_{\text{transfer}} \cdot H \quad \text{where} \quad r\_{\text{transfer}} = \text{Poseidon}(\delta\_{\text{transfer\\\_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_public_key: BytesN<64>, // Y = sk · H - viewing_public_key: BytesN<64>, // PVK = vk · H - spendable_commitment: BytesN<64>, // C_spend: single Pedersen commitment - receiving_commitment: BytesN<64>, // C_receive: single Pedersen commitment - auditor_id: u32, -} -``` - -**`spending_public_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_commitment`** - -The commitment the owner can spend from. Modified by owner-authorized operations and, in a deployment that enables the optional compliance extension, by its clawback ([COMPLIANCE.md](./COMPLIANCE.md) §5.4). Encoded as a single Grumpkin affine point (64 bytes). - - -**`receiving_commitment`** - -Accumulates incoming deposits and transfers via homomorphic addition. The contract adds to this without any proof from the recipient. Reset to $$\mathcal{O}$$ on merge (§7.4) and on clawback ([COMPLIANCE.md](./COMPLIANCE.md) §5.4). 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 contract 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 spender 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 Spender Delegation - -Spender delegations are stored in persistent storage, keyed by `(owner, spender)`: - -```rust -SpenderDelegation { - allowance_commitment: BytesN<64>, // Single Pedersen commitment - a_tilde: BytesN<32>, // Poseidon-encrypted allowance scalar - escrowed_dvk: BytesN<64>, // ECDH escrow of dvk_i under spender key - allowance_salt: BytesN<32>, - live_until_ledger: u32, -} -``` - -**`allowance_commitment`** - -The spender'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). - -**`a_tilde`** - -Poseidon-encrypted allowance scalar: $$\tilde{a} = v\_a + \text{Poseidon}(\delta\_{\text{enc\\\_allow}}, dvk\_i, \sigma\_a)$$. Enables the spender (who holds $$dvk\_i$$ via `escrowed_dvk`) to read the current allowance without DLP when constructing an `SpenderTransfer` witness. The owner can also read it via $$vk \rightarrow dvk\_i$$. It is emitted alongside `allowance_salt` in the `RevokeSpender` event (§7.9). - -**`escrowed_dvk`** - -$$dvk\_i$$ encrypted under the spender'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 it opens the current allowance: the stored `allowance_commitment` and `a_tilde` are both derived under it. It is not the freshness input for a spender transfer's pads -- its replacement is (*Transfer nonce* below). Set by the owner at `set_spender` and replaced by the spender on every `confidential_transfer_from`. 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, and it is emitted in the `RevokeSpender` event (§7.9). - -**Transfer nonce.** Unlike $$\sigma$$, which an owner samples afresh for every operation, $$\sigma\_a$$ is stored state: a reverted transfer leaves the delegation entry untouched, so the retry is forced to reuse it. Pads keyed to it would repeat $$r\_e$$ and every mask across the two attempts, and a retry that changed the amount would publish the difference in the clear (§2.5). - -A spender transfer avoids this by splitting the salt's two jobs: opening the stored allowance must be deterministic, since the value has to reproduce the randomness the commitment on-chain was built under, while keying pads must be fresh on every attempt. The stored $$\sigma\_a$$ does only the first (O3); every pad the transfer derives, and its ephemeral scalar, absorbs a prover-chosen replacement $$\sigma\_a'$$ instead (§7.8). - -**`live_until_ledger`** - -The ledger number at which the delegation expires. The delegation is live while `ledger.sequence() <= live_until_ledger` and expired once `ledger.sequence() > live_until_ledger`. Checked on every `confidential_transfer_from`. The entry lives in persistent, not temporary, storage: automatic cleanup would destroy escrowed funds. - -The `(owner, spender)` storage entry holds at most one delegation. `set_spender` (Section 7.7) reverts if a delegation already exists for that pair, regardless of whether the existing delegation is past `live_until_ledger`. Expiry only prevents the spender from spending; the escrowed value persists on-chain until `revoke_spender` (Section 7.9) folds it back into the owner's spendable balance. Re-delegating to the same spender therefore requires the sequence: `revoke_spender` then `set_spender`. This rule is what keeps the balance-conservation invariant (Section 9.3) well-defined 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, contract, or auditor those values are supposed to describe. Binding each public input to the correct provenance is the contract's responsibility. If the contract takes a value that should come from trusted state (e.g. the sender's `spending_public_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 contract loads it from -- persistent account storage, the delegation entry, the contract'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 contract itself. The contract 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{addr\\\_f})$$ (viewing key correctly derived, binds proof to contract) | -| 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_public_key` and `account.viewing_public_key` on success | -| $$\text{addr\\\_f}$$ | Loaded from instance storage; set once at construction (§3.5) | -| $$\text{acct\\\_f}$$ | Binds the proof to the registering address that is authenticated with `require_auth()`| - -$$\text{acct\\\_f}$$ is referenced by no circuit constraint; its membership in the public-input set is the binding. The verifier absorbs every public input into the proof transcript, so a proof produced for one account fails verification when the contract assembles the blob for any other address. Without this input, the register proof and its public keys — all published on-chain by a legitimate registration — could be replayed by any caller to create duplicate-key accounts under fresh addresses. - -**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_public_key`, `viewing_public_key`, `auditor_id`, and initializes `spendable_commitment = receiving_commitment = ` $$\mathcal{O}$$. - -**Auditor selection.** The registering account owner chooses `auditor_id` freely: the register proof does not constrain it, and the core validates only that the id exists in the auditor registry. On a shared auditor registry, deployments that must restrict which auditors an account may bind to MUST enforce that restriction in their `Hooks::on_register` implementation — the default `ComplianceHooks::on_register` deliberately does not restrict it. See [COMPLIANCE.md](./COMPLIANCE.md) §4.3 for a worked example. - -### 7.3 Deposit - -Transparent tokens flow from the depositor to the contract via `token.transfer(from, self, amount)`. The amount $$a$$ is public and typed as `i128`. The contract 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` storage entry. 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 is not front-runnable, and incoming transfers cannot invalidate an in-flight spend proof (Proposition 2, Section 9.1; Proposition 3, Section 9.2). - -**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 contract 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{addr\\\_f})$$ (binds proof to contract) | -| 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} = \text{ECDH}(r\_e, K\_{\text{aud,s}})$$ (sender-auditor ECDH shared scalar, §2.4) | -| W\_a3 | $$(\cdot, m\_b, m\_r) = \text{SpongeSqueeze}\_3(\delta\_{\text{aud\\\_s}}, s\_{a,s}, \sigma)$$ (sender-auditor channel masks; `lane[0]`, the amount slot, is unused) | -| W\_a4 | $$\tilde{b}\_{\text{aud,s}} = (v - a) + m\_b$$ (sender-auditor encrypted balance checkpoint) | -| W\_a5 | $$\tilde{r}\_{\text{aud,s}} = r' + m\_r$$ (sender-auditor escrow of the new spendable blinding, over W5's $$r'$$) | - -**Public inputs (16 fields):** - -| Input | Notes | -|:---|:---| -| $$C\_{\text{spend}}$$ | Loaded from `from.spendable_commitment` | -| $$Y$$ | Loaded from `from.spending_public_key` | -| $$\text{addr\\\_f}$$ | 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}}$$, $$\tilde{r}\_{\text{aud,s}}$$ | Prover-supplied, in this order; $$C\_{\text{spend}}'$$ written to `from.spendable_commitment`, 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_commitment` $$= C\_{\text{spend}}'$$, and calls `token.transfer(self, to, a)`. Emits event with $$(R\_e, \sigma, \tilde{b}, \tilde{b}\_{\text{aud,s}}, \tilde{r}\_{\text{aud,s}})$$. - -### 7.6 Confidential Transfer - -The sender (account $$A$$, spending key $$sk\_A$$) transfers a hidden amount $$v\_{\text{transfer}}$$ to recipient $$B$$ (public viewing key $$\text{PVK}\_B$$). - -**Sender computation.** The constraint table below doubles as the specification of what the sender computes: every quantity the prover produces appears there as the equation the circuit enforces on it. Two prover-side steps are not constraints — the sender draws the salt $$\sigma$$ by the §2.2 procedure and derives the ephemeral scalar from it per §5.3. - -**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{addr\\\_f})$$ (binds proof to contract) | -| T3 | Prover knows opening $$(v\_A, r\_A)$$ of $$C\_{\text{spend}}^A$$ | -| T4 | $$v\_A \in [0, 2^{127})$$, $$v\_{\text{transfer}} \in [0, 2^{127})$$, $$v\_A - v\_{\text{transfer}} \in [0, 2^{127})$$ (range validity, Section 2.6) | -| T5 | $$s = \text{ECDH}(r\_e, \text{PVK}\_B)$$ (recipient ECDH shared scalar correctly derived, §2.4) | -| T6 | $$R\_e = r\_e \cdot H$$ (ephemeral key well-formed) | -| T7 | $$r\_{\text{transfer}} = \text{Poseidon}(\delta\_{\text{transfer\\\_blind}}, s, \sigma)$$ (blinding correctly derived) | -| T8 | $$C\_{\text{transfer}} = v\_{\text{transfer}} \cdot G + r\_{\text{transfer}} \cdot H$$ (transfer commitment well-formed) | -| T9 | $$\tilde{v} = v\_{\text{transfer}} + \text{Poseidon}(\delta\_{\text{transfer\\\_amount}}, s, \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{transfer}}) \cdot G + r\_A' \cdot H$$ (new sender balance) | -| T12 | $$\tilde{b} = (v\_A - v\_{\text{transfer}}) + \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} = \text{ECDH}(r\_e, K\_{\text{aud,r}})$$ (recipient-auditor ECDH shared scalar, reuses ephemeral scalar) | -| T\_a2 | $$(m\_{v,r}, m\_{r,r}) = \text{SpongeSqueeze}\_2(\delta\_{\text{aud\\\_r}}, s\_{a,r}, \sigma)$$ (recipient-auditor channel masks) | -| T\_a3 | $$\tilde{v}\_{\text{aud,r}} = v\_{\text{transfer}} + m\_{v,r}$$ (recipient-auditor encrypted transfer amount) | -| T\_a4 | $$\tilde{r}\_{\text{aud,r}} = r\_{\text{transfer}} + 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} = \text{ECDH}(r\_e, K\_{\text{aud,s}})$$ (sender-auditor ECDH shared scalar, reuses ephemeral scalar) | -| T\_a6 | $$(m\_{v,s}, m\_{b,s}, m\_{r,s}) = \text{SpongeSqueeze}\_3(\delta\_{\text{aud\\\_s}}, s\_{a,s}, \sigma)$$ (sender-auditor channel masks) | -| T\_a7 | $$\tilde{v}\_{\text{aud,s}} = v\_{\text{transfer}} + m\_{v,s}$$ (sender-auditor encrypted transfer amount) | -| T\_a8 | $$\tilde{b}\_{\text{aud,s}} = (v\_A - v\_{\text{transfer}}) + m\_{b,s}$$ (sender-auditor encrypted balance checkpoint) | -| T\_a9 | $$\tilde{r}\_{\text{aud,s}} = r\_A' + m\_{r,s}$$ (sender-auditor escrow of the new spendable blinding, over T10's $$r\_A'$$) | - -**Public inputs (25 fields, counting each Grumpkin point as two $$\mathbb{F}\_r$$ coordinates):** - -| Input | Notes | -|:---|:---| -| $$C\_{\text{spend}}^A$$ | Loaded from sender's `spendable_commitment` | -| $$Y\_A$$ | Loaded from sender's `spending_public_key` | -| $$\text{PVK}\_B$$ | Loaded from recipient's `viewing_public_key`. Recipient must be registered. | -| $$\text{addr\\\_f}$$ | 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{transfer}}$$, $$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}}$$, $$\tilde{r}\_{\text{aud,s}}$$ | Prover-supplied, in this order; $$C\_{\text{spend}}'$$ written to sender's `spendable_commitment`, $$C\_{\text{transfer}}$$ added to recipient's `receiving_commitment`, the rest emitted in event | - -**Private witnesses:** $$sk\_A$$, $$vk\_A$$, $$v\_A$$, $$r\_A$$, $$v\_{\text{transfer}}$$, $$r\_e$$. - -**Post-verification:** The contract verifies the proof, then: -- Sets $$A$$`.spendable_commitment` $$= C\_{\text{spend}}'$$ -- Adds to recipient: $$B$$`.receiving_commitment` $$\mathrel{+}= C\_{\text{transfer}}$$ -- 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}}, \tilde{r}\_{\text{aud,s}})$$ - -**Recipient processing.** Upon observing the event, the recipient computes $$s = \text{ECDH}(vk, R\_e)$$, derives amount and blinding. The decryption flow is independent of whether the sender was the owner or a spender. - -### 7.7 Set Spender - -The owner locks funds from their spendable balance into a per-spender escrow. The spender must be a registered account in the contract, so that $$Y\_{\text{op}}$$ (needed for $$dvk\_i$$ escrow) can be looked up from the spender's stored `spending_public_key`. - -**Circuit constraints (SetSpender):** - -| # | Constraint | -|:--|:---| -| S1 | $$Y = sk \cdot H$$ (owner key ownership) | -| S2 | $$vk = \text{Poseidon}(\delta\_{\text{vk}}, sk, \text{addr\\\_f})$$ (binds proof to contract) | -| 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; contract-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) | -| S14 | $$\tilde{r}\_{a,\text{aud,s}} = \text{Poseidon}(\delta\_{\text{esc\\\_allow\\\_r\\\_aud}}, s\_{a,s}, \text{op}\_i) + r\_a$$ | -| S\_a1 | $$R\_e = r\_e \cdot H$$ (ephemeral key for auditor ECDH) | -| S\_a2 | $$s\_{a,s} = \text{ECDH}(r\_e, K\_{\text{aud,s}})$$ (owner-auditor ECDH shared scalar, §2.4) | -| S\_a3 | $$(m\_v, m\_b, m\_r) = \text{SpongeSqueeze}\_3(\delta\_{\text{aud\\\_s}}, s\_{a,s}, \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) | -| S\_a6 | $$\tilde{r}\_{\text{aud,s}} = r' + m\_r$$ (owner-auditor escrow of the new spendable blinding, over S9's $$r'$$) | - -**Public inputs (26 fields):** - -| Input | Notes | -|:---|:---| -| $$C\_{\text{spend}}$$ | Loaded from owner's `spendable_commitment` | -| $$Y$$ | Loaded from owner's `spending_public_key` | -| $$Y\_{\text{op}}$$ | Loaded from spender account's `spending_public_key`. Spender must be registered. | -| $$\text{op}\_i$$ | $$\text{address\\\_to\\\_field}$$(`spender` argument), computed per-call by the contract (§2.7) | -| $$\text{addr\\\_f}$$ | 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}}$$, $$\tilde{r}\_{\text{aud,s}}$$, $$\tilde{r}\_{a,\text{aud,s}}$$ | Prover-supplied, in this order; $$C\_{\text{spend}}'$$ written to owner's `spendable_commitment`, 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_commitment` $$= C\_{\text{spend}}'$$ and stores the `SpenderDelegation`. Emits event with $$(R\_e, \sigma, \tilde{b}, \tilde{v}\_{\text{aud,s}}, \tilde{b}\_{\text{aud,s}}, \tilde{r}\_{\text{aud,s}}, \tilde{r}\_{a,\text{aud,s}})$$. - -### 7.8 Spender Transfer - -The spender transfers from the owner's escrowed allowance to a recipient. - -**Circuit constraints (SpenderTransfer):** - -| # | Constraint | -|:--|:---| -| O1 | $$Y\_{\text{op}} = sk\_{\text{op}} \cdot H$$ (spender 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{transfer}} \in [0, 2^{127})$$, $$v\_a - v\_{\text{transfer}} \in [0, 2^{127})$$ (range validity, Section 2.6) | -| O5 | $$s = \text{ECDH}(r\_e, \text{PVK}\_{\text{recipient}})$$ (recipient ECDH shared scalar, §2.4) | -| O6 | $$R\_e = r\_e \cdot H$$ | -| O7 | $$r\_{\text{transfer}} = \text{Poseidon}(\delta\_{\text{transfer\\\_blind}}, s, \sigma\_a')$$ (transfer blinding) | -| O8 | $$C\_{\text{transfer}} = v\_{\text{transfer}} \cdot G + r\_{\text{transfer}} \cdot H$$ | -| O9 | $$\tilde{v} = v\_{\text{transfer}} + \text{Poseidon}(\delta\_{\text{transfer\\\_amount}}, s, \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{transfer}}) \cdot G + r\_a' \cdot H$$ (new allowance) | -| O12 | $$\tilde{a}' = (v\_a - v\_{\text{transfer}}) + \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}$$) | -| O14 | $$\sigma\_a' \neq \sigma\_a$$ (nonce rotation) | -| O\_a1 | $$s\_{a,r} = \text{ECDH}(r\_e, K\_{\text{aud,r}})$$ (recipient-auditor ECDH shared scalar, reuses ephemeral scalar) | -| O\_a2 | $$(m\_{v,r}, m\_{r,r}) = \text{SpongeSqueeze}\_2(\delta\_{\text{aud\\\_r}}, s\_{a,r}, \sigma\_a')$$ (recipient-auditor channel masks) | -| O\_a3 | $$\tilde{v}\_{\text{aud,r}} = v\_{\text{transfer}} + m\_{v,r}$$ (recipient-auditor encrypted transfer amount) | -| O\_a4 | $$\tilde{r}\_{\text{aud,r}} = r\_{\text{transfer}} + 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} = \text{ECDH}(r\_e, K\_{\text{aud,s}})$$ (owner-auditor ECDH shared scalar, reuses ephemeral scalar) | -| O\_a6 | $$(m\_{v,s}, m\_{a,s}, m\_{r,s}) = \text{SpongeSqueeze}\_3(\delta\_{\text{aud\\\_s}}, s\_{a,s}, \sigma\_a')$$ (owner-auditor channel masks) | -| O\_a7 | $$\tilde{v}\_{\text{aud,s}} = v\_{\text{transfer}} + m\_{v,s}$$ (owner-auditor encrypted transfer amount) | -| O\_a8 | $$\tilde{a}\_{\text{aud,s}} = (v\_a - v\_{\text{transfer}}) + m\_{a,s}$$ (owner-auditor encrypted post-transfer allowance) | -| O\_a9 | $$\tilde{r}\_{\text{aud,s}} = r\_a' + m\_{r,s}$$ (owner-auditor escrow of the new allowance blinding, over O10's $$r\_a'$$) | - -**Public inputs (25 fields):** - -| Input | Notes | -|:---|:---| -| $$C\_a$$, $$\sigma\_a$$ | Loaded from the `(from, spender)` delegation entry | -| $$Y\_{\text{op}}$$ | Loaded from spender's `spending_public_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 spender's. The visibility model points balance- and allowance-checkpoint ciphertexts at the funds' owner. | -| $$C\_a'$$, $$C\_{\text{transfer}}$$, $$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}}$$, $$\tilde{r}\_{\text{aud,s}}$$ | Prover-supplied, in this order; allowance fields written to delegation storage, $$C\_{\text{transfer}}$$ added to recipient's `receiving_commitment`, 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{transfer}}$$, $$r\_e$$. - -**Post-verification:** The contract checks `ledger.sequence() <= live_until_ledger`, updates `allowance_commitment`, `a_tilde`, stores $$\sigma\_a'$$ as the new `allowance_salt`, and adds $$C\_{\text{transfer}}$$ to the recipient's `receiving_commitment`. 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}}, \tilde{r}\_{\text{aud,s}})$$. - -**Ephemeral scalar.** The spender derives $$r\_e = \text{Poseidon}(\delta\_{\text{eph}}, vk\_{\text{op}}, \sigma\_a')$$ (§5.3, §6.2 *Transfer nonce*) from its *own* viewing key rather than the owner's, so that the spender can later disclose it ([SELECTIVE_DISCLOSURE.md](./SELECTIVE_DISCLOSURE.md) §7). The circuit does not constrain the derivation; it does not constrain $$vk\_{\text{op}}$$ at all, per *Contract binding* below. One consequence follows for the owner: since the owner does not hold $$vk\_{\text{op}}$$, the owner cannot recompute $$r\_e$$ for a spender transfer and cannot disclose it without the spender's cooperation ([SELECTIVE_DISCLOSURE.md](./SELECTIVE_DISCLOSURE.md) §7, *Coverage asymmetry*). - -**Recipient uniformity.** The recipient path is identical to the direct-transfer path of §7.6 *Recipient processing*. - -**Contract binding.** Unlike owner-initiated circuits, the SpenderTransfer circuit does not constrain the $$vk$$ derivation (the spender has no access to the owner's $$sk$$). Contract binding is instead inherited indirectly through the allowance commitment chain: the SetSpender circuit derives $$dvk\_i$$ from the contract-specific $$vk$$ (S2, S5), which determines $$r\_a$$ (S6) and thus $$C\_a$$ (S7). The SpenderTransfer circuit verifies $$dvk\_i$$ against $$C\_a$$ via $$\sigma\_a$$ (O3). Since $$C\_a$$ is a public input and was constructed with contract-specific randomness, a proof generated against one contract's $$C\_a$$ cannot verify against another's. - -### 7.9 Revoke Spender - -The owner reclaims the remaining escrowed allowance. Like merge (§7.4), revocation is a homomorphic fold and carries no proof: nothing is re-randomized and no private value is asserted. The escrowed value was range-proven at S4 and re-bounded by O4 on every spender transfer, and the next spend re-bounds the result under W4 / T4 -- the posture Section 2.6 already accepts for the receiving balance. - -**Contract logic (no proof):** - -``` -require account.require_auth() -C_spend ← C_spend + C_a -delete Delegation(account, spender) -emit RevokeSpender(account, spender, a_tilde, allowance_salt) -``` - -The fold works for both active and expired-but-not-revoked delegations (§6.2). - -**Owner state update.** By Proposition 1 applied to the allowance commitment, the post-revoke opening is $$(v\_s + v\_a, \\; r\_s + r\_a)$$. The owner derives $$dvk\_i$$ from $$vk$$ (§4.4), recovers $$v\_a = \tilde{a} - \text{Poseidon}(\delta\_{\text{enc\\\_allow}}, dvk\_i, \sigma\_a)$$ and $$r\_a = \text{Poseidon}(\delta\_{\text{allow\\\_r}}, dvk\_i, \sigma\_a)$$ from the event, and applies $$W\_{\text{spend}} \mathrel{+}= (v\_a, r\_a)$$ (§5.2). - -**Why the event carries $$\tilde{a}$$ and $$\sigma\_a$$.** The fold deletes the delegation entry in the same invocation, so neither field is readable from storage afterwards. `SpenderTransfer` writes its $$\tilde{a}'$$ to storage only. $$\sigma\_a$$ reaches events only through `SpenderTransfer`, which emits the replacement $$\sigma\_a'$$ it writes and not the salt it consumed (§6.2 *Transfer nonce*): the salt this fold consumes is therefore the `sigma_a_new` of the most recent `SpenderTransfer`, recoverable only by scanning back to it, and for a delegation revoked without ever being spent from it is published nowhere, since `SetSpender` emits the owner's spendable salt $$\sigma$$ and no allowance salt. - -**Encrypted balance.** Revocation emits no $$\tilde{b}$$ and is not a checkpoint (§5.2); the next owner-initiated proof operation issues a fresh checkpoint. The auditor carries its opening of $$C\_{\text{spend}}$$ through the fold as Section 8.1 specifies, using the opening of $$C\_a$$ it holds from the delegation's last state change (Section 8.5). - -### 7.10 Owner Operations with Active Spenders - -Owner transfers, withdrawals, and merges proceed identically to the no-spender case. Spender allowances are independently escrowed - no synchronization is needed. The owner's spendable balance and spender allowances are fully isolated. - -### 7.11 Delegation Key Escrow - -At `set_spender`, the owner escrows $$dvk\_i$$ to the spender on-chain via ECDH, eliminating off-chain key sharing: - -1. Owner derives ephemeral $$r\_e$$ per §5.3 — the same scalar as the `set_spender` proof's outer ECDH; see §5.3, "Why reusing $$r\_e$$ is safe" — and computes $$R = r\_e \cdot H$$. -2. Shared secret: $$s = \text{ECDH}(r\_e, Y\_{\text{op}})$$ (§2.4) -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. Reconstructing the curve point from `R_x` alone is sign-ambiguous: the two roots of $$y^2 = R.x^3 - 17$$ in $$\mathbb{F}\_r$$ are $$\pm R$$, and since the shared scalar binds $$S.y$$ (§2.4), the candidates $$sk\_{\text{op}} \cdot R$$ and $$sk\_{\text{op}} \cdot (-R) = -(sk\_{\text{op}} \cdot R)$$ yield two *different* masks. The ambiguity resolves with one scalar multiplication and a trial decryption, still without storing $$R.y$$: the two candidate shared points are inverses of one another, so the spender computes $$S = sk\_{\text{op}} \cdot R$$ for either root, forms both candidate scalars $$s\_{\pm} = \text{Poseidon}(\delta\_{\text{ecdh}}, S.x, \pm S.y)$$, decrypts a $$dvk\_i$$ candidate from each, and keeps the one consistent with the on-chain delegation entry (§6.2, read via `get_spender_delegation`, §11.3): $$dvk = \text{dvk\\\_cipher} - \text{Poseidon}(\delta\_{\text{esc\\\_dvk}}, s\_{\pm}, \text{op}\_i)$$ is correct iff $$C\_a = \text{Com}(\tilde{a} - \text{Poseidon}(\delta\_{\text{enc\\\_allow}}, dvk, \sigma\_a), \\; \text{Poseidon}(\delta\_{\text{allow\\\_r}}, dvk, \sigma\_a))$$. The wrong candidate fails this check except with negligible probability. - -The spender decrypts using $$sk\_{\text{op}}$$. The `set_spender` 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}} = \text{ECDH}(r\_e, Y\_{\text{op}})$$ (§2.4) -- $$\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. - -The same proof also escrows the allowance blinding $$r\_a$$ to the owner's auditor (S14), under its own domain tag over the auditor shared scalar; the construction and its decryption path are specified in [DESIGN_cont.md](./DESIGN_cont.md) §8.5. - ---- - - - -*The specification continues in [DESIGN_cont.md](./DESIGN_cont.md) with Sections 8-13:* -*Auditing, Security Analysis, Proof System, Interface, and Domain Separation Constants.* diff --git a/packages/tokens/src/confidential/docs/DESIGN_cont.md b/packages/tokens/src/confidential/docs/DESIGN_cont.md deleted file mode 100644 index 879c63f91..000000000 --- a/packages/tokens/src/confidential/docs/DESIGN_cont.md +++ /dev/null @@ -1,556 +0,0 @@ -# Confidential Token (continued) - - - -This document continues [DESIGN.md](./DESIGN.md), which covers Sections 1-7. -Section numbering is shared across both parts: references of the form -"DESIGN §N" resolve to [DESIGN.md](./DESIGN.md) for §1-§7 and to this document -for §8-§13. - ---- - -## 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$$; the recipient channel squeezes two masks, the sender channel three (Section 2.5 *Lane assignment*). - -**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} = \text{ECDH}(r\_e, K\_{\text{aud,r}}) \qquad \text{(DESIGN §2.4)}$$ -$$(m\_{v,r}, m\_{r,r}) = \text{SpongeSqueeze}\_2(\delta\_{\text{aud\\\_r}}, s\_{a,r}, \sigma)$$ -$$\tilde{v}\_{\text{aud,r}} = v\_{\text{transfer}} + m\_{v,r}, \qquad \tilde{r}\_{\text{aud,r}} = r\_{\text{transfer}} + m\_{r,r}$$ - -**Sender's auditor** ($$K\_{\text{aud,s}}$$, from the sender's `auditor_id`) receives the transfer amount, the sender's post-transfer balance, and the sender's post-transfer spendable blinding $$r\_A'$$: - -$$s\_{a,s} = \text{ECDH}(r\_e, K\_{\text{aud,s}}) \qquad \text{(DESIGN §2.4)}$$ -$$(m\_{v,s}, m\_{b,s}, m\_{r,s}) = \text{SpongeSqueeze}\_3(\delta\_{\text{aud\\\_s}}, s\_{a,s}, \sigma)$$ -$$\tilde{v}\_{\text{aud,s}} = v\_{\text{transfer}} + m\_{v,s}, \qquad \tilde{b}\_{\text{aud,s}} = (v\_A - v\_{\text{transfer}}) + m\_{b,s}, \qquad \tilde{r}\_{\text{aud,s}} = r\_A' + m\_{r,s}$$ - -The transfer circuit (constraints T\_a1--T\_a9) enforces correct computation. Which auditor an account is bound to is chosen by the account owner at registration (DESIGN §7.2), subject only to existence in the auditor registry unless the deployment gates the selection in its `Hooks::on_register` implementation ([COMPLIANCE.md](./COMPLIANCE.md) §4.3). - -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} = \text{Poseidon}(\delta\_{\text{ecdh}}, S\_{a,s}.x, S\_{a,s}.y)$$ -$$(m\_{v,s}, m\_{b,s}, m\_{r,s}) = \text{SpongeSqueeze}\_3(\delta\_{\text{aud\\\_s}}, s\_{a,s}, \sigma)$$ -$$v\_{\text{transfer}} = \tilde{v}\_{\text{aud,s}} - m\_{v,s}, \qquad v\_{\text{new}} = \tilde{b}\_{\text{aud,s}} - m\_{b,s}, \qquad r\_{\text{new}} = \tilde{r}\_{\text{aud,s}} - m\_{r,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{transfer}}, r\_{\text{transfer}})$$. - -**Recipient-auditor opening capability.** Because the recipient-auditor recovers $$r\_{\text{transfer}}$$ for every inbound transfer, and because deposits add to `receiving_commitment` 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{transfer},i} + \sum\_j a\_j, \qquad r\_r = \sum\_i r\_{\text{transfer},i}$$ - -where $$i$$ ranges over inbound transfers and spender-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 capability is bounded in three ways: - -- **Forward-only.** Only events emitted while the auditor key was active are decryptable. -- **Recipient-channel only.** The reconstruction above yields an opening of `receiving_commitment`: the spend-side blinding $$r\_s = \text{Poseidon}(\delta\_{\text{spend\\\_r}}, vk\_A, \sigma)$$ depends on $$vk\_A$$ and is not derivable from the recipient channel. -- **Reset by merge.** Merge folds $$r\_r$$ into the spendable-balance randomness ($$r\_{\text{spend}}' = r\_s + r\_r$$, Section 7.4) and emits no checkpoint, so the reconstruction above restarts from the next inbound flow. A clawback resets it the same way ([COMPLIANCE.md](./COMPLIANCE.md) §5.7). - -**Sender-auditor opening capability.** The `lane[2]` escrow hands the sender-auditor the *blinding* of the account's post-operation spendable balance directly, without $$vk\_A$$: together with the value in $$\tilde{b}\_{\text{aud,s}}$$ it is a full Pedersen opening of $$C\_{\text{spend}}'$$. It is available at the three checkpoint operations that escrow the spendable blinding -- withdrawal (W\_a5), outgoing transfer (T\_a9), and `set_spender` (S\_a6) -- and is likewise bounded: - -- **Forward-only**, on the same grounds as the recipient side. -- **Maintained across merges.** An account binds a single `auditor_id` (Section 6.1), so the key that decrypts the `lane[2]` escrow is the same key that decrypts the recipient channel of every inbound flow to that account. Merge adds both the values and the blindings ($$v\_{\text{spend}}' = v\_s + v\_r$$, $$r\_{\text{spend}}' = r\_s + r\_r$$, Section 7.4). An auditor that decrypted the inbound flows since the previous merge holds each addend -- $$(v\_{\text{transfer},i}, r\_{\text{transfer},i})$$ from the recipient-channel reconstruction above, and $$(a\_j, 0)$$ from the public deposits -- and carries the escrowed opening forward by the same addition the contract performs. -- **Carried through `revoke_spender`.** The revoke is a public homomorphic fold, $$C\_{\text{spend}}' = C\_{\text{spend}} + C\_a$$, emitting no new checkpoint (Section 7.9). An auditor that decrypted the delegation's last state-changing event holds the opening of the $$C\_a$$ being folded (Section 8.5) and advances its own opening by the same addition the contract performs. -- **Rotation-scoped.** Both folds above depend on the auditor having decrypted the event that produced the addend; Section 8.3 *Decryption capability across rotation* states what a rotated key can and cannot open. - -The clawback flow specified in [COMPLIANCE.md](./COMPLIANCE.md) §5 draws its witness from these bounded openings. - -### 8.2 Auditor Visibility Properties - -**Transfer amounts.** Both auditors see the transfer amount in real time. The recipient's auditor decrypts $$v\_{\text{transfer}}$$ 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{transfer}})$$ from $$\tilde{b}\_{\text{aud,s}}$$ and the matching blinding $$r\_A'$$ from $$\tilde{r}\_{\text{aud,s}}$$ (constraints T\_a5--T\_a9). -- **Withdrawal**: auditor decrypts post-withdrawal balance $$(v - a)$$ from $$\tilde{b}\_{\text{aud,s}}$$ and the matching blinding $$r'$$ from $$\tilde{r}\_{\text{aud,s}}$$ (constraints W\_a1--W\_a5). The withdrawal amount $$a$$ is also visible as a public input. -- **Set spender**: auditor decrypts escrowed amount $$v\_a$$ from $$\tilde{v}\_{\text{aud,s}}$$, post-escrow balance $$(v - v\_a)$$ from $$\tilde{b}\_{\text{aud,s}}$$, and the matching blinding $$r'$$ from $$\tilde{r}\_{\text{aud,s}}$$ (constraints S\_a1--S\_a6). - -The recipient's auditor does not see the sender's balance in any of these operations. - -**Per-transfer Pedersen randomness (recipient-auditor).** Beyond the transfer amount, the recipient's auditor also decrypts the per-transfer Pedersen blinding $$r\_{\text{transfer}}$$ from $$\tilde{r}\_{\text{aud,r}}$$ on every confidential transfer and spender-transfer. The sender's auditor does not see $$r\_{\text{transfer}}$$. - -**Key rotation.** When the auditor contract sets a new key under the account's `auditor_id` (§8.3), the new key reads the balance checkpoint at the next owner-initiated operation with no event replay: the checkpoint is self-contained, depending only on the auditor's ECDH secret key and the published $$(R\_e, \sigma)$$. Reading one checkpoint is not the same as holding the account's accumulated opening; Section 8.3 states what a rotated key can open. Note that `auditor_id` itself is immutable per account (§6.1); only the key under that `auditor_id` rotates. - -### 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, in which case rotation appends a new entry rather than overwriting the previous one. The reference registry shipped in this repository takes the simpler form: it keeps a single current key per `auditor_id`, which `rotate_key` overwrites in place; a versioned, activation-ledger registry is an optional production target. - -When building public inputs for any operation that produces auditor ciphertexts (transfers, withdrawals, `set_spender`), the contract fetches the relevant auditor keys for the recipient's and/or sender's `auditor_id`. The contract passes the full Grumpkin point as a public input; the circuit constrains the ECDH ciphertexts against that exact point. The contract 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 contract 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 spender) reconstructs the proof against the new $$K\_{\text{aud}}$$ and resubmits. The rejection is benign: the contract's spendable balance, receiving balance, and delegation state are unchanged by the reverted call, the operation's salt 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 resolves the version from its own rotation records (with a versioned activation-ledger registry, the auditor instead 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. - -**Decryption capability across rotation.** A key decrypts an auditor ciphertext exactly when it was the active key at the moment that ciphertext was produced. Rotation therefore affects the two holders asymmetrically: - -- **Retired key.** Its holder keeps what it already opened. Rotation does not revoke a held opening, and the proofless folds advance it exactly as Section 8.1 specifies. -- **New key.** Its holder starts with nothing and re-anchors per commitment: at the next checkpoint operation for $$C\_{\text{spend}}$$ (Section 8.2), and at the delegation's next state-changing operation for $$C\_a$$ (Section 8.5). - -Each proofless fold consumes an addend produced by an earlier event, and a holder that bootstrapped from the new key alone cannot open an addend that predates the rotation. Such a holder loses its opening at every fold that consumes one, and re-anchors only at the next checkpoint operation following it. A merge's addends reach back only as far as the previous merge; a delegation's last state change is bounded only by its entry TTL. - -### 8.4 Spender Transfer Auditing - -Each spender transfer produces auditor ciphertexts under two keys (constraints O\_a1--O\_a9), 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{transfer}} = \tilde{v}\_{\text{aud,r}} - m\_{v,r}, \qquad r\_{\text{transfer}} = \tilde{r}\_{\text{aud,r}} - m\_{r,r}$$ - -The owner's auditor decrypts the transfer amount, the post-transfer allowance, and the *new* allowance blinding $$r\_a'$$, the one O11 commits $$C\_a'$$ under (O\_a9): - -$$(m\_{v,s}, m\_{a,s}, m\_{r,s}) = \text{SpongeSqueeze}\_3(\delta\_{\text{aud\\\_s}}, s\_{a,s}, \sigma\_a')$$ -$$v\_{\text{transfer}} = \tilde{v}\_{\text{aud,s}} - m\_{v,s}, \qquad v\_a' = \tilde{a}\_{\text{aud,s}} - m\_{a,s}, \qquad r\_a' = \tilde{r}\_{\text{aud,s}} - m\_{r,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 spender-transfer inbound flows: $$r\_{\text{transfer}}$$ from spender-transfers contributes to $$r\_r$$ in $$C\_{\text{receive}}$$ identically to owner-transfer inbound flows. - -$$(\tilde{a}\_{\text{aud,s}}, \tilde{r}\_{\text{aud,s}})$$ open the post-transfer $$C\_a'$$, the state left on-chain. - -### 8.5 Spender Allowance Auditing - -The auditor tracks each allowance's current value from the per-event ciphertexts of its two state-changing operations, `set_spender` (Section 8.2) and `confidential_transfer_from` (Section 8.4). - -**Allowance opening.** The owner's auditor also receives the *blinding* of the allowance commitment each event writes: $$r\_a$$ under $$\delta\_{\text{esc\\\_allow\\\_r\\\_aud}}$$ at `set_spender` (S14, below) and $$r\_a'$$ in `lane[2]` at every spender transfer (O\_a9, §8.4). Paired with the value that event already publishes -- $$\tilde{v}\_{\text{aud,s}}$$ on `SetSpender`, $$\tilde{a}\_{\text{aud,s}}$$ on `SpenderTransfer` -- that is the full Pedersen opening of the $$C\_a$$ left on-chain, reconstructed from the event alone with no storage read. Unlike the spendable side there is no merge to fold in, since a delegation's only state transitions are the events themselves. This is what `revoke_spender` folds against: it publishes no ciphertext of its own (§7.9), and the auditor opens the $$C\_a$$ from whichever event last wrote it. - -**Auditor-side allowance-blinding escrow.** At `set_spender` the owner escrows $$r\_a$$ to its own auditor. A blinding is per state, so one leaked escrow ciphertext opens one allowance state. - -$$\tilde{r}\_{a,\text{aud,s}} = \text{Poseidon}(\delta\_{\text{esc\\\_allow\\\_r\\\_aud}}, s\_{a,s}, \text{op}\_i) + r\_a$$ - -reusing the S\_a2 shared scalar $$s\_{a,s} = \text{ECDH}(r\_e, K\_{\text{aud,s}})$$ rather than opening a new ECDH channel; §10.3 gives the cost. The auditor recovers $$s\_{a,s}$$ from $$k\_{\text{aud,s}}$$ and the event's $$R\_e$$, and $$\text{op}\_i$$ from the event's `spender` topic, then subtracts. Because the escrowed value is pinned to S6, the opening the auditor holds necessarily matches the $$C\_a$$ the same proof wrote. - -It is a single-output pad because `lane[2]` of this channel is taken by the spendable blinding (S\_a6). Distinct tags separate the two despite the shared scalar, each confined to one sponge mode as DESIGN §2.5 *Mode exclusivity* requires. Its pad freshness is that of $$r\_e$$ (DESIGN §5.3 *Why reusing $$r\_e$$ is safe*). - -**Archive dependence.** The auditor must have *observed* the event: the escrowed blinding lives nowhere in contract storage, so an auditor that missed a delegation event cannot recover that opening from `a_tilde` and $$\sigma\_a$$ the way a $$dvk\_i$$ holder could. DESIGN.md §5.2 already makes a durable event archive normative; [INDEXER.md](./INDEXER.md) §7.1 states the operational consequences for auditor clients. - ---- - -## 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_spender`, `revoke_spender`) - 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) spender allowance. Expired-but-not-revoked allowances are included: the escrowed value resides on-chain in $$C\_a$$ until `revoke_spender` reclaims it (Section 6.2). - -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{transfer}}$$, enforced by circuit constraints T3–T8. -- **Merge** moves value from $$v\_{\text{receive}}$$ to $$v\_{\text{spend}}$$ (Proposition 1); the sum is unchanged. -- **Set spender** moves value from $$v\_{\text{spend}}$$ to $$v\_{\text{allowance}\_i}$$; enforced by S3–S7. -- **Spender transfer** decreases $$v\_{\text{allowance}\_i}$$ and increases recipient's $$v\_{\text{receive}}$$ by $$v\_{\text{transfer}}$$; enforced by O2–O8. -- **Revoke** moves remaining $$v\_{\text{allowance}\_i}$$ back to $$v\_{\text{spend}}$$ (Proposition 1); the sum is unchanged. -- **Clawback** (compliance extension) removes a public amount from $$v\_{\text{spend}} + v\_{\text{receive}}$$ without a withdrawal, enforced by CB1–CB3; in a deployment that enables it, seized amounts count alongside $$w\_k$$ on the left-hand side ([COMPLIANCE.md](./COMPLIANCE.md) §5.4). - -### 9.4 Privacy Properties - -**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{transfer\\\_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 contract-specific (Section 4.2), compromise of one contract's viewing key does not affect the owner's accounts in other deployments. Within the compromised contract, 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), derive all $$dvk\_i$$ to read spender allowances, and recompute the ephemeral scalar $$r\_e$$ of every transfer the account *originated* (DESIGN.md §5.3), hence the recipient shared scalar $$s$$, hence a full Pedersen opening $$(v\_{\text{transfer}}, r\_{\text{transfer}})$$ of each such $$C\_{\text{transfer}}$$. The attacker **cannot** authorize any spending operation (requires $$sk$$, and $$vk$$ cannot recover $$sk$$ by Poseidon preimage resistance). - -Two properties of that last capability deserve stating plainly. It is **retroactive**: the derivation is deterministic in $$(vk, \sigma)$$ and $$\sigma$$ is published, so a compromise today opens every transfer the account ever originated. And it **reaches outside the account's own state**: the commitments it opens were added to *recipients'* `receiving_commitment` values. The transfer *amounts* were already inferable from $$vk$$ alone, by differencing consecutive balance checkpoints and netting the inbound credits and merges between them, so amount visibility is not what changes; the openings are, and an opening is a self-verifying artifact that its holder can hand to any third party. This is why $$vk$$ cannot be treated as a safely shareable read-only credential ([SDK.md](./SDK.md) §13): a counterparty that needs visibility into an account's outbound transfers receives per-event disclosure proofs bound to it and to a nonce ([SELECTIVE_DISCLOSURE.md](./SELECTIVE_DISCLOSURE.md) §7), not the key. - -**Auditor key compromise.** An account binds one key for both channels (Section 6.1). For every account that used the compromised key, the attacker acquires: all amounts and balance checkpoints; the standing opening of $$C\_{\text{spend}}$$ from the first post-activation checkpoint, maintained across merges exactly as Section 8.1 describes; the opening of $$C\_{\text{receive}}$$ between merges; and the escrowed allowance blinding $$r\_a$$ from every delegation event it observed, hence the opening of each $$C\_a$$ those events wrote (Section 8.5). It cannot recover viewing keys, read data from before the key was active, or authorize any spending. After key rotation, new operations are protected by the new key. - -As with $$vk$$ above, the openings are self-verifying artifacts a holder can hand to any third party, so an auditor key is custodied at the same level as a viewing key ([SDK.md](./SDK.md) §13). - -### 9.5 State Recovery - -The recovery procedure, the definition of the **checkpoint** it is built around, the replay window it runs over, and the durable-archive requirement it depends on are all specified in [DESIGN.md](./DESIGN.md) §5.2 *Recovery*. This section states the security properties that follow from it. - -**Why the two anchors compose.** No `Merge` or `Clawback` falls strictly between $$T\_0$$ and the checkpoint, by construction of $$T\_0$$ as the last of either at or before it. Within $$(T\_0, \text{checkpoint}]$$ the only spendable-affecting events are therefore checkpoint events and `RevokeSpender` folds, and the latest checkpoint's $$(\tilde{b}, \sigma)$$ supersedes both — which is why the replay skips them ([DESIGN.md](./DESIGN.md) §5.2 *Recovery*, step 6). Any `Merge` the replay encounters necessarily sits after the checkpoint and is folded normally, against the receiving opening it actually consumed. - -**Recoverability.** An owner who holds $$vk$$ reconstructs both openings deterministically from the account's event history, so the loss of local wallet state is not a loss of funds. The single-lookup shortcut on the spendable side is sound: each checkpoint's $$\tilde{b}$$ is bound to the $$C\_{\text{spend}}$$ it certifies by the emitting operation's own proof (W7, T12, S11), so a checkpoint cannot misreport the balance the wallet restarts from. Beyond the event history and the on-chain commitments, recovery requires nothing from the auditor, from counterparties, or from the contract admin. - -**Integrity fails closed.** The event archive is trusted for availability, not for integrity. Recovery terminates in a consistency check against the on-chain commitments ([DESIGN.md](./DESIGN.md) §5.2 *Recovery*, step 7) and Pedersen commitments are binding (Section 2.3), so a tampered or truncated history cannot yield an opening that verifies against a balance the account does not hold — it yields a detectable mismatch. The residual risk is therefore denial of recovery, a liveness failure, rather than silent corruption of the reconstructed balance. [INDEXER.md](./INDEXER.md) §7 specifies the archive's trust model and the client-side checks that follow from it. - -**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 picks a new random salt -- $$\sigma$$, or $$\sigma\_a'$$ for a spender transfer -- and recomputes the proof. No special-case logic is needed. The salt 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 spender 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 six circuits, the last belonging to the optional compliance extension: - -```rust -#[contracttype] -#[repr(u32)] -pub enum CircuitType { - Register = 0, - Withdraw = 1, - Transfer = 2, - SpenderTransfer = 3, - SetSpender = 4, - Clawback = 100, -} -``` - -`deposit`, `merge`, and `revoke_spender` carry no proof and have no circuit (Sections 7.3, 7.4, 7.9). The discriminants are part of the on-chain interface and are never renumbered; core circuits occupy `0..=4` and extension circuits start at `100`. - -### 10.2 Circuit Summary - -| Circuit | What it proves | -|:---|:---| -| `Register` | Spending key well-formedness; contract-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 ciphertexts (balance checkpoint + `lane[2]` escrow of the new spendable blinding); 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 + `lane[2]` escrow of the new spendable blinding); deterministic randomness for new sender balance; encrypted balance scalar; sender key ownership; range validity (balance $$\in [0, 2^{127})$$, amount $$\in [0, 2^{127})$$) | -| `SpenderTransfer` | Allowance sufficiency; ECDH-derived blinding and encrypted amount for recipient; dual-auditor channel sponges (recipient auditor: amount + per-transfer Pedersen randomness; owner auditor: amount + allowance + `lane[2]` escrow of the new allowance blinding); deterministic randomness for new allowance; encrypted allowance scalar; spender key ownership; contract-bound indirectly via $$C\_a$$ chain (Section 7.8) | -| `SetSpender` | Balance split; $$dvk\_i$$ derivation; ECDH escrow of $$dvk\_i$$ to the spender and of the allowance blinding to the owner's auditor; allowance commitment with deterministic randomness; encrypted balance and allowance scalars; owner-auditor ECDH ciphertexts (escrow amount + balance checkpoint + `lane[2]` escrow of the new spendable blinding); owner key ownership; contract-bound via $$vk$$ derivation | -| `Clawback` | Knowledge of the openings of the target's $$C\_{\text{spend}}$$ and $$C\_{\text{receive}}$$; public seize amount bounded by their sum, remainder in range; auditor key ownership; no ephemeral ([COMPLIANCE.md](./COMPLIANCE.md) §5.3) | - -### 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. - -**Counting unit.** The totals below count `multi_scalar_mul` *call sites*, matching how the circuits are written (`circuits/lib/src/lib.nr`): a Pedersen commitment is one call over two scalars, and each `ecdh` is one call plus a Poseidon2 absorb (§2.4). A per-scalar count is higher, since every commitment contributes two. Each row lists every site, so the constraint identifiers are the audit trail. - -| Circuit | Calls | Sites | -|:---|:--:|:---| -| `Register` | 2 | $$Y$$ (R1), $$\text{PVK}$$ (R3) | -| `Withdraw` | 5 | $$Y$$ (W1), $$C\_{\text{spend}}$$ opening (W3), $$C\_{\text{spend}}'$$ (W6), $$R\_e$$ (W\_a1), sender-auditor ECDH (W\_a2) | -| `Transfer` | 8 | $$Y\_A$$ (T1), $$C\_{\text{spend}}^A$$ opening (T3), recipient ECDH (T5), $$R\_e$$ (T6), $$C\_{\text{transfer}}$$ (T8), $$C\_{\text{spend}}'$$ (T11), recipient-auditor ECDH (T\_a1), sender-auditor ECDH (T\_a5) | -| `SpenderTransfer` | 8 | $$Y\_{\text{op}}$$ (O1), $$C\_a$$ opening (O2), recipient ECDH (O5), $$R\_e$$ (O6), $$C\_{\text{transfer}}$$ (O8), $$C\_a'$$ (O11), recipient-auditor ECDH (O\_a1), owner-auditor ECDH (O\_a5) | -| `SetSpender` | 7 | $$Y$$ (S1), $$C\_{\text{spend}}$$ opening (S3), $$C\_a$$ (S7), $$C\_{\text{spend}}'$$ (S10), $$R\_e$$ (S\_a1), $$dvk\_i$$ escrow ECDH (S12, §7.11), owner-auditor ECDH (S\_a2) | -| `Clawback` | 3 | $$K\_{\text{aud}}$$ (CB1), $$C\_{\text{spend}}$$ opening (CB2), $$C\_{\text{receive}}$$ opening (CB3) | - -`SetSpender` is the one circuit with a third ECDH beyond the auditor channel: the $$dvk\_i$$ handoff of §7.11 reuses $$r\_e$$ but multiplies it against $$Y\_{\text{op}}$$, so it is a separate call, not a reuse of the S\_a2 shared secret. The auditor-side escrow of the allowance blinding $$r\_a$$ (S14) reuses the S\_a2 shared scalar and adds one Poseidon evaluation. The `lane[2]` escrows (W\_a5, T\_a9, S\_a6, O\_a9) read `lane[2]` of a permutation each circuit already computes and cost one field addition apiece. The ordering these totals imply is consistent with the committed ACIR opcode counts in `circuits/constraints.baseline`: `Register` 33, `Clawback` 64, `Withdraw` 95, `Transfer` 134, `SetSpender` 135, `SpenderTransfer` 136. - -The ECDH computations add scalar multiplications compared to a random-blinding scheme, but the unchunked design eliminates all per-chunk constraints (which, in a chunked scheme, would involve 8+ scalar multiplications for balance chunks and per-chunk range proofs). - -### 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 and -/// of both balance commitments in CB1/CB2) -/// 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 shared-secret scalar (Section 2.4): S = scalar * point, then -/// s = Poseidon2(delta_ecdh, S.x, S.y). Absorbing both coordinates binds -/// the full shared point, removing the (P, -P) negation invariance of an -/// x-only extraction. All ECDH scalars in this protocol are F_r-sampled -/// (sk, vk, r_e_point), so single-limb conversion is sound. -fn ecdh(scalar: Field, point: EmbeddedCurvePoint) -> Field { - let s = multi_scalar_mul( - [point], - [EmbeddedCurveScalar::from_field(scalar)] - ); - poseidon_with_domain(ECDH_SHARED_SECRET, [s.x, s.y]) -} -``` - -**Post-merge witness availability.** Every opening witnessed in any circuit is encoded as a single $$\mathbb{F}\_r$$ `Field` via the same `commit` primitive. After a proofless fold -- `Merge` (§7.4), `revoke_spender` (§7.9), or `clawback` ([COMPLIANCE.md](./COMPLIANCE.md) §5.4, where the compliance extension is enabled) -- the spendable-balance blinding is a sum over $$\mathbb{F}\_q$$: $$r\_s + r\_r$$ for a merge or a clawback, $$r\_s + r\_a$$ for a revoke. Its canonical $$\mathbb{F}\_q$$ representative lies in $$[0, r)$$ -- representable as a Noir `Field` -- with probability $$\geq 1 - (q - r)/q \approx 1 - 2^{-127}$$ per fold (§2.3). With the complementary probability $$\approx 2^{-127}$$ it lies in $$[r, q)$$. In that case the on-chain state remains well-formed (the commitment is a valid Grumpkin point), but the wallet's local opening witness is unencodable as a `Field`, so no spend / transfer / set-spender proof can be constructed against the affected $$C\_{\text{spend}}$$ until further accumulation shifts the blinding back into $$\mathbb{F}\_r$$. - -**Soft recovery.** Every subsequent inbound confidential transfer or spender transfer, once merged, adds a fresh $$\mathbb{F}\_r$$-derived blinding. For each transfer-derived addend, the new canonical $$\mathbb{F}\_q$$ representative falls in $$[0, r)$$ with probability $$\geq 1 - 2^{-127}$$ regardless of the current stuck value (worst case: the current value sits at the lower edge of $$[r, q)$$, requiring the new $$\mathbb{F}\_r$$ addend to cross the mod $$q$$ boundary; the probability of failing to do so is bounded by $$(q - r)/r \approx 2^{-127}$$). For accounts that continue to receive confidential transfers, the unspendable window is self-resolving at the next merge with overwhelming probability; accounts whose only inflows are deposits remain stuck until a confidential transfer arrives. - -### 10.5 Verification Flow - -1. Contract 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. Contract applies homomorphic balance updates (Grumpkin point arithmetic via $$\mathbb{F}\_r$$ ops) - -The proofless operations (`deposit`, `merge`, `revoke_spender`) perform step 5 only. - -### 10.6 Structured Reference String - -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 the host functions required for efficient UltraHonk verification and on-chain Grumpkin point arithmetic. The rollout spans two protocols; both are required, so **protocol 26 is the effective minimum**. - -- **Protocol 25:** `bn254_g1_{add, mul}`, `bn254_multi_pairing_check`. -- **Protocol 26:** `bn254_g1_msm`, `bn254_g1_is_on_curve`, `bn254_fr_{add, sub, mul, inv, pow}` -- the $$\mathbb{F}\_r$$ scalar arithmetic underpinning Grumpkin point operations. - -### 10.8 On-Chain Point Arithmetic - -The contract 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 contract 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), $$C\_{\text{transfer}}$$ (T8, O8), $$C\_{\text{spend}}'$$ (T11, W6, S10), $$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). 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 the operation's salt, 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 contract 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 contract trusts the fetched value. - -**Canonical encoding** ($$x, y \in [0, r)$$ as 32-byte representatives) is enforced **by the contract** at the verifier boundary, not by the Soroban host, which reduces non-canonical inputs rather than rejecting them (§2.2 *Host deserialiser caveat*). Every prover-supplied scalar and coordinate that reaches the verifier — and therefore every byte string that gets persisted or emitted downstream — is the unique canonical representative of its field element. - ---- - -## 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 contract 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 ConfidentialToken { - 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, spender: Address, - from: Address, to: Address, data: Bytes); - - fn set_spender(e: Env, account: Address, spender: Address, - live_until_ledger: u32, data: Bytes); - - fn revoke_spender(e: Env, account: Address, spender: Address); - - fn confidential_balance(e: Env, account: Address) -> ConfidentialAccount; - - fn is_spender(e: Env, account: Address, spender: Address) -> bool; - - fn get_spender_delegation(e: Env, account: Address, spender: Address) -> SpenderDelegation; -} -``` - -A deployment that enables the optional compliance extension adds `compliance: Option` to this constructor and the freeze, configuration-rotation, read, and — when it opts into `ConfidentialClawback` — seizure entry points of [COMPLIANCE.md](./COMPLIANCE.md) §6 to this interface. The admin-gated ones are authorized by the deployment's own access-control module. - -**Hooks.** Every entry point invokes the deployment's `Hooks` implementation after authorization and payload decoding and before the storage-layer operation. Hooks for operations that carry `data` receive the decoded payload by reference; the proofless `on_deposit`, `on_merge`, and `on_revoke_spender` receive none. The two seizure entry points, `clawback` and `force_revoke_spender`, invoke no hook, since a gating hook rejects exactly their precondition — a frozen target ([COMPLIANCE.md](./COMPLIANCE.md) §5). - -This table is authoritative: every entry is exactly the set of prover-supplied public inputs from the corresponding Section 7 operation (the contract loads the remaining public inputs from trusted state per §7.1), plus the `proof` blob. Names map directly to the Section 7 symbols. - -| Operation | `data` contents | -|:---|:---| -| `register` | $$Y$$, $$\text{PVK}$$, `proof` | -| `withdraw` | $$C\_{\text{spend}}'$$, $$\tilde{b}$$, $$R\_e$$, $$\sigma$$, $$\tilde{b}\_{\text{aud,s}}$$, $$\tilde{r}\_{\text{aud,s}}$$, `proof` | -| `confidential_transfer` | $$C\_{\text{spend}}'$$, $$C\_{\text{transfer}}$$, $$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}}$$, $$\tilde{r}\_{\text{aud,s}}$$, `proof` | -| `confidential_transfer_from` | $$C\_a'$$, $$C\_{\text{transfer}}$$, $$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}}$$, $$\tilde{r}\_{\text{aud,s}}$$, `proof` | -| `set_spender` | $$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}}$$, $$\tilde{r}\_{\text{aud,s}}$$, $$\tilde{r}\_{a,\text{aud,s}}$$, `proof` | - -For `confidential_transfer_from`, the stored allowance salt $$\sigma\_a$$ is **not** carried in `data`: the contract loads it from the `(from, spender)` delegation entry (§7.8 public-input table). Only the prover-chosen replacement $$\sigma\_a'$$ travels in `data`. It is bound as this transfer's channel nonce (O7, O9, O\_a2, O\_a6), as the new allowance state's salt (O10, O12), and against the salt it replaces (O14); it is then written back to the delegation entry as the new `allowance_salt` and emitted in the event (§6.2 *Transfer nonce*). 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_spender`, 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 contract 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(spender, from, to, data)` | `spender` (not `from`) | -| `set_spender(account, spender, live_until_ledger, data)` | `account` | -| `revoke_spender(account, spender)` | `account` | -| `confidential_balance`, `is_spender`, `get_spender_delegation` | 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` storage entry. - -**`set_spender` rejects replacement.** It reverts if a non-revoked delegation already exists for `(account, spender)` -- see §6.2. - -**`confidential_transfer_from` is spender-authorized.** The owner's authorization was granted out-of-band at `set_spender` and persists in the on-chain delegation entry until expiry or revocation. The spender'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}}$$, $$\tilde{r}\_{\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}}$$, $$\tilde{r}\_{\text{aud,s}}$$ | -| `SpenderTransfer` | `spender`, `from`, `to`, $$R\_e$$, $$\tilde{v}$$, $$\sigma\_a'$$, $$\tilde{v}\_{\text{aud,r}}$$, $$\tilde{r}\_{\text{aud,r}}$$, $$\tilde{v}\_{\text{aud,s}}$$, $$\tilde{a}\_{\text{aud,s}}$$, $$\tilde{r}\_{\text{aud,s}}$$ | -| `SetSpender` | `account`, `spender`, `live_until_ledger`, $$R\_e$$, $$\sigma$$, $$\tilde{b}$$, $$\tilde{v}\_{\text{aud,s}}$$, $$\tilde{b}\_{\text{aud,s}}$$, $$\tilde{r}\_{\text{aud,s}}$$, $$\tilde{r}\_{a,\text{aud,s}}$$ | -| `RevokeSpender` | `account`, `spender`, $$\tilde{a}$$, $$\sigma\_a$$ | -| `Clawback` | `account`, `amount`, `destination` | - -Amount fields in `Deposit`, `Withdraw`, and `Clawback` are typed `i128`, matching SEP-41. - -`Clawback` is emitted only by deployments that enable the optional compliance extension ([COMPLIANCE.md](./COMPLIANCE.md) §5). - -**Usage by consumers:** - -- **Recipient wallet**: processes `Transfer` and `SpenderTransfer` events using $$(R\_e, \tilde{v}, \sigma)$$ -- with $$\sigma\_a'$$ in place of $$\sigma$$ for `SpenderTransfer` -- to derive $$v\_{\text{transfer}}$$ and $$r\_{\text{transfer}}$$ (Section 5.3). -- **Owner wallet**: processes all events for recovery (Section 5.2). The $$(\tilde{b}, \sigma)$$ pair from the most recent checkpoint event (`Withdraw`, `Transfer` as sender, `SetSpender`) anchors $$W\_{\text{spend}}$$; `Merge`, `RevokeSpender`, and `Clawback` are folded into it during replay. -- **Auditor**: processes events containing $$R\_e$$ to compute ECDH shared secrets and decrypt amounts, balance checkpoints, and the escrowed blindings (Sections 8.1, 8.2, 8.5); folds `Merge`, `RevokeSpender`, and `Clawback` into its accumulators as Section 8.1 and [COMPLIANCE.md](./COMPLIANCE.md) §5.7 specify. -- **Indexer**: reconciles the pooled balance against the sum of claims (Section 9.3, [INDEXER.md](./INDEXER.md)). - -### 11.3 Read Methods - -**`confidential_balance(account) -> ConfidentialAccount`.** Returns the `ConfidentialAccount` struct for the given account (§6.1), i.e. the tuple `(spending_public_key, viewing_public_key, spendable_commitment, receiving_commitment, 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_spender(account, spender) -> bool`.** Returns `true` iff a delegation entry exists for `(account, spender)` **and** `ledger.sequence() <= live_until_ledger`. Returns `false` for: - -- pairs with no delegation entry, -- pairs whose entry has `ledger.sequence() > live_until_ledger` (expired-but-not-yet-revoked: the spender can no longer spend, while the escrowed value stays on-chain until `revoke_spender` reclaims it, §6.2), -- pairs whose entry was revoked (deleted) by `revoke_spender`. - -The function returns the *spending-authority* state, not the *escrow-existence* state. Consumers that need to distinguish "no delegation" from "expired delegation" inspect `get_spender_delegation` (below) or replay `SetSpender` / `RevokeSpender` events. - -**`get_spender_delegation(account, spender) -> SpenderDelegation`.** Returns the `SpenderDelegation` struct (§6.2) for the `(account, spender)` pair, i.e. `(allowance_commitment, a_tilde, escrowed_dvk, allowance_salt, live_until_ledger)`. Reverts if no delegation entry exists for the pair. Unlike `is_spender`, this surfaces the raw on-chain delegation state without applying the expiry filter, so callers can separate an absent delegation (revert) from an active one and from an expired-but-not-yet-revoked one, by comparing `live_until_ledger` against `ledger.sequence()` as `is_spender` does. Primary consumers: - -- **Spender wallet:** fetches `allowance_commitment`, `a_tilde`, `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_spender`, 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. `a_tilde` is keyed to $$dvk\_i$$ and is unreadable without it, the owner's auditor included (§8.5). - ---- - -## 12. Void - ---- - -## 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 contract 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{transfer\\\_blind}}$$ | 5 | ECDH-derived transfer blinding factor (§5.3 Definition 1) | -| $$\delta\_{\text{transfer\\\_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 *a\_tilde*) | -| $$\delta\_{\text{allow\\\_r}}$$ | 9 | Deterministic randomness for spender allowance commitments (§6.2 *allowance\_commitment*) | -| $$\delta\_{\text{esc\\\_dvk}}$$ | 10 | Delegation key escrow (spender 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) | -| $$\delta\_{\text{ecdh}}$$ | 13 | ECDH shared-secret scalar extraction (§2.4) | -| $$\delta\_{\text{eph}}$$ | 14 | Deterministic ephemeral-scalar derivation (§5.3) | -| $$\delta\_{\text{disc\\\_bind}}$$ | 15 | Disclosure ciphertext to the disclosure recipient, aggregate variant ([SELECTIVE_DISCLOSURE.md](./SELECTIVE_DISCLOSURE.md) §10) | -| $$\delta\_{\text{disc}}$$ | 16 | Disclosure ciphertext to the disclosure recipient ([SELECTIVE_DISCLOSURE.md](./SELECTIVE_DISCLOSURE.md) §4) | -| $$\delta\_{\text{esc\\\_allow\\\_r\\\_aud}}$$ | 17 | Allowance-blinding escrow to the owner's auditor (S14, §8.5) | - -This table assigns all seventeen values; no other document assigns them. Tags 14–16 are never absorbed inside a core circuit — 14 is derived off-circuit (DESIGN.md §5.3 makes its derivation normative for every operation whose originator holds a viewing key), and 15–16 belong to the off-chain selective-disclosure layer ([SELECTIVE_DISCLOSURE.md](./SELECTIVE_DISCLOSURE.md) §2.2) — so they are not part of the on-chain wire contract; `circuits/lib/src/lib.nr` accordingly implements 1–13 and 17. All seventeen values MUST still be distinct and each MUST be confined to a single sponge mode, so a deployment treats them as one namespace. Tag 17's construction is specified in §8.5. - -**Provenance.** Sequential small integers are the simplest assignment that satisfies the requirement of *distinctness* across all Poseidon2 invocations in this protocol -- §3.2 models Poseidon2 as a pseudorandom function, so evaluations whose leading input differs are computationally independent. Distinctness alone is not sufficient: each tag must also be confined to a single sponge mode, since the multi-lane forms of §2.5 share `lane[0]` with the single-output form on the same inputs; $$\delta\_{\text{aud\\\_s}}$$ is the one tag ever squeezed three-wide, and the widths it is read at agree on their shared lanes (§2.5 *Mode exclusivity*). 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. - -**Cross-protocol collision.** Future protocols that share Grumpkin / BN254 / Poseidon2 with this protocol -- 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/v1:X"}))$$, but this is a deployment-time choice that must be applied uniformly and disclosed in the deployment's circuit-binding documentation. diff --git a/packages/tokens/src/confidential/docs/README.md b/packages/tokens/src/confidential/docs/README.md new file mode 100644 index 000000000..c232ccc06 --- /dev/null +++ b/packages/tokens/src/confidential/docs/README.md @@ -0,0 +1,67 @@ +# Confidential Token Documentation + +Documentation set for the confidential token module. The protocol specification is the `protocol/` directory, one topic per file, read top to bottom; the companion specifications for compliance, selective disclosure, indexing, and the SDK cite it. Every file ends with a footer linking to the previous file, the next file, and this index. + +## Where to Start + +- **New to the design:** [overview.md](overview.md) explains what the token does, why it is a separate contract, and how each user flow works, without mathematics. Its footer leads into the protocol specification at [protocol/README.md](protocol/README.md). +- **Protocol, end to end:** the [Protocol](#protocol) table below in order, or follow the footers from [protocol/README.md](protocol/README.md). Concatenating the files in this order reproduces the specification as a single document. +- **Contract implementer or reviewer:** [protocol/interface.md](protocol/interface.md), [protocol/operations/](protocol/operations/README.md), [protocol/proof-system.md](protocol/proof-system.md), [compliance.md](compliance.md). +- **Wallet or SDK implementer:** [sdk/README.md](sdk/README.md) through [sdk/requirements.md](sdk/requirements.md) in file order, then [indexer.md](indexer.md) and [selective-disclosure/README.md](selective-disclosure/README.md) onward. +- **Auditor operator:** [protocol/auditing.md](protocol/auditing.md), [sdk/auditor-client.md](sdk/auditor-client.md), the [Clawback](compliance.md#clawback) section of `compliance.md`. +- **Indexer operator:** [indexer.md](indexer.md), [protocol/wallet-state.md](protocol/wallet-state.md). + +## Map + +### Protocol + +Table order is reading order. + +| File | Owns | +|:---|:---| +| [protocol/README.md](protocol/README.md) | Abstract, design goals, approach | +| [protocol/primitives.md](protocol/primitives.md) | Notation, Grumpkin and BN254, Pedersen commitments, ECDH, Poseidon2 sponge and lane assignment, range proofs, address-to-field encoding | +| [protocol/system-model.md](protocol/system-model.md) | Components, threat model, trust assumptions, underlying-token requirements, governance | +| [protocol/keys-and-commitments.md](protocol/keys-and-commitments.md) | Key hierarchy, balance commitments, ECDH-derived blinding, anti-poisoning constraint, encrypted balance scalar | +| [protocol/wallet-state.md](protocol/wallet-state.md) | Wallet accumulators, update rules, checkpoints, consistency check, recovery procedure and its properties, event-durability requirement | +| [protocol/account-state.md](protocol/account-state.md) | `ConfidentialAccount` and `SpenderDelegation` storage entries, transfer nonce | +| [protocol/operations/README.md](protocol/operations/README.md) | Index of operations, public-input sources and the trust-boundary rule | +| [protocol/operations/](protocol/operations/) | One file per operation: constraints, public inputs, private witnesses, post-verification state | +| [protocol/auditing.md](protocol/auditing.md) | Auditor ciphertexts, opening capabilities, key rotation, spender and allowance auditing | +| [protocol/security.md](protocol/security.md) | Griefing resistance, merge safety, balance conservation, privacy properties, revert safety, replay protection | +| [protocol/proof-system.md](protocol/proof-system.md) | Circuit set and costs, Noir primitives, verification flow, structured reference string, CAP-80, on-chain point arithmetic | +| [protocol/interface.md](protocol/interface.md) | Entry points, payload encoding, authorization model, event schema, read methods | +| [protocol/domain-separators.md](protocol/domain-separators.md) | The seventeen Poseidon2 domain tags | + +### Companions + +| File | Owns | +|:---|:---| +| [compliance.md](compliance.md) | Freeze, SAC passthrough, policy contract, `Hooks` customisation, clawback and forced revocation | +| [selective-disclosure/README.md](selective-disclosure/README.md) | Motivation, goals, preliminaries, threat model of the disclosure layer | +| [selective-disclosure/protocol.md](selective-disclosure/protocol.md) | Disclosure ciphertext, proof bundle, verifier protocol, on-chain read surface, end-to-end flow | +| [selective-disclosure/circuits/](selective-disclosure/circuits/) | D-recipient, D-sender, D-auditor, D-balance, aggregate disclosures | +| [selective-disclosure/security.md](selective-disclosure/security.md) | Soundness, recipient binding, scope boundary, implementation notes | +| [indexer.md](indexer.md) | Archive data model, ingestion contract, retention, API surface, trust model | +| [sdk/README.md](sdk/README.md) | Why the SDK is load-bearing, terminology, layering, roles | +| [sdk/crypto-core.md](sdk/crypto-core.md) | Client-side obligations for fields, canonicality, sponge, generators, ECDH, blinding accumulation, sampling, address compression | +| [sdk/key-derivation.md](sdk/key-derivation.md) | Root to spending key derivation, signer and raw roots, account discovery | +| [sdk/conformance.md](sdk/conformance.md) | Primitive fixtures, circuit-execution parity, required vectors | +| [sdk/proving.md](sdk/proving.md) | Witness assembly, prover, chain adapter | +| [sdk/wallet.md](sdk/wallet.md) | Holder wallet state, event application, salt freshness, ephemeral scalars, consistency, recovery, spender-side wallet | +| [sdk/auditor-client.md](sdk/auditor-client.md) | Auditor decryption, standing openings, clawback witness | +| [sdk/clients.md](sdk/clients.md) | Disclosure construction and verification, indexer client, hybrid read path | +| [sdk/requirements.md](sdk/requirements.md) | Security requirements, non-functional requirements, conformance and versioning | +| [overview.md](overview.md) | Non-normative user-flows overview | + +## Normativity and Precedence + +- `protocol/` is normative for the on-chain protocol. `compliance.md` is normative for the optional compliance extension, `selective-disclosure/` for the off-chain disclosure layer, `indexer.md` for the durable event archive, and `sdk/` for client implementations. `overview.md` is non-normative. +- Where a document and `circuits/lib/src/lib.nr` disagree about a primitive, the Noir library is authoritative. The contract's `#[contracttype]` definitions are authoritative for the shape of on-chain types and payloads. +- Every fact has one owning section. Other files cite it rather than restating it. + +## Citing a Section + +Cite by relative path and heading anchor, for example `protocol/wallet-state.md#recovery`: as a Markdown link from the docs, as a plain `docs/...` path from Rust and Noir comments. Anchors are GitHub heading slugs: lower-case, punctuation removed, spaces replaced by hyphens. Constraint identifiers such as `T_a9`, `S14`, or `CB3` are stable and may be cited by name. + +CI resolves every such reference with lychee, the docs directly and the code citations through a generated link list, and fails on a missing file or heading. Headings carry no LaTeX: lychee drops math when it derives a heading slug and GitHub does not. diff --git a/packages/tokens/src/confidential/docs/SDK.md b/packages/tokens/src/confidential/docs/SDK.md deleted file mode 100644 index adace6844..000000000 --- a/packages/tokens/src/confidential/docs/SDK.md +++ /dev/null @@ -1,570 +0,0 @@ -# Confidential Token: SDK - -Companion specification to [DESIGN.md](./DESIGN.md) §4 (Key Hierarchy) and §5.2 (Off-Chain Opening Maintenance), [DESIGN_cont.md](./DESIGN_cont.md) §9.5 (State Recovery) and §11 (Interface), [INDEXER.md](./INDEXER.md), and [SELECTIVE_DISCLOSURE.md](./SELECTIVE_DISCLOSURE.md) §5 and §15. It specifies the client layer those documents assume: the crypto core that mirrors the Noir circuits off-chain, the key derivation the protocol leaves open, the witness and payload construction, the wallet state machine, and the auditor, disclosure, and indexer clients. - -The key words MUST, MUST NOT, SHOULD, SHOULD NOT, and MAY are to be interpreted as in RFC 2119. The normative audience is threefold: - -- **SDK implementers** MUST satisfy §4–§12. -- **Wallet, auditor, and application integrators** MUST NOT bypass §13; the security properties of the protocol do not survive it. -- **Port authors** (mobile, hardware wallet, a second language) MUST pass §6. - -**Scope.** This document specifies obligations, not an API. It does not prescribe function signatures, module names, package layout, or class design, and it does not restate protocol formulas that [DESIGN.md](./DESIGN.md) already fixes — each requirement cites its source section instead. - ---- - -## 1. Why the SDK Is Load-Bearing - -Four properties of the protocol place correctness and confidentiality in the client rather than in the contract. - -**The opening exists only off-chain.** A balance is a Pedersen commitment $$C = v \cdot G + r \cdot H$$. The chain stores the point; the opening $$(v, r)$$ that authorizes the next spend lives exclusively in client state (DESIGN.md §5.2). A client that loses, misderives, or misaccumulates the opening makes the funds unspendable, and the contract cannot help because it never knew the value. - -**Every amount a user sees is client-decrypted.** The contract performs homomorphic point arithmetic and never learns a value. Balances, transfer amounts, allowances, and audit figures are all produced by client-side decryption of event ciphertexts, so a decryption defect yields a plausible wrong number rather than a visible failure. - -**The client is an enforcement point for canonicality.** Neither the Soroban host nor the verifier distinguishes a canonical $$\mathbb{F}_r$$ representative from a non-canonical one (DESIGN.md §2.2, *Host deserialiser caveat*); the contract enforces canonicality at its boundary, but the client is where the bytes are produced. - -**The client is where all secrets live and all randomness is sampled.** The spending key, the viewing key, every blinding factor, and every per-operation salt originate client-side, so the protocol's confidentiality reduces to the client's key handling and CSPRNG quality. DESIGN.md §2.5 makes salt uniqueness a confidentiality requirement: a reused salt repeats the deterministic ephemeral scalar and every sponge mask keyed by it. - ---- - -## 2. Terminology and Layering - -### 2.1 Terminology - -- **Root** — the secret an implementation feeds to §5.1's derivation: a SEP-0053 signature by a signer on the account, or a raw 32-byte value for an address with no ed25519 signer of its own (§5). -- **Opening** — the pair $$(v, r)$$ such that $$C = v \cdot G + r \cdot H$$ for an on-chain commitment $$C$$. -- **Checkpoint** — an owner-initiated proof-carrying event publishing $$(\tilde{b}, \sigma)$$ for the owner's spendable balance (DESIGN.md §5.2 *Recovery*, which enumerates the qualifying events). -- **Witness material** — any value that appears as a private witness in any circuit: $$sk$$, $$vk$$, $$dvk_i$$, $$v$$, $$r$$, $$r_e$$, $$v_{\text{transfer}}$$, and every intermediate derived from them. -- **Trust boundary** — the process and storage under the account holder's exclusive control. Witness material inside it is secret; witness material that crosses it is disclosed. -- **In-flight operation** — a submitted operation whose event has not yet been observed. Its projected post-operation opening is known locally but not yet confirmed against chain state. -- **Facade** — a role-scoped interface over the crypto core (§3). - -### 2.2 Layering - -An implementation MUST separate the following concerns. The boundaries are normative because §15 defines conformance over the lower layers only; the upper layers are deployment-shaped and deliberately unconstrained in structure. - -| Layer | Section | Responsibility | Purity | -|:--|:--|:--|:--| -| Crypto core | §4 | Field and curve arithmetic, Poseidon2, derivations, encodings | Deterministic; no I/O; holds no state | -| Key derivation | §5 | Root → $$sk$$ → the DESIGN.md §4 hierarchy | Deterministic; no I/O | -| Conformance vectors | §6 | Fixture and circuit-execution parity | Test-only | -| Witness assembly | §7 | Per-circuit private witnesses and public inputs | Deterministic given randomness | -| Prover | §8 | Circuit artifacts, verification keys, proof generation | I/O; pluggable backend | -| Chain adapter | §9 | Reads, payload encoding, submission, typed errors | I/O | -| Role facades | §10–§12 | Holder wallet, auditor, disclosure, indexer clients | Stateful | - ---- - -## 3. Roles and Capability Separation - -Five roles consume the protocol. Each holds distinct key material and MUST be *structurally* incapable of exceeding its capability. - -| Role | Holds | Can | -|:--|:--|:--| -| Holder | Root, $$sk$$, $$vk$$ | Spend, withdraw, merge, delegate, disclose, read own balances | -| Spender | Own $$sk_{\text{op}}$$, escrowed $$dvk_i$$ | Spend from the allowance, read allowance state, disclose own spender transfers | -| Auditor | Auditor secret $$k$$ | Decrypt both channels for accounts bound to its `auditor_id` (DESIGN_cont.md §8.1) | -| Disclosure recipient | $$(r_R, P_R)$$ | Verify a disclosure proof and recover the disclosed amount | -| Observer | Nothing | Read commitments, ciphertexts, ephemerals, addresses, public amounts | - ---- - -## 4. Crypto Core - -Every requirement in this section is reproduced from `circuits/lib/src/lib.nr`, which is the source of truth wherever this document and the protocol documents disagree. - -### 4.1 Fields and the `q` / `p` notation hazard - -Two moduli are in play, and confusing them silently corrupts state (§4.6). - -| Modulus | Value | Role | Called elsewhere | -|:--|:--|:--|:--| -| $$r$$ | `0x30644e72e131a029b85045b68181585d2833e84879b9709143e1f593f0000001` | BN254 scalar field. Noir's `Field`; the host's `Bn254Fr`; Grumpkin **coordinate** field | `FR_MODULUS` | -| $$q$$ | `0x30644e72e131a029b85045b68181585d97816a916871ca8d3c208c16d87cfd47` | BN254 base field. Grumpkin **scalar** (multiplier) field, i.e. the group order | `FP_MODULUS`, and conventionally $$p$$ in the pairing literature | - -$$r < q$$, so every $$\mathbb{F}_r$$ element is already a valid Grumpkin scalar with no reduction, which is why a Noir `Field` can be passed to `multi_scalar_mul` unambiguously. - -### 4.2 Canonicality - -A value is a **canonical** $$\mathbb{F}_r$$ representative iff it is a 32-byte big-endian encoding of an integer in $$[0, r)$$. - -- Every $$\mathbb{F}_r$$ value the SDK emits — into a payload, an event assertion, a proof input, or persisted state — MUST be canonical. -- The SDK MUST reject a non-canonical value at its own boundary rather than relying on the contract's check (DESIGN.md §2.2). A client that produces non-canonical bytes has already lost byte-uniqueness in the local state that recovery reads from. -- Points are encoded as `BytesN<64>` = $$\text{be}(x) \\| \text{be}(y)$$, a **flat** 64-byte value. The identity $$\mathcal{O}$$ is all 64 bytes zero, and decodes back to the identity. - -### 4.3 Poseidon2 sponge - -The sponge construction, its width and rate, the IV placement, the padding rule, and the two- and three-lane forms $$\text{SpongeSqueeze}_2$$ and $$\text{SpongeSqueeze}_3$$ are specified normatively in DESIGN.md §2.5. What follows is what that construction additionally requires of a client. - -**Three self-checks are available before any proof is generated.** The absorbed length in both squeeze forms is always 3, so the IV is fixed at $$3 \cdot 2^{64}$$; `lane[0]` is identical to $$\text{poseidon\\\_with\\\_domain}(\delta, [s, \sigma])$$ on the same inputs; and $$\text{SpongeSqueeze}_3(\delta, s, \sigma)[i] = \text{SpongeSqueeze}_2(\delta, s, \sigma)[i]$$ for $$i \in \\{0, 1\\}$$, since the absorb fits one rate-3 block and both forms read the same permutation. An implementation that reproduces all three has the block layout and the IV lane right. The third is pinned by `circuits/lib/testdata/sponge_squeeze_3.json`. - -**The domain-tagged funnel.** Every Poseidon2 invocation in the protocol routes through one entry point that places the domain tag as the **first absorbed element**: - -$$\text{poseidon\\\_with\\\_domain}(\delta, [x_1, \ldots, x_n]) = \text{sponge}([\delta, x_1, \ldots, x_n])$$ - -Squeeze-slot assignment is canonical and MUST be followed: `lane[0]` is always an amount mask, `lane[1]` is always a balance, allowance, or per-transfer-randomness mask, and `lane[2]` is always the sender-auditor blinding-escrow slot — the new spendable blinding on `Withdraw`, `Transfer`, and `SetSpender`, the new allowance blinding $$r_a'$$ on `SpenderTransfer` (DESIGN.md §2.5). Only the sender-auditor channel ($$\delta_{\text{aud\\\_s}}$$) is squeezed three-wide; the recipient channel ($$\delta_{\text{aud\\\_r}}$$) stays at two lanes. `Withdraw`, whose amount is public, takes `lane[1]` and `lane[2]` and leaves `lane[0]` unused (DESIGN.md W_a3–W_a5), so a checkpoint pad can never coincide with an amount pad. - -### 4.4 Generators and commitments - -$$G$$ and $$H$$ are Barretenberg's `derive_generators("DEFAULT_DOMAIN_SEPARATOR")` outputs at indices 0 and 1, fixed in DESIGN_cont.md §10.4. - -$$\text{commit}(v, r) = v \cdot G + r \cdot H$$ - -Scalar multiplication MUST map a zero scalar to the identity rather than erroring, because a registered account's opening commitments are the identity and a zero blinding factor is legitimate (deposits commit with $$r = 0$$, DESIGN.md §7.3). - -### 4.5 ECDH - -$$\text{ECDH}(a, B) = \text{poseidon\\\_with\\\_domain}(\delta_{\text{ecdh}}, [S.x, S.y]) \quad \text{where} \quad S = a \cdot B$$ - -**Both coordinates MUST be absorbed.** An x-only extraction is negation-invariant: $$P$$ and $$-P$$ share an x-coordinate, and $$-\text{PVK} = (-vk) \cdot H$$ is itself a valid canonical registration, so an x-only map would collapse each $$(vk, -vk)$$ pair onto one shared secret (DESIGN.md §2.4). The absorb fills exactly one rate-3 block. - -The derivation MUST fail rather than proceed if $$S$$ is the identity: with $$\sigma$$ public, an identity shared secret makes every derived ciphertext trivially decryptable, which is why the circuits carry explicit nonzero-scalar constraints (DESIGN_cont.md §10.8). - -### 4.6 Blinding accumulation - -Commitment blinding factors compose under homomorphic point addition, so their arithmetic is that of the **Grumpkin scalar field** $$\mathbb{F}_q$$ (§4.1): - -$$\text{Com}(v_1, r_1) + \text{Com}(v_2, r_2) = \text{Com}(v_1 + v_2, \\, (r_1 + r_2) \bmod q)$$ - -Reducing modulo $$r$$ instead yields an opening that is off by $$q - r$$ and no longer matches the on-chain point, and for two full-size blindings the integer sum crosses $$q$$ roughly half the time. Implementations MUST provide distinct, clearly named reduction operations for the two moduli. - -**One reduction point.** An implementation MUST fold blindings with unbounded integer addition and MUST NOT reduce by either modulus as folds compose. This governs merge (DESIGN.md §7.4), the `RevokeSpender` and `Clawback` folds, and receiving-balance credit (DESIGN.md §5.2 *Update rules*) alike. - -An implementation MUST reduce modulo $$q$$ to the canonical representative only where a blinding leaves an accumulator for the curve: a proof witness, or the recommit of the §10.6 consistency check. §10.7 tests for encodability at that same point. - -Committed **values** accumulate as exact integers and are never reduced; DESIGN.md §2.3 establishes that they never wrap. - -### 4.7 Scalar sampling - -Secret scalars — $$\sigma$$, $$\sigma_a$$ — MUST be produced by the rejection procedure of DESIGN.md §2.2: - -1. Draw 32 bytes from a CSPRNG. -2. Clear the top **2** bits, yielding a 254-bit candidate. -3. Reject and redraw if the candidate is $$\geq r$$, or if it is zero and the call site requires nonzero. - -### 4.8 Domain separators - -| Tag | Value | Absorbed in a core circuit? | -|:--|:--:|:--| -| $$\delta_{\text{addr}}$$ | 1 | No — absorbed on-chain by the contract (DESIGN.md §2.7) | -| $$\delta_{\text{vk}}$$ | 2 | Yes | -| $$\delta_{\text{dvk}}$$ | 3 | Yes | -| $$\delta_{\text{spend\\\_r}}$$ | 4 | Yes | -| $$\delta_{\text{transfer\\\_blind}}$$ | 5 | Yes | -| $$\delta_{\text{transfer\\\_amount}}$$ | 6 | Yes | -| $$\delta_{\text{enc\\\_bal}}$$ | 7 | Yes | -| $$\delta_{\text{enc\\\_allow}}$$ | 8 | Yes | -| $$\delta_{\text{allow\\\_r}}$$ | 9 | Yes | -| $$\delta_{\text{esc\\\_dvk}}$$ | 10 | Yes | -| $$\delta_{\text{aud\\\_s}}$$ | 11 | Yes | -| $$\delta_{\text{aud\\\_r}}$$ | 12 | Yes | -| $$\delta_{\text{ecdh}}$$ | 13 | Yes | -| $$\delta_{\text{eph}}$$ | 14 | No — derived off-circuit (DESIGN.md §5.3) | -| $$\delta_{\text{disc\\\_bind}}$$ | 15 | No — off-chain disclosure only | -| $$\delta_{\text{disc}}$$ | 16 | No — off-chain disclosure only | -| $$\delta_{\text{esc\\\_allow\\\_r\\\_aud}}$$ | 17 | Yes | - -DESIGN_cont.md §13 assigns all seventeen values and is their only source; the right-hand column is this document's addition. $$\delta_{\text{disc\\\_bind}}$$ and $$\delta_{\text{disc}}$$ belong to the off-chain disclosure layer (SELECTIVE_DISCLOSURE.md §2.2). Tag 1 is absorbed by the contract rather than by a circuit — the contract derives $$\text{addr\\\_f}$$ and $$\text{op}_i$$ on-chain and the circuits receive them as opaque public inputs (DESIGN.md §2.7 *Usage sites*) — so it is part of the on-chain wire contract all the same. None of 14–16 is absorbed either in a circuit or on-chain, so none is part of the on-chain wire contract, but all three are part of the cross-client contract because two wallets serving the same account must agree on them (§6.3). - -All seventeen values MUST be distinct, and each MUST be used in exactly one sponge mode, per DESIGN.md §2.5 *Mode exclusivity*. Tags 11 and 12 are the multi-lane tags — 11 read three-wide wherever the sender / owner channel is instantiated, 12 always two-wide; the remaining fifteen, including 1, 14–16, and 17, are single-output tags. Tag 17 is absorbed only by the `SetSpender` circuit (DESIGN.md S14, DESIGN_cont.md §8.5); its fixture is `circuits/lib/testdata/encrypt_esc_allow_r_auditor.json`. - -### 4.9 Address compression - -$$\text{address\\\_to\\\_field}(a) = \text{poseidon\\\_with\\\_domain}(\delta_{\text{addr}}, [\text{lo}(a), \text{hi}(a)])$$ - -where $$\text{enc}(a)$$ is the 56-character ASCII strkey (SEP-23), and $$\text{lo}$$ and $$\text{hi}$$ interpret its lower and upper 28 bytes respectively in **little-endian** order (DESIGN.md §2.7). Implementations MUST obtain the strkey from their language's stellar-strkey library. - -**Bootstrap check.** On first contact with a deployment, an implementation MAY compute $$\text{addr\\\_f}$$ for the contract's own address and assert equality against the value the contract stores in instance storage (DESIGN.md §3.5). - ---- - -## 5. Key Derivation - -DESIGN.md §4 specifies the hierarchy below $$sk$$ — $$vk$$ from $$(sk, \text{addr\\\_f})$$, $$\text{PVK}$$ from $$vk$$, $$dvk_i$$ from $$(vk, \text{op}_i)$$. It does not specify where $$sk$$ itself comes from. This section supplies a derivation, because recovery from backup material is a stated protocol property (DESIGN.md §5.2 *Recovery*, DESIGN_cont.md §9.5, INDEXER.md §1) and two clients given the same backup material would otherwise derive different accounts. - -The derivation is a single function (§5.1) over a **root**, and the root's class is determined by what controls the address rather than chosen per client. An address controlled by a Stellar ed25519 key uses a deterministic signature by that key (§5.2). An address with no ed25519 key of its own — a smart account or any other contract address — uses raw bytes from whatever custody mechanism controls it (§5.3). Tying the class to the address rather than to client preference is what keeps two clients from disagreeing about which class produced an account's $$sk$$, a disagreement `register` being single-use (DESIGN_cont.md §11) would make unrepairable. The one residual case is §5.3's fallback, where an ed25519-controlled address has no way to sign the §5.2 message; a client MUST therefore read a $$Y$$ mismatch under §5.2 as evidence that the account uses a root it does not hold, not as a derivation defect. - -### 5.1 Derivation - -$$sk = \text{RS}\Big(\text{HKDF-SHA-512}\big(\text{IKM} = \text{root}, \\;\\; \text{salt} = \texttt{"openzeppelin/confidential-token/v1/sk"}, \\;\\; \text{info} = \text{be}_{32}(\text{addr\\\_f}) \\,\\|\\, \text{be}_{32}(\text{acct\\\_f}) \\,\\|\\, \text{le}_{4}(j)\big)\Big)$$ - -where: - -| Input | Definition | -|:--|:--| -| $$\text{IKM}$$ | RFC 5869's input keying material: the byte string HKDF-Extract consumes, here the account's root | -| $$\text{root}$$ | Exactly one of — the 64-byte ed25519 signature of §5.2, or a raw 32-byte value (§5.3) | -| $$\text{addr\\\_f}$$ | $$\text{address\\\_to\\\_field}$$ of the confidential token contract (§4.9) | -| $$\text{acct\\\_f}$$ | $$\text{address\\\_to\\\_field}$$ of the address being registered | -| $$j$$ | Rejection counter, starting at 0 | -| $$\text{RS}$$ | The §4.7 procedure applied to the 32-byte HKDF output: clear the top 2 bits, accept iff the result is in $$[1, r)$$, otherwise increment $$j$$ and re-derive | - -The candidate MUST also be rejected if the resulting $$vk = \text{poseidon\\\_with\\\_domain}(\delta_{\text{vk}}, [sk, \text{addr\\\_f}])$$ is zero, since registration constraint R5 requires $$vk \neq 0$$. - -**The IKM is the root's bytes, verbatim.** HKDF-Extract accepts input keying material of any length, the 64 signature bytes or the 32 raw bytes go in as they are. - -Why each element is present: - -**HKDF-SHA-512, not Poseidon2.** No circuit constrains how $$sk$$ was obtained — the register circuit constrains only $$Y = sk \cdot H$$ (R1) and $$vk$$'s derivation from $$sk$$ (R2). There is therefore no in-circuit consistency argument for Poseidon2 here, and SHA-512 keeps the custody path on the primitive ed25519 already uses internally, which every Stellar SDK and secure element therefore already implements. Implementations MUST NOT substitute a different KDF, since the choice is arbitrary in isolation but must be identical across clients. - -**Bound to $$\text{addr\\\_f}$$.** $$vk$$ is already deployment-scoped by DESIGN.md §4.2, which bounds the blast radius of a *viewing*-key compromise to one deployment. Binding $$sk$$ likewise bounds a *spending*-key compromise, and the contract address is known whenever a client talks to a deployment. - -**Bound to $$\text{acct\\\_f}$$.** $$vk$$ depends only on $$(sk, \text{addr\\\_f})$$, so the same $$sk$$ registered under two addresses yields the same $$vk$$, hence identical $$Y$$ and identical $$\text{PVK}$$ published under both accounts and readable by any observer through the account read method, linking two addresses that are otherwise unlinkable. Implementations MUST derive a distinct $$sk$$ per address. - -**No account-index input.** Binding $$\text{acct\\\_f}$$ makes a separate SEP-0005 index redundant: the address determines the account, and the index is merely the path that produced the address. - -### 5.2 Signer roots - -A signer root is a SEP-0053 signature over a message naming this protocol, the deployment, and the account: - -$$\text{msg} = \texttt{"openzeppelin/confidential-token/v1/sk"} \\,\\|\\, \texttt{0x0a} \\,\\|\\, \text{enc}(\text{contract}) \\,\\|\\, \texttt{0x0a} \\,\\|\\, \text{enc}(\text{account})$$ - -$$\text{root} = \text{Ed25519-Sign}\big(sk_{\text{ed}}, \\;\\; \text{SHA-256}(\text{prefix} \\,\\|\\, \text{msg})\big)$$ - -where `prefix` is SEP-0053's 24 ASCII bytes `Stellar Signed Message:\n`, $$\text{enc}$$ is the 56-character strkey of §4.9, and $$sk_{\text{ed}}$$ is the ed25519 secret of a signer on the account. The message is 151 bytes, printable ASCII apart from its two separators, and carries the strkeys rather than their §4.9 compressions so that a wallet rendering SEP-0053 messages as text shows the user addresses they can compare against the deployment they intend to register on. The signature is the 64-byte RFC 8032 encoding $$R \\,\\|\\, S$$ that every Stellar SDK and SEP-0053 wallet already returns, and those 64 bytes are the IKM. - -Binding both addresses into the *message* rather than relying on §5.1's `info` alone is what bounds a harvested signature: a dapp that tricks a user into signing once obtains the root for that account on that deployment, not for every account the key controls on every deployment. - -**The SEP-0053 envelope is mandatory even where the secret is extractable.** A client holding the raw ed25519 secret MUST compute this signature itself rather than use the secret's 32 bytes as the IKM directly. One form then covers both custody shapes: an account enrolled through a wallet prompt is reproducible by a client that later imports the secret, and the reverse. - -**The ed25519 key is a single point of failure, and this MUST be disclosed.** Whoever obtains the ed25519 secret can recompute the signature and therefore $$sk$$, gaining both view and spend of the confidential account. `register` is single-use, so $$sk$$ cannot be rotated in place, and remediation means registering a fresh address and moving the funds through a transfer. An implementation MUST state, at the point where it offers to create a confidential account, that the account's confidentiality is bounded by the secrecy of the account's signing key. - -**Availability is not guaranteed.** A signer root exists only where the custody stack implements SEP-0053 message signing, and support across Stellar wallets and hardware apps is uneven. An implementation MUST treat its absence as an expected outcome and fall back to a raw root (§5.3), which is not reproducible from anything the user already holds and therefore MUST be backed up explicitly, rather than fail enrolment. - -**Verify the signature before using it.** An implementation MUST verify the returned signature against the ed25519 public key it expects to have signed, and MUST abort on mismatch. A wallet with a different account selected returns a well-formed signature over the same message, yielding a wrong but entirely usable $$sk$$: registration succeeds, and the account is then unreproducible from the key the user believes controls it. - -**Determinism is a precondition.** RFC 8032 ed25519 derives its nonce from the secret and the message, so a conforming signer returns the same 64 bytes forever. Signers that randomise the nonce do not, and threshold and MPC ed25519 are in that category — the nonce is generated per signing session, so a signature does not reproduce in the next one. An implementation MUST obtain the signature twice from independent invocations and MUST abort if they differ. That detects the common case and not every case, since a signer can be deterministic within a session and not across sessions, so an implementation MUST additionally offer $$sk$$ export as a direct-import backup (§5.3) and SHOULD prompt for it before the account first receives funds. - -**The IKM MUST NOT be persisted.** The 64-byte signature is equivalent to $$sk$$ for this account and deployment. Implementations MUST derive on demand; where $$sk$$ itself is cached, §13's storage-at-rest rules govern it. - -**Record the enrolled signer.** A Stellar address may have signers besides its master key, and $$sk$$ is keyed to the *address* through $$\text{acct\\\_f}$$ rather than to the key that signed for it, so which signer enrolled is not recoverable from the address or from chain state. Implementations MUST record the enrolled ed25519 public key and MUST NOT assume it is the master key. A second client enrolling the same address with a different signer derives a different $$sk$$ whose $$Y$$ does not match the registered spending public key. §5.4's comparison detects that, and an implementation holding more than one candidate signer SHOULD resolve it by trying each and adopting the one whose $$Y$$ matches, rather than reporting a mismatch under whichever it tried first. - -**Signer rotation orphans the account.** `set_options` can replace or remove the key controlling an address, and the confidential account survives that rotation while its root does not. An implementation SHOULD compare the recorded signer against the address's current signer set on sync and surface a warning when the enrolled key is no longer among them, because discarding that key after rotation destroys the only path back to $$sk$$. - -**Discovery costs one signature per candidate.** §5.4's scan derives $$sk$$ once per candidate index, and each derivation needs a signature by that index's key. A client holding the mnemonic signs locally and scans offline; a client fronted by a custody stack spends a user approval per candidate and reaches nothing for an index whose key that stack will not sign with. An implementation MUST surface which of the two it is doing rather than presenting a bounded scan as exhaustive. - -### 5.3 Raw roots and imported keys - -Implementations MUST accept a raw 32-byte `root`, those 32 bytes being the IKM. It is the class for every address with no ed25519 key of its own, and three cases require it: - -- **Contract addresses.** A confidential account registered by a smart account or other contract address has no ed25519 signer to sign §5.2's message, so its root comes from whatever custody mechanism controls the contract. Where a smart account authorises through signers of its own, the root is supplied by that mechanism rather than derived from any one of them, since the set can change without the address changing. -- **No SEP-0053 path.** An ed25519-controlled account whose custody stack cannot sign arbitrary messages (§5.2) falls back here. -- **Imported keys.** Deployments predating this specification hold $$sk$$ values sampled directly from a CSPRNG with no root behind them. Such a key MUST remain usable as a first-class account secret via direct import, bypassing §5.1 entirely. - -A raw root is reproducible from nothing the user already holds, so an implementation that generates one MUST surface it for backup at creation and MUST NOT treat it as recoverable from the account's other credentials. - -An implementation MUST record, per account, which of the three forms produced its $$sk$$ — signer root, raw root, or direct import — because they differ in what regenerates the key: a live signer, a stored 32-byte value, or nothing beyond the stored $$sk$$. A user MUST NOT be shown a recovery affordance an account's form cannot satisfy. - -### 5.4 Recovery and account discovery - -Recovering a signer-root account requires the enrolled signer (§5.2) and the contract address. Where the signer came from a mnemonic, the mnemonic, its passphrase if any, and the SEP-0005 index reproduce it; where it did not, the signer itself must still be held. The set of addresses is discovered rather than remembered: - -1. Enumerate candidate Stellar addresses by scanning SEP-0005 indices $$i = 0, 1, 2, \ldots$$ from the seed. -2. For each candidate address, compute $$\text{acct\\\_f}$$, obtain the §5.2 signature from that index's key, derive $$sk$$ per §5.1, and compute $$Y = sk \cdot H$$. -3. Read the account record at that address and compare its stored spending public key against $$Y$$. A match identifies a registered confidential account belonging to this signer. - -Step 2 needs a signature rather than a hash, so a client that cannot sign locally SHOULD drive recovery from its own per-account record instead of scanning (§5.2). An account registered under a raw root, or under a signer that is neither held nor reproducible, is not reachable by any scan: only the direct import of a previously exported $$sk$$ recovers it. - ---- - -## 6. Conformance Vectors - -### 6.1 Primitive fixtures - -`circuits/lib/testdata/*.json` pins one primitive's output per file for a fixed input set, and is the language-agnostic contract for every off-chain consumer. An implementation MUST reproduce every output in every file byte-for-byte. - -Its test suite MUST **read** those files rather than transcribe their values into source, so that a change to the Noir library's `print_fixtures` output becomes a test failure rather than a silent divergence. - -### 6.2 Circuit-execution parity - -Fixtures pin primitives, not witnesses. A client can reproduce every fixture and still assemble a witness with a transposed public input, a wrong field name, or a stale ordering, none of which any fixture covers. - -An implementation MUST therefore additionally, for every circuit it supports: - -- Build the witness from its own crypto core and have the **real compiled circuit** solve it, asserting success. -- Include **tamper cases** — a witness with one value perturbed — and assert the circuit *rejects* them. - -### 6.3 Vectors this specification requires - -Three derivations this document specifies or relies on are computed outside the core circuits, so the Noir library's `print_fixtures` does not emit them. The first already has a fixture; the other two require one: - -- **$$\text{address\\\_to\\\_field}$$** (§4.9) is pinned by `circuits/lib/testdata/address_to_field.json`, which §6.1's obligation already covers. The circuits receive $$\text{addr\\\_f}$$ as an opaque public input, so this derivation is implemented twice — by the contract on-chain and by every client — making it the sole primitive with two independent implementations and no Noir version, and §4.9's bootstrap check detects a divergence only against an already-deployed contract. Being outside `print_fixtures`, it is also outside the in-Noir `fixtures_match_testdata` guard; the Rust test `address_to_field_matches_testdata_vectors` guards it instead (`circuits/lib/testdata/README.md`). -- **$$\delta_{\text{eph}}$$ derivation** (DESIGN.md §5.3, restated in §10.5). No circuit constrains $$r_e$$, so a fixture is the only mechanism keeping a user's clients in agreement; where two disagree, transfers sent from one are not disclosable from the other. -- **The §5.1 $$sk$$ chain**, from a fixed root, $$\text{addr\\\_f}$$, and $$\text{acct\\\_f}$$ through to $$sk$$, $$vk$$, $$Y$$, and $$\text{PVK}$$, without which recovery from backup material is untestable across implementations. The vector MUST start from a fixed ed25519 secret and run through §5.2's message, its SEP-0053 preimage, and the resulting signature, since the signature is where two clients most plausibly diverge — the only step in the chain whose format is set outside this document. A second vector from a fixed raw root covers §5.3. - ---- - -## 7. Witness Assembly - -An implementation MUST provide witness assembly for each circuit it supports, covering the five circuits of DESIGN_cont.md §10.1: `Register`, `Withdraw`, `Transfer`, `SpenderTransfer`, `SetSpender`. - -**Public-input order is a wire contract.** The verifier sees an ordered vector of field elements with no knowledge of what they denote (DESIGN.md §7.1), so a permutation of two same-typed inputs produces a well-formed vector that verifies a different statement. Each builder MUST assemble public inputs in exactly the order the contract assembles them, and MUST cite the contract function it mirrors at the site of the ordering. The per-operation public-input tables in DESIGN.md §7.2–§7.9 are authoritative for *membership*; the contract's assembly is authoritative for *order*. - -**The trust-boundary rule constrains the client too.** DESIGN.md §7.1 requires the contract to load state-derived public inputs itself and never accept them from the caller. The client-side corollary: a builder MUST NOT include a contract-loaded input in the payload it submits. Doing so does not break soundness, since the contract ignores it, but it produces a payload whose fields disagree with the proof's public inputs. - -**Prover-supplied values MUST be freshly derived per attempt**, never reused from a previous attempt at the same logical operation (§10.4). - -Builders SHOULD return, alongside the witness and payload, the projected post-operation opening and — for transfer-family circuits — the values the recipient will recover, both of which §10 needs. - ---- - -## 8. Prover - -### 8.1 Toolchain pinning - -Proofs the deployed verifier accepts MUST be generated with the toolchain and non-default flags pinned in `circuits/vks/README.md`. Two flags govern acceptance: - -- **A Keccak Fiat-Shamir transcript is mandatory.** The on-chain verifier reconstructs the transcript with Keccak, while proving backends commonly default to Poseidon2, and a default-transcript proof verifies locally then fails on-chain. Implementations MUST set the Keccak transcript for proof generation, local verification, and verification-key derivation alike. -- **Zero-knowledge mode MUST NOT be enabled** while the verifier implements only the non-zk flavour. - -Circuit artifacts and verification keys MUST record the toolchain version that produced them. Version drift between a client's vendored artifacts and the deployment's pinned toolchain produces verification keys that differ from the deployed ones while every local test passes. - -### 8.2 Verification-key identity - -Before submitting a proof, an implementation MUST verify that the verification key its circuit artifact implies matches the one the deployment holds for that circuit type. A mismatch means the client and the chain disagree about what is being proven, and the proof cannot succeed. - -Where a deployment permits verification-key rotation (DESIGN.md §3.5), an implementation MUST re-check rather than cache across sessions. - -### 8.3 Backend pluggability and the trust boundary - -Proving MUST sit behind an interface that admits multiple backends — in-process WASM, a native binary, a remote service — because the viable choice differs per platform. Browser bundlers, for instance, break worker-spawning proving libraries by rewriting the worker URL into a hashed chunk, so an implementation targeting browsers MUST allow the backend to be supplied by the host application rather than resolved statically. - -**Witness material MUST NOT cross the trust boundary by default** (§2.1). Remote proving discloses the spending key, the balance, and the transfer amount to the prover. An implementation MAY offer it, but MUST require explicit opt-in, MUST NOT select it as a fallback when a local backend fails, and MUST state precisely which values leave the device. - ---- - -## 9. Chain Adapter - -### 9.1 Reads - -The adapter MUST expose the account record and the spender delegation record (DESIGN_cont.md §11.3), and MUST treat the contract crate's types as authoritative for their shape. It MUST distinguish the three delegation states that DESIGN_cont.md §11.3 separates — absent, active, and expired-but-not-yet-revoked — and MUST NOT collapse the latter two. - -Auditor keys MUST be read from the auditor contract by the account's bound `auditor_id`, never supplied by the caller. - -### 9.2 Payload encoding - -Proof-carrying entry points take an XDR-encoded payload. Its byte representation is fixed by Soroban's canonical XDR rules, so independent implementations compiling against the same `#[contracttype]` definitions produce byte-identical payloads (DESIGN_cont.md §11). - -Two specifics: - -- Points are flat 64-byte values, not nested two-field structures (§4.2). -- Struct fields serialise as a map with symbol keys in canonical sorted order. - -### 9.3 Authorization and submission - -Every state-changing operation requires both the appropriate `require_auth()` principal (DESIGN_cont.md §11.1) and, where applicable, a valid proof. The adapter MUST submit the principal the interface specifies — notably the *spender*, not the owner, for a delegated transfer. - -Operations SHOULD be simulated before submission, which catches a stale commitment, a frozen account, or an expired delegation without a fee. - -### 9.4 Typed errors - -The adapter MUST surface contract failures as typed, distinguishable outcomes. At minimum it MUST separate: - -| Class | Meaning | Caller's next step | -|:--|:--|:--| -| Proof verification failed | The proof did not verify against the assembled public inputs | Do not retry blindly; check §8.2 and witness assembly | -| State mismatch | The referenced commitment is no longer current | Re-sync (§10.2) and rebuild the proof | -| Not registered | The account or counterparty has no confidential account | Register, or reject the recipient | -| Compliance rejection | Frozen, policy-denied, or unauthorized by the underlying asset (COMPLIANCE.md §2) | Surface to the user as an administrative state, not an error | -| Delegation state | Absent, duplicate, or expired | Distinguish per §9.1 | - -Separating the first two matters most: they present as the same opaque failure on the wire but have opposite remedies. - ---- - -## 10. Holder Wallet - -### 10.1 State model - -The wallet maintains the two accumulators of DESIGN.md §5.2 — $$W_{\text{spend}}$$ and $$W_{\text{receive}}$$ — plus a sync position and any in-flight projection (§10.3). Values and blindings alike accumulate as exact integers; a blinding is reduced modulo $$q$$ only where it leaves an accumulator for the curve (§4.6). - -Persistence MUST be pluggable, since the same core serves environments with very different storage. With RPC-only event access, discarding persisted state loses the receiving-side openings permanently (§10.9), so it MUST NOT be treated as an evictable cache. - -### 10.2 Event application - -Event application MUST be ordered, deduplicated, and idempotent in combination, and an implementation MUST state which layer discharges each obligation. The three are commonly split, with ordering and dedup at the event source and application left non-idempotent, which is conformant only if the split is explicit. - -- **Ordering.** Events MUST be applied in emission order, because reconstruction is order-sensitive: a merge and a deposit in the same ledger produce different state depending on which is applied first. The canonical total order is INDEXER.md §3.4's $$(\text{ledger\\\_seq}, \text{tx\\\_application\\\_order}, \text{event\\\_index})$$. -- **Deduplication.** Events MUST be deduplicated by event id, since a hybrid source (§12.4) can deliver the same event twice at its seam. -- **Idempotence.** Either application is idempotent, or dedup provably precedes it. Crediting rules accumulate, so a duplicate inflates a balance. - -Application rules are DESIGN.md §5.2's update table and are not restated here. Two properties worth making explicit: - -- `Withdraw`, sender-side `Transfer`, and `SetSpender` **overwrite** $$W_{\text{spend}}$$ from the event's $$(\tilde{b}, \sigma)$$ rather than adjusting it, so a wallet that missed intervening events still converges on the spendable side. -- `RevokeSpender` is not a checkpoint — it carries no $$\tilde{b}$$ — and is **folded**, like `Merge`. `RevokeSpender` adds the reclaimed allowance opening to $$W_{\text{spend}}$$, with $$v_a$$ and $$r_a$$ recovered from the event's $$\tilde{a}$$ and `allowance_salt` under the owner-derived $$dvk_i$$ (DESIGN.md §7.9). A wallet that misses either event diverges until the next checkpoint and MUST rely on §10.6 to detect that. -- `Clawback` is likewise not a checkpoint and is **folded**: the `Merge` rule, then the event's public `amount` subtracted from $$W_{\text{spend}}$$'s value (COMPLIANCE.md §5.7). It occurs only in deployments that enable the compliance extension. -- A self-transfer — a `Transfer` whose `from` and `to` are the same account — MUST be applied in both roles: the sender side overwrites $$W_{\text{spend}}$$ from the event's $$(\tilde{b}, \sigma)$$, and the recipient side credits $$W_{\text{receive}}$$ from the same event's recipient-channel ciphertexts. The two roles act on different accumulators, so their relative order does not affect the result; applying only one loses the other accumulator's update. - -### 10.3 In-flight operations - -A wallet MAY project the post-operation opening immediately on successful submission rather than waiting for the event, so that the balance a user sees reflects an action they just took. - -The projection MUST still be reconciled against the event, and MUST NOT be treated as confirmed for the purpose of §10.6. - -### 10.4 Salt freshness - -A fresh salt MUST be sampled for every **attempt**, including retries after a reverted or dropped transaction. The salt that must be fresh is the one the operation's pads absorb: $$\sigma$$ for owner-initiated operations, and $$\sigma_a'$$ — the replacement allowance salt — for spender transfers (DESIGN.md §6.2 *Transfer nonce*). - -DESIGN_cont.md §9.6 motivates this as unlinkability: a fresh salt prevents an observer correlating a reverted attempt with its retry. It is equally a confidentiality requirement, because the salt is the sole freshness input to every derived pad in the operation, the ephemeral scalar included (DESIGN.md §2.5, §5.3). Reuse therefore repeats the ephemeral key and every channel mask that depends on it. - -An implementation MUST NOT cache or reuse a salt across attempts, and MUST NOT derive it from anything an observer can predict. - -**Freshness is not enforced on-chain.** Constraint O14 rejects only $$\sigma_a' = \sigma_a$$, the adjacent collision; a circuit cannot see a delegation's older salts. An implementation MUST therefore treat non-repetition of $$\sigma_a'$$ over the whole life of a delegation as its own obligation, and MUST NOT cycle salts through a bounded set. - -### 10.5 Deterministic ephemeral scalars - -An implementation MUST derive the ephemeral scalar of every operation the holder or spender originates: - -$$r_e = \text{poseidon\\\_with\\\_domain}(\delta_{\text{eph}}, [vk, \sigma_E])$$ - -where $$vk$$ is the originator's viewing key and $$\sigma_E$$ the operation's salt. DESIGN.md §5.3 is the normative source: it fixes which viewing key and salt each operation derives from, and the retry rule for the negligible case that the derivation yields zero. - -**Three consequences an implementation MUST handle.** - -First, **$$vk$$ carries more authority than balance decryption.** Recomputing $$r_e$$ yields the recipient shared scalar, hence $$r_{\text{transfer}}$$, hence a full Pedersen opening of every transfer commitment the account created — retroactively, and reaching commitments that sit inside recipients' receiving balances (DESIGN_cont.md §9.4, §8.2). An implementation MUST treat $$vk$$ accordingly in §13 and MUST NOT export it as a read-only credential without stating this. - -Second, **the salt requirement of §10.4 is a confidentiality requirement**, not only an unlinkability one, the salt being the operation's sole freshness input. - -Third, **disclosability is unverifiable from chain data.** No on-chain value distinguishes an ephemeral this derivation produced from one it did not, so an implementation MUST NOT infer disclosability from a stored per-transfer flag and MUST determine it by test (§12.2). Transfers predating this specification may not be disclosable by their sender. - -### 10.6 Consistency checking - -The wallet MUST verify its openings against on-chain commitments by re-committing and comparing (DESIGN.md §5.2 *Consistency check*), and MUST do so both after every sync and before constructing any proof. A missed event, a duplicate, an expired credit, or a defect then produces a mismatch rather than a plausible wrong balance, and a mismatched state MUST NOT be spent from. - -Implementations MUST report which accumulator diverged, since the two have different causes and different remedies. - -### 10.7 The unspendable-blinding case - -$$W_{\text{spend}}.r$$ is an exact integer, but a proof witnesses it as a single $$\mathbb{F}_r$$ `Field`. After any of the three proofless folds — `Merge`, `RevokeSpender`, `Clawback` — its canonical $$\mathbb{F}_q$$ representative can land in $$[r, q)$$, which no `Field` encodes, so no proof can be constructed against that commitment. The state itself is sound: the commitment is a well-formed Grumpkin point, and §10.6's check recommits from the same representative and still passes (DESIGN_cont.md §10.4 *Post-merge witness availability*). - -An implementation MUST test the reduction rather than the accumulator: before attempting a proof, reduce $$W_{\text{spend}}.r$$ modulo $$q$$ at §4.6's reduction point and compare the representative against $$r$$. The unreduced accumulator carries no signal: after a few folds an integer sum past $$r$$ is the ordinary case. - -A failed comparison MUST surface as a distinct, named state rather than as a generic proof-construction failure. The recovery path MUST surface with it: the condition resolves at the next merge that folds in an inbound confidential transfer, and an account whose only inflows are deposits stays affected until one arrives (DESIGN_cont.md §10.4 *Soft recovery*). - -### 10.8 Merge policy - -Received funds are spendable only after a merge (DESIGN.md §7.4). A wallet SHOULD merge automatically, or prompt, ahead of a spend that the spendable balance alone cannot cover. Merging ahead of a spend also bounds the recovery replay window, which starts at the last merge at or before the latest checkpoint (§10.9). - -Merge is proof-less and owner-authorized, and neither a merge nor an in-flight spend proof can be disrupted by a third party (DESIGN_cont.md §9.1, §9.2). - -### 10.9 Recovery - -Recovery follows the procedure of DESIGN.md §5.2 *Recovery*, with the reconstructed state verified per §10.6. Its two anchors differ: the latest checkpoint event pins $$W_{\text{spend}}$$ as of that checkpoint in one lookup, with the `Merge`, `RevokeSpender`, and `Clawback` folds that follow it applied over the replay window per §10.2, while $$W_{\text{receive}}$$ restarts at $$T_0$$ — the account's last `Merge` or `Clawback` at or before that checkpoint — from which that window runs. - -Two further obligations follow from data availability: - -- Recovery from a root alone depends on a conforming indexer (INDEXER.md). Without one, a client can see that funds exist but cannot reconstruct the opening needed to spend them. -- **With RPC-only event access, a client MUST sync at least once per RPC retention window**, and MUST warn when it has not. Any event that ages out before it is applied takes its opening with it permanently: a crediting event on the receiving side, which is a running sum from $$T_0$$, and a `RevokeSpender` after the latest checkpoint, whose $$\tilde{a}$$ and `allowance_salt` survive nowhere else once the delegation entry is deleted (DESIGN.md §7.9). Only the spend history preceding the checkpoint is expendable, each checkpoint being self-contained. - -### 10.10 Spender-side wallet - -A spender reconstructs its allowance state from the on-chain delegation entry rather than from event replay: it recovers $$dvk_i$$ from the escrowed value by ECDH (DESIGN.md §7.11), then reads the current allowance from the entry's encrypted allowance and salt (DESIGN_cont.md §11.3). - -Implementations MUST surface the delegation's expiry ledger and SHOULD warn ahead of it. They MUST represent expired-but-unrevoked delegations as still holding escrowed value (DESIGN.md §6.2). A `RevokeSpender` event with the entry gone means the owner folded the delegation back. - -A spender MUST NOT be able to reach the owner's spendable balance through any interface (§3). - ---- - -## 11. Auditor Client - -An auditor decrypts from the public event and its own secret $$k$$ alone, with no viewing key, holder cooperation, or extra on-chain read. For each channel it computes the shared scalar against the event's ephemeral point, derives that channel's lane masks (§4.3) — three on the sender / owner channel, two on the recipient channel — and subtracts. The allowance opening comes straight out of the event: the blinding of the $$C_a$$ that operation writes is escrowed in the event itself — tag 17 on `SetSpender`, `lane[2]` on `SpenderTransfer` — and the matching value is in the sender-channel ciphertext (DESIGN_cont.md §8.5). An auditor that did not observe the event holds no opening for that state. - -The two channels differ in what they yield (DESIGN_cont.md §8.1): - -| Channel | `lane[0]` | `lane[1]` | `lane[2]` | -|:--|:--|:--|:--| -| Sender / owner ($$\delta_{\text{aud\\\_s}}$$) | Transfer amount, or the escrowed amount for `SetSpender` | Sender's post-operation balance, or post-operation allowance for a spender transfer | Post-operation spendable blinding on `Withdraw`, `Transfer`, and `SetSpender`; post-transfer allowance blinding $$r_a'$$ on `SpenderTransfer` | -| Recipient ($$\delta_{\text{aud\\\_r}}$$) | Transfer amount | Per-transfer Pedersen randomness $$r_{\text{transfer}}$$ | — (channel is two-lane) | - -`Withdraw` and `SetSpender` carry a sender-channel balance checkpoint whose pad is `lane[1]`. Only `Withdraw` leaves `lane[0]` unused, its amount being public (DESIGN.md W_a3, §4.3); `SetSpender` reads `lane[0]` as well, for the escrowed amount (DESIGN.md S_a4). - -An implementation MUST squeeze the sender / owner channel three-wide and MUST NOT widen the recipient channel. `RevokeSpender` opens no auditor channel, the fold being proofless (DESIGN.md §7.9); an implementation MUST treat that event as carrying no escrowed blinding rather than substituting a stale one. - -**Cross-channel agreement.** Where an auditor holds the key for both parties, the amount decrypts independently on each channel and the circuit constrains both to the same value, so the two MUST agree. An implementation SHOULD perform this comparison and treat disagreement as evidence that $$k$$ is not the auditor key for both parties of that event. - -**Scope MUST be represented, not implied.** The recipient-channel capability is forward-only, reset by merge, and yields no opening of the spendable commitment (DESIGN_cont.md §8.1). The `lane[2]` opening of the sender channel is forward-only and **standing**: it opens the spendable commitment as of the checkpoint that escrowed it and stays valid through merges provided the client folded in every inbound flow since the previous merge, one account key serving both channels (DESIGN_cont.md §8.1 *Sender-auditor opening capability*). Maintaining it is the client's job: an implementation MUST add the `lane[0]` amount and `lane[1]` $$r_{\text{transfer}}$$ of every inbound `Transfer` and `SpenderTransfer` to its stored $$(v, r)$$, and MUST treat each `Deposit` as $$(\text{amount}, 0)$$ (DESIGN.md §7.3). On a `RevokeSpender` event it MUST add the $$(v_a, r_a)$$ it recorded for that delegation, the fold being public and the addend already escrowed (DESIGN_cont.md §8.5); where it holds no such record it MUST mark the opening unavailable until the next `lane[2]` escrow rather than carry a stale one. Across a key rotation, what an implementation may carry forward and what it MUST treat as unopened are fixed by DESIGN_cont.md §8.3. - -**Clawback witness.** In a deployment that enables seizure (COMPLIANCE.md §5), the clawback witness is the auditor secret key together with the pair of openings the client already maintains — the standing sender-channel opening of $$C_{\text{spend}}$$ and the recipient-channel opening of $$C_{\text{receive}}$$ (COMPLIANCE.md §5.2, §5.3). An implementation MUST verify both against the on-chain commitments before proving, MUST read the account's current `clawback_nonce` into the public inputs (COMPLIANCE.md §5.3), and on a `Clawback` event MUST advance the standing opening by the `Merge` rule and the event's public `amount` (COMPLIANCE.md §5.7). - -An auditor facade MUST NOT be able to construct a spending witness. It can open the spendable balance past a merge — the inbound $$r_{\text{transfer}}$$ reaches the same key on the recipient channel — so a facade that exposes openings exposes them for the account's whole history under the active key. - ---- - -## 12. Disclosure and Indexer Clients - -### 12.1 Disclosure construction - -An implementation supporting selective disclosure MUST follow SELECTIVE_DISCLOSURE.md for the holder, sender, and auditor variants, and MUST bind each proof to the requesting recipient's key and nonce so that a proof cannot be replayed against a different recipient or a later request (SELECTIVE_DISCLOSURE.md §2.1). - -Disclosure circuits are verified entirely off-chain and MUST NOT be registered with the on-chain verifier set (SELECTIVE_DISCLOSURE.md §15.1). - -### 12.2 Disclosure verification - -The verifier MUST be distributable independently of any wallet, since its purpose is to let a party who trusts no holder check a claim. It consumes a chain endpoint, the recipient keypair, and the proof bundle with its event reference, and returns the disclosed amount or a **typed** indication of which check failed — proof verification, on-chain state mismatch, or decryption failure. A single boolean is not conformant, because the three outcomes have different meanings to the recipient. - -Verification MUST include comparing the circuit's verification key against the pinned key for that disclosure circuit, without which the proof attests to an unknown statement. - -Not every historical transfer is disclosable by its sender: one predating §10.5's requirement may carry an ephemeral scalar that does not reproduce. An implementation MUST report that as *not disclosable* rather than as a verification failure, and MUST establish it by test: derive the candidate $$r_e$$ from $$(vk, \sigma_E)$$ and compare $$r_e \cdot H$$ against the event's $$R_e$$. The comparison costs one Poseidon2 call and one scalar multiplication and is authoritative, where a stored per-transfer flag is not (§10.5). - -### 12.3 Indexer client - -Recovery beyond the RPC retention window requires a conforming durable archive (INDEXER.md). A client MUST propagate the archive's completeness signal to its caller rather than swallowing it, since an incomplete range and a tampered range both end in the same refusal at §10.6 and only that signal distinguishes them. - -Clients SHOULD support multiple independent archive endpoints, since withholding is the residual trust the archive retains (INDEXER.md §7). - -### 12.4 The hybrid read path and its two failure modes - -RPC and archive compose: the RPC serves the recent tail, the archive everything older, and the client stitches them at a seam (INDEXER.md §1). Two requirements are not derivable from INDEXER.md and are specified here. - -**The seam MUST sit strictly above the RPC's reported retention floor, by a margin.** The floor advances as ledgers are collected, including between the moment the client reads it and the moment it issues the range query, with the archive request in between taking real time. A seam placed exactly at the observed floor therefore intermittently produces a rejected query. The archive covers everything below the seam, so a margin loses no events. Implementations MUST set the two legs to disjoint ledger ranges so that correctness does not depend on cross-source id equality, and MUST still deduplicate (§10.2) as a guard at the boundary. - -**A configured archive's failure MUST fail the whole sync.** If an archive is configured and its request fails, an implementation MUST NOT degrade silently to RPC-only and MUST NOT persist a sync position derived from the RPC leg alone. Persisting it moves the position past the pre-window range, so every later sync takes the warm path that never consults the archive and the openings in the skipped range become unrecoverable. An archive that is *not* configured is a different case and MAY be absent, provided §10.9's warning is surfaced. - ---- - -## 13. Security Requirements - -**Secret handling.** The root, $$sk$$, $$vk$$, $$dvk_i$$, every derived $$r_e$$, every cached opening, and — on the auditor side — $$k$$ and the accumulated openings of §11 are secrets. Implementations MUST keep them within the trust boundary (§2.1), SHOULD zeroize buffers holding them once no longer needed, and MUST NOT transmit them to any remote service except under §8.3's explicit opt-in. - -$$vk$$ MUST NOT be presented as a safely-shareable read-only credential. It exposes every historical balance checkpoint, every incoming amount, every delegation allowance, and — through the ephemeral-scalar derivation of DESIGN.md §5.3 — the opening of every transfer the account originated. Its only guarantee is that it cannot authorize spending. A party that needs outbound visibility is served with D-sender proofs, which are bound to that party and to a nonce (SELECTIVE_DISCLOSURE.md §13.2), never by handing over the key. - -**Storage at rest.** Persisted openings are as sensitive as the amounts they represent, and a persisted root is equivalent to the funds. Implementations MUST document what they persist and where, and SHOULD encrypt both at rest under a key that is not itself derived from persisted material. A signer root (§5.2) MUST NOT be persisted at all, since it is reproducible on demand from the signer. - -**Logging and telemetry.** Witness material, decrypted amounts, balances, and openings MUST NOT reach logs, telemetry, analytics, error reports, or crash dumps. - -**Randomness.** All sampling MUST use a cryptographically secure generator (§4.7). An implementation MUST NOT fall back to a non-CSPRNG source when one is unavailable; it MUST fail. - -**Supply chain.** Soundness depends on the verification keys corresponding to the intended circuits and on the structured reference string having been generated honestly (DESIGN_cont.md §10.6). Implementations MUST pin verification keys and the proving toolchain (§8.1), SHOULD document how a user reproduces a deployed key from circuit source, and MUST NOT fetch either at runtime from a mutable location. - -**What the SDK cannot do.** It cannot mitigate a compromised host: malicious code in the process, the bundle, or the storage layer reaches the root. Implementations MUST NOT present software-only custody as equivalent to hardware custody. Hardware custody is additionally constrained here, because proving requires $$sk$$ as a witness, so a device that does not prove internally must expose $$sk$$ to the host. - ---- - -## 14. Non-Functional Requirements - -**Proving latency.** Single-digit seconds on contemporary hardware is the design target (OVERVIEW.md). Implementations MUST treat it as user-visible. - -**Sync and replay bounds.** The replay window runs from the account's last `Merge` or `Clawback` at or before its latest checkpoint, or from registration if there is none before that checkpoint (DESIGN.md §5.2 *Recovery*), which is unbounded in age. Implementations MUST NOT assume a bounded window, and SHOULD use the archive's checkpoint lookup where available (INDEXER.md §6, C1) so that a dormant account is not obliged to transfer its entire history. - -**Storage growth.** Per-account event volume is linear in inbound transfers and unbounded by design, since incoming-transfer spam is rate-limited only by transaction fees (DESIGN_cont.md §9.5). Implementations MUST NOT size local storage on the assumption that inbound volume tracks the user's own activity. - -**Offline capability.** Key derivation, witness assembly, and proof generation need no network. Balance display needs synced state. Submission and consistency checking need the chain. Implementations SHOULD make the boundary explicit rather than failing opaquely when offline. - -**Version matrix.** An implementation MUST expose, for inspection and for bug reports: the protocol documentation version it targets, the contract address and its `addr_f`, the verification-key set and producing toolchain versions (§8.1), and the domain-separator scheme in use (§4.8, including whether the alternate hashed scheme of DESIGN_cont.md §13 was selected). Each is an interoperability boundary whose mismatch is undiagnosable without knowing its value. - ---- - -## 15. Conformance and Versioning - -An implementation conforms to this specification iff it: - -1. reproduces every vector of §6.1 byte-for-byte, reading the fixture files rather than transcribing them; -2. passes circuit-execution parity with tamper rejection (§6.2) for every circuit it supports; -3. satisfies §4–§12 for the roles it implements; -4. exposes the version matrix of §14. - -Partial role coverage is conformant and MUST be declared: an implementation supporting only the holder role is conformant for that role, and MUST NOT claim spender, auditor, or disclosure conformance. Partial *circuit* coverage MUST likewise be declared. - -**The portable surface is §4–§7.** The crypto core, key derivation, and witness assembly are what two implementations must agree on byte-for-byte. The facades of §10–§12 are deployment-shaped, and this document constrains their obligations rather than their structure. - -This document is versioned with the protocol documentation set. A change to any primitive in §4, to the derivation in §5, to the ephemeral-scalar derivation (DESIGN.md §5.3, restated in §10.5), or to the domain separators in §4.8 breaks the cross-implementation contract: it MUST bump the protocol documentation version, MUST be called out in release notes, and MUST be accompanied by updated fixtures in `circuits/lib/testdata/`. A change to the numeric value of a domain separator, or to the sponge convention, invalidates every previously derived key and every previously emitted ciphertext, and is a new deployment rather than an upgrade. diff --git a/packages/tokens/src/confidential/docs/SELECTIVE_DISCLOSURE.md b/packages/tokens/src/confidential/docs/SELECTIVE_DISCLOSURE.md deleted file mode 100644 index 3dab1edd2..000000000 --- a/packages/tokens/src/confidential/docs/SELECTIVE_DISCLOSURE.md +++ /dev/null @@ -1,561 +0,0 @@ -# Confidential Token: Selective Disclosure - -## Abstract - -This document specifies an off-chain selective-disclosure layer for the Confidential Token (see [DESIGN.md](DESIGN.md)). The core protocol already provides forward-only auditor visibility (DESIGN_cont.md §8): a registered auditor decrypts every transfer the account participates in. That model is sufficient for trusted-third-party regulatory access but insufficient for the routine compliance case where the account holder must prove a *single* fact (a specific transfer amount, an aggregate over a window, a counterparty relationship) to a *specific* counterparty (a bank's compliance desk, a tax authority, a KYC provider) without granting blanket visibility. - -This layer addresses that gap with a family of Noir circuits that produce per-event, recipient-bound disclosure proofs. The on-chain contract is untouched. Disclosure proofs are generated client-side by either the account holder (using their viewing key) or the auditor (using their auditor key), delivered out-of-band, and verified off-chain by the disclosure recipient against the on-chain event log. - ---- - -## 1. Introduction - -### 1.1 Overview - -The Confidential Token hides the *amounts* that move between accounts: balances and transfer values live on-chain as encrypted commitments rather than as readable numbers (DESIGN.md §1). That is the right default for privacy, but it collides with a routine reality of regulated finance — sometimes the holder of an account *must* prove a specific fact about their own activity to a specific outside party. A bank's compliance desk asks a customer to show that a particular incoming payment was for the amount claimed. A tax authority asks for the total received over a quarter. A KYC provider asks for evidence that an account's balance sits below a threshold. Each of these is a request to reveal *one* fact, to *one* counterparty, and nothing else. - -The protocol already includes an auditor mechanism (DESIGN_cont.md §8), but it is all-or-nothing: an auditor holds a key that decrypts *every* transfer an account takes part in. Handing that key — or its decrypted output — to a bank or a tax office to answer a single question would expose the account's entire history. Selective disclosure is the missing middle ground: a way for the holder to prove exactly one statement, to exactly one recipient, in a form the recipient can check against the public ledger but cannot reuse, resell, or replay against anyone else. - -The mechanism lives entirely *outside* the on-chain contract: both proving and verifying happen off-chain, and the protocol pays nothing (the narrow cases where verification can be moved on-chain are in §5.4). It behaves like a notarized, single-use statement, produced in four steps: - -1. **The recipient asks.** The counterparty (bank, auditor, tax office) gives the holder a one-time reference number tied to this specific request. -2. **The holder proves.** Using the secret keys already in their wallet, the holder generates a *zero-knowledge proof* — a mathematical certificate that the claimed fact is true and is genuinely tied to a real event recorded on-chain, without revealing any of the secrets behind it. The disclosed value itself is sealed so that only the requesting recipient can open it. -3. **The holder delivers.** The proof and the sealed value go to the recipient directly, off-chain. They are never published. -4. **The recipient verifies.** The recipient checks the proof against the public on-chain record. If it passes, the recipient learns the one disclosed fact — and can trust it as much as they trust the blockchain itself — and learns nothing else about the account. - -Because each proof is locked to the recipient's identity *and* to their one-time reference number, a proof handed to one bank is useless to anyone else, and useless even to the same bank for a different request. A leaked or archived proof reveals nothing. - -The layer supports a small family of these statements, each a different shape of question: - -- *"This on-chain payment paid me this amount."* — the recipient of a transfer proves what they received (§6). -- *"This on-chain payment was sent by me for this amount."* — the sender of a transfer proves what they sent (§7). -- *"My current balance is this — or is at most this."* — the holder proves a fact about the balance they hold right now (§9). -- *"The total across this set of payments is this."* — the holder proves an aggregate over several transfers at once (§10). -- The same proofs produced by the *auditor* instead of the holder, for cases where the disclosure must come from the regulator-facing side rather than the account owner (§8). - -The remainder of this document specifies these statements precisely as zero-knowledge circuits, defines the exact checks a recipient must perform, and analyzes what each one does and does not reveal. - -### 1.2 The Selective-Disclosure Gap - -The core protocol's audit surface (DESIGN_cont.md §8) gives each auditor a key that decrypts every transfer ciphertext for accounts under their scope. This is appropriate for the auditor-as-disclosure-agent role, where the auditor responds to authorized regulatory requests by decrypting specific events. - -Three properties make this insufficient as the only disclosure surface: - -1. **Granularity.** The auditor key cannot decrypt one transfer without being able to decrypt all of them. There is no cryptographic enforcement that the auditor disclose only what was asked for. -2. **Counterparty disclosures.** Common compliance flows (KYC source-of-funds proofs, bank inbound-payment attestations, tax declarations) require the holder to disclose to a counterparty that is not the auditor. Routing every such request through the auditor concentrates trust and adds operational latency. -3. **Recipient binding.** A plaintext disclosure can be re-shared, replayed, or archived. There is no cryptographic anchor that ties a disclosed value to the specific recipient that requested it. - -### 1.3 Design Goals - -**Per-event scope.** A disclosure proof corresponds to one named on-chain event (or a finite enumerated set), not to an account's history. - -**Recipient binding.** Each proof is bound to a specific disclosure recipient's public key plus a fresh nonce so that proofs are non-replayable and not transferable to other parties. - -**Verifiable correctness.** The disclosure recipient verifies the proof against on-chain state (event log, account record, auditor key registry) without trusting the prover. - -**Zero protocol cost.** No changes to the contract's storage model, entry points, or per-transfer ciphertext layout. Disclosure proofs add wallet-side cost only. - -**Two prover roles.** Either the holder (using their viewing key $$vk$$) or the auditor (using their auditor secret $$aud\_{sk}$$) can produce a disclosure proof. The roles share a circuit family with swappable witness blocks. - -### 1.4 Non-Goals - -**Completeness proofs.** This layer proves positive statements ("this event paid me $$X$$"). It does not prove negatives ("I have no other transfers from $$Y$$"). Completeness, where required, continues to route through the auditor (DESIGN_cont.md §8) or through a future Merkle-accumulator extension that is out of scope here. - -**On-chain disclosure logging.** Disclosures are off-chain artifacts exchanged between holder and recipient. The contract does not log disclosure events; doing so would leak the metadata the rest of the protocol works to hide. - -**Disclosure-recipient registry.** Recipients identify themselves by Grumpkin public keys exchanged out-of-band. The contract does not register, gate, or approve specific recipients. - ---- - -## 2. Preliminaries - -This document reuses the notation, key hierarchy, and commitment scheme from DESIGN.md §2 and §4 without restatement. The following are referenced repeatedly: - -- $$sk\_A$$, $$vk\_A$$, $$\text{PVK}\_A$$: an account's spending key, viewing key, and public viewing key (DESIGN.md §4). -- $$\text{addr\\\_f}$$: the contract's compressed address Field $$\text{address\\\_to\\\_field}(\text{contract})$$, bound into $$vk$$ derivation (DESIGN.md §2.7, §4.2). Stored once at construction in the contract's instance storage (DESIGN.md §3.5). -- $$K\_{\text{aud,s}}$$, $$K\_{\text{aud,r}}$$, $$aud\_{sk}$$: the sender-side and recipient-side auditor Grumpkin public keys, and an auditor's secret key (DESIGN_cont.md §8.1, §8.3). Each account selects an `auditor_id` at registration; the same `auditor_id` may resolve to either role depending on the transfer's direction. -- $$(R\_e, \sigma, \tilde{v}, \tilde{b}, \tilde{v}\_{\text{aud,r}}, \tilde{r}\_{\text{aud,r}}, \tilde{v}\_{\text{aud,s}}, \tilde{b}\_{\text{aud,s}}, \tilde{r}\_{\text{aud,s}})$$: per-transfer event fields (DESIGN.md §7.6, §11.2). For `SpenderTransfer` events the recipient/auditor ECDH nonce is $$\sigma\_a'$$ in place of $$\sigma$$, and the sender-auditor channel emits $$\tilde{a}\_{\text{aud,s}}$$ in place of $$\tilde{b}\_{\text{aud,s}}$$, with `lane[2]`'s $$\tilde{r}\_{\text{aud,s}}$$ carrying the post-transfer allowance blinding $$r\_a'$$ rather than a spendable one (DESIGN.md §7.8, §11.2). Throughout this document, the symbol $$\sigma\_E$$ refers to the **event ECDH nonce**, equal to $$\sigma$$ for `Transfer` events and to $$\sigma\_a'$$ for `SpenderTransfer` events; one circuit handles both families, parameterized by which nonce the disclosing event emitted. -- $$H$$: the Grumpkin Pedersen generator used uniformly for key derivation and ECDH (DESIGN.md §2.3, §2.4). - -### 2.1 Disclosure Recipient - -A disclosure recipient publishes a long-lived Grumpkin keypair $$(r\_R, P\_R)$$ with $$P\_R = r\_R \cdot H$$, by the same mechanism they publish any other public key (web PKI, certificate, identity document). Publication is out-of-band; no on-chain registration. - -For each disclosure request, the recipient supplies a fresh nonce $$\nu \in \mathbb{F}\_r$$ over an authenticated channel. The pair $$(P\_R, \nu)$$ binds the resulting proof. A holder cannot reuse a proof bound to $$(P\_R, \nu)$$ against a different recipient or against the same recipient's future requests. - -### 2.2 Domain Separators - -Two domain separators are specific to this layer: $$\delta\_{\text{disc\\\_bind}}$$, the disclosure-ciphertext domain for aggregate disclosures (§10), and $$\delta\_{\text{disc}}$$, which keys the disclosure ciphertext to the recipient (§4). Neither is absorbed in a core circuit. Both take their values from DESIGN_cont.md §13, which assigns them as a continuation of its own sequence. - -A wallet producing a D-sender proof also uses $$\delta\_{\text{eph}}$$ (DESIGN_cont.md §13) to recover the event's ephemeral scalar before proving (§7). That use is off-circuit: no disclosure circuit absorbs the tag. - ---- - -## 3. Threat Model - -The disclosure layer inherits the protocol's threat model (DESIGN.md §3.2) and adds: - -**Holder is the prover for D-recipient and D-sender variants.** The holder is trusted only to produce *correct* proofs about events they choose to disclose. The holder is *not* trusted to be complete: they may withhold events. Recipients that require completeness must obtain it from the auditor (DESIGN_cont.md §8) or from out-of-band evidence. - -**Auditor is the prover for D-auditor variants.** The auditor is trusted to disclose accurately when asked. The auditor's existing trust scope (DESIGN.md §3.3) is not enlarged. That scope includes the full Pedersen opening of every $$C\_a$$ (DESIGN_cont.md §8.5) and, at each checkpoint event that escrows `lane[2]`, of the account's $$C\_{\text{spend}}$$ as of that event (DESIGN_cont.md §8.1 *Sender-auditor opening capability*, §8.2). The latter is standing rather than event-scoped: it carries across merges and across `revoke_spender` (DESIGN_cont.md §8.1). The D-auditor variants expose to a disclosure recipient only what the chosen variant states. - -**Disclosure recipient is honest-but-curious.** The recipient correctly verifies proofs and decrypts ciphertexts addressed to their key. The recipient may attempt to replay or rebroadcast proofs; nonce binding prevents reuse against other parties. - ---- - -## 4. Disclosure Ciphertext to Recipient - -All variants below share a common output stage that encrypts the disclosed value $$v\_{\text{transfer}}$$ under the recipient's key $$P\_R$$. - -The prover samples an ephemeral scalar $$r\_{\text{disc}} \in \mathbb{F}\_r$$ and computes: - -$$R\_{\text{disc}} = r\_{\text{disc}} \cdot H$$ -$$s\_{\text{disc}} = \text{ECDH}(r\_{\text{disc}}, P\_R) \qquad \text{(DESIGN.md §2.4)}$$ -$$\tilde{v}\_{\text{disc}} = v\_{\text{transfer}} + \text{Poseidon}(\delta\_{\text{disc}}, s\_{\text{disc}}, \nu)$$ - -The recipient decrypts: - -$$s\_{\text{disc}} = \text{ECDH}(r\_R, R\_{\text{disc}}), \qquad v\_{\text{transfer}} = \tilde{v}\_{\text{disc}} - \text{Poseidon}(\delta\_{\text{disc}}, s\_{\text{disc}}, \nu)$$ - -The pair $$(R\_{\text{disc}}, \tilde{v}\_{\text{disc}})$$ is part of the proof's public inputs. The disclosed amount is therefore confidential to any party other than the recipient even if the proof itself is archived in the clear. - -This block is constraints **U1–U3**: - -| # | Constraint | -|:--|:---| -| U1 | $$R\_{\text{disc}} = r\_{\text{disc}} \cdot H$$ | -| U2 | $$s\_{\text{disc}} = \text{ECDH}(r\_{\text{disc}}, P\_R)$$ | -| U3 | $$\tilde{v}\_{\text{disc}} = v\_{\text{transfer}} + \text{Poseidon}(\delta\_{\text{disc}}, s\_{\text{disc}}, \nu)$$ | - -Subsequent variants reference this block by name. - ---- - -## 5. Proof Bundle and Verifier Protocol - -The circuit-specific sections that follow (§6 - §10) define what each variant proves. This section defines the *transport* and the *verifier's mandatory checks*, which are common to all variants and are the only checks that bind a proof to specific on-chain state. - -The disclosure layer runs entirely between two off-chain parties — the prover (holder, sender, or auditor) and the disclosure recipient — with the blockchain participating only as a read-only source of truth. Three properties define this operating model. - -**Shared circuit artifacts.** Both parties derive their tools from the *same* compiled Noir circuit. The prover side runs the proving key and the witness generator, embedded in the wallet (§15.2); the recipient side runs the verification key for the matching `circuit_id`, embedded in the verifier library (§15.3). A verification key is the cryptographic fingerprint of one specific circuit, so the `circuit_id` carried in the bundle (§5.2) is what lets the recipient load the right key and reject a proof produced by any other circuit. Prover and verifier must therefore agree, out of band, on *which* compiled circuit each `circuit_id` denotes. A recipient who loads a verification key for a maliciously altered circuit can be convinced of false statements, so the verification-key set is a trusted input. - -**Authenticated channel.** The request (§12 step 1) and the bundle delivery (§12 step 4) travel over a channel the two parties already trust for authenticity — TLS to a compliance API, a signed email, a dedicated KYC portal. The channel carries the recipient's $$(P\_R, \nu)$$ outbound and the bundle inbound. What it does **not** have to provide is confidentiality of the disclosed value: the value is sealed to $$P\_R$$ inside $$\tilde{v}\_{\text{disc}}$$ (§4), so an eavesdropper learns nothing even in the value-revealing variants, and channel confidentiality is defense-in-depth rather than a requirement. - -**Independent on-chain reads.** Neither party trusts the other for any value the chain can supply. The recipient resolves every event field and account record directly from the ledger (§5.3) through its own RPC endpoint or indexer (§15.3); the prover likewise reads the chain to assemble its witnesses and public inputs. The bundle is the *only* prover-to-verifier data transfer, and it carries only the proof and the references needed to locate the on-chain anchors — never the anchors themselves (§5.2). - -The net effect is that a disclosure leaves no on-chain trace: no transaction, no event, no state change is associated with it (§1.4, §12). The chain does not know that a disclosure happened, to whom, or about what. - -### 5.1 Event Reference - -An **event reference** $$\text{ref}\_E$$ uniquely identifies one on-chain transfer-family event: - -$$\text{ref}\_E = (\text{tx\\\_hash}, \text{op\\\_index}, \text{event\\\_index})$$ - -where `tx_hash` is the Soroban transaction hash that emitted the event and `event_index` selects the event among those the transaction emitted; `op_index` is always 0, since a Soroban transaction carries a single operation. This triple deterministically resolves to exactly one on-chain event, which is all the verifier's lookup (§5.3) needs — it is a resolver, not an ordering key. Its `tx_hash` and `event_index` are the same fields the durable indexer keys on; the indexer's full event-identity and ordering scheme is defined in [INDEXER.md](./INDEXER.md) §2 — `(ledger_seq, tx_hash, event_index)` for identity, `(ledger_seq, tx_application_order, event_index)` for order. Implementations MAY substitute any identifier the indexer in use exposes, provided it deterministically resolves to a single on-chain event. - -### 5.2 Proof Bundle - -The prover (holder, sender, or auditor) delivers the following bundle to the disclosure recipient over any authenticated channel: - -$$\text{Bundle} = (\text{circuit\\\_id}, \text{ref}\_E, \pi, R\_{\text{disc}}, \tilde{v}\_{\text{disc}})$$ - -| Field | Purpose | -|:---|:---| -| `circuit_id` | Identifies the variant — D-recipient, D-sender, D-auditor, or one of their aggregate / balance / randomness sub-forms. Pins the verification key the recipient loads. | -| $$\text{ref}\_E$$ | Event reference (§5.1). Tells the verifier which on-chain event the proof claims to describe. | -| $$\pi$$ | UltraHonk proof. | -| $$R\_{\text{disc}}, \tilde{v}\_{\text{disc}}$$ | Disclosure ciphertext (§4). Also appear in the proof's public-input vector. | - -The bundle does **not** include the event's payload, the disclosing account's address, or any of the circuit's other public inputs. Those are reconstructed by the verifier from $$\text{ref}\_E$$ and from on-chain state, never accepted from the prover's bundle. This is the analogue of DESIGN.md §7.1's trust-boundary rule: the prover supplies the proof and the event reference; everything else comes from authenticated on-chain state. - -### 5.3 Verifier Protocol - -Given a bundle for $$(P\_R, \nu)$$ that this verifier previously issued, the recipient MUST perform every step below in order. Each step's failure is a hard reject; the recipient MUST NOT learn $$v\_{\text{transfer}}$$ from a bundle that fails any step. - -1. **Resolve the event.** Look up $$\text{ref}\_E$$ via the indexer or via direct RPC of the transaction. The lookup MUST return exactly one event whose contract address equals the deployed confidential-token contract. Extract the event's payload fields verbatim: - - For `Transfer`: `from`, `to`, $$R\_e$$, $$\sigma$$, $$\tilde{v}$$, $$\tilde{b}$$, $$\tilde{v}\_{\text{aud,r}}$$, $$\tilde{r}\_{\text{aud,r}}$$, $$\tilde{v}\_{\text{aud,s}}$$, $$\tilde{b}\_{\text{aud,s}}$$, $$\tilde{r}\_{\text{aud,s}}$$ (DESIGN_cont.md §11.2). - - For `SpenderTransfer`: `spender`, `from`, `to`, $$R\_e$$, $$\sigma\_a'$$, $$\tilde{v}$$, $$\tilde{v}\_{\text{aud,r}}$$, $$\tilde{r}\_{\text{aud,r}}$$, $$\tilde{v}\_{\text{aud,s}}$$, $$\tilde{a}\_{\text{aud,s}}$$, $$\tilde{r}\_{\text{aud,s}}$$ (DESIGN_cont.md §11.2). - - Any other event type, or a `circuit_id` whose constraints reference a field the event does not carry, is rejected here. - -2. **Resolve the disclosing account(s).** Determine which on-chain account records the proof's $$\text{PVK}\_A$$ (and, for D-sender, $$\text{PVK}\_B$$) MUST be drawn from. This is dictated by the variant and the event payload — NOT by anything in the bundle: - - D-recipient: $$\text{PVK}\_A$$ is read from the account at $$E.\text{to}$$. - - D-sender on `Transfer`: $$\text{PVK}\_A$$ from $$E.\text{from}$$, $$\text{PVK}\_B$$ from $$E.\text{to}$$. - - D-sender on `SpenderTransfer`: $$\text{PVK}\_A$$ from $$E.\text{spender}$$, $$\text{PVK}\_B$$ from $$E.\text{to}$$. - - D-auditor: no on-chain account record is consulted for $$\text{PVK}\_A$$; instead the auditor key $$K\_{\text{aud}}$$ is resolved per step 3. - -3. **Resolve auxiliary on-chain state.** Read $$\text{addr\\\_f}$$ from the contract's instance storage (DESIGN.md §3.5). For D-auditor, look up the auditor key for the disclosing account's `auditor_id` at the version active at the event's ledger (DESIGN_cont.md §8.3 *Auditor's off-chain obligation*); pick $$K\_{\text{aud,r}}$$ vs. $$K\_{\text{aud,s}}$$ according to which channel ciphertext the proof claims to disclose. The verifier MUST reject if the version cannot be resolved (auditor contract has no key active at that ledger). - -4. **Construct the public-input vector.** Build the vector from the event payload (step 1), the on-chain account records (step 2), the auxiliary state (step 3), the recipient's own $$(P\_R, \nu)$$, and the bundle's $$(R\_{\text{disc}}, \tilde{v}\_{\text{disc}})$$. The verifier MUST NOT use any value from the bundle other than these last two. If any public input the circuit expects is unavailable (e.g., a referenced account is not registered), the verifier rejects. - -5. **Verify the proof.** Run UltraHonk verification with the verification key for `circuit_id` against the constructed public inputs and $$\pi$$. Reject on failure. - -6. **Decrypt.** Compute $$s\_{\text{disc}} = \text{ECDH}(r\_R, R\_{\text{disc}})$$ and $$v\_{\text{transfer}} = \tilde{v}\_{\text{disc}} - \text{Poseidon}(\delta\_{\text{disc}}, s\_{\text{disc}}, \nu)$$ as in §4. - -### 5.4 On-Chain Verification - -Nothing about the proofs themselves prevents a contract from verifying them. They are UltraHonk proofs, and the confidential-token contract already runs UltraHonk verification on-chain for the core transfer-family circuits (DESIGN_cont.md §10); a disclosure circuit's verification key could be registered the same way, behind an entry point that verifies a submitted proof and acts on the result. Verifying on-chain means submitting the proof as a transaction, which publishes the disclosure's existence, the recipient identity, the referenced event, and the timing into the public ledger. The defining property of the off-chain model is that a disclosure leaves no on-chain trace. - -**When it makes sense.** The situation that justifies the cost for on-chain verification is when the *result* of a disclosure must gate another on-chain action: a compliance escrow that releases funds only after a "balance ≥ $$X$$" proof verifies, an on-chain attestation registry, a permissioned pool that admits an account once an eligibility predicate passes. - -**A separate on-chain verifier protocol (out of scope).** Serving those cases is a protocol in its own rather than an addition to the disclosure layer, but its shape is straightforward, and the trust-boundary rule (§5.2) carries over unchanged: the public inputs must come from somewhere other than the prover, who supplies only $$\pi$$. - -For *current-state* facts the inputs are already on-chain. A D-balance predicate (§9) draws its public inputs — $$\text{addr\\\_f}$$, $$\text{PVK}\_A$$, $$C\_{\text{spend}}$$ — from the token contract's live storage, which a verifier contract reads by cross-contract call (`confidential_balance`, §11). The verifier contract assembles the vector itself and runs UltraHonk verification (DESIGN_cont.md §10) to produce a verdict the gating logic consumes. - -For *event-anchored* facts the event fields ($$R\_e$$, $$\sigma\_E$$, $$\tilde{v}$$, the auditor ciphertexts) are emitted as events rather than held in contract storage, so they reach the contract through the request itself. A natural design is request/response: the disclosure recipient — the party that will rely on the verdict — submits an on-chain request to the verifier contract carrying the event data to be proven; the contract records it under the requester's state; the prover then posts $$\pi$$ in a follow-up transaction; and the contract builds the public-input vector from the requester's stored request, plus whatever it reads from the token contract (such as $$\text{PVK}\_A$$), and verifies. The trust boundary holds because the inputs originate with the requester and the token contract, never with the prover — the same division of roles as the off-chain protocol, where the verifier is likewise the party that supplies the public inputs. The one design point such a protocol must settle is that the contract attests *consistency with the submitted event data* but does not by itself confirm that data is a genuine ledger event: that suffices when the requester is the consumer of the verdict, but trustworthiness to unrelated third parties needs an additional event-inclusion binding. - -Either way the request and the proof are public, which is the deliberate privacy cost. None of this is part of the present document (§14); it is outlined here only to show that on-chain verification is a separable protocol rather than a property of this layer. - ---- - -## 6. Circuit D-recipient: Holder Discloses an Inbound Transfer - -The account holder is the recipient of an on-chain confidential transfer (either a `Transfer` to them or a `SpenderTransfer` whose `to` is them) and proves to a third party that the transfer was for amount $$v\_{\text{transfer}}$$. The same circuit covers both event families because the recipient-side ECDH constraint has identical shape in either case; only the value of the event nonce $$\sigma\_E$$ differs ($$\sigma$$ for `Transfer`, $$\sigma\_a'$$ for `SpenderTransfer`; see DESIGN.md §7.6 T9, §7.8 O9). - -**Public inputs** - -| Symbol | Source | -|:---|:---| -| $$\text{addr\\\_f}$$ | compressed contract-address Field, loaded from instance storage (DESIGN.md §2.7, §3.5) | -| $$\text{PVK}\_A$$ | disclosing account's stored `viewing_public_key` (DESIGN.md §6.1); $$A$$ is the address listed as the event's `to` | -| $$R\_e, \sigma\_E, \tilde{v}$$ | from the on-chain event being disclosed (DESIGN_cont.md §11.2). $$\sigma\_E = \sigma$$ for `Transfer`, $$\sigma\_E = \sigma\_a'$$ for `SpenderTransfer`. | -| $$P\_R$$ | disclosure recipient's Grumpkin pubkey (§2.1) | -| $$\nu$$ | recipient-supplied nonce (§2.1) | -| $$R\_{\text{disc}}, \tilde{v}\_{\text{disc}}$$ | disclosure ciphertext to recipient (§4) | - -**Private witnesses:** $$sk\_A$$, $$vk\_A$$, $$v\_{\text{transfer}}$$, $$r\_{\text{disc}}$$. - -**Circuit constraints (D-recipient):** - -| # | Constraint | -|:--|:---| -| D1 | $$vk\_A = \text{Poseidon}(\delta\_{\text{vk}}, sk\_A, \text{addr\\\_f})$$ (viewing key correctly derived, binds proof to contract; mirrors DESIGN.md R2/T2/W2/S2) | -| D2 | $$\text{PVK}\_A = vk\_A \cdot H$$ (binds proof to on-chain account) | -| D3 | $$s = \text{ECDH}(vk\_A, R\_e)$$ (recipient-side ECDH shared scalar, DESIGN.md §2.4) | -| D4 | $$v\_{\text{transfer}} = \tilde{v} - \text{Poseidon}(\delta\_{\text{transfer\\\_amount}}, s, \sigma\_E)$$ (correct decryption of event amount; matches DESIGN.md T9 for `Transfer` and O9 for `SpenderTransfer`) | -| D5 | $$v\_{\text{transfer}} \in [0, 2^{127})$$ (range, DESIGN.md §2.6) | -| U1–U3 | Disclosure ciphertext to recipient (§4) | - -D1 and D2 anchor the proof to the disclosing account's on-chain record without revealing $$sk\_A$$ or $$vk\_A$$. D3 and D4 recompute the standard recipient-side decryption that the holder would normally perform offline to learn the incoming amount. The result $$v\_{\text{transfer}}$$ then feeds the U-block, which encrypts it to the disclosure recipient. - -**Verifier flow.** Follow §5.3 with `circuit_id = D-recipient`. Step 2 resolves $$\text{PVK}\_A$$ at $$E.\text{to}$$ (the only account record this variant consults). On success, the recipient now knows that the named on-chain event paid the named account exactly $$v\_{\text{transfer}}$$ tokens, and learns nothing else. - ---- - -## 7. Circuit D-sender: Sender Discloses an Outbound Transfer - -The party that **originated** an on-chain confidential transfer proves to a third party that they paid $$v\_{\text{transfer}}$$ to the on-chain recipient address recorded in the event. "Sender" here covers both: - -- **`Transfer` events:** the originator is the account holder $$A$$ at `from`. The disclosing key material is the holder's own $$(sk\_A, vk\_A)$$ and the ephemeral scalar $$r\_e$$ used at transfer time. -- **`SpenderTransfer` events:** the originator is the spender at `spender`, **not** the owner at `from`. The disclosing key material is the spender's own $$(sk\_{\text{op}}, vk\_{\text{op}})$$ and the ephemeral scalar $$r\_e$$ used at transfer time. - -In both cases the prover must supply the ephemeral scalar $$r\_e$$ as a witness: the sender has no ECDH path through their own $$vk$$ into the event ciphertext $$\tilde{v}$$ (that ciphertext is keyed to the recipient's $$\text{PVK}\_B$$), so $$r\_e$$ is necessary to reconstruct the recipient-side decryption from the sender's side. - -**Recovering $$r\_e$$.** The originator recomputes the ephemeral scalar from its own viewing key and the event nonce (DESIGN.md §5.3): - -$$r\_e = \text{Poseidon2}(\delta\_{\text{eph}}, vk, \sigma\_E)$$ - -where $$vk$$ is the originator's viewing key ($$vk\_A$$ for `Transfer`, $$vk\_{\text{op}}$$ for `SpenderTransfer`) and $$\sigma\_E$$ is the event nonce ($$\sigma$$ or $$\sigma\_a'$$). The disclosed amount then follows: - -$$v\_{\text{transfer}} = \tilde{v} - \text{Poseidon}(\delta\_{\text{transfer\\\_amount}}, \text{ECDH}(r\_e, \text{PVK}\_B), \sigma\_E) \qquad \text{(DESIGN.md §2.4)}$$ - -with $$\text{PVK}\_B$$ read from the event's `to` address. Both quantities come from the wallet's $$vk$$ and an on-chain read of the event, so D-sender needs **no** per-transfer wallet state, matching the storage-free posture of D-recipient (§6). Recovery happens off-circuit: $$r\_e$$ enters the proof as a witness pinned by DS3, and no disclosure circuit absorbs $$\delta\_{\text{eph}}$$. - -**What this implies for $$vk$$.** Since $$r\_e$$ is recoverable from $$vk$$, so is a full Pedersen opening of every transfer the account originated. DESIGN_cont.md §9.4 states the capability a compromised $$vk$$ therefore carries, and DESIGN_cont.md §8.2 records that per-transfer openings are not exclusive to the recipient's auditor. The operative consequence for this layer is that a counterparty needing outbound visibility is served with D-sender proofs, which are bound to that counterparty and to a nonce (§13.2) — never by handing over $$vk$$ (SDK.md §13). - -In the symbols below, $$A$$ denotes the **originating** address — the holder's address for `Transfer` and the spender's address for `SpenderTransfer`. $$sk\_A$$ is the originator's spending key, $$\text{PVK}\_A$$ is the originator's stored public viewing key, and $$\sigma\_E = \sigma$$ for `Transfer`, $$\sigma\_E = \sigma\_a'$$ for `SpenderTransfer`. - -**Public inputs** - -| Symbol | Source | -|:---|:---| -| $$\text{addr\\\_f}$$ | compressed contract-address Field, loaded from instance storage | -| $$\text{PVK}\_A$$ | originating account's stored `viewing_public_key` (holder for `Transfer`, spender for `SpenderTransfer`) | -| $$R\_e, \sigma\_E, \tilde{v}$$ | from the on-chain event | -| $$\text{PVK}\_B$$ | recipient's stored `viewing_public_key` (looked up from event's `to` address) | -| $$P\_R, \nu$$ | disclosure recipient pubkey and nonce | -| $$R\_{\text{disc}}, \tilde{v}\_{\text{disc}}$$ | disclosure ciphertext | - -**Private witnesses:** $$sk\_A$$, $$vk\_A$$, $$r\_e$$, $$v\_{\text{transfer}}$$, $$r\_{\text{disc}}$$. - -**Circuit constraints (D-sender):** - -| # | Constraint | -|:--|:---| -| D1 | $$vk\_A = \text{Poseidon}(\delta\_{\text{vk}}, sk\_A, \text{addr\\\_f})$$ | -| D2 | $$\text{PVK}\_A = vk\_A \cdot H$$ | -| DS3 | $$R\_e = r\_e \cdot H$$ (prover knows the ephemeral scalar used at transfer time; same shape as DESIGN.md T6 for `Transfer` and O6 for `SpenderTransfer`) | -| DS4 | $$s\_B = \text{ECDH}(r\_e, \text{PVK}\_B)$$ (sender-side ECDH shared scalar to recipient, DESIGN.md §2.4) | -| DS5 | $$v\_{\text{transfer}} = \tilde{v} - \text{Poseidon}(\delta\_{\text{transfer\\\_amount}}, s\_B, \sigma\_E)$$ | -| D5 | $$v\_{\text{transfer}} \in [0, 2^{127})$$ | -| U1–U3 | Disclosure ciphertext to recipient (§4) | - -DS3 anchors $$R\_e$$ to the originator by forcing them to know $$r\_e$$. Combined with D1/D2, this proves the prover is the same party that produced the transfer's ephemeral key — the holder for `Transfer`, the spender for `SpenderTransfer`. DS4 and DS5 reconstruct the recipient-side decryption from the originator's perspective. - -**Coverage asymmetry: owner cannot D-sender a `SpenderTransfer`.** A spender transfer's ephemeral scalar derives from the spender's $$vk\_{\text{op}}$$ (DESIGN.md §7.8), which the owner does not hold, so the owner cannot recover $$r\_e$$ for the event; nor does the owner have any other ECDH path into $$\tilde{v}$$, which is keyed to $$\text{PVK}\_B$$. The owner therefore cannot independently produce a D-sender disclosure for a `SpenderTransfer`. The owner's cryptographic paths for that event are: - -1. **D-auditor (§8)** routed through the owner's auditor key $$K\_{\text{aud,s}}$$, which decrypts $$\tilde{v}\_{\text{aud,s}}$$ for every `SpenderTransfer` from the owner's account (DESIGN_cont.md §8.4). This is the canonical owner-side path. -2. **D-sender by the cooperating spender.** If the spender is willing, they construct a D-sender proof against the spender's own $$(sk\_{\text{op}}, \text{PVK}\_{\text{op}})$$ and deliver it to the owner, who forwards it (or the owner asks the disclosure recipient to accept proofs originated by the spender). The proof's $$\text{PVK}\_A$$ is the spender's PVK; the verifier looks it up at the event's `spender` address. - -A D-sender proof for a `SpenderTransfer` proves that the spender (not the owner) paid the on-chain `to`. If the disclosure recipient additionally needs proof that the owner authorized this spender, they read the `SetSpender` event and observe the on-chain `(owner, spender)` delegation entry; nothing in D-sender attests to delegation provenance. - -**Verifier flow.** Follow §5.3 with `circuit_id = D-sender`. Step 2 looks up two account records: $$\text{PVK}\_A$$ at $$E.\text{from}$$ (for `Transfer`) or $$E.\text{spender}$$ (for `SpenderTransfer`), and $$\text{PVK}\_B$$ at $$E.\text{to}$$ in both cases. - ---- - -## 8. Circuit D-auditor: Auditor Discloses a Transfer - -The auditor proves to a third party that an on-chain event corresponds to a transfer of amount $$v\_{\text{transfer}}$$ for one of the accounts under the auditor's scope. Used when the holder is uncooperative or when the disclosure recipient requires a guarantee that the auditor (not just the holder) has attested. - -**Which auditor.** Every transfer carries ciphertexts under *two* auditor keys (DESIGN_cont.md §8.1): the recipient-side key $$K\_{\text{aud,r}}$$ (channel $$\delta\_{\text{aud\\\_r}}$$, two lanes yielding masks for $$v\_{\text{transfer}}$$ and $$r\_{\text{transfer}}$$) and the sender-side key $$K\_{\text{aud,s}}$$ (channel $$\delta\_{\text{aud\\\_s}}$$, three lanes yielding masks for $$v\_{\text{transfer}}$$, the sender's post-transfer balance, and the blinding-escrow slot of DESIGN.md §2.5). Whichever auditor is disclosing reuses the same shared-secret derivation they perform to read events natively; the circuit additionally encrypts the result to the disclosure recipient. - -The constraints below parameterize the channel as $$\delta\_{\text{aud}} \in \\{\delta\_{\text{aud\\\_r}}, \delta\_{\text{aud\\\_s}}\\}$$ and the corresponding event ciphertext as $$\tilde{v}\_{\text{aud}} \in \\{\tilde{v}\_{\text{aud,r}}, \tilde{v}\_{\text{aud,s}}\\}$$. In each case the amount mask is `lane[0]` of the channel's sponge; the remaining lanes ($$m\_{r,r}$$ on the recipient channel; $$m\_{b,s}$$ and $$m\_{r,s}$$ on the sender channel) are computed and discarded for an amount disclosure, or used in place of `lane[0]` for the balance/randomness variants noted below. A3 is written with $$\text{SpongeSqueeze}\_2$$ because its first two lanes coincide with those of $$\text{SpongeSqueeze}\_3$$ on either channel (DESIGN.md §2.5); only a variant reading `lane[2]` squeezes three-wide. - -**Public inputs** - -| Symbol | Source | -|:---|:---| -| $$K\_{\text{aud}}$$ | auditor's on-chain Grumpkin pubkey for the chosen channel ($$K\_{\text{aud,r}}$$ or $$K\_{\text{aud,s}}$$) (DESIGN_cont.md §8.3) | -| $$R\_e, \sigma\_E, \tilde{v}\_{\text{aud}}$$ | from the on-chain event ($$\tilde{v}\_{\text{aud,r}}$$ for the recipient-side channel, $$\tilde{v}\_{\text{aud,s}}$$ for the sender-side channel). $$\sigma\_E = \sigma$$ for `Transfer`, $$\sigma\_E = \sigma\_a'$$ for `SpenderTransfer` (DESIGN.md §7.8). | -| $$P\_R, \nu$$ | disclosure recipient pubkey and nonce | -| $$R\_{\text{disc}}, \tilde{v}\_{\text{disc}}$$ | disclosure ciphertext | - -**Private witnesses:** $$aud\_{sk}$$, $$v\_{\text{transfer}}$$, $$r\_{\text{disc}}$$. - -**Circuit constraints (D-auditor):** - -| # | Constraint | -|:--|:---| -| A1 | $$K\_{\text{aud}} = aud\_{sk} \cdot H$$ (auditor key ownership) | -| A2 | $$s\_{\text{aud}} = \text{ECDH}(aud\_{sk}, R\_e)$$ (auditor-side ECDH shared scalar; mirrors the prover-side $$\text{ECDH}(r\_e, K\_{\text{aud}})$$ from DESIGN.md T_a1 / T_a5) | -| A3 | $$(m\_v, m\_2) = \text{SpongeSqueeze}\_2(\delta\_{\text{aud}}, s\_{\text{aud}}, \sigma\_E)$$ (auditor channel sponge; same construction as DESIGN.md §2.5, §8.1) | -| A4 | $$v\_{\text{transfer}} = \tilde{v}\_{\text{aud}} - m\_v$$ (correct decryption of the channel's amount slot, the first squeeze) | -| D5 | $$v\_{\text{transfer}} \in [0, 2^{127})$$ | -| U1–U3 | Disclosure ciphertext to recipient (§4) | - -D-auditor does not bind to an account record; the auditor key already binds the proof. The disclosure recipient confirms which account the event concerns by reading the event's sender and recipient addresses directly. - -**Verifier flow.** Follow §5.3 with `circuit_id = D-auditor` (or the chosen balance / randomness variant). Step 2 is skipped — no $$\text{PVK}\_A$$ lookup is needed. Step 3 resolves $$K\_{\text{aud}}$$ at the event's ledger: $$K\_{\text{aud,r}}$$ from the `auditor_id` on the event's `to` account when disclosing the recipient-side channel, or $$K\_{\text{aud,s}}$$ from the `auditor_id` on the `from` account when disclosing the sender-side channel. `from` is the funds' owner in both `Transfer` and `SpenderTransfer`, since the sender-auditor channel always tracks the owner (DESIGN.md §7.8). - -**Balance / randomness variants.** `lane[1]` of each channel carries a distinct datum: $$m\_{b,s}$$ (sender's post-transfer balance checkpoint, channel $$\delta\_{\text{aud\\\_s}}$$, recovered from $$\tilde{b}\_{\text{aud,s}}$$) or $$m\_{r,r}$$ (per-transfer Pedersen randomness, channel $$\delta\_{\text{aud\\\_r}}$$, recovered from $$\tilde{r}\_{\text{aud,r}}$$). A circuit that discloses either of these substitutes the corresponding event ciphertext for $$\tilde{v}\_{\text{aud}}$$ in A4 and reads $$m\_2$$ rather than $$m\_v$$ from the sponge output. Range constraint D5 applies unchanged to a balance disclosure; for a randomness disclosure D5 is dropped since $$r\_{\text{transfer}} \in \mathbb{F}\_r$$ is not range-bounded. The balance variant has a blinding sibling on `lane[2]` of the sender channel: $$m\_{r,s}$$ recovers the sender's post-transfer spendable blinding from $$\tilde{r}\_{\text{aud,s}}$$ (DESIGN.md §7.6 T\_a9), so an auditor can disclose the full opening of the sender's $$C\_{\text{spend}}$$ *as of that transfer* rather than its value alone (DESIGN_cont.md §8.1); A3 then squeezes three-wide and D5 is dropped as for the randomness variant. On a `SpenderTransfer` the same lane carries the post-transfer allowance blinding $$r\_a'$$ (DESIGN.md §7.8 O\_a9), so the same variant discloses the opening of $$C\_a'$$ rather than of $$C\_{\text{spend}}'$$ (DESIGN_cont.md §8.5). These variants are not separately tabulated. - ---- - -## 9. Circuit D-balance: Holder Discloses Current Balance - -The account holder proves a property of their **current** confidential balance to a third party. Unlike the transfer-event variants (§6–§8), D-balance attests to present state, not a past event: the proof opens the on-chain Pedersen commitment $$C\_{\text{spend}}$$ that records the holder's latest spend-side balance (DESIGN.md §5.1, §5.2) using the holder's retained opening $$(v\_s, r\_s)$$. Typical uses are reporting-threshold attestations — "balance is at most $$V\_{\text{threshold}}$$" for non-reportability, "balance is at least $$V\_{\text{threshold}}$$" for solvency. - -The holder maintains $$(v\_s, r\_s)$$ as normal wallet state — every successful transfer settles a fresh opening (DESIGN.md §5.2) and the wallet retains the latest pair. D-balance therefore needs the current spendable opening, which the wallet either holds locally or reconstructs from the latest checkpoint per DESIGN.md §5.2 *Recovery*; ordinary spend-proof construction is under the same condition. - -**Public inputs** - -| Symbol | Source | -|:---|:---| -| $$\text{addr\\\_f}$$ | compressed contract-address Field, loaded from instance storage | -| $$\text{PVK}\_A$$ | disclosing account's stored `viewing_public_key` | -| $$C\_{\text{spend}}$$ | on-chain Pedersen commitment to the holder's current spend-side balance, read from the account's `confidential_balance` record (DESIGN.md §6.1, §11.3) | -| $$V\_{\text{threshold}}$$ | (predicate variant only) threshold value | -| $$P\_R, \nu$$ | disclosure recipient pubkey and nonce | -| $$R\_{\text{disc}}, \tilde{v}\_{\text{disc}}$$ | (value-revealing variant only) disclosure ciphertext | - -**Private witnesses:** $$sk\_A$$, $$vk\_A$$, $$v\_s$$, $$r\_s$$, and $$r\_{\text{disc}}$$ when the value-revealing variant is in use. - -**Circuit constraints (D-balance):** - -| # | Constraint | -|:--|:---| -| D1 | $$vk\_A = \text{Poseidon}(\delta\_{\text{vk}}, sk\_A, \text{addr\\\_f})$$ | -| D2 | $$\text{PVK}\_A = vk\_A \cdot H$$ | -| DB3 | $$C\_{\text{spend}}$$ opens to $$(v\_s, r\_s)$$ under the Grumpkin Pedersen scheme of DESIGN.md §2.3 | -| DB4 | (predicate variant) $$v\_s \geq V\_{\text{threshold}}$$ **or** $$v\_s \leq V\_{\text{threshold}}$$, fixed per `circuit_id` | -| D5 | $$v\_s \in [0, 2^{127})$$ | -| U1–U3 | (value-revealing variant) disclosure ciphertext of $$v\_s$$ to recipient (§4) | - -Two `circuit_id` shapes are exposed: a **predicate-only** form (`disclose_balance_ge` / `disclose_balance_le`) that includes DB4 and omits U1–U3, where the proof's mere validity asserts the predicate; and a **value-revealing** form (`disclose_balance_value`) that includes U1–U3 and omits DB4, where the recipient decrypts $$\tilde{v}\_{\text{disc}}$$ to learn $$v\_s$$ exactly. - -D1, D2 bind the proof to the disclosing account. DB3 forces the witnessed $$v\_s$$ to be the value the on-chain commitment opens to — by Pedersen binding (DESIGN.md §2.3), no alternative opening exists with non-negligible probability. D5 prevents the predicate from being satisfied by a wrapped-negative $$v\_s$$ that doesn't represent any real balance. - -**Distinguishing from D-auditor balance variant (§8).** §8's balance variant decrypts the sender's *post-transfer* balance, and with its `lane[2]` sibling the full opening, from a specific transfer event — event-anchored, historical, requires auditor cooperation. D-balance is holder-side, reflects *current* on-chain state, and supports predicate-only disclosure that §8's variant does not. Capability does not distinguish them: the auditor tracks the current opening $$(v\_s, r\_s)$$ of every scoped account's $$C\_{\text{spend}}$$ forward through checkpoints, merges, and revokes (DESIGN_cont.md §8.1, §8.2), so it could satisfy DB3 against the live commitment as readily as the holder. What distinguishes D-balance is who is bound: D1 and D2 tie the proof to the holder's $$sk\_A$$, making it the holder's own attestation, whereas an auditor-produced current-balance proof would bind through auditor key ownership (A1) instead. No such variant is specified here; a recipient that needs a backstop with auditor attestation uses D-auditor over a recent event, and a recipient that needs predicate-only disclosure, or a statement the holder rather than the auditor stands behind, uses D-balance. - -**Verifier flow.** D-balance has no on-chain event to reference, so the bundle is: - -$$\text{Bundle}\_{\text{balance}} = (\text{circuit\\\_id}, \text{account}, \pi, R\_{\text{disc}}, \tilde{v}\_{\text{disc}}?)$$ - -where `account` is the disclosing address (agreed during the request, not blindly accepted from the prover) and $$\tilde{v}\_{\text{disc}}$$ is omitted in the predicate-only variant. The recipient performs §5.3 with the following substitutions: - -1. **Resolve account state.** Read `confidential_balance(account)`, extracting $$\text{PVK}\_A$$ and $$C\_{\text{spend}}$$. -2. **Resolve auxiliary state.** Read $$\text{addr\\\_f}$$ from instance storage. -3. **Construct the public-input vector.** Combine the resolved on-chain state, the recipient's $$(P\_R, \nu)$$, the agreed $$V\_{\text{threshold}}$$ (predicate variants), and the bundle's $$(R\_{\text{disc}}, \tilde{v}\_{\text{disc}})$$. As in §5.2 the verifier MUST NOT accept $$\text{PVK}\_A$$, $$C\_{\text{spend}}$$, or $$V\_{\text{threshold}}$$ from the bundle. -4. **Verify proof and decrypt** as in §5.3 steps 5–6 (decryption applies only to the value-revealing variant). - -The recipient and prover MUST agree on $$V\_{\text{threshold}}$$ during the request — otherwise the holder could pick a threshold the recipient never authorized and produce a proof against it. The freshness of the disclosure is the ledger at which the recipient read $$C\_{\text{spend}}$$: if $$C\_{\text{spend}}$$ changed between proving and verification — by the holder's own operation, or without the holder's participation through a compliance clawback (COMPLIANCE.md §5) — verification fails naturally; the prover then re-runs against the new commitment. - ---- - -## 10. Aggregate Disclosures - -For statements of the form "this account received at least $$X$$ from counterparty $$Y$$ during window $$W$$", the D-recipient circuit (or D-auditor) is vectorized over $$n$$ events. - -**Public inputs** - -| Symbol | Source | -|:---|:---| -| Common: $$\text{addr\\\_f}$$, $$\text{PVK}\_A$$, $$P\_R$$, $$\nu$$, $$R\_{\text{disc}}, \tilde{V}\_{\text{disc}}$$ | as in §6 | -| List: $$(R\_{e,i}, \sigma\_{E,i}, \tilde{v}\_i)$$ for $$i \in [1, n]$$ | from $$n$$ on-chain transfer-family events; $$\sigma\_{E,i} = \sigma$$ if event $$i$$ is a `Transfer`, $$\sigma\_a'$$ if `SpenderTransfer`. Each event MUST be identified by a $$\text{ref}\_{E,i}$$ in the proof bundle and resolved per §5.3. | -| Optional: $$V\_{\text{threshold}}$$ | aggregate threshold | - -**Private witnesses:** $$sk\_A$$, $$vk\_A$$, $$\\{v\_{\text{transfer},i}\\}\_{i=1}^n$$, $$r\_{\text{disc}}$$. - -**Circuit constraints (D-aggregate, n events):** - -| # | Constraint | -|:--|:---| -| D1, D2 | As in §6 | -| For each $$i$$: D3$$\_i$$ | $$s\_i = \text{ECDH}(vk\_A, R\_{e,i})$$ | -| For each $$i$$: D4$$\_i$$ | $$v\_{\text{transfer},i} = \tilde{v}\_i - \text{Poseidon}(\delta\_{\text{transfer\\\_amount}}, s\_i, \sigma\_{E,i})$$ | -| For each $$i$$: D5$$\_i$$ | $$v\_{\text{transfer},i} \in [0, 2^{127})$$ | -| AGG | $$V\_{\text{total}} = \sum\_{i=1}^n v\_{\text{transfer},i}$$ | -| THRESH (optional) | $$V\_{\text{total}} \geq V\_{\text{threshold}}$$ | -| U1–U3 | Encrypt $$V\_{\text{total}}$$ (not the individual $$v\_{\text{transfer},i}$$) to the recipient, with $$\delta\_{\text{disc\\\_bind}}$$ replacing $$\delta\_{\text{disc}}$$ to separate domain | - -The recipient filters the $$n$$ events off-chain by the criteria they care about (sender address, block timestamp) before constructing the verifier's public inputs. They learn the aggregate $$V\_{\text{total}}$$ but not the individual amounts. If THRESH is included and the recipient does not need the aggregate value itself, U1–U3 can be omitted; the proof's mere validity asserts the threshold. - -Aggregate disclosures over outbound transfers use the D-sender constraint block per event; aggregate auditor disclosures use the D-auditor block per event. - ---- - -## 11. On-Chain Read Surface - -The confidential-token contract requires no new state-modifying entry points to support this layer. The disclosure verifier needs the following public reads, all of which return data that is already stored or already emitted: - -| Read | Purpose | Notes | -|:---|:---|:---| -| `confidential_balance(account) -> ConfidentialAccount` | Verifier extracts $$\text{PVK}\_A$$ (and $$\text{PVK}\_B$$ for D-sender, $$C\_{\text{spend}}$$ for D-balance) from the returned `ConfidentialAccount` tuple | Already exposed (DESIGN_cont.md §11.3); the struct carries every field this layer reads, so no narrower accessor is required | -| Auditor contract's key lookup for `auditor_id` | Verifier looks up $$K\_{\text{aud,r}}$$ or $$K\_{\text{aud,s}}$$ | Already exposed (DESIGN_cont.md §8.3). The auditor contract MAY maintain a sequence of versioned keys per `auditor_id` with activation ledgers; the verifier MUST select the version whose activation ledger is the largest value not exceeding the disclosed event's ledger (DESIGN_cont.md §8.3, *Auditor's off-chain obligation*). | -| Transfer-family events | Verifier reads the per-event fields ($$R\_e$$, $$\sigma$$ or $$\sigma\_a'$$, $$\tilde{v}$$, $$\tilde{b}$$, $$\tilde{v}\_{\text{aud,r}}$$, $$\tilde{r}\_{\text{aud,r}}$$, $$\tilde{v}\_{\text{aud,s}}$$, $$\tilde{b}\_{\text{aud,s}}$$ / $$\tilde{a}\_{\text{aud,s}}$$, $$\tilde{r}\_{\text{aud,s}}$$) | Already emitted (DESIGN_cont.md §11.2). `SpenderTransfer` uses $$\sigma\_a'$$ in place of $$\sigma$$ and $$\tilde{a}\_{\text{aud,s}}$$ in place of $$\tilde{b}\_{\text{aud,s}}$$. | -| Instance storage: $$\text{addr\\\_f}$$ | D-recipient, D-sender, and D-balance bind $$vk$$ derivation to the contract via $$\text{addr\\\_f}$$ | Computed once at construction (DESIGN.md §3.5); the verifier reproduces it from the contract address using the encoding in DESIGN.md §2.7 | - -These are the only on-chain dependencies. Disclosure proofs are otherwise self-contained off-chain artifacts. - ---- - -## 12. End-to-End Flow - -The diagram below shows the D-recipient case. D-sender and D-auditor follow the same shape with different prover roles and a different `circuit_id` in step (4); D-balance (§9) differs in that no $$\text{ref}\_E$$ is exchanged — see §9 for its bundle shape. - -```text - Recipient/Verifier Holder Wallet - | | - | (1) request: "disclose event E to me" | - | includes (P_R, ν) and ref_E | - | ---------------------------------------------->| - | | - | (2) resolve ref_E to - | on-chain event E - | (3) run D-recipient - | circuit locally - | | - | (4) Bundle = | - | (circuit_id, ref_E, π, R_disc, ṽ_disc) | - | <----------------------------------------------| - | | - (5) §5.3 step 1: resolve ref_E from chain - (event E with from, to, R_e, σ_E, ṽ, ...) - (6) §5.3 step 2: read PVK_A from E.to - (7) §5.3 step 3: read addr_f from instance storage - (8) §5.3 step 4: build public-input vector - (9) §5.3 step 5: verify π - (10) §5.3 step 6: decrypt ṽ_disc with r_R, ν - → learns v_transfer -``` - -The recipient MAY agree on ref_E in step (1) ahead of time (e.g., "disclose the transfer at tx 0xabc, log #3") or leave it to the holder, in which case the bundle in step (4) is the first time ref_E is communicated; either way the verifier resolves it independently in step (5). There is no on-chain transaction for the disclosure itself. Steps (1) and (4) flow over any authenticated channel the parties already use (TLS, signed email, dedicated compliance API). - ---- - -## 13. Security Analysis - -### 13.1 Soundness of Disclosed Amount - -For the D-recipient circuit, soundness reduces to two facts: - -1. D1, D2 force the prover to know an $$sk\_A$$ whose derived $$vk\_A$$ matches the on-chain $$\text{PVK}\_A$$. By DESIGN.md §4.2 this party is the account owner. -2. D3, D4 force $$v\_{\text{transfer}}$$ to equal the decryption of $$\tilde{v}$$ under that owner's $$vk\_A$$. By DESIGN.md §5.3 this is the same value the on-chain transfer commitment $$C\_{\text{transfer}}$$ commits to, since the transfer circuit enforced consistent ECDH derivation at transfer time. - -Therefore, a soundness break would require either a key-derivation collision (Poseidon2 preimage break, DESIGN.md §2.5, §3.2) or a discrete-log break on Grumpkin. Both are out of scope. - -D-sender soundness is symmetric: DS3 forces the prover to know $$r\_e$$ with $$R\_e = r\_e \cdot H$$, which by the transfer circuit's constraint T6 (DESIGN.md §7.6) was the same $$r\_e$$ used to derive the auditor and recipient ciphertexts. DS4, DS5 reconstruct the decryption from the sender side. Soundness rests on DS3's binding through the event's $$R\_e$$ rather than on how the prover came to hold $$r\_e$$; §7's recovery step bears on wallet state, not on the soundness of the disclosed amount. - -D-auditor soundness is direct: A1 forces auditor-key ownership; A2–A4 reconstruct the standard auditor sponge decryption (DESIGN_cont.md §8.1). - -D-balance soundness reduces to Pedersen binding (DESIGN.md §2.3): given the on-chain $$C\_{\text{spend}}$$, the prover's witnesses $$(v\_s, r\_s)$$ satisfying DB3 uniquely determine $$v\_s$$ up to negligible probability. D1, D2 anchor the proof to the disclosing account as in D-recipient. The "current state" framing is established by the verifier's read protocol, not the circuit: a proof against a stale $$C\_{\text{spend}}$$ simply fails to verify against the current public-input vector, so the recipient only ever accepts proofs about the on-chain state at the moment of verification. - -**Event binding.** None of the soundness arguments above pin the proof to a *specific* on-chain event by themselves — they only force consistency with whatever $$(\text{PVK}\_A, R\_e, \sigma\_E, \tilde{v})$$ tuple the public-input vector commits to. The binding to the on-chain event is established off-chain by the §5.3 verifier protocol: the verifier MUST resolve $$\text{ref}\_E$$ from the bundle to a specific event, MUST take all event-derived public inputs verbatim from that event, and MUST take all account-derived inputs from the on-chain account record at the address the *event* names. Skipping any of these steps voids the binding. Because $$R\_e$$ is unique per operation (DESIGN.md §2.5, §5.3), no two distinct on-chain events share an $$R\_e$$ except with negligible probability, so a proof that verifies against the vector built from event $$E$$ cannot also verify against the vector built from any $$E' \neq E$$. Event binding therefore inherits DESIGN.md §2.5's salt-uniqueness requirement: a repeated nonce within one account would collapse it alongside the channel masks. This is the soundness role the trust-boundary rule (§5.2) plays for the disclosure layer; it is the analogue of DESIGN.md §7.1's on-chain trust-boundary rule. - -### 13.2 Recipient Binding - -The disclosed value $$v\_{\text{transfer}}$$ is delivered only through $$\tilde{v}\_{\text{disc}} = v\_{\text{transfer}} + \text{Poseidon}(\delta\_{\text{disc}}, s\_{\text{disc}}, \nu)$$, where $$s\_{\text{disc}} = \text{ECDH}(r\_{\text{disc}}, P\_R)$$ is recoverable only by the holder of $$r\_R$$. - -A party other than the intended recipient who obtains $$(\pi, R\_{\text{disc}}, \tilde{v}\_{\text{disc}})$$ can verify $$\pi$$ but cannot decrypt $$\tilde{v}\_{\text{disc}}$$. They learn that *some* value was disclosed but not the value itself. - -Nonce $$\nu$$ is bound into the Poseidon argument. A holder cannot reuse $$(\pi, R\_{\text{disc}}, \tilde{v}\_{\text{disc}})$$ against a recipient request that issued a different nonce, because the verifier's public inputs would not match. - -### 13.3 What This Does Not Prevent - -**Holder cherry-picking.** A holder may disclose three inbound transfers from counterparty $$Y$$ while withholding a fourth. The verifier cannot detect this from the proof alone. Mitigation: completeness routes through the auditor (D-auditor variants), who sees every event under their scope. Recipients that require completeness must request from the auditor, not the holder. - -**Disclosure recipient leakage.** Once decrypted, $$v\_{\text{transfer}}$$ is plaintext in the recipient's possession. The recipient may store, share, or leak it. This is a non-cryptographic concern handled by the recipient's own data-protection obligations, not by the protocol. - -**Recipient compelling disclosure.** A recipient cannot force a holder to produce a proof. Compelled disclosure is a legal mechanism, not a cryptographic one; this layer enables disclosure when the holder is willing, and the auditor variants serve as the cryptographic backstop when the holder is not. - -**Side-channel inference from event metadata.** Sender and recipient addresses are cleartext in transfer events (DESIGN.md §1.2). A disclosure recipient who reads the event log can already determine *who* transacted with *whom* without any disclosure proof. The disclosure layer protects only the amount. - ---- - -## 14. Out of Scope - -The following extensions are deliberately not part of this document. They are mentioned to make the scope boundary explicit: - -**Delegated viewers (passive ongoing disclosure).** A deployment where the same counterparty needs every transfer disclosed (e.g., a custody bank with continuous AML monitoring) would prefer extra per-transfer ciphertexts to per-transfer disclosure proofs. This requires core-protocol changes: an on-chain registry of viewer keys per account and modifications to the transfer-family circuits to emit additional ciphertexts. Out of scope here; revisit if real deployments demonstrate the need. - -**Merkle-accumulated event history with non-membership proofs.** Would enable cryptographic completeness ("the disclosed set is exhaustive") without trusting the auditor. Requires substantial on-chain storage changes and a new accumulator-maintenance circuit. - -**Public disclosure proofs (no recipient binding).** Replacing the U-block with a public-input $$v\_{\text{transfer}}$$ produces a portable proof anyone can verify. Useful for fire-and-forget compliance archives but loses recipient binding. Not included as a primary variant; can be added by trivially dropping U1–U3 and exposing $$v\_{\text{transfer}}$$ as a public input. This is also the proof shape an on-chain verifier would consume (§5.4). - ---- - -## 15. Implementation Notes - -### 15.1 Circuits - -Four new Noir circuits are added to the proof system: - -| Circuit | Purpose | -|:---|:---| -| `disclose_recipient` | D-recipient (§6) and its aggregate form (§10) | -| `disclose_sender` | D-sender (§7) and its aggregate form | -| `disclose_auditor` | D-auditor (§8) and its aggregate form | -| `disclose_balance` | D-balance (§9), exposed as predicate-only (`disclose_balance_ge` / `disclose_balance_le`) and value-revealing (`disclose_balance_value`) variants | - -The aggregate forms can be implemented as a single parameterized circuit per role with a compile-time event-count bound, or as a family of circuits at $$n \in \\{1, 4, 16, 64\\}$$ to balance proving time against generality. - -These circuits do *not* register with the on-chain verifier set (DESIGN_cont.md §10). They are verified entirely off-chain. - -### 15.2 Wallet Responsibilities - -A wallet that supports holder-side disclosures must: - -1. Recover the event's ephemeral scalar $$r\_e$$ per DESIGN.md §5.3 when constructing a D-sender proof (§7). No per-transfer storage is required: both $$r\_e$$ and $$v\_{\text{transfer}}$$ are recomputed at disclosure time from the wallet's $$vk$$ and the on-chain event. -2. Retain the latest opening $$(v\_s, r\_s)$$ of $$C\_{\text{spend}}$$ to support D-balance. This is part of the wallet's normal spend state. -3. Index event references (transaction hash, log index) per account event to enable selecting events by user-facing criteria (date, counterparty). -4. Expose a UI flow that takes a disclosure request $$(P\_R, \nu)$$ and a target event (or set), produces the disclosure proof, and delivers the result over the requested channel. - -### 15.3 Verifier Library - -A standalone verifier library (independent of the wallet) consumes: - -- Network endpoint or RPC for on-chain reads. -- Disclosure recipient's $$(r\_R, P\_R)$$ keypair. -- Proof bundle $$(\pi, R\_{\text{disc}}, \tilde{v}\_{\text{disc}})$$ and event reference. - -It returns the decrypted $$v\_{\text{transfer}}$$ on successful verification, or a typed error indicating which check failed (proof verification, on-chain state mismatch, decryption failure). diff --git a/packages/tokens/src/confidential/docs/COMPLIANCE.md b/packages/tokens/src/confidential/docs/compliance.md similarity index 61% rename from packages/tokens/src/confidential/docs/COMPLIANCE.md rename to packages/tokens/src/confidential/docs/compliance.md index ae1a5faeb..d4fbee835 100644 --- a/packages/tokens/src/confidential/docs/COMPLIANCE.md +++ b/packages/tokens/src/confidential/docs/compliance.md @@ -2,29 +2,29 @@ ## Abstract -This document specifies optional, deployer-configurable controls layered on top of the core Confidential Token (see [DESIGN.md](DESIGN.md)). It covers account freezing, SAC authorization passthrough, pluggable authorization policies, customization patterns for the `Hooks` extension surface, and the pooled-custody clawback flow. +This document specifies optional, deployer-configurable controls layered on top of the core Confidential Token (see [the protocol specification](protocol/README.md)). It covers account freezing, SAC authorization passthrough, pluggable authorization policies, customization patterns for the `Hooks` extension surface, 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 entry once; subsequent state changes (freeze toggles, admin rotation, policy swap) flow through admin-gated entry points. --- -## 1. Configuration +## Configuration ```rust struct ComplianceConfig { - policy: Option
, // §3 - sac_passthrough: bool, // §2 + policy: Option
, // Policy Contract + sac_passthrough: bool, // Contract-Level Freeze } ``` | 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). | +| `policy` | Optional external authorization contract ([Policy Contract](#policy-contract)). `None` means no policy gate. | +| `sac_passthrough` | When `true`, every state-modifying operation additionally consults the underlying SAC's `authorized()` check ([Contract-Level Freeze](#contract-level-freeze)). | -The constructor takes `compliance: Option`. When `None`, the contract behaves exactly as `DESIGN.md` specifies: no pre-checks run, and the admin-gated entry points in §6 revert with `NotConfigured`. +The constructor takes `compliance: Option`. When `None`, the contract behaves exactly as [the protocol specification](protocol/README.md) specifies: no pre-checks run, and the admin-gated entry points in [Interface Summary](#interface-summary) revert with `NotConfigured`. -### 1.1 Admin Authority +### Admin Authority This document refers to an "admin" as the authority gating freeze, unfreeze, configuration rotation, and clawback. The contract 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. @@ -32,15 +32,15 @@ Deployments that need separation of duties (distinct freeze, policy, and clawbac --- -## 2. Contract-Level Freeze +## Contract-Level Freeze The contract maintains a `frozen(account) -> bool` entry per account. Before applying any state change, every state-modifying operation runs `check_not_frozen` against each account it names (sender, recipient). A frozen account cannot send, receive, deposit, or withdraw. The check reverts at the contract boundary. Full freeze (rather than outbound-only) keeps semantics clean: no further accumulation is possible after the freeze takes effect. -The spender named by the delegation flows (`set_spender`, `confidential_transfer_from`) is not an account for the purposes of the freeze check: the freeze targets fund ownership, and the spender holds no funds — the value being moved stays the owner's, and freezing the owner halts the delegation. This mirrors the allowance models of the library's fungible and rwa tokens. The spender is instead gated by the policy contract (§3). +The spender named by the delegation flows (`set_spender`, `confidential_transfer_from`) is not an account for the purposes of the freeze check: the freeze targets fund ownership, and the spender holds no funds — the value being moved stays the owner's, and freezing the owner halts the delegation. This mirrors the allowance models of the library's fungible and rwa tokens. The spender is instead gated by the policy contract ([Policy Contract](#policy-contract)). -### 2.1 Core Interface Additions +### Core Interface Additions Three functions are added to the core contract interface: @@ -52,9 +52,9 @@ impl Token { } ``` -`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. +`freeze` and `unfreeze` are gated by the implementor's access-control module ([Admin Authority](#admin-authority)) 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 +### 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 contract's freeze with the issuer's freeze without requiring the admin to mirror state: @@ -62,11 +62,11 @@ $$\text{permitted}(a) = \neg \text{frozen}(a) \\;\land\\; \text{policy\\\_ok}(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. This is the *transitive compliance* pattern: the issuer's own freeze/deauthorize, driven through the SAC's standardized admin interface (`set_authorized`, CAP-0046-06), takes effect at the confidential layer with no state mirrored by the token admin. -Like the contract-level freeze (§2), the SAC check names only fund-holding parties: the spender of a delegated flow is exempt. +Like the contract-level freeze ([Contract-Level Freeze](#contract-level-freeze)), the SAC check names only fund-holding parties: the spender of a delegated flow is exempt. --- -## 3. Policy Contract +## Policy Contract When `compliance.policy = Some(addr)`, every state-modifying operation invokes `policy.is_authorized(account, token) -> 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. @@ -86,21 +86,21 @@ Membership management, list semantics, and identity proofs live entirely inside Externalizing the policy also lets a single registry serve multiple tokens. An issuer running several confidential tokens (different denominations, jurisdictions, or product lines) can point every token at the same KYC or sanctions contract and maintain one source of truth, rather than mirroring lists into each token. The `token` argument to `is_authorized` lets the registry apply per-token rules when needed (e.g., a jurisdiction filter) without giving up the shared baseline. -**Spender gating.** Unlike the freeze (§2) and SAC (§2.2) checks, the policy gate also names the spender of a delegated flow: `set_spender` checks the spender at grant time — a delegation to a policy-denied spender fails when it is established, not only when it is exercised — and `confidential_transfer_from` checks the spender at spend time, alongside `from` and `to`. `revoke_spender` deliberately does not gate the spender: revocation is the owner's escape hatch, and blocking it once the spender turns non-compliant would entrench the bad delegation (the owner is still gated on revocation, as on every other operation). +**Spender gating.** Unlike the freeze ([Contract-Level Freeze](#contract-level-freeze)) and SAC ([SAC Authorization Passthrough](#sac-authorization-passthrough)) checks, the policy gate also names the spender of a delegated flow: `set_spender` checks the spender at grant time — a delegation to a policy-denied spender fails when it is established, not only when it is exercised — and `confidential_transfer_from` checks the spender at spend time, alongside `from` and `to`. `revoke_spender` deliberately does not gate the spender: revocation is the owner's escape hatch, and blocking it once the spender turns non-compliant would entrench the bad delegation (the owner is still gated on revocation, as on every other operation). -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. +The policy address is rotatable via `set_compliance_config` ([Interface Summary](#interface-summary)) under admin auth ([Admin Authority](#admin-authority)). Setting it to `None` disables the gate. The policy is part of the deployment's trust surface. -**Why the policy is optional.** Making it required would assume every deployment needs address-level gating, which is not the case. A confidential token deployed over a Stellar Asset Contract can rely on the base asset's own restriction configuration (the issuer's `set_authorized`/freeze, surfaced through `sac_passthrough`, §2.2) instead of a separate policy gate. Non-production deployments — testnet demos where a lightweight dapp suffices — likewise need none. +**Why the policy is optional.** Making it required would assume every deployment needs address-level gating, which is not the case. A confidential token deployed over a Stellar Asset Contract can rely on the base asset's own restriction configuration (the issuer's `set_authorized`/freeze, surfaced through `sac_passthrough`, [SAC Authorization Passthrough](#sac-authorization-passthrough)) instead of a separate policy gate. Non-production deployments — testnet demos where a lightweight dapp suffices — likewise need none. --- -## 4. Customizing the Hooks Trait +## Customizing the Hooks Trait -The compliance surface in §§2–3 is delivered as `ComplianceHooks`, a turnkey implementation of the contract's `Hooks` trait. Deployments that need behaviour beyond the default gating — for example, the deposit-side policies sketched below — replace `ComplianceHooks` with a bespoke `Hooks` impl. The custom impl typically delegates to the same primitives the default uses (`storage::gate_account`, `storage::check_policy`, `storage::check_sac`) and only overrides the callbacks that require non-default semantics. +The compliance surface of [Contract-Level Freeze](#contract-level-freeze) and [Policy Contract](#policy-contract) is delivered as `ComplianceHooks`, a turnkey implementation of the contract's `Hooks` trait. Deployments that need behaviour beyond the default gating — for example, the deposit-side policies sketched below — replace `ComplianceHooks` with a bespoke `Hooks` impl. The custom impl typically delegates to the same primitives the default uses (`storage::gate_account`, `storage::check_policy`, `storage::check_sac`) and only overrides the callbacks that require non-default semantics. `deposit` is the canonical entry point for customization because it is the only operation where `from` may legitimately be an address that has never registered with the contract (the depositor only needs to hold the underlying SEP-41). The default `ComplianceHooks::on_deposit` gates both `from` and `to` unconditionally, which means every depositor must first register and pass the policy gate. Deployments that need other semantics override `on_deposit`. -### 4.1 Permit Unregistered Deposits +### Permit Unregistered Deposits ```rust impl Hooks for PermissiveDepositHooks { @@ -126,7 +126,7 @@ When `from` is not registered with the contract, this example skips the freeze a Skipping the *policy* gate on an unregistered sender is a deliberate trade-off, not a recommendation. The policy contract screens an address and its history (SDN, KYT) and does not require that address to be a registered wrapper user, so a deployment that must screen every inbound counterparty can instead call `storage::check_policy(e, from, &config)` for the unregistered `from` and skip only the registration-dependent freeze check. The default `ComplianceHooks` gates both parties unconditionally. -### 4.2 Permit Deposits Only For Oneself +### Permit Deposits Only For Oneself ```rust impl Hooks for SelfDepositOnlyHooks { @@ -145,9 +145,9 @@ The depositor is required to be the recipient — no one can deposit on someone These two examples are illustrative; the same surface accommodates per-deposit rate limits, allowlists keyed off the deposit amount, mirror writes to an audit log, or any other synchronous policy. The token's only agreement with the `Hooks` impl is that callbacks revert (via `panic_with_error!`) when the operation must be rejected. -### 4.3 Restrict Auditor Selection +### Restrict Auditor Selection -At `register`, the account owner chooses which `auditor_id` the account binds to. The core validates only that the id exists in the auditor registry ([DESIGN.md](DESIGN.md) §7.2), and the default `ComplianceHooks::on_register` deliberately does not restrict the choice. On a shared auditor registry, a deployment with designated auditors gates the selection against its own approved set: +At `register`, the account owner chooses which `auditor_id` the account binds to. The core validates only that the id exists in the auditor registry ([Registration](protocol/operations/register.md)), and the default `ComplianceHooks::on_register` deliberately does not restrict the choice. On a shared auditor registry, a deployment with designated auditors gates the selection against its own approved set: ```rust impl Hooks for ApprovedAuditorHooks { @@ -167,33 +167,33 @@ impl Hooks for ApprovedAuditorHooks { } ``` -`ApprovedAuditors` is a deployment-maintained instance-storage allowlist (populated at construction or through an admin entry point) and `AuditorNotApproved` a deployment-defined error. Because `auditor_id` is immutable once the account is registered ([DESIGN.md](DESIGN.md) §6.1), this gate is the single point where a deployment controls which auditors may ever observe an account's flows. +`ApprovedAuditors` is a deployment-maintained instance-storage allowlist (populated at construction or through an admin entry point) and `AuditorNotApproved` a deployment-defined error. Because `auditor_id` is immutable once the account is registered ([Account Data Model](protocol/account-state.md#account-data-model)), this gate is the single point where a deployment controls which auditors may ever observe an account's flows. --- -## 5. Clawback +## Clawback -This section specifies seizing value from a single frozen confidential account: reducing its committed claim by a public amount, bounded by what the account holds, and settling the corresponding underlying over a transparent path. The flow is coordinated rather than unilateral, and it presupposes a freeze (§2), which keeps the target's commitments from changing between proof construction and submission (§5.6). +This section specifies seizing value from a single frozen confidential account: reducing its committed claim by a public amount, bounded by what the account holds, and settling the corresponding underlying over a transparent path. The flow is coordinated rather than unilateral, and it presupposes a freeze ([Contract-Level Freeze](#contract-level-freeze)), which keeps the target's commitments from changing between proof construction and submission ([Anti-Replay and the Freeze](#anti-replay-and-the-freeze)). -**Terminology.** This flow is called *clawback* because it mirrors the clawback semantics of Stellar Classic / SAC assets, but it is a distinct mechanism. It is delivered by the opt-in `ConfidentialClawback` trait, whose two entry points are `clawback` and `force_revoke_spender` (§6); a deployment that omits that impl block ships freeze and policy gating with no seizure capability. +**Terminology.** This flow is called *clawback* because it mirrors the clawback semantics of Stellar Classic / SAC assets, but it is a distinct mechanism. It is delivered by the opt-in `ConfidentialClawback` trait, whose two entry points are `clawback` and `force_revoke_spender` ([Interface Summary](#interface-summary)); a deployment that omits that impl block ships freeze and policy gating with no seizure capability. -### 5.1 The Pooled-Custody Problem +### The Pooled-Custody Problem Once an account deposits into the contract, the underlying SEP-41 ledger lists the token contract as the holder of those funds, not the depositor. An issuer's SAC-level `clawback(token_address, amount)` drains the pool as a whole and debits every holder. Seizing from one account therefore has to happen at the confidential layer: reduce that account's committed claim, then reconcile the pool. The contract does not know the targeted account's balance. `C_spend` and `C_receive` are Pedersen commitments whose openings are private, so the seize amount must be validated against the committed values without exposing them on-chain and without trusting the admin's word for them. -### 5.2 Roles and Separation +### Roles and Separation -- **Token admin** — the access-control authority on the confidential-token contract (§1.1). Authorizes the freeze and the two seizure entry points; decides *whether* to seize. -- **Auditor** — the holder of the secret key behind the auditor the target's account is bound to (`auditor_id`, `DESIGN.md` §6.1) and of the Pedersen openings of the target's `C_spend` and `C_receive`. Produces the proof and thereby decides *how much* and *where to*, both being bound into it (§5.3). -- **Issuer (SAC admin)** — when the base asset is a Stellar Asset Contract, the holder of its standardized admin interface (CAP-0046-06). Extracts the pool surplus a `None` settlement leaves behind (§5.4) and can freeze independently of the token admin via SAC passthrough (§2.2). +- **Token admin** — the access-control authority on the confidential-token contract ([Admin Authority](#admin-authority)). Authorizes the freeze and the two seizure entry points; decides *whether* to seize. +- **Auditor** — the holder of the secret key behind the auditor the target's account is bound to (`auditor_id`, [Account Data Model](protocol/account-state.md#account-data-model)) and of the Pedersen openings of the target's `C_spend` and `C_receive`. Produces the proof and thereby decides *how much* and *where to*, both being bound into it ([Circuit](#circuit)). +- **Issuer (SAC admin)** — when the base asset is a Stellar Asset Contract, the holder of its standardized admin interface (CAP-0046-06). Extracts the pool surplus a `None` settlement leaves behind ([Contract Flow](#contract-flow)) and can freeze independently of the token admin via SAC passthrough ([SAC Authorization Passthrough](#sac-authorization-passthrough)). -The openings alone do not single out the auditor. An account whose history is registration, public deposits, and merges carries zero blinding on both commitments (`DESIGN.md` §7.2–§7.4), so anyone — the admin included — reconstructs its openings from events. The circuit therefore also binds the witness to the auditor's secret key (CB1, §5.3). The auditor holds both openings by the standing capability of `DESIGN_cont.md` §8.1, advanced across `Clawback` per §5.7. +The openings alone do not single out the auditor. An account whose history is registration, public deposits, and merges carries zero blinding on both commitments ([Registration](protocol/operations/register.md), [Deposit](protocol/operations/deposit.md), [Merge](protocol/operations/merge.md)), so anyone — the admin included — reconstructs its openings from events. The circuit therefore also binds the witness to the auditor's secret key (CB1, [Circuit](#circuit)). The auditor holds both openings by the standing capability of [Per-Transfer Auditor Ciphertexts](protocol/auditing.md#per-transfer-auditor-ciphertexts), advanced across `Clawback` per [Wallet and Auditor Consequences](#wallet-and-auditor-consequences). -Neither party can act alone: the admin cannot produce the proof, and the auditor cannot pass the admin gate. Deployments typically place the seizure authority under a dedicated role, separate from the freeze role (§1.1). +Neither party can act alone: the admin cannot produce the proof, and the auditor cannot pass the admin gate. Deployments typically place the seizure authority under a dedicated role, separate from the freeze role ([Admin Authority](#admin-authority)). -### 5.3 Circuit +### Circuit The clawback circuit proves that a public seize amount is bounded by the target's committed total without revealing either balance, and that the prover is the target's auditor. It is the only circuit with no ephemeral scalar, and the only one whose key-ownership constraint is over an auditor key rather than an owner's: both openings are pinned by Pedersen binding, and the witness is pinned to the account's `auditor_id`. @@ -204,88 +204,88 @@ The clawback circuit proves that a public seize amount is bounded by the target' | CB1 | `K_aud = k_aud · H` (auditor key ownership) | | CB2 | `C_spend = Com(v_s, r_s)` (prover knows the spendable opening) | | CB3 | `C_receive = Com(v_r, r_r)` (prover knows the receiving opening) | -| CB4 | `v_s, v_r, alpha, v_s + v_r - alpha ∈ [0, 2^127)` (range validity, `DESIGN.md` §2.6) | +| CB4 | `v_s, v_r, alpha, v_s + v_r - alpha ∈ [0, 2^127)` (range validity, [Integer Embedding and Range Proofs](protocol/primitives.md#integer-embedding-and-range-proofs)) | -CB1 is what makes the witness an approval rather than a computation: without it, the public openings of a zero-blinding account (§5.2) would let the clawback signer prove — and settle to itself — a seizure of everything such an account holds. Range on `v_s` and `v_r` alone does not bound their sum against `alpha`, and an over-seize would drive the committed value negative mod `r` — a commitment the owner can still open but never again satisfy under W4 / T4. +CB1 is what makes the witness an approval rather than a computation: without it, the public openings of a zero-blinding account ([Roles and Separation](#roles-and-separation)) would let the clawback signer prove — and settle to itself — a seizure of everything such an account holds. Range on `v_s` and `v_r` alone does not bound their sum against `alpha`, and an over-seize would drive the committed value negative mod `r` — a commitment the owner can still open but never again satisfy under W4 / T4. **Public inputs (11 fields):** | Input | Notes | |:---|:---| | `C_spend`, `C_receive` | Loaded from the target's `spendable_commitment` and `receiving_commitment`, in this order | -| `K_aud` | Fetched from the auditor registry under the target's `auditor_id` (`DESIGN_cont.md` §8.3) | +| `K_aud` | Fetched from the auditor registry under the target's `auditor_id` ([Auditor Key Management and Rotation](protocol/auditing.md#auditor-key-management-and-rotation)) | | `alpha` | Public seize amount from invocation inputs | -| `addr_f` | Loaded from instance storage (`DESIGN.md` §2.7) | +| `addr_f` | Loaded from instance storage ([Address-to-Field Encoding](protocol/primitives.md#address-to-field-encoding)) | | `acct_f` | Binds the proof to the target account | | `dest_f` | `address_to_field(destination)` under `Some`, the zero field under `None` | -| `nonce` | The account's clawback nonce, loaded from storage (§5.4) | +| `nonce` | The account's clawback nonce, loaded from storage ([Contract Flow](#contract-flow)) | -No public input is prover-supplied (`DESIGN.md` §7.1). `addr_f`, `acct_f`, `dest_f`, and `nonce` are referenced by no gate; their membership in the public-input set is the binding, on the `register` / `acct_f` precedent (`DESIGN.md` §7.2). For `dest_f` that binding is what stops a compromised clawback signer from settling a witness built for one destination to an address of its own choosing; its zero sentinel for `None` is unambiguous because `address_to_field` is a Poseidon2 output. For `nonce` it is what makes a proof single-use (§5.6). +No public input is prover-supplied ([Public Input Sources](protocol/operations/README.md#public-input-sources)). `addr_f`, `acct_f`, `dest_f`, and `nonce` are referenced by no gate; their membership in the public-input set is the binding, on the `register` / `acct_f` precedent ([Registration](protocol/operations/register.md)). For `dest_f` that binding is what stops a compromised clawback signer from settling a witness built for one destination to an address of its own choosing; its zero sentinel for `None` is unambiguous because `address_to_field` is a Poseidon2 output. For `nonce` it is what makes a proof single-use ([Anti-Replay and the Freeze](#anti-replay-and-the-freeze)). **Private witnesses:** `k_aud`, `v_s`, `r_s`, `v_r`, `r_r` — the auditor secret and the openings of the two commitments. -### 5.4 Contract Flow +### Contract Flow `clawback(account, amount, destination, data, operator)` runs, after the deployment's access-control check on `operator`: 1. **Preconditions.** `is_frozen(account)`, else `AccountNotFrozen`; `amount > 0`, else `InvalidClawbackAmount`; `destination` is not `Some` naming this contract, else `InvalidClawbackDestination` — such a seize is a `None` in effect while reporting a settlement, which breaks per-branch pool reconciliation for any indexer. -2. **Public inputs.** Assembled in the §5.3 order through `append_point` / `append_field` / `append_amount`, so every coordinate and scalar passes the canonicality guards. `data` decodes to `ClawbackData { proof }`. +2. **Public inputs.** Assembled in the [Circuit](#circuit) order through `append_point` / `append_field` / `append_amount`, so every coordinate and scalar passes the canonicality guards. `data` decodes to `ClawbackData { proof }`. 3. **Verification** against `CircuitType::Clawback`. -4. **State update.** `C_spend <- C_spend + C_receive - alpha·G` and `C_receive <- O`: the `Merge` rule (`DESIGN.md` §7.4) followed by a public debit, with no fresh randomness. The new opening is `(v_s + v_r - alpha, r_s + r_r)`, which the owner and the auditor recompute from the event's `amount` alone, so the seized account stays spendable. Re-randomizing under a prover-chosen blinding instead would leave the owner unable to open its own commitment. -5. **Nonce.** `ClawbackNonce(account) <- nonce + 1`, where `nonce` is the value step 2 read into the public inputs. The entry is absent until the first seizure and reads as `0`; `clawback_nonce(account)` exposes it (§6). +4. **State update.** `C_spend <- C_spend + C_receive - alpha·G` and `C_receive <- O`: the `Merge` rule ([Merge](protocol/operations/merge.md)) followed by a public debit, with no fresh randomness. The new opening is `(v_s + v_r - alpha, r_s + r_r)`, which the owner and the auditor recompute from the event's `amount` alone, so the seized account stays spendable. Re-randomizing under a prover-chosen blinding instead would leave the owner unable to open its own commitment. +5. **Nonce.** `ClawbackNonce(account) <- nonce + 1`, where `nonce` is the value step 2 read into the public inputs. The entry is absent until the first seizure and reads as `0`; `clawback_nonce(account)` exposes it ([Interface Summary](#interface-summary)). 6. **Settlement**, by `destination`: - `None` — no underlying moves. - - `Some(d)` — exactly `amount` is transferred to `d` in the same invocation. `d` passes no compliance gate (§2, §3): the auditor chooses it when building the proof (§5.3) and the operator confirms it as an argument. -7. **Event.** `Clawback { account, amount, destination }` (§6.1). + - `Some(d)` — exactly `amount` is transferred to `d` in the same invocation. `d` passes no compliance gate ([Contract-Level Freeze](#contract-level-freeze), [Policy Contract](#policy-contract)): the auditor chooses it when building the proof ([Circuit](#circuit)) and the operator confirms it as an argument. +7. **Event.** `Clawback { account, amount, destination }` ([Events](#events)). **Extraction order.** The seize and the issuer's extraction are separate invocations by different parties, and Soroban admits one per transaction, so the pool sits mismatched for at least a ledger between them. Nothing in the contract enforces which comes first; under a `None` settlement the extraction must follow the seize: - **Seize, then extract.** Between the two the pool holds `amount` more than the claims against it. No holder is affected, and the extraction returns it to exact collateralization. -- **Extract, then seize.** Between the two the pool holds `amount` less than the claims against it, so it cannot honour them all: the shortfall lands on whichever holders withdraw last, none of whom is the target. It becomes permanent if the seize then turns out to be unbuildable — no witness holder produces the proof (§5.2). +- **Extract, then seize.** Between the two the pool holds `amount` less than the claims against it, so it cannot honour them all: the shortfall lands on whichever holders withdraw last, none of whom is the target. It becomes permanent if the seize then turns out to be unbuildable — the auditor produces no proof ([Roles and Separation](#roles-and-separation)). -The confidential debit strictly precedes the SEP-41 transfer, matching `withdraw`. Clawback is the one operation that reduces the sum of claims without a `Withdraw` (`DESIGN_cont.md` §9.3), and it invokes no `Hooks` callback: the freeze gate would reject exactly the account it acts on (`DESIGN_cont.md` §11). +The confidential debit strictly precedes the SEP-41 transfer, matching `withdraw`. Clawback is the one operation that reduces the sum of claims without a `Withdraw` ([Balance Conservation](protocol/security.md#balance-conservation)), and it invokes no `Hooks` callback: the freeze gate would reject exactly the account it acts on ([Interface](protocol/interface.md)). -### 5.5 Forced Revocation +### Forced Revocation -Escrowed allowances are invisible to `clawback`, which sees only `C_spend` and `C_receive`. `force_revoke_spender(account, spender, operator)` moves them into reach: with `account` frozen (`AccountNotFrozen` otherwise), it performs the owner's `revoke_spender` fold (`DESIGN.md` §7.9) under the admin gate in place of the owner's authorization and emits the same `RevokeSpender` event, carrying `a_tilde` and `allowance_salt`. No proof is involved, and expired delegations are revocable: expiry blocks spending but not reclamation. Like `clawback`, it invokes no `Hooks` callback. +Escrowed allowances are invisible to `clawback`, which sees only `C_spend` and `C_receive`. `force_revoke_spender(account, spender, operator)` moves them into reach: with `account` frozen (`AccountNotFrozen` otherwise), it performs the owner's `revoke_spender` fold ([Revoke Spender](protocol/operations/revoke-spender.md)) under the admin gate in place of the owner's authorization and emits the same `RevokeSpender` event, carrying `a_tilde` and `allowance_salt`. No proof is involved, and expired delegations are revocable: expiry blocks spending but not reclamation. Like `clawback`, it invokes no `Hooks` callback. -The owner and the auditor fold the event as they would an owner-initiated one (`DESIGN.md` §5.2; `DESIGN_cont.md` §8.5). For the auditor the archive dependence of `DESIGN_cont.md` §8.5 applies unchanged: `r_a` is not derivable from `a_tilde` and `σ_a`, so its standing opening of `C_spend` survives the fold only if it observed the delegation's last `SetSpender` / `SpenderTransfer` event. +The owner and the auditor fold the event as they would an owner-initiated one ([Wallet State and Recovery](protocol/wallet-state.md); [Spender Allowance Auditing](protocol/auditing.md#spender-allowance-auditing)). For the auditor the archive dependence of [Spender Allowance Auditing](protocol/auditing.md#spender-allowance-auditing) applies unchanged: `r_a` is not derivable from `a_tilde` and `σ_a`, so its standing opening of `C_spend` survives the fold only if it observed the delegation's last `SetSpender` / `SpenderTransfer` event. -### 5.6 Anti-Replay and the Freeze +### Anti-Replay and the Freeze -`C_spend` and `C_receive` are public inputs, so any change to either between proof construction and submission — an inbound transfer, a merge, a revoke — fails verification with `InvalidProof`. The freeze holds the commitments still. An auditor key rotation in the same window fails the same way (`DESIGN_cont.md` §8.3). +`C_spend` and `C_receive` are public inputs, so any change to either between proof construction and submission — an inbound transfer, a merge, a revoke — fails verification with `InvalidProof`. The freeze holds the commitments still. An auditor key rotation in the same window fails the same way ([In-flight proofs across rotation](protocol/auditing.md#in-flight-proofs-across-rotation)). -Commitment equality is not freshness. A seizure of `alpha` from `(C, O)` leaves `(C - alpha·G, O)`, and a zero-blinding credit of `alpha` restores `(C, O)` exactly — a public deposit followed by a merge once the account is unfrozen, or a `force_revoke_spender` fold of an allowance committed as `Com(alpha, 0)` while it is still frozen — with every other public input unchanged. The `nonce` input advances on every seizure (§5.4) and never returns to an earlier value, so the retained proof cannot execute a second seizure the auditor did not approve. +Commitment equality is not freshness. A seizure of `alpha` from `(C, O)` leaves `(C - alpha·G, O)`, and a zero-blinding credit of `alpha` restores `(C, O)` exactly — a public deposit followed by a merge once the account is unfrozen, or a `force_revoke_spender` fold of an allowance committed as `Com(alpha, 0)` while it is still frozen — with every other public input unchanged. The `nonce` input advances on every seizure ([Contract Flow](#contract-flow)) and never returns to an earlier value, so the retained proof cannot execute a second seizure the auditor did not approve. -`ConfidentialClawback: ConfidentialCompliance` forces a `freeze` / `unfreeze` implementation but constrains nothing about the deployment's `Hooks`. Wiring `NoHooks` next to a `ConfidentialClawback` impl yields a `freeze` that writes the flag and an `is_frozen` that returns `true` while every token operation stays ungated, so the target spends out before the seizure lands and the admin's only signal is an `InvalidProof` once the commitments have moved. A deployment that enables clawback MUST wire `ComplianceHooks`, or a custom `Hooks` impl that gates the same seven positions (§4). +`ConfidentialClawback: ConfidentialCompliance` forces a `freeze` / `unfreeze` implementation but constrains nothing about the deployment's `Hooks`. Wiring `NoHooks` next to a `ConfidentialClawback` impl yields a `freeze` that writes the flag and an `is_frozen` that returns `true` while every token operation stays ungated, so the target spends out before the seizure lands and the admin's only signal is an `InvalidProof` once the commitments have moved. A deployment that enables clawback MUST wire `ComplianceHooks`, or a custom `Hooks` impl that gates the same seven positions ([Customizing the Hooks Trait](#customizing-the-hooks-trait)). -### 5.7 Wallet and Auditor Consequences +### Wallet and Auditor Consequences -`Clawback` is a receiving-side reset and a `T_0` anchor, alongside `Merge` (`DESIGN.md` §5.2 *Recovery*; `INDEXER.md` §2). It is not a checkpoint: it carries no `b_tilde`, so its effect on the spendable side is absorbed into the next owner-initiated proof operation. The owner applies the `Merge` row of `DESIGN.md` §5.2 followed by `W_spend.v -= amount`; the auditor advances its standing opening identically (`DESIGN_cont.md` §8.1 *Sender-auditor opening capability*). A `RevokeSpender` emitted by `force_revoke_spender` is indistinguishable from an owner-initiated one for both consumers. +`Clawback` is a receiving-side reset and a `T_0` anchor, alongside `Merge` ([Recovery](protocol/wallet-state.md#recovery); [Terminology](indexer.md#terminology)). It is not a checkpoint: it carries no `b_tilde`, so its effect on the spendable side is absorbed into the next owner-initiated proof operation. The owner applies the `Merge` row of [Wallet State and Recovery](protocol/wallet-state.md) followed by `W_spend.v -= amount`; the auditor advances its standing opening identically ([Sender-auditor opening capability](protocol/auditing.md#sender-auditor-opening-capability)). A `RevokeSpender` emitted by `force_revoke_spender` is indistinguishable from an owner-initiated one for both consumers. --- -## 6. Interface Summary +## Interface Summary ```rust impl Token { - // Core arguments as declared in DESIGN_cont.md §11, plus the compliance entry. + // Core arguments as declared in protocol/interface.md, plus the compliance entry. fn __constructor(e: Env, admin: Address, token: Address, verifier: Address, auditor: Address, compliance: Option); - // Freeze (§2) + // Freeze (Contract-Level Freeze) fn freeze(e: Env, account: Address, operator: Address); fn unfreeze(e: Env, account: Address, operator: Address); fn is_frozen(e: Env, account: Address) -> bool; - // Config rotation (admin auth per §1.1, reverts when compliance.is_none()) + // Config rotation (admin auth per Admin Authority, reverts when compliance.is_none()) // Replaces the entire ComplianceConfig in one call. fn set_compliance_config(e: Env, config: ComplianceConfig, operator: Address); // Reads fn compliance_config(e: Env) -> Option; - // Clawback (§5), opt-in via ConfidentialClawback + // Clawback, opt-in via ConfidentialClawback fn clawback(e: Env, account: Address, amount: i128, destination: Option
, data: Bytes /* XDR ClawbackData { proof } */, operator: Address); fn force_revoke_spender(e: Env, account: Address, spender: Address, operator: Address); @@ -293,18 +293,18 @@ impl Token { } ``` -`operator` is the address whose authorization the deployment's access-control module checks (§1.1). +`operator` is the address whose authorization the deployment's access-control module checks ([Admin Authority](#admin-authority)). -### 6.1 Events +### Events | Event | Fields | |:---|:---| | `Frozen`, `Unfrozen` | `account` | | `ComplianceConfigChanged` | `policy`, `sac_passthrough` | | `Clawback` | `account` (topic), `amount`, `destination` | -| `RevokeSpender` | as `DESIGN_cont.md` §11.2, when emitted by `force_revoke_spender` | +| `RevokeSpender` | as [Event Schema](protocol/interface.md#event-schema), when emitted by `force_revoke_spender` | -### 6.2 Errors +### Errors The seizure entry points add three variants to `ComplianceError`: @@ -313,3 +313,7 @@ The seizure entry points add three variants to `ComplianceError`: | `AccountNotFrozen` | 3604 | the target of `clawback` or `force_revoke_spender` is not frozen | | `InvalidClawbackAmount` | 3605 | `amount <= 0` | | `InvalidClawbackDestination` | 3606 | `destination` is `Some` naming the contract's own address | + +--- + +Up: [Index](README.md#companions) diff --git a/packages/tokens/src/confidential/docs/INDEXER.md b/packages/tokens/src/confidential/docs/indexer.md similarity index 52% rename from packages/tokens/src/confidential/docs/INDEXER.md rename to packages/tokens/src/confidential/docs/indexer.md index 7e9f82286..f995ec009 100644 --- a/packages/tokens/src/confidential/docs/INDEXER.md +++ b/packages/tokens/src/confidential/docs/indexer.md @@ -1,30 +1,30 @@ # Indexing and Off-Chain State Recovery -Companion specification to [DESIGN.md](./DESIGN.md) §5.2 (Off-Chain Opening Maintenance) and [DESIGN_cont.md](./DESIGN_cont.md) §9.5 (State Recovery). It specifies the durable event archive — the *indexer* — that those sections assume: its data model, ingestion contract, retention obligations, and recommended API surface. +Companion specification to the protocol's [Wallet State and Recovery](protocol/wallet-state.md). It specifies the durable event archive — the *indexer* — that those sections assume: its data model, ingestion contract, retention obligations, and recommended API surface. The key words MUST, MUST NOT, SHOULD, and MAY are to be interpreted as in RFC 2119. The normative audience is twofold: -- **Indexer operators** MUST satisfy §3–§5 for the deployment to support recovery from seed. -- **Wallets and SDKs** MUST consume an indexer meeting this contract for recovery, and SHOULD apply the client-side verification in §7. +- **Indexer operators** MUST satisfy [Data Model](#data-model) through [Retention Obligations](#retention-obligations) for the deployment to support recovery from seed. +- **Wallets and SDKs** MUST consume an indexer meeting this contract for recovery, and SHOULD apply the client-side verification in [Trust Model and Client-Side Verification](#trust-model-and-client-side-verification). -## 1. Why the Indexer Is Load-Bearing +## Why the Indexer Is Load-Bearing -Confidential balances are Pedersen commitments; the on-chain entry alone does not reveal the opening `(v, r)` needed to spend. A wallet that loses its local cache reconstructs the opening deterministically from the master secret plus the account's event history: the latest *checkpoint* event supplies `(b_tilde, sigma)` from which the spendable opening as of that checkpoint is derived (DESIGN §5.2), while the receiving-side opening is rebuilt by replaying deposits and incoming transfers back to the account's last `Merge` or `Clawback` at or before that checkpoint, or to registration if there is none before it (§2). That same replay carries the post-checkpoint folds — `Merge`, `RevokeSpender`, and `Clawback` — onto the spendable side. +Confidential balances are Pedersen commitments; the on-chain entry alone does not reveal the opening `(v, r)` needed to spend. A wallet that loses its local cache reconstructs both openings deterministically from the master secret plus the account's event history, by the procedure in [Recovery](protocol/wallet-state.md#recovery): the latest checkpoint supplies the spendable opening, and a replay of the window from `T_0` rebuilds the receiving opening and carries the post-checkpoint folds onto the spendable one. -Stellar RPC retains events for a **7-day window** only. A wallet that loses local state after that window can still see that its funds exist (the commitment remains on-chain) but cannot reconstruct the opening required to spend them — unless a durable archive holds the missing events. That archive is this document's subject. Without a conforming indexer, recovery from seed is not guaranteed, and deployments MUST treat wallet-local state as unrecoverable after the RPC window. +Stellar RPC retains events for a bounded window the node reports ([Event durability requirement](protocol/wallet-state.md#event-durability-requirement)). A wallet that loses local state after that window can still see that its funds exist (the commitment remains on-chain) but cannot reconstruct the opening required to spend them — unless a durable archive holds the missing events. That archive is this document's subject. Without a conforming indexer, recovery from seed is not guaranteed, and deployments MUST treat wallet-local state as unrecoverable after the RPC window. -**RPC and the archive compose.** A wallet need not read everything from the archive. Recent history is still live on Stellar RPC, so a client reads the recent tail from RPC directly and only the older portion — everything past the RPC retention floor — from the archive, stitching the two at a *seam* (§2) and deduplicating by event id (§3.4). The archive's obligation is therefore durable retention of everything older than the RPC window, not keeping pace with the chain head. Reading the whole history from the archive is equally conformant; the split is a client optimization. Either way the guarantee holds only if the archive does not fall so far behind that its ingested-through ledger (§6 C4) drops below the seam — see §4. +**RPC and the archive compose.** A wallet need not read everything from the archive. Recent history is still live on Stellar RPC, so a client reads the recent tail from RPC directly and only the older portion — everything past the RPC retention floor — from the archive, stitching the two at a *seam* ([Terminology](#terminology)) and deduplicating by event id ([Ordering](#ordering)). The archive's obligation is therefore durable retention of everything older than the RPC window, not keeping pace with the chain head. Reading the whole history from the archive is equally conformant; the split is a client optimization. Either way the guarantee holds only if the archive does not fall so far behind that its ingested-through ledger ([API Surface](#api-surface) C4) drops below the seam — see [Ingestion Contract](#ingestion-contract). -## 2. Terminology +## Terminology -- **Checkpoint** — an owner-initiated proof-carrying event that publishes `(b_tilde, sigma)` for the owner's spendable balance (DESIGN §5.2; §3.2 lists the qualifying event types in ingestion scope). -- **Replay window** — the range from `T_0` to the current ledger, where `T_0` is the account's most recent `Merge` or `Clawback` at or before its latest checkpoint, or its `Register` event if there is none before that checkpoint (DESIGN §5.2 *Recovery*). One window serves both sides: the checkpoint supplies the spendable opening as of the checkpoint, the window carries the `Merge`, `RevokeSpender`, and `Clawback` folds that follow it onto that opening, and the receiving opening is rebuilt across the window. Anchoring the receiving side at the account's last `Merge` overall is not sufficient — a `Merge` after the checkpoint reconstructs the current receiving opening correctly but leaves the spendable opening short by the amount that merge folded in, the checkpoint predating it. -- **Event id** — the triple `(ledger_seq, tx_hash, event_index)`, unique per emitted event: `tx_hash` is globally unique, and `event_index` is unambiguous because a Soroban transaction carries a single operation. The same event MUST carry the same id whether served from the archive or from RPC, so a hybrid client can deduplicate across the seam. The id does not by itself encode position within a ledger — `tx_hash` conveys no ordering — so the canonical total order is instead `(ledger_seq, tx_application_order, event_index)`, where `tx_application_order` is persisted as its own field (§3.1) and drives §3.4. -- **Seam** — in a hybrid client (§1) that reads the recent tail from Stellar RPC and older history from the archive, the ledger at which it switches sources. A client sets the seam above the RPC retention floor (`getHealth().oldestLedger`) so the RPC side is always served from live retention, and requires the archive's ingested-through ledger (§6 C4) to reach the seam. +- **Checkpoint** and **`T_0`** — as defined in [Recovery](protocol/wallet-state.md#recovery); [Events in scope](#events-in-scope) lists the qualifying event types in ingestion scope. +- **Replay window** — the range from `T_0` to the current ledger. One window serves both sides ([Why the two anchors compose](protocol/wallet-state.md#why-the-two-anchors-compose)). +- **Event id** — the triple `(ledger_seq, tx_hash, event_index)`, unique per emitted event: `tx_hash` is globally unique, and `event_index` is unambiguous because a Soroban transaction carries a single operation. The same event MUST carry the same id whether served from the archive or from RPC, so a hybrid client can deduplicate across the seam. The id does not by itself encode position within a ledger — `tx_hash` conveys no ordering — so the canonical total order is instead `(ledger_seq, tx_application_order, event_index)`, where `tx_application_order` is persisted as its own field ([Archived record](#archived-record)) and drives [Ordering](#ordering). +- **Seam** — in a hybrid client ([Why the Indexer Is Load-Bearing](#why-the-indexer-is-load-bearing)) that reads the recent tail from Stellar RPC and older history from the archive, the ledger at which it switches sources. A client sets the seam above the RPC retention floor (`getHealth().oldestLedger`) so the RPC side is always served from live retention, and requires the archive's ingested-through ledger ([API Surface](#api-surface) C4) to reach the seam. -## 3. Data Model +## Data Model -### 3.1 Archived record +### Archived record For every in-scope event the indexer MUST persist: @@ -34,15 +34,15 @@ For every in-scope event the indexer MUST persist: | `ledger_seq` | Ledger sequence the event was emitted in. | | `ledger_close_time` | Close time of that ledger. | | `tx_hash` | Transaction hash. | -| `tx_application_order` | Position of the transaction within its ledger; supplies the intra-ledger ordering that `tx_hash` does not (§3.4). | +| `tx_application_order` | Position of the transaction within its ledger; supplies the intra-ledger ordering that `tx_hash` does not ([Ordering](#ordering)). | | `event_index` | Index of the event within the transaction. | | `topics`, `data` | The event's topics and data payload — verbatim XDR (RECOMMENDED) or a pinned decoded encoding (see note). | Verbatim XDR is the RECOMMENDED payload: the wallet decodes it directly and, being the on-chain wire form, it survives field renames in the Rust bindings without a schema migration. A decoded representation — for example the JSON a managed indexing pipeline emits — MAY be served instead. Whichever form is served, it MUST reproduce the on-chain event exactly under that decoder. Indexers MAY additionally store decoded columns for querying. -### 3.2 Events in scope +### Events in scope -All events emitted by the confidential token (DESIGN_cont §11.2) with the following recovery roles: +All events emitted by the confidential token ([Event Schema](protocol/interface.md#event-schema)) with the following recovery roles: | Event | Role in recovery | |:--|:--| @@ -51,50 +51,50 @@ All events emitted by the confidential token (DESIGN_cont §11.2) with the follo | `Transfer` (recipient side) | Receiving-side replay: carries the recipient-channel ciphertexts for `(v_transfer, r_transfer)`. | | `SpenderTransfer` (recipient side) | Receiving-side replay, as above. | | `Merge` | Anchor: folds the receiving opening into the spendable opening; resets the receiving side. | -| `Clawback` | Anchor, as `Merge`, less the public `amount` on the spendable side; not a checkpoint. Emitted only by deployments that enable the compliance extension (COMPLIANCE §5.7). | +| `Clawback` | Anchor, as `Merge`, less the public `amount` on the spendable side; not a checkpoint. Emitted only by deployments that enable the compliance extension ([Wallet and Auditor Consequences](compliance.md#wallet-and-auditor-consequences)). | | `Withdraw`, `Transfer` (sender side), `SetSpender` | **Checkpoints**: publish `(b_tilde, sigma)` for the owner's spendable balance. `SetSpender` is in scope as an owner checkpoint only — a spender recovers allowance state from the on-chain delegation entry (`allowance_commitment`, `a_tilde`, `escrowed_dvk`, `allowance_salt`), not from the archive. | -| `RevokeSpender` | Spendable-side fold, not a checkpoint: the owner adds the reclaimed allowance opening to the spendable opening, deriving it from the event's `a_tilde` and `allowance_salt` (DESIGN §7.9). May be emitted by the compliance extension's `force_revoke_spender` without an owner-signed transaction; the fold is the same (COMPLIANCE §5.5). | +| `RevokeSpender` | Spendable-side fold, not a checkpoint: the owner adds the reclaimed allowance opening to the spendable opening, deriving it from the event's `a_tilde` and `allowance_salt` ([Revoke Spender](protocol/operations/revoke-spender.md)). May be emitted by the compliance extension's `force_revoke_spender` without an owner-signed transaction; the fold is the same ([Forced Revocation](compliance.md#forced-revocation)). | -A self-transfer — a `Transfer` whose `from` and `to` are the same account — carries both roles at once: it is a sender-side checkpoint and a recipient-side replay event, and recovery applies both (DESIGN §5.2). +A self-transfer — a `Transfer` whose `from` and `to` are the same account — carries both roles at once: it is a sender-side checkpoint and a recipient-side replay event, and recovery applies both ([Wallet State and Recovery](protocol/wallet-state.md)). -The auditor-channel fields these events carry (`v_tilde_aud_*`, `b_tilde_aud_s`, `r_tilde_aud_s`, `a_tilde_aud_s`, `r_a_tilde_aud_s`) are out of scope for wallet recovery; for the auditor they are the only route to an allowance opening, since the escrowed blinding (`r_a_tilde_aud_s` on `SetSpender`, `r_tilde_aud_s` on `SpenderTransfer`) appears nowhere in contract storage (DESIGN_cont §8.5 *Archive dependence*; §7.1). They are archived regardless, since the record is the verbatim event (§3.1). Event shapes are normative in DESIGN_cont §11.2. +The auditor-channel fields these events carry (`v_tilde_aud_*`, `b_tilde_aud_s`, `r_tilde_aud_s`, `a_tilde_aud_s`, `r_a_tilde_aud_s`) are out of scope for wallet recovery; for the auditor they are the only route to an allowance opening, since the escrowed blinding (`r_a_tilde_aud_s` on `SetSpender`, `r_tilde_aud_s` on `SpenderTransfer`) appears nowhere in contract storage ([Archive dependence](protocol/auditing.md#archive-dependence); [Auditor recovery](#auditor-recovery)). They are archived regardless, since the record is the verbatim event ([Archived record](#archived-record)). Event shapes are normative in [Event Schema](protocol/interface.md#event-schema). Configuration events (`UnderlyingAssetSet`, `VerifierSet`, `AuditorSet`, `AddressAsFieldSet`, verification-key events) and the compliance events `Frozen`, `Unfrozen`, and `ComplianceConfigChanged` are not needed for balance recovery; indexers SHOULD archive them anyway — they are low-volume and useful for deployment forensics. -### 3.3 Account attribution +### Account attribution Recovery is per-account, and an event belongs to **each** account address appearing in its topics — a `Transfer` to both the sender's and the recipient's history, a `SpenderTransfer` to the owner's, recipient's, and spender's, a `RevokeSpender` to the owner's and the spender's alone, a `Clawback` to the target's alone. Attribution MUST come from the event topics, never from the transaction source account. -The indexer MAY apply this attribution server-side (per-account queries, §6 C2) or serve the whole per-contract stream and leave the client to select the events touching its account; both conform, since attribution is a pure function of the topics. Server-side per-account filtering is RECOMMENDED for high-volume contracts, where downloading the full contract history to every wallet does not scale. +The indexer MAY apply this attribution server-side (per-account queries, [API Surface](#api-surface) C2) or serve the whole per-contract stream and leave the client to select the events touching its account; both conform, since attribution is a pure function of the topics. Server-side per-account filtering is RECOMMENDED for high-volume contracts, where downloading the full contract history to every wallet does not scale. -### 3.4 Ordering +### Ordering -The indexer MUST preserve and expose the total order `(ledger_seq, tx_application_order, event_index)` — all three components are persisted per §3.1. Replay correctness depends on it: interleaved deposits, transfers, merges, and revokes only reconstruct the right openings when applied in emission order (DESIGN §5.2 step 6). +The indexer MUST preserve and expose the total order `(ledger_seq, tx_application_order, event_index)` — all three components are persisted per [Archived record](#archived-record). Replay correctness depends on it: interleaved deposits, transfers, merges, and revokes only reconstruct the right openings when applied in emission order ([Wallet State and Recovery](protocol/wallet-state.md) step 6). -## 4. Ingestion Contract +## Ingestion Contract - **Source.** Any source that yields the complete, final event stream (Stellar RPC `getEvents`, Horizon, or a captive core). Stellar ledgers are final at close; there is no reorg handling. -- **Freshness.** The archive's ingested-through ledger (§6 C4) MUST stay within the source's retention window — at or above the seam a hybrid client would set (§2). It need not track the chain head: the RPC serves the recent tail (§1). But if it falls below the seam, a gap opens that neither source covers. Detect and backfill any gap while its ledgers are still retrievable from a source; a gap that can no longer be filled is permanent, and affected ranges MUST then be reported incomplete (§6 C3). +- **Freshness.** The archive's ingested-through ledger ([API Surface](#api-surface) C4) MUST stay within the source's retention window — at or above the seam a hybrid client would set ([Terminology](#terminology)). It need not track the chain head: the RPC serves the recent tail ([Why the Indexer Is Load-Bearing](#why-the-indexer-is-load-bearing)). But if it falls below the seam, a gap opens that neither source covers. Detect and backfill any gap while its ledgers are still retrievable from a source; a gap that can no longer be filled is permanent, and affected ranges MUST then be reported incomplete ([API Surface](#api-surface) C3). - **Idempotency.** Ingestion MUST be at-least-once, deduplicated by event id. -- **Gaps.** The indexer MUST track contiguous ingested ledger ranges. If a gap can no longer be backfilled from any source, the indexer MUST NOT silently serve affected histories as complete (see C3 in §6). -- **Fidelity.** Events MUST be stored faithfully (§3.1) — verbatim XDR or a decoded form pinned to the canonical decoder. Decoding for queries is a read-side concern. +- **Gaps.** The indexer MUST track contiguous ingested ledger ranges. If a gap can no longer be backfilled from any source, the indexer MUST NOT silently serve affected histories as complete (see C3 in [API Surface](#api-surface)). +- **Fidelity.** Events MUST be stored faithfully ([Archived record](#archived-record)) — verbatim XDR or a decoded form pinned to the canonical decoder. Decoding for queries is a read-side concern. -## 5. Retention Obligations +## Retention Obligations The indexer MUST retain the full per-account history of every in-scope event **indefinitely**. No pruning horizon is safe in general: - The spendable opening is taken from the account's latest checkpoint, which is arbitrarily old for a dormant account. - The receiving side is replayed from the last `Merge` or `Clawback` at or before that checkpoint, which for an account that receives but never merges is its registration. -- A `RevokeSpender` after the latest checkpoint is the only surviving record of the addend it folded: the delegation entry carrying `a_tilde` and `allowance_salt` is deleted in the same invocation (DESIGN §7.9). +- A `RevokeSpender` after the latest checkpoint is the only surviving record of the addend it folded: the delegation entry carrying `a_tilde` and `allowance_salt` is deleted in the same invocation ([Revoke Spender](protocol/operations/revoke-spender.md)). -## 6. API Surface +## API Surface C2–C4 below are normative; C1 is RECOMMENDED. The REST shape is RECOMMENDED — any transport exposing the same capabilities conforms. -- **C1 — Latest checkpoint (RECOMMENDED).** Return the most recent checkpoint event for `(contract_id, account)` at or before a given ledger. This is an optimization, not a correctness requirement: each checkpoint carries a self-contained `(b_tilde, sigma)` that re-derives the spendable opening as of that checkpoint, so a client can also obtain the latest checkpoint by scanning the ordered history (C2). Exposing C1 lets a dormant account with a long history skip transferring that history. A client additionally needs `T_0`, the last `Merge` or `Clawback` at or before that checkpoint (§2), which C1 does not return and which is obtainable from C2's ordered history. -- **C2 — Ordered history.** Return all in-scope events for `(contract_id, account)` within a ledger range, in the total order of §3.4, paginated, each carrying its event id and payload (§3.1). An indexer that serves only the per-contract stream (§3.3) satisfies C2 by delivering that stream in order for client-side attribution. +- **C1 — Latest checkpoint (RECOMMENDED).** Return the most recent checkpoint event for `(contract_id, account)` at or before a given ledger. This is an optimization, not a correctness requirement: each checkpoint carries a self-contained `(b_tilde, sigma)` that re-derives the spendable opening as of that checkpoint, so a client can also obtain the latest checkpoint by scanning the ordered history (C2). Exposing C1 lets a dormant account with a long history skip transferring that history. A client additionally needs `T_0`, the last `Merge` or `Clawback` at or before that checkpoint ([Terminology](#terminology)), which C1 does not return and which is obtainable from C2's ordered history. +- **C2 — Ordered history.** Return all in-scope events for `(contract_id, account)` within a ledger range, in the total order of [Ordering](#ordering), paginated, each carrying its event id and payload ([Archived record](#archived-record)). An indexer that serves only the per-contract stream ([Account attribution](#account-attribution)) satisfies C2 by delivering that stream in order for client-side attribution. - **C3 — Completeness signal.** Every response MUST state whether the served range is complete (`complete: true` only when the indexer holds a gap-free history for the whole requested range). -- **C4 — Ingestion status.** Expose the latest fully-ingested ledger so clients can bound staleness. A hybrid client (§1) compares it against the seam it derives from the RPC retention floor; if the archive has not ingested through the seam, the client MUST treat the crossing range as incomplete (C3). +- **C4 — Ingestion status.** Expose the latest fully-ingested ledger so clients can bound staleness. A hybrid client ([Why the Indexer Is Load-Bearing](#why-the-indexer-is-load-bearing)) compares it against the seam it derives from the RPC retention floor; if the archive has not ingested through the seam, the client MUST treat the crossing range as incomplete (C3). Recommended shape: @@ -108,23 +108,27 @@ GET /v1/tokens/{contract_id}/accounts/{account}/checkpoint?at_ledger={n} GET /v1/tokens/{contract_id}/accounts/{account}/events ?from_ledger={n}&to_ledger={m}&types={csv}&cursor={c}&limit={k} - -> { events: [ ... §3.1 records ... ], cursor, complete } + -> { events: [ ... archived records ... ], cursor, complete } ``` -`types` filters by event name; servers MUST apply it after attribution (§3.3), never by dropping events from storage. The `/checkpoint` endpoint implements the optional C1 and MAY be omitted. A deployment MAY instead expose history as a single per-contract stream (`GET /v1/tokens/{contract_id}/events`) and leave attribution to the client (§3.3). +`types` filters by event name; servers MUST apply it after attribution ([Account attribution](#account-attribution)), never by dropping events from storage. The `/checkpoint` endpoint implements the optional C1 and MAY be omitted. A deployment MAY instead expose history as a single per-contract stream (`GET /v1/tokens/{contract_id}/events`) and leave attribution to the client ([Account attribution](#account-attribution)). -## 7. Trust Model and Client-Side Verification +## Trust Model and Client-Side Verification The indexer is trusted for **availability and completeness only** — never for confidentiality or integrity: -- **Confidentiality.** Everything the indexer holds is public chain data: commitments, masked ciphertexts, and ECDH ephemerals. A curious indexer learns nothing beyond what any chain observer sees (DESIGN_cont §9). -- **Integrity fails closed.** Recovery ends with the wallet checking its reconstructed openings against the **on-chain** commitments (`C_spend =? v·G + r·H`, DESIGN §5.2 step 7). A tampered or incomplete history cannot produce a wrong balance that verifies; it produces a detectable mismatch. -- **Withholding is the residual risk.** A malicious or broken indexer can deny recovery (a liveness failure, not a soundness one). Two structural mitigations: for the recent window the RPC is an independent source of the same events (the hybrid split of §1), so archive withholding bites only the pre-window history; and for that older history wallets SHOULD support multiple independent archive endpoints, with deployments running or contracting at least two. +- **Confidentiality.** Everything the indexer holds is public chain data: commitments, masked ciphertexts, and ECDH ephemerals. A curious indexer learns nothing beyond what any chain observer sees ([Security Analysis](protocol/security.md)). +- **Integrity fails closed.** Recovery ends with the wallet checking its reconstructed openings against the **on-chain** commitments (`C_spend =? v·G + r·H`, [Wallet State and Recovery](protocol/wallet-state.md) step 7). A tampered or incomplete history cannot produce a wrong balance that verifies; it produces a detectable mismatch. +- **Withholding is the residual risk.** A malicious or broken indexer can deny recovery (a liveness failure, not a soundness one). Two structural mitigations: for the recent window the RPC is an independent source of the same events (the hybrid split of [Why the Indexer Is Load-Bearing](#why-the-indexer-is-load-bearing)), so archive withholding bites only the pre-window history; and for that older history wallets SHOULD support multiple independent archive endpoints, with deployments running or contracting at least two. -### 7.1 Auditor recovery +### Auditor recovery -An auditor's allowance tracking is strictly event-scoped and has no state-based fallback. The opening of a delegation's `C_a` is escrowed only in the event that wrote it (DESIGN_cont §8.5), so a missed, reordered, or unarchived `SetSpender` / `SpenderTransfer` leaves the auditor without that opening (DESIGN_cont §8.3 *Decryption capability across rotation*). An auditor client SHOULD verify each reconstructed allowance opening against the stored `allowance_commitment` — `C_a =? v_a·G + r_a·H` — which is the same fails-closed check §7 states for wallets. A mismatch is evidence of a missed, reordered, or pruned event rather than of a wrong balance. `live_until_ledger` governs spending authority and is independent of the delegation entry's persistent-entry TTL. +An auditor's allowance tracking is strictly event-scoped and has no state-based fallback. The opening of a delegation's `C_a` is escrowed only in the event that wrote it ([Spender Allowance Auditing](protocol/auditing.md#spender-allowance-auditing)), so a missed, reordered, or unarchived `SetSpender` / `SpenderTransfer` leaves the auditor without that opening ([Decryption capability across rotation](protocol/auditing.md#decryption-capability-across-rotation)). An auditor client SHOULD verify each reconstructed allowance opening against the stored `allowance_commitment` — `C_a =? v_a·G + r_a·H` — which is the same fails-closed check [Trust Model and Client-Side Verification](#trust-model-and-client-side-verification) states for wallets. A mismatch is evidence of a missed, reordered, or pruned event rather than of a wrong balance. `live_until_ledger` governs spending authority and is independent of the delegation entry's persistent-entry TTL. -## 8. Conformance and Versioning +## Conformance and Versioning -An implementation conforms to this specification iff it satisfies §3–§5 and exposes the normative capabilities C2, C3, and C4 (C1 is RECOMMENDED). This document is versioned with the protocol documentation set; breaking changes to the archived record shape or the normative capabilities bump the protocol documentation version and MUST be called out in release notes. +An implementation conforms to this specification iff it satisfies [Data Model](#data-model) through [Retention Obligations](#retention-obligations) and exposes the normative capabilities C2, C3, and C4 (C1 is RECOMMENDED). This document is versioned with the protocol documentation set; breaking changes to the archived record shape or the normative capabilities bump the protocol documentation version and MUST be called out in release notes. + +--- + +Up: [Index](README.md#companions) diff --git a/packages/tokens/src/confidential/docs/OVERVIEW.md b/packages/tokens/src/confidential/docs/overview.md similarity index 81% rename from packages/tokens/src/confidential/docs/OVERVIEW.md rename to packages/tokens/src/confidential/docs/overview.md index 2a7040f9a..8ca534049 100644 --- a/packages/tokens/src/confidential/docs/OVERVIEW.md +++ b/packages/tokens/src/confidential/docs/overview.md @@ -14,7 +14,7 @@ The system provides **confidentiality**, not anonymity. ## Why a Separate Contract -- **Works with any SEP-41 token**, including XLM via its Stellar Asset Contract. No issuer changes required. (The underlying token must be non-rebasing and free of transfer fees; see `DESIGN.md` §3.4.) +- **Works with any SEP-41 token**, including XLM via its Stellar Asset Contract. No issuer changes required. (The underlying token must be non-rebasing and free of transfer fees; see [Underlying Token Assumptions](protocol/system-model.md#underlying-token-assumptions).) - **Evolves independently** of the token standard - upgrades to the privacy layer do not touch the underlying asset. - **Clean separation** - the token keeps doing what it does today; confidentiality is an opt-in layer on top. @@ -35,7 +35,7 @@ The system provides **confidentiality**, not anonymity. ## Main User Flows -### 1. Account Setup (Registration) +### Account Setup (Registration) | Step | Who | What happens | |:-----|:----|:-------------| @@ -48,7 +48,7 @@ The system provides **confidentiality**, not anonymity. --- -### 2. Deposit (Public to Confidential) +### Deposit (Public to Confidential) | Step | Who | What happens | |:-----|:----|:-------------| @@ -60,7 +60,7 @@ Anyone can deposit into any registered account; the depositor itself does not ne --- -### 3. Merge (Making Received Funds Spendable) +### Merge (Making Received Funds Spendable) | Step | Who | What happens | |:-----|:----|:-------------| @@ -71,7 +71,7 @@ Merge is the gate between received funds and spendable funds. --- -### 4. Confidential Transfer +### Confidential Transfer | Step | Who | What happens | |:-----|:----|:-------------| @@ -86,7 +86,7 @@ Merge is the gate between received funds and spendable funds. --- -### 5. Withdrawal (Confidential to Public) +### Withdrawal (Confidential to Public) | Step | Who | What happens | |:-----|:----|:-------------| @@ -96,7 +96,7 @@ Merge is the gate between received funds and spendable funds. --- -### 6. Delegated Spending (Spenders) +### Delegated Spending (Spenders) Spenders enable use cases like automated trading bots, payment processors, or custodial services. @@ -125,7 +125,7 @@ The system supports **real-time auditing** via a dual-auditor model. Each accoun | What | Recipient's auditor sees | Sender's auditor sees | |:-----|:------------------------|:---------------------| | Transfer amount | Yes | Yes | -| Per-transfer Pedersen randomness $r_{\text{transfer}}$ | Yes (enables Pedersen-opening reconstruction of the recipient's receiving balance) | No | +| Per-transfer Pedersen randomness $$r\_{\text{transfer}}$$ | Yes (enables Pedersen-opening reconstruction of the recipient's receiving balance) | No | | Sender's post-transfer balance | No | Yes | | Withdrawal amount | n/a | Yes (publicly visible) | | Post-withdrawal balance | n/a | Yes | @@ -133,9 +133,9 @@ The system supports **real-time auditing** via a dual-auditor model. Each accoun | Spender escrow amount | n/a | Yes (owner's auditor) | | Post-transfer spender allowance | No (for spender transfers) | Yes (owner's auditor) | | Post-escrow balance | n/a | Yes (owner's auditor), at spender setup | -| Allowance blinding $r_a$ | No | Yes (owner's auditor), at spender setup and every spender transfer | +| Allowance blinding $$r\_a$$ | No | Yes (owner's auditor), at spender setup and every spender transfer | -The table covers every auditor ciphertext the protocol produces; `DESIGN_cont.md` §8.1-§8.5 is the normative account, including the bounds on each opening capability. +The table covers every auditor ciphertext the protocol produces; [Auditing](protocol/auditing.md) is the normative account, including the bounds on each opening capability. Each auditor decrypts its ciphertexts by running the channel sponge (recipient-auditor channel for recipients, sender-auditor channel for senders/owners) with its private key, the ephemeral public key, and the per-operation salt published in the operation's event. @@ -144,10 +144,10 @@ Each auditor decrypts its ciphertexts by running the channel sponge (recipient-a - **Per-account auditor selection.** Each account selects an auditor at registration. The `auditor_id` is immutable and determines which auditor receives ciphertexts for the account's activity. - **Dual-auditor ciphertexts.** The ciphertexts each operation produces are enforced by its zero-knowledge proof, so they cannot be omitted or malformed, and no extra action is needed from users. - **Per-account scope.** Auditing one account reveals nothing about any other account. -- **Recipient-side opening capability.** The recipient's auditor holds the per-transfer Pedersen blinding $r_{\text{transfer}}$, hence the full Pedersen opening of the recipient's receiving balance between merges, which is what enables the seizure/clawback flow specified in `COMPLIANCE.md` §5; the capability and its bounds are specified in `DESIGN_cont.md` §8.1. -- **Seizure.** A deployment that opts into the compliance extension can reduce a frozen account's balance by a public amount against a proof that the amount does not exceed what the account holds; the proof is produced by the auditor or the owner, never by the admin alone, and the account stays spendable afterwards (`COMPLIANCE.md` §5). -- **Sender-side opening capability.** The sender's auditor holds the opening of the account's spendable balance as of each withdrawal, outgoing transfer, and spender setup, because those operations also encrypt the post-operation blinding factor to it. Because one key serves both of an account's auditor channels, the capability survives merges; its bounds are specified in `DESIGN_cont.md` §8.1. -- **Seamless auditor rotation.** When an auditor key is rotated, the new key immediately receives ciphertexts on subsequent operations. For the sender's auditor, the balance checkpoint at the next owner-initiated proof operation (transfer, withdrawal, or set spender) provides the current balance with no event replay; `DESIGN_cont.md` §8.3 bounds what a rotated key can open beyond that checkpoint. +- **Recipient-side opening capability.** The recipient's auditor holds the per-transfer Pedersen blinding $$r\_{\text{transfer}}$$, hence the full Pedersen opening of the recipient's receiving balance between merges, which is what enables the seizure/clawback flow specified in [Clawback](compliance.md#clawback); the capability and its bounds are specified in [Per-Transfer Auditor Ciphertexts](protocol/auditing.md#per-transfer-auditor-ciphertexts). +- **Seizure.** A deployment that opts into the compliance extension can reduce a frozen account's balance by a public amount against a proof that the amount does not exceed what the account holds; the proof is produced by the auditor or the owner, never by the admin alone, and the account stays spendable afterwards ([Clawback](compliance.md#clawback)). +- **Sender-side opening capability.** The sender's auditor holds the opening of the account's spendable balance as of each withdrawal, outgoing transfer, and spender setup, because those operations also encrypt the post-operation blinding factor to it. Because one key serves both of an account's auditor channels, the capability survives merges; its bounds are specified in [Per-Transfer Auditor Ciphertexts](protocol/auditing.md#per-transfer-auditor-ciphertexts). +- **Seamless auditor rotation.** When an auditor key is rotated, the new key immediately receives ciphertexts on subsequent operations. For the sender's auditor, the balance checkpoint at the next owner-initiated proof operation (transfer, withdrawal, or set spender) provides the current balance with no event replay; [Auditor Key Management and Rotation](protocol/auditing.md#auditor-key-management-and-rotation) bounds what a rotated key can open beyond that checkpoint. - **Spender visibility.** The owner's auditor sees spender transfer amounts and post-transfer allowances via the same dual-auditor mechanism, and additionally sees the escrowed amount at `set_spender`. - **Viewing vs. spending separation.** A viewing key cannot move or spend funds. Spending requires the separate spending key, which is never shared. @@ -170,9 +170,9 @@ The wallet abstracts all cryptographic operations. Account holders interact with The wallet must: - **Generate and store keys** - derive the full key hierarchy (spending key, viewing key, public viewing key, delegation viewing keys) from a single master secret. -- **Produce zero-knowledge proofs** - the heaviest client-side computation. Proof generation time depends on the circuit complexity but targets single-digit seconds on modern hardware. The Transfer circuit involves 8 elliptic-curve scalar multiplications (including two auditor ECDH exchanges); the Register circuit needs 2. `DESIGN_cont.md` §10.3 lists the per-circuit totals. +- **Produce zero-knowledge proofs** - the heaviest client-side computation. Proof generation time depends on the circuit complexity but targets single-digit seconds on modern hardware; [Circuit Cost Analysis](protocol/proof-system.md#circuit-cost-analysis) lists the per-circuit scalar-multiplication totals. - **Track local state** - maintain running commitment openings (value and blinding factor pairs) for the spendable and receiving balances by processing on-chain events. This is comparable to wallet sync in UTXO-based privacy systems (Zcash, Monero). -- **Handle recovery** - if local state is lost, reconstruct balances from on-chain data using the viewing key: fetch the encrypted balance scalar and salt from the most recent spend-boundary event, derive the deterministic blinding factor, and replay the events emitted after the last merge (or compliance clawback) preceding that spend boundary, since only those clear the receiving balance and since merges, spender revocations, and clawbacks in that window fold into the spendable balance too. Recovery requires the master secret plus access to a durable event archive (Stellar RPC retains only 7 days of history); the indexer this archive must satisfy is specified in the companion [Indexing and Off-Chain State Recovery](./INDEXER.md) document. +- **Handle recovery** - if local state is lost, reconstruct both balance openings from the viewing key and the account's event history ([Recovery](protocol/wallet-state.md#recovery)). Recovery requires the master secret plus access to a durable event archive (Stellar RPC retains only a bounded, node-reported window of history); the indexer this archive must satisfy is specified in the companion [Indexing and Off-Chain State Recovery](indexer.md) document. ### For Developers (Integration) @@ -182,7 +182,7 @@ The wallet must: | **Verifier contract** | Validates zero-knowledge proofs on-chain. Stores one verification key per operation type. | | **Auditor contract** | Manages auditor public keys. Shared across tokens. | | **Noir circuits** | Six proof circuits (register, withdraw, transfer, spender transfer, set spender, and the compliance extension's clawback). Written in Noir, compiled to UltraHonk. | -| **Client library** | SDK for wallets: key management, proof generation, event processing, balance tracking, encryption/decryption. Specified in [SDK.md](./SDK.md). | +| **Client library** | SDK for wallets: key management, proof generation, event processing, balance tracking, encryption/decryption. Specified in [SDK](sdk/README.md). | --- @@ -207,12 +207,16 @@ If the wallet is lost or reinstalled on a new device: 1. The account holder restores from the master secret (seed phrase or equivalent). 2. The wallet re-derives the full key hierarchy. -3. The wallet fetches the latest spend-boundary event for the account, reads the encrypted balance scalar and salt from it, recovers the spendable balance opening as of that boundary using the viewing key, then replays the deposits, incoming transfers, merges, spender revocations, and compliance clawbacks emitted after the last merge or clawback preceding that spend boundary — rebuilding the receiving balance and folding the merges, revocations, and clawbacks onto the spendable one. A spend boundary does not clear the receiving balance — only a merge or a clawback does — so the replay starts there rather than at the spend boundary. +3. The wallet reconstructs the spendable and receiving balance openings from the account's event history by the procedure in [Recovery](protocol/wallet-state.md#recovery). -The recovery process is fully deterministic given the master secret and access to the account's event history from that merge onward. Because Stellar RPC retains only the last 7 days of events, recovery from seed alone depends on a durable indexer ([INDEXER.md](./INDEXER.md)) that retains the per-account event log; without one, the on-chain commitments remain visible but their openings cannot be reconstructed. +The recovery process is fully deterministic given the master secret and access to the account's event history. Because Stellar RPC retains only a bounded, node-reported window of events, recovery from seed alone depends on a durable indexer ([Indexing and Off-Chain State Recovery](indexer.md)) that retains the per-account event log; without one, the on-chain commitments remain visible but their openings cannot be reconstructed. ### Edge Cases the Wallet Handles -- **Spam resistance.** Incoming transfers cannot block or delay spending. They modify only the receiving balance; spend proofs reference only the spendable balance, so in-flight proofs remain valid regardless of incoming activity (`DESIGN_cont.md` §9.1). +- **Spam resistance.** Incoming transfers cannot block or delay spending. They modify only the receiving balance; spend proofs reference only the spendable balance, so in-flight proofs remain valid regardless of incoming activity ([Griefing Resistance](protocol/security.md#griefing-resistance)). - **Failed transactions.** If a transaction reverts, the wallet uses a fresh random salt on retry, producing different deterministic randomness. This prevents an observer who saw the reverted transaction from correlating the retried commitment. The salt is a public input so the auditor can still reconstruct state. -- **Spender expiry.** Delegations carry a `live_until_ledger` after which spender transfers are rejected, and an expired delegation still holds its escrow until the owner revokes it (`DESIGN.md` §6.2), so the wallet should surface upcoming expirations and facilitate renewal or revocation. +- **Spender expiry.** Delegations carry a `live_until_ledger` after which spender transfers are rejected, and an expired delegation still holds its escrow until the owner revokes it ([Spender Delegation](protocol/account-state.md#spender-delegation)), so the wallet should surface upcoming expirations and facilitate renewal or revocation. + +--- + +Up: [Index](README.md#companions) · Next: [Introduction](protocol/README.md) diff --git a/packages/tokens/src/confidential/docs/protocol/README.md b/packages/tokens/src/confidential/docs/protocol/README.md new file mode 100644 index 000000000..7ef2f38f2 --- /dev/null +++ b/packages/tokens/src/confidential/docs/protocol/README.md @@ -0,0 +1,63 @@ +# Confidential Token + +## Abstract + +We present a confidential token 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 spender transfers), enabling real-time auditing. Account owners can delegate spending to time-limited spenders via escrowed allowances with derived delegation viewing keys. The system uses 6 Noir circuits, and works seamlessly with Soroban's BN254 host functions, and requires approximately 288 bytes of on-chain storage per account. + +--- + +## Project Documents + +The protocol specification is this file followed by, in order: + +- [Primitives](primitives.md) +- [System Model](system-model.md) +- [Key Hierarchy and Commitment Scheme](keys-and-commitments.md) +- [Wallet State and Recovery](wallet-state.md) +- [Account State](account-state.md) +- [Operations](operations/README.md) +- [Auditing](auditing.md) +- [Security Analysis](security.md) +- [Proof System](proof-system.md) +- [Interface](interface.md) +- [Domain Separation Constants](domain-separators.md) + +The [documentation index](../README.md) lists the companion specifications for compliance, selective disclosure, indexing, and the SDK. + +--- + +## Introduction + +### 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 Soroban contracts suite that provides confidential balances and transfers features on top of SEP-41 tokens. It is not an extension to the fungible token standard; it defines a separate contract that holds tokens on behalf of users and manages encrypted state independently. The wrapping approach 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 underlying token. + +### 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, so 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 spenders (separate addresses) to spend from escrowed allowances, enabling use cases like automated market makers and custodial services without sharing the owner's secret key. + +### 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. **Dual-balance model.** 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. + +Five core Noir/UltraHonk circuits cover registration, withdrawal, confidential transfer, spender transfer, and spender delegation; the optional compliance extension adds a sixth, [clawback](../compliance.md#clawback). 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. + +--- + +Previous: [User Flows Overview](../overview.md) · Up: [Index](../README.md#protocol) · Next: [Primitives](primitives.md) diff --git a/packages/tokens/src/confidential/docs/protocol/account-state.md b/packages/tokens/src/confidential/docs/protocol/account-state.md new file mode 100644 index 000000000..1bd01f2e1 --- /dev/null +++ b/packages/tokens/src/confidential/docs/protocol/account-state.md @@ -0,0 +1,81 @@ +# Account State + +## Account Data Model + +Each registered account stores a `ConfidentialAccount` in persistent storage, keyed by `Address`: + +```rust +ConfidentialAccount { + spending_public_key: BytesN<64>, // Y = sk · H + viewing_public_key: BytesN<64>, // PVK = vk · H + spendable_commitment: BytesN<64>, // C_spend: single Pedersen commitment + receiving_commitment: BytesN<64>, // C_receive: single Pedersen commitment + auditor_id: u32, +} +``` + +### `spending_public_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_commitment` + +The commitment the owner can spend from. Modified by owner-authorized operations and, in a deployment that enables the optional compliance extension, by its clawback ([Contract Flow](../compliance.md#contract-flow)). Encoded as a single Grumpkin affine point (64 bytes). + +### `receiving_commitment` + +Accumulates incoming deposits and transfers via homomorphic addition. The contract adds to this without any proof from the recipient. Reset to $$\mathcal{O}$$ on merge ([Merge](operations/merge.md)) and on clawback ([Contract Flow](../compliance.md#contract-flow)). 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 contract 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 spender 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. + +## Spender Delegation + +Spender delegations are stored in persistent storage, keyed by `(owner, spender)`: + +```rust +SpenderDelegation { + allowance_commitment: BytesN<64>, // Single Pedersen commitment + a_tilde: BytesN<32>, // Poseidon-encrypted allowance scalar + escrowed_dvk: BytesN<64>, // ECDH escrow of dvk_i under spender key + allowance_salt: BytesN<32>, + live_until_ledger: u32, +} +``` + +### `allowance_commitment` + +The spender'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). + +### `a_tilde` + +Poseidon-encrypted allowance scalar: $$\tilde{a} = v\_a + \text{Poseidon}(\delta\_{\text{enc\\\_allow}}, dvk\_i, \sigma\_a)$$. Enables the spender (who holds $$dvk\_i$$ via `escrowed_dvk`) to read the current allowance without DLP when constructing an `SpenderTransfer` witness. The owner can also read it via $$vk \rightarrow dvk\_i$$. It is emitted alongside `allowance_salt` in the `RevokeSpender` event ([Revoke Spender](operations/revoke-spender.md)). + +### `escrowed_dvk` + +$$dvk\_i$$ encrypted under the spender'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 [Grumpkin-BN254 Cycle](primitives.md#grumpkin-bn254-cycle) (same as $$\sigma$$), and it opens the current allowance: the stored `allowance_commitment` and `a_tilde` are both derived under it. It is not the freshness input for a spender transfer's pads -- its replacement is (*Transfer nonce* below). Set by the owner at `set_spender` and replaced by the spender on every `confidential_transfer_from`. 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, and it is emitted in the `RevokeSpender` event ([Revoke Spender](operations/revoke-spender.md)). + +### Transfer nonce + +Unlike $$\sigma$$, which an owner samples afresh for every operation, $$\sigma\_a$$ is stored state: a reverted transfer leaves the delegation entry untouched, so the retry is forced to reuse it. Pads keyed to it would repeat $$r\_e$$ and every mask across the two attempts, and a retry that changed the amount would publish the difference in the clear ([Poseidon2 Hash](primitives.md#poseidon2-hash)). + +A spender transfer avoids this by splitting the salt's two jobs: opening the stored allowance must be deterministic, since the value has to reproduce the randomness the commitment on-chain was built under, while keying pads must be fresh on every attempt. The stored $$\sigma\_a$$ does only the first (O3); every pad the transfer derives, and its ephemeral scalar, absorbs a prover-chosen replacement $$\sigma\_a'$$ instead ([Spender Transfer](operations/spender-transfer.md)). + +### `live_until_ledger` + +The ledger number at which the delegation expires. The delegation is live while `ledger.sequence() <= live_until_ledger` and expired once `ledger.sequence() > live_until_ledger`. Checked on every `confidential_transfer_from`. The entry lives in persistent, not temporary, storage: automatic cleanup would destroy escrowed funds. + +The `(owner, spender)` storage entry holds at most one delegation. `set_spender` ([Set Spender](operations/set-spender.md)) reverts if a delegation already exists for that pair, regardless of whether the existing delegation is past `live_until_ledger`. Expiry only prevents the spender from spending; the escrowed value persists on-chain until `revoke_spender` ([Revoke Spender](operations/revoke-spender.md)) folds it back into the owner's spendable balance. Re-delegating to the same spender therefore requires the sequence: `revoke_spender` then `set_spender`. This rule is what keeps the balance-conservation invariant ([Balance Conservation](security.md#balance-conservation)) well-defined over stored delegations: every delegation is either active, expired-pending-revoke, or absent, and the escrowed value is never silently dropped. + +--- + +Previous: [Wallet State and Recovery](wallet-state.md) · Up: [Index](../README.md#protocol) · Next: [Operations](operations/README.md) diff --git a/packages/tokens/src/confidential/docs/protocol/auditing.md b/packages/tokens/src/confidential/docs/protocol/auditing.md new file mode 100644 index 000000000..d971a3e93 --- /dev/null +++ b/packages/tokens/src/confidential/docs/protocol/auditing.md @@ -0,0 +1,140 @@ +# Auditing + +## 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 ([Poseidon2 Hash](primitives.md#poseidon2-hash)), absorbing the channel's domain tag, the ECDH shared scalar, and $$\sigma$$; the recipient channel squeezes two masks, the sender channel three ([Lane assignment](primitives.md#lane-assignment)). + +**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} = \text{ECDH}(r\_e, K\_{\text{aud,r}}) \qquad \text{(Elliptic Curve Diffie-Hellman)}$$ +$$(m\_{v,r}, m\_{r,r}) = \text{SpongeSqueeze}\_2(\delta\_{\text{aud\\\_r}}, s\_{a,r}, \sigma)$$ +$$\tilde{v}\_{\text{aud,r}} = v\_{\text{transfer}} + m\_{v,r}, \qquad \tilde{r}\_{\text{aud,r}} = r\_{\text{transfer}} + m\_{r,r}$$ + +**Sender's auditor** ($$K\_{\text{aud,s}}$$, from the sender's `auditor_id`) receives the transfer amount, the sender's post-transfer balance, and the sender's post-transfer spendable blinding $$r\_A'$$: + +$$s\_{a,s} = \text{ECDH}(r\_e, K\_{\text{aud,s}}) \qquad \text{(Elliptic Curve Diffie-Hellman)}$$ +$$(m\_{v,s}, m\_{b,s}, m\_{r,s}) = \text{SpongeSqueeze}\_3(\delta\_{\text{aud\\\_s}}, s\_{a,s}, \sigma)$$ +$$\tilde{v}\_{\text{aud,s}} = v\_{\text{transfer}} + m\_{v,s}, \qquad \tilde{b}\_{\text{aud,s}} = (v\_A - v\_{\text{transfer}}) + m\_{b,s}, \qquad \tilde{r}\_{\text{aud,s}} = r\_A' + m\_{r,s}$$ + +The transfer circuit (constraints T\_a1--T\_a9) enforces correct computation. Which auditor an account is bound to is chosen by the account owner at registration ([Registration](operations/register.md)), subject only to existence in the auditor registry unless the deployment gates the selection in its `Hooks::on_register` implementation ([Restrict Auditor Selection](../compliance.md#restrict-auditor-selection)). + +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} = \text{Poseidon}(\delta\_{\text{ecdh}}, S\_{a,s}.x, S\_{a,s}.y)$$ +$$(m\_{v,s}, m\_{b,s}, m\_{r,s}) = \text{SpongeSqueeze}\_3(\delta\_{\text{aud\\\_s}}, s\_{a,s}, \sigma)$$ +$$v\_{\text{transfer}} = \tilde{v}\_{\text{aud,s}} - m\_{v,s}, \qquad v\_{\text{new}} = \tilde{b}\_{\text{aud,s}} - m\_{b,s}, \qquad r\_{\text{new}} = \tilde{r}\_{\text{aud,s}} - m\_{r,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{transfer}}, r\_{\text{transfer}})$$. + +### Recipient-auditor opening capability + +Because the recipient-auditor recovers $$r\_{\text{transfer}}$$ for every inbound transfer, and because deposits add to `receiving_commitment` with $$r = 0$$ ([Deposit](operations/deposit.md)), the recipient-auditor can reconstruct the full Pedersen opening of $$C\_{\text{receive}}$$ between merges: + +$$v\_r = \sum\_i v\_{\text{transfer},i} + \sum\_j a\_j, \qquad r\_r = \sum\_i r\_{\text{transfer},i}$$ + +where $$i$$ ranges over inbound transfers and spender-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 capability is bounded in three ways: + +- **Forward-only.** Only events emitted while the auditor key was active are decryptable. +- **Recipient-channel only.** The reconstruction above yields an opening of `receiving_commitment`: the spend-side blinding $$r\_s = \text{Poseidon}(\delta\_{\text{spend\\\_r}}, vk\_A, \sigma)$$ depends on $$vk\_A$$ and is not derivable from the recipient channel. +- **Reset by merge.** Merge folds $$r\_r$$ into the spendable-balance randomness ($$r\_{\text{spend}}' = r\_s + r\_r$$, [Merge](operations/merge.md)) and emits no checkpoint, so the reconstruction above restarts from the next inbound flow. A clawback resets it the same way ([Wallet and Auditor Consequences](../compliance.md#wallet-and-auditor-consequences)). + +### Sender-auditor opening capability + +The `lane[2]` escrow hands the sender-auditor the *blinding* of the account's post-operation spendable balance directly, without $$vk\_A$$: together with the value in $$\tilde{b}\_{\text{aud,s}}$$ it is a full Pedersen opening of $$C\_{\text{spend}}'$$. It is available at the three checkpoint operations that escrow the spendable blinding -- withdrawal (W\_a5), outgoing transfer (T\_a9), and `set_spender` (S\_a6) -- and is likewise bounded: + +- **Forward-only**, on the same grounds as the recipient side. +- **Maintained across merges.** An account binds a single `auditor_id` ([Account Data Model](account-state.md#account-data-model)), so the key that decrypts the `lane[2]` escrow is the same key that decrypts the recipient channel of every inbound flow to that account. Merge adds both the values and the blindings ($$v\_{\text{spend}}' = v\_s + v\_r$$, $$r\_{\text{spend}}' = r\_s + r\_r$$, [Merge](operations/merge.md)). An auditor that decrypted the inbound flows since the previous merge holds each addend -- $$(v\_{\text{transfer},i}, r\_{\text{transfer},i})$$ from the recipient-channel reconstruction above, and $$(a\_j, 0)$$ from the public deposits -- and carries the escrowed opening forward by the same addition the contract performs. +- **Carried through `revoke_spender`.** The revoke is a public homomorphic fold, $$C\_{\text{spend}}' = C\_{\text{spend}} + C\_a$$, emitting no new checkpoint ([Revoke Spender](operations/revoke-spender.md)). An auditor that decrypted the delegation's last state-changing event holds the opening of the $$C\_a$$ being folded ([Spender Allowance Auditing](#spender-allowance-auditing)) and advances its own opening by the same addition the contract performs. +- **Rotation-scoped.** Both folds above depend on the auditor having decrypted the event that produced the addend; [Decryption capability across rotation](#decryption-capability-across-rotation) states what a rotated key can and cannot open. + +The clawback flow specified in [Clawback](../compliance.md#clawback) draws its witness from these bounded openings. + +## Auditor Visibility Properties + +Per channel and lane, the auditor ciphertexts carry: + +| Channel | `lane[0]` | `lane[1]` | `lane[2]` | +|:--|:--|:--|:--| +| Sender / owner ($$\delta\_{\text{aud\\\_s}}$$) | Transfer amount, or the escrowed amount for `SetSpender`; unused on `Withdraw`, whose amount is public | Sender's post-operation balance, or post-operation allowance for a spender transfer | Post-operation spendable blinding on `Withdraw`, `Transfer`, and `SetSpender`; post-transfer allowance blinding $$r\_a'$$ on `SpenderTransfer` | +| Recipient ($$\delta\_{\text{aud\\\_r}}$$) | Transfer amount | Per-transfer Pedersen randomness $$r\_{\text{transfer}}$$ | — (channel is two-lane) | + +**Transfer amounts.** Both auditors see the transfer amount in real time. The recipient's auditor decrypts $$v\_{\text{transfer}}$$ 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{transfer}})$$ from $$\tilde{b}\_{\text{aud,s}}$$ and the matching blinding $$r\_A'$$ from $$\tilde{r}\_{\text{aud,s}}$$ (constraints T\_a5--T\_a9). +- **Withdrawal**: auditor decrypts post-withdrawal balance $$(v - a)$$ from $$\tilde{b}\_{\text{aud,s}}$$ and the matching blinding $$r'$$ from $$\tilde{r}\_{\text{aud,s}}$$ (constraints W\_a1--W\_a5). The withdrawal amount $$a$$ is also visible as a public input. +- **Set spender**: auditor decrypts escrowed amount $$v\_a$$ from $$\tilde{v}\_{\text{aud,s}}$$, post-escrow balance $$(v - v\_a)$$ from $$\tilde{b}\_{\text{aud,s}}$$, and the matching blinding $$r'$$ from $$\tilde{r}\_{\text{aud,s}}$$ (constraints S\_a1--S\_a6). + +The recipient's auditor does not see the sender's balance in any of these operations. + +**Per-transfer Pedersen randomness (recipient-auditor).** Beyond the transfer amount, the recipient's auditor also decrypts the per-transfer Pedersen blinding $$r\_{\text{transfer}}$$ from $$\tilde{r}\_{\text{aud,r}}$$ on every confidential transfer and spender-transfer. The sender's auditor does not see $$r\_{\text{transfer}}$$. + +**Key rotation.** When the auditor contract sets a new key under the account's `auditor_id` ([Auditor Key Management and Rotation](#auditor-key-management-and-rotation)), the new key reads the balance checkpoint at the next owner-initiated operation with no event replay: the checkpoint is self-contained, depending only on the auditor's ECDH secret key and the published $$(R\_e, \sigma)$$. Reading one checkpoint is not the same as holding the account's accumulated opening; [Auditor Key Management and Rotation](#auditor-key-management-and-rotation) states what a rotated key can open. Note that `auditor_id` itself is immutable per account ([Account Data Model](account-state.md#account-data-model)); only the key under that `auditor_id` rotates. + +## 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 ([Components](system-model.md#components), [On-Chain Point Arithmetic](proof-system.md#on-chain-point-arithmetic)). Each `auditor_id` MAY maintain a sequence of versions, each carrying its activation ledger, in which case rotation appends a new entry rather than overwriting the previous one. The reference registry shipped in this repository takes the simpler form: it keeps a single current key per `auditor_id`, which `rotate_key` overwrites in place; a versioned, activation-ledger registry is an optional production target. + +When building public inputs for any operation that produces auditor ciphertexts (transfers, withdrawals, `set_spender`), the contract fetches the relevant auditor keys for the recipient's and/or sender's `auditor_id`. The contract passes the full Grumpkin point as a public input; the circuit constrains the ECDH ciphertexts against that exact point. The contract 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 contract 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 spender) reconstructs the proof against the new $$K\_{\text{aud}}$$ and resubmits. The rejection is benign: the contract's spendable balance, receiving balance, and delegation state are unchanged by the reverted call, the operation's salt is freshly sampled on retry ([Revert Safety](security.md#revert-safety)), 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 resolves the version from its own rotation records (with a versioned activation-ledger registry, the auditor instead 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. + +### Decryption capability across rotation + +A key decrypts an auditor ciphertext exactly when it was the active key at the moment that ciphertext was produced. Rotation therefore affects the two holders asymmetrically: + +- **Retired key.** Its holder keeps what it already opened. Rotation does not revoke a held opening, and the proofless folds advance it exactly as [Per-Transfer Auditor Ciphertexts](#per-transfer-auditor-ciphertexts) specifies. +- **New key.** Its holder starts with nothing and re-anchors per commitment: at the next checkpoint operation for $$C\_{\text{spend}}$$ ([Auditor Visibility Properties](#auditor-visibility-properties)), and at the delegation's next state-changing operation for $$C\_a$$ ([Spender Allowance Auditing](#spender-allowance-auditing)). + +Each proofless fold consumes an addend produced by an earlier event, and a holder that bootstrapped from the new key alone cannot open an addend that predates the rotation. Such a holder loses its opening at every fold that consumes one, and re-anchors only at the next checkpoint operation following it. A merge's addends reach back only as far as the previous merge; a delegation's last state change is bounded only by its entry TTL. + +## Spender Transfer Auditing + +Each spender transfer produces auditor ciphertexts under two keys (constraints O\_a1--O\_a9), 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{transfer}} = \tilde{v}\_{\text{aud,r}} - m\_{v,r}, \qquad r\_{\text{transfer}} = \tilde{r}\_{\text{aud,r}} - m\_{r,r}$$ + +The owner's auditor decrypts the transfer amount, the post-transfer allowance, and the *new* allowance blinding $$r\_a'$$, the one O11 commits $$C\_a'$$ under (O\_a9): + +$$(m\_{v,s}, m\_{a,s}, m\_{r,s}) = \text{SpongeSqueeze}\_3(\delta\_{\text{aud\\\_s}}, s\_{a,s}, \sigma\_a')$$ +$$v\_{\text{transfer}} = \tilde{v}\_{\text{aud,s}} - m\_{v,s}, \qquad v\_a' = \tilde{a}\_{\text{aud,s}} - m\_{a,s}, \qquad r\_a' = \tilde{r}\_{\text{aud,s}} - m\_{r,s}$$ + +where $$s\_{a,r}$$, $$s\_{a,s}$$, and $$\sigma\_a'$$ are recovered from the event as in [Per-Transfer Auditor Ciphertexts](#per-transfer-auditor-ciphertexts). The recipient-auditor opening capability stated in [Per-Transfer Auditor Ciphertexts](#per-transfer-auditor-ciphertexts) extends to spender-transfer inbound flows: $$r\_{\text{transfer}}$$ from spender-transfers contributes to $$r\_r$$ in $$C\_{\text{receive}}$$ identically to owner-transfer inbound flows. + +$$(\tilde{a}\_{\text{aud,s}}, \tilde{r}\_{\text{aud,s}})$$ open the post-transfer $$C\_a'$$, the state left on-chain. + +## Spender Allowance Auditing + +The auditor tracks each allowance's current value from the per-event ciphertexts of its two state-changing operations, `set_spender` ([Auditor Visibility Properties](#auditor-visibility-properties)) and `confidential_transfer_from` ([Spender Transfer Auditing](#spender-transfer-auditing)). + +### Allowance opening + +The owner's auditor also receives the *blinding* of the allowance commitment each event writes: $$r\_a$$ under $$\delta\_{\text{esc\\\_allow\\\_r\\\_aud}}$$ at `set_spender` (S14, below) and $$r\_a'$$ in `lane[2]` at every spender transfer (O\_a9, [Spender Transfer Auditing](#spender-transfer-auditing)). Paired with the value that event already publishes -- $$\tilde{v}\_{\text{aud,s}}$$ on `SetSpender`, $$\tilde{a}\_{\text{aud,s}}$$ on `SpenderTransfer` -- that is the full Pedersen opening of the $$C\_a$$ left on-chain, reconstructed from the event alone with no storage read. Unlike the spendable side there is no merge to fold in, since a delegation's only state transitions are the events themselves. This is what `revoke_spender` folds against: it publishes no ciphertext of its own ([Revoke Spender](operations/revoke-spender.md)), and the auditor opens the $$C\_a$$ from whichever event last wrote it. + +### Auditor-side allowance-blinding escrow + +At `set_spender` the owner escrows $$r\_a$$ to its own auditor. A blinding is per state, so one leaked escrow ciphertext opens one allowance state. + +$$\tilde{r}\_{a,\text{aud,s}} = \text{Poseidon}(\delta\_{\text{esc\\\_allow\\\_r\\\_aud}}, s\_{a,s}, \text{op}\_i) + r\_a$$ + +reusing the S\_a2 shared scalar $$s\_{a,s} = \text{ECDH}(r\_e, K\_{\text{aud,s}})$$ rather than opening a new ECDH channel; [Circuit Cost Analysis](proof-system.md#circuit-cost-analysis) gives the cost. The auditor recovers $$s\_{a,s}$$ from $$k\_{\text{aud,s}}$$ and the event's $$R\_e$$, and $$\text{op}\_i$$ from the event's `spender` topic, then subtracts. Because the escrowed value is pinned to S6, the opening the auditor holds necessarily matches the $$C\_a$$ the same proof wrote. + +It is a single-output pad because `lane[2]` of this channel is taken by the spendable blinding (S\_a6). Distinct tags separate the two despite the shared scalar, each confined to one sponge mode as [Mode exclusivity](primitives.md#mode-exclusivity) requires. Its pad freshness is that of $$r\_e$$ ([Why reusing `r_e` is safe](keys-and-commitments.md#why-reusing-r_e-is-safe)). + +### Archive dependence + +The auditor must have *observed* the event: the escrowed blinding lives nowhere in contract storage, so an auditor that missed a delegation event cannot recover that opening from `a_tilde` and $$\sigma\_a$$ the way a $$dvk\_i$$ holder could. [Wallet State and Recovery](wallet-state.md) already makes a durable event archive normative; [Auditor recovery](../indexer.md#auditor-recovery) states the operational consequences for auditor clients. + +--- + +Previous: [Revoke Spender](operations/revoke-spender.md) · Up: [Index](../README.md#protocol) · Next: [Security Analysis](security.md) diff --git a/packages/tokens/src/confidential/docs/protocol/domain-separators.md b/packages/tokens/src/confidential/docs/protocol/domain-separators.md new file mode 100644 index 000000000..6c2ded796 --- /dev/null +++ b/packages/tokens/src/confidential/docs/protocol/domain-separators.md @@ -0,0 +1,33 @@ +# 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 ([Governance and Upgradeability](system-model.md#governance-and-upgradeability), [Structured Reference String](proof-system.md#structured-reference-string)). + +| $$\delta$$ | Value | Absorbed by | Context | +|:---|:---:|:---|:---| +| $$\delta\_{\text{addr}}$$ | 1 | Contract | Soroban Address compression into a single $$\mathbb{F}\_r$$ Field ([Address-to-Field Encoding](primitives.md#address-to-field-encoding)) | +| $$\delta\_{\text{vk}}$$ | 2 | Circuit | Viewing key derivation from spending key and contract address ([Viewing Key](keys-and-commitments.md#viewing-key)) | +| $$\delta\_{\text{dvk}}$$ | 3 | Circuit | Delegation viewing key derivation ([Delegation Viewing Key](keys-and-commitments.md#delegation-viewing-key)) | +| $$\delta\_{\text{spend\\\_r}}$$ | 4 | Circuit | Deterministic randomness for spendable balance commitments ([Update rules](wallet-state.md#update-rules)) | +| $$\delta\_{\text{transfer\\\_blind}}$$ | 5 | Circuit | ECDH-derived transfer blinding factor ([ECDH-Derived Blinding](keys-and-commitments.md#ecdh-derived-blinding) Definition 1) | +| $$\delta\_{\text{transfer\\\_amount}}$$ | 6 | Circuit | ECDH-derived transfer amount encryption ([ECDH-Derived Blinding](keys-and-commitments.md#ecdh-derived-blinding) Definition 1) | +| $$\delta\_{\text{enc\\\_bal}}$$ | 7 | Circuit | Encrypted balance scalar masking ([Encrypted Balance Scalar](keys-and-commitments.md#encrypted-balance-scalar)) | +| $$\delta\_{\text{enc\\\_allow}}$$ | 8 | Circuit | Encrypted allowance scalar masking ([`a_tilde`](account-state.md#a_tilde)) | +| $$\delta\_{\text{allow\\\_r}}$$ | 9 | Circuit | Deterministic randomness for spender allowance commitments ([`allowance_commitment`](account-state.md#allowance_commitment)) | +| $$\delta\_{\text{esc\\\_dvk}}$$ | 10 | Circuit | Delegation key escrow (spender ECDH) ([Delegation Key Escrow](operations/set-spender.md#delegation-key-escrow)) | +| $$\delta\_{\text{aud\\\_s}}$$ | 11 | Circuit | Sender / owner-auditor channel sponge, three lanes ([Poseidon2 Hash](primitives.md#poseidon2-hash), [Per-Transfer Auditor Ciphertexts](auditing.md#per-transfer-auditor-ciphertexts)) | +| $$\delta\_{\text{aud\\\_r}}$$ | 12 | Circuit | Recipient-auditor channel sponge, two lanes ([Poseidon2 Hash](primitives.md#poseidon2-hash), [Per-Transfer Auditor Ciphertexts](auditing.md#per-transfer-auditor-ciphertexts)) | +| $$\delta\_{\text{ecdh}}$$ | 13 | Circuit | ECDH shared-secret scalar extraction ([Elliptic Curve Diffie-Hellman](primitives.md#elliptic-curve-diffie-hellman)) | +| $$\delta\_{\text{eph}}$$ | 14 | Off-circuit | Deterministic ephemeral-scalar derivation ([ECDH-Derived Blinding](keys-and-commitments.md#ecdh-derived-blinding)) | +| $$\delta\_{\text{disc\\\_bind}}$$ | 15 | Off-chain | Disclosure ciphertext to the disclosure recipient, aggregate variant ([Aggregate Disclosures](../selective-disclosure/circuits/aggregate.md)) | +| $$\delta\_{\text{disc}}$$ | 16 | Off-chain | Disclosure ciphertext to the disclosure recipient ([Disclosure Ciphertext to Recipient](../selective-disclosure/protocol.md#disclosure-ciphertext-to-recipient)) | +| $$\delta\_{\text{esc\\\_allow\\\_r\\\_aud}}$$ | 17 | Circuit | Allowance-blinding escrow to the owner's auditor (S14, [Spender Allowance Auditing](auditing.md#spender-allowance-auditing)); fixture `circuits/lib/testdata/encrypt_esc_allow_r_auditor.json` | + +This table assigns all seventeen values; no other document assigns them. Tag 1 is absorbed by the contract rather than by a circuit — it derives $$\text{addr\\\_f}$$ and $$\text{op}\_i$$ on-chain and the circuits receive them as opaque public inputs ([Usage sites](primitives.md#usage-sites)) — and is part of the on-chain wire contract all the same. Tags 14–16 are never absorbed inside a core circuit or on-chain — 14 is derived off-circuit ([ECDH-Derived Blinding](keys-and-commitments.md#ecdh-derived-blinding) makes its derivation normative for every operation whose originator holds a viewing key), and 15–16 belong to the off-chain selective-disclosure layer ([Domain Separators](../selective-disclosure/README.md#domain-separators)) — so they are not part of the on-chain wire contract, but all three are part of the cross-client contract because two wallets serving the same account must agree on them ([Vectors this specification requires](../sdk/conformance.md#vectors-this-specification-requires)); `circuits/lib/src/lib.nr` accordingly implements 1–13 and 17. All seventeen values MUST still be distinct and each MUST be confined to a single sponge mode, so a deployment treats them as one namespace. Tag 17's construction is specified in [Spender Allowance Auditing](auditing.md#spender-allowance-auditing). + +**Provenance.** Sequential small integers are the simplest assignment that satisfies the requirement of *distinctness* across all Poseidon2 invocations in this protocol -- [Threat Model](system-model.md#threat-model) models Poseidon2 as a pseudorandom function, so evaluations whose leading input differs are computationally independent. Distinctness alone is not sufficient: each tag must also be confined to a single sponge mode, since the multi-lane forms of [Poseidon2 Hash](primitives.md#poseidon2-hash) share `lane[0]` with the single-output form on the same inputs; $$\delta\_{\text{aud\\\_s}}$$ is the one tag ever squeezed three-wide, and the widths it is read at agree on their shared lanes ([Mode exclusivity](primitives.md#mode-exclusivity)). 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. + +**Cross-protocol collision.** Future protocols that share Grumpkin / BN254 / Poseidon2 with this protocol -- 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/v1:X"}))$$, but this is a deployment-time choice that must be applied uniformly and disclosed in the deployment's circuit-binding documentation. + +--- + +Previous: [Interface](interface.md) · Up: [Index](../README.md#protocol) diff --git a/packages/tokens/src/confidential/docs/protocol/interface.md b/packages/tokens/src/confidential/docs/protocol/interface.md new file mode 100644 index 000000000..e3ab112bc --- /dev/null +++ b/packages/tokens/src/confidential/docs/protocol/interface.md @@ -0,0 +1,125 @@ +# 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 contract 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` keyed by field-name symbols in canonical sorted order, which the host enforces; unnamed fields and tuple-enum variants as an `ScVec` in declaration order. 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 ConfidentialToken { + 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, spender: Address, + from: Address, to: Address, data: Bytes); + + fn set_spender(e: Env, account: Address, spender: Address, + live_until_ledger: u32, data: Bytes); + + fn revoke_spender(e: Env, account: Address, spender: Address); + + fn confidential_balance(e: Env, account: Address) -> ConfidentialAccount; + + fn is_spender(e: Env, account: Address, spender: Address) -> bool; + + fn get_spender_delegation(e: Env, account: Address, spender: Address) -> SpenderDelegation; +} +``` + +A deployment that enables the optional compliance extension adds `compliance: Option` to this constructor and the freeze, configuration-rotation, read, and — when it opts into `ConfidentialClawback` — seizure entry points of [Interface Summary](../compliance.md#interface-summary) to this interface. The admin-gated ones are authorized by the deployment's own access-control module. + +**Hooks.** Every entry point invokes the deployment's `Hooks` implementation after authorization and payload decoding and before the storage-layer operation. Hooks for operations that carry `data` receive the decoded payload by reference; the proofless `on_deposit`, `on_merge`, and `on_revoke_spender` receive none. The two seizure entry points, `clawback` and `force_revoke_spender`, invoke no hook, since a gating hook rejects exactly their precondition — a frozen target ([Clawback](../compliance.md#clawback)). + +This table is authoritative: every entry is exactly the set of prover-supplied public inputs from the corresponding [Operations](operations/README.md) operation (the contract loads the remaining public inputs from trusted state per [Public Input Sources](operations/README.md#public-input-sources)), plus the `proof` blob. Names map directly to the [Operations](operations/README.md) symbols. + +| Operation | `data` contents | +|:---|:---| +| `register` | $$Y$$, $$\text{PVK}$$, `proof` | +| `withdraw` | $$C\_{\text{spend}}'$$, $$\tilde{b}$$, $$R\_e$$, $$\sigma$$, $$\tilde{b}\_{\text{aud,s}}$$, $$\tilde{r}\_{\text{aud,s}}$$, `proof` | +| `confidential_transfer` | $$C\_{\text{spend}}'$$, $$C\_{\text{transfer}}$$, $$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}}$$, $$\tilde{r}\_{\text{aud,s}}$$, `proof` | +| `confidential_transfer_from` | $$C\_a'$$, $$C\_{\text{transfer}}$$, $$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}}$$, $$\tilde{r}\_{\text{aud,s}}$$, `proof` | +| `set_spender` | $$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}}$$, $$\tilde{r}\_{\text{aud,s}}$$, $$\tilde{r}\_{a,\text{aud,s}}$$, `proof` | + +For `confidential_transfer_from`, the stored allowance salt $$\sigma\_a$$ is **not** carried in `data`: the contract loads it from the `(from, spender)` delegation entry ([Spender Transfer](operations/spender-transfer.md) public-input table). Only the prover-chosen replacement $$\sigma\_a'$$ travels in `data`. It is bound as this transfer's channel nonce (O7, O9, O\_a2, O\_a6), as the new allowance state's salt (O10, O12), and against the salt it replaces (O14); it is then written back to the delegation entry as the new `allowance_salt` and emitted in the event ([Transfer nonce](account-state.md#transfer-nonce)). This keeps the trust-boundary rule of [Public Input Sources](operations/README.md#public-input-sources) intact: caller-controlled bytes never overwrite the live $$\sigma\_a$$ used to verify the proof. `set_spender`, by contrast, has no prior delegation entry to load from, so its $$\sigma\_a$$ is prover-supplied and bound by S6. + +## 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 contract 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(spender, from, to, data)` | `spender` (not `from`) | +| `set_spender(account, spender, live_until_ledger, data)` | `account` | +| `revoke_spender(account, spender)` | `account` | +| `confidential_balance`, `is_spender`, `get_spender_delegation` | 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` storage entry. + +**`set_spender` rejects replacement.** It reverts if a non-revoked delegation already exists for `(account, spender)` -- see [Spender Delegation](account-state.md#spender-delegation). + +**`confidential_transfer_from` is spender-authorized.** The owner's authorization was granted out-of-band at `set_spender` and persists in the on-chain delegation entry until expiry or revocation. The spender's `require_auth()` binds `from`, `to`, and `data`. + +## 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}}$$, $$\tilde{r}\_{\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}}$$, $$\tilde{r}\_{\text{aud,s}}$$ | +| `SpenderTransfer` | `spender`, `from`, `to`, $$R\_e$$, $$\tilde{v}$$, $$\sigma\_a'$$, $$\tilde{v}\_{\text{aud,r}}$$, $$\tilde{r}\_{\text{aud,r}}$$, $$\tilde{v}\_{\text{aud,s}}$$, $$\tilde{a}\_{\text{aud,s}}$$, $$\tilde{r}\_{\text{aud,s}}$$ | +| `SetSpender` | `account`, `spender`, `live_until_ledger`, $$R\_e$$, $$\sigma$$, $$\tilde{b}$$, $$\tilde{v}\_{\text{aud,s}}$$, $$\tilde{b}\_{\text{aud,s}}$$, $$\tilde{r}\_{\text{aud,s}}$$, $$\tilde{r}\_{a,\text{aud,s}}$$ | +| `RevokeSpender` | `account`, `spender`, $$\tilde{a}$$, $$\sigma\_a$$ | +| `Clawback` | `account`, `amount`, `destination` | + +Amount fields in `Deposit`, `Withdraw`, and `Clawback` are typed `i128`, matching SEP-41. + +`Clawback` is emitted only by deployments that enable the optional compliance extension ([Clawback](../compliance.md#clawback)). + +**Usage by consumers:** + +- **Recipient wallet**: processes `Transfer` and `SpenderTransfer` events using $$(R\_e, \tilde{v}, \sigma)$$ -- with $$\sigma\_a'$$ in place of $$\sigma$$ for `SpenderTransfer` -- to derive $$v\_{\text{transfer}}$$ and $$r\_{\text{transfer}}$$ ([ECDH-Derived Blinding](keys-and-commitments.md#ecdh-derived-blinding)). +- **Owner wallet**: processes all events for recovery ([Wallet State and Recovery](wallet-state.md)). The $$(\tilde{b}, \sigma)$$ pair from the most recent checkpoint event (`Withdraw`, `Transfer` as sender, `SetSpender`) anchors $$W\_{\text{spend}}$$; `Merge`, `RevokeSpender`, and `Clawback` are folded into it during replay. +- **Auditor**: processes events containing $$R\_e$$ to compute ECDH shared secrets and decrypt amounts, balance checkpoints, and the escrowed blindings ([Per-Transfer Auditor Ciphertexts](auditing.md#per-transfer-auditor-ciphertexts), [Auditor Visibility Properties](auditing.md#auditor-visibility-properties), [Spender Allowance Auditing](auditing.md#spender-allowance-auditing)); folds `Merge`, `RevokeSpender`, and `Clawback` into its accumulators as [Per-Transfer Auditor Ciphertexts](auditing.md#per-transfer-auditor-ciphertexts) and [Wallet and Auditor Consequences](../compliance.md#wallet-and-auditor-consequences) specify. +- **Indexer**: reconciles the pooled balance against the sum of claims ([Balance Conservation](security.md#balance-conservation), [Indexing and Off-Chain State Recovery](../indexer.md)). + +## Read Methods + +**`confidential_balance(account) -> ConfidentialAccount`.** Returns the `ConfidentialAccount` struct for the given account ([Account Data Model](account-state.md#account-data-model)), i.e. the tuple `(spending_public_key, viewing_public_key, spendable_commitment, receiving_commitment, 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 ([Consistency check](wallet-state.md#consistency-check)). + +**`is_spender(account, spender) -> bool`.** Returns `true` iff a delegation entry exists for `(account, spender)` **and** `ledger.sequence() <= live_until_ledger`. Returns `false` for: + +- pairs with no delegation entry, +- pairs whose entry has `ledger.sequence() > live_until_ledger` (expired-but-not-yet-revoked: the spender can no longer spend, while the escrowed value stays on-chain until `revoke_spender` reclaims it, [Spender Delegation](account-state.md#spender-delegation)), +- pairs whose entry was revoked (deleted) by `revoke_spender`. + +The function returns the *spending-authority* state, not the *escrow-existence* state. Consumers that need to distinguish "no delegation" from "expired delegation" inspect `get_spender_delegation` (below) or replay `SetSpender` / `RevokeSpender` events. + +**`get_spender_delegation(account, spender) -> SpenderDelegation`.** Returns the `SpenderDelegation` struct ([Spender Delegation](account-state.md#spender-delegation)) for the `(account, spender)` pair, i.e. `(allowance_commitment, a_tilde, escrowed_dvk, allowance_salt, live_until_ledger)`. Reverts if no delegation entry exists for the pair. Unlike `is_spender`, this surfaces the raw on-chain delegation state without applying the expiry filter, so callers can separate an absent delegation (revert) from an active one and from an expired-but-not-yet-revoked one, by comparing `live_until_ledger` against `ledger.sequence()` as `is_spender` does. Primary consumers: + +- **Spender wallet:** fetches `allowance_commitment`, `a_tilde`, `escrowed_dvk`, and `allowance_salt` to recover $$dvk\_i$$ via [Delegation Key Escrow](operations/set-spender.md#delegation-key-escrow) 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_spender`, 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 ([Consistency check](wallet-state.md#consistency-check)). + +The auditor's allowance tracking does **not** use this method: per-event allowance ciphertexts ([Spender Allowance Auditing](auditing.md#spender-allowance-auditing)) are the auditor's data path. `a_tilde` is keyed to $$dvk\_i$$ and is unreadable without it, the owner's auditor included ([Spender Allowance Auditing](auditing.md#spender-allowance-auditing)). + +--- + +Previous: [Proof System](proof-system.md) · Up: [Index](../README.md#protocol) · Next: [Domain Separation Constants](domain-separators.md) diff --git a/packages/tokens/src/confidential/docs/protocol/keys-and-commitments.md b/packages/tokens/src/confidential/docs/protocol/keys-and-commitments.md new file mode 100644 index 000000000..8b0ce645c --- /dev/null +++ b/packages/tokens/src/confidential/docs/protocol/keys-and-commitments.md @@ -0,0 +1,110 @@ +# Key Hierarchy and Commitment Scheme + +## Key Hierarchy + +All keys derive from a single spending secret $$sk \in \mathbb{F}\_r$$. + +### 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, spender delegations, and merges. + +### Viewing Key + +$$vk = \text{Poseidon}(\delta\_{\text{vk}}, sk, \text{addr\\\_f})$$ + +A scalar in $$\mathbb{F}\_r$$, unique per $$(sk, \text{addr\\\_f})$$ pair. Enables balance decryption without spending authority, and — through the ephemeral-scalar derivation of [ECDH-Derived Blinding](#ecdh-derived-blinding) — reconstruction of the Pedersen openings of transfers the account originated; [Privacy Properties](security.md#privacy-properties) states the full capability of a compromised $$vk$$. Cannot recover $$sk$$ (Poseidon preimage resistance). Because $$\text{addr\\\_f}$$ is bound into the derivation, proofs that constrain $$vk$$ (R2, W2, T2, S2) are inherently bound to the contract, eliminating the need for explicit per-circuit context binding. + +### 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{addr\\\_f})$$ and $$Y = sk \cdot H$$, preventing a user from registering an unrelated $$\text{PVK}$$. + +### Delegation Viewing Key + +For spender $$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 spender's allowance state in this contract's context ($$vk$$ is contract-specific, [Viewing Key](#viewing-key)). +- $$dvk\_i$$ cannot recover $$vk$$ (preimage resistance). +- Different $$(vk, \text{op}\_i)$$ tuples yield independent keys. + +--- + +## 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{transfer}}$$ | 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{transfer}}$$ | Transfer amount (private) | +| $$r\_{\text{transfer}}$$ | ECDH-derived blinding factor for $$C\_{\text{transfer}}$$ | +| $$W\_{\text{spend}}, W\_{\text{receive}}$$ | Wallet-side accumulators: $$(v, r)$$ pairs tracking commitment openings | +| $$r\_e$$ | Ephemeral scalar, derived per operation from the $$vk$$ and the operation salt ([ECDH-Derived Blinding](#ecdh-derived-blinding)) | +| $$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 shared secret $$\text{ECDH}(r\_e, \text{PVK}\_B) = \text{Poseidon}(\delta\_{\text{ecdh}}, S.x, S.y)$$ ([Elliptic Curve Diffie-Hellman](primitives.md#elliptic-curve-diffie-hellman)) | +| $$\tilde{v}$$ | Encrypted transfer amount: $$v\_{\text{transfer}} + \text{Poseidon}(\delta\_{\text{transfer\\\_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 [Grumpkin-BN254 Cycle](primitives.md#grumpkin-bn254-cycle); canonical $$\mathbb{F}\_r$$ representative encoded as `BytesN<32>` | + +### 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 ([Wallet State and Recovery](wallet-state.md)) and the auditor reads an encrypted scalar ([Per-Transfer Auditor Ciphertexts](auditing.md#per-transfer-auditor-ciphertexts)). + +### 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 $$\sigma \in \mathbb{F}\_r$$ via the rejection sampling procedure ([Grumpkin-BN254 Cycle](primitives.md#grumpkin-bn254-cycle)), then computes: + +$$r\_e = \text{Poseidon}(\delta\_{\text{eph}}, vk\_A, \sigma)$$ +$$R\_e = r\_e \cdot H$$ +$$S = r\_e \cdot \text{PVK}\_B$$ +$$s = \text{Poseidon}(\delta\_{\text{ecdh}}, S.x, S.y) \in \mathbb{F}\_r \qquad \text{(Elliptic Curve Diffie-Hellman)}$$ +$$r\_{\text{transfer}} = \text{Poseidon}(\delta\_{\text{transfer\\\_blind}}, s, \sigma)$$ +$$\tilde{v} = v\_{\text{transfer}} + \text{Poseidon}(\delta\_{\text{transfer\\\_amount}}, s, \sigma)$$ + +where $$v\_{\text{transfer}}$$ is the transfer amount. The transfer commitment is $$C\_{\text{transfer}} = \text{Com}(v\_{\text{transfer}}, r\_{\text{transfer}})$$. 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{transfer}}$$ and $$r\_{\text{transfer}}$$ 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{transfer}}$$ and decrypt $$v\_{\text{transfer}} = \tilde{v} - \text{Poseidon}(\delta\_{\text{transfer\\\_amount}}, s, \sigma)$$, provided they know $$\sigma$$ emitted with the event. The auditor decrypts the transfer amount via a separate ECDH channel ([Per-Transfer Auditor Ciphertexts](auditing.md#per-transfer-auditor-ciphertexts)). + +**Deterministic ephemeral scalar.** $$r\_e$$ is derived from the originator's own viewing key and the operation salt. Every operation that has an ephemeral derives it this way: `Transfer`, `Withdraw`, and `SetSpender` from the owner's viewing key, `SpenderTransfer` from the spender's own ([Spender Transfer](operations/spender-transfer.md)). The derivation MUST be re-attempted with a fresh salt in the negligible case that it yields zero. + +Because $$\sigma$$ is published in the event and $$vk$$ is held by the originator, the derivation lets the originator recompute $$r\_e$$ for any past transfer from the event alone, which is what makes sender-side selective disclosure possible with no per-transfer wallet state ([D-sender](../selective-disclosure/circuits/d-sender.md)). A transfer whose $$r\_e$$ was sampled and not retained is permanently undisclosable by its sender. No circuit constrains $$r\_e$$ beyond $$R\_e = r\_e \cdot H$$ and $$r\_e \neq 0$$. + +#### Why reusing `r_e` is safe + +A single $$r\_e$$ keys every ECDH channel an operation opens: the recipient's ($$\text{PVK}\_B$$), both auditors' ($$K\_{\text{aud,r}}$$ and $$K\_{\text{aud,s}}$$, [Per-Transfer Auditor Ciphertexts](auditing.md#per-transfer-auditor-ciphertexts)), and, at `set_spender`, the $$dvk\_i$$ escrow to the spender ($$Y\_{\text{op}}$$, [Delegation Key Escrow](operations/set-spender.md#delegation-key-escrow)). Three properties separate them. *Distinct shared scalars*: those four counterparty points are independent Grumpkin points, none derivable from another. *Distinct domains*: each channel absorbs its own tag ([Domain Separation Constants](domain-separators.md) enumerates them), so masks across channels are independent under the PRF assumption on Poseidon ([Threat Model](system-model.md#threat-model)). *Fresh per-operation nonce*: every channel sponge re-absorbs the operation salt, which [Pad freshness](primitives.md#pad-freshness) requires to be fresh. Together they close the standard ECDH key-reuse attack surface. One derivation stands outside the argument: the auditor-side allowance-blinding escrow (S14, $$\delta\_{\text{esc\\\_allow\\\_r\\\_aud}}$$) opens no channel of its own, reusing the sender-auditor shared scalar and absorbing the per-delegation constant $$\text{op}\_i$$ rather than a nonce. Only the distinct-domain leg holds there in its own right; its pad freshness is inherited from $$r\_e$$, hence from the salt ([Spender Allowance Auditing](auditing.md#spender-allowance-auditing)). + +### Anti-Poisoning Constraint + +The transfer circuit enforces that $$C\_{\text{transfer}}$$ was constructed using the ECDH-derived $$r\_{\text{transfer}}$$: + +$$C\_{\text{transfer}} = v\_{\text{transfer}} \cdot G + r\_{\text{transfer}} \cdot H \quad \text{where} \quad r\_{\text{transfer}} = \text{Poseidon}(\delta\_{\text{transfer\\\_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. + +### Encrypted Balance Scalar + +Owner-initiated operations (transfers, withdrawals) produce a new spendable balance commitment with deterministic randomness ([Operations](operations/README.md)). 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 ([Event Schema](interface.md#event-schema)) 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 ([Wallet State and Recovery](wallet-state.md)); auditors do not hold $$vk$$ and instead read balances via per-transfer ECDH ciphertexts ([Per-Transfer Auditor Ciphertexts](auditing.md#per-transfer-auditor-ciphertexts)). The circuit enforces consistency between $$\tilde{b}$$ and the committed value in $$C\_{\text{spend}}$$. + +--- + +Previous: [System Model](system-model.md) · Up: [Index](../README.md#protocol) · Next: [Wallet State and Recovery](wallet-state.md) diff --git a/packages/tokens/src/confidential/docs/protocol/operations/README.md b/packages/tokens/src/confidential/docs/protocol/operations/README.md new file mode 100644 index 000000000..de39c17c1 --- /dev/null +++ b/packages/tokens/src/confidential/docs/protocol/operations/README.md @@ -0,0 +1,30 @@ +# Operations + +Table order is reading order. + +| File | Owns | +|:---|:---| +| [register.md](register.md) | Registration | +| [deposit.md](deposit.md) | Deposit | +| [merge.md](merge.md) | Merge | +| [withdraw.md](withdraw.md) | Withdrawal | +| [transfer.md](transfer.md) | Confidential transfer, recipient processing | +| [set-spender.md](set-spender.md) | Set spender, delegation key escrow | +| [spender-transfer.md](spender-transfer.md) | Spender transfer, contract binding | +| [revoke-spender.md](revoke-spender.md) | Revoke spender | + +## 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, contract, or auditor those values are supposed to describe. Binding each public input to the correct provenance is the contract's responsibility. If the contract takes a value that should come from trusted state (e.g. the sender's `spending_public_key`) and instead reads it from caller-controlled invocation inputs, a soundly proven statement can verify for the wrong account. + +Each operation's file lists, for every public input, where the contract loads it from -- persistent account storage, the delegation entry, the contract'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 contract itself. The contract MUST NOT accept these values from the caller's `data` payload. Only invocation arguments (which are bound under `require_auth()` per [Authorization Model](../interface.md#authorization-model)) 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. + +## Owner Operations with Active Spenders + +Owner transfers, withdrawals, and merges proceed identically to the no-spender case. Spender allowances are independently escrowed - no synchronization is needed. The owner's spendable balance and spender allowances are fully isolated. + +--- + +Previous: [Account State](../account-state.md) · Up: [Index](../../README.md#protocol) · Next: [Registration](register.md) diff --git a/packages/tokens/src/confidential/docs/protocol/operations/deposit.md b/packages/tokens/src/confidential/docs/protocol/operations/deposit.md new file mode 100644 index 000000000..5f2576091 --- /dev/null +++ b/packages/tokens/src/confidential/docs/protocol/operations/deposit.md @@ -0,0 +1,15 @@ +# Deposit + +Transparent tokens flow from the depositor to the contract via `token.transfer(from, self, amount)`. The amount $$a$$ is public and typed as `i128`. The contract checks $$a \ge 0$$ at the entrypoint and reverts on violation ([Underlying Token Assumptions](../system-model.md#underlying-token-assumptions)). 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` storage entry. 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$$. + +--- + +Previous: [Registration](register.md) · Up: [Operations](README.md) · Next: [Merge](merge.md) diff --git a/packages/tokens/src/confidential/docs/protocol/operations/merge.md b/packages/tokens/src/confidential/docs/protocol/operations/merge.md new file mode 100644 index 000000000..c4426b273 --- /dev/null +++ b/packages/tokens/src/confidential/docs/protocol/operations/merge.md @@ -0,0 +1,29 @@ +# 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}}$$ ([Update rules](../wallet-state.md#update-rules); the per-transfer derivation is Definition 1 in [ECDH-Derived Blinding](../keys-and-commitments.md#ecdh-derived-blinding)). The values $$v\_s$$ and $$r\_s$$ are known from the owner's last proof output. + +**Griefing analysis.** Merge is not front-runnable, and incoming transfers cannot invalidate an in-flight spend proof (Proposition 2, [Griefing Resistance](../security.md#griefing-resistance); Proposition 3, [Merge Safety](../security.md#merge-safety)). + +## 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. + +--- + +Previous: [Deposit](deposit.md) · Up: [Operations](README.md) · Next: [Withdrawal](withdraw.md) diff --git a/packages/tokens/src/confidential/docs/protocol/operations/register.md b/packages/tokens/src/confidential/docs/protocol/operations/register.md new file mode 100644 index 000000000..60bc33215 --- /dev/null +++ b/packages/tokens/src/confidential/docs/protocol/operations/register.md @@ -0,0 +1,37 @@ +# 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. + +## Constraints + +| # | Constraint | +|:--|:---| +| R1 | $$Y = sk \cdot H$$ (spending key well-formed) | +| R2 | $$vk = \text{Poseidon}(\delta\_{\text{vk}}, sk, \text{addr\\\_f})$$ (viewing key correctly derived, binds proof to contract) | +| 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_public_key` and `account.viewing_public_key` on success | +| $$\text{addr\\\_f}$$ | Loaded from instance storage; set once at construction ([Governance and Upgradeability](../system-model.md#governance-and-upgradeability)) | +| $$\text{acct\\\_f}$$ | Binds the proof to the registering address that is authenticated with `require_auth()`| + +$$\text{acct\\\_f}$$ is referenced by no circuit constraint; its membership in the public-input set is the binding. The verifier absorbs every public input into the proof transcript, so a proof produced for one account fails verification when the contract assembles the blob for any other address. Without this input, the register proof and its public keys — all published on-chain by a legitimate registration — could be replayed by any caller to create duplicate-key accounts under fresh addresses. + +## 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_public_key`, `viewing_public_key`, `auditor_id`, and initializes `spendable_commitment = receiving_commitment = ` $$\mathcal{O}$$. + +**Auditor selection.** The registering account owner chooses `auditor_id` freely: the register proof does not constrain it, and the core validates only that the id exists in the auditor registry. On a shared auditor registry, deployments that must restrict which auditors an account may bind to MUST enforce that restriction in their `Hooks::on_register` implementation — the default `ComplianceHooks::on_register` deliberately does not restrict it. See [Restrict Auditor Selection](../../compliance.md#restrict-auditor-selection) for a worked example. + +--- + +Up: [Operations](README.md) · Next: [Deposit](deposit.md) diff --git a/packages/tokens/src/confidential/docs/protocol/operations/revoke-spender.md b/packages/tokens/src/confidential/docs/protocol/operations/revoke-spender.md new file mode 100644 index 000000000..182b7aa37 --- /dev/null +++ b/packages/tokens/src/confidential/docs/protocol/operations/revoke-spender.md @@ -0,0 +1,26 @@ +# Revoke Spender + +The owner reclaims the remaining escrowed allowance. Like merge ([Merge](merge.md)), revocation is a homomorphic fold and carries no proof: nothing is re-randomized and no private value is asserted. The escrowed value was range-proven at S4 and re-bounded by O4 on every spender transfer, and the next spend re-bounds the result under W4 / T4 -- the posture [Integer Embedding and Range Proofs](../primitives.md#integer-embedding-and-range-proofs) already accepts for the receiving balance. + +## Contract logic (no proof) + +``` +require account.require_auth() +C_spend ← C_spend + C_a +delete Delegation(account, spender) +emit RevokeSpender(account, spender, a_tilde, allowance_salt) +``` + +The fold works for both active and expired-but-not-revoked delegations ([Spender Delegation](../account-state.md#spender-delegation)). + +**Owner state update.** By Proposition 1 applied to the allowance commitment, the post-revoke opening is $$(v\_s + v\_a, \\; r\_s + r\_a)$$. The owner derives $$dvk\_i$$ from $$vk$$ ([Delegation Viewing Key](../keys-and-commitments.md#delegation-viewing-key)), recovers $$v\_a = \tilde{a} - \text{Poseidon}(\delta\_{\text{enc\\\_allow}}, dvk\_i, \sigma\_a)$$ and $$r\_a = \text{Poseidon}(\delta\_{\text{allow\\\_r}}, dvk\_i, \sigma\_a)$$ from the event, and applies $$W\_{\text{spend}} \mathrel{+}= (v\_a, r\_a)$$ ([Wallet State and Recovery](../wallet-state.md)). + +**Why the event carries $$\tilde{a}$$ and $$\sigma\_a$$.** The event publishes the `a_tilde` and `allowance_salt` the fold consumes so the recovery above reads both from this event alone. The fold deletes the delegation entry in the same invocation, so neither field is readable from storage afterwards. `SpenderTransfer` writes its $$\tilde{a}'$$ to storage only and emits the replacement salt $$\sigma\_a'$$ it writes, not the salt it consumed ([Transfer nonce](../account-state.md#transfer-nonce)), so without this event the salt would be recoverable only by scanning back to the most recent `SpenderTransfer`'s `sigma_a_new`. For a delegation revoked without ever being spent from, it would be published nowhere: `SetSpender` emits the owner's spendable salt $$\sigma$$ and no allowance salt. + +## Encrypted balance + +Revocation emits no $$\tilde{b}$$ and is not a checkpoint ([Wallet State and Recovery](../wallet-state.md)); the next owner-initiated proof operation issues a fresh checkpoint. The auditor carries its opening of $$C\_{\text{spend}}$$ through the fold as [Per-Transfer Auditor Ciphertexts](../auditing.md#per-transfer-auditor-ciphertexts) specifies, using the opening of $$C\_a$$ it holds from the delegation's last state change ([Spender Allowance Auditing](../auditing.md#spender-allowance-auditing)). + +--- + +Previous: [Spender Transfer](spender-transfer.md) · Up: [Operations](README.md) · Next: [Auditing](../auditing.md) diff --git a/packages/tokens/src/confidential/docs/protocol/operations/set-spender.md b/packages/tokens/src/confidential/docs/protocol/operations/set-spender.md new file mode 100644 index 000000000..2c8d4ef36 --- /dev/null +++ b/packages/tokens/src/confidential/docs/protocol/operations/set-spender.md @@ -0,0 +1,74 @@ +# Set Spender + +The owner locks funds from their spendable balance into a per-spender escrow. The spender must be a registered account in the contract, so that $$Y\_{\text{op}}$$ (needed for $$dvk\_i$$ escrow) can be looked up from the spender's stored `spending_public_key`. + +## Constraints + +| # | Constraint | +|:--|:---| +| S1 | $$Y = sk \cdot H$$ (owner key ownership) | +| S2 | $$vk = \text{Poseidon}(\delta\_{\text{vk}}, sk, \text{addr\\\_f})$$ (binds proof to contract) | +| 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, [Integer Embedding and Range Proofs](../primitives.md#integer-embedding-and-range-proofs)) | +| S5 | $$dvk\_i = \text{Poseidon}(\delta\_{\text{dvk}}, vk, \text{op}\_i)$$ (delegation key derivation; contract-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 [Delegation Key Escrow](#delegation-key-escrow), so this also rules out a trivial escrow shared secret) | +| S14 | $$\tilde{r}\_{a,\text{aud,s}} = \text{Poseidon}(\delta\_{\text{esc\\\_allow\\\_r\\\_aud}}, s\_{a,s}, \text{op}\_i) + r\_a$$ | +| S\_a1 | $$R\_e = r\_e \cdot H$$ (ephemeral key for auditor ECDH) | +| S\_a2 | $$s\_{a,s} = \text{ECDH}(r\_e, K\_{\text{aud,s}})$$ (owner-auditor ECDH shared scalar, [Elliptic Curve Diffie-Hellman](../primitives.md#elliptic-curve-diffie-hellman)) | +| S\_a3 | $$(m\_v, m\_b, m\_r) = \text{SpongeSqueeze}\_3(\delta\_{\text{aud\\\_s}}, s\_{a,s}, \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) | +| S\_a6 | $$\tilde{r}\_{\text{aud,s}} = r' + m\_r$$ (owner-auditor escrow of the new spendable blinding, over S9's $$r'$$) | + +## Public inputs (26 fields) + +| Input | Notes | +|:---|:---| +| $$C\_{\text{spend}}$$ | Loaded from owner's `spendable_commitment` | +| $$Y$$ | Loaded from owner's `spending_public_key` | +| $$Y\_{\text{op}}$$ | Loaded from spender account's `spending_public_key`. Spender must be registered. | +| $$\text{op}\_i$$ | $$\text{address\\\_to\\\_field}$$(`spender` argument), computed per-call by the contract ([Address-to-Field Encoding](../primitives.md#address-to-field-encoding)) | +| $$\text{addr\\\_f}$$ | Loaded from instance storage; set once at construction ([Governance and Upgradeability](../system-model.md#governance-and-upgradeability)) | +| $$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}}$$, $$\tilde{r}\_{\text{aud,s}}$$, $$\tilde{r}\_{a,\text{aud,s}}$$ | Prover-supplied, in this order; $$C\_{\text{spend}}'$$ written to owner's `spendable_commitment`, 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_commitment` $$= C\_{\text{spend}}'$$ and stores the `SpenderDelegation`. Emits event with $$(R\_e, \sigma, \tilde{b}, \tilde{v}\_{\text{aud,s}}, \tilde{b}\_{\text{aud,s}}, \tilde{r}\_{\text{aud,s}}, \tilde{r}\_{a,\text{aud,s}})$$. + +--- + +## Delegation Key Escrow + +At `set_spender`, the owner escrows $$dvk\_i$$ to the spender on-chain via ECDH, eliminating off-chain key sharing: + +1. Owner derives ephemeral $$r\_e$$ per [ECDH-Derived Blinding](../keys-and-commitments.md#ecdh-derived-blinding) — the same scalar as the `set_spender` proof's outer ECDH; see [Why reusing `r_e` is safe](../keys-and-commitments.md#why-reusing-r_e-is-safe) — and computes $$R = r\_e \cdot H$$. +2. Shared secret: $$s = \text{ECDH}(r\_e, Y\_{\text{op}})$$ ([Elliptic Curve Diffie-Hellman](../primitives.md#elliptic-curve-diffie-hellman)) +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. Reconstructing the curve point from `R_x` alone is sign-ambiguous: the two roots of $$y^2 = R.x^3 - 17$$ in $$\mathbb{F}\_r$$ are $$\pm R$$, and since the shared scalar binds $$S.y$$ ([Elliptic Curve Diffie-Hellman](../primitives.md#elliptic-curve-diffie-hellman)), the candidates $$sk\_{\text{op}} \cdot R$$ and $$sk\_{\text{op}} \cdot (-R) = -(sk\_{\text{op}} \cdot R)$$ yield two *different* masks. The ambiguity resolves with one scalar multiplication and a trial decryption, still without storing $$R.y$$: the two candidate shared points are inverses of one another, so the spender computes $$S = sk\_{\text{op}} \cdot R$$ for either root, forms both candidate scalars $$s\_{\pm} = \text{Poseidon}(\delta\_{\text{ecdh}}, S.x, \pm S.y)$$, decrypts a $$dvk\_i$$ candidate from each, and keeps the one consistent with the on-chain delegation entry ([Spender Delegation](../account-state.md#spender-delegation), read via `get_spender_delegation`, [Read Methods](../interface.md#read-methods)): $$dvk = \text{dvk\\\_cipher} - \text{Poseidon}(\delta\_{\text{esc\\\_dvk}}, s\_{\pm}, \text{op}\_i)$$ is correct iff $$C\_a = \text{Com}(\tilde{a} - \text{Poseidon}(\delta\_{\text{enc\\\_allow}}, dvk, \sigma\_a), \\; \text{Poseidon}(\delta\_{\text{allow\\\_r}}, dvk, \sigma\_a))$$. The wrong candidate fails this check except with negligible probability. + +The spender decrypts using $$sk\_{\text{op}}$$. The `set_spender` 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}} = \text{ECDH}(r\_e, Y\_{\text{op}})$$ ([Elliptic Curve Diffie-Hellman](../primitives.md#elliptic-curve-diffie-hellman)) +- $$\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. + +The same proof also escrows the allowance blinding $$r\_a$$ to the owner's auditor (S14), under its own domain tag over the auditor shared scalar; the construction and its decryption path are specified in [Spender Allowance Auditing](../auditing.md#spender-allowance-auditing). + +--- + +Previous: [Confidential Transfer](transfer.md) · Up: [Operations](README.md) · Next: [Spender Transfer](spender-transfer.md) diff --git a/packages/tokens/src/confidential/docs/protocol/operations/spender-transfer.md b/packages/tokens/src/confidential/docs/protocol/operations/spender-transfer.md new file mode 100644 index 000000000..a15d5d634 --- /dev/null +++ b/packages/tokens/src/confidential/docs/protocol/operations/spender-transfer.md @@ -0,0 +1,62 @@ +# Spender Transfer + +The spender transfers from the owner's escrowed allowance to a recipient. + +## Constraints + +| # | Constraint | +|:--|:---| +| O1 | $$Y\_{\text{op}} = sk\_{\text{op}} \cdot H$$ (spender 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{transfer}} \in [0, 2^{127})$$, $$v\_a - v\_{\text{transfer}} \in [0, 2^{127})$$ (range validity, [Integer Embedding and Range Proofs](../primitives.md#integer-embedding-and-range-proofs)) | +| O5 | $$s = \text{ECDH}(r\_e, \text{PVK}\_{\text{recipient}})$$ (recipient ECDH shared scalar, [Elliptic Curve Diffie-Hellman](../primitives.md#elliptic-curve-diffie-hellman)) | +| O6 | $$R\_e = r\_e \cdot H$$ | +| O7 | $$r\_{\text{transfer}} = \text{Poseidon}(\delta\_{\text{transfer\\\_blind}}, s, \sigma\_a')$$ (transfer blinding) | +| O8 | $$C\_{\text{transfer}} = v\_{\text{transfer}} \cdot G + r\_{\text{transfer}} \cdot H$$ | +| O9 | $$\tilde{v} = v\_{\text{transfer}} + \text{Poseidon}(\delta\_{\text{transfer\\\_amount}}, s, \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{transfer}}) \cdot G + r\_a' \cdot H$$ (new allowance) | +| O12 | $$\tilde{a}' = (v\_a - v\_{\text{transfer}}) + \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}$$) | +| O14 | $$\sigma\_a' \neq \sigma\_a$$ (nonce rotation) | +| O\_a1 | $$s\_{a,r} = \text{ECDH}(r\_e, K\_{\text{aud,r}})$$ (recipient-auditor ECDH shared scalar, reuses ephemeral scalar) | +| O\_a2 | $$(m\_{v,r}, m\_{r,r}) = \text{SpongeSqueeze}\_2(\delta\_{\text{aud\\\_r}}, s\_{a,r}, \sigma\_a')$$ (recipient-auditor channel masks) | +| O\_a3 | $$\tilde{v}\_{\text{aud,r}} = v\_{\text{transfer}} + m\_{v,r}$$ (recipient-auditor encrypted transfer amount) | +| O\_a4 | $$\tilde{r}\_{\text{aud,r}} = r\_{\text{transfer}} + m\_{r,r}$$ (recipient-auditor encrypted transfer randomness, enables Pedersen-opening reconstruction of $$C\_{\text{receive}}$$, see [Per-Transfer Auditor Ciphertexts](../auditing.md#per-transfer-auditor-ciphertexts)) | +| O\_a5 | $$s\_{a,s} = \text{ECDH}(r\_e, K\_{\text{aud,s}})$$ (owner-auditor ECDH shared scalar, reuses ephemeral scalar) | +| O\_a6 | $$(m\_{v,s}, m\_{a,s}, m\_{r,s}) = \text{SpongeSqueeze}\_3(\delta\_{\text{aud\\\_s}}, s\_{a,s}, \sigma\_a')$$ (owner-auditor channel masks) | +| O\_a7 | $$\tilde{v}\_{\text{aud,s}} = v\_{\text{transfer}} + m\_{v,s}$$ (owner-auditor encrypted transfer amount) | +| O\_a8 | $$\tilde{a}\_{\text{aud,s}} = (v\_a - v\_{\text{transfer}}) + m\_{a,s}$$ (owner-auditor encrypted post-transfer allowance) | +| O\_a9 | $$\tilde{r}\_{\text{aud,s}} = r\_a' + m\_{r,s}$$ (owner-auditor escrow of the new allowance blinding, over O10's $$r\_a'$$) | + +## Public inputs (25 fields) + +| Input | Notes | +|:---|:---| +| $$C\_a$$, $$\sigma\_a$$ | Loaded from the `(from, spender)` delegation entry | +| $$Y\_{\text{op}}$$ | Loaded from spender's `spending_public_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 spender's. The visibility model points balance- and allowance-checkpoint ciphertexts at the funds' owner. | +| $$C\_a'$$, $$C\_{\text{transfer}}$$, $$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}}$$, $$\tilde{r}\_{\text{aud,s}}$$ | Prover-supplied, in this order; allowance fields written to delegation storage, $$C\_{\text{transfer}}$$ added to recipient's `receiving_commitment`, 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{transfer}}$$, $$r\_e$$. + +## Post-verification + +The contract checks `ledger.sequence() <= live_until_ledger`, updates `allowance_commitment`, `a_tilde`, stores $$\sigma\_a'$$ as the new `allowance_salt`, and adds $$C\_{\text{transfer}}$$ to the recipient's `receiving_commitment`. 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}}, \tilde{r}\_{\text{aud,s}})$$. + +**Ephemeral scalar.** The spender derives $$r\_e = \text{Poseidon}(\delta\_{\text{eph}}, vk\_{\text{op}}, \sigma\_a')$$ ([ECDH-Derived Blinding](../keys-and-commitments.md#ecdh-derived-blinding), [Transfer nonce](../account-state.md#transfer-nonce)) from its *own* viewing key rather than the owner's, so that the spender can later disclose it ([D-sender](../../selective-disclosure/circuits/d-sender.md)). The circuit does not constrain the derivation; it does not constrain $$vk\_{\text{op}}$$ at all, per *Contract binding* below. One consequence follows for the owner: since the owner does not hold $$vk\_{\text{op}}$$, the owner cannot recompute $$r\_e$$ for a spender transfer and cannot disclose it without the spender's cooperation ([Coverage asymmetry](../../selective-disclosure/circuits/d-sender.md#coverage-asymmetry-owner-cannot-d-sender-a-spendertransfer)). + +**Recipient uniformity.** The recipient path is identical to the direct-transfer path of [Recipient processing](transfer.md#recipient-processing). + +## Contract binding + +Unlike owner-initiated circuits, the SpenderTransfer circuit does not constrain the $$vk$$ derivation (the spender has no access to the owner's $$sk$$). Contract binding is instead inherited indirectly through the allowance commitment chain: the SetSpender circuit derives $$dvk\_i$$ from the contract-specific $$vk$$ (S2, S5), which determines $$r\_a$$ (S6) and thus $$C\_a$$ (S7). The SpenderTransfer circuit verifies $$dvk\_i$$ against $$C\_a$$ via $$\sigma\_a$$ (O3). Since $$C\_a$$ is a public input and was constructed with contract-specific randomness, a proof generated against one contract's $$C\_a$$ cannot verify against another's. + +--- + +Previous: [Set Spender](set-spender.md) · Up: [Operations](README.md) · Next: [Revoke Spender](revoke-spender.md) diff --git a/packages/tokens/src/confidential/docs/protocol/operations/transfer.md b/packages/tokens/src/confidential/docs/protocol/operations/transfer.md new file mode 100644 index 000000000..053f6ed62 --- /dev/null +++ b/packages/tokens/src/confidential/docs/protocol/operations/transfer.md @@ -0,0 +1,63 @@ +# Confidential Transfer + +The sender (account $$A$$, spending key $$sk\_A$$) transfers a hidden amount $$v\_{\text{transfer}}$$ to recipient $$B$$ (public viewing key $$\text{PVK}\_B$$). + +**Sender computation.** The constraint table below doubles as the specification of what the sender computes: every quantity the prover produces appears there as the equation the circuit enforces on it. Two prover-side steps are not constraints — the sender draws the salt $$\sigma$$ by the [Grumpkin-BN254 Cycle](../primitives.md#grumpkin-bn254-cycle) procedure and derives the ephemeral scalar from it per [ECDH-Derived Blinding](../keys-and-commitments.md#ecdh-derived-blinding). + +## Constraints + +| # | Constraint | +|:--|:---| +| T1 | $$Y\_A = sk\_A \cdot H$$ (sender key ownership) | +| T2 | $$vk\_A = \text{Poseidon}(\delta\_{\text{vk}}, sk\_A, \text{addr\\\_f})$$ (binds proof to contract) | +| T3 | Prover knows opening $$(v\_A, r\_A)$$ of $$C\_{\text{spend}}^A$$ | +| T4 | $$v\_A \in [0, 2^{127})$$, $$v\_{\text{transfer}} \in [0, 2^{127})$$, $$v\_A - v\_{\text{transfer}} \in [0, 2^{127})$$ (range validity, [Integer Embedding and Range Proofs](../primitives.md#integer-embedding-and-range-proofs)) | +| T5 | $$s = \text{ECDH}(r\_e, \text{PVK}\_B)$$ (recipient ECDH shared scalar correctly derived, [Elliptic Curve Diffie-Hellman](../primitives.md#elliptic-curve-diffie-hellman)) | +| T6 | $$R\_e = r\_e \cdot H$$ (ephemeral key well-formed) | +| T7 | $$r\_{\text{transfer}} = \text{Poseidon}(\delta\_{\text{transfer\\\_blind}}, s, \sigma)$$ (blinding correctly derived) | +| T8 | $$C\_{\text{transfer}} = v\_{\text{transfer}} \cdot G + r\_{\text{transfer}} \cdot H$$ (transfer commitment well-formed) | +| T9 | $$\tilde{v} = v\_{\text{transfer}} + \text{Poseidon}(\delta\_{\text{transfer\\\_amount}}, s, \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{transfer}}) \cdot G + r\_A' \cdot H$$ (new sender balance) | +| T12 | $$\tilde{b} = (v\_A - v\_{\text{transfer}}) + \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} = \text{ECDH}(r\_e, K\_{\text{aud,r}})$$ (recipient-auditor ECDH shared scalar, reuses ephemeral scalar) | +| T\_a2 | $$(m\_{v,r}, m\_{r,r}) = \text{SpongeSqueeze}\_2(\delta\_{\text{aud\\\_r}}, s\_{a,r}, \sigma)$$ (recipient-auditor channel masks) | +| T\_a3 | $$\tilde{v}\_{\text{aud,r}} = v\_{\text{transfer}} + m\_{v,r}$$ (recipient-auditor encrypted transfer amount) | +| T\_a4 | $$\tilde{r}\_{\text{aud,r}} = r\_{\text{transfer}} + m\_{r,r}$$ (recipient-auditor encrypted transfer randomness, enables Pedersen-opening reconstruction of $$C\_{\text{transfer}}$$, see [Per-Transfer Auditor Ciphertexts](../auditing.md#per-transfer-auditor-ciphertexts)) | +| T\_a5 | $$s\_{a,s} = \text{ECDH}(r\_e, K\_{\text{aud,s}})$$ (sender-auditor ECDH shared scalar, reuses ephemeral scalar) | +| T\_a6 | $$(m\_{v,s}, m\_{b,s}, m\_{r,s}) = \text{SpongeSqueeze}\_3(\delta\_{\text{aud\\\_s}}, s\_{a,s}, \sigma)$$ (sender-auditor channel masks) | +| T\_a7 | $$\tilde{v}\_{\text{aud,s}} = v\_{\text{transfer}} + m\_{v,s}$$ (sender-auditor encrypted transfer amount) | +| T\_a8 | $$\tilde{b}\_{\text{aud,s}} = (v\_A - v\_{\text{transfer}}) + m\_{b,s}$$ (sender-auditor encrypted balance checkpoint) | +| T\_a9 | $$\tilde{r}\_{\text{aud,s}} = r\_A' + m\_{r,s}$$ (sender-auditor escrow of the new spendable blinding, over T10's $$r\_A'$$) | + +## Public inputs (25 fields, counting each Grumpkin point as two $$\mathbb{F}\_r$$ coordinates) + +| Input | Notes | +|:---|:---| +| $$C\_{\text{spend}}^A$$ | Loaded from sender's `spendable_commitment` | +| $$Y\_A$$ | Loaded from sender's `spending_public_key` | +| $$\text{PVK}\_B$$ | Loaded from recipient's `viewing_public_key`. Recipient must be registered. | +| $$\text{addr\\\_f}$$ | Loaded from instance storage; set once at construction ([Governance and Upgradeability](../system-model.md#governance-and-upgradeability)) | +| $$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{transfer}}$$, $$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}}$$, $$\tilde{r}\_{\text{aud,s}}$$ | Prover-supplied, in this order; $$C\_{\text{spend}}'$$ written to sender's `spendable_commitment`, $$C\_{\text{transfer}}$$ added to recipient's `receiving_commitment`, the rest emitted in event | + +## Private witnesses + +$$sk\_A$$, $$vk\_A$$, $$v\_A$$, $$r\_A$$, $$v\_{\text{transfer}}$$, $$r\_e$$. + +## Post-verification + +The contract verifies the proof, then: +- Sets $$A$$`.spendable_commitment` $$= C\_{\text{spend}}'$$ +- Adds to recipient: $$B$$`.receiving_commitment` $$\mathrel{+}= C\_{\text{transfer}}$$ +- 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}}, \tilde{r}\_{\text{aud,s}})$$ + +## Recipient processing + +Upon observing the event, the recipient computes $$s = \text{ECDH}(vk, R\_e)$$, derives amount and blinding. The decryption flow is independent of whether the sender was the owner or a spender. + +--- + +Previous: [Withdrawal](withdraw.md) · Up: [Operations](README.md) · Next: [Set Spender](set-spender.md) diff --git a/packages/tokens/src/confidential/docs/protocol/operations/withdraw.md b/packages/tokens/src/confidential/docs/protocol/operations/withdraw.md new file mode 100644 index 000000000..4476ca3f5 --- /dev/null +++ b/packages/tokens/src/confidential/docs/protocol/operations/withdraw.md @@ -0,0 +1,46 @@ +# 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 contract additionally checks $$a \ge 0$$ at the entrypoint ([Underlying Token Assumptions](../system-model.md#underlying-token-assumptions)). + +## Constraints + +| # | Constraint | +|:--|:---| +| W1 | $$Y = sk \cdot H$$ (owner key ownership) | +| W2 | $$vk = \text{Poseidon}(\delta\_{\text{vk}}, sk, \text{addr\\\_f})$$ (binds proof to contract) | +| 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, [Integer Embedding and Range Proofs](../primitives.md#integer-embedding-and-range-proofs)) | +| 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} = \text{ECDH}(r\_e, K\_{\text{aud,s}})$$ (sender-auditor ECDH shared scalar, [Elliptic Curve Diffie-Hellman](../primitives.md#elliptic-curve-diffie-hellman)) | +| W\_a3 | $$(\cdot, m\_b, m\_r) = \text{SpongeSqueeze}\_3(\delta\_{\text{aud\\\_s}}, s\_{a,s}, \sigma)$$ (sender-auditor channel masks; `lane[0]`, the amount slot, is unused) | +| W\_a4 | $$\tilde{b}\_{\text{aud,s}} = (v - a) + m\_b$$ (sender-auditor encrypted balance checkpoint) | +| W\_a5 | $$\tilde{r}\_{\text{aud,s}} = r' + m\_r$$ (sender-auditor escrow of the new spendable blinding, over W5's $$r'$$) | + +## Public inputs (16 fields) + +| Input | Notes | +|:---|:---| +| $$C\_{\text{spend}}$$ | Loaded from `from.spendable_commitment` | +| $$Y$$ | Loaded from `from.spending_public_key` | +| $$\text{addr\\\_f}$$ | Loaded from instance storage; set once at construction ([Governance and Upgradeability](../system-model.md#governance-and-upgradeability)) | +| $$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}}$$, $$\tilde{r}\_{\text{aud,s}}$$ | Prover-supplied, in this order; $$C\_{\text{spend}}'$$ written to `from.spendable_commitment`, 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_commitment` $$= C\_{\text{spend}}'$$, and calls `token.transfer(self, to, a)`. Emits event with $$(R\_e, \sigma, \tilde{b}, \tilde{b}\_{\text{aud,s}}, \tilde{r}\_{\text{aud,s}})$$. + +--- + +Previous: [Merge](merge.md) · Up: [Operations](README.md) · Next: [Confidential Transfer](transfer.md) diff --git a/packages/tokens/src/confidential/docs/protocol/primitives.md b/packages/tokens/src/confidential/docs/protocol/primitives.md new file mode 100644 index 000000000..19b49dad8 --- /dev/null +++ b/packages/tokens/src/confidential/docs/protocol/primitives.md @@ -0,0 +1,176 @@ +# Primitives + +## 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$$ ([Poseidon2 Hash](#poseidon2-hash)) | +| $$\delta\_{\ast}$$ | Domain separation constants (subscript identifies the domain) | +| $$[n]$$ | The set $$\\{0, 1, \ldots, n-1\\}$$ | + +## 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$$. Every secret scalar in the core protocol that is drawn rather than derived ($$\sigma$$, $$\sigma\_a$$) is produced by the **rejection sampling** procedure, which yields a uniform draw from $$\mathbb{F}\_r$$; the extension layers draw one further scalar the same way, $$r\_{\text{disc}}$$ ([Disclosure Ciphertext to Recipient](../selective-disclosure/protocol.md#disclosure-ciphertext-to-recipient)). + +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. + +### Host deserialiser caveat + +The Soroban host's `bn254_fr_from_u256val` (the underlying primitive of `Bn254Fr::from_bytes`) accepts any 32-byte representative and *silently reduces* values $$x \geq r$$ modulo $$r$$ rather than rejecting them. Two distinct byte strings ($$x$$ and $$x + r$$) therefore deserialise to the same $$\mathbb{F}\_r$$ element, which means a verifier alone cannot distinguish canonical from non-canonical inputs. To keep stored state and emitted events byte-unique per logical value, the contract layer enforces canonicality on every prover-supplied $$\mathbb{F}\_r$$ representative *before* the bytes reach the verifier. + +## 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$$ ([Grumpkin-BN254 Cycle](#grumpkin-bn254-cycle)): $$v$$ is a non-negative integer below $$2^{127}$$ ([Integer Embedding and Range Proofs](#integer-embedding-and-range-proofs)) 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$$ ([Grumpkin-BN254 Cycle](#grumpkin-bn254-cycle)) 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}$$ ([Integer Embedding and Range Proofs](#integer-embedding-and-range-proofs)) 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 (see [Post-merge witness availability](proof-system.md#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 contract inherits both the generators and the soundness assumption that $$\log\_G H$$ is unknown. Concretely they are `derive_generators("DEFAULT_DOMAIN_SEPARATOR")` at indices 0 and 1; the values are listed in [Noir Primitives](proof-system.md#noir-primitives). + +## 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. The scalar shared secret used as a Poseidon input binds both coordinates of $$S$$: + +$$s = \text{Poseidon}(\delta\_{\text{ecdh}}, S.x, S.y) \in \mathbb{F}\_r$$ + +Throughout this document, $$\text{ECDH}(a, B)$$ denotes this derivation with $$S = a \cdot B$$. + +**Why the y-coordinate is bound.** A point and its negation share an x-coordinate ($$-P = (P.x, -P.y)$$), so an x-only extraction $$s = S.x$$ would satisfy $$\text{ECDH}(r\_e, P) = \text{ECDH}(r\_e, -P)$$ for every scalar. The negated key is reachable: $$-\text{PVK} = (-vk) \cdot H$$ is itself a valid, canonical, on-curve registration (with secret $$-vk$$), so an x-only map from registered keys to shared secrets would be two-to-one, collapsing each $$(vk, -vk)$$ pair onto one shared secret. Absorbing $$S.y$$ into the Poseidon funnel binds the full shared point. The y-coordinate is consumed *inside* the derivation and never carried through any downstream formula, event, or storage encoding; the absorb $$(\delta\_{\text{ecdh}}, S.x, S.y)$$ fills exactly one rate-3 block, so the binding costs a single Poseidon2 permutation. + +## 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 + +**Sponge construction.** Every Poseidon2 value in this specification is produced by the following sponge over $$\mathbb{F}\_r$$ at width $$t = 4$$, rate 3, capacity 1. + +1. Let $$M$$ be the number of absorbed field elements, counting the leading domain tag. Set $$\text{iv} = M \cdot 2^{64}$$. +2. Initialise the state to $$[0, 0, 0, \text{iv}]$$, placing the IV in the capacity lane $$\text{state}[3]$$. +3. For each full block of three inputs, **add** them into $$\text{state}[0], \text{state}[1], \text{state}[2]$$ -- addition in $$\mathbb{F}\_r$$, not assignment -- then apply the permutation. +4. If $$M$$ is not a multiple of three, add the remaining inputs into $$\text{state}[0]$$ upward and apply one further permutation. This trailing permutation is also applied when $$M = 0$$, so the empty input hashes to $$\text{permute}([0,0,0,0])[0]$$ rather than to zero. +5. Output $$\text{state}[0]$$. + +The domain tag is always the first element absorbed, so $$\text{Poseidon2}(\delta, x\_1, \ldots, x\_n)$$ throughout this document denotes this sponge applied to $$(\delta, x\_1, \ldots, x\_n)$$. + +**Usage in this system:** + +- Key derivation: $$vk = \text{Poseidon2}(\delta\_{\text{vk}}, sk, \text{addr\\\_f})$$ +- Randomness derivation: $$r = \text{Poseidon2}(\delta\_{\text{spend\\\_r}}, vk, \sigma)$$ +- Symmetric encryption: $$\tilde{v} = v + \text{Poseidon2}(\delta\_{\text{transfer\\\_amount}}, s, \sigma)$$ +- Domain separation: each invocation includes a leading constant $$\delta$$ to prevent cross-context collisions + +**Multi-lane mode for auditor channels.** The per-transfer auditor ciphertexts ([Auditing](auditing.md)) need several masks from one absorb. Since $$(\delta\_{\text{channel}}, s, \sigma)$$ is exactly one rate-3 block, the masks are taken as **lanes of a single permutation output**, not as sequential squeezes: + +$$\text{SpongeSqueeze}\_n(\delta\_{\text{channel}}, s, \sigma) = \bigl(\text{state}[0], \\, \ldots, \\, \text{state}[n-1]\bigr), \qquad \text{state} = \text{permute}\bigl([\delta\_{\text{channel}}, \\, s, \\, \sigma, \\, 3 \cdot 2^{64}]\bigr)$$ + +where $$s$$ is the ECDH shared scalar of [Elliptic Curve Diffie-Hellman](#elliptic-curve-diffie-hellman) and $$n \in \\{2, 3\\}$$ is the number of rate lanes read; the capacity lane $$\text{state}[3]$$ is never squeezed. Two channel tags are used: $$\delta\_{\text{aud\\\_s}}$$ for the sender-auditor channel keyed by $$s\_{a,s} = \text{ECDH}(r\_e, K\_{\text{aud,s}})$$, squeezed three-wide, and $$\delta\_{\text{aud\\\_r}}$$ for the recipient-auditor channel keyed by $$s\_{a,r} = \text{ECDH}(r\_e, K\_{\text{aud,r}})$$, squeezed two-wide. No other arity is instantiated. Because the absorb is one block, every width reads a prefix of one permutation output: $$\text{SpongeSqueeze}\_3(\delta, s, \sigma)[i] = \text{SpongeSqueeze}\_2(\delta, s, \sigma)[i]$$ for $$i \in \\{0, 1\\}$$, and $$\text{SpongeSqueeze}\_n(\delta, s, \sigma)[0] = \text{Poseidon2}(\delta, s, \sigma)$$. + +### Lane assignment + +Squeeze order is canonical. Lanes are named by their zero-based index into the squeeze output: `lane[i]` is $$\text{SpongeSqueeze}\_n(\delta, s, \sigma)[i]$$, so a three-wide squeeze yields `lane[0]`, `lane[1]`, and `lane[2]`. `lane[0]` is always an amount mask and `lane[1]` is always a balance, allowance, or randomness mask, fixed per operation by the formulas in [Operations](operations/README.md) and [Auditing](auditing.md). `lane[2]`, present only on the sender-auditor channel, is the **blinding-escrow slot**. It carries the new spendable blinding on the three checkpoint operations (W\_a5, T\_a9, S\_a6) and the new allowance blinding on spender transfers (O\_a9). The Withdraw checkpoint (W\_a3, W\_a5) takes `lane[1]` and `lane[2]` and leaves the amount lane unused, so a checkpoint pad can never coincide with an amount pad. + +### Mode exclusivity + +By the prefix property, the single-output form is `lane[0]` of the multi-lane one, so distinct domain tags ([Domain Separation Constants](domain-separators.md)) are not sufficient on their own: each tag MUST additionally be used in exactly one sponge mode, or the same $$(\delta, s, \sigma)$$ would yield one mode's mask as the other's output. $$\delta\_{\text{aud\\\_s}}$$ and $$\delta\_{\text{aud\\\_r}}$$ are the two multi-lane tags; every other tag in [Domain Separation Constants](domain-separators.md) is used only with the single-output form above. + +### Pad freshness + +The sponge masks are deterministic in $$(s, \sigma)$$, so reusing the pair across two operations reuses every pad slot they share, and a slot whose plaintext is known in one operation (e.g. a transfer amount known to its recipient) decrypts the other operation's ciphertext in that slot. The canonical slot assignment above limits the blast radius of such reuse to same-slot pairs, but does not eliminate it; provers and wallets MUST use a fresh $$(r\_e, \sigma)$$ for every proof. Because $$r\_e$$ is derived from the originator's viewing key and the salt rather than drawn independently ([ECDH-Derived Blinding](keys-and-commitments.md#ecdh-derived-blinding)), a fresh salt is the only thing that makes the pair fresh, and [Revert Safety](security.md#revert-safety)'s retry rule is what discharges the requirement. Freshness applies to whichever salt the operation's pads absorb -- $$\sigma$$ for owner-initiated operations, the prover-chosen $$\sigma\_a'$$ for spender transfers ([Transfer nonce](account-state.md#transfer-nonce)). + +All references to "Poseidon" in this document denote this Poseidon2 instantiation. + +## 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{transfer}}$$ 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{transfer}}$$ such that the circuit verifies: + +1. $$v\_A \in [0, 2^{127})$$ (the opening of $$C\_{\text{spend}}$$) +2. $$v\_{\text{transfer}} \in [0, 2^{127})$$ (the transfer amount) +3. $$v\_A - v\_{\text{transfer}} \in [0, 2^{127})$$ (the new balance) + +then $$v\_A - v\_{\text{transfer}}$$ 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{transfer}} < 2^{127}$$, so if $$v\_A < v\_{\text{transfer}}$$ as integers, then $$v\_A - v\_{\text{transfer}} \pmod{r}$$ would be $$r - (v\_{\text{transfer}} - 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 contract'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{transfer}} \in [0, 2^{127})$$ (constraint T4 / O4). +3. All tokens in the contract 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. + +## Address-to-Field Encoding + +In Soroban, the host's `address_to_strkey` function is defined for the two `ScAddressType` variants the contract interacts with as actors -- `Account` (Stellar ed25519 account) and `Contract` (Soroban contract instance) -- and errors on every other variant the SDK's `Address` type can wrap. 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 SEP-23 version byte (`0x30` for `Account`, `0x10` 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 whose leading character is correspondingly `G` (`0x47` in ASCII) or `C` (`0x43`). 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 contract, 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{addr\\\_f}$$ | Once, by the contract's `__constructor` over `env.current_contract_address()` | Stored as a single Field in the contract's **instance storage** ([Governance and Upgradeability](system-model.md#governance-and-upgradeability)); read on every proof verification | +| $$\text{op}\_i$$ | Per-call, by the contract at `set_spender` over the `spender` argument | Not stored; recomputed each call. The circuit binds it via S5 and absorbs it as the S12 / S14 escrow nonce. | + +--- + +Previous: [Introduction](README.md) · Up: [Index](../README.md#protocol) · Next: [System Model](system-model.md) diff --git a/packages/tokens/src/confidential/docs/protocol/proof-system.md b/packages/tokens/src/confidential/docs/protocol/proof-system.md new file mode 100644 index 000000000..e9d9f50b1 --- /dev/null +++ b/packages/tokens/src/confidential/docs/protocol/proof-system.md @@ -0,0 +1,191 @@ +# Proof System + +## Circuits + +All proof logic is written in Noir, compiled to UltraHonk circuits. The system uses six circuits, the last belonging to the optional compliance extension: + +```rust +#[contracttype] +#[repr(u32)] +pub enum CircuitType { + Register = 0, + Withdraw = 1, + Transfer = 2, + SpenderTransfer = 3, + SetSpender = 4, + Clawback = 100, +} +``` + +`deposit`, `merge`, and `revoke_spender` carry no proof and have no circuit ([Deposit](operations/deposit.md), [Merge](operations/merge.md), [Revoke Spender](operations/revoke-spender.md)). The discriminants are part of the on-chain interface and are never renumbered; core circuits occupy `0..=4` and extension circuits start at `100`. + +## Circuit Summary + +| Circuit | What it proves | +|:---|:---| +| `Register` | Spending key well-formedness; contract-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 ciphertexts (balance checkpoint + `lane[2]` escrow of the new spendable blinding); 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 + `lane[2]` escrow of the new spendable blinding); deterministic randomness for new sender balance; encrypted balance scalar; sender key ownership; range validity (balance $$\in [0, 2^{127})$$, amount $$\in [0, 2^{127})$$) | +| `SpenderTransfer` | Allowance sufficiency; ECDH-derived blinding and encrypted amount for recipient; dual-auditor channel sponges (recipient auditor: amount + per-transfer Pedersen randomness; owner auditor: amount + allowance + `lane[2]` escrow of the new allowance blinding); deterministic randomness for new allowance; encrypted allowance scalar; spender key ownership; contract-bound indirectly via $$C\_a$$ chain ([Spender Transfer](operations/spender-transfer.md)) | +| `SetSpender` | Balance split; $$dvk\_i$$ derivation; ECDH escrow of $$dvk\_i$$ to the spender and of the allowance blinding to the owner's auditor; allowance commitment with deterministic randomness; encrypted balance and allowance scalars; owner-auditor ECDH ciphertexts (escrow amount + balance checkpoint + `lane[2]` escrow of the new spendable blinding); owner key ownership; contract-bound via $$vk$$ derivation | +| `Clawback` | Knowledge of the openings of the target's $$C\_{\text{spend}}$$ and $$C\_{\text{receive}}$$; public seize amount bounded by their sum, remainder in range; auditor key ownership; no ephemeral ([Circuit](../compliance.md#circuit)) | + +## 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. + +**Counting unit.** The totals below count `multi_scalar_mul` *call sites*, matching how the circuits are written (`circuits/lib/src/lib.nr`): a Pedersen commitment is one call over two scalars, and each `ecdh` is one call plus a Poseidon2 absorb ([Elliptic Curve Diffie-Hellman](primitives.md#elliptic-curve-diffie-hellman)). A per-scalar count is higher, since every commitment contributes two. Each row lists every site, so the constraint identifiers are the audit trail. + +| Circuit | Calls | Sites | +|:---|:--:|:---| +| `Register` | 2 | $$Y$$ (R1), $$\text{PVK}$$ (R3) | +| `Withdraw` | 5 | $$Y$$ (W1), $$C\_{\text{spend}}$$ opening (W3), $$C\_{\text{spend}}'$$ (W6), $$R\_e$$ (W\_a1), sender-auditor ECDH (W\_a2) | +| `Transfer` | 8 | $$Y\_A$$ (T1), $$C\_{\text{spend}}^A$$ opening (T3), recipient ECDH (T5), $$R\_e$$ (T6), $$C\_{\text{transfer}}$$ (T8), $$C\_{\text{spend}}'$$ (T11), recipient-auditor ECDH (T\_a1), sender-auditor ECDH (T\_a5) | +| `SpenderTransfer` | 8 | $$Y\_{\text{op}}$$ (O1), $$C\_a$$ opening (O2), recipient ECDH (O5), $$R\_e$$ (O6), $$C\_{\text{transfer}}$$ (O8), $$C\_a'$$ (O11), recipient-auditor ECDH (O\_a1), owner-auditor ECDH (O\_a5) | +| `SetSpender` | 7 | $$Y$$ (S1), $$C\_{\text{spend}}$$ opening (S3), $$C\_a$$ (S7), $$C\_{\text{spend}}'$$ (S10), $$R\_e$$ (S\_a1), $$dvk\_i$$ escrow ECDH (S12, [Delegation Key Escrow](operations/set-spender.md#delegation-key-escrow)), owner-auditor ECDH (S\_a2) | +| `Clawback` | 3 | $$K\_{\text{aud}}$$ (CB1), $$C\_{\text{spend}}$$ opening (CB2), $$C\_{\text{receive}}$$ opening (CB3) | + +`SetSpender` is the one circuit with a third ECDH beyond the auditor channel: the $$dvk\_i$$ handoff of [Delegation Key Escrow](operations/set-spender.md#delegation-key-escrow) reuses $$r\_e$$ but multiplies it against $$Y\_{\text{op}}$$, so it is a separate call, not a reuse of the S\_a2 shared secret. The auditor-side escrow of the allowance blinding $$r\_a$$ (S14) reuses the S\_a2 shared scalar and adds one Poseidon evaluation. The `lane[2]` escrows (W\_a5, T\_a9, S\_a6, O\_a9) read `lane[2]` of a permutation each circuit already computes and cost one field addition apiece. The ordering these totals imply is consistent with the committed ACIR opcode counts in `circuits/constraints.baseline`. + +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). + +## 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 and +/// of both balance commitments in CB2/CB3) +/// 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 shared-secret scalar (primitives.md#elliptic-curve-diffie-hellman): S = scalar * point, then +/// s = Poseidon2(delta_ecdh, S.x, S.y). Absorbing both coordinates binds +/// the full shared point, removing the (P, -P) negation invariance of an +/// x-only extraction. All ECDH scalars in this protocol are F_r-sampled +/// (sk, vk, r_e_point), so single-limb conversion is sound. +fn ecdh(scalar: Field, point: EmbeddedCurvePoint) -> Field { + let s = multi_scalar_mul( + [point], + [EmbeddedCurveScalar::from_field(scalar)] + ); + poseidon_with_domain(ECDH_SHARED_SECRET, [s.x, s.y]) +} +``` + +### Post-merge witness availability + +Every opening witnessed in any circuit is encoded as a single $$\mathbb{F}\_r$$ `Field` via the same `commit` primitive. After a proofless fold -- `Merge` ([Merge](operations/merge.md)), `revoke_spender` ([Revoke Spender](operations/revoke-spender.md)), or `clawback` ([Contract Flow](../compliance.md#contract-flow), where the compliance extension is enabled) -- the spendable-balance blinding is a sum over $$\mathbb{F}\_q$$: $$r\_s + r\_r$$ for a merge or a clawback, $$r\_s + r\_a$$ for a revoke. Its canonical $$\mathbb{F}\_q$$ representative lies in $$[0, r)$$ -- representable as a Noir `Field` -- with probability $$\geq 1 - (q - r)/q \approx 1 - 2^{-127}$$ per fold ([Pedersen Commitments](primitives.md#pedersen-commitments)). With the complementary probability $$\approx 2^{-127}$$ it lies in $$[r, q)$$. In that case the on-chain state remains well-formed (the commitment is a valid Grumpkin point), but the wallet's local opening witness is unencodable as a `Field`, so no spend / transfer / set-spender proof can be constructed against the affected $$C\_{\text{spend}}$$ until further accumulation shifts the blinding back into $$\mathbb{F}\_r$$. + +### Soft recovery + +Every subsequent inbound confidential transfer or spender transfer, once merged, adds a fresh $$\mathbb{F}\_r$$-derived blinding. For each transfer-derived addend, the new canonical $$\mathbb{F}\_q$$ representative falls in $$[0, r)$$ with probability $$\geq 1 - 2^{-127}$$ regardless of the current stuck value (worst case: the current value sits at the lower edge of $$[r, q)$$, requiring the new $$\mathbb{F}\_r$$ addend to cross the mod $$q$$ boundary; the probability of failing to do so is bounded by $$(q - r)/r \approx 2^{-127}$$). For accounts that continue to receive confidential transfers, the unspendable window is self-resolving at the next merge with overwhelming probability; accounts whose only inflows are deposits remain stuck until a confidential transfer arrives. + +## Verification Flow + +1. Contract 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. Contract applies homomorphic balance updates (Grumpkin point arithmetic via $$\mathbb{F}\_r$$ ops) + +The proofless operations (`deposit`, `merge`, `revoke_spender`) perform step 5 only. + +## Structured Reference String + +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. + +## Dependency: CAP-80 + +[CAP-80](https://github.com/stellar/stellar-protocol/blob/master/core/cap-0080.md) introduces the host functions required for efficient UltraHonk verification and on-chain Grumpkin point arithmetic. The rollout spans two protocols; both are required, so **protocol 26 is the effective minimum**. + +- **Protocol 25:** `bn254_g1_{add, mul}`, `bn254_multi_pairing_check`. +- **Protocol 26:** `bn254_g1_msm`, `bn254_g1_is_on_curve`, `bn254_fr_{add, sub, mul, inv, pow}` -- the $$\mathbb{F}\_r$$ scalar arithmetic underpinning Grumpkin point operations. + +## On-Chain Point Arithmetic + +The contract 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$$ ([Grumpkin-BN254 Cycle](primitives.md#grumpkin-bn254-cycle)) 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, [Dependency: CAP-80](#dependency-cap-80)). + +**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 contract 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), $$C\_{\text{transfer}}$$ (T8, O8), $$C\_{\text{spend}}'$$ (T11, W6, S10), $$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). 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 the operation's salt, 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 contract 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 ([Components](system-model.md#components)); the contract trusts the fetched value. + +**Canonical encoding** ($$x, y \in [0, r)$$ as 32-byte representatives) is enforced **by the contract** at the verifier boundary, not by the Soroban host, which reduces non-canonical inputs rather than rejecting them ([Host deserialiser caveat](primitives.md#host-deserialiser-caveat)). Every prover-supplied scalar and coordinate that reaches the verifier — and therefore every byte string that gets persisted or emitted downstream — is the unique canonical representative of its field element. + +--- + +Previous: [Security Analysis](security.md) · Up: [Index](../README.md#protocol) · Next: [Interface](interface.md) diff --git a/packages/tokens/src/confidential/docs/protocol/security.md b/packages/tokens/src/confidential/docs/protocol/security.md new file mode 100644 index 000000000..ce2e211db --- /dev/null +++ b/packages/tokens/src/confidential/docs/protocol/security.md @@ -0,0 +1,73 @@ +# Security Analysis + +## Griefing Resistance + +**Proposition 2** (Spend-proof stability). *No third party can invalidate an honest owner's in-flight spend proof.* + +*Proof.* A spend proof ([Confidential Transfer](operations/transfer.md)) 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_spender`, `revoke_spender`) - 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 any number of transfers without any mandatory owner action. The receiving balance is a single point whose committed value $$v\_{\text{receive}}$$ grows by exact integer addition: every increment is a contract-validated deposit amount or a circuit-proven transfer amount in $$[0, 2^{127})$$, and since all value entered through deposits, $$v\_{\text{receive}}$$ stays below the underlying token's supply, itself below $$2^{127}$$. Wrap-around in the scalar field would require more than $$2^{127}$$ incoming transfers ([Integer Embedding and Range Proofs](primitives.md#integer-embedding-and-range-proofs)). The next spend after a merge range-checks $$v\_{\text{spend}} + v\_{\text{receive}} \in [0, 2^{127})$$ in-circuit. + +## 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.* + +## 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) spender allowance. Expired-but-not-revoked allowances are included: the escrowed value resides on-chain in $$C\_a$$ until `revoke_spender` reclaims it ([Spender Delegation](account-state.md#spender-delegation)). + +This invariant is maintained by: +- **Deposits** increase $$v\_{\text{receive}}$$ by $$d\_j$$ ([Deposit](operations/deposit.md)). +- **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{transfer}}$$, enforced by circuit constraints T3–T8. +- **Merge** moves value from $$v\_{\text{receive}}$$ to $$v\_{\text{spend}}$$ (Proposition 1); the sum is unchanged. +- **Set spender** moves value from $$v\_{\text{spend}}$$ to $$v\_{\text{allowance}\_i}$$; enforced by S3–S7. +- **Spender transfer** decreases $$v\_{\text{allowance}\_i}$$ and increases recipient's $$v\_{\text{receive}}$$ by $$v\_{\text{transfer}}$$; enforced by O2–O8. +- **Revoke** moves remaining $$v\_{\text{allowance}\_i}$$ back to $$v\_{\text{spend}}$$ (Proposition 1); the sum is unchanged. +- **Clawback** (compliance extension) removes a public amount from $$v\_{\text{spend}} + v\_{\text{receive}}$$ without a withdrawal, enforced by CB2–CB4; in a deployment that enables it, seized amounts count alongside $$w\_k$$ on the left-hand side ([Contract Flow](../compliance.md#contract-flow)). + +## 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{transfer\\\_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 contract-specific ([Viewing Key](keys-and-commitments.md#viewing-key)), compromise of one contract's viewing key does not affect the owner's accounts in other deployments. Within the compromised contract, 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), derive all $$dvk\_i$$ to read spender allowances, and recompute the ephemeral scalar $$r\_e$$ of every transfer the account *originated* under the deterministic derivation of [ECDH-Derived Blinding](keys-and-commitments.md#ecdh-derived-blinding), hence the recipient shared scalar $$s$$, hence a full Pedersen opening $$(v\_{\text{transfer}}, r\_{\text{transfer}})$$ of each such $$C\_{\text{transfer}}$$. The attacker **cannot** authorize any spending operation. + +Two properties of that last capability deserve stating plainly. It is **retroactive**: the derivation is deterministic in $$(vk, \sigma)$$ and $$\sigma$$ is published, so a compromise today opens every transfer the account ever originated with a derived $$r\_e$$. A transfer whose $$r\_e$$ was sampled instead stays closed: no function of $$(vk, \sigma)$$ reproduces its ephemeral scalar, so neither $$s$$ nor the opening follows. And it **reaches outside the account's own state**: the commitments it opens were added to *recipients'* `receiving_commitment` values. The transfer *amounts* were already inferable from $$vk$$ alone, by differencing consecutive balance checkpoints and netting the inbound credits and merges between them, so amount visibility is not what changes; the openings are, and an opening is a self-verifying artifact that its holder can hand to any third party. This is why $$vk$$ cannot be treated as a safely shareable read-only credential ([Security Requirements](../sdk/requirements.md#security-requirements)): a counterparty that needs visibility into an account's outbound transfers receives per-event disclosure proofs bound to it and to a nonce ([D-sender](../selective-disclosure/circuits/d-sender.md)). + +**Auditor key compromise.** An account binds one key for both channels ([Account Data Model](account-state.md#account-data-model)). For every account that used the compromised key, the attacker acquires: all amounts and balance checkpoints; the standing opening of $$C\_{\text{spend}}$$ from the first post-activation checkpoint, maintained across merges exactly as [Per-Transfer Auditor Ciphertexts](auditing.md#per-transfer-auditor-ciphertexts) describes; the opening of $$C\_{\text{receive}}$$ between merges; and the escrowed allowance blinding $$r\_a$$ from every delegation event it observed, hence the opening of each $$C\_a$$ those events wrote ([Spender Allowance Auditing](auditing.md#spender-allowance-auditing)). It cannot recover viewing keys, read data from before the key was active, or authorize any spending. After key rotation, new operations are protected by the new key. + +As with $$vk$$ above, the openings are self-verifying artifacts a holder can hand to any third party, so an auditor key is custodied at the same level as a viewing key ([Security Requirements](../sdk/requirements.md#security-requirements)). + +## 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 picks a new random salt -- $$\sigma$$, or $$\sigma\_a'$$ for a spender transfer -- and recomputes the proof. No special-case logic is needed. The salt is a public input and emitted in events so the auditor and owner can reconstruct randomness. + +## 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 spender 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. + +--- + +Previous: [Auditing](auditing.md) · Up: [Index](../README.md#protocol) · Next: [Proof System](proof-system.md) diff --git a/packages/tokens/src/confidential/docs/protocol/system-model.md b/packages/tokens/src/confidential/docs/protocol/system-model.md new file mode 100644 index 000000000..22987dca2 --- /dev/null +++ b/packages/tokens/src/confidential/docs/protocol/system-model.md @@ -0,0 +1,57 @@ +# System Model + +## Components + +The system comprises three contracts deployed on Soroban: + +**Token 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.** An UltraHonk verifier storing one verification key per circuit type. Accepts a circuit identifier, serialized public inputs, and a proof blob; returns success or failure. Builds on top of [NethermindEth/rs-soroban-ultrahonk](https://github.com/NethermindEth/rs-soroban-ultrahonk). + +**Auditor contract.** Manages auditor encryption keys independently of the contract. One auditor contract serves multiple token contracts. 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 contract fetches the active auditor key at operation time and passes it as a public input to the relevant circuit. + +## 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 ([Structured Reference String](proof-system.md#structured-reference-string)). +- The discrete logarithm problem on Grumpkin is hard. +- Poseidon2 ([Poseidon2 Hash](primitives.md#poseidon2-hash)) 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. + +## Trust Assumptions + +The contract, 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 ([Structured Reference String](proof-system.md#structured-reference-string)). The auditor is trusted to protect its decryption key and exercise access only upon legitimate regulatory request. + +## Underlying Token Assumptions + +The contract holds units of an underlying SEP-41 token on behalf of its users. The confidential accounting invariant ([Balance Conservation](security.md#balance-conservation)) implicitly assumes: + +$$\sum\_i v\_{\text{committed},i} \\;\le\\; \text{token.balance}(\text{contract})$$ + +i.e., the total committed value across all confidential accounts never exceeds the public token balance held by the contract. The deployer's choice of underlying token determines whether that invariant is actually preserved over time. The contract 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 contract address changes only as a result of explicit operations that the contract itself originated. Tokens whose balances change as a function of supply, oracle data, or external triggers break the accounting invariant and are unsupported. +- *No fee-on-transfer.* `token.transfer(from, to, amount)` MUST move exactly `amount` units. A fee deducted in transit would leave the contract's confidential accounting larger than its public backing. +- *Deterministic revert.* A failed `token.transfer` MUST cause the enclosing contract invocation (`deposit` or `withdraw`) to revert atomically, so confidential state is never updated against a token transfer that did not happen. +- *Underlying clawback / freeze / deauthorization.* These are surfaces of the Stellar Asset Contract (`StellarAssetInterface`), not the generic SEP-41 (`TokenInterface`). If the underlying token is a SAC whose issuer can clawback, freeze, or deauthorize the contract's holdings, confidential accounting at the contract layer may temporarily or permanently exceed the contract's accessible backing. This is an operational risk borne by the deployer's choice of underlying token. The token layer offers its own freeze and per-account clawback flows that operate inside the confidential surface; see [Contract-Level Freeze](../compliance.md#contract-level-freeze) (contract-level freeze) and [Commitment Scheme](keys-and-commitments.md#commitment-scheme) (admin + auditor clawback). [SAC Authorization Passthrough](../compliance.md#sac-authorization-passthrough) additionally specifies SAC authorization passthrough, which composes the contract's freeze with the issuer's freeze without requiring the admin to mirror state. + +**Non-negativity check.** The contract's public interface uses `i128` end-to-end, matching SEP-41. Every entrypoint that accepts a public amount (`deposit`, `withdraw`) MUST reject `amount < 0` and revert. The in-circuit range constraint ([Integer Embedding and Range Proofs](primitives.md#integer-embedding-and-range-proofs)) bounds the same value at $$2^{127}$$ from above; together they pin the contract's value domain to $$[0, 2^{127}) = [0, \text{i128::MAX}]$$, matching SEP-41 exactly. No conversion at the SEP-41 boundary is needed. + +## Governance and Upgradeability + +The constructor binds the contract to fixed `admin`, `token`, `verifier`, and `auditor` addresses. It additionally computes and stores `addr_f = address_to_field(env.current_contract_address())` ([Address-to-Field Encoding](primitives.md#address-to-field-encoding)) 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. The compressed `addr_f` Field is computed once at construction (not recomputed per call) to ensure all proofs across the contract's lifetime bind to the same Field representative of the contract's address. Beyond that, this specification does not prescribe a governance policy for upgrading these components or for rotating per-circuit verification keys. Concrete deployments differ widely in spender structure, regulatory posture, and emergency-response requirements, so these decisions are deliberately left to implementers. + +Questions an implementer must answer: + +- 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 ([Structured Reference String](proof-system.md#structured-reference-string))? + +**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. + +--- + +Previous: [Primitives](primitives.md) · Up: [Index](../README.md#protocol) · Next: [Key Hierarchy and Commitment Scheme](keys-and-commitments.md) diff --git a/packages/tokens/src/confidential/docs/protocol/wallet-state.md b/packages/tokens/src/confidential/docs/protocol/wallet-state.md new file mode 100644 index 000000000..e3def8974 --- /dev/null +++ b/packages/tokens/src/confidential/docs/protocol/wallet-state.md @@ -0,0 +1,79 @@ +# Wallet State and Recovery + +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 the accumulators as follows: + +| 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 = \text{ECDH}(vk, R\_e)$$ ([Elliptic Curve Diffie-Hellman](primitives.md#elliptic-curve-diffie-hellman)); derive $$v\_{\text{transfer}} = \tilde{v} - \text{Poseidon}(\delta\_{\text{transfer\\\_amount}}, s, \sigma)$$ and $$r\_{\text{transfer}} = \text{Poseidon}(\delta\_{\text{transfer\\\_blind}}, s, \sigma)$$. Then $$W\_{\text{receive}} \mathrel{+}= (v\_{\text{transfer}}, r\_{\text{transfer}})$$ | +| 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 spender (escrow amount $$a$$) | Proof outputs new commitment. $$W\_{\text{spend}} \leftarrow (v\_s - a, \\; \text{Poseidon}(\delta\_{\text{spend\\\_r}}, vk, \sigma))$$ | +| Revoke spender | Proofless fold. $$W\_{\text{spend}} \mathrel{+}= (v\_a, r\_a)$$, the escrow opening recovered from the event per [Revoke Spender](operations/revoke-spender.md) | +| Clawback (compliance extension) | The Merge row, then the event's public `amount` is subtracted from the spendable value ([Contract Flow](../compliance.md#contract-flow)) | + +The Merge, Revoke spender, and Clawback rows fold openings the wallet already holds, leaving $$W\_{\text{spend}}.r$$ a sum; [Post-merge witness availability](proof-system.md#post-merge-witness-availability) covers the case where no circuit can witness it. + +After every owner-initiated operation that produces a proof, $$r\_s$$ resets to a deterministic value. This is the **normalization** property: the spendable balance's blinding factor is always recoverable from $$(vk, \sigma)$$ at spend boundaries. Together with $$\tilde{b}$$, both emitted in the spend-boundary event, each spend boundary forms a **checkpoint** from which the spendable opening $$(v\_s, r\_s)$$ *as of that boundary* is recoverable via a single event lookup, with no replay of the spend history preceding it. $$W\_{\text{receive}}$$ has no such anchor: a checkpoint leaves $$C\_{\text{receive}}$$ untouched, which only `Merge` ([Merge](operations/merge.md)) and `Clawback` reset, so recovering it requires a replay (see Recovery below). + +## Consistency check + +At any time, the wallet can verify its state: $$C\_{\text{spend}} \stackrel{?}{=} v\_s \cdot G + r\_s \cdot H$$ and $$C\_{\text{receive}} \stackrel{?}{=} v\_r \cdot G + r\_r \cdot H$$, where $$C\_{\text{spend}}$$ and $$C\_{\text{receive}}$$ are read from on-chain state. + +## Recovery + +If the wallet loses local state, it recovers from **two anchors**: the account's last **checkpoint** for the spendable side (steps 1-4), and $$T\_0$$ -- its most recent `Merge` or `Clawback` at or before that checkpoint -- for the receiving side (step 5). Since $$W\_{\text{receive}}$$ restarts at $$(0, 0)$$ as of $$T\_0$$, one replay window $$(T\_0, \text{now}]$$ serves both: it rebuilds the receiving side and carries the post-checkpoint proofless folds onto the spendable one (step 6). [Why the two anchors compose](#why-the-two-anchors-compose) gives the argument that they do: + +1. Fetch $$(\tilde{b}, \sigma)$$ from the most recent **checkpoint event** for this account: exactly one of `Withdraw`, `Transfer` (where the account is the `from`), or `SetSpender` -- the three event types carrying a proof-bound $$(\tilde{b}, \sigma)$$ for this account's spendable balance. `Merge`, `RevokeSpender`, and `Clawback` do change $$C\_{\text{spend}}$$ but are not checkpoints: none binds a $$\tilde{b}$$ to the resulting commitment ([Encrypted balance](operations/merge.md#encrypted-balance), [Encrypted balance](operations/revoke-spender.md#encrypted-balance), [Wallet and Auditor Consequences](../compliance.md#wallet-and-auditor-consequences)), and their effect is absorbed into the next checkpoint. **No-checkpoint case:** if the account has no checkpoint event since `Register`, initialize $$W\_{\text{spend}} \leftarrow (0, 0)$$ and skip to step 5 with $$T\_0$$ = the `Register` event. +2. Recover the spendable balance value: $$v\_s = \tilde{b} - \text{Poseidon}(\delta\_{\text{enc\\\_bal}}, vk, \sigma)$$. +3. Recover the spendable balance blinding: $$r\_s = \text{Poseidon}(\delta\_{\text{spend\\\_r}}, vk, \sigma)$$. +4. Set $$W\_{\text{spend}} \leftarrow (v\_s, r\_s)$$. +5. Locate $$T\_0$$: the account's most recent `Merge` or `Clawback` event at or before the checkpoint of step 1, or its `Register` event if neither exists. Set $$W\_{\text{receive}} \leftarrow (0, 0)$$ as of $$T\_0$$. +6. Replay every event after $$T\_0$$ in ledger order, applying the *Update rules* above with three replay-specific amendments: + - **Checkpoint event**: skip the spendable side -- step 1 already captured it. + - **Revoke spender**: skip if at or before the checkpoint of step 1, which absorbed it; otherwise fold as the table specifies. + - **Self-transfer** (a `Transfer` whose `from` and `to` are both this account): simultaneously a checkpoint and an incoming transfer -- apply the incoming-transfer rule to $$W\_{\text{receive}}$$ as for any other. +7. Verify consistency: $$C\_{\text{spend}} \stackrel{?}{=} W\_{\text{spend}}.v \cdot G + W\_{\text{spend}}.r \cdot H$$ and $$C\_{\text{receive}} \stackrel{?}{=} W\_{\text{receive}}.v \cdot G + W\_{\text{receive}}.r \cdot H$$. + +The window's length is set by how recently the account merged before its latest checkpoint, not by how often it spends; the wallet merge policy that keeps it short in practice is [Merge policy](../sdk/wallet.md#merge-policy)'s. In the worst case (funds received but never merged), it extends back to registration. + +## Event durability requirement + +Recovery depends on the wallet being able to locate $$T\_0$$ and retrieve every event after it, the checkpoint event included, in ledger order. Stellar RPC retains events for a bounded window the node reports through `getHealth` as `oldestLedger` and `ledgerRetentionWindow`, and `getEvents` rejects a `startLedger` below `oldestLedger`, so a wallet whose $$T\_0$$ has fallen below the node's `oldestLedger` cannot recover from RPC alone. The protocol therefore assumes a durable event archive retaining, forever, the full per-account history of every event these rules read. That event set, along with the data model, ingestion contract, retention obligations, and recommended API surface for the archive, is specified in [Indexing and Off-Chain State Recovery](../indexer.md) ([Threat Model](system-model.md#threat-model) for the set itself). Wallets and SDKs MUST consume an indexer that meets that contract for recovery. + +--- + +## Recovery Properties + +The recovery procedure, the definition of the **checkpoint** it is built around, the replay window it runs over, and the durable-archive requirement it depends on are all specified in [Recovery](#recovery). This section states the security properties that follow from it. + +### Why the two anchors compose + +No `Merge` or `Clawback` falls strictly between $$T\_0$$ and the checkpoint, by construction of $$T\_0$$ as the last of either at or before it. Within $$(T\_0, \text{checkpoint}]$$ the only spendable-affecting events are therefore checkpoint events and `RevokeSpender` folds, and the latest checkpoint's $$(\tilde{b}, \sigma)$$ supersedes both — which is why the replay skips them ([Recovery](#recovery), step 6). Any `Merge` the replay encounters necessarily sits after the checkpoint and is folded normally, against the receiving opening it actually consumed. + +### Recoverability + +An owner who holds $$vk$$ reconstructs both openings deterministically from the account's event history, so the loss of local wallet state is not a loss of funds. The single-lookup shortcut on the spendable side is sound: each checkpoint's $$\tilde{b}$$ is bound to the $$C\_{\text{spend}}$$ it certifies by the emitting operation's own proof (W7, T12, S11), so a checkpoint cannot misreport the balance the wallet restarts from. Beyond the event history and the on-chain commitments, recovery requires nothing from the auditor, from counterparties, or from the contract admin. + +### Integrity fails closed + +The event archive is trusted for availability, not for integrity. Recovery terminates in a consistency check against the on-chain commitments ([Recovery](#recovery), step 7) and Pedersen commitments are binding ([Pedersen Commitments](primitives.md#pedersen-commitments)), so a tampered or truncated history cannot yield an opening that verifies against a balance the account does not hold — it yields a detectable mismatch. The residual risk is therefore denial of recovery, a liveness failure, rather than silent corruption of the reconstructed balance. [Trust Model and Client-Side Verification](../indexer.md#trust-model-and-client-side-verification) specifies the archive's trust model and the client-side checks that follow from it. + +### Incoming-transfer spam + +A third party can spam an account with confidential transfers (including zero-value transfers, see [Griefing Resistance](security.md#griefing-resistance) 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. + +--- + +Previous: [Key Hierarchy and Commitment Scheme](keys-and-commitments.md) · Up: [Index](../README.md#protocol) · Next: [Account State](account-state.md) diff --git a/packages/tokens/src/confidential/docs/sdk/README.md b/packages/tokens/src/confidential/docs/sdk/README.md new file mode 100644 index 000000000..0cc012f25 --- /dev/null +++ b/packages/tokens/src/confidential/docs/sdk/README.md @@ -0,0 +1,71 @@ +# Confidential Token: SDK + +Companion specification to the protocol's [Key Hierarchy](../protocol/keys-and-commitments.md#key-hierarchy), [Wallet State and Recovery](../protocol/wallet-state.md), and [Interface](../protocol/interface.md), to [Indexing and Off-Chain State Recovery](../indexer.md), and to the [Disclosure Protocol](../selective-disclosure/protocol.md) and its [Implementation Notes](../selective-disclosure/security.md#implementation-notes). It specifies the client layer those documents assume: the crypto core that mirrors the Noir circuits off-chain, the key derivation the protocol leaves open, the witness and payload construction, the wallet state machine, and the auditor, disclosure, and indexer clients. + +The key words MUST, MUST NOT, SHOULD, SHOULD NOT, and MAY are to be interpreted as in RFC 2119. The normative audience is threefold: + +- **SDK implementers** MUST satisfy [Crypto Core](crypto-core.md) through [Disclosure and Indexer Clients](clients.md). +- **Wallet, auditor, and application integrators** MUST NOT bypass the [Security Requirements](requirements.md#security-requirements); the security properties of the protocol do not survive it. +- **Port authors** (mobile, hardware wallet, a second language) MUST pass the [Conformance Vectors](conformance.md). + +**Scope.** This document specifies obligations, not an API. It does not prescribe function signatures, module names, package layout, or class design, and it does not restate protocol formulas that [the protocol specification](../protocol/README.md) already fixes — each requirement cites its source section instead. + +--- + +## Why the SDK Is Load-Bearing + +Four properties of the protocol place correctness and confidentiality in the client rather than in the contract. + +**The opening exists only off-chain.** A balance is a Pedersen commitment $$C = v \cdot G + r \cdot H$$. The chain stores the point; the opening $$(v, r)$$ that authorizes the next spend lives exclusively in client state ([Wallet State and Recovery](../protocol/wallet-state.md)). A client that loses, misderives, or misaccumulates the opening makes the funds unspendable, and the contract cannot help because it never knew the value. + +**Every amount a user sees is client-decrypted.** The contract performs homomorphic point arithmetic and never learns a value. Balances, transfer amounts, allowances, and audit figures are all produced by client-side decryption of event ciphertexts, so a decryption defect yields a plausible wrong number rather than a visible failure. + +**The client is an enforcement point for canonicality.** Neither the Soroban host nor the verifier distinguishes a canonical $$\mathbb{F}\_r$$ representative from a non-canonical one ([Host deserialiser caveat](../protocol/primitives.md#host-deserialiser-caveat)); the contract enforces canonicality at its boundary, but the client is where the bytes are produced. + +**The client is where all secrets live and all randomness is sampled.** The spending key, the viewing key, every blinding factor, and every per-operation salt originate client-side, so the protocol's confidentiality reduces to the client's key handling and CSPRNG quality. [Poseidon2 Hash](../protocol/primitives.md#poseidon2-hash) makes salt uniqueness a confidentiality requirement: a reused salt repeats the deterministic ephemeral scalar and every sponge mask keyed by it. + +--- + +## Terminology and Layering + +### Terminology + +- **Root** — the secret an implementation feeds to [Derivation](key-derivation.md#derivation)'s derivation: a SEP-0053 signature by a signer on the account, or a raw 32-byte value for an address with no ed25519 signer of its own ([Key Derivation](key-derivation.md)). +- **Opening** — the pair $$(v, r)$$ such that $$C = v \cdot G + r \cdot H$$ for an on-chain commitment $$C$$. +- **Checkpoint** — as defined in [Recovery](../protocol/wallet-state.md#recovery), which enumerates the qualifying events. +- **Witness material** — any value that appears as a private witness in any circuit: $$sk$$, $$vk$$, $$dvk\_i$$, $$v$$, $$r$$, $$r\_e$$, $$v\_{\text{transfer}}$$, and every intermediate derived from them. +- **Trust boundary** — the process and storage under the account holder's exclusive control. Witness material inside it is secret; witness material that crosses it is disclosed. +- **In-flight operation** — a submitted operation whose event has not yet been observed. Its projected post-operation opening is known locally but not yet confirmed against chain state. +- **Facade** — a role-scoped interface over the crypto core ([Roles and Capability Separation](#roles-and-capability-separation)). + +### Layering + +An implementation MUST separate the following concerns. The boundaries are normative because [Conformance and Versioning](requirements.md#conformance-and-versioning) defines conformance over the lower layers only; the upper layers are deployment-shaped and deliberately unconstrained in structure. + +| Layer | Section | Responsibility | Purity | +|:--|:--|:--|:--| +| Crypto core | [crypto-core.md](crypto-core.md) | Field and curve arithmetic, Poseidon2, derivations, encodings | Deterministic; no I/O; holds no state | +| Key derivation | [key-derivation.md](key-derivation.md) | Root → $$sk$$ → the [Key Hierarchy](../protocol/keys-and-commitments.md#key-hierarchy) hierarchy | Deterministic; no I/O | +| Conformance vectors | [conformance.md](conformance.md) | Fixture and circuit-execution parity | Test-only | +| Witness assembly | [proving.md](proving.md#witness-assembly) | Per-circuit private witnesses and public inputs | Deterministic given randomness | +| Prover | [proving.md](proving.md#prover) | Circuit artifacts, verification keys, proof generation | I/O; pluggable backend | +| Chain adapter | [proving.md](proving.md#chain-adapter) | Reads, payload encoding, submission, typed errors | I/O | +| Role facades | [wallet.md](wallet.md), [auditor-client.md](auditor-client.md), [clients.md](clients.md) | Holder wallet, auditor, disclosure, indexer clients | Stateful | + +--- + +## Roles and Capability Separation + +Five roles consume the protocol. Each holds distinct key material and MUST be *structurally* incapable of exceeding its capability. + +| Role | Holds | Can | +|:--|:--|:--| +| Holder | Root, $$sk$$, $$vk$$ | Spend, withdraw, merge, delegate, disclose, read own balances | +| Spender | Own $$sk\_{\text{op}}$$, escrowed $$dvk\_i$$ | Spend from the allowance, read allowance state, disclose own spender transfers | +| Auditor | Auditor secret $$k$$ | Decrypt both channels for accounts bound to its `auditor_id` ([Per-Transfer Auditor Ciphertexts](../protocol/auditing.md#per-transfer-auditor-ciphertexts)) | +| Disclosure recipient | $$(r\_R, P\_R)$$ | Verify a disclosure proof and recover the disclosed amount | +| Observer | Nothing | Read commitments, ciphertexts, ephemerals, addresses, public amounts | + +--- + +Up: [Index](../README.md#companions) · Next: [Crypto Core](crypto-core.md) diff --git a/packages/tokens/src/confidential/docs/sdk/auditor-client.md b/packages/tokens/src/confidential/docs/sdk/auditor-client.md new file mode 100644 index 000000000..31b56b0bc --- /dev/null +++ b/packages/tokens/src/confidential/docs/sdk/auditor-client.md @@ -0,0 +1,19 @@ +# Auditor Client + +An auditor decrypts from the public event and its own secret $$k$$ alone, with no viewing key, holder cooperation, or extra on-chain read. For each channel it computes the shared scalar against the event's ephemeral point, derives that channel's lane masks ([Poseidon2 sponge](crypto-core.md#poseidon2-sponge)) — three on the sender / owner channel, two on the recipient channel — and subtracts. The allowance opening comes straight out of the event: the blinding of the $$C\_a$$ that operation writes is escrowed in the event itself — tag 17 on `SetSpender`, `lane[2]` on `SpenderTransfer` — and the matching value is in the sender-channel ciphertext ([Spender Allowance Auditing](../protocol/auditing.md#spender-allowance-auditing)). An auditor that did not observe the event holds no opening for that state. + +What each channel yields per lane is fixed by the lane map in [Auditor Visibility Properties](../protocol/auditing.md#auditor-visibility-properties). + +An implementation MUST squeeze the sender / owner channel three-wide and MUST NOT widen the recipient channel. `RevokeSpender` opens no auditor channel, the fold being proofless ([Revoke Spender](../protocol/operations/revoke-spender.md)); an implementation MUST treat that event as carrying no escrowed blinding rather than substituting a stale one. + +**Cross-channel agreement.** Where an auditor holds the key for both parties, the amount decrypts independently on each channel and the circuit constrains both to the same value, so the two MUST agree. An implementation SHOULD perform this comparison and treat disagreement as evidence that $$k$$ is not the auditor key for both parties of that event. + +**Scope MUST be represented, not implied.** The recipient-channel capability is forward-only, reset by merge, and yields no opening of the spendable commitment ([Per-Transfer Auditor Ciphertexts](../protocol/auditing.md#per-transfer-auditor-ciphertexts)). The `lane[2]` opening of the sender channel is forward-only and **standing**: it opens the spendable commitment as of the checkpoint that escrowed it and stays valid through merges provided the client folded in every inbound flow since the previous merge, one account key serving both channels ([Sender-auditor opening capability](../protocol/auditing.md#sender-auditor-opening-capability)). Maintaining it is the client's job: an implementation MUST add the `lane[0]` amount and `lane[1]` $$r\_{\text{transfer}}$$ of every inbound `Transfer` and `SpenderTransfer` to its stored $$(v, r)$$, and MUST treat each `Deposit` as $$(\text{amount}, 0)$$ ([Deposit](../protocol/operations/deposit.md)). On a `RevokeSpender` event it MUST add the $$(v\_a, r\_a)$$ it recorded for that delegation, the fold being public and the addend already escrowed ([Spender Allowance Auditing](../protocol/auditing.md#spender-allowance-auditing)); where it holds no such record it MUST mark the opening unavailable until the next `lane[2]` escrow rather than carry a stale one. Across a key rotation, what an implementation may carry forward and what it MUST treat as unopened are fixed by [Auditor Key Management and Rotation](../protocol/auditing.md#auditor-key-management-and-rotation). + +**Clawback witness.** In a deployment that enables seizure ([Clawback](../compliance.md#clawback)), the clawback witness is the auditor secret key together with the pair of openings the client already maintains — the standing sender-channel opening of $$C\_{\text{spend}}$$ and the recipient-channel opening of $$C\_{\text{receive}}$$ ([Roles and Separation](../compliance.md#roles-and-separation), [Circuit](../compliance.md#circuit)). An implementation MUST verify both against the on-chain commitments before proving, MUST read the account's current `clawback_nonce` into the public inputs ([Circuit](../compliance.md#circuit)), and on a `Clawback` event MUST advance the standing opening by the `Merge` rule and the event's public `amount` ([Wallet and Auditor Consequences](../compliance.md#wallet-and-auditor-consequences)). + +An auditor facade MUST NOT be able to construct a spending witness. It can open the spendable balance past a merge — the inbound $$r\_{\text{transfer}}$$ reaches the same key on the recipient channel — so a facade that exposes openings exposes them for the account's whole history under the active key. + +--- + +Previous: [Holder Wallet](wallet.md) · Up: [Index](../README.md#companions) · Next: [Disclosure and Indexer Clients](clients.md) diff --git a/packages/tokens/src/confidential/docs/sdk/clients.md b/packages/tokens/src/confidential/docs/sdk/clients.md new file mode 100644 index 000000000..c1a1bfb99 --- /dev/null +++ b/packages/tokens/src/confidential/docs/sdk/clients.md @@ -0,0 +1,33 @@ +# Disclosure and Indexer Clients + +## Disclosure construction + +An implementation supporting selective disclosure MUST follow the [Selective Disclosure](../selective-disclosure/README.md) specification for the holder, sender, and auditor variants, and MUST bind each proof to the requesting recipient's key and nonce so that a proof cannot be replayed against a different recipient or a later request ([Disclosure Recipient](../selective-disclosure/README.md#disclosure-recipient)). A D-auditor witness reads each auditor channel at the width [Poseidon2 sponge](crypto-core.md#poseidon2-sponge) fixes for its tag — three lanes on $$\delta\_{\text{aud\\\_s}}$$, two on $$\delta\_{\text{aud\\\_r}}$$ — exactly as [Auditor Client](auditor-client.md) requires of the auditor client ([D-auditor](../selective-disclosure/circuits/d-auditor.md) A3). + +Not every historical transfer is disclosable by its sender: one predating [Deterministic ephemeral scalars](wallet.md#deterministic-ephemeral-scalars)'s requirement may carry an ephemeral scalar that does not reproduce, and the disclosure recipient holds no key material that could detect this. The disclosing wallet MUST therefore establish disclosability before constructing a D-sender proof: derive the candidate $$r\_e$$ from $$(vk, \sigma\_E)$$ and compare $$r\_e \cdot H$$ against the event's $$R\_e$$. A mismatch MUST surface to the caller as a distinct *not disclosable* outcome that carries neither a proof nor an amount, and MUST NOT be reported as a proof-construction error. The comparison costs one Poseidon2 call and one scalar multiplication and is authoritative, where a stored per-transfer flag is not ([Deterministic ephemeral scalars](wallet.md#deterministic-ephemeral-scalars)). + +Disclosure circuits are verified entirely off-chain and MUST NOT be registered with the on-chain verifier set ([Circuits](../selective-disclosure/security.md#circuits)). + +## Disclosure verification + +The verifier MUST be distributable independently of any wallet, since its purpose is to let a party who trusts no holder check a claim. It consumes a chain endpoint, the recipient keypair, and the proof bundle with its event reference, and returns the disclosed amount or a **typed** indication of which check failed — proof verification, on-chain state mismatch, or decryption failure. A single boolean is not conformant, because the three outcomes have different meanings to the recipient. + +Verification MUST include comparing the circuit's verification key against the pinned key for that disclosure circuit, without which the proof attests to an unknown statement. + +## Indexer client + +Recovery beyond the RPC retention window requires a conforming durable archive ([Indexing and Off-Chain State Recovery](../indexer.md)). A client MUST propagate the archive's completeness signal to its caller rather than swallowing it, since an incomplete range and a tampered range both end in the same refusal at [Consistency checking](wallet.md#consistency-checking) and only that signal distinguishes them. + +Clients SHOULD support multiple independent archive endpoints, since withholding is the residual trust the archive retains ([Trust Model and Client-Side Verification](../indexer.md#trust-model-and-client-side-verification)). + +## The hybrid read path and its two failure modes + +RPC and archive compose: the RPC serves the recent tail, the archive everything older, and the client stitches them at a seam ([Why the Indexer Is Load-Bearing](../indexer.md#why-the-indexer-is-load-bearing)). Two requirements are not derivable from [Indexing and Off-Chain State Recovery](../indexer.md) and are specified here. + +**The seam MUST sit strictly above the RPC's reported retention floor, by a margin.** The floor advances as ledgers are collected, including between the moment the client reads it and the moment it issues the range query, with the archive request in between taking real time. A seam placed exactly at the observed floor therefore intermittently produces a rejected query. The archive covers everything below the seam, so a margin loses no events. Implementations MUST set the two legs to disjoint ledger ranges so that correctness does not depend on cross-source id equality, and MUST still deduplicate ([Event application](wallet.md#event-application)) as a guard at the boundary. + +**A configured archive's failure MUST fail the whole sync.** If an archive is configured and its request fails, an implementation MUST NOT degrade silently to RPC-only and MUST NOT persist a sync position derived from the RPC leg alone. Persisting it moves the position past the pre-window range, so every later sync takes the warm path that never consults the archive and the openings in the skipped range become unrecoverable. An archive that is *not* configured is a different case and MAY be absent, provided [Recovery](wallet.md#recovery)'s warning is surfaced. + +--- + +Previous: [Auditor Client](auditor-client.md) · Up: [Index](../README.md#companions) · Next: [Security, Non-Functional, and Conformance Requirements](requirements.md) diff --git a/packages/tokens/src/confidential/docs/sdk/conformance.md b/packages/tokens/src/confidential/docs/sdk/conformance.md new file mode 100644 index 000000000..6fda60e99 --- /dev/null +++ b/packages/tokens/src/confidential/docs/sdk/conformance.md @@ -0,0 +1,28 @@ +# Conformance Vectors + +## Primitive fixtures + +`circuits/lib/testdata/*.json` pins one primitive's output per file for a fixed input set, and is the language-agnostic contract for every off-chain consumer. An implementation MUST reproduce every output in every file byte-for-byte. + +Its test suite MUST **read** those files rather than transcribe their values into source, so that a change to the Noir library's `print_fixtures` output becomes a test failure rather than a silent divergence. + +## Circuit-execution parity + +Fixtures pin primitives, not witnesses. A client can reproduce every fixture and still assemble a witness with a transposed public input, a wrong field name, or a stale ordering, none of which any fixture covers. + +An implementation MUST therefore additionally, for every circuit it supports: + +- Build the witness from its own crypto core and have the **real compiled circuit** solve it, asserting success. +- Include **tamper cases** — a witness with one value perturbed — and assert the circuit *rejects* them. + +## Vectors this specification requires + +Three derivations this document specifies or relies on are computed outside the core circuits, so the Noir library's `print_fixtures` does not emit them. The first already has a fixture; the other two require one: + +- **$$\text{address\\\_to\\\_field}$$** ([Address compression](crypto-core.md#address-compression)) is pinned by `circuits/lib/testdata/address_to_field.json`, which [Primitive fixtures](#primitive-fixtures)'s obligation already covers. The circuits receive $$\text{addr\\\_f}$$ as an opaque public input, so this derivation is implemented twice — by the contract on-chain and by every client — making it the sole primitive with two independent implementations and no Noir version, and [Address compression](crypto-core.md#address-compression)'s bootstrap check detects a divergence only against an already-deployed contract. Being outside `print_fixtures`, it is also outside the in-Noir `fixtures_match_testdata` guard; the Rust test `address_to_field_matches_testdata_vectors` guards it instead (`circuits/lib/testdata/README.md`). +- **$$\delta\_{\text{eph}}$$ derivation** ([ECDH-Derived Blinding](../protocol/keys-and-commitments.md#ecdh-derived-blinding), restated in [Deterministic ephemeral scalars](wallet.md#deterministic-ephemeral-scalars)). No circuit constrains $$r\_e$$, so a fixture is the only mechanism keeping a user's clients in agreement; where two disagree, transfers sent from one are not disclosable from the other. +- **The [Derivation](key-derivation.md#derivation) $$sk$$ chain**, from a fixed root, $$\text{addr\\\_f}$$, and $$\text{acct\\\_f}$$ through to $$sk$$, $$vk$$, $$Y$$, and $$\text{PVK}$$, without which recovery from backup material is untestable across implementations. The vector MUST start from a fixed ed25519 secret and run through [Signer roots](key-derivation.md#signer-roots)'s message, its SEP-0053 preimage, and the resulting signature, since the signature is where two clients most plausibly diverge — the only step in the chain whose format is set outside this document. A second vector from a fixed raw root covers [Raw roots and imported keys](key-derivation.md#raw-roots-and-imported-keys). + +--- + +Previous: [Key Derivation](key-derivation.md) · Up: [Index](../README.md#companions) · Next: [Witness Assembly, Prover, and Chain Adapter](proving.md) diff --git a/packages/tokens/src/confidential/docs/sdk/crypto-core.md b/packages/tokens/src/confidential/docs/sdk/crypto-core.md new file mode 100644 index 000000000..463bf41dc --- /dev/null +++ b/packages/tokens/src/confidential/docs/sdk/crypto-core.md @@ -0,0 +1,90 @@ +# Crypto Core + +Every requirement in this section is reproduced from `circuits/lib/src/lib.nr`, which is the source of truth wherever this document and the protocol documents disagree. + +## Fields and the `q` / `p` notation hazard + +Two moduli are in play, and confusing them silently corrupts state ([Blinding accumulation](#blinding-accumulation)). + +| Modulus | Value | Role | Called elsewhere | +|:--|:--|:--|:--| +| $$r$$ | `0x30644e72e131a029b85045b68181585d2833e84879b9709143e1f593f0000001` | BN254 scalar field. Noir's `Field`; the host's `Bn254Fr`; Grumpkin **coordinate** field | `FR_MODULUS` | +| $$q$$ | `0x30644e72e131a029b85045b68181585d97816a916871ca8d3c208c16d87cfd47` | BN254 base field. Grumpkin **scalar** (multiplier) field, i.e. the group order | `FP_MODULUS`, and conventionally $$p$$ in the pairing literature | + +$$r < q$$, so every $$\mathbb{F}\_r$$ element is already a valid Grumpkin scalar with no reduction, which is why a Noir `Field` can be passed to `multi_scalar_mul` unambiguously. + +## Canonicality + +A value is a **canonical** $$\mathbb{F}\_r$$ representative iff it is a 32-byte big-endian encoding of an integer in $$[0, r)$$. + +- Every $$\mathbb{F}\_r$$ value the SDK emits — into a payload, an event assertion, a proof input, or persisted state — MUST be canonical. +- The SDK MUST reject a non-canonical value at its own boundary rather than relying on the contract's check ([Grumpkin-BN254 Cycle](../protocol/primitives.md#grumpkin-bn254-cycle)). A client that produces non-canonical bytes has already lost byte-uniqueness in the local state that recovery reads from. +- Points are encoded as `BytesN<64>` = $$\text{be}(x) \\| \text{be}(y)$$, a **flat** 64-byte value. The identity $$\mathcal{O}$$ is all 64 bytes zero, and decodes back to the identity. + +## Poseidon2 sponge + +The sponge construction, its width and rate, the IV placement, the padding rule, and the two- and three-lane forms $$\text{SpongeSqueeze}\_2$$ and $$\text{SpongeSqueeze}\_3$$ are specified normatively in [Poseidon2 Hash](../protocol/primitives.md#poseidon2-hash). What follows is what that construction additionally requires of a client. + +**Three self-checks are available before any proof is generated.** The absorbed length in both squeeze forms is always 3, so the IV is fixed at $$3 \cdot 2^{64}$$; `lane[0]` is identical to $$\text{poseidon\\\_with\\\_domain}(\delta, [s, \sigma])$$ on the same inputs; and $$\text{SpongeSqueeze}\_3(\delta, s, \sigma)[i] = \text{SpongeSqueeze}\_2(\delta, s, \sigma)[i]$$ for $$i \in \\{0, 1\\}$$, since the absorb fits one rate-3 block and both forms read the same permutation. An implementation that reproduces all three has the block layout and the IV lane right. The third is pinned by `circuits/lib/testdata/sponge_squeeze_3.json`. + +**The domain-tagged funnel.** Every Poseidon2 invocation in the protocol routes through one entry point that places the domain tag as the **first absorbed element**: + +$$\text{poseidon\\\_with\\\_domain}(\delta, [x\_1, \ldots, x\_n]) = \text{sponge}([\delta, x\_1, \ldots, x\_n])$$ + +Squeeze-slot assignment is canonical and MUST be followed as [Lane assignment](../protocol/primitives.md#lane-assignment) fixes it, and each channel MUST be read at exactly the width its tag is assigned ([Mode exclusivity](../protocol/primitives.md#mode-exclusivity)). + +## Generators and commitments + +$$G$$ and $$H$$ are Barretenberg's `derive_generators("DEFAULT_DOMAIN_SEPARATOR")` outputs at indices 0 and 1, fixed in [Noir Primitives](../protocol/proof-system.md#noir-primitives). + +$$\text{commit}(v, r) = v \cdot G + r \cdot H$$ + +Scalar multiplication MUST map a zero scalar to the identity rather than erroring, because a registered account's opening commitments are the identity and a zero blinding factor is legitimate (deposits commit with $$r = 0$$, [Deposit](../protocol/operations/deposit.md)). + +## ECDH + +$$\text{ECDH}(a, B) = \text{poseidon\\\_with\\\_domain}(\delta\_{\text{ecdh}}, [S.x, S.y]) \quad \text{where} \quad S = a \cdot B$$ + +**Both coordinates MUST be absorbed.** An x-only extraction is negation-invariant: $$P$$ and $$-P$$ share an x-coordinate, and $$-\text{PVK} = (-vk) \cdot H$$ is itself a valid canonical registration, so an x-only map would collapse each $$(vk, -vk)$$ pair onto one shared secret ([Elliptic Curve Diffie-Hellman](../protocol/primitives.md#elliptic-curve-diffie-hellman)). The absorb fills exactly one rate-3 block. + +The derivation MUST fail rather than proceed if $$S$$ is the identity: with $$\sigma$$ public, an identity shared secret makes every derived ciphertext trivially decryptable, which is why the circuits carry explicit nonzero-scalar constraints ([On-Chain Point Arithmetic](../protocol/proof-system.md#on-chain-point-arithmetic)). + +## Blinding accumulation + +Commitment blinding factors compose under homomorphic point addition, so their arithmetic is that of the **Grumpkin scalar field** $$\mathbb{F}\_q$$ ([Fields and the `q` / `p` notation hazard](#fields-and-the-q--p-notation-hazard)): + +$$\text{Com}(v\_1, r\_1) + \text{Com}(v\_2, r\_2) = \text{Com}(v\_1 + v\_2, \\, (r\_1 + r\_2) \bmod q)$$ + +Reducing modulo $$r$$ instead yields an opening that is off and no longer matches the on-chain point, and for two full-size blindings the integer sum crosses $$q$$ roughly half the time. Implementations MUST provide distinct, clearly named reduction operations for the two moduli. + +**One reduction point.** An implementation MUST fold blindings with unbounded integer addition and MUST NOT reduce by either modulus as folds compose. This governs merge ([Merge](../protocol/operations/merge.md)), the `RevokeSpender` and `Clawback` folds, and receiving-balance credit ([Update rules](../protocol/wallet-state.md#update-rules)) alike. + +An implementation MUST reduce modulo $$q$$ to the canonical representative only where a blinding leaves an accumulator for the curve: a proof witness, or the recommit of the [Consistency checking](wallet.md#consistency-checking) consistency check. [The unspendable-blinding case](wallet.md#the-unspendable-blinding-case) tests for encodability at that same point. + +Committed **values** accumulate as exact integers and are never reduced; [Pedersen Commitments](../protocol/primitives.md#pedersen-commitments) establishes that they never wrap. + +## Scalar sampling + +Sampled scalars — $$\sigma$$, $$\sigma\_a$$, the replacement $$\sigma\_a'$$ of a spender transfer ([Transfer nonce](../protocol/account-state.md#transfer-nonce)), and the disclosure layer's $$r\_{\text{disc}}$$ ([Disclosure Ciphertext to Recipient](../selective-disclosure/protocol.md#disclosure-ciphertext-to-recipient)) — MUST be produced by the rejection procedure of [Grumpkin-BN254 Cycle](../protocol/primitives.md#grumpkin-bn254-cycle): + +1. Draw 32 bytes from a CSPRNG. +2. Clear the top **2** bits, yielding a 254-bit candidate. +3. Reject and redraw if the candidate is $$\geq r$$, or if it is zero and the call site requires nonzero. + +The requirement is unpredictability and uniform distribution, not confidentiality. The protocol emits $$\sigma$$, $$\sigma\_a$$, and $$\sigma\_a'$$ in events and stores $$\sigma\_a$$ and $$\sigma\_a'$$ on-chain as `allowance_salt` ([Event application](wallet.md#event-application), [`allowance_salt`](../protocol/account-state.md#allowance_salt)), and a wallet MAY persist any of them for [Recovery](wallet.md#recovery). + +## Domain separators + +The seventeen tag values, the layer that absorbs each, and the sponge mode each is confined to are fixed in [Domain Separation Constants](../protocol/domain-separators.md). An implementation MUST hardcode those values and MUST NOT read any tag at a second arity ([Mode exclusivity](../protocol/primitives.md#mode-exclusivity)). + +## Address compression + +$$\text{address\\\_to\\\_field}(a) = \text{poseidon\\\_with\\\_domain}(\delta\_{\text{addr}}, [\text{lo}(a), \text{hi}(a)])$$ + +where $$\text{enc}(a)$$ is the 56-character ASCII strkey (SEP-23), and $$\text{lo}$$ and $$\text{hi}$$ interpret its lower and upper 28 bytes respectively in **little-endian** order ([Address-to-Field Encoding](../protocol/primitives.md#address-to-field-encoding)). Implementations MUST obtain the strkey from their language's stellar-strkey library. + +**Bootstrap check.** On first contact with a deployment, an implementation MAY compute $$\text{addr\\\_f}$$ for the contract's own address and assert equality against the value the contract stores in instance storage ([Governance and Upgradeability](../protocol/system-model.md#governance-and-upgradeability)). + +--- + +Previous: [SDK](README.md) · Up: [Index](../README.md#companions) · Next: [Key Derivation](key-derivation.md) diff --git a/packages/tokens/src/confidential/docs/sdk/key-derivation.md b/packages/tokens/src/confidential/docs/sdk/key-derivation.md new file mode 100644 index 000000000..0ed242ef9 --- /dev/null +++ b/packages/tokens/src/confidential/docs/sdk/key-derivation.md @@ -0,0 +1,90 @@ +# Key Derivation + +[Key Hierarchy](../protocol/keys-and-commitments.md#key-hierarchy) specifies the hierarchy below $$sk$$ — $$vk$$ from $$(sk, \text{addr\\\_f})$$, $$\text{PVK}$$ from $$vk$$, $$dvk\_i$$ from $$(vk, \text{op}\_i)$$. It does not specify where $$sk$$ itself comes from. This section supplies a derivation, because recovery from backup material is a stated protocol property ([Recovery](../protocol/wallet-state.md#recovery), [Recovery Properties](../protocol/wallet-state.md#recovery-properties), [Why the Indexer Is Load-Bearing](../indexer.md#why-the-indexer-is-load-bearing)) and two clients given the same backup material would otherwise derive different accounts. + +The derivation is a single function ([Derivation](#derivation)) over a **root**, and the root's class is determined by what controls the address rather than chosen per client. An address controlled by a Stellar ed25519 key uses a deterministic signature by that key ([Signer roots](#signer-roots)). An address with no ed25519 key of its own — a smart account or any other contract address — uses raw bytes from whatever custody mechanism controls it ([Raw roots and imported keys](#raw-roots-and-imported-keys)). Tying the class to the address rather than to client preference is what keeps two clients from disagreeing about which class produced an account's $$sk$$, a disagreement `register` being single-use ([Interface](../protocol/interface.md)) would make unrepairable. The one residual case is [Raw roots and imported keys](#raw-roots-and-imported-keys)'s fallback, where an ed25519-controlled address has no way to sign the [Signer roots](#signer-roots) message; a client MUST therefore read a $$Y$$ mismatch under [Signer roots](#signer-roots) as evidence that the account uses a root it does not hold, not as a derivation defect. + +## Derivation + +$$sk = \text{RS}\Big(\text{HKDF-SHA-512}\big(\text{IKM} = \text{root}, \\;\\; \text{salt} = \texttt{"openzeppelin/confidential-token/v1/sk"}, \\;\\; \text{info} = \text{be}\_{32}(\text{addr\\\_f}) \\,\\|\\, \text{be}\_{32}(\text{acct\\\_f}) \\,\\|\\, \text{le}\_{4}(j)\big)\Big)$$ + +where: + +| Input | Definition | +|:--|:--| +| $$\text{IKM}$$ | RFC 5869's input keying material: the byte string HKDF-Extract consumes, here the account's root | +| $$\text{root}$$ | Exactly one of — the 64-byte ed25519 signature of [Signer roots](#signer-roots), or a raw 32-byte value ([Raw roots and imported keys](#raw-roots-and-imported-keys)) | +| $$\text{addr\\\_f}$$ | $$\text{address\\\_to\\\_field}$$ of the confidential token contract ([Address compression](crypto-core.md#address-compression)) | +| $$\text{acct\\\_f}$$ | $$\text{address\\\_to\\\_field}$$ of the address being registered | +| $$j$$ | Rejection counter, starting at 0 | +| $$\text{RS}$$ | The [Scalar sampling](crypto-core.md#scalar-sampling) procedure applied to the 32-byte HKDF output: clear the top 2 bits, accept iff the result is in $$[1, r)$$, otherwise increment $$j$$ and re-derive | + +The candidate MUST also be rejected if the resulting $$vk = \text{poseidon\\\_with\\\_domain}(\delta\_{\text{vk}}, [sk, \text{addr\\\_f}])$$ is zero, since registration constraint R5 requires $$vk \neq 0$$. + +**The IKM is the root's bytes, verbatim.** HKDF-Extract accepts input keying material of any length, the 64 signature bytes or the 32 raw bytes go in as they are. + +Why each element is present: + +**HKDF-SHA-512, not Poseidon2.** No circuit constrains how $$sk$$ was obtained — the register circuit constrains only $$Y = sk \cdot H$$ (R1) and $$vk$$'s derivation from $$sk$$ (R2). There is therefore no in-circuit consistency argument for Poseidon2 here, and SHA-512 keeps the custody path on the primitive ed25519 already uses internally, which every Stellar SDK and secure element therefore already implements. Implementations MUST NOT substitute a different KDF, since the choice is arbitrary in isolation but must be identical across clients. + +**Bound to $$\text{addr\\\_f}$$.** $$vk$$ is already deployment-scoped by [Viewing Key](../protocol/keys-and-commitments.md#viewing-key), which bounds the blast radius of a *viewing*-key compromise to one deployment. Binding $$sk$$ likewise bounds a *spending*-key compromise, and the contract address is known whenever a client talks to a deployment. + +**Bound to $$\text{acct\\\_f}$$.** $$vk$$ depends only on $$(sk, \text{addr\\\_f})$$, so the same $$sk$$ registered under two addresses yields the same $$vk$$, hence identical $$Y$$ and identical $$\text{PVK}$$ published under both accounts and readable by any observer through the account read method, linking two addresses that are otherwise unlinkable. Implementations MUST derive a distinct $$sk$$ per address. + +**No account-index input.** Binding $$\text{acct\\\_f}$$ makes a separate SEP-0005 index redundant: the address determines the account, and the index is merely the path that produced the address. + +## Signer roots + +A signer root is a SEP-0053 signature over a message naming this protocol, the deployment, and the account: + +$$\text{msg} = \texttt{"openzeppelin/confidential-token/v1/sk"} \\,\\|\\, \texttt{0x0a} \\,\\|\\, \text{enc}(\text{contract}) \\,\\|\\, \texttt{0x0a} \\,\\|\\, \text{enc}(\text{account})$$ + +$$\text{root} = \text{Ed25519-Sign}\big(sk\_{\text{ed}}, \\;\\; \text{SHA-256}(\text{prefix} \\,\\|\\, \text{msg})\big)$$ + +where `prefix` is SEP-0053's 24 ASCII bytes `Stellar Signed Message:\n`, $$\text{enc}$$ is the 56-character strkey of [Address compression](crypto-core.md#address-compression), and $$sk\_{\text{ed}}$$ is the ed25519 secret of a signer on the account. The message is 151 bytes, printable ASCII apart from its two separators, and carries the strkeys rather than their [Address compression](crypto-core.md#address-compression) compressions so that a wallet rendering SEP-0053 messages as text shows the user addresses they can compare against the deployment they intend to register on. The signature is the 64-byte RFC 8032 encoding $$R \\,\\|\\, S$$ that every Stellar SDK and SEP-0053 wallet already returns, and those 64 bytes are the IKM. + +Binding both addresses into the *message* rather than relying on [Derivation](#derivation)'s `info` alone is what bounds a harvested signature: a dapp that tricks a user into signing once obtains the root for that account on that deployment, not for every account the key controls on every deployment. + +**The SEP-0053 envelope is mandatory even where the secret is extractable.** A client holding the raw ed25519 secret MUST compute this signature itself rather than use the secret's 32 bytes as the IKM directly. One form then covers both custody shapes: an account enrolled through a wallet prompt is reproducible by a client that later imports the secret, and the reverse. + +**The ed25519 key is a single point of failure, and this MUST be disclosed.** Whoever obtains the ed25519 secret can recompute the signature and therefore $$sk$$, gaining both view and spend of the confidential account. `register` is single-use, so $$sk$$ cannot be rotated in place, and remediation means registering a fresh address and moving the funds through a transfer. An implementation MUST state, at the point where it offers to create a confidential account, that the account's confidentiality is bounded by the secrecy of the account's signing key. + +**Availability is not guaranteed.** A signer root exists only where the custody stack implements SEP-0053 message signing, and support across Stellar wallets and hardware apps is uneven. An implementation MUST treat its absence as an expected outcome and fall back to a raw root ([Raw roots and imported keys](#raw-roots-and-imported-keys)), which is not reproducible from anything the user already holds and therefore MUST be backed up explicitly, rather than fail enrolment. + +**Verify the signature before using it.** An implementation MUST verify the returned signature against the ed25519 public key it expects to have signed, and MUST abort on mismatch. A wallet with a different account selected returns a well-formed signature over the same message, yielding a wrong but entirely usable $$sk$$: registration succeeds, and the account is then unreproducible from the key the user believes controls it. + +**Determinism is a precondition.** RFC 8032 ed25519 derives its nonce from the secret and the message, so a conforming signer returns the same 64 bytes forever. Signers that randomise the nonce do not, and threshold and MPC ed25519 are in that category — the nonce is generated per signing session, so a signature does not reproduce in the next one. An implementation MUST obtain the signature twice from independent invocations and MUST abort if they differ. That detects the common case and not every case, since a signer can be deterministic within a session and not across sessions, so an implementation MUST additionally offer $$sk$$ export as a direct-import backup ([Raw roots and imported keys](#raw-roots-and-imported-keys)) and SHOULD prompt for it before the account first receives funds. + +**The IKM MUST NOT be persisted.** The 64-byte signature is equivalent to $$sk$$ for this account and deployment. Implementations MUST derive on demand; where $$sk$$ itself is cached, [Security Requirements](requirements.md#security-requirements)'s storage-at-rest rules govern it. + +**Record the enrolled signer.** A Stellar address may have signers besides its master key, and $$sk$$ is keyed to the *address* through $$\text{acct\\\_f}$$ rather than to the key that signed for it, so which signer enrolled is not recoverable from the address or from chain state. Implementations MUST record the enrolled ed25519 public key and MUST NOT assume it is the master key. A second client enrolling the same address with a different signer derives a different $$sk$$ whose $$Y$$ does not match the registered spending public key. [Recovery and account discovery](#recovery-and-account-discovery)'s comparison detects that, and an implementation holding more than one candidate signer SHOULD resolve it by trying each and adopting the one whose $$Y$$ matches, rather than reporting a mismatch under whichever it tried first. + +**Signer rotation orphans the account.** `set_options` can replace or remove the key controlling an address, and the confidential account survives that rotation while its root does not. An implementation SHOULD compare the recorded signer against the address's current signer set on sync and surface a warning when the enrolled key is no longer among them, because discarding that key after rotation destroys the only path back to $$sk$$. + +**Discovery costs one signature per candidate.** [Recovery and account discovery](#recovery-and-account-discovery)'s scan derives $$sk$$ once per candidate index, and each derivation needs a signature by that index's key. A client holding the mnemonic signs locally and scans offline; a client fronted by a custody stack spends a user approval per candidate and reaches nothing for an index whose key that stack will not sign with. An implementation MUST surface which of the two it is doing rather than presenting a bounded scan as exhaustive. + +## Raw roots and imported keys + +Implementations MUST accept a raw 32-byte `root`, those 32 bytes being the IKM. It is the class for every address with no ed25519 key of its own, and three cases require it: + +- **Contract addresses.** A confidential account registered by a smart account or other contract address has no ed25519 signer to sign [Signer roots](#signer-roots)'s message, so its root comes from whatever custody mechanism controls the contract. Where a smart account authorises through signers of its own, the root is supplied by that mechanism rather than derived from any one of them, since the set can change without the address changing. +- **No SEP-0053 path.** An ed25519-controlled account whose custody stack cannot sign arbitrary messages ([Signer roots](#signer-roots)) falls back here. +- **Imported keys.** Deployments predating this specification hold $$sk$$ values sampled directly from a CSPRNG with no root behind them. Such a key MUST remain usable as a first-class account secret via direct import, bypassing [Derivation](#derivation) entirely. + +A raw root is reproducible from nothing the user already holds, so an implementation that generates one MUST surface it for backup at creation and MUST NOT treat it as recoverable from the account's other credentials. + +An implementation MUST record, per account, which of the three forms produced its $$sk$$ — signer root, raw root, or direct import — because they differ in what regenerates the key: a live signer, a stored 32-byte value, or nothing beyond the stored $$sk$$. A user MUST NOT be shown a recovery affordance an account's form cannot satisfy. + +## Recovery and account discovery + +Recovering a signer-root account requires the enrolled signer ([Signer roots](#signer-roots)) and the contract address. Where the signer came from a mnemonic, the mnemonic, its passphrase if any, and the SEP-0005 index reproduce it; where it did not, the signer itself must still be held. The set of addresses is discovered rather than remembered: + +1. Enumerate candidate Stellar addresses by scanning SEP-0005 indices $$i = 0, 1, 2, \ldots$$ from the seed. +2. For each candidate address, compute $$\text{acct\\\_f}$$, obtain the [Signer roots](#signer-roots) signature from that index's key, derive $$sk$$ per [Derivation](#derivation), and compute $$Y = sk \cdot H$$. +3. Read the account record at that address and compare its stored spending public key against $$Y$$. A match identifies a registered confidential account belonging to this signer. + +Step 2 needs a signature rather than a hash, so a client that cannot sign locally SHOULD drive recovery from its own per-account record instead of scanning ([Signer roots](#signer-roots)). An account registered under a raw root, or under a signer that is neither held nor reproducible, is not reachable by any scan: only the direct import of a previously exported $$sk$$ recovers it. + +--- + +Previous: [Crypto Core](crypto-core.md) · Up: [Index](../README.md#companions) · Next: [Conformance Vectors](conformance.md) diff --git a/packages/tokens/src/confidential/docs/sdk/proving.md b/packages/tokens/src/confidential/docs/sdk/proving.md new file mode 100644 index 000000000..f5d97ef88 --- /dev/null +++ b/packages/tokens/src/confidential/docs/sdk/proving.md @@ -0,0 +1,81 @@ +# Witness Assembly, Prover, and Chain Adapter + +## Witness Assembly + +An implementation MUST provide witness assembly for each circuit it supports, covering the six circuits of [Circuits](../protocol/proof-system.md#circuits): the five core circuits `Register`, `Withdraw`, `Transfer`, `SpenderTransfer`, `SetSpender`, and `Clawback`, which the auditor role proves in a deployment that enables the compliance extension ([Auditor Client](auditor-client.md), [Circuit](../compliance.md#circuit)). + +**Public-input order is a wire contract.** The verifier sees an ordered vector of field elements with no knowledge of what they denote ([Public Input Sources](../protocol/operations/README.md#public-input-sources)), so a permutation of two same-typed inputs produces a well-formed vector that verifies a different statement. Each builder MUST assemble public inputs in exactly the order the contract assembles them, and MUST cite the contract function it mirrors at the site of the ordering. The per-operation public-input tables in the [operation specifications](../protocol/operations/README.md) are authoritative for *membership*; the contract's assembly is authoritative for *order*. + +**The trust-boundary rule constrains the client too.** [Public Input Sources](../protocol/operations/README.md#public-input-sources) requires the contract to load state-derived public inputs itself and never accept them from the caller. The client-side corollary: a builder MUST NOT include a contract-loaded input in the payload it submits. Doing so does not break soundness, since the contract ignores it, but it produces a payload whose fields disagree with the proof's public inputs. + +**Prover-supplied values MUST be freshly derived per attempt**, never reused from a previous attempt at the same logical operation ([Salt freshness](wallet.md#salt-freshness)). + +Builders SHOULD return, alongside the witness and payload, the projected post-operation opening and — for transfer-family circuits — the values the recipient will recover, both of which [Holder Wallet](wallet.md) needs. + +--- + +## Prover + +### Toolchain pinning + +Proofs the deployed verifier accepts MUST be generated with the toolchain and non-default flags pinned in `circuits/vks/README.md`. Two flags govern acceptance: + +- **A Keccak Fiat-Shamir transcript is mandatory.** The on-chain verifier reconstructs the transcript with Keccak, while proving backends commonly default to Poseidon2, and a default-transcript proof verifies locally then fails on-chain. Implementations MUST set the Keccak transcript for proof generation, local verification, and verification-key derivation alike. +- **Zero-knowledge mode MUST NOT be enabled** while the verifier implements only the non-zk flavour. + +Circuit artifacts and verification keys MUST record the toolchain version that produced them. Version drift between a client's vendored artifacts and the deployment's pinned toolchain produces verification keys that differ from the deployed ones while every local test passes. + +### Verification-key identity + +Before submitting a proof, an implementation MUST verify that the verification key its circuit artifact implies matches the one the deployment holds for that circuit type. A mismatch means the client and the chain disagree about what is being proven, and the proof cannot succeed. + +Where a deployment permits verification-key rotation ([Governance and Upgradeability](../protocol/system-model.md#governance-and-upgradeability)), an implementation MUST re-check rather than cache across sessions. + +### Backend pluggability and the trust boundary + +Proving MUST sit behind an interface that admits multiple backends — in-process WASM, a native binary, a remote service — because the viable choice differs per platform. Browser bundlers, for instance, break worker-spawning proving libraries by rewriting the worker URL into a hashed chunk, so an implementation targeting browsers MUST allow the backend to be supplied by the host application rather than resolved statically. + +**Witness material MUST NOT cross the trust boundary by default** ([Terminology](README.md#terminology)). Remote proving discloses the spending key, the balance, and the transfer amount to the prover. An implementation MAY offer it, but MUST require explicit opt-in, MUST NOT select it as a fallback when a local backend fails, and MUST state precisely which values leave the device. + +--- + +## Chain Adapter + +### Reads + +The adapter MUST expose the account record and the spender delegation record ([Read Methods](../protocol/interface.md#read-methods)), and MUST treat the contract crate's types as authoritative for their shape. It MUST distinguish the three delegation states that [Read Methods](../protocol/interface.md#read-methods) separates — absent, active, and expired-but-not-yet-revoked — and MUST NOT collapse the latter two. + +Auditor keys MUST be read from the auditor contract by the account's bound `auditor_id`, never supplied by the caller. + +### Payload encoding + +Proof-carrying entry points take an XDR-encoded payload. Its byte representation is fixed by Soroban's canonical XDR rules, so independent implementations compiling against the same `#[contracttype]` definitions produce byte-identical payloads ([Interface](../protocol/interface.md)). + +Two specifics: + +- Points are flat 64-byte values, not nested two-field structures ([Canonicality](crypto-core.md#canonicality)). +- Struct fields serialise as a map with symbol keys in canonical sorted order. + +### Authorization and submission + +Every state-changing operation requires both the appropriate `require_auth()` principal ([Authorization Model](../protocol/interface.md#authorization-model)) and, where applicable, a valid proof. The adapter MUST submit the principal the interface specifies — notably the *spender*, not the owner, for a delegated transfer. + +Operations SHOULD be simulated before submission, which catches a stale commitment, a frozen account, or an expired delegation without a fee. + +### Typed errors + +The adapter MUST surface contract failures as typed, distinguishable outcomes. At minimum it MUST separate: + +| Class | Meaning | Caller's next step | +|:--|:--|:--| +| Proof verification failed | The proof did not verify against the assembled public inputs | Do not retry blindly; check [Verification-key identity](#verification-key-identity) and witness assembly | +| State mismatch | The referenced commitment is no longer current | Re-sync ([Event application](wallet.md#event-application)) and rebuild the proof | +| Not registered | The account or counterparty has no confidential account | Register, or reject the recipient | +| Compliance rejection | Frozen, policy-denied, or unauthorized by the underlying asset ([Contract-Level Freeze](../compliance.md#contract-level-freeze)) | Surface to the user as an administrative state, not an error | +| Delegation state | Absent, duplicate, or expired | Distinguish per [Reads](#reads) | + +Separating the first two matters most: they present as the same opaque failure on the wire but have opposite remedies. + +--- + +Previous: [Conformance Vectors](conformance.md) · Up: [Index](../README.md#companions) · Next: [Holder Wallet](wallet.md) diff --git a/packages/tokens/src/confidential/docs/sdk/requirements.md b/packages/tokens/src/confidential/docs/sdk/requirements.md new file mode 100644 index 000000000..6db57dbb3 --- /dev/null +++ b/packages/tokens/src/confidential/docs/sdk/requirements.md @@ -0,0 +1,52 @@ +# Security, Non-Functional, and Conformance Requirements + +## Security Requirements + +**Secret handling.** The root, $$sk$$, $$vk$$, $$dvk\_i$$, every derived $$r\_e$$, every cached opening, and — on the auditor side — $$k$$ and the accumulated openings of [Auditor Client](auditor-client.md) are secrets. Implementations MUST keep them within the trust boundary ([Terminology](README.md#terminology)), SHOULD zeroize buffers holding them once no longer needed, and MUST NOT transmit them to any remote service except under [Backend pluggability and the trust boundary](proving.md#backend-pluggability-and-the-trust-boundary)'s explicit opt-in. + +$$vk$$ MUST NOT be presented as a safely-shareable read-only credential. It exposes every historical balance checkpoint, every incoming amount, every delegation allowance, and — through the ephemeral-scalar derivation of [ECDH-Derived Blinding](../protocol/keys-and-commitments.md#ecdh-derived-blinding) — the opening of every transfer the account originated. Its only guarantee is that it cannot authorize spending. A party that needs outbound visibility is served with D-sender proofs, which are bound to that party and to a nonce ([Recipient Binding](../selective-disclosure/security.md#recipient-binding)), never by handing over the key. + +**Storage at rest.** Persisted openings are as sensitive as the amounts they represent, and a persisted root is equivalent to the funds. Implementations MUST document what they persist and where, and SHOULD encrypt both at rest under a key that is not itself derived from persisted material. A signer root ([Signer roots](key-derivation.md#signer-roots)) MUST NOT be persisted at all, since it is reproducible on demand from the signer. + +**Logging and telemetry.** Witness material, decrypted amounts, balances, and openings MUST NOT reach logs, telemetry, analytics, error reports, or crash dumps. + +**Randomness.** All sampling MUST use a cryptographically secure generator ([Scalar sampling](crypto-core.md#scalar-sampling)). An implementation MUST NOT fall back to a non-CSPRNG source when one is unavailable; it MUST fail. + +**Supply chain.** Soundness depends on the verification keys corresponding to the intended circuits and on the structured reference string having been generated honestly ([Structured Reference String](../protocol/proof-system.md#structured-reference-string)). Implementations MUST pin verification keys and the proving toolchain ([Toolchain pinning](proving.md#toolchain-pinning)), SHOULD document how a user reproduces a deployed key from circuit source, and MUST NOT fetch either at runtime from a mutable location. + +**What the SDK cannot do.** It cannot mitigate a compromised host: malicious code in the process, the bundle, or the storage layer reaches the root. Implementations MUST NOT present software-only custody as equivalent to hardware custody. Hardware custody is additionally constrained here, because proving requires $$sk$$ as a witness, so a device that does not prove internally must expose $$sk$$ to the host. + +--- + +## Non-Functional Requirements + +**Proving latency.** Single-digit seconds on contemporary hardware is the design target ([User Flows Overview](../overview.md)). Implementations MUST treat it as user-visible. + +**Sync and replay bounds.** The replay window ([Recovery](../protocol/wallet-state.md#recovery)) is unbounded in age. Implementations MUST NOT assume a bounded window, and SHOULD use the archive's checkpoint lookup where available ([API Surface](../indexer.md#api-surface), C1) so that a dormant account is not obliged to transfer its entire history. + +**Storage growth.** Per-account event volume is linear in inbound transfers and unbounded by design, since incoming-transfer spam is rate-limited only by transaction fees ([Recovery Properties](../protocol/wallet-state.md#recovery-properties)). Implementations MUST NOT size local storage on the assumption that inbound volume tracks the user's own activity. + +**Offline capability.** Key derivation, witness assembly, and proof generation need no network. Balance display needs synced state. Submission and consistency checking need the chain. Implementations SHOULD make the boundary explicit rather than failing opaquely when offline. + +**Version matrix.** An implementation MUST expose, for inspection and for bug reports: the protocol documentation version it targets, the contract address and its `addr_f`, the verification-key set and producing toolchain versions ([Toolchain pinning](proving.md#toolchain-pinning)), and the domain-separator scheme in use ([Domain separators](crypto-core.md#domain-separators), including whether the alternate hashed scheme of [Domain Separation Constants](../protocol/domain-separators.md) was selected). Each is an interoperability boundary whose mismatch is undiagnosable without knowing its value. + +--- + +## Conformance and Versioning + +An implementation conforms to this specification iff it: + +1. reproduces every vector of [Primitive fixtures](conformance.md#primitive-fixtures) byte-for-byte, reading the fixture files rather than transcribing them; +2. passes circuit-execution parity with tamper rejection ([Circuit-execution parity](conformance.md#circuit-execution-parity)) for every circuit it supports; +3. satisfies [Crypto Core](crypto-core.md) through [Disclosure and Indexer Clients](clients.md) for the roles it implements; +4. exposes the version matrix of [Non-Functional Requirements](#non-functional-requirements). + +Partial role coverage is conformant and MUST be declared: an implementation supporting only the holder role is conformant for that role, and MUST NOT claim spender, auditor, or disclosure conformance. Partial *circuit* coverage MUST likewise be declared. + +**The portable surface is [Crypto Core](crypto-core.md) through [Witness Assembly](proving.md#witness-assembly).** The crypto core, key derivation, and witness assembly are what two implementations must agree on byte-for-byte. The facades of [Holder Wallet](wallet.md), [Auditor Client](auditor-client.md), and [Disclosure and Indexer Clients](clients.md) are deployment-shaped, and this document constrains their obligations rather than their structure. + +This document is versioned with the protocol documentation set. A change to any primitive in [Crypto Core](crypto-core.md), to the derivation in [Key Derivation](key-derivation.md), to the ephemeral-scalar derivation ([ECDH-Derived Blinding](../protocol/keys-and-commitments.md#ecdh-derived-blinding), restated in [Deterministic ephemeral scalars](wallet.md#deterministic-ephemeral-scalars)), or to the domain separators in [Domain separators](crypto-core.md#domain-separators) breaks the cross-implementation contract: it MUST bump the protocol documentation version, MUST be called out in release notes, and MUST be accompanied by updated fixtures in `circuits/lib/testdata/`. A change to the numeric value of a domain separator, or to the sponge convention, invalidates every previously derived key and every previously emitted ciphertext, and is a new deployment rather than an upgrade. + +--- + +Previous: [Disclosure and Indexer Clients](clients.md) · Up: [Index](../README.md#companions) diff --git a/packages/tokens/src/confidential/docs/sdk/wallet.md b/packages/tokens/src/confidential/docs/sdk/wallet.md new file mode 100644 index 000000000..b6205bb06 --- /dev/null +++ b/packages/tokens/src/confidential/docs/sdk/wallet.md @@ -0,0 +1,95 @@ +# Holder Wallet + +## State model + +The wallet maintains the two accumulators of [Wallet State and Recovery](../protocol/wallet-state.md) — $$W\_{\text{spend}}$$ and $$W\_{\text{receive}}$$ — plus a sync position and any in-flight projection ([In-flight operations](#in-flight-operations)). Values and blindings alike accumulate as exact integers; a blinding is reduced modulo $$q$$ only where it leaves an accumulator for the curve ([Blinding accumulation](crypto-core.md#blinding-accumulation)). + +Persistence MUST be pluggable, since the same core serves environments with very different storage. With RPC-only event access, discarding persisted state loses the receiving-side openings permanently ([Recovery](#recovery)), so it MUST NOT be treated as an evictable cache. + +## Event application + +Event application MUST be ordered, deduplicated, and idempotent in combination, and an implementation MUST state which layer discharges each obligation. The three are commonly split, with ordering and dedup at the event source and application left non-idempotent, which is conformant only if the split is explicit. + +- **Ordering.** Events MUST be applied in emission order, because reconstruction is order-sensitive: a merge and a deposit in the same ledger produce different state depending on which is applied first. The canonical total order is [Ordering](../indexer.md#ordering)'s $$(\text{ledger\\\_seq}, \text{tx\\\_application\\\_order}, \text{event\\\_index})$$. +- **Deduplication.** Events MUST be deduplicated by event id, since a hybrid source ([The hybrid read path and its two failure modes](clients.md#the-hybrid-read-path-and-its-two-failure-modes)) can deliver the same event twice at its seam. +- **Idempotence.** Either application is idempotent, or dedup provably precedes it. Crediting rules accumulate, so a duplicate inflates a balance. + +Application rules are [Wallet State and Recovery](../protocol/wallet-state.md)'s update table and are not restated here. Two properties worth making explicit: + +- `Withdraw`, sender-side `Transfer`, and `SetSpender` **overwrite** $$W\_{\text{spend}}$$ from the event's $$(\tilde{b}, \sigma)$$ rather than adjusting it, so a wallet that missed intervening events still converges on the spendable side. +- `RevokeSpender` is not a checkpoint — it carries no $$\tilde{b}$$ — and is **folded**, like `Merge`. `RevokeSpender` adds the reclaimed allowance opening to $$W\_{\text{spend}}$$, with $$v\_a$$ and $$r\_a$$ recovered from the event's $$\tilde{a}$$ and `allowance_salt` under the owner-derived $$dvk\_i$$ ([Revoke Spender](../protocol/operations/revoke-spender.md)). A wallet that misses either event diverges until the next checkpoint and MUST rely on [Consistency checking](#consistency-checking) to detect that. +- `Clawback` is likewise not a checkpoint and is **folded**: the `Merge` rule, then the event's public `amount` subtracted from $$W\_{\text{spend}}$$'s value ([Wallet and Auditor Consequences](../compliance.md#wallet-and-auditor-consequences)). It occurs only in deployments that enable the compliance extension. +- A self-transfer — a `Transfer` whose `from` and `to` are the same account — MUST be applied in both roles: the sender side overwrites $$W\_{\text{spend}}$$ from the event's $$(\tilde{b}, \sigma)$$, and the recipient side credits $$W\_{\text{receive}}$$ from the same event's recipient-channel ciphertexts. The two roles act on different accumulators, so their relative order does not affect the result; applying only one loses the other accumulator's update. + +## In-flight operations + +A wallet MAY project the post-operation opening immediately on successful submission rather than waiting for the event, so that the balance a user sees reflects an action they just took. + +The projection MUST still be reconciled against the event, and MUST NOT be treated as confirmed for the purpose of [Consistency checking](#consistency-checking). + +## Salt freshness + +A fresh salt MUST be sampled for every **attempt**, including retries after a reverted or dropped transaction. The salt that must be fresh is the one the operation's pads absorb: $$\sigma$$ for owner-initiated operations, and $$\sigma\_a'$$ — the replacement allowance salt — for spender transfers ([Transfer nonce](../protocol/account-state.md#transfer-nonce)). + +[Revert Safety](../protocol/security.md#revert-safety) motivates this as unlinkability: a fresh salt prevents an observer correlating a reverted attempt with its retry. It is equally a confidentiality requirement, because the salt is the sole freshness input to every derived pad in the operation, the ephemeral scalar included ([Poseidon2 Hash](../protocol/primitives.md#poseidon2-hash), [ECDH-Derived Blinding](../protocol/keys-and-commitments.md#ecdh-derived-blinding)). Reuse therefore repeats the ephemeral key and every channel mask that depends on it. + +An implementation MUST NOT cache or reuse a salt across attempts, and MUST NOT derive it from anything an observer can predict. + +**Freshness is not enforced on-chain.** Constraint O14 rejects only $$\sigma\_a' = \sigma\_a$$, the adjacent collision; a circuit cannot see a delegation's older salts. An implementation MUST therefore treat non-repetition of $$\sigma\_a'$$ over the whole life of a delegation as its own obligation, and MUST NOT cycle salts through a bounded set. + +## Deterministic ephemeral scalars + +An implementation MUST derive the ephemeral scalar of every operation the holder or spender originates: + +$$r\_e = \text{poseidon\\\_with\\\_domain}(\delta\_{\text{eph}}, [vk, \sigma\_E])$$ + +where $$vk$$ is the originator's viewing key and $$\sigma\_E$$ the operation's salt. [ECDH-Derived Blinding](../protocol/keys-and-commitments.md#ecdh-derived-blinding) is the normative source: it fixes which viewing key and salt each operation derives from, and the retry rule for the negligible case that the derivation yields zero. + +**Three consequences an implementation MUST handle.** + +First, **$$vk$$ carries more authority than balance decryption.** Recomputing $$r\_e$$ yields the recipient shared scalar, hence $$r\_{\text{transfer}}$$, hence a full Pedersen opening of every transfer commitment the account created — retroactively, and reaching commitments that sit inside recipients' receiving balances ([Privacy Properties](../protocol/security.md#privacy-properties), [Per-Transfer Auditor Ciphertexts](../protocol/auditing.md#per-transfer-auditor-ciphertexts)). An implementation MUST treat $$vk$$ accordingly in [Security Requirements](requirements.md#security-requirements) and MUST NOT export it as a read-only credential without stating this. + +Second, **the salt requirement of [Salt freshness](#salt-freshness) is a confidentiality requirement**, not only an unlinkability one, the salt being the operation's sole freshness input. + +Third, **disclosability is unverifiable from chain data.** No on-chain value distinguishes an ephemeral this derivation produced from one it did not, so an implementation MUST NOT infer disclosability from a stored per-transfer flag and MUST determine it by test ([Disclosure construction](clients.md#disclosure-construction)). Transfers predating this specification may not be disclosable by their sender. + +## Consistency checking + +The wallet MUST verify its openings against on-chain commitments by re-committing and comparing ([Consistency check](../protocol/wallet-state.md#consistency-check)), and MUST do so both after every sync and before constructing any proof. A missed event, a duplicate, an expired credit, or a defect then produces a mismatch rather than a plausible wrong balance, and a mismatched state MUST NOT be spent from. + +Implementations MUST report which accumulator diverged, since the two have different causes and different remedies. + +## The unspendable-blinding case + +$$W\_{\text{spend}}.r$$ is an exact integer, but a proof witnesses it as a single $$\mathbb{F}\_r$$ `Field`. After any of the three proofless folds — `Merge`, `RevokeSpender`, `Clawback` — its canonical $$\mathbb{F}\_q$$ representative can land in $$[r, q)$$, which no `Field` encodes, so no proof can be constructed against that commitment. The state itself is sound: the commitment is a well-formed Grumpkin point, and [Consistency checking](#consistency-checking)'s check recommits from the same representative and still passes ([Post-merge witness availability](../protocol/proof-system.md#post-merge-witness-availability)). + +An implementation MUST test the reduction rather than the accumulator: before attempting a proof, reduce $$W\_{\text{spend}}.r$$ modulo $$q$$ at [Blinding accumulation](crypto-core.md#blinding-accumulation)'s reduction point and compare the representative against $$r$$. The unreduced accumulator carries no signal: after a few folds an integer sum past $$r$$ is the ordinary case. + +A failed comparison MUST surface as a distinct, named state rather than as a generic proof-construction failure. The recovery path MUST surface with it: the condition resolves at the next merge that folds in an inbound confidential transfer, and an account whose only inflows are deposits stays affected until one arrives ([Soft recovery](../protocol/proof-system.md#soft-recovery)). + +## Merge policy + +Received funds are spendable only after a merge ([Merge](../protocol/operations/merge.md)). A wallet SHOULD merge automatically, or prompt, ahead of a spend that the spendable balance alone cannot cover. Merging ahead of a spend also bounds the recovery replay window ([Recovery](../protocol/wallet-state.md#recovery)). + +Merge is proof-less and owner-authorized, and neither a merge nor an in-flight spend proof can be disrupted by a third party ([Griefing Resistance](../protocol/security.md#griefing-resistance), [Merge Safety](../protocol/security.md#merge-safety)). + +## Recovery + +Recovery follows the procedure of [Recovery](../protocol/wallet-state.md#recovery), with the replayed events applied per [Event application](#event-application) and the reconstructed state verified per [Consistency checking](#consistency-checking). + +Two further obligations follow from data availability: + +- Recovery from a root alone depends on a conforming indexer ([Indexing and Off-Chain State Recovery](../indexer.md)). Without one, a client can see that funds exist but cannot reconstruct the opening needed to spend them. +- **With RPC-only event access, a client MUST sync at least once per RPC retention window**, and MUST warn when it has not. Any event that ages out before it is applied takes its opening with it permanently: a crediting event on the receiving side, which is a running sum from $$T\_0$$, and a `RevokeSpender` after the latest checkpoint, whose $$\tilde{a}$$ and `allowance_salt` survive nowhere else once the delegation entry is deleted ([Revoke Spender](../protocol/operations/revoke-spender.md)). Only the spend history preceding the checkpoint is expendable, each checkpoint being self-contained. + +## Spender-side wallet + +A spender reconstructs its allowance state from the on-chain delegation entry rather than from event replay: it recovers $$dvk\_i$$ from the escrowed value by ECDH ([Delegation Key Escrow](../protocol/operations/set-spender.md#delegation-key-escrow)), then reads the current allowance from the entry's encrypted allowance and salt ([Read Methods](../protocol/interface.md#read-methods)). + +Implementations MUST surface the delegation's expiry ledger and SHOULD warn ahead of it. They MUST represent expired-but-unrevoked delegations as still holding escrowed value ([Spender Delegation](../protocol/account-state.md#spender-delegation)). A `RevokeSpender` event with the entry gone means the owner folded the delegation back. + +A spender MUST NOT be able to reach the owner's spendable balance through any interface ([Roles and Capability Separation](README.md#roles-and-capability-separation)). + +--- + +Previous: [Witness Assembly, Prover, and Chain Adapter](proving.md) · Up: [Index](../README.md#companions) · Next: [Auditor Client](auditor-client.md) diff --git a/packages/tokens/src/confidential/docs/selective-disclosure/README.md b/packages/tokens/src/confidential/docs/selective-disclosure/README.md new file mode 100644 index 000000000..efb8e7d30 --- /dev/null +++ b/packages/tokens/src/confidential/docs/selective-disclosure/README.md @@ -0,0 +1,106 @@ +# Confidential Token: Selective Disclosure + +## Abstract + +This document specifies an off-chain selective-disclosure layer for the Confidential Token (see [the protocol specification](../protocol/README.md)). The core protocol already provides forward-only auditor visibility ([Auditing](../protocol/auditing.md)): a registered auditor decrypts every transfer the account participates in. That model is sufficient for trusted-third-party regulatory access but insufficient for the routine compliance case where the account holder must prove a *single* fact (a specific transfer amount, an aggregate over a window, a counterparty relationship) to a *specific* counterparty (a bank's compliance desk, a tax authority, a KYC provider) without granting blanket visibility. + +This layer addresses that gap with a family of Noir circuits that produce per-event, recipient-bound disclosure proofs. The on-chain contract is untouched. Disclosure proofs are generated client-side by either the account holder (using their viewing key) or the auditor (using their auditor key), delivered out-of-band, and verified off-chain by the disclosure recipient against the on-chain event log. + +--- + +## Introduction + +### Overview + +The Confidential Token hides the *amounts* that move between accounts: balances and transfer values live on-chain as encrypted commitments rather than as readable numbers ([Introduction](../protocol/README.md#introduction)). That is the right default for privacy, but it collides with a routine reality of regulated finance — sometimes the holder of an account *must* prove a specific fact about their own activity to a specific outside party. A bank's compliance desk asks a customer to show that a particular incoming payment was for the amount claimed. A tax authority asks for the total received over a quarter. A KYC provider asks for evidence that an account's balance sits below a threshold. Each of these is a request to reveal *one* fact, to *one* counterparty, and nothing else. + +The protocol already includes an auditor mechanism ([Auditing](../protocol/auditing.md)), but it is all-or-nothing: an auditor holds a key that decrypts *every* transfer an account takes part in. Handing that key — or its decrypted output — to a bank or a tax office to answer a single question would expose the account's entire history. Selective disclosure is the missing middle ground: a way for the holder to prove exactly one statement, to exactly one recipient, in a form the recipient can check against the public ledger but cannot reuse, resell, or replay against anyone else. + +The mechanism lives entirely *outside* the on-chain contract: both proving and verifying happen off-chain, and the protocol pays nothing (the narrow cases where verification can be moved on-chain are in [On-Chain Verification](protocol.md#on-chain-verification)). It behaves like a notarized, single-use statement, produced in four steps: + +1. **The recipient asks.** The counterparty (bank, auditor, tax office) gives the holder a one-time reference number tied to this specific request. +2. **The holder proves.** Using the secret keys already in their wallet, the holder generates a *zero-knowledge proof* — a mathematical certificate that the claimed fact is true and is genuinely tied to a real event recorded on-chain, without revealing any of the secrets behind it. The disclosed value itself is sealed so that only the requesting recipient can open it. +3. **The holder delivers.** The proof and the sealed value go to the recipient directly, off-chain. They are never published. +4. **The recipient verifies.** The recipient checks the proof against the public on-chain record. If it passes, the recipient learns the one disclosed fact — and can trust it as much as they trust the blockchain itself — and learns nothing else about the account. + +Anyone holding the proof can check that it is valid and see that a disclosure was made, but the sealed value opens only with the recipient's own key, and the bound reference number stops the same proof from serving a different request ([Recipient Binding](security.md#recipient-binding)). What the recipient does with the value once opened — keep it, share it — is outside the protocol ([What This Does Not Prevent](security.md#what-this-does-not-prevent)). + +The layer supports a small family of these statements, each a different shape of question: + +- *"This on-chain payment paid me this amount."* — the recipient of a transfer proves what they received ([D-recipient](circuits/d-recipient.md)). +- *"This on-chain payment was sent by me for this amount."* — the sender of a transfer proves what they sent ([D-sender](circuits/d-sender.md)). +- *"My current balance is this — or is at most this."* — the holder proves a fact about the balance they hold right now ([D-balance](circuits/d-balance.md)). +- *"The total across this set of payments is this."* — the holder proves an aggregate over several transfers at once ([Aggregate Disclosures](circuits/aggregate.md)). +- The same proofs produced by the *auditor* instead of the holder, for cases where the disclosure must come from the regulator-facing side rather than the account owner ([D-auditor](circuits/d-auditor.md)). + +The remainder of this document specifies these statements precisely as zero-knowledge circuits, defines the exact checks a recipient must perform, and analyzes what each one does and does not reveal. + +### The Selective-Disclosure Gap + +The core protocol's audit surface ([Auditing](../protocol/auditing.md)) gives each auditor a key that decrypts every transfer ciphertext for accounts under their scope. This is appropriate for the auditor-as-disclosure-agent role, where the auditor responds to authorized regulatory requests by decrypting specific events. + +Three properties make this insufficient as the only disclosure surface: + +1. **Granularity.** The auditor key cannot decrypt one transfer without being able to decrypt all of them. There is no cryptographic enforcement that the auditor disclose only what was asked for. +2. **Counterparty disclosures.** Common compliance flows (KYC source-of-funds proofs, bank inbound-payment attestations, tax declarations) require the holder to disclose to a counterparty that is not the auditor. Routing every such request through the auditor concentrates trust and adds operational latency. +3. **Recipient binding.** A plaintext disclosure can be re-shared, replayed, or archived. There is no cryptographic anchor that ties a disclosed value to the specific recipient that requested it. + +### Design Goals + +**Per-event scope.** A disclosure proof corresponds to one named on-chain event (or a finite enumerated set), not to an account's history. + +**Recipient binding.** Each proof is bound to a specific disclosure recipient's public key plus a fresh nonce so that a proof cannot be reused for another request and its disclosed value opens only for that recipient. + +**Verifiable correctness.** The disclosure recipient verifies the proof against on-chain state (event log, account record, auditor key registry) without trusting the prover. + +**Zero protocol cost.** No changes to the contract's storage model, entry points, or per-transfer ciphertext layout. Disclosure proofs add wallet-side cost only. + +**Two prover roles.** Either the holder (using their viewing key $$vk$$) or the auditor (using their auditor secret $$aud\_{sk}$$) can produce a disclosure proof. The roles share a circuit family with swappable witness blocks. + +### Non-Goals + +**Completeness proofs.** This layer proves positive statements ("this event paid me $$X$$"). It does not prove negatives ("I have no other transfers from $$Y$$"). Completeness, where required, continues to route through the auditor ([Auditing](../protocol/auditing.md)) or through a future Merkle-accumulator extension that is out of scope here. + +**On-chain disclosure logging.** Disclosures are off-chain artifacts exchanged between holder and recipient. The contract does not log disclosure events; doing so would leak the metadata the rest of the protocol works to hide. + +**Disclosure-recipient registry.** Recipients identify themselves by Grumpkin public keys exchanged out-of-band. The contract does not register, gate, or approve specific recipients. + +--- + +## Preliminaries + +This document reuses the notation, key hierarchy, and commitment scheme from [Primitives](../protocol/primitives.md) and [Key Hierarchy](../protocol/keys-and-commitments.md#key-hierarchy) without restatement. The following are referenced repeatedly: + +- $$sk\_A$$, $$vk\_A$$, $$\text{PVK}\_A$$: an account's spending key, viewing key, and public viewing key ([Key Hierarchy](../protocol/keys-and-commitments.md#key-hierarchy)). +- $$\text{addr\\\_f}$$: the contract's compressed address Field $$\text{address\\\_to\\\_field}(\text{contract})$$, bound into $$vk$$ derivation ([Address-to-Field Encoding](../protocol/primitives.md#address-to-field-encoding), [Viewing Key](../protocol/keys-and-commitments.md#viewing-key)). Stored once at construction in the contract's instance storage ([Governance and Upgradeability](../protocol/system-model.md#governance-and-upgradeability)). +- $$K\_{\text{aud,s}}$$, $$K\_{\text{aud,r}}$$, $$aud\_{sk}$$: the sender-side and recipient-side auditor Grumpkin public keys, and an auditor's secret key ([Per-Transfer Auditor Ciphertexts](../protocol/auditing.md#per-transfer-auditor-ciphertexts), [Auditor Key Management and Rotation](../protocol/auditing.md#auditor-key-management-and-rotation)). Each account selects an `auditor_id` at registration; the same `auditor_id` may resolve to either role depending on the transfer's direction. +- $$(R\_e, \sigma, \tilde{v}, \tilde{b}, \tilde{v}\_{\text{aud,r}}, \tilde{r}\_{\text{aud,r}}, \tilde{v}\_{\text{aud,s}}, \tilde{b}\_{\text{aud,s}}, \tilde{r}\_{\text{aud,s}})$$: per-transfer event fields ([Confidential Transfer](../protocol/operations/transfer.md), [Event Schema](../protocol/interface.md#event-schema)). For `SpenderTransfer` events the recipient/auditor ECDH nonce is $$\sigma\_a'$$ in place of $$\sigma$$, and the sender-auditor channel emits $$\tilde{a}\_{\text{aud,s}}$$ in place of $$\tilde{b}\_{\text{aud,s}}$$, with `lane[2]`'s $$\tilde{r}\_{\text{aud,s}}$$ carrying the post-transfer allowance blinding $$r\_a'$$ rather than a spendable one ([Spender Transfer](../protocol/operations/spender-transfer.md), [Event Schema](../protocol/interface.md#event-schema)). Throughout this document, the symbol $$\sigma\_E$$ refers to the **event ECDH nonce**, equal to $$\sigma$$ for `Transfer` events and to $$\sigma\_a'$$ for `SpenderTransfer` events; one circuit handles both families, parameterized by which nonce the disclosing event emitted. +- $$H$$: the Grumpkin Pedersen generator used uniformly for key derivation and ECDH ([Pedersen Commitments](../protocol/primitives.md#pedersen-commitments), [Elliptic Curve Diffie-Hellman](../protocol/primitives.md#elliptic-curve-diffie-hellman)). + +### Disclosure Recipient + +A disclosure recipient publishes a long-lived Grumpkin keypair $$(r\_R, P\_R)$$ with $$P\_R = r\_R \cdot H$$, by the same mechanism they publish any other public key (web PKI, certificate, identity document). Publication is out-of-band; no on-chain registration. + +For each disclosure request, the recipient supplies a fresh nonce $$\nu \in \mathbb{F}\_r$$ over an authenticated channel. The pair $$(P\_R, \nu)$$ binds the resulting proof. A holder cannot reuse a proof bound to $$(P\_R, \nu)$$ against a different recipient or against the same recipient's future requests. + +### Domain Separators + +Two domain separators are specific to this layer: $$\delta\_{\text{disc\\\_bind}}$$, the disclosure-ciphertext domain for aggregate disclosures ([Aggregate Disclosures](circuits/aggregate.md)), and $$\delta\_{\text{disc}}$$, which keys the disclosure ciphertext to the recipient ([Disclosure Ciphertext to Recipient](protocol.md#disclosure-ciphertext-to-recipient)). Neither is absorbed in a core circuit. Both take their values from [Domain Separation Constants](../protocol/domain-separators.md), which assigns them as a continuation of its own sequence. + +A wallet producing a D-sender proof also uses $$\delta\_{\text{eph}}$$ ([Domain Separation Constants](../protocol/domain-separators.md)) to recover the event's ephemeral scalar before proving ([D-sender](circuits/d-sender.md)). That use is off-circuit: no disclosure circuit absorbs the tag. + +--- + +## Threat Model + +The disclosure layer inherits the protocol's threat model ([Threat Model](../protocol/system-model.md#threat-model)) and adds: + +**Holder is the prover for D-recipient and D-sender variants.** The holder is trusted only to produce *correct* proofs about events they choose to disclose. The holder is *not* trusted to be complete: they may withhold events. Recipients that require completeness must obtain it from the auditor ([Auditing](../protocol/auditing.md)) or from out-of-band evidence. + +**Auditor is the prover for D-auditor variants.** The auditor is trusted to disclose accurately when asked. The auditor's existing trust scope ([Trust Assumptions](../protocol/system-model.md#trust-assumptions)) is not enlarged. That scope includes the full Pedersen opening of every $$C\_a$$ ([Spender Allowance Auditing](../protocol/auditing.md#spender-allowance-auditing)) and, at each checkpoint event that escrows `lane[2]`, of the account's $$C\_{\text{spend}}$$ as of that event ([Sender-auditor opening capability](../protocol/auditing.md#sender-auditor-opening-capability), [Auditor Visibility Properties](../protocol/auditing.md#auditor-visibility-properties)). The latter is standing rather than event-scoped: it carries across merges and across `revoke_spender` ([Per-Transfer Auditor Ciphertexts](../protocol/auditing.md#per-transfer-auditor-ciphertexts)). The D-auditor variants expose to a disclosure recipient only what the chosen variant states. + +**Disclosure recipient is honest-but-curious.** The recipient correctly verifies proofs and decrypts ciphertexts addressed to their key. The recipient may attempt to replay or rebroadcast proofs; nonce binding prevents reuse against other parties. + +--- + +Up: [Index](../README.md#companions) · Next: [Disclosure Protocol](protocol.md) diff --git a/packages/tokens/src/confidential/docs/selective-disclosure/circuits/aggregate.md b/packages/tokens/src/confidential/docs/selective-disclosure/circuits/aggregate.md new file mode 100644 index 000000000..2a4f692ca --- /dev/null +++ b/packages/tokens/src/confidential/docs/selective-disclosure/circuits/aggregate.md @@ -0,0 +1,58 @@ +# Aggregate Disclosures + +For statements of the form "this account received at least $$X$$ from counterparty $$Y$$ during window $$W$$" or "these disbursements total exactly $$X$$", the single-event circuits are vectorized over $$n$$ events. The per-event decryption block of D-recipient, D-sender, or D-auditor runs once per event, and the recovered amounts feed one shared aggregation tail. Each role's aggregate is exposed in the two `circuit_id` shapes of [D-balance](d-balance.md): a **predicate-only** shape that includes THRESH and omits U1–U3, and a **value-revealing** shape that includes U1–U3 and omits THRESH ([Circuits](../security.md#circuits)). For D-auditor the `circuit_id` additionally fixes the channel, $$\delta\_{\text{aud\\\_r}}$$ or $$\delta\_{\text{aud\\\_s}}$$, on which every event in the aggregate is disclosed. + +The inputs that bind an event to a key holder are per event: $$\text{PVK}\_{A,i}$$ for D-recipient and D-sender, $$\text{PVK}\_{B,i}$$ for D-sender, $$K\_{\text{aud},i}$$ for D-auditor. An outbound aggregate has a different recipient in every event, so $$\text{PVK}\_B$$ cannot be shared, and a per-event prover-side key lets one aggregate span several accounts held by the same prover, or several key versions of one auditor across a rotation ([Auditor Key Management and Rotation](../../protocol/auditing.md#auditor-key-management-and-rotation)) provided the verifier can resolve each historical key ([Verifier Protocol](../protocol.md#verifier-protocol) step 3). A single-account aggregate is the case where every slot carries the same key and the prover supplies the same secret in every witness slot; the circuit does not require the slots to coincide. + +## Public inputs + +| Symbol | Source | +|:---|:---| +| Common: $$P\_R$$, $$\nu$$ | as in [Circuit D-recipient: Holder Discloses an Inbound Transfer](d-recipient.md) | +| Common: $$m$$ | active event count, $$1 \le m \le n$$; the number of $$\text{ref}\_{E,i}$$ in the bundle | +| Common, D-recipient and D-sender: $$\text{addr\\\_f}$$ | as in [Circuit D-recipient: Holder Discloses an Inbound Transfer](d-recipient.md) | +| Common, predicate-only shape: $$V\_{\text{threshold}}$$ | aggregate threshold, agreed during the request | +| Common, value-revealing shape: $$R\_{\text{disc}}, \tilde{V}\_{\text{disc}}$$ | disclosure ciphertext of $$V\_{\text{total}}$$ | +| D-recipient, for each $$i \in [1, n]$$: $$(\text{PVK}\_{A,i}, R\_{e,i}, \sigma\_{E,i}, \tilde{v}\_i)$$ | $$\text{PVK}\_{A,i}$$ from the account at $$E\_i.\text{to}$$; the rest from event $$i$$ | +| D-sender, for each $$i \in [1, n]$$: $$(\text{PVK}\_{A,i}, R\_{e,i}, \sigma\_{E,i}, \tilde{v}\_i, \text{PVK}\_{B,i})$$ | $$\text{PVK}\_{A,i}$$ from the account at $$E\_i.\text{from}$$ (`Transfer`) or $$E\_i.\text{spender}$$ (`SpenderTransfer`); $$\text{PVK}\_{B,i}$$ from the account at $$E\_i.\text{to}$$; the rest from event $$i$$ | +| D-auditor, for each $$i \in [1, n]$$: $$(K\_{\text{aud},i}, R\_{e,i}, \sigma\_{E,i}, \tilde{v}\_{\text{aud},i})$$ | $$K\_{\text{aud},i}$$ is the auditor key active at event $$i$$'s ledger on the `circuit_id`'s channel, $$\tilde{v}\_{\text{aud},i}$$ that channel's ciphertext in event $$i$$; the rest from event $$i$$ | + +In every variant $$\sigma\_{E,i} = \sigma$$ if event $$i$$ is a `Transfer`, $$\sigma\_a'$$ if `SpenderTransfer`. Each event MUST be identified by its own $$\text{ref}\_{E,i}$$ in the proof bundle ([Proof Bundle](../protocol.md#proof-bundle)) and resolved per [Verifier Protocol](../protocol.md#verifier-protocol). Slots $$i > m$$ are inactive and the verifier fills their tuple with zeros. + +## Private witnesses + +| Variant | Witnesses | +|:---|:---| +| D-recipient | $$\\{sk\_{A,i}\\}\_{i=1}^n$$, $$\\{vk\_{A,i}\\}\_{i=1}^n$$, $$\\{v\_{\text{transfer},i}\\}\_{i=1}^n$$, and $$r\_{\text{disc}}$$ in the value-revealing shape | +| D-sender | $$\\{sk\_{A,i}\\}\_{i=1}^n$$, $$\\{vk\_{A,i}\\}\_{i=1}^n$$, $$\\{r\_{e,i}\\}\_{i=1}^n$$, $$\\{v\_{\text{transfer},i}\\}\_{i=1}^n$$, and $$r\_{\text{disc}}$$ in the value-revealing shape | +| D-auditor | $$\\{aud\_{sk,i}\\}\_{i=1}^n$$, $$\\{v\_{\text{transfer},i}\\}\_{i=1}^n$$, and $$r\_{\text{disc}}$$ in the value-revealing shape | + +## Constraints + +| # | Constraint | +|:--|:---| +| D-recipient, for each $$i \le m$$: $$\text{D1}\_i$$ – $$\text{D4}\_i$$ | D1–D4 of [Circuit D-recipient: Holder Discloses an Inbound Transfer](d-recipient.md) on $$(sk\_{A,i}, vk\_{A,i}, \text{PVK}\_{A,i}, R\_{e,i}, \sigma\_{E,i}, \tilde{v}\_i)$$, recovering $$v\_{\text{transfer},i}$$ | +| D-sender, for each $$i \le m$$: $$\text{D1}\_i$$, $$\text{D2}\_i$$, $$\text{DS3}\_i$$ – $$\text{DS5}\_i$$ | D1, D2, DS3–DS5 of [Circuit D-sender: Sender Discloses an Outbound Transfer](d-sender.md) on $$(sk\_{A,i}, vk\_{A,i}, \text{PVK}\_{A,i}, r\_{e,i}, R\_{e,i}, \text{PVK}\_{B,i}, \sigma\_{E,i}, \tilde{v}\_i)$$, recovering $$v\_{\text{transfer},i}$$ | +| D-auditor, for each $$i \le m$$: $$\text{A1}\_i$$ – $$\text{A4}\_i$$ | A1–A4 of [Circuit D-auditor: Auditor Discloses a Transfer](d-auditor.md) on $$(aud\_{sk,i}, K\_{\text{aud},i}, R\_{e,i}, \sigma\_{E,i}, \tilde{v}\_{\text{aud},i})$$ with $$\delta\_{\text{aud}}$$ the `circuit_id`'s channel, recovering $$v\_{\text{transfer},i}$$ | +| For each $$i \le m$$: $$\text{D5}\_i$$ | $$v\_{\text{transfer},i} \in [0, 2^{127})$$ | +| For each $$i > m$$: $$\text{PAD}\_i$$ | $$v\_{\text{transfer},i} = 0$$; the slot's per-event block is not enforced | +| AGG | $$V\_{\text{total}} = \sum\_{i=1}^n v\_{\text{transfer},i}$$ | +| AGG-R | $$V\_{\text{total}} \in [0, 2^{127})$$ (range, [Integer Embedding and Range Proofs](../../protocol/primitives.md#integer-embedding-and-range-proofs)) | +| THRESH (predicate-only shape) | $$V\_{\text{total}} \geq V\_{\text{threshold}}$$, evaluated as $$V\_{\text{total}} - V\_{\text{threshold}} \in [0, 2^{127})$$ on the AGG-R-checked $$V\_{\text{total}}$$, with $$V\_{\text{threshold}} \in [0, 2^{127})$$ | +| U1–U3 (value-revealing shape) | Encrypt the AGG-R-checked $$V\_{\text{total}}$$ (not the individual $$v\_{\text{transfer},i}$$) to the recipient, with $$\delta\_{\text{disc\\\_bind}}$$ replacing $$\delta\_{\text{disc}}$$ to separate domain | + +The circuit derives the activity flag $$a\_i = [i \le m]$$ from the public input $$m$$ and enforces every per-event constraint of slot $$i$$ under $$a\_i$$, so an inactive slot carries no decryption obligation and contributes nothing to AGG. Because $$m$$ is public, the prover cannot deactivate a slot the verifier filled from a resolved event. + +Over $$\mathbb{F}\_r$$ the comparison in THRESH is undefined and decrypting $$\tilde{V}\_{\text{disc}}$$ recovers $$V\_{\text{total}}$$ only modulo $$r$$, so AGG-R MUST precede THRESH and U1–U3. The slot count $$n$$ is fixed per aggregate `circuit_id` ([Circuits](../security.md#circuits)); every realizable $$n$$ satisfies $$n \cdot 2^{127} < r$$, so the field sum in AGG is the integer sum and AGG-R bounds that integer. An aggregate of $$2^{127}$$ or more exceeds the token's `i128` domain and is not disclosable by this circuit. + +The disclosure recipient filters the events off-chain by the criteria they care about (counterparty address, block timestamp) before constructing the verifier's public inputs. They learn $$V\_{\text{total}}$$ in the value-revealing shape and only whether it clears $$V\_{\text{threshold}}$$ in the predicate-only shape; the individual amounts are revealed in neither. + +Per-event $$\text{PVK}\_{A,i}$$ matters most for outbound aggregates. Spends from one account serialise, since each spend proof opens the current $$C\_{\text{spend}}$$, so a payer that parallelises disbursements across several accounts would otherwise disclose one aggregate per account, and per-account subtotals over few events approach per-recipient disclosure. Spanning accounts reveals nothing beyond the event stream: the verifier reads every participating address from the events it resolves. + +## Verifier flow + +Follow [Verifier Protocol](../protocol.md#verifier-protocol) with the aggregate `circuit_id` for the role and shape. Steps 1–3 run once per $$\text{ref}\_{E,i}$$: step 2 resolves $$\text{PVK}\_{A,i}$$ (and, for D-sender, $$\text{PVK}\_{B,i}$$) from event $$i$$'s addresses as for the single-event variant, and step 3 resolves $$K\_{\text{aud},i}$$ at event $$i$$'s ledger on the `circuit_id`'s channel. Step 4 lays out the per-event tuples in bundle order, sets $$m$$ to the number of refs, zero-fills the slots above $$m$$, and in the predicate-only shape enters the $$V\_{\text{threshold}}$$ agreed during the request, never one taken from the bundle. Step 6 decrypts under $$\delta\_{\text{disc\\\_bind}}$$ in place of $$\delta\_{\text{disc}}$$ and applies only to the value-revealing shape; a predicate-only bundle omits $$(R\_{\text{disc}}, \tilde{V}\_{\text{disc}})$$. The verifier MUST reject a bundle in which two $$\text{ref}\_{E,i}$$ resolve to the same event: AGG sums whatever it is given, and a repeated event would count its amount twice. + +--- + +Previous: [D-balance](d-balance.md) · Up: [Index](../../README.md#companions) · Next: [Security and Implementation Notes](../security.md) diff --git a/packages/tokens/src/confidential/docs/selective-disclosure/circuits/d-auditor.md b/packages/tokens/src/confidential/docs/selective-disclosure/circuits/d-auditor.md new file mode 100644 index 000000000..2d1e6ee9c --- /dev/null +++ b/packages/tokens/src/confidential/docs/selective-disclosure/circuits/d-auditor.md @@ -0,0 +1,43 @@ +# Circuit D-auditor: Auditor Discloses a Transfer + +The auditor proves to a third party that an on-chain event corresponds to a transfer of amount $$v\_{\text{transfer}}$$ for one of the accounts under the auditor's scope. Used when the holder is uncooperative or when the disclosure recipient requires a guarantee that the auditor (not just the holder) has attested. + +**Which auditor.** Every transfer carries ciphertexts under *two* auditor keys ([Per-Transfer Auditor Ciphertexts](../../protocol/auditing.md#per-transfer-auditor-ciphertexts)): the recipient-side key $$K\_{\text{aud,r}}$$ (channel $$\delta\_{\text{aud\\\_r}}$$, two lanes yielding masks for $$v\_{\text{transfer}}$$ and $$r\_{\text{transfer}}$$) and the sender-side key $$K\_{\text{aud,s}}$$ (channel $$\delta\_{\text{aud\\\_s}}$$, three lanes yielding masks for $$v\_{\text{transfer}}$$, the sender's post-transfer balance, and the blinding-escrow slot of [Poseidon2 Hash](../../protocol/primitives.md#poseidon2-hash)). Whichever auditor is disclosing reuses the same shared-secret derivation they perform to read events natively; the circuit additionally encrypts the result to the disclosure recipient. + +The constraints below parameterize the channel as $$\delta\_{\text{aud}} \in \\{\delta\_{\text{aud\\\_r}}, \delta\_{\text{aud\\\_s}}\\}$$ and the corresponding event ciphertext as $$\tilde{v}\_{\text{aud}} \in \\{\tilde{v}\_{\text{aud,r}}, \tilde{v}\_{\text{aud,s}}\\}$$. In each case the amount mask is `lane[0]` of the channel's sponge; the remaining lanes ($$m\_{r,r}$$ on the recipient channel; $$m\_{b,s}$$ and $$m\_{r,s}$$ on the sender channel) are computed and discarded for an amount disclosure, or used in place of `lane[0]` for the balance/randomness variants noted below. A3 squeezes each channel at the width [Poseidon2 Hash](../../protocol/primitives.md#poseidon2-hash) fixes for its tag — two lanes on $$\delta\_{\text{aud\\\_r}}$$, three on $$\delta\_{\text{aud\\\_s}}$$ — so neither tag is ever read at a second arity ([Mode exclusivity](../../protocol/primitives.md#mode-exclusivity)). + +## Public inputs + +| Symbol | Source | +|:---|:---| +| $$K\_{\text{aud}}$$ | auditor's on-chain Grumpkin pubkey for the chosen channel ($$K\_{\text{aud,r}}$$ or $$K\_{\text{aud,s}}$$) ([Auditor Key Management and Rotation](../../protocol/auditing.md#auditor-key-management-and-rotation)) | +| $$R\_e, \sigma\_E, \tilde{v}\_{\text{aud}}$$ | from the on-chain event ($$\tilde{v}\_{\text{aud,r}}$$ for the recipient-side channel, $$\tilde{v}\_{\text{aud,s}}$$ for the sender-side channel). $$\sigma\_E = \sigma$$ for `Transfer`, $$\sigma\_E = \sigma\_a'$$ for `SpenderTransfer` ([Spender Transfer](../../protocol/operations/spender-transfer.md)). | +| $$P\_R, \nu$$ | disclosure recipient pubkey and nonce | +| $$R\_{\text{disc}}, \tilde{v}\_{\text{disc}}$$ | disclosure ciphertext | + +## Private witnesses + +$$aud\_{sk}$$, $$v\_{\text{transfer}}$$, $$r\_{\text{disc}}$$. + +## Constraints + +| # | Constraint | +|:--|:---| +| A1 | $$K\_{\text{aud}} = aud\_{sk} \cdot H$$ (auditor key ownership) | +| A2 | $$s\_{\text{aud}} = \text{ECDH}(aud\_{sk}, R\_e)$$ (auditor-side ECDH shared scalar; mirrors the prover-side $$\text{ECDH}(r\_e, K\_{\text{aud}})$$ from [T_a1](../../protocol/operations/transfer.md#constraints) / T_a5) | +| A3 | $$(m\_v, m\_2, \ldots) = \text{SpongeSqueeze}\_n(\delta\_{\text{aud}}, s\_{\text{aud}}, \sigma\_E)$$, $$n = 2$$ on $$\delta\_{\text{aud\\\_r}}$$ and $$n = 3$$ on $$\delta\_{\text{aud\\\_s}}$$ (auditor channel sponge at the width [Poseidon2 Hash](../../protocol/primitives.md#poseidon2-hash) fixes per tag; same construction as [Per-Transfer Auditor Ciphertexts](../../protocol/auditing.md#per-transfer-auditor-ciphertexts)) | +| A4 | $$v\_{\text{transfer}} = \tilde{v}\_{\text{aud}} - m\_v$$ (correct decryption of the channel's amount slot, the first squeeze) | +| D5 | $$v\_{\text{transfer}} \in [0, 2^{127})$$ | +| U1–U3 | Disclosure ciphertext to recipient ([Disclosure Ciphertext to Recipient](../protocol.md#disclosure-ciphertext-to-recipient)) | + +D-auditor does not bind to an account record; the auditor key already binds the proof. The disclosure recipient confirms which account the event concerns by reading the event's sender and recipient addresses directly. + +## Verifier flow + +Follow [Verifier Protocol](../protocol.md#verifier-protocol) with `circuit_id = D-auditor` (or the chosen balance / randomness variant). Step 2 is skipped — no $$\text{PVK}\_A$$ lookup is needed. Step 3 resolves $$K\_{\text{aud}}$$ at the event's ledger: $$K\_{\text{aud,r}}$$ from the `auditor_id` on the event's `to` account when disclosing the recipient-side channel, or $$K\_{\text{aud,s}}$$ from the `auditor_id` on the `from` account when disclosing the sender-side channel. `from` is the funds' owner in both `Transfer` and `SpenderTransfer`, since the sender-auditor channel always tracks the owner ([Spender Transfer](../../protocol/operations/spender-transfer.md)). + +**Balance / randomness variants.** `lane[1]` of each channel carries a distinct datum: $$m\_{b,s}$$ (sender's post-transfer balance checkpoint, channel $$\delta\_{\text{aud\\\_s}}$$, recovered from $$\tilde{b}\_{\text{aud,s}}$$; on a `SpenderTransfer` the same lane carries the post-transfer allowance, recovered from $$\tilde{a}\_{\text{aud,s}}$$, [Spender Transfer](../../protocol/operations/spender-transfer.md) O\_a8) or $$m\_{r,r}$$ (per-transfer Pedersen randomness, channel $$\delta\_{\text{aud\\\_r}}$$, recovered from $$\tilde{r}\_{\text{aud,r}}$$). A circuit that discloses either of these substitutes the corresponding event ciphertext for $$\tilde{v}\_{\text{aud}}$$ in A4 and reads $$m\_2$$ rather than $$m\_v$$ from the sponge output. Range constraint D5 applies unchanged to a balance disclosure; for a randomness disclosure D5 is dropped since $$r\_{\text{transfer}} \in \mathbb{F}\_r$$ is not range-bounded. The balance variant has a blinding sibling on `lane[2]` of the sender channel: $$m\_{r,s}$$ recovers the sender's post-transfer spendable blinding from $$\tilde{r}\_{\text{aud,s}}$$ ([Confidential Transfer](../../protocol/operations/transfer.md) T\_a9), so an auditor can disclose the full opening of the sender's $$C\_{\text{spend}}$$ *as of that transfer* rather than its value alone ([Per-Transfer Auditor Ciphertexts](../../protocol/auditing.md#per-transfer-auditor-ciphertexts)); the variant reads A3's third lane and D5 is dropped as for the randomness variant. On a `SpenderTransfer` the same lane carries the post-transfer allowance blinding $$r\_a'$$ ([Spender Transfer](../../protocol/operations/spender-transfer.md) O\_a9), so the same variant discloses the opening of $$C\_a'$$ rather than of $$C\_{\text{spend}}'$$ ([Spender Allowance Auditing](../../protocol/auditing.md#spender-allowance-auditing)). These variants are not separately tabulated. + +--- + +Previous: [D-sender](d-sender.md) · Up: [Index](../../README.md#companions) · Next: [D-balance](d-balance.md) diff --git a/packages/tokens/src/confidential/docs/selective-disclosure/circuits/d-balance.md b/packages/tokens/src/confidential/docs/selective-disclosure/circuits/d-balance.md new file mode 100644 index 000000000..cc9125b1d --- /dev/null +++ b/packages/tokens/src/confidential/docs/selective-disclosure/circuits/d-balance.md @@ -0,0 +1,56 @@ +# Circuit D-balance: Holder Discloses Current Balance + +The account holder proves a property of their **current** confidential balance to a third party. Unlike the transfer-event variants ([D-recipient](d-recipient.md), [D-sender](d-sender.md), [D-auditor](d-auditor.md)), D-balance attests to present state, not a past event: the proof opens the on-chain Pedersen commitment $$C\_{\text{spend}}$$ that records the holder's latest spend-side balance ([Balance Commitments](../../protocol/keys-and-commitments.md#balance-commitments), [Wallet State and Recovery](../../protocol/wallet-state.md)) using the holder's retained opening $$(v\_s, r\_s)$$. Typical uses are reporting-threshold attestations — "balance is at most $$V\_{\text{threshold}}$$" for non-reportability, "balance is at least $$V\_{\text{threshold}}$$" for solvency. + +The holder maintains $$(v\_s, r\_s)$$ as normal wallet state — every successful transfer settles a fresh opening ([Wallet State and Recovery](../../protocol/wallet-state.md)) and the wallet retains the latest pair. D-balance therefore needs the current spendable opening, which the wallet either holds locally or reconstructs from the latest checkpoint per [Recovery](../../protocol/wallet-state.md#recovery); ordinary spend-proof construction is under the same condition. + +## Public inputs + +| Symbol | Source | +|:---|:---| +| $$\text{addr\\\_f}$$ | compressed contract-address Field, loaded from instance storage | +| $$\text{PVK}\_A$$ | disclosing account's stored `viewing_public_key` | +| $$C\_{\text{spend}}$$ | on-chain Pedersen commitment to the holder's current spend-side balance, read from the account's `confidential_balance` record ([Account Data Model](../../protocol/account-state.md#account-data-model), [Read Methods](../../protocol/interface.md#read-methods)) | +| $$V\_{\text{threshold}}$$ | (predicate variant only) threshold value | +| $$P\_R, \nu$$ | disclosure recipient pubkey and nonce | +| $$R\_{\text{disc}}, \tilde{v}\_{\text{disc}}$$ | (value-revealing variant only) disclosure ciphertext | + +## Private witnesses + +$$sk\_A$$, $$vk\_A$$, $$v\_s$$, $$r\_s$$, and $$r\_{\text{disc}}$$ when the value-revealing variant is in use. + +## Constraints + +| # | Constraint | +|:--|:---| +| D1 | $$vk\_A = \text{Poseidon}(\delta\_{\text{vk}}, sk\_A, \text{addr\\\_f})$$ | +| D2 | $$\text{PVK}\_A = vk\_A \cdot H$$ | +| DB3 | $$C\_{\text{spend}}$$ opens to $$(v\_s, r\_s)$$ under the Grumpkin Pedersen scheme of [Pedersen Commitments](../../protocol/primitives.md#pedersen-commitments) | +| DB4 | (predicate variant) $$v\_s \geq V\_{\text{threshold}}$$ **or** $$v\_s \leq V\_{\text{threshold}}$$, fixed per `circuit_id` | +| D5 | $$v\_s \in [0, 2^{127})$$ | +| U1–U3 | (value-revealing variant) disclosure ciphertext of $$v\_s$$ to recipient ([Disclosure Ciphertext to Recipient](../protocol.md#disclosure-ciphertext-to-recipient)) | + +Two `circuit_id` shapes are exposed: a **predicate-only** form (`disclose_balance_ge` / `disclose_balance_le`) that includes DB4 and omits U1–U3, where the proof's mere validity asserts the predicate; and a **value-revealing** form (`disclose_balance_value`) that includes U1–U3 and omits DB4, where the recipient decrypts $$\tilde{v}\_{\text{disc}}$$ to learn $$v\_s$$ exactly. + +D1, D2 bind the proof to the disclosing account. DB3 forces the witnessed $$v\_s$$ to be the value the on-chain commitment opens to — by Pedersen binding ([Pedersen Commitments](../../protocol/primitives.md#pedersen-commitments)), no alternative opening exists with non-negligible probability. D5 prevents the predicate from being satisfied by a wrapped-negative $$v\_s$$ that doesn't represent any real balance. + +**Distinguishing from D-auditor balance variant ([Circuit D-auditor: Auditor Discloses a Transfer](d-auditor.md)).** [Circuit D-auditor: Auditor Discloses a Transfer](d-auditor.md)'s balance variant decrypts the sender's *post-transfer* balance, and with its `lane[2]` sibling the full opening, from a specific transfer event — event-anchored, historical, requires auditor cooperation. D-balance is holder-side, reflects *current* on-chain state, and supports predicate-only disclosure that [Circuit D-auditor: Auditor Discloses a Transfer](d-auditor.md)'s variant does not. Capability does not distinguish them: the auditor tracks the current opening $$(v\_s, r\_s)$$ of every scoped account's $$C\_{\text{spend}}$$ forward through checkpoints, merges, and revokes ([Per-Transfer Auditor Ciphertexts](../../protocol/auditing.md#per-transfer-auditor-ciphertexts), [Auditor Visibility Properties](../../protocol/auditing.md#auditor-visibility-properties)), so it could satisfy DB3 against the live commitment as readily as the holder. What distinguishes D-balance is who is bound: D1 and D2 tie the proof to the holder's $$sk\_A$$, making it the holder's own attestation, whereas an auditor-produced current-balance proof would bind through auditor key ownership (A1) instead. No such variant is specified here; a recipient that needs a backstop with auditor attestation uses D-auditor over a recent event, and a recipient that needs predicate-only disclosure, or a statement the holder rather than the auditor stands behind, uses D-balance. + +## Verifier flow + +D-balance has no on-chain event to reference, so the bundle is: + +$$\text{Bundle}\_{\text{balance}} = (\text{circuit\\\_id}, \text{account}, \pi, R\_{\text{disc}}, \tilde{v}\_{\text{disc}}?)$$ + +where `account` is the disclosing address (agreed during the request, not blindly accepted from the prover) and $$\tilde{v}\_{\text{disc}}$$ is omitted in the predicate-only variant. The recipient performs [Verifier Protocol](../protocol.md#verifier-protocol) with the following substitutions: + +1. **Resolve account state.** Read `confidential_balance(account)`, extracting $$\text{PVK}\_A$$ and $$C\_{\text{spend}}$$. +2. **Resolve auxiliary state.** Read $$\text{addr\\\_f}$$ from instance storage. +3. **Construct the public-input vector.** Combine the resolved on-chain state, the recipient's $$(P\_R, \nu)$$, the agreed $$V\_{\text{threshold}}$$ (predicate variants), and the bundle's $$(R\_{\text{disc}}, \tilde{v}\_{\text{disc}})$$. As in [Proof Bundle](../protocol.md#proof-bundle) the verifier MUST NOT accept $$\text{PVK}\_A$$, $$C\_{\text{spend}}$$, or $$V\_{\text{threshold}}$$ from the bundle. +4. **Verify proof and decrypt** as in [Verifier Protocol](../protocol.md#verifier-protocol) steps 5–6 (decryption applies only to the value-revealing variant). + +The recipient and prover MUST agree on $$V\_{\text{threshold}}$$ during the request — otherwise the holder could pick a threshold the recipient never authorized and produce a proof against it. The freshness of the disclosure is the ledger at which the recipient read $$C\_{\text{spend}}$$: if $$C\_{\text{spend}}$$ changed between proving and verification — by the holder's own operation, or without the holder's participation through a compliance clawback or forced revocation ([Contract Flow](../../compliance.md#contract-flow), [Forced Revocation](../../compliance.md#forced-revocation)) — verification fails naturally; the prover then re-runs against the new commitment. + +--- + +Previous: [D-auditor](d-auditor.md) · Up: [Index](../../README.md#companions) · Next: [Aggregate Disclosures](aggregate.md) diff --git a/packages/tokens/src/confidential/docs/selective-disclosure/circuits/d-recipient.md b/packages/tokens/src/confidential/docs/selective-disclosure/circuits/d-recipient.md new file mode 100644 index 000000000..86d2108b6 --- /dev/null +++ b/packages/tokens/src/confidential/docs/selective-disclosure/circuits/d-recipient.md @@ -0,0 +1,39 @@ +# Circuit D-recipient: Holder Discloses an Inbound Transfer + +The account holder is the recipient of an on-chain confidential transfer (either a `Transfer` to them or a `SpenderTransfer` whose `to` is them) and proves to a third party that the transfer was for amount $$v\_{\text{transfer}}$$. The same circuit covers both event families because the recipient-side ECDH constraint has identical shape in either case; only the value of the event nonce $$\sigma\_E$$ differs ($$\sigma$$ for `Transfer`, $$\sigma\_a'$$ for `SpenderTransfer`; see [T9](../../protocol/operations/transfer.md#constraints) and [O9](../../protocol/operations/spender-transfer.md#constraints)). + +## Public inputs + +| Symbol | Source | +|:---|:---| +| $$\text{addr\\\_f}$$ | compressed contract-address Field, loaded from instance storage ([Address-to-Field Encoding](../../protocol/primitives.md#address-to-field-encoding), [Governance and Upgradeability](../../protocol/system-model.md#governance-and-upgradeability)) | +| $$\text{PVK}\_A$$ | disclosing account's stored `viewing_public_key` ([Account Data Model](../../protocol/account-state.md#account-data-model)); $$A$$ is the address listed as the event's `to` | +| $$R\_e, \sigma\_E, \tilde{v}$$ | from the on-chain event being disclosed ([Event Schema](../../protocol/interface.md#event-schema)). $$\sigma\_E = \sigma$$ for `Transfer`, $$\sigma\_E = \sigma\_a'$$ for `SpenderTransfer`. | +| $$P\_R$$ | disclosure recipient's Grumpkin pubkey ([Disclosure Recipient](../README.md#disclosure-recipient)) | +| $$\nu$$ | recipient-supplied nonce ([Disclosure Recipient](../README.md#disclosure-recipient)) | +| $$R\_{\text{disc}}, \tilde{v}\_{\text{disc}}$$ | disclosure ciphertext to recipient ([Disclosure Ciphertext to Recipient](../protocol.md#disclosure-ciphertext-to-recipient)) | + +## Private witnesses + +$$sk\_A$$, $$vk\_A$$, $$v\_{\text{transfer}}$$, $$r\_{\text{disc}}$$. + +## Constraints + +| # | Constraint | +|:--|:---| +| D1 | $$vk\_A = \text{Poseidon}(\delta\_{\text{vk}}, sk\_A, \text{addr\\\_f})$$ (viewing key correctly derived, binds proof to contract; mirrors [R2/T2/W2/S2](../../protocol/operations/register.md#constraints)) | +| D2 | $$\text{PVK}\_A = vk\_A \cdot H$$ (binds proof to on-chain account) | +| D3 | $$s = \text{ECDH}(vk\_A, R\_e)$$ (recipient-side ECDH shared scalar, [Elliptic Curve Diffie-Hellman](../../protocol/primitives.md#elliptic-curve-diffie-hellman)) | +| D4 | $$v\_{\text{transfer}} = \tilde{v} - \text{Poseidon}(\delta\_{\text{transfer\\\_amount}}, s, \sigma\_E)$$ (correct decryption of event amount; matches [T9](../../protocol/operations/transfer.md#constraints) for `Transfer` and O9 for `SpenderTransfer`) | +| D5 | $$v\_{\text{transfer}} \in [0, 2^{127})$$ (range, [Integer Embedding and Range Proofs](../../protocol/primitives.md#integer-embedding-and-range-proofs)) | +| U1–U3 | Disclosure ciphertext to recipient ([Disclosure Ciphertext to Recipient](../protocol.md#disclosure-ciphertext-to-recipient)) | + +D1 and D2 anchor the proof to the disclosing account's on-chain record without revealing $$sk\_A$$ or $$vk\_A$$. D3 and D4 recompute the standard recipient-side decryption that the holder would normally perform offline to learn the incoming amount. The result $$v\_{\text{transfer}}$$ then feeds the U-block, which encrypts it to the disclosure recipient. + +## Verifier flow + +Follow [Verifier Protocol](../protocol.md#verifier-protocol) with `circuit_id = D-recipient`. Step 2 resolves $$\text{PVK}\_A$$ at $$E.\text{to}$$ (the only account record this variant consults). On success, the recipient now knows that the named on-chain event paid the named account exactly $$v\_{\text{transfer}}$$ tokens, and learns nothing else. + +--- + +Previous: [Disclosure Protocol](../protocol.md) · Up: [Index](../../README.md#companions) · Next: [D-sender](d-sender.md) diff --git a/packages/tokens/src/confidential/docs/selective-disclosure/circuits/d-sender.md b/packages/tokens/src/confidential/docs/selective-disclosure/circuits/d-sender.md new file mode 100644 index 000000000..9f09299f8 --- /dev/null +++ b/packages/tokens/src/confidential/docs/selective-disclosure/circuits/d-sender.md @@ -0,0 +1,68 @@ +# Circuit D-sender: Sender Discloses an Outbound Transfer + +The party that **originated** an on-chain confidential transfer proves to a third party that they paid $$v\_{\text{transfer}}$$ to the on-chain recipient address recorded in the event. "Sender" here covers both: + +- **`Transfer` events:** the originator is the account holder $$A$$ at `from`. The disclosing key material is the holder's own $$(sk\_A, vk\_A)$$ and the ephemeral scalar $$r\_e$$ used at transfer time. +- **`SpenderTransfer` events:** the originator is the spender at `spender`, **not** the owner at `from`. The disclosing key material is the spender's own $$(sk\_{\text{op}}, vk\_{\text{op}})$$ and the ephemeral scalar $$r\_e$$ used at transfer time. + +In both cases the prover must supply the ephemeral scalar $$r\_e$$ as a witness: the sender has no ECDH path through their own $$vk$$ into the event ciphertext $$\tilde{v}$$ (that ciphertext is keyed to the recipient's $$\text{PVK}\_B$$), so $$r\_e$$ is necessary to reconstruct the recipient-side decryption from the sender's side. + +**Recovering $$r\_e$$.** The originator recomputes the ephemeral scalar from its own viewing key and the event nonce ([ECDH-Derived Blinding](../../protocol/keys-and-commitments.md#ecdh-derived-blinding)): + +$$r\_e = \text{Poseidon2}(\delta\_{\text{eph}}, vk, \sigma\_E)$$ + +where $$vk$$ is the originator's viewing key ($$vk\_A$$ for `Transfer`, $$vk\_{\text{op}}$$ for `SpenderTransfer`) and $$\sigma\_E$$ is the event nonce ($$\sigma$$ or $$\sigma\_a'$$). The disclosed amount then follows: + +$$v\_{\text{transfer}} = \tilde{v} - \text{Poseidon}(\delta\_{\text{transfer\\\_amount}}, \text{ECDH}(r\_e, \text{PVK}\_B), \sigma\_E) \qquad \text{(Elliptic Curve Diffie-Hellman)}$$ + +with $$\text{PVK}\_B$$ read from the event's `to` address. Both quantities come from the wallet's $$vk$$ and an on-chain read of the event, so D-sender needs **no** per-transfer wallet state, matching the storage-free posture of D-recipient ([Circuit D-recipient: Holder Discloses an Inbound Transfer](d-recipient.md)). Recovery happens off-circuit: $$r\_e$$ enters the proof as a witness pinned by DS3, and no disclosure circuit absorbs $$\delta\_{\text{eph}}$$. + +**What this implies for $$vk$$.** Since $$r\_e$$ is recoverable from $$vk$$, so is a full Pedersen opening of every transfer the account originated. [Privacy Properties](../../protocol/security.md#privacy-properties) states the capability a compromised $$vk$$ therefore carries; [Recipient-auditor opening capability](../../protocol/auditing.md#recipient-auditor-opening-capability) records the other holder of those per-transfer openings, so they are exclusive to neither party. The operative consequence for this layer is that a counterparty needing outbound visibility is served with D-sender proofs, which are bound to that counterparty and to a nonce ([Recipient Binding](../security.md#recipient-binding)) — never by handing over $$vk$$ ([Security Requirements](../../sdk/requirements.md#security-requirements)). + +In the symbols below, $$A$$ denotes the **originating** address — the holder's address for `Transfer` and the spender's address for `SpenderTransfer`. $$sk\_A$$ is the originator's spending key, $$\text{PVK}\_A$$ is the originator's stored public viewing key, and $$\sigma\_E = \sigma$$ for `Transfer`, $$\sigma\_E = \sigma\_a'$$ for `SpenderTransfer`. + +## Public inputs + +| Symbol | Source | +|:---|:---| +| $$\text{addr\\\_f}$$ | compressed contract-address Field, loaded from instance storage | +| $$\text{PVK}\_A$$ | originating account's stored `viewing_public_key` (holder for `Transfer`, spender for `SpenderTransfer`) | +| $$R\_e, \sigma\_E, \tilde{v}$$ | from the on-chain event | +| $$\text{PVK}\_B$$ | recipient's stored `viewing_public_key` (looked up from event's `to` address) | +| $$P\_R, \nu$$ | disclosure recipient pubkey and nonce | +| $$R\_{\text{disc}}, \tilde{v}\_{\text{disc}}$$ | disclosure ciphertext | + +## Private witnesses + +$$sk\_A$$, $$vk\_A$$, $$r\_e$$, $$v\_{\text{transfer}}$$, $$r\_{\text{disc}}$$. + +## Constraints + +| # | Constraint | +|:--|:---| +| D1 | $$vk\_A = \text{Poseidon}(\delta\_{\text{vk}}, sk\_A, \text{addr\\\_f})$$ | +| D2 | $$\text{PVK}\_A = vk\_A \cdot H$$ | +| DS3 | $$R\_e = r\_e \cdot H$$ (prover knows the ephemeral scalar used at transfer time; same shape as [T6](../../protocol/operations/transfer.md#constraints) for `Transfer` and O6 for `SpenderTransfer`) | +| DS4 | $$s\_B = \text{ECDH}(r\_e, \text{PVK}\_B)$$ (sender-side ECDH shared scalar to recipient, [Elliptic Curve Diffie-Hellman](../../protocol/primitives.md#elliptic-curve-diffie-hellman)) | +| DS5 | $$v\_{\text{transfer}} = \tilde{v} - \text{Poseidon}(\delta\_{\text{transfer\\\_amount}}, s\_B, \sigma\_E)$$ | +| D5 | $$v\_{\text{transfer}} \in [0, 2^{127})$$ | +| U1–U3 | Disclosure ciphertext to recipient ([Disclosure Ciphertext to Recipient](../protocol.md#disclosure-ciphertext-to-recipient)) | + +DS3 anchors $$R\_e$$ to the originator by forcing them to know $$r\_e$$. Combined with D1/D2, this proves the prover is the same party that produced the transfer's ephemeral key — the holder for `Transfer`, the spender for `SpenderTransfer`. DS4 and DS5 reconstruct the recipient-side decryption from the originator's perspective. + +## Coverage asymmetry: owner cannot D-sender a `SpenderTransfer` + +A spender transfer's ephemeral scalar derives from the spender's $$vk\_{\text{op}}$$ ([Spender Transfer](../../protocol/operations/spender-transfer.md)), which the owner does not hold, so the owner cannot recover $$r\_e$$ for the event; nor does the owner have any other ECDH path into $$\tilde{v}$$, which is keyed to $$\text{PVK}\_B$$. The owner therefore cannot independently produce a D-sender disclosure for a `SpenderTransfer`. The owner's cryptographic paths for that event are: + +1. **D-auditor ([Circuit D-auditor: Auditor Discloses a Transfer](d-auditor.md))** routed through the owner's auditor key $$K\_{\text{aud,s}}$$, which decrypts $$\tilde{v}\_{\text{aud,s}}$$ for every `SpenderTransfer` from the owner's account ([Spender Transfer Auditing](../../protocol/auditing.md#spender-transfer-auditing)). This is the canonical owner-side path. +2. **D-sender by the cooperating spender.** If the spender is willing, they construct a D-sender proof against the spender's own $$(sk\_{\text{op}}, \text{PVK}\_{\text{op}})$$ and deliver it to the owner, who forwards it (or the owner asks the disclosure recipient to accept proofs originated by the spender). The proof's $$\text{PVK}\_A$$ is the spender's PVK; the verifier looks it up at the event's `spender` address. + +A D-sender proof for a `SpenderTransfer` proves that the spender (not the owner) paid the on-chain `to`. If the disclosure recipient additionally needs proof that the owner authorized this spender, they read the `SetSpender` event and observe the on-chain `(owner, spender)` delegation entry; nothing in D-sender attests to delegation provenance. + +## Verifier flow + +Follow [Verifier Protocol](../protocol.md#verifier-protocol) with `circuit_id = D-sender`. Step 2 looks up two account records: $$\text{PVK}\_A$$ at $$E.\text{from}$$ (for `Transfer`) or $$E.\text{spender}$$ (for `SpenderTransfer`), and $$\text{PVK}\_B$$ at $$E.\text{to}$$ in both cases. + +--- + +Previous: [D-recipient](d-recipient.md) · Up: [Index](../../README.md#companions) · Next: [D-auditor](d-auditor.md) diff --git a/packages/tokens/src/confidential/docs/selective-disclosure/protocol.md b/packages/tokens/src/confidential/docs/selective-disclosure/protocol.md new file mode 100644 index 000000000..580d68e02 --- /dev/null +++ b/packages/tokens/src/confidential/docs/selective-disclosure/protocol.md @@ -0,0 +1,157 @@ +# Disclosure Protocol + +## Disclosure Ciphertext to Recipient + +All variants below share a common output stage that encrypts the disclosed value $$v\_{\text{transfer}}$$ under the recipient's key $$P\_R$$. + +The prover samples an ephemeral scalar $$r\_{\text{disc}} \in \mathbb{F}\_r$$ by the rejection-sampling procedure of [Grumpkin-BN254 Cycle](../protocol/primitives.md#grumpkin-bn254-cycle) and computes: + +$$R\_{\text{disc}} = r\_{\text{disc}} \cdot H$$ +$$s\_{\text{disc}} = \text{ECDH}(r\_{\text{disc}}, P\_R) \qquad \text{(Elliptic Curve Diffie-Hellman)}$$ +$$\tilde{v}\_{\text{disc}} = v\_{\text{transfer}} + \text{Poseidon}(\delta\_{\text{disc}}, s\_{\text{disc}}, \nu)$$ + +The recipient decrypts: + +$$s\_{\text{disc}} = \text{ECDH}(r\_R, R\_{\text{disc}}), \qquad v\_{\text{transfer}} = \tilde{v}\_{\text{disc}} - \text{Poseidon}(\delta\_{\text{disc}}, s\_{\text{disc}}, \nu)$$ + +The pair $$(R\_{\text{disc}}, \tilde{v}\_{\text{disc}})$$ is part of the proof's public inputs. The disclosed amount is therefore confidential to any party other than the recipient even if the proof itself is archived in the clear. + +This block is constraints **U1–U3**: + +| # | Constraint | +|:--|:---| +| U1 | $$R\_{\text{disc}} = r\_{\text{disc}} \cdot H$$ | +| U2 | $$s\_{\text{disc}} = \text{ECDH}(r\_{\text{disc}}, P\_R)$$ | +| U3 | $$\tilde{v}\_{\text{disc}} = v\_{\text{transfer}} + \text{Poseidon}(\delta\_{\text{disc}}, s\_{\text{disc}}, \nu)$$ | + +Subsequent variants reference this block by name. + +--- + +## Proof Bundle and Verifier Protocol + +The [circuit specifications](circuits/) define what each variant proves. This section defines the *transport* and the *verifier's mandatory checks*, which are common to all variants and are the only checks that bind a proof to specific on-chain state. + +The disclosure layer runs entirely between two off-chain parties — the prover (holder, sender, or auditor) and the disclosure recipient — with the blockchain participating only as a read-only source of truth. Three properties define this operating model. + +**Shared circuit artifacts.** Both parties derive their tools from the *same* compiled Noir circuit. The prover side runs the proving key and the witness generator, embedded in the wallet ([Wallet Responsibilities](security.md#wallet-responsibilities)); the recipient side runs the verification key for the matching `circuit_id`, embedded in the verifier library ([Verifier Library](security.md#verifier-library)). A verification key is the cryptographic fingerprint of one specific circuit, so the `circuit_id` carried in the bundle ([Proof Bundle](#proof-bundle)) is what lets the recipient load the right key and reject a proof produced by any other circuit. Prover and verifier must therefore agree, out of band, on *which* compiled circuit each `circuit_id` denotes. A recipient who loads a verification key for a maliciously altered circuit can be convinced of false statements, so the verification-key set is a trusted input. + +**Authenticated channel.** The request ([End-to-End Flow](#end-to-end-flow) step 1) and the bundle delivery ([End-to-End Flow](#end-to-end-flow) step 4) travel over a channel the two parties already trust for authenticity — TLS to a compliance API, a signed email, a dedicated KYC portal. The channel carries the recipient's $$(P\_R, \nu)$$ outbound and the bundle inbound. What it does **not** have to provide is confidentiality of the disclosed value: the value is sealed to $$P\_R$$ inside $$\tilde{v}\_{\text{disc}}$$ ([Disclosure Ciphertext to Recipient](#disclosure-ciphertext-to-recipient)), so an eavesdropper learns nothing even in the value-revealing variants, and channel confidentiality is defense-in-depth rather than a requirement. + +**Independent on-chain reads.** Neither party trusts the other for any value the chain can supply. The recipient resolves every event field and account record directly from the ledger ([Verifier Protocol](#verifier-protocol)) through its own RPC endpoint or indexer ([Verifier Library](security.md#verifier-library)); the prover likewise reads the chain to assemble its witnesses and public inputs. The bundle is the *only* prover-to-verifier data transfer, and it carries only the proof and the references needed to locate the on-chain anchors — never the anchors themselves ([Proof Bundle](#proof-bundle)). + +The net effect is that a disclosure leaves no on-chain trace: no transaction, no event, no state change is associated with it ([Non-Goals](README.md#non-goals), [End-to-End Flow](#end-to-end-flow)). The chain does not know that a disclosure happened, to whom, or about what. + +### Event Reference + +An **event reference** $$\text{ref}\_E$$ uniquely identifies one on-chain transfer-family event: + +$$\text{ref}\_E = (\text{tx\\\_hash}, \text{op\\\_index}, \text{event\\\_index})$$ + +where `tx_hash` is the Soroban transaction hash that emitted the event and `event_index` selects the event among those the transaction emitted; `op_index` is always 0, since a Soroban transaction carries a single operation. This triple deterministically resolves to exactly one on-chain event, which is all the verifier's lookup ([Verifier Protocol](#verifier-protocol)) needs — it is a resolver, not an ordering key. Its `tx_hash` and `event_index` are the same fields the durable indexer keys on; the indexer's full event-identity and ordering scheme is defined in [Terminology](../indexer.md#terminology) — `(ledger_seq, tx_hash, event_index)` for identity, `(ledger_seq, tx_application_order, event_index)` for order. Implementations MAY substitute any identifier the indexer in use exposes, provided it deterministically resolves to a single on-chain event. + +### Proof Bundle + +The prover (holder, sender, or auditor) delivers the following bundle to the disclosure recipient over any authenticated channel: + +$$\text{Bundle} = (\text{circuit\\\_id}, \text{ref}\_E, \pi, R\_{\text{disc}}, \tilde{v}\_{\text{disc}})$$ + +| Field | Purpose | +|:---|:---| +| `circuit_id` | Identifies the variant — D-recipient, D-sender, D-auditor, or one of their aggregate / balance / randomness sub-forms — and, for D-auditor, the channel disclosed. Pins the verification key the recipient loads. | +| $$\text{ref}\_E$$ | Event reference ([Event Reference](#event-reference)). Tells the verifier which on-chain event the proof claims to describe. An aggregate form ([Aggregate Disclosures](circuits/aggregate.md)) carries one $$\text{ref}\_{E,i}$$ per event, in the order the circuit's per-event list expects. | +| $$\pi$$ | UltraHonk proof. | +| $$R\_{\text{disc}}, \tilde{v}\_{\text{disc}}$$ | Disclosure ciphertext ([Disclosure Ciphertext to Recipient](#disclosure-ciphertext-to-recipient)). Also appear in the proof's public-input vector. | + +The bundle does **not** include the event's payload, the disclosing account's address, or any of the circuit's other public inputs. Those are reconstructed by the verifier from $$\text{ref}\_E$$ and from on-chain state, never accepted from the prover's bundle. This is the analogue of [Public Input Sources](../protocol/operations/README.md#public-input-sources)'s trust-boundary rule: the prover supplies the proof and the event reference; everything else comes from authenticated on-chain state. + +### Verifier Protocol + +Given a bundle for $$(P\_R, \nu)$$ that this verifier previously issued, the recipient MUST perform every step below in order. Each step's failure is a hard reject; the recipient MUST NOT learn $$v\_{\text{transfer}}$$ from a bundle that fails any step. + +1. **Resolve the event.** Look up $$\text{ref}\_E$$ via the indexer or via direct RPC of the transaction. The lookup MUST return exactly one event whose contract address equals the deployed confidential-token contract. Extract the event's payload fields verbatim: + - For `Transfer`: `from`, `to`, $$R\_e$$, $$\sigma$$, $$\tilde{v}$$, $$\tilde{b}$$, $$\tilde{v}\_{\text{aud,r}}$$, $$\tilde{r}\_{\text{aud,r}}$$, $$\tilde{v}\_{\text{aud,s}}$$, $$\tilde{b}\_{\text{aud,s}}$$, $$\tilde{r}\_{\text{aud,s}}$$ ([Event Schema](../protocol/interface.md#event-schema)). + - For `SpenderTransfer`: `spender`, `from`, `to`, $$R\_e$$, $$\sigma\_a'$$, $$\tilde{v}$$, $$\tilde{v}\_{\text{aud,r}}$$, $$\tilde{r}\_{\text{aud,r}}$$, $$\tilde{v}\_{\text{aud,s}}$$, $$\tilde{a}\_{\text{aud,s}}$$, $$\tilde{r}\_{\text{aud,s}}$$ ([Event Schema](../protocol/interface.md#event-schema)). + + Any other event type, or a `circuit_id` whose constraints reference a field the event does not carry, is rejected here. + +2. **Resolve the disclosing account(s).** Determine which on-chain account records the proof's $$\text{PVK}\_A$$ (and, for D-sender, $$\text{PVK}\_B$$) MUST be drawn from. This is dictated by the variant and the event payload — NOT by anything in the bundle: + - D-recipient: $$\text{PVK}\_A$$ is read from the account at $$E.\text{to}$$. + - D-sender on `Transfer`: $$\text{PVK}\_A$$ from $$E.\text{from}$$, $$\text{PVK}\_B$$ from $$E.\text{to}$$. + - D-sender on `SpenderTransfer`: $$\text{PVK}\_A$$ from $$E.\text{spender}$$, $$\text{PVK}\_B$$ from $$E.\text{to}$$. + - D-auditor: no on-chain account record is consulted for $$\text{PVK}\_A$$; instead the auditor key $$K\_{\text{aud}}$$ is resolved per step 3. + +3. **Resolve auxiliary on-chain state.** Read $$\text{addr\\\_f}$$ from the contract's instance storage ([Governance and Upgradeability](../protocol/system-model.md#governance-and-upgradeability)). For D-auditor, look up the auditor key for the disclosing account's `auditor_id` at the version active at the event's ledger ([Auditor's off-chain obligation](../protocol/auditing.md#auditors-off-chain-obligation)); pick $$K\_{\text{aud,r}}$$ vs. $$K\_{\text{aud,s}}$$ according to the channel the `circuit_id` names. The verifier MUST reject if the version cannot be resolved (auditor contract has no key active at that ledger). + +4. **Construct the public-input vector.** Build the vector from the event payload (step 1), the on-chain account records (step 2), the auxiliary state (step 3), the recipient's own $$(P\_R, \nu)$$, and the bundle's $$(R\_{\text{disc}}, \tilde{v}\_{\text{disc}})$$. The verifier MUST NOT use any value from the bundle other than these last two. If any public input the circuit expects is unavailable (e.g., a referenced account is not registered), the verifier rejects. + +5. **Verify the proof.** Run UltraHonk verification with the verification key for `circuit_id` against the constructed public inputs and $$\pi$$. Reject on failure. + +6. **Decrypt.** Compute $$s\_{\text{disc}} = \text{ECDH}(r\_R, R\_{\text{disc}})$$ and $$v\_{\text{transfer}} = \tilde{v}\_{\text{disc}} - \text{Poseidon}(\delta\_{\text{disc}}, s\_{\text{disc}}, \nu)$$ as in [Disclosure Ciphertext to Recipient](#disclosure-ciphertext-to-recipient). Aggregate forms substitute $$\delta\_{\text{disc\\\_bind}}$$ for $$\delta\_{\text{disc}}$$ ([Aggregate Disclosures](circuits/aggregate.md)). + +### On-Chain Verification + +Nothing about the proofs themselves prevents a contract from verifying them. They are UltraHonk proofs, and the confidential-token contract already runs UltraHonk verification on-chain for the core transfer-family circuits ([Proof System](../protocol/proof-system.md)); a disclosure circuit's verification key could be registered the same way, behind an entry point that verifies a submitted proof and acts on the result. Verifying on-chain means submitting the proof as a transaction, which publishes the disclosure's existence, the recipient identity, the referenced event, and the timing into the public ledger. The defining property of the off-chain model is that a disclosure leaves no on-chain trace. + +**When it makes sense.** The situation that justifies the cost for on-chain verification is when the *result* of a disclosure must gate another on-chain action: a compliance escrow that releases funds only after a "balance ≥ $$X$$" proof verifies, an on-chain attestation registry, a permissioned pool that admits an account once an eligibility predicate passes. + +**A separate on-chain verifier protocol (out of scope).** Serving those cases is a protocol in its own rather than an addition to the disclosure layer, but its shape is straightforward, and the trust-boundary rule ([Proof Bundle](#proof-bundle)) carries over unchanged: the public inputs must come from somewhere other than the prover, who supplies only $$\pi$$. + +For *current-state* facts the inputs are already on-chain. A D-balance predicate ([D-balance](circuits/d-balance.md)) draws its public inputs — $$\text{addr\\\_f}$$, $$\text{PVK}\_A$$, $$C\_{\text{spend}}$$ — from the token contract's live storage, which a verifier contract reads by cross-contract call (`confidential_balance`, [On-Chain Read Surface](#on-chain-read-surface)). The verifier contract assembles the vector itself and runs UltraHonk verification ([Proof System](../protocol/proof-system.md)) to produce a verdict the gating logic consumes. + +For *event-anchored* facts the event fields ($$R\_e$$, $$\sigma\_E$$, $$\tilde{v}$$, the auditor ciphertexts) are emitted as events rather than held in contract storage, so they reach the contract through the request itself. A natural design is request/response: the disclosure recipient — the party that will rely on the verdict — submits an on-chain request to the verifier contract carrying the event data to be proven; the contract records it under the requester's state; the prover then posts $$\pi$$ in a follow-up transaction; and the contract builds the public-input vector from the requester's stored request, plus whatever it reads from the token contract (such as $$\text{PVK}\_A$$), and verifies. The trust boundary holds because the inputs originate with the requester and the token contract, never with the prover — the same division of roles as the off-chain protocol, where the verifier is likewise the party that supplies the public inputs. The one design point such a protocol must settle is that the contract attests *consistency with the submitted event data* but does not by itself confirm that data is a genuine ledger event: that suffices when the requester is the consumer of the verdict, but trustworthiness to unrelated third parties needs an additional event-inclusion binding. + +Either way the request and the proof are public, which is the deliberate privacy cost. None of this is part of the present document ([Out of Scope](security.md#out-of-scope)); it is outlined here only to show that on-chain verification is a separable protocol rather than a property of this layer. + +--- + +## On-Chain Read Surface + +The confidential-token contract requires no new state-modifying entry points to support this layer. The disclosure verifier needs the following public reads, all of which return data that is already stored or already emitted: + +| Read | Purpose | Notes | +|:---|:---|:---| +| `confidential_balance(account) -> ConfidentialAccount` | Verifier extracts $$\text{PVK}\_A$$ (and $$\text{PVK}\_B$$ for D-sender, $$C\_{\text{spend}}$$ for D-balance) from the returned `ConfidentialAccount` tuple | Already exposed ([Read Methods](../protocol/interface.md#read-methods)); the struct carries every field this layer reads, so no narrower accessor is required | +| Auditor contract's key lookup for `auditor_id` | Verifier looks up $$K\_{\text{aud,r}}$$ or $$K\_{\text{aud,s}}$$ | Already exposed ([Auditor Key Management and Rotation](../protocol/auditing.md#auditor-key-management-and-rotation)). The auditor contract MAY maintain a sequence of versioned keys per `auditor_id` with activation ledgers; the verifier MUST select the version whose activation ledger is the largest value not exceeding the disclosed event's ledger ([Auditor's off-chain obligation](../protocol/auditing.md#auditors-off-chain-obligation)). | +| Transfer-family events | Verifier reads the per-event fields ($$R\_e$$, $$\sigma$$ or $$\sigma\_a'$$, $$\tilde{v}$$, $$\tilde{b}$$, $$\tilde{v}\_{\text{aud,r}}$$, $$\tilde{r}\_{\text{aud,r}}$$, $$\tilde{v}\_{\text{aud,s}}$$, $$\tilde{b}\_{\text{aud,s}}$$ / $$\tilde{a}\_{\text{aud,s}}$$, $$\tilde{r}\_{\text{aud,s}}$$) | Already emitted ([Event Schema](../protocol/interface.md#event-schema)). `SpenderTransfer` uses $$\sigma\_a'$$ in place of $$\sigma$$ and $$\tilde{a}\_{\text{aud,s}}$$ in place of $$\tilde{b}\_{\text{aud,s}}$$. | +| Instance storage: $$\text{addr\\\_f}$$ | D-recipient, D-sender, and D-balance bind $$vk$$ derivation to the contract via $$\text{addr\\\_f}$$ | Computed once at construction ([Governance and Upgradeability](../protocol/system-model.md#governance-and-upgradeability)); the verifier reproduces it from the contract address using the encoding in [Address-to-Field Encoding](../protocol/primitives.md#address-to-field-encoding) | + +These are the only on-chain dependencies. Disclosure proofs are otherwise self-contained off-chain artifacts. + +--- + +## End-to-End Flow + +The diagram below shows the D-recipient case. D-sender and D-auditor follow the same shape with different prover roles and a different `circuit_id` in step (4); D-balance ([D-balance](circuits/d-balance.md)) differs in that no $$\text{ref}\_E$$ is exchanged — see [D-balance](circuits/d-balance.md) for its bundle shape. + +```text + Recipient/Verifier Holder Wallet + | | + | (1) request: "disclose event E to me" | + | includes (P_R, ν) and ref_E | + | ---------------------------------------------->| + | | + | (2) resolve ref_E to + | on-chain event E + | (3) run D-recipient + | circuit locally + | | + | (4) Bundle = | + | (circuit_id, ref_E, π, R_disc, ṽ_disc) | + | <----------------------------------------------| + | | + (5) Verifier Protocol step 1: resolve ref_E from chain + (event E with from, to, R_e, σ_E, ṽ, ...) + (6) Verifier Protocol step 2: read PVK_A from E.to + (7) Verifier Protocol step 3: read addr_f from instance storage + (8) Verifier Protocol step 4: build public-input vector + (9) Verifier Protocol step 5: verify π + (10) Verifier Protocol step 6: decrypt ṽ_disc with r_R, ν + → learns v_transfer +``` + +The recipient MAY agree on ref_E in step (1) ahead of time (e.g., "disclose the transfer at tx 0xabc, log #3") or leave it to the holder, in which case the bundle in step (4) is the first time ref_E is communicated; either way the verifier resolves it independently in step (5). There is no on-chain transaction for the disclosure itself. Steps (1) and (4) flow over any authenticated channel the parties already use (TLS, signed email, dedicated compliance API). + +--- + +Previous: [Selective Disclosure](README.md) · Up: [Index](../README.md#companions) · Next: [D-recipient](circuits/d-recipient.md) diff --git a/packages/tokens/src/confidential/docs/selective-disclosure/security.md b/packages/tokens/src/confidential/docs/selective-disclosure/security.md new file mode 100644 index 000000000..ad8042578 --- /dev/null +++ b/packages/tokens/src/confidential/docs/selective-disclosure/security.md @@ -0,0 +1,92 @@ +# Security and Implementation Notes + +## Security Analysis + +### Soundness of Disclosed Amount + +For the D-recipient circuit, soundness reduces to two facts: + +1. D1, D2 force the prover to know an $$sk\_A$$ whose derived $$vk\_A$$ matches the on-chain $$\text{PVK}\_A$$. By [Viewing Key](../protocol/keys-and-commitments.md#viewing-key) this party is the account owner. +2. D3, D4 force $$v\_{\text{transfer}}$$ to equal the decryption of $$\tilde{v}$$ under that owner's $$vk\_A$$. By [ECDH-Derived Blinding](../protocol/keys-and-commitments.md#ecdh-derived-blinding) this is the same value the on-chain transfer commitment $$C\_{\text{transfer}}$$ commits to, since the transfer circuit enforced consistent ECDH derivation at transfer time. + +Therefore, a soundness break would require either a key-derivation collision (Poseidon2 preimage break, [Poseidon2 Hash](../protocol/primitives.md#poseidon2-hash), [Threat Model](../protocol/system-model.md#threat-model)) or a discrete-log break on Grumpkin. Both are out of scope. + +D-sender soundness is symmetric: DS3 forces the prover to know $$r\_e$$ with $$R\_e = r\_e \cdot H$$, which by the transfer circuit's constraint T6 ([Confidential Transfer](../protocol/operations/transfer.md)) was the same $$r\_e$$ used to derive the auditor and recipient ciphertexts. DS4, DS5 reconstruct the decryption from the sender side. Soundness rests on DS3's binding through the event's $$R\_e$$ rather than on how the prover came to hold $$r\_e$$; [D-sender](circuits/d-sender.md)'s recovery step bears on wallet state, not on the soundness of the disclosed amount. + +D-auditor soundness is direct: A1 forces auditor-key ownership; A2–A4 reconstruct the standard auditor sponge decryption ([Per-Transfer Auditor Ciphertexts](../protocol/auditing.md#per-transfer-auditor-ciphertexts)). + +D-balance soundness reduces to Pedersen binding ([Pedersen Commitments](../protocol/primitives.md#pedersen-commitments)): given the on-chain $$C\_{\text{spend}}$$, the prover's witnesses $$(v\_s, r\_s)$$ satisfying DB3 uniquely determine $$v\_s$$ up to negligible probability. D1, D2 anchor the proof to the disclosing account as in D-recipient. The "current state" framing is established by the verifier's read protocol, not the circuit: a proof against a stale $$C\_{\text{spend}}$$ simply fails to verify against the current public-input vector, so the recipient only ever accepts proofs about the on-chain state at the moment of verification. + +**Event binding.** None of the soundness arguments above pin the proof to a *specific* on-chain event by themselves — they only force consistency with whatever $$(\text{PVK}\_A, R\_e, \sigma\_E, \tilde{v})$$ tuple the public-input vector commits to. The binding to the on-chain event is established off-chain by the [Verifier Protocol](protocol.md#verifier-protocol) verifier protocol: the verifier MUST resolve $$\text{ref}\_E$$ from the bundle to a specific event, MUST take all event-derived public inputs verbatim from that event, and MUST take all account-derived inputs from the on-chain account record at the address the *event* names. Skipping any of these steps voids the binding. Because $$R\_e$$ is unique per operation ([Poseidon2 Hash](../protocol/primitives.md#poseidon2-hash), [ECDH-Derived Blinding](../protocol/keys-and-commitments.md#ecdh-derived-blinding)), no two distinct on-chain events share an $$R\_e$$ except with negligible probability, so a proof that verifies against the vector built from event $$E$$ cannot also verify against the vector built from any $$E' \neq E$$. Event binding therefore inherits [Poseidon2 Hash](../protocol/primitives.md#poseidon2-hash)'s salt-uniqueness requirement: a repeated nonce within one account would collapse it alongside the channel masks. This is the soundness role the trust-boundary rule ([Proof Bundle](protocol.md#proof-bundle)) plays for the disclosure layer; it is the analogue of [Public Input Sources](../protocol/operations/README.md#public-input-sources)'s on-chain trust-boundary rule. + +### Recipient Binding + +The disclosed value $$v\_{\text{transfer}}$$ is delivered only through $$\tilde{v}\_{\text{disc}} = v\_{\text{transfer}} + \text{Poseidon}(\delta\_{\text{disc}}, s\_{\text{disc}}, \nu)$$, where $$s\_{\text{disc}} = \text{ECDH}(r\_{\text{disc}}, P\_R)$$ is recoverable only by the holder of $$r\_R$$. + +A party other than the intended recipient who obtains $$(\pi, R\_{\text{disc}}, \tilde{v}\_{\text{disc}})$$ can verify $$\pi$$ but cannot decrypt $$\tilde{v}\_{\text{disc}}$$. They learn that *some* value was disclosed but not the value itself. + +Nonce $$\nu$$ is bound into the Poseidon argument. A holder cannot reuse $$(\pi, R\_{\text{disc}}, \tilde{v}\_{\text{disc}})$$ against a recipient request that issued a different nonce, because the verifier's public inputs would not match. + +### What This Does Not Prevent + +**Holder cherry-picking.** A holder may disclose three inbound transfers from counterparty $$Y$$ while withholding a fourth. The verifier cannot detect this from the proof alone. Mitigation: completeness routes through the auditor (D-auditor variants), who sees every event under their scope. Recipients that require completeness must request from the auditor, not the holder. + +**Disclosure recipient leakage.** Once decrypted, $$v\_{\text{transfer}}$$ is plaintext in the recipient's possession. The recipient may store, share, or leak it. This is a non-cryptographic concern handled by the recipient's own data-protection obligations, not by the protocol. + +**Recipient compelling disclosure.** A recipient cannot force a holder to produce a proof. Compelled disclosure is a legal mechanism, not a cryptographic one; this layer enables disclosure when the holder is willing, and the auditor variants serve as the cryptographic backstop when the holder is not. + +**Side-channel inference from event metadata.** Sender and recipient addresses are cleartext in transfer events ([Design Goals](../protocol/README.md#design-goals)). A disclosure recipient who reads the event log can already determine *who* transacted with *whom* without any disclosure proof. The disclosure layer protects only the amount. + +--- + +## Out of Scope + +The following extensions are deliberately not part of this document. They are mentioned to make the scope boundary explicit: + +**Delegated viewers (passive ongoing disclosure).** A deployment where the same counterparty needs every transfer disclosed (e.g., a custody bank with continuous AML monitoring) would prefer extra per-transfer ciphertexts to per-transfer disclosure proofs. This requires core-protocol changes: an on-chain registry of viewer keys per account and modifications to the transfer-family circuits to emit additional ciphertexts. Out of scope here; revisit if real deployments demonstrate the need. + +**Merkle-accumulated event history with non-membership proofs.** Would enable cryptographic completeness ("the disclosed set is exhaustive") without trusting the auditor. Requires substantial on-chain storage changes and a new accumulator-maintenance circuit. + +**Public disclosure proofs (no recipient binding).** Replacing the U-block with a public-input $$v\_{\text{transfer}}$$ produces a portable proof anyone can verify. Useful for fire-and-forget compliance archives but loses recipient binding. Not included as a primary variant; can be added by trivially dropping U1–U3 and exposing $$v\_{\text{transfer}}$$ as a public input. This is also the proof shape an on-chain verifier would consume ([On-Chain Verification](protocol.md#on-chain-verification)). + +--- + +## Implementation Notes + +### Circuits + +Four new Noir circuits are added to the proof system: + +| Circuit | Purpose | +|:---|:---| +| `disclose_recipient` | D-recipient ([D-recipient](circuits/d-recipient.md)) and its aggregate form ([Aggregate Disclosures](circuits/aggregate.md)) | +| `disclose_sender` | D-sender ([D-sender](circuits/d-sender.md)) and its aggregate form ([Aggregate Disclosures](circuits/aggregate.md)) | +| `disclose_auditor` | D-auditor ([D-auditor](circuits/d-auditor.md)) and its aggregate form ([Aggregate Disclosures](circuits/aggregate.md)) | +| `disclose_balance` | D-balance ([D-balance](circuits/d-balance.md)), exposed as predicate-only (`disclose_balance_ge` / `disclose_balance_le`) and value-revealing (`disclose_balance_value`) variants | + +The aggregate forms can be implemented as a single parameterized circuit per role with a compile-time event-count bound, or as a family of circuits at $$n \in \\{1, 4, 16, 64\\}$$ to balance proving time against generality. Either way a proof over $$m \le n$$ events zero-fills the unused slots under the active-count input, and each aggregate form is exposed in the predicate-only and value-revealing shapes of D-balance ([Aggregate Disclosures](circuits/aggregate.md)). + +These circuits do *not* register with the on-chain verifier set ([Proof System](../protocol/proof-system.md)). They are verified entirely off-chain. + +### Wallet Responsibilities + +A wallet that supports holder-side disclosures must: + +1. Recover the event's ephemeral scalar $$r\_e$$ per [ECDH-Derived Blinding](../protocol/keys-and-commitments.md#ecdh-derived-blinding) when constructing a D-sender proof ([D-sender](circuits/d-sender.md)). No per-transfer storage is required: both $$r\_e$$ and $$v\_{\text{transfer}}$$ are recomputed at disclosure time from the wallet's $$vk$$ and the on-chain event. +2. Retain the latest opening $$(v\_s, r\_s)$$ of $$C\_{\text{spend}}$$ to support D-balance. This is part of the wallet's normal spend state. +3. Index event references (transaction hash, log index) per account event to enable selecting events by user-facing criteria (date, counterparty). +4. Expose a UI flow that takes a disclosure request $$(P\_R, \nu)$$ and a target event (or set), produces the disclosure proof, and delivers the result over the requested channel. + +### Verifier Library + +A standalone verifier library (independent of the wallet) consumes: + +- Network endpoint or RPC for on-chain reads. +- Disclosure recipient's $$(r\_R, P\_R)$$ keypair. +- Proof bundle $$(\pi, R\_{\text{disc}}, \tilde{v}\_{\text{disc}})$$ and event reference. + +It returns the decrypted $$v\_{\text{transfer}}$$ on successful verification, or a typed error indicating which check failed (proof verification, on-chain state mismatch, decryption failure). + +--- + +Previous: [Aggregate Disclosures](circuits/aggregate.md) · Up: [Index](../README.md#companions) diff --git a/packages/tokens/src/confidential/mod.rs b/packages/tokens/src/confidential/mod.rs index 78e1dc36b..30041872d 100644 --- a/packages/tokens/src/confidential/mod.rs +++ b/packages/tokens/src/confidential/mod.rs @@ -6,8 +6,7 @@ //! accompanied by an UltraHonk proof that the contract verifies via a separate //! verifier contract. Auditor keys are read from a separate registry contract //! and dual auditor ciphertexts are emitted in each transfer event. See -//! `docs/DESIGN.md` (§1–§7) and `docs/DESIGN_cont.md` (§8–§13) for the -//! full specification. +//! `docs/README.md` for the full specification. //! //! # ⚠️ Not Production Ready //! @@ -25,17 +24,19 @@ //! and three read methods) with default bodies that delegate to the matching //! free functions in [`storage`]. //! - The on-chain account state types [`ConfidentialAccount`] and -//! [`SpenderDelegation`] (DESIGN §6). -//! - The five XDR payload types carried in the `data: Bytes` parameter (DESIGN -//! §11). `revoke_spender` is proofless and carries none. +//! [`SpenderDelegation`] (`docs/protocol/account-state.md`). +//! - The five XDR payload types carried in the `data: Bytes` parameter +//! (`docs/protocol/interface.md`). `revoke_spender` is proofless and carries +//! none. //! - Storage helpers and operation-level orchestration under [`storage`]. //! //! ## Public-Input Encoding //! //! The contract assembles the public-input blob handed to the verifier by //! concatenating canonical 32-byte big-endian `Bn254Fr` representatives in -//! the order specified by each circuit's table in DESIGN §7. Grumpkin -//! points contribute two consecutive 32-byte limbs (`x` then `y`). Public +//! the order specified by each operation's table under +//! `docs/protocol/operations/`. Grumpkin points contribute two consecutive +//! 32-byte limbs (`x` then `y`). Public //! `i128` amounts are zero-padded to 32 bytes. The encoding is positional //! (no length prefix); any divergence from the order the prover used will //! cause verification to fail. @@ -63,14 +64,16 @@ //! The check is by deliberate design **not** an on-curve check. Prover- //! supplied points are constrained on-curve in-circuit by their //! `multi_scalar_mul` derivation and pinned to `≠ O` by explicit -//! nonzero-scalar constraints (DESIGN §10.8: R4/R5, W8, T13, S13, O13). +//! nonzero-scalar constraints (R4/R5, W8, T13, S13, O13; +//! `docs/protocol/proof-system.md#on-chain-point-arithmetic`). //! The only proof-less Grumpkin entry point is the auditor key, which the //! auditor contract validates canonical-encoding, on-curve, and non-identity //! at insertion via [`auditor::storage::validate_point`]. //! //! Together these three boundaries discharge the trust-boundary rule of -//! DESIGN §7.1: the canonical-encoding guard at the verifier boundary keeps -//! caller bytes byte-unique, the in-circuit constraints keep prover-supplied +//! `docs/protocol/operations/README.md#public-input-sources`: the +//! canonical-encoding guard at the verifier boundary keeps caller bytes +//! byte-unique, the in-circuit constraints keep prover-supplied //! points on-curve, and the auditor registry's insertion-time validation //! covers the lone proof-less point input. //! @@ -78,7 +81,9 @@ //! //! Every owner-initiated proof references a `addr_f` field, computed once at //! construction as `Poseidon2(δ_addr, lo, hi)` over the contract's own -//! address (DESIGN §2.7, §3.5) and stored in instance storage. +//! address (`docs/protocol/primitives.md#address-to-field-encoding`, +//! `docs/protocol/system-model.md#governance-and-upgradeability`) and stored +//! in instance storage. //! //! ## Underlying Token Requirements //! @@ -110,8 +115,9 @@ //! All deposits sit in a single pooled balance held by the contract's own //! address, and every withdrawal pays out of it via `token.transfer`. If //! the underlying is a Stellar Asset Contract, its issuer holds two -//! distinct powers over that pooled balance (DESIGN §3.4), and they behave -//! very differently. +//! distinct powers over that pooled balance +//! (`docs/protocol/system-model.md#underlying-token-assumptions`), and they +//! behave very differently. //! //! *Freeze / deauthorization* is a reversible block that removes no value. //! One issuer action against the contract's address blocks every deposit diff --git a/packages/tokens/src/confidential/storage.rs b/packages/tokens/src/confidential/storage.rs index fdf68894a..31e411e75 100644 --- a/packages/tokens/src/confidential/storage.rs +++ b/packages/tokens/src/confidential/storage.rs @@ -82,7 +82,8 @@ pub struct SpenderDelegation { // ################## DATA PAYLOADS ################## // -// Each `*Payload` struct mirrors DESIGN §11.1 minus the proof field. The +// Each `*Payload` struct mirrors the `data` table in +// docs/protocol/interface.md minus the proof field. The // matching `*Data` envelope is what the trait-entry-point `data: Bytes` // argument decodes into: `{ payload, proof }`. Splitting at the storage // boundary lets storage fns take `(payload, proof)` as two distinct args @@ -414,7 +415,8 @@ pub fn register( let _k_aud = auditor.get_key(&auditor_id); let addr_f = get_address_as_field_element(e); - // PI order (DESIGN §7.2): Y, PVK, addr_f, acct_f. Recomputing acct_f + // PI order (docs/protocol/operations/register.md): Y, PVK, addr_f, + // acct_f. Recomputing acct_f // from `account` (never accepting it from caller bytes) is what binds // the proof to the registering address: any other caller assembles a // different blob, so published registration material cannot be @@ -592,7 +594,7 @@ pub fn withdraw( let k_aud_s = auditor.get_key(&account.auditor_id); let addr_f = get_address_as_field_element(e); - // PI order (DESIGN §7.5): + // PI order (docs/protocol/operations/withdraw.md): // C_spend, Y, addr_f, K_aud_s, a, // C_spend', sigma, b_tilde, R_e, b_tilde_aud_s, r_tilde_aud_s let mut pi = Bytes::new(e); @@ -673,7 +675,7 @@ pub fn confidential_transfer( let k_aud_s = auditor.get_key(&sender.auditor_id); let addr_f = get_address_as_field_element(e); - // PI order (DESIGN §7.6): + // PI order (docs/protocol/operations/transfer.md): // C_spend_A, Y_A, PVK_B, addr_f, K_aud_r, K_aud_s, // C_spend', C_transfer, R_e, v_tilde, b_tilde, sigma, // v_tilde_aud_r, r_tilde_aud_r, v_tilde_aud_s, b_tilde_aud_s, @@ -774,12 +776,12 @@ pub fn confidential_transfer_from( let recipient = get_account(e, to); let auditor = ConfidentialAuditorClient::new(e, &get_auditor(e)); let k_aud_r = auditor.get_key(&recipient.auditor_id); - // Sender-auditor key is the OWNER's auditor, not the spender's (DESIGN - // §7.8 — visibility points balance/allowance ciphertexts at the funds' - // owner). + // Sender-auditor key is the OWNER's auditor, not the spender's + // (docs/protocol/operations/spender-transfer.md: visibility points + // balance/allowance ciphertexts at the funds' owner). let k_aud_s = auditor.get_key(&owner.auditor_id); - // PI order (DESIGN §7.8): + // PI order (docs/protocol/operations/spender-transfer.md): // C_a, sigma_a, Y_op, PVK_recipient, K_aud_r, K_aud_s, // C_a', C_transfer, R_e, v_tilde, a_tilde', sigma_a', // v_tilde_aud_r, r_tilde_aud_r, v_tilde_aud_s, a_tilde_aud_s, @@ -884,7 +886,7 @@ pub fn set_spender( let addr_f = get_address_as_field_element(e); let spender_id = address_to_field(e, spender); - // PI order (DESIGN §7.7): + // PI order (docs/protocol/operations/set-spender.md): // C_spend, Y, Y_op, spender_id (op_i), addr_f, K_aud_s, // C_spend', C_a, escrowed_dvk, b_tilde, a_tilde, // sigma, sigma_a, R_e, v_tilde_aud_s, b_tilde_aud_s, @@ -944,7 +946,8 @@ pub fn set_spender( /// commitment `C_a` back into `owner`'s spendable commitment and deletes the /// delegation entry. Works for both active and expired-but-not-revoked /// delegations. The fold and the openings it yields to the owner and the -/// auditor are specified in DESIGN §7.9 and DESIGN_cont §8.5. +/// auditor are specified in `docs/protocol/operations/revoke-spender.md` and +/// `docs/protocol/auditing.md#spender-allowance-auditing`. /// /// # Arguments /// @@ -1145,7 +1148,8 @@ pub fn set_address_as_field_element(e: &Env) { /// /// **IMPORTANT**: This function performs no authorization check, consumes no /// proof, and verifies no conservation property. Establishing that the write -/// preserves balance conservation (DESIGN_cont §9.3) is the caller's +/// preserves balance conservation +/// (`docs/protocol/security.md#balance-conservation`) is the caller's /// obligation. It exists for the compliance module's clawback flow /// ([`crate::confidential::compliance::storage::clawback`]), which discharges /// that obligation in-circuit before calling. diff --git a/packages/tokens/src/confidential/test.rs b/packages/tokens/src/confidential/test.rs index c25a99372..bae7e7787 100644 --- a/packages/tokens/src/confidential/test.rs +++ b/packages/tokens/src/confidential/test.rs @@ -167,7 +167,8 @@ impl ConfidentialVerifier for ReplayGuardVerifier { /// this event" reduces, at the contract level, to "which `K_aud_s` went into /// the public-input blob". This mock captures exactly that. `K_aud_s` sits at /// limb 8 of the SetSpender blob and limb 9 of the SpenderTransfer blob -/// (DESIGN §7.7 - §7.8). +/// (`docs/protocol/operations/set-spender.md`, +/// `docs/protocol/operations/spender-transfer.md`). #[contract] struct KeyRecordingVerifier; @@ -889,7 +890,8 @@ fn auditor_key_rotation_rescopes_the_escrowed_allowance_opening() { let after = h.token.get_spender_delegation(&alice, &spender); assert_ne!(before.allowance_salt, after.allowance_salt); - // Step 4 -- revocation is a proofless fold (§7.9), so it opens no auditor + // Step 4 -- revocation is a proofless fold + // (docs/protocol/operations/revoke-spender.md), so it opens no auditor // channel: the event republishes the two delegation fields the fold // deletes and nothing else. An auditor that missed step 3 folds the // allowance it already holds into C_spend, or waits for the owner's next @@ -955,8 +957,9 @@ fn confidential_transfer_from_updates_delegation_and_recipient() { } /// The `SpenderTransfer` event carries the transfer's channel nonce -/// `sigma_a'`, not the stored `allowance_salt` it replaces (DESIGN §6.2 -/// *Transfer nonce*). Emitting the stored salt would hand the recipient and +/// `sigma_a'`, not the stored `allowance_salt` it replaces +/// (`docs/protocol/account-state.md#transfer-nonce`). Emitting the stored +/// salt would hand the recipient and /// both auditors a nonce none of the pads absorbed, and would repeat across a /// retry. #[test] @@ -1061,7 +1064,8 @@ fn is_spender_returns_false_for_missing_and_expired() { h.e.ledger().set_sequence_number(100); assert!(!h.token.is_spender(&alice, &spender)); - // But the entry still exists (DESIGN §6.2). + // But the entry still exists + // (docs/protocol/account-state.md#spender-delegation). let _ = h.token.get_spender_delegation(&alice, &spender); } @@ -1156,8 +1160,10 @@ fn delegation_type_export_compiles() { /// This derivation is the only Poseidon2 primitive in the protocol with two /// independent implementations: the circuits take `addr_f` as an opaque public /// input, so it lives in the contract (here) and in every client, with no Noir -/// version to hold them together. DESIGN.md §2.7 asserts that all of them -/// reproduce the same Field from the same Address; this test is what makes that +/// version to hold them together. +/// `docs/protocol/primitives.md#address-to-field-encoding` asserts that all of +/// them reproduce the same Field from the same Address; this test is what makes +/// that /// assertion executable on the contract side. The expected values are the /// committed vectors -- update both together or not at all. #[test] diff --git a/packages/tokens/src/confidential/verifier/mod.rs b/packages/tokens/src/confidential/verifier/mod.rs index f61a7747e..1a64559b9 100644 --- a/packages/tokens/src/confidential/verifier/mod.rs +++ b/packages/tokens/src/confidential/verifier/mod.rs @@ -4,7 +4,10 @@ //! verify zero-knowledge proofs accompanying every state-changing operation. //! Each key is indexed by [`CircuitType`]. A single deployment can serve //! multiple confidential tokens: per-token binding is enforced inside the -//! circuit via the `addr_f` field (DESIGN §2.7, §4.2), not by the verifier, so +//! circuit via the `addr_f` field +//! (`docs/protocol/primitives.md#address-to-field-encoding`, +//! `docs/protocol/keys-and-commitments.md#viewing-key`), not by the verifier, +//! so //! the same VK set is reusable across every confidential token that targets the //! same protocol version. //! @@ -30,7 +33,8 @@ //! distinct from token admin powers. //! - **Lifecycle**: per-circuit VKs can be rotated (e.g. when a circuit is //! patched) without redeploying the confidential token, subject to the -//! deployer's governance posture (see DESIGN §3.5). +//! deployer's governance posture (see +//! `docs/protocol/system-model.md#governance-and-upgradeability`). //! //! ## VK Encoding //! @@ -52,7 +56,8 @@ //! //! [`ConfidentialVerifier::update_verification_key`] (and the underlying //! [`storage::update_verification_key`] helper) is **soundness-critical** and -//! should be treated as a break-glass operation. DESIGN §3.5 makes the +//! should be treated as a break-glass operation. +//! `docs/protocol/system-model.md#governance-and-upgradeability` makes the //! strongest possible recommendation: ship VKs immutably and require a fresh //! deployment for any circuit change. If a deployment exposes an update path //! at all, it should be reserved for one situation: a discovered soundness @@ -69,13 +74,15 @@ //! generated against the previous VK fails verification the instant the new //! VK is activated, so the corresponding transactions revert at the //! proof-verification boundary. Wallets must regenerate against the new VK -//! and resubmit (DESIGN §8.3 discusses this for the related auditor-key -//! rotation case; the same reasoning applies here). +//! and resubmit +//! (`docs/protocol/auditing.md#auditor-key-management-and-rotation` discusses +//! this for the related auditor-key rotation case; the same reasoning applies +//! here). //! - **Should be gated.** Implementors are expected to put the update path //! behind strong access control (multisig + timelock at a minimum) and to //! publish enough material — circuit source, toolchain pin, SRS transcript -//! reference (DESIGN §10.6) — that any user can independently reproduce the -//! new VK before trusting it. +//! reference (`docs/protocol/proof-system.md#structured-reference-string`) — +//! that any user can independently reproduce the new VK before trusting it. //! //! Treat every call to `update_verification_key` as an emergency, not a //! routine operation. @@ -171,7 +178,8 @@ pub trait ConfidentialVerifier { /// soundness bug in a circuit or verifier, and should be behind strong /// governance (multisig + timelock at minimum). The new VK must be /// independently reproducible from the audited circuit source, pinned - /// toolchain, and SRS transcript (DESIGN §10.6). + /// toolchain, and SRS transcript + /// (`docs/protocol/proof-system.md#structured-reference-string`). /// /// Only an operator with sufficient permissions should be able to call /// this function. diff --git a/packages/tokens/src/confidential/verifier/storage.rs b/packages/tokens/src/confidential/verifier/storage.rs index e579ab419..89c47e345 100644 --- a/packages/tokens/src/confidential/verifier/storage.rs +++ b/packages/tokens/src/confidential/verifier/storage.rs @@ -89,7 +89,7 @@ pub fn register_verification_key(e: &Env, circuit_type: CircuitType, verificatio /// full immutability. If an update path is exposed at all, it should be gated /// by multisig + timelock, and the new VK must be independently reproducible /// from the audited circuit source, pinned toolchain, and SRS transcript -/// (DESIGN §10.6). +/// (`docs/protocol/proof-system.md#structured-reference-string`). /// /// Use only in response to a discovered soundness bug in a circuit or /// verifier that cannot be fixed by a fresh deployment. From 49056d18c8f35e3050ccbed11ac34266038f1b30 Mon Sep 17 00:00:00 2001 From: Boyan Barakov <9572072+brozorec@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:49:27 +0200 Subject: [PATCH 12/17] Smart account: auth payload digest (#868) * feat(accounts): commit signers to a structured AuthDigestPreimage Signers now commit to an AuthDigestPreimage of the smart account address, the host signature payload, and the selected context rule IDs. External signers sign its SHA-256 digest, and delegated signers authorize the struct through require_auth_for_args, so their auth entry carries named fields instead of an opaque hash. * fix(accounts): re-export getters * feat(accounts): expose auth_digest and test real authorization entries Add an `auth_digest` view so `AuthDigestPreimage` enters the contract spec and clients can verify a digest through simulation. New tests build the smart account's and the delegated signer's authorization entries by hand and drive them through the host's `__check_auth`. Document the client-side flow and the entry that simulation does not return. * fix: typo * fix: export get_validated_context_by_id --- examples/multisig-smart-account/README.md | 46 +-- .../account/src/contract.rs | 4 +- packages/accounts/README.md | 55 ++- packages/accounts/src/lib.rs | 5 + packages/accounts/src/smart_account/mod.rs | 35 +- .../accounts/src/smart_account/storage.rs | 75 ++-- .../src/smart_account/test/auth_entries.rs | 327 ++++++++++++++++++ .../src/smart_account/test/context_rules.rs | 145 ++++++-- .../accounts/src/smart_account/test/mod.rs | 1 + 9 files changed, 612 insertions(+), 81 deletions(-) create mode 100644 packages/accounts/src/smart_account/test/auth_entries.rs diff --git a/examples/multisig-smart-account/README.md b/examples/multisig-smart-account/README.md index d143aeb14..836f0c268 100644 --- a/examples/multisig-smart-account/README.md +++ b/examples/multisig-smart-account/README.md @@ -170,47 +170,52 @@ This setup demonstrates the flexibility of smart accounts, combining different s ## 6. Constructing the Signature Payload -When authorizing transactions with a smart account, signers do **not** sign the raw `signature_payload` provided by the Soroban host. Instead, the smart account binds the selected `context_rule_ids` into the digest to prevent rule-selection downgrade attacks. +When authorizing transactions with a smart account, signers do **not** sign the raw `signature_payload` provided by the Soroban host. Instead, every signer commits to an `AuthDigestPreimage` that binds the smart account address and the selected `context_rule_ids` alongside `signature_payload`. -### How the Auth Digest Is Computed +### What Signers Commit To -The Soroban host computes a `signature_payload` (`Hash<32>`) from the transaction's network ID, nonce, expiration ledger, and authorized invocation tree. The smart account then derives the **auth digest** that signers must actually sign: +The Soroban host computes a `signature_payload` (`Hash<32>`) from the transaction's network ID, nonce, expiration ledger, and authorized invocation tree. The smart account wraps it in a structured message: -``` -auth_digest = sha256(signature_payload || context_rule_ids.to_xdr()) +```rust +AuthDigestPreimage { + account: Address, // the smart account + signature_payload: BytesN<32>, // from the host + context_rule_ids: Vec, // one per auth context +} ``` -Where: -- `signature_payload` is the 32-byte hash provided by the host to `__check_auth` -- `context_rule_ids` is the `Vec` of rule IDs (one per auth context), serialized to XDR -- `||` denotes byte concatenation +- `External` signers sign the **auth digest**, `sha256(preimage.to_xdr())`, which the smart account passes to their verifier contract. +- `Delegated` signers authorize a call to `__check_auth` on the smart account with the preimage as its single argument. Their manually crafted authorization entry therefore shows the named fields rather than an opaque hash. ### Why This Matters -Without this binding, a malicious sponsor could collect signatures under a strict rule (e.g., one requiring a threshold policy) and then swap the `context_rule_ids` in the `AuthPayload` to reference a weaker rule — bypassing spending limits or threshold policies that signers expected to apply. By including rule IDs in the signed digest, any alteration of `context_rule_ids` after signature collection invalidates all signatures. +Binding `context_rule_ids` stops a malicious sponsor from collecting signatures under a strict rule (e.g., one requiring a threshold policy) and then swapping the `context_rule_ids` in the `AuthPayload` to a weaker rule — bypassing spending limits or threshold policies that signers expected to apply. The `account` field scopes the message to a single smart account. ### Example: Computing the Auth Digest Off-Chain Using the Soroban Rust SDK: ```rust -use soroban_sdk::xdr::ToXdr; +use stellar_accounts::smart_account::AuthDigestPreimage; // signature_payload: Hash<32> from the host (via __check_auth) // context_rule_ids: Vec — the rule IDs the signer agrees to -let mut preimage = signature_payload.to_bytes().to_bytes(); -preimage.append(&context_rule_ids.to_xdr(e)); -let auth_digest = e.crypto().sha256(&preimage); +let preimage = AuthDigestPreimage { + account: smart_account_address, + signature_payload: signature_payload.to_bytes(), + context_rule_ids, +}; +let auth_digest = preimage.digest(e); -// Signers sign `auth_digest`, NOT `signature_payload` directly. +// External signers sign `auth_digest`, NOT `signature_payload` directly. ``` -For off-chain clients (e.g., JS SDK), replicate the same concatenation: -1. Take the raw 32 bytes of `signature_payload` -2. XDR-encode the `Vec` of rule IDs (as `ScVal::Vec` of `ScVal::U32` entries) -3. Concatenate them and compute SHA-256 -4. Sign the resulting 32-byte digest +For off-chain clients (e.g., JS SDK): +1. Build the preimage as an `ScVal::Map` with symbol keys `account`, `context_rule_ids`, and `signature_payload` (`ScMap` keys must be sorted) +2. XDR-encode it and compute SHA-256 +3. External signers sign the resulting 32-byte digest +4. Delegated signers authorize `__check_auth` on the smart account with the same `ScVal::Map` as the only argument. Simulation does not return this entry, so the client adds it as a second root-level authorization entry for the delegated address ## Next Steps @@ -220,3 +225,4 @@ For off-chain clients (e.g., JS SDK), replicate the same concatenation: - [caveats](https://docs.openzeppelin.com/stellar-contracts/accounts/policies#caveats) of policy management and configurations 2. Explore how to invoke functions using this multisig smart account. You can use the [brozorec/smart-account-sign](https://github.com/brozorec/smart-account-sign) tool for demonstration purposes. > **⚠️ Disclaimer:** This tool is provided for demonstration purposes only. We do not vouch for its security or recommend it for production use. Always conduct your own security audit before using third-party tools in production environments. +3. Sign and submit from a TypeScript client with the [smart-account-kit](https://github.com/stellar/smart-account-kit) SDK. Check that its version targets the `stellar-accounts` release the account was built with. diff --git a/examples/multisig-smart-account/account/src/contract.rs b/examples/multisig-smart-account/account/src/contract.rs index 45004fc60..b4003f10a 100644 --- a/examples/multisig-smart-account/account/src/contract.rs +++ b/examples/multisig-smart-account/account/src/contract.rs @@ -11,8 +11,8 @@ use soroban_sdk::{ Address, BytesN, Env, Map, String, Symbol, Val, Vec, }; use stellar_accounts::smart_account::{ - self, AuthPayload, ContextRule, ContextRuleType, ExecutionEntryPoint, Signer, SmartAccount, - SmartAccountError, + self, AuthDigestPreimage, AuthPayload, ContextRule, ContextRuleType, ExecutionEntryPoint, + Signer, SmartAccount, SmartAccountError, }; use stellar_contract_utils::upgradeable::{self as upgradeable, Upgradeable}; diff --git a/packages/accounts/README.md b/packages/accounts/README.md index ee73d07ff..dbef1376d 100644 --- a/packages/accounts/README.md +++ b/packages/accounts/README.md @@ -92,8 +92,8 @@ Signer::Delegated(Address) ``` - Any Soroban address (contract or account) -- Verification uses `require_auth_for_args(payload)` -- This model requires manual authorization entry crafting, because it is not returned in a simulation mode. +- Verification uses `require_auth_for_args((preimage,))`, where `preimage` is the `AuthDigestPreimage` described in [Signature Authentication](#1-signature-authentication) +- Transaction simulation does not return the delegated signer's authorization entry, because `require_auth_for_args` runs inside `__check_auth`. Clients add it manually; see [Authorizing from a Client](#authorizing-from-a-client). #### External Signers @@ -194,9 +194,22 @@ Authorization is determined by explicitly selecting the rule to validate against ### 1. Signature Authentication -All provided signatures are authenticated up front: -- `Delegated` signers use `require_auth_for_args(payload)`. -- `External` signers are verified through their verifier contract. +All provided signatures are authenticated up front against an `AuthDigestPreimage`, the structured message every signer commits to: + +```rust +AuthDigestPreimage { + account: e.current_contract_address(), + signature_payload, // BytesN<32> from the host + context_rule_ids, // Vec, one per auth context +} +``` + +- `Delegated` signers authorize `require_auth_for_args((preimage,))` on the smart account, so their authorization entry shows the named fields. +- `External` signers sign `sha256(preimage.to_xdr())`, which is passed to their verifier contract. + +The `account` field scopes the message to a single smart account. Binding `context_rule_ids` rejects rule-selection downgrades after signature collection. + +The `auth_digest(preimage)` view returns `sha256(preimage.to_xdr())` for a given preimage. Simulating it lets a client check its locally computed digest before collecting signatures. ### 2. Per-Context Validation @@ -239,6 +252,15 @@ Each rule must: Consider a call in the `CallContract(dex_address)` context. The client presents an ed25519 key and a passkey signature. The smart account has a `CallContract(dex_address)` rule requiring both signers and a daily spending limit policy. The client sets `context_rule_ids = [rule_id]`. The account authenticates both keys, validates the rule, and then calls the spending limit policy’s `enforce()`, which checks the limit and updates counters. Authorization succeeds. +### Authorizing from a Client + +A smart account is authorized like any custom account: the client simulates the transaction, replaces the void signature in the smart account's authorization entry with an `AuthPayload`, and submits. Two details are specific to this framework. + +1. Signers commit to the `AuthDigestPreimage`, not to the host's `signature_payload`. The client computes `signature_payload = sha256(HashIdPreimage::SorobanAuthorization { network_id, nonce, signature_expiration_ledger, invocation })` from the simulated entry, builds the preimage as an `ScVal::Map` with the symbol keys `account`, `context_rule_ids` and `signature_payload` (in that order), and has every `External` signer sign `sha256(preimage.to_xdr())`. The `auth_digest` view returns the same value and can be simulated to check the client-side computation. +2. Simulation does not return the authorization entry of a `Delegated` signer, because `require_auth_for_args` runs inside `__check_auth`. The client adds a second root-level entry for the delegated address whose root invocation is `__check_auth` on the smart account with the preimage as its single argument, signed the way that address normally authorizes (an ed25519 signature for a G-account, its own `AuthPayload` for a nested smart account). A missing or wrongly encoded entry fails with `Error(Auth, InvalidAction)` and a trap inside `__check_auth`, not with a `SmartAccountError`. + +The [`auth_entries` test module](./src/smart_account/test/auth_entries.rs) builds both entries in Rust and drives them through the host's `__check_auth`; it is the reference for client implementations. The [smart-account-kit](https://github.com/stellar/smart-account-kit) TypeScript SDK implements this flow end to end; check that its version targets the `stellar-accounts` release the account was built with. See also [Transaction Simulation Behavior](https://docs.openzeppelin.com/stellar-contracts/accounts/signers-and-verifiers#transaction-simulation-behavior) in the documentation. + ## Use Cases ### 1. Session Logins (Web3 dApps) @@ -372,6 +394,8 @@ let signatures = AuthPayload { }; ``` +See [Authorizing from a Client](#authorizing-from-a-client) for the full off-chain flow, including the authorization entry that simulation does not return for `Delegated` signers. + ### 3. Create Context Rules ```rust @@ -433,6 +457,27 @@ For external signers, there are two options: - Maximum policies per context rule: 5 - Signers and policies are stored in a global registry and deduplicated across rules. The same signer object (identified by its XDR encoding) is stored once regardless of how many rules reference it. Each signer and policy is assigned a stable `u32` ID that is used when removing them from a rule (`remove_signer`, `remove_policy`). +## Migration Guide: from v0.7.x to 0.8.0 + +### Signed message now binds the smart account address (breaking change) + +The digest that signers commit to is now the SHA-256 hash of a structured `AuthDigestPreimage` instead of `sha256(signature_payload || context_rule_ids.to_xdr())`: + +```rust +#[contracttype] +pub struct AuthDigestPreimage { + pub account: Address, // the smart account + pub signature_payload: BytesN<32>, // from the host + pub context_rule_ids: Vec, // one per auth context +} +``` + +Off-chain signing flows must be updated: + +- `External` signers sign `sha256(preimage.to_xdr())`. In Rust: `AuthDigestPreimage { account, signature_payload, context_rule_ids }.digest(e)`. Other clients build the struct as an `ScVal::Map` with symbol keys `account`, `context_rule_ids`, and `signature_payload` (`ScMap` keys must be sorted), XDR-encode it, and hash it. +- `Delegated` signers now authorize `__check_auth` on the smart account with the preimage struct as its single argument instead of the 32-byte digest. The manually crafted authorization entry for the delegated signer must carry the struct in `args`. +- The new `auth_digest(preimage)` view returns the digest for a given preimage, so a client can verify its encoding through simulation before signing. Contracts that implement `SmartAccount` through `#[contractimpl(contracttrait)]` must import `AuthDigestPreimage` next to the other trait types, because the macro re-emits the trait signatures at the implementation site. + ## Migration Guide: from v0.6.0 to 0.7.0 ### `AuthPayload` struct (breaking change) diff --git a/packages/accounts/src/lib.rs b/packages/accounts/src/lib.rs index 25b6a2b55..cde06a75e 100644 --- a/packages/accounts/src/lib.rs +++ b/packages/accounts/src/lib.rs @@ -3,6 +3,11 @@ //! A flexible and modular smart account framework for Soroban that enables //! advanced authentication and authorization patterns through composable rules, //! signers, and policies. +//! +//! The crate [README](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/accounts) +//! and the [documentation](https://docs.openzeppelin.com/stellar-contracts/accounts/smart-account) +//! cover the client-side authorization flow, including the authorization +//! entry that transaction simulation does not return for delegated signers. #![no_std] pub mod policies; diff --git a/packages/accounts/src/smart_account/mod.rs b/packages/accounts/src/smart_account/mod.rs index ac91e9125..eba5e3ed7 100644 --- a/packages/accounts/src/smart_account/mod.rs +++ b/packages/accounts/src/smart_account/mod.rs @@ -11,6 +11,11 @@ //! - **External**: A public key paired with a verifier contract for custom //! cryptographic verification (e.g., secp256r1, passkeys). //! +//! Both kinds commit to an [`AuthDigestPreimage`] that binds the smart account +//! address, the host `signature_payload`, and the selected `context_rule_ids`. +//! External signers sign its SHA-256 digest. Delegated signers authorize the +//! preimage itself through `require_auth_for_args`. +//! //! ### Context Rules - Scope and Routing //! - A context rule binds a set of signers and policies to a specific operation //! scope (`Default`, `CallContract(Address)`, or @@ -115,16 +120,17 @@ mod storage; #[cfg(test)] mod test; use soroban_sdk::{ - auth::CustomAccountInterface, contracterror, contractevent, contracttrait, Address, Env, Map, - String, Symbol, Val, Vec, + auth::CustomAccountInterface, contracterror, contractevent, contracttrait, Address, BytesN, + Env, Map, String, Symbol, Val, Vec, }; pub use storage::{ add_context_rule, add_policy, add_signer, authenticate, batch_add_signer, do_check_auth, - get_context_rule, get_context_rules_count, get_validated_context_by_id, remove_context_rule, - remove_policy, remove_signer, update_context_rule_name, update_context_rule_valid_until, - validate_context_rule_name, validate_no_canonical_duplicates, validate_signer_key_size, - validate_signers_and_policies, AuthPayload, ContextRule, ContextRuleEntry, ContextRuleType, - Signer, SmartAccountStorageKey, + get_context_rule, get_context_rules_count, get_policy_id, get_signer_id, + get_validated_context_by_id, remove_context_rule, remove_policy, remove_signer, + update_context_rule_name, update_context_rule_valid_until, validate_context_rule_name, + validate_no_canonical_duplicates, validate_signer_key_size, validate_signers_and_policies, + AuthDigestPreimage, AuthPayload, ContextRule, ContextRuleEntry, ContextRuleType, Signer, + SmartAccountStorageKey, }; /// Core trait for smart account functionality, extending Soroban's @@ -192,6 +198,21 @@ pub trait SmartAccount: CustomAccountInterface { storage::get_policy_id(e, &policy) } + /// Computes the digest that [`Signer::External`] signers sign for + /// `preimage`, that is `sha256(preimage.to_xdr())`. A pure helper for + /// off-chain clients: simulating it verifies a locally computed digest + /// before any signature is collected, and it exposes + /// [`AuthDigestPreimage`] in the contract spec so generated bindings can + /// encode the value that [`Signer::Delegated`] signers authorize. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + /// * `preimage` - The structured message that signers commit to. + fn auth_digest(e: &Env, preimage: AuthDigestPreimage) -> BytesN<32> { + preimage.digest(e) + } + /// Creates a new context rule with the specified configuration, returning /// the newly created `ContextRule` with a unique ID assigned. Installs /// all specified policies during creation. diff --git a/packages/accounts/src/smart_account/storage.rs b/packages/accounts/src/smart_account/storage.rs index 7968cc5e1..f6ddbcd94 100644 --- a/packages/accounts/src/smart_account/storage.rs +++ b/packages/accounts/src/smart_account/storage.rs @@ -94,7 +94,14 @@ pub struct PolicyEntry { #[contracttype] #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] pub enum Signer { - /// A delegated signer that uses built-in signature verification. + /// A delegated signer, any contract or account address, authenticated + /// through `require_auth_for_args((preimage,))`. Transaction simulation + /// does not return this nested authorization entry, because the call + /// happens inside `__check_auth`. Clients add it manually: an entry for + /// the delegated address whose root invocation is `__check_auth` on the + /// smart account with the [`AuthDigestPreimage`] as its single argument. + /// See `src/smart_account/test/auth_entries.rs` for a reference + /// construction. Delegated(Address), /// An external signer with custom verification logic. /// Contains the verifier contract address and the public key data. @@ -123,11 +130,9 @@ pub enum Signer { /// a mismatch is rejected with /// [`SmartAccountError::ContextRuleIdsLengthMismatch`]. /// -/// **Important:** `context_rule_ids` are bound into the digest that signers -/// authenticate against: `auth_digest = sha256(signature_payload || -/// context_rule_ids.to_xdr())`. Signers must sign `auth_digest`, not the -/// raw `signature_payload` from the host. This prevents rule-selection -/// downgrade attacks. +/// **Important:** signers do not sign the raw `signature_payload` from the +/// host. They commit to an [`AuthDigestPreimage`] that binds the smart account +/// address, `context_rule_ids` and `signature_payload`. #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct AuthPayload { @@ -137,6 +142,33 @@ pub struct AuthPayload { pub context_rule_ids: Vec, } +/// The structured message that every signer commits to in `__check_auth`. +/// +/// [`Signer::External`] signers sign [`AuthDigestPreimage::digest`], the +/// SHA-256 hash of this struct's XDR encoding. [`Signer::Delegated`] signers +/// authorize the struct itself through `require_auth_for_args`. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct AuthDigestPreimage { + /// The smart account performing the authorization check. + pub account: Address, + /// The 32-byte payload provided by the host to `__check_auth`. + pub signature_payload: BytesN<32>, + /// Per-context rule IDs, aligned by index with `auth_contexts`. + pub context_rule_ids: Vec, +} + +impl AuthDigestPreimage { + /// Computes the digest signed by [`Signer::External`] signers. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + pub fn digest(&self, e: &Env) -> BytesN<32> { + e.crypto().sha256(&self.clone().to_xdr(e)).to_bytes() + } +} + /// Types of contexts that can be authorized by smart account rules. #[contracttype] #[derive(Clone, Debug, PartialEq, Eq)] @@ -327,10 +359,14 @@ pub fn get_validated_context_by_id( /// Verifies an `Address` authorization or a cryptographic signature through an /// external verifier contract. /// +/// [`Signer::External`] signers are verified against +/// [`AuthDigestPreimage::digest`]. [`Signer::Delegated`] signers must have +/// authorized `require_auth_for_args((preimage,))` on the smart account. +/// /// # Arguments /// /// * `e` - Access to the Soroban environment. -/// * `auth_digest` - The hash of the data that was signed. +/// * `preimage` - The structured message the signer committed to. /// * `signer` - The signer who signed the payload. /// * `sig_data` - The signature to verify for an external signer. /// @@ -338,10 +374,10 @@ pub fn get_validated_context_by_id( /// /// * [`SmartAccountError::ExternalVerificationFailed`] - When an external /// signature fails verification through its verifier contract. -pub fn authenticate(e: &Env, auth_digest: &Hash<32>, signer: &Signer, sig_data: &Bytes) { +pub fn authenticate(e: &Env, preimage: &AuthDigestPreimage, signer: &Signer, sig_data: &Bytes) { match signer { Signer::External(verifier, key_data) => { - let sig_payload = auth_digest.to_bytes().to_bytes(); + let sig_payload = preimage.digest(e).to_bytes(); if !VerifierClient::new(e, verifier).verify( &sig_payload, &key_data.into_val(e), @@ -351,7 +387,7 @@ pub fn authenticate(e: &Env, auth_digest: &Hash<32>, signer: &Signer, sig_data: } } Signer::Delegated(addr) => { - let args = (auth_digest.clone(),).into_val(e); + let args = (preimage.clone(),).into_val(e); addr.require_auth_for_args(args) } } @@ -438,11 +474,9 @@ pub fn validate_context_rule_name(e: &Env, name: &String) { /// validate against for the corresponding auth context (by index). Its length /// must equal `auth_contexts.len()`. /// -/// To prevent rule-selection downgrade attacks, `context_rule_ids` are bound -/// into the digest that signers authenticate against: -/// `auth_digest = sha256(signature_payload || context_rule_ids.to_xdr())`. -/// Altering `context_rule_ids` after signature collection therefore -/// invalidates all signatures. +/// Signers do not sign the raw `signature_payload`. They commit to an +/// [`AuthDigestPreimage`] that binds the smart account address, +/// `context_rule_ids` and `signature_payload`. /// /// # Arguments /// @@ -489,10 +523,11 @@ pub fn do_check_auth( } } - // Bind context_rule_ids into the signed digest. - let mut preimage = signature_payload.to_bytes().to_bytes(); - preimage.append(&signatures.context_rule_ids.clone().to_xdr(e)); - let auth_digest = e.crypto().sha256(&preimage); + let preimage = AuthDigestPreimage { + account: e.current_contract_address(), + signature_payload: signature_payload.to_bytes(), + context_rule_ids: signatures.context_rule_ids.clone(), + }; // Authenticate and reject any signer that is not part of any // selected rule, preventing arbitrary external calls via @@ -501,7 +536,7 @@ pub fn do_check_auth( if !allowed_signers.contains_key(signer.clone()) { panic_with_error!(e, SmartAccountError::UnauthorizedSigner); } - authenticate(e, &auth_digest, &signer, &sig_data); + authenticate(e, &preimage, &signer, &sig_data); } // Enforce all policies. diff --git a/packages/accounts/src/smart_account/test/auth_entries.rs b/packages/accounts/src/smart_account/test/auth_entries.rs new file mode 100644 index 000000000..dc5aeed76 --- /dev/null +++ b/packages/accounts/src/smart_account/test/auth_entries.rs @@ -0,0 +1,327 @@ +extern crate std; + +use ed25519_dalek::{Signer as Ed25519Signer, SigningKey}; +use soroban_sdk::{ + auth::{Context, CustomAccountInterface}, + contract, contractimpl, + crypto::Hash, + map, + testutils::Ledger, + vec, + xdr::{ + Hash as XdrHash, HashIdPreimage, HashIdPreimageSorobanAuthorization, InvokeContractArgs, + Limits, ScVal, SorobanAddressCredentials, SorobanAuthorizationEntry, + SorobanAuthorizedFunction, SorobanAuthorizedInvocation, SorobanCredentials, VecM, WriteXdr, + }, + Address, Bytes, BytesN, Env, IntoVal, Map, String, TryFromVal, Val, Vec, +}; + +use crate::{ + smart_account::{ + add_context_rule, do_check_auth, AuthDigestPreimage, AuthPayload, ContextRule, + ContextRuleType, Signer, SmartAccount, SmartAccountError, + }, + verifiers::{ed25519, Verifier}, +}; + +const ACCOUNT_NONCE: i64 = 1; +const DELEGATE_NONCE: i64 = 2; +const EXPIRATION_LEDGER: u32 = 100; +const SECRET_KEY: [u8; 32] = [ + 157, 97, 177, 157, 239, 253, 90, 96, 186, 132, 74, 244, 146, 236, 44, 196, 68, 73, 197, 105, + 123, 50, 105, 25, 112, 59, 172, 3, 28, 174, 127, 96, +]; + +#[contract] +struct SmartAccountContract; + +#[contractimpl] +impl CustomAccountInterface for SmartAccountContract { + type Error = SmartAccountError; + type Signature = AuthPayload; + + fn __check_auth( + e: Env, + signature_payload: Hash<32>, + signatures: AuthPayload, + auth_contexts: Vec, + ) -> Result<(), Self::Error> { + do_check_auth(&e, &signature_payload, &signatures, &auth_contexts) + } +} + +#[contractimpl(contracttrait)] +impl SmartAccount for SmartAccountContract {} + +#[contract] +struct Probe; + +#[contractimpl] +impl Probe { + pub fn ping(_e: &Env, caller: Address) { + caller.require_auth(); + } +} + +#[contract] +struct ApprovingAccount; + +#[contractimpl] +impl CustomAccountInterface for ApprovingAccount { + type Error = SmartAccountError; + type Signature = Val; + + fn __check_auth( + _e: Env, + _signature_payload: Hash<32>, + _signature: Val, + _auth_contexts: Vec, + ) -> Result<(), Self::Error> { + Ok(()) + } +} + +#[contract] +struct Ed25519VerifierContract; + +#[contractimpl] +impl Verifier for Ed25519VerifierContract { + type KeyData = BytesN<32>; + type SigData = BytesN<64>; + + fn verify(e: &Env, hash: Bytes, key_data: BytesN<32>, sig_data: BytesN<64>) -> bool { + ed25519::verify(e, &hash, &key_data, &sig_data) + } + + fn canonicalize_key(e: &Env, key_data: BytesN<32>) -> Bytes { + ed25519::canonicalize_key(e, &key_data) + } + + fn batch_canonicalize_key(e: &Env, key_data: Vec>) -> Vec { + ed25519::batch_canonicalize_key(e, &key_data) + } +} + +fn to_scval>(e: &Env, value: T) -> ScVal { + ScVal::try_from_val(e, &value.into_val(e)).unwrap() +} + +fn contract_fn( + contract: &Address, + name: &str, + args: std::vec::Vec, +) -> SorobanAuthorizedInvocation { + SorobanAuthorizedInvocation { + function: SorobanAuthorizedFunction::ContractFn(InvokeContractArgs { + contract_address: contract.into(), + function_name: name.try_into().unwrap(), + args: args.try_into().unwrap(), + }), + sub_invocations: VecM::default(), + } +} + +fn signature_payload(e: &Env, nonce: i64, invocation: &SorobanAuthorizedInvocation) -> BytesN<32> { + let preimage = HashIdPreimage::SorobanAuthorization(HashIdPreimageSorobanAuthorization { + network_id: XdrHash(e.ledger().get().network_id), + nonce, + signature_expiration_ledger: EXPIRATION_LEDGER, + invocation: invocation.clone(), + }); + let xdr = preimage.to_xdr(Limits::none()).unwrap(); + e.crypto().sha256(&Bytes::from_slice(e, &xdr)).to_bytes() +} + +fn auth_entry( + address: &Address, + nonce: i64, + signature: ScVal, + root_invocation: SorobanAuthorizedInvocation, +) -> SorobanAuthorizationEntry { + SorobanAuthorizationEntry { + credentials: SorobanCredentials::Address(SorobanAddressCredentials { + address: address.into(), + nonce, + signature_expiration_ledger: EXPIRATION_LEDGER, + signature, + }), + root_invocation, + } +} + +fn register_account(e: &Env, signer: &Signer) -> (Address, Address, u32) { + let account = e.register(SmartAccountContract, ()); + let probe = e.register(Probe, ()); + let rule_id = e.as_contract(&account, || { + add_context_rule( + e, + &ContextRuleType::CallContract(probe.clone()), + &String::from_str(e, "probe"), + None, + &vec![e, signer.clone()], + &Map::new(e), + ) + .id + }); + (account, probe, rule_id) +} + +fn ping_invocation(e: &Env, probe: &Address, account: &Address) -> SorobanAuthorizedInvocation { + contract_fn(probe, "ping", std::vec![to_scval(e, account.clone())]) +} + +fn account_preimage( + e: &Env, + account: &Address, + rule_id: u32, + root_invocation: &SorobanAuthorizedInvocation, +) -> AuthDigestPreimage { + AuthDigestPreimage { + account: account.clone(), + signature_payload: signature_payload(e, ACCOUNT_NONCE, root_invocation), + context_rule_ids: vec![e, rule_id], + } +} + +fn account_entry( + e: &Env, + account: &Address, + signer: &Signer, + sig_data: Bytes, + rule_id: u32, + root_invocation: SorobanAuthorizedInvocation, +) -> SorobanAuthorizationEntry { + let payload = AuthPayload { + signers: map![e, (signer.clone(), sig_data)], + context_rule_ids: vec![e, rule_id], + }; + auth_entry(account, ACCOUNT_NONCE, to_scval(e, payload), root_invocation) +} + +fn delegate_entry(delegate: &Address, account: &Address, arg: ScVal) -> SorobanAuthorizationEntry { + auth_entry( + delegate, + DELEGATE_NONCE, + ScVal::Void, + contract_fn(account, "__check_auth", std::vec![arg]), + ) +} + +fn external_signer(e: &Env) -> (Signer, SigningKey) { + let verifier = e.register(Ed25519VerifierContract, ()); + let signing_key = SigningKey::from_bytes(&SECRET_KEY); + let public_key = Bytes::from_array(e, signing_key.verifying_key().as_bytes()); + (Signer::External(verifier, public_key), signing_key) +} + +fn sign(e: &Env, signing_key: &SigningKey, message: &BytesN<32>) -> Bytes { + Bytes::from_array(e, &signing_key.sign(&message.to_array()).to_bytes()) +} + +#[test] +fn delegated_signer_authorizes_with_nested_auth_entry() { + let e = Env::default(); + let delegate = e.register(ApprovingAccount, ()); + let signer = Signer::Delegated(delegate.clone()); + let (account, probe, rule_id) = register_account(&e, &signer); + + let root_invocation = ping_invocation(&e, &probe, &account); + let preimage = account_preimage(&e, &account, rule_id, &root_invocation); + + e.set_auths(&[ + account_entry(&e, &account, &signer, Bytes::new(&e), rule_id, root_invocation), + delegate_entry(&delegate, &account, to_scval(&e, preimage)), + ]); + + ProbeClient::new(&e, &probe).ping(&account); +} + +#[test] +fn delegated_signer_without_nested_auth_entry_fails() { + let e = Env::default(); + let delegate = e.register(ApprovingAccount, ()); + let signer = Signer::Delegated(delegate.clone()); + let (account, probe, rule_id) = register_account(&e, &signer); + + let root_invocation = ping_invocation(&e, &probe, &account); + + e.set_auths(&[account_entry(&e, &account, &signer, Bytes::new(&e), rule_id, root_invocation)]); + + assert!(ProbeClient::new(&e, &probe).try_ping(&account).is_err()); +} + +#[test] +fn delegated_signer_nested_entry_with_digest_argument_fails() { + let e = Env::default(); + let delegate = e.register(ApprovingAccount, ()); + let signer = Signer::Delegated(delegate.clone()); + let (account, probe, rule_id) = register_account(&e, &signer); + + let root_invocation = ping_invocation(&e, &probe, &account); + let preimage = account_preimage(&e, &account, rule_id, &root_invocation); + let digest = to_scval(&e, preimage.digest(&e)); + + e.set_auths(&[ + account_entry(&e, &account, &signer, Bytes::new(&e), rule_id, root_invocation), + delegate_entry(&delegate, &account, digest), + ]); + + assert!(ProbeClient::new(&e, &probe).try_ping(&account).is_err()); +} + +#[test] +fn external_signer_authorizes_by_signing_auth_digest() { + let e = Env::default(); + let (signer, signing_key) = external_signer(&e); + let (account, probe, rule_id) = register_account(&e, &signer); + + let root_invocation = ping_invocation(&e, &probe, &account); + let preimage = account_preimage(&e, &account, rule_id, &root_invocation); + let sig_data = sign(&e, &signing_key, &preimage.digest(&e)); + + e.set_auths(&[account_entry(&e, &account, &signer, sig_data, rule_id, root_invocation)]); + + ProbeClient::new(&e, &probe).ping(&account); +} + +#[test] +fn external_signer_signing_signature_payload_fails() { + let e = Env::default(); + let (signer, signing_key) = external_signer(&e); + let (account, probe, rule_id) = register_account(&e, &signer); + + let root_invocation = ping_invocation(&e, &probe, &account); + let preimage = account_preimage(&e, &account, rule_id, &root_invocation); + let sig_data = sign(&e, &signing_key, &preimage.signature_payload); + + e.set_auths(&[account_entry(&e, &account, &signer, sig_data, rule_id, root_invocation)]); + + assert!(ProbeClient::new(&e, &probe).try_ping(&account).is_err()); +} + +#[test] +fn auth_digest_matches_off_chain_encoding() { + let e = Env::default(); + let account = e.register(SmartAccountContract, ()); + let preimage = AuthDigestPreimage { + account: account.clone(), + signature_payload: BytesN::from_array(&e, &[7u8; 32]), + context_rule_ids: vec![&e, 0, 1], + }; + + let scval = to_scval(&e, preimage.clone()); + let ScVal::Map(Some(map)) = &scval else { panic!("preimage must encode as a map") }; + let keys: std::vec::Vec = map.iter().map(|entry| entry.key.clone()).collect(); + assert_eq!( + keys, + std::vec![ + ScVal::Symbol("account".try_into().unwrap()), + ScVal::Symbol("context_rule_ids".try_into().unwrap()), + ScVal::Symbol("signature_payload".try_into().unwrap()), + ] + ); + + let xdr = scval.to_xdr(Limits::none()).unwrap(); + let expected = e.crypto().sha256(&Bytes::from_slice(&e, &xdr)).to_bytes(); + assert_eq!(SmartAccountContractClient::new(&e, &account).auth_digest(&preimage), expected); +} diff --git a/packages/accounts/src/smart_account/test/context_rules.rs b/packages/accounts/src/smart_account/test/context_rules.rs index 1bc8efd89..47f0099db 100644 --- a/packages/accounts/src/smart_account/test/context_rules.rs +++ b/packages/accounts/src/smart_account/test/context_rules.rs @@ -6,10 +6,8 @@ use soroban_sdk::{ CreateContractWithConstructorHostFnContext, }, contract, contractimpl, symbol_short, - testutils::{Address as _, Events, Ledger}, - vec, - xdr::ToXdr, - Address, Bytes, BytesN, Env, Map, String, Symbol, TryFromVal, Val, Vec, + testutils::{Address as _, AuthorizedFunction, Events, Ledger}, + vec, Address, Bytes, BytesN, Env, IntoVal, Map, String, Symbol, TryFromVal, Val, Vec, }; use crate::{ @@ -19,8 +17,8 @@ use crate::{ add_context_rule, authenticate, do_check_auth, get_context_rule, get_context_rules_count, get_validated_context_by_id, remove_context_rule, update_context_rule_name, update_context_rule_valid_until, - validate_no_canonical_duplicates, AuthPayload, ContextRule, ContextRuleType, Signer, - SmartAccountStorageKey, + validate_no_canonical_duplicates, AuthDigestPreimage, AuthPayload, ContextRule, + ContextRuleType, Signer, SmartAccountStorageKey, }, MAX_EXTERNAL_KEY_SIZE, }, @@ -713,9 +711,13 @@ fn authenticate_external_signer_verification_fails() { e.storage().persistent().set(&symbol_short!("verify"), &false); }); - let payload = e.crypto().sha256(&Bytes::from_array(&e, &[1u8; 32])); + let preimage = AuthDigestPreimage { + account: address.clone(), + signature_payload: BytesN::from_array(&e, &[1u8; 32]), + context_rule_ids: vec![&e, 0], + }; - authenticate(&e, &payload, &signer, &sig_data); + authenticate(&e, &preimage, &signer, &sig_data); }); } @@ -739,13 +741,48 @@ fn authenticate_mixed_signers_success() { e.storage().persistent().set(&symbol_short!("verify"), &true); }); - let payload = e.crypto().sha256(&Bytes::from_array(&e, &[1u8; 32])); + let preimage = AuthDigestPreimage { + account: address.clone(), + signature_payload: BytesN::from_array(&e, &[1u8; 32]), + context_rule_ids: vec![&e, 0], + }; - authenticate(&e, &payload, &native_signer, &Bytes::new(&e)); - authenticate(&e, &payload, &external_signer, &Bytes::from_array(&e, &[5, 6, 7, 8])); + authenticate(&e, &preimage, &native_signer, &Bytes::new(&e)); + authenticate(&e, &preimage, &external_signer, &Bytes::from_array(&e, &[5, 6, 7, 8])); }); } +#[test] +fn authenticate_delegated_signer_authorizes_preimage() { + let e = Env::default(); + let address = e.register(MockContract, ()); + + e.mock_all_auths(); + + let native_addr = Address::generate(&e); + let preimage = AuthDigestPreimage { + account: address.clone(), + signature_payload: BytesN::from_array(&e, &[1u8; 32]), + context_rule_ids: vec![&e, 0], + }; + + e.as_contract(&address, || { + authenticate(&e, &preimage, &Signer::Delegated(native_addr.clone()), &Bytes::new(&e)); + }); + + let auths = e.auths(); + assert_eq!(auths.len(), 1); + let (signer, invocation) = &auths[0]; + assert_eq!(signer, &native_addr); + match &invocation.function { + AuthorizedFunction::Contract((contract, _, args)) => { + assert_eq!(contract, &address); + assert_eq!(args, &vec![&e, preimage.into_val(&e)]); + } + _ => panic!("expected a contract invocation"), + } +} + #[test] fn get_validated_context_by_id_direct_match_success() { let e = Env::default(); @@ -1175,17 +1212,16 @@ fn do_check_auth_rule_selection_downgrade_fails() { let signature_payload = e.crypto().sha256(&payload); // Signer signs expecting the STRICT rule (id 0). - // auth_digest = sha256(signature_payload || [strict_rule.id].to_xdr()) - let intended_rule_ids = vec![&e, strict_rule.id]; - let mut intended_preimage = signature_payload.to_bytes().to_bytes(); - intended_preimage.append(&intended_rule_ids.to_xdr(&e)); - let intended_digest = e.crypto().sha256(&intended_preimage); + let intended_digest = AuthDigestPreimage { + account: address.clone(), + signature_payload: signature_payload.to_bytes(), + context_rule_ids: vec![&e, strict_rule.id], + } + .digest(&e); // Store the expected hash in the verifier so it accepts this digest e.as_contract(&verifier_addr, || { - e.storage() - .persistent() - .set(&symbol_short!("exp_hash"), &intended_digest.to_bytes().to_bytes()); + e.storage().persistent().set(&symbol_short!("exp_hash"), &intended_digest.to_bytes()); }); // Attacker swaps context_rule_ids to the WEAK rule after signatures @@ -1198,8 +1234,8 @@ fn do_check_auth_rule_selection_downgrade_fails() { let tampered_signatures = AuthPayload { signers: signature_map, context_rule_ids: vec![&e, weak_rule.id] }; - // This must fail: the auth_digest computed inside do_check_auth will - // use weak_rule.id, which differs from the digest the signer signed. + // This must fail: the digest computed inside do_check_auth will use + // weak_rule.id, which differs from the digest the signer signed. let _ = do_check_auth(&e, &signature_payload, &tampered_signatures, &auth_contexts); }); } @@ -1232,14 +1268,15 @@ fn do_check_auth_rule_selection_matching_succeeds() { // Signer signs with the correct rule id. let rule_ids = vec![&e, rule.id]; - let mut preimage = signature_payload.to_bytes().to_bytes(); - preimage.append(&rule_ids.clone().to_xdr(&e)); - let expected_digest = e.crypto().sha256(&preimage); + let expected_digest = AuthDigestPreimage { + account: address.clone(), + signature_payload: signature_payload.to_bytes(), + context_rule_ids: rule_ids.clone(), + } + .digest(&e); e.as_contract(&verifier_addr, || { - e.storage() - .persistent() - .set(&symbol_short!("exp_hash"), &expected_digest.to_bytes().to_bytes()); + e.storage().persistent().set(&symbol_short!("exp_hash"), &expected_digest.to_bytes()); }); let context = get_context(contract_addr, symbol_short!("test"), vec![&e]); @@ -1255,6 +1292,60 @@ fn do_check_auth_rule_selection_matching_succeeds() { }); } +#[test] +#[should_panic(expected = "Error(Contract, #3003)")] +fn do_check_auth_digest_bound_to_other_account_fails() { + let e = Env::default(); + let account_a = e.register(MockContract, ()); + let account_b = e.register(MockContract, ()); + let verifier_addr = e.register(HashCheckingVerifier, ()); + + let contract_addr = Address::generate(&e); + let context_type = ContextRuleType::CallContract(contract_addr.clone()); + let external_signer = + Signer::External(verifier_addr.clone(), Bytes::from_array(&e, &[1, 2, 3, 4])); + + // Both accounts hold the same signer under the same rule id. + for account in [&account_a, &account_b] { + let rule = e.as_contract(account, || { + add_context_rule( + &e, + &context_type, + &String::from_str(&e, "rule"), + None, + &Vec::from_array(&e, [external_signer.clone()]), + &Map::new(&e), + ) + }); + assert_eq!(rule.id, 0); + } + + let signature_payload = e.crypto().sha256(&Bytes::from_array(&e, &[1u8; 32])); + let rule_ids = vec![&e, 0u32]; + + // The signer signed a digest bound to account A. + let digest_for_a = AuthDigestPreimage { + account: account_a.clone(), + signature_payload: signature_payload.to_bytes(), + context_rule_ids: rule_ids.clone(), + } + .digest(&e); + e.as_contract(&verifier_addr, || { + e.storage().persistent().set(&symbol_short!("exp_hash"), &digest_for_a.to_bytes()); + }); + + let context = get_context(contract_addr, symbol_short!("test"), vec![&e]); + let auth_contexts = Vec::from_array(&e, [context]); + let mut signature_map = Map::new(&e); + signature_map.set(external_signer, Bytes::from_array(&e, &[5, 6, 7, 8])); + let signatures = AuthPayload { signers: signature_map, context_rule_ids: rule_ids }; + + // Account B must reject it. + e.as_contract(&account_b, || { + let _ = do_check_auth(&e, &signature_payload, &signatures, &auth_contexts); + }); +} + // ################## MATH OVERFLOW TESTS ################## #[test] diff --git a/packages/accounts/src/smart_account/test/mod.rs b/packages/accounts/src/smart_account/test/mod.rs index 8a125c917..255febd68 100644 --- a/packages/accounts/src/smart_account/test/mod.rs +++ b/packages/accounts/src/smart_account/test/mod.rs @@ -1,2 +1,3 @@ +mod auth_entries; mod context_rules; mod signers_and_policies; From b2b3fbffbe0e571194bbaaa980acc94aa6e6e7d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96zg=C3=BCn=20=C3=96zerk?= Date: Thu, 10 Sep 2026 17:31:10 +0300 Subject: [PATCH 13/17] uniform contract type composition via Compose (#821) * uniform contract type composition via Compose Select the ContractType uniformly by listing what the token is made of: `type ContractType = Compose<(AllowList,)>`, for every fungible and non-fungible contract type. Resolution is a type-level pairwise fold, so additive extensions (`Burnable`, `Royalties`) may be listed as well: they are ignored by the resolution (an additive-only list resolves to `Base`), letting developers list every extension they use without knowing which ones override behavior and which merely add it. Invalid lists fail at compile time with curated diagnostics naming the offending pair. No behavioral change: every list resolves to an already existing contract type, and all examples, README snippets, trait docs and Architecture.md are converted to the uniform form. Also fixes stale trait-doc bullet lists (missing Vault/FungibleVotes/NonFungibleVotes, incorrect NonFungibleBurnable incompatibility claims). --- Architecture.md | 14 +- .../fee-forwarder-permissioned/src/test.rs | 4 +- .../fee-forwarder-permissionless/src/test.rs | 4 +- examples/fungible-allowlist/src/contract.rs | 4 +- examples/fungible-blocklist/src/contract.rs | 4 +- examples/fungible-capped/src/contract.rs | 4 +- .../token/src/token.rs | 4 +- examples/fungible-governor/token/src/token.rs | 4 +- examples/fungible-merkle-airdrop/src/test.rs | 4 +- examples/fungible-pausable/src/contract.rs | 4 +- examples/fungible-vault/src/contract.rs | 4 +- examples/fungible-vault/src/test.rs | 4 +- examples/fungible-votes/src/contract.rs | 4 +- examples/nft-access-control/src/contract.rs | 6 +- examples/nft-consecutive/src/contract.rs | 4 +- examples/nft-enumerable/src/contract.rs | 4 +- examples/nft-royalties/src/contract.rs | 6 +- .../nft-sequential-minting/src/contract.rs | 6 +- examples/rwa/token/src/contract.rs | 4 +- packages/fee-abstraction/src/test.rs | 4 +- packages/tokens/README.md | 13 +- .../src/fungible/extensions/burnable/mod.rs | 16 +- .../fungible/extensions/combinations/mod.rs | 306 ++++++++++++++++++ .../fungible/extensions/combinations/test.rs | 71 ++++ .../tokens/src/fungible/extensions/mod.rs | 1 + packages/tokens/src/fungible/mod.rs | 48 +-- packages/tokens/src/fungible/overrides.rs | 2 +- .../non_fungible/extensions/burnable/mod.rs | 14 +- .../extensions/combinations/mod.rs | 278 ++++++++++++++++ .../extensions/combinations/test.rs | 63 ++++ .../tokens/src/non_fungible/extensions/mod.rs | 1 + .../non_fungible/extensions/royalties/mod.rs | 9 + packages/tokens/src/non_fungible/mod.rs | 49 +-- packages/tokens/src/non_fungible/overrides.rs | 2 +- 34 files changed, 875 insertions(+), 94 deletions(-) create mode 100644 packages/tokens/src/fungible/extensions/combinations/mod.rs create mode 100644 packages/tokens/src/fungible/extensions/combinations/test.rs create mode 100644 packages/tokens/src/non_fungible/extensions/combinations/mod.rs create mode 100644 packages/tokens/src/non_fungible/extensions/combinations/test.rs diff --git a/Architecture.md b/Architecture.md index d4ccc966c..167a1ea43 100644 --- a/Architecture.md +++ b/Architecture.md @@ -80,7 +80,7 @@ This design makes it **impossible** to implement conflicting extensions: ```rust // ✅ This works - using Enumerable impl NonFungibleToken for MyContract { - type ContractType = Enumerable; + type ContractType = Compose<(Enumerable,)>; // ... implementations } impl NonFungibleEnumerable for MyContract { @@ -199,7 +199,17 @@ pub struct MyToken; #[contractimpl(contracttrait)] impl FungibleToken for MyToken { - type ContractType = Base; + // From a type-safety perspective there are two kinds of extensions: + // *overriding* ones change the base behavior and compete for the single + // `ContractType` slot, so `Compose` must resolve them; *additive* ones + // (e.g. `Burnable`) change nothing and override nothing. Listing an + // additive extension in `Compose` serves no type-safety purpose: the + // entry is ignored by the resolution, and the extension is enabled by + // implementing its trait, listed or not. Listing them is made optional + // purely to unify the developer experience: developers can list every + // extension they use and never have to figure out which ones are + // additive and which ones are overriding. + type ContractType = Compose<(Base,)>; // The macro fills in every method body, no manual overrides needed. // Alternatively, custom overrides can be provided here (optional) } diff --git a/examples/fee-forwarder-permissioned/src/test.rs b/examples/fee-forwarder-permissioned/src/test.rs index 95114d6e9..d7fa71575 100644 --- a/examples/fee-forwarder-permissioned/src/test.rs +++ b/examples/fee-forwarder-permissioned/src/test.rs @@ -5,7 +5,7 @@ use soroban_sdk::{ testutils::{Address as _, MockAuth, MockAuthInvoke}, vec, Address, Env, IntoVal, MuxedAddress, String, Symbol, TryIntoVal, Val, Vec, }; -use stellar_tokens::fungible::{Base, FungibleToken}; +use stellar_tokens::fungible::{Base, Compose, FungibleToken}; use crate::contract::{FeeForwarder, FeeForwarderClient}; @@ -22,7 +22,7 @@ impl MockToken { #[contractimpl(contracttrait)] impl FungibleToken for MockToken { - type ContractType = Base; + type ContractType = Compose<(Base,)>; } #[contract] diff --git a/examples/fee-forwarder-permissionless/src/test.rs b/examples/fee-forwarder-permissionless/src/test.rs index 6a7e1945a..951c026a2 100644 --- a/examples/fee-forwarder-permissionless/src/test.rs +++ b/examples/fee-forwarder-permissionless/src/test.rs @@ -5,7 +5,7 @@ use soroban_sdk::{ testutils::{Address as _, MockAuth, MockAuthInvoke}, vec, Address, Env, IntoVal, MuxedAddress, String, Symbol, TryIntoVal, Val, Vec, }; -use stellar_tokens::fungible::{Base, FungibleToken}; +use stellar_tokens::fungible::{Base, Compose, FungibleToken}; use crate::contract::{FeeForwarder, FeeForwarderClient}; @@ -22,7 +22,7 @@ impl MockToken { #[contractimpl(contracttrait)] impl FungibleToken for MockToken { - type ContractType = Base; + type ContractType = Compose<(Base,)>; } #[contract] diff --git a/examples/fungible-allowlist/src/contract.rs b/examples/fungible-allowlist/src/contract.rs index e64c2ecbb..932bd15de 100644 --- a/examples/fungible-allowlist/src/contract.rs +++ b/examples/fungible-allowlist/src/contract.rs @@ -13,7 +13,7 @@ use stellar_macros::only_role; use stellar_tokens::fungible::{ allowlist::{AllowList, FungibleAllowList}, burnable::FungibleBurnable, - Base, FungibleToken, + Base, Compose, FungibleToken, }; #[contract] @@ -46,7 +46,7 @@ impl ExampleContract { #[contractimpl(contracttrait)] impl FungibleToken for ExampleContract { - type ContractType = AllowList; + type ContractType = Compose<(AllowList,)>; } #[contractimpl(contracttrait)] diff --git a/examples/fungible-blocklist/src/contract.rs b/examples/fungible-blocklist/src/contract.rs index 2759e691c..d19e8760a 100644 --- a/examples/fungible-blocklist/src/contract.rs +++ b/examples/fungible-blocklist/src/contract.rs @@ -14,7 +14,7 @@ use stellar_macros::only_role; use stellar_tokens::fungible::{ blocklist::{BlockList, FungibleBlockList}, burnable::FungibleBurnable, - Base, FungibleToken, + Base, Compose, FungibleToken, }; #[contract] @@ -52,7 +52,7 @@ impl ExampleContract { #[contractimpl(contracttrait)] impl FungibleToken for ExampleContract { - type ContractType = BlockList; + type ContractType = Compose<(BlockList,)>; } #[contractimpl(contracttrait)] diff --git a/examples/fungible-capped/src/contract.rs b/examples/fungible-capped/src/contract.rs index aa8c89b54..7392341f0 100644 --- a/examples/fungible-capped/src/contract.rs +++ b/examples/fungible-capped/src/contract.rs @@ -10,7 +10,7 @@ use soroban_sdk::{contract, contractimpl, Address, Env, MuxedAddress, String}; use stellar_tokens::fungible::{ capped::{check_cap, set_cap}, - Base, FungibleToken, + Base, Compose, FungibleToken, }; #[contract] @@ -30,5 +30,5 @@ impl ExampleContract { #[contractimpl(contracttrait)] impl FungibleToken for ExampleContract { - type ContractType = Base; + type ContractType = Compose<(Base,)>; } diff --git a/examples/fungible-governor-timelock/token/src/token.rs b/examples/fungible-governor-timelock/token/src/token.rs index 5a961bf8b..fb1f4d35b 100644 --- a/examples/fungible-governor-timelock/token/src/token.rs +++ b/examples/fungible-governor-timelock/token/src/token.rs @@ -2,7 +2,7 @@ use soroban_sdk::{contract, contractimpl, Address, Env, MuxedAddress, String}; use stellar_access::ownable::{set_owner, Ownable}; use stellar_governance::votes::Votes; use stellar_macros::only_owner; -use stellar_tokens::fungible::{votes::FungibleVotes, Base, FungibleToken}; +use stellar_tokens::fungible::{votes::FungibleVotes, Base, Compose, FungibleToken}; #[contract] pub struct TokenContract; @@ -27,7 +27,7 @@ impl TokenContract { #[contractimpl(contracttrait)] impl FungibleToken for TokenContract { - type ContractType = FungibleVotes; + type ContractType = Compose<(FungibleVotes,)>; } #[contractimpl(contracttrait)] diff --git a/examples/fungible-governor/token/src/token.rs b/examples/fungible-governor/token/src/token.rs index 5a961bf8b..fb1f4d35b 100644 --- a/examples/fungible-governor/token/src/token.rs +++ b/examples/fungible-governor/token/src/token.rs @@ -2,7 +2,7 @@ use soroban_sdk::{contract, contractimpl, Address, Env, MuxedAddress, String}; use stellar_access::ownable::{set_owner, Ownable}; use stellar_governance::votes::Votes; use stellar_macros::only_owner; -use stellar_tokens::fungible::{votes::FungibleVotes, Base, FungibleToken}; +use stellar_tokens::fungible::{votes::FungibleVotes, Base, Compose, FungibleToken}; #[contract] pub struct TokenContract; @@ -27,7 +27,7 @@ impl TokenContract { #[contractimpl(contracttrait)] impl FungibleToken for TokenContract { - type ContractType = FungibleVotes; + type ContractType = Compose<(FungibleVotes,)>; } #[contractimpl(contracttrait)] diff --git a/examples/fungible-merkle-airdrop/src/test.rs b/examples/fungible-merkle-airdrop/src/test.rs index ad4ec619a..aa5c09e4c 100644 --- a/examples/fungible-merkle-airdrop/src/test.rs +++ b/examples/fungible-merkle-airdrop/src/test.rs @@ -3,7 +3,7 @@ use soroban_sdk::{ contract, contractimpl, testutils::Address as _, vec, Address, BytesN, Env, MuxedAddress, String, }; -use stellar_tokens::fungible::{Base, FungibleToken}; +use stellar_tokens::fungible::{Base, Compose, FungibleToken}; use crate::contract::{AirdropContract, AirdropContractClient}; @@ -23,7 +23,7 @@ impl TokenContract { #[contractimpl(contracttrait)] impl FungibleToken for TokenContract { - type ContractType = Base; + type ContractType = Compose<(Base,)>; } fn create_token_contract<'a>(e: &Env, owner: &Address) -> TokenContractClient<'a> { diff --git a/examples/fungible-pausable/src/contract.rs b/examples/fungible-pausable/src/contract.rs index 8cd104da3..823163f95 100644 --- a/examples/fungible-pausable/src/contract.rs +++ b/examples/fungible-pausable/src/contract.rs @@ -15,7 +15,7 @@ use soroban_sdk::{ }; use stellar_contract_utils::pausable::{self as pausable, Pausable}; use stellar_macros::when_not_paused; -use stellar_tokens::fungible::{burnable::FungibleBurnable, Base, FungibleToken}; +use stellar_tokens::fungible::{burnable::FungibleBurnable, Base, Compose, FungibleToken}; pub const OWNER: Symbol = symbol_short!("OWNER"); @@ -90,7 +90,7 @@ impl Pausable for ExampleContract { #[contractimpl] impl FungibleToken for ExampleContract { - type ContractType = Base; + type ContractType = Compose<(Base,)>; fn total_supply(e: &Env) -> i128 { Self::ContractType::total_supply(e) diff --git a/examples/fungible-vault/src/contract.rs b/examples/fungible-vault/src/contract.rs index 2a20939d4..36b084352 100644 --- a/examples/fungible-vault/src/contract.rs +++ b/examples/fungible-vault/src/contract.rs @@ -2,7 +2,7 @@ use soroban_sdk::{contract, contractimpl, Address, Env, MuxedAddress, String}; use stellar_tokens::{ - fungible::{Base, FungibleToken}, + fungible::{Base, Compose, FungibleToken}, vault::{FungibleVault, Vault}, }; @@ -30,7 +30,7 @@ impl ExampleContract { #[contractimpl(contracttrait)] impl FungibleToken for ExampleContract { - type ContractType = Vault; + type ContractType = Compose<(Vault,)>; // Allows override of decimals and other base functions. diff --git a/examples/fungible-vault/src/test.rs b/examples/fungible-vault/src/test.rs index 754d2373a..cdf43f12b 100644 --- a/examples/fungible-vault/src/test.rs +++ b/examples/fungible-vault/src/test.rs @@ -5,7 +5,7 @@ extern crate std; use soroban_sdk::{ contract, contractimpl, testutils::Address as _, Address, Env, MuxedAddress, String, }; -use stellar_tokens::fungible::{Base, FungibleToken}; +use stellar_tokens::fungible::{Base, Compose, FungibleToken}; use crate::contract::{ExampleContract, ExampleContractClient}; @@ -28,7 +28,7 @@ impl MockAssetContract { #[contractimpl(contracttrait)] impl FungibleToken for MockAssetContract { - type ContractType = stellar_tokens::fungible::Base; + type ContractType = Compose<(Base,)>; } // Client for the mock asset contract is automatically generated by the diff --git a/examples/fungible-votes/src/contract.rs b/examples/fungible-votes/src/contract.rs index b66aa5441..1e1d29a83 100644 --- a/examples/fungible-votes/src/contract.rs +++ b/examples/fungible-votes/src/contract.rs @@ -3,7 +3,7 @@ use stellar_access::ownable::{set_owner, Ownable}; use stellar_governance::votes::Votes; use stellar_macros::only_owner; use stellar_tokens::fungible::{ - burnable::FungibleBurnable, votes::FungibleVotes, Base, FungibleToken, + burnable::FungibleBurnable, votes::FungibleVotes, Base, Compose, FungibleToken, }; #[contract] @@ -24,7 +24,7 @@ impl ExampleContract { #[contractimpl(contracttrait)] impl FungibleToken for ExampleContract { - type ContractType = FungibleVotes; + type ContractType = Compose<(FungibleVotes,)>; } #[contractimpl(contracttrait)] diff --git a/examples/nft-access-control/src/contract.rs b/examples/nft-access-control/src/contract.rs index 354937583..d7e36d13f 100644 --- a/examples/nft-access-control/src/contract.rs +++ b/examples/nft-access-control/src/contract.rs @@ -5,7 +5,9 @@ use soroban_sdk::{contract, contractimpl, vec, Address, Env, String, Symbol, Vec}; use stellar_access::access_control::{set_admin, AccessControl}; use stellar_macros::{has_any_role, has_role, only_admin, only_any_role, only_role}; -use stellar_tokens::non_fungible::{burnable::NonFungibleBurnable, Base, NonFungibleToken}; +use stellar_tokens::non_fungible::{ + burnable::NonFungibleBurnable, Base, Compose, NonFungibleToken, +}; #[contract] pub struct ExampleContract; @@ -47,7 +49,7 @@ impl ExampleContract { #[contractimpl(contracttrait)] impl NonFungibleToken for ExampleContract { - type ContractType = Base; + type ContractType = Compose<(Base,)>; } // for this contract, the `burn*` functions are only meant to be called by diff --git a/examples/nft-consecutive/src/contract.rs b/examples/nft-consecutive/src/contract.rs index a7ecee399..3d7785b6c 100644 --- a/examples/nft-consecutive/src/contract.rs +++ b/examples/nft-consecutive/src/contract.rs @@ -7,7 +7,7 @@ use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String}; use stellar_tokens::non_fungible::{ burnable::NonFungibleBurnable, consecutive::{Consecutive, NonFungibleConsecutive}, - Base, ContractOverrides, NonFungibleToken, + Base, Compose, ContractOverrides, NonFungibleToken, }; #[contracttype] @@ -39,7 +39,7 @@ impl ExampleContract { // `#[contractimpl(contracttrait)]` macro. #[contractimpl(contracttrait)] impl NonFungibleToken for ExampleContract { - type ContractType = Consecutive; + type ContractType = Compose<(Consecutive,)>; fn balance(e: &Env, owner: Address) -> u32 { Self::ContractType::balance(e, &owner) diff --git a/examples/nft-enumerable/src/contract.rs b/examples/nft-enumerable/src/contract.rs index 94b0a6e8a..0c224eaae 100644 --- a/examples/nft-enumerable/src/contract.rs +++ b/examples/nft-enumerable/src/contract.rs @@ -8,7 +8,7 @@ use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String}; use stellar_tokens::non_fungible::{ burnable::NonFungibleBurnable, enumerable::{Enumerable, NonFungibleEnumerable}, - Base, NonFungibleToken, + Base, Compose, NonFungibleToken, }; #[contracttype] @@ -36,7 +36,7 @@ impl ExampleContract { #[contractimpl(contracttrait)] impl NonFungibleToken for ExampleContract { - type ContractType = Enumerable; + type ContractType = Compose<(Enumerable,)>; } #[contractimpl(contracttrait)] diff --git a/examples/nft-royalties/src/contract.rs b/examples/nft-royalties/src/contract.rs index 99a5ef80a..6ec359b04 100644 --- a/examples/nft-royalties/src/contract.rs +++ b/examples/nft-royalties/src/contract.rs @@ -7,7 +7,9 @@ use soroban_sdk::{contract, contractimpl, symbol_short, Address, Env, String, Symbol, Vec}; use stellar_access::access_control::{self as access_control, AccessControl}; use stellar_macros::{only_admin, only_role}; -use stellar_tokens::non_fungible::{royalties::NonFungibleRoyalties, Base, NonFungibleToken}; +use stellar_tokens::non_fungible::{ + royalties::NonFungibleRoyalties, Base, Compose, NonFungibleToken, +}; #[contract] pub struct ExampleContract; @@ -53,7 +55,7 @@ impl ExampleContract { #[contractimpl(contracttrait)] impl NonFungibleToken for ExampleContract { - type ContractType = Base; + type ContractType = Compose<(Base,)>; } #[contractimpl(contracttrait)] diff --git a/examples/nft-sequential-minting/src/contract.rs b/examples/nft-sequential-minting/src/contract.rs index 3e9aba7e2..df3be767a 100644 --- a/examples/nft-sequential-minting/src/contract.rs +++ b/examples/nft-sequential-minting/src/contract.rs @@ -3,7 +3,9 @@ //! Demonstrates an example usage of the NFT default base implementation. use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String}; -use stellar_tokens::non_fungible::{burnable::NonFungibleBurnable, Base, NonFungibleToken}; +use stellar_tokens::non_fungible::{ + burnable::NonFungibleBurnable, Base, Compose, NonFungibleToken, +}; #[contracttype] pub enum DataKey { @@ -30,7 +32,7 @@ impl ExampleContract { #[contractimpl(contracttrait)] impl NonFungibleToken for ExampleContract { - type ContractType = Base; + type ContractType = Compose<(Base,)>; } #[contractimpl(contracttrait)] diff --git a/examples/rwa/token/src/contract.rs b/examples/rwa/token/src/contract.rs index fcb2108fd..8236ac9c9 100644 --- a/examples/rwa/token/src/contract.rs +++ b/examples/rwa/token/src/contract.rs @@ -8,7 +8,7 @@ use stellar_access::access_control::{self as access_control, AccessControl}; use stellar_contract_utils::pausable::{self as pausable, Pausable}; use stellar_macros::{only_admin, only_role}; use stellar_tokens::{ - fungible::{Base, FungibleToken}, + fungible::{Base, Compose, FungibleToken}, rwa::{RWAError, RWAToken, RWA}, }; @@ -55,7 +55,7 @@ impl Pausable for RWATokenContract { #[contractimpl(contracttrait)] impl FungibleToken for RWATokenContract { - type ContractType = RWA; + type ContractType = Compose<(RWA,)>; /// Showcase: how to opt out of custodial (muxed) destinations. /// diff --git a/packages/fee-abstraction/src/test.rs b/packages/fee-abstraction/src/test.rs index c16e95a79..547b5d151 100644 --- a/packages/fee-abstraction/src/test.rs +++ b/packages/fee-abstraction/src/test.rs @@ -4,7 +4,7 @@ use soroban_sdk::{ token::TokenClient, vec, Address, Env, Event, FromVal, MuxedAddress, String, Symbol, Val, Vec, }; -use stellar_tokens::fungible::{Approve, Base, FungibleToken, Transfer}; +use stellar_tokens::fungible::{Approve, Base, Compose, FungibleToken, Transfer}; use crate::{ collect_fee, collect_fee_and_invoke, is_allowed_fee_token, is_fee_token_allowlist_enabled, @@ -37,7 +37,7 @@ impl MockToken { #[contractimpl(contracttrait)] impl FungibleToken for MockToken { - type ContractType = Base; + type ContractType = Compose<(Base,)>; } #[test] diff --git a/packages/tokens/README.md b/packages/tokens/README.md index 02c5aa787..024996c37 100644 --- a/packages/tokens/README.md +++ b/packages/tokens/README.md @@ -24,7 +24,7 @@ The `fungible` module provides functionalities for fungible tokens: balance mana ```rust use soroban_sdk::{contract, contractimpl, Address, Env, String}; -use stellar_tokens::fungible::{burnable::FungibleBurnable, Base, FungibleToken}; +use stellar_tokens::fungible::{burnable::FungibleBurnable, Base, Compose, FungibleToken}; use stellar_access::ownable::{self as ownable, Ownable}; use stellar_macros::{only_owner}; @@ -63,15 +63,16 @@ impl MyContract { #[contractimpl(contracttrait)] impl FungibleToken for MyContract { - type ContractType = Base; + type ContractType = Compose<(Base,)>; } #[contractimpl(contracttrait)] impl FungibleBurnable for MyContract {} ``` -Notice the empty trait bodies. Only a `ContractType` has to be picked (here -`Base`, but it could be `AllowList`, `BlockList`, `RWA`, ...) and the +Notice the empty trait bodies. Only a `ContractType` has to be picked with +`Compose` (here `Base`, but it could be `AllowList`, `BlockList`, `RWA`, ...) +and the `#[contractimpl(contracttrait)]` macro fills in every method, routing it to the behavior that matches that `ContractType`. The override machinery that makes this work (`ContractOverrides`, `BurnableOverrides`) operates in the @@ -101,7 +102,7 @@ The `non_fungible` module implements non-fungible token functionality: use soroban_sdk::{contract, contractimpl, Address, Env, String}; use stellar_tokens::non_fungible::{ burnable::NonFungibleBurnable, - Base, NonFungibleToken, + Base, Compose, NonFungibleToken, }; #[contract] @@ -129,7 +130,7 @@ impl MyNFTContract { #[contractimpl(contracttrait)] impl NonFungibleToken for MyNFTContract { - type ContractType = Base; + type ContractType = Compose<(Base,)>; } #[contractimpl(contracttrait)] diff --git a/packages/tokens/src/fungible/extensions/burnable/mod.rs b/packages/tokens/src/fungible/extensions/burnable/mod.rs index 8cd699498..598b72c6f 100644 --- a/packages/tokens/src/fungible/extensions/burnable/mod.rs +++ b/packages/tokens/src/fungible/extensions/burnable/mod.rs @@ -7,6 +7,15 @@ use soroban_sdk::{contractevent, contracttrait, Address, Env}; use crate::fungible::{overrides::BurnableOverrides, FungibleToken}; +/// Type-level name of the burnable extension, for use in a +/// [`crate::fungible::combinations::Compose`] list. +/// +/// Burnable is additive: it does not override the base behavior, so listing +/// it is purely declarative and does not affect the resolved contract type. +/// Burning is enabled by implementing [`FungibleBurnable`], whether or not +/// `Burnable` is listed. +pub enum Burnable {} + /// Burnable Trait for Fungible Token /// /// The `FungibleBurnable` trait extends the `FungibleToken` trait to provide @@ -33,9 +42,10 @@ use crate::fungible::{overrides::BurnableOverrides, FungibleToken}; /// /// The burn logic is selected automatically based on the `ContractType` set /// on the `FungibleToken` implementation. If the contract uses -/// `type ContractType = AllowList`, burning checks the allowlist; with -/// `type ContractType = Base` it uses the vanilla behavior, and so on. There -/// is no need to interact with the override machinery, it works in the +/// `type ContractType = Compose<(AllowList,)>`, burning checks the allowlist; +/// with `type ContractType = Compose<(Base,)>` it uses the vanilla behavior, +/// and so on. +/// There is no need to interact with the override machinery, it works in the /// background. #[contracttrait] pub trait FungibleBurnable: FungibleToken { diff --git a/packages/tokens/src/fungible/extensions/combinations/mod.rs b/packages/tokens/src/fungible/extensions/combinations/mod.rs new file mode 100644 index 000000000..bd5def9f9 --- /dev/null +++ b/packages/tokens/src/fungible/extensions/combinations/mod.rs @@ -0,0 +1,306 @@ +//! # Contract Type Composition for Fungible Token. +//! +//! A contract selects exactly one `ContractType` on its +//! [`crate::fungible::FungibleToken`] implementation, and that single slot +//! decides all overridable behavior. The contract type is selected uniformly +//! with [`Compose`], by listing the extensions the token is made of. +//! +//! Additive extensions (e.g. [`Burnable`] for +//! [`crate::fungible::burnable::FungibleBurnable`]) may be listed as well: +//! they do not affect the resolved contract type and are enabled by +//! implementing their trait, whether or not they are listed. A list holding +//! only additive extensions resolves to [`Base`]. +//! +//! Usage: +//! +//! ```ignore +//! #[contractimpl(contracttrait)] +//! impl FungibleToken for MyToken { +//! // resolves to `AllowList`; `Burnable` is declarative +//! type ContractType = Compose<(AllowList, Burnable)>; +//! } +//! +//! #[contractimpl(contracttrait)] +//! impl FungibleAllowList for MyToken { +//! // ... +//! } +//! +//! #[contractimpl(contracttrait)] +//! impl FungibleBurnable for MyToken {} +//! ``` +//! +//! No multi-type combinations of contract types are curated for fungible +//! tokens yet; every valid list currently holds at most one contract type +//! (plus any additive extensions). Invalid lists do not compile: `AllowList` +//! and `BlockList` are mutually exclusive, and implementing an extension +//! trait the list does not back (e.g. +//! [`crate::fungible::allowlist::FungibleAllowList`] without `AllowList` in +//! the list) is rejected by that trait's bound. + +#[cfg(test)] +mod test; + +use crate::{ + fungible::{ + extensions::{ + allowlist::AllowList, blocklist::BlockList, burnable::Burnable, votes::FungibleVotes, + }, + Base, ContractOverrides, + }, + rwa::RWA, + vault::Vault, +}; + +/// Resolves a list of contract types and additive extensions to the combined +/// contract type, e.g. `Compose<(AllowList,)>` or +/// `Compose<(AllowList, Burnable)>` (both resolve to `AllowList`). +/// +/// This is shorthand for `::Out`; refer to [`Composable`] for +/// the valid lists. +pub type Compose = ::Out; + +/// Type-level lookup backing [`Compose`]: each valid list resolves to its +/// combined contract type through the `Out` associated type. +/// +/// A list is folded pairwise, left to right. Single contract types resolve to +/// themselves (with or without the one-element tuple form), and additive +/// extensions are ignored. Mutually exclusive contract types (e.g. +/// `AllowList` and `BlockList`) have no pairwise combination, so listing them +/// together does not compile. +#[diagnostic::on_unimplemented( + message = "`{Self}` is not a valid contract type combination", + note = "valid single contract types: `Base`, `AllowList`, `BlockList`, `RWA`, `Vault`, \ + `FungibleVotes`", + note = "additive extensions (e.g. `Burnable`) may be listed alongside a contract type; they \ + do not affect the resolved type", + note = "lists of up to 5 entries are supported" +)] +pub trait Composable { + type Out: ContractOverrides; +} + +// The resolution machinery below is internal: contract developers only +// interact with `Compose`, and the traits in this module are unnameable +// outside the crate. +mod fold { + /// Identity element of composition: the contribution of an additive + /// extension. Folding it into any contribution leaves that contribution + /// unchanged, which is how additive extensions are ignored. + pub enum Nil {} + + /// Implemented by every name that may appear in a `Compose` list. The + /// `Contribution` is what the entry adds to the resolved contract type: + /// contract types contribute themselves, additive extensions contribute + /// [`Nil`]. + #[diagnostic::on_unimplemented( + message = "`{Self}` cannot appear in a `Compose` list", + note = "valid entries are the contract types (`Base`, `AllowList`, `BlockList`, `RWA`, \ + `Vault`, `FungibleVotes`) and the additive extensions (`Burnable`)" + )] + pub trait Extension { + type Contribution; + } + + /// Pairwise combination table for contributions: the fold reduces a list + /// left to right through this table. A missing row means the pair is + /// invalid (mutually exclusive, or no curated combination exists). + #[diagnostic::on_unimplemented( + message = "`{Self}` cannot be combined with `{B}` in a `Compose` list", + note = "the contract types are mutually exclusive, or no combination of them is curated" + )] + pub trait Combine { + type Out; + } + + /// Final step of the fold: maps the folded contribution to the resolved + /// contract type. Its only non-trivial rule is [`Nil`] resolving to + /// `Base` (a list of only additive extensions is a vanilla token). + pub trait Finalize { + type Out: super::ContractOverrides; + } +} + +use fold::{Combine, Extension, Finalize, Nil}; + +type Contrib = ::Contribution; +type Pair = >::Out; +type Final = ::Out; + +// What each list entry contributes to the resolved contract type. +impl Extension for Base { + type Contribution = Base; +} +impl Extension for AllowList { + type Contribution = AllowList; +} +impl Extension for BlockList { + type Contribution = BlockList; +} +impl Extension for RWA { + type Contribution = RWA; +} +impl Extension for Vault { + type Contribution = Vault; +} +impl Extension for FungibleVotes { + type Contribution = FungibleVotes; +} +// Additive extensions contribute nothing. +impl Extension for Burnable { + type Contribution = Nil; +} + +// Identity rows: combining with `Nil` changes nothing. One pair of rows per +// contract type, plus the `Nil`/`Nil` row for lists of only additive +// extensions. Curated multi-type combinations would be added here as +// additional rows (in both orders, so lists stay order-insensitive). +impl Combine for Nil { + type Out = Nil; +} +impl Combine for Base { + type Out = Base; +} +impl Combine for Nil { + type Out = Base; +} +impl Combine for AllowList { + type Out = AllowList; +} +impl Combine for Nil { + type Out = AllowList; +} +impl Combine for BlockList { + type Out = BlockList; +} +impl Combine for Nil { + type Out = BlockList; +} +impl Combine for RWA { + type Out = RWA; +} +impl Combine for Nil { + type Out = RWA; +} +impl Combine for Vault { + type Out = Vault; +} +impl Combine for Nil { + type Out = Vault; +} +impl Combine for FungibleVotes { + type Out = FungibleVotes; +} +impl Combine for Nil { + type Out = FungibleVotes; +} + +impl Finalize for Nil { + type Out = Base; +} +impl Finalize for Base { + type Out = Base; +} +impl Finalize for AllowList { + type Out = AllowList; +} +impl Finalize for BlockList { + type Out = BlockList; +} +impl Finalize for RWA { + type Out = RWA; +} +impl Finalize for Vault { + type Out = Vault; +} +impl Finalize for FungibleVotes { + type Out = FungibleVotes; +} + +// Bare forms: a single entry may also be written without the one-element +// tuple, so a stray trailing comma (or its absence) does not change the +// meaning. +impl Composable for Base { + type Out = Base; +} +impl Composable for AllowList { + type Out = AllowList; +} +impl Composable for BlockList { + type Out = BlockList; +} +impl Composable for RWA { + type Out = RWA; +} +impl Composable for Vault { + type Out = Vault; +} +impl Composable for FungibleVotes { + type Out = FungibleVotes; +} +impl Composable for Burnable { + type Out = Base; +} + +// Lists are folded left to right through the `Combine` table; additive +// entries contribute `Nil`, the identity, so they vanish. Longer lists are +// supported by adding one impl per arity. +impl Composable for (A,) +where + A: Extension, + Contrib: Finalize, +{ + type Out = Final>; +} + +impl Composable for (A, B) +where + A: Extension, + B: Extension, + Contrib: Combine>, + Pair, Contrib>: Finalize, +{ + type Out = Final, Contrib>>; +} + +impl Composable for (A, B, C) +where + A: Extension, + B: Extension, + C: Extension, + Contrib: Combine>, + Pair, Contrib>: Combine>, + Pair, Contrib>, Contrib>: Finalize, +{ + type Out = Final, Contrib>, Contrib>>; +} + +impl Composable for (A, B, C, D) +where + A: Extension, + B: Extension, + C: Extension, + D: Extension, + Contrib: Combine>, + Pair, Contrib>: Combine>, + Pair, Contrib>, Contrib>: Combine>, + Pair, Contrib>, Contrib>, Contrib>: Finalize, +{ + type Out = Final, Contrib>, Contrib>, Contrib>>; +} + +impl Composable for (A, B, C, D, E) +where + A: Extension, + B: Extension, + C: Extension, + D: Extension, + E: Extension, + Contrib: Combine>, + Pair, Contrib>: Combine>, + Pair, Contrib>, Contrib>: Combine>, + Pair, Contrib>, Contrib>, Contrib>: Combine>, + Pair, Contrib>, Contrib>, Contrib>, Contrib>: Finalize, +{ + type Out = + Final, Contrib>, Contrib>, Contrib>, Contrib>>; +} diff --git a/packages/tokens/src/fungible/extensions/combinations/test.rs b/packages/tokens/src/fungible/extensions/combinations/test.rs new file mode 100644 index 000000000..56599bf74 --- /dev/null +++ b/packages/tokens/src/fungible/extensions/combinations/test.rs @@ -0,0 +1,71 @@ +extern crate std; + +use core::any::TypeId; + +use crate::{ + fungible::{ + extensions::{ + allowlist::AllowList, + blocklist::BlockList, + burnable::Burnable, + combinations::{Composable, Compose}, + votes::FungibleVotes, + }, + Base, + }, + rwa::RWA, + vault::Vault, +}; + +// Pins every `Composable` mapping: the resolved `Out` type must be exactly the +// expected contract type. Invalid lists are rejected at compile time and +// cannot be covered here without a compile-fail harness. +fn assert_composes_to() +where + List: Composable, + Compose: 'static, + Expected: 'static, +{ + assert_eq!(TypeId::of::>(), TypeId::of::()); +} + +#[test] +fn bare_forms_resolve_to_themselves() { + assert_composes_to::(); + assert_composes_to::(); + assert_composes_to::(); + assert_composes_to::(); + assert_composes_to::(); + assert_composes_to::(); +} + +#[test] +fn tuple_forms_resolve_to_themselves() { + assert_composes_to::<(Base,), Base>(); + assert_composes_to::<(AllowList,), AllowList>(); + assert_composes_to::<(BlockList,), BlockList>(); + assert_composes_to::<(RWA,), RWA>(); + assert_composes_to::<(Vault,), Vault>(); + assert_composes_to::<(FungibleVotes,), FungibleVotes>(); +} + +#[test] +fn additive_extensions_are_ignored() { + // Additive markers may appear in any position without changing the + // resolution. + assert_composes_to::<(AllowList, Burnable), AllowList>(); + assert_composes_to::<(Burnable, AllowList), AllowList>(); + assert_composes_to::<(Vault, Burnable), Vault>(); + assert_composes_to::<(RWA, Burnable), RWA>(); + // Higher arities, covering every tuple impl. + assert_composes_to::<(Burnable, FungibleVotes, Burnable), FungibleVotes>(); + assert_composes_to::<(BlockList, Burnable, Burnable, Burnable), BlockList>(); + assert_composes_to::<(Burnable, Burnable, Base, Burnable, Burnable), Base>(); +} + +#[test] +fn additive_only_lists_resolve_to_base() { + assert_composes_to::(); + assert_composes_to::<(Burnable,), Base>(); + assert_composes_to::<(Burnable, Burnable), Base>(); +} diff --git a/packages/tokens/src/fungible/extensions/mod.rs b/packages/tokens/src/fungible/extensions/mod.rs index 7b9304053..3af97ab36 100644 --- a/packages/tokens/src/fungible/extensions/mod.rs +++ b/packages/tokens/src/fungible/extensions/mod.rs @@ -2,4 +2,5 @@ pub mod allowlist; pub mod blocklist; pub mod burnable; pub mod capped; +pub mod combinations; pub mod votes; diff --git a/packages/tokens/src/fungible/mod.rs b/packages/tokens/src/fungible/mod.rs index 9ba144ae6..9aecf2854 100644 --- a/packages/tokens/src/fungible/mod.rs +++ b/packages/tokens/src/fungible/mod.rs @@ -74,7 +74,9 @@ mod utils; #[cfg(test)] mod test; -pub use extensions::{allowlist, blocklist, burnable, capped, votes}; +pub use extensions::{ + allowlist, blocklist, burnable, capped, combinations, combinations::Compose, votes, +}; pub use overrides::{Base, ContractOverrides}; use soroban_sdk::{ contracterror, contractevent, contracttrait, Address, Env, MuxedAddress, String, @@ -103,23 +105,30 @@ pub use utils::{sac_admin_generic, sac_admin_wrapper}; /// /// This trait is implemented for the following Contract Types: /// * [`crate::fungible::Base`] (covering the vanilla case, and compatible with -/// [`crate::fungible::burnable::FungibleBurnable`]) trait +/// the [`crate::fungible::burnable::FungibleBurnable`] trait) /// * [`crate::fungible::allowlist::AllowList`] (enabling the compatibility and -/// overrides for [`crate::fungible::allowlist::FungibleAllowList`]) trait, -/// incompatible with [`crate::fungible::blocklist::BlockList`] trait and -/// [`crate::rwa::RWA`] trait. +/// overrides for the [`crate::fungible::allowlist::FungibleAllowList`] +/// trait), incompatible with [`crate::fungible::blocklist::BlockList`] and +/// [`crate::rwa::RWA`]. /// * [`crate::fungible::blocklist::BlockList`] (enabling the compatibility and -/// overrides for [`crate::fungible::blocklist::FungibleBlockList`]) trait, -/// incompatible with [`crate::fungible::allowlist::AllowList`] trait and -/// [`crate::rwa::RWA`] trait. -/// * [`crate::rwa::RWA`] (enabling the compatibility and overrides for -/// [`crate::rwa::RWAToken`]) trait, incompatible with -/// [`crate::fungible::allowlist::AllowList`] trait and -/// [`crate::fungible::blocklist::BlockList`] trait. +/// overrides for the [`crate::fungible::blocklist::FungibleBlockList`] +/// trait), incompatible with [`crate::fungible::allowlist::AllowList`] and +/// [`crate::rwa::RWA`]. +/// * [`crate::rwa::RWA`] (enabling the compatibility and overrides for the +/// [`crate::rwa::RWAToken`] trait), incompatible with +/// [`crate::fungible::allowlist::AllowList`] and +/// [`crate::fungible::blocklist::BlockList`]. +/// * [`crate::vault::Vault`] (enabling the compatibility and overrides for the +/// [`crate::vault::FungibleVault`] trait). +/// * [`crate::fungible::votes::FungibleVotes`] (enabling the compatibility and +/// overrides for the [`stellar_governance::votes::Votes`] trait). +/// +/// The contract type is selected with +/// [`crate::fungible::combinations::Compose`]; invalid combinations are +/// rejected at compile time. /// -/// The default implementations of this trait for `Base`, `Allowlist`, -/// `Blocklist` and `RWA` can be found by navigating to: -/// `ContractType::{method_name}`. +/// The default implementations of this trait for each contract type can be +/// found by navigating to: `ContractType::{method_name}`. /// /// For example, the implementation of [`FungibleToken::transfer`] for the /// `Allowlist` contract type can be found at @@ -128,10 +137,11 @@ pub use utils::{sac_admin_generic, sac_admin_wrapper}; pub trait FungibleToken { /// Helper type that allows some of the functionality of the base trait to /// be overridden based on the extensions implemented. - /// [`crate::fungible::Base`] should be used as the type when - /// not using - /// [`crate::fungible::allowlist::AllowList`] or - /// [`crate::fungible::blocklist::BlockList`] extensions. + /// The contract type is selected with + /// [`crate::fungible::combinations::Compose`], by listing the extensions + /// the token is made of: `Compose<(Base,)>` for the vanilla case, + /// `Compose<(AllowList,)>` for an allowlist token, and so on. Invalid + /// lists are rejected at compile time. type ContractType: ContractOverrides; /// Returns the total amount of tokens in circulation. diff --git a/packages/tokens/src/fungible/overrides.rs b/packages/tokens/src/fungible/overrides.rs index 2943af9d3..7a7bdd44f 100644 --- a/packages/tokens/src/fungible/overrides.rs +++ b/packages/tokens/src/fungible/overrides.rs @@ -25,7 +25,7 @@ use soroban_sdk::{Address, Env, MuxedAddress, String}; /// ```rust /// #[contractimpl(contracttrait)] /// impl FungibleToken for ExampleContract { -/// type ContractType = Base; +/// type ContractType = Compose<(Base,)>; /// } /// ``` pub trait ContractOverrides { diff --git a/packages/tokens/src/non_fungible/extensions/burnable/mod.rs b/packages/tokens/src/non_fungible/extensions/burnable/mod.rs index b13ba45ab..ec40af379 100644 --- a/packages/tokens/src/non_fungible/extensions/burnable/mod.rs +++ b/packages/tokens/src/non_fungible/extensions/burnable/mod.rs @@ -6,6 +6,15 @@ mod test; use soroban_sdk::{contractevent, contracttrait, Address, Env}; +/// Type-level name of the burnable extension, for use in a +/// [`crate::non_fungible::combinations::Compose`] list. +/// +/// Burnable is additive: it does not override the base behavior, so listing +/// it is purely declarative and does not affect the resolved contract type. +/// Burning is enabled by implementing [`NonFungibleBurnable`], whether or not +/// `Burnable` is listed. +pub enum Burnable {} + /// Burnable Trait for Non-Fungible Token /// /// The `NonFungibleBurnable` trait extends the `NonFungibleToken` trait to @@ -34,8 +43,9 @@ use soroban_sdk::{contractevent, contracttrait, Address, Env}; /// /// The burn logic is selected automatically based on the `ContractType` set /// on the `NonFungibleToken` implementation. If the contract uses -/// `type ContractType = Consecutive`, burning uses the consecutive bookkeeping; -/// with `type ContractType = Base` it uses the vanilla behavior, and so on. +/// `type ContractType = Compose<(Consecutive,)>`, burning uses the consecutive +/// bookkeeping; with `type ContractType = Compose<(Base,)>` it uses the vanilla +/// behavior, and so on. /// There is no need to interact with the override machinery, it works in the /// background. #[contracttrait] diff --git a/packages/tokens/src/non_fungible/extensions/combinations/mod.rs b/packages/tokens/src/non_fungible/extensions/combinations/mod.rs new file mode 100644 index 000000000..7b247aa38 --- /dev/null +++ b/packages/tokens/src/non_fungible/extensions/combinations/mod.rs @@ -0,0 +1,278 @@ +//! # Contract Type Composition for Non-Fungible Token. +//! +//! A contract selects exactly one `ContractType` on its +//! [`crate::non_fungible::NonFungibleToken`] implementation, and that single +//! slot decides all overridable behavior. The contract type is selected +//! uniformly with [`Compose`], by listing the extensions the token is made +//! of. +//! +//! Additive extensions (e.g. [`Burnable`] for +//! [`crate::non_fungible::burnable::NonFungibleBurnable`], or [`Royalties`] +//! for [`crate::non_fungible::royalties::NonFungibleRoyalties`]) may be +//! listed as well: they do not affect the resolved contract type and are +//! enabled by implementing their trait, whether or not they are listed. A +//! list holding only additive extensions resolves to [`Base`]. +//! +//! Usage: +//! +//! ```ignore +//! #[contractimpl(contracttrait)] +//! impl NonFungibleToken for MyToken { +//! // resolves to `Enumerable`; `Burnable` is declarative +//! type ContractType = Compose<(Enumerable, Burnable)>; +//! } +//! +//! #[contractimpl(contracttrait)] +//! impl NonFungibleEnumerable for MyToken {} +//! +//! #[contractimpl(contracttrait)] +//! impl NonFungibleBurnable for MyToken {} +//! ``` +//! +//! No multi-type combinations of contract types are curated for non-fungible +//! tokens yet; every valid list currently holds at most one contract type +//! (plus any additive extensions). Invalid lists do not compile: `Enumerable` +//! and `Consecutive` are mutually exclusive, and implementing an extension +//! trait the list does not back (e.g. +//! [`crate::non_fungible::enumerable::NonFungibleEnumerable`] without +//! `Enumerable` in the list) is rejected by that trait's bound. + +#[cfg(test)] +mod test; + +use crate::non_fungible::{ + extensions::{ + burnable::Burnable, consecutive::Consecutive, enumerable::Enumerable, royalties::Royalties, + votes::NonFungibleVotes, + }, + Base, ContractOverrides, +}; + +/// Resolves a list of contract types and additive extensions to the combined +/// contract type, e.g. `Compose<(Enumerable,)>` or +/// `Compose<(Enumerable, Burnable)>` (both resolve to `Enumerable`). +/// +/// This is shorthand for `::Out`; refer to [`Composable`] for +/// the valid lists. +pub type Compose = ::Out; + +/// Type-level lookup backing [`Compose`]: each valid list resolves to its +/// contract type through the `Out` associated type. +/// +/// A list is folded pairwise, left to right. Single contract types resolve to +/// themselves (with or without the one-element tuple form), and additive +/// extensions are ignored. Mutually exclusive contract types (e.g. +/// `Enumerable` and `Consecutive`) have no pairwise combination, so listing +/// them together does not compile. +#[diagnostic::on_unimplemented( + message = "`{Self}` is not a valid contract type combination", + note = "valid single contract types: `Base`, `Enumerable`, `Consecutive`, `NonFungibleVotes`", + note = "additive extensions (e.g. `Burnable`, `Royalties`) may be listed alongside a contract \ + type; they do not affect the resolved type", + note = "lists of up to 5 entries are supported" +)] +pub trait Composable { + type Out: ContractOverrides; +} + +// The resolution machinery below is internal: contract developers only +// interact with `Compose`, and the traits in this module are unnameable +// outside the crate. +mod fold { + /// Identity element of composition: the contribution of an additive + /// extension. Folding it into any contribution leaves that contribution + /// unchanged, which is how additive extensions are ignored. + pub enum Nil {} + + /// Implemented by every name that may appear in a `Compose` list. The + /// `Contribution` is what the entry adds to the resolved contract type: + /// contract types contribute themselves, additive extensions contribute + /// [`Nil`]. + #[diagnostic::on_unimplemented( + message = "`{Self}` cannot appear in a `Compose` list", + note = "valid entries are the contract types (`Base`, `Enumerable`, `Consecutive`, \ + `NonFungibleVotes`) and the additive extensions (`Burnable`, `Royalties`)" + )] + pub trait Extension { + type Contribution; + } + + /// Pairwise combination table for contributions: the fold reduces a list + /// left to right through this table. A missing row means the pair is + /// invalid (mutually exclusive, or no curated combination exists). + #[diagnostic::on_unimplemented( + message = "`{Self}` cannot be combined with `{B}` in a `Compose` list", + note = "the contract types are mutually exclusive, or no combination of them is curated" + )] + pub trait Combine { + type Out; + } + + /// Final step of the fold: maps the folded contribution to the resolved + /// contract type. Its only non-trivial rule is [`Nil`] resolving to + /// `Base` (a list of only additive extensions is a vanilla token). + pub trait Finalize { + type Out: super::ContractOverrides; + } +} + +use fold::{Combine, Extension, Finalize, Nil}; + +type Contrib = ::Contribution; +type Pair = >::Out; +type Final = ::Out; + +// What each list entry contributes to the resolved contract type. +impl Extension for Base { + type Contribution = Base; +} +impl Extension for Enumerable { + type Contribution = Enumerable; +} +impl Extension for Consecutive { + type Contribution = Consecutive; +} +impl Extension for NonFungibleVotes { + type Contribution = NonFungibleVotes; +} +// Additive extensions contribute nothing. +impl Extension for Burnable { + type Contribution = Nil; +} +impl Extension for Royalties { + type Contribution = Nil; +} + +// Identity rows: combining with `Nil` changes nothing. One pair of rows per +// contract type, plus the `Nil`/`Nil` row for lists of only additive +// extensions. Curated multi-type combinations would be added here as +// additional rows (in both orders, so lists stay order-insensitive). +impl Combine for Nil { + type Out = Nil; +} +impl Combine for Base { + type Out = Base; +} +impl Combine for Nil { + type Out = Base; +} +impl Combine for Enumerable { + type Out = Enumerable; +} +impl Combine for Nil { + type Out = Enumerable; +} +impl Combine for Consecutive { + type Out = Consecutive; +} +impl Combine for Nil { + type Out = Consecutive; +} +impl Combine for NonFungibleVotes { + type Out = NonFungibleVotes; +} +impl Combine for Nil { + type Out = NonFungibleVotes; +} + +impl Finalize for Nil { + type Out = Base; +} +impl Finalize for Base { + type Out = Base; +} +impl Finalize for Enumerable { + type Out = Enumerable; +} +impl Finalize for Consecutive { + type Out = Consecutive; +} +impl Finalize for NonFungibleVotes { + type Out = NonFungibleVotes; +} + +// Bare forms: a single entry may also be written without the one-element +// tuple, so a stray trailing comma (or its absence) does not change the +// meaning. +impl Composable for Base { + type Out = Base; +} +impl Composable for Enumerable { + type Out = Enumerable; +} +impl Composable for Consecutive { + type Out = Consecutive; +} +impl Composable for NonFungibleVotes { + type Out = NonFungibleVotes; +} +impl Composable for Burnable { + type Out = Base; +} +impl Composable for Royalties { + type Out = Base; +} + +// Lists are folded left to right through the `Combine` table; additive +// entries contribute `Nil`, the identity, so they vanish. Longer lists are +// supported by adding one impl per arity. +impl Composable for (A,) +where + A: Extension, + Contrib: Finalize, +{ + type Out = Final>; +} + +impl Composable for (A, B) +where + A: Extension, + B: Extension, + Contrib: Combine>, + Pair, Contrib>: Finalize, +{ + type Out = Final, Contrib>>; +} + +impl Composable for (A, B, C) +where + A: Extension, + B: Extension, + C: Extension, + Contrib: Combine>, + Pair, Contrib>: Combine>, + Pair, Contrib>, Contrib>: Finalize, +{ + type Out = Final, Contrib>, Contrib>>; +} + +impl Composable for (A, B, C, D) +where + A: Extension, + B: Extension, + C: Extension, + D: Extension, + Contrib: Combine>, + Pair, Contrib>: Combine>, + Pair, Contrib>, Contrib>: Combine>, + Pair, Contrib>, Contrib>, Contrib>: Finalize, +{ + type Out = Final, Contrib>, Contrib>, Contrib>>; +} + +impl Composable for (A, B, C, D, E) +where + A: Extension, + B: Extension, + C: Extension, + D: Extension, + E: Extension, + Contrib: Combine>, + Pair, Contrib>: Combine>, + Pair, Contrib>, Contrib>: Combine>, + Pair, Contrib>, Contrib>, Contrib>: Combine>, + Pair, Contrib>, Contrib>, Contrib>, Contrib>: Finalize, +{ + type Out = + Final, Contrib>, Contrib>, Contrib>, Contrib>>; +} diff --git a/packages/tokens/src/non_fungible/extensions/combinations/test.rs b/packages/tokens/src/non_fungible/extensions/combinations/test.rs new file mode 100644 index 000000000..a766edddb --- /dev/null +++ b/packages/tokens/src/non_fungible/extensions/combinations/test.rs @@ -0,0 +1,63 @@ +extern crate std; + +use core::any::TypeId; + +use crate::non_fungible::{ + extensions::{ + burnable::Burnable, + combinations::{Composable, Compose}, + consecutive::Consecutive, + enumerable::Enumerable, + royalties::Royalties, + votes::NonFungibleVotes, + }, + Base, +}; + +// Pins every `Composable` mapping: the resolved `Out` type must be exactly the +// expected contract type. Invalid lists are rejected at compile time and +// cannot be covered here without a compile-fail harness. +fn assert_composes_to() +where + List: Composable, + Compose: 'static, + Expected: 'static, +{ + assert_eq!(TypeId::of::>(), TypeId::of::()); +} + +#[test] +fn bare_forms_resolve_to_themselves() { + assert_composes_to::(); + assert_composes_to::(); + assert_composes_to::(); + assert_composes_to::(); +} + +#[test] +fn tuple_forms_resolve_to_themselves() { + assert_composes_to::<(Base,), Base>(); + assert_composes_to::<(Enumerable,), Enumerable>(); + assert_composes_to::<(Consecutive,), Consecutive>(); + assert_composes_to::<(NonFungibleVotes,), NonFungibleVotes>(); +} + +#[test] +fn additive_extensions_are_ignored() { + // Additive markers may appear in any position without changing the + // resolution. + assert_composes_to::<(Enumerable, Burnable), Enumerable>(); + assert_composes_to::<(Burnable, Consecutive), Consecutive>(); + assert_composes_to::<(Base, Royalties), Base>(); + // Higher arities, covering every tuple impl. + assert_composes_to::<(Enumerable, Burnable, Royalties), Enumerable>(); + assert_composes_to::<(Royalties, NonFungibleVotes, Burnable, Royalties), NonFungibleVotes>(); + assert_composes_to::<(Burnable, Royalties, Consecutive, Burnable, Royalties), Consecutive>(); +} + +#[test] +fn additive_only_lists_resolve_to_base() { + assert_composes_to::(); + assert_composes_to::(); + assert_composes_to::<(Burnable, Royalties), Base>(); +} diff --git a/packages/tokens/src/non_fungible/extensions/mod.rs b/packages/tokens/src/non_fungible/extensions/mod.rs index c8bbbf7f6..76f926fee 100644 --- a/packages/tokens/src/non_fungible/extensions/mod.rs +++ b/packages/tokens/src/non_fungible/extensions/mod.rs @@ -1,4 +1,5 @@ pub mod burnable; +pub mod combinations; pub mod consecutive; pub mod enumerable; pub mod royalties; diff --git a/packages/tokens/src/non_fungible/extensions/royalties/mod.rs b/packages/tokens/src/non_fungible/extensions/royalties/mod.rs index 4a29e514b..c3ac6d3f3 100644 --- a/packages/tokens/src/non_fungible/extensions/royalties/mod.rs +++ b/packages/tokens/src/non_fungible/extensions/royalties/mod.rs @@ -6,6 +6,15 @@ mod test; use soroban_sdk::{contractevent, contracttrait, Address, Env}; +/// Type-level name of the royalties extension, for use in a +/// [`crate::non_fungible::combinations::Compose`] list. +/// +/// Royalties is additive: it does not override the base behavior, so listing +/// it is purely declarative and does not affect the resolved contract type. +/// Royalty support is enabled by implementing [`NonFungibleRoyalties`], +/// whether or not `Royalties` is listed. +pub enum Royalties {} + /// Royalties Trait for Non-Fungible Token (ERC2981) /// /// The `NonFungibleRoyalties` trait extends the `NonFungibleToken` trait to diff --git a/packages/tokens/src/non_fungible/mod.rs b/packages/tokens/src/non_fungible/mod.rs index f6d625ce6..2c7a3a027 100644 --- a/packages/tokens/src/non_fungible/mod.rs +++ b/packages/tokens/src/non_fungible/mod.rs @@ -71,7 +71,9 @@ mod utils; #[cfg(test)] mod test; -pub use extensions::{burnable, consecutive, enumerable, royalties, votes}; +pub use extensions::{ + burnable, combinations, combinations::Compose, consecutive, enumerable, royalties, votes, +}; pub use overrides::{Base, ContractOverrides}; // ################## TRAIT ################## use soroban_sdk::{contracterror, contractevent, contracttrait, Address, Env, String}; @@ -94,25 +96,28 @@ pub use utils::sequential; /// /// This trait is implemented for the following Contract Types: /// * [`crate::non_fungible::Base`] (covering the vanilla case, and compatible -/// with [`crate::non_fungible::extensions::burnable::NonFungibleBurnable`]) -/// trait +/// with the [`crate::non_fungible::extensions::burnable::NonFungibleBurnable`] +/// trait) /// * [`crate::non_fungible::extensions::enumerable::Enumerable`] (enabling the -/// compatibility and overrides for -/// [`crate::non_fungible::extensions::enumerable::NonFungibleEnumerable`]) -/// trait, incompatible with -/// [`crate::non_fungible::extensions::burnable::NonFungibleBurnable`]) and -/// [`crate::non_fungible::extensions::consecutive::NonFungibleConsecutive`] -/// trait. -/// * [`crate::non_fungible::extensions::consecutive::Consecutive`] (enabling -/// the compatibility and overrides for -/// [`crate::non_fungible::extensions::consecutive::NonFungibleConsecutive`]) -/// trait, incompatible with -/// [`crate::non_fungible::extensions::burnable::NonFungibleBurnable`]) and +/// compatibility and overrides for the /// [`crate::non_fungible::extensions::enumerable::NonFungibleEnumerable`] -/// trait. +/// trait), incompatible with +/// [`crate::non_fungible::extensions::consecutive::Consecutive`]. +/// * [`crate::non_fungible::extensions::consecutive::Consecutive`] (enabling +/// the compatibility and overrides for the +/// [`crate::non_fungible::extensions::consecutive::NonFungibleConsecutive`] +/// trait), incompatible with +/// [`crate::non_fungible::extensions::enumerable::Enumerable`]. +/// * [`crate::non_fungible::extensions::votes::NonFungibleVotes`] (enabling the +/// compatibility and overrides for the [`stellar_governance::votes::Votes`] +/// trait). +/// +/// The contract type is selected with +/// [`crate::non_fungible::combinations::Compose`]; invalid combinations are +/// rejected at compile time. /// -/// The default implementations of this trait for `Base`, `Enumerable`, and -/// `Consecutive` can be found by navigating to `ContractType::{method_name}`. +/// The default implementations of this trait for each contract type can be +/// found by navigating to `ContractType::{method_name}`. /// For example, the implementation of [`NonFungibleToken::transfer`] for the /// `Enumerable` contract type can be found at /// [`crate::non_fungible::extensions::enumerable::Enumerable::transfer`]. @@ -120,11 +125,11 @@ pub use utils::sequential; pub trait NonFungibleToken { /// Helper type that allows some of the functionality of the base trait to /// be overridden based on the extensions implemented. - /// [`crate::non_fungible::Base`] should be used as the type when - /// not using - /// [`crate::non_fungible::extensions::enumerable::Enumerable`] or - /// [`crate::non_fungible::extensions::consecutive::Consecutive`] - /// extensions. + /// The contract type is selected with + /// [`crate::non_fungible::combinations::Compose`], by listing the + /// extensions the token is made of: `Compose<(Base,)>` for the vanilla + /// case, `Compose<(Enumerable,)>` for an enumerable token, and so on. + /// Invalid lists are rejected at compile time. type ContractType: ContractOverrides; /// Returns the number of tokens owned by `account`. diff --git a/packages/tokens/src/non_fungible/overrides.rs b/packages/tokens/src/non_fungible/overrides.rs index ab368f2df..dd4c84b3a 100644 --- a/packages/tokens/src/non_fungible/overrides.rs +++ b/packages/tokens/src/non_fungible/overrides.rs @@ -26,7 +26,7 @@ use soroban_sdk::{Address, Env, String}; /// ```rust /// #[contractimpl(contracttrait)] /// impl NonFungibleToken for ExampleContract { -/// type ContractType = Consecutive; +/// type ContractType = Compose<(Consecutive,)>; /// } /// ``` pub trait ContractOverrides { From b235a7829e0eb41099a151ae32f876aae7ee82ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96zg=C3=BCn=20=C3=96zerk?= Date: Thu, 10 Sep 2026 17:40:46 +0300 Subject: [PATCH 14/17] make votes work with enumerable and consecutive (#878) * make votes work with enumerable and consecutive --- .../src/fungible/extensions/burnable/mod.rs | 10 +- .../fungible/extensions/burnable/storage.rs | 10 + .../non_fungible/extensions/burnable/mod.rs | 10 +- .../extensions/burnable/storage.rs | 10 + .../extensions/combinations/mod.rs | 46 ++++- .../extensions/combinations/storage.rs | 173 +++++++++++++++++ .../extensions/combinations/test.rs | 181 ++++++++++++++++++ .../extensions/consecutive/mod.rs | 4 +- .../extensions/consecutive/storage.rs | 10 + .../non_fungible/extensions/enumerable/mod.rs | 4 +- .../extensions/enumerable/storage.rs | 10 + .../non_fungible/extensions/royalties/mod.rs | 10 +- .../extensions/royalties/storage.rs | 10 + 13 files changed, 450 insertions(+), 38 deletions(-) create mode 100644 packages/tokens/src/non_fungible/extensions/combinations/storage.rs diff --git a/packages/tokens/src/fungible/extensions/burnable/mod.rs b/packages/tokens/src/fungible/extensions/burnable/mod.rs index 598b72c6f..8ea03a05f 100644 --- a/packages/tokens/src/fungible/extensions/burnable/mod.rs +++ b/packages/tokens/src/fungible/extensions/burnable/mod.rs @@ -4,18 +4,10 @@ mod storage; mod test; use soroban_sdk::{contractevent, contracttrait, Address, Env}; +pub use storage::Burnable; use crate::fungible::{overrides::BurnableOverrides, FungibleToken}; -/// Type-level name of the burnable extension, for use in a -/// [`crate::fungible::combinations::Compose`] list. -/// -/// Burnable is additive: it does not override the base behavior, so listing -/// it is purely declarative and does not affect the resolved contract type. -/// Burning is enabled by implementing [`FungibleBurnable`], whether or not -/// `Burnable` is listed. -pub enum Burnable {} - /// Burnable Trait for Fungible Token /// /// The `FungibleBurnable` trait extends the `FungibleToken` trait to provide diff --git a/packages/tokens/src/fungible/extensions/burnable/storage.rs b/packages/tokens/src/fungible/extensions/burnable/storage.rs index 88cf85731..ec1c846b1 100644 --- a/packages/tokens/src/fungible/extensions/burnable/storage.rs +++ b/packages/tokens/src/fungible/extensions/burnable/storage.rs @@ -2,6 +2,16 @@ use soroban_sdk::{Address, Env}; use crate::fungible::{extensions::burnable::emit_burn, Base}; +/// Type-level name of the burnable extension, for use in a +/// [`crate::fungible::combinations::Compose`] list. +/// +/// Burnable is additive: it does not override the base behavior, so listing +/// it is purely declarative and does not affect the resolved contract type. +/// Burning is enabled by implementing +/// [`crate::fungible::burnable::FungibleBurnable`], whether or not `Burnable` +/// is listed. +pub enum Burnable {} + impl Base { /// Destroys `amount` of tokens from `from`. Updates the total /// supply accordingly. diff --git a/packages/tokens/src/non_fungible/extensions/burnable/mod.rs b/packages/tokens/src/non_fungible/extensions/burnable/mod.rs index ec40af379..e106199c3 100644 --- a/packages/tokens/src/non_fungible/extensions/burnable/mod.rs +++ b/packages/tokens/src/non_fungible/extensions/burnable/mod.rs @@ -5,15 +5,7 @@ use crate::non_fungible::{overrides::BurnableOverrides, NonFungibleToken}; mod test; use soroban_sdk::{contractevent, contracttrait, Address, Env}; - -/// Type-level name of the burnable extension, for use in a -/// [`crate::non_fungible::combinations::Compose`] list. -/// -/// Burnable is additive: it does not override the base behavior, so listing -/// it is purely declarative and does not affect the resolved contract type. -/// Burning is enabled by implementing [`NonFungibleBurnable`], whether or not -/// `Burnable` is listed. -pub enum Burnable {} +pub use storage::Burnable; /// Burnable Trait for Non-Fungible Token /// diff --git a/packages/tokens/src/non_fungible/extensions/burnable/storage.rs b/packages/tokens/src/non_fungible/extensions/burnable/storage.rs index 4d2a82dae..107d2510c 100644 --- a/packages/tokens/src/non_fungible/extensions/burnable/storage.rs +++ b/packages/tokens/src/non_fungible/extensions/burnable/storage.rs @@ -2,6 +2,16 @@ use soroban_sdk::{Address, Env}; use crate::non_fungible::{burnable::emit_burn, Base}; +/// Type-level name of the burnable extension, for use in a +/// [`crate::non_fungible::combinations::Compose`] list. +/// +/// Burnable is additive: it does not override the base behavior, so listing +/// it is purely declarative and does not affect the resolved contract type. +/// Burning is enabled by implementing +/// [`crate::non_fungible::burnable::NonFungibleBurnable`], whether or not +/// `Burnable` is listed. +pub enum Burnable {} + impl Base { /// Destroys the token with `token_id` from `from`, ensuring ownership /// checks, and emits a `burn` event. diff --git a/packages/tokens/src/non_fungible/extensions/combinations/mod.rs b/packages/tokens/src/non_fungible/extensions/combinations/mod.rs index 7b247aa38..76955adb5 100644 --- a/packages/tokens/src/non_fungible/extensions/combinations/mod.rs +++ b/packages/tokens/src/non_fungible/extensions/combinations/mod.rs @@ -29,17 +29,21 @@ //! impl NonFungibleBurnable for MyToken {} //! ``` //! -//! No multi-type combinations of contract types are curated for non-fungible -//! tokens yet; every valid list currently holds at most one contract type -//! (plus any additive extensions). Invalid lists do not compile: `Enumerable` -//! and `Consecutive` are mutually exclusive, and implementing an extension -//! trait the list does not back (e.g. +//! Curated multi-type combinations: [`NonFungibleVotes`] can be combined with +//! either [`Enumerable`] or [`Consecutive`], e.g. +//! `Compose<(Enumerable, NonFungibleVotes)>`. Invalid lists do not compile: +//! `Enumerable` and `Consecutive` are mutually exclusive, and implementing an +//! extension trait the list does not back (e.g. //! [`crate::non_fungible::enumerable::NonFungibleEnumerable`] without //! `Enumerable` in the list) is rejected by that trait's bound. +mod storage; + #[cfg(test)] mod test; +use storage::{ConsecutiveVotes, EnumerableVotes}; + use crate::non_fungible::{ extensions::{ burnable::Burnable, consecutive::Consecutive, enumerable::Enumerable, royalties::Royalties, @@ -67,6 +71,8 @@ pub type Compose = ::Out; #[diagnostic::on_unimplemented( message = "`{Self}` is not a valid contract type combination", note = "valid single contract types: `Base`, `Enumerable`, `Consecutive`, `NonFungibleVotes`", + note = "curated combinations: `(Enumerable, NonFungibleVotes)`, `(Consecutive, \ + NonFungibleVotes)`", note = "additive extensions (e.g. `Burnable`, `Royalties`) may be listed alongside a contract \ type; they do not affect the resolved type", note = "lists of up to 5 entries are supported" @@ -145,8 +151,8 @@ impl Extension for Royalties { // Identity rows: combining with `Nil` changes nothing. One pair of rows per // contract type, plus the `Nil`/`Nil` row for lists of only additive -// extensions. Curated multi-type combinations would be added here as -// additional rows (in both orders, so lists stay order-insensitive). +// extensions. Curated multi-type combinations are additional rows, declared +// in both orders so lists stay order-insensitive. impl Combine for Nil { type Out = Nil; } @@ -174,6 +180,26 @@ impl Combine for NonFungibleVotes { impl Combine for Nil { type Out = NonFungibleVotes; } +impl Combine for EnumerableVotes { + type Out = EnumerableVotes; +} +impl Combine for ConsecutiveVotes { + type Out = ConsecutiveVotes; +} + +// Curated pairs. +impl Combine for Enumerable { + type Out = EnumerableVotes; +} +impl Combine for NonFungibleVotes { + type Out = EnumerableVotes; +} +impl Combine for Consecutive { + type Out = ConsecutiveVotes; +} +impl Combine for NonFungibleVotes { + type Out = ConsecutiveVotes; +} impl Finalize for Nil { type Out = Base; @@ -190,6 +216,12 @@ impl Finalize for Consecutive { impl Finalize for NonFungibleVotes { type Out = NonFungibleVotes; } +impl Finalize for EnumerableVotes { + type Out = EnumerableVotes; +} +impl Finalize for ConsecutiveVotes { + type Out = ConsecutiveVotes; +} // Bare forms: a single entry may also be written without the one-element // tuple, so a stray trailing comma (or its absence) does not change the diff --git a/packages/tokens/src/non_fungible/extensions/combinations/storage.rs b/packages/tokens/src/non_fungible/extensions/combinations/storage.rs new file mode 100644 index 000000000..2b0ba8e12 --- /dev/null +++ b/packages/tokens/src/non_fungible/extensions/combinations/storage.rs @@ -0,0 +1,173 @@ +use soroban_sdk::{Address, Env, String}; +use stellar_governance::votes::transfer_voting_units; + +use crate::non_fungible::{ + extensions::{ + consecutive::{Consecutive, ConsecutiveContractType}, + enumerable::{Enumerable, EnumerableContractType}, + }, + overrides::{BurnableOverrides, ContractOverrides}, +}; + +/// Contract type combining the [`Enumerable`] bookkeeping with vote +/// checkpoints: every mint, transfer and burn updates the enumeration and +/// moves the corresponding voting unit. +pub struct EnumerableVotes; + +// The combined contract type keeps backing the enumerable extension. +impl EnumerableContractType for EnumerableVotes {} + +impl ContractOverrides for EnumerableVotes { + fn transfer(e: &Env, from: &Address, to: &Address, token_id: u32) { + EnumerableVotes::transfer(e, from, to, token_id); + } + + fn transfer_from(e: &Env, spender: &Address, from: &Address, to: &Address, token_id: u32) { + EnumerableVotes::transfer_from(e, spender, from, to, token_id); + } +} + +impl BurnableOverrides for EnumerableVotes { + fn burn(e: &Env, from: &Address, token_id: u32) { + EnumerableVotes::burn(e, from, token_id); + } + + fn burn_from(e: &Env, spender: &Address, from: &Address, token_id: u32) { + EnumerableVotes::burn_from(e, spender, from, token_id); + } +} + +impl EnumerableVotes { + /// refer to [`Enumerable::sequential_mint`] and [`transfer_voting_units`] + /// for the inline documentation. + pub fn sequential_mint(e: &Env, to: &Address) -> u32 { + let token_id = Enumerable::sequential_mint(e, to); + transfer_voting_units(e, None, Some(to), 1); + token_id + } + + /// refer to [`Enumerable::non_sequential_mint`] and + /// [`transfer_voting_units`] for the inline documentation. + pub fn non_sequential_mint(e: &Env, to: &Address, token_id: u32) { + Enumerable::non_sequential_mint(e, to, token_id); + transfer_voting_units(e, None, Some(to), 1); + } + + /// refer to [`Enumerable::transfer`] and [`transfer_voting_units`] for + /// the inline documentation. + pub fn transfer(e: &Env, from: &Address, to: &Address, token_id: u32) { + Enumerable::transfer(e, from, to, token_id); + transfer_voting_units(e, Some(from), Some(to), 1); + } + + /// refer to [`Enumerable::transfer_from`] and [`transfer_voting_units`] + /// for the inline documentation. + pub fn transfer_from(e: &Env, spender: &Address, from: &Address, to: &Address, token_id: u32) { + Enumerable::transfer_from(e, spender, from, to, token_id); + transfer_voting_units(e, Some(from), Some(to), 1); + } + + /// refer to [`Enumerable::burn`] and [`transfer_voting_units`] for the + /// inline documentation. + pub fn burn(e: &Env, from: &Address, token_id: u32) { + Enumerable::burn(e, from, token_id); + transfer_voting_units(e, Some(from), None, 1); + } + + /// refer to [`Enumerable::burn_from`] and [`transfer_voting_units`] for + /// the inline documentation. + pub fn burn_from(e: &Env, spender: &Address, from: &Address, token_id: u32) { + Enumerable::burn_from(e, spender, from, token_id); + transfer_voting_units(e, Some(from), None, 1); + } +} + +/// Contract type combining the [`Consecutive`] accounting model with vote +/// checkpoints: batch minting adds the matching amount of voting units in a +/// single checkpoint update, and every transfer and burn moves the +/// corresponding voting unit. +pub struct ConsecutiveVotes; + +// The combined contract type keeps backing the consecutive extension. +impl ConsecutiveContractType for ConsecutiveVotes {} + +impl ContractOverrides for ConsecutiveVotes { + // Ownership resolution and metadata follow the consecutive bucket model + // unchanged. + fn owner_of(e: &Env, token_id: u32) -> Address { + Consecutive::owner_of(e, token_id) + } + + fn token_uri(e: &Env, token_id: u32) -> String { + Consecutive::token_uri(e, token_id) + } + + fn approve( + e: &Env, + approver: &Address, + approved: &Address, + token_id: u32, + live_until_ledger: u32, + ) { + Consecutive::approve(e, approver, approved, token_id, live_until_ledger); + } + + fn transfer(e: &Env, from: &Address, to: &Address, token_id: u32) { + ConsecutiveVotes::transfer(e, from, to, token_id); + } + + fn transfer_from(e: &Env, spender: &Address, from: &Address, to: &Address, token_id: u32) { + ConsecutiveVotes::transfer_from(e, spender, from, to, token_id); + } +} + +impl BurnableOverrides for ConsecutiveVotes { + fn burn(e: &Env, from: &Address, token_id: u32) { + ConsecutiveVotes::burn(e, from, token_id); + } + + fn burn_from(e: &Env, spender: &Address, from: &Address, token_id: u32) { + ConsecutiveVotes::burn_from(e, spender, from, token_id); + } +} + +impl ConsecutiveVotes { + /// Creates a batch of `amount` tokens for `to` and adds the matching + /// amount of voting units in a single checkpoint update. + /// + /// refer to [`Consecutive::batch_mint`] and [`transfer_voting_units`] for + /// the inline documentation. + pub fn batch_mint(e: &Env, to: &Address, amount: u32) -> u32 { + let last_id = Consecutive::batch_mint(e, to, amount); + transfer_voting_units(e, None, Some(to), u128::from(amount)); + last_id + } + + /// refer to [`Consecutive::transfer`] and [`transfer_voting_units`] for + /// the inline documentation. + pub fn transfer(e: &Env, from: &Address, to: &Address, token_id: u32) { + Consecutive::transfer(e, from, to, token_id); + transfer_voting_units(e, Some(from), Some(to), 1); + } + + /// refer to [`Consecutive::transfer_from`] and [`transfer_voting_units`] + /// for the inline documentation. + pub fn transfer_from(e: &Env, spender: &Address, from: &Address, to: &Address, token_id: u32) { + Consecutive::transfer_from(e, spender, from, to, token_id); + transfer_voting_units(e, Some(from), Some(to), 1); + } + + /// refer to [`Consecutive::burn`] and [`transfer_voting_units`] for the + /// inline documentation. + pub fn burn(e: &Env, from: &Address, token_id: u32) { + Consecutive::burn(e, from, token_id); + transfer_voting_units(e, Some(from), None, 1); + } + + /// refer to [`Consecutive::burn_from`] and [`transfer_voting_units`] for + /// the inline documentation. + pub fn burn_from(e: &Env, spender: &Address, from: &Address, token_id: u32) { + Consecutive::burn_from(e, spender, from, token_id); + transfer_voting_units(e, Some(from), None, 1); + } +} diff --git a/packages/tokens/src/non_fungible/extensions/combinations/test.rs b/packages/tokens/src/non_fungible/extensions/combinations/test.rs index a766edddb..d14ffc1a2 100644 --- a/packages/tokens/src/non_fungible/extensions/combinations/test.rs +++ b/packages/tokens/src/non_fungible/extensions/combinations/test.rs @@ -2,6 +2,9 @@ extern crate std; use core::any::TypeId; +use soroban_sdk::{contract, testutils::Address as _, Address, Env, String}; +use stellar_governance::votes::get_voting_units; + use crate::non_fungible::{ extensions::{ burnable::Burnable, @@ -11,9 +14,15 @@ use crate::non_fungible::{ royalties::Royalties, votes::NonFungibleVotes, }, + overrides::{BurnableOverrides, ContractOverrides}, Base, }; +type EnumerableVotes = Compose<(Enumerable, NonFungibleVotes)>; +// deliberately the swapped order, asserting that the list is +// order-insensitive +type ConsecutiveVotes = Compose<(NonFungibleVotes, Consecutive)>; + // Pins every `Composable` mapping: the resolved `Out` type must be exactly the // expected contract type. Invalid lists are rejected at compile time and // cannot be covered here without a compile-fail harness. @@ -61,3 +70,175 @@ fn additive_only_lists_resolve_to_base() { assert_composes_to::(); assert_composes_to::<(Burnable, Royalties), Base>(); } + +#[test] +fn votes_combinations_are_order_insensitive() { + assert_eq!( + TypeId::of::>(), + TypeId::of::>(), + ); + assert_eq!( + TypeId::of::>(), + TypeId::of::>(), + ); + // the combined types are distinct from their members + assert_ne!(TypeId::of::(), TypeId::of::()); + assert_ne!(TypeId::of::(), TypeId::of::()); + assert_ne!(TypeId::of::(), TypeId::of::()); + // additive markers stay ignored around a curated pair + assert_eq!( + TypeId::of::>(), + TypeId::of::(), + ); +} + +#[contract] +struct MockContract; + +fn setup_env() -> (Env, Address) { + let e = Env::default(); + e.mock_all_auths(); + let contract_address = e.register(MockContract, ()); + + e.as_contract(&contract_address, || { + Base::set_metadata( + &e, + String::from_str(&e, "https://example.com/"), + String::from_str(&e, "Test NFT"), + String::from_str(&e, "TNFT"), + ); + }); + + (e, contract_address) +} + +#[test] +fn enumerable_votes_tracks_enumeration_and_voting_units() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + let bob = Address::generate(&e); + + e.as_contract(&contract_address, || { + let token_id = EnumerableVotes::sequential_mint(&e, &alice); + assert_eq!(Enumerable::total_supply(&e), 1); + assert_eq!(Enumerable::get_owner_token_id(&e, &alice, 0), token_id); + assert_eq!(get_voting_units(&e, &alice), 1); + + ::transfer(&e, &alice, &bob, token_id); + assert_eq!(get_voting_units(&e, &alice), 0); + assert_eq!(get_voting_units(&e, &bob), 1); + assert_eq!(Enumerable::get_owner_token_id(&e, &bob, 0), token_id); + + ::burn(&e, &bob, token_id); + assert_eq!(get_voting_units(&e, &bob), 0); + assert_eq!(Enumerable::total_supply(&e), 0); + }); +} + +#[test] +fn consecutive_votes_tracks_batches_and_voting_units() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + let bob = Address::generate(&e); + + e.as_contract(&contract_address, || { + let last_id = ConsecutiveVotes::batch_mint(&e, &alice, 5); + // one checkpoint update covers the whole batch + assert_eq!(get_voting_units(&e, &alice), 5); + // ownership resolves for non-boundary tokens of the batch + assert_eq!(Consecutive::owner_of(&e, last_id - 2), alice); + + ::transfer(&e, &alice, &bob, last_id); + assert_eq!(get_voting_units(&e, &alice), 4); + assert_eq!(get_voting_units(&e, &bob), 1); + assert_eq!(Consecutive::owner_of(&e, last_id), bob); + // transferring the batch boundary materializes the previous token's + // owner, keeping the rest of the batch resolvable + assert_eq!(Consecutive::owner_of(&e, last_id - 1), alice); + + ::burn(&e, &bob, last_id); + assert_eq!(get_voting_units(&e, &bob), 0); + }); +} + +#[test] +fn enumerable_votes_non_sequential_mint_and_transfer_from() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + let bob = Address::generate(&e); + let spender = Address::generate(&e); + + e.as_contract(&contract_address, || { + EnumerableVotes::non_sequential_mint(&e, &alice, 42); + assert_eq!(Enumerable::get_owner_token_id(&e, &alice, 0), 42); + assert_eq!(get_voting_units(&e, &alice), 1); + + Base::approve(&e, &alice, &spender, 42, e.ledger().sequence() + 100); + ::transfer_from(&e, &spender, &alice, &bob, 42); + assert_eq!(get_voting_units(&e, &alice), 0); + assert_eq!(get_voting_units(&e, &bob), 1); + assert_eq!(Enumerable::get_owner_token_id(&e, &bob, 0), 42); + }); +} + +#[test] +fn enumerable_votes_burn_from() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + let spender = Address::generate(&e); + + e.as_contract(&contract_address, || { + let token_id = EnumerableVotes::sequential_mint(&e, &alice); + + Base::approve(&e, &alice, &spender, token_id, e.ledger().sequence() + 100); + ::burn_from(&e, &spender, &alice, token_id); + assert_eq!(get_voting_units(&e, &alice), 0); + assert_eq!(Enumerable::total_supply(&e), 0); + }); +} + +#[test] +fn consecutive_votes_queries_and_transfer_from() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + let bob = Address::generate(&e); + let spender = Address::generate(&e); + + e.as_contract(&contract_address, || { + let last_id = ConsecutiveVotes::batch_mint(&e, &alice, 3); + + // queries route through the consecutive bucket model unchanged + assert_eq!(::owner_of(&e, last_id - 1), alice); + assert_eq!( + ::token_uri(&e, last_id), + Consecutive::token_uri(&e, last_id) + ); + + ::approve( + &e, + &alice, + &spender, + last_id, + e.ledger().sequence() + 100, + ); + ::transfer_from(&e, &spender, &alice, &bob, last_id); + assert_eq!(get_voting_units(&e, &alice), 2); + assert_eq!(get_voting_units(&e, &bob), 1); + assert_eq!(Consecutive::owner_of(&e, last_id), bob); + }); +} + +#[test] +fn consecutive_votes_burn_from() { + let (e, contract_address) = setup_env(); + let alice = Address::generate(&e); + let spender = Address::generate(&e); + + e.as_contract(&contract_address, || { + let last_id = ConsecutiveVotes::batch_mint(&e, &alice, 2); + + Base::approve(&e, &alice, &spender, last_id, e.ledger().sequence() + 100); + ::burn_from(&e, &spender, &alice, last_id); + assert_eq!(get_voting_units(&e, &alice), 1); + }); +} diff --git a/packages/tokens/src/non_fungible/extensions/consecutive/mod.rs b/packages/tokens/src/non_fungible/extensions/consecutive/mod.rs index 89ef5d190..a2858fec5 100644 --- a/packages/tokens/src/non_fungible/extensions/consecutive/mod.rs +++ b/packages/tokens/src/non_fungible/extensions/consecutive/mod.rs @@ -63,7 +63,7 @@ //! the logic of tracking ownership. pub mod storage; use soroban_sdk::{contractevent, Address, Env}; -pub use storage::Consecutive; +pub use storage::{Consecutive, ConsecutiveContractType}; use crate::non_fungible::NonFungibleToken; @@ -74,7 +74,7 @@ use crate::non_fungible::NonFungibleToken; /// The `consecutive` extension provides its own business logic for creating and /// destroying tokens. Therefore, this trait is INCOMPATIBLE with the /// `Enumerable` extension. -pub trait NonFungibleConsecutive: NonFungibleToken {} +pub trait NonFungibleConsecutive: NonFungibleToken {} #[cfg(test)] mod test; diff --git a/packages/tokens/src/non_fungible/extensions/consecutive/storage.rs b/packages/tokens/src/non_fungible/extensions/consecutive/storage.rs index 67b5eccdc..e3714dea4 100644 --- a/packages/tokens/src/non_fungible/extensions/consecutive/storage.rs +++ b/packages/tokens/src/non_fungible/extensions/consecutive/storage.rs @@ -15,6 +15,16 @@ use crate::non_fungible::{ pub struct Consecutive; +/// Marker for the contract types backing the +/// [`crate::non_fungible::consecutive::NonFungibleConsecutive`] trait: +/// [`Consecutive`] itself and the curated combinations that include it. +/// Contract authors never interact with this trait; it only appears as the +/// bound enforcing that the selected `ContractType` follows the consecutive +/// accounting model. +pub trait ConsecutiveContractType {} + +impl ConsecutiveContractType for Consecutive {} + impl ContractOverrides for Consecutive { fn owner_of(e: &Env, token_id: u32) -> Address { Consecutive::owner_of(e, token_id) diff --git a/packages/tokens/src/non_fungible/extensions/enumerable/mod.rs b/packages/tokens/src/non_fungible/extensions/enumerable/mod.rs index 89d92cd94..8c078951d 100644 --- a/packages/tokens/src/non_fungible/extensions/enumerable/mod.rs +++ b/packages/tokens/src/non_fungible/extensions/enumerable/mod.rs @@ -4,7 +4,7 @@ pub mod storage; mod test; use soroban_sdk::{contracttrait, Address, Env}; -pub use storage::Enumerable; +pub use storage::{Enumerable, EnumerableContractType}; use crate::non_fungible::NonFungibleToken; @@ -41,7 +41,7 @@ use crate::non_fungible::NonFungibleToken; /// exists for the use-cases where the enumeration is required as an on-chain /// operation. #[contracttrait] -pub trait NonFungibleEnumerable: NonFungibleToken { +pub trait NonFungibleEnumerable: NonFungibleToken { /// Returns the total amount of tokens stored by the contract. /// /// # Arguments diff --git a/packages/tokens/src/non_fungible/extensions/enumerable/storage.rs b/packages/tokens/src/non_fungible/extensions/enumerable/storage.rs index 0b4a8abc6..65877283d 100644 --- a/packages/tokens/src/non_fungible/extensions/enumerable/storage.rs +++ b/packages/tokens/src/non_fungible/extensions/enumerable/storage.rs @@ -7,6 +7,16 @@ use crate::non_fungible::{ pub struct Enumerable; +/// Marker for the contract types backing the +/// [`crate::non_fungible::enumerable::NonFungibleEnumerable`] trait: +/// [`Enumerable`] itself and the curated combinations that include it. +/// Contract authors never interact with this trait; it only appears as the +/// bound enforcing that the selected `ContractType` keeps the enumeration +/// bookkeeping. +pub trait EnumerableContractType {} + +impl EnumerableContractType for Enumerable {} + impl ContractOverrides for Enumerable { fn transfer(e: &Env, from: &Address, to: &Address, token_id: u32) { Enumerable::transfer(e, from, to, token_id); diff --git a/packages/tokens/src/non_fungible/extensions/royalties/mod.rs b/packages/tokens/src/non_fungible/extensions/royalties/mod.rs index c3ac6d3f3..b2b34280b 100644 --- a/packages/tokens/src/non_fungible/extensions/royalties/mod.rs +++ b/packages/tokens/src/non_fungible/extensions/royalties/mod.rs @@ -5,15 +5,7 @@ use crate::non_fungible::{Base, NonFungibleToken}; mod test; use soroban_sdk::{contractevent, contracttrait, Address, Env}; - -/// Type-level name of the royalties extension, for use in a -/// [`crate::non_fungible::combinations::Compose`] list. -/// -/// Royalties is additive: it does not override the base behavior, so listing -/// it is purely declarative and does not affect the resolved contract type. -/// Royalty support is enabled by implementing [`NonFungibleRoyalties`], -/// whether or not `Royalties` is listed. -pub enum Royalties {} +pub use storage::Royalties; /// Royalties Trait for Non-Fungible Token (ERC2981) /// diff --git a/packages/tokens/src/non_fungible/extensions/royalties/storage.rs b/packages/tokens/src/non_fungible/extensions/royalties/storage.rs index d7e149283..1981074ba 100644 --- a/packages/tokens/src/non_fungible/extensions/royalties/storage.rs +++ b/packages/tokens/src/non_fungible/extensions/royalties/storage.rs @@ -5,6 +5,16 @@ use crate::non_fungible::{ Base, NonFungibleTokenError, OWNER_EXTEND_AMOUNT, OWNER_TTL_THRESHOLD, }; +/// Type-level name of the royalties extension, for use in a +/// [`crate::non_fungible::combinations::Compose`] list. +/// +/// Royalties is additive: it does not override the base behavior, so listing +/// it is purely declarative and does not affect the resolved contract type. +/// Royalty support is enabled by implementing +/// [`crate::non_fungible::royalties::NonFungibleRoyalties`], whether or not +/// `Royalties` is listed. +pub enum Royalties {} + /// Storage container for royalty information #[contracttype] pub struct RoyaltyInfo { From b20d327d853afc046b28529ba6426ab455dd0731 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96zg=C3=BCn=20=C3=96zerk?= Date: Thu, 10 Sep 2026 17:52:36 +0300 Subject: [PATCH 15/17] consecutive and royalty support (#879) * consecutive and royalty support --- examples/nft-royalties/src/contract.rs | 19 +- .../extensions/combinations/storage.rs | 11 + .../extensions/combinations/test.rs | 16 +- .../extensions/consecutive/storage.rs | 6 + .../extensions/enumerable/storage.rs | 9 +- .../non_fungible/extensions/royalties/mod.rs | 30 ++- .../extensions/royalties/storage.rs | 232 +++++++++--------- .../non_fungible/extensions/royalties/test.rs | 137 ++++++++++- .../non_fungible/extensions/votes/storage.rs | 9 +- packages/tokens/src/non_fungible/overrides.rs | 110 +++++++++ 10 files changed, 446 insertions(+), 133 deletions(-) diff --git a/examples/nft-royalties/src/contract.rs b/examples/nft-royalties/src/contract.rs index 6ec359b04..7e34a1f17 100644 --- a/examples/nft-royalties/src/contract.rs +++ b/examples/nft-royalties/src/contract.rs @@ -8,7 +8,8 @@ use soroban_sdk::{contract, contractimpl, symbol_short, Address, Env, String, Sy use stellar_access::access_control::{self as access_control, AccessControl}; use stellar_macros::{only_admin, only_role}; use stellar_tokens::non_fungible::{ - royalties::NonFungibleRoyalties, Base, Compose, NonFungibleToken, + royalties::{NonFungibleRoyalties, Royalties, RoyaltySupport}, + Base, Compose, NonFungibleToken, }; #[contract] @@ -46,8 +47,14 @@ impl ExampleContract { // Mint token with sequential ID let token_id = Base::sequential_mint(e, &to); - // Set token-specific royalty - Base::set_token_royalty(e, token_id, &receiver, basis_points); + // Set token-specific royalty, routed through the contract type so + // the token existence check matches its ownership model. + ::ContractType::set_token_royalty( + e, + token_id, + &receiver, + basis_points, + ); token_id } @@ -55,7 +62,7 @@ impl ExampleContract { #[contractimpl(contracttrait)] impl NonFungibleToken for ExampleContract { - type ContractType = Compose<(Base,)>; + type ContractType = Compose<(Base, Royalties)>; } #[contractimpl(contracttrait)] @@ -73,12 +80,12 @@ impl NonFungibleRoyalties for ExampleContract { basis_points: u32, operator: Address, ) { - Base::set_token_royalty(e, token_id, &receiver, basis_points); + Self::ContractType::set_token_royalty(e, token_id, &receiver, basis_points); } #[only_role(operator, "manager")] fn remove_token_royalty(e: &Env, token_id: u32, operator: Address) { - Base::remove_token_royalty(e, token_id); + Self::ContractType::remove_token_royalty(e, token_id); } } diff --git a/packages/tokens/src/non_fungible/extensions/combinations/storage.rs b/packages/tokens/src/non_fungible/extensions/combinations/storage.rs index 2b0ba8e12..d9a509820 100644 --- a/packages/tokens/src/non_fungible/extensions/combinations/storage.rs +++ b/packages/tokens/src/non_fungible/extensions/combinations/storage.rs @@ -7,6 +7,7 @@ use crate::non_fungible::{ enumerable::{Enumerable, EnumerableContractType}, }, overrides::{BurnableOverrides, ContractOverrides}, + royalties::RoyaltySupport, }; /// Contract type combining the [`Enumerable`] bookkeeping with vote @@ -37,6 +38,11 @@ impl BurnableOverrides for EnumerableVotes { } } +// Implemented for `EnumerableVotes`, so that the royalty existence check +// routes through the `Enumerable` ownership model (one `Owner` entry per +// token). +impl RoyaltySupport for EnumerableVotes {} + impl EnumerableVotes { /// refer to [`Enumerable::sequential_mint`] and [`transfer_voting_units`] /// for the inline documentation. @@ -131,6 +137,11 @@ impl BurnableOverrides for ConsecutiveVotes { } } +// Implemented for `ConsecutiveVotes`, so that the royalty existence check +// routes through `Consecutive`'s `owner_of` instead of `Base`'s direct +// entry lookup. +impl RoyaltySupport for ConsecutiveVotes {} + impl ConsecutiveVotes { /// Creates a batch of `amount` tokens for `to` and adds the matching /// amount of voting units in a single checkpoint update. diff --git a/packages/tokens/src/non_fungible/extensions/combinations/test.rs b/packages/tokens/src/non_fungible/extensions/combinations/test.rs index d14ffc1a2..d52ffe02e 100644 --- a/packages/tokens/src/non_fungible/extensions/combinations/test.rs +++ b/packages/tokens/src/non_fungible/extensions/combinations/test.rs @@ -11,7 +11,7 @@ use crate::non_fungible::{ combinations::{Composable, Compose}, consecutive::Consecutive, enumerable::Enumerable, - royalties::Royalties, + royalties::{Royalties, RoyaltySupport}, votes::NonFungibleVotes, }, overrides::{BurnableOverrides, ContractOverrides}, @@ -92,6 +92,20 @@ fn votes_combinations_are_order_insensitive() { ); } +// Every contract type must implement `RoyaltySupport`, so that the royalties +// extension can be paired with any of them. A missing impl fails here at +// compile time instead of at some downstream contract's build. +#[test] +fn all_contract_types_support_royalties() { + fn assert_support() {} + assert_support::(); + assert_support::(); + assert_support::(); + assert_support::(); + assert_support::(); + assert_support::(); +} + #[contract] struct MockContract; diff --git a/packages/tokens/src/non_fungible/extensions/consecutive/storage.rs b/packages/tokens/src/non_fungible/extensions/consecutive/storage.rs index e3714dea4..ae0bf59a1 100644 --- a/packages/tokens/src/non_fungible/extensions/consecutive/storage.rs +++ b/packages/tokens/src/non_fungible/extensions/consecutive/storage.rs @@ -7,6 +7,7 @@ use crate::non_fungible::{ emit_transfer, extensions::consecutive::emit_consecutive_mint, overrides::BurnableOverrides, + royalties::RoyaltySupport, sequential::{self as sequential}, Base, ContractOverrides, NonFungibleTokenError, OWNERSHIP_EXTEND_AMOUNT, OWNERSHIP_TTL_THRESHOLD, OWNER_EXTEND_AMOUNT, OWNER_TTL_THRESHOLD, TOKEN_EXTEND_AMOUNT, @@ -63,6 +64,11 @@ impl BurnableOverrides for Consecutive { } } +// Implemented for `Consecutive`, so that the royalty existence check routes +// through `Consecutive`'s `owner_of` (sparse, bucket-based ownership) +// instead of `Base`'s direct entry lookup. +impl RoyaltySupport for Consecutive {} + /// For 32,000 total IDs with ITEM of type u32 and 100 items per bucket: /// /// Bucket 0 diff --git a/packages/tokens/src/non_fungible/extensions/enumerable/storage.rs b/packages/tokens/src/non_fungible/extensions/enumerable/storage.rs index 65877283d..f97dc98a1 100644 --- a/packages/tokens/src/non_fungible/extensions/enumerable/storage.rs +++ b/packages/tokens/src/non_fungible/extensions/enumerable/storage.rs @@ -1,8 +1,9 @@ use soroban_sdk::{contracttype, panic_with_error, Address, Env}; use crate::non_fungible::{ - emit_mint, overrides::BurnableOverrides, Base, ContractOverrides, NonFungibleTokenError, - OWNER_EXTEND_AMOUNT, OWNER_TTL_THRESHOLD, TOKEN_EXTEND_AMOUNT, TOKEN_TTL_THRESHOLD, + emit_mint, overrides::BurnableOverrides, royalties::RoyaltySupport, Base, ContractOverrides, + NonFungibleTokenError, OWNER_EXTEND_AMOUNT, OWNER_TTL_THRESHOLD, TOKEN_EXTEND_AMOUNT, + TOKEN_TTL_THRESHOLD, }; pub struct Enumerable; @@ -37,6 +38,10 @@ impl BurnableOverrides for Enumerable { } } +// Implemented for `Enumerable`, so that the royalty existence check routes +// through `Enumerable`'s `owner_of` (one `Owner` entry per token). +impl RoyaltySupport for Enumerable {} + #[contracttype] pub struct OwnerTokensKey { pub owner: Address, diff --git a/packages/tokens/src/non_fungible/extensions/royalties/mod.rs b/packages/tokens/src/non_fungible/extensions/royalties/mod.rs index b2b34280b..450091926 100644 --- a/packages/tokens/src/non_fungible/extensions/royalties/mod.rs +++ b/packages/tokens/src/non_fungible/extensions/royalties/mod.rs @@ -1,11 +1,15 @@ mod storage; -use crate::non_fungible::{Base, NonFungibleToken}; +pub use crate::non_fungible::overrides::RoyaltySupport; +use crate::non_fungible::NonFungibleToken; #[cfg(test)] mod test; use soroban_sdk::{contractevent, contracttrait, Address, Env}; pub use storage::Royalties; +pub(crate) use storage::{ + remove_token_royalty_unchecked, royalty_info_unchecked, set_token_royalty_unchecked, +}; /// Royalties Trait for Non-Fungible Token (ERC2981) /// @@ -22,8 +26,15 @@ pub use storage::Royalties; /// - Remove per-token royalties to fall-back to the global royalty set for the /// contract /// -/// `storage.rs` file of this module provides the `NonFungibleRoyalties` trait -/// implementation. +/// [`NonFungibleRoyalties::set_default_royalty`] does not take a `token_id`, +/// and its implementation calls +/// [`crate::non_fungible::Base::set_default_royalty`] directly. The other +/// operations take a `token_id` and first check that the token exists. How a +/// token is looked up depends on the contract type: `Base` stores an `Owner` +/// entry for every token, while `Consecutive` stores one only for the last +/// token of a minted batch. These operations are therefore called on the +/// contract type, as `Self::ContractType::set_token_royalty(...)`, and are +/// provided by [`RoyaltySupport`]. /// /// # Notes /// @@ -33,7 +44,7 @@ pub use storage::Royalties; /// Non-Fungible and Fungible tokens, `i128` is used instead of `u128` for the /// `sale_price`, due to SEP-41. #[contracttrait] -pub trait NonFungibleRoyalties: NonFungibleToken { +pub trait NonFungibleRoyalties: NonFungibleToken { /// Sets the global default royalty information for the entire collection. /// This will be used for all tokens that don't have specific royalty /// information. @@ -61,7 +72,8 @@ pub trait NonFungibleRoyalties: NonFungibleToken { /// No default implementation is provided because this is a privileged /// operation that requires custom access control. Access control should be /// enforced on `operator` before calling - /// [`Base::set_default_royalty`] for the implementation. + /// [`crate::non_fungible::Base::set_default_royalty`] for the + /// implementation. fn set_default_royalty(e: &Env, receiver: Address, basis_points: u32, operator: Address); /// Sets the royalty information for a specific token. @@ -92,7 +104,8 @@ pub trait NonFungibleRoyalties: NonFungibleToken { /// No default implementation is provided because this is a privileged /// operation that requires custom access control. Access control should be /// enforced on `operator` before calling - /// [`Base::set_token_royalty`] for the implementation. + /// `Self::ContractType::set_token_royalty(e, token_id, &receiver, + /// basis_points)` for the implementation. fn set_token_royalty( e: &Env, token_id: u32, @@ -125,7 +138,8 @@ pub trait NonFungibleRoyalties: NonFungibleToken { /// No default implementation is provided because this is a privileged /// operation that requires custom access control. Access control should be /// enforced on `operator` before calling - /// [`Base::remove_token_royalty`] for the implementation. + /// `Self::ContractType::remove_token_royalty(e, token_id)` for the + /// implementation. fn remove_token_royalty(e: &Env, token_id: u32, operator: Address); /// Returns `(Address, i128)` - A tuple containing the receiver address and @@ -143,7 +157,7 @@ pub trait NonFungibleRoyalties: NonFungibleToken { /// * [`crate::non_fungible::NonFungibleTokenError::NonExistentToken`] - If /// the token does not exist. fn royalty_info(e: &Env, token_id: u32, sale_price: i128) -> (Address, i128) { - Base::royalty_info(e, token_id, sale_price) + Self::ContractType::royalty_info(e, token_id, sale_price) } } diff --git a/packages/tokens/src/non_fungible/extensions/royalties/storage.rs b/packages/tokens/src/non_fungible/extensions/royalties/storage.rs index 1981074ba..5cc8c7dbe 100644 --- a/packages/tokens/src/non_fungible/extensions/royalties/storage.rs +++ b/packages/tokens/src/non_fungible/extensions/royalties/storage.rs @@ -1,7 +1,7 @@ use soroban_sdk::{contracttype, panic_with_error, Address, Env}; use crate::non_fungible::{ - royalties::{emit_set_default_royalty, emit_set_token_royalty}, + royalties::{emit_remove_token_royalty, emit_set_default_royalty, emit_set_token_royalty}, Base, NonFungibleTokenError, OWNER_EXTEND_AMOUNT, OWNER_TTL_THRESHOLD, }; @@ -56,6 +56,11 @@ impl Base { /// **IMPORTANT**: This function lacks authorization controls. It should /// generally be invoked from a constructor or from another function with /// admin-only authorization. + /// + /// This function does not take a `token_id` and performs no token + /// existence check, so it works the same for every contract type. That is + /// why it lives on [`Base`] and not on + /// [`crate::non_fungible::royalties::RoyaltySupport`]. pub fn set_default_royalty(e: &Env, receiver: &Address, basis_points: u32) { // check if basis points is valid if basis_points > 10000 { @@ -69,126 +74,127 @@ impl Base { emit_set_default_royalty(e, receiver, basis_points); } +} - /// Sets the royalty information for a specific token. - /// This must be called during minting, as royalties are immutable after - /// minting. - /// - /// # Arguments - /// - /// * `e` - Access to the Soroban environment. - /// * `token_id` - The identifier of the token. - /// * `receiver` - The address that should receive royalty payments. - /// * `basis_points` - The royalty percentage in basis points (100 = 1%, - /// 10000 = 100%). - /// - /// # Events - /// - /// * topics - `["set_token_royalty", receiver: Address, token_id: u32]` - /// * data - `[basis_points: u32]` - /// - /// # Errors - /// - /// * [`NonFungibleTokenError::InvalidRoyaltyAmount`] - If the royalty - /// amount is higher than 10_000 (100%) basis points. - /// * refer to [`Base::owner_of`] errors. - /// - /// # Notes - /// - /// **IMPORTANT**: This function lacks authorization controls. It should - /// generally be invoked from a constructor or from another function with - /// admin-only authorization. - pub fn set_token_royalty(e: &Env, token_id: u32, receiver: &Address, basis_points: u32) { - // check if basis points is valid - if basis_points > 10000 { - panic_with_error!(e, NonFungibleTokenError::InvalidRoyaltyAmount); - } - - // Verify token exists by checking owner - let _ = Base::owner_of(e, token_id); +// ################## LOW-LEVEL HELPERS ################## - // Store the token royalty information - let key = NFTRoyaltiesStorageKey::TokenRoyalty(token_id); - let royalty_info = RoyaltyInfo { receiver: receiver.clone(), basis_points }; - e.storage().persistent().set(&key, &royalty_info); +// The functions below skip the token existence check on purpose. What +// "existing" means depends on the contract type's ownership model, so the +// check belongs to [`crate::non_fungible::royalties::RoyaltySupport`]: its +// default bodies call `Self::owner_of` and then delegate here. They are +// `pub(crate)` because routing around the existence check must not be +// possible from outside the library. - emit_set_token_royalty(e, receiver, token_id, basis_points); +/// Sets the royalty information for a specific token, **without** verifying +/// that the token exists. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token_id` - The identifier of the token. +/// * `receiver` - The address that should receive royalty payments. +/// * `basis_points` - The royalty percentage in basis points (100 = 1%, 10000 = +/// 100%). +/// +/// # Events +/// +/// * topics - `["set_token_royalty", receiver: Address, token_id: u32]` +/// * data - `[basis_points: u32]` +/// +/// # Errors +/// +/// * [`NonFungibleTokenError::InvalidRoyaltyAmount`] - If the royalty amount is +/// higher than 10_000 (100%) basis points. +/// +/// # Security Warning +/// +/// **IMPORTANT**: This function does not verify that the token exists. +/// Callers must establish existence first, through the ownership model of +/// the contract type in use. +/// [`crate::non_fungible::royalties::RoyaltySupport::set_token_royalty`] +/// does exactly that and is the intended entry point. +pub(crate) fn set_token_royalty_unchecked( + e: &Env, + token_id: u32, + receiver: &Address, + basis_points: u32, +) { + // check if basis points is valid + if basis_points > 10000 { + panic_with_error!(e, NonFungibleTokenError::InvalidRoyaltyAmount); } - /// Removes token-specific royalty information, allowing the token to fall - /// back to the collection-wide default royalty settings. - /// - /// # Arguments - /// - /// * `e` - Access to the Soroban environment. - /// * `token_id` - The identifier of the token. - /// - /// # Events - /// - /// * topics - `["remove_token_royalty", token_id: u32]` - /// * data - `[]` - /// - /// # Errors - /// - /// * [`NonFungibleTokenError::NonExistentToken`] - If the token does not - /// exist. - /// - /// # Notes - /// - /// **IMPORTANT**: This function lacks authorization controls. It should - /// generally be invoked from a function with admin-only authorization. - pub fn remove_token_royalty(e: &Env, token_id: u32) { - // Verify token exists by checking owner - let _ = Base::owner_of(e, token_id); + // Store the token royalty information + let key = NFTRoyaltiesStorageKey::TokenRoyalty(token_id); + let royalty_info = RoyaltyInfo { receiver: receiver.clone(), basis_points }; + e.storage().persistent().set(&key, &royalty_info); - // Remove the token royalty information - let key = NFTRoyaltiesStorageKey::TokenRoyalty(token_id); - e.storage().persistent().remove(&key); - - crate::non_fungible::extensions::royalties::emit_remove_token_royalty(e, token_id); - } + emit_set_token_royalty(e, receiver, token_id, basis_points); +} - /// Returns `(Address, u32)` - A tuple containing the receiver address and - /// the royalty amount. If there is no token-specific royalty set, it - /// returns the default royalty. If there is no default royalty set, it - /// returns the contract address and zero royalty. - /// - /// # Arguments - /// - /// * `e` - Access to the Soroban environment. - /// * `token_id` - The identifier of the token. - /// * `sale_price` - The sale price for which royalties are being - /// calculated. - /// - /// # Errors - /// - /// * [`NonFungibleTokenError::NonExistentToken`] - If the token does not - /// exist. - /// * refer to [`Base::owner_of`] errors. - pub fn royalty_info(e: &Env, token_id: u32, sale_price: i128) -> (Address, i128) { - // Verify token exists by checking owner - let _ = Base::owner_of(e, token_id); - - // Check if there's a specific royalty for this token - let token_key = NFTRoyaltiesStorageKey::TokenRoyalty(token_id); - if let Some(royalty_info) = e.storage().persistent().get::<_, RoyaltyInfo>(&token_key) { - e.storage().persistent().extend_ttl( - &token_key, - OWNER_TTL_THRESHOLD, - OWNER_EXTEND_AMOUNT, - ); - let royalty_amount = sale_price * royalty_info.basis_points as i128 / 10000; - return (royalty_info.receiver, royalty_amount); - } +/// Removes token-specific royalty information, **without** verifying that +/// the token exists. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token_id` - The identifier of the token. +/// +/// # Events +/// +/// * topics - `["remove_token_royalty", token_id: u32]` +/// * data - `[]` +/// +/// # Security Warning +/// +/// **IMPORTANT**: This function does not verify that the token exists. +/// Callers must establish existence first, through the ownership model of +/// the contract type in use. +/// [`crate::non_fungible::royalties::RoyaltySupport::remove_token_royalty`] +/// does exactly that and is the intended entry point. +pub(crate) fn remove_token_royalty_unchecked(e: &Env, token_id: u32) { + // Remove the token royalty information + let key = NFTRoyaltiesStorageKey::TokenRoyalty(token_id); + e.storage().persistent().remove(&key); + + emit_remove_token_royalty(e, token_id); +} - // Fall back to default royalty if no token-specific royalty is set - let default_key = NFTRoyaltiesStorageKey::DefaultRoyalty; - if let Some(royalty_info) = e.storage().instance().get::<_, RoyaltyInfo>(&default_key) { - let royalty_amount = sale_price * royalty_info.basis_points as i128 / 10000; - return (royalty_info.receiver, royalty_amount); - } +/// Returns `(Address, i128)` - A tuple containing the receiver address and +/// the royalty amount, **without** verifying that the token exists. If there +/// is no token-specific royalty set, the default royalty is returned. If +/// there is no default royalty set, the contract address and zero royalty +/// are returned. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `token_id` - The identifier of the token. +/// * `sale_price` - The sale price for which royalties are being calculated. +/// +/// # Security Warning +/// +/// **IMPORTANT**: This function does not verify that the token exists. +/// Callers must establish existence first, through the ownership model of +/// the contract type in use. +/// [`crate::non_fungible::royalties::RoyaltySupport::royalty_info`] does +/// exactly that and is the intended entry point. +pub(crate) fn royalty_info_unchecked(e: &Env, token_id: u32, sale_price: i128) -> (Address, i128) { + // Check if there's a specific royalty for this token + let token_key = NFTRoyaltiesStorageKey::TokenRoyalty(token_id); + if let Some(royalty_info) = e.storage().persistent().get::<_, RoyaltyInfo>(&token_key) { + e.storage().persistent().extend_ttl(&token_key, OWNER_TTL_THRESHOLD, OWNER_EXTEND_AMOUNT); + let royalty_amount = sale_price * royalty_info.basis_points as i128 / 10000; + return (royalty_info.receiver, royalty_amount); + } - // No royalty set, return zero royalty - (e.current_contract_address(), 0) + // Fall back to default royalty if no token-specific royalty is set + let default_key = NFTRoyaltiesStorageKey::DefaultRoyalty; + if let Some(royalty_info) = e.storage().instance().get::<_, RoyaltyInfo>(&default_key) { + let royalty_amount = sale_price * royalty_info.basis_points as i128 / 10000; + return (royalty_info.receiver, royalty_amount); } + + // No royalty set, return zero royalty + (e.current_contract_address(), 0) } diff --git a/packages/tokens/src/non_fungible/extensions/royalties/test.rs b/packages/tokens/src/non_fungible/extensions/royalties/test.rs index e9152f2e5..865f24c29 100644 --- a/packages/tokens/src/non_fungible/extensions/royalties/test.rs +++ b/packages/tokens/src/non_fungible/extensions/royalties/test.rs @@ -1,8 +1,13 @@ extern crate std; -use soroban_sdk::{contract, testutils::Address as _, Address, Env}; +use soroban_sdk::{contract, contractimpl, testutils::Address as _, Address, Env, String}; -use crate::non_fungible::{extensions::enumerable::Enumerable, Base}; +use crate::non_fungible::{ + consecutive::Consecutive, + extensions::enumerable::Enumerable, + royalties::{NonFungibleRoyalties, RoyaltySupport}, + Base, NonFungibleToken, +}; #[contract] struct MockContract; @@ -218,3 +223,131 @@ fn test_remove_token_royalty_no_default() { assert_eq!(royalty_amount, 0); }); } + +// A contract pairing the royalties extension with `Consecutive`, whose +// ownership is stored sparsely: only the last token of a batch gets a +// materialized `Owner` entry, and the rest are resolved by scanning the +// ownership buckets. Every royalty operation must therefore establish +// existence through `Self::ContractType::owner_of`, not `Base::owner_of`. +#[contract] +struct ConsecutiveMockContract; + +#[contractimpl(contracttrait)] +impl NonFungibleToken for ConsecutiveMockContract { + type ContractType = Consecutive; +} + +#[contractimpl(contracttrait)] +impl NonFungibleRoyalties for ConsecutiveMockContract { + fn set_default_royalty(e: &Env, receiver: Address, basis_points: u32, _operator: Address) { + Base::set_default_royalty(e, &receiver, basis_points); + } + + fn set_token_royalty( + e: &Env, + token_id: u32, + receiver: Address, + basis_points: u32, + _operator: Address, + ) { + Self::ContractType::set_token_royalty(e, token_id, &receiver, basis_points); + } + + fn remove_token_royalty(e: &Env, token_id: u32, _operator: Address) { + Self::ContractType::remove_token_royalty(e, token_id); + } +} + +#[test] +fn test_consecutive_royalty_info_resolves_every_token() { + let e = Env::default(); + e.mock_all_auths(); + let address = e.register(ConsecutiveMockContract, ()); + let client = ConsecutiveMockContractClient::new(&e, &address); + + let owner = Address::generate(&e); + let receiver = Address::generate(&e); + let operator = Address::generate(&e); + + e.as_contract(&address, || { + Consecutive::batch_mint(&e, &owner, 10); + }); + client.set_default_royalty(&receiver, &500, &operator); // 5% + + // Only token 9 (the batch boundary) has a materialized `Owner` entry; + // the rest must still resolve through the bucket scan. + for token_id in 0..10u32 { + assert_eq!(client.owner_of(&token_id), owner); + + let (royalty_receiver, royalty_amount) = client.royalty_info(&token_id, &1000); + assert_eq!(royalty_receiver, receiver); + assert_eq!(royalty_amount, 50); // 5% of 1000 + } +} + +#[test] +fn test_consecutive_set_and_remove_token_royalty_on_non_boundary_token() { + let e = Env::default(); + e.mock_all_auths(); + let address = e.register(ConsecutiveMockContract, ()); + let client = ConsecutiveMockContractClient::new(&e, &address); + + let owner = Address::generate(&e); + let default_receiver = Address::generate(&e); + let token_receiver = Address::generate(&e); + let operator = Address::generate(&e); + + e.as_contract(&address, || { + Consecutive::batch_mint(&e, &owner, 10); + }); + client.set_default_royalty(&default_receiver, &1000, &operator); // 10% + + // Token 3 has no materialized `Owner` entry; the existence check must + // still pass so the token-specific royalty can be set and removed. + client.set_token_royalty(&3, &token_receiver, &500, &operator); // 5% + let (royalty_receiver, royalty_amount) = client.royalty_info(&3, &2000); + assert_eq!(royalty_receiver, token_receiver); + assert_eq!(royalty_amount, 100); // 5% of 2000 + + client.remove_token_royalty(&3, &operator); + let (royalty_receiver, royalty_amount) = client.royalty_info(&3, &2000); + assert_eq!(royalty_receiver, default_receiver); + assert_eq!(royalty_amount, 200); // 10% of 2000 +} + +#[test] +#[should_panic(expected = "Error(Contract, #200)")] +fn test_consecutive_royalty_info_non_existent_token() { + let e = Env::default(); + e.mock_all_auths(); + let address = e.register(ConsecutiveMockContract, ()); + let client = ConsecutiveMockContractClient::new(&e, &address); + + let owner = Address::generate(&e); + + e.as_contract(&address, || { + Consecutive::batch_mint(&e, &owner, 10); + }); + + // The batch covers ids 0..=9; id 10 does not exist. + client.royalty_info(&10, &1000); +} + +#[test] +#[should_panic(expected = "Error(Contract, #200)")] +fn test_consecutive_set_token_royalty_non_existent_token() { + let e = Env::default(); + e.mock_all_auths(); + let address = e.register(ConsecutiveMockContract, ()); + let client = ConsecutiveMockContractClient::new(&e, &address); + + let owner = Address::generate(&e); + let operator = Address::generate(&e); + + e.as_contract(&address, || { + Consecutive::batch_mint(&e, &owner, 10); + }); + + // The batch covers ids 0..=9; id 10 does not exist. + client.set_token_royalty(&10, &Address::generate(&e), &500, &operator); +} diff --git a/packages/tokens/src/non_fungible/extensions/votes/storage.rs b/packages/tokens/src/non_fungible/extensions/votes/storage.rs index 9e66e6543..2cd867533 100644 --- a/packages/tokens/src/non_fungible/extensions/votes/storage.rs +++ b/packages/tokens/src/non_fungible/extensions/votes/storage.rs @@ -1,7 +1,9 @@ use soroban_sdk::{Address, Env}; use stellar_governance::votes::transfer_voting_units; -use crate::non_fungible::{overrides::BurnableOverrides, Base, ContractOverrides}; +use crate::non_fungible::{ + overrides::BurnableOverrides, royalties::RoyaltySupport, Base, ContractOverrides, +}; pub struct NonFungibleVotes; @@ -25,6 +27,11 @@ impl BurnableOverrides for NonFungibleVotes { } } +// Implemented for `NonFungibleVotes`, so that the royalty existence check +// routes through `NonFungibleVotes`'s `owner_of` (one `Owner` entry per +// token). +impl RoyaltySupport for NonFungibleVotes {} + impl NonFungibleVotes { /// Transfers a non-fungible token from `from` to `to`. /// Also updates voting units for the respective delegates (1 unit per NFT). diff --git a/packages/tokens/src/non_fungible/overrides.rs b/packages/tokens/src/non_fungible/overrides.rs index dd4c84b3a..d7989d38f 100644 --- a/packages/tokens/src/non_fungible/overrides.rs +++ b/packages/tokens/src/non_fungible/overrides.rs @@ -1,5 +1,9 @@ use soroban_sdk::{Address, Env, String}; +use crate::non_fungible::royalties::{ + remove_token_royalty_unchecked, royalty_info_unchecked, set_token_royalty_unchecked, +}; + /// Internal override hook for [`crate::non_fungible::NonFungibleToken`]. /// /// # Note @@ -109,3 +113,109 @@ pub trait BurnableOverrides { } impl BurnableOverrides for Base {} + +/// Contract-type-dependent entry points of the royalties extension. +/// +/// Every royalty operation must first establish that the token +/// exists, and what "exists" means is decided by the contract type's +/// ownership model. [`Base`] materializes an `Owner` entry for every token, +/// so its check is a direct lookup. +/// [`crate::non_fungible::consecutive::Consecutive`] stores ownership +/// sparsely (one entry per batch boundary) and resolves the rest by scanning +/// its ownership buckets, so an existence check hard-wired to +/// [`Base::owner_of`] would reject almost every token of a consecutive +/// collection. The check must therefore be routed through +/// `ContractType::owner_of`. +/// +/// This trait is how that routing is made reachable from everywhere it is +/// needed: +/// +/// 1. For [`crate::non_fungible::royalties::NonFungibleRoyalties::royalty_info`] alone, no extra machinery +/// would be required. The method has a default implementation, and a default +/// body can already reach the correct check through `Self::ContractType`, +/// whose [`ContractOverrides`] bound carries `owner_of`. +/// +/// 2. [`crate::non_fungible::royalties::NonFungibleRoyalties::set_token_royalty`] and +/// [`crate::non_fungible::royalties::NonFungibleRoyalties::remove_token_royalty`] deliberately have no +/// default implementations: they are privileged operations, and the access +/// control is up to the implementing contract. Their bodies are written by +/// the contract author, so the correctly-routed logic must be callable from +/// the author's own code, and the only name available there that knows the +/// ownership model is `Self::ContractType`. +/// +/// 3. A function becomes callable on `Self::ContractType` by being defined on a +/// trait that `ContractType` is bound by. [`ContractOverrides`] is not +/// extended with royalty functions, because royalty logic belongs to this +/// extension. Instead, this trait carries the royalty entry points, requires +/// [`ContractOverrides`] as a supertrait so that its default bodies can call +/// `Self::owner_of`, and +/// [`crate::non_fungible::royalties::NonFungibleRoyalties`] bounds +/// `ContractType` by it. +/// +/// Like the other dispatch traits, the definition lives in this file with +/// the rest of the plumbing, but unlike them it must be nameable by +/// contract authors, so it is re-exported at +/// `crate::non_fungible::royalties::RoyaltySupport`, next to the trait +/// whose implementations call it. +/// +/// # "Support", not "Overrides" +/// +/// Unlike `BurnableOverrides` (the internal dispatch behind +/// [`crate::non_fungible::burnable::NonFungibleBurnable`]), where the burn +/// logic genuinely differs across contract types, nothing here is ever +/// overridden. The royalty logic is identical for every contract type; the +/// only type-dependent piece is the `owner_of` existence check, which the +/// default bodies obtain through the supertrait. Every implementation of +/// this trait is therefore empty: implementing it declares that the +/// royalties extension can be used with a contract type, nothing more. A +/// contract type defined outside this library opts in the same way, with an +/// empty `impl`. +/// +/// # Usage +/// +/// The privileged functions are called on `Self::ContractType` after access +/// control: +/// +/// ```ignore +/// use stellar_tokens::non_fungible::royalties::{NonFungibleRoyalties, RoyaltySupport}; +/// +/// #[contractimpl(contracttrait)] +/// impl NonFungibleRoyalties for MyContract { +/// #[only_admin] +/// fn set_token_royalty( +/// e: &Env, +/// token_id: u32, +/// receiver: Address, +/// basis_points: u32, +/// operator: Address, +/// ) { +/// Self::ContractType::set_token_royalty(e, token_id, &receiver, basis_points); +/// } +/// +/// // ... +/// } +/// ``` +pub trait RoyaltySupport: ContractOverrides { + fn set_token_royalty(e: &Env, token_id: u32, receiver: &Address, basis_points: u32) { + // Verify token exists, through this contract type's ownership model + let _ = Self::owner_of(e, token_id); + + set_token_royalty_unchecked(e, token_id, receiver, basis_points); + } + + fn remove_token_royalty(e: &Env, token_id: u32) { + // Verify token exists, through this contract type's ownership model + let _ = Self::owner_of(e, token_id); + + remove_token_royalty_unchecked(e, token_id); + } + + fn royalty_info(e: &Env, token_id: u32, sale_price: i128) -> (Address, i128) { + // Verify token exists, through this contract type's ownership model + let _ = Self::owner_of(e, token_id); + + royalty_info_unchecked(e, token_id, sale_price) + } +} + +impl RoyaltySupport for Base {} From 774ac44bb7a8659b8c6a3e3ec82d1534d266d84a Mon Sep 17 00:00:00 2001 From: brozorec <9572072+brozorec@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:36:22 +0200 Subject: [PATCH 16/17] fix(rwa): pin recovered identity to its target until the old wallet is drained A zero-balance recovery target could be removed and re-registered under another identity before the old wallet's tokens were recovered, so a later recover_balance moved them under the wrong identity and MaxBalance skipped the debit/credit as a same-identity transfer. The IRS now keeps a RecoveredFrom link and rejects removing or re-recovering a target while its old wallet still holds a balance in a linked token (IRSError::PendingRecovery). --- .../compliance/modules/max_balance/storage.rs | 6 +- .../identity_registry_storage/mod.rs | 21 ++- .../identity_registry_storage/storage.rs | 71 ++++++++++- .../identity_registry_storage/test.rs | 120 ++++++++++++++++++ 4 files changed, 209 insertions(+), 9 deletions(-) diff --git a/packages/tokens/src/rwa/compliance/modules/max_balance/storage.rs b/packages/tokens/src/rwa/compliance/modules/max_balance/storage.rs index 0a9640b7f..78b9ab835 100644 --- a/packages/tokens/src/rwa/compliance/modules/max_balance/storage.rs +++ b/packages/tokens/src/rwa/compliance/modules/max_balance/storage.rs @@ -238,7 +238,11 @@ pub fn mark_preset_completed(e: &Env, token: &Address) { /// recovery, the sender is resolved through the IRS recovery record rather than /// reverting on the source lookup, so a forced/recovery transfer still updates /// the books. Once `from` recovered to `to`, both sides resolve to the same -/// identity and the movement is a same-identity no-op. +/// identity and the movement is a same-identity no-op. Reading the recovery +/// target's current identity is sound because the IRS pins the recovered +/// identity to the target until `from` has been drained: the target can +/// neither be re-registered under another identity nor recovered onward +/// while `from` still holds tokens in a linked token. /// /// # Arguments /// diff --git a/packages/tokens/src/rwa/identity_verification/identity_registry_storage/mod.rs b/packages/tokens/src/rwa/identity_verification/identity_registry_storage/mod.rs index 7500951be..ffaf83fc0 100644 --- a/packages/tokens/src/rwa/identity_verification/identity_registry_storage/mod.rs +++ b/packages/tokens/src/rwa/identity_verification/identity_registry_storage/mod.rs @@ -29,14 +29,20 @@ //! so two wallets of the same investor each carry their own copy. //! - `RecoveredTo(old_wallet) -> new_wallet`: a permanent tombstone written by //! account recovery; recovered wallets can never be registered again. +//! - `RecoveredFrom(new_wallet) -> old_wallet`: the reverse link, kept only +//! while the recovered identity is still linked to `new_wallet`. It pins the +//! identity to the new wallet until the old wallet's tokens have been +//! recovered. //! //! ## Lifecycle Operations //! //! - `add_identity` registers a wallet under an identity contract. //! - `remove_identity` deletes a wallet's registration and profile. It is -//! rejected while the wallet still holds a balance in any linked token. +//! rejected while the wallet still holds a balance in any linked token, and +//! while the wallet is a recovery target whose old wallet still holds one. //! - `recover_identity` handles wallet loss: the same identity moves to a new -//! wallet, and the old wallet is tombstoned. +//! wallet, and the old wallet is tombstoned. Recoveries chain, but each hop's +//! balance must be recovered before the identity moves on again. //! //! ## Replacing an Identity //! @@ -478,7 +484,8 @@ pub trait IdentityRegistryStorage: TokenBinder { /// implementation. /// /// The library-provided [`remove_identity`] rejects removal while - /// `account` holds a non-zero balance in any linked token, so that + /// `account` holds a non-zero balance in any linked token, or while it is + /// the target of a recovery whose old account still holds one, so that /// identity-keyed compliance state is never orphaned; refer to its /// documentation. fn remove_identity(e: &Env, account: Address, operator: Address); @@ -507,6 +514,11 @@ pub trait IdentityRegistryStorage: TokenBinder { /// operation that requires custom access control. Access control should be /// enforced on `operator` before calling [`recover_identity`] for the /// implementation. + /// + /// The library-provided [`recover_identity`] rejects moving the identity + /// on from `old_account` while `old_account` is itself the target of an + /// earlier recovery whose old account still holds a balance in any linked + /// token; refer to its documentation. fn recover_identity(e: &Env, old_account: Address, new_account: Address, operator: Address); /// Retrieves the stored identity for a given account. @@ -672,6 +684,9 @@ pub enum IRSError { AccountHasBalance = 328, /// The parallel arrays of a batch call have different lengths. BatchSizeMismatch = 329, + /// The account is a recovery target whose old account still holds a + /// balance in a linked token. + PendingRecovery = 330, } // ################## CONSTANTS ################## diff --git a/packages/tokens/src/rwa/identity_verification/identity_registry_storage/storage.rs b/packages/tokens/src/rwa/identity_verification/identity_registry_storage/storage.rs index ccc6a0e56..43e291f35 100644 --- a/packages/tokens/src/rwa/identity_verification/identity_registry_storage/storage.rs +++ b/packages/tokens/src/rwa/identity_verification/identity_registry_storage/storage.rs @@ -214,6 +214,10 @@ pub enum IRSStorageKey { IdentityProfile(Address), /// Maps old account to new account after recovery RecoveredTo(Address), + /// Maps a recovery target back to the old account whose identity it + /// received. Present only while the recovered identity is still linked to + /// the target. + RecoveredFrom(Address), } // ################## QUERY STATE ################## @@ -471,6 +475,9 @@ pub fn batch_add_identity( /// `account`. /// * [`IRSError::AccountHasBalance`] - If `account` holds a non-zero balance in /// any linked token. +/// * [`IRSError::PendingRecovery`] - If `account` is the target of an identity +/// recovery whose old account still holds a non-zero balance in any linked +/// token. /// /// # Events /// @@ -483,11 +490,20 @@ pub fn batch_add_identity( /// /// # Notes /// -/// One cross-contract `balance` call is made per linked token; the token -/// binder's `MAX_TOKENS` cap is sized so this sweep fits within a single -/// transaction. The zero balance check is only as complete as the token -/// binder registry: a token that resolves identities through this contract -/// without being bound to it is not checked. +/// One cross-contract `balance` call is made per linked token, and one more +/// per linked token when `account` is a recovery target, since the old +/// account's balance is checked as well; the token binder's `MAX_TOKENS` cap +/// is sized so this sweep fits within a single transaction. The zero balance +/// check is only as complete as the token binder registry: a token that +/// resolves identities through this contract without being bound to it is +/// not checked. +/// +/// A recovery target keeps the recovered identity until the old account has +/// been drained. Balance recovery on the token side moves the old account's +/// tokens to the target under the identity the target currently resolves to, +/// so re-registering the target under another identity beforehand would hand +/// those tokens to a different investor and desynchronize identity-keyed +/// compliance accounting. /// /// The check covers balance-backed module state. Time-windowed transfer /// counters are flow-based and stay keyed to the old identity: a wallet @@ -520,6 +536,8 @@ pub fn remove_identity(e: &Env, account: &Address) { } } + settle_recovery_target(e, account); + e.storage().persistent().remove(&identity_key); emit_identity_unstored(e, account, &identity); @@ -557,6 +575,9 @@ pub fn remove_identity(e: &Env, account: &Address) { /// recovered to another account. /// * [`IRSError::IdentityOverwrite`] - If the `new_account` is already linked /// to an identity. +/// * [`IRSError::PendingRecovery`] - If `old_account` is itself the target of +/// an earlier recovery whose old account still holds a non-zero balance in +/// any linked token. /// /// # Events /// @@ -564,6 +585,13 @@ pub fn remove_identity(e: &Env, account: &Address) { /// Address]` /// * data - `[]` /// +/// # Notes +/// +/// Recoveries can be chained, but the balance of each hop must be recovered +/// before the identity moves on: a wallet whose identity has left it can no +/// longer be the destination of a balance recovery, so the tokens of an +/// undrained earlier hop would otherwise be stranded. +/// /// # Security Warning /// /// **IMPORTANT**: This function bypasses authorization checks and should only @@ -591,8 +619,11 @@ pub fn recover_identity(e: &Env, old_account: &Address, new_account: &Address) { panic_with_error!(e, IRSError::IdentityOverwrite) } + settle_recovery_target(e, old_account); + e.storage().persistent().set(&new_identity_key, &identity); e.storage().persistent().remove(&old_identity_key); + e.storage().persistent().set(&IRSStorageKey::RecoveredFrom(new_account.clone()), old_account); // Recover identity profile let old_profile_key = IRSStorageKey::IdentityProfile(old_account.clone()); @@ -771,6 +802,36 @@ pub fn delete_country_data(e: &Env, account: &Address, index: u32) { // ################## HELPERS ################## +/// Releases `account` from its role as a recovery target, if it has one, so +/// that the recovered identity may leave it. Panics while the old account the +/// identity came from still holds tokens in any linked token, since those +/// tokens can only be recovered into `account` under the identity it holds +/// now. +/// +/// # Arguments +/// +/// * `e` - The Soroban environment. +/// * `account` - The account whose identity is about to be removed or moved. +/// +/// # Errors +/// +/// * [`IRSError::PendingRecovery`] - If `account` is a recovery target whose +/// old account holds a non-zero balance in any linked token. +fn settle_recovery_target(e: &Env, account: &Address) { + let key = IRSStorageKey::RecoveredFrom(account.clone()); + let Some(old_account) = e.storage().persistent().get::<_, Address>(&key) else { + return; + }; + + for token in linked_tokens(e).iter() { + if TokenClient::new(e, &token).balance(&old_account) != 0 { + panic_with_error!(e, IRSError::PendingRecovery); + } + } + + e.storage().persistent().remove(&key); +} + /// Validates a single country data entry to ensure metadata constraints are /// met. /// diff --git a/packages/tokens/src/rwa/identity_verification/identity_registry_storage/test.rs b/packages/tokens/src/rwa/identity_verification/identity_registry_storage/test.rs index 24be9a1ff..e14e46838 100644 --- a/packages/tokens/src/rwa/identity_verification/identity_registry_storage/test.rs +++ b/packages/tokens/src/rwa/identity_verification/identity_registry_storage/test.rs @@ -1322,3 +1322,123 @@ fn batch_add_identity_accepts_an_empty_batch() { assert_eq!(e.events().all().events().len(), 0); }); } + +#[test] +#[should_panic(expected = "Error(Contract, #330)")] // PendingRecovery +fn remove_identity_recovery_target_with_undrained_old_account_panics() { + let e = Env::default(); + let contract_id = e.register(MockContract, ()); + let token = e.register(MockToken, ()); + + let old_account = Address::generate(&e); + let new_account = Address::generate(&e); + let identity = Address::generate(&e); + MockTokenClient::new(&e, &token).set_balance(&old_account, &100); + + e.as_contract(&contract_id, || { + let country = CountryData { country: residence(840), metadata: None }; + + bind_token(&e, &token); + add_identity(&e, &old_account, &identity, IdentityType::Individual, &vec![&e, country]); + recover_identity(&e, &old_account, &new_account); + + // The recovered identity is pinned to `new_account` until the old + // account's tokens have been recovered into it. + remove_identity(&e, &new_account); + }); +} + +#[test] +fn remove_identity_recovery_target_after_old_account_drained_succeeds() { + let e = Env::default(); + let contract_id = e.register(MockContract, ()); + let token = e.register(MockToken, ()); + let token_client = MockTokenClient::new(&e, &token); + + let old_account = Address::generate(&e); + let new_account = Address::generate(&e); + let identity = Address::generate(&e); + let other_identity = Address::generate(&e); + + e.as_contract(&contract_id, || { + let country = CountryData { country: residence(840), metadata: None }; + + bind_token(&e, &token); + add_identity(&e, &old_account, &identity, IdentityType::Individual, &vec![&e, country]); + recover_identity(&e, &old_account, &new_account); + + remove_identity(&e, &new_account); + assert_eq!(get_recovered_to(&e, &old_account), Some(new_account.clone())); + }); + + // Once released, the target is an ordinary wallet again and no longer + // tracks the old account: a later balance on the old account does not + // block its removal. + token_client.set_balance(&old_account, &100); + + e.as_contract(&contract_id, || { + let country = CountryData { country: residence(840), metadata: None }; + + add_identity( + &e, + &new_account, + &other_identity, + IdentityType::Individual, + &vec![&e, country], + ); + assert_eq!(stored_identity(&e, &new_account), other_identity); + + remove_identity(&e, &new_account); + }); +} + +#[test] +#[should_panic(expected = "Error(Contract, #330)")] // PendingRecovery +fn recover_identity_from_undrained_recovery_target_panics() { + let e = Env::default(); + let contract_id = e.register(MockContract, ()); + let token = e.register(MockToken, ()); + + let account1 = Address::generate(&e); + let account2 = Address::generate(&e); + let account3 = Address::generate(&e); + let identity = Address::generate(&e); + MockTokenClient::new(&e, &token).set_balance(&account1, &100); + + e.as_contract(&contract_id, || { + let country = CountryData { country: residence(840), metadata: None }; + + bind_token(&e, &token); + add_identity(&e, &account1, &identity, IdentityType::Individual, &vec![&e, country]); + recover_identity(&e, &account1, &account2); + + // account1's tokens can only be recovered into account2, so the + // identity may not move on before they are. + recover_identity(&e, &account2, &account3); + }); +} + +#[test] +fn recover_identity_from_drained_recovery_target_succeeds() { + let e = Env::default(); + let contract_id = e.register(MockContract, ()); + let token = e.register(MockToken, ()); + + let account1 = Address::generate(&e); + let account2 = Address::generate(&e); + let account3 = Address::generate(&e); + let identity = Address::generate(&e); + + e.as_contract(&contract_id, || { + let country = CountryData { country: residence(840), metadata: None }; + + bind_token(&e, &token); + add_identity(&e, &account1, &identity, IdentityType::Individual, &vec![&e, country]); + recover_identity(&e, &account1, &account2); + recover_identity(&e, &account2, &account3); + + assert_eq!(stored_identity(&e, &account3), identity); + assert_eq!(get_recovered_to(&e, &account1), Some(account2.clone())); + assert_eq!(get_recovered_to(&e, &account2), Some(account3.clone())); + }); +} From b2e0a73b2dd7f4342765f7437195e541b15a8344 Mon Sep 17 00:00:00 2001 From: brozorec <9572072+brozorec@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:40:04 +0200 Subject: [PATCH 17/17] docs(rwa): state that country profiles are per wallet and must be updated per wallet Sibling wallets of one identity each carry their own country data and nothing reconciles them, so a jurisdiction change is applied by the operator to every wallet registered under that identity. --- .../tokens/src/rwa/compliance/modules/country_allow/mod.rs | 5 +++++ .../src/rwa/compliance/modules/country_restrict/mod.rs | 5 +++++ .../identity_verification/identity_registry_storage/mod.rs | 5 ++++- 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/tokens/src/rwa/compliance/modules/country_allow/mod.rs b/packages/tokens/src/rwa/compliance/modules/country_allow/mod.rs index 8413cb613..29386725b 100644 --- a/packages/tokens/src/rwa/compliance/modules/country_allow/mod.rs +++ b/packages/tokens/src/rwa/compliance/modules/country_allow/mod.rs @@ -32,6 +32,11 @@ use crate::rwa::compliance::modules::ComplianceModule; /// the sender or scale with amount, those belong in a different compliance /// module that you register alongside this one. /// +/// Country data is read for the recipient wallet, not for its identity: the +/// Identity Registry Storage keeps a profile per wallet, so sibling wallets of +/// one investor are admitted on their own entries. A jurisdiction change for +/// an investor must reach every wallet registered under that identity. +/// /// Countries are identified by ISO 3166-1 numeric codes and tracked /// per-`token`, so a single compliance module contract can serve multiple /// tokens with independent allowlists. diff --git a/packages/tokens/src/rwa/compliance/modules/country_restrict/mod.rs b/packages/tokens/src/rwa/compliance/modules/country_restrict/mod.rs index 8f16c6c87..783a7a8b0 100644 --- a/packages/tokens/src/rwa/compliance/modules/country_restrict/mod.rs +++ b/packages/tokens/src/rwa/compliance/modules/country_restrict/mod.rs @@ -32,6 +32,11 @@ use crate::rwa::compliance::modules::ComplianceModule; /// constrain the sender or scale with amount, those belong in a different /// compliance module that you register alongside this one. /// +/// Country data is read for the recipient wallet, not for its identity: the +/// Identity Registry Storage keeps a profile per wallet, so sibling wallets of +/// one investor are blocked on their own entries. A jurisdiction change for +/// an investor must reach every wallet registered under that identity. +/// /// Countries are identified by ISO 3166-1 numeric codes and tracked /// per-`token`, so a single compliance module contract can serve multiple /// tokens with independent restriction lists. diff --git a/packages/tokens/src/rwa/identity_verification/identity_registry_storage/mod.rs b/packages/tokens/src/rwa/identity_verification/identity_registry_storage/mod.rs index ffaf83fc0..9d5efd558 100644 --- a/packages/tokens/src/rwa/identity_verification/identity_registry_storage/mod.rs +++ b/packages/tokens/src/rwa/identity_verification/identity_registry_storage/mod.rs @@ -26,7 +26,10 @@ //! identity: the identity represents the investor, the wallets are its keys. //! - `IdentityProfile(wallet)`: registry-side metadata, an [`IdentityType`] //! plus the country data entries. This is kept per wallet, not per identity, -//! so two wallets of the same investor each carry their own copy. +//! so two wallets of the same investor each carry their own copy. The copies +//! are not reconciled: a jurisdiction change for an investor is applied by +//! the operator to every wallet registered under that identity, which the +//! `identity_stored` and `identity_recovered` events enumerate. //! - `RecoveredTo(old_wallet) -> new_wallet`: a permanent tombstone written by //! account recovery; recovered wallets can never be registered again. //! - `RecoveredFrom(new_wallet) -> old_wallet`: the reverse link, kept only