From 61fb2cfca476339e0ba841daab4447db5a32d2d3 Mon Sep 17 00:00:00 2001 From: AI Assistant Date: Sun, 29 Mar 2026 01:22:53 +0100 Subject: [PATCH] feat(vault): deduct validation, auth, and events --- contracts/vault/src/lib.rs | 96 +++++++++-- contracts/vault/src/test.rs | 332 ++++++++++++++++++++++++++++++++++++ 2 files changed, 414 insertions(+), 14 deletions(-) diff --git a/contracts/vault/src/lib.rs b/contracts/vault/src/lib.rs index 44972ab..9a5a38e 100644 --- a/contracts/vault/src/lib.rs +++ b/contracts/vault/src/lib.rs @@ -292,6 +292,10 @@ impl CalloraVault { meta.balance } + /// Returns the configured maximum amount allowed per single deduct call. + /// + /// If no `max_deduct` was set at `init`, returns [`DEFAULT_MAX_DEDUCT`] (effectively no cap). + /// This value is stored under [`StorageKey::MaxDeduct`] and is immutable after initialization. pub fn get_max_deduct(env: Env) -> i128 { env.storage() .instance() @@ -299,27 +303,94 @@ impl CalloraVault { .unwrap_or(DEFAULT_MAX_DEDUCT) } - /// Deducts USDC from the vault for settlement or revenue pool. - /// Can be called by the Owner or the Authorized Caller. + /// Deducts `amount` of USDC from the vault's internal balance in a single atomic call. + /// + /// ## Validation (fail-fast, in order) + /// 1. `amount > 0` — zero or negative amounts are rejected immediately. + /// 2. `amount <= max_deduct` — enforces the per-call cap configured at `init`. + /// 3. Authorization — `caller` must be the vault owner **or** the `authorized_caller` + /// stored in [`VaultMeta`]. See [Authorization Model](#authorization-model) below. + /// 4. `balance >= amount` — prevents negative balances; uses an explicit guard so + /// the subtraction is always safe (workspace also has `overflow-checks = true`). + /// + /// ## Authorization Model + /// + /// The check is deterministic and based solely on stored state — no implicit trust: + /// - If `VaultMeta.authorized_caller` is `Some(addr)`, the caller must equal `addr` + /// **or** the vault owner. + /// - If `VaultMeta.authorized_caller` is `None`, only the vault owner may call. + /// + /// In production the `authorized_caller` is typically a backend service address set + /// at `init` (or via `set_authorized_caller`). The owner retains the ability to deduct + /// directly at all times. + /// + /// ## max_deduct Behavior + /// + /// `max_deduct` caps the amount that can be deducted in a single call. It is stored + /// under [`StorageKey::MaxDeduct`] and defaults to [`DEFAULT_MAX_DEDUCT`] (i128::MAX, + /// i.e. no cap) when not provided at `init`. Passing `max_deduct = Some(n)` at `init` + /// enforces that every future `deduct` call satisfies `amount <= n`. + /// + /// ## Atomicity + /// + /// All validation runs before any state mutation. If any assertion fails the + /// transaction is aborted and **no storage is written**. The USDC transfer (if a + /// revenue pool or settlement address is configured) happens **after** the internal + /// balance is updated; a transfer failure will revert the entire transaction including + /// the balance write. + /// + /// ## Arguments + /// * `caller` – Address invoking the deduction; must be authorized (see above). + /// * `amount` – Amount to deduct in USDC base units (must be > 0 and ≤ max_deduct). + /// * `request_id` – Optional idempotency / tracking symbol emitted in the event. + /// + /// ## Returns + /// The vault's internal balance after the deduction. + /// + /// ## Panics + /// * `"amount must be positive"` — `amount <= 0`. + /// * `"deduct amount exceeds max_deduct"` — `amount > max_deduct`. + /// * `"unauthorized caller"` — caller is neither owner nor authorized_caller. + /// * `"insufficient balance"` — `balance < amount`. + /// + /// ## Events + /// Emits topic `("deduct", caller, request_id_or_empty)` with data `(amount, new_balance)` + /// **only** after all state mutations succeed. Schema matches [`EVENT_SCHEMA.md`]. pub fn deduct(env: Env, caller: Address, amount: i128, request_id: Option) -> i128 { + // ── 1. Require Soroban-level auth for the caller ────────────────────── caller.require_auth(); + + // ── 2. Validate amount > 0 ──────────────────────────────────────────── assert!(amount > 0, "amount must be positive"); + + // ── 3. Enforce max_deduct cap ───────────────────────────────────────── let max_deduct = Self::get_max_deduct(env.clone()); assert!(amount <= max_deduct, "deduct amount exceeds max_deduct"); + + // ── 4. Load state (read-only until all checks pass) ─────────────────── let mut meta = Self::get_meta(env.clone()); - // Check authorization: must be either the authorized_caller if set, or the owner. + // ── 5. Authorization: owner OR explicitly stored authorized_caller ───── + // Deterministic — no implicit trust, validated against stored address. let authorized = match &meta.authorized_caller { Some(auth_caller) => caller == *auth_caller || caller == meta.owner, None => caller == meta.owner, }; assert!(authorized, "unauthorized caller"); + // ── 6. Balance safety: explicit guard prevents underflow ────────────── assert!(meta.balance >= amount, "insufficient balance"); - meta.balance -= amount; + + // ── 7. Mutate state (only reached if all checks above passed) ───────── + meta.balance = meta + .balance + .checked_sub(amount) + .expect("balance underflow"); env.storage().instance().set(&StorageKey::Meta, &meta); - // Transfer USDC to settlement contract or revenue pool if configured + // ── 8. Optional USDC transfer to settlement / revenue pool ──────────── + // Deduct from internal balance FIRST (step 7), then transfer. + // If the transfer panics, the entire transaction reverts (including step 7). let inst = env.storage().instance(); if let Some(settlement) = inst.get::(&StorageKey::Settlement) { let usdc_token: Address = inst.get(&StorageKey::UsdcToken).unwrap(); @@ -330,15 +401,12 @@ impl CalloraVault { Self::transfer_funds(&env, &usdc_token, &revenue_pool, amount); } - 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)); + // ── 9. Emit event ONLY after successful deduction ───────────────────── + // Schema: topics = ("deduct", caller, request_id | ""), data = (amount, new_balance) + let rid = request_id.unwrap_or(Symbol::new(&env, "")); + env.events() + .publish((Symbol::new(&env, "deduct"), caller, rid), (amount, meta.balance)); + meta.balance } diff --git a/contracts/vault/src/test.rs b/contracts/vault/src/test.rs index 427ae50..9d18990 100644 --- a/contracts/vault/src/test.rs +++ b/contracts/vault/src/test.rs @@ -1409,3 +1409,335 @@ fn get_settlement_before_set_panics() { client.init(&owner, &usdc, &None, &None, &None, &None, &None); client.get_settlement(); } + +// --------------------------------------------------------------------------- +// max_deduct constraint tests +// --------------------------------------------------------------------------- + +#[test] +fn deduct_at_max_deduct_boundary_succeeds() { + let env = Env::default(); + let owner = Address::generate(&env); + let caller = Address::generate(&env); + let (vault_address, client) = create_vault(&env); + let (usdc, _, usdc_admin) = create_usdc(&env, &owner); + + env.mock_all_auths(); + fund_vault(&usdc_admin, &vault_address, 500); + // max_deduct = 100; deducting exactly 100 must succeed + client.init( + &owner, + &usdc, + &Some(500), + &Some(caller.clone()), + &None, + &None, + &Some(100), + ); + + let remaining = client.deduct(&caller, &100, &None); + assert_eq!(remaining, 400); + assert_eq!(client.balance(), 400); +} + +#[test] +fn deduct_exceeds_max_deduct_fails() { + let env = Env::default(); + let owner = Address::generate(&env); + let caller = Address::generate(&env); + let (vault_address, client) = create_vault(&env); + let (usdc, _, usdc_admin) = create_usdc(&env, &owner); + + env.mock_all_auths(); + fund_vault(&usdc_admin, &vault_address, 500); + client.init( + &owner, + &usdc, + &Some(500), + &Some(caller.clone()), + &None, + &None, + &Some(100), + ); + + let result = client.try_deduct(&caller, &101, &None); + assert!(result.is_err(), "expected error when amount > max_deduct"); + // Balance must be unchanged + assert_eq!(client.balance(), 500); +} + +#[test] +fn deduct_one_above_max_deduct_fails() { + let env = Env::default(); + let owner = Address::generate(&env); + let caller = Address::generate(&env); + let (vault_address, client) = create_vault(&env); + let (usdc, _, usdc_admin) = create_usdc(&env, &owner); + + env.mock_all_auths(); + fund_vault(&usdc_admin, &vault_address, 1000); + client.init( + &owner, + &usdc, + &Some(1000), + &Some(caller.clone()), + &None, + &None, + &Some(50), + ); + + // 51 > max_deduct(50) — must fail + let result = client.try_deduct(&caller, &51, &None); + assert!(result.is_err(), "expected error for amount one above max_deduct"); + assert_eq!(client.balance(), 1000); +} + +// --------------------------------------------------------------------------- +// Authorization security tests +// --------------------------------------------------------------------------- + +#[test] +fn unauthorized_caller_cannot_deduct() { + let env = Env::default(); + let owner = Address::generate(&env); + let authorized = Address::generate(&env); + let attacker = Address::generate(&env); + let (vault_address, client) = create_vault(&env); + let (usdc, _, usdc_admin) = create_usdc(&env, &owner); + + env.mock_all_auths(); + fund_vault(&usdc_admin, &vault_address, 500); + client.init( + &owner, + &usdc, + &Some(500), + &Some(authorized.clone()), + &None, + &None, + &None, + ); + + // attacker is neither owner nor authorized_caller + let result = client.try_deduct(&attacker, &50, &None); + assert!(result.is_err(), "expected error for unauthorized caller"); + // No state mutation must have occurred + assert_eq!(client.balance(), 500); +} + +#[test] +fn owner_can_deduct_without_authorized_caller() { + let env = Env::default(); + let owner = Address::generate(&env); + let (vault_address, client) = create_vault(&env); + let (usdc, _, usdc_admin) = create_usdc(&env, &owner); + + env.mock_all_auths(); + fund_vault(&usdc_admin, &vault_address, 200); + // No authorized_caller set + client.init(&owner, &usdc, &Some(200), &None, &None, &None, &None); + + let remaining = client.deduct(&owner, &75, &None); + assert_eq!(remaining, 125); + assert_eq!(client.balance(), 125); +} + +#[test] +fn owner_can_deduct_even_when_authorized_caller_is_set() { + let env = Env::default(); + let owner = Address::generate(&env); + let backend = Address::generate(&env); + let (vault_address, client) = create_vault(&env); + let (usdc, _, usdc_admin) = create_usdc(&env, &owner); + + env.mock_all_auths(); + fund_vault(&usdc_admin, &vault_address, 300); + client.init( + &owner, + &usdc, + &Some(300), + &Some(backend.clone()), + &None, + &None, + &None, + ); + + // Owner should still be able to deduct even though authorized_caller is set + let remaining = client.deduct(&owner, &100, &None); + assert_eq!(remaining, 200); +} + +#[test] +fn no_state_mutation_on_unauthorized_deduct() { + let env = Env::default(); + let owner = Address::generate(&env); + let attacker = Address::generate(&env); + let (vault_address, client) = create_vault(&env); + let (usdc, _, usdc_admin) = create_usdc(&env, &owner); + + env.mock_all_auths(); + fund_vault(&usdc_admin, &vault_address, 100); + client.init(&owner, &usdc, &Some(100), &None, &None, &None, &None); + + let _ = client.try_deduct(&attacker, &50, &None); + // Balance must be completely unchanged + assert_eq!(client.balance(), 100); + let meta = client.get_meta(); + assert_eq!(meta.balance, 100); +} + +// --------------------------------------------------------------------------- +// Repeated deductions / edge cases +// --------------------------------------------------------------------------- + +#[test] +fn repeated_deductions_accumulate_correctly() { + let env = Env::default(); + let owner = Address::generate(&env); + let caller = Address::generate(&env); + let (vault_address, client) = create_vault(&env); + let (usdc, _, usdc_admin) = create_usdc(&env, &owner); + + env.mock_all_auths(); + fund_vault(&usdc_admin, &vault_address, 300); + client.init( + &owner, + &usdc, + &Some(300), + &Some(caller.clone()), + &None, + &None, + &None, + ); + + client.deduct(&caller, &100, &None); + client.deduct(&caller, &100, &None); + client.deduct(&caller, &100, &None); + + assert_eq!(client.balance(), 0); +} + +#[test] +fn deduct_to_zero_then_further_deduct_fails() { + let env = Env::default(); + let owner = Address::generate(&env); + let caller = Address::generate(&env); + let (vault_address, client) = create_vault(&env); + let (usdc, _, usdc_admin) = create_usdc(&env, &owner); + + env.mock_all_auths(); + fund_vault(&usdc_admin, &vault_address, 50); + client.init( + &owner, + &usdc, + &Some(50), + &Some(caller.clone()), + &None, + &None, + &None, + ); + + // Drain to zero + client.deduct(&caller, &50, &None); + assert_eq!(client.balance(), 0); + + // Any further deduct must fail — balance cannot go negative + let result = client.try_deduct(&caller, &1, &None); + assert!(result.is_err(), "expected error when balance is 0"); + assert_eq!(client.balance(), 0); +} + +#[test] +fn no_state_mutation_on_zero_amount_deduct() { + let env = Env::default(); + let owner = Address::generate(&env); + let caller = Address::generate(&env); + let (vault_address, client) = create_vault(&env); + let (usdc, _, usdc_admin) = create_usdc(&env, &owner); + + env.mock_all_auths(); + fund_vault(&usdc_admin, &vault_address, 100); + client.init( + &owner, + &usdc, + &Some(100), + &Some(caller.clone()), + &None, + &None, + &None, + ); + + let _ = client.try_deduct(&caller, &0, &None); + assert_eq!(client.balance(), 100); +} + +#[test] +fn no_state_mutation_on_max_deduct_exceeded() { + let env = Env::default(); + let owner = Address::generate(&env); + let caller = Address::generate(&env); + let (vault_address, client) = create_vault(&env); + let (usdc, _, usdc_admin) = create_usdc(&env, &owner); + + env.mock_all_auths(); + fund_vault(&usdc_admin, &vault_address, 1000); + client.init( + &owner, + &usdc, + &Some(1000), + &Some(caller.clone()), + &None, + &None, + &Some(200), + ); + + let _ = client.try_deduct(&caller, &201, &None); + // Balance must be completely unchanged + assert_eq!(client.balance(), 1000); +} + +#[test] +fn deduct_event_schema_matches_spec() { + // Verifies the event strictly matches EVENT_SCHEMA.md: + // topics = ("deduct", caller: Address, request_id: Symbol) + // data = (amount: i128, new_balance: i128) + let env = Env::default(); + let owner = Address::generate(&env); + let caller = Address::generate(&env); + let (vault_address, client) = create_vault(&env); + let (usdc, _, usdc_admin) = create_usdc(&env, &owner); + + env.mock_all_auths(); + fund_vault(&usdc_admin, &vault_address, 1000); + client.init( + &owner, + &usdc, + &Some(1000), + &Some(caller.clone()), + &None, + &None, + &None, + ); + + let rid = Symbol::new(&env, "schema_test"); + client.deduct(&caller, &250, &Some(rid.clone())); + + let events = env.events().all(); + let ev = events.last().expect("expected deduct event"); + + // Contract address + assert_eq!(ev.0, vault_address); + + // Topics + assert_eq!(ev.1.len(), 3); + let t0: Symbol = ev.1.get(0).unwrap().into_val(&env); + let t1: Address = ev.1.get(1).unwrap().into_val(&env); + let t2: Symbol = ev.1.get(2).unwrap().into_val(&env); + assert_eq!(t0, Symbol::new(&env, "deduct")); + assert_eq!(t1, caller); + assert_eq!(t2, rid); + + // Data + let (emitted_amount, new_balance): (i128, i128) = ev.2.into_val(&env); + assert_eq!(emitted_amount, 250); + assert_eq!(new_balance, 750); +}