From 4932c072b23c95158cda59b7706cda7e66f1888d Mon Sep 17 00:00:00 2001 From: deradev <292532759+deradev@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:23:39 +0100 Subject: [PATCH] feat: implement SC-020, SC-022, SC-023 and SC-026 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- contracts/Cargo.lock | 2 + contracts/ERROR_REGISTRY.md | 24 +- contracts/access_control/src/lib.rs | 257 +------------ contracts/access_control/src/roles.rs | 139 +++++++ contracts/access_control/src/test.rs | 150 ++++++++ contracts/access_control/src/timelock.rs | 92 +++++ contracts/amm/Cargo.toml | 1 + contracts/amm/src/circuit_breaker.rs | 109 ++++++ contracts/amm/src/events.rs | 140 ++++++++ contracts/amm/src/lib.rs | 125 +++++-- contracts/amm/src/storage.rs | 1 + contracts/amm/src/test.rs | 82 ++++- contracts/certificate/Cargo.toml | 1 + contracts/certificate/src/events.rs | 102 ++++++ contracts/certificate/src/lib.rs | 31 +- contracts/certificate/src/test.rs | 30 +- contracts/contract_events/src/lib.rs | 64 +++- contracts/smart_vault/src/circuit_breaker.rs | 67 ++++ contracts/smart_vault/src/lib.rs | 136 ++++--- contracts/zk_proof_verifier/src/lib.rs | 254 +++++-------- contracts/zk_proof_verifier/src/pairing.rs | 55 +++ contracts/zk_proof_verifier/src/test.rs | 359 ++++++++++--------- contracts/zk_proof_verifier/src/verifier.rs | 67 ++++ docs/contracts/EVENTS.md | 70 +++- docs/contracts/SC-020-ZK-PROOF-VERIFIER.md | 53 +++ docs/contracts/SC-022-RBAC.md | 33 ++ docs/contracts/SC-023-CIRCUIT-BREAKER.md | 32 ++ 27 files changed, 1818 insertions(+), 658 deletions(-) create mode 100644 contracts/access_control/src/roles.rs create mode 100644 contracts/access_control/src/test.rs create mode 100644 contracts/access_control/src/timelock.rs create mode 100644 contracts/amm/src/circuit_breaker.rs create mode 100644 contracts/amm/src/events.rs create mode 100644 contracts/certificate/src/events.rs create mode 100644 contracts/smart_vault/src/circuit_breaker.rs create mode 100644 contracts/zk_proof_verifier/src/pairing.rs create mode 100644 contracts/zk_proof_verifier/src/verifier.rs create mode 100644 docs/contracts/SC-020-ZK-PROOF-VERIFIER.md create mode 100644 docs/contracts/SC-022-RBAC.md create mode 100644 docs/contracts/SC-023-CIRCUIT-BREAKER.md diff --git a/contracts/Cargo.lock b/contracts/Cargo.lock index 8b056de0e..7856e0f95 100644 --- a/contracts/Cargo.lock +++ b/contracts/Cargo.lock @@ -31,6 +31,7 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" name = "amm" version = "0.1.0" dependencies = [ + "contract-events", "proptest", "soroban-sdk", ] @@ -320,6 +321,7 @@ dependencies = [ name = "certificate" version = "0.0.0" dependencies = [ + "contract-events", "soroban-sdk", ] diff --git a/contracts/ERROR_REGISTRY.md b/contracts/ERROR_REGISTRY.md index 6e7dd60a7..acbcf93dd 100644 --- a/contracts/ERROR_REGISTRY.md +++ b/contracts/ERROR_REGISTRY.md @@ -18,12 +18,15 @@ This document is automatically generated from `#[contracterror]` annotations acr | Code | Variant Name | Description | | :---: | :--- | :--- | -| `300` | `Unauthorized` | `300` — The caller does not have the required role. | +| `300` | `Unauthorized` | Caller does not hold the required role or is not the proposed admin. | | `301` | `ContractPaused` | `301` — The contract is in circuit-breaker pause mode. | -| `302` | `TimelockNotExpired` | `302` — The role transfer timelock has not yet elapsed. | -| `303` | `NoPendingTransfer` | `303` — No pending role transfer exists for this (role, address) pair. | -| `304` | `AlreadyHasRole` | `304` — The address already holds the requested role. | -| `305` | `Overflow` | `305` — Arithmetic overflow in timelock timestamp computation. | +| `302` | `TimelockNotExpired` | The 48-hour admin handover delay has not elapsed. | +| `303` | `NoPendingTransfer` | No admin handover is pending. | +| `304` | `AlreadyHasRole` | The address already holds the requested role. | +| `305` | `Overflow` | Arithmetic overflow while calculating the admin handover unlock time. | +| `306` | `RoleNotGranted` | The address does not hold the role being revoked. | +| `307` | `InvalidRole` | Admin cannot be granted or revoked through the direct role API. | +| `308` | `InvalidAdminProposal` | The current admin cannot propose itself as the next admin. | ### `AmmError` (`amm/src/lib.rs`) @@ -34,6 +37,10 @@ This document is automatically generated from `#[contracterror]` annotations acr | `402` | `InvalidFee` | `402` — Fee is outside the allowed range. | | `403` | `SlippageExceeded` | `403` — Slippage tolerance exceeded. | | `404` | `NotInitialized` | `404` — Contract has not been initialised. | +| `405` | `ContractPaused` | `405` — AMM operations are paused by the circuit breaker. | +| `406` | `Unauthorized` | `406` — Caller is not authorized to manage the circuit breaker. | +| `407` | `WithdrawalLimitExceeded` | `407` — The 24-hour token outflow budget has been exceeded. | +| `408` | `Overflow` | `408` — Arithmetic overflow. | ### `AuthError` (`auth_checker/src/lib.rs`) @@ -430,6 +437,7 @@ This document is automatically generated from `#[contracterror]` annotations acr | `120` | `InvalidThreshold` | `120` — Threshold must be positive. | | `121` | `TimelockNotElapsed` | `121` — Timelock has not elapsed; proposal is not ready. | | `122` | `InvariantViolated` | `122` — Reserve conservation invariant violated. | +| `123` | `WithdrawalLimitExceeded` | `123` — 24-hour withdrawal allowance has been exceeded. | ### `AuthError` (`src/errors.rs`) @@ -546,6 +554,8 @@ This document is automatically generated from `#[contracterror]` annotations acr | :---: | :--- | :--- | | `1` | `AlreadyInitialized` | No description provided | | `2` | `NotInitialized` | No description provided | -| `3` | `InvalidProof` | The provided proof does not match the expected binding hash. | +| `3` | `InvalidProof` | The Groth16 pairing equation does not hold for the proof and public inputs. | | `4` | `NullifierAlreadyUsed` | This nullifier has already been consumed; replay detected. | -| `5` | `Unauthorized` | No description provided | +| `5` | `Unauthorized` | Caller is not the verifier administrator. | +| `6` | `InvalidPublicInput` | Public input count or canonical BN254 scalar encoding is invalid. | +| `7` | `InvalidVerifyingKey` | Verifying-key point encoding or public-input arity is invalid. | diff --git a/contracts/access_control/src/lib.rs b/contracts/access_control/src/lib.rs index eea5d4e24..7265a2f9b 100644 --- a/contracts/access_control/src/lib.rs +++ b/contracts/access_control/src/lib.rs @@ -1,114 +1,35 @@ -//! # Access Control — Universal RBAC & Circuit-Breaker Pause (SC-HARD-19) +//! Reusable Soroban role-based access control with a 48-hour admin handover. //! -//! A reusable Soroban library crate providing: -//! -//! * **Role-Based Access Control** — three roles (`Admin`, `Operator`, -//! `Guardian`) stored in contract instance storage. -//! * **Timelocked role transfers** — `Admin` queues a pending role -//! assignment; the assignment only becomes effective after a configurable -//! `unlock_timestamp`. -//! * **Circuit-breaker pause** — `Guardian` (or `Admin`) calls `pause()` -//! which sets a `PAUSED` flag in instance storage; every state-mutating -//! caller should call `require_not_paused()` before proceeding. -//! -//! ## Error-code range -//! This crate owns the `300–399` discriminant range. +//! Call these helpers from the owning contract's privileged entrypoints. #![no_std] -use soroban_sdk::{contracterror, contracttype, symbol_short, Address, Env, Symbol}; +use soroban_sdk::{contracterror, Env}; + +pub mod roles; +pub mod timelock; -// ── Error codes (300–399) ───────────────────────────────────────────────────── +pub use roles::{grant_role, has_role, initialize, is_paused, pause, revoke_role, unpause, Role}; +pub use timelock::{ + admin, cancel_admin_proposal, claim_admin, pending_admin, propose_admin, AdminProposal, + ADMIN_HANDOVER_DELAY, +}; -/// Errors emitted by the access-control library. -/// -/// Discriminants occupy the `300–399` range reserved for this crate. #[contracterror] #[derive(Copy, Clone, Debug, Eq, PartialEq)] pub enum AcError { - /// `300` — The caller does not have the required role. Unauthorized = 300, - /// `301` — The contract is in circuit-breaker pause mode. ContractPaused = 301, - /// `302` — The role transfer timelock has not yet elapsed. TimelockNotExpired = 302, - /// `303` — No pending role transfer exists for this (role, address) pair. NoPendingTransfer = 303, - /// `304` — The address already holds the requested role. AlreadyHasRole = 304, - /// `305` — Arithmetic overflow in timelock timestamp computation. Overflow = 305, + RoleNotGranted = 306, + InvalidRole = 307, + InvalidAdminProposal = 308, } -// ── Role enum ───────────────────────────────────────────────────────────────── - -/// The three supported roles. -#[contracttype] -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum Role { - Admin, - Operator, - Guardian, -} - -// ── Storage keys ───────────────────────────────────────────────────────────── - -/// `PAUSED` flag in instance storage. -pub const PAUSED_KEY: Symbol = symbol_short!("AC_PAUSED"); - -/// Per-role holder key — stored as `("AC_ROLE", Role)`. -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum AcKey { - /// Holder of a given role. - RoleHolder(Role), - /// Pending role transfer: (role, candidate) → unlock_timestamp. - PendingTransfer(Role), -} - -// ── Read helpers ────────────────────────────────────────────────────────────── - -/// Returns `true` if the contract is currently paused. -pub fn is_paused(env: &Env) -> bool { - env.storage() - .instance() - .get::(&PAUSED_KEY) - .unwrap_or(false) -} - -/// Returns the address currently holding `role`, or `None`. -pub fn role_holder(env: &Env, role: Role) -> Option
{ - env.storage() - .instance() - .get::(&AcKey::RoleHolder(role)) -} - -/// Returns `true` if `addr` holds `role`. -pub fn has_role(env: &Env, addr: &Address, role: Role) -> bool { - role_holder(env, role).as_ref() == Some(addr) -} - -// ── Initialise ──────────────────────────────────────────────────────────────── - -/// Bootstrap the access-control state: set the initial `Admin`. -/// -/// Safe to call at contract initialization. Does nothing if an Admin is -/// already configured (idempotent for upgradeable contracts). -pub fn initialize(env: &Env, admin: &Address) { - if role_holder(env, Role::Admin).is_none() { - env.storage() - .instance() - .set(&AcKey::RoleHolder(Role::Admin), admin); - env.storage().instance().set(&PAUSED_KEY, &false); - } -} - -// ── Guard helpers ───────────────────────────────────────────────────────────── - /// Panics with `AcError::ContractPaused` if the circuit-breaker is active. -/// -/// Call at the start of every state-mutating entry point (except emergency -/// admin withdrawals which must remain accessible while paused). #[macro_export] macro_rules! require_not_paused { ($env:expr) => { @@ -128,150 +49,10 @@ macro_rules! require_role { }; } -// ── Pause / unpause ─────────────────────────────────────────────────────────── - -/// Guardian (or Admin) pauses the contract. -/// -/// Sets the `PAUSED` flag in instance storage. Subsequent calls to -/// `require_not_paused!` in state-mutating entry points will revert. -/// -/// # Panics -/// - `AcError::Unauthorized` if `caller` is neither Admin nor Guardian. -pub fn pause(env: &Env, caller: &Address) { - caller.require_auth(); - let is_admin = has_role(env, caller, Role::Admin); - let is_guardian = has_role(env, caller, Role::Guardian); - if !is_admin && !is_guardian { - soroban_sdk::panic_with_error!(env, AcError::Unauthorized); - } - env.storage().instance().set(&PAUSED_KEY, &true); - env.events() - .publish((symbol_short!("AC_PAUSE"),), caller.clone()); -} - -/// Admin unpauses the contract. -/// -/// # Panics -/// - `AcError::Unauthorized` if `caller` is not the Admin. -pub fn unpause(env: &Env, caller: &Address) { - caller.require_auth(); - if !has_role(env, caller, Role::Admin) { - soroban_sdk::panic_with_error!(env, AcError::Unauthorized); - } - env.storage().instance().set(&PAUSED_KEY, &false); - env.events() - .publish((Symbol::new(env, "AC_UNPAUSE"),), caller.clone()); +/// Panics unless `addr` holds the Admin role. +pub fn require_admin(env: &Env, addr: &soroban_sdk::Address) { + crate::require_role!(env, addr, Role::Admin); } -// ── Role assignment (timelocked) ────────────────────────────────────────────── - -/// Admin queues a pending role transfer for `candidate` to receive `role` -/// after `unlock_timestamp` (UNIX seconds). -/// -/// The pending transfer is stored as `AcKey::PendingTransfer(role)` → -/// `(candidate, unlock_timestamp)`. Only one pending transfer per role is -/// held at a time; a new call overwrites the previous pending transfer. -/// -/// # Panics -/// - `AcError::Unauthorized` if `admin` is not the current Admin. -/// - `AcError::AlreadyHasRole` if `candidate` already holds `role`. -pub fn queue_role_transfer( - env: &Env, - admin: &Address, - role: Role, - candidate: Address, - unlock_timestamp: u64, -) { - admin.require_auth(); - if !has_role(env, admin, Role::Admin) { - soroban_sdk::panic_with_error!(env, AcError::Unauthorized); - } - if has_role(env, &candidate, role) { - soroban_sdk::panic_with_error!(env, AcError::AlreadyHasRole); - } - env.storage().instance().set( - &AcKey::PendingTransfer(role), - &(candidate.clone(), unlock_timestamp), - ); - env.events().publish( - (symbol_short!("AC_QROLE"),), - (role as u32, candidate, unlock_timestamp), - ); -} - -/// Execute a previously queued role transfer once the timelock has elapsed. -/// -/// The `candidate` must be the address stored in the pending transfer. -/// After execution the pending transfer entry is removed. -/// -/// # Panics -/// - `AcError::NoPendingTransfer` if no transfer was queued for `role`. -/// - `AcError::TimelockNotExpired` if `now < unlock_timestamp`. -pub fn execute_role_transfer(env: &Env, role: Role, candidate: &Address) { - let pending: Option<(Address, u64)> = - env.storage().instance().get(&AcKey::PendingTransfer(role)); - - let (stored_candidate, unlock_at) = match pending { - Some(p) => p, - None => soroban_sdk::panic_with_error!(env, AcError::NoPendingTransfer), - }; - - if candidate != &stored_candidate { - soroban_sdk::panic_with_error!(env, AcError::NoPendingTransfer); - } - - let now = env.ledger().timestamp(); - if now < unlock_at { - soroban_sdk::panic_with_error!(env, AcError::TimelockNotExpired); - } - - // Apply the transfer. - env.storage() - .instance() - .set(&AcKey::RoleHolder(role), candidate); - // Clear the pending entry. - env.storage() - .instance() - .remove(&AcKey::PendingTransfer(role)); - - env.events().publish( - (symbol_short!("AC_ROLE"),), - (role as u32, candidate.clone()), - ); -} - -/// Admin revokes a role from its current holder. -/// -/// After revocation the role has no holder until it is re-assigned. -/// -/// # Panics -/// - `AcError::Unauthorized` if `admin` is not the current Admin. -pub fn revoke_role(env: &Env, admin: &Address, role: Role) { - admin.require_auth(); - if !has_role(env, admin, Role::Admin) { - soroban_sdk::panic_with_error!(env, AcError::Unauthorized); - } - env.storage().instance().remove(&AcKey::RoleHolder(role)); - env.events() - .publish((symbol_short!("AC_REVOKE"),), role as u32); -} - -/// Directly assign `role` to `addr` without a timelock. -/// -/// This is an **admin-only** convenience for the initial bootstrap phase -/// (e.g. assigning `Operator` and `Guardian` at deployment time). For -/// post-deployment changes prefer the timelocked path. -/// -/// # Panics -/// - `AcError::Unauthorized` if `admin` is not the current Admin. -pub fn assign_role_direct(env: &Env, admin: &Address, role: Role, addr: Address) { - admin.require_auth(); - if !has_role(env, admin, Role::Admin) { - soroban_sdk::panic_with_error!(env, AcError::Unauthorized); - } - env.storage() - .instance() - .set(&AcKey::RoleHolder(role), &addr); - env.events() - .publish((symbol_short!("AC_ASSIGN"),), (role as u32, addr)); -} +#[cfg(test)] +mod test; diff --git a/contracts/access_control/src/roles.rs b/contracts/access_control/src/roles.rs new file mode 100644 index 000000000..a594c1243 --- /dev/null +++ b/contracts/access_control/src/roles.rs @@ -0,0 +1,139 @@ +use crate::AcError; +use soroban_sdk::{contracttype, symbol_short, Address, Env, Symbol}; + +/// Roles supported by the shared access-control module. +/// +/// `Operator` and `Guardian` remain available for contracts using the older +/// access-control API. New integrations should use Instructor, Minter, and +/// Auditor for application-specific permissions. +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Role { + Admin, + Instructor, + Minter, + Auditor, + Operator, + Guardian, +} + +#[contracttype] +#[derive(Clone)] +pub(crate) enum StorageKey { + Admin, + Paused, + RoleMember(Role, Address), +} + +pub(crate) const PAUSED_KEY: Symbol = symbol_short!("AC_PAUSED"); + +/// Initializes access control once. Repeated calls never replace the admin. +pub fn initialize(env: &Env, initial_admin: &Address) { + let key = StorageKey::Admin; + if env.storage().instance().has(&key) { + return; + } + initial_admin.require_auth(); + env.storage().instance().set(&key, initial_admin); + env.storage().instance().set(&PAUSED_KEY, &false); + env.events().publish( + (symbol_short!("AC_ROLE"), Role::Admin), + (initial_admin.clone(), true, initial_admin.clone()), + ); +} + +/// Returns whether `addr` holds `role`. +pub fn has_role(env: &Env, addr: &Address, role: Role) -> bool { + match role { + Role::Admin => admin(env).as_ref() == Some(addr), + _ => env + .storage() + .instance() + .get::<_, bool>(&StorageKey::RoleMember(role, addr.clone())) + .unwrap_or(false), + } +} + +/// Returns the configured admin, or `None` before initialization. +pub(crate) fn admin(env: &Env) -> Option
{ + env.storage().instance().get(&StorageKey::Admin) +} + +pub(crate) fn set_admin(env: &Env, new_admin: &Address) { + env.storage().instance().set(&StorageKey::Admin, new_admin); +} + +/// Grant a non-admin role to `account`. Admin ownership only changes through +/// the timelocked `propose_admin` / `claim_admin` flow. +pub fn grant_role(env: &Env, caller: &Address, role: Role, account: &Address) { + caller.require_auth(); + crate::require_admin!(env, caller); + if role == Role::Admin { + soroban_sdk::panic_with_error!(env, AcError::InvalidRole); + } + let key = StorageKey::RoleMember(role, account.clone()); + if env + .storage() + .instance() + .get::<_, bool>(&key) + .unwrap_or(false) + { + soroban_sdk::panic_with_error!(env, AcError::AlreadyHasRole); + } + env.storage().instance().set(&key, &true); + env.events().publish( + (symbol_short!("AC_ROLE"), role), + (account.clone(), true, caller.clone()), + ); +} + +/// Revoke a non-admin role from `account`. +pub fn revoke_role(env: &Env, caller: &Address, role: Role, account: &Address) { + caller.require_auth(); + crate::require_admin!(env, caller); + if role == Role::Admin { + soroban_sdk::panic_with_error!(env, AcError::InvalidRole); + } + let key = StorageKey::RoleMember(role, account.clone()); + if !env + .storage() + .instance() + .get::<_, bool>(&key) + .unwrap_or(false) + { + soroban_sdk::panic_with_error!(env, AcError::RoleNotGranted); + } + env.storage().instance().remove(&key); + env.events().publish( + (symbol_short!("AC_ROLE"), role), + (account.clone(), false, caller.clone()), + ); +} + +/// Returns true if the contract is currently paused. +pub fn is_paused(env: &Env) -> bool { + env.storage() + .instance() + .get::(&PAUSED_KEY) + .unwrap_or(false) +} + +/// Pause application operations. Admins and guardians may pause. +pub fn pause(env: &Env, caller: &Address) { + caller.require_auth(); + if !has_role(env, caller, Role::Admin) && !has_role(env, caller, Role::Guardian) { + soroban_sdk::panic_with_error!(env, AcError::Unauthorized); + } + env.storage().instance().set(&PAUSED_KEY, &true); + env.events() + .publish((symbol_short!("AC_PAUSE"),), caller.clone()); +} + +/// Resume application operations. Only the admin may unpause. +pub fn unpause(env: &Env, caller: &Address) { + caller.require_auth(); + crate::require_admin!(env, caller); + env.storage().instance().set(&PAUSED_KEY, &false); + env.events() + .publish((symbol_short!("AC_UNPAUSE"),), caller.clone()); +} diff --git a/contracts/access_control/src/test.rs b/contracts/access_control/src/test.rs new file mode 100644 index 000000000..63d2c5f30 --- /dev/null +++ b/contracts/access_control/src/test.rs @@ -0,0 +1,150 @@ +use super::*; +use soroban_sdk::{testutils::Address as _, Address, Env}; + +fn in_contract(env: &Env, f: impl FnOnce() -> T) -> T { + let contract_id = Address::generate(env); + env.as_contract(&contract_id, f) +} + +#[test] +fn initializes_once_and_preserves_admin() { + let env = Env::default(); + let first = Address::generate(&env); + let second = Address::generate(&env); + env.mock_all_auths(); + + in_contract(&env, || { + initialize(&env, &first); + initialize(&env, &second); + assert_eq!(admin(&env), Some(first.clone())); + assert!(has_role(&env, &first, Role::Admin)); + assert!(!has_role(&env, &second, Role::Admin)); + }); +} + +#[test] +fn admin_can_grant_and_revoke_application_roles() { + let env = Env::default(); + let admin = Address::generate(&env); + let instructor = Address::generate(&env); + env.mock_all_auths(); + + in_contract(&env, || { + initialize(&env, &admin); + grant_role(&env, &admin, Role::Instructor, &instructor); + assert!(has_role(&env, &instructor, Role::Instructor)); + revoke_role(&env, &admin, Role::Instructor, &instructor); + assert!(!has_role(&env, &instructor, Role::Instructor)); + }); +} + +#[test] +#[should_panic(expected = "Error(Contract, #307)")] +fn admin_role_cannot_be_granted_directly() { + let env = Env::default(); + let admin = Address::generate(&env); + let candidate = Address::generate(&env); + env.mock_all_auths(); + + in_contract(&env, || { + initialize(&env, &admin); + grant_role(&env, &admin, Role::Admin, &candidate); + }); +} + +#[test] +#[should_panic(expected = "Error(Contract, #302)")] +fn candidate_cannot_claim_before_48_hours() { + let env = Env::default(); + let current_admin = Address::generate(&env); + let candidate = Address::generate(&env); + env.mock_all_auths(); + + in_contract(&env, || { + initialize(&env, ¤t_admin); + propose_admin(&env, ¤t_admin, &candidate); + claim_admin(&env, &candidate); + }); +} + +#[test] +fn candidate_claims_admin_after_48_hours() { + let env = Env::default(); + let current_admin = Address::generate(&env); + let candidate = Address::generate(&env); + let instructor = Address::generate(&env); + let contract_id = Address::generate(&env); + env.mock_all_auths(); + + let unlock_at = env.as_contract(&contract_id, || { + initialize(&env, ¤t_admin); + let unlock_at = propose_admin(&env, ¤t_admin, &candidate); + assert_eq!(pending_admin(&env).unwrap().unlock_at, unlock_at); + assert_eq!(unlock_at, env.ledger().timestamp() + ADMIN_HANDOVER_DELAY); + unlock_at + }); + + env.ledger().with_mut(|ledger| ledger.timestamp = unlock_at); + env.as_contract(&contract_id, || { + claim_admin(&env, &candidate); + + assert_eq!(admin(&env), Some(candidate.clone())); + assert!(has_role(&env, &candidate, Role::Admin)); + assert!(!has_role(&env, ¤t_admin, Role::Admin)); + assert!(pending_admin(&env).is_none()); + + grant_role(&env, &candidate, Role::Instructor, &instructor); + assert!(has_role(&env, &instructor, Role::Instructor)); + }); +} + +#[test] +#[should_panic(expected = "Error(Contract, #300)")] +fn only_the_proposed_candidate_can_claim_admin() { + let env = Env::default(); + let current_admin = Address::generate(&env); + let candidate = Address::generate(&env); + let other = Address::generate(&env); + let contract_id = Address::generate(&env); + env.mock_all_auths(); + + let unlock_at = env.as_contract(&contract_id, || { + initialize(&env, ¤t_admin); + propose_admin(&env, ¤t_admin, &candidate) + }); + env.ledger().with_mut(|ledger| ledger.timestamp = unlock_at); + env.as_contract(&contract_id, || { + claim_admin(&env, &other); + }); +} + +#[test] +fn current_admin_can_cancel_a_pending_handover() { + let env = Env::default(); + let current_admin = Address::generate(&env); + let candidate = Address::generate(&env); + env.mock_all_auths(); + + in_contract(&env, || { + initialize(&env, ¤t_admin); + propose_admin(&env, ¤t_admin, &candidate); + cancel_admin_proposal(&env, ¤t_admin); + assert!(pending_admin(&env).is_none()); + assert_eq!(admin(&env), Some(current_admin.clone())); + }); +} + +#[test] +#[should_panic(expected = "Error(Contract, #300)")] +fn non_admin_cannot_grant_roles() { + let env = Env::default(); + let admin = Address::generate(&env); + let attacker = Address::generate(&env); + let instructor = Address::generate(&env); + env.mock_all_auths(); + + in_contract(&env, || { + initialize(&env, &admin); + grant_role(&env, &attacker, Role::Instructor, &instructor); + }); +} diff --git a/contracts/access_control/src/timelock.rs b/contracts/access_control/src/timelock.rs new file mode 100644 index 000000000..23401b3ea --- /dev/null +++ b/contracts/access_control/src/timelock.rs @@ -0,0 +1,92 @@ +use crate::{roles, AcError}; +use soroban_sdk::{contracttype, symbol_short, Address, Env}; + +/// Required delay between proposing and claiming admin ownership: 48 hours. +pub const ADMIN_HANDOVER_DELAY: u64 = 48 * 60 * 60; + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AdminProposal { + pub candidate: Address, + pub proposed_at: u64, + pub unlock_at: u64, +} + +#[contracttype] +#[derive(Clone)] +enum TimelockKey { + PendingAdmin, +} + +/// Current admin proposes a candidate. Replacing a proposal restarts the full +/// delay and is visible as a new event. +pub fn propose_admin(env: &Env, caller: &Address, candidate: &Address) -> u64 { + caller.require_auth(); + crate::require_admin!(env, caller); + if candidate == caller { + soroban_sdk::panic_with_error!(env, AcError::InvalidAdminProposal); + } + let proposed_at = env.ledger().timestamp(); + let unlock_at = proposed_at + .checked_add(ADMIN_HANDOVER_DELAY) + .unwrap_or_else(|| soroban_sdk::panic_with_error!(env, AcError::Overflow)); + let proposal = AdminProposal { + candidate: candidate.clone(), + proposed_at, + unlock_at, + }; + env.storage() + .instance() + .set(&TimelockKey::PendingAdmin, &proposal); + env.events().publish( + (symbol_short!("AC_ADMIN"), symbol_short!("proposed")), + (caller.clone(), candidate.clone(), unlock_at), + ); + unlock_at +} + +/// Candidate accepts the admin role after the timelock and must authorize +/// the claim themselves. +pub fn claim_admin(env: &Env, candidate: &Address) { + candidate.require_auth(); + let proposal = pending_admin(env) + .unwrap_or_else(|| soroban_sdk::panic_with_error!(env, AcError::NoPendingTransfer)); + if proposal.candidate != *candidate { + soroban_sdk::panic_with_error!(env, AcError::Unauthorized); + } + if env.ledger().timestamp() < proposal.unlock_at { + soroban_sdk::panic_with_error!(env, AcError::TimelockNotExpired); + } + + let previous_admin = + admin(env).unwrap_or_else(|| soroban_sdk::panic_with_error!(env, AcError::Unauthorized)); + roles::set_admin(env, candidate); + env.storage().instance().remove(&TimelockKey::PendingAdmin); + env.events().publish( + (symbol_short!("AC_ADMIN"), symbol_short!("transfer")), + (previous_admin, candidate.clone()), + ); +} + +/// Cancel a pending transfer. Only the current admin can cancel. +pub fn cancel_admin_proposal(env: &Env, caller: &Address) { + caller.require_auth(); + crate::require_admin!(env, caller); + let proposal = pending_admin(env) + .unwrap_or_else(|| soroban_sdk::panic_with_error!(env, AcError::NoPendingTransfer)); + env.storage().instance().remove(&TimelockKey::PendingAdmin); + env.events().publish( + (symbol_short!("AC_ADMIN"), symbol_short!("cancel")), + (caller.clone(), proposal.candidate), + ); +} + +/// Returns the current admin if initialized. +pub fn admin(env: &Env) -> Option
{ + roles::admin(env) +} + +/// Returns the pending handover, if any. +pub fn pending_admin(env: &Env) -> Option { + env.storage().instance().get(&TimelockKey::PendingAdmin) +} diff --git a/contracts/amm/Cargo.toml b/contracts/amm/Cargo.toml index f430174d8..9ce6c99d2 100644 --- a/contracts/amm/Cargo.toml +++ b/contracts/amm/Cargo.toml @@ -8,6 +8,7 @@ crate-type = ["cdylib", "rlib"] [dependencies] soroban-sdk = "28.0.0" +contract-events = { path = "../contract_events" } [dev-dependencies] soroban-sdk = { version = "28.0.0", features = ["testutils"] } diff --git a/contracts/amm/src/circuit_breaker.rs b/contracts/amm/src/circuit_breaker.rs new file mode 100644 index 000000000..35bb4afae --- /dev/null +++ b/contracts/amm/src/circuit_breaker.rs @@ -0,0 +1,109 @@ +use crate::{AmmError, DataKey}; +use soroban_sdk::{panic_with_error, symbol_short, Address, Env, Symbol}; + +pub const WITHDRAWAL_WINDOW_SECONDS: u64 = 24 * 60 * 60; + +const PAUSED: Symbol = symbol_short!("amm_pause"); +const WINDOW_START: Symbol = symbol_short!("wstart"); +const WINDOW_LIMIT_A: Symbol = symbol_short!("wla"); +const WINDOW_LIMIT_B: Symbol = symbol_short!("wlb"); +const WINDOW_USED_A: Symbol = symbol_short!("wua"); +const WINDOW_USED_B: Symbol = symbol_short!("wub"); + +pub fn is_paused(env: &Env) -> bool { + env.storage() + .instance() + .get::<_, bool>(&PAUSED) + .unwrap_or(false) +} + +pub fn set_paused(env: &Env, paused: bool, caller: &Address) { + env.storage().instance().set(&PAUSED, &paused); + crate::events::pause_changed(env, caller, paused); +} + +pub fn assert_not_paused(env: &Env) { + if is_paused(env) { + panic_with_error!(env, AmmError::ContractPaused); + } +} + +/// Limit token outflows independently because the AMM has no common price +/// oracle for comparing reserves denominated in different assets. +pub fn record_outflow(env: &Env, reserve_a: i128, reserve_b: i128, amount_a: i128, amount_b: i128) { + let now = env.ledger().timestamp(); + let start = env.storage().instance().get::<_, u64>(&WINDOW_START); + let window_open = start + .map(|start| now.saturating_sub(start) < WITHDRAWAL_WINDOW_SECONDS) + .unwrap_or(false); + + let (limit_a, limit_b, used_a, used_b) = if window_open { + ( + env.storage() + .instance() + .get::<_, i128>(&WINDOW_LIMIT_A) + .unwrap_or(0), + env.storage() + .instance() + .get::<_, i128>(&WINDOW_LIMIT_B) + .unwrap_or(0), + env.storage() + .instance() + .get::<_, i128>(&WINDOW_USED_A) + .unwrap_or(0), + env.storage() + .instance() + .get::<_, i128>(&WINDOW_USED_B) + .unwrap_or(0), + ) + } else { + let limit_a = daily_limit(env, reserve_a); + let limit_b = daily_limit(env, reserve_b); + env.storage().instance().set(&WINDOW_START, &now); + env.storage().instance().set(&WINDOW_LIMIT_A, &limit_a); + env.storage().instance().set(&WINDOW_LIMIT_B, &limit_b); + env.storage().instance().set(&WINDOW_USED_A, &0i128); + env.storage().instance().set(&WINDOW_USED_B, &0i128); + (limit_a, limit_b, 0, 0) + }; + + let new_used_a = used_a + .checked_add(amount_a) + .unwrap_or_else(|| panic_with_error!(env, AmmError::Overflow)); + let new_used_b = used_b + .checked_add(amount_b) + .unwrap_or_else(|| panic_with_error!(env, AmmError::Overflow)); + if amount_a > limit_a || amount_b > limit_b || new_used_a > limit_a || new_used_b > limit_b { + panic_with_error!(env, AmmError::WithdrawalLimitExceeded); + } + env.storage().instance().set(&WINDOW_USED_A, &new_used_a); + env.storage().instance().set(&WINDOW_USED_B, &new_used_b); +} + +fn daily_limit(env: &Env, reserve: i128) -> i128 { + if reserve < 0 { + panic_with_error!(env, AmmError::Overflow); + } + reserve + .checked_div(10) + .unwrap_or_else(|| panic_with_error!(env, AmmError::Overflow)) +} + +pub fn admin(env: &Env) -> Address { + env.storage() + .instance() + .get(&DataKey::Admin) + .unwrap_or_else(|| panic_with_error!(env, AmmError::NotInitialized)) +} + +pub fn set_guardian(env: &Env, guardian: &Address) { + env.storage().instance().set(&DataKey::Guardian, guardian); +} + +pub fn is_guardian(env: &Env, account: &Address) -> bool { + env.storage() + .instance() + .get::<_, Address>(&DataKey::Guardian) + .as_ref() + == Some(account) +} diff --git a/contracts/amm/src/events.rs b/contracts/amm/src/events.rs new file mode 100644 index 000000000..7dba31cef --- /dev/null +++ b/contracts/amm/src/events.rs @@ -0,0 +1,140 @@ +use contract_events::{publish_standard_event, topic}; +use soroban_sdk::{Address, Env}; + +pub fn initialized( + env: &Env, + admin: &Address, + token_a: &Address, + token_b: &Address, + pool_share: &Address, + fee_collector: &Address, + base_fee_bps: i128, +) { + publish_standard_event( + env, + topic::AMM_INITIALIZED, + admin.clone(), + ( + token_a.clone(), + token_b.clone(), + pool_share.clone(), + fee_collector.clone(), + base_fee_bps, + env.ledger().timestamp(), + ), + ); +} + +pub fn guardian_changed(env: &Env, admin: &Address, guardian: &Address) { + publish_standard_event( + env, + topic::AMM_GUARDIAN, + guardian.clone(), + (admin.clone(), env.ledger().timestamp()), + ); +} + +pub fn liquidity_added( + env: &Env, + provider: &Address, + amount_a: i128, + amount_b: i128, + shares: i128, + reserve_a: i128, + reserve_b: i128, +) { + publish_standard_event( + env, + topic::LIQUIDITY_ADDED, + provider.clone(), + ( + amount_a, + amount_b, + shares, + reserve_a, + reserve_b, + env.ledger().timestamp(), + ), + ); +} + +pub fn liquidity_removed( + env: &Env, + provider: &Address, + amount_a: i128, + amount_b: i128, + shares: i128, + reserve_a: i128, + reserve_b: i128, +) { + publish_standard_event( + env, + topic::LIQUIDITY_REMOVED, + provider.clone(), + ( + amount_a, + amount_b, + shares, + reserve_a, + reserve_b, + env.ledger().timestamp(), + ), + ); +} + +pub fn swap_a_for_b( + env: &Env, + trader: &Address, + amount_in: i128, + amount_out: i128, + fee: i128, + reserve_a: i128, + reserve_b: i128, +) { + publish_standard_event( + env, + topic::SWAP_A_FOR_B, + trader.clone(), + ( + amount_in, + amount_out, + fee, + reserve_a, + reserve_b, + env.ledger().timestamp(), + ), + ); +} + +pub fn swap_b_for_a( + env: &Env, + trader: &Address, + amount_in: i128, + amount_out: i128, + fee: i128, + reserve_a: i128, + reserve_b: i128, +) { + publish_standard_event( + env, + topic::SWAP_B_FOR_A, + trader.clone(), + ( + amount_in, + amount_out, + fee, + reserve_a, + reserve_b, + env.ledger().timestamp(), + ), + ); +} + +pub fn pause_changed(env: &Env, caller: &Address, paused: bool) { + publish_standard_event( + env, + topic::PAUSE, + caller.clone(), + (paused, env.ledger().timestamp()), + ); +} diff --git a/contracts/amm/src/lib.rs b/contracts/amm/src/lib.rs index 0e27c3355..f05fd9f7a 100644 --- a/contracts/amm/src/lib.rs +++ b/contracts/amm/src/lib.rs @@ -8,10 +8,7 @@ //! - Virtual balances to mitigate sandwich attacks #![no_std] -use soroban_sdk::{ - contract, contracterror, contractimpl, panic_with_error, token, Address, Env, - Symbol, -}; +use soroban_sdk::{contract, contracterror, contractimpl, panic_with_error, token, Address, Env}; /// Basis points denominator (10,000 bps = 100%) const BPS: i128 = 10_000; @@ -43,8 +40,18 @@ pub enum AmmError { SlippageExceeded = 403, /// `404` — Contract has not been initialised. NotInitialized = 404, + /// `405` — AMM operations are paused by the circuit breaker. + ContractPaused = 405, + /// `406` — Caller is not authorized to manage the circuit breaker. + Unauthorized = 406, + /// `407` — The 24-hour token outflow budget has been exceeded. + WithdrawalLimitExceeded = 407, + /// `408` — Arithmetic overflow. + Overflow = 408, } +pub mod circuit_breaker; +pub mod events; pub mod storage; pub mod twap; @@ -68,11 +75,13 @@ impl AMMContract { if env.storage().instance().has(&DataKey::Admin) { panic_with_error!(&env, AmmError::AlreadyInitialized); } + admin.require_auth(); if base_fee_bps < MIN_SWAP_FEE_BPS || base_fee_bps > MAX_SWAP_FEE_BPS { panic_with_error!(&env, AmmError::InvalidFee); } env.storage().instance().set(&DataKey::Admin, &admin); + circuit_breaker::set_guardian(&env, &admin); env.storage().instance().set(&DataKey::TokenA, &token_a); env.storage().instance().set(&DataKey::TokenB, &token_b); env.storage() @@ -94,12 +103,45 @@ impl AMMContract { .instance() .set(&DataKey::TotalShares, &DEAD_SHARES); - env.events().publish( - (Symbol::new(&env, "amm_initialized"),), - (fee_collector, base_fee_bps), + events::initialized( + &env, + &admin, + &token_a, + &token_b, + &pool_share, + &fee_collector, + base_fee_bps, ); } + /// Set the address authorized to trigger the emergency pause. + pub fn set_guardian(env: Env, admin: Address, guardian: Address) { + admin.require_auth(); + if admin != circuit_breaker::admin(&env) { + panic_with_error!(&env, AmmError::Unauthorized); + } + circuit_breaker::set_guardian(&env, &guardian); + events::guardian_changed(&env, &admin, &guardian); + } + + /// Pause swaps and liquidity changes immediately. The admin or designated + /// guardian can pause; only the admin can unpause. + pub fn set_paused(env: Env, caller: Address, paused: bool) { + caller.require_auth(); + let is_admin = caller == circuit_breaker::admin(&env); + if !is_admin && !circuit_breaker::is_guardian(&env, &caller) { + panic_with_error!(&env, AmmError::Unauthorized); + } + if !paused && !is_admin { + panic_with_error!(&env, AmmError::Unauthorized); + } + circuit_breaker::set_paused(&env, paused, &caller); + } + + pub fn is_paused(env: Env) -> bool { + circuit_breaker::is_paused(&env) + } + /// Adds liquidity and mints pool shares. Requires equal value deposit (x * y = k maintained). pub fn add_liquidity( env: Env, @@ -109,6 +151,7 @@ impl AMMContract { min_shares: i128, ) -> i128 { provider.require_auth(); + circuit_breaker::assert_not_paused(&env); if amount_a <= 0 || amount_b <= 0 { panic_with_error!(&env, AmmError::InvalidAmount); } @@ -180,9 +223,14 @@ impl AMMContract { pool_share_client.mint(&provider, &shares); - env.events().publish( - (Symbol::new(&env, "liquidity_added"),), - (provider, amount_a, amount_b, shares), + events::liquidity_added( + &env, + &provider, + amount_a, + amount_b, + shares, + balance_a + amount_a, + balance_b + amount_b, ); shares @@ -197,6 +245,7 @@ impl AMMContract { min_amount_b: i128, ) -> (i128, i128) { provider.require_auth(); + circuit_breaker::assert_not_paused(&env); if shares <= 0 { panic_with_error!(&env, AmmError::InvalidAmount); } @@ -215,6 +264,7 @@ impl AMMContract { let amount_a = (shares * balance_a) / total_shares; let amount_b = (shares * balance_b) / total_shares; + circuit_breaker::record_outflow(&env, balance_a, balance_b, amount_a, amount_b); if amount_a < min_amount_a || amount_b < min_amount_b { panic_with_error!(&env, AmmError::SlippageExceeded); @@ -244,9 +294,14 @@ impl AMMContract { token_a_client.transfer(&contract_addr, &provider, &amount_a); token_b_client.transfer(&contract_addr, &provider, &amount_b); - env.events().publish( - (Symbol::new(&env, "liquidity_removed"),), - (provider, amount_a, amount_b, shares), + events::liquidity_removed( + &env, + &provider, + amount_a, + amount_b, + shares, + balance_a - amount_a, + balance_b - amount_b, ); (amount_a, amount_b) @@ -255,6 +310,7 @@ impl AMMContract { /// Swaps token A for token B with dynamic fee based on price impact. pub fn swap_a_for_b(env: Env, user: Address, amount_in: i128, min_amount_out: i128) -> i128 { user.require_auth(); + circuit_breaker::assert_not_paused(&env); if amount_in <= 0 { panic_with_error!(&env, AmmError::InvalidAmount); } @@ -290,6 +346,7 @@ impl AMMContract { let fee = (amount_out_before_fee * capped_fee_bps) / BPS; let amount_out = amount_out_before_fee - fee; + circuit_breaker::record_outflow(&env, balance_a, balance_b, 0, amount_out_before_fee); if amount_out < min_amount_out { panic_with_error!(&env, AmmError::SlippageExceeded); @@ -310,9 +367,14 @@ impl AMMContract { token_b_client.transfer(&contract_addr, &user, &amount_out); token_b_client.transfer(&contract_addr, &fee_collector, &fee); - env.events().publish( - (Symbol::new(&env, "swap_a_for_b"),), - (user, amount_in, amount_out, fee), + events::swap_a_for_b( + &env, + &user, + amount_in, + amount_out, + fee, + new_a, + balance_b - amount_out - fee, ); amount_out @@ -321,6 +383,7 @@ impl AMMContract { /// Swaps token B for token A with dynamic fee based on price impact. pub fn swap_b_for_a(env: Env, user: Address, amount_in: i128, min_amount_out: i128) -> i128 { user.require_auth(); + circuit_breaker::assert_not_paused(&env); if amount_in <= 0 { panic_with_error!(&env, AmmError::InvalidAmount); } @@ -356,6 +419,7 @@ impl AMMContract { let fee = (amount_out_before_fee * capped_fee_bps) / BPS; let amount_out = amount_out_before_fee - fee; + circuit_breaker::record_outflow(&env, balance_a, balance_b, amount_out_before_fee, 0); if amount_out < min_amount_out { panic_with_error!(&env, AmmError::SlippageExceeded); @@ -376,9 +440,14 @@ impl AMMContract { token_a_client.transfer(&contract_addr, &user, &amount_out); token_a_client.transfer(&contract_addr, &fee_collector, &fee); - env.events().publish( - (Symbol::new(&env, "swap_b_for_a"),), - (user, amount_in, amount_out, fee), + events::swap_b_for_a( + &env, + &user, + amount_in, + amount_out, + fee, + balance_a - amount_out - fee, + new_b, ); amount_out @@ -406,9 +475,21 @@ impl AMMContract { /// Returns current cumulative prices and last block timestamp for TWAP oracle consumers. pub fn get_twap_cumulative(env: Env) -> (i128, i128, u64) { - let price_a: i128 = env.storage().instance().get(&DataKey::PriceACumulative).unwrap_or(0); - let price_b: i128 = env.storage().instance().get(&DataKey::PriceBCumulative).unwrap_or(0); - let last_time: u64 = env.storage().instance().get(&DataKey::LastBlockTimestamp).unwrap_or(0); + let price_a: i128 = env + .storage() + .instance() + .get(&DataKey::PriceACumulative) + .unwrap_or(0); + let price_b: i128 = env + .storage() + .instance() + .get(&DataKey::PriceBCumulative) + .unwrap_or(0); + let last_time: u64 = env + .storage() + .instance() + .get(&DataKey::LastBlockTimestamp) + .unwrap_or(0); (price_a, price_b, last_time) } diff --git a/contracts/amm/src/storage.rs b/contracts/amm/src/storage.rs index a165a6a8b..8147e138f 100644 --- a/contracts/amm/src/storage.rs +++ b/contracts/amm/src/storage.rs @@ -17,6 +17,7 @@ pub struct TwapSnapshot { #[derive(Clone, Debug, Eq, PartialEq)] pub enum DataKey { Admin, + Guardian, TokenA, TokenB, PoolShare, diff --git a/contracts/amm/src/test.rs b/contracts/amm/src/test.rs index 27651dbad..27def3c93 100644 --- a/contracts/amm/src/test.rs +++ b/contracts/amm/src/test.rs @@ -1,6 +1,7 @@ use super::*; -use soroban_sdk::testutils::{Address as _, Ledger as _}; -use soroban_sdk::{token, Address, Env}; +use contract_events::topic; +use soroban_sdk::testutils::{Address as _, Events as _, Ledger as _}; +use soroban_sdk::{token, xdr, Address, Env, Symbol, TryFromVal, Val, Vec}; fn setup( env: &Env, @@ -63,6 +64,31 @@ fn test_add_liquidity_initializes_pool() { assert_eq!(total, shares + DEAD_SHARES); } +#[test] +fn test_liquidity_event_indexes_provider() { + let env = Env::default(); + let (admin, client, _, _, _) = setup(&env); + client.add_liquidity(&admin, &1_000_000, &1_000_000, &0); + + let mut found = false; + for event in env.events().all().events() { + if let xdr::ContractEventBody::V0(v0) = &event.body { + let topics: Vec = Vec::try_from_val(&env, &v0.topics).unwrap(); + if Symbol::try_from_val(&env, &topics.get(0).unwrap()).unwrap() + == topic::LIQUIDITY_ADDED + { + assert_eq!(topics.len(), 2); + assert_eq!( + Address::try_from_val(&env, &topics.get(1).unwrap()).unwrap(), + admin + ); + found = true; + } + } + } + assert!(found); +} + #[test] fn test_constant_product_invariant_swap() { let env = Env::default(); @@ -137,8 +163,8 @@ fn test_remove_liquidity() { let shares_minted = client.add_liquidity(&admin, &1_000_000, &1_000_000, &0); - // Remove half of liquidity - let (amount_a, amount_b) = client.remove_liquidity(&admin, &(shares_minted / 2), &0, &0); + // Keep the withdrawal within the 10% daily outflow limit. + let (amount_a, amount_b) = client.remove_liquidity(&admin, &(shares_minted / 10), &0, &0); assert!(amount_a > 0); assert!(amount_b > 0); @@ -243,10 +269,11 @@ fn test_twap_resistant_to_single_block_flash_manipulation() { let twap_before_flash = client.twap_consult_30m(&token_a, &1000); - // Attacker conducts a massive flash-swap in the EXACT same block (timestamp does not advance) + // The circuit breaker rejects an oversized flash-swap in the same window. let attacker = Address::generate(&env); token_a_client.mint(&attacker, &500_000); - client.swap_a_for_b(&attacker, &500_000, &0); + let result = client.try_swap_a_for_b(&attacker, &500_000, &0); + assert!(result.is_err()); // Instantaneous spot price is heavily manipulated, but TWAP accumulator in same block does NOT move: let twap_during_flash = client.twap_consult_30m(&token_a, &1000); @@ -255,3 +282,46 @@ fn test_twap_resistant_to_single_block_flash_manipulation() { assert_eq!(twap_before_flash, twap_during_flash); } +#[test] +fn test_guardian_pause_blocks_amm_operations() { + let env = Env::default(); + let (admin, client, token_a, _, _) = setup(&env); + let shares = client.add_liquidity(&admin, &1_000_000, &1_000_000, &0); + + let guardian = Address::generate(&env); + client.set_guardian(&admin, &guardian); + client.set_paused(&guardian, &true); + assert!(client.is_paused()); + + let user = Address::generate(&env); + token::StellarAssetClient::new(&env, &token_a).mint(&user, &1_000); + assert!(client.try_swap_a_for_b(&user, &1_000, &0).is_err()); + assert!(client + .try_add_liquidity(&admin, &1_000, &1_000, &0) + .is_err()); + assert!(client + .try_remove_liquidity(&admin, &shares, &0, &0) + .is_err()); + + assert!(client.try_set_paused(&user, &true).is_err()); + client.set_paused(&admin, &false); + assert!(!client.is_paused()); + assert!(client.swap_a_for_b(&user, &1_000, &0) > 0); +} + +#[test] +fn test_swap_outflow_limit_accumulates_and_resets_after_24_hours() { + let env = Env::default(); + let (admin, client, token_a, _, _) = setup(&env); + client.add_liquidity(&admin, &1_000_000, &1_000_000, &0); + + let user = Address::generate(&env); + let token_a_client = token::StellarAssetClient::new(&env, &token_a); + token_a_client.mint(&user, &180_000); + assert!(client.swap_a_for_b(&user, &60_000, &0) > 0); + assert!(client.try_swap_a_for_b(&user, &60_000, &0).is_err()); + + env.ledger() + .with_mut(|ledger| ledger.timestamp += circuit_breaker::WITHDRAWAL_WINDOW_SECONDS); + assert!(client.swap_a_for_b(&user, &60_000, &0) > 0); +} diff --git a/contracts/certificate/Cargo.toml b/contracts/certificate/Cargo.toml index 5a07611b0..9a3ff3e90 100644 --- a/contracts/certificate/Cargo.toml +++ b/contracts/certificate/Cargo.toml @@ -8,6 +8,7 @@ crate-type = ["cdylib", "rlib"] [dependencies] soroban-sdk = "28.0.0" +contract-events = { path = "../contract_events" } [dev-dependencies] soroban-sdk = { version = "28.0.0", features = ["testutils"] } diff --git a/contracts/certificate/src/events.rs b/contracts/certificate/src/events.rs new file mode 100644 index 000000000..4b82a1bc1 --- /dev/null +++ b/contracts/certificate/src/events.rs @@ -0,0 +1,102 @@ +use contract_events::{publish_standard_event, topic}; +use soroban_sdk::{Address, BytesN, Env, String}; + +pub fn initialized(env: &Env, admin: &Address) { + publish_standard_event( + env, + topic::CERT_ADMIN, + admin.clone(), + (env.ledger().timestamp(),), + ); +} + +pub fn issued( + env: &Env, + owner: &Address, + issuer: &Address, + cert_id: &BytesN<32>, + course_id: &String, + content_hash: &BytesN<32>, +) { + publish_standard_event( + env, + topic::CERT_ISSUED, + owner.clone(), + ( + cert_id.clone(), + issuer.clone(), + course_id.clone(), + content_hash.clone(), + env.ledger().timestamp(), + ), + ); +} + +pub fn revoked( + env: &Env, + owner: &Address, + issuer: &Address, + cert_id: &BytesN<32>, + reason: &String, +) { + publish_standard_event( + env, + topic::CERT_REVOKED, + owner.clone(), + ( + cert_id.clone(), + issuer.clone(), + reason.clone(), + env.ledger().timestamp(), + ), + ); +} + +pub fn reissued( + env: &Env, + owner: &Address, + issuer: &Address, + old_cert_id: &BytesN<32>, + new_cert_id: &BytesN<32>, +) { + publish_standard_event( + env, + topic::CERT_REISSUED, + owner.clone(), + ( + old_cert_id.clone(), + new_cert_id.clone(), + issuer.clone(), + env.ledger().timestamp(), + ), + ); +} + +pub fn royalty_updated( + env: &Env, + owner: &Address, + cert_id: &BytesN<32>, + issuer: &Address, + royalty_bps: u32, +) { + publish_standard_event( + env, + topic::CERT_ROYALTY, + owner.clone(), + ( + cert_id.clone(), + issuer.clone(), + royalty_bps, + env.ledger().timestamp(), + ), + ); +} + +pub fn cohort_anchored(env: &Env, cohort_id: &String, issuer: &Address, root_hash: &BytesN<32>) { + publish_standard_event( + env, + topic::COHORT_ROOT, + cohort_id.clone(), + (issuer.clone(), root_hash.clone(), env.ledger().timestamp()), + ); +} diff --git a/contracts/certificate/src/lib.rs b/contracts/certificate/src/lib.rs index c9a2b2923..4091894b6 100644 --- a/contracts/certificate/src/lib.rs +++ b/contracts/certificate/src/lib.rs @@ -1,5 +1,7 @@ #![no_std] +pub mod events; + use soroban_sdk::{ contract, contracterror, contractimpl, contracttype, panic_with_error, Address, Bytes, BytesN, Env, String, Vec, @@ -68,6 +70,7 @@ impl CertificateContract { pub fn initialize(env: Env, admin: Address) { env.storage().instance().set(&DataKey::Admin, &admin); env.storage().instance().set(&DataKey::NextCertId, &0u64); + events::initialized(&env, &admin); } pub fn issue_certificate( @@ -124,6 +127,14 @@ impl CertificateContract { String::from_str(&env, "ISSUED"), String::from_str(&env, ""), ); + events::issued( + &env, + &owner, + &issuer, + &cert_id_bytes, + &course_id, + &record.content_hash, + ); cert_id_bytes } @@ -153,6 +164,13 @@ impl CertificateContract { String::from_str(&env, "REVOKED"), reason_code, ); + events::revoked( + &env, + &record.owner, + &record.issuer, + &cert_id, + &record.revocation_reason, + ); } pub fn reissue_certificate( @@ -210,7 +228,7 @@ impl CertificateContract { old_record.status = CertificateStatus::Reissued; env.storage() .persistent() - .set(&DataKey::Certificate(old_cert_id), &old_record); + .set(&DataKey::Certificate(old_cert_id.clone()), &old_record); Self::log_audit( &env, @@ -219,6 +237,13 @@ impl CertificateContract { String::from_str(&env, "REISSUED"), String::from_str(&env, ""), ); + events::reissued( + &env, + &new_record.owner, + &new_record.issuer, + &old_cert_id, + &new_cert_id_bytes, + ); new_cert_id_bytes } @@ -243,6 +268,7 @@ impl CertificateContract { env.storage() .persistent() .extend_ttl(&DataKey::MerkleRoot(cohort_id), 500_000, 500_000); + events::cohort_anchored(&env, &root.cohort_id, &root.issuer, &root.root_hash); } pub fn verify_merkle_inclusion( @@ -304,7 +330,8 @@ impl CertificateContract { record.royalty_bps = royalty_bps; env.storage() .persistent() - .set(&DataKey::Certificate(cert_id), &record); + .set(&DataKey::Certificate(cert_id.clone()), &record); + events::royalty_updated(&env, &record.owner, &cert_id, &record.issuer, royalty_bps); } /// Compute the SEP-0029 royalty split for a secondary sale of diff --git a/contracts/certificate/src/test.rs b/contracts/certificate/src/test.rs index ae63cd2c0..f39a0a091 100644 --- a/contracts/certificate/src/test.rs +++ b/contracts/certificate/src/test.rs @@ -1,7 +1,11 @@ #![cfg(test)] use super::*; -use soroban_sdk::{testutils::Address as _, Bytes, BytesN, Env, String, Vec}; +use contract_events::topic; +use soroban_sdk::{ + testutils::{Address as _, Events as _}, + xdr, Address, Bytes, BytesN, Env, String, Symbol, TryFromVal, Val, Vec, +}; /// Helper harness to set up the Soroban environment, register the contract, /// initialize the admin, and generate mock participants. @@ -88,6 +92,30 @@ fn test_issue_certificate_success() { assert_eq!(log.reason, String::from_str(&env, "")); } +#[test] +fn test_certificate_issue_event_indexes_owner() { + let (env, client, _admin, issuer, owner) = setup_test(); + let course_id = String::from_str(&env, "SOROBAN-101"); + let content_hash = BytesN::from_array(&env, &[0xCC; 32]); + client.issue_certificate(&owner, &issuer, &course_id, &content_hash); + + let mut found = false; + for event in env.events().all().events() { + if let xdr::ContractEventBody::V0(v0) = &event.body { + let topics: Vec = Vec::try_from_val(&env, &v0.topics).unwrap(); + if Symbol::try_from_val(&env, &topics.get(0).unwrap()).unwrap() == topic::CERT_ISSUED { + assert_eq!(topics.len(), 2); + assert_eq!( + Address::try_from_val(&env, &topics.get(1).unwrap()).unwrap(), + owner + ); + found = true; + } + } + } + assert!(found); +} + #[test] fn test_issue_multiple_certificates_duplicate_prevention() { let (env, client, _admin, issuer, owner) = setup_test(); diff --git a/contracts/contract_events/src/lib.rs b/contracts/contract_events/src/lib.rs index d068e64bd..6265a119c 100644 --- a/contracts/contract_events/src/lib.rs +++ b/contracts/contract_events/src/lib.rs @@ -20,7 +20,7 @@ //! (as returned by `env.events().all()`) back into structured values, for //! indexers and integration tests. -use soroban_sdk::{Address, BytesN, Env, Symbol, TryFromVal, Val, Vec}; +use soroban_sdk::{Address, BytesN, Env, IntoVal, Symbol, TryFromVal, Val, Vec}; #[cfg(test)] extern crate std; @@ -76,6 +76,35 @@ pub mod topic { pub const CERT_MINT: Symbol = symbol_short!("cert"); /// A fungible token transfer executed by a platform contract. pub const TRANSFER: Symbol = symbol_short!("xfer"); + + // ---- AMM / liquidity ---- + pub const AMM_INITIALIZED: Symbol = symbol_short!("amm_init"); + pub const AMM_GUARDIAN: Symbol = symbol_short!("amm_guard"); + pub const LIQUIDITY_ADDED: Symbol = symbol_short!("liq_add"); + pub const LIQUIDITY_REMOVED: Symbol = symbol_short!("liq_rm"); + pub const SWAP_A_FOR_B: Symbol = symbol_short!("swap_a_b"); + pub const SWAP_B_FOR_A: Symbol = symbol_short!("swap_b_a"); + + // ---- certificate lifecycle ---- + pub const CERT_ISSUED: Symbol = symbol_short!("cert_iss"); + pub const CERT_REVOKED: Symbol = symbol_short!("cert_rev"); + pub const CERT_REISSUED: Symbol = symbol_short!("cert_reis"); + pub const CERT_ROYALTY: Symbol = symbol_short!("cert_roy"); + pub const COHORT_ROOT: Symbol = symbol_short!("cohort_rt"); + pub const CERT_ADMIN: Symbol = symbol_short!("cert_adm"); +} + +/// Publish a schema event with a category symbol and one directly indexed +/// entity key. The payload should be a typed tuple and include its timestamp. +pub fn publish_standard_event(env: &Env, event_type: Symbol, entity_id: I, data: D) +where + I: IntoVal, + D: IntoVal, +{ + let mut topics = Vec::new(env); + topics.push_back(event_type.into_val(env)); + topics.push_back(entity_id.into_val(env)); + env.events().publish(topics, data); } // --------------------------------------------------------------------------- @@ -815,6 +844,15 @@ mod tests { publish_certificate_mint(&env, &admin, symbol_short!("cs101")); publish_transfer(&env, &admin, &user, 123); } + + pub fn emit_standard(env: Env, user: Address) { + publish_standard_event( + &env, + topic::CERT_ISSUED, + user, + (42u64, env.ledger().timestamp()), + ); + } } fn addr(env: &Env) -> Address { @@ -850,6 +888,30 @@ mod tests { out } + #[test] + fn standard_events_index_category_and_entity() { + let env = Env::default(); + env.mock_all_auths(); + let user = addr(&env); + let id = env.register(EventEmitter, ()); + EventEmitterClient::new(&env, &id).emit_standard(&user); + + let events = raw_events(&env); + assert_eq!(events.len(), 1); + let (topics, payload) = &events[0]; + assert_eq!(topics.len(), 2); + assert_eq!( + Symbol::try_from_val(&env, &topics[0]).unwrap(), + topic::CERT_ISSUED + ); + assert_eq!(Address::try_from_val(&env, &topics[1]).unwrap(), user); + assert_eq!(u64::try_from_val(&env, &payload[0]).unwrap(), 42); + assert_eq!( + u64::try_from_val(&env, &payload[1]).unwrap(), + env.ledger().timestamp() + ); + } + #[test] fn all_event_families_round_trip_through_decode() { let env = Env::default(); diff --git a/contracts/smart_vault/src/circuit_breaker.rs b/contracts/smart_vault/src/circuit_breaker.rs new file mode 100644 index 000000000..a6d85948f --- /dev/null +++ b/contracts/smart_vault/src/circuit_breaker.rs @@ -0,0 +1,67 @@ +use crate::VaultError; +use soroban_sdk::{panic_with_error, symbol_short, Address, Env, Symbol}; + +pub const WITHDRAWAL_WINDOW_SECONDS: u64 = 24 * 60 * 60; + +const PAUSED: Symbol = symbol_short!("sv_pause"); +const WINDOW_START: Symbol = symbol_short!("wd_start"); +const WINDOW_LIMIT: Symbol = symbol_short!("wd_limit"); +const WINDOW_USED: Symbol = symbol_short!("wd_used"); + +pub fn is_paused(env: &Env) -> bool { + env.storage() + .instance() + .get::<_, bool>(&PAUSED) + .unwrap_or(false) +} + +pub fn set_paused(env: &Env, caller: &Address, paused: bool) { + env.storage().instance().set(&PAUSED, &paused); + env.events() + .publish((symbol_short!("sv_pause"),), (paused, caller.clone())); +} + +pub fn assert_not_paused(env: &Env) { + if is_paused(env) { + panic_with_error!(env, VaultError::ContractPaused); + } +} + +/// Caps all withdrawals in each 24-hour window to 10% of assets at the start. +pub fn record_withdrawal(env: &Env, total_assets: i128, amount: i128) { + let now = env.ledger().timestamp(); + let start = env.storage().instance().get::<_, u64>(&WINDOW_START); + let window_open = start + .map(|start| now.saturating_sub(start) < WITHDRAWAL_WINDOW_SECONDS) + .unwrap_or(false); + + let (limit, used) = if window_open { + ( + env.storage() + .instance() + .get::<_, i128>(&WINDOW_LIMIT) + .unwrap_or(0), + env.storage() + .instance() + .get::<_, i128>(&WINDOW_USED) + .unwrap_or(0), + ) + } else { + let limit = total_assets + .checked_div(10) + .unwrap_or_else(|| panic_with_error!(env, VaultError::Overflow)); + env.storage().instance().set(&WINDOW_START, &now); + env.storage().instance().set(&WINDOW_LIMIT, &limit); + env.storage().instance().set(&WINDOW_USED, &0i128); + (limit, 0) + }; + + let new_used = used + .checked_add(amount) + .unwrap_or_else(|| panic_with_error!(env, VaultError::Overflow)); + if amount > limit || new_used > limit { + panic_with_error!(env, VaultError::WithdrawalLimitExceeded); + } + + env.storage().instance().set(&WINDOW_USED, &new_used); +} diff --git a/contracts/smart_vault/src/lib.rs b/contracts/smart_vault/src/lib.rs index f98ef9d00..feb596382 100644 --- a/contracts/smart_vault/src/lib.rs +++ b/contracts/smart_vault/src/lib.rs @@ -34,12 +34,12 @@ use soroban_sdk::{ }; pub mod bitmap; +pub mod circuit_breaker; pub mod merkle_distributor; pub use bitmap::BitMap; pub use merkle_distributor::{ - DistributorError, MerkleDistributor, MerkleDistributorConfig, - AIRDROP_CLAWBACK_WINDOW_SECONDS, + DistributorError, MerkleDistributor, MerkleDistributorConfig, AIRDROP_CLAWBACK_WINDOW_SECONDS, }; // ── Storage keys ──────────────────────────────────────────────────────────── @@ -61,9 +61,6 @@ const FREEZED: Symbol = symbol_short!("freezed"); /// Minimum 48-hour delay (in seconds) between authorization and execution. const MIN_GOV_PERIOD: u64 = 172_800; -// ── Circuit-breaker pause (SC-HARD-19) ────────────────────────────────────── -const PAUSED_KEY: Symbol = symbol_short!("sv_pause"); - // ── Synthetic Asset Constants ──────────────────────────────────────────────── const MIN_COLLATERAL_RATIO_BPS: i128 = 15_000; // 150% collateralization required const GLOBAL_DEBT_SHARES: Symbol = symbol_short!("debt_sh"); @@ -123,6 +120,8 @@ pub enum VaultError { TimelockNotElapsed = 121, /// `122` — Reserve conservation invariant violated. InvariantViolated = 122, + /// `123` — 24-hour withdrawal allowance has been exceeded. + WithdrawalLimitExceeded = 123, } // ── Data types ─────────────────────────────────────────────────────────────── @@ -178,7 +177,7 @@ impl SmartVault { panic_with_error!(&env, VaultError::ZeroAmount); } Self::assert_not_frozen(&env); - Self::assert_not_paused(&env); + circuit_breaker::assert_not_paused(&env); Self::lock(&env); let total_assets: i128 = env.storage().instance().get(&TOTAL_ASSETS).unwrap_or(0i128); @@ -239,7 +238,7 @@ impl SmartVault { panic_with_error!(&env, VaultError::ZeroAmount); } Self::assert_not_frozen(&env); - Self::assert_not_paused(&env); + circuit_breaker::assert_not_paused(&env); Self::lock(&env); let mut pos = Self::get_position(&env, &user); @@ -262,6 +261,7 @@ impl SmartVault { .unwrap_or_else(|| panic_with_error!(&env, VaultError::Overflow)) .checked_div(total_shares) .unwrap_or_else(|| panic_with_error!(&env, VaultError::DivisionByZero)); + circuit_breaker::record_withdrawal(&env, total_assets, assets_out); pos.shares = pos .shares @@ -302,6 +302,7 @@ impl SmartVault { /// Mark vault assets as "staked". pub fn stake(env: Env, admin: Address) { admin.require_auth(); + circuit_breaker::assert_not_paused(&env); let ledger = env.ledger().sequence(); env.storage() .instance() @@ -317,7 +318,7 @@ impl SmartVault { /// Enforces a `HARVEST_COOL` ledger cooldown to mitigate front-running. pub fn harvest(env: Env, user: Address) -> i128 { user.require_auth(); - Self::assert_not_paused(&env); + circuit_breaker::assert_not_paused(&env); Self::lock(&env); let current_ledger = env.ledger().sequence(); @@ -606,6 +607,7 @@ impl SmartVault { panic_with_error!(&env, VaultError::Unauthorized); } env.storage().instance().set(&FREEZED, &true); + circuit_breaker::set_paused(&env, &guardian, true); env.events() .publish((symbol_short!("gov"), symbol_short!("freeze")), guardian); } @@ -619,6 +621,7 @@ impl SmartVault { panic_with_error!(&env, VaultError::Unauthorized); } env.storage().instance().set(&FREEZED, &false); + circuit_breaker::set_paused(&env, &guardian, false); env.events() .publish((symbol_short!("gov"), symbol_short!("unfreeze")), guardian); } @@ -645,16 +648,16 @@ impl SmartVault { /// caller via require_auth). pub fn set_paused(env: Env, caller: Address, paused: bool) { caller.require_auth(); - // If governance is initialized, caller must be a guardian or the proposer; - // otherwise it acts as an admin-only pause. - if env.storage().instance().has(&GOV_INIT) { - let guardians: Vec
= env.storage().instance().get(&GUARDIANS).unwrap(); - if !guardians.contains(&caller) { - panic_with_error!(&env, VaultError::Unauthorized); - } + Self::assert_governance(&env); + let guardians: Vec
= env.storage().instance().get(&GUARDIANS).unwrap(); + if !guardians.contains(&caller) { + panic_with_error!(&env, VaultError::Unauthorized); } - env.storage().instance().set(&PAUSED_KEY, &paused); - env.events().publish((symbol_short!("sv_pause"),), paused); + circuit_breaker::set_paused(&env, &caller, paused); + } + + pub fn is_paused(env: Env) -> bool { + circuit_breaker::is_paused(&env) } // ── View helpers ───────────────────────────────────────────────────────── @@ -705,14 +708,7 @@ impl SmartVault { } fn assert_not_paused(env: &Env) { - let paused: bool = env - .storage() - .instance() - .get::(&PAUSED_KEY) - .unwrap_or(false); - if paused { - panic_with_error!(env, VaultError::ContractPaused); - } + circuit_breaker::assert_not_paused(env); } fn load_proposal(env: &Env, proposals: &Map, id: u32) -> Proposal { @@ -793,7 +789,7 @@ impl SmartVault { if amount <= 0 { panic_with_error!(&env, VaultError::ZeroAmount); } - Self::assert_not_paused(&env); + circuit_breaker::assert_not_paused(&env); let pos = Self::get_position(&env, &user); let collateral = pos.shares; @@ -911,6 +907,7 @@ impl SmartVault { amount: i128, proof: Vec>, ) { + circuit_breaker::assert_not_paused(&env); MerkleDistributor::claim(&env, index, account, amount, proof); } @@ -921,6 +918,7 @@ impl SmartVault { /// Reclaim unclaimed tokens to `to` address after 90-day window expires. pub fn clawback_airdrop(env: Env, admin: Address, to: Address) -> i128 { + circuit_breaker::assert_not_paused(&env); MerkleDistributor::clawback(&env, admin, to) } @@ -940,6 +938,8 @@ impl SmartVault { ) -> bool { let leaf = MerkleDistributor::compute_leaf(&env, index, &account, amount); MerkleDistributor::verify_proof(&env, &leaf, &proof, &root) + } + // ── Vesting Schedule Endpoints (SC-028) ─────────────────────────────────── /// Creates a new customizable linear vesting schedule for a beneficiary. @@ -1015,6 +1015,7 @@ impl SmartVault { /// Claims unlocked tokens for `beneficiary`. pub fn claim_vesting(env: Env, beneficiary: Address, schedule_id: u64) -> i128 { beneficiary.require_auth(); + circuit_breaker::assert_not_paused(&env); let mut schedule = Self::get_vesting_schedule(env.clone(), schedule_id); if schedule.beneficiary != beneficiary { panic_with_error!(&env, VaultError::Unauthorized); @@ -1088,10 +1089,56 @@ mod tests { let (env, contract_id, user) = setup(); let client = SmartVaultClient::new(&env, &contract_id); - client.deposit(&user, &1000); + client.deposit(&user, &10_000); let returned = client.withdraw(&user, &500); assert_eq!(returned, 500); - assert_eq!(client.shares_of(&user), 500); + assert_eq!(client.shares_of(&user), 9_500); + } + + #[test] + #[should_panic(expected = "Error(Contract, #123)")] + fn withdrawals_are_limited_to_ten_percent_per_day() { + let (env, contract_id, user) = setup(); + let client = SmartVaultClient::new(&env, &contract_id); + + client.deposit(&user, &1000); + client.withdraw(&user, &101); + } + + #[test] + fn withdrawal_limit_accumulates_and_resets_after_24_hours() { + let (env, contract_id, user) = setup(); + let client = SmartVaultClient::new(&env, &contract_id); + + client.deposit(&user, &1000); + assert_eq!(client.withdraw(&user, &100), 100); + let blocked = client.try_withdraw(&user, &1); + assert!(blocked.is_err()); + + env.ledger() + .with_mut(|ledger| ledger.timestamp += circuit_breaker::WITHDRAWAL_WINDOW_SECONDS); + assert_eq!(client.withdraw(&user, &90), 90); + } + + #[test] + fn guardian_can_pause_and_unpause_user_operations() { + let (env, contract_id, user) = setup(); + let client = SmartVaultClient::new(&env, &contract_id); + let guardian = Address::generate(&env); + let mut guardians = Vec::new(&env); + guardians.push_back(guardian.clone()); + client.init_governance(&guardians, &1); + client.deposit(&user, &100); + + client.set_paused(&guardian, &true); + assert!(client.is_paused()); + let blocked = client.try_deposit(&user, &100); + assert!(blocked.is_err()); + assert!(client.try_withdraw(&user, &10).is_err()); + + client.set_paused(&guardian, &false); + assert!(!client.is_paused()); + client.deposit(&user, &100); } #[test] @@ -1191,10 +1238,10 @@ mod tests { client.deposit(&user, &1000); client.deposit(&user, &500); - let out = client.withdraw(&user, &300); + let out = client.withdraw(&user, &100); - assert_eq!(client.shares_of(&user), 1200); - assert_eq!(out, 300); + assert_eq!(client.shares_of(&user), 1400); + assert_eq!(out, 100); let total_assets: i128 = env.as_contract(&contract_id, || { env.storage().instance().get(&TOTAL_ASSETS).unwrap() }); @@ -1202,7 +1249,7 @@ mod tests { env.storage().instance().get(&RESERVES).unwrap() }); assert_eq!(total_assets, reserves); - assert_eq!(total_assets, 1200); + assert_eq!(total_assets, 1400); } #[test] @@ -1454,13 +1501,16 @@ mod tests { // Fast forward 90 days let current_timestamp = env.ledger().timestamp(); - env.ledger().set_timestamp(current_timestamp + AIRDROP_CLAWBACK_WINDOW_SECONDS); + env.ledger() + .set_timestamp(current_timestamp + AIRDROP_CLAWBACK_WINDOW_SECONDS); let reclaimed = client.clawback_airdrop(&admin, &treasury); assert_eq!(reclaimed, 750); let config = client.get_airdrop_config(); assert_eq!(config.claimed_amount, 1250); + } + #[test] fn test_vesting_cliff_and_linear_prorata_unlock() { let (env, contract_id, _) = setup(); @@ -1469,14 +1519,8 @@ mod tests { let beneficiary = Address::generate(&env); // Schedule: 100,000 tokens, cliff = 100 ledgers, total = 1000 ledgers - let sched_id = client.create_vesting_schedule( - &admin, - &beneficiary, - &100_000, - &100, - &1000, - &true, - ); + let sched_id = + client.create_vesting_schedule(&admin, &beneficiary, &100_000, &100, &1000, &true); // 1. Before cliff (sequence + 50 ledgers): ZERO tokens claimable env.ledger().with_mut(|li| li.sequence_number += 50); @@ -1508,14 +1552,8 @@ mod tests { let teacher = Address::generate(&env); // Revocable grant: 200,000 tokens over 1000 ledgers with 200 ledger cliff - let sched_id = client.create_vesting_schedule( - &admin, - &teacher, - &200_000, - &200, - &1000, - &true, - ); + let sched_id = + client.create_vesting_schedule(&admin, &teacher, &200_000, &200, &1000, &true); // Advance to ledger 400 (40% vested = 80,000 tokens) env.ledger().with_mut(|li| li.sequence_number += 400); diff --git a/contracts/zk_proof_verifier/src/lib.rs b/contracts/zk_proof_verifier/src/lib.rs index 5368a574c..74a01ee5b 100644 --- a/contracts/zk_proof_verifier/src/lib.rs +++ b/contracts/zk_proof_verifier/src/lib.rs @@ -1,205 +1,160 @@ #![no_std] -//! # Zero-Knowledge Proof Verifier Compute Optimization — SC-HARD-15 +//! On-chain Groth16 verification for private exercise completion proofs. //! -//! Optimized zero-knowledge proof verification for Soroban smart contracts, -//! designed to execute within Stellar's strict CPU instruction and RAM -//! budgets. -//! -//! ## Design Approach -//! -//! Full on-chain Groth16 / BN254 pairing arithmetic is impractical within -//! Soroban's instruction budget. Instead, this contract implements a -//! **commit-and-verify** pattern: -//! -//! 1. The verifying key (or its hash) is stored on-chain at deploy time. -//! 2. Off-chain tooling computes the full pairing check and produces a -//! compact *proof binding*: `sha256(vk_hash || public_inputs || nullifier -//! || student_address)`. -//! 3. The contract verifies the binding hash with a single `sha256` call, -//! consuming O(1) instructions regardless of circuit complexity. -//! 4. A per-proof **nullifier** prevents replay attacks. -//! -//! This keeps gas (instruction budget) usage constant and well within -//! Soroban's limits while still binding the proof to a specific verifying -//! key, public input set, student identity, and one-time nullifier. -//! -//! ## Storage TTL (SC-HARD-16) -//! -//! Every state-mutating call bumps instance and persistent storage TTLs -//! beyond 100,000 ledgers to protect against automatic archival. +//! Proof submission is identity-free: the course hash and nullifier are +//! public circuit inputs, while the completion witness remains private. use soroban_sdk::{ - contract, contracterror, contractimpl, contracttype, panic_with_error, xdr::ToXdr, Address, - Bytes, BytesN, Env, + contract, contracterror, contractimpl, contracttype, panic_with_error, symbol_short, Bytes, + BytesN, Env, Vec, }; -// ── TTL constants (SC-HARD-16) ─────────────────────────────────────────────── +mod pairing; +mod verifier; -/// Extend instance TTL when remaining lifetime drops below this. +/// Instance storage is extended when its remaining lifetime gets low. pub const TTL_THRESHOLD: u32 = 10_000; -/// Target instance TTL: ~100,000 ledgers. +/// Target instance lifetime after a mutation. pub const INSTANCE_BUMP: u32 = 100_000; -/// Target persistent TTL: ~518,400 ledgers (30 days). -pub const PERSISTENT_BUMP: u32 = 518_400; - -// ── Storage keys ───────────────────────────────────────────────────────────── +/// Keep nullifiers for the longest practical Soroban persistent-entry TTL. +pub const PERSISTENT_BUMP: u32 = 6_000_000; +/// Maximum number of additional public circuit inputs besides course and nullifier. +pub const MAX_PUBLIC_INPUTS: u32 = 32; #[contracttype] #[derive(Clone)] pub enum DataKey { - /// Contract administrator address. Admin, - /// Hash of the current Groth16 / BN254 verifying key. - VerifyingKeyHash, - /// Persistent nullifier store — prevents proof replay. + VerifyingKey, UsedNullifier(BytesN<32>), } -// ── Errors ──────────────────────────────────────────────────────────────────── +/// Groth16 verification key over BN254. +/// +/// Points use the uncompressed encoding documented by Soroban's BN254 API: +/// G1 = X || Y (64 bytes), G2 = X || Y (128 bytes), big-endian field limbs. +#[contracttype] +#[derive(Clone)] +pub struct VerifyingKey { + pub alpha_g1: Bytes, + pub beta_g2: Bytes, + pub gamma_g2: Bytes, + pub delta_g2: Bytes, + /// IC points in public-input order, preceded by the constant term. + pub gamma_abc_g1: Vec, +} + +/// A Groth16 proof over BN254. +#[contracttype] +#[derive(Clone)] +pub struct Groth16Proof { + pub a_g1: Bytes, + pub b_g2: Bytes, + pub c_g1: Bytes, +} #[contracterror] #[derive(Copy, Clone, Eq, PartialEq, Debug)] pub enum VerifierError { AlreadyInitialized = 1, NotInitialized = 2, - /// The provided proof does not match the expected binding hash. InvalidProof = 3, - /// This nullifier has already been consumed; replay detected. NullifierAlreadyUsed = 4, Unauthorized = 5, + InvalidPublicInput = 6, + InvalidVerifyingKey = 7, } -// ── Contract ────────────────────────────────────────────────────────────────── - #[contract] pub struct ZkProofVerifierContract; #[contractimpl] impl ZkProofVerifierContract { - // ── Initialisation ──────────────────────────────────────────────────────── - - /// Initialize the verifier with the hash of the Groth16 verifying key. - /// - /// Storing the *hash* rather than the full key keeps instance storage - /// compact (32 bytes vs. potentially hundreds of bytes for BN254 G2 - /// affine points), and is sufficient because proofs are bound to the - /// same hash pre-image. - pub fn initialize(env: Env, admin: Address, verifying_key_hash: BytesN<32>) { + /// Initialize the verifier with a BN254 Groth16 verifying key. + pub fn initialize(env: Env, admin: soroban_sdk::Address, verifying_key: VerifyingKey) { if env.storage().instance().has(&DataKey::Admin) { panic_with_error!(&env, VerifierError::AlreadyInitialized); } admin.require_auth(); + pairing::validate_verifying_key(&env, &verifying_key); env.storage().instance().set(&DataKey::Admin, &admin); env.storage() .instance() - .set(&DataKey::VerifyingKeyHash, &verifying_key_hash); - // SC-HARD-16: protect from archival on every mutation. + .set(&DataKey::VerifyingKey, &verifying_key); bump_instance(&env); } - // ── Admin: update verifying key ─────────────────────────────────────────── - - /// Replace the verifying key hash (circuit upgrade). - /// - /// Only the admin may perform this operation. After an upgrade existing - /// nullifiers remain valid — they prevent replay even across key changes. - pub fn update_verifying_key(env: Env, admin: Address, new_verifying_key_hash: BytesN<32>) { + /// Replace the Groth16 key. Previously consumed nullifiers remain reserved. + pub fn update_verifying_key( + env: Env, + admin: soroban_sdk::Address, + new_verifying_key: VerifyingKey, + ) { ensure_initialized(&env); - let stored_admin = read_admin(&env); - if admin != stored_admin { + if admin != read_admin(&env) { panic_with_error!(&env, VerifierError::Unauthorized); } admin.require_auth(); + pairing::validate_verifying_key(&env, &new_verifying_key); env.storage() .instance() - .set(&DataKey::VerifyingKeyHash, &new_verifying_key_hash); + .set(&DataKey::VerifyingKey, &new_verifying_key); bump_instance(&env); } - // ── Proof verification ──────────────────────────────────────────────────── - - /// Verify that a student completed a lab by checking their ZK proof. - /// - /// ## Compute Budget Optimization - /// - /// This function executes in O(1) Soroban instructions regardless of - /// circuit complexity: - /// - One `sha256` call to recompute the expected binding hash. - /// - One `sha256` call on the supplied proof bytes. - /// - One equality comparison. + /// Verify course completion without requiring or recording a prover address. /// - /// The full Groth16 / BN254 pairing check is performed off-chain; the - /// contract verifies the resulting compact commitment instead. - /// - /// ## Arguments - /// - /// * `student` — must authorize this call; their XDR encoding is part - /// of the proof pre-image, binding the proof to a single identity. - /// * `public_input_hash` — hash of the circuit's public inputs (e.g. - /// course ID, lab ID, score threshold). - /// * `proof` — the raw proof bytes whose SHA-256 must equal the - /// expected binding hash. - /// * `nullifier` — single-use value; consumed on success to prevent - /// replay. - /// - /// ## Returns - /// - /// `true` on valid proof; panics with `InvalidProof` or - /// `NullifierAlreadyUsed` on failure. - pub fn verify_lab_completion( + /// The circuit public inputs are ordered as: + /// `[course_hash, nullifier, public_inputs...]`. + /// Both the course hash and nullifier must be canonical BN254 scalar + /// encodings. The circuit must constrain the nullifier to its private + /// witness (normally a secret-derived, course-scoped value). + pub fn verify_completion( env: Env, - student: Address, - public_input_hash: BytesN<32>, - proof: Bytes, + course_hash: BytesN<32>, nullifier: BytesN<32>, + public_inputs: Vec>, + proof: Groth16Proof, ) -> bool { ensure_initialized(&env); - student.require_auth(); + if public_inputs.len() > MAX_PUBLIC_INPUTS { + panic_with_error!(&env, VerifierError::InvalidPublicInput); + } - // Replay protection: reject if nullifier was already consumed. - if env - .storage() - .persistent() - .has(&DataKey::UsedNullifier(nullifier.clone())) - { + let nullifier_key = DataKey::UsedNullifier(nullifier.clone()); + if env.storage().persistent().has(&nullifier_key) { panic_with_error!(&env, VerifierError::NullifierAlreadyUsed); } - // Recompute the expected binding hash and compare with sha256(proof). - let expected = expected_proof_hash(&env, &student, &public_input_hash, &nullifier); - let provided: BytesN<32> = env.crypto().sha256(&proof).into(); - - if provided != expected { + let verifying_key: VerifyingKey = env + .storage() + .instance() + .get(&DataKey::VerifyingKey) + .unwrap_or_else(|| panic_with_error!(&env, VerifierError::NotInitialized)); + + if !verifier::verify( + &env, + &verifying_key, + &proof, + &course_hash, + &nullifier, + &public_inputs, + ) { panic_with_error!(&env, VerifierError::InvalidProof); } - // Mark nullifier as consumed (persistent — survives ledger TTL bumps). + env.storage().persistent().set(&nullifier_key, &true); env.storage() .persistent() - .set(&DataKey::UsedNullifier(nullifier.clone()), &true); - - // SC-HARD-16: bump nullifier entry TTL so it is never evicted. - env.storage().persistent().extend_ttl( - &DataKey::UsedNullifier(nullifier.clone()), - TTL_THRESHOLD, - PERSISTENT_BUMP, - ); - - env.events().publish( - ("zk_verified", student.clone()), - (public_input_hash, nullifier), - ); - - // SC-HARD-16: bump instance TTL on every execution. + .extend_ttl(&nullifier_key, TTL_THRESHOLD, PERSISTENT_BUMP); + env.events() + .publish((symbol_short!("zk_verify"),), (course_hash, nullifier)); bump_instance(&env); - true } - // ── View helpers ────────────────────────────────────────────────────────── - - /// Returns `true` if `nullifier` has already been consumed. + /// Returns whether a nullifier has already been consumed. pub fn is_nullifier_used(env: Env, nullifier: BytesN<32>) -> bool { env.storage() .persistent() @@ -207,55 +162,24 @@ impl ZkProofVerifierContract { } } -// ── Internal helpers ────────────────────────────────────────────────────────── - fn ensure_initialized(env: &Env) { if !env.storage().instance().has(&DataKey::Admin) { panic_with_error!(env, VerifierError::NotInitialized); } } -fn read_admin(env: &Env) -> Address { +fn read_admin(env: &Env) -> soroban_sdk::Address { env.storage() .instance() - .get::<_, Address>(&DataKey::Admin) + .get(&DataKey::Admin) .unwrap_or_else(|| panic_with_error!(env, VerifierError::NotInitialized)) } -/// Constructs the expected proof binding hash: -/// `sha256(vk_hash || public_input_hash || nullifier || student_xdr)`. -/// -/// Binding the student's XDR-encoded address ensures the proof cannot be -/// transferred to a different identity. -pub(crate) fn expected_proof_hash( - env: &Env, - student: &Address, - public_input_hash: &BytesN<32>, - nullifier: &BytesN<32>, -) -> BytesN<32> { - let vk_hash: BytesN<32> = env - .storage() - .instance() - .get(&DataKey::VerifyingKeyHash) - .unwrap_or_else(|| panic_with_error!(env, VerifierError::NotInitialized)); - - let mut payload = Bytes::new(env); - payload.append(&Bytes::from_array(env, &vk_hash.to_array())); - payload.append(&Bytes::from_array(env, &public_input_hash.to_array())); - payload.append(&Bytes::from_array(env, &nullifier.to_array())); - payload.append(&student.clone().to_xdr(env)); - - env.crypto().sha256(&payload).into() -} - -/// Bump instance TTL (SC-HARD-16). -pub(crate) fn bump_instance(env: &Env) { +fn bump_instance(env: &Env) { env.storage() .instance() .extend_ttl(TTL_THRESHOLD, INSTANCE_BUMP); } -// ── Tests (in separate file per issue requirements) ─────────────────────────── - #[cfg(test)] mod test; diff --git a/contracts/zk_proof_verifier/src/pairing.rs b/contracts/zk_proof_verifier/src/pairing.rs new file mode 100644 index 000000000..299b3dff0 --- /dev/null +++ b/contracts/zk_proof_verifier/src/pairing.rs @@ -0,0 +1,55 @@ +use crate::{VerifierError, VerifyingKey, MAX_PUBLIC_INPUTS}; +use soroban_sdk::{ + crypto::bn254::{Bn254G1Affine, Bn254G2Affine}, + panic_with_error, Bytes, BytesN, Env, TryFromVal, +}; + +const G1_BYTES: u32 = 64; +const G2_BYTES: u32 = 128; +const FIELD_BYTES: usize = 32; +const BN254_FP_MODULUS: [u8; FIELD_BYTES] = [ + 0x30, 0x64, 0x4e, 0x72, 0xe1, 0x31, 0xa0, 0x29, 0xb8, 0x50, 0x45, 0xb6, 0x81, 0x81, 0x58, 0x5d, + 0x97, 0x81, 0x6a, 0x91, 0x68, 0x71, 0xca, 0x8d, 0x3c, 0x20, 0x8c, 0x16, 0xd8, 0x7c, 0xfd, 0x47, +]; + +pub(crate) fn parse_g1(env: &Env, bytes: &Bytes, error: VerifierError) -> Bn254G1Affine { + if bytes.len() != G1_BYTES { + panic_with_error!(env, error); + } + let encoded = BytesN::<64>::try_from_val(env, bytes.as_val()) + .unwrap_or_else(|_| panic_with_error!(env, error)); + let point = Bn254G1Affine::from_bytes(encoded); + if !env.crypto().bn254().g1_is_on_curve(&point) { + panic_with_error!(env, error); + } + point +} + +pub(crate) fn parse_g2(env: &Env, bytes: &Bytes, error: VerifierError) -> Bn254G2Affine { + if bytes.len() != G2_BYTES { + panic_with_error!(env, error); + } + let encoded = BytesN::<128>::try_from_val(env, bytes.as_val()) + .unwrap_or_else(|_| panic_with_error!(env, error)); + let limbs = encoded.to_array(); + for limb in limbs.chunks_exact(FIELD_BYTES) { + if limb >= BN254_FP_MODULUS.as_slice() { + panic_with_error!(env, error); + } + } + Bn254G2Affine::from_bytes(encoded) +} + +pub(crate) fn validate_verifying_key(env: &Env, key: &VerifyingKey) { + if key.gamma_abc_g1.len() < 3 || key.gamma_abc_g1.len() > MAX_PUBLIC_INPUTS + 3 { + panic_with_error!(env, VerifierError::InvalidVerifyingKey); + } + + parse_g1(env, &key.alpha_g1, VerifierError::InvalidVerifyingKey); + parse_g2(env, &key.beta_g2, VerifierError::InvalidVerifyingKey); + parse_g2(env, &key.gamma_g2, VerifierError::InvalidVerifyingKey); + parse_g2(env, &key.delta_g2, VerifierError::InvalidVerifyingKey); + for point in key.gamma_abc_g1.iter() { + parse_g1(env, &point, VerifierError::InvalidVerifyingKey); + } +} diff --git a/contracts/zk_proof_verifier/src/test.rs b/contracts/zk_proof_verifier/src/test.rs index 12f01705f..8138b8b75 100644 --- a/contracts/zk_proof_verifier/src/test.rs +++ b/contracts/zk_proof_verifier/src/test.rs @@ -1,199 +1,238 @@ -//! # SC-HARD-15 — ZK Proof Verifier Compute Optimization: Test Suite -//! -//! Tests verify: -//! - Valid proofs are accepted exactly once. -//! - Invalid proofs are rejected. -//! - Replay attacks via the same nullifier are rejected. -//! - The verifying key can be updated by the admin. -//! - Unauthorized key update is rejected. -//! - Nullifier state is queryable. - use super::*; -use soroban_sdk::{testutils::Address as _, Address, Bytes, BytesN, Env}; +use soroban_sdk::{ + crypto::bn254::{Bn254Fr, Bn254G1Affine}, + testutils::Address as _, + Address, Bytes, BytesN, Env, TryFromVal, Vec, +}; + +const ONE: [u8; 32] = [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, +]; +const G1_GENERATOR: [u8; 64] = [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, +]; +const G2_GENERATOR: [u8; 128] = [ + 0x19, 0x8e, 0x93, 0x93, 0x92, 0x0d, 0x48, 0x3a, 0x72, 0x60, 0xbf, 0xb7, 0x31, 0xfb, 0x5d, 0x25, + 0xf1, 0xaa, 0x49, 0x33, 0x35, 0xa9, 0xe7, 0x12, 0x97, 0xe4, 0x85, 0xb7, 0xae, 0xf3, 0x12, 0xc2, + 0x18, 0x00, 0xde, 0xef, 0x12, 0x1f, 0x1e, 0x76, 0x42, 0x6a, 0x00, 0x66, 0x5e, 0x5c, 0x44, 0x79, + 0x67, 0x43, 0x22, 0xd4, 0xf7, 0x5e, 0xda, 0xdd, 0x46, 0xde, 0xbd, 0x5c, 0xd9, 0x92, 0xf6, 0xed, + 0x09, 0x06, 0x89, 0xd0, 0x58, 0x5f, 0xf0, 0x75, 0xec, 0x9e, 0x99, 0xad, 0x69, 0x0c, 0x33, 0x95, + 0xbc, 0x4b, 0x31, 0x33, 0x70, 0xb3, 0x8e, 0xf3, 0x55, 0xac, 0xda, 0xdc, 0xd1, 0x22, 0x97, 0x5b, + 0x12, 0xc8, 0x5e, 0xa5, 0xdb, 0x8c, 0x6d, 0xeb, 0x4a, 0xab, 0x71, 0x80, 0x8d, 0xcb, 0x40, 0x8f, + 0xe3, 0xd1, 0xe7, 0x69, 0x0c, 0x43, 0xd3, 0x7b, 0x4c, 0xe6, 0xcc, 0x01, 0x66, 0xfa, 0x7d, 0xaa, +]; + +fn g1_generator(env: &Env) -> Bytes { + Bytes::from_array(env, &G1_GENERATOR) +} -// ── Helper ──────────────────────────────────────────────────────────────────── +fn g1_infinity(env: &Env) -> Bytes { + Bytes::from_array(env, &[0; 64]) +} + +fn g2_generator(env: &Env) -> Bytes { + Bytes::from_array(env, &G2_GENERATOR) +} + +fn g2_infinity(env: &Env) -> Bytes { + Bytes::from_array(env, &[0; 128]) +} + +fn test_key(env: &Env) -> VerifyingKey { + let mut gamma_abc_g1 = Vec::new(env); + gamma_abc_g1.push_back(g1_infinity(env)); + gamma_abc_g1.push_back(g1_generator(env)); + gamma_abc_g1.push_back(g1_generator(env)); + VerifyingKey { + alpha_g1: g1_infinity(env), + beta_g2: g2_infinity(env), + gamma_g2: g2_generator(env), + delta_g2: g2_generator(env), + gamma_abc_g1, + } +} -/// Build the correct proof bytes that will be accepted by the contract. -/// The contract checks: sha256(proof) == expected_proof_hash(…) -/// So proof must equal the pre-image of that hash — which is the payload -/// used in `expected_proof_hash`. -pub(crate) fn make_valid_proof( +fn proof_for( env: &Env, - vk_hash: &BytesN<32>, - student: &Address, - public_input_hash: &BytesN<32>, + key: &VerifyingKey, + course_hash: &BytesN<32>, nullifier: &BytesN<32>, -) -> Bytes { - // The proof bytes ARE the sha256 pre-image: vk_hash || pub_input || nul || student_xdr. - // The contract computes expected = sha256(pre-image) and provided = sha256(proof). - // For provided == expected we need sha256(proof) == sha256(pre-image), - // which means proof == pre-image. - let mut payload = Bytes::new(env); - payload.append(&Bytes::from_array(env, &vk_hash.to_array())); - payload.append(&Bytes::from_array(env, &public_input_hash.to_array())); - payload.append(&Bytes::from_array(env, &nullifier.to_array())); - payload.append(&student.clone().to_xdr(env)); - payload -} - -// ── Core behaviour tests ────────────────────────────────────────────────────── - -/// A correctly formed proof is accepted exactly once, and the nullifier is -/// marked as consumed. -#[test] -fn verifies_valid_proof_once() { - let env = Env::default(); - let contract_id = env.register(ZkProofVerifierContract, ()); - let client = ZkProofVerifierContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let student = Address::generate(&env); - let vk_hash = BytesN::from_array(&env, &[1u8; 32]); - let public_input_hash = BytesN::from_array(&env, &[2u8; 32]); - let nullifier = BytesN::from_array(&env, &[3u8; 32]); +) -> Groth16Proof { + let mut ic_points = Vec::::new(env); + for point in key.gamma_abc_g1.iter().skip(1) { + ic_points.push_back(Bn254G1Affine::from_bytes( + BytesN::<64>::try_from_val(env, point.as_val()).unwrap(), + )); + } + let mut scalars = Vec::new(env); + scalars.push_back(Bn254Fr::from_bytes(course_hash.clone())); + scalars.push_back(Bn254Fr::from_bytes(nullifier.clone())); + let vk_x = env.crypto().bn254().g1_msm(ic_points, scalars); + let c = -vk_x; + Groth16Proof { + a_g1: g1_infinity(env), + b_g2: g2_infinity(env), + c_g1: Bytes::from_array(env, &c.to_bytes().to_array()), + } +} +fn initialized_client(env: &Env) -> (ZkProofVerifierContractClient<'_>, Address) { + let contract_id = env.register(ZkProofVerifierContract, ()); + let client = ZkProofVerifierContractClient::new(env, &contract_id); + let admin = Address::generate(env); env.mock_all_auths(); - client.initialize(&admin, &vk_hash); + client.initialize(&admin, &test_key(env)); + (client, admin) +} - let proof = make_valid_proof(&env, &vk_hash, &student, &public_input_hash, &nullifier); - let ok = client.verify_lab_completion(&student, &public_input_hash, &proof, &nullifier); +#[test] +fn verifies_groth16_pairing_without_revealing_address() { + let env = Env::default(); + let (client, _) = initialized_client(&env); + let course_hash = BytesN::from_array(&env, &ONE); + let nullifier = BytesN::from_array(&env, &ONE); + let public_inputs = Vec::new(&env); + let proof = proof_for(&env, &test_key(&env), &course_hash, &nullifier); - assert!(ok); + assert!(client.verify_completion(&course_hash, &nullifier, &public_inputs, &proof)); assert!(client.is_nullifier_used(&nullifier)); } -/// A proof whose bytes have been tampered with must be rejected. #[test] #[should_panic(expected = "Error(Contract, #3)")] -fn rejects_invalid_proof() { +fn rejects_a_proof_that_fails_the_pairing_equation() { let env = Env::default(); - let contract_id = env.register(ZkProofVerifierContract, ()); - let client = ZkProofVerifierContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let student = Address::generate(&env); - let vk_hash = BytesN::from_array(&env, &[10u8; 32]); - let public_input_hash = BytesN::from_array(&env, &[11u8; 32]); - let nullifier = BytesN::from_array(&env, &[12u8; 32]); - - env.mock_all_auths(); - client.initialize(&admin, &vk_hash); - - // Completely wrong proof bytes. - let fake_proof = Bytes::from_array(&env, &[9u8, 9, 9, 9]); - let _ = client.verify_lab_completion(&student, &public_input_hash, &fake_proof, &nullifier); + let (client, _) = initialized_client(&env); + let course_hash = BytesN::from_array(&env, &ONE); + let nullifier = BytesN::from_array(&env, &ONE); + let public_inputs = Vec::new(&env); + let invalid_proof = Groth16Proof { + c_g1: g1_infinity(&env), + ..proof_for(&env, &test_key(&env), &course_hash, &nullifier) + }; + + client.verify_completion(&course_hash, &nullifier, &public_inputs, &invalid_proof); } -/// Submitting the same (valid) proof twice must be rejected on the second -/// attempt via nullifier replay detection. #[test] -#[should_panic(expected = "Error(Contract, #4)")] -fn rejects_replay_with_same_nullifier() { +#[should_panic(expected = "Error(Contract, #3)")] +fn proof_is_bound_to_its_course_hash() { let env = Env::default(); - let contract_id = env.register(ZkProofVerifierContract, ()); - let client = ZkProofVerifierContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let student = Address::generate(&env); - let vk_hash = BytesN::from_array(&env, &[4u8; 32]); - let public_input_hash = BytesN::from_array(&env, &[5u8; 32]); - let nullifier = BytesN::from_array(&env, &[6u8; 32]); - - env.mock_all_auths(); - client.initialize(&admin, &vk_hash); - - let proof = make_valid_proof(&env, &vk_hash, &student, &public_input_hash, &nullifier); - - // First submission succeeds. - let _ = client.verify_lab_completion(&student, &public_input_hash, &proof, &nullifier); - // Second submission with the same nullifier must panic. - let _ = client.verify_lab_completion(&student, &public_input_hash, &proof, &nullifier); + let (client, _) = initialized_client(&env); + let course_hash = BytesN::from_array(&env, &ONE); + let other_course = BytesN::from_array(&env, &{ + let mut two = ONE; + two[31] = 2; + two + }); + let nullifier = BytesN::from_array(&env, &ONE); + let public_inputs = Vec::new(&env); + let proof = proof_for(&env, &test_key(&env), &course_hash, &nullifier); + + client.verify_completion(&other_course, &nullifier, &public_inputs, &proof); } -/// A fresh nullifier (never seen before) is not flagged as used. #[test] -fn fresh_nullifier_is_not_used() { +#[should_panic(expected = "Error(Contract, #3)")] +fn proof_is_bound_to_its_nullifier() { let env = Env::default(); - let contract_id = env.register(ZkProofVerifierContract, ()); - let client = ZkProofVerifierContractClient::new(&env, &contract_id); - let admin = Address::generate(&env); - let vk_hash = BytesN::from_array(&env, &[0u8; 32]); - - env.mock_all_auths(); - client.initialize(&admin, &vk_hash); - - let nullifier = BytesN::from_array(&env, &[99u8; 32]); - assert!(!client.is_nullifier_used(&nullifier)); + let (client, _) = initialized_client(&env); + let course_hash = BytesN::from_array(&env, &ONE); + let nullifier = BytesN::from_array(&env, &ONE); + let other_nullifier = BytesN::from_array(&env, &{ + let mut two = ONE; + two[31] = 2; + two + }); + let public_inputs = Vec::new(&env); + let proof = proof_for(&env, &test_key(&env), &course_hash, &nullifier); + + client.verify_completion(&course_hash, &other_nullifier, &public_inputs, &proof); } -/// The admin can update the verifying key hash (circuit upgrade scenario). #[test] -fn admin_can_update_verifying_key() { +#[should_panic(expected = "Error(Contract, #6)")] +fn rejects_public_input_vectors_with_the_wrong_arity() { let env = Env::default(); - let contract_id = env.register(ZkProofVerifierContract, ()); - let client = ZkProofVerifierContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let vk_hash_v1 = BytesN::from_array(&env, &[1u8; 32]); - let vk_hash_v2 = BytesN::from_array(&env, &[2u8; 32]); - - env.mock_all_auths(); - client.initialize(&admin, &vk_hash_v1); - client.update_verifying_key(&admin, &vk_hash_v2); - - // After the upgrade a proof generated against vk_v2 must be accepted. - let student = Address::generate(&env); - let public_input_hash = BytesN::from_array(&env, &[7u8; 32]); - let nullifier = BytesN::from_array(&env, &[8u8; 32]); - let proof = make_valid_proof(&env, &vk_hash_v2, &student, &public_input_hash, &nullifier); - let ok = client.verify_lab_completion(&student, &public_input_hash, &proof, &nullifier); - assert!(ok); + let (client, _) = initialized_client(&env); + let course_hash = BytesN::from_array(&env, &ONE); + let nullifier = BytesN::from_array(&env, &ONE); + let mut public_inputs = Vec::new(&env); + public_inputs.push_back(BytesN::from_array(&env, &ONE)); + let proof = proof_for(&env, &test_key(&env), &course_hash, &nullifier); + + client.verify_completion(&course_hash, &nullifier, &public_inputs, &proof); } -/// A non-admin address must not be able to update the verifying key. #[test] -#[should_panic(expected = "Error(Contract, #5)")] -fn non_admin_cannot_update_verifying_key() { +#[should_panic(expected = "Error(Contract, #3)")] +fn rejects_malformed_proof_point_encodings() { let env = Env::default(); - let contract_id = env.register(ZkProofVerifierContract, ()); - let client = ZkProofVerifierContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let attacker = Address::generate(&env); - let vk_hash = BytesN::from_array(&env, &[1u8; 32]); - let new_vk = BytesN::from_array(&env, &[2u8; 32]); - - env.mock_all_auths(); - client.initialize(&admin, &vk_hash); - // attacker is not the admin → must panic with Unauthorized (5). - client.update_verifying_key(&attacker, &new_vk); + let (client, _) = initialized_client(&env); + let course_hash = BytesN::from_array(&env, &ONE); + let nullifier = BytesN::from_array(&env, &ONE); + let public_inputs = Vec::new(&env); + let invalid_proof = Groth16Proof { + b_g2: Bytes::from_array(&env, &[0; 127]), + ..proof_for(&env, &test_key(&env), &course_hash, &nullifier) + }; + + client.verify_completion(&course_hash, &nullifier, &public_inputs, &invalid_proof); } -/// Proof built against the old VK must be rejected after a key upgrade, -/// ensuring circuit upgrades invalidate outstanding stale proofs. #[test] -#[should_panic(expected = "Error(Contract, #3)")] -fn proof_against_old_vk_rejected_after_upgrade() { +#[should_panic(expected = "Error(Contract, #4)")] +fn rejects_replay_of_a_valid_proof() { let env = Env::default(); - let contract_id = env.register(ZkProofVerifierContract, ()); - let client = ZkProofVerifierContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let student = Address::generate(&env); - let vk_v1 = BytesN::from_array(&env, &[1u8; 32]); - let vk_v2 = BytesN::from_array(&env, &[2u8; 32]); - let public_input_hash = BytesN::from_array(&env, &[3u8; 32]); - let nullifier = BytesN::from_array(&env, &[4u8; 32]); - - env.mock_all_auths(); - client.initialize(&admin, &vk_v1); + let (client, _) = initialized_client(&env); + let course_hash = BytesN::from_array(&env, &ONE); + let nullifier = BytesN::from_array(&env, &ONE); + let public_inputs = Vec::new(&env); + let proof = proof_for(&env, &test_key(&env), &course_hash, &nullifier); + + client.verify_completion(&course_hash, &nullifier, &public_inputs, &proof); + client.verify_completion(&course_hash, &nullifier, &public_inputs, &proof); +} - // Proof generated against old VK. - let old_proof = make_valid_proof(&env, &vk_v1, &student, &public_input_hash, &nullifier); +#[test] +#[should_panic(expected = "Error(Contract, #6)")] +fn rejects_noncanonical_course_hash_scalar() { + let env = Env::default(); + let (client, _) = initialized_client(&env); + let course_hash = BytesN::from_array(&env, &[0xff; 32]); + let nullifier = BytesN::from_array(&env, &ONE); + let public_inputs = Vec::new(&env); + let canonical_course = BytesN::from_array(&env, &ONE); + let proof = proof_for(&env, &test_key(&env), &canonical_course, &nullifier); + + client.verify_completion(&course_hash, &nullifier, &public_inputs, &proof); +} - // Admin upgrades to v2. - client.update_verifying_key(&admin, &vk_v2); +#[test] +fn admin_can_rotate_the_verifying_key() { + let env = Env::default(); + let (client, admin) = initialized_client(&env); + let mut new_key = test_key(&env); + let mut new_ic = Vec::new(&env); + new_ic.push_back(g1_infinity(&env)); + new_ic.push_back(g1_infinity(&env)); + new_ic.push_back(g1_generator(&env)); + new_key.gamma_abc_g1 = new_ic; + client.update_verifying_key(&admin, &new_key); + + let course_hash = BytesN::from_array(&env, &ONE); + let nullifier = BytesN::from_array(&env, &ONE); + let public_inputs = Vec::new(&env); + let proof = proof_for(&env, &new_key, &course_hash, &nullifier); + assert!(client.verify_completion(&course_hash, &nullifier, &public_inputs, &proof)); +} - // Old proof must now fail. - let _ = client.verify_lab_completion(&student, &public_input_hash, &old_proof, &nullifier); +#[test] +#[should_panic(expected = "Error(Contract, #5)")] +fn non_admin_cannot_rotate_the_verifying_key() { + let env = Env::default(); + let (client, _) = initialized_client(&env); + let attacker = Address::generate(&env); + client.update_verifying_key(&attacker, &test_key(&env)); } diff --git a/contracts/zk_proof_verifier/src/verifier.rs b/contracts/zk_proof_verifier/src/verifier.rs new file mode 100644 index 000000000..eac0534e0 --- /dev/null +++ b/contracts/zk_proof_verifier/src/verifier.rs @@ -0,0 +1,67 @@ +use crate::{ + pairing::{parse_g1, parse_g2}, + Groth16Proof, VerifierError, VerifyingKey, +}; +use soroban_sdk::{ + crypto::bn254::{Bn254Fr, Bn254G1Affine, Bn254G2Affine}, + panic_with_error, BytesN, Env, Vec, U256, +}; + +pub(crate) fn verify( + env: &Env, + key: &VerifyingKey, + proof: &Groth16Proof, + course_hash: &BytesN<32>, + nullifier: &BytesN<32>, + public_inputs: &Vec>, +) -> bool { + let expected_ic_len = public_inputs.len() + 3; + if key.gamma_abc_g1.len() != expected_ic_len { + panic_with_error!(env, VerifierError::InvalidPublicInput); + } + + let one = Bn254Fr::from_u256(U256::from_u32(env, 1)); + let mut scalars = Vec::new(env); + scalars.push_back(one); + scalars.push_back(parse_scalar(env, course_hash)); + scalars.push_back(parse_scalar(env, nullifier)); + for input in public_inputs.iter() { + scalars.push_back(parse_scalar(env, &input)); + } + + let bn254 = env.crypto().bn254(); + let mut ic_points = Vec::::new(env); + for point in key.gamma_abc_g1.iter() { + ic_points.push_back(parse_g1(env, &point, VerifierError::InvalidVerifyingKey)); + } + let vk_x = bn254.g1_msm(ic_points, scalars); + + let a = parse_g1(env, &proof.a_g1, VerifierError::InvalidProof); + let b = parse_g2(env, &proof.b_g2, VerifierError::InvalidProof); + let c = parse_g1(env, &proof.c_g1, VerifierError::InvalidProof); + let alpha = parse_g1(env, &key.alpha_g1, VerifierError::InvalidVerifyingKey); + let beta = parse_g2(env, &key.beta_g2, VerifierError::InvalidVerifyingKey); + let gamma = parse_g2(env, &key.gamma_g2, VerifierError::InvalidVerifyingKey); + let delta = parse_g2(env, &key.delta_g2, VerifierError::InvalidVerifyingKey); + + let mut g1 = Vec::::new(env); + let mut g2 = Vec::::new(env); + g1.push_back(a); + g2.push_back(b); + g1.push_back(-alpha); + g2.push_back(beta); + g1.push_back(-vk_x); + g2.push_back(gamma); + g1.push_back(-c); + g2.push_back(delta); + + bn254.pairing_check(g1, g2) +} + +fn parse_scalar(env: &Env, bytes: &BytesN<32>) -> Bn254Fr { + let scalar = Bn254Fr::from_bytes(bytes.clone()); + if scalar.to_bytes() != *bytes { + panic_with_error!(env, VerifierError::InvalidPublicInput); + } + scalar +} diff --git a/docs/contracts/EVENTS.md b/docs/contracts/EVENTS.md index 9909fa2bd..b2c42e337 100644 --- a/docs/contracts/EVENTS.md +++ b/docs/contracts/EVENTS.md @@ -1,9 +1,9 @@ # On-Chain Event Schemas -Standardized event vocabulary for the Web3-Student-Lab Soroban platform -contracts. Every state-modifying function emits structured, searchable -events so indexers, auditors, and frontend subscription streams consume a -single, predictable schema. +Canonical event vocabulary for the Web3-Student-Lab Soroban platform +contracts. Event families listed here use structured, searchable topics and +typed payloads. Legacy event families not listed here still need to be +migrated before claiming workspace-wide conformance. The canonical topic `Symbol`s and typed decode helpers live in the shared [`contract-events`](../../contracts/contract_events/) crate (workspace @@ -12,6 +12,19 @@ on it and use its `publish_*` helpers; off-chain consumers can use the `decode_*` helpers to turn raw `(topics, data)` pairs back into typed values. +## Indexed event envelope + +New and migrated state-transition events use exactly two topics: + +1. A canonical event-category `Symbol` (the first topic). +2. The primary entity key (an address, identifier, or other stable key). + +The event payload is serialized as a typed tuple in Soroban event data. It +contains the transition details and ends with the ledger timestamp. The shared +`publish_standard_event` helper enforces this topic layout; event-specific +helpers define the payload shape. This allows Horizon/RPC consumers to filter +by category and entity without decoding the payload. + ## Conventions * **Topics** — the first topic element is always one of the canonical @@ -46,6 +59,19 @@ values. | `milestn` | course/content contracts | milestone achieved | | `cert` | `certificate-nft` | certificate minted | | `xfer` | platform contracts | fungible token transfer | +| `amm_init` | `amm` | pool initialized | +| `amm_guard` | `amm` | emergency guardian changed | +| `paused` | `amm` | pool paused/unpaused | +| `liq_add` | `amm` | liquidity added | +| `liq_rm` | `amm` | liquidity removed | +| `swap_a_b` | `amm` | token A swapped for B | +| `swap_b_a` | `amm` | token B swapped for A | +| `cert_iss` | `certificate` | certificate issued | +| `cert_rev` | `certificate` | certificate revoked | +| `cert_reis` | `certificate` | certificate reissued | +| `cert_roy` | `certificate` | certificate royalty updated | +| `cohort_rt` | `certificate` | Merkle cohort root anchored | +| `cert_adm` | `certificate` | certificate contract initialized | --- @@ -77,7 +103,7 @@ for event in events.filter_by_topic("trade") { ## `paused` — pool pause/unpause -Topics: `(paused, admin)` +Topics: `(paused, actor)` — actor is the authorized admin or guardian. Data: `(paused, ts)` @@ -194,13 +220,43 @@ Topics: `(xfer, from)` Data: `(to, amount, ts)` +## AMM lifecycle and trading events + +All AMM event categories index the acting admin, guardian, liquidity provider, +or trader as their second topic. + +| Topic | Data payload | +|---|---| +| `amm_init` | `(token_a, token_b, pool_share, fee_collector, base_fee_bps, ts)` | +| `amm_guard` | `(admin, ts)` | +| `paused` | `(paused, ts)` | +| `liq_add` | `(amount_a, amount_b, shares, reserve_a, reserve_b, ts)` | +| `liq_rm` | `(amount_a, amount_b, shares, reserve_a, reserve_b, ts)` | +| `swap_a_b` | `(amount_in, amount_out, fee, reserve_a, reserve_b, ts)` | +| `swap_b_a` | `(amount_in, amount_out, fee, reserve_a, reserve_b, ts)` | + +## Certificate lifecycle events + +Certificate lifecycle events index the certificate owner as the second topic; +`cohort_rt` instead indexes the cohort identifier. + +| Topic | Data payload | +|---|---| +| `cert_iss` | `(cert_id, issuer, course_id, content_hash, ts)` | +| `cert_rev` | `(cert_id, issuer, reason, ts)` | +| `cert_reis` | `(old_cert_id, new_cert_id, issuer, ts)` | +| `cert_roy` | `(cert_id, issuer, royalty_bps, ts)` | +| `cohort_rt` | `(issuer, root_hash, ts)` | +| `cert_adm` | `(ts)` | + --- ## Adding a new event 1. Add the canonical topic `Symbol` in `contract-events` `topic` module. -2. Add a `publish_*` helper (topics + payload layout) and a typed +2. Emit through `publish_standard_event` or add a `publish_*` helper that + delegates to it, defining the indexed entity and payload layout. +3. Add a typed `decode_*` helper plus its struct. -3. Emit from the contract using the helper. 4. Add a payload example above and a round-trip assertion in `contract-events` tests. diff --git a/docs/contracts/SC-020-ZK-PROOF-VERIFIER.md b/docs/contracts/SC-020-ZK-PROOF-VERIFIER.md new file mode 100644 index 000000000..d23333463 --- /dev/null +++ b/docs/contracts/SC-020-ZK-PROOF-VERIFIER.md @@ -0,0 +1,53 @@ +# Soroban Groth16 verifier + +This contract performs an on-chain Groth16 verification over BN254 using the +native Soroban `bn254` pairing and G1 multi-scalar multiplication host +operations. It does not accept an off-chain verification assertion or a hash +commitment in place of a proof. + +## Circuit and proof format + +The circuit must expose public scalars in this order: + +1. `course_hash` +2. `nullifier` +3. Any additional public inputs + +The proving witness, including the completion evidence and any secret used to +derive the nullifier, remains private. Both named values and all additional +public inputs are 32-byte big-endian canonical BN254 scalar encodings. A +course digest must therefore be mapped into the scalar field consistently by +the circuit and prover. The circuit must constrain its nullifier to the secret +witness (typically using a course-scoped secret hash); the contract then +registers that same nullifier after the pairing equation succeeds. + +G1 and G2 points in `VerifyingKey` and `Groth16Proof` use the uncompressed +BN254 encodings expected by Soroban: 64-byte G1 points and 128-byte G2 points. +Each coordinate is big-endian; each G2 Fp2 coefficient is encoded as +`c1 || c0`. The verifying key's `gamma_abc_g1` has one constant point followed +by exactly one point for every public scalar. + +The pairing equation checked is: + +```text +e(A, B) * e(-alpha, beta) * e(-vk_x, gamma) * e(-C, delta) == 1 +``` + +where `vk_x` is calculated from the verifying key's IC points and the ordered +public scalars. Course completion calls are permissionless and do not accept a +student address, preventing wallet identity from being recorded or included +in the proof interface. + +## Replay protection and storage + +A successful verification consumes its nullifier and emits an event containing +only the course hash and nullifier. Nullifiers are stored as persistent Soroban +entries with a six-million-ledger TTL extension. As with all expiring Soroban +storage, this is a bounded retention period; applications requiring replay +protection beyond that period must renew retained entries through a keeper or +restore policy. + +The contract bounds additional public inputs to 32 to keep the MSM and storage +costs predictable. Invalid pairing equations, malformed G1 encodings, wrong +input counts, and noncanonical public scalars are rejected with typed contract +errors. diff --git a/docs/contracts/SC-022-RBAC.md b/docs/contracts/SC-022-RBAC.md new file mode 100644 index 000000000..b8663ca3a --- /dev/null +++ b/docs/contracts/SC-022-RBAC.md @@ -0,0 +1,33 @@ +# SC-022: RBAC with timelocked admin handover + +`access-control` is a helper library for Soroban contracts, not a standalone +contract. The owning contract initializes the library and must add role checks +to each privileged entrypoint. + +```rust +use access_control::{self as ac, Role}; + +// In the owning contract's initialization: +ac::initialize(&env, &admin); + +// At the beginning of a privileged endpoint: +ac::require_role!(&env, &caller, Role::Instructor); + +// Admin-managed application roles: +ac::grant_role(&env, &caller, Role::Minter, &account); +ac::revoke_role(&env, &caller, Role::Minter, &account); +``` + +The supported application roles are `Admin`, `Instructor`, `Minter`, and +`Auditor`. `Operator` and `Guardian` remain available for existing integrations; +guardians retain the existing pause permission. + +Admin ownership cannot be changed through `grant_role` or `revoke_role`. +Instead, the current admin calls `propose_admin` and the candidate must +authorize `claim_admin` after 48 hours. The current admin may cancel a pending +proposal, and proposing a replacement candidate starts a fresh 48-hour delay. +The pending proposal is queryable using `pending_admin`. + +Role grant/revoke events identify the affected role, account, actor, and +whether the account was granted or revoked. Admin proposal, cancellation, and +handover events publish the relevant parties and unlock time. diff --git a/docs/contracts/SC-023-CIRCUIT-BREAKER.md b/docs/contracts/SC-023-CIRCUIT-BREAKER.md new file mode 100644 index 000000000..4528a0077 --- /dev/null +++ b/docs/contracts/SC-023-CIRCUIT-BREAKER.md @@ -0,0 +1,32 @@ +# SC-023: Emergency Circuit Breaker and Withdrawal Limits + +The SmartVault and AMM contracts each expose an immediate pause and a rolling +24-hour outflow budget. A pause blocks deposits, withdrawals, swaps, liquidity +changes, and other user-facing payout or asset mutations. Read-only queries and +guardian/admin control calls remain available while paused. + +## SmartVault + +The existing governance guardian set is authorized to pause immediately through +`emergency_freeze` or `set_paused`. Any configured guardian can trigger the +pause; `unfreeze`/`set_paused(false)` is also restricted to a configured +guardian. Governance must be initialized before these calls can be used. + +Withdrawals are capped cumulatively at 10% of total vault assets measured at +the start of the first withdrawal in each 24-hour window. A rejected withdrawal +does not consume the allowance. `VaultError::WithdrawalLimitExceeded` (`123`) +reports an exceeded budget. + +## AMM + +The admin designates one guardian with `set_guardian`. That guardian or the +admin can pause swaps and liquidity changes in one call. Only the admin can +unpause. The admin is the initial guardian. + +Because the AMM has no shared price oracle, token A and token B outflows are +limited independently to 10% of each reserve at the start of the 24-hour +window. Swap output plus its fee counts against the relevant token's budget; +liquidity removals count against both budgets. `AmmError::WithdrawalLimitExceeded` +(`407`) identifies a rejected outflow. + +Both contracts publish pause-state events and expose an `is_paused` view.