diff --git a/contracts/settlement/API.md b/contracts/settlement/API.md new file mode 100644 index 00000000..cebe4e2f --- /dev/null +++ b/contracts/settlement/API.md @@ -0,0 +1,15 @@ +# Settlement API Notes + +## `simulate_claim(developer, amount, to)` + +`simulate_claim` is a read-only view that previews the result of a developer +claim through `withdraw_developer_balance` without side effects. It returns a +`ClaimSimulation` record containing the simulated recipient, configured USDC +token, current developer balance, remaining balance, contract token balance, +daily cap, current same-day withdrawal amount, and same-day withdrawal amount +after the simulated claim. + +The view performs the same claim validations for positive amount, claim window, +developer balance, daily withdrawal cap, configured USDC token, and settlement +contract token liquidity. It does not require developer authorization, transfer +tokens, mutate storage, extend TTLs, or emit events. diff --git a/contracts/settlement/src/lib.rs b/contracts/settlement/src/lib.rs index f0c3d54e..cda866a6 100644 --- a/contracts/settlement/src/lib.rs +++ b/contracts/settlement/src/lib.rs @@ -12,7 +12,221 @@ pub mod replay_guard; pub mod timelock; mod types; -use soroban_sdk::{contract, contractimpl, token, Address, BytesN, Env, Symbol, Vec}; +use soroban_sdk::{ + contract, contracterror, contractimpl, contracttype, token, Address, BytesN, Env, Symbol, Vec, +}; + +/// Maximum number of items allowed in a single `batch_receive_payment` call. +pub const MAX_BATCH_SIZE: u32 = 50; + +/// Maximum number of developer balances returned per page in paginated queries. +pub const MAX_DEVELOPER_BALANCES_PAGE_SIZE: u32 = 100; + +/// Typed errors for the settlement contract. +/// +/// Using `#[contracterror]` encodes each variant as a stable `u32` code. +/// Callers and indexers can match on the code rather than parsing raw panic strings, +/// and the WASM binary shrinks because no error string literals are embedded. +/// +/// | Code | Variant | When | +/// |------|------------------------------|---------------------------------------------------| +/// | 1 | NotInitialized | A function is called before `init` | +/// | 2 | AlreadyInitialized | `init` is called more than once | +/// | 3 | Unauthorized | Caller is not the vault or admin | +/// | 4 | AmountNotPositive | `amount` is zero or negative | +/// | 5 | DeveloperRequired | `to_pool=false` but no developer address supplied | +/// | 6 | DeveloperMustBeNone | `to_pool=true` but a developer address was given | +/// | 7 | PoolOverflow | Global pool `i128` addition would overflow | +/// | 8 | DeveloperOverflow | Developer balance `i128` addition would overflow | +/// | 9 | UsdcTokenNotConfigured | USDC token address not configured for withdrawals | +/// | 10 | InsufficientDeveloperBalance | Developer balance is less than withdrawal amount | +/// | 11 | DeveloperBalanceUnderflow | Developer balance subtraction would overflow | +/// | 12 | InsufficientContractBalance | Settlement contract lacks on-ledger USDC | +/// | 13 | DailyWithdrawCapExceeded | Developer's daily withdrawal cap would be exceeded| +/// | 14 | GasExhaustionRisk | Index too large for safe full scan; use pagination| +/// | 15 | ReasonTooLong | Reason Symbol exceeds maximum allowed length | +/// | 16 | InvalidClaimWindow | Claim window end is before start | +/// | 17 | ClaimWindowClosed | Claim attempted outside developer's claim window | +#[contracterror] +#[derive(Clone, Copy, Debug, PartialEq)] +#[repr(u32)] +pub enum SettlementError { + NotInitialized = 1, + AlreadyInitialized = 2, + Unauthorized = 3, + AmountNotPositive = 4, + DeveloperRequired = 5, + DeveloperMustBeNone = 6, + PoolOverflow = 7, + DeveloperOverflow = 8, + UsdcTokenNotConfigured = 9, + InsufficientDeveloperBalance = 10, + DeveloperBalanceUnderflow = 11, + InsufficientContractBalance = 12, + DailyWithdrawCapExceeded = 13, + GasExhaustionRisk = 14, + ReasonTooLong = 15, + InvalidClaimWindow = 16, + ClaimWindowClosed = 17, +} + +/// Persistent storage keys for settlement contract +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum StorageKey { + Admin, + Vault, + PendingAdmin, + PendingVault, + DeveloperIndex, + DeveloperBalance(Address), + GlobalPool, + Usdc, + DailyWithdrawCap(Address), + WithdrawalToday(Address), + DeveloperClaimWindow(Address), + ContractVersion, +} + +/// Developer balance record in settlement contract +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct DeveloperBalance { + pub address: Address, + pub balance: i128, +} + +/// Global pool balance tracking. +/// +/// `last_updated` is set to `env.ledger().timestamp()` on every +/// `receive_payment` call that credits the pool (`to_pool = true`). +/// It is also set at `init` time. It is **not** updated when payments +/// are routed to individual developer balances. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct GlobalPool { + pub total_balance: i128, + /// Ledger timestamp of the last pool credit. Useful for analytics + /// and staleness checks. + pub last_updated: u64, +} + +/// Tracks a developer's cumulative withdrawal amount for a given epoch day. +/// +/// `day` is `timestamp / 86400` (UTC epoch day). When the current call's day +/// differs from the stored day the accumulator is silently reset. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct DailyWithdrawState { + pub day: u64, + pub amount: i128, +} + +/// Timestamp range during which a developer may claim accrued balance. +/// +/// `start_ts` and `end_ts` are ledger timestamps in seconds. The window is +/// inclusive on both ends: a withdrawal is allowed when +/// `start_ts <= env.ledger().timestamp() <= end_ts`. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct DeveloperClaimWindow { + pub start_ts: u64, + pub end_ts: u64, +} + +/// Read-only preview of a developer claim/withdrawal. +/// +/// Returned by `simulate_claim` after running the same validation checks as +/// `withdraw_developer_balance`, without requiring auth, transferring tokens, +/// writing storage, or emitting events. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct ClaimSimulation { + pub developer: Address, + pub amount: i128, + pub recipient: Address, + pub token: Address, + pub current_balance: i128, + pub remaining_balance: i128, + pub contract_balance: i128, + pub daily_withdraw_cap: i128, + pub withdrawn_today: i128, + pub withdrawn_today_after: i128, +} + +/// Payment received event +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct PaymentReceivedEvent { + pub from_vault: Address, + pub amount: i128, + pub to_pool: bool, // true if credited to global pool, false if to specific developer + pub developer: Option
, // developer address if credited to specific developer +} + +/// Balance credited event +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct BalanceCreditedEvent { + pub developer: Address, + pub amount: i128, + pub new_balance: i128, +} + +/// Emitted when a new vault address is proposed via `propose_vault()`. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct VaultProposedEvent { + pub current_vault: Address, + pub proposed_vault: Address, +} + +/// Emitted when the proposed vault is accepted via `accept_vault()`. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct VaultAcceptedEvent { + pub old_vault: Address, + pub new_vault: Address, + pub accepted_by: Address, +} + +/// Emitted when a developer withdraws their balance. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct DeveloperWithdrawEvent { + pub developer: Address, + pub amount: i128, + pub remaining_balance: i128, + pub to: Address, +} + +/// Emitted when the admin sets or changes a developer's daily withdrawal cap. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct DailyWithdrawCapChanged { + pub developer: Address, + pub new_cap: i128, +} + +/// Emitted when the admin sets or clears a developer claim window. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct DeveloperClaimWindowChanged { + pub developer: Address, + pub start_ts: u64, + pub end_ts: u64, + pub enabled: bool, +} + +/// Emitted when an admin force-credits a developer balance (escape hatch). +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct DeveloperForceCreditedEvent { + pub developer: Address, + pub amount: i128, + pub reason: Symbol, + pub new_balance: i128, +} pub use errors::SettlementError; pub use migrate::{STORAGE_VERSION_V1, STORAGE_VERSION_V2}; @@ -616,6 +830,89 @@ impl CalloraSettlement { Ok(()) } + /// Simulate a developer claim without side effects. + /// + /// This read-only view previews the outcome of `withdraw_developer_balance` + /// for the configured USDC token. It intentionally does not require + /// developer authorization and does not transfer tokens, mutate balances, + /// update daily withdrawal counters, extend TTLs, or emit events. + /// + /// The view returns the same typed errors as a real claim for amount, claim + /// window, developer balance, daily cap, USDC configuration, and contract + /// token-liquidity failures. If `to` is `None`, the simulated recipient is + /// the developer address. + pub fn simulate_claim( + env: Env, + developer: Address, + amount: i128, + to: Option
, + ) -> Result { + if amount <= 0 { + return Err(SettlementError::AmountNotPositive); + } + + let recipient = to.unwrap_or_else(|| developer.clone()); + let contract_address = env.current_contract_address(); + if recipient == contract_address { + panic!("invalid recipient: cannot withdraw to contract itself"); + } + + Self::require_claim_window_open(&env, &developer)?; + + let current_balance: i128 = env + .storage() + .persistent() + .get(&StorageKey::DeveloperBalance(developer.clone())) + .unwrap_or(0); + if amount > current_balance { + return Err(SettlementError::InsufficientDeveloperBalance); + } + + let today = env.ledger().timestamp() / 86400; + let cap: i128 = env + .storage() + .persistent() + .get(&StorageKey::DailyWithdrawCap(developer.clone())) + .unwrap_or(0); + let daily = env + .storage() + .persistent() + .get::<_, DailyWithdrawState>(&StorageKey::WithdrawalToday(developer.clone())) + .unwrap_or(DailyWithdrawState { + day: today, + amount: 0, + }); + let withdrawn_today = if daily.day == today { daily.amount } else { 0 }; + let withdrawn_today_after = withdrawn_today + .checked_add(amount) + .ok_or(SettlementError::DailyWithdrawCapExceeded)?; + if cap > 0 && withdrawn_today_after > cap { + return Err(SettlementError::DailyWithdrawCapExceeded); + } + + let remaining_balance = current_balance + .checked_sub(amount) + .ok_or(SettlementError::DeveloperBalanceUnderflow)?; + let usdc_address = Self::get_usdc_token(env.clone())?; + let contract_balance = token::Client::new(&env, &usdc_address).balance(&contract_address); + if contract_balance < amount { + return Err(SettlementError::InsufficientContractBalance); + } + + Ok(ClaimSimulation { + developer, + amount, + recipient, + token: usdc_address, + current_balance, + remaining_balance, + contract_balance, + daily_withdraw_cap: cap, + withdrawn_today, + withdrawn_today_after, + }) + } + /// Configure the inclusive claim window for a developer. /// /// A configured window restricts `withdraw_developer_balance` so the diff --git a/contracts/settlement/src/test.rs b/contracts/settlement/src/test.rs index 1a2d2cdb..823377a3 100644 --- a/contracts/settlement/src/test.rs +++ b/contracts/settlement/src/test.rs @@ -1,20 +1,3007 @@ -#[test] -fn deposit_event_emitted_on_receive_payment() { - let (env, contract_id, admin, vault, usdc) = setup(); // use whatever your existing test harness does - let developer = Address::generate(&env); - let amount = 2_500_000i128; - - env.mock_all_auths(); - let client = CalloraSettlementClient::new(&env, &contract_id); - client.receive_payment(&vault, &amount, &false, &Some(developer.clone()), &usdc, &1u32); - - let events = env.events().all(); - let deposit_event = events.iter().find(|e| { - // match on the "deposit" topic symbol - true // replace with real topic matching per repo's existing event-test helpers - }); - assert!(deposit_event.is_some(), "expected a deposit event to be emitted"); -} +#[cfg(test)] +mod settlement_tests { + extern crate std; + + use crate::{CalloraSettlement, CalloraSettlementClient, SettlementError, StorageKey}; + use soroban_sdk::testutils::{Address as _, Events as _, Ledger as _}; + use soroban_sdk::{token, Address, BytesN, Env, Error, InvokeError, Symbol, TryFromVal}; + + fn setup_contract() -> (Env, Address, Address, Address, Address, Address) { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + client.init(&admin, &vault); + let third_party = Address::generate(&env); + let token = Address::generate(&env); + (env, addr, admin, vault, third_party, token) + } + + fn is_error, E: Into>( + result: Result, Result>, + expected: SettlementError, + ) -> bool { + let expected_code = expected as u32; + match result { + Err(Ok(e)) => e.into().get_code() == expected_code, + _ => false, + } + } + + fn create_usdc<'a>( + env: &'a Env, + admin: &Address, + ) -> (Address, token::Client<'a>, token::StellarAssetClient<'a>) { + let contract_address = env.register_stellar_asset_contract_v2(admin.clone()); + let address = contract_address.address(); + let client = token::Client::new(env, &address); + let admin_client = token::StellarAssetClient::new(env, &address); + (address, client, admin_client) + } + + #[test] + fn test_settlement_initialization() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1_700_000_000); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let developer = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + let token = Address::generate(&env); + + client.init(&admin, &vault); + + env.as_contract(&addr, || { + let inst = env.storage().instance(); + assert!(inst.has(&StorageKey::Admin)); + assert!(inst.has(&StorageKey::Vault)); + assert!(inst.has(&StorageKey::GlobalPool)); + // DeveloperIndex is written lazily on first payment, not at init + }); + + assert_eq!(client.get_admin(), admin); + assert_eq!(client.get_vault(), vault); + + let global_pool = client.get_global_pool(); + assert_eq!(global_pool.total_balance, 0); + assert_eq!(global_pool.last_updated, 1_700_000_000); + + let all_balances = client.get_all_developer_balances(&admin, &token); + assert_eq!(all_balances.len(), 0); + assert_eq!(client.get_developer_balance(&developer, &token), 0); + } + #[test] + #[should_panic(expected = "invalid config: admin and vault_address must be distinct")] + fn test_init_admin_equals_vault_panics() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + + // Passing the same address for admin and vault should be rejected. + client.init(&admin, &admin); + } + + #[test] + #[should_panic(expected = "invalid config: admin cannot be the contract itself")] + fn test_init_admin_is_contract_panics() { + let env = Env::default(); + env.mock_all_auths(); + let vault = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + + // Passing the contract's own address as admin should be rejected. + client.init(&addr, &vault); + } + + #[test] + #[should_panic(expected = "invalid config: vault_address cannot be the contract itself")] + fn test_init_vault_is_contract_panics() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + + // Passing the contract's own address as vault_address should be rejected. + client.init(&admin, &addr); + } + + #[test] + fn test_init_requires_admin_signature() { + 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); + + env.set_auths(&[]); + let result = client.try_init(&admin, &vault); + assert!( + result.is_err(), + "expected init to require the admin signature" + ); + } + + #[test] + fn test_receive_payment_to_pool() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + client.init(&admin, &vault); + let token = Address::generate(&env); + + client.receive_payment(&vault, &1000i128, &true, &None, &token); + + let global_pool = client.get_global_pool(); + assert_eq!(global_pool.total_balance, 1000i128); + } + + #[test] + fn test_receive_payment_to_developer() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let developer = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + client.init(&admin, &vault); + let token = Address::generate(&env); + + client.receive_payment(&vault, &500i128, &false, &Some(developer.clone()), &token); + + assert_eq!(client.get_developer_balance(&developer, &token), 500i128); + assert_eq!(client.get_global_pool().total_balance, 0); + } + + #[test] + fn test_receive_multiple_payments_accumulate() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let developer = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + client.init(&admin, &vault); + let token = Address::generate(&env); + + client.receive_payment(&vault, &100i128, &false, &Some(developer.clone()), &token); + client.receive_payment(&vault, &150i128, &false, &Some(developer.clone()), &token); + + assert_eq!(client.get_developer_balance(&developer, &token), 250i128); + } + + #[test] + fn test_get_developer_balance_when_empty() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let developer = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + client.init(&admin, &vault); + let token = Address::generate(&env); + + let balance = client.get_developer_balance(&developer, &token); + assert_eq!(balance, 0); + } + + #[test] + fn test_get_all_developer_balances_when_empty() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + client.init(&admin, &vault); + let token = Address::generate(&env); + + let all = client.get_all_developer_balances(&admin, &token); + assert_eq!(all.len(), 0); + } + + #[test] + fn test_admin_can_receive_payment_to_pool() { + // Admin can route payments directly to global pool (not just via vault) + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + client.init(&admin, &vault); + let token = Address::generate(&env); + client.receive_payment(&admin, &100i128, &true, &None, &token); + } + + #[test] + fn test_admin_can_receive_payment_to_developer() { + // Admin routing a payment directly to a developer (not via vault) + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let developer = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + client.init(&admin, &vault); + let token = Address::generate(&env); + + client.receive_payment(&admin, &200i128, &false, &Some(developer.clone()), &token); + + assert_eq!(client.get_developer_balance(&developer, &token), 200i128); + } + + #[test] + fn test_pool_accumulates_across_multiple_payments() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + client.init(&admin, &vault); + let token = Address::generate(&env); + + client.receive_payment(&vault, &400i128, &true, &None, &token); + client.receive_payment(&vault, &600i128, &true, &None, &token); + + assert_eq!(client.get_global_pool().total_balance, 1000i128); + } + + #[test] + fn test_get_developer_balance_returns_zero_for_unknown() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let stranger = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + client.init(&admin, &vault); + let token = Address::generate(&env); + + assert_eq!(client.get_developer_balance(&stranger, &token), 0i128); + } + + #[test] + fn test_simulate_claim_returns_preview_without_side_effects() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(86_400); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let developer = Address::generate(&env); + let recipient = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + let (usdc_address, usdc, usdc_admin_client) = create_usdc(&env, &admin); + + client.init(&admin, &vault); + client.set_usdc_token(&admin, &usdc_address); + client.receive_payment(&vault, &500i128, &false, &Some(developer.clone()), &usdc_address); + client.set_daily_withdraw_cap(&admin, &developer, &400i128); + usdc_admin_client.mint(&addr, &500i128); + + let preview = client + .try_simulate_claim(&developer, &250i128, &Some(recipient.clone())) + .unwrap() + .unwrap(); + + assert_eq!(preview.developer, developer); + assert_eq!(preview.amount, 250i128); + assert_eq!(preview.recipient, recipient); + assert_eq!(preview.token, usdc_address); + assert_eq!(preview.current_balance, 500i128); + assert_eq!(preview.remaining_balance, 250i128); + assert_eq!(preview.contract_balance, 500i128); + assert_eq!(preview.daily_withdraw_cap, 400i128); + assert_eq!(preview.withdrawn_today, 0i128); + assert_eq!(preview.withdrawn_today_after, 250i128); + + assert_eq!(client.get_developer_balance(&developer, &usdc_address), 500i128); + assert_eq!(client.get_withdrawal_today(&developer), 0i128); + assert_eq!(usdc.balance(&addr), 500i128); + assert_eq!(usdc.balance(&recipient), 0i128); + } + + #[test] + fn test_simulate_claim_reuses_claim_validation_errors() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(50); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let developer = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + let (usdc_address, _, usdc_admin_client) = create_usdc(&env, &admin); + + client.init(&admin, &vault); + client.set_usdc_token(&admin, &usdc_address); + client.receive_payment(&vault, &100i128, &false, &Some(developer.clone()), &usdc_address); + usdc_admin_client.mint(&addr, &100i128); + + let zero = client.try_simulate_claim(&developer, &0i128, &None); + assert!(is_error(zero, SettlementError::AmountNotPositive)); + + let overdraw = client.try_simulate_claim(&developer, &101i128, &None); + assert!(is_error(overdraw, SettlementError::InsufficientDeveloperBalance)); + + client + .try_set_developer_claim_window(&admin, &developer, &100u64, &200u64) + .unwrap() + .unwrap(); + let closed = client.try_simulate_claim(&developer, &100i128, &None); + assert!(is_error(closed, SettlementError::ClaimWindowClosed)); + } + + #[test] + fn test_withdraw_developer_balance_succeeds_exact_balance() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let developer = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + let (usdc_address, _, usdc_admin_client) = create_usdc(&env, &admin); + + client.init(&admin, &vault); + client.set_usdc_token(&admin, &usdc_address); + client.receive_payment(&vault, &100i128, &false, &Some(developer.clone()), &usdc_address); + usdc_admin_client.mint(&addr, &100i128); + + let result = client.try_withdraw_developer_balance(&developer, &100i128, &None, &usdc_address); + assert!(result.is_ok()); + assert_eq!(client.get_developer_balance(&developer, &usdc_address), 0i128); + assert_eq!( + token::Client::new(&env, &usdc_address).balance(&addr), + 0i128 + ); + assert_eq!( + token::Client::new(&env, &usdc_address).balance(&developer), + 100i128 + ); + } + + #[test] + fn test_withdraw_developer_balance_rejects_overdraw() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let developer = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + let (usdc_address, _, usdc_admin_client) = create_usdc(&env, &admin); + + client.init(&admin, &vault); + client.set_usdc_token(&admin, &usdc_address); + client.receive_payment(&vault, &100i128, &false, &Some(developer.clone()), &usdc_address); + usdc_admin_client.mint(&addr, &100i128); + + let result = client.try_withdraw_developer_balance(&developer, &101i128, &None, &usdc_address); + assert!(result.is_err()); + assert_eq!(client.get_developer_balance(&developer, &usdc_address), 100i128); + } + + #[test] + fn test_withdraw_developer_balance_rejects_non_positive_amount() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let developer = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + let token = Address::generate(&env); + + client.init(&admin, &vault); + + let zero_result = client.try_withdraw_developer_balance(&developer, &0i128, &None, &token); + let negative_result = client.try_withdraw_developer_balance(&developer, &-1i128, &None, &token); + + assert!(zero_result.is_err()); + assert!(negative_result.is_err()); + } + + #[test] + fn test_withdraw_developer_balance_emits_event() { + use soroban_sdk::testutils::Events as _; + use soroban_sdk::{IntoVal, Symbol}; + + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let developer = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + let (usdc_address, _, usdc_admin_client) = create_usdc(&env, &admin); + + client.init(&admin, &vault); + client.set_usdc_token(&admin, &usdc_address); + client.receive_payment(&vault, &200i128, &false, &Some(developer.clone()), &usdc_address); + usdc_admin_client.mint(&addr, &200i128); + + let result = client.try_withdraw_developer_balance(&developer, &200i128, &None, &usdc_address); + assert!(result.is_ok()); + + let events = env.events().all(); + let ev = events + .iter() + .find(|e| { + !e.1.is_empty() && { + let t: Symbol = e.1.get(0).unwrap().into_val(&env); + t == Symbol::new(&env, "developer_withdraw") + } + }) + .expect("expected developer_withdraw event"); + + let topic1: Address = ev.1.get(1).unwrap().into_val(&env); + assert_eq!(topic1, developer); + + let data: crate::DeveloperWithdrawEvent = ev.2.into_val(&env); + assert_eq!(data.developer, developer); + assert_eq!(data.amount, 200i128); + assert_eq!(data.remaining_balance, 0i128); + assert_eq!(data.to, developer); + } + + #[test] + fn test_withdraw_developer_balance_to_custodial_address() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let developer = Address::generate(&env); + let custodial = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + let (usdc_address, _, usdc_admin_client) = create_usdc(&env, &admin); + + client.init(&admin, &vault); + client.set_usdc_token(&admin, &usdc_address); + client.receive_payment(&vault, &150i128, &false, &Some(developer.clone()), &usdc_address); + usdc_admin_client.mint(&addr, &150i128); + + let result = + client.try_withdraw_developer_balance(&developer, &150i128, &Some(custodial.clone())); + assert!(result.is_ok()); + assert_eq!(client.get_developer_balance(&developer, &usdc_address), 0i128); + assert_eq!( + token::Client::new(&env, &usdc_address).balance(&addr), + 0i128 + ); + assert_eq!( + token::Client::new(&env, &usdc_address).balance(&custodial), + 150i128 + ); + } + + #[test] + fn test_withdraw_developer_balance_emits_event_with_custodial_to() { + use soroban_sdk::testutils::Events as _; + use soroban_sdk::{IntoVal, Symbol}; + + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let developer = Address::generate(&env); + let custodial = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + let (usdc_address, _, usdc_admin_client) = create_usdc(&env, &admin); + + client.init(&admin, &vault); + client.set_usdc_token(&admin, &usdc_address); + client.receive_payment(&vault, &200i128, &false, &Some(developer.clone()), &usdc_address); + usdc_admin_client.mint(&addr, &200i128); + + let result = + client.try_withdraw_developer_balance(&developer, &200i128, &Some(custodial.clone())); + assert!(result.is_ok()); + + let events = env.events().all(); + let ev = events + .iter() + .find(|e| { + !e.1.is_empty() && { + let t: Symbol = e.1.get(0).unwrap().into_val(&env); + t == Symbol::new(&env, "developer_withdraw") + } + }) + .expect("expected developer_withdraw event"); + + let data: crate::DeveloperWithdrawEvent = ev.2.into_val(&env); + assert_eq!(data.developer, developer); + assert_eq!(data.amount, 200i128); + assert_eq!(data.remaining_balance, 0i128); + assert_eq!(data.to, custodial); + } + + #[test] + #[should_panic(expected = "invalid recipient: cannot withdraw to contract itself")] + fn test_withdraw_developer_balance_rejects_self_address() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let developer = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + let (usdc_address, _, usdc_admin_client) = create_usdc(&env, &admin); + + client.init(&admin, &vault); + client.set_usdc_token(&admin, &usdc_address); + client.receive_payment(&vault, &100i128, &false, &Some(developer.clone()), &usdc_address); + usdc_admin_client.mint(&addr, &100i128); + + client.withdraw_developer_balance(&developer, &100i128, &Some(addr.clone()), &usdc_address); + } + + #[test] + fn test_get_all_developer_balances() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let dev1 = Address::generate(&env); + let dev2 = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + client.init(&admin, &vault); + let token = Address::generate(&env); + + client.receive_payment(&vault, &300i128, &false, &Some(dev1.clone()), &token); + client.receive_payment(&vault, &200i128, &false, &Some(dev2.clone()), &token); + client.receive_payment(&vault, &150i128, &false, &Some(dev1.clone()), &token); + + let all = client.get_all_developer_balances(&admin, &token); + assert_eq!(all.len(), 2); + let mut dev1_seen = false; + let mut dev2_seen = false; + for balance in all.iter() { + if balance.address == dev1 { + assert_eq!(balance.balance, 450i128); + dev1_seen = true; + } else if balance.address == dev2 { + assert_eq!(balance.balance, 200i128); + dev2_seen = true; + } else { + panic!("unexpected developer in get_all_developer_balances"); + } + } + assert!(dev1_seen); + assert!(dev2_seen); + } + + #[test] + fn test_get_all_developer_balances_empty() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + client.init(&admin, &vault); + let token = Address::generate(&env); + + let all = client.get_all_developer_balances(&admin, &token); + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let dev1 = Address::generate(&env); + let dev2 = Address::generate(&env); + let dev3 = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + client.init(&admin, &vault); + let token = Address::generate(&env); + + client.receive_payment(&vault, &100i128, &false, &Some(dev1.clone()), &token); + client.receive_payment(&vault, &200i128, &false, &Some(dev2.clone()), &token); + client.receive_payment(&vault, &300i128, &false, &Some(dev3.clone()), &token); + + let page = client.get_developer_balances_page(&admin, &1u32, &2u32, &token); + assert_eq!(page.len(), 2); + assert_eq!(page.get(0).unwrap().address, dev2); + assert_eq!(page.get(1).unwrap().address, dev3); + } + + #[test] + fn test_get_developer_balances_page_respects_limit_cap() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + client.init(&admin, &vault); + let token = Address::generate(&env); + + for _ in 0..105 { + let developer = Address::generate(&env); + client.receive_payment(&vault, &1i128, &false, &Some(developer), &token); + } + + // limit higher than MAX should be capped at MAX_DEVELOPER_BALANCES_PAGE_SIZE (100) + let page = client.get_developer_balances_page(&admin, &0u32, &200u32, &token); + assert_eq!(page.len(), 100); + } + + #[test] + fn test_get_all_developer_balances_large_index() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + client.init(&admin, &vault); + let token = Address::generate(&env); + + for _ in 0..101 { + let developer = Address::generate(&env); + client.receive_payment(&vault, &1i128, &false, &Some(developer), &token); + } + + let all = client.get_all_developer_balances(&admin, &token); + assert_eq!(all.len(), 101); + } + + #[test] + fn test_set_admin_two_step() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let new_admin = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + client.init(&admin, &vault); + + client.set_admin(&admin, &new_admin); + assert_eq!(client.get_admin(), admin); // Still old admin + + client.accept_admin(); + assert_eq!(client.get_admin(), new_admin); + } + + #[test] + fn test_get_pending_admin_none_before_nomination() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + client.init(&admin, &vault); + + assert_eq!(client.get_pending_admin(), None); + } + + #[test] + fn test_get_pending_admin_some_after_nomination() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let new_admin = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + client.init(&admin, &vault); + + client.set_admin(&admin, &new_admin); + assert_eq!(client.get_pending_admin(), Some(new_admin.clone())); + + // clears after acceptance + client.accept_admin(); + assert_eq!(client.get_pending_admin(), None); + } + + #[test] + #[should_panic(expected = "no admin transfer pending")] + fn test_accept_admin_fails_if_not_nominated() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + client.init(&admin, &vault); + + client.accept_admin(); + } + + #[test] + fn test_set_admin_unauthorized() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let new_admin = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + client.init(&admin, &vault); + + let result = client.try_set_admin(&vault, &new_admin); + assert!(is_error(result, SettlementError::Unauthorized)); + } + + #[test] + fn test_cancel_admin_transfer_success() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let new_admin = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + client.init(&admin, &vault); + + client.set_admin(&admin, &new_admin); + assert_eq!(client.get_pending_admin(), Some(new_admin.clone())); + + client.cancel_admin_transfer(&admin); + assert_eq!(client.get_pending_admin(), None); + + let events = env.events().all(); + let last_event = events.last().unwrap(); + let event_name = Symbol::try_from_val(&env, &last_event.1.get(0).unwrap()).unwrap(); + assert_eq!(event_name, Symbol::new(&env, "admin_cancelled")); + } + + #[test] + fn test_cancel_admin_transfer_unauthorized() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let new_admin = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + client.init(&admin, &vault); + + client.set_admin(&admin, &new_admin); + + let result = client.try_cancel_admin_transfer(&vault); + assert!(is_error(result, SettlementError::Unauthorized)); + } + + #[test] + #[should_panic(expected = "no admin transfer pending")] + fn test_cancel_admin_transfer_no_pending_panics() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + client.init(&admin, &vault); + + client.cancel_admin_transfer(&admin); + } + + #[test] + fn test_set_vault_unauthorized() { + let env = Env::default(); + env.mock_all_auths(); + let admin = 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); + + let attacker = Address::generate(&env); + let result = client.try_set_vault(&attacker, &new_vault); + assert!(is_error(result, SettlementError::Unauthorized)); + } + + #[test] + fn test_propose_and_accept_vault_happy_path() { + let env = Env::default(); + env.mock_all_auths(); + let admin = 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); + + // Step 1: propose by admin + client.propose_vault(&admin, &new_vault); + assert_eq!(client.get_vault(), vault); // still old until accepted + + // Step 2: accept by pending vault + client.accept_vault(&new_vault); + assert_eq!(client.get_vault(), new_vault); + } + + #[test] + fn test_propose_vault_emits_event() { + use soroban_sdk::testutils::Events as _; + use soroban_sdk::{IntoVal, Symbol}; + + let env = Env::default(); + env.mock_all_auths(); + let admin = 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); + + client.propose_vault(&admin, &new_vault); + + let events = env.events().all(); + let ev = events + .iter() + .find(|e| { + !e.1.is_empty() && { + let t: Symbol = e.1.get(0).unwrap().into_val(&env); + t == Symbol::new(&env, "vault_proposed") + } + }) + .expect("expected vault_proposed event"); + + let topic1: Address = ev.1.get(1).unwrap().into_val(&env); + assert_eq!(topic1, admin); + + let data: crate::VaultProposedEvent = ev.2.into_val(&env); + assert_eq!(data.current_vault, vault); + assert_eq!(data.proposed_vault, new_vault); + } + + #[test] + fn test_accept_vault_emits_event() { + use soroban_sdk::testutils::Events as _; + use soroban_sdk::{IntoVal, Symbol}; + + let env = Env::default(); + env.mock_all_auths(); + let admin = 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); + + client.propose_vault(&admin, &new_vault); + client.accept_vault(&new_vault); + + let events = env.events().all(); + let ev = events + .iter() + .find(|e| { + !e.1.is_empty() && { + let t: Symbol = e.1.get(0).unwrap().into_val(&env); + t == Symbol::new(&env, "vault_accepted") + } + }) + .expect("expected vault_accepted event"); + + let topic1: Address = ev.1.get(1).unwrap().into_val(&env); + assert_eq!(topic1, new_vault); + + let data: crate::VaultAcceptedEvent = ev.2.into_val(&env); + assert_eq!(data.old_vault, vault); + assert_eq!(data.new_vault, new_vault); + assert_eq!(data.accepted_by, new_vault); + } + + // ── admin rotation edge cases ──────────────────────────────────────────── + + #[test] + fn test_set_admin_to_same_address_succeeds() { + // Admin can nominate themselves again (useful for re-confirming control) + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + client.init(&admin, &vault); + + client.set_admin(&admin, &admin); + // Still current admin until accept + assert_eq!(client.get_admin(), admin); + + client.accept_admin(); + assert_eq!(client.get_admin(), admin); + } + + #[test] + fn test_set_vault_to_same_address_succeeds() { + // Admin can propose + accept the same vault (no-op but valid) + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + client.init(&admin, &vault); + + client.propose_vault(&admin, &vault); + client.accept_vault(&vault); + assert_eq!(client.get_vault(), vault); + } + + #[test] + fn test_rapid_consecutive_admin_updates() { + // Admin can change nomination before acceptance + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let new_admin1 = Address::generate(&env); + let new_admin2 = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + client.init(&admin, &vault); + + // First nomination + client.set_admin(&admin, &new_admin1); + // Change nomination before acceptance + client.set_admin(&admin, &new_admin2); + // Only second nominee can accept + client.accept_admin(); + assert_eq!(client.get_admin(), new_admin2); + } + + #[test] + fn test_admin_cannot_accept_own_nomination() { + // Current admin cannot bypass two-step process + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + client.init(&admin, &vault); + + client.set_admin(&admin, &admin); + // Admin must still accept to complete transfer + client.accept_admin(); + assert_eq!(client.get_admin(), admin); + } + + #[test] + fn test_pending_admin_cannot_set_admin() { + // Pending admin has no privileges until accepted + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let new_admin = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + client.init(&admin, &vault); + + client.set_admin(&admin, &new_admin); + // New admin tries to set another admin before accepting + let result = client.try_set_admin(&new_admin, &vault); + assert!(is_error(result, SettlementError::Unauthorized)); + } + + #[test] + fn test_vault_update_after_admin_rotation() { + // Ensure vault updates work correctly after admin change + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let new_admin = Address::generate(&env); + let new_vault = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + client.init(&admin, &vault); + + // Rotate admin + client.set_admin(&admin, &new_admin); + client.accept_admin(); + + // New admin updates vault + client.propose_vault(&new_admin, &new_vault); + client.accept_vault(&new_vault); + assert_eq!(client.get_vault(), new_vault); + assert_eq!(client.get_admin(), new_admin); + } + + #[test] + fn test_admin_rotation_preserves_state() { + // Admin rotation doesn't affect pool or balances + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let developer = Address::generate(&env); + let new_admin = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + client.init(&admin, &vault); + let token = Address::generate(&env); + + // Add some balance + client.receive_payment(&vault, &1000i128, &false, &Some(developer.clone()), &token); + let dev_balance_before = client.get_developer_balance(&developer, &token); + let pool_before = client.get_global_pool(); + + // Rotate admin + client.set_admin(&admin, &new_admin); + client.accept_admin(); + + // State preserved + assert_eq!(client.get_developer_balance(&developer, &token), dev_balance_before); + assert_eq!( + client.get_global_pool().total_balance, + pool_before.total_balance + ); + } + + // ── event emission tests ──────────────────────────────────────────────── + + #[test] + fn test_set_admin_emits_nomination_event() { + use soroban_sdk::testutils::Events as _; + use soroban_sdk::{IntoVal, Symbol}; + + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let new_admin = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + client.init(&admin, &vault); + + client.set_admin(&admin, &new_admin); + + let events = env.events().all(); + let nom_ev = events + .iter() + .find(|e| { + !e.1.is_empty() && { + let t: Symbol = e.1.get(0).unwrap().into_val(&env); + t == Symbol::new(&env, "admin_nominated") + } + }) + .expect("expected admin_nominated event"); + + let topic_current: Address = nom_ev.1.get(1).unwrap().into_val(&env); + let topic_new: Address = nom_ev.1.get(2).unwrap().into_val(&env); + assert_eq!(topic_current, admin); + assert_eq!(topic_new, new_admin); + } + + #[test] + fn test_accept_admin_emits_accepted_event() { + use soroban_sdk::testutils::Events as _; + use soroban_sdk::{IntoVal, Symbol}; + + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let new_admin = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + client.init(&admin, &vault); + + client.set_admin(&admin, &new_admin); + client.accept_admin(); + + let events = env.events().all(); + let acc_ev = events + .iter() + .find(|e| { + !e.1.is_empty() && { + let t: Symbol = e.1.get(0).unwrap().into_val(&env); + t == Symbol::new(&env, "admin_accepted") + } + }) + .expect("expected admin_accepted event"); + + let topic_old: Address = acc_ev.1.get(1).unwrap().into_val(&env); + let topic_new: Address = acc_ev.1.get(2).unwrap().into_val(&env); + assert_eq!(topic_old, admin); + assert_eq!(topic_new, new_admin); + } + + // ── panic / error paths ────────────────────────────────────────────────── + + #[test] + fn test_double_init_returns_already_initialized() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + client.init(&admin, &vault); + let result = client.try_init(&admin, &vault); + assert!( + is_error(result, SettlementError::AlreadyInitialized), + "expected AlreadyInitialized" + ); + } + + #[test] + fn test_receive_payment_zero_amount() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + client.init(&admin, &vault); + let token = Address::generate(&env); + + let result = client.try_receive_payment(&vault, &0i128, &true, &None, &token); + assert!(is_error(result, SettlementError::AmountNotPositive)); + } + + #[test] + fn test_receive_payment_negative_amount() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + client.init(&admin, &vault); + let token = Address::generate(&env); + + let result = client.try_receive_payment(&vault, &-1i128, &true, &None, &token); + assert!(is_error(result, SettlementError::AmountNotPositive)); + } + + #[test] + fn test_receive_payment_to_pool_overflow() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + client.init(&admin, &vault); + let token = Address::generate(&env); + + env.as_contract(&addr, || { + let inst = env.storage().instance(); + let pool = crate::GlobalPool { + total_balance: i128::MAX, + last_updated: env.ledger().timestamp(), + }; + inst.set(&crate::StorageKey::GlobalPool, &pool); + }); + + let result = client.try_receive_payment(&vault, &1i128, &true, &None, &token); + assert!(is_error(result, SettlementError::PoolOverflow)); + } + + #[test] + fn test_receive_payment_to_developer_overflow() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let developer = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + client.init(&admin, &vault); + let token = Address::generate(&env); + + env.as_contract(&addr, || { + env.storage().persistent().set( + &crate::StorageKey::DeveloperBalance(developer.clone(), token.clone()), + &i128::MAX, + ); + }); + + let result = client.try_receive_payment(&vault, &1i128, &false, &Some(developer), &token); + assert!(is_error(result, SettlementError::DeveloperOverflow)); + } + + #[test] + fn test_receive_payment_pool_false_no_developer() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + client.init(&admin, &vault); + let token = Address::generate(&env); + + let result = client.try_receive_payment(&vault, &100i128, &false, &None, &token); + assert!(is_error(result, SettlementError::DeveloperRequired)); + } + + #[test] + fn test_receive_payment_pool_true_with_developer() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let developer = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + client.init(&admin, &vault); + let token = Address::generate(&env); + + let result = client.try_receive_payment(&vault, &100i128, &true, &Some(developer), &token); + assert!(is_error(result, SettlementError::DeveloperMustBeNone)); + } + + #[test] + fn test_receive_payment_authorization_matrix() { + enum CallerRole { + Vault, + Admin, + ThirdParty, + } + + struct Case { + name: &'static str, + role: CallerRole, + should_succeed: bool, + } + + let cases = [ + Case { + name: "vault address succeeds", + role: CallerRole::Vault, + should_succeed: true, + }, + Case { + name: "admin address succeeds", + role: CallerRole::Admin, + should_succeed: true, + }, + Case { + name: "third party fails", + role: CallerRole::ThirdParty, + should_succeed: false, + }, + ]; + + for case in cases { + let (env, addr, admin, vault, third_party, token) = setup_contract(); + let client = CalloraSettlementClient::new(&env, &addr); + let caller = match case.role { + CallerRole::Vault => vault, + CallerRole::Admin => admin, + CallerRole::ThirdParty => third_party, + }; + + let result = client.try_receive_payment(&caller, &100i128, &true, &None, &token); + + if case.should_succeed { + assert!(result.is_ok(), "expected success for case: {}", case.name); + assert_eq!(client.get_global_pool().total_balance, 100i128); + } else { + assert!( + is_error(result, SettlementError::Unauthorized), + "expected Unauthorized for case: {}", + case.name + ); + } + } + } + + // ── event shape tests ──────────────────────────────────────────────────── + + #[test] + fn test_payment_received_event_to_pool() { + use soroban_sdk::testutils::Events as _; + use soroban_sdk::{IntoVal, Symbol}; + + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + client.init(&admin, &vault); + let token = Address::generate(&env); + + client.receive_payment(&vault, &1000i128, &true, &None, &token); + + let events = env.events().all(); + let ev = events + .iter() + .find(|e| { + !e.1.is_empty() && { + let t: Symbol = e.1.get(0).unwrap().into_val(&env); + t == Symbol::new(&env, "payment_received") + } + }) + .expect("expected payment_received event"); + + let topic1: Address = ev.1.get(1).unwrap().into_val(&env); + assert_eq!(topic1, vault); + + let data: crate::PaymentReceivedEvent = ev.2.into_val(&env); + assert_eq!(data.from_vault, vault); + assert_eq!(data.amount, 1000i128); + assert!(data.to_pool); + assert!(data.developer.is_none()); + } + + /// Snapshot: asserts the full `PaymentReceivedEvent` struct shape and values + /// in a single comparison, for the to_pool=true branch. Any future change to + /// the struct's fields or this call's emitted values will fail this test, + /// making accidental output-structure drift impossible to miss. + #[test] + fn test_payment_received_event_snapshot_to_pool() { + use soroban_sdk::testutils::Events as _; + use soroban_sdk::{IntoVal, Symbol}; + + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + client.init(&admin, &vault); + + client.receive_payment(&vault, &750i128, &true, &None); + + let events = env.events().all(); + let ev = events + .iter() + .find(|e| { + !e.1.is_empty() && { + let t: Symbol = e.1.get(0).unwrap().into_val(&env); + t == Symbol::new(&env, "payment_received") + } + }) + .expect("expected payment_received event"); + + let data: crate::PaymentReceivedEvent = ev.2.into_val(&env); + + let expected = crate::PaymentReceivedEvent { + from_vault: vault.clone(), + amount: 750i128, + to_pool: true, + developer: None, + }; + + assert_eq!(data, expected); + } + + /// Snapshot: asserts the full `PaymentReceivedEvent` struct shape and values + /// in a single comparison, for the to_pool=false (developer) branch. + #[test] + fn test_payment_received_event_snapshot_to_developer() { + use soroban_sdk::testutils::Events as _; + use soroban_sdk::{IntoVal, Symbol}; + + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let developer = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + client.init(&admin, &vault); + + client.receive_payment(&vault, &321i128, &false, &Some(developer.clone())); + + let events = env.events().all(); + let ev = events + .iter() + .find(|e| { + !e.1.is_empty() && { + let t: Symbol = e.1.get(0).unwrap().into_val(&env); + t == Symbol::new(&env, "payment_received") + } + }) + .expect("expected payment_received event"); + + let data: crate::PaymentReceivedEvent = ev.2.into_val(&env); + + let expected = crate::PaymentReceivedEvent { + from_vault: vault.clone(), + amount: 321i128, + to_pool: false, + developer: Some(developer.clone()), + }; + + assert_eq!(data, expected); + } + + #[test] + fn test_payment_received_and_balance_credited_events_to_developer() { + use soroban_sdk::testutils::Events as _; + use soroban_sdk::{IntoVal, Symbol}; + + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let developer = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + client.init(&admin, &vault); + let token = Address::generate(&env); + + client.receive_payment(&vault, &500i128, &false, &Some(developer.clone()), &token); + + let events = env.events().all(); + let pr_ev = events + .iter() + .find(|e| { + !e.1.is_empty() && { + let t: Symbol = e.1.get(0).unwrap().into_val(&env); + t == Symbol::new(&env, "payment_received") + } + }) + .expect("expected payment_received event"); + + let pr_data: crate::PaymentReceivedEvent = pr_ev.2.into_val(&env); + assert!(!pr_data.to_pool); + assert_eq!(pr_data.developer, Some(developer.clone())); + + let bc_ev = events + .iter() + .find(|e| { + !e.1.is_empty() && { + let t: Symbol = e.1.get(0).unwrap().into_val(&env); + t == Symbol::new(&env, "balance_credited") + } + }) + .expect("expected balance_credited event"); + + let topic1: Address = bc_ev.1.get(1).unwrap().into_val(&env); + assert_eq!(topic1, developer); + + let bc_data: crate::BalanceCreditedEvent = bc_ev.2.into_val(&env); + assert_eq!(bc_data.developer, developer); + assert_eq!(bc_data.amount, 500i128); + assert_eq!(bc_data.new_balance, 500i128); + } + + #[test] + fn test_balance_credited_new_balance_is_cumulative() { + use soroban_sdk::testutils::Events as _; + use soroban_sdk::{IntoVal, Symbol}; + + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let developer = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + client.init(&admin, &vault); + let token = Address::generate(&env); + + client.receive_payment(&vault, &300i128, &false, &Some(developer.clone()), &token); + client.receive_payment(&vault, &200i128, &false, &Some(developer.clone()), &token); + + // grab the last balance_credited event + let events = env.events().all(); + let bc_ev = events + .iter() + .rev() + .find(|e| { + !e.1.is_empty() && { + let t: Symbol = e.1.get(0).unwrap().into_val(&env); + t == Symbol::new(&env, "balance_credited") + } + }) + .expect("expected balance_credited event"); + + let bc_data: crate::BalanceCreditedEvent = bc_ev.2.into_val(&env); + assert_eq!(bc_data.new_balance, 500i128); + } + + // ── regression tests: ensure settlement logic intact after rotation ───── + + #[test] + fn test_receive_payment_works_after_admin_rotation() { + // Ensure payment processing still works after admin change + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let new_admin = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + client.init(&admin, &vault); + let token = Address::generate(&env); + + // Rotate admin + client.set_admin(&admin, &new_admin); + client.accept_admin(); + + // Vault can still send payments + client.receive_payment(&vault, &1000i128, &true, &None, &token); + assert_eq!(client.get_global_pool().total_balance, 1000i128); + } + + #[test] + fn test_receive_payment_works_after_vault_update() { + // Ensure payment processing works with new vault address + let env = Env::default(); + env.mock_all_auths(); + let admin = 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); + let token = Address::generate(&env); + + // Update vault + client.propose_vault(&admin, &new_vault); + client.accept_vault(&new_vault); + + // Old vault cannot send payments + let result = client.try_receive_payment(&vault, &1000i128, &true, &None, &token); + assert!(is_error(result, SettlementError::Unauthorized)); + + // New vault can send payments + client.receive_payment(&new_vault, &1000i128, &true, &None, &token); + assert_eq!(client.get_global_pool().total_balance, 1000i128); + } + + #[test] + fn test_developer_withdrawal_after_admin_rotation() { + // Ensure developer balances accessible after admin change + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let developer = Address::generate(&env); + let new_admin = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + client.init(&admin, &vault); + let token = Address::generate(&env); + + // Credit developer + client.receive_payment(&vault, &500i128, &false, &Some(developer.clone()), &token); + + // Rotate admin + client.set_admin(&admin, &new_admin); + client.accept_admin(); + + // Balance still accessible + assert_eq!(client.get_developer_balance(&developer, &token), 500i128); + + // Admin can still view all balances + let all_balances = client.get_all_developer_balances(&new_admin, &token); + assert_eq!(all_balances.len(), 1); + assert_eq!(all_balances.get(0).unwrap().balance, 500i128); + } + + #[test] + fn test_multiple_payments_accumulate_after_vault_update() { + // Ensure accumulation logic works correctly after vault changes + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let new_vault = Address::generate(&env); + let developer = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + client.init(&admin, &vault); + let token = Address::generate(&env); + + // Some payments from old vault + client.receive_payment(&vault, &100i128, &false, &Some(developer.clone()), &token); + + // Update vault + client.propose_vault(&admin, &new_vault); + client.accept_vault(&new_vault); + + // More payments from new vault + client.receive_payment(&new_vault, &150i128, &false, &Some(developer.clone()), &token); + client.receive_payment(&new_vault, &200i128, &false, &Some(developer.clone()), &token); + + // Total should accumulate correctly + assert_eq!(client.get_developer_balance(&developer, &token), 450i128); + } + + #[test] + fn test_global_pool_timestamp_updates_after_admin_change() { + // Ensure pool timestamp updates correctly regardless of admin + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1_700_000_000); + + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let new_admin = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + client.init(&admin, &vault); + let token = Address::generate(&env); + + // Initial payment + client.receive_payment(&vault, &1000i128, &true, &None, &token); + let pool_before = client.get_global_pool(); + assert_eq!(pool_before.last_updated, 1_700_000_000); + + // Rotate admin and advance time + client.set_admin(&admin, &new_admin); + client.accept_admin(); + env.ledger().set_timestamp(1_700_000_100); + + // New payment updates timestamp + client.receive_payment(&vault, &500i128, &true, &None, &token); + let pool_after = client.get_global_pool(); + assert_eq!(pool_after.last_updated, 1_700_000_100); + assert_eq!(pool_after.total_balance, 1500i128); + } + + /// `last_updated` reflects the ledger timestamp at the moment of each pool credit. + #[test] + fn test_global_pool_last_updated_on_receive_payment() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + let token = Address::generate(&env); + + env.ledger().set_timestamp(1_000); + client.init(&admin, &vault); + assert_eq!(client.get_global_pool().last_updated, 1_000); + + // Advance time and credit pool � last_updated must change + env.ledger().set_timestamp(2_000); + client.receive_payment(&vault, &100i128, &true, &None, &token); + let pool = client.get_global_pool(); + assert_eq!(pool.last_updated, 2_000); + assert_eq!(pool.total_balance, 100i128); + + // Advance again � each credit stamps the new time + env.ledger().set_timestamp(3_000); + client.receive_payment(&vault, &50i128, &true, &None, &token); + let pool2 = client.get_global_pool(); + assert_eq!(pool2.last_updated, 3_000); + assert_eq!(pool2.total_balance, 150i128); + } + + /// Routing to a developer does NOT update `last_updated` on the global pool. + #[test] + fn test_global_pool_last_updated_unchanged_for_developer_payment() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let developer = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + let token = Address::generate(&env); + + env.ledger().set_timestamp(1_000); + client.init(&admin, &vault); + + env.ledger().set_timestamp(5_000); + client.receive_payment(&vault, &200i128, &false, &Some(developer.clone()), &token); + + // Pool timestamp must still be the init timestamp + assert_eq!(client.get_global_pool().last_updated, 1_000); + assert_eq!(client.get_global_pool().total_balance, 0); + assert_eq!(client.get_developer_balance(&developer, &token), 200i128); + } + + // --- Authorization Matrix Tests --- + + #[test] + fn test_set_admin_authorization_matrix() { + let (env, addr, admin, vault, third_party, _token) = setup_contract(); + let client = CalloraSettlementClient::new(&env, &addr); + let new_admin = Address::generate(&env); + + // Admin can set admin + client.set_admin(&admin, &new_admin); + + // Vault cannot set admin + let result = client.try_set_admin(&vault, &new_admin); + assert!(is_error(result, SettlementError::Unauthorized)); + + // Third party cannot set admin + let result = client.try_set_admin(&third_party, &new_admin); + assert!(is_error(result, SettlementError::Unauthorized)); + } + + #[test] + fn test_set_vault_authorization_matrix() { + let (env, addr, admin, vault, third_party, _token) = setup_contract(); + let client = CalloraSettlementClient::new(&env, &addr); + let new_vault = Address::generate(&env); + + // Admin can propose vault (set_vault is an alias) + client.propose_vault(&admin, &new_vault); + + // Vault cannot set vault + let result = client.try_set_vault(&vault, &new_vault); + assert!(is_error(result, SettlementError::Unauthorized)); + + // Third party cannot set vault + let result = client.try_set_vault(&third_party, &new_vault); + assert!(is_error(result, SettlementError::Unauthorized)); + } + + #[test] + fn test_accept_vault_rejects_unauthorized_caller() { + let (env, addr, admin, vault, third_party, _token) = setup_contract(); + let client = CalloraSettlementClient::new(&env, &addr); + let new_vault = Address::generate(&env); + + client.propose_vault(&admin, &new_vault); + assert_eq!(client.get_vault(), vault); + + let result = client.try_accept_vault(&third_party); + assert!(result.is_err()); + } + + #[test] + fn test_accept_vault_allows_admin_to_finalize() { + let (env, addr, admin, vault, _third_party, _token) = setup_contract(); + let client = CalloraSettlementClient::new(&env, &addr); + let new_vault = Address::generate(&env); + + client.propose_vault(&admin, &new_vault); + assert_eq!(client.get_vault(), vault); + + client.accept_vault(&admin); + assert_eq!(client.get_vault(), new_vault); + } + + #[test] + fn test_propose_vault_rejects_self_address() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + client.init(&admin, &vault); + + let result = client.try_propose_vault(&admin, &addr); + assert!(result.is_err()); + } + + #[test] + fn test_accept_admin_authorization_matrix() { + let (env, addr, admin, _vault, _third_party, _token) = setup_contract(); + let client = CalloraSettlementClient::new(&env, &addr); + let new_admin = Address::generate(&env); + + client.set_admin(&admin, &new_admin); + + // Accept for new_admin (using mock_all_auths which is ON from setup_contract) + client.accept_admin(); + assert_eq!(client.get_admin(), new_admin); + } + + #[test] + fn test_get_all_developer_balances_authorization_matrix() { + let (env, addr, admin, vault, third_party, token) = setup_contract(); + let client = CalloraSettlementClient::new(&env, &addr); + + // Admin can call + let _ = client.get_all_developer_balances(&admin, &token); + + // Vault cannot call + let result = client.try_get_all_developer_balances(&vault, &token); + assert!(is_error(result, SettlementError::Unauthorized)); + + // Third party cannot call + let result = client.try_get_all_developer_balances(&third_party, &token); + assert!(is_error(result, SettlementError::Unauthorized)); + } + + // ── batch_receive_payment tests ────────────────────────────────────────── + + #[test] + fn test_batch_receive_payment_credits_multiple_developers() { + let (env, addr, _admin, vault, _third_party, token) = 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, &token); + + assert_eq!(client.get_developer_balance(&dev1, &token), 100i128); + assert_eq!(client.get_developer_balance(&dev2, &token), 200i128); + } + + #[test] + fn test_batch_receive_payment_accumulates_existing_balance() { + let (env, addr, _admin, vault, _third_party, token) = setup_contract(); + let client = CalloraSettlementClient::new(&env, &addr); + let dev = Address::generate(&env); + + client.receive_payment(&vault, &50i128, &false, &Some(dev.clone()), &token); + + let mut items = soroban_sdk::Vec::new(&env); + items.push_back((dev.clone(), 75i128)); + client.batch_receive_payment(&vault, &items, &token); + + assert_eq!(client.get_developer_balance(&dev, &token), 125i128); + } + + #[test] + fn test_batch_receive_payment_admin_caller_allowed() { + let (env, addr, admin, _vault, _third_party, token) = 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, &token); + + assert_eq!(client.get_developer_balance(&dev, &token), 300i128); + } + + #[test] + fn test_batch_receive_payment_rejects_empty_batch() { + let (env, addr, _admin, vault, _third_party, token) = setup_contract(); + let client = CalloraSettlementClient::new(&env, &addr); + + let items: soroban_sdk::Vec<(Address, i128)> = soroban_sdk::Vec::new(&env); + let result = client.try_batch_receive_payment(&vault, &items, &token); + assert!(result.is_err()); + } + + #[test] + fn test_batch_receive_payment_rejects_oversized_batch() { + use crate::MAX_BATCH_SIZE; + let (env, addr, _admin, vault, _third_party, token) = 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 = client.try_batch_receive_payment(&vault, &items, &token); + assert!(result.is_err()); + } + + #[test] + fn test_batch_receive_payment_rejects_zero_amount() { + let (env, addr, _admin, vault, _third_party, token) = 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 = client.try_batch_receive_payment(&vault, &items, &token); + assert!(result.is_err()); + } + + #[test] + fn test_batch_receive_payment_rejects_negative_amount() { + let (env, addr, _admin, vault, _third_party, token) = 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 = client.try_batch_receive_payment(&vault, &items, &token); + assert!(result.is_err()); + } + + #[test] + fn test_batch_receive_payment_unauthorized_caller_rejected() { + let (env, addr, _admin, _vault, third_party, token) = 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 = client.try_batch_receive_payment(&third_party, &items, &token); + assert!(is_error(result, SettlementError::Unauthorized)); + } + + #[test] + fn test_batch_receive_payment_single_item() { + let (env, addr, _admin, vault, _third_party, token) = 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, &token); + + assert_eq!(client.get_developer_balance(&dev, &token), 999i128); + } + + #[test] + fn test_batch_receive_payment_max_batch_size_accepted() { + use crate::MAX_BATCH_SIZE; + let (env, addr, _admin, vault, _third_party, token) = 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, &token); + + for dev in &devs { + assert_eq!(client.get_developer_balance(dev, &token), 1i128); + } + } + + // ── force_credit_developer tests ───────────────────────────────────────── + + #[test] + fn test_force_credit_developer_happy_path() { + let (env, addr, admin, _vault, _third_party, token) = setup_contract(); + let client = CalloraSettlementClient::new(&env, &addr); + let developer = Address::generate(&env); + let reason = Symbol::new(&env, "offline_settlement"); + + client.force_credit_developer(&admin, &developer, &1000i128, &token, &reason); + + assert_eq!(client.get_developer_balance(&developer, &token), 1000i128); + } + + #[test] + fn test_force_credit_developer_accumulates() { + let (env, addr, admin, _vault, _third_party, token) = setup_contract(); + let client = CalloraSettlementClient::new(&env, &addr); + let developer = Address::generate(&env); + + client.force_credit_developer(&admin, &developer, &500i128, &Symbol::new(&env, "first")); + client.force_credit_developer(&admin, &developer, &300i128, &Symbol::new(&env, "second")); + + assert_eq!(client.get_developer_balance(&developer, &token), 800i128); + } + + #[test] + fn test_force_credit_developer_unauthorized() { + let (env, addr, _admin, vault, third_party, token) = setup_contract(); + let client = CalloraSettlementClient::new(&env, &addr); + let developer = Address::generate(&env); + let reason = Symbol::new(&env, "unauthorized_test"); + + let vault_result = client.try_force_credit_developer(&vault, &developer, &100i128, &reason); + assert!(is_error(vault_result, SettlementError::Unauthorized)); + + let third_party_result = + client.try_force_credit_developer(&third_party, &developer, &100i128, &token, &reason); + assert!(is_error(third_party_result, SettlementError::Unauthorized)); + } + + #[test] + fn test_force_credit_developer_zero_amount() { + let (env, addr, admin, _vault, _third_party, token) = setup_contract(); + let client = CalloraSettlementClient::new(&env, &addr); + let developer = Address::generate(&env); + + let result = client.try_force_credit_developer( + &admin, + &developer, + &0i128, + &token, + &Symbol::new(&env, "zero"), + ); + assert!(is_error(result, SettlementError::AmountNotPositive)); + } + + #[test] + fn test_force_credit_developer_negative_amount() { + let (env, addr, admin, _vault, _third_party, token) = setup_contract(); + let client = CalloraSettlementClient::new(&env, &addr); + let developer = Address::generate(&env); + + let result = client.try_force_credit_developer( + &admin, + &developer, + &-1i128, + &token, + &Symbol::new(&env, "negative"), + ); + assert!(is_error(result, SettlementError::AmountNotPositive)); + } + + #[test] + fn test_force_credit_developer_emits_event() { + use soroban_sdk::testutils::Events as _; + use soroban_sdk::IntoVal; + + let (env, addr, admin, _vault, _third_party, token) = setup_contract(); + let client = CalloraSettlementClient::new(&env, &addr); + let developer = Address::generate(&env); + let reason = Symbol::new(&env, "dispute_resolution"); + + client.force_credit_developer(&admin, &developer, &2500i128, &token, &reason); + + let events = env.events().all(); + let ev = events + .iter() + .find(|e| { + !e.1.is_empty() && { + let t: Symbol = e.1.get(0).unwrap().into_val(&env); + t == Symbol::new(&env, "developer_force_credited") + } + }) + .expect("expected developer_force_credited event"); + + let topic1: Address = ev.1.get(1).unwrap().into_val(&env); + assert_eq!(topic1, developer); + + let data: crate::DeveloperForceCreditedEvent = ev.2.into_val(&env); + assert_eq!(data.developer, developer); + assert_eq!(data.amount, 2500i128); + assert_eq!(data.reason, reason); + assert_eq!(data.new_balance, 2500i128); + } + + #[test] + fn test_force_credit_developer_repeated_reason() { + let (env, addr, admin, _vault, _third_party, token) = setup_contract(); + let client = CalloraSettlementClient::new(&env, &addr); + let developer1 = Address::generate(&env); + let developer2 = Address::generate(&env); + let reason = Symbol::new(&env, "bulk_reconciliation"); + + client.force_credit_developer(&admin, &developer1, &100i128, &token, &reason); + client.force_credit_developer(&admin, &developer2, &200i128, &token, &reason); + + assert_eq!(client.get_developer_balance(&developer1, &token), 100i128); + assert_eq!(client.get_developer_balance(&developer2, &token), 200i128); + } + + #[test] + fn test_force_credit_developer_overflow() { + let (env, addr, admin, _vault, _third_party, token) = setup_contract(); + let client = CalloraSettlementClient::new(&env, &addr); + let developer = Address::generate(&env); + + env.as_contract(&addr, || { + env.storage().persistent().set( + &crate::StorageKey::DeveloperBalance(developer.clone()), + &i128::MAX, + ); + }); + + let result = client.try_force_credit_developer( + &admin, + &developer, + &1i128, + &token, + &Symbol::new(&env, "overflow"), + ); + assert!(is_error(result, SettlementError::DeveloperOverflow)); + } + + /// Property-based test that drives many randomized receive_payment calls + /// (mix of to_pool=true / false) and asserts the conservation invariant: + /// sum of all credits == pool total + sum of all developer balances. + /// Includes overflow-boundary cases near i128::MAX. + #[test] + fn test_conservation_invariant_randomized() { + let (env, addr, admin, vault, _third_party, token) = setup_contract(); + let client = CalloraSettlementClient::new(&env, &addr); + + let mut developers = std::vec::Vec::new(); + for _ in 0..10 { + developers.push(Address::generate(&env)); + } + + let mut total_credited: i128 = 0; + + // Simple deterministic pseudo-random generator + let mut seed: u128 = 42; + let mut next_rand = || { + seed = seed.wrapping_mul(1103515245).wrapping_add(12345); + seed + }; + + // 1. Run 100 randomized payments with small-to-medium amounts + for _ in 0..100 { + let to_pool = (next_rand() % 2) == 0; + let amount = (next_rand() % 1_000_000) as i128 + 1; + + if to_pool { + client.receive_payment(&vault, &amount, &true, &None, &token); + } else { + let dev_idx = (next_rand() % 10) as usize; + if let Some(developer) = developers.get(dev_idx) { + client.receive_payment(&vault, &amount, &false, &Some(developer.clone()), &token); + } + } + total_credited += amount; + } + + // 2. Drive towards i128::MAX boundary + // Calculate remaining room to reach very close to i128::MAX + let buffer = 1_000_000_000_i128; + let remaining = i128::MAX - total_credited - buffer; + + if remaining > 0 { + let half_remaining = remaining / 2; + + // Large credit to pool + client.receive_payment(&vault, &half_remaining, &true, &None, &token); + total_credited += half_remaining; + + // Large credit to a developer + if let Some(developer) = developers.get(0) { + client.receive_payment(&vault, &half_remaining, &false, &Some(developer.clone()), &token); + total_credited += half_remaining; + } + } + + // Final Invariant Check + let pool = client.get_global_pool(); + let mut sum_dev_balances: i128 = 0; + + let all_balances = client.get_all_developer_balances(&admin, &token); + for record in all_balances.iter() { + sum_dev_balances += record.balance; + } + + assert_eq!( + total_credited, + pool.total_balance + sum_dev_balances, + "Conservation invariant violated: total credits must equal pool + developer balances" + ); + } + #[test] + fn test_upgrade_and_get_version() { + let (env, addr, admin, _vault, _third_party, _token) = setup_contract(); + let client = CalloraSettlementClient::new(&env, &addr); + + assert_eq!(client.get_version(), None); + + let new_hash = BytesN::from_array(&env, &[1u8; 32]); + client.upgrade(&admin, &new_hash); + + assert_eq!(client.get_version(), Some(new_hash.clone())); + + // An `upgraded` event should have been emitted + let events = env.events().all(); + let ev = events.last().unwrap(); + let name = soroban_sdk::Symbol::try_from_val(&env, &ev.1.get(0).unwrap()).unwrap(); + assert_eq!(name, soroban_sdk::Symbol::new(&env, "upgraded")); + } + + // ── daily withdrawal cap tests ────────────────────────────────────────── + + #[test] + fn test_withdraw_respects_daily_cap() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let developer = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + let (usdc_address, _, usdc_admin_client) = create_usdc(&env, &admin); + + client.init(&admin, &vault); + client.set_usdc_token(&admin, &usdc_address); + client.set_daily_withdraw_cap(&admin, &developer, &500i128); + client.receive_payment(&vault, &1000i128, &false, &Some(developer.clone()), &usdc_address); + usdc_admin_client.mint(&addr, &1000i128); + + // First withdrawal of 300 should succeed (under 500 cap) + let result = client.try_withdraw_developer_balance(&developer, &300i128, &None); + assert!(result.is_ok()); + assert_eq!(client.get_developer_balance(&developer, &usdc_address), 700i128); + + // Second withdrawal of 300 would push total to 600 (over 500 cap) + let result = client.try_withdraw_developer_balance(&developer, &300i128, &None); + assert!(is_error(result, SettlementError::DailyWithdrawCapExceeded)); + assert_eq!(client.get_developer_balance(&developer, &usdc_address), 700i128); + } + + #[test] + fn test_daily_cap_accumulates_across_multiple_withdrawals() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let developer = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + let (usdc_address, _, usdc_admin_client) = create_usdc(&env, &admin); + + client.init(&admin, &vault); + client.set_usdc_token(&admin, &usdc_address); + client.set_daily_withdraw_cap(&admin, &developer, &500i128); + client.receive_payment(&vault, &1000i128, &false, &Some(developer.clone()), &usdc_address); + usdc_admin_client.mint(&addr, &1000i128); + + // Withdraw 200 + 200 = 400, still under 500 + assert!(client + .try_withdraw_developer_balance(&developer, &200i128, &None) + .is_ok()); + assert!(client + .try_withdraw_developer_balance(&developer, &200i128, &None) + .is_ok()); + assert_eq!(client.get_developer_balance(&developer), 600i128); + + // Third withdrawal of 100 would push to 500 (exact cap — allowed) + assert!(client + .try_withdraw_developer_balance(&developer, &100i128, &None) + .is_ok()); + assert_eq!(client.get_developer_balance(&developer), 500i128); + + // Fourth withdrawal of 1 would exceed cap + let result = client.try_withdraw_developer_balance(&developer, &1i128, &None); + assert!(is_error(result, SettlementError::DailyWithdrawCapExceeded)); + } + + #[test] + fn test_daily_cap_zero_means_unlimited() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let developer = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + let (usdc_address, _, usdc_admin_client) = create_usdc(&env, &admin); + + client.init(&admin, &vault); + client.set_usdc_token(&admin, &usdc_address); + // Cap = 0 explicitly means unlimited + client.set_daily_withdraw_cap(&admin, &developer, &0i128); + client.receive_payment(&vault, &1000i128, &false, &Some(developer.clone()), &usdc_address); + usdc_admin_client.mint(&addr, &1000i128); + + assert!(client + .try_withdraw_developer_balance(&developer, &1000i128, &None) + .is_ok()); + assert_eq!(client.get_developer_balance(&developer), 0i128); + } + + #[test] + fn test_no_cap_set_is_unlimited() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let developer = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + let (usdc_address, _, usdc_admin_client) = create_usdc(&env, &admin); + + client.init(&admin, &vault); + client.set_usdc_token(&admin, &usdc_address); + // No cap set at all — should be unlimited + client.receive_payment(&vault, &1000i128, &false, &Some(developer.clone()), &usdc_address); + usdc_admin_client.mint(&addr, &1000i128); + + assert!(client + .try_withdraw_developer_balance(&developer, &1000i128, &None) + .is_ok()); + assert_eq!(client.get_developer_balance(&developer), 0i128); + } + + #[test] + fn test_daily_cap_resets_on_new_day() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let developer = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + let (usdc_address, _, usdc_admin_client) = create_usdc(&env, &admin); + + // Day 0: timestamp = 0 + env.ledger().set_timestamp(0); + client.init(&admin, &vault); + client.set_usdc_token(&admin, &usdc_address); + client.set_daily_withdraw_cap(&admin, &developer, &500i128); + client.receive_payment(&vault, &1000i128, &false, &Some(developer.clone()), &usdc_address); + usdc_admin_client.mint(&addr, &1000i128); + + // Withdraw 400 on day 0 + assert!(client + .try_withdraw_developer_balance(&developer, &400i128, &None) + .is_ok()); + assert_eq!(client.get_developer_balance(&developer), 600i128); + + // Another 200 would exceed the 500 cap + let result = client.try_withdraw_developer_balance(&developer, &200i128, &None); + assert!(is_error(result, SettlementError::DailyWithdrawCapExceeded)); + + // Advance to day 1 + env.ledger().set_timestamp(86400); + // Mint more USDC for the new day + usdc_admin_client.mint(&addr, &500i128); + + // Withdrawal should succeed now (cap resets) + assert!(client + .try_withdraw_developer_balance(&developer, &500i128, &None) + .is_ok()); + assert_eq!(client.get_developer_balance(&developer), 100i128); + } + + #[test] + fn test_set_daily_withdraw_cap_unauthorized() { + let (env, addr, _admin, vault, third_party, _token) = setup_contract(); + let client = CalloraSettlementClient::new(&env, &addr); + let developer = Address::generate(&env); + + // Vault cannot set cap + let result = client.try_set_daily_withdraw_cap(&vault, &developer, &1000i128); + assert!(is_error(result, SettlementError::Unauthorized)); + + // Third party cannot set cap + let result = client.try_set_daily_withdraw_cap(&third_party, &developer, &1000i128); + assert!(is_error(result, SettlementError::Unauthorized)); + } + + #[test] + fn test_set_daily_withdraw_cap_emits_event() { + use soroban_sdk::testutils::Events as _; + use soroban_sdk::{IntoVal, Symbol}; + + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let developer = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + client.init(&admin, &vault); + + client.set_daily_withdraw_cap(&admin, &developer, &1000i128); + + let events = env.events().all(); + let ev = events + .iter() + .find(|e| { + !e.1.is_empty() && { + let t: Symbol = e.1.get(0).unwrap().into_val(&env); + t == Symbol::new(&env, "daily_withdraw_cap_changed") + } + }) + .expect("expected daily_withdraw_cap_changed event"); + + let topic1: Address = ev.1.get(1).unwrap().into_val(&env); + assert_eq!(topic1, admin); + + let data: crate::DailyWithdrawCapChanged = ev.2.into_val(&env); + assert_eq!(data.developer, developer); + assert_eq!(data.new_cap, 1000i128); + } + + #[test] + fn test_get_daily_withdraw_cap_returns_zero_when_unset() { + let (env, addr, _admin, _vault, _third_party, _token) = setup_contract(); + let client = CalloraSettlementClient::new(&env, &addr); + let developer = Address::generate(&env); + + let cap = client.get_daily_withdraw_cap(&developer); + assert_eq!(cap, 0); + } + + #[test] + fn test_get_withdrawal_today_returns_zero_after_no_withdrawals() { + let (env, addr, _admin, _vault, _third_party, _token) = setup_contract(); + let client = CalloraSettlementClient::new(&env, &addr); + let developer = Address::generate(&env); + + let today = client.get_withdrawal_today(&developer); + assert_eq!(today, 0); + } + + #[test] + fn test_get_withdrawal_today_tracks_cumulative_withdrawals() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let developer = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + let (usdc_address, _, usdc_admin_client) = create_usdc(&env, &admin); + + client.init(&admin, &vault); + client.set_usdc_token(&admin, &usdc_address); + client.set_daily_withdraw_cap(&admin, &developer, &1000i128); + client.receive_payment(&vault, &1000i128, &false, &Some(developer.clone()), &usdc_address); + usdc_admin_client.mint(&addr, &1000i128); + + assert_eq!(client.get_withdrawal_today(&developer), 0i128); + + client.withdraw_developer_balance(&developer, &300i128, &None); + assert_eq!(client.get_withdrawal_today(&developer), 300i128); + + client.withdraw_developer_balance(&developer, &200i128, &None); + assert_eq!(client.get_withdrawal_today(&developer), 500i128); + } + + #[test] + fn test_daily_cap_does_not_affect_other_developers() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(0); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let dev1 = Address::generate(&env); + let dev2 = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + let (usdc_address, _, usdc_admin_client) = create_usdc(&env, &admin); + + client.init(&admin, &vault); + client.set_usdc_token(&admin, &usdc_address); + client.set_daily_withdraw_cap(&admin, &dev1, &500i128); + // dev2 has no cap (unlimited) + client.receive_payment(&vault, &1000i128, &false, &Some(dev1.clone()), &usdc_address); + client.receive_payment(&vault, &500i128, &false, &Some(dev2.clone()), &usdc_address); + usdc_admin_client.mint(&addr, &1500i128); + + // dev1 hits cap at 500 + assert!(client + .try_withdraw_developer_balance(&dev1, &300i128, &None) + .is_ok()); + // Still within cap (300 < 500) + assert!(client + .try_withdraw_developer_balance(&dev1, &200i128, &None) + .is_ok()); + // Exceeds cap (300 + 200 + 1 > 500) + let result = client.try_withdraw_developer_balance(&dev1, &1i128, &None); + assert!(is_error(result, SettlementError::DailyWithdrawCapExceeded)); + + // dev2 can still withdraw (no cap) + assert!(client + .try_withdraw_developer_balance(&dev2, &500i128, &None) + .is_ok()); + } + + // ── developer claim window tests ──────────────────────────────────────── + + #[test] + fn test_claim_window_blocks_withdraw_before_start() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(99); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let developer = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + let (usdc_address, _, usdc_admin_client) = create_usdc(&env, &admin); + + client.init(&admin, &vault); + client.set_usdc_token(&admin, &usdc_address); + client + .try_set_developer_claim_window(&admin, &developer, &100u64, &200u64) + .unwrap() + .unwrap(); + client.receive_payment(&vault, &100i128, &false, &Some(developer.clone())); + usdc_admin_client.mint(&addr, &100i128); + + let result = client.try_withdraw_developer_balance(&developer, &100i128, &None); + assert!(is_error(result, SettlementError::ClaimWindowClosed)); + assert_eq!(client.get_developer_balance(&developer), 100i128); + assert_eq!( + token::Client::new(&env, &usdc_address).balance(&developer), + 0i128 + ); + } + + #[test] + fn test_claim_window_allows_withdraw_at_start_and_end() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(100); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let developer = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + let (usdc_address, _, usdc_admin_client) = create_usdc(&env, &admin); + + client.init(&admin, &vault); + client.set_usdc_token(&admin, &usdc_address); + client + .try_set_developer_claim_window(&admin, &developer, &100u64, &200u64) + .unwrap() + .unwrap(); + client.receive_payment(&vault, &200i128, &false, &Some(developer.clone())); + usdc_admin_client.mint(&addr, &200i128); + + assert!(client + .try_withdraw_developer_balance(&developer, &50i128, &None) + .is_ok()); + assert_eq!(client.get_developer_balance(&developer), 150i128); + + env.ledger().set_timestamp(200); + assert!(client + .try_withdraw_developer_balance(&developer, &150i128, &None) + .is_ok()); + assert_eq!(client.get_developer_balance(&developer), 0i128); + assert_eq!( + token::Client::new(&env, &usdc_address).balance(&developer), + 200i128 + ); + } + + #[test] + fn test_claim_window_blocks_withdraw_after_end() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(201); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let developer = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + let (usdc_address, _, usdc_admin_client) = create_usdc(&env, &admin); + + client.init(&admin, &vault); + client.set_usdc_token(&admin, &usdc_address); + client + .try_set_developer_claim_window(&admin, &developer, &100u64, &200u64) + .unwrap() + .unwrap(); + client.receive_payment(&vault, &100i128, &false, &Some(developer.clone())); + usdc_admin_client.mint(&addr, &100i128); + + let result = client.try_withdraw_developer_balance(&developer, &100i128, &None); + assert!(is_error(result, SettlementError::ClaimWindowClosed)); + assert_eq!(client.get_developer_balance(&developer), 100i128); + } + + #[test] + fn test_claim_window_clear_restores_unrestricted_withdraw() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(201); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let developer = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + let (usdc_address, _, usdc_admin_client) = create_usdc(&env, &admin); + + client.init(&admin, &vault); + client.set_usdc_token(&admin, &usdc_address); + client + .try_set_developer_claim_window(&admin, &developer, &100u64, &200u64) + .unwrap() + .unwrap(); + assert!(client.get_developer_claim_window(&developer).is_some()); + + client + .try_clear_developer_claim_window(&admin, &developer) + .unwrap() + .unwrap(); + assert!(client.get_developer_claim_window(&developer).is_none()); + + client.receive_payment(&vault, &100i128, &false, &Some(developer.clone())); + usdc_admin_client.mint(&addr, &100i128); + + assert!(client + .try_withdraw_developer_balance(&developer, &100i128, &None) + .is_ok()); + assert_eq!(client.get_developer_balance(&developer), 0i128); + } + + #[test] + fn test_set_claim_window_rejects_invalid_range() { + let (env, addr, admin, _vault, _third_party) = setup_contract(); + let client = CalloraSettlementClient::new(&env, &addr); + let developer = Address::generate(&env); + + let result = client.try_set_developer_claim_window(&admin, &developer, &200u64, &100u64); + + assert!(is_error(result, SettlementError::InvalidClaimWindow)); + assert!(client.get_developer_claim_window(&developer).is_none()); + } + + #[test] + fn test_set_and_clear_claim_window_unauthorized() { + let (env, addr, _admin, vault, third_party) = setup_contract(); + let client = CalloraSettlementClient::new(&env, &addr); + let developer = Address::generate(&env); + + let result = client.try_set_developer_claim_window(&vault, &developer, &100u64, &200u64); + assert!(is_error(result, SettlementError::Unauthorized)); + + let result = + client.try_set_developer_claim_window(&third_party, &developer, &100u64, &200u64); + assert!(is_error(result, SettlementError::Unauthorized)); + + let result = client.try_clear_developer_claim_window(&third_party, &developer); + assert!(is_error(result, SettlementError::Unauthorized)); + } + + #[test] + fn test_claim_window_is_per_developer() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(201); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let restricted = Address::generate(&env); + let unrestricted = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + let (usdc_address, _, usdc_admin_client) = create_usdc(&env, &admin); + + client.init(&admin, &vault); + client.set_usdc_token(&admin, &usdc_address); + client + .try_set_developer_claim_window(&admin, &restricted, &100u64, &200u64) + .unwrap() + .unwrap(); + client.receive_payment(&vault, &100i128, &false, &Some(restricted.clone())); + client.receive_payment(&vault, &100i128, &false, &Some(unrestricted.clone())); + usdc_admin_client.mint(&addr, &200i128); + + let result = client.try_withdraw_developer_balance(&restricted, &100i128, &None); + assert!(is_error(result, SettlementError::ClaimWindowClosed)); + assert!(client + .try_withdraw_developer_balance(&unrestricted, &100i128, &None) + .is_ok()); + assert_eq!(client.get_developer_balance(&restricted), 100i128); + assert_eq!(client.get_developer_balance(&unrestricted), 0i128); + } + + #[test] + fn test_set_claim_window_emits_event_and_getter_returns_window() { + use soroban_sdk::testutils::Events as _; + use soroban_sdk::{IntoVal, Symbol}; + + let (env, addr, admin, _vault, _third_party) = setup_contract(); + let client = CalloraSettlementClient::new(&env, &addr); + let developer = Address::generate(&env); + + client + .try_set_developer_claim_window(&admin, &developer, &100u64, &200u64) + .unwrap() + .unwrap(); + + let window = client.get_developer_claim_window(&developer).unwrap(); + assert_eq!(window.start_ts, 100u64); + assert_eq!(window.end_ts, 200u64); + + let events = env.events().all(); + let ev = events + .iter() + .find(|e| { + !e.1.is_empty() && { + let t: Symbol = e.1.get(0).unwrap().into_val(&env); + t == Symbol::new(&env, "claim_window_changed") + } + }) + .expect("expected claim_window_changed event"); + + let topic1: Address = ev.1.get(1).unwrap().into_val(&env); + assert_eq!(topic1, developer); + + let data: crate::DeveloperClaimWindowChanged = ev.2.into_val(&env); + assert_eq!(data.developer, developer); + assert_eq!(data.start_ts, 100u64); + assert_eq!(data.end_ts, 200u64); + assert!(data.enabled); + } + + // ── cursor-based pagination tests ──────────────────────────────────────── + + /// First page with cursor=None returns up to `limit` records from the + /// beginning of the sorted index and yields a non-None next_cursor when the + /// index has more entries. + #[test] + fn test_cursor_first_page_returns_records_and_next_cursor() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + client.init(&admin, &vault); + let token = Address::generate(&env); + + let dev1 = Address::generate(&env); + let dev2 = Address::generate(&env); + let dev3 = Address::generate(&env); + client.receive_payment(&vault, &100i128, &false, &Some(dev1.clone()), &token); + client.receive_payment(&vault, &200i128, &false, &Some(dev2.clone()), &token); + client.receive_payment(&vault, &300i128, &false, &Some(dev3.clone()), &token); + + let (page, next) = client.get_developer_balances_cursor(&admin, &None, &2u32, &token); + + assert_eq!( + page.len(), + 2, + "first page must contain exactly limit records" + ); + // next_cursor must point at the last record on this page so the caller + // can continue from there. + assert!( + next.is_some(), + "next_cursor must be Some when more records exist" + ); + assert_eq!( + next.as_ref().unwrap(), + &page.get(1).unwrap().address, + "next_cursor must equal the last address on the page" + ); + } + + /// Subsequent page retrieved via next_cursor returns the remaining records. + #[test] + fn test_cursor_subsequent_page_returns_remaining_records() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + client.init(&admin, &vault); + let token = Address::generate(&env); + + let dev1 = Address::generate(&env); + let dev2 = Address::generate(&env); + let dev3 = Address::generate(&env); + client.receive_payment(&vault, &10i128, &false, &Some(dev1.clone()), &token); + client.receive_payment(&vault, &20i128, &false, &Some(dev2.clone()), &token); + client.receive_payment(&vault, &30i128, &false, &Some(dev3.clone()), &token); + + // Page 1 + let (page1, next1) = client.get_developer_balances_cursor(&admin, &None, &2u32, &token); + assert_eq!(page1.len(), 2); + assert!(next1.is_some()); + + // Page 2 — use next_cursor from page 1 + let (page2, next2) = client.get_developer_balances_cursor(&admin, &next1, &2u32); + assert_eq!( + page2.len(), + 1, + "last page must contain the remaining record" + ); + // Reached the end of the index. + assert!(next2.is_none(), "next_cursor must be None on the last page"); + + // Together the two pages must cover all three developers exactly once. + let mut all_addrs: std::vec::Vec
= std::vec::Vec::new(); + for r in page1.iter() { + all_addrs.push(r.address.clone()); + } + for r in page2.iter() { + all_addrs.push(r.address.clone()); + } + assert_eq!(all_addrs.len(), 3); + assert!(all_addrs.contains(&dev1)); + assert!(all_addrs.contains(&dev2)); + assert!(all_addrs.contains(&dev3)); + } + + /// Cursor pointing past the last entry returns an empty page and None. + #[test] + fn test_cursor_past_last_entry_returns_empty_page() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + client.init(&admin, &vault); + let token = Address::generate(&env); + + let dev1 = Address::generate(&env); + let dev2 = Address::generate(&env); + client.receive_payment(&vault, &1i128, &false, &Some(dev1.clone()), &token); + client.receive_payment(&vault, &2i128, &false, &Some(dev2.clone()), &token); + + // Exhaust the index with a large limit to find the last cursor. + let (full_page, last_cursor) = client.get_developer_balances_cursor(&admin, &None, &100u32, &token); + assert_eq!(full_page.len(), 2); + assert!(last_cursor.is_none()); + + // Use the address of the last record as the cursor — nothing should follow. + let last_addr = full_page.get(full_page.len() - 1).unwrap().address; + let (empty_page, next) = + client.get_developer_balances_cursor(&admin, &Some(last_addr), &10u32, &token); + assert_eq!(empty_page.len(), 0, "page after last cursor must be empty"); + assert!(next.is_none()); + } + + /// Cursor stability: credits to developers that sort **after** the cursor do + /// not disturb already-returned pages. + #[test] + fn test_cursor_stable_across_interleaved_credits() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + client.init(&admin, &vault); + let token = Address::generate(&env); + + // Pre-populate three developers so the sorted index is stable. + let dev_a = Address::generate(&env); + let dev_b = Address::generate(&env); + let dev_c = Address::generate(&env); + client.receive_payment(&vault, &1i128, &false, &Some(dev_a.clone()), &token); + client.receive_payment(&vault, &1i128, &false, &Some(dev_b.clone()), &token); + client.receive_payment(&vault, &1i128, &false, &Some(dev_c.clone()), &token); + + // Fetch first page (limit=1) to get the cursor. + let (page1, cursor_after_first) = + client.get_developer_balances_cursor(&admin, &None, &1u32, &token); + assert_eq!(page1.len(), 1); + let first_addr = page1.get(0).unwrap().address.clone(); + + // Credit the first developer again — this must NOT shift remaining pages. + client.receive_payment(&vault, &999i128, &false, &Some(first_addr.clone()), &token); + + // Continue pagination from the saved cursor. + let (page2, _) = client.get_developer_balances_cursor(&admin, &cursor_after_first, &10u32); + assert_eq!(page2.len(), 2, "two records must remain after the cursor"); + + // The first developer must not appear again in page2. + for rec in page2.iter() { + assert_ne!( + rec.address, first_addr, + "already-paged developer must not re-appear" + ); + } + } + + /// Limit is capped at MAX_DEVELOPER_BALANCES_PAGE_SIZE even if caller + /// passes a larger value. + #[test] + fn test_cursor_limit_is_capped_at_max_page_size() { + use crate::MAX_DEVELOPER_BALANCES_PAGE_SIZE; + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + client.init(&admin, &vault); + let token = Address::generate(&env); + + // Insert more developers than MAX_DEVELOPER_BALANCES_PAGE_SIZE. + for _ in 0..(MAX_DEVELOPER_BALANCES_PAGE_SIZE + 10) { + let dev = Address::generate(&env); + client.receive_payment(&vault, &1i128, &false, &Some(dev), &token); + } + + // Request more than the cap. + let (page, _) = client.get_developer_balances_cursor( + &admin, + &None, + &(MAX_DEVELOPER_BALANCES_PAGE_SIZE + 50), + ); + assert_eq!( + page.len(), + MAX_DEVELOPER_BALANCES_PAGE_SIZE, + "page must be capped at MAX_DEVELOPER_BALANCES_PAGE_SIZE" + ); + } + + /// limit=0 returns an empty page and None immediately. + #[test] + fn test_cursor_zero_limit_returns_empty() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + client.init(&admin, &vault); + let token = Address::generate(&env); + + let dev = Address::generate(&env); + client.receive_payment(&vault, &1i128, &false, &Some(dev), &token); + + let (page, next) = client.get_developer_balances_cursor(&admin, &None, &0u32, &token); + assert_eq!(page.len(), 0); + assert!(next.is_none()); + } + + /// Cursor on an empty index returns an empty page and None. + #[test] + fn test_cursor_empty_index_returns_empty_page() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + client.init(&admin, &vault); + let token = Address::generate(&env); + + let (page, next) = client.get_developer_balances_cursor(&admin, &None, &10u32, &token); + assert_eq!(page.len(), 0); + assert!(next.is_none()); + } + + /// Non-admin caller is rejected with Unauthorized. + #[test] + fn test_cursor_unauthorized_caller_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + client.init(&admin, &vault); + let token = Address::generate(&env); + + let result = client.try_get_developer_balances_cursor(&vault, &None, &10u32, &token); + assert!( + is_error(result, SettlementError::Unauthorized), + "non-admin caller must be rejected with Unauthorized" + ); + } + + /// Sorted order: DeveloperIndex stays sorted; cursor pages come out in the + /// same deterministic order regardless of credit sequence. + #[test] + fn test_cursor_sorted_order_is_deterministic() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + client.init(&admin, &vault); + let token = Address::generate(&env); + + // Generate addresses in arbitrary order and credit them. + let mut devs: std::vec::Vec
= (0..5).map(|_| Address::generate(&env)).collect(); + for dev in &devs { + client.receive_payment(&vault, &1i128, &false, &Some(dev.clone()), &token); + } + + // Collect all balances via cursor pagination. + let mut cursor_pages: std::vec::Vec
= std::vec::Vec::new(); + let mut next: Option
= None; + loop { + let (page, nc) = client.get_developer_balances_cursor(&admin, &next, &2u32, &token); + for r in page.iter() { + cursor_pages.push(r.address.clone()); + } + next = nc; + if next.is_none() { + break; + } + } #[test] fn deposit_event_emitted_once_per_batch_item() {