diff --git a/SECURITY.md b/SECURITY.md index 95aac34..0ba7430 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -165,3 +165,22 @@ Before any mainnet deployment: --- **Note**: This checklist should be reviewed and updated regularly as new security patterns emerge and the codebase evolves. + +## require_auth() Audit (Issue #160) + +All privileged entrypoints across `vault`, `revenue_pool`, and `settlement` contracts +have been audited for `require_auth()` coverage as part of Issue #160. + +### Findings +- All privileged functions call `require_auth()` on the caller before executing. ✅ +- Negative tests added to each crate's `test.rs` confirming unauthenticated calls are rejected. + +### Intentional Exceptions +| Contract | Function | Reason | +|------------|------------------|--------| +| settlement | `init()` | One-time initializer guarded by already-initialized panic; no auth required by design. | +| vault | `require_owner()`| Internal helper using `assert!` for address equality. All public callers invoke `caller.require_auth()` before calling this helper, so host-level auth is enforced transitively. Documented gap: `require_owner` itself does not call `require_auth()`. | + +### Cross-reference +- Audit branch: `test/require-auth-sweep` +- Tests: `contracts/vault/src/test.rs`, `contracts/revenue_pool/src/test.rs`, `contracts/settlement/src/test.rs` diff --git a/contracts/revenue_pool/src/test.rs b/contracts/revenue_pool/src/test.rs index 7d86bb1..3f1a907 100644 --- a/contracts/revenue_pool/src/test.rs +++ b/contracts/revenue_pool/src/test.rs @@ -266,3 +266,145 @@ fn batch_distribute_success_events() { } } } + +// --- require_auth audit tests (Issue #160) --- +#[test] +fn require_auth_set_admin_fails_without_auth() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let attacker = Address::generate(&env); + let new_admin = Address::generate(&env); + let (_, client) = create_pool(&env); + let (usdc, _, _) = create_usdc(&env, &admin); + client.init(&admin, &usdc); + env.set_auths(&[]); + let result = client.try_set_admin(&attacker, &new_admin); + assert!(result.is_err(), "set_admin must require auth"); +} +#[test] +fn require_auth_distribute_fails_without_auth() { + let env = Env::default(); + let admin = Address::generate(&env); + let attacker = Address::generate(&env); + let recipient = Address::generate(&env); + let (pool_addr, client) = create_pool(&env); + let (usdc, _, usdc_admin) = create_usdc(&env, &admin); + env.mock_all_auths(); + client.init(&admin, &usdc); + fund_pool(&usdc_admin, &pool_addr, 1000); + env.set_auths(&[]); + let result = client.try_distribute(&attacker, &recipient, &100); + assert!(result.is_err(), "distribute must require auth"); +} +#[test] +fn require_auth_batch_distribute_fails_without_auth() { + let env = Env::default(); + let admin = Address::generate(&env); + let attacker = Address::generate(&env); + let dev = Address::generate(&env); + let (pool_addr, client) = create_pool(&env); + let (usdc, _, usdc_admin) = create_usdc(&env, &admin); + env.mock_all_auths(); + client.init(&admin, &usdc); + fund_pool(&usdc_admin, &pool_addr, 1000); + let mut payments: Vec<(Address, i128)> = Vec::new(&env); + payments.push_back((dev.clone(), 100_i128)); + env.set_auths(&[]); + let result = client.try_batch_distribute(&attacker, &payments); + assert!(result.is_err(), "batch_distribute must require auth"); +} +#[test] +fn require_auth_receive_payment_fails_without_auth() { + let env = Env::default(); + let admin = Address::generate(&env); + let attacker = Address::generate(&env); + let (pool_addr, client) = create_pool(&env); + let (usdc, _, usdc_admin) = create_usdc(&env, &admin); + env.mock_all_auths(); + client.init(&admin, &usdc); + fund_pool(&usdc_admin, &pool_addr, 1000); + env.set_auths(&[]); + let result = client.try_receive_payment(&attacker, &100, &false); + assert!(result.is_err(), "receive_payment must require auth"); +} + +#[test] +fn set_admin_non_admin_caller_panics() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let attacker = Address::generate(&env); + let new_admin = Address::generate(&env); + let (_, client) = create_pool(&env); + let (usdc, _, _) = create_usdc(&env, &admin); + client.init(&admin, &usdc); + let result = client.try_set_admin(&attacker, &new_admin); + assert!(result.is_err(), "non-admin cannot call set_admin"); +} +#[test] +fn claim_admin_wrong_pending_panics() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let new_admin = Address::generate(&env); + let attacker = Address::generate(&env); + let (_, client) = create_pool(&env); + let (usdc, _, _) = create_usdc(&env, &admin); + client.init(&admin, &usdc); + client.set_admin(&admin, &new_admin); + let result = client.try_claim_admin(&attacker); + assert!(result.is_err(), "wrong pending admin cannot claim"); +} +#[test] +fn receive_payment_emits_event() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let (pool_addr, client) = create_pool(&env); + let (usdc, _, usdc_admin) = create_usdc(&env, &admin); + client.init(&admin, &usdc); + fund_pool(&usdc_admin, &pool_addr, 500); + client.receive_payment(&admin, &100, &true); + let events = env.events().all(); + assert!(!events.is_empty()); +} +#[test] +fn distribute_self_recipient_panics() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let (pool_addr, client) = create_pool(&env); + let (usdc, _, usdc_admin) = create_usdc(&env, &admin); + client.init(&admin, &usdc); + fund_pool(&usdc_admin, &pool_addr, 500); + let result = client.try_distribute(&admin, &pool_addr, &100); + assert!(result.is_err(), "cannot distribute to contract itself"); +} +#[test] +fn distribute_non_admin_panics() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let attacker = Address::generate(&env); + let recipient = Address::generate(&env); + let (pool_addr, client) = create_pool(&env); + let (usdc, _, usdc_admin) = create_usdc(&env, &admin); + client.init(&admin, &usdc); + fund_pool(&usdc_admin, &pool_addr, 500); + let result = client.try_distribute(&attacker, &recipient, &100); + assert!(result.is_err(), "non-admin cannot distribute"); +} +#[test] +fn distribute_zero_amount_panics() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let recipient = Address::generate(&env); + let (pool_addr, client) = create_pool(&env); + let (usdc, _, usdc_admin) = create_usdc(&env, &admin); + client.init(&admin, &usdc); + fund_pool(&usdc_admin, &pool_addr, 500); + let result = client.try_distribute(&admin, &recipient, &0); + assert!(result.is_err(), "zero amount distribute must fail"); +} diff --git a/contracts/settlement/src/test.rs b/contracts/settlement/src/test.rs index 08f19c5..0352c1a 100644 --- a/contracts/settlement/src/test.rs +++ b/contracts/settlement/src/test.rs @@ -13,9 +13,10 @@ mod settlement_tests { env.mock_all_auths(); let admin = Address::generate(&env); let vault = Address::generate(&env); - let addr = env.register(CalloraSettlement, ()); + let addr = env.register(CalloraSettlement, ()); let client = CalloraSettlementClient::new(&env, &addr); client.init(&admin, &vault); + let third_party = Address::generate(&env); (env, addr, admin, vault, third_party) } @@ -152,7 +153,7 @@ mod settlement_tests { env.mock_all_auths(); let admin = Address::generate(&env); let vault = Address::generate(&env); - let addr = env.register(CalloraSettlement, ()); + let addr = env.register(CalloraSettlement, ()); let client = CalloraSettlementClient::new(&env, &addr); client.init(&admin, &vault); client.receive_payment(&admin, &100i128, &true, &None); @@ -913,4 +914,64 @@ mod settlement_tests { assert_eq!(pool_after.last_updated, 1_700_000_100); assert_eq!(pool_after.total_balance, 1500i128); } + + // --- require_auth audit tests (Issue #160) --- + #[test] + fn require_auth_set_admin_fails_without_auth() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let attacker = Address::generate(&env); + let new_admin = Address::generate(&env); + let vault = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + client.init(&admin, &vault); + env.set_auths(&[]); + let result = client.try_set_admin(&attacker, &new_admin); + assert!(result.is_err(), "set_admin must require auth"); + } + #[test] + fn require_auth_set_vault_fails_without_auth() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let attacker = Address::generate(&env); + let vault = Address::generate(&env); + let new_vault = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + client.init(&admin, &vault); + env.set_auths(&[]); + let result = client.try_set_vault(&attacker, &new_vault); + assert!(result.is_err(), "set_vault must require auth"); + } + #[test] + fn require_auth_receive_payment_fails_without_auth() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let attacker = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + client.init(&admin, &vault); + env.set_auths(&[]); + let result = client.try_receive_payment(&attacker, &100i128, &true, &None); + assert!(result.is_err(), "receive_payment must require auth"); + } + // SECURITY NOTE: settlement::init has no require_auth() by design. + // It is a one-time initializer guarded by an already-initialized panic. + // This is an intentional exception documented per Issue #160. + #[test] + fn init_no_auth_required_intentional_exception() { + let env = Env::default(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + // No mock_all_auths — init should succeed without host auth (intentional) + client.init(&admin, &vault); + assert_eq!(client.get_admin(), admin); + } } diff --git a/contracts/vault/src/lib.rs b/contracts/vault/src/lib.rs index f27769b..1a04516 100644 --- a/contracts/vault/src/lib.rs +++ b/contracts/vault/src/lib.rs @@ -325,7 +325,10 @@ impl CalloraVault { if let Some(s) = inst.get::(&StorageKey::Settlement) { let ut: Address = inst.get(&StorageKey::UsdcToken).unwrap(); Self::transfer_funds(&env, &ut, &s, amount); - } else if inst.get::(&StorageKey::RevenuePool).is_some() { + } else if inst + .get::(&StorageKey::RevenuePool) + .is_some() + { Self::transfer_to_revenue_pool(env.clone(), amount); } let rid = request_id.unwrap_or(Symbol::new(&env, "")); @@ -372,7 +375,10 @@ impl CalloraVault { if let Some(s) = inst.get::(&StorageKey::Settlement) { let ut: Address = inst.get(&StorageKey::UsdcToken).unwrap(); Self::transfer_funds(&env, &ut, &s, total); - } else if inst.get::(&StorageKey::RevenuePool).is_some() { + } else if inst + .get::(&StorageKey::RevenuePool) + .is_some() + { Self::transfer_to_revenue_pool(env.clone(), total); } meta.balance diff --git a/contracts/vault/src/test.rs b/contracts/vault/src/test.rs index e05f35a..688ed25 100644 --- a/contracts/vault/src/test.rs +++ b/contracts/vault/src/test.rs @@ -2141,3 +2141,411 @@ fn non_owner_cannot_clear_allowed_depositors() { let result = client.try_clear_allowed_depositors(&attacker); assert!(result.is_err(), "non-owner must not clear allowlist"); } + +// --- require_auth audit tests (Issue #160) --- +#[test] +fn require_auth_set_admin_fails_without_auth() { + let env = Env::default(); + let owner = Address::generate(&env); + let attacker = Address::generate(&env); + let new_admin = 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); + env.set_auths(&[]); + let result = client.try_set_admin(&attacker, &new_admin); + assert!(result.is_err(), "set_admin must require auth"); +} +#[test] +fn require_auth_distribute_fails_without_auth() { + let env = Env::default(); + let owner = Address::generate(&env); + let attacker = Address::generate(&env); + let recipient = 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), &None, &None, &None, &None); + env.set_auths(&[]); + let result = client.try_distribute(&attacker, &recipient, &100); + assert!(result.is_err(), "distribute must require auth"); +} +#[test] +fn require_auth_pause_fails_without_auth() { + 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); + env.set_auths(&[]); + let result = client.try_pause(&attacker); + assert!(result.is_err(), "pause must require auth"); +} +#[test] +fn require_auth_unpause_fails_without_auth() { + 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); + client.pause(&owner); + env.set_auths(&[]); + let result = client.try_unpause(&attacker); + assert!(result.is_err(), "unpause must require auth"); +} +#[test] +fn require_auth_deposit_fails_without_auth() { + let env = Env::default(); + let owner = Address::generate(&env); + let depositor = 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); + usdc_admin.mint(&depositor, &500); + client.init(&owner, &usdc, &Some(100), &None, &None, &None, &None); + client.set_allowed_depositor(&owner, &Some(depositor.clone())); + env.set_auths(&[]); + let result = client.try_deposit(&depositor, &100); + assert!(result.is_err(), "deposit must require auth"); +} +#[test] +fn require_auth_deduct_fails_without_auth() { + 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, 1000); + client.init(&owner, &usdc, &Some(1000), &None, &None, &None, &None); + env.set_auths(&[]); + let result = client.try_deduct(&attacker, &100, &None); + assert!(result.is_err(), "deduct must require auth"); +} +#[test] +fn require_auth_set_revenue_pool_fails_without_auth() { + let env = Env::default(); + let owner = Address::generate(&env); + let attacker = Address::generate(&env); + let pool = 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); + env.set_auths(&[]); + let result = client.try_set_revenue_pool(&attacker, &Some(pool)); + assert!(result.is_err(), "set_revenue_pool must require auth"); +} +#[test] +fn require_auth_set_settlement_fails_without_auth() { + let env = Env::default(); + let owner = Address::generate(&env); + let attacker = Address::generate(&env); + let settlement = 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); + env.set_auths(&[]); + let result = client.try_set_settlement(&attacker, &settlement); + assert!(result.is_err(), "set_settlement must require auth"); +} +#[test] +fn require_auth_transfer_ownership_fails_without_auth() { + let env = Env::default(); + let owner = Address::generate(&env); + let new_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, 100); + client.init(&owner, &usdc, &Some(100), &None, &None, &None, &None); + env.set_auths(&[]); + let result = client.try_transfer_ownership(&new_owner); + assert!(result.is_err(), "transfer_ownership must require auth"); +} +// SECURITY NOTE: require_owner() uses assert! instead of require_auth(). +// All callers already invoke caller.require_auth() before calling require_owner, +// so host-level auth is enforced transitively. Documented per Issue #160. +#[test] +fn require_owner_rejects_non_owner_via_assert() { + 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 result = client.try_require_owner(&attacker); + assert!( + result.is_err(), + "require_owner rejects non-owner via assert" + ); +} + +// --------------------------------------------------------------------------- +// Issue #151 — min_deposit boundary tests +// --------------------------------------------------------------------------- + +#[test] +fn deposit_exact_min_deposit_succeeds() { + let env = Env::default(); + let owner = Address::generate(&env); + let (vault_address, client) = create_vault(&env); + let (usdc, usdc_client, usdc_admin) = create_usdc(&env, &owner); + + env.mock_all_auths(); + fund_vault(&usdc_admin, &vault_address, 0); + client.init(&owner, &usdc, &None, &None, &Some(50), &None, &None); + + usdc_admin.mint(&owner, &50); + usdc_client.approve(&owner, &vault_address, &50, &1000); + let balance = client.deposit(&owner, &50); + assert_eq!(balance, 50); +} + +#[test] +#[should_panic(expected = "deposit below minimum")] +fn deposit_below_min_deposit_panics() { + let env = Env::default(); + let owner = Address::generate(&env); + let (vault_address, client) = create_vault(&env); + let (usdc, usdc_client, usdc_admin) = create_usdc(&env, &owner); + + env.mock_all_auths(); + fund_vault(&usdc_admin, &vault_address, 0); + client.init(&owner, &usdc, &None, &None, &Some(50), &None, &None); + + usdc_admin.mint(&owner, &49); + usdc_client.approve(&owner, &vault_address, &49, &1000); + client.deposit(&owner, &49); +} + +#[test] +fn deposit_above_min_deposit_succeeds() { + let env = Env::default(); + let owner = Address::generate(&env); + let (vault_address, client) = create_vault(&env); + let (usdc, usdc_client, usdc_admin) = create_usdc(&env, &owner); + + env.mock_all_auths(); + fund_vault(&usdc_admin, &vault_address, 0); + client.init(&owner, &usdc, &None, &None, &Some(50), &None, &None); + + usdc_admin.mint(&owner, &51); + usdc_client.approve(&owner, &vault_address, &51, &1000); + let balance = client.deposit(&owner, &51); + assert_eq!(balance, 51); +} + +#[test] +fn deposit_with_zero_min_deposit_allows_any_positive_amount() { + // When min_deposit is 0 (default), any positive amount should succeed. + let env = Env::default(); + let owner = Address::generate(&env); + let (vault_address, client) = create_vault(&env); + let (usdc, usdc_client, usdc_admin) = create_usdc(&env, &owner); + + env.mock_all_auths(); + fund_vault(&usdc_admin, &vault_address, 0); + client.init(&owner, &usdc, &None, &None, &Some(0), &None, &None); + + usdc_admin.mint(&owner, &1); + usdc_client.approve(&owner, &vault_address, &1, &1000); + let balance = client.deposit(&owner, &1); + assert_eq!(balance, 1); +} + +#[test] +fn init_min_deposit_stored_in_meta() { + let env = Env::default(); + let owner = Address::generate(&env); + let (_, client) = create_vault(&env); + let (usdc, _, _) = create_usdc(&env, &owner); + + env.mock_all_auths(); + client.init(&owner, &usdc, &None, &None, &Some(100), &None, &None); + let meta = client.get_meta(); + assert_eq!(meta.min_deposit, 100); +} + +#[test] +fn init_default_min_deposit_is_zero() { + let env = Env::default(); + let owner = Address::generate(&env); + let (_, client) = create_vault(&env); + let (usdc, _, _) = create_usdc(&env, &owner); + + env.mock_all_auths(); + client.init(&owner, &usdc, &None, &None, &None, &None, &None); + let meta = client.get_meta(); + assert_eq!(meta.min_deposit, 0); +} + +#[test] +fn deposit_one_below_large_min_deposit_panics() { + let env = Env::default(); + let owner = Address::generate(&env); + let (vault_address, client) = create_vault(&env); + let (usdc, usdc_client, usdc_admin) = create_usdc(&env, &owner); + + env.mock_all_auths(); + fund_vault(&usdc_admin, &vault_address, 0); + client.init(&owner, &usdc, &None, &None, &Some(1_000_000), &None, &None); + + usdc_admin.mint(&owner, &999_999); + usdc_client.approve(&owner, &vault_address, &999_999, &1000); + let result = client.try_deposit(&owner, &999_999); + assert!( + result.is_err(), + "deposit one below large min_deposit must fail" + ); +} + +#[test] +fn deposit_exact_large_min_deposit_succeeds() { + let env = Env::default(); + let owner = Address::generate(&env); + let (vault_address, client) = create_vault(&env); + let (usdc, usdc_client, usdc_admin) = create_usdc(&env, &owner); + + env.mock_all_auths(); + fund_vault(&usdc_admin, &vault_address, 0); + client.init(&owner, &usdc, &None, &None, &Some(1_000_000), &None, &None); + + usdc_admin.mint(&owner, &1_000_000); + usdc_client.approve(&owner, &vault_address, &1_000_000, &1000); + let balance = client.deposit(&owner, &1_000_000); + assert_eq!(balance, 1_000_000); +} + +// --------------------------------------------------------------------------- +// max_deduct boundary tests +// --------------------------------------------------------------------------- + +#[test] +fn deduct_equal_to_max_deduct_succeeds() { + let env = Env::default(); + let owner = Address::generate(&env); + let (vault_address, client) = create_vault(&env); + let (usdc, usdc_client, usdc_admin) = create_usdc(&env, &owner); + env.mock_all_auths(); + fund_vault(&usdc_admin, &vault_address, 500); + // max_deduct = 100, deposit 200 so balance is sufficient + client.init(&owner, &usdc, &Some(500), &None, &None, &None, &Some(100)); + usdc_admin.mint(&owner, &200); + usdc_client.approve(&owner, &vault_address, &200, &1000); + client.deposit(&owner, &200); + // deduct exactly equal to max_deduct — must succeed + let balance = client.deduct(&owner, &100, &None); + assert_eq!(balance, 600); +} + +#[test] +#[should_panic(expected = "deduct amount exceeds max_deduct")] +fn deduct_above_max_deduct_panics() { + let env = Env::default(); + let owner = Address::generate(&env); + let (vault_address, client) = create_vault(&env); + let (usdc, usdc_client, usdc_admin) = create_usdc(&env, &owner); + env.mock_all_auths(); + fund_vault(&usdc_admin, &vault_address, 500); + client.init(&owner, &usdc, &Some(500), &None, &None, &None, &Some(100)); + usdc_admin.mint(&owner, &200); + usdc_client.approve(&owner, &vault_address, &200, &1000); + client.deposit(&owner, &200); + // deduct 101 > max_deduct 100 — must panic + client.deduct(&owner, &101, &None); +} + +#[test] +fn deduct_default_cap_is_i128_max() { + let env = Env::default(); + let owner = Address::generate(&env); + let (vault_address, client) = create_vault(&env); + let (usdc, usdc_client, usdc_admin) = create_usdc(&env, &owner); + env.mock_all_auths(); + fund_vault(&usdc_admin, &vault_address, 0); + // no max_deduct supplied — default cap (i128::MAX) applies + client.init(&owner, &usdc, &None, &None, &None, &None, &None); + usdc_admin.mint(&owner, &1_000_000); + usdc_client.approve(&owner, &vault_address, &1_000_000, &1000); + client.deposit(&owner, &1_000_000); + // large deduct well below i128::MAX should succeed + let balance = client.deduct(&owner, &999_999, &None); + assert_eq!(balance, 1); +} + +#[test] +fn batch_deduct_each_item_constrained_by_max_deduct() { + let env = Env::default(); + let owner = Address::generate(&env); + let (vault_address, client) = create_vault(&env); + let (usdc, usdc_client, usdc_admin) = create_usdc(&env, &owner); + env.mock_all_auths(); + fund_vault(&usdc_admin, &vault_address, 0); + // max_deduct = 50 + client.init(&owner, &usdc, &None, &None, &None, &None, &Some(50)); + usdc_admin.mint(&owner, &300); + usdc_client.approve(&owner, &vault_address, &300, &1000); + client.deposit(&owner, &300); + // three items each exactly at the cap — all must pass + let items = soroban_sdk::vec![ + &env, + DeductItem { + amount: 50, + request_id: None + }, + DeductItem { + amount: 50, + request_id: None + }, + DeductItem { + amount: 50, + request_id: None + }, + ]; + let balance = client.batch_deduct(&owner, &items); + assert_eq!(balance, 150); +} + +#[test] +#[should_panic(expected = "deduct amount exceeds max_deduct")] +fn batch_deduct_one_item_above_max_deduct_panics() { + let env = Env::default(); + let owner = Address::generate(&env); + let (vault_address, client) = create_vault(&env); + let (usdc, usdc_client, usdc_admin) = create_usdc(&env, &owner); + env.mock_all_auths(); + fund_vault(&usdc_admin, &vault_address, 0); + client.init(&owner, &usdc, &None, &None, &None, &None, &Some(50)); + usdc_admin.mint(&owner, &300); + usdc_client.approve(&owner, &vault_address, &300, &1000); + client.deposit(&owner, &300); + // second item exceeds cap — must panic + let items = soroban_sdk::vec![ + &env, + DeductItem { + amount: 50, + request_id: None + }, + DeductItem { + amount: 51, + request_id: None + }, + ]; + client.batch_deduct(&owner, &items); +}