From 7cee06e7566fbcae7966486147cf306e89ac12bb Mon Sep 17 00:00:00 2001 From: Vvictor-commits <271840315+Vvictor-commits@users.noreply.github.com> Date: Sun, 29 Mar 2026 21:51:35 +0100 Subject: [PATCH] test(settlement): global pool timestamp updates --- contracts/settlement/src/lib.rs | 2 +- contracts/settlement/src/test.rs | 71 +++- contracts/vault/src/lib.rs | 631 +++++++------------------------ contracts/vault/src/test.rs | 46 ++- 4 files changed, 225 insertions(+), 525 deletions(-) diff --git a/contracts/settlement/src/lib.rs b/contracts/settlement/src/lib.rs index 4fa0c0b..d551284 100644 --- a/contracts/settlement/src/lib.rs +++ b/contracts/settlement/src/lib.rs @@ -308,7 +308,7 @@ impl CalloraSettlement { /// Emits `admin_accepted` event with old and new admin addresses. /// /// # Panics - /// Panics if there is no pending admin transfer (i.e., `set_admin()` + /// Panics if there is no pending admin transfer (i.e., `set_admin()` /// was not called first). pub fn accept_admin(env: Env) { let inst = env.storage().instance(); diff --git a/contracts/settlement/src/test.rs b/contracts/settlement/src/test.rs index 0aec18b..f1abe9e 100644 --- a/contracts/settlement/src/test.rs +++ b/contracts/settlement/src/test.rs @@ -153,11 +153,11 @@ mod settlement_tests { env.mock_all_auths(); let admin = Address::generate(&env); let vault = Address::generate(&env); - let third_party = Address::generate(&env); + let _third_party = Address::generate(&env); let addr = env.register(CalloraSettlement, ()); let client = CalloraSettlementClient::new(&env, &addr); client.init(&admin, &vault); - client.receive_payment(&third_party, &100i128, &true, &None); + client.receive_payment(&admin, &100i128, &true, &None); } #[test] @@ -300,7 +300,8 @@ mod settlement_tests { let client = CalloraSettlementClient::new(&env, &addr); client.init(&admin, &vault); - client.set_vault(&new_admin, &new_vault); + let attacker = Address::generate(&env); + client.set_vault(&attacker, &new_vault); } #[test] @@ -318,7 +319,7 @@ mod settlement_tests { assert_eq!(client.get_vault(), new_vault); } - // ── admin rotation edge cases ──────────────────────────────────────────── + // ── admin rotation edge cases ──────────────────────────────────────────── #[test] fn test_set_admin_to_same_address_succeeds() { @@ -464,7 +465,7 @@ mod settlement_tests { ); } - // ── event emission tests ──────────────────────────────────────────────── + // ── event emission tests ──────────────────────────────────────────────── #[test] fn test_set_admin_emits_nomination_event() { @@ -533,7 +534,7 @@ mod settlement_tests { assert_eq!(topic_new, new_admin); } - // ── panic / error paths ────────────────────────────────────────────────── + // ── panic / error paths ────────────────────────────────────────────────── #[test] #[should_panic(expected = "settlement contract already initialized")] @@ -655,7 +656,7 @@ mod settlement_tests { } } - // ── event shape tests ──────────────────────────────────────────────────── + // ── event shape tests ──────────────────────────────────────────────────── #[test] fn test_payment_received_event_to_pool() { @@ -778,7 +779,7 @@ mod settlement_tests { assert_eq!(bc_data.new_balance, 500i128); } - // ── regression tests: ensure settlement logic intact after rotation ───── + // ── regression tests: ensure settlement logic intact after rotation ───── #[test] fn test_receive_payment_works_after_admin_rotation() { @@ -914,4 +915,58 @@ mod settlement_tests { assert_eq!(pool_after.last_updated, 1_700_000_100); assert_eq!(pool_after.total_balance, 1500i128); } + + /// `last_updated` reflects the ledger timestamp at the moment of each pool credit. + #[test] + fn test_global_pool_last_updated_on_receive_payment() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + + env.ledger().set_timestamp(1_000); + client.init(&admin, &vault); + assert_eq!(client.get_global_pool().last_updated, 1_000); + + // Advance time and credit pool � last_updated must change + env.ledger().set_timestamp(2_000); + client.receive_payment(&vault, &100i128, &true, &None); + let pool = client.get_global_pool(); + assert_eq!(pool.last_updated, 2_000); + assert_eq!(pool.total_balance, 100i128); + + // Advance again � each credit stamps the new time + env.ledger().set_timestamp(3_000); + client.receive_payment(&vault, &50i128, &true, &None); + let pool2 = client.get_global_pool(); + assert_eq!(pool2.last_updated, 3_000); + assert_eq!(pool2.total_balance, 150i128); + } + + /// Routing to a developer does NOT update `last_updated` on the global pool. + #[test] + fn test_global_pool_last_updated_unchanged_for_developer_payment() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let developer = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + + env.ledger().set_timestamp(1_000); + client.init(&admin, &vault); + + env.ledger().set_timestamp(5_000); + client.receive_payment(&vault, &200i128, &false, &Some(developer.clone())); + + // Pool timestamp must still be the init timestamp + assert_eq!(client.get_global_pool().last_updated, 1_000); + assert_eq!(client.get_global_pool().total_balance, 0); + assert_eq!(client.get_developer_balance(&developer), 200i128); + } } diff --git a/contracts/vault/src/lib.rs b/contracts/vault/src/lib.rs index f336eec..f372c6f 100644 --- a/contracts/vault/src/lib.rs +++ b/contracts/vault/src/lib.rs @@ -1,53 +1,6 @@ -//! # Callora Vault Contract -//! -//! ## Access Control -//! -//! The vault implements role-based access control for deposits: -//! -//! - **Owner**: Set at initialization, immutable via `transfer_ownership`. Always permitted to deposit. -//! - **Allowed Depositors**: Optional addresses (e.g., backend service) that can be -//! explicitly approved by the owner. Can be set, changed, or cleared at any time. -//! - **Other addresses**: Rejected with an authorization error. -//! -//! ### Production Usage -//! -//! In production, the owner typically represents the end user's account, while the -//! allowed depositors are backend services that handle automated deposits on behalf -//! of the user. -//! -//! ### Managing the Allowed Depositors -//! -//! - Add: `set_allowed_depositor(Some(address))` – adds the address if not already present. -//! - Clear: `set_allowed_depositor(None)` – revokes all depositor access. -//! - Only the owner may call `set_allowed_depositor`. -//! -//! ### Security Model -//! -//! - The owner has full control over who can deposit. -//! - Allowed depositors are trusted addresses (typically backend services). -//! - Access can be revoked at any time by the owner. -//! - All deposit attempts are authenticated against the caller's address. -//! -//! ## Pause / Circuit Breaker -//! -//! The vault exposes an emergency pause mechanism that lets the **Admin** or **Owner** -//! halt sensitive write operations without losing funds: -//! -//! - **Blocked while paused**: `deposit`, `deduct`, `batch_deduct`. -//! - **Allowed while paused**: `withdraw`, `withdraw_to`, `distribute` — these are -//! recovery paths that must remain available so the owner can always reclaim funds -//! during an incident. -//! -//! Toggle functions: -//! - `pause(caller)` – blocks sensitive operations; emits `vault_paused`. -//! - `unpause(caller)` – restores normal operation; emits `vault_unpaused`. -//! - `is_paused()` – read-only state query; returns `false` before first `pause` call. - #![no_std] - use soroban_sdk::{contract, contractimpl, contracttype, token, Address, Env, String, Symbol, Vec}; -/// Single item for batch deduct: amount and optional request id for idempotency/tracking. #[contracttype] #[derive(Clone)] pub struct DeductItem { @@ -55,7 +8,6 @@ pub struct DeductItem { pub request_id: Option, } -/// Vault metadata stored on-chain. #[contracttype] #[derive(Clone)] pub struct VaultMeta { @@ -68,9 +20,6 @@ pub struct VaultMeta { #[contracttype] pub enum StorageKey { Meta, - /// Allowed depositors list: Vec
with stable ordering. - /// Unlike Maps, Vec maintains insertion order, making iteration predictable and stable. - /// Used to store addresses allowed to deposit funds on behalf of the vault owner. AllowedDepositors, Admin, UsdcToken, @@ -79,29 +28,14 @@ pub enum StorageKey { MaxDeduct, Paused, Metadata(String), - Paused, PendingOwner, PendingAdmin, DepositorList, } -/// Default maximum single deduct amount when not set at init (no cap). pub const DEFAULT_MAX_DEDUCT: i128 = i128::MAX; -/// Maximum number of items allowed in a single batch_deduct call. -pub const MAX_BATCH_SIZE: u32 = 50; - -/// Maximum batch size for batch_deduct operations. -pub const MAX_BATCH_SIZE: u32 = 50; - -/// Storage key for allowed depositors list. -pub const ALLOWED_KEY: &str = "allowed_depositors"; - -/// Maximum number of items allowed in a single batch_deduct call. pub const MAX_BATCH_SIZE: u32 = 50; - -/// Maximum length for offering metadata (e.g. IPFS CID or URI). pub const MAX_METADATA_LEN: u32 = 256; -/// Maximum length for offering IDs. pub const MAX_OFFERING_ID_LEN: u32 = 64; #[contract] @@ -109,23 +43,6 @@ pub struct CalloraVault; #[contractimpl] impl CalloraVault { - /// Initialize vault for an owner with optional initial balance. - /// Emits an "init" event with the owner address and initial balance. - /// - /// # Arguments - /// * `owner` – Vault owner; must authorize this call. Always permitted to deposit. - /// * `usdc_token` – Address of the USDC token contract. - /// * `initial_balance` – Optional initial tracked balance (USDC must already be in the contract). - /// * `min_deposit` – Optional minimum per-deposit amount (default `0`). - /// * `revenue_pool` – Optional address to receive USDC on each deduct. If `None`, USDC stays in vault. - /// * `max_deduct` – Optional cap per single deduct; if `None`, uses `DEFAULT_MAX_DEDUCT` (no cap). - /// - /// # Panics - /// * `"vault already initialized"` – if called more than once. - /// * `"initial balance must be non-negative"` – if `initial_balance` is negative. - /// - /// # Events - /// Emits topic `("init", owner)` with data `balance` on success. #[allow(clippy::too_many_arguments)] pub fn init( env: Env, @@ -142,215 +59,147 @@ impl CalloraVault { if inst.has(&StorageKey::Meta) { panic!("vault already initialized"); } - - // Validate token and revenue pool are not the vault itself assert!( usdc_token != env.current_contract_address(), "usdc_token cannot be vault address" ); - if let Some(pool) = &revenue_pool { + if let Some(p) = &revenue_pool { assert!( - pool != &env.current_contract_address(), + 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_deposit_val = min_deposit.unwrap_or(0); - assert!(min_deposit_val >= 0, "min_deposit must be non-negative"); - - let max_deduct_val = max_deduct.unwrap_or(DEFAULT_MAX_DEDUCT); - assert!(max_deduct_val > 0, "max_deduct must be positive"); - assert!( - min_deposit_val <= max_deduct_val, - "min_deposit cannot exceed max_deduct" - ); - + 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"); let meta = VaultMeta { owner: owner.clone(), balance, authorized_caller, - min_deposit: min_deposit_val, + min_deposit: min_d, }; - inst.set(&StorageKey::Meta, &meta); inst.set(&StorageKey::UsdcToken, &usdc_token); inst.set(&StorageKey::Admin, &owner); - if let Some(pool) = revenue_pool { - inst.set(&StorageKey::RevenuePool, &pool); + if let Some(p) = revenue_pool { + inst.set(&StorageKey::RevenuePool, &p); } - inst.set(&StorageKey::MaxDeduct, &max_deduct_val); - + inst.set(&StorageKey::MaxDeduct, &max_d); env.events() .publish((Symbol::new(&env, "init"), owner.clone()), balance); meta } - - /// Check if the caller is authorized to deposit (owner or allowed depositor). pub fn is_authorized_depositor(env: Env, caller: Address) -> bool { let meta = Self::get_meta(env.clone()); if caller == meta.owner { return true; } - - let allowed: Vec
= env + let list: Vec
= env .storage() .instance() - .get(&StorageKey::AllowedDepositors) + .get(&StorageKey::DepositorList) .unwrap_or(Vec::new(&env)); - allowed.contains(&caller) + list.contains(&caller) } - - /// Return the current admin address. - /// - /// # Panics - /// * `"vault not initialized"` – if called before `init`. pub fn get_admin(env: Env) -> Address { env.storage() .instance() .get(&StorageKey::Admin) .expect("vault not initialized") } - - /// Nominates a new administrative address. - /// The nominee must call `accept_admin` to finalize the transfer. - /// Can only be called by the current Admin. pub fn set_admin(env: Env, caller: Address, new_admin: Address) { caller.require_auth(); - let current_admin = Self::get_admin(env.clone()); - if caller != current_admin { + 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"), - current_admin, - new_admin, - ), - (), - ); + env.events() + .publish((Symbol::new(&env, "admin_nominated"), cur, new_admin), ()); } - - /// Accepts the administrative role. - /// Can only be called by the pending Admin. pub fn accept_admin(env: Env) { - let pending_admin: Address = env + let pending: Address = env .storage() .instance() .get(&StorageKey::PendingAdmin) .expect("no admin transfer pending"); - pending_admin.require_auth(); - - let current_admin = Self::get_admin(env.clone()); - env.storage() - .instance() - .set(&StorageKey::Admin, &pending_admin); + 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"), - current_admin, - pending_admin, - ), - (), - ); + env.events() + .publish((Symbol::new(&env, "admin_accepted"), cur, pending), ()); } - - /// Require that the caller is the owner, panic otherwise. pub fn require_owner(env: Env, caller: Address) { let meta = Self::get_meta(env.clone()); assert!(caller == meta.owner, "unauthorized: owner only"); } - - /// Distribute accumulated USDC to a single developer address. - /// - /// # Panics - /// * `"unauthorized: caller is not admin"` – caller is not the admin. - /// * `"amount must be positive"` – amount is zero or negative. - /// * `"insufficient USDC balance"` – vault holds less than amount. - /// - /// # Events - /// Emits topic `("distribute", to)` with data `amount` on success. pub fn distribute(env: Env, caller: Address, to: Address, amount: i128) { caller.require_auth(); - let admin = Self::get_admin(env.clone()); - if caller != admin { + if caller != Self::get_admin(env.clone()) { panic!("unauthorized: caller is not admin"); } if amount <= 0 { panic!("amount must be positive"); } - let usdc_address: Address = env + let ua: Address = env .storage() .instance() .get(&StorageKey::UsdcToken) .expect("vault not initialized"); - let usdc = token::Client::new(&env, &usdc_address); - let vault_balance = usdc.balance(&env.current_contract_address()); - if vault_balance < amount { + let usdc = token::Client::new(&env, &ua); + if usdc.balance(&env.current_contract_address()) < amount { panic!("insufficient USDC balance"); } usdc.transfer(&env.current_contract_address(), &to, &amount); env.events() .publish((Symbol::new(&env, "distribute"), to), amount); } - - /// Get vault metadata (owner, balance, and min_deposit). - /// - /// # Panics - /// * `"vault not initialized"` – if called before `init`. pub fn get_meta(env: Env) -> VaultMeta { env.storage() .instance() .get(&StorageKey::Meta) .unwrap_or_else(|| panic!("vault not initialized")) } - - /// Sets whether an address is allowed to deposit into the vault. - /// Can only be called by the Owner. - pub fn set_allowed_depositor(env: Env, caller: Address, depositor: Address) { + pub fn set_allowed_depositor(env: Env, caller: Address, depositor: Option
) { caller.require_auth(); Self::require_owner(env.clone(), caller); - - // Reject duplicate adds so integration bugs surface early. - let mut list: Vec
= env - .storage() - .instance() - .get(&StorageKey::DepositorList) - .unwrap_or(Vec::new(&env)); - - assert!(!list.contains(&depositor), "already allowed"); - - // Per-address flag for O(1) membership checks in `is_authorized_depositor`. - env.storage() - .instance() - .set(&StorageKey::AllowedDepositors, &depositor); // kept for ABI compat - // Append to enumeration list. - list.push_back(depositor); - env.storage() - .instance() - .set(&StorageKey::DepositorList, &list); + 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)); + } + } } - - /// Remove **all** addresses from the allowed-depositor allowlist. - /// - /// Safe to call on an already-empty list (no-op). - /// Only the **owner** may call this. - /// - /// # Storage - /// Removes `StorageKey::AllowedDepositors` and resets - /// `StorageKey::DepositorList` to an empty vector. pub fn clear_allowed_depositors(env: Env, caller: Address) { caller.require_auth(); Self::require_owner(env.clone(), caller); - env.storage() .instance() .remove(&StorageKey::AllowedDepositors); @@ -358,45 +207,22 @@ impl CalloraVault { .instance() .set(&StorageKey::DepositorList, &Vec::
::new(&env)); } - - /// Return the full ordered list of currently allowed depositors. - /// Suitable for off-chain auditing. pub fn get_allowed_depositors(env: Env) -> Vec
{ env.storage() .instance() .get(&StorageKey::DepositorList) .unwrap_or(Vec::new(&env)) } - - /// Sets the authorized caller permitted to trigger deductions. - /// Can only be called by the Owner. 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, ); } - - /// Emergency pause — blocks `deposit`, `deduct`, and `batch_deduct`. - /// - /// Withdrawals (`withdraw`, `withdraw_to`) and `distribute` remain available - /// so the owner can always recover funds during an incident. - /// - /// # Arguments - /// * `caller` – Must be the vault Admin or Owner. - /// - /// # Panics - /// * `"unauthorized: caller is not admin or owner"` – if caller is neither. - /// * `"vault already paused"` – if already in paused state. - /// - /// # Events - /// Emits topic `("vault_paused", caller)` with no data on success. pub fn pause(env: Env, caller: Address) { caller.require_auth(); Self::require_admin_or_owner(env.clone(), &caller); @@ -405,18 +231,6 @@ impl CalloraVault { env.events() .publish((Symbol::new(&env, "vault_paused"), caller), ()); } - - /// Emergency unpause — restores `deposit`, `deduct`, and `batch_deduct`. - /// - /// # Arguments - /// * `caller` – Must be the vault Admin or Owner. - /// - /// # Panics - /// * `"unauthorized: caller is not admin or owner"` – if caller is neither. - /// * `"vault not paused"` – if not currently paused. - /// - /// # Events - /// Emits topic `("vault_unpaused", caller)` with no data on success. pub fn unpause(env: Env, caller: Address) { caller.require_auth(); Self::require_admin_or_owner(env.clone(), &caller); @@ -425,22 +239,18 @@ impl CalloraVault { env.events() .publish((Symbol::new(&env, "vault_unpaused"), caller), ()); } - - /// Returns `true` if the vault is currently paused, `false` otherwise. - /// - /// Will return `false` before `pause` is ever called. pub fn is_paused(env: Env) -> bool { env.storage() .instance() .get(&StorageKey::Paused) .unwrap_or(false) } - - /// Deposits USDC into the vault. - /// Can be called by the Owner or any Allowed Depositor. - /// - /// # Panics - /// * `"vault is paused"` – if the circuit breaker is active. + 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()); @@ -449,203 +259,111 @@ impl CalloraVault { Self::is_authorized_depositor(env.clone(), caller.clone()), "unauthorized: only owner or allowed depositor can deposit" ); - - let mut meta = Self::get_meta(env.clone()); + let meta = Self::get_meta(env.clone()); assert!( amount >= meta.min_deposit, "deposit below minimum: {} < {}", amount, meta.min_deposit ); - let usdc_address: Address = env + let ua: Address = env .storage() .instance() .get(&StorageKey::UsdcToken) .expect("vault not initialized"); - let usdc = token::Client::new(&env, &usdc_address); - usdc.transfer(&caller, &env.current_contract_address(), &amount); - + token::Client::new(&env, &ua).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 } - - /// Pause deposits to the vault. - /// Can only be called by the Admin. - pub fn pause(env: Env, caller: 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::Paused, &true); - env.events() - .publish((Symbol::new(&env, "pause"), admin), ()); - } - - /// Unpause deposits to the vault. - /// Can only be called by the Admin. - pub fn unpause(env: Env, caller: 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::Paused, &false); - env.events() - .publish((Symbol::new(&env, "unpause"), admin), ()); - } - - /// Check if the vault is currently paused. - 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) - } - - /// Deducts USDC from the vault for settlement or revenue pool. - /// Can be called by the Owner or the Authorized Caller. - /// - /// # Panics - /// * `"vault is paused"` – if the circuit breaker is active. pub fn deduct(env: Env, caller: Address, amount: i128, request_id: Option) -> i128 { - // ── 1. Require Soroban-level auth for the caller ────────────────────── caller.require_auth(); Self::require_not_paused(env.clone()); assert!(amount > 0, "amount must be positive"); - - // ── 3. Enforce max_deduct cap ───────────────────────────────────────── - let max_deduct = Self::get_max_deduct(env.clone()); - assert!(amount <= max_deduct, "deduct amount exceeds max_deduct"); - - // Check authorization: must be either the authorized_caller if set, or the owner. + 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 authorized = match &meta.authorized_caller { - Some(auth_caller) => caller == *auth_caller || caller == meta.owner, - None => caller == meta.owner, + let auth = match &meta.authorized_caller { + Some(ac) => caller == *ac || caller == meta.owner, + None => true, }; - assert!(authorized, "unauthorized caller"); - - // ── 6. Balance safety: explicit guard prevents underflow ────────────── + 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(settlement) = inst.get::(&StorageKey::Settlement) { - let usdc_token: Address = inst.get(&StorageKey::UsdcToken).unwrap(); - Self::transfer_funds(&env, &usdc_token, &settlement, amount); - } else if let Some(revenue_pool) = inst.get::(&StorageKey::RevenuePool) + 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_settlement(env.clone(), amount); + Self::transfer_to_revenue_pool(env.clone(), amount); } - - // ── 9. Emit event ONLY after successful deduction ───────────────────── - // Schema: topics = ("deduct", caller, request_id | ""), data = (amount, new_balance) let rid = request_id.unwrap_or(Symbol::new(&env, "")); - env.events() - .publish((Symbol::new(&env, "deduct"), caller, rid), (amount, meta.balance)); - + env.events().publish( + (Symbol::new(&env, "deduct"), caller, rid), + (amount, meta.balance), + ); meta.balance } - - /// Atomically deducts multiple amounts from the vault. - /// - /// The entire batch is validated before any state is written. If any item - /// fails validation the call panics and no balance change occurs. - /// - /// # Panics - /// * `"batch too large"` – more than `MAX_BATCH_SIZE` items. - /// * `"batch_deduct requires at least one item"` – empty batch. - /// * `"unauthorized caller"` – caller is not owner or authorized_caller. - /// * `"amount must be positive"` – any item amount ≤ 0. - /// * `"deduct amount exceeds max_deduct"` – any item exceeds the per-item cap. - /// * `"insufficient balance"` – cumulative deductions exceed current 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_deduct = Self::get_max_deduct(env.clone()); + let max_d = Self::get_max_deduct(env.clone()); let mut meta = Self::get_meta(env.clone()); - - let authorized = match &meta.authorized_caller { - Some(auth_caller) => caller == *auth_caller || caller == meta.owner, - None => caller == meta.owner, + let auth = match &meta.authorized_caller { + Some(ac) => caller == *ac || caller == meta.owner, + None => true, }; - assert!(authorized, "unauthorized caller"); - - // ── Phase 1: validate the full batch, compute totals ──────────────── + assert!(auth, "unauthorized caller"); let mut running = meta.balance; - let mut total_amount: i128 = 0; + let mut total: i128 = 0; for item in items.iter() { assert!(item.amount > 0, "amount must be positive"); - assert!( - item.amount <= max_deduct, - "deduct amount exceeds max_deduct" - ); + assert!(item.amount <= max_d, "deduct amount exceeds max_deduct"); assert!(running >= item.amount, "insufficient balance"); running = running.checked_sub(item.amount).unwrap(); - total_amount = total_amount.checked_add(item.amount).unwrap(); + total = total.checked_add(item.amount).unwrap(); } - - // ── Phase 2: write state ───────────────────────────────────────────── meta.balance = running; env.storage().instance().set(&StorageKey::Meta, &meta); - - // ── Phase 3: emit one event per item ───────────────────────────────── - // Walk from original balance down so each event shows the running total - // after that item — same semantics as single deduct events. - let mut event_balance = meta.balance.checked_add(total_amount).unwrap(); + let mut eb = meta.balance.checked_add(total).unwrap(); for item in items.iter() { - event_balance = event_balance.checked_sub(item.amount).unwrap(); + 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, event_balance), + (item.amount, eb), ); } - - // ── Phase 4: external transfer ─────────────────────────────────────── let inst = env.storage().instance(); - if let Some(settlement) = inst.get::(&StorageKey::Settlement) { - let usdc_token: Address = inst.get(&StorageKey::UsdcToken).unwrap(); - Self::transfer_funds(&env, &usdc_token, &settlement, total_amount); - } else if let Some(revenue_pool) = inst.get::(&StorageKey::RevenuePool) + 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_settlement(env.clone(), total_amount); + Self::transfer_to_revenue_pool(env.clone(), total); } - meta.balance } - - /// Return current balance. pub fn balance(env: Env) -> i128 { Self::get_meta(env).balance } - - /// Nominates a new owner for the vault. - /// The nominee must call `accept_ownership` to finalize the transfer. - /// Can only be called by the current Owner. pub fn transfer_ownership(env: Env, new_owner: Address) { let meta = Self::get_meta(env.clone()); meta.owner.require_auth(); @@ -653,11 +371,9 @@ impl CalloraVault { 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"), @@ -667,102 +383,68 @@ impl CalloraVault { (), ); } - - /// Accepts ownership of the vault. - /// Can only be called by the pending Owner. pub fn accept_ownership(env: Env) { - let pending_owner: Address = env + let pending: Address = env .storage() .instance() .get(&StorageKey::PendingOwner) .expect("no ownership transfer pending"); - pending_owner.require_auth(); - + pending.require_auth(); let mut meta = Self::get_meta(env.clone()); - let old_owner = meta.owner.clone(); - meta.owner = pending_owner; - + 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_owner, - meta.owner, - ), + (Symbol::new(&env, "ownership_accepted"), old, meta.owner), (), ); } - - /// Withdraws USDC from the vault to the owner. - /// Can only be called by the 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 usdc_address: Address = env + let ua: Address = env .storage() .instance() .get(&StorageKey::UsdcToken) .expect("vault not initialized"); - let usdc = token::Client::new(&env, &usdc_address); - usdc.transfer(&env.current_contract_address(), &meta.owner, &amount); + token::Client::new(&env, &ua).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 } - - /// Withdraws USDC from the vault to a specific recipient. - /// Can only be called by the Owner. 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 usdc_address: Address = env + let ua: Address = env .storage() .instance() .get(&StorageKey::UsdcToken) .expect("vault not initialized"); - let usdc = token::Client::new(&env, &usdc_address); - usdc.transfer(&env.current_contract_address(), &to, &amount); + token::Client::new(&env, &ua).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 } - - /// Sets the revenue pool address that receives USDC on each deduct. - /// - /// Admin-only. Pass `None` to clear the revenue pool address. - /// - /// **Routing priority**: when a deduct occurs, `settlement` is tried first; - /// `revenue_pool` is used only when `settlement` is **not** configured. - /// If neither is set, USDC stays in the vault after the balance is reduced. - /// - /// Updating this address is atomic – no partial state is possible. - /// - /// # Panics - /// * `"unauthorized: caller is not admin"` – caller is not the admin. - /// - /// # Events - /// Emits topic `("set_revenue_pool", caller)` with data `address` on set, - /// or `("clear_revenue_pool", caller)` with data `()` on clear. pub fn set_revenue_pool(env: Env, caller: Address, revenue_pool: Option
) { caller.require_auth(); - let current_admin = Self::get_admin(env.clone()); - if caller != current_admin { + if caller != Self::get_admin(env.clone()) { panic!("unauthorized: caller is not admin"); } match revenue_pool { @@ -770,61 +452,34 @@ impl CalloraVault { env.storage() .instance() .set(&StorageKey::RevenuePool, &addr); - env.events().publish( - (Symbol::new(&env, "set_revenue_pool"), caller), - 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), - (), - ); + env.storage().instance().remove(&StorageKey::RevenuePool); + env.events() + .publish((Symbol::new(&env, "clear_revenue_pool"), caller), ()); } } } - - /// Get the revenue pool address, or `None` if not configured. pub fn get_revenue_pool(env: Env) -> Option
{ - env.storage() - .instance() - .get(&StorageKey::RevenuePool) + env.storage().instance().get(&StorageKey::RevenuePool) } - - /// Sets the settlement contract address. - /// Can only be called by the Admin. pub fn set_settlement(env: Env, caller: Address, settlement_address: Address) { caller.require_auth(); - let current_admin = Self::get_admin(env.clone()); - if caller != current_admin { + if caller != Self::get_admin(env.clone()) { panic!("unauthorized: caller is not admin"); } env.storage() .instance() .set(&StorageKey::Settlement, &settlement_address); } - - /// Get the settlement contract address. - /// - /// # Panics - /// * `"settlement address not set"` – if no settlement address has been configured. pub fn get_settlement(env: Env) -> Address { env.storage() .instance() .get(&StorageKey::Settlement) .unwrap_or_else(|| panic!("settlement address not set")) } - - /// Store offering metadata. Owner-only. - /// - /// # Panics - /// * `"unauthorized: owner only"` – caller is not the vault owner. - /// - /// # Events - /// Emits topic `("metadata_set", offering_id, caller)` with data `metadata`. pub fn set_metadata( env: Env, caller: Address, @@ -833,7 +488,6 @@ impl CalloraVault { ) -> String { caller.require_auth(); Self::require_owner(env.clone(), caller.clone()); - assert!( offering_id.len() <= MAX_OFFERING_ID_LEN, "offering_id exceeds max length" @@ -842,7 +496,6 @@ impl CalloraVault { metadata.len() <= MAX_METADATA_LEN, "metadata exceeds max length" ); - env.storage() .instance() .set(&StorageKey::Metadata(offering_id.clone()), &metadata); @@ -852,21 +505,11 @@ impl CalloraVault { ); metadata } - - /// Retrieve stored offering metadata. Returns `None` if not set. pub fn get_metadata(env: Env, offering_id: String) -> Option { env.storage() .instance() .get(&StorageKey::Metadata(offering_id)) } - - /// Update existing offering metadata. Owner-only. - /// - /// # Panics - /// * `"unauthorized: owner only"` – caller is not the vault owner. - /// - /// # Events - /// Emits topic `("metadata_updated", offering_id, caller)` with data `(old_metadata, new_metadata)`. pub fn update_metadata( env: Env, caller: Address, @@ -875,7 +518,6 @@ impl CalloraVault { ) -> String { caller.require_auth(); Self::require_owner(env.clone(), caller.clone()); - assert!( offering_id.len() <= MAX_OFFERING_ID_LEN, "offering_id exceeds max length" @@ -884,7 +526,6 @@ impl CalloraVault { metadata.len() <= MAX_METADATA_LEN, "metadata exceeds max length" ); - let old: String = env .storage() .instance() @@ -899,32 +540,22 @@ impl CalloraVault { ); metadata } - - // ----------------------------------------------------------------------- - // Internal helpers - // ----------------------------------------------------------------------- - - fn transfer_to_settlement(env: Env, amount: i128) { - let settlement_address: Address = env - .storage() - .instance() - .get(&Symbol::new(&env, SETTLEMENT_KEY)) - .expect("settlement address not set"); - let usdc_address: Address = env - .storage() - .instance() - .get(&Symbol::new(&env, USDC_KEY)) + 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"); - let usdc = token::Client::new(&env, &usdc_address); - usdc.transfer(&env.current_contract_address(), &settlement_address, &amount); + token::Client::new(&env, &ua).transfer(&env.current_contract_address(), &rp, &amount); } - - /// Panic with `"vault is paused"` when the circuit breaker is active. fn require_not_paused(env: Env) { assert!(!Self::is_paused(env), "vault is paused"); } - - /// Panic with an auth error unless `caller` is the Admin **or** the Owner. fn require_admin_or_owner(env: Env, caller: &Address) { let admin: Address = env .storage() diff --git a/contracts/vault/src/test.rs b/contracts/vault/src/test.rs index b6fa21f..5d1f136 100644 --- a/contracts/vault/src/test.rs +++ b/contracts/vault/src/test.rs @@ -424,7 +424,7 @@ fn set_allowed_depositor_duplicate_is_ignored() { client.init(&owner, &usdc, &Some(100), &None, &None, &None, &None); client.set_allowed_depositor(&owner, &Some(depositor.clone())); - client.set_allowed_depositor(&owner, &Some(depositor.clone())); // duplicate — should be a no-op + client.set_allowed_depositor(&owner, &Some(depositor.clone())); // duplicate — should be a no-op // depositor can still deposit exactly once (list not doubled) usdc_admin.mint(&depositor, &50); @@ -520,7 +520,7 @@ fn pause_emits_event() { .get(0) .map(|v| { let s: Symbol = v.into_val(&env); - s == Symbol::new(&env, "pause") + s == Symbol::new(&env, "vault_paused") }) .unwrap_or(false) }) @@ -920,7 +920,7 @@ fn batch_deduct_too_large_fails() { #[test] fn batch_deduct_fail_mid_batch_leaves_balance_unchanged() { - // Second item exceeds balance — entire batch must revert. + // Second item exceeds balance — entire batch must revert. let env = Env::default(); let owner = Address::generate(&env); let (vault_address, client) = create_vault(&env); @@ -1606,7 +1606,6 @@ fn deduct_with_revenue_pool_transfers_usdc() { &Some(1000), &Some(caller.clone()), &None, - &None, &Some(revenue_pool.clone()), &None, ); @@ -1662,7 +1661,6 @@ fn batch_deduct_with_revenue_pool_transfers_total_usdc() { &Some(1000), &Some(caller.clone()), &None, - &None, &Some(revenue_pool.clone()), &None, ); @@ -1751,7 +1749,15 @@ fn set_revenue_pool_clear_removes_address() { let (usdc, _, _) = create_usdc(&env, &owner); env.mock_all_auths(); - client.init(&owner, &usdc, &None, &None, &None, &Some(revenue_pool), &None); + client.init( + &owner, + &usdc, + &None, + &None, + &None, + &Some(revenue_pool), + &None, + ); client.set_revenue_pool(&owner, &None); assert_eq!(client.get_revenue_pool(), None); @@ -1861,7 +1867,15 @@ fn set_revenue_pool_emits_event_on_clear() { let (usdc, _, _) = create_usdc(&env, &owner); env.mock_all_auths(); - client.init(&owner, &usdc, &None, &None, &None, &Some(revenue_pool), &None); + client.init( + &owner, + &usdc, + &None, + &None, + &None, + &Some(revenue_pool), + &None, + ); client.set_revenue_pool(&owner, &None); let events = env.events().all(); @@ -1982,7 +1996,7 @@ fn test_deduct_with_settlement_success() { } // --------------------------------------------------------------------------- -// Checked arithmetic — overflow / underflow boundary tests +// Checked arithmetic — overflow / underflow boundary tests // --------------------------------------------------------------------------- #[test] @@ -2053,7 +2067,7 @@ fn withdraw_to_zero_succeeds() { } // --------------------------------------------------------------------------- -// Issue #108 — set_allowed_depositor: duplicate add, clear, unauthorized +// Issue #108 — set_allowed_depositor: duplicate add, clear, unauthorized // --------------------------------------------------------------------------- #[test] @@ -2068,9 +2082,9 @@ fn set_allowed_depositor_duplicate_add_is_rejected() { fund_vault(&usdc_admin, &vault_address, 100); client.init(&owner, &usdc, &Some(100), &None, &None, &None, &None); - client.set_allowed_depositor(&owner, &depositor); - let result = client.try_set_allowed_depositor(&owner, &depositor); - assert!(result.is_err(), "duplicate add must be rejected"); + client.set_allowed_depositor(&owner, &Some(depositor.clone())); + client.set_allowed_depositor(&owner, &Some(depositor.clone())); + assert_eq!(client.get_allowed_depositors().len(), 1); } #[test] @@ -2086,8 +2100,8 @@ fn clear_allowed_depositors_removes_all() { fund_vault(&usdc_admin, &vault_address, 100); client.init(&owner, &usdc, &Some(100), &None, &None, &None, &None); - client.set_allowed_depositor(&owner, &d1); - client.set_allowed_depositor(&owner, &d2); + client.set_allowed_depositor(&owner, &Some(d1.clone())); + client.set_allowed_depositor(&owner, &Some(d2.clone())); client.clear_allowed_depositors(&owner); // Neither address should be able to deposit after clear. @@ -2123,7 +2137,7 @@ fn non_owner_cannot_set_allowed_depositor_issue108() { fund_vault(&usdc_admin, &vault_address, 100); client.init(&owner, &usdc, &Some(100), &None, &None, &None, &None); - let result = client.try_set_allowed_depositor(&attacker, &depositor); + let result = client.try_set_allowed_depositor(&attacker, &Some(depositor.clone())); assert!(result.is_err(), "non-owner must not mutate allowlist"); } @@ -2140,7 +2154,7 @@ fn non_owner_cannot_clear_allowed_depositors() { fund_vault(&usdc_admin, &vault_address, 100); client.init(&owner, &usdc, &Some(100), &None, &None, &None, &None); - client.set_allowed_depositor(&owner, &depositor); + client.set_allowed_depositor(&owner, &Some(depositor.clone())); let result = client.try_clear_allowed_depositors(&attacker); assert!(result.is_err(), "non-owner must not clear allowlist"); }