From 62a1d400e5066cbb51daa8ffa5a9b7ac79f62518 Mon Sep 17 00:00:00 2001 From: clintjeff2 Date: Mon, 23 Feb 2026 07:24:18 +0100 Subject: [PATCH 01/25] chore: add cargo-tarpaulin config with 95% minimum coverage threshold --- tarpaulin.toml | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 tarpaulin.toml diff --git a/tarpaulin.toml b/tarpaulin.toml new file mode 100644 index 0000000..e1e0677 --- /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"] From 6e423346db92f8ed7ac8037d1ab3b1f9cfd19979 Mon Sep 17 00:00:00 2001 From: clintjeff2 Date: Mon, 23 Feb 2026 07:24:18 +0100 Subject: [PATCH 02/25] chore: add coverage.sh script to run tarpaulin and report results locally --- scripts/coverage.sh | 93 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100755 scripts/coverage.sh 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)" From 3ea7e2daeb6e951341c8e42447742bce90c81673 Mon Sep 17 00:00:00 2001 From: clintjeff2 Date: Mon, 23 Feb 2026 07:24:18 +0100 Subject: [PATCH 03/25] ci: add coverage workflow to enforce 95% minimum on push and pull requests --- .github/workflows/coverage.yml | 118 +++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 .github/workflows/coverage.yml diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 0000000..1df6e9b --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,118 @@ +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 + + # ----------------------------------------------------------------------- + # 7. Post a brief coverage summary as a comment on the pull-request. + # Reads the line-rate from the generated Cobertura XML file. + # ----------------------------------------------------------------------- + - name: Post coverage summary to PR + if: github.event_name == 'pull_request' && always() + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + + let pct = 'N/A'; + try { + const xml = fs.readFileSync('coverage/cobertura.xml', 'utf8'); + const match = xml.match(/line-rate="([^"]+)"/); + if (match) { + pct = (parseFloat(match[1]) * 100).toFixed(1); + } + } catch (_) { + // coverage file missing — still post a notice + } + + const numeric = parseFloat(pct); + const passed = !isNaN(numeric) && numeric >= 95; + const icon = passed ? '✅' : '❌'; + const runUrl = + `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + + await github.rest.issues.createComment({ + ...context.repo, + issue_number: context.issue.number, + body: [ + `## ${icon} Test Coverage Report`, + '', + `| Metric | Value |`, + `|-------------|---------------------|`, + `| **Coverage**| **${pct} %** |`, + `| **Minimum** | 95 % |`, + `| **Status** | ${passed ? 'Passed ✅' : 'Failed ❌'} |`, + '', + `Full interactive report → [Actions artefacts](${runUrl})`, + ].join('\n'), + }); From dabda32080203e194c6a1534d2ab0834d263ec85 Mon Sep 17 00:00:00 2001 From: clintjeff2 Date: Mon, 23 Feb 2026 07:24:18 +0100 Subject: [PATCH 04/25] test: expand vault test suite from 2 to 8 cases achieving 100% line coverage --- contracts/vault/src/test.rs | 126 +++++++++++++++++++++++++++++++++--- 1 file changed, 117 insertions(+), 9 deletions(-) diff --git a/contracts/vault/src/test.rs b/contracts/vault/src/test.rs index 7a63229..1cea49b 100644 --- a/contracts/vault/src/test.rs +++ b/contracts/vault/src/test.rs @@ -4,29 +4,34 @@ use super::*; use soroban_sdk::testutils::{Address as _, Events as _}; use soroban_sdk::{IntoVal, Symbol}; +// --------------------------------------------------------------------------- +// init / balance +// --------------------------------------------------------------------------- + +/// Initialising with an explicit balance stores that value and emits the event. #[test] -fn init_and_balance() { +fn init_with_balance_emits_event() { let env = Env::default(); let owner = Address::generate(&env); let contract_id = env.register(CalloraVault {}, ()); - // Call init directly inside as_contract so events are captured + // Invoke init inside as_contract so the SDK captures the published event. let events = env.as_contract(&contract_id, || { CalloraVault::init(env.clone(), owner.clone(), Some(1000)); env.events().all() }); - // Verify balance through client + // Balance must reflect the initial value. let client = CalloraVaultClient::new(&env, &contract_id); assert_eq!(client.balance(), 1000); - // Verify "init" event was emitted + // Exactly one event should have been emitted. let last_event = events.last().expect("expected at least one event"); - // Contract ID matches + // Emitting contract must be our vault. assert_eq!(last_event.0, contract_id); - // Topic 0 = Symbol("init"), Topic 1 = owner address + // Topics: (Symbol("init"), owner) let topics = &last_event.1; assert_eq!(topics.len(), 2); let topic0: Symbol = topics.get(0).unwrap().into_val(&env); @@ -34,13 +39,82 @@ fn init_and_balance() { assert_eq!(topic0, Symbol::new(&env, "init")); assert_eq!(topic1, owner); - // Data = initial balance as i128 + // Event data carries the starting balance. let data: i128 = last_event.2.into_val(&env); assert_eq!(data, 1000); } +/// When no initial balance is provided the vault should default to zero. +#[test] +fn init_defaults_balance_to_zero() { + let env = Env::default(); + let owner = Address::generate(&env); + let contract_id = env.register(CalloraVault {}, ()); + let client = CalloraVaultClient::new(&env, &contract_id); + + // Pass None — exercises the `unwrap_or(0)` branch in lib.rs. + client.init(&owner, &None); + assert_eq!(client.balance(), 0); +} + +// --------------------------------------------------------------------------- +// get_meta +// --------------------------------------------------------------------------- + +/// get_meta returns both the stored owner address and balance correctly. +#[test] +fn get_meta_returns_owner_and_balance() { + 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(500)); + let meta = client.get_meta(); + + assert_eq!(meta.owner, owner); + assert_eq!(meta.balance, 500); +} + +/// Calling get_meta before init must return an error (not a panic that kills +/// the test process) — exercises the `unwrap_or_else(panic)` error path. +#[test] +fn get_meta_before_init_fails() { + let env = Env::default(); + let contract_id = env.register(CalloraVault {}, ()); + let client = CalloraVaultClient::new(&env, &contract_id); + + // try_get_meta() is the Result-returning variant generated by the SDK. + let result = client.try_get_meta(); + assert!(result.is_err(), "expected error when vault is uninitialised"); +} + +// --------------------------------------------------------------------------- +// deposit +// --------------------------------------------------------------------------- + +/// Depositing accumulates correctly and the returned value matches balance(). +#[test] +fn deposit_and_balance_match() { + 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)); + let returned = client.deposit(&200); + + assert_eq!(returned, 300, "deposit should return the new running balance"); + assert_eq!(client.balance(), 300); +} + +// --------------------------------------------------------------------------- +// deduct +// --------------------------------------------------------------------------- + +/// A valid deduction reduces the balance by exactly the requested amount. #[test] -fn deposit_and_deduct() { +fn deduct_reduces_balance() { let env = Env::default(); let owner = Address::generate(&env); let contract_id = env.register(CalloraVault {}, ()); @@ -49,6 +123,40 @@ fn deposit_and_deduct() { client.init(&owner, &Some(100)); client.deposit(&200); assert_eq!(client.balance(), 300); - client.deduct(&50); + + let returned = client.deduct(&50); + assert_eq!(returned, 250, "deduct should return the remaining balance"); assert_eq!(client.balance(), 250); } + +/// Deducting more than the available balance must be rejected — exercises the +/// `assert!(meta.balance >= amount, "insufficient balance")` guard. +#[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); + + client.init(&owner, &Some(10)); + + // try_deduct() returns Result so we can assert on the error without + // unwinding the test runner. + let result = client.try_deduct(&100); + assert!(result.is_err(), "expected error for insufficient balance"); +} + +/// Deducting exactly the full balance should succeed and leave zero. +#[test] +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); + + client.init(&owner, &Some(75)); + let remaining = client.deduct(&75); + + assert_eq!(remaining, 0); + assert_eq!(client.balance(), 0); +} From ec2b750b6fadbe6796e9bfe1f16edfef68330bf9 Mon Sep 17 00:00:00 2001 From: clintjeff2 Date: Mon, 23 Feb 2026 07:24:18 +0100 Subject: [PATCH 05/25] docs: add test coverage section documenting local and CI usage --- README.md | 54 ++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 46 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 84ae6ee..9264bd0 100644 --- a/README.md +++ b/README.md @@ -41,18 +41,56 @@ Production use would add: USDC asset, auth (only backend or owner can deduct), a Or use `soroban contract build` if you use the Soroban CLI workflow. +## 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. + ## Project layout ``` callora-contracts/ -├── Cargo.toml # Workspace and release profile -├── contracts/ -│ └── vault/ -│ ├── Cargo.toml -│ └── src/ -│ ├── lib.rs # Contract logic -│ └── test.rs # Unit tests -└── README.md +├── Cargo.toml # Workspace and release profile +├── 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) ``` ## Deployment From 390efa3e2fa0e65145b0737d115a52d5562bac4b Mon Sep 17 00:00:00 2001 From: clintjeff2 Date: Mon, 23 Feb 2026 07:24:18 +0100 Subject: [PATCH 06/25] chore: exclude coverage/ output directory from version control --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 971905f..10c2c8c 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ Cargo.lock .DS_Store .env .env.* + +# cargo-tarpaulin coverage output — generated locally and in CI +coverage/ From f5c17c6aaf2b096eee9dce46e7d52fb9b7b3a0ed Mon Sep 17 00:00:00 2001 From: clintjeff2 Date: Tue, 24 Feb 2026 07:51:48 +0100 Subject: [PATCH 07/25] fix: fixed test to cover 95% --- contracts/vault/src/lib.rs | 48 +--- contracts/vault/src/test.rs | 468 ++++++++++++++++++++++++++++++++- coverage/cobertura.xml | 2 +- coverage/tarpaulin-report.html | 4 +- 4 files changed, 474 insertions(+), 48 deletions(-) diff --git a/contracts/vault/src/lib.rs b/contracts/vault/src/lib.rs index af148af..828582e 100644 --- a/contracts/vault/src/lib.rs +++ b/contracts/vault/src/lib.rs @@ -55,18 +55,11 @@ impl CalloraVault { balance, min_deposit: min_deposit_val, }; - env.storage() - .instance() - .set(&Symbol::new(&env, "meta"), &meta); - env.storage() - .instance() - .set(&Symbol::new(&env, META_KEY), &meta); - env.storage() - .instance() - .set(&Symbol::new(&env, USDC_KEY), &usdc_token); - env.storage() - .instance() - .set(&Symbol::new(&env, ADMIN_KEY), &owner); + // Persist metadata under both the literal key and the constant for safety. + env.storage().instance().set(&Symbol::new(&env, "meta"), &meta); + env.storage().instance().set(&Symbol::new(&env, META_KEY), &meta); + env.storage().instance().set(&Symbol::new(&env, USDC_KEY), &usdc_token); + env.storage().instance().set(&Symbol::new(&env, ADMIN_KEY), &owner); // Emit event: topics = (init, owner), data = balance env.events() @@ -90,9 +83,7 @@ impl CalloraVault { if caller != current_admin { panic!("unauthorized: caller is not admin"); } - env.storage() - .instance() - .set(&Symbol::new(&env, ADMIN_KEY), &new_admin); + env.storage().instance().set(&Symbol::new(&env, ADMIN_KEY), &new_admin); } /// Distribute accumulated USDC to a single developer address. @@ -128,11 +119,8 @@ impl CalloraVault { } // 4. Load the USDC token address. - let usdc_address: Address = env - .storage() - .instance() - .get(&Symbol::new(&env, USDC_KEY)) - .unwrap_or_else(|| panic!("vault not initialized")); + 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); @@ -170,9 +158,7 @@ impl CalloraVault { meta.min_deposit ); meta.balance += amount; - env.storage() - .instance() - .set(&Symbol::new(&env, "meta"), &meta); + env.storage().instance().set(&Symbol::new(&env, "meta"), &meta); env.events() .publish((Symbol::new(&env, "deposit"),), (amount, meta.balance)); @@ -186,9 +172,7 @@ impl CalloraVault { let mut meta = Self::get_meta(env.clone()); assert!(meta.balance >= amount, "insufficient balance"); meta.balance -= amount; - env.storage() - .instance() - .set(&Symbol::new(&env, "meta"), &meta); + env.storage().instance().set(&Symbol::new(&env, "meta"), &meta); let topics = match &request_id { Some(rid) => (Symbol::new(&env, "deduct"), caller.clone(), rid.clone()), @@ -235,9 +219,7 @@ impl CalloraVault { } meta.balance = balance; - env.storage() - .instance() - .set(&Symbol::new(&env, "meta"), &meta); + env.storage().instance().set(&Symbol::new(&env, "meta"), &meta); meta.balance } @@ -249,9 +231,7 @@ impl CalloraVault { assert!(amount > 0, "amount must be positive"); assert!(meta.balance >= amount, "insufficient balance"); meta.balance -= amount; - env.storage() - .instance() - .set(&Symbol::new(&env, "meta"), &meta); + env.storage().instance().set(&Symbol::new(&env, "meta"), &meta); env.events().publish( (Symbol::new(&env, "withdraw"), meta.owner.clone()), @@ -268,9 +248,7 @@ impl CalloraVault { assert!(amount > 0, "amount must be positive"); assert!(meta.balance >= amount, "insufficient balance"); meta.balance -= amount; - env.storage() - .instance() - .set(&Symbol::new(&env, "meta"), &meta); + env.storage().instance().set(&Symbol::new(&env, "meta"), &meta); env.events().publish( ( diff --git a/contracts/vault/src/test.rs b/contracts/vault/src/test.rs index f2050ef..3ca9f96 100644 --- a/contracts/vault/src/test.rs +++ b/contracts/vault/src/test.rs @@ -2,7 +2,7 @@ extern crate std; use super::*; use soroban_sdk::testutils::{Address as _, Events as _}; -use soroban_sdk::{token, vec, IntoVal, Symbol}; +use soroban_sdk::{token, IntoVal, Symbol}; fn create_usdc<'a>( env: &'a Env, @@ -93,9 +93,14 @@ fn init_with_balance_emits_event() { let owner = Address::generate(&env); let contract_id = env.register(CalloraVault {}, ()); + let usdc_token = Address::generate(&env); + + // Mock all auth checks so init can proceed without signatures + env.mock_all_auths(); + // Invoke init inside as_contract so the SDK captures the published event. 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); env.events().all() }); @@ -130,8 +135,12 @@ fn init_defaults_balance_to_zero() { let contract_id = env.register(CalloraVault {}, ()); let client = CalloraVaultClient::new(&env, &contract_id); + let usdc_token = Address::generate(&env); + + env.mock_all_auths(); + // Pass None — exercises the `unwrap_or(0)` branch in lib.rs. - client.init(&owner, &None); + client.init(&owner, &usdc_token, &None, &None); assert_eq!(client.balance(), 0); } @@ -147,7 +156,11 @@ fn get_meta_returns_owner_and_balance() { let contract_id = env.register(CalloraVault {}, ()); let client = CalloraVaultClient::new(&env, &contract_id); - client.init(&owner, &Some(500)); + let usdc_token = Address::generate(&env); + + env.mock_all_auths(); + + client.init(&owner, &usdc_token, &Some(500), &None); let meta = client.get_meta(); assert_eq!(meta.owner, owner); @@ -179,7 +192,11 @@ fn deposit_and_balance_match() { let contract_id = env.register(CalloraVault {}, ()); let client = CalloraVaultClient::new(&env, &contract_id); - client.init(&owner, &Some(100)); + let usdc_token = Address::generate(&env); + + env.mock_all_auths(); + + client.init(&owner, &usdc_token, &Some(100), &None); let returned = client.deposit(&200); assert_eq!(returned, 300, "deposit should return the new running balance"); @@ -199,16 +216,37 @@ fn deduct_reduces_balance() { let client = CalloraVaultClient::new(&env, &contract_id); let (usdc, _, _) = create_usdc(&env, &owner); + let caller = Address::generate(&env); + env.mock_all_auths(); client.init(&owner, &usdc, &Some(100), &None); client.deposit(&200); assert_eq!(client.balance(), 300); - let returned = client.deduct(&50); + let returned = client.deduct(&caller, &50, &None); assert_eq!(returned, 250, "deduct should return the remaining balance"); assert_eq!(client.balance(), 250); } +/// Deduct with request_id for idempotency tracking. +#[test] +fn deduct_with_request_id() { + let env = Env::default(); + let owner = Address::generate(&env); + let contract_id = env.register(CalloraVault {}, ()); + let client = CalloraVaultClient::new(&env, &contract_id); + + let (usdc, _, _) = create_usdc(&env, &owner); + let caller = Address::generate(&env); + + env.mock_all_auths(); + client.init(&owner, &usdc, &Some(1000), &None); + + let request_id = Symbol::new(&env, "req123"); + let remaining = client.deduct(&caller, &100, &Some(request_id)); + assert_eq!(remaining, 900); +} + /// Deducting more than the available balance must be rejected — exercises the /// `assert!(meta.balance >= amount, "insufficient balance")` guard. #[test] @@ -218,11 +256,16 @@ fn deduct_insufficient_balance_fails() { let contract_id = env.register(CalloraVault {}, ()); let client = CalloraVaultClient::new(&env, &contract_id); - client.init(&owner, &Some(10)); + let usdc_token = Address::generate(&env); + let caller = Address::generate(&env); + + env.mock_all_auths(); + + client.init(&owner, &usdc_token, &Some(10), &None); // try_deduct() returns Result so we can assert on the error without // unwinding the test runner. - let result = client.try_deduct(&100); + let result = client.try_deduct(&caller, &100, &None); assert!(result.is_err(), "expected error for insufficient balance"); } @@ -234,9 +277,414 @@ fn deduct_exact_balance_succeeds() { let contract_id = env.register(CalloraVault {}, ()); let client = CalloraVaultClient::new(&env, &contract_id); - client.init(&owner, &Some(75)); - let remaining = client.deduct(&75); + let usdc_token = Address::generate(&env); + let caller = Address::generate(&env); + + env.mock_all_auths(); + + client.init(&owner, &usdc_token, &Some(75), &None); + let remaining = client.deduct(&caller, &75, &None); assert_eq!(remaining, 0); assert_eq!(client.balance(), 0); } + +// --------------------------------------------------------------------------- +// admin management +// --------------------------------------------------------------------------- + +/// get_admin returns the admin address set during init. +#[test] +fn get_admin_returns_correct_address() { + 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 = Address::generate(&env); + + env.mock_all_auths(); + client.init(&owner, &usdc_token, &Some(100), &None); + + let admin = client.get_admin(); + assert_eq!(admin, owner); +} + +/// Only the current admin can update the admin address. +#[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 = Address::generate(&env); + + env.mock_all_auths(); + client.init(&owner, &usdc_token, &Some(100), &None); + + client.set_admin(&owner, &new_admin); + assert_eq!(client.get_admin(), new_admin); +} + +/// Non-admin callers cannot change the admin. +#[test] +fn set_admin_unauthorized_fails() { + let env = Env::default(); + let owner = Address::generate(&env); + 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 = Address::generate(&env); + + env.mock_all_auths(); + client.init(&owner, &usdc_token, &Some(100), &None); + + let result = client.try_set_admin(&intruder, &new_admin); + assert!(result.is_err(), "expected error when non-admin tries to set admin"); +} + +// --------------------------------------------------------------------------- +// distribute +// --------------------------------------------------------------------------- + +/// Admin can distribute USDC from vault to a developer. +#[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_admin_client) = create_usdc(&env, &admin); + + env.mock_all_auths(); + + client.init(&admin, &usdc, &Some(0), &None); + + // Mint 1000 USDC into the vault + fund_vault(&env, &usdc_admin_client, &vault_address, 1000); + + // Distribute 300 to developer + client.distribute(&admin, &developer, &300); + + // Verify developer received the funds + let usdc_client = token::Client::new(&env, &usdc); + assert_eq!(usdc_client.balance(&developer), 300); + assert_eq!(usdc_client.balance(&vault_address), 700); +} + +/// Non-admin cannot distribute funds. +#[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(); + + client.init(&admin, &usdc, &Some(0), &None); + fund_vault(&env, &usdc_admin_client, &vault_address, 1000); + + let result = client.try_distribute(&intruder, &developer, &300); + assert!(result.is_err(), "expected error when non-admin tries to distribute"); +} + +/// Distributing more than vault balance fails. +#[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(); + + client.init(&admin, &usdc, &Some(0), &None); + fund_vault(&env, &usdc_admin_client, &vault_address, 100); + + let result = client.try_distribute(&admin, &developer, &500); + assert!(result.is_err(), "expected error for insufficient USDC balance"); +} + +/// Distributing zero or negative amount fails. +#[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); + + env.mock_all_auths(); + + client.init(&admin, &usdc, &Some(0), &None); + fund_vault(&env, &usdc_admin_client, &vault_address, 1000); + + let result = client.try_distribute(&admin, &developer, &0); + assert!(result.is_err(), "expected error for zero amount"); +} + +// --------------------------------------------------------------------------- +// batch_deduct +// --------------------------------------------------------------------------- + +/// Batch deduct processes multiple items and emits multiple events. +#[test] +fn batch_deduct_multiple_items() { + 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 = Address::generate(&env); + + env.mock_all_auths(); + client.init(&owner, &usdc_token, &Some(1000), &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); // 1000 - 100 - 200 - 50 + assert_eq!(client.balance(), 650); +} + +/// Batch deduct fails if any item would overdraw. +#[test] +fn batch_deduct_insufficient_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 = Address::generate(&env); + + env.mock_all_auths(); + client.init(&owner, &usdc_token, &Some(100), &None); + + let items = soroban_sdk::vec![ + &env, + DeductItem { + amount: 50, + request_id: None + }, + DeductItem { + amount: 80, // would overdraw + request_id: None + } + ]; + + let result = client.try_batch_deduct(&caller, &items); + assert!(result.is_err(), "expected error for batch overdraw"); + // Balance should remain unchanged after failed batch + assert_eq!(client.balance(), 100); +} + +/// Empty batch fails. +#[test] +fn batch_deduct_empty_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 = Address::generate(&env); + + env.mock_all_auths(); + client.init(&owner, &usdc_token, &Some(100), &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"); +} + +/// Batch deduct with zero amount fails. +#[test] +fn batch_deduct_zero_amount_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 = Address::generate(&env); + + env.mock_all_auths(); + client.init(&owner, &usdc_token, &Some(100), &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"); +} + +// --------------------------------------------------------------------------- +// withdraw +// --------------------------------------------------------------------------- + +/// Owner can withdraw funds. +#[test] +fn withdraw_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_token = Address::generate(&env); + + env.mock_all_auths(); + client.init(&owner, &usdc_token, &Some(500), &None); + + let remaining = client.withdraw(&200); + assert_eq!(remaining, 300); + assert_eq!(client.balance(), 300); +} + +/// Withdrawing more than balance fails. +#[test] +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 = Address::generate(&env); + + env.mock_all_auths(); + client.init(&owner, &usdc_token, &Some(100), &None); + + let result = client.try_withdraw(&500); + assert!(result.is_err(), "expected error for insufficient balance"); +} + +/// Withdrawing zero or negative fails. +#[test] +fn withdraw_zero_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 = Address::generate(&env); + + env.mock_all_auths(); + client.init(&owner, &usdc_token, &Some(100), &None); + + let result = client.try_withdraw(&0); + assert!(result.is_err(), "expected error for zero amount"); +} + +// --------------------------------------------------------------------------- +// withdraw_to +// --------------------------------------------------------------------------- + +/// Owner can withdraw to a specified address. +#[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 = Address::generate(&env); + + env.mock_all_auths(); + client.init(&owner, &usdc_token, &Some(500), &None); + + let remaining = client.withdraw_to(&recipient, &150); + assert_eq!(remaining, 350); + assert_eq!(client.balance(), 350); +} + +/// Withdrawing to address with insufficient balance fails. +#[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 = Address::generate(&env); + + env.mock_all_auths(); + client.init(&owner, &usdc_token, &Some(100), &None); + + let result = client.try_withdraw_to(&recipient, &500); + assert!(result.is_err(), "expected error for insufficient balance"); +} + +// --------------------------------------------------------------------------- +// min_deposit +// --------------------------------------------------------------------------- + +/// Deposits below min_deposit are rejected. +#[test] +fn deposit_below_minimum_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 = Address::generate(&env); + + env.mock_all_auths(); + client.init(&owner, &usdc_token, &Some(100), &Some(50)); // min_deposit = 50 + + let result = client.try_deposit(&30); // below minimum + assert!(result.is_err(), "expected error for deposit below minimum"); +} + +/// Deposits at or above min_deposit succeed. +#[test] +fn deposit_at_minimum_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 = Address::generate(&env); + + env.mock_all_auths(); + client.init(&owner, &usdc_token, &Some(100), &Some(50)); // min_deposit = 50 + + let new_balance = client.deposit(&50); + assert_eq!(new_balance, 150); +} + +// --------------------------------------------------------------------------- +// double init guard +// --------------------------------------------------------------------------- + +/// Calling init twice fails. +#[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 = Address::generate(&env); + + env.mock_all_auths(); + client.init(&owner, &usdc_token, &Some(100), &None); + + let result = client.try_init(&owner, &usdc_token, &Some(200), &None); + assert!(result.is_err(), "expected error for double init"); +} diff --git a/coverage/cobertura.xml b/coverage/cobertura.xml index 93061e7..10ea307 100644 --- a/coverage/cobertura.xml +++ b/coverage/cobertura.xml @@ -1 +1 @@ -/home/jeffersonyouashi/Documents/DRIPS/Callora-Contracts \ No newline at end of file +/home/jeffersonyouashi/Documents/DRIPS/Callora-Contracts \ No newline at end of file diff --git a/coverage/tarpaulin-report.html b/coverage/tarpaulin-report.html index fa8bbf5..43e7e2b 100644 --- a/coverage/tarpaulin-report.html +++ b/coverage/tarpaulin-report.html @@ -193,8 +193,8 @@