diff --git a/EVENT_SCHEMA.md b/EVENT_SCHEMA.md index 05e8074f..c7d380d8 100644 --- a/EVENT_SCHEMA.md +++ b/EVENT_SCHEMA.md @@ -132,15 +132,6 @@ Emitted when the vault is unpaused by the admin. ## Not yet implemented - **OwnershipTransfer**: not present in current vault; would list old_owner, new_owner. -### `admin_nominated` -| Field | Location | Type | Description | -|---------|----------|--------|---------------| -| topic 0 | topics | Symbol | `"admin_nominated"` | -| topic 1 | topics | Address| current admin | -| topic 2 | topics | Address| nominee | -| data | data | () | empty | - - --- @@ -214,6 +205,32 @@ Emitted by `receive_payment()` **only** when `to_pool = false`. Follows the `pay } ``` +--- + +### `admin_nominated` + +Emitted when the current admin nominates a successor. + +| Field | Location | Type | Description | +|---------|----------|--------|-----------------------| +| topic 0 | topics | Symbol | `"admin_nominated"` | +| topic 1 | topics | Address| current admin | +| topic 2 | topics | Address| nominee | +| data | data | () | empty | + +--- + +### `admin_accepted` + +Emitted when the nominee accepts the admin role. + +| Field | Location | Type | Description | +|---------|----------|--------|-----------------------| +| topic 0 | topics | Symbol | `"admin_accepted"` | +| topic 1 | topics | Address| old admin | +| topic 2 | topics | Address| new admin | +| data | data | () | empty | + > **Note:** `balance_credited` is never emitted when `to_pool = true`. Indexers tracking developer earnings should subscribe to this event; indexers tracking total protocol revenue should subscribe to `payment_received` with `to_pool = true`. --- diff --git a/SECURITY.md b/SECURITY.md index 0ba7430c..3c552228 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -184,3 +184,10 @@ have been audited for `require_auth()` coverage as part of Issue #160. ### Cross-reference - Audit branch: `test/require-auth-sweep` - Tests: `contracts/vault/src/test.rs`, `contracts/revenue_pool/src/test.rs`, `contracts/settlement/src/test.rs` + +## Authorization Matrix Update (Settlement) + +As part of the authorization matrix hardening for the `callora-settlement` contract: +- `get_all_developer_balances` now requires `admin` authorization via `require_auth()`. This prevents bulk data scraping while allowing administrative oversight. +- Comprehensive negative tests have been added to `contracts/settlement/src/test.rs` covering `receive_payment`, `set_admin`, `set_vault`, and `get_all_developer_balances`. +- Admin rotation (two-step) has been verified to correctly gate access during the transition period. diff --git a/contracts/settlement/src/lib.rs b/contracts/settlement/src/lib.rs index 8ee019d3..cf5b502f 100644 --- a/contracts/settlement/src/lib.rs +++ b/contracts/settlement/src/lib.rs @@ -217,13 +217,19 @@ impl CalloraSettlement { balances.get(developer).unwrap_or(0) } - /// Get all developer balances (for admin use) + /// Get all developer balances (admin only) /// /// **CRITICAL**: Map iteration order is **NOT stable** and should not be relied upon. /// Use this function only for administrative queries or reporting purposes. /// For production integrations with many developers (>100), implement off-chain indexing /// by listening to `BalanceCreditedEvent` and maintaining a local database. /// + /// # Arguments + /// * `caller` - Must be the current admin address. + /// + /// # Access Control + /// Only the current admin can call this function. + /// /// # Iteration Behavior /// - **Small maps (< 100 entries)**: Safe to iterate; yields current state but order is unstable /// - **Large maps (> 100 entries)**: Consider off-chain indexing to avoid excessive gas costs @@ -244,9 +250,11 @@ impl CalloraSettlement { /// - 50 developers: ~500 gas /// - 100 developers: ~1,000 gas /// - 500 developers: ~5,000 gas (consider off-chain indexing) - pub fn get_all_developer_balances(env: Env) -> Vec { - if !env.storage().instance().has(&Symbol::new(&env, ADMIN_KEY)) { - panic!("settlement contract not initialized"); + pub fn get_all_developer_balances(env: Env, caller: Address) -> Vec { + caller.require_auth(); + let admin = Self::get_admin(env.clone()); + if caller != admin { + panic!("unauthorized: caller is not admin"); } let inst = env.storage().instance(); let balances: Map = inst diff --git a/contracts/settlement/src/test.rs b/contracts/settlement/src/test.rs index 621c064c..00f8f8a5 100644 --- a/contracts/settlement/src/test.rs +++ b/contracts/settlement/src/test.rs @@ -63,7 +63,7 @@ mod settlement_tests { assert_eq!(global_pool.total_balance, 0); assert_eq!(global_pool.last_updated, 1_700_000_000); - let all_balances = client.get_all_developer_balances(); + let all_balances = client.get_all_developer_balances(&admin); assert_eq!(all_balances.len(), 0); assert_eq!(client.get_developer_balance(&developer), 0); } @@ -143,7 +143,7 @@ mod settlement_tests { let client = CalloraSettlementClient::new(&env, &addr); client.init(&admin, &vault); - let all = client.get_all_developer_balances(); + let all = client.get_all_developer_balances(&admin); assert_eq!(all.len(), 0); } @@ -222,7 +222,7 @@ mod settlement_tests { client.receive_payment(&vault, &300i128, &false, &Some(dev1.clone())); client.receive_payment(&vault, &200i128, &false, &Some(dev2.clone())); - let all = client.get_all_developer_balances(); + let all = client.get_all_developer_balances(&admin); assert_eq!(all.len(), 2); } @@ -236,7 +236,7 @@ mod settlement_tests { let client = CalloraSettlementClient::new(&env, &addr); client.init(&admin, &vault); - let all = client.get_all_developer_balances(); + let all = client.get_all_developer_balances(&admin); assert_eq!(all.len(), 0); } @@ -853,7 +853,7 @@ mod settlement_tests { assert_eq!(client.get_developer_balance(&developer), 500i128); // Admin can still view all balances - let all_balances = client.get_all_developer_balances(); + let all_balances = client.get_all_developer_balances(&new_admin); assert_eq!(all_balances.len(), 1); assert_eq!(all_balances.get(0).unwrap().balance, 500i128); } @@ -969,4 +969,92 @@ mod settlement_tests { assert_eq!(client.get_global_pool().total_balance, 0); assert_eq!(client.get_developer_balance(&developer), 200i128); } + + // --- Authorization Matrix Tests --- + + #[test] + fn test_set_admin_authorization_matrix() { + let (env, addr, admin, vault, third_party) = setup_contract(); + let client = CalloraSettlementClient::new(&env, &addr); + let new_admin = Address::generate(&env); + + // Admin can set admin + client.set_admin(&admin, &new_admin); + + // Vault cannot set admin + let result = catch_unwind(AssertUnwindSafe(|| { + client.set_admin(&vault, &new_admin); + })); + assert!(result.is_err()); + assert!(panic_message(result.unwrap_err()).contains("unauthorized: caller is not admin")); + + // Third party cannot set admin + let result = catch_unwind(AssertUnwindSafe(|| { + client.set_admin(&third_party, &new_admin); + })); + assert!(result.is_err()); + assert!(panic_message(result.unwrap_err()).contains("unauthorized: caller is not admin")); + } + + #[test] + fn test_set_vault_authorization_matrix() { + let (env, addr, admin, vault, third_party) = setup_contract(); + let client = CalloraSettlementClient::new(&env, &addr); + let new_vault = Address::generate(&env); + + // Admin can set vault + client.set_vault(&admin, &new_vault); + + // Vault cannot set vault + let result = catch_unwind(AssertUnwindSafe(|| { + client.set_vault(&vault, &new_vault); + })); + assert!(result.is_err()); + assert!(panic_message(result.unwrap_err()).contains("unauthorized: caller is not admin")); + + // Third party cannot set vault + let result = catch_unwind(AssertUnwindSafe(|| { + client.set_vault(&third_party, &new_vault); + })); + assert!(result.is_err()); + assert!(panic_message(result.unwrap_err()).contains("unauthorized: caller is not admin")); + } + + #[test] + fn test_accept_admin_authorization_matrix() { + let (env, addr, admin, vault, third_party) = setup_contract(); + let client = CalloraSettlementClient::new(&env, &addr); + let new_admin = Address::generate(&env); + + client.set_admin(&admin, &new_admin); + + // Accept for new_admin (using mock_all_auths which is ON from setup_contract) + client.accept_admin(); + assert_eq!(client.get_admin(), new_admin); + } + + + + #[test] + fn test_get_all_developer_balances_authorization_matrix() { + let (env, addr, admin, vault, third_party) = setup_contract(); + let client = CalloraSettlementClient::new(&env, &addr); + + // Admin can call + client.get_all_developer_balances(&admin); + + // Vault cannot call + let result = catch_unwind(AssertUnwindSafe(|| { + client.get_all_developer_balances(&vault); + })); + assert!(result.is_err()); + assert!(panic_message(result.unwrap_err()).contains("unauthorized: caller is not admin")); + + // Third party cannot call + let result = catch_unwind(AssertUnwindSafe(|| { + client.get_all_developer_balances(&third_party); + })); + assert!(result.is_err()); + assert!(panic_message(result.unwrap_err()).contains("unauthorized: caller is not admin")); + } } diff --git a/contracts/settlement/src/test_views.rs b/contracts/settlement/src/test_views.rs index b577cc2e..9058a422 100644 --- a/contracts/settlement/src/test_views.rs +++ b/contracts/settlement/src/test_views.rs @@ -46,10 +46,12 @@ fn test_get_developer_balance_uninitialized_panics() { #[should_panic(expected = "settlement contract not initialized")] fn test_get_all_developer_balances_uninitialized_panics() { let env = Env::default(); + env.mock_all_auths(); let addr = env.register(CalloraSettlement, ()); let client = CalloraSettlementClient::new(&env, &addr); + let dummy = Address::generate(&env); - client.get_all_developer_balances(); + client.get_all_developer_balances(&dummy); } #[test] diff --git a/contracts/vault/src/lib.rs b/contracts/vault/src/lib.rs index f3f70304..7bb5caaf 100644 --- a/contracts/vault/src/lib.rs +++ b/contracts/vault/src/lib.rs @@ -1,589 +1,623 @@ -//! # Callora Vault Contract deposit/withdraw/deduct/distribute with pause circuit-breaker. -#![no_std] -use soroban_sdk::{contract, contractimpl, contracttype, token, Address, Env, String, Symbol, Vec}; - -#[contracttype] -#[derive(Clone)] -pub struct DeductItem { - pub amount: i128, - pub request_id: Option, -} - -#[contracttype] -#[derive(Clone)] -pub struct VaultMeta { - pub owner: Address, - pub balance: i128, - pub authorized_caller: Option
, - pub min_deposit: i128, -} - -#[contracttype] -pub enum StorageKey { - Meta, - AllowedDepositors, - Admin, - UsdcToken, - Settlement, - RevenuePool, - MaxDeduct, - Paused, - Metadata(String), - PendingOwner, - PendingAdmin, - DepositorList, -} - -pub const DEFAULT_MAX_DEDUCT: i128 = i128::MAX; -pub const MAX_BATCH_SIZE: u32 = 50; -pub const MAX_METADATA_LEN: u32 = 256; -pub const MAX_OFFERING_ID_LEN: u32 = 64; - -#[contract] -pub struct CalloraVault; - -#[contractimpl] -impl CalloraVault { - #[allow(clippy::too_many_arguments)] - pub fn init(env: Env, owner: Address, usdc_token: Address, initial_balance: Option, - authorized_caller: Option
, min_deposit: Option, - revenue_pool: Option
, max_deduct: Option) -> VaultMeta { - owner.require_auth(); - let inst = env.storage().instance(); - if inst.has(&StorageKey::Meta) { - panic!("vault already initialized"); - } - assert!( - usdc_token != env.current_contract_address(), - "usdc_token cannot be vault address" - ); - if let Some(p) = &revenue_pool { - assert!( - p != &env.current_contract_address(), - "revenue_pool cannot be vault address" - ); - } - let balance = initial_balance.unwrap_or(0); - assert!(balance >= 0, "initial balance must be non-negative"); - let min_d = min_deposit.unwrap_or(0); - assert!(min_d >= 0, "min_deposit must be non-negative"); - let max_d = max_deduct.unwrap_or(DEFAULT_MAX_DEDUCT); - assert!(max_d > 0, "max_deduct must be positive"); - assert!(min_d <= max_d, "min_deposit cannot exceed max_deduct"); - if balance > 0 { - let onchain_usdc_balance = - token::Client::new(&env, &usdc_token).balance(&env.current_contract_address()); - assert!( - onchain_usdc_balance >= balance, - "initial_balance exceeds on-ledger USDC balance" - ); - } - let meta = VaultMeta { - owner: owner.clone(), - balance, - authorized_caller, - min_deposit: min_d, - }; - inst.set(&StorageKey::Meta, &meta); - inst.set(&StorageKey::UsdcToken, &usdc_token); - inst.set(&StorageKey::Admin, &owner); - if let Some(p) = revenue_pool { - inst.set(&StorageKey::RevenuePool, &p); - } - inst.set(&StorageKey::MaxDeduct, &max_d); - env.events() - .publish((Symbol::new(&env, "init"), owner.clone()), balance); - meta - } - - pub fn is_authorized_depositor(env: Env, caller: Address) -> bool { - let meta = Self::get_meta(env.clone()); - if caller == meta.owner { - return true; - } - let list: Vec
= env - .storage() - .instance() - .get(&StorageKey::DepositorList) - .unwrap_or(Vec::new(&env)); - list.contains(&caller) - } - - pub fn get_admin(env: Env) -> Address { - env.storage().instance().get(&StorageKey::Admin).expect("vault not initialized") - } - - pub fn set_admin(env: Env, caller: Address, new_admin: Address) { - caller.require_auth(); - let cur = Self::get_admin(env.clone()); - if caller != cur { - panic!("unauthorized: caller is not admin"); - } - env.storage() - .instance() - .set(&StorageKey::PendingAdmin, &new_admin); - env.events() - .publish((Symbol::new(&env, "admin_nominated"), cur, new_admin), ()); - } - - pub fn accept_admin(env: Env) { - let pending: Address = env - .storage() - .instance() - .get(&StorageKey::PendingAdmin) - .expect("no admin transfer pending"); - pending.require_auth(); - let cur = Self::get_admin(env.clone()); - env.storage().instance().set(&StorageKey::Admin, &pending); - env.storage().instance().remove(&StorageKey::PendingAdmin); - env.events() - .publish((Symbol::new(&env, "admin_accepted"), cur, pending), ()); - } - - pub fn require_owner(env: Env, caller: Address) { - let meta = Self::get_meta(env.clone()); - assert!(caller == meta.owner, "unauthorized: owner only"); - } - - pub fn distribute(env: Env, caller: Address, to: Address, amount: i128) { - caller.require_auth(); - let admin = Self::get_admin(env.clone()); - if caller != admin { - panic!("unauthorized: caller is not admin"); - } - if amount <= 0 { - panic!("amount must be positive"); - } - let usdc_addr: Address = env - .storage() - .instance() - .get(&StorageKey::UsdcToken) - .expect("vault not initialized"); - let usdc = token::Client::new(&env, &usdc_addr); - let vb = usdc.balance(&env.current_contract_address()); - if vb < amount { - panic!("insufficient USDC balance"); - } - usdc.transfer(&env.current_contract_address(), &to, &amount); - env.events().publish((Symbol::new(&env, "distribute"), to), amount); - } - - pub fn get_meta(env: Env) -> VaultMeta { - env.storage().instance().get(&StorageKey::Meta).unwrap_or_else(|| panic!("vault not initialized")) - } - - pub fn set_allowed_depositor(env: Env, caller: Address, depositor: Option
) { - caller.require_auth(); - Self::require_owner(env.clone(), caller); - match depositor { - Some(d) => { - let mut list: Vec
= env - .storage() - .instance() - .get(&StorageKey::DepositorList) - .unwrap_or(Vec::new(&env)); - if !list.contains(&d) { - env.storage() - .instance() - .set(&StorageKey::AllowedDepositors, &d); - list.push_back(d); - } - env.storage() - .instance() - .set(&StorageKey::DepositorList, &list); - } - None => { - env.storage() - .instance() - .remove(&StorageKey::AllowedDepositors); - env.storage() - .instance() - .set(&StorageKey::DepositorList, &Vec::
::new(&env)); - } - } - } - - pub fn clear_allowed_depositors(env: Env, caller: Address) { - caller.require_auth(); - Self::require_owner(env.clone(), caller); - env.storage() - .instance() - .remove(&StorageKey::AllowedDepositors); - env.storage() - .instance() - .set(&StorageKey::DepositorList, &Vec::
::new(&env)); - } - - pub fn get_allowed_depositors(env: Env) -> Vec
{ - env.storage().instance().get(&StorageKey::DepositorList).unwrap_or(Vec::new(&env)) - } - - pub fn set_authorized_caller(env: Env, caller: Address) { - let mut meta = Self::get_meta(env.clone()); - meta.owner.require_auth(); - meta.authorized_caller = Some(caller.clone()); - env.storage().instance().set(&StorageKey::Meta, &meta); - env.events().publish( - (Symbol::new(&env, "set_auth_caller"), meta.owner.clone()), - caller, - ); - } - - pub fn pause(env: Env, caller: Address) { - caller.require_auth(); - Self::require_admin_or_owner(env.clone(), &caller); - assert!(!Self::is_paused(env.clone()), "vault already paused"); - env.storage().instance().set(&StorageKey::Paused, &true); - env.events() - .publish((Symbol::new(&env, "vault_paused"), caller), ()); - } - - pub fn unpause(env: Env, caller: Address) { - caller.require_auth(); - Self::require_admin_or_owner(env.clone(), &caller); - assert!(Self::is_paused(env.clone()), "vault not paused"); - env.storage().instance().set(&StorageKey::Paused, &false); - env.events().publish((Symbol::new(&env, "vault_unpaused"), caller), ()); - } - - pub fn is_paused(env: Env) -> bool { - env.storage().instance().get(&StorageKey::Paused).unwrap_or(false) - } - - pub fn get_max_deduct(env: Env) -> i128 { - env.storage().instance().get(&StorageKey::MaxDeduct).unwrap_or(DEFAULT_MAX_DEDUCT) - } - - pub fn get_max_deduct(env: Env) -> i128 { - env.storage() - .instance() - .get(&StorageKey::MaxDeduct) - .unwrap_or(DEFAULT_MAX_DEDUCT) - } - - pub fn deposit(env: Env, caller: Address, amount: i128) -> i128 { - caller.require_auth(); - Self::require_not_paused(env.clone()); - assert!(amount > 0, "amount must be positive"); - assert!( - Self::is_authorized_depositor(env.clone(), caller.clone()), - "unauthorized: only owner or allowed depositor can deposit" - ); - let meta = Self::get_meta(env.clone()); - assert!( - amount >= meta.min_deposit, - "deposit below minimum: {} < {}", - amount, - meta.min_deposit - ); - let usdc_addr: Address = env - .storage() - .instance() - .get(&StorageKey::UsdcToken) - .expect("vault not initialized"); - let usdc = token::Client::new(&env, &usdc_addr); - usdc.transfer(&caller, &env.current_contract_address(), &amount); - let mut meta = Self::get_meta(env.clone()); - meta.balance = meta.balance.checked_add(amount).unwrap_or_else(|| panic!("balance overflow")); - env.storage().instance().set(&StorageKey::Meta, &meta); - env.events().publish( - (Symbol::new(&env, "deposit"), caller.clone()), - (amount, meta.balance), - ); - meta.balance - } - - pub fn deduct(env: Env, caller: Address, amount: i128, request_id: Option) -> i128 { - caller.require_auth(); - Self::require_not_paused(env.clone()); - assert!(amount > 0, "amount must be positive"); - let max_d = Self::get_max_deduct(env.clone()); - assert!(amount <= max_d, "deduct amount exceeds max_deduct"); - let meta = Self::get_meta(env.clone()); - let auth = match &meta.authorized_caller { - Some(ac) => caller == *ac || caller == meta.owner, - None => true, - }; - assert!(auth, "unauthorized caller"); - assert!(meta.balance >= amount, "insufficient balance"); - let mut meta = Self::get_meta(env.clone()); - meta.balance = meta.balance.checked_sub(amount).unwrap(); - env.storage().instance().set(&StorageKey::Meta, &meta); - let inst = env.storage().instance(); - if let Some(s) = inst.get::(&StorageKey::Settlement) { - let ut: Address = inst.get(&StorageKey::UsdcToken).unwrap(); - Self::transfer_funds(&env, &ut, &s, amount); - } else if inst - .get::(&StorageKey::RevenuePool) - .is_some() - { - Self::transfer_to_revenue_pool(env.clone(), amount); - } - let rid = request_id.unwrap_or(Symbol::new(&env, "")); - env.events().publish( - (Symbol::new(&env, "deduct"), caller, rid), - (amount, meta.balance), - ); - meta.balance - } - - pub fn batch_deduct(env: Env, caller: Address, items: Vec) -> i128 { - caller.require_auth(); - let n = items.len(); - assert!(n > 0, "batch_deduct requires at least one item"); - assert!(n <= MAX_BATCH_SIZE, "batch too large"); - let max_d = Self::get_max_deduct(env.clone()); - let mut meta = Self::get_meta(env.clone()); - let auth = match &meta.authorized_caller { - Some(ac) => caller == *ac || caller == meta.owner, - None => true, - }; - assert!(auth, "unauthorized caller"); - let mut running = meta.balance; - let mut total: i128 = 0; - for item in items.iter() { - assert!(item.amount > 0, "amount must be positive"); - assert!(item.amount <= max_d, "deduct amount exceeds max_deduct"); - assert!(running >= item.amount, "insufficient balance"); - running = running.checked_sub(item.amount).unwrap(); - total = total.checked_add(item.amount).unwrap(); - } - meta.balance = running; - env.storage().instance().set(&StorageKey::Meta, &meta); - let mut eb = meta.balance.checked_add(total).unwrap(); - for item in items.iter() { - eb = eb.checked_sub(item.amount).unwrap(); - let rid = item.request_id.clone().unwrap_or(Symbol::new(&env, "")); - env.events().publish( - (Symbol::new(&env, "deduct"), caller.clone(), rid), - (item.amount, eb), - ); - } - let inst = env.storage().instance(); - if let Some(s) = inst.get::(&StorageKey::Settlement) { - let ut: Address = inst.get(&StorageKey::UsdcToken).unwrap(); - Self::transfer_funds(&env, &ut, &s, total); - } else if inst - .get::(&StorageKey::RevenuePool) - .is_some() - { - Self::transfer_to_revenue_pool(env.clone(), total); - } - meta.balance - } - - pub fn balance(env: Env) -> i128 { - Self::get_meta(env).balance - } - - pub fn transfer_ownership(env: Env, new_owner: Address) { - let meta = Self::get_meta(env.clone()); - meta.owner.require_auth(); - assert!( - new_owner != meta.owner, - "new_owner must be different from current owner" - ); - env.storage() - .instance() - .set(&StorageKey::PendingOwner, &new_owner); - env.events().publish( - ( - Symbol::new(&env, "ownership_nominated"), - meta.owner, - new_owner, - ), - (), - ); - } - - pub fn accept_ownership(env: Env) { - let pending: Address = env - .storage() - .instance() - .get(&StorageKey::PendingOwner) - .expect("no ownership transfer pending"); - pending.require_auth(); - let mut meta = Self::get_meta(env.clone()); - let old = meta.owner.clone(); - meta.owner = pending; - env.storage().instance().set(&StorageKey::Meta, &meta); - env.storage().instance().remove(&StorageKey::PendingOwner); - env.events().publish( - (Symbol::new(&env, "ownership_accepted"), old, meta.owner), - (), - ); - } - - pub fn withdraw(env: Env, amount: i128) -> i128 { - let mut meta = Self::get_meta(env.clone()); - meta.owner.require_auth(); - assert!(amount > 0, "amount must be positive"); - assert!(meta.balance >= amount, "insufficient balance"); - let ua: Address = env - .storage() - .instance() - .get(&StorageKey::UsdcToken) - .expect("vault not initialized"); - let usdc = token::Client::new(&env, &ua); - usdc.transfer(&env.current_contract_address(), &meta.owner, &amount); - meta.balance = meta.balance.checked_sub(amount).unwrap(); - env.storage().instance().set(&StorageKey::Meta, &meta); - env.events().publish( - (Symbol::new(&env, "withdraw"), meta.owner.clone()), - (amount, meta.balance), - ); - meta.balance - } - - pub fn withdraw_to(env: Env, to: Address, amount: i128) -> i128 { - let mut meta = Self::get_meta(env.clone()); - meta.owner.require_auth(); - assert!(amount > 0, "amount must be positive"); - assert!(meta.balance >= amount, "insufficient balance"); - let ua: Address = env - .storage() - .instance() - .get(&StorageKey::UsdcToken) - .expect("vault not initialized"); - let usdc = token::Client::new(&env, &ua); - usdc.transfer(&env.current_contract_address(), &to, &amount); - meta.balance = meta.balance.checked_sub(amount).unwrap(); - env.storage().instance().set(&StorageKey::Meta, &meta); - env.events().publish( - (Symbol::new(&env, "withdraw_to"), meta.owner.clone(), to), - (amount, meta.balance), - ); - meta.balance - } - - pub fn set_revenue_pool(env: Env, caller: Address, revenue_pool: Option
) { - caller.require_auth(); - let admin = Self::get_admin(env.clone()); - if caller != admin { - panic!("unauthorized: caller is not admin"); - } - match revenue_pool { - Some(addr) => { - env.storage() - .instance() - .set(&StorageKey::RevenuePool, &addr); - env.events() - .publish((Symbol::new(&env, "set_revenue_pool"), caller), addr); - } - None => { - env.storage().instance().remove(&StorageKey::RevenuePool); - env.events() - .publish((Symbol::new(&env, "clear_revenue_pool"), caller), ()); - } - } - } - - pub fn get_revenue_pool(env: Env) -> Option
{ - env.storage().instance().get(&StorageKey::RevenuePool) - } - - pub fn set_settlement(env: Env, caller: Address, settlement_address: Address) { - caller.require_auth(); - let admin = Self::get_admin(env.clone()); - if caller != admin { - panic!("unauthorized: caller is not admin"); - } - env.storage() - .instance() - .set(&StorageKey::Settlement, &settlement_address); - } - - pub fn get_settlement(env: Env) -> Address { - env.storage().instance().get(&StorageKey::Settlement) - .unwrap_or_else(|| panic!("settlement address not set")) - } - - pub fn set_metadata( - env: Env, - caller: Address, - offering_id: String, - metadata: String, - ) -> String { - caller.require_auth(); - Self::require_owner(env.clone(), caller.clone()); - assert!( - offering_id.len() <= MAX_OFFERING_ID_LEN, - "offering_id exceeds max length" - ); - assert!( - metadata.len() <= MAX_METADATA_LEN, - "metadata exceeds max length" - ); - env.storage() - .instance() - .set(&StorageKey::Metadata(offering_id.clone()), &metadata); - env.events().publish( - (Symbol::new(&env, "metadata_set"), offering_id, caller), - metadata.clone(), - ); - metadata - } - - pub fn get_metadata(env: Env, offering_id: String) -> Option { - env.storage() - .instance() - .get(&StorageKey::Metadata(offering_id)) - } - - pub fn update_metadata( - env: Env, - caller: Address, - offering_id: String, - metadata: String, - ) -> String { - caller.require_auth(); - Self::require_owner(env.clone(), caller.clone()); - assert!( - offering_id.len() <= MAX_OFFERING_ID_LEN, - "offering_id exceeds max length" - ); - assert!( - metadata.len() <= MAX_METADATA_LEN, - "metadata exceeds max length" - ); - let old: String = env - .storage() - .instance() - .get(&StorageKey::Metadata(offering_id.clone())) - .unwrap_or(String::from_str(&env, "")); - env.storage().instance().set(&StorageKey::Metadata(offering_id.clone()), &metadata); - env.events().publish((Symbol::new(&env, "metadata_updated"), offering_id, caller), (old, metadata.clone())); - metadata - } - - fn transfer_funds(env: &Env, usdc_token: &Address, to: &Address, amount: i128) { - token::Client::new(env, usdc_token).transfer(&env.current_contract_address(), to, &amount); - } - - fn transfer_to_revenue_pool(env: Env, amount: i128) { - let inst = env.storage().instance(); - let rp: Address = inst - .get(&StorageKey::RevenuePool) - .expect("revenue pool address not set"); - let ua: Address = inst - .get(&StorageKey::UsdcToken) - .expect("vault not initialized"); - token::Client::new(&env, &ua).transfer(&env.current_contract_address(), &rp, &amount); - } - - fn require_not_paused(env: Env) { - assert!(!Self::is_paused(env), "vault is paused"); - } - - fn require_admin_or_owner(env: Env, caller: &Address) { - let admin: Address = env.storage().instance().get(&StorageKey::Admin).expect("vault not initialized"); - let meta = Self::get_meta(env); - assert!(*caller == admin || *caller == meta.owner, "unauthorized: caller is not admin or owner"); - } -} - -#[cfg(test)] -mod test; - -#[cfg(test)] -mod test_init_hardening; \ No newline at end of file +//! # Callora Vault Contract deposit/withdraw/deduct/distribute with pause circuit-breaker. +#![no_std] +use soroban_sdk::{contract, contractimpl, contracttype, token, Address, Env, String, Symbol, Vec}; + +#[contracttype] +#[derive(Clone)] +pub struct DeductItem { + pub amount: i128, + pub request_id: Option, +} + +#[contracttype] +#[derive(Clone)] +pub struct VaultMeta { + pub owner: Address, + pub balance: i128, + pub authorized_caller: Option
, + pub min_deposit: i128, +} + +#[contracttype] +pub enum StorageKey { + Meta, + AllowedDepositors, + Admin, + UsdcToken, + Settlement, + RevenuePool, + MaxDeduct, + Paused, + Metadata(String), + PendingOwner, + PendingAdmin, + DepositorList, +} + +pub const DEFAULT_MAX_DEDUCT: i128 = i128::MAX; +pub const MAX_BATCH_SIZE: u32 = 50; +pub const MAX_METADATA_LEN: u32 = 256; +pub const MAX_OFFERING_ID_LEN: u32 = 64; + +#[contract] +pub struct CalloraVault; + +#[contractimpl] +impl CalloraVault { + #[allow(clippy::too_many_arguments)] + pub fn init( + env: Env, + owner: Address, + usdc_token: Address, + initial_balance: Option, + authorized_caller: Option
, + min_deposit: Option, + revenue_pool: Option
, + max_deduct: Option, + ) -> VaultMeta { + owner.require_auth(); + let inst = env.storage().instance(); + if inst.has(&StorageKey::Meta) { + panic!("vault already initialized"); + } + assert!( + usdc_token != env.current_contract_address(), + "usdc_token cannot be vault address" + ); + if let Some(p) = &revenue_pool { + assert!( + p != &env.current_contract_address(), + "revenue_pool cannot be vault address" + ); + } + let balance = initial_balance.unwrap_or(0); + assert!(balance >= 0, "initial balance must be non-negative"); + let min_d = min_deposit.unwrap_or(0); + assert!(min_d >= 0, "min_deposit must be non-negative"); + let max_d = max_deduct.unwrap_or(DEFAULT_MAX_DEDUCT); + assert!(max_d > 0, "max_deduct must be positive"); + assert!(min_d <= max_d, "min_deposit cannot exceed max_deduct"); + if balance > 0 { + let onchain_usdc_balance = + token::Client::new(&env, &usdc_token).balance(&env.current_contract_address()); + assert!( + onchain_usdc_balance >= balance, + "initial_balance exceeds on-ledger USDC balance" + ); + } + let meta = VaultMeta { + owner: owner.clone(), + balance, + authorized_caller, + min_deposit: min_d, + }; + inst.set(&StorageKey::Meta, &meta); + inst.set(&StorageKey::UsdcToken, &usdc_token); + inst.set(&StorageKey::Admin, &owner); + if let Some(p) = revenue_pool { + inst.set(&StorageKey::RevenuePool, &p); + } + inst.set(&StorageKey::MaxDeduct, &max_d); + env.events() + .publish((Symbol::new(&env, "init"), owner.clone()), balance); + meta + } + + pub fn is_authorized_depositor(env: Env, caller: Address) -> bool { + let meta = Self::get_meta(env.clone()); + if caller == meta.owner { + return true; + } + let list: Vec
= env + .storage() + .instance() + .get(&StorageKey::DepositorList) + .unwrap_or(Vec::new(&env)); + list.contains(&caller) + } + + pub fn get_admin(env: Env) -> Address { + env.storage() + .instance() + .get(&StorageKey::Admin) + .expect("vault not initialized") + } + + pub fn set_admin(env: Env, caller: Address, new_admin: Address) { + caller.require_auth(); + let cur = Self::get_admin(env.clone()); + if caller != cur { + panic!("unauthorized: caller is not admin"); + } + env.storage() + .instance() + .set(&StorageKey::PendingAdmin, &new_admin); + env.events() + .publish((Symbol::new(&env, "admin_nominated"), cur, new_admin), ()); + } + + pub fn accept_admin(env: Env) { + let pending: Address = env + .storage() + .instance() + .get(&StorageKey::PendingAdmin) + .expect("no admin transfer pending"); + pending.require_auth(); + let cur = Self::get_admin(env.clone()); + env.storage().instance().set(&StorageKey::Admin, &pending); + env.storage().instance().remove(&StorageKey::PendingAdmin); + env.events() + .publish((Symbol::new(&env, "admin_accepted"), cur, pending), ()); + } + + pub fn require_owner(env: Env, caller: Address) { + let meta = Self::get_meta(env.clone()); + assert!(caller == meta.owner, "unauthorized: owner only"); + } + + pub fn distribute(env: Env, caller: Address, to: Address, amount: i128) { + caller.require_auth(); + let admin = Self::get_admin(env.clone()); + if caller != admin { + panic!("unauthorized: caller is not admin"); + } + if amount <= 0 { + panic!("amount must be positive"); + } + let usdc_addr: Address = env + .storage() + .instance() + .get(&StorageKey::UsdcToken) + .expect("vault not initialized"); + let usdc = token::Client::new(&env, &usdc_addr); + let vb = usdc.balance(&env.current_contract_address()); + if vb < amount { + panic!("insufficient USDC balance"); + } + usdc.transfer(&env.current_contract_address(), &to, &amount); + env.events() + .publish((Symbol::new(&env, "distribute"), to), amount); + } + + pub fn get_meta(env: Env) -> VaultMeta { + env.storage() + .instance() + .get(&StorageKey::Meta) + .unwrap_or_else(|| panic!("vault not initialized")) + } + + pub fn set_allowed_depositor(env: Env, caller: Address, depositor: Option
) { + caller.require_auth(); + Self::require_owner(env.clone(), caller); + match depositor { + Some(d) => { + let mut list: Vec
= env + .storage() + .instance() + .get(&StorageKey::DepositorList) + .unwrap_or(Vec::new(&env)); + if !list.contains(&d) { + env.storage() + .instance() + .set(&StorageKey::AllowedDepositors, &d); + list.push_back(d); + } + env.storage() + .instance() + .set(&StorageKey::DepositorList, &list); + } + None => { + env.storage() + .instance() + .remove(&StorageKey::AllowedDepositors); + env.storage() + .instance() + .set(&StorageKey::DepositorList, &Vec::
::new(&env)); + } + } + } + + pub fn clear_allowed_depositors(env: Env, caller: Address) { + caller.require_auth(); + Self::require_owner(env.clone(), caller); + env.storage() + .instance() + .remove(&StorageKey::AllowedDepositors); + env.storage() + .instance() + .set(&StorageKey::DepositorList, &Vec::
::new(&env)); + } + + pub fn get_allowed_depositors(env: Env) -> Vec
{ + env.storage() + .instance() + .get(&StorageKey::DepositorList) + .unwrap_or(Vec::new(&env)) + } + + pub fn set_authorized_caller(env: Env, caller: Address) { + let mut meta = Self::get_meta(env.clone()); + meta.owner.require_auth(); + meta.authorized_caller = Some(caller.clone()); + env.storage().instance().set(&StorageKey::Meta, &meta); + env.events().publish( + (Symbol::new(&env, "set_auth_caller"), meta.owner.clone()), + caller, + ); + } + + pub fn pause(env: Env, caller: Address) { + caller.require_auth(); + Self::require_admin_or_owner(env.clone(), &caller); + assert!(!Self::is_paused(env.clone()), "vault already paused"); + env.storage().instance().set(&StorageKey::Paused, &true); + env.events() + .publish((Symbol::new(&env, "vault_paused"), caller), ()); + } + + pub fn unpause(env: Env, caller: Address) { + caller.require_auth(); + Self::require_admin_or_owner(env.clone(), &caller); + assert!(Self::is_paused(env.clone()), "vault not paused"); + env.storage().instance().set(&StorageKey::Paused, &false); + env.events() + .publish((Symbol::new(&env, "vault_unpaused"), caller), ()); + } + + pub fn is_paused(env: Env) -> bool { + env.storage() + .instance() + .get(&StorageKey::Paused) + .unwrap_or(false) + } + + pub fn get_max_deduct(env: Env) -> i128 { + env.storage() + .instance() + .get(&StorageKey::MaxDeduct) + .unwrap_or(DEFAULT_MAX_DEDUCT) + } + + pub fn deposit(env: Env, caller: Address, amount: i128) -> i128 { + caller.require_auth(); + Self::require_not_paused(env.clone()); + assert!(amount > 0, "amount must be positive"); + assert!( + Self::is_authorized_depositor(env.clone(), caller.clone()), + "unauthorized: only owner or allowed depositor can deposit" + ); + let meta = Self::get_meta(env.clone()); + assert!( + amount >= meta.min_deposit, + "deposit below minimum: {} < {}", + amount, + meta.min_deposit + ); + let usdc_addr: Address = env + .storage() + .instance() + .get(&StorageKey::UsdcToken) + .expect("vault not initialized"); + let usdc = token::Client::new(&env, &usdc_addr); + usdc.transfer(&caller, &env.current_contract_address(), &amount); + let mut meta = Self::get_meta(env.clone()); + meta.balance = meta + .balance + .checked_add(amount) + .unwrap_or_else(|| panic!("balance overflow")); + env.storage().instance().set(&StorageKey::Meta, &meta); + env.events().publish( + (Symbol::new(&env, "deposit"), caller.clone()), + (amount, meta.balance), + ); + meta.balance + } + + pub fn deduct(env: Env, caller: Address, amount: i128, request_id: Option) -> i128 { + caller.require_auth(); + Self::require_not_paused(env.clone()); + assert!(amount > 0, "amount must be positive"); + let max_d = Self::get_max_deduct(env.clone()); + assert!(amount <= max_d, "deduct amount exceeds max_deduct"); + let meta = Self::get_meta(env.clone()); + let auth = match &meta.authorized_caller { + Some(ac) => caller == *ac || caller == meta.owner, + None => true, + }; + assert!(auth, "unauthorized caller"); + assert!(meta.balance >= amount, "insufficient balance"); + let mut meta = Self::get_meta(env.clone()); + meta.balance = meta.balance.checked_sub(amount).unwrap(); + env.storage().instance().set(&StorageKey::Meta, &meta); + let inst = env.storage().instance(); + if let Some(s) = inst.get::(&StorageKey::Settlement) { + let ut: Address = inst.get(&StorageKey::UsdcToken).unwrap(); + Self::transfer_funds(&env, &ut, &s, amount); + } else if inst + .get::(&StorageKey::RevenuePool) + .is_some() + { + Self::transfer_to_revenue_pool(env.clone(), amount); + } + let rid = request_id.unwrap_or(Symbol::new(&env, "")); + env.events().publish( + (Symbol::new(&env, "deduct"), caller, rid), + (amount, meta.balance), + ); + meta.balance + } + + pub fn batch_deduct(env: Env, caller: Address, items: Vec) -> i128 { + caller.require_auth(); + let n = items.len(); + assert!(n > 0, "batch_deduct requires at least one item"); + assert!(n <= MAX_BATCH_SIZE, "batch too large"); + let max_d = Self::get_max_deduct(env.clone()); + let mut meta = Self::get_meta(env.clone()); + let auth = match &meta.authorized_caller { + Some(ac) => caller == *ac || caller == meta.owner, + None => true, + }; + assert!(auth, "unauthorized caller"); + let mut running = meta.balance; + let mut total: i128 = 0; + for item in items.iter() { + assert!(item.amount > 0, "amount must be positive"); + assert!(item.amount <= max_d, "deduct amount exceeds max_deduct"); + assert!(running >= item.amount, "insufficient balance"); + running = running.checked_sub(item.amount).unwrap(); + total = total.checked_add(item.amount).unwrap(); + } + meta.balance = running; + env.storage().instance().set(&StorageKey::Meta, &meta); + let mut eb = meta.balance.checked_add(total).unwrap(); + for item in items.iter() { + eb = eb.checked_sub(item.amount).unwrap(); + let rid = item.request_id.clone().unwrap_or(Symbol::new(&env, "")); + env.events().publish( + (Symbol::new(&env, "deduct"), caller.clone(), rid), + (item.amount, eb), + ); + } + let inst = env.storage().instance(); + if let Some(s) = inst.get::(&StorageKey::Settlement) { + let ut: Address = inst.get(&StorageKey::UsdcToken).unwrap(); + Self::transfer_funds(&env, &ut, &s, total); + } else if inst + .get::(&StorageKey::RevenuePool) + .is_some() + { + Self::transfer_to_revenue_pool(env.clone(), total); + } + meta.balance + } + + pub fn balance(env: Env) -> i128 { + Self::get_meta(env).balance + } + + pub fn transfer_ownership(env: Env, new_owner: Address) { + let meta = Self::get_meta(env.clone()); + meta.owner.require_auth(); + assert!( + new_owner != meta.owner, + "new_owner must be different from current owner" + ); + env.storage() + .instance() + .set(&StorageKey::PendingOwner, &new_owner); + env.events().publish( + ( + Symbol::new(&env, "ownership_nominated"), + meta.owner, + new_owner, + ), + (), + ); + } + + pub fn accept_ownership(env: Env) { + let pending: Address = env + .storage() + .instance() + .get(&StorageKey::PendingOwner) + .expect("no ownership transfer pending"); + pending.require_auth(); + let mut meta = Self::get_meta(env.clone()); + let old = meta.owner.clone(); + meta.owner = pending; + env.storage().instance().set(&StorageKey::Meta, &meta); + env.storage().instance().remove(&StorageKey::PendingOwner); + env.events().publish( + (Symbol::new(&env, "ownership_accepted"), old, meta.owner), + (), + ); + } + + pub fn withdraw(env: Env, amount: i128) -> i128 { + let mut meta = Self::get_meta(env.clone()); + meta.owner.require_auth(); + assert!(amount > 0, "amount must be positive"); + assert!(meta.balance >= amount, "insufficient balance"); + let ua: Address = env + .storage() + .instance() + .get(&StorageKey::UsdcToken) + .expect("vault not initialized"); + let usdc = token::Client::new(&env, &ua); + usdc.transfer(&env.current_contract_address(), &meta.owner, &amount); + meta.balance = meta.balance.checked_sub(amount).unwrap(); + env.storage().instance().set(&StorageKey::Meta, &meta); + env.events().publish( + (Symbol::new(&env, "withdraw"), meta.owner.clone()), + (amount, meta.balance), + ); + meta.balance + } + + pub fn withdraw_to(env: Env, to: Address, amount: i128) -> i128 { + let mut meta = Self::get_meta(env.clone()); + meta.owner.require_auth(); + assert!(amount > 0, "amount must be positive"); + assert!(meta.balance >= amount, "insufficient balance"); + let ua: Address = env + .storage() + .instance() + .get(&StorageKey::UsdcToken) + .expect("vault not initialized"); + let usdc = token::Client::new(&env, &ua); + usdc.transfer(&env.current_contract_address(), &to, &amount); + meta.balance = meta.balance.checked_sub(amount).unwrap(); + env.storage().instance().set(&StorageKey::Meta, &meta); + env.events().publish( + (Symbol::new(&env, "withdraw_to"), meta.owner.clone(), to), + (amount, meta.balance), + ); + meta.balance + } + + pub fn set_revenue_pool(env: Env, caller: Address, revenue_pool: Option
) { + caller.require_auth(); + let admin = Self::get_admin(env.clone()); + if caller != admin { + panic!("unauthorized: caller is not admin"); + } + match revenue_pool { + Some(addr) => { + env.storage() + .instance() + .set(&StorageKey::RevenuePool, &addr); + env.events() + .publish((Symbol::new(&env, "set_revenue_pool"), caller), addr); + } + None => { + env.storage().instance().remove(&StorageKey::RevenuePool); + env.events() + .publish((Symbol::new(&env, "clear_revenue_pool"), caller), ()); + } + } + } + + pub fn get_revenue_pool(env: Env) -> Option
{ + env.storage().instance().get(&StorageKey::RevenuePool) + } + + pub fn set_settlement(env: Env, caller: Address, settlement_address: Address) { + caller.require_auth(); + let admin = Self::get_admin(env.clone()); + if caller != admin { + panic!("unauthorized: caller is not admin"); + } + env.storage() + .instance() + .set(&StorageKey::Settlement, &settlement_address); + } + + pub fn get_settlement(env: Env) -> Address { + env.storage() + .instance() + .get(&StorageKey::Settlement) + .unwrap_or_else(|| panic!("settlement address not set")) + } + + pub fn set_metadata( + env: Env, + caller: Address, + offering_id: String, + metadata: String, + ) -> String { + caller.require_auth(); + Self::require_owner(env.clone(), caller.clone()); + assert!( + offering_id.len() <= MAX_OFFERING_ID_LEN, + "offering_id exceeds max length" + ); + assert!( + metadata.len() <= MAX_METADATA_LEN, + "metadata exceeds max length" + ); + env.storage() + .instance() + .set(&StorageKey::Metadata(offering_id.clone()), &metadata); + env.events().publish( + (Symbol::new(&env, "metadata_set"), offering_id, caller), + metadata.clone(), + ); + metadata + } + + pub fn get_metadata(env: Env, offering_id: String) -> Option { + env.storage() + .instance() + .get(&StorageKey::Metadata(offering_id)) + } + + pub fn update_metadata( + env: Env, + caller: Address, + offering_id: String, + metadata: String, + ) -> String { + caller.require_auth(); + Self::require_owner(env.clone(), caller.clone()); + assert!( + offering_id.len() <= MAX_OFFERING_ID_LEN, + "offering_id exceeds max length" + ); + assert!( + metadata.len() <= MAX_METADATA_LEN, + "metadata exceeds max length" + ); + let old: String = env + .storage() + .instance() + .get(&StorageKey::Metadata(offering_id.clone())) + .unwrap_or(String::from_str(&env, "")); + env.storage() + .instance() + .set(&StorageKey::Metadata(offering_id.clone()), &metadata); + env.events().publish( + (Symbol::new(&env, "metadata_updated"), offering_id, caller), + (old, metadata.clone()), + ); + metadata + } + + fn transfer_funds(env: &Env, usdc_token: &Address, to: &Address, amount: i128) { + token::Client::new(env, usdc_token).transfer(&env.current_contract_address(), to, &amount); + } + + fn transfer_to_revenue_pool(env: Env, amount: i128) { + let inst = env.storage().instance(); + let rp: Address = inst + .get(&StorageKey::RevenuePool) + .expect("revenue pool address not set"); + let ua: Address = inst + .get(&StorageKey::UsdcToken) + .expect("vault not initialized"); + token::Client::new(&env, &ua).transfer(&env.current_contract_address(), &rp, &amount); + } + + fn require_not_paused(env: Env) { + assert!(!Self::is_paused(env), "vault is paused"); + } + + fn require_admin_or_owner(env: Env, caller: &Address) { + let admin: Address = env + .storage() + .instance() + .get(&StorageKey::Admin) + .expect("vault not initialized"); + let meta = Self::get_meta(env); + assert!( + *caller == admin || *caller == meta.owner, + "unauthorized: caller is not admin or owner" + ); + } +} + +#[cfg(test)] +mod test; + +#[cfg(test)] +mod test_init_hardening; \ No newline at end of file diff --git a/contracts/vault/src/test.rs b/contracts/vault/src/test.rs index 3083d71e..877638e4 100644 --- a/contracts/vault/src/test.rs +++ b/contracts/vault/src/test.rs @@ -2321,7 +2321,13 @@ fn batch_deduct_while_paused_fails() { fund_vault(&usdc_admin, &vault_address, 500); client.init(&owner, &usdc, &Some(500), &None, &None, &None, &None); client.pause(&owner); - let items = soroban_sdk::vec![&env, DeductItem { amount: 100, request_id: None }]; + let items = soroban_sdk::vec![ + &env, + DeductItem { + amount: 100, + request_id: None, + }, + ]; client.batch_deduct(&owner, &items); } @@ -2353,7 +2359,13 @@ fn batch_deduct_unauthorized_caller_fails() { fund_vault(&usdc_admin, &vault_address, 500); let auth = Address::generate(&env); client.init(&owner, &usdc, &Some(500), &Some(auth), &None, &None, &None); - let items = soroban_sdk::vec![&env, DeductItem { amount: 100, request_id: None }]; + let items = soroban_sdk::vec![ + &env, + DeductItem { + amount: 100, + request_id: None, + }, + ]; client.batch_deduct(&attacker, &items); } @@ -2380,7 +2392,13 @@ fn batch_deduct_item_exceeds_max_deduct_fails() { env.mock_all_auths(); fund_vault(&usdc_admin, &vault_address, 1000); client.init(&owner, &usdc, &Some(1000), &None, &None, &None, &Some(50)); - let items = soroban_sdk::vec![&env, DeductItem { amount: 100, request_id: None }]; + let items = soroban_sdk::vec![ + &env, + DeductItem { + amount: 100, + request_id: None, + }, + ]; client.batch_deduct(&owner, &items); } @@ -2476,8 +2494,14 @@ fn batch_deduct_no_routing_stays_in_vault() { client.init(&owner, &usdc, &Some(500), &None, &None, &None, &None); let items = soroban_sdk::vec![ &env, - DeductItem { amount: 100, request_id: None }, - DeductItem { amount: 50, request_id: None }, + DeductItem { + amount: 100, + request_id: None, + }, + DeductItem { + amount: 50, + request_id: None, + }, ]; client.batch_deduct(&owner, &items); assert_eq!(client.balance(), 350); @@ -2495,12 +2519,15 @@ fn withdraw_emits_event() { client.init(&owner, &usdc, &Some(300), &None, &None, &None, &None); client.withdraw(&100); let events = env.events().all(); - let ev = events.iter().find(|e| { - e.0 == vault_address && !e.1.is_empty() && { - let t: Symbol = e.1.get(0).unwrap().into_val(&env); - t == Symbol::new(&env, "withdraw") - } - }).expect("expected withdraw event"); + let ev = events + .iter() + .find(|e| { + e.0 == vault_address && !e.1.is_empty() && { + let t: Symbol = e.1.get(0).unwrap().into_val(&env); + t == Symbol::new(&env, "withdraw") + } + }) + .expect("expected withdraw event"); let (amt, bal): (i128, i128) = ev.2.into_val(&env); assert_eq!(amt, 100); assert_eq!(bal, 200); @@ -2518,12 +2545,15 @@ fn withdraw_to_emits_event() { client.init(&owner, &usdc, &Some(300), &None, &None, &None, &None); client.withdraw_to(&recipient, &150); let events = env.events().all(); - let ev = events.iter().find(|e| { - e.0 == vault_address && !e.1.is_empty() && { - let t: Symbol = e.1.get(0).unwrap().into_val(&env); - t == Symbol::new(&env, "withdraw_to") - } - }).expect("expected withdraw_to event"); + let ev = events + .iter() + .find(|e| { + e.0 == vault_address && !e.1.is_empty() && { + let t: Symbol = e.1.get(0).unwrap().into_val(&env); + t == Symbol::new(&env, "withdraw_to") + } + }) + .expect("expected withdraw_to event"); let (amt, bal): (i128, i128) = ev.2.into_val(&env); assert_eq!(amt, 150); assert_eq!(bal, 150); @@ -2541,12 +2571,15 @@ fn distribute_emits_event() { client.init(&owner, &usdc, &Some(0), &None, &None, &None, &None); client.distribute(&owner, &dev, &200); let events = env.events().all(); - let ev = events.iter().find(|e| { - e.0 == vault_address && !e.1.is_empty() && { - let t: Symbol = e.1.get(0).unwrap().into_val(&env); - t == Symbol::new(&env, "distribute") - } - }).expect("expected distribute event"); + let ev = events + .iter() + .find(|e| { + e.0 == vault_address && !e.1.is_empty() && { + let t: Symbol = e.1.get(0).unwrap().into_val(&env); + t == Symbol::new(&env, "distribute") + } + }) + .expect("expected distribute event"); let amt: i128 = ev.2.into_val(&env); assert_eq!(amt, 200); } @@ -2561,8 +2594,8 @@ fn get_allowed_depositors_returns_list() { let (usdc, _, _) = create_usdc(&env, &owner); env.mock_all_auths(); client.init(&owner, &usdc, &None, &None, &None, &None, &None); - client.set_allowed_depositor(&owner, &d1); - client.set_allowed_depositor(&owner, &d2); + client.set_allowed_depositor(&owner, &Some(d1)); + client.set_allowed_depositor(&owner, &Some(d2)); let list = client.get_allowed_depositors(); assert_eq!(list.len(), 2); } @@ -2578,12 +2611,15 @@ fn vault_unpaused_event_emitted() { client.pause(&owner); client.unpause(&owner); let events = env.events().all(); - let ev = events.iter().find(|e| { - e.0 == vault_address && !e.1.is_empty() && { - let t: Symbol = e.1.get(0).unwrap().into_val(&env); - t == Symbol::new(&env, "vault_unpaused") - } - }).expect("expected vault_unpaused event"); + let ev = events + .iter() + .find(|e| { + e.0 == vault_address && !e.1.is_empty() && { + let t: Symbol = e.1.get(0).unwrap().into_val(&env); + t == Symbol::new(&env, "vault_unpaused") + } + }) + .expect("expected vault_unpaused event"); let caller: Address = ev.1.get(1).unwrap().into_val(&env); assert_eq!(caller, owner); } @@ -2604,8 +2640,8 @@ fn vault_unpaused_event_emitted() { #[cfg(test)] mod fuzz { use super::*; - use rand::{Rng, SeedableRng}; use rand::rngs::StdRng; + use rand::{Rng, SeedableRng}; /// Run a mixed sequence of deposit / deduct / batch_deduct / pause / unpause /// and assert after every step that: @@ -2627,7 +2663,7 @@ mod fuzz { &usdc_addr, &Some(initial), &None, - &Some(1), // min_deposit = 1 + &Some(1), // min_deposit = 1 &None, &Some(max_deduct_val), ); @@ -2681,9 +2717,15 @@ mod fuzz { let amt: i128 = rng.gen_range(1..=max_deduct_val); batch_total = match batch_total.checked_add(amt) { Some(v) => v, - None => { valid = false; break; } + None => { + valid = false; + break; + } }; - items.push_back(DeductItem { amount: amt, request_id: None }); + items.push_back(DeductItem { + amount: amt, + request_id: None, + }); } if valid && sim >= batch_total { sim -= batch_total; @@ -2692,7 +2734,11 @@ mod fuzz { // batch must fail atomically — balance unchanged let before = client.balance(); let _ = client.try_batch_deduct(&caller, &items); - assert_eq!(client.balance(), before, "failed batch must not change balance"); + assert_eq!( + client.balance(), + before, + "failed batch must not change balance" + ); } } @@ -2765,7 +2811,15 @@ mod fuzz { let (vault_addr, client) = create_vault(&env); usdc_admin.mint(&vault_addr, &300); - client.init(&owner, &usdc_addr, &Some(300), &None, &None, &None, &Some(200)); + client.init( + &owner, + &usdc_addr, + &Some(300), + &None, + &None, + &None, + &Some(200), + ); let mut rng = StdRng::seed_from_u64(0x5eed_0001); // Build batches that sometimes overdraw; assert atomicity each time. @@ -2804,7 +2858,15 @@ mod fuzz { let max_d: i128 = 100; usdc_admin.mint(&vault_addr, &10_000); - client.init(&owner, &usdc_addr, &Some(10_000), &None, &None, &None, &Some(max_d)); + client.init( + &owner, + &usdc_addr, + &Some(10_000), + &None, + &None, + &None, + &Some(max_d), + ); let mut rng = StdRng::seed_from_u64(0x5eed_0002); for _ in 0..40 { @@ -2817,7 +2879,10 @@ mod fuzz { }; let items = soroban_sdk::vec![ &env, - DeductItem { amount: amt, request_id: None } + DeductItem { + amount: amt, + request_id: None, + }, ]; if exceed { assert!( diff --git a/docs/ACCESS_CONTROL.md b/docs/ACCESS_CONTROL.md index b8bf6c22..0c0688bd 100644 --- a/docs/ACCESS_CONTROL.md +++ b/docs/ACCESS_CONTROL.md @@ -1,116 +1,51 @@ -# Vault Access Control +# Access Control -## Overview +## 1. Vault Access Control +### Overview The Callora Vault implements role-based access control for deposit operations to ensure only authorized parties can increase the vault balance. -## Roles +### Roles +- **Owner**: Set during contract initialization. Exclusive authority to manage allowed depositors and withdraw funds. +- **Allowed Depositor**: Addresses approved by the owner to handle automated deposits. +- **Authorized Caller**: Optional address permitted to trigger `deduct` operations. -### Owner -- Set during contract initialization via `init()` -- Immutable after initialization -- Always permitted to deposit -- Exclusive authority to manage the allowed depositor -- Typically represents the end user's account in production +--- -### Allowed Depositor -- One or more addresses explicitly approved by the owner via `set_allowed_depositor()` -- Mutable — addresses can be added or the entire list cleared at any time by the owner -- Commonly used for backend service hot-wallets that handle automated deposits -- When added, each address has the same deposit privileges as the owner -- The full allowlist is publicly auditable via `get_allowed_depositors()` +## 2. Settlement Access Control -### Unauthorized Addresses -- Any address that is neither the owner nor the allowed depositor -- Deposit attempts are rejected with: `"unauthorized: only owner or allowed depositor can deposit"` +### Overview +The Callora Settlement contract tracks individual developer balances and global protocol revenue. It enforces strict access control for incoming payments and administrative updates. -## Production Usage +### Roles +- **Admin**: Primary authority over contract configuration and sensitive data. +- **Vault**: The registered vault contract authorized to send payments. +- **Pending Admin**: Nominee awaiting acceptance of the admin role. -In a typical production deployment: +### Authorization Matrix -1. **User Account (Owner)**: The end user's wallet address is set as the owner during initialization -2. **Backend Service (Allowed Depositor)**: A trusted backend service address is set as the allowed depositor to handle automated deposits on behalf of users -3. **Access Control**: Only these two addresses can increase the vault balance +| Function | Admin | Vault | Pending Admin | Others | +|----------|-------|-------|---------------|--------| +| `receive_payment` | ✅ | ✅ | ❌ | ❌ | +| `set_admin` | ✅ | ❌ | ❌ | ❌ | +| `accept_admin` | ❌ | ❌ | ✅ | ❌ | +| `set_vault` | ✅ | ❌ | ❌ | ❌ | +| `get_all_developer_balances` | ✅ | ❌ | ❌ | ❌ | -## Managing the Allowed Depositor - -### Setting or Updating -```rust -// Owner sets the allowed depositor -vault.set_allowed_depositor(owner_address, Some(backend_service_address)); -``` - -### Clearing (Revoking Access) -```rust -// Owner revokes depositor access -vault.set_allowed_depositor(owner_address, None); -``` - -### Rotating the Depositor -```rust -// Owner can change the allowed depositor at any time -vault.set_allowed_depositor(owner_address, Some(new_backend_address)); -``` - -## Security Model - -### Trust Assumptions -- The owner has full control over deposit permissions -- The allowed depositor is a trusted address (typically a backend service under the owner's control) -- Access can be revoked instantly by the owner at any time - -### Authorization Flow -1. Caller invokes `deposit()` with their address -2. Contract verifies caller is either: - - The owner (always authorized), OR - - The currently set allowed depositor (if any) -3. If neither condition is met, the transaction fails with an authorization error - -### Best Practices -- Rotate the allowed depositor address periodically for security -- Clear the allowed depositor when not actively needed -- Monitor deposit events to detect unauthorized access attempts -- Use secure key management for both owner and depositor addresses - -## API Reference - -### `set_allowed_depositor(caller: Address, depositor: Option
)` -Owner-only function to manage the allowed depositor. - -**Parameters:** -- `caller`: Must be the owner address (authenticated via `require_auth()`) -- `depositor`: - - `Some(address)` - Sets or updates the allowed depositor - - `None` - Clears the allowed depositor (revokes access) - -**Errors:** -- Panics with `"unauthorized: owner only"` if caller is not the owner - -### `deposit(caller: Address, amount: i128) -> i128` -Increases the vault balance by the specified amount. - -**Parameters:** -- `caller`: Must be either the owner or allowed depositor (authenticated via `require_auth()`) -- `amount`: Amount to add to the balance - -**Returns:** -- The new balance after deposit - -**Errors:** -- Panics with `"unauthorized: only owner or allowed depositor can deposit"` if caller is not authorized +### Security Model +- **Two-Step Admin Rotation**: Prevents accidental loss of control by requiring the nominee to explicitly accept the role. +- **Restricted Views**: Sensitive batch queries like `get_all_developer_balances` are restricted to the admin to prevent unnecessary exposure of the full ledger via the contract interface. ## Test Coverage - The implementation includes comprehensive tests covering: -- ✅ Owner can deposit successfully -- ✅ Allowed depositor can deposit successfully -- ✅ Unauthorized addresses cannot deposit (expect auth error) -- ✅ Owner can set and clear allowed depositor -- ✅ Non-owner cannot call `set_allowed_depositor` -- ✅ Deposit after allowed depositor is cleared is rejected -- ✅ All existing tests continue to pass +- ✅ Admin and Vault can call `receive_payment` +- ✅ Unauthorized callers are rejected from `receive_payment` +- ✅ Only Admin can call `set_admin` and `set_vault` +- ✅ Only Pending Admin can call `accept_admin` +- ✅ Only Admin can call `get_all_developer_balances` +- ✅ All rotation and update logic preserves state integrity Run tests with: ```bash -cargo test --manifest-path contracts/vault/Cargo.toml +cargo test -p callora-settlement ```