diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 0000000..cb6b330 --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,74 @@ +name: Test Coverage + +# Run on every push to the main branches and on every pull-request targeting them. +# Also triggers manually from the Actions UI (workflow_dispatch). +on: + push: + branches: [main, master, "chore/coverage-report"] + pull_request: + branches: [main, master] + workflow_dispatch: + +jobs: + coverage: + name: "Cargo Test Coverage (≥ 95 %)" + runs-on: ubuntu-latest + + steps: + # ----------------------------------------------------------------------- + # 1. Source + # ----------------------------------------------------------------------- + - name: Checkout repository + uses: actions/checkout@v4 + + # ----------------------------------------------------------------------- + # 2. Rust toolchain — stable is sufficient for Soroban unit tests + # ----------------------------------------------------------------------- + - name: Install Rust stable toolchain + uses: dtolnay/rust-toolchain@stable + + # ----------------------------------------------------------------------- + # 3. Cache — speeds up subsequent runs considerably + # ----------------------------------------------------------------------- + - name: Cache Cargo registry and build artefacts + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target/ + # Key rotates when Cargo.lock changes; falls back to any prior entry. + key: ${{ runner.os }}-cargo-tarpaulin-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-tarpaulin- + ${{ runner.os }}-cargo- + + # ----------------------------------------------------------------------- + # 4. Install cargo-tarpaulin via the taiki-e installer — faster than + # `cargo install` because it downloads a pre-built binary. + # ----------------------------------------------------------------------- + - name: Install cargo-tarpaulin + uses: taiki-e/install-action@v2 + with: + tool: cargo-tarpaulin + + # ----------------------------------------------------------------------- + # 5. Run coverage + # Configuration lives in tarpaulin.toml (workspace root). + # A non-zero exit here means coverage fell below the 95 % threshold — + # this deliberately fails the workflow. + # ----------------------------------------------------------------------- + - name: Run test coverage + run: cargo tarpaulin --config tarpaulin.toml + + # ----------------------------------------------------------------------- + # 6. Upload HTML + XML report as a downloadable workflow artefact. + # Always runs so the report is available even when coverage fails. + # ----------------------------------------------------------------------- + - name: Upload coverage report artefact + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: coverage/ + retention-days: 30 diff --git a/.gitignore b/.gitignore index 31c707c..47657e0 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,6 @@ Cargo.lock .env .env.* /target/ -/target_local/ \ No newline at end of file +/target_local/ +.md-* +hidden \ No newline at end of file diff --git a/README.md b/README.md index 4340a79..9892a29 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,42 @@ All tests use `#[should_panic]` assertions for guaranteed validation. This resol ## Development Use one branch per issue or feature (e.g. `test/minimum-deposit-rejected`, `docs/vault-gas-notes`) to keep PRs small and reduce merge conflicts. Run `cargo fmt`, `cargo clippy --all-targets --all-features -- -D warnings`, and `cargo test` before pushing. +<<<<<<< HEAD + +## Test coverage + +The project enforces a **minimum of 95 % line coverage** on every push and pull-request via GitHub Actions. + +### Run coverage locally + +```bash +# First time only — the script auto-installs cargo-tarpaulin if absent +./scripts/coverage.sh +``` + +The script will: + +1. Check for `cargo-tarpaulin`; install it automatically if it is missing. +2. Run all tests with instrumentation according to `tarpaulin.toml`. +3. Exit with a non-zero code if coverage drops below 95 %. +4. Write reports to the `coverage/` directory (git-ignored). + +| Report file | Description | +| -------------------------------- | ----------------------------------------------- | +| `coverage/tarpaulin-report.html` | Interactive per-file view — open in any browser | +| `coverage/cobertura.xml` | Cobertura XML consumed by CI | + +> **Tip:** You can also run `cargo tarpaulin` directly from the workspace root; +> the settings in `tarpaulin.toml` are picked up automatically. + +### CI enforcement + +`.github/workflows/coverage.yml` runs on every push and pull-request. +It installs tarpaulin, runs coverage, uploads the HTML report as a downloadable +artefact, and posts a coverage summary table as a PR comment. +A result below 95 % causes the workflow — and the required status check — to fail. +======= +>>>>>>> b0229e42e4d4517da9f548ea3e374a5886304bf2 ## Project layout @@ -95,6 +131,24 @@ Use one branch per issue or feature (e.g. `test/minimum-deposit-rejected`, `docs callora-contracts/ ├── .github/workflows/ │ └── ci.yml # CI: fmt, clippy, test, WASM build +<<<<<<< HEAD +├── Cargo.toml # Workspace and release profile +├── BENCHMARKS.md # Vault operation gas/cost notes +├── EVENT_SCHEMA.md # Event names, topics, and payload types +├── UPGRADE.md # Vault upgrade and migration path +├── tarpaulin.toml # cargo-tarpaulin config (≥ 95 % enforced) +├── scripts/ +│ └── coverage.sh # One-command local coverage runner +├── .github/ +│ └── workflows/ +│ └── coverage.yml # CI: enforces 95 % on every push / PR +└── contracts/ + └── vault/ + ├── Cargo.toml + └── src/ + ├── lib.rs # Contract logic + └── test.rs # Unit tests (covers all code paths) +======= ├── Cargo.toml # Workspace and release profile ├── BENCHMARKS.md # Vault operation gas/cost notes ├── EVENT_SCHEMA.md # Event names, topics, and payload types @@ -111,6 +165,7 @@ callora-contracts/ │ ├── lib.rs # Settlement contract │ └── test.rs # Unit tests └── README.md +>>>>>>> b0229e42e4d4517da9f548ea3e374a5886304bf2 ``` ## Security Notes diff --git a/contracts/revenue_pool/src/test.rs b/contracts/revenue_pool/src/test.rs index c1b9444..8327701 100644 --- a/contracts/revenue_pool/src/test.rs +++ b/contracts/revenue_pool/src/test.rs @@ -219,3 +219,34 @@ fn batch_distribute_unauthorized_panics() { let payments = vec![&env, (dev1.clone(), 300)]; client.batch_distribute(&attacker, &payments); } + +#[test] +fn get_admin_before_init_fails() { + let env = Env::default(); + let (_, client) = create_pool(&env); + let result = client.try_get_admin(); + assert!(result.is_err(), "expected error when pool not initialized"); +} + +#[test] +fn balance_before_init_fails() { + let env = Env::default(); + let (_, client) = create_pool(&env); + let result = client.try_balance(); + assert!(result.is_err(), "expected error when pool not initialized"); +} + +#[test] +fn set_admin_unauthorized_fails() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let intruder = Address::generate(&env); + let new_admin = Address::generate(&env); + let (_, client) = create_pool(&env); + let (usdc, _, _) = create_usdc(&env, &admin); + + client.init(&admin, &usdc); + let result = client.try_set_admin(&intruder, &new_admin); + assert!(result.is_err(), "expected error for unauthorized set_admin"); +} diff --git a/contracts/vault/src/lib.rs b/contracts/vault/src/lib.rs index 87d7d58..f49ca7e 100644 --- a/contracts/vault/src/lib.rs +++ b/contracts/vault/src/lib.rs @@ -1,35 +1,14 @@ -//! # 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 - #![no_std] +use soroban_sdk::{contract, contractimpl, contracttype, token, Address, Env, Symbol, Vec}; + +/// Single item for batch deduct: amount and optional request id for idempotency/tracking. +#[contracttype] +#[derive(Clone)] +pub struct DeductItem { + pub amount: i128, + pub request_id: Option, +} use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Symbol}; use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, Symbol, Vec}; @@ -38,8 +17,24 @@ use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, Symbol, Ve pub struct VaultMeta { pub owner: Address, pub balance: i128, + /// Minimum amount required per deposit; deposits below this panic. + pub min_deposit: i128, } +const META_KEY: &str = "meta"; +const USDC_KEY: &str = "usdc"; +const ADMIN_KEY: &str = "admin"; +const REVENUE_POOL_KEY: &str = "revenue_pool"; +const MAX_DEDUCT_KEY: &str = "max_deduct"; + +/// Default maximum single deduct amount when not set at init (no cap). +pub const DEFAULT_MAX_DEDUCT: i128 = i128::MAX; + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct DistributeEvent { + pub to: Address, + pub amount: i128, /// Maximum allowed length for metadata strings (IPFS CID or URI). /// IPFS CIDv1 (base32) is typically ~59 chars, CIDv0 is 46 chars. /// HTTPS URIs can vary, but we cap at 256 chars to prevent storage abuse. @@ -64,9 +59,24 @@ pub struct CalloraVault; #[contractimpl] impl CalloraVault { - /// Initialize vault for an owner with optional initial balance. + /// 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). /// Emits an "init" event with the owner address and initial balance. /// + /// # Arguments + /// * `revenue_pool` – Optional address to receive USDC on each deduct (e.g. settlement contract). If None, USDC stays in vault. + /// * `max_deduct` – Optional cap per single deduct; if None, uses DEFAULT_MAX_DEDUCT (no cap). + pub fn init( + env: Env, + owner: Address, + usdc_token: Address, + initial_balance: Option, + min_deposit: Option, + revenue_pool: Option
, + max_deduct: Option, + ) -> VaultMeta { + owner.require_auth(); + if env.storage().instance().has(&Symbol::new(&env, META_KEY)) { /// # Security Note /// The `owner` address is required to authorize the initialization transaction via `owner.require_auth()`. /// This prevents unauthorized parties from initializing the vault with a "zero" or unauthenticated owner. @@ -80,19 +90,54 @@ impl CalloraVault { panic!("vault already initialized"); } let balance = initial_balance.unwrap_or(0); - assert!(balance >= 0, "initial balance must be non-negative"); + 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"); + } let meta = VaultMeta { owner: owner.clone(), balance, + min_deposit: min_deposit_val, }; - env.storage().instance().set(&StorageKey::Meta, &meta); + // Persist metadata under both the literal key and the constant for safety. + let inst = env.storage().instance(); + inst.set(&Symbol::new(&env, "meta"), &meta); + inst.set(&Symbol::new(&env, META_KEY), &meta); + inst.set(&Symbol::new(&env, USDC_KEY), &usdc_token); + inst.set(&Symbol::new(&env, ADMIN_KEY), &owner); + if let Some(pool) = revenue_pool { + inst.set(&Symbol::new(&env, REVENUE_POOL_KEY), &pool); + } + 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.clone()), balance); + .publish((Symbol::new(&env, "init"), owner), balance); + meta } + /// Return the current admin address. + pub fn get_admin(env: Env) -> Address { + env.storage() + .instance() + .get(&Symbol::new(&env, ADMIN_KEY)) + .unwrap_or_else(|| panic!("vault not initialized")) + } + + /// Replace the current admin. Only the existing admin may call this. + 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 { + panic!("unauthorized: caller is not admin"); /// Check if the caller is authorized to deposit (owner or allowed depositor). fn is_authorized_depositor(env: Env, caller: Address) -> bool { let meta = Self::get_meta(env.clone()); @@ -100,81 +145,132 @@ impl CalloraVault { if caller == meta.owner { return true; } + let inst = env.storage().instance(); + inst.set(&Symbol::new(&env, ADMIN_KEY), &new_admin); + } - // Check if caller is in the allowed depositors - let allowed: Vec
= env - .storage() + /// Return the maximum allowed amount for a single deduct (configurable at init). + pub fn get_max_deduct(env: Env) -> i128 { + env.storage() .instance() - .get(&StorageKey::AllowedDepositors) - .unwrap_or(Vec::new(&env)); - allowed.contains(&caller) + .get(&Symbol::new(&env, MAX_DEDUCT_KEY)) + .unwrap_or_else(|| panic!("vault not initialized")) } - /// 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"); + /// 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) } - /// Get vault metadata (owner and balance). + /// 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). /// /// # 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. + /// + /// # Events + /// Emits topic `("distribute", to)` with data `amount` on success. + pub fn distribute(env: Env, caller: Address, to: Address, amount: i128) { + // 1. Require on-chain signature from caller. + caller.require_auth(); + + // 2. Only the registered admin may distribute. + let admin = Self::get_admin(env.clone()); + if caller != admin { + panic!("unauthorized: caller is not admin"); + } + + // 3. Amount must be positive. + if amount <= 0 { + panic!("amount must be positive"); + } + + // 4. Load the USDC token address. + let usdc_opt: Option
= env.storage().instance().get(&Symbol::new(&env, USDC_KEY)); + let usdc_address: Address = usdc_opt.unwrap_or_else(|| panic!("vault not initialized")); + + let usdc = token::Client::new(&env, &usdc_address); + + // 5. Check vault has enough USDC. + let vault_balance = usdc.balance(&env.current_contract_address()); + if vault_balance < amount { + panic!("insufficient USDC balance"); + } + + // 6. Transfer USDC from vault to developer. + usdc.transfer(&env.current_contract_address(), &to, &amount); + + // 7. Emit distribute event. + env.events() + .publish((Symbol::new(&env, "distribute"), to), amount); + } + + /// Get vault metadata (owner and balance). pub fn get_meta(env: Env) -> VaultMeta { env.storage() .instance() - .get(&StorageKey::Meta) + .get(&Symbol::new(&env, META_KEY)) .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. + /// 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, caller: Address, amount: i128) -> i128 { - caller.require_auth(); - assert!(amount > 0, "amount must be positive"); + pub fn deposit(env: Env, from: Address, amount: i128) -> i128 { + from.require_auth(); + 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, + meta.min_deposit + ); + + let usdc_address: Address = env + .storage() + .instance() + .get(&Symbol::new(&env, USDC_KEY)) + .expect("vault not initialized"); + let usdc = token::Client::new(&env, &usdc_address); + usdc.transfer_from( + &env.current_contract_address(), + &from, + &env.current_contract_address(), + &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"), caller), amount); + .publish((Symbol::new(&env, "deposit"), from), amount); + meta.balance } + /// Deduct balance for an API call. Callable by authorized caller (e.g. backend). + /// Amount must not exceed max single deduct (see init / get_max_deduct). + /// If revenue pool is set, USDC is transferred to it; otherwise it remains in the vault. + /// Emits a "deduct" event with caller, optional request_id, amount, and new balance. + pub fn deduct(env: Env, caller: Address, amount: i128, request_id: Option) -> i128 { + caller.require_auth(); + let max_deduct = Self::get_max_deduct(env.clone()); + assert!(amount > 0, "amount must be positive"); + assert!(amount <= max_deduct, "deduct amount exceeds max_deduct"); /// Pause the vault. Only the owner may call this. pub fn pause(env: Env, caller: Address) { caller.require_auth(); @@ -205,13 +301,68 @@ impl CalloraVault { assert!(!Self::paused(env.clone()), "vault is paused"); 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); + let inst = env.storage().instance(); + inst.set(&Symbol::new(&env, "meta"), &meta); + + let topics = match &request_id { + Some(rid) => (Symbol::new(&env, "deduct"), caller.clone(), rid.clone()), + None => ( + Symbol::new(&env, "deduct"), + caller.clone(), + Symbol::new(&env, ""), + ), + }; + env.events().publish(topics, (amount, meta.balance)); + meta.balance + } + + /// Batch deduct: multiple (amount, optional request_id) in one transaction. + /// Each amount must not exceed max_deduct. Reverts entire batch if any check fails. + /// If revenue pool is set, total deducted USDC is transferred to it once. + /// Emits one "deduct" event per item. + pub fn batch_deduct(env: Env, caller: Address, items: Vec) -> i128 { + caller.require_auth(); + let max_deduct = Self::get_max_deduct(env.clone()); + let mut meta = Self::get_meta(env.clone()); + let n = items.len(); + assert!(n > 0, "batch_deduct requires at least one item"); + + let mut running = meta.balance; + for item in items.iter() { + assert!(item.amount > 0, "amount must be positive"); + assert!( + item.amount <= max_deduct, + "deduct amount exceeds max_deduct" + ); + assert!(running >= item.amount, "insufficient balance"); + running -= item.amount; + } + + let mut balance = meta.balance; + for item in items.iter() { + balance -= item.amount; + let topics = match &item.request_id { + Some(rid) => (Symbol::new(&env, "deduct"), caller.clone(), rid.clone()), + None => ( + Symbol::new(&env, "deduct"), + caller.clone(), + Symbol::new(&env, ""), + ), + }; + env.events().publish(topics, (item.amount, balance)); + } + + meta.balance = balance; + let inst = env.storage().instance(); + inst.set(&Symbol::new(&env, "meta"), &meta); meta.balance } + /// Withdraw from vault. Callable only by the vault owner; reduces balance and transfers USDC to owner. + pub fn withdraw(env: Env, amount: i128) -> i128 { /// Set the price per API call (in smallest USDC units) for a given API ID. /// Callable by the owner or allowed depositor (backend/admin). pub fn set_price(env: Env, caller: Address, api_id: Symbol, price: i128) { @@ -378,26 +529,61 @@ impl CalloraVault { pub fn transfer_ownership(env: Env, new_owner: Address) { 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"); - // Validate new_owner is not the same as current owner - assert!( - new_owner != meta.owner, - "new_owner must be different from current owner" + let usdc_address: Address = env + .storage() + .instance() + .get(&Symbol::new(&env, USDC_KEY)) + .expect("vault not initialized"); + let usdc = token::Client::new(&env, &usdc_address); + usdc.transfer(&env.current_contract_address(), &meta.owner, &amount); + + meta.balance -= amount; + 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), ); + meta.balance + } + + /// Withdraw from vault to a designated address. Owner-only; transfers USDC to `to`. + 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 + .storage() + .instance() + .get(&Symbol::new(&env, USDC_KEY)) + .expect("vault not initialized"); + let usdc = token::Client::new(&env, &usdc_address); + usdc.transfer(&env.current_contract_address(), &to, &amount); + + meta.balance -= amount; + 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, "transfer_ownership"), + Symbol::new(&env, "withdraw_to"), meta.owner.clone(), - new_owner.clone(), + to.clone(), ), - (), + (amount, meta.balance), ); + meta.balance + } - meta.owner = new_owner; - env.storage().instance().set(&StorageKey::Meta, &meta); + /// Return current balance. + pub fn balance(env: Env) -> i128 { + Self::get_meta(env).balance } } diff --git a/contracts/vault/src/test.rs b/contracts/vault/src/test.rs index 311d43a..b445bc8 100644 --- a/contracts/vault/src/test.rs +++ b/contracts/vault/src/test.rs @@ -3,35 +3,132 @@ 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}; +use soroban_sdk::testutils::{Address as _, Events as _}; +use soroban_sdk::{token, IntoVal, Symbol}; + +fn create_usdc<'a>( + env: &'a Env, + admin: &Address, +) -> (Address, token::Client<'a>, token::StellarAssetClient<'a>) { + let contract_address = env.register_stellar_asset_contract_v2(admin.clone()); + let address = contract_address.address(); + let client = token::Client::new(env, &address); + let admin_client = token::StellarAssetClient::new(env, &address); + (address, client, admin_client) +} + +fn create_vault(env: &Env) -> (Address, CalloraVaultClient<'_>) { + let address = env.register(CalloraVault, ()); + let client = CalloraVaultClient::new(env, &address); + (address, client) +} + +fn fund_vault( + usdc_admin_client: &token::StellarAssetClient, + vault_address: &Address, + amount: i128, +) { + usdc_admin_client.mint(vault_address, &amount); +} #[test] -fn init_and_balance() { +fn vault_full_lifecycle() { let env = Env::default(); let owner = Address::generate(&env); - let contract_id = env.register(CalloraVault, ()); + let new_admin = Address::generate(&env); + let caller = Address::generate(&env); + let recipient = Address::generate(&env); + let contract_id = env.register(CalloraVault {}, ()); + let client = CalloraVaultClient::new(&env, &contract_id); + let (usdc, _, usdc_admin) = create_usdc(&env, &owner); + + env.mock_all_auths(); + + fund_vault(&usdc_admin, &contract_id, 500); + let meta = client.init(&owner, &usdc, &Some(500), &Some(10), &None, &None); + assert_eq!(meta.balance, 500); + assert_eq!(meta.owner, owner); + assert_eq!(client.balance(), 500); + assert_eq!(client.get_admin(), owner); + + let depositor = Address::generate(&env); + fund_vault(&usdc_admin, &depositor, 200); + let usdc_client = token::Client::new(&env, &usdc); + usdc_client.approve(&depositor, &contract_id, &200, &1000); + let after_deposit = client.deposit(&depositor, &200); + assert_eq!(after_deposit, 700); + assert_eq!(client.balance(), 700); + + let items = soroban_sdk::vec![ + &env, + DeductItem { + amount: 100, + request_id: Some(Symbol::new(&env, "r1")), + }, + DeductItem { + amount: 50, + request_id: None, + }, + DeductItem { + amount: 25, + request_id: Some(Symbol::new(&env, "r3")), + }, + ]; + let after_batch = client.batch_deduct(&caller, &items); + assert_eq!(after_batch, 525); + assert_eq!(client.balance(), 525); + + let after_deduct = client.deduct(&caller, &25, &Some(Symbol::new(&env, "r4"))); + assert_eq!(after_deduct, 500); + + client.set_admin(&owner, &new_admin); + assert_eq!(client.get_admin(), new_admin); + + let after_withdraw = client.withdraw_to(&recipient, &100); + assert_eq!(after_withdraw, 400); + assert_eq!(client.balance(), 400); + + let after_withdraw2 = client.withdraw(&50); + assert_eq!(after_withdraw2, 350); + assert_eq!(client.balance(), 350); + + let final_meta = client.get_meta(); + assert_eq!(final_meta.balance, 350); + assert_eq!(final_meta.owner, owner); + assert_eq!(final_meta.min_deposit, 10); +} + +#[test] +fn init_with_balance_emits_event() { + let env = Env::default(); + let owner = Address::generate(&env); + let contract_id = env.register(CalloraVault {}, ()); + let (usdc_token, _, usdc_admin) = create_usdc(&env, &owner); + + env.mock_all_auths(); + fund_vault(&usdc_admin, &contract_id, 1000); 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)); + CalloraVault::init( + env.clone(), + owner.clone(), + usdc_token.clone(), + Some(1000), + None, + None, + None, + ); 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); @@ -39,229 +136,405 @@ fn init_and_balance() { 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() { +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); env.mock_all_auths(); - client.init(&owner, &None); + client.init(&owner, &usdc_token, &None, &None, &None, &None); assert_eq!(client.balance(), 0); } #[test] -fn deposit_and_deduct() { +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); env.mock_all_auths(); client.init(&owner, &Some(100)); env.mock_all_auths(); - client.deposit(&owner, &200); - assert_eq!(client.balance(), 300); + fund_vault(&usdc_admin, &contract_id, 500); + client.init(&owner, &usdc_token, &Some(500), &None, &None, &None); + let meta = client.get_meta(); - client.deduct(&owner, &50); - assert_eq!(client.balance(), 250); + assert_eq!(meta.owner, owner); + assert_eq!(meta.balance, 500); } #[test] -fn owner_can_deposit() { +fn get_meta_before_init_fails() { + let env = Env::default(); + let contract_id = env.register(CalloraVault {}, ()); + let client = CalloraVaultClient::new(&env, &contract_id); + + let result = client.try_get_meta(); + assert!( + result.is_err(), + "expected error when vault is uninitialised" + ); +} + +#[test] +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); - // Initialize vault with initial balance env.mock_all_auths(); - client.init(&owner, &Some(500)); - - 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"); + fund_vault(&usdc_admin, &contract_id, 100); + client.init(&owner, &usdc_token, &Some(100), &None, &None, &None); - 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"); + fund_vault(&usdc_admin, &depositor, 200); + let usdc_client = token::Client::new(&env, &usdc_token); + usdc_client.approve(&depositor, &contract_id, &200, &1000); + let returned = client.deposit(&depositor, &200); - // 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" + returned, 300, + "deposit should return the new running balance" ); - assert_eq!(balance, 650, "incorrect final balance"); + assert_eq!(client.balance(), 300); } #[test] -#[should_panic(expected = "insufficient balance")] -fn deduct_exact_balance_and_panic() { +fn deduct_reduces_balance() { let env = Env::default(); let owner = Address::generate(&env); let contract_id = env.register(CalloraVault, ()); let client = CalloraVaultClient::new(&env, &contract_id); + let (usdc, _, usdc_admin) = create_usdc(&env, &owner); + let caller = Address::generate(&env); + let depositor = Address::generate(&env); env.mock_all_auths(); - client.init(&owner, &Some(100)); - assert_eq!(client.balance(), 100); + fund_vault(&usdc_admin, &contract_id, 100); + client.init(&owner, &usdc, &Some(100), &None, &None, &None); - // Deduct exact balance - client.deduct(&owner, &100); - assert_eq!(client.balance(), 0); + fund_vault(&usdc_admin, &depositor, 200); + let usdc_client = token::Client::new(&env, &usdc); + usdc_client.approve(&depositor, &contract_id, &200, &1000); + client.deposit(&depositor, &200); + assert_eq!(client.balance(), 300); - // Further deduct should panic - client.deduct(&owner, &1); + let returned = client.deduct(&caller, &50, &None); + assert_eq!(returned, 250, "deduct should return the remaining balance"); + assert_eq!(client.balance(), 250); } #[test] -fn allowed_depositor_can_deposit() { +fn deduct_with_request_id() { 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); + let (usdc, _, usdc_admin) = create_usdc(&env, &owner); + let caller = Address::generate(&env); + // Initialize vault with initial balance + env.mock_all_auths(); + fund_vault(&usdc_admin, &contract_id, 1000); + client.init(&owner, &usdc, &Some(1000), &None, &None, &None); + + let request_id = Symbol::new(&env, "req123"); + let remaining = client.deduct(&caller, &100, &Some(request_id)); + assert_eq!(remaining, 900); +} + +#[test] +fn deduct_insufficient_balance_fails() { + let env = Env::default(); + let owner = 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); + let caller = Address::generate(&env); env.mock_all_auths(); client.init(&owner, &Some(100)); - // Owner sets the allowed depositor env.mock_all_auths(); - client.set_allowed_depositor(&owner, &Some(depositor.clone())); + fund_vault(&usdc_admin, &contract_id, 10); + client.init(&owner, &usdc_token, &Some(10), &None, &None, &None); - // Depositor can now deposit - client.deposit(&depositor, &50); - assert_eq!(client.balance(), 150); + let result = client.try_deduct(&caller, &100, &None); + assert!(result.is_err(), "expected error for insufficient balance"); } #[test] -#[should_panic(expected = "unauthorized: only owner or allowed depositor can deposit")] -fn unauthorized_address_cannot_deposit() { +fn deduct_exact_balance_succeeds() { let env = Env::default(); let owner = 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); + let caller = Address::generate(&env); env.mock_all_auths(); 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); + fund_vault(&usdc_admin, &contract_id, 75); + client.init(&owner, &usdc_token, &Some(75), &None, &None, &None); + let remaining = client.deduct(&caller, &75, &None); + + assert_eq!(remaining, 0); + assert_eq!(client.balance(), 0); } #[test] -fn owner_can_set_allowed_depositor() { +fn deduct_event_contains_request_id() { let env = Env::default(); let owner = Address::generate(&env); - let depositor = Address::generate(&env); + let caller = 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)); // Owner sets allowed depositor env.mock_all_auths(); - client.set_allowed_depositor(&owner, &Some(depositor.clone())); + fund_vault(&usdc_admin, &contract_id, 500); + client.init(&owner, &usdc_token, &Some(500), &None, &None, &None); + + let request_id = Symbol::new(&env, "api_call_42"); + client.deduct(&caller, &150, &Some(request_id.clone())); + + let events = env.events().all(); + let ev = events.last().expect("expected deduct event"); - // Depositor can deposit - client.deposit(&depositor, &25); - assert_eq!(client.balance(), 125); + let topic0: Symbol = ev.1.get(0).unwrap().into_val(&env); + let topic1: Address = ev.1.get(1).unwrap().into_val(&env); + let topic2: Symbol = ev.1.get(2).unwrap().into_val(&env); + + assert_eq!(topic0, Symbol::new(&env, "deduct")); + assert_eq!(topic1, caller); + assert_eq!(topic2, request_id); + + let (emitted_amount, remaining): (i128, i128) = ev.2.into_val(&env); + assert_eq!(emitted_amount, 150); + assert_eq!(remaining, 350); } #[test] -fn owner_can_clear_allowed_depositor() { +fn deduct_event_no_request_id_uses_empty_symbol() { let env = Env::default(); let owner = Address::generate(&env); - let depositor = Address::generate(&env); - let contract_id = env.register(CalloraVault, ()); + let caller = 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)); + fund_vault(&usdc_admin, &contract_id, 300); + client.init(&owner, &usdc_token, &Some(300), &None, &None, &None); + client.deduct(&caller, &100, &None); + + let events = env.events().all(); + let ev = events.last().expect("expected deduct event"); + + let topic0: Symbol = ev.1.get(0).unwrap().into_val(&env); + let topic2: Symbol = ev.1.get(2).unwrap().into_val(&env); + + assert_eq!(topic0, Symbol::new(&env, "deduct")); + assert_eq!(topic2, Symbol::new(&env, "")); +} +#[test] +fn batch_deduct_events_contain_request_ids() { + let env = Env::default(); + let owner = Address::generate(&env); + let caller = 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)); - // Set depositor - client.set_allowed_depositor(&owner, &Some(depositor.clone())); - client.deposit(&depositor, &50); - assert_eq!(client.balance(), 150); + env.mock_all_auths(); + fund_vault(&usdc_admin, &contract_id, 1000); + client.init(&owner, &usdc_token, &Some(1000), &None, &None, &None); - // Clear depositor - client.set_allowed_depositor(&owner, &None); + let rid_a = Symbol::new(&env, "batch_a"); + let rid_b = Symbol::new(&env, "batch_b"); - // Owner can still deposit - client.deposit(&owner, &25); - assert_eq!(client.balance(), 175); + let items = soroban_sdk::vec![ + &env, + DeductItem { + amount: 200, + request_id: Some(rid_a.clone()), + }, + DeductItem { + amount: 300, + request_id: Some(rid_b.clone()), + }, + ]; + client.batch_deduct(&caller, &items); + + let all_events = env.events().all(); + assert_eq!(all_events.len(), 2); + + let ev_a = all_events.get(0).unwrap(); + let ev_b = all_events.get(1).unwrap(); + + let req_a: Symbol = ev_a.1.get(2).unwrap().into_val(&env); + let req_b: Symbol = ev_b.1.get(2).unwrap().into_val(&env); + assert_eq!(req_a, rid_a); + assert_eq!(req_b, rid_b); + + let (amt_a, _): (i128, i128) = ev_a.2.into_val(&env); + let (amt_b, _): (i128, i128) = ev_b.2.into_val(&env); + assert_eq!(amt_a, 200); + assert_eq!(amt_b, 300); } #[test] -#[should_panic(expected = "unauthorized: owner only")] -fn non_owner_cannot_set_allowed_depositor() { +fn get_admin_returns_correct_address() { let env = Env::default(); let owner = Address::generate(&env); - let depositor = 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); env.mock_all_auths(); + fund_vault(&usdc_admin, &contract_id, 100); + client.init(&owner, &usdc_token, &Some(100), &None, &None, &None); + + let admin = client.get_admin(); + assert_eq!(admin, owner); +} + +#[test] +fn set_admin_updates_admin() { + let env = Env::default(); + let owner = Address::generate(&env); + let new_admin = 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); 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)); + fund_vault(&usdc_admin, &contract_id, 100); + client.init(&owner, &usdc_token, &Some(100), &None, &None, &None); + + client.set_admin(&owner, &new_admin); + assert_eq!(client.get_admin(), new_admin); } #[test] -#[should_panic(expected = "unauthorized: only owner or allowed depositor can deposit")] -fn deposit_after_depositor_cleared_is_rejected() { +fn set_admin_unauthorized_fails() { let env = Env::default(); let owner = Address::generate(&env); - let depositor = Address::generate(&env); - let contract_id = env.register(CalloraVault, ()); + let intruder = Address::generate(&env); + let new_admin = 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(); + fund_vault(&usdc_admin, &contract_id, 100); + client.init(&owner, &usdc_token, &Some(100), &None, &None, &None); + + let result = client.try_set_admin(&intruder, &new_admin); + assert!( + result.is_err(), + "expected error when non-admin tries to set admin" + ); +} + +#[test] +fn distribute_transfers_usdc_to_developer() { + let env = Env::default(); + let admin = Address::generate(&env); + let developer = Address::generate(&env); + let (vault_address, client) = create_vault(&env); + let (usdc, usdc_client, usdc_admin_client) = create_usdc(&env, &admin); + + env.mock_all_auths(); + + fund_vault(&usdc_admin_client, &vault_address, 1000); + client.init(&admin, &usdc, &Some(0), &None, &None, &None); + + client.distribute(&admin, &developer, &300); + + assert_eq!(usdc_client.balance(&developer), 300); + assert_eq!(usdc_client.balance(&vault_address), 700); +} + +#[test] +fn distribute_unauthorized_fails() { + let env = Env::default(); + let admin = Address::generate(&env); + let intruder = Address::generate(&env); + let developer = Address::generate(&env); + let (vault_address, client) = create_vault(&env); + let (usdc, _, usdc_admin_client) = create_usdc(&env, &admin); env.mock_all_auths(); + + fund_vault(&usdc_admin_client, &vault_address, 1000); + client.init(&admin, &usdc, &Some(0), &None, &None, &None); + + let result = client.try_distribute(&intruder, &developer, &300); + assert!( + result.is_err(), + "expected error when non-admin tries to distribute" + ); +} + +#[test] +fn distribute_insufficient_usdc_fails() { + let env = Env::default(); + let admin = Address::generate(&env); + let developer = Address::generate(&env); + let (vault_address, client) = create_vault(&env); + let (usdc, _, usdc_admin_client) = create_usdc(&env, &admin); + + env.mock_all_auths(); + + fund_vault(&usdc_admin_client, &vault_address, 100); + client.init(&admin, &usdc, &Some(0), &None, &None, &None); + + let result = client.try_distribute(&admin, &developer, &500); + assert!( + result.is_err(), + "expected error for insufficient USDC balance" + ); +} + +#[test] +fn distribute_zero_amount_fails() { + let env = Env::default(); + let admin = Address::generate(&env); + let developer = Address::generate(&env); + let (vault_address, client) = create_vault(&env); + let (usdc, _, usdc_admin_client) = create_usdc(&env, &admin); 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); + fund_vault(&usdc_admin_client, &vault_address, 1000); + client.init(&admin, &usdc, &Some(0), &None, &None, &None); - // Depositor should no longer be able to deposit - client.deposit(&depositor, &50); + let result = client.try_distribute(&admin, &developer, &0); + assert!(result.is_err(), "expected error for zero amount"); } // ============================================================================ @@ -371,114 +644,208 @@ fn update_metadata_and_verify() { } #[test] -#[should_panic(expected = "amount must be positive")] -fn deposit_zero_panics() { +fn batch_deduct_multiple_items() { let env = Env::default(); let owner = Address::generate(&env); - let contract_id = env.register(CalloraVault, ()); + let caller = 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(1000)); - client.deposit(&owner, &0); + fund_vault(&usdc_admin, &contract_id, 1000); + client.init(&owner, &usdc_token, &Some(1000), &None, &None, &None); + + let items = soroban_sdk::vec![ + &env, + DeductItem { + amount: 100, + request_id: Some(Symbol::new(&env, "req1")) + }, + DeductItem { + amount: 200, + request_id: None + }, + DeductItem { + amount: 50, + request_id: Some(Symbol::new(&env, "req2")) + } + ]; + + let remaining = client.batch_deduct(&caller, &items); + assert_eq!(remaining, 650); + assert_eq!(client.balance(), 650); } #[test] -#[should_panic(expected = "amount must be positive")] -fn deposit_negative_panics() { +fn batch_deduct_insufficient_balance_fails() { let env = Env::default(); let owner = Address::generate(&env); - let contract_id = env.register(CalloraVault, ()); + let caller = 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)); - client.deposit(&owner, &-100); + fund_vault(&usdc_admin, &contract_id, 100); + client.init(&owner, &usdc_token, &Some(100), &None, &None, &None); + + let items = soroban_sdk::vec![ + &env, + DeductItem { + amount: 50, + request_id: None + }, + DeductItem { + amount: 80, + request_id: None + } + ]; + + let result = client.try_batch_deduct(&caller, &items); + assert!(result.is_err(), "expected error for batch overdraw"); + assert_eq!(client.balance(), 100); } #[test] -#[should_panic(expected = "amount must be positive")] -fn deduct_zero_panics() { +fn batch_deduct_empty_fails() { let env = Env::default(); let owner = Address::generate(&env); - let contract_id = env.register(CalloraVault, ()); + let caller = 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(500)); - client.deduct(&owner, &0); + fund_vault(&usdc_admin, &contract_id, 100); + client.init(&owner, &usdc_token, &Some(100), &None, &None, &None); + + let items: soroban_sdk::Vec = soroban_sdk::vec![&env]; + + let result = client.try_batch_deduct(&caller, &items); + assert!(result.is_err(), "expected error for empty batch"); } #[test] -#[should_panic(expected = "amount must be positive")] -fn deduct_negative_panics() { +fn batch_deduct_zero_amount_fails() { let env = Env::default(); let owner = Address::generate(&env); - let contract_id = env.register(CalloraVault, ()); + let caller = 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)); - client.deduct(&owner, &-50); + fund_vault(&usdc_admin, &contract_id, 100); + client.init(&owner, &usdc_token, &Some(100), &None, &None, &None); + + let items = soroban_sdk::vec![ + &env, + DeductItem { + amount: 0, + request_id: None + } + ]; + + let result = client.try_batch_deduct(&caller, &items); + assert!(result.is_err(), "expected error for zero amount"); } #[test] -#[should_panic(expected = "insufficient balance")] -fn deduct_exceeds_balance_panics() { +fn withdraw_reduces_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); env.mock_all_auths(); - client.init(&owner, &Some(50)); - client.deduct(&owner, &100); + fund_vault(&usdc_admin, &contract_id, 500); + client.init(&owner, &usdc_token, &Some(500), &None, &None, &None); + + let remaining = client.withdraw(&200); + assert_eq!(remaining, 300); + assert_eq!(client.balance(), 300); } #[test] -fn test_transfer_ownership() { +fn withdraw_insufficient_balance_fails() { let env = Env::default(); + let owner = 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(); + fund_vault(&usdc_admin, &contract_id, 100); + client.init(&owner, &usdc_token, &Some(100), &None, &None, &None); + let result = client.try_withdraw(&500); + assert!(result.is_err(), "expected error for insufficient balance"); +} + +#[test] +fn withdraw_zero_fails() { + let env = Env::default(); let owner = Address::generate(&env); - let new_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); - client.init(&owner, &Some(100)); + env.mock_all_auths(); + fund_vault(&usdc_admin, &contract_id, 100); + client.init(&owner, &usdc_token, &Some(100), &None, &None, &None); + let result = client.try_withdraw(&0); + assert!(result.is_err(), "expected error for zero amount"); +} env.mock_all_auths(); // transfer ownership via client // Owner authorizes transfer (require_auth in contract) 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"); +#[test] +fn withdraw_to_reduces_balance() { + let env = Env::default(); + let owner = Address::generate(&env); + let recipient = 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(); + fund_vault(&usdc_admin, &contract_id, 500); + client.init(&owner, &usdc_token, &Some(500), &None, &None, &None); - let topics = &transfer_event.1; - let topic_old_owner: Address = topics.get(1).unwrap().into_val(&env); - assert_eq!(topic_old_owner, owner); + let remaining = client.withdraw_to(&recipient, &150); + assert_eq!(remaining, 350); + assert_eq!(client.balance(), 350); +} - let topic_new_owner: Address = topics.get(2).unwrap().into_val(&env); - assert_eq!(topic_new_owner, new_owner); +#[test] +fn withdraw_to_insufficient_balance_fails() { + let env = Env::default(); + let owner = Address::generate(&env); + let recipient = 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(); + fund_vault(&usdc_admin, &contract_id, 100); + client.init(&owner, &usdc_token, &Some(100), &None, &None, &None); + + let result = client.try_withdraw_to(&recipient, &500); + assert!(result.is_err(), "expected error for insufficient balance"); } #[test] +fn deposit_below_minimum_fails() { + let env = Env::default(); + let owner = Address::generate(&env); + let depositor = Address::generate(&env); + let contract_id = env.register(CalloraVault {}, ()); fn allowed_depositor_can_set_price() { let env = Env::default(); let owner = Address::generate(&env); @@ -507,7 +874,21 @@ fn unauthorized_cannot_set_price() { let unauthorized = 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(); + fund_vault(&usdc_admin, &contract_id, 100); + client.init(&owner, &usdc_token, &Some(100), &Some(50), &None, &None); + + fund_vault(&usdc_admin, &depositor, 30); + let usdc_client = token::Client::new(&env, &usdc_token); + usdc_client.approve(&depositor, &contract_id, &30, &1000); + let result = client.try_deposit(&depositor, &30); + assert!(result.is_err(), "expected error for deposit below minimum"); +} +#[test] +fn deposit_at_minimum_succeeds() { client.init(&owner, &Some(100)); let api_id = Symbol::new(&env, "restricted_api"); @@ -604,25 +985,46 @@ fn test_transfer_ownership_same_address_fails() { fn deduct_greater_than_balance_panics() { 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); + let (usdc_token, _, usdc_admin) = create_usdc(&env, &owner); env.mock_all_auths(); + fund_vault(&usdc_admin, &contract_id, 100); + client.init(&owner, &usdc_token, &Some(100), &Some(50), &None, &None); + + fund_vault(&usdc_admin, &depositor, 50); + let usdc_client = token::Client::new(&env, &usdc_token); + usdc_client.approve(&depositor, &contract_id, &50, &1000); + let new_balance = client.deposit(&depositor, &50); + assert_eq!(new_balance, 150); +} + +#[test] +fn double_init_fails() { + let env = Env::default(); + let owner = 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); client.init(&owner, &Some(100)); - // Mock the owner as the invoker env.mock_all_auths(); + fund_vault(&usdc_admin, &contract_id, 100); + client.init(&owner, &usdc_token, &Some(100), &None, &None, &None); - // This should panic with "insufficient balance" - client.deduct(&owner, &101); + let result = client.try_init(&owner, &usdc_token, &Some(200), &None, &None, &None); + assert!(result.is_err(), "expected error for double init"); } #[test] -fn balance_unchanged_after_failed_deduct() { +fn init_insufficient_usdc_balance_fails() { let env = Env::default(); let owner = 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(); // Initialize with balance of 100 @@ -631,7 +1033,14 @@ fn balance_unchanged_after_failed_deduct() { // Mock the owner as the invoker env.mock_all_auths(); + fund_vault(&usdc_admin, &contract_id, 50); + let result = client.try_init(&owner, &usdc_token, &Some(100), &None, &None, &None); + assert!( + result.is_err(), + "expected error when initial_balance exceeds contract USDC" + ); +} env.mock_all_auths(); // This should panic because new_owner is the same as current owner @@ -641,14 +1050,22 @@ fn balance_unchanged_after_failed_deduct() { client.deduct(&owner, &101); })); - // Verify the operation panicked - assert!(result.is_err()); +#[test] +fn init_with_zero_max_deduct_fails() { + let env = Env::default(); + let owner = Address::generate(&env); + let contract_id = env.register(CalloraVault {}, ()); + let client = CalloraVaultClient::new(&env, &contract_id); + let (usdc_token, _, _) = create_usdc(&env, &owner); - // Verify balance is still 100 (unchanged after the failed deduct) - assert_eq!(client.balance(), 100); + env.mock_all_auths(); + + let result = client.try_init(&owner, &usdc_token, &None, &None, &None, &Some(0)); + assert!(result.is_err(), "expected error for max_deduct <= 0"); } #[test] +fn init_with_revenue_pool_and_get_revenue_pool() { #[should_panic(expected = "unauthorized: owner only")] fn unauthorized_cannot_update_metadata() { let env = Env::default(); @@ -890,11 +1307,38 @@ fn multiple_offerings_can_have_metadata() { fn test_transfer_ownership_not_owner() { let env = Env::default(); let owner = Address::generate(&env); - let new_owner = Address::generate(&env); - let contract_id = env.register(CalloraVault, ()); + let revenue_pool = Address::generate(&env); + let contract_id = env.register(CalloraVault {}, ()); let client = CalloraVaultClient::new(&env, &contract_id); + let (usdc_token, _, _) = create_usdc(&env, &owner); env.mock_all_auths(); + client.init( + &owner, + &usdc_token, + &None, + &None, + &Some(revenue_pool.clone()), + &None, + ); + + let retrieved_pool = client.get_revenue_pool(); + assert_eq!(retrieved_pool, Some(revenue_pool)); +} + +#[test] +fn get_revenue_pool_returns_none_when_not_set() { + let env = Env::default(); + let owner = Address::generate(&env); + let contract_id = env.register(CalloraVault {}, ()); + let client = CalloraVaultClient::new(&env, &contract_id); + let (usdc_token, _, _) = create_usdc(&env, &owner); + + env.mock_all_auths(); + client.init(&owner, &usdc_token, &None, &None, &None, &None); + + let retrieved_pool = client.get_revenue_pool(); + assert_eq!(retrieved_pool, None); client.init(&owner, &Some(100)); // No auth for owner — transfer_ownership requires current owner to authorize @@ -903,16 +1347,18 @@ fn test_transfer_ownership_not_owner() { } #[test] -#[should_panic(expected = "vault already initialized")] -fn init_already_initialized_panics() { +fn get_max_deduct_returns_configured_value() { 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); env.mock_all_auths(); - client.init(&owner, &Some(100)); - client.init(&owner, &Some(200)); // Should panic + client.init(&owner, &usdc_token, &None, &None, &None, &Some(5000)); + + let max_deduct = client.get_max_deduct(); + assert_eq!(max_deduct, 5000); } /// Fuzz test: random deposit/deduct sequence asserting balance >= 0 and matches expected. @@ -925,27 +1371,37 @@ 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 (vault_address, vault) = create_vault(&env); + let (usdc_address, usdc_client, usdc_admin) = create_usdc(&env, &owner); let initial_balance: i128 = 1_000; - client.init(&owner, &Some(initial_balance)); - + fund_vault(&usdc_admin, &vault_address, initial_balance); + // Pre-fund owner for deposits in the loop + usdc_admin.mint(&owner, &250_000); + usdc_client.approve(&owner, &vault_address, &250_000, &10_000); + vault.init( + &owner, + &usdc_address, + &Some(initial_balance), + &None, + &None, + &None, + ); let mut expected = initial_balance; let mut rng = rand::thread_rng(); for _ in 0..500 { if rng.gen_bool(0.5) { let amount = rng.gen_range(1..=500); - client.deposit(&owner, &amount); + vault.deposit(&owner, &amount); expected += amount; } else if expected > 0 { let amount = rng.gen_range(1..=expected.min(500)); - client.deduct(&owner, &amount); + vault.deduct(&owner, &amount, &None); expected -= amount; } - let balance = client.balance(); + let balance = vault.balance(); assert!(balance >= 0, "balance went negative: {}", balance); assert_eq!( balance, expected, @@ -954,7 +1410,7 @@ fn fuzz_deposit_and_deduct() { ); } - assert_eq!(client.balance(), expected); + assert_eq!(vault.balance(), expected); } #[test] @@ -963,52 +1419,153 @@ 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); + let (vault_address, vault) = create_vault(&env); + let (usdc_address, _, usdc_admin) = create_usdc(&env, &owner); - client.init(&owner, &Some(100)); - let new_balance = client.deduct(&owner, &30); + fund_vault(&usdc_admin, &vault_address, 100); + 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!(client.balance(), 70); + assert_eq!(vault.balance(), 70); } +/// Fuzz test (seeded): deterministic deposit/deduct sequence asserting balance >= 0 and matches expected. #[test] -fn test_concurrent_deposits() { +fn fuzz_deposit_and_deduct_seeded() { + use rand::rngs::StdRng; + use rand::{Rng, SeedableRng}; + 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); + let (vault_address, vault) = create_vault(&env); + let (usdc_address, usdc_client, usdc_admin) = create_usdc(&env, &owner); - client.init(&owner, &Some(100)); + // Pre-fund owner for deposits in the loop + usdc_admin.mint(&owner, &5_000_000); + usdc_client.approve(&owner, &vault_address, &5_000_000, &10_000); + vault.init(&owner, &usdc_address, &Some(0), &None, &None, &None); + let mut expected: i128 = 0; + let mut rng = StdRng::seed_from_u64(42); - let dep1 = Address::generate(&env); - let dep2 = Address::generate(&env); + for _ in 0..500 { + let action: u8 = rng.gen_range(0..2); - client.set_allowed_depositor(&owner, &Some(dep1.clone())); - client.set_allowed_depositor(&owner, &Some(dep2.clone())); + if action == 0 { + let amount: i128 = rng.gen_range(1..=10_000); + vault.deposit(&owner, &amount); + expected += amount; + } else if expected > 0 { + let amount: i128 = rng.gen_range(1..=expected); + vault.deduct(&owner, &amount, &None); + expected -= amount; + } - // Concurrent deposits - client.deposit(&dep1, &200); - client.deposit(&dep2, &300); + assert!(expected >= 0, "balance went negative"); + assert_eq!(vault.balance(), expected, "balance mismatch at iteration"); + } +} - assert_eq!(client.balance(), 600); +#[test] +fn batch_deduct_all_succeed() { + let env = Env::default(); + let owner = Address::generate(&env); + let contract_id = env.register(CalloraVault {}, ()); + let client = CalloraVaultClient::new(&env, &contract_id); + let (usdc_address, _, usdc_admin) = create_usdc(&env, &owner); + + env.mock_all_auths(); + fund_vault(&usdc_admin, &contract_id, 60); + client.init(&owner, &usdc_address, &Some(60), &None, &None, &None); + let items = soroban_sdk::vec![ + &env, + DeductItem { + amount: 10, + request_id: None, + }, + DeductItem { + amount: 20, + request_id: None, + }, + DeductItem { + amount: 30, + request_id: None, + }, + ]; + let caller = Address::generate(&env); + env.mock_all_auths(); + let new_balance = client.batch_deduct(&caller, &items); + assert_eq!(new_balance, 0); + assert_eq!(client.balance(), 0); +} + +#[test] +#[should_panic(expected = "insufficient balance")] +fn batch_deduct_all_revert() { + let env = Env::default(); + + let owner = Address::generate(&env); + let contract_id = env.register(CalloraVault, ()); + let client = CalloraVaultClient::new(&env, &contract_id); + let (usdc_address, _, usdc_admin) = create_usdc(&env, &owner); + + env.mock_all_auths(); + fund_vault(&usdc_admin, &contract_id, 25); + client.init(&owner, &usdc_address, &Some(25), &None, &None, &None); + assert_eq!(client.balance(), 25); + let items = soroban_sdk::vec![ + &env, + DeductItem { + amount: 10, + request_id: None, + }, + DeductItem { + amount: 20, + request_id: None, + }, + DeductItem { + amount: 30, + request_id: None, + }, + ]; + let caller = Address::generate(&env); + env.mock_all_auths(); + client.batch_deduct(&caller, &items); } #[test] -fn init_twice_panics_on_reinit() { +fn batch_deduct_revert_preserves_balance() { let env = Env::default(); let owner = Address::generate(&env); let contract_id = env.register(CalloraVault, ()); let client = CalloraVaultClient::new(&env, &contract_id); + let (usdc_address, _, usdc_admin) = create_usdc(&env, &owner); env.mock_all_auths(); - client.init(&owner, &Some(25)); + fund_vault(&usdc_admin, &contract_id, 25); + client.init(&owner, &usdc_address, &Some(25), &None, &None, &None); assert_eq!(client.balance(), 25); + let items = soroban_sdk::vec![ + &env, + DeductItem { + amount: 10, + request_id: None, + }, + DeductItem { + amount: 20, + request_id: None, + }, + DeductItem { + amount: 30, + request_id: None, + }, + ]; + let caller = Address::generate(&env); + env.mock_all_auths(); let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - client.init(&owner, &Some(50)); + client.batch_deduct(&caller, &items); })); assert!(result.is_err()); @@ -1032,18 +1589,247 @@ fn test_deduct_when_paused_panics() { #[test] fn owner_unchanged_after_deposit_and_deduct() { 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); + let (usdc_address, usdc_client, usdc_admin) = create_usdc(&env, &owner); env.mock_all_auths(); - client.init(&owner, &Some(100)); + fund_vault(&usdc_admin, &contract_id, 100); + // Fund owner for the deposit call + usdc_admin.mint(&owner, &50); + 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); + client.deduct(&owner, &30, &None); + assert_eq!(client.get_meta().owner, owner); } #[test] +fn batch_deduct_exceeds_max_deduct_fails() { + let env = Env::default(); + let owner = Address::generate(&env); + let caller = 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(); + fund_vault(&usdc_admin, &contract_id, 1000); + client.init(&owner, &usdc_token, &Some(1000), &None, &None, &Some(50)); + + let items = soroban_sdk::vec![ + &env, + DeductItem { + amount: 100, + request_id: None, + }, + ]; + + let result = client.try_batch_deduct(&caller, &items); + assert!( + result.is_err(), + "expected error for amount exceeding max_deduct" + ); +} + +// --------------------------------------------------------------------------- +// large balance and large deduct +// --------------------------------------------------------------------------- + +#[test] +fn large_balance_init_and_deduct() { + let env = Env::default(); + let owner = Address::generate(&env); + let caller = 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(); + + let large_balance: i128 = i128::MAX / 2; + fund_vault(&usdc_admin, &contract_id, large_balance); + client.init( + &owner, + &usdc_token, + &Some(large_balance), + &None, + &None, + &None, + ); + assert_eq!(client.balance(), large_balance); + + let deduct_amount: i128 = i128::MAX / 4; + let remaining = client.deduct(&caller, &deduct_amount, &None); + let expected = large_balance - deduct_amount; + assert_eq!(remaining, expected); + assert_eq!(client.balance(), expected); +} + +#[test] +fn large_balance_deduct_entire_balance() { + let env = Env::default(); + let owner = Address::generate(&env); + let caller = 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(); + + let large_balance: i128 = i128::MAX; + fund_vault(&usdc_admin, &contract_id, large_balance); + client.init( + &owner, + &usdc_token, + &Some(large_balance), + &None, + &None, + &None, + ); + assert_eq!(client.balance(), large_balance); + + let remaining = client.deduct(&caller, &large_balance, &None); + assert_eq!(remaining, 0); + assert_eq!(client.balance(), 0); +} + +#[test] +fn large_balance_sequential_deducts() { + let env = Env::default(); + env.mock_all_auths(); + + let owner = Address::generate(&env); + let caller = 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(); + + let large_balance: i128 = 1_000_000_000_000_000_000; + fund_vault(&usdc_admin, &contract_id, large_balance); + client.init( + &owner, + &usdc_token, + &Some(large_balance), + &None, + &None, + &None, + ); + + let first = client.deduct(&caller, &400_000_000_000_000_000, &None); + assert_eq!(first, 600_000_000_000_000_000); + + let second = client.deduct(&caller, &600_000_000_000_000_000, &None); + assert_eq!(second, 0); + assert_eq!(client.balance(), 0); +} + +#[test] +fn large_batch_deduct_correctness() { + let env = Env::default(); + env.mock_all_auths(); + + let owner = Address::generate(&env); + let caller = 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(); + + let large_balance: i128 = i128::MAX / 2; + fund_vault(&usdc_admin, &contract_id, large_balance); + client.init( + &owner, + &usdc_token, + &Some(large_balance), + &None, + &None, + &None, + ); + + let chunk = large_balance / 3; + let items = soroban_sdk::vec![ + &env, + DeductItem { + amount: chunk, + request_id: None + }, + DeductItem { + amount: chunk, + request_id: None + }, + DeductItem { + amount: chunk, + request_id: None + }, + ]; + + let remaining = client.batch_deduct(&caller, &items); + let expected = large_balance - (chunk * 3); + assert_eq!(remaining, expected); + assert_eq!(client.balance(), expected); +} + +#[test] +fn deposit_overflow_panics() { + 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); + let (usdc_token, _, usdc_admin) = create_usdc(&env, &owner); + + env.mock_all_auths(); + + let near_max: i128 = i128::MAX - 10; + fund_vault(&usdc_admin, &contract_id, near_max); + client.init(&owner, &usdc_token, &Some(near_max), &None, &None, &None); + + fund_vault(&usdc_admin, &depositor, 100); + let usdc_client = token::Client::new(&env, &usdc_token); + usdc_client.approve(&depositor, &contract_id, &100, &1000); + let result = client.try_deposit(&depositor, &100); + assert!( + result.is_err(), + "expected overflow on deposit exceeding i128::MAX" + ); +} + +#[test] +fn large_deduct_exceeding_balance_fails() { + let env = Env::default(); + let owner = Address::generate(&env); + let caller = 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(); + + let large_balance: i128 = i128::MAX / 2; + fund_vault(&usdc_admin, &contract_id, large_balance); + client.init( + &owner, + &usdc_token, + &Some(large_balance), + &None, + &None, + &None, + ); + + let result = client.try_deduct(&caller, &(large_balance + 1), &None); + assert!( + result.is_err(), + "expected error when deducting more than large balance" + ); + assert_eq!(client.balance(), large_balance); #[should_panic] fn init_unauthorized_owner_panics() { let env = Env::default(); diff --git a/coverage/cobertura.xml b/coverage/cobertura.xml new file mode 100644 index 0000000..19af539 --- /dev/null +++ b/coverage/cobertura.xml @@ -0,0 +1 @@ +/home/jeffersonyouashi/Documents/DRIPS/Callora-Contracts \ No newline at end of file diff --git a/coverage/tarpaulin-report.html b/coverage/tarpaulin-report.html new file mode 100644 index 0000000..0cb9653 --- /dev/null +++ b/coverage/tarpaulin-report.html @@ -0,0 +1,794 @@ + + + + + + + +
+ + + + + + \ No newline at end of file diff --git a/scripts/coverage.sh b/scripts/coverage.sh new file mode 100755 index 0000000..a1328c4 --- /dev/null +++ b/scripts/coverage.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# scripts/coverage.sh +# +# Generate a test coverage report for the Callora Contracts workspace using +# cargo-tarpaulin and enforce a minimum of 95 % line coverage. +# +# Usage +# ----- +# ./scripts/coverage.sh # run from the workspace root +# +# First-time setup +# ---------------- +# The script installs cargo-tarpaulin automatically if it is not found. +# You only need a working Rust / Cargo toolchain (stable). +# +# Output +# ------ +# coverage/tarpaulin-report.html – interactive per-file report +# coverage/cobertura.xml – Cobertura XML (consumed by CI) +# Stdout summary printed at end of run + +set -euo pipefail + +# --------------------------------------------------------------------------- +# Configuration — keep in sync with tarpaulin.toml +# --------------------------------------------------------------------------- +MINIMUM_COVERAGE=95 +COVERAGE_DIR="coverage" +TARPAULIN_VERSION="0.31" # minimum version; any newer release also works + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +info() { echo -e " \033[1;34m[INFO]\033[0m $*"; } +success() { echo -e " \033[1;32m[PASS]\033[0m $*"; } +error() { echo -e " \033[1;31m[FAIL]\033[0m $*" >&2; } + +# --------------------------------------------------------------------------- +# Make sure we run from the workspace root (directory containing Cargo.toml) +# --------------------------------------------------------------------------- +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "${SCRIPT_DIR}/.." + +if [[ ! -f "Cargo.toml" ]]; then + error "Could not locate workspace Cargo.toml. Run this script from the repo root." + exit 1 +fi + +# --------------------------------------------------------------------------- +# Install cargo-tarpaulin if missing +# --------------------------------------------------------------------------- +if ! cargo tarpaulin --version &>/dev/null 2>&1; then + info "cargo-tarpaulin not found — installing (this happens once)..." + cargo install cargo-tarpaulin --version "^${TARPAULIN_VERSION}" --locked + success "cargo-tarpaulin installed." +else + INSTALLED=$(cargo tarpaulin --version 2>&1 | head -1) + info "Using ${INSTALLED}" +fi + +# --------------------------------------------------------------------------- +# Prepare output directory +# --------------------------------------------------------------------------- +mkdir -p "${COVERAGE_DIR}" + +# --------------------------------------------------------------------------- +# Run coverage +# tarpaulin.toml in the workspace root carries the full configuration; +# flags below match it so the script can also be run without the config file. +# --------------------------------------------------------------------------- +info "Running tests with coverage instrumentation..." +echo "" + +cargo tarpaulin \ + --config tarpaulin.toml + +echo "" + +# --------------------------------------------------------------------------- +# Friendly reminder of where to find the reports +# --------------------------------------------------------------------------- +success "Coverage run complete." +echo "" +echo " Reports written to ./${COVERAGE_DIR}/" +echo " HTML → ./${COVERAGE_DIR}/tarpaulin-report.html" +echo " XML → ./${COVERAGE_DIR}/cobertura.xml" +echo "" +echo " Open the HTML report in a browser:" +echo " xdg-open ./${COVERAGE_DIR}/tarpaulin-report.html # Linux" +echo " open ./${COVERAGE_DIR}/tarpaulin-report.html # macOS" +echo "" +echo " Minimum enforced: ${MINIMUM_COVERAGE}%" +echo " (non-zero exit from tarpaulin means coverage fell below the threshold)" diff --git a/tarpaulin.toml b/tarpaulin.toml new file mode 100644 index 0000000..ca9ac8c --- /dev/null +++ b/tarpaulin.toml @@ -0,0 +1,33 @@ +# cargo-tarpaulin configuration for Callora Contracts +# https://github.com/xd009642/tarpaulin +# +# Run locally: ./scripts/coverage.sh +# Run directly: cargo tarpaulin +# +# The `lib` flag avoids linker errors that arise from the `cdylib` crate-type +# required by Soroban — tests are still compiled and executed in full. + +[coverage] +# Hard-fail the command (non-zero exit) when coverage drops below this. +fail-under = 95.0 + +# Emit three report formats at once: +# Stdout – instant human-readable summary in the terminal +# Html – interactive per-file report; open coverage/tarpaulin-report.html +# Xml – Cobertura-compatible; consumed by the GitHub Actions PR comment +out = ["Stdout", "Html", "Xml"] +output-dir = "coverage" + +# Measure every crate in the workspace (currently just callora-vault). +workspace = true + +# Compile and instrument the library target only. +# Soroban contracts declare crate-type = ["cdylib", "rlib"]; instrumenting the +# cdylib target causes linker failures during coverage builds. +lib = true + +# Give each test binary a generous window before tarpaulin kills it. +timeout = "120s" + +# Exclude auto-generated or build-only source files from the line counts. +exclude-files = ["*/build.rs", "hidden/*"]