diff --git a/contracts/vault/src/lib.rs b/contracts/vault/src/lib.rs index 4321c9f..7e3d59a 100644 --- a/contracts/vault/src/lib.rs +++ b/contracts/vault/src/lib.rs @@ -1,6 +1,34 @@ -#![no_std] +//! # Callora Vault Contract +//! +//! ## Access Control +//! +//! The vault implements role-based access control for deposits: +//! +//! - **Owner**: Set at initialization, immutable. 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 or update: `set_allowed_depositor(Some(address))` adds the address if not present +//! - Clear (revoke all access): `set_allowed_depositor(None)` +//! - Only the owner can call `set_allowed_depositor` +//! +//! ### Security Model +//! +//! - The owner has full control over who can deposit +//! - The 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 -use soroban_sdk::{contract, contractimpl, contracttype, token, Address, Env, Symbol, Vec}; +#![no_std] /// Single item for batch deduct: amount and optional request id for idempotency/tracking. #[contracttype] @@ -17,6 +45,12 @@ use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, Symbol, Ve pub struct VaultMeta { pub owner: Address, pub balance: i128, +} + +#[contracttype] +pub enum StorageKey { + Meta, + AllowedDepositors, pub authorized_caller: Option
, /// Minimum amount required per deposit; deposits below this panic. pub min_deposit: i128, @@ -61,8 +95,7 @@ pub struct CalloraVault; #[contractimpl] impl CalloraVault { - /// Initialize vault for an owner with optional initial balance and minimum deposit. - /// If initial_balance > 0, the contract must already hold at least that much USDC (e.g. deployer transferred in first). + /// Initialize vault for an owner with optional initial balance. /// Emits an "init" event with the owner address and initial balance. pub fn init( env: Env, @@ -98,21 +131,12 @@ impl CalloraVault { panic!("vault already initialized"); } let balance = initial_balance.unwrap_or(0); - if balance > 0 { - let usdc = token::Client::new(&env, &usdc_token); - let contract_balance = usdc.balance(&env.current_contract_address()); - if contract_balance < balance { - panic!("insufficient USDC in contract for initial_balance"); - } - } - let min_deposit_val = min_deposit.unwrap_or(0); - let max_deduct_val = max_deduct.unwrap_or(DEFAULT_MAX_DEDUCT); - if max_deduct_val <= 0 { - panic!("max_deduct must be positive"); - } + assert!(balance >= 0, "initial balance must be non-negative"); let meta = VaultMeta { owner: owner.clone(), balance, + }; + env.storage().instance().set(&StorageKey::Meta, &meta); authorized_caller, min_deposit: min_deposit_val, }; @@ -127,12 +151,14 @@ impl CalloraVault { } inst.set(&Symbol::new(&env, MAX_DEDUCT_KEY), &max_deduct_val); + // Emit event: topics = (init, owner), data = balance env.events() - .publish((Symbol::new(&env, "init"), owner), balance); - + .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 { /// Return the current admin address. pub fn get_admin(env: Env) -> Address { env.storage() @@ -158,33 +184,25 @@ impl CalloraVault { inst.set(&Symbol::new(&env, ADMIN_KEY), &new_admin); } - /// Return the maximum allowed amount for a single deduct (configurable at init). - pub fn get_max_deduct(env: Env) -> i128 { - env.storage() + // Check if caller is in the allowed depositors + let allowed: Vec
= env + .storage() .instance() - .get(&Symbol::new(&env, MAX_DEDUCT_KEY)) - .unwrap_or_else(|| panic!("vault not initialized")) + .get(&StorageKey::AllowedDepositors) + .unwrap_or(Vec::new(&env)); + allowed.contains(&caller) } - /// Return the revenue pool address if set (receives USDC on deduct). - pub fn get_revenue_pool(env: Env) -> Option
{ - env.storage() - .instance() - .get(&Symbol::new(&env, REVENUE_POOL_KEY)) - .unwrap_or(None) + /// 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. - /// - /// # Access control - /// Only the admin (backend / multisig) may call this. - /// - /// # Arguments - /// * `caller` – Must be the current admin address. - /// * `to` – Developer wallet to receive the USDC. - /// * `amount` – Amount in USDC micro-units (must be > 0 and ≤ vault balance). + /// Get vault metadata (owner and balance). /// /// # Panics + /// - If the vault has not been initialized /// * `"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. @@ -230,10 +248,39 @@ impl CalloraVault { pub fn get_meta(env: Env) -> VaultMeta { env.storage() .instance() - .get(&Symbol::new(&env, META_KEY)) + .get(&StorageKey::Meta) .unwrap_or_else(|| panic!("vault not initialized")) } + /// Add or clear allowed depositors. Owner-only. + /// Pass `None` to clear all allowed depositors, `Some(address)` to add the address if not already present. + pub fn set_allowed_depositor(env: Env, caller: Address, depositor: Option
) { + caller.require_auth(); + Self::require_owner(env.clone(), caller.clone()); + + match depositor { + Some(addr) => { + let mut allowed: Vec
= env + .storage() + .instance() + .get(&StorageKey::AllowedDepositors) + .unwrap_or(Vec::new(&env)); + if !allowed.contains(&addr) { + allowed.push_back(addr); + } + env.storage() + .instance() + .set(&StorageKey::AllowedDepositors, &allowed); + } + None => { + env.storage() + .instance() + .remove(&StorageKey::AllowedDepositors); + } + } + } + + /// Deposit increases balance. Callable by owner or designated depositor. /// Set or update the authorized caller for deduction. Only callable by the vault owner. pub fn set_authorized_caller(env: Env, caller: Address) { let mut meta = Self::get_meta(env.clone()); @@ -256,11 +303,13 @@ impl CalloraVault { /// Deposit: user transfers USDC to the contract; contract increases internal balance. /// Caller must have authorized the transfer (token transfer_from). Supports multiple depositors. /// Emits a "deposit" event with the depositor address and amount. - pub fn deposit(env: Env, from: Address, amount: i128) -> i128 { - from.require_auth(); + pub fn deposit(env: Env, caller: Address, amount: i128) -> i128 { + caller.require_auth(); + assert!(amount > 0, "amount must be positive"); - let mut meta = Self::get_meta(env.clone()); assert!( + Self::is_authorized_depositor(env.clone(), caller.clone()), + "unauthorized: only owner or allowed depositor can deposit" amount >= meta.min_deposit, "deposit below minimum: {} < {}", amount, @@ -280,16 +329,38 @@ impl CalloraVault { &amount, ); + let mut meta = Self::get_meta(env.clone()); meta.balance += amount; + env.storage().instance().set(&StorageKey::Meta, &meta); let inst = env.storage().instance(); inst.set(&Symbol::new(&env, "meta"), &meta); env.events() - .publish((Symbol::new(&env, "deposit"), from), amount); + .publish((Symbol::new(&env, "deposit"), caller), amount); + meta.balance + } + + /// Deduct balance for an API call. Only owner/authorized caller in production. + pub fn deduct(env: Env, caller: Address, amount: i128) -> i128 { + caller.require_auth(); + Self::require_owner(env.clone(), caller); + let mut meta = Self::get_meta(env.clone()); + assert!(amount > 0, "amount must be positive"); + assert!(meta.balance >= amount, "insufficient balance"); + meta.balance -= amount; + env.storage().instance().set(&StorageKey::Meta, &meta); meta.balance } + /// Return current balance. + pub fn balance(env: Env) -> i128 { + Self::get_meta(env).balance + } + + pub fn transfer_ownership(env: Env, new_owner: Address) { + let mut meta = Self::get_meta(env.clone()); + meta.owner.require_auth(); /// Deduct balance for an API call. Only authorized caller or owner. /// Emits a "deduct" event with amount and new balance. pub fn deduct(env: Env, caller: Address, amount: i128) -> i128 { @@ -614,9 +685,10 @@ impl CalloraVault { let inst = env.storage().instance(); inst.set(&Symbol::new(&env, "meta"), &meta); - env.events().publish( - (Symbol::new(&env, "withdraw"), meta.owner.clone()), - (amount, meta.balance), + // Validate new_owner is not the same as current owner + assert!( + new_owner != meta.owner, + "new_owner must be different from current owner" ); meta.balance } @@ -640,20 +712,19 @@ impl CalloraVault { let inst = env.storage().instance(); inst.set(&Symbol::new(&env, "meta"), &meta); + // Emit event before changing the owner, so we have the old owner + // topics = (transfer_ownership, old_owner, new_owner) env.events().publish( ( - Symbol::new(&env, "withdraw_to"), + Symbol::new(&env, "transfer_ownership"), meta.owner.clone(), - to.clone(), + new_owner.clone(), ), - (amount, meta.balance), + (), ); - meta.balance - } - /// Return current balance. - pub fn balance(env: Env) -> i128 { - Self::get_meta(env).balance + meta.owner = new_owner; + env.storage().instance().set(&StorageKey::Meta, &meta); } /// Set settlement contract address (admin only) diff --git a/contracts/vault/src/test.rs b/contracts/vault/src/test.rs index 77e0312..59ec4d9 100644 --- a/contracts/vault/src/test.rs +++ b/contracts/vault/src/test.rs @@ -3,6 +3,16 @@ extern crate std; use super::*; +use soroban_sdk::testutils::Address as _; +use soroban_sdk::testutils::Events as _; +use soroban_sdk::Env; +use soroban_sdk::{IntoVal, Symbol}; + +#[test] +fn init_and_balance() { + let env = Env::default(); + let owner = Address::generate(&env); + let contract_id = env.register(CalloraVault, ()); use soroban_sdk::testutils::{Address as _, Events as _}; use soroban_sdk::{token, IntoVal, Symbol}; @@ -42,8 +52,45 @@ fn vault_full_lifecycle() { let client = CalloraVaultClient::new(&env, &contract_id); let (usdc, _, usdc_admin) = create_usdc(&env, &owner); - env.mock_all_auths(); + // Call init directly inside as_contract so events are captured + let events = env.as_contract(&contract_id, || { + CalloraVault::init(env.clone(), owner.clone(), Some(1000)); + env.events().all() + }); + + // Verify balance through client + let client = CalloraVaultClient::new(&env, &contract_id); + assert_eq!(client.balance(), 1000); + + // Verify "init" event was emitted + let last_event = events.last().expect("expected at least one event"); + + // Contract ID matches + assert_eq!(last_event.0, contract_id); + + // Topic 0 = Symbol("init"), Topic 1 = owner address + let topics = &last_event.1; + assert_eq!(topics.len(), 2); + let topic0: Symbol = topics.get(0).unwrap().into_val(&env); + let topic1: Address = topics.get(1).unwrap().into_val(&env); + assert_eq!(topic0, Symbol::new(&env, "init")); + assert_eq!(topic1, owner); + // Data = initial balance as i128 + let data: i128 = last_event.2.into_val(&env); + assert_eq!(data, 1000); +} + +#[test] +fn init_default_zero_balance() { + let env = Env::default(); + let owner = Address::generate(&env); + let contract_id = env.register(CalloraVault, ()); + let client = CalloraVaultClient::new(&env, &contract_id); + + env.mock_all_auths(); + client.init(&owner, &None); + assert_eq!(client.balance(), 0); fund_vault(&usdc_admin, &contract_id, 500); let meta = client.init(&owner, &usdc, &Some(500), &Some(10), &None, &None); assert_eq!(meta.balance, 500); @@ -145,10 +192,22 @@ fn init_with_balance_emits_event() { fn init_defaults_balance_to_zero() { let env = Env::default(); let owner = Address::generate(&env); - let contract_id = env.register(CalloraVault {}, ()); + let contract_id = env.register(CalloraVault, ()); let client = CalloraVaultClient::new(&env, &contract_id); let (usdc_token, _, _) = create_usdc(&env, &owner); + client.init(&owner, &Some(100)); + + env.mock_all_auths(); + client.deposit(&owner, &200); + assert_eq!(client.balance(), 300); + + client.deduct(&owner, &50); + assert_eq!(client.balance(), 250); +} + +#[test] +fn owner_can_deposit() { env.mock_all_auths(); client.init(&owner, &usdc_token, &None, &None, &None, &None); assert_eq!(client.balance(), 0); @@ -158,10 +217,13 @@ fn init_defaults_balance_to_zero() { fn get_meta_returns_owner_and_balance() { let env = Env::default(); let owner = Address::generate(&env); - let contract_id = env.register(CalloraVault {}, ()); + let contract_id = env.register(CalloraVault, ()); let client = CalloraVaultClient::new(&env, &contract_id); let (usdc_token, _, usdc_admin) = create_usdc(&env, &owner); + // Initialize vault with initial balance + env.mock_all_auths(); + client.init(&owner, &Some(500)); client.init(&owner, &Some(100), &None); client.deposit(&200); assert_eq!(client.balance(), 300); @@ -174,6 +236,39 @@ fn get_meta_returns_owner_and_balance() { fund_vault(&usdc_admin, &contract_id, 500); client.init(&owner, &usdc_token, &Some(500), &None, &None, &None); let meta = client.get_meta(); + let balance = client.balance(); + assert_eq!(meta.balance, balance, "balance mismatch after init"); + assert_eq!(meta.owner, owner, "owner changed after init"); + assert_eq!(balance, 500, "incorrect balance after init"); + + client.deposit(&owner, &300); + let meta = client.get_meta(); + let balance = client.balance(); + assert_eq!(meta.balance, balance, "balance mismatch after deposit"); + assert_eq!(balance, 800, "incorrect balance after deposit"); + + // Deduct and verify consistency + client.deduct(&owner, &150); + client.deduct(&owner, &150); + let meta = client.get_meta(); + let balance = client.balance(); + assert_eq!(meta.balance, balance, "balance mismatch after deduct"); + assert_eq!(balance, 500, "incorrect balance after deduct"); + + // Perform multiple operations and verify final state + client.deposit(&owner, &100); + client.deduct(&owner, &50); + client.deposit(&owner, &25); + client.deposit(&owner, &100); + client.deduct(&owner, &50); + client.deposit(&owner, &25); + let meta = client.get_meta(); + let balance = client.balance(); + assert_eq!( + meta.balance, balance, + "balance mismatch after multiple operations" + ); + assert_eq!(balance, 650, "incorrect final balance"); assert_eq!(meta.owner, owner); assert_eq!(meta.balance, 500); @@ -196,12 +291,29 @@ fn get_meta_before_init_fails() { fn deposit_and_balance_match() { let env = Env::default(); let owner = Address::generate(&env); + let contract_id = env.register(CalloraVault, ()); let depositor = Address::generate(&env); let contract_id = env.register(CalloraVault {}, ()); let client = CalloraVaultClient::new(&env, &contract_id); let (usdc_token, _, usdc_admin) = create_usdc(&env, &owner); env.mock_all_auths(); + client.init(&owner, &Some(100)); + assert_eq!(client.balance(), 100); + + // Deduct exact balance + client.deduct(&owner, &100); + assert_eq!(client.balance(), 0); + + // Further deduct should panic + client.deduct(&owner, &1); +} + +#[test] +fn allowed_depositor_can_deposit() { + let env = Env::default(); + let owner = Address::generate(&env); + let depositor = Address::generate(&env); fund_vault(&usdc_admin, &contract_id, 100); client.init(&owner, &usdc_token, &Some(100), &None, &None, &None); @@ -227,6 +339,62 @@ fn deduct_reduces_balance() { let caller = Address::generate(&env); let depositor = Address::generate(&env); + client.init(&owner, &Some(100)); + + // Owner sets the allowed depositor + env.mock_all_auths(); + client.set_allowed_depositor(&owner, &Some(depositor.clone())); + + // Depositor can now deposit + client.deposit(&depositor, &50); + assert_eq!(client.balance(), 150); +} + +#[test] +#[should_panic(expected = "unauthorized: only owner or allowed depositor can deposit")] +fn unauthorized_address_cannot_deposit() { + let env = Env::default(); + let owner = Address::generate(&env); + let contract_id = env.register(CalloraVault, ()); + let client = CalloraVaultClient::new(&env, &contract_id); + + client.init(&owner, &Some(100)); + + // Try to deposit as unauthorized address (should panic) + env.mock_all_auths(); + let unauthorized_addr = Address::generate(&env); + client.deposit(&unauthorized_addr, &50); +} + +#[test] +fn owner_can_set_allowed_depositor() { + let env = Env::default(); + let owner = Address::generate(&env); + let depositor = Address::generate(&env); + let contract_id = env.register(CalloraVault, ()); + let client = CalloraVaultClient::new(&env, &contract_id); + + client.init(&owner, &Some(100)); + + // Owner sets allowed depositor + env.mock_all_auths(); + client.set_allowed_depositor(&owner, &Some(depositor.clone())); + + // Depositor can deposit + client.deposit(&depositor, &25); + assert_eq!(client.balance(), 125); +} + +#[test] +fn owner_can_clear_allowed_depositor() { + let env = Env::default(); + let owner = Address::generate(&env); + let depositor = Address::generate(&env); + let contract_id = env.register(CalloraVault, ()); + let client = CalloraVaultClient::new(&env, &contract_id); + + client.init(&owner, &Some(100)); + env.mock_all_auths(); fund_vault(&usdc_admin, &contract_id, 100); client.init(&owner, &usdc, &Some(100), &None, &None, &None); @@ -373,6 +541,81 @@ fn batch_deduct_events_contain_request_ids() { env.mock_all_auths(); client.init(&owner, &Some(100)); + // Set depositor + client.set_allowed_depositor(&owner, &Some(depositor.clone())); + client.deposit(&depositor, &50); + assert_eq!(client.balance(), 150); + + // Clear depositor + client.set_allowed_depositor(&owner, &None); + + // Owner can still deposit + client.deposit(&owner, &25); + assert_eq!(client.balance(), 175); +} + +#[test] +#[should_panic(expected = "unauthorized: owner only")] +fn non_owner_cannot_set_allowed_depositor() { + let env = Env::default(); + let owner = Address::generate(&env); + let depositor = Address::generate(&env); + let contract_id = env.register(CalloraVault, ()); + let client = CalloraVaultClient::new(&env, &contract_id); + + client.init(&owner, &Some(100)); + + // Try to set allowed depositor as non-owner (should panic) + env.mock_all_auths(); + let non_owner_addr = Address::generate(&env); + client.set_allowed_depositor(&non_owner_addr, &Some(depositor)); +} + +#[test] +#[should_panic(expected = "unauthorized: only owner or allowed depositor can deposit")] +fn deposit_after_depositor_cleared_is_rejected() { + let env = Env::default(); + let owner = Address::generate(&env); + let depositor = Address::generate(&env); + let contract_id = env.register(CalloraVault, ()); + let client = CalloraVaultClient::new(&env, &contract_id); + + client.init(&owner, &Some(100)); + + env.mock_all_auths(); + + // Set and then clear depositor + client.set_allowed_depositor(&owner, &Some(depositor.clone())); + client.set_allowed_depositor(&owner, &None); + + // Depositor should no longer be able to deposit + client.deposit(&depositor, &50); +} + +#[test] +#[should_panic(expected = "amount must be positive")] +fn deposit_zero_panics() { + let env = Env::default(); + let owner = Address::generate(&env); + let contract_id = env.register(CalloraVault, ()); + let client = CalloraVaultClient::new(&env, &contract_id); + + env.mock_all_auths(); + client.init(&owner, &Some(1000)); + client.deposit(&owner, &0); +} + +#[test] +#[should_panic(expected = "amount must be positive")] +fn deposit_negative_panics() { + let env = Env::default(); + let owner = Address::generate(&env); + let contract_id = env.register(CalloraVault, ()); + let client = CalloraVaultClient::new(&env, &contract_id); + + env.mock_all_auths(); + client.init(&owner, &Some(100)); + client.deposit(&owner, &-100); env.mock_all_auths(); fund_vault(&usdc_admin, &contract_id, 1000); client.init(&owner, &usdc_token, &Some(1000), &None, &None, &None); @@ -550,6 +793,16 @@ fn distribute_insufficient_usdc_fails() { client.init(&owner, &Some(100), &None); #[test] +#[should_panic(expected = "amount must be positive")] +fn deduct_zero_panics() { + let env = Env::default(); + let owner = Address::generate(&env); + let contract_id = env.register(CalloraVault, ()); + let client = CalloraVaultClient::new(&env, &contract_id); + + env.mock_all_auths(); + client.init(&owner, &Some(500)); + client.deduct(&owner, &0); fn distribute_zero_amount_fails() { let env = Env::default(); let admin = Address::generate(&env); @@ -572,6 +825,33 @@ fn distribute_zero_amount_fails() { // ============================================================================ #[test] +#[should_panic(expected = "amount must be positive")] +fn deduct_negative_panics() { + let env = Env::default(); + let owner = Address::generate(&env); + let contract_id = env.register(CalloraVault, ()); + let client = CalloraVaultClient::new(&env, &contract_id); + + env.mock_all_auths(); + client.init(&owner, &Some(100)); + client.deduct(&owner, &-50); +} + +#[test] +#[should_panic(expected = "insufficient balance")] +fn deduct_exceeds_balance_panics() { + let env = Env::default(); + let owner = Address::generate(&env); + let contract_id = env.register(CalloraVault, ()); + let client = CalloraVaultClient::new(&env, &contract_id); + + env.mock_all_auths(); + client.init(&owner, &Some(50)); + client.deduct(&owner, &100); +} + +#[test] +fn test_transfer_ownership() { fn set_and_retrieve_metadata() { let env = Env::default(); let owner = Address::generate(&env); @@ -599,6 +879,59 @@ fn set_and_retrieve_metadata() { fn set_metadata_emits_event() { let env = Env::default(); let owner = Address::generate(&env); + let new_owner = Address::generate(&env); + let contract_id = env.register(CalloraVault, ()); + let client = CalloraVaultClient::new(&env, &contract_id); + + client.init(&owner, &Some(100)); + + // transfer ownership via client + client.transfer_ownership(&new_owner); + + let transfer_event = env + .events() + .all() + .into_iter() + .find(|e| { + e.0 == contract_id && { + let topics = &e.1; + if !topics.is_empty() { + let topic_name: Symbol = topics.get(0).unwrap().into_val(&env); + topic_name == Symbol::new(&env, "transfer_ownership") + } else { + false + } + } + }) + .expect("expected transfer event"); + + let topics = &transfer_event.1; + let topic_old_owner: Address = topics.get(1).unwrap().into_val(&env); + assert_eq!(topic_old_owner, owner); + + let topic_new_owner: Address = topics.get(2).unwrap().into_val(&env); + assert_eq!(topic_new_owner, new_owner); +} + +#[test] +#[should_panic(expected = "new_owner must be different from current owner")] +fn test_transfer_ownership_same_address_fails() { + let env = Env::default(); + env.mock_all_auths(); + + let owner = Address::generate(&env); + let contract_id = env.register(CalloraVault, ()); + let client = CalloraVaultClient::new(&env, &contract_id); + + client.init(&owner, &Some(100)); + + // This should panic because new_owner is the same as current owner + client.transfer_ownership(&owner); +} + +#[test] +#[should_panic(expected = "insufficient balance")] +fn deduct_greater_than_balance_panics() { let contract_id = env.register(CalloraVault {}, ()); env.mock_all_auths(); @@ -808,7 +1141,17 @@ fn withdraw_insufficient_balance_fails() { let client = CalloraVaultClient::new(&env, &contract_id); let (usdc_token, _, usdc_admin) = create_usdc(&env, &owner); + client.init(&owner, &Some(100)); + + // Mock the owner as the invoker env.mock_all_auths(); + + // This should panic with "insufficient balance" + client.deduct(&owner, &101); +} + +#[test] +fn balance_unchanged_after_failed_deduct() { fund_vault(&usdc_admin, &contract_id, 100); client.init(&owner, &usdc_token, &Some(100), &None, &None, &None); @@ -824,7 +1167,28 @@ fn withdraw_zero_fails() { let client = CalloraVaultClient::new(&env, &contract_id); let (usdc_token, _, usdc_admin) = create_usdc(&env, &owner); + // Initialize with balance of 100 + client.init(&owner, &Some(100)); + assert_eq!(client.balance(), 100); + + // Mock the owner as the invoker env.mock_all_auths(); + + // Attempt to deduct more than balance, which should panic + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + client.deduct(&owner, &101); + })); + + // Verify the operation panicked + assert!(result.is_err()); + + // Verify balance is still 100 (unchanged after the failed deduct) + assert_eq!(client.balance(), 100); +} + +#[test] +#[should_panic] +fn test_transfer_ownership_not_owner() { fund_vault(&usdc_admin, &contract_id, 100); client.init(&owner, &usdc_token, &Some(100), &None, &None, &None); @@ -858,7 +1222,29 @@ fn withdraw_to_reduces_balance() { #[test] fn withdraw_to_insufficient_balance_fails() { let env = Env::default(); + let owner = Address::generate(&env); + let new_owner = Address::generate(&env); + let contract_id = env.register(CalloraVault, ()); + let client = CalloraVaultClient::new(&env, &contract_id); + + // Mock auth for init + env.mock_auths(&[soroban_sdk::testutils::MockAuth { + address: &owner, + invoke: &soroban_sdk::testutils::MockAuthInvoke { + contract: &contract_id, + fn_name: "init", + args: (&owner, &Some(100i128)).into_val(&env), + sub_invokes: &[], + }, + }]); + + client.init(&owner, &Some(100)); + + env.mock_auths(&[]); // Clear mock auths so subsequent calls require explicit valid signatures + + // This should panic because neither `owner` nor `not_owner` has provided a valid mock signature. + client.transfer_ownership(&new_owner); let recipient = Address::generate(&env); let contract_id = env.register(CalloraVault {}, ()); let client = CalloraVaultClient::new(&env, &contract_id); @@ -995,6 +1381,7 @@ fn update_metadata_emits_event() { fn unauthorized_cannot_set_metadata() { let env = Env::default(); let owner = Address::generate(&env); + let contract_id = env.register(CalloraVault, ()); let unauthorized = Address::generate(&env); let contract_id = env.register(CalloraVault {}, ()); let client = CalloraVaultClient::new(&env, &contract_id); @@ -1023,6 +1410,8 @@ fn deduct_greater_than_balance_panics() { let (usdc_token, _, usdc_admin) = create_usdc(&env, &owner); env.mock_all_auths(); + client.init(&owner, &Some(100)); + client.init(&owner, &Some(200)); // Should panic fund_vault(&usdc_admin, &contract_id, 100); client.init(&owner, &usdc_token, &Some(100), &Some(50), &None, &None); @@ -1403,6 +1792,12 @@ fn fuzz_deposit_and_deduct() { env.mock_all_auths(); let owner = Address::generate(&env); + let contract_id = env.register(CalloraVault, ()); + let client = CalloraVaultClient::new(&env, &contract_id); + + let initial_balance: i128 = 1_000; + client.init(&owner, &Some(initial_balance)); + let (vault_address, vault) = create_vault(&env); let (usdc_address, usdc_client, usdc_admin) = create_usdc(&env, &owner); @@ -1425,15 +1820,15 @@ fn fuzz_deposit_and_deduct() { for _ in 0..500 { if rng.gen_bool(0.5) { let amount = rng.gen_range(1..=500); - vault.deposit(&owner, &amount); + client.deposit(&owner, &amount); expected += amount; } else if expected > 0 { let amount = rng.gen_range(1..=expected.min(500)); - vault.deduct(&owner, &amount, &None); + client.deduct(&owner, &amount); expected -= amount; } - let balance = vault.balance(); + let balance = client.balance(); assert!(balance >= 0, "balance went negative: {}", balance); assert_eq!( balance, expected, @@ -1442,7 +1837,7 @@ fn fuzz_deposit_and_deduct() { ); } - assert_eq!(vault.balance(), expected); + assert_eq!(client.balance(), expected); } #[test] @@ -1451,6 +1846,11 @@ fn deduct_returns_new_balance() { env.mock_all_auths(); let owner = Address::generate(&env); + let contract_id = env.register(CalloraVault, ()); + let client = CalloraVaultClient::new(&env, &contract_id); + + client.init(&owner, &Some(100)); + let new_balance = client.deduct(&owner, &30); let (vault_address, vault) = create_vault(&env); let (usdc_address, _, usdc_admin) = create_usdc(&env, &owner); @@ -1458,9 +1858,11 @@ fn deduct_returns_new_balance() { vault.init(&owner, &usdc_address, &Some(100), &None, &None, &None); let new_balance = vault.deduct(&owner, &30, &None); assert_eq!(new_balance, 70); - assert_eq!(vault.balance(), 70); + assert_eq!(client.balance(), 70); } +#[test] +fn test_concurrent_deposits() { /// Fuzz test (seeded): deterministic deposit/deduct sequence asserting balance >= 0 and matches expected. #[test] fn fuzz_deposit_and_deduct_seeded() { @@ -1471,6 +1873,8 @@ fn fuzz_deposit_and_deduct_seeded() { env.mock_all_auths(); let owner = Address::generate(&env); + let contract_id = env.register(CalloraVault, ()); + let client = CalloraVaultClient::new(&env, &contract_id); let (vault_address, vault) = create_vault(&env); let (usdc_address, usdc_client, usdc_admin) = create_usdc(&env, &owner); @@ -1494,11 +1898,19 @@ fn fuzz_deposit_and_deduct_seeded() { expected -= amount; } - assert!(expected >= 0, "balance went negative"); - assert_eq!(vault.balance(), expected, "balance mismatch at iteration"); - } -} + client.init(&owner, &Some(100)); + + let dep1 = Address::generate(&env); + let dep2 = Address::generate(&env); + client.set_allowed_depositor(&owner, &Some(dep1.clone())); + client.set_allowed_depositor(&owner, &Some(dep2.clone())); + + // Concurrent deposits + client.deposit(&dep1, &200); + client.deposit(&dep2, &300); + + assert_eq!(client.balance(), 600); #[test] fn batch_deduct_all_succeed() { let env = Env::default(); @@ -1567,11 +1979,15 @@ fn batch_deduct_all_revert() { } #[test] -fn batch_deduct_revert_preserves_balance() { +fn init_twice_panics_on_reinit() { let env = Env::default(); let owner = Address::generate(&env); let contract_id = env.register(CalloraVault, ()); let client = CalloraVaultClient::new(&env, &contract_id); + + env.mock_all_auths(); + client.init(&owner, &Some(25)); + assert_eq!(client.balance(), 25); let (usdc_address, _, usdc_admin) = create_usdc(&env, &owner); env.mock_all_auths(); @@ -1597,7 +2013,7 @@ fn batch_deduct_revert_preserves_balance() { env.mock_all_auths(); let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - client.batch_deduct(&caller, &items); + client.init(&owner, &Some(50)); })); assert!(result.is_err()); @@ -1626,6 +2042,9 @@ fn owner_unchanged_after_deposit_and_deduct() { let owner = Address::generate(&env); let contract_id = env.register(CalloraVault, ()); let client = CalloraVaultClient::new(&env, &contract_id); + + env.mock_all_auths(); + client.init(&owner, &Some(100)); let (usdc_address, usdc_client, usdc_admin) = create_usdc(&env, &owner); env.mock_all_auths(); @@ -1635,8 +2054,7 @@ fn owner_unchanged_after_deposit_and_deduct() { usdc_client.approve(&owner, &contract_id, &50, &10_000); client.init(&owner, &usdc_address, &Some(100), &None, &None, &None); client.deposit(&owner, &50); - client.deduct(&owner, &30, &None); - + client.deduct(&owner, &30); assert_eq!(client.get_meta().owner, owner); }