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
63 changes: 63 additions & 0 deletions contracts/settlement/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Address, i128> = 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()
Expand Down
158 changes: 158 additions & 0 deletions contracts/settlement/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
Loading