diff --git a/contracts/settlement/src/lib.rs b/contracts/settlement/src/lib.rs index 7b33e72e..38c5fd01 100644 --- a/contracts/settlement/src/lib.rs +++ b/contracts/settlement/src/lib.rs @@ -198,6 +198,69 @@ impl CalloraSettlement { } } + /// Atomically credit multiple developer balances in a single call. + /// + /// # Arguments + /// * `caller` - Must be the registered vault address or admin + /// * `items` - Vec of `(developer_address, amount)` pairs; 1–[`MAX_BATCH_SIZE`] entries + /// + /// # Access Control + /// Only the registered vault address or admin can call this function. + /// + /// # Validation + /// All amounts must be `> 0`. Empty and oversized batches are rejected before any state change. + /// + /// # Atomicity + /// All validation runs before any state is written. A failure on any item leaves the + /// contract state unchanged. + /// + /// # Events + /// Emits `balance_credited` for each item in the batch. + /// + /// # Panics + /// * `"batch_receive_payment requires at least one item"` — empty batch + /// * `"batch too large"` — more than [`MAX_BATCH_SIZE`] items + /// * `"amount must be positive"` — any amount ≤ 0 + /// * `"developer balance overflow"` — `i128` overflow on any developer balance + pub fn batch_receive_payment(env: Env, caller: Address, items: Vec<(Address, i128)>) { + caller.require_auth(); + Self::require_authorized_caller(env.clone(), caller.clone()); + + let n = items.len(); + assert!(n > 0, "batch_receive_payment requires at least one item"); + assert!(n <= MAX_BATCH_SIZE, "batch too large"); + + // Validate all amounts before touching state. + for item in items.iter() { + let (_, amount) = item; + assert!(amount > 0, "amount must be positive"); + } + + let inst = env.storage().instance(); + let mut balances: Map = inst + .get(&Symbol::new(&env, DEVELOPER_BALANCES_KEY)) + .unwrap_or_else(|| Map::new(&env)); + + for item in items.iter() { + let (dev, amount) = item; + let current = balances.get(dev.clone()).unwrap_or(0); + let new_balance = current + .checked_add(amount) + .unwrap_or_else(|| panic!("developer balance overflow")); + balances.set(dev.clone(), new_balance); + env.events().publish( + (Symbol::new(&env, "balance_credited"), dev.clone()), + BalanceCreditedEvent { + developer: dev, + amount, + new_balance, + }, + ); + } + + inst.set(&Symbol::new(&env, DEVELOPER_BALANCES_KEY), &balances); + } + /// Get current admin address pub fn get_admin(env: Env) -> Address { env.storage() diff --git a/contracts/settlement/src/test.rs b/contracts/settlement/src/test.rs index 90937941..253ebc95 100644 --- a/contracts/settlement/src/test.rs +++ b/contracts/settlement/src/test.rs @@ -1217,4 +1217,162 @@ mod settlement_tests { assert!(result.is_err()); assert!(panic_message(result.unwrap_err()).contains("unauthorized: caller is not admin")); } + + // ── batch_receive_payment tests ────────────────────────────────────────── + + #[test] + fn test_batch_receive_payment_credits_multiple_developers() { + let (env, addr, _admin, vault, _third_party) = setup_contract(); + let client = CalloraSettlementClient::new(&env, &addr); + let dev1 = Address::generate(&env); + let dev2 = Address::generate(&env); + + let mut items = soroban_sdk::Vec::new(&env); + items.push_back((dev1.clone(), 100i128)); + items.push_back((dev2.clone(), 200i128)); + + client.batch_receive_payment(&vault, &items); + + assert_eq!(client.get_developer_balance(&dev1), 100i128); + assert_eq!(client.get_developer_balance(&dev2), 200i128); + } + + #[test] + fn test_batch_receive_payment_accumulates_existing_balance() { + let (env, addr, _admin, vault, _third_party) = setup_contract(); + let client = CalloraSettlementClient::new(&env, &addr); + let dev = Address::generate(&env); + + client.receive_payment(&vault, &50i128, &false, &Some(dev.clone())); + + let mut items = soroban_sdk::Vec::new(&env); + items.push_back((dev.clone(), 75i128)); + client.batch_receive_payment(&vault, &items); + + assert_eq!(client.get_developer_balance(&dev), 125i128); + } + + #[test] + fn test_batch_receive_payment_admin_caller_allowed() { + let (env, addr, admin, _vault, _third_party) = setup_contract(); + let client = CalloraSettlementClient::new(&env, &addr); + let dev = Address::generate(&env); + + let mut items = soroban_sdk::Vec::new(&env); + items.push_back((dev.clone(), 300i128)); + client.batch_receive_payment(&admin, &items); + + assert_eq!(client.get_developer_balance(&dev), 300i128); + } + + #[test] + fn test_batch_receive_payment_rejects_empty_batch() { + let (env, addr, _admin, vault, _third_party) = setup_contract(); + let client = CalloraSettlementClient::new(&env, &addr); + + let items: soroban_sdk::Vec<(Address, i128)> = soroban_sdk::Vec::new(&env); + let result = catch_unwind(AssertUnwindSafe(|| { + client.batch_receive_payment(&vault, &items); + })); + assert!(result.is_err()); + assert!(panic_message(result.unwrap_err()) + .contains("batch_receive_payment requires at least one item")); + } + + #[test] + fn test_batch_receive_payment_rejects_oversized_batch() { + use crate::MAX_BATCH_SIZE; + let (env, addr, _admin, vault, _third_party) = setup_contract(); + let client = CalloraSettlementClient::new(&env, &addr); + let dev = Address::generate(&env); + + let mut items = soroban_sdk::Vec::new(&env); + for _ in 0..=MAX_BATCH_SIZE { + items.push_back((dev.clone(), 1i128)); + } + let result = catch_unwind(AssertUnwindSafe(|| { + client.batch_receive_payment(&vault, &items); + })); + assert!(result.is_err()); + assert!(panic_message(result.unwrap_err()).contains("batch too large")); + } + + #[test] + fn test_batch_receive_payment_rejects_zero_amount() { + let (env, addr, _admin, vault, _third_party) = setup_contract(); + let client = CalloraSettlementClient::new(&env, &addr); + let dev = Address::generate(&env); + + let mut items = soroban_sdk::Vec::new(&env); + items.push_back((dev.clone(), 0i128)); + let result = catch_unwind(AssertUnwindSafe(|| { + client.batch_receive_payment(&vault, &items); + })); + assert!(result.is_err()); + assert!(panic_message(result.unwrap_err()).contains("amount must be positive")); + } + + #[test] + fn test_batch_receive_payment_rejects_negative_amount() { + let (env, addr, _admin, vault, _third_party) = setup_contract(); + let client = CalloraSettlementClient::new(&env, &addr); + let dev = Address::generate(&env); + + let mut items = soroban_sdk::Vec::new(&env); + items.push_back((dev.clone(), -1i128)); + let result = catch_unwind(AssertUnwindSafe(|| { + client.batch_receive_payment(&vault, &items); + })); + assert!(result.is_err()); + assert!(panic_message(result.unwrap_err()).contains("amount must be positive")); + } + + #[test] + fn test_batch_receive_payment_unauthorized_caller_rejected() { + let (env, addr, _admin, _vault, third_party) = setup_contract(); + let client = CalloraSettlementClient::new(&env, &addr); + let dev = Address::generate(&env); + + let mut items = soroban_sdk::Vec::new(&env); + items.push_back((dev.clone(), 100i128)); + let result = catch_unwind(AssertUnwindSafe(|| { + client.batch_receive_payment(&third_party, &items); + })); + assert!(result.is_err()); + assert!(panic_message(result.unwrap_err()) + .contains("unauthorized: caller must be vault or admin")); + } + + #[test] + fn test_batch_receive_payment_single_item() { + let (env, addr, _admin, vault, _third_party) = setup_contract(); + let client = CalloraSettlementClient::new(&env, &addr); + let dev = Address::generate(&env); + + let mut items = soroban_sdk::Vec::new(&env); + items.push_back((dev.clone(), 999i128)); + client.batch_receive_payment(&vault, &items); + + assert_eq!(client.get_developer_balance(&dev), 999i128); + } + + #[test] + fn test_batch_receive_payment_max_batch_size_accepted() { + use crate::MAX_BATCH_SIZE; + let (env, addr, _admin, vault, _third_party) = setup_contract(); + let client = CalloraSettlementClient::new(&env, &addr); + + let mut items = soroban_sdk::Vec::new(&env); + let mut devs = std::vec::Vec::new(); + for _ in 0..MAX_BATCH_SIZE { + let dev = Address::generate(&env); + devs.push(dev.clone()); + items.push_back((dev, 1i128)); + } + client.batch_receive_payment(&vault, &items); + + for dev in &devs { + assert_eq!(client.get_developer_balance(dev), 1i128); + } + } }