From 76cfd90d04fd31ee12800a219e0d055551af909f Mon Sep 17 00:00:00 2001 From: Ijay Abby Date: Fri, 24 Apr 2026 02:56:43 +0300 Subject: [PATCH] feat(vault): atomic batch_deduct with tests --- INVARIANTS.md | 4 +++ contracts/vault/src/lib.rs | 33 ++++++++++++------ contracts/vault/src/test.rs | 68 ++++++++++++++++++++++++++++++++++++- 3 files changed, 94 insertions(+), 11 deletions(-) diff --git a/INVARIANTS.md b/INVARIANTS.md index f1277cbb..139f3784 100644 --- a/INVARIANTS.md +++ b/INVARIANTS.md @@ -102,6 +102,10 @@ Helper and view functions such as `get_meta`, `get_max_deduct`, `get_revenue_poo - Caller is authorized: `caller.require_auth()` - Vault is initialized. - `1 <= items.len() <= MAX_BATCH_SIZE` (50) +- The explicit batch cap is a practical Soroban resource bound: + it limits looped validation work, transfer/event overhead, and invocation + footprint in one call. Tune this cap conservatively if production + workloads approach network CPU or budget limits. - For every item: `item.amount > 0` and `item.amount <= get_max_deduct(env)` - Cumulative deductions do not exceed balance: - Validated in a single pass before any state is written. diff --git a/contracts/vault/src/lib.rs b/contracts/vault/src/lib.rs index 8157e2ec..71a52484 100644 --- a/contracts/vault/src/lib.rs +++ b/contracts/vault/src/lib.rs @@ -347,6 +347,14 @@ impl CalloraVault { meta.balance } + /// Deduct multiple items atomically. + /// + /// Full-batch validation is completed before any external transfer, + /// state update, or event emission. If validation or transfer fails, + /// the invocation reverts with no partial effects. + /// + /// `MAX_BATCH_SIZE` is an explicit practical cap to bound Soroban + /// CPU/memory work and emitted events in a single invocation. pub fn batch_deduct(env: Env, caller: Address, items: Vec) -> i128 { caller.require_auth(); Self::require_not_paused(env.clone()); @@ -369,17 +377,9 @@ impl CalloraVault { running = running.checked_sub(item.amount).unwrap(); total = total.checked_add(item.amount).unwrap(); } + meta.balance = running; - env.storage().instance().set(&StorageKey::Meta, &meta); - let mut eb = meta.balance.checked_add(total).unwrap(); - for item in items.iter() { - eb = eb.checked_sub(item.amount).unwrap(); - let rid = item.request_id.clone().unwrap_or(Symbol::new(&env, "")); - env.events().publish( - (Symbol::new(&env, "deduct"), caller.clone(), rid), - (item.amount, eb), - ); - } + let inst = env.storage().instance(); if let Some(s) = inst.get::(&StorageKey::Settlement) { let ut: Address = inst.get(&StorageKey::UsdcToken).unwrap(); @@ -390,6 +390,19 @@ impl CalloraVault { { Self::transfer_to_revenue_pool(env.clone(), total); } + + env.storage().instance().set(&StorageKey::Meta, &meta); + + let mut eb = meta.balance.checked_add(total).unwrap(); + for item in items.iter() { + eb = eb.checked_sub(item.amount).unwrap(); + let rid = item.request_id.clone().unwrap_or(Symbol::new(&env, "")); + env.events().publish( + (Symbol::new(&env, "deduct"), caller.clone(), rid), + (item.amount, eb), + ); + } + meta.balance } diff --git a/contracts/vault/src/test.rs b/contracts/vault/src/test.rs index 581d9cdd..ec965d63 100644 --- a/contracts/vault/src/test.rs +++ b/contracts/vault/src/test.rs @@ -954,7 +954,7 @@ fn batch_deduct_too_large_fails() { #[test] fn batch_deduct_fail_mid_batch_leaves_balance_unchanged() { - // Second item exceeds balance — entire batch must revert. + // Second item exceeds balance - entire batch must revert. let env = Env::default(); let owner = Address::generate(&env); let (vault_address, client) = create_vault(&env); @@ -981,6 +981,72 @@ fn batch_deduct_fail_mid_batch_leaves_balance_unchanged() { assert_eq!(client.balance(), 100); } +#[test] +fn batch_deduct_fail_mid_batch_has_no_transfer_or_deduct_events() { + let env = Env::default(); + let owner = Address::generate(&env); + let caller = Address::generate(&env); + let settlement = Address::generate(&env); + let (vault_address, client) = create_vault(&env); + let (usdc_address, usdc_client, usdc_admin) = create_usdc(&env, &owner); + + env.mock_all_auths(); + fund_vault(&usdc_admin, &vault_address, 100); + client.init( + &owner, + &usdc_address, + &Some(100), + &Some(caller.clone()), + &None, + &None, + &None, + ); + client.set_settlement(&owner, &settlement); + + let deduct_events_before = env + .events() + .all() + .iter() + .filter(|e| { + !e.1.is_empty() && { + let s: Symbol = e.1.get(0).unwrap().into_val(&env); + s == Symbol::new(&env, "deduct") + } + }) + .count(); + + let items = soroban_sdk::vec![ + &env, + DeductItem { + amount: 60, + request_id: Some(Symbol::new(&env, "x1")) + }, + DeductItem { + amount: 60, + request_id: Some(Symbol::new(&env, "x2")) + }, + ]; + + let result = client.try_batch_deduct(&caller, &items); + assert!(result.is_err(), "expected insufficient balance error"); + + assert_eq!(client.balance(), 100); + assert_eq!(usdc_client.balance(&settlement), 0); + + let deduct_events_after = env + .events() + .all() + .iter() + .filter(|e| { + !e.1.is_empty() && { + let s: Symbol = e.1.get(0).unwrap().into_val(&env); + s == Symbol::new(&env, "deduct") + } + }) + .count(); + assert_eq!(deduct_events_after, deduct_events_before); +} + // --------------------------------------------------------------------------- // Withdraw tests // ---------------------------------------------------------------------------