From 502b731c80e3a6e794b601f16a8ed27ad2cb1dcb Mon Sep 17 00:00:00 2001 From: karanjakevin39-collab Date: Wed, 27 May 2026 04:50:08 +0300 Subject: [PATCH 1/2] test: add batch_deduct event ordering and revert tests --- contracts/vault/src/test.rs | 242 ++++++++++++++++++++++++++++++++++++ 1 file changed, 242 insertions(+) diff --git a/contracts/vault/src/test.rs b/contracts/vault/src/test.rs index f7e7a572..738aa6ce 100644 --- a/contracts/vault/src/test.rs +++ b/contracts/vault/src/test.rs @@ -3272,3 +3272,245 @@ fn get_contract_addresses_updates_after_clear_revenue_pool() { let (_, _, got_pool) = client.get_contract_addresses(); assert_eq!(got_pool, None); } + +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// Issue #346 — batch_deduct event-ordering and partial-failure regression tests +// --------------------------------------------------------------------------- + +/// Assert that deduct events fire in the same order as the input items. +/// +/// batch_deduct must emit one `deduct` event per item, and the sequence of +/// those events must match the input Vec order (index 0 first, last item last). +#[test] +fn batch_deduct_events_fire_in_input_order() { + 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, 1_000); + client.init(&owner, &usdc, &Some(1_000), &None, &None, &None, &None); + + let rid0 = Symbol::new(&env, "order_0"); + let rid1 = Symbol::new(&env, "order_1"); + let rid2 = Symbol::new(&env, "order_2"); + + let items = soroban_sdk::vec![ + &env, + DeductItem { amount: 100, request_id: Some(rid0.clone()) }, + DeductItem { amount: 200, request_id: Some(rid1.clone()) }, + DeductItem { amount: 300, request_id: Some(rid2.clone()) }, + ]; + + client.batch_deduct(&caller, &items); + + let all = env.events().all(); + let deduct_sym = Symbol::new(&env, "deduct"); + + // Collect into a std Vec — soroban_sdk::Vec does not implement FromIterator. + let deduct_events: std::vec::Vec<_> = all + .iter() + .filter(|e| { + e.0 == vault_address + && e.1.len() >= 3 + && { + let t: Symbol = e.1.get(0).unwrap().into_val(&env); + t == deduct_sym + } + }) + .collect(); + + assert_eq!(deduct_events.len(), 3, "expected 3 deduct events"); + + let expected_rids = [rid0, rid1, rid2]; + for (i, ev) in deduct_events.iter().enumerate() { + let rid: Symbol = ev.1.get(2).unwrap().into_val(&env); + assert_eq!( + rid, expected_rids[i], + "event[{i}] request_id does not match input order" + ); + } +} + +/// Assert that every deduct event emitted by batch_deduct carries the correct +/// running balance in its data payload. +/// +/// Documented behaviour: each event's balance field reflects the vault balance +/// *after* that specific item is deducted, computed as a running subtraction +/// across the batch in input order. +#[test] +fn batch_deduct_events_carry_correct_running_balance() { + 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, 1_000); + client.init(&owner, &usdc, &Some(1_000), &None, &None, &None, &None); + + // Amounts: 100, 250, 150 → running balances after each: 900, 650, 500 + let items = soroban_sdk::vec![ + &env, + DeductItem { amount: 100, request_id: Some(Symbol::new(&env, "r0")) }, + DeductItem { amount: 250, request_id: Some(Symbol::new(&env, "r1")) }, + DeductItem { amount: 150, request_id: Some(Symbol::new(&env, "r2")) }, + ]; + + client.batch_deduct(&caller, &items); + + let all = env.events().all(); + let deduct_sym = Symbol::new(&env, "deduct"); + + let deduct_events: std::vec::Vec<_> = all + .iter() + .filter(|e| { + e.0 == vault_address + && e.1.len() >= 3 + && { + let t: Symbol = e.1.get(0).unwrap().into_val(&env); + t == deduct_sym + } + }) + .collect(); + + assert_eq!(deduct_events.len(), 3, "expected 3 deduct events"); + + // Expected (amount, running_balance) pairs in input order. + let expected: [(i128, i128); 3] = [(100, 900), (250, 650), (150, 500)]; + + for (i, ev) in deduct_events.iter().enumerate() { + let (amt, bal): (i128, i128) = ev.2.into_val(&env); + assert_eq!(amt, expected[i].0, "event[{i}] amount mismatch"); + assert_eq!( + bal, expected[i].1, + "event[{i}] running balance mismatch: expected {} got {}", + expected[i].1, bal + ); + } +} + +/// A max_deduct violation on the FIRST item (index 0) must revert the entire +/// batch — no deduct events emitted, balance unchanged. +#[test] +fn batch_deduct_max_deduct_violation_at_index_0_reverts_all() { + 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, 1_000); + // max_deduct = 50; item 0 sends 100 — violates immediately. + client.init(&owner, &usdc, &Some(1_000), &None, &None, &None, &Some(50)); + + let before = client.balance(); + + let items = soroban_sdk::vec![ + &env, + DeductItem { amount: 100, request_id: Some(Symbol::new(&env, "bad0")) }, // violates + DeductItem { amount: 10, request_id: Some(Symbol::new(&env, "ok1")) }, + DeductItem { amount: 10, request_id: Some(Symbol::new(&env, "ok2")) }, + ]; + + let result = client.try_batch_deduct(&caller, &items); + assert!(result.is_err(), "expected batch to be rejected"); + assert_eq!(client.balance(), before, "balance must not change on revert"); + + let deduct_sym = Symbol::new(&env, "deduct"); + let deduct_count = env.events().all().iter().filter(|e| { + e.0 == vault_address + && e.1.len() >= 1 + && { + let t: Symbol = e.1.get(0).unwrap().into_val(&env); + t == deduct_sym + } + }).count(); + assert_eq!(deduct_count, 0, "no deduct events must be emitted on full revert"); +} + +/// A max_deduct violation on a MIDDLE item must revert the entire batch — +/// no deduct events emitted, balance unchanged. +#[test] +fn batch_deduct_max_deduct_violation_at_middle_index_reverts_all() { + 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, 1_000); + // max_deduct = 50; item at index 1 sends 75 — violates. + client.init(&owner, &usdc, &Some(1_000), &None, &None, &None, &Some(50)); + + let before = client.balance(); + + let items = soroban_sdk::vec![ + &env, + DeductItem { amount: 10, request_id: Some(Symbol::new(&env, "ok0")) }, + DeductItem { amount: 75, request_id: Some(Symbol::new(&env, "bad1")) }, // violates + DeductItem { amount: 10, request_id: Some(Symbol::new(&env, "ok2")) }, + ]; + + let result = client.try_batch_deduct(&caller, &items); + assert!(result.is_err(), "expected batch to be rejected"); + assert_eq!(client.balance(), before, "balance must not change on revert"); + + let deduct_sym = Symbol::new(&env, "deduct"); + let deduct_count = env.events().all().iter().filter(|e| { + e.0 == vault_address + && e.1.len() >= 1 + && { + let t: Symbol = e.1.get(0).unwrap().into_val(&env); + t == deduct_sym + } + }).count(); + assert_eq!(deduct_count, 0, "no deduct events must be emitted on full revert"); +} + +/// A max_deduct violation on the LAST item must revert the entire batch — +/// no deduct events emitted, balance unchanged. +#[test] +fn batch_deduct_max_deduct_violation_at_last_index_reverts_all() { + 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, 1_000); + // max_deduct = 50; last item sends 99 — violates. + client.init(&owner, &usdc, &Some(1_000), &None, &None, &None, &Some(50)); + + let before = client.balance(); + + let items = soroban_sdk::vec![ + &env, + DeductItem { amount: 10, request_id: Some(Symbol::new(&env, "ok0")) }, + DeductItem { amount: 10, request_id: Some(Symbol::new(&env, "ok1")) }, + DeductItem { amount: 99, request_id: Some(Symbol::new(&env, "bad2")) }, // violates + ]; + + let result = client.try_batch_deduct(&caller, &items); + assert!(result.is_err(), "expected batch to be rejected"); + assert_eq!(client.balance(), before, "balance must not change on revert"); + + let deduct_sym = Symbol::new(&env, "deduct"); + let deduct_count = env.events().all().iter().filter(|e| { + e.0 == vault_address + && e.1.len() >= 1 + && { + let t: Symbol = e.1.get(0).unwrap().into_val(&env); + t == deduct_sym + } + }).count(); + assert_eq!(deduct_count, 0, "no deduct events must be emitted on full revert"); +} From c291808790f9a03a6148c22d0be99c802afff6bf Mon Sep 17 00:00:00 2001 From: karanjakevin39-collab Date: Wed, 27 May 2026 04:56:38 +0300 Subject: [PATCH 2/2] style: apply cargo fmt to test.rs --- contracts/vault/src/test.rs | 167 +++++++++++++++++++++++++----------- 1 file changed, 119 insertions(+), 48 deletions(-) diff --git a/contracts/vault/src/test.rs b/contracts/vault/src/test.rs index 738aa6ce..8bd08432 100644 --- a/contracts/vault/src/test.rs +++ b/contracts/vault/src/test.rs @@ -3301,9 +3301,18 @@ fn batch_deduct_events_fire_in_input_order() { let items = soroban_sdk::vec![ &env, - DeductItem { amount: 100, request_id: Some(rid0.clone()) }, - DeductItem { amount: 200, request_id: Some(rid1.clone()) }, - DeductItem { amount: 300, request_id: Some(rid2.clone()) }, + DeductItem { + amount: 100, + request_id: Some(rid0.clone()) + }, + DeductItem { + amount: 200, + request_id: Some(rid1.clone()) + }, + DeductItem { + amount: 300, + request_id: Some(rid2.clone()) + }, ]; client.batch_deduct(&caller, &items); @@ -3315,12 +3324,10 @@ fn batch_deduct_events_fire_in_input_order() { let deduct_events: std::vec::Vec<_> = all .iter() .filter(|e| { - e.0 == vault_address - && e.1.len() >= 3 - && { - let t: Symbol = e.1.get(0).unwrap().into_val(&env); - t == deduct_sym - } + e.0 == vault_address && e.1.len() >= 3 && { + let t: Symbol = e.1.get(0).unwrap().into_val(&env); + t == deduct_sym + } }) .collect(); @@ -3357,9 +3364,18 @@ fn batch_deduct_events_carry_correct_running_balance() { // Amounts: 100, 250, 150 → running balances after each: 900, 650, 500 let items = soroban_sdk::vec![ &env, - DeductItem { amount: 100, request_id: Some(Symbol::new(&env, "r0")) }, - DeductItem { amount: 250, request_id: Some(Symbol::new(&env, "r1")) }, - DeductItem { amount: 150, request_id: Some(Symbol::new(&env, "r2")) }, + DeductItem { + amount: 100, + request_id: Some(Symbol::new(&env, "r0")) + }, + DeductItem { + amount: 250, + request_id: Some(Symbol::new(&env, "r1")) + }, + DeductItem { + amount: 150, + request_id: Some(Symbol::new(&env, "r2")) + }, ]; client.batch_deduct(&caller, &items); @@ -3370,12 +3386,10 @@ fn batch_deduct_events_carry_correct_running_balance() { let deduct_events: std::vec::Vec<_> = all .iter() .filter(|e| { - e.0 == vault_address - && e.1.len() >= 3 - && { - let t: Symbol = e.1.get(0).unwrap().into_val(&env); - t == deduct_sym - } + e.0 == vault_address && e.1.len() >= 3 && { + let t: Symbol = e.1.get(0).unwrap().into_val(&env); + t == deduct_sym + } }) .collect(); @@ -3414,25 +3428,44 @@ fn batch_deduct_max_deduct_violation_at_index_0_reverts_all() { let items = soroban_sdk::vec![ &env, - DeductItem { amount: 100, request_id: Some(Symbol::new(&env, "bad0")) }, // violates - DeductItem { amount: 10, request_id: Some(Symbol::new(&env, "ok1")) }, - DeductItem { amount: 10, request_id: Some(Symbol::new(&env, "ok2")) }, + DeductItem { + amount: 100, + request_id: Some(Symbol::new(&env, "bad0")) + }, // violates + DeductItem { + amount: 10, + request_id: Some(Symbol::new(&env, "ok1")) + }, + DeductItem { + amount: 10, + request_id: Some(Symbol::new(&env, "ok2")) + }, ]; let result = client.try_batch_deduct(&caller, &items); assert!(result.is_err(), "expected batch to be rejected"); - assert_eq!(client.balance(), before, "balance must not change on revert"); + assert_eq!( + client.balance(), + before, + "balance must not change on revert" + ); let deduct_sym = Symbol::new(&env, "deduct"); - let deduct_count = env.events().all().iter().filter(|e| { - e.0 == vault_address - && e.1.len() >= 1 - && { + let deduct_count = env + .events() + .all() + .iter() + .filter(|e| { + e.0 == vault_address && e.1.len() >= 1 && { let t: Symbol = e.1.get(0).unwrap().into_val(&env); t == deduct_sym } - }).count(); - assert_eq!(deduct_count, 0, "no deduct events must be emitted on full revert"); + }) + .count(); + assert_eq!( + deduct_count, 0, + "no deduct events must be emitted on full revert" + ); } /// A max_deduct violation on a MIDDLE item must revert the entire batch — @@ -3454,25 +3487,44 @@ fn batch_deduct_max_deduct_violation_at_middle_index_reverts_all() { let items = soroban_sdk::vec![ &env, - DeductItem { amount: 10, request_id: Some(Symbol::new(&env, "ok0")) }, - DeductItem { amount: 75, request_id: Some(Symbol::new(&env, "bad1")) }, // violates - DeductItem { amount: 10, request_id: Some(Symbol::new(&env, "ok2")) }, + DeductItem { + amount: 10, + request_id: Some(Symbol::new(&env, "ok0")) + }, + DeductItem { + amount: 75, + request_id: Some(Symbol::new(&env, "bad1")) + }, // violates + DeductItem { + amount: 10, + request_id: Some(Symbol::new(&env, "ok2")) + }, ]; let result = client.try_batch_deduct(&caller, &items); assert!(result.is_err(), "expected batch to be rejected"); - assert_eq!(client.balance(), before, "balance must not change on revert"); + assert_eq!( + client.balance(), + before, + "balance must not change on revert" + ); let deduct_sym = Symbol::new(&env, "deduct"); - let deduct_count = env.events().all().iter().filter(|e| { - e.0 == vault_address - && e.1.len() >= 1 - && { + let deduct_count = env + .events() + .all() + .iter() + .filter(|e| { + e.0 == vault_address && e.1.len() >= 1 && { let t: Symbol = e.1.get(0).unwrap().into_val(&env); t == deduct_sym } - }).count(); - assert_eq!(deduct_count, 0, "no deduct events must be emitted on full revert"); + }) + .count(); + assert_eq!( + deduct_count, 0, + "no deduct events must be emitted on full revert" + ); } /// A max_deduct violation on the LAST item must revert the entire batch — @@ -3494,23 +3546,42 @@ fn batch_deduct_max_deduct_violation_at_last_index_reverts_all() { let items = soroban_sdk::vec![ &env, - DeductItem { amount: 10, request_id: Some(Symbol::new(&env, "ok0")) }, - DeductItem { amount: 10, request_id: Some(Symbol::new(&env, "ok1")) }, - DeductItem { amount: 99, request_id: Some(Symbol::new(&env, "bad2")) }, // violates + DeductItem { + amount: 10, + request_id: Some(Symbol::new(&env, "ok0")) + }, + DeductItem { + amount: 10, + request_id: Some(Symbol::new(&env, "ok1")) + }, + DeductItem { + amount: 99, + request_id: Some(Symbol::new(&env, "bad2")) + }, // violates ]; let result = client.try_batch_deduct(&caller, &items); assert!(result.is_err(), "expected batch to be rejected"); - assert_eq!(client.balance(), before, "balance must not change on revert"); + assert_eq!( + client.balance(), + before, + "balance must not change on revert" + ); let deduct_sym = Symbol::new(&env, "deduct"); - let deduct_count = env.events().all().iter().filter(|e| { - e.0 == vault_address - && e.1.len() >= 1 - && { + let deduct_count = env + .events() + .all() + .iter() + .filter(|e| { + e.0 == vault_address && e.1.len() >= 1 && { let t: Symbol = e.1.get(0).unwrap().into_val(&env); t == deduct_sym } - }).count(); - assert_eq!(deduct_count, 0, "no deduct events must be emitted on full revert"); + }) + .count(); + assert_eq!( + deduct_count, 0, + "no deduct events must be emitted on full revert" + ); }