Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions INVARIANTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
18 changes: 9 additions & 9 deletions contracts/vault/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,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<DeductItem>) -> i128 {
Self::require_not_paused(env.clone());
caller.require_auth();
Expand Down Expand Up @@ -444,15 +452,7 @@ impl CalloraVault {
.unwrap_or_else(|| panic!("total overflow"));
}

let mut eb = meta.balance;
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 = running;

let inst = env.storage().instance();
if let Some(s) = inst.get(&StorageKey::Settlement) {
Expand Down
68 changes: 67 additions & 1 deletion contracts/vault/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1125,7 +1125,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);
Expand All @@ -1152,6 +1152,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
// ---------------------------------------------------------------------------
Expand Down
Loading