diff --git a/contracts/settlement/src/lib.rs b/contracts/settlement/src/lib.rs index 86ad38d..38cfd94 100644 --- a/contracts/settlement/src/lib.rs +++ b/contracts/settlement/src/lib.rs @@ -1,135 +1,12 @@ #![no_std] -use soroban_sdk::{contract, contractimpl, contracttype, token, Address, BytesN, Env, Symbol, Vec}; +use soroban_sdk::{contract, contractimpl, token, Address, BytesN, Env, String, Symbol, Vec}; mod errors; pub use errors::SettlementError; -/// 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; - -/// 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), - 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, -} - -/// 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 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, -} - -/// Maximum byte length for the `reason` Symbol in `force_credit_developer`. -/// The Soroban SDK enforces a 32-byte limit on Symbol values at construction; -/// this constant is used for explicit defense-in-depth validation. -pub const MAX_REASON_LENGTH: u32 = 32; +mod types; +pub use types::*; #[contract] @@ -180,16 +57,17 @@ impl CalloraSettlement { /// /// # Arguments /// * `caller` - Must be authorized vault address or admin - /// * `amount` - Payment amount in USDC micro-units; must be > 0 + /// * `amount` - Payment amount in token micro-units; must be > 0 /// * `to_pool` - If true, credit global pool; if false, credit a specific developer /// * `developer` - Required when `to_pool=false`; ignored when `to_pool=true` + /// * `token` - The token contract address for this payment /// /// # Access Control /// Only the registered vault address or admin can call this function. /// /// # Persistent Storage Operations /// When crediting to developer balance: - /// - Performs O(1) point-read from persistent storage for the developer + /// - Performs O(1) point-read from persistent storage for the developer + token /// - Updates the specific developer's balance in persistent storage /// - Extends persistent TTL for the developer's balance entry /// - Adds developer to index if not already present @@ -208,6 +86,7 @@ impl CalloraSettlement { amount: i128, to_pool: bool, developer: Option
, + token: Address, ) { caller.require_auth(); Self::require_authorized_caller(env.clone(), caller.clone()); @@ -233,34 +112,33 @@ impl CalloraSettlement { amount, to_pool: true, developer: None, + token: token.clone(), }, ); } else { let dev_address = developer .unwrap_or_else(|| env.panic_with_error(SettlementError::DeveloperRequired)); + // Per-token balance key: (developer, token) + let balance_key = StorageKey::DeveloperBalance(dev_address.clone(), token.clone()); + // Read current balance from persistent storage let current_balance: i128 = env .storage() .persistent() - .get(&StorageKey::DeveloperBalance(dev_address.clone())) + .get(&balance_key) .unwrap_or(0i128); let new_balance = current_balance .checked_add(amount) .unwrap_or_else(|| env.panic_with_error(SettlementError::DeveloperOverflow)); // Write to persistent storage with TTL extension - env.storage().persistent().set( - &StorageKey::DeveloperBalance(dev_address.clone()), - &new_balance, - ); + env.storage().persistent().set(&balance_key, &new_balance); // Extend TTL for the developer's balance entry (persistent storage live for 1 year) - env.storage().persistent().extend_ttl( - &StorageKey::DeveloperBalance(dev_address.clone()), - 50000, - 50000, - ); + env.storage() + .persistent() + .extend_ttl(&balance_key, 50000, 50000); // Add developer to index in sorted order if not already present let mut index: Vec
= inst @@ -276,6 +154,7 @@ impl CalloraSettlement { amount, to_pool: false, developer: Some(dev_address.clone()), + token: token.clone(), }, ); env.events().publish( @@ -284,6 +163,7 @@ impl CalloraSettlement { developer: dev_address, amount, new_balance, + token, }, ); } @@ -294,6 +174,7 @@ impl CalloraSettlement { /// # Arguments /// * `caller` - Must be the registered vault address or admin /// * `items` - Vec of `(developer_address, amount)` pairs; 1–[`MAX_BATCH_SIZE`] entries + /// * `token` - The token contract address for this batch payment /// /// # Access Control /// Only the registered vault address or admin can call this function. @@ -313,7 +194,12 @@ impl CalloraSettlement { /// * `"batch too large"` — more than [`MAX_BATCH_SIZE`] items /// * `"amount must be positive"` — any amount ≤ 0 /// * `"developer balance overflow"` — `i128` overflow on any developer balance - pub fn batch_receive_payment(env: Env, caller: Address, items: Vec<(Address, i128)>) { + pub fn batch_receive_payment( + env: Env, + caller: Address, + items: Vec<(Address, i128)>, + token: Address, + ) { caller.require_auth(); Self::require_authorized_caller(env.clone(), caller.clone()); @@ -331,20 +217,21 @@ impl CalloraSettlement { for item in items.iter() { let (dev, amount) = item; + let balance_key = StorageKey::DeveloperBalance(dev.clone(), token.clone()); let current: i128 = env .storage() .persistent() - .get(&StorageKey::DeveloperBalance(dev.clone())) + .get(&balance_key) .unwrap_or(0); let new_balance = current .checked_add(amount) .unwrap_or_else(|| env.panic_with_error(SettlementError::DeveloperOverflow)); env.storage() .persistent() - .set(&StorageKey::DeveloperBalance(dev.clone()), &new_balance); + .set(&balance_key, &new_balance); env.storage() .persistent() - .extend_ttl(&StorageKey::DeveloperBalance(dev.clone()), 50000, 50000); + .extend_ttl(&balance_key, 50000, 50000); // Add to index in sorted order if not already present let mut index: Vec
= inst .get(&StorageKey::DeveloperIndex) @@ -355,8 +242,9 @@ impl CalloraSettlement { (events::event_balance_credited(&env), dev.clone()), BalanceCreditedEvent { developer: dev.clone(), - amount: amount, + amount, new_balance, + token: token.clone(), }, ); } @@ -386,26 +274,27 @@ impl CalloraSettlement { .unwrap_or_else(|| env.panic_with_error(SettlementError::NotInitialized)) } - /// Get developer balance + /// Get developer balance for a specific token. /// - /// Performs a direct O(1) persistent storage lookup for the specified developer's balance. - /// This is the preferred method for querying individual balances as it uses point storage. + /// Performs a direct O(1) persistent storage lookup for the specified + /// developer's balance denominated in `token`. /// /// # Arguments /// * `developer` - Developer address to query + /// * `token` - Token contract address /// /// # Returns - /// Balance in USDC micro-units, or 0 if no balance recorded + /// Balance in token micro-units, or 0 if no balance recorded /// /// # Safety /// Safe for all use cases; uses persistent storage with TTL. - pub fn get_developer_balance(env: Env, developer: Address) -> i128 { + pub fn get_developer_balance(env: Env, developer: Address, token: Address) -> i128 { if !env.storage().instance().has(&StorageKey::Admin) { env.panic_with_error(SettlementError::NotInitialized); } env.storage() .persistent() - .get(&StorageKey::DeveloperBalance(developer)) + .get(&StorageKey::DeveloperBalance(developer, token)) .unwrap_or(0) } @@ -434,56 +323,145 @@ impl CalloraSettlement { .ok_or(SettlementError::UsdcTokenNotConfigured) } - /// Withdraw developer balance as USDC to a designated recipient (defaults to the developer). -/// -/// Requires the developer to authorize the request and the requested amount -/// to be positive and covered by the tracked developer balance. -/// -/// # Arguments -/// * `developer` - Address of the developer withdrawing their balance -/// * `amount` - Amount to withdraw in USDC micro-units -/// * `to` - Optional recipient address; if `None`, defaults to `developer` -/// -/// # Errors -/// - `AmountNotPositive` if amount is ≤ 0 -/// - `InsufficientDeveloperBalance` if developer balance < amount -/// - `DailyWithdrawCapExceeded` if daily cap is exceeded -/// - `DeveloperBalanceUnderflow` if subtraction underflows -/// - `UsdcTokenNotConfigured` if USDC token not set -/// - `InsufficientContractBalance` if contract has insufficient USDC -/// - Panics if `to` is the contract's own address -pub fn withdraw_developer_balance( - env: Env, - developer: Address, - amount: i128, - to: Option
, -) -> Result<(), SettlementError> { - developer.require_auth(); - if amount <= 0 { - return Err(SettlementError::AmountNotPositive); - } + /// Migrate a developer's balance from the legacy single-token format + /// `DeveloperBalanceV1(dev)` to the new per-token format + /// `DeveloperBalance(dev, usdc_token)`. + /// + /// After migration, the old entry is removed from storage. This is a + /// one-way, idempotent operation: calling it again for the same developer + /// will see a zero legacy balance and be a no-op. + /// + /// # Arguments + /// * `caller` – Must be the current admin. + /// * `developer` – The developer address whose balance to migrate. + /// + /// # Errors + /// * `SettlementError::Unauthorized` – caller is not admin. + /// * `SettlementError::UsdcTokenNotConfigured` – no USDC token has been set + /// via `set_usdc_token`, which is required because the legacy format was + /// single-token (USDC). + /// + /// # Events + /// Does not emit an event; the state change is observable via balance reads. + pub fn migrate_developer_balance( + env: Env, + caller: Address, + developer: Address, + ) -> Result<(), SettlementError> { + caller.require_auth(); + let admin = Self::get_admin(env.clone()); + if caller != admin { + return Err(SettlementError::Unauthorized); + } - 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"); - } + let usdc = Self::get_usdc_token(env.clone())?; + let legacy_key = StorageKey::DeveloperBalanceV1(developer.clone()); + let pers = env.storage().persistent(); + + // Read legacy balance. + let balance: i128 = pers.get(&legacy_key).unwrap_or(0); + if balance == 0 { + // Nothing to migrate — still ok, idempotent. + return Ok(()); + } - let current_balance: i128 = env - .storage() - .persistent() - .get(&StorageKey::DeveloperBalance(developer.clone())) - .unwrap_or(0); - if amount > current_balance { - return Err(SettlementError::InsufficientDeveloperBalance); + // Write new per-token entry. + let new_key = StorageKey::DeveloperBalance(developer.clone(), usdc); + pers.set(&new_key, &balance); + pers.extend_ttl(&new_key, 50000, 50000); + + // Remove legacy entry. + pers.remove(&legacy_key); + + Ok(()) } - let cap: i128 = env - .storage() - .persistent() - .get(&StorageKey::DailyWithdrawCap(developer.clone())) - .unwrap_or(0); - if cap > 0 { + /// Withdraw developer balance as a specific token to a designated recipient + /// (defaults to the developer). + /// + /// Requires the developer to authorize the request and the requested amount + /// to be positive and covered by the tracked developer balance. + /// + /// # Arguments + /// * `developer` - Address of the developer withdrawing their balance + /// * `amount` - Amount to withdraw in token micro-units + /// * `to` - Optional recipient address; if `None`, defaults to `developer` + /// * `token` - The token contract address to withdraw + /// + /// # Errors + /// - `AmountNotPositive` if amount is ≤ 0 + /// - `InsufficientDeveloperBalance` if developer balance < amount + /// - `DailyWithdrawCapExceeded` if daily cap is exceeded + /// - `DeveloperBalanceUnderflow` if subtraction underflows + /// - `InsufficientContractBalance` if contract has insufficient token balance + /// - Panics if `to` is the contract's own address + pub fn withdraw_developer_balance( + env: Env, + developer: Address, + amount: i128, + to: Option
, + token: Address, + ) -> Result<(), SettlementError> { + developer.require_auth(); + 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"); + } + + let balance_key = StorageKey::DeveloperBalance(developer.clone(), token.clone()); + let current_balance: i128 = env + .storage() + .persistent() + .get(&balance_key) + .unwrap_or(0); + if amount > current_balance { + return Err(SettlementError::InsufficientDeveloperBalance); + } + + let cap: i128 = env + .storage() + .persistent() + .get(&StorageKey::DailyWithdrawCap(developer.clone())) + .unwrap_or(0); + if cap > 0 { + let today = env.ledger().timestamp() / 86400; + let mut daily = env + .storage() + .persistent() + .get::<_, DailyWithdrawState>(&StorageKey::WithdrawalToday(developer.clone())) + .unwrap_or(DailyWithdrawState { day: today, amount: 0 }); + if daily.day != today { + daily.day = today; + daily.amount = 0; + } + if daily.amount.checked_add(amount).is_none_or(|sum| sum > cap) { + return Err(SettlementError::DailyWithdrawCapExceeded); + } + } + + let new_balance = current_balance + .checked_sub(amount) + .ok_or(SettlementError::DeveloperBalanceUnderflow)?; + + let token_client = token::Client::new(&env, &token); + + if token_client.balance(&contract_address) < amount { + return Err(SettlementError::InsufficientContractBalance); + } + + token_client.transfer(&contract_address, &recipient, &amount); + + env.storage().persistent().set(&balance_key, &new_balance); + env.storage() + .persistent() + .extend_ttl(&balance_key, 50000, 50000); + + // Update daily withdrawal accumulator let today = env.ledger().timestamp() / 86400; let mut daily = env .storage() @@ -494,65 +472,27 @@ pub fn withdraw_developer_balance( daily.day = today; daily.amount = 0; } - if daily.amount.checked_add(amount).is_none_or(|sum| sum > cap) { - return Err(SettlementError::DailyWithdrawCapExceeded); - } - } - - let new_balance = current_balance - .checked_sub(amount) - .ok_or(SettlementError::DeveloperBalanceUnderflow)?; - - let usdc_address = Self::get_usdc_token(env.clone())?; - let usdc = token::Client::new(&env, &usdc_address); + daily.amount = daily.amount.saturating_add(amount); + env.storage() + .persistent() + .set(&StorageKey::WithdrawalToday(developer.clone()), &daily); + env.storage() + .persistent() + .extend_ttl(&StorageKey::WithdrawalToday(developer.clone()), 50000, 50000); - if usdc.balance(&contract_address) < amount { - return Err(SettlementError::InsufficientContractBalance); - } + env.events().publish( + (events::event_developer_withdraw(&env), developer.clone()), + DeveloperWithdrawEvent { + developer, + amount, + remaining_balance: new_balance, + to: recipient, + token, + }, + ); - usdc.transfer(&contract_address, &recipient, &amount); - - env.storage().persistent().set( - &StorageKey::DeveloperBalance(developer.clone()), - &new_balance, - ); - env.storage().persistent().extend_ttl( - &StorageKey::DeveloperBalance(developer.clone()), - 50000, - 50000, - ); - - // Update daily withdrawal accumulator - let today = env.ledger().timestamp() / 86400; - let mut daily = env - .storage() - .persistent() - .get::<_, DailyWithdrawState>(&StorageKey::WithdrawalToday(developer.clone())) - .unwrap_or(DailyWithdrawState { day: today, amount: 0 }); - if daily.day != today { - daily.day = today; - daily.amount = 0; + Ok(()) } - daily.amount = daily.amount.saturating_add(amount); - env.storage() - .persistent() - .set(&StorageKey::WithdrawalToday(developer.clone()), &daily); - env.storage() - .persistent() - .extend_ttl(&StorageKey::WithdrawalToday(developer.clone()), 50000, 50000); - - env.events().publish( - (events::event_developer_withdraw(&env), developer.clone()), - DeveloperWithdrawEvent { - developer, - amount, - remaining_balance: new_balance, - to: recipient, - }, - ); - - Ok(()) -} /// Set the daily withdrawal cap for a developer (admin only). /// @@ -606,17 +546,19 @@ pub fn withdraw_developer_balance( } } - /// Admin-only escape hatch to manually credit a developer balance. + /// Admin-only escape hatch to manually credit a developer balance for a + /// specific token. /// /// This function is designed for operational edge cases where a developer /// must be credited outside the normal `receive_payment` flow (e.g., /// off-chain payment reconciliation, dispute resolution). It does **not** - /// move on-ledger USDC and is treated as an audited administrative inflow. + /// move on-ledger tokens and is treated as an audited administrative inflow. /// /// # Arguments /// * `caller` - Must be the current admin address. /// * `developer` - Address of the developer to credit. - /// * `amount` - Amount in USDC micro-units; must be `> 0`. + /// * `amount` - Amount in token micro-units; must be `> 0`. + /// * `token` - The token contract address for this credit. /// * `reason` - On-chain reason code (Symbol); used for auditability. /// The Soroban SDK enforces a 32-byte maximum on Symbol values at /// construction, so a reason Symbol received here is always ≤ 32 bytes. @@ -628,12 +570,13 @@ pub fn withdraw_developer_balance( /// /// # Events /// Emits `developer_force_credited` with - /// `(developer, amount, reason, new_balance)`. + /// `(developer, amount, token, reason, new_balance)`. pub fn force_credit_developer( env: Env, caller: Address, developer: Address, amount: i128, + token: Address, reason: Symbol, ) { caller.require_auth(); @@ -645,25 +588,20 @@ pub fn withdraw_developer_balance( env.panic_with_error(SettlementError::AmountNotPositive); } + let balance_key = StorageKey::DeveloperBalance(developer.clone(), token.clone()); let current_balance: i128 = env .storage() .persistent() - .get(&StorageKey::DeveloperBalance(developer.clone())) + .get(&balance_key) .unwrap_or(0i128); let new_balance = current_balance .checked_add(amount) .unwrap_or_else(|| env.panic_with_error(SettlementError::DeveloperOverflow)); + env.storage().persistent().set(&balance_key, &new_balance); env.storage() .persistent() - .set(&StorageKey::DeveloperBalance(developer.clone()), &new_balance); - env.storage() - .persistent() - .extend_ttl( - &StorageKey::DeveloperBalance(developer.clone()), - 50000, - 50000, - ); + .extend_ttl(&balance_key, 50000, 50000); let mut index: Vec
= env .storage() @@ -688,7 +626,7 @@ pub fn withdraw_developer_balance( ); } - /// Get all developer balances (admin only) + /// Get all developer balances for a specific token (admin only). /// /// **CRITICAL**: Uses developer index for iteration; order is based on index insertion order. /// Use this function only for administrative queries or reporting purposes. @@ -697,6 +635,7 @@ pub fn withdraw_developer_balance( /// /// # Arguments /// * `caller` - Must be the current admin address. + /// * `token` - Token contract address to query balances for. /// /// # Access Control /// Only the current admin can call this function. @@ -726,6 +665,7 @@ pub fn withdraw_developer_balance( pub fn get_all_developer_balances( env: Env, caller: Address, + token: Address, ) -> Result, SettlementError> { caller.require_auth(); let admin = Self::get_admin(env.clone()); @@ -748,26 +688,34 @@ pub fn withdraw_developer_balance( let balance: i128 = env .storage() .persistent() - .get(&StorageKey::DeveloperBalance(address.clone())) + .get(&StorageKey::DeveloperBalance(address.clone(), token.clone())) .unwrap_or(0i128); result.push_back(DeveloperBalance { address: address.clone(), + token: token.clone(), balance, }); } Ok(result) } - /// Get a paginated slice of developer balances (admin only). + /// Get a paginated slice of developer balances for a token (admin only). /// /// This method avoids expensive full-index iteration by returning /// a bounded window of developer balance records. Use it for /// admin dashboards and off-chain pagination. + /// + /// # Arguments + /// * `caller` - Must be the current admin address. + /// * `start` - Zero-based start index. + /// * `limit` - Maximum records to return; capped at 100. + /// * `token` - Token contract address to query balances for. pub fn get_developer_balances_page( env: Env, caller: Address, start: u32, limit: u32, + token: Address, ) -> Result, SettlementError> { caller.require_auth(); let admin = Self::get_admin(env.clone()); @@ -794,10 +742,11 @@ pub fn withdraw_developer_balance( let balance = env .storage() .persistent() - .get(&StorageKey::DeveloperBalance(address.clone())) + .get(&StorageKey::DeveloperBalance(address.clone(), token.clone())) .unwrap_or(0); result.push_back(DeveloperBalance { address: address.clone(), + token: token.clone(), balance, }); } @@ -809,7 +758,7 @@ pub fn withdraw_developer_balance( Ok(result) } - /// Cursor-based paginated developer balances (admin only). + /// Cursor-based paginated developer balances for a specific token (admin only). /// /// Returns up to `limit` developer balance records starting **after** the /// supplied `cursor` address (exclusive), or from the beginning of the @@ -825,6 +774,7 @@ pub fn withdraw_developer_balance( /// subsequent pages. /// * `limit` – Maximum records to return; capped at /// [`MAX_DEVELOPER_BALANCES_PAGE_SIZE`] (100). + /// * `token` – Token contract address to query balances for. /// /// # Returns /// `(page, next_cursor)` where: @@ -839,19 +789,12 @@ pub fn withdraw_developer_balance( /// # Errors /// * [`SettlementError::NotInitialized`] – contract not yet initialised. /// * [`SettlementError::Unauthorized`] – caller is not the admin. - /// - /// # Example - /// ```text - /// // First page - /// let (page1, next) = client.get_developer_balances_cursor(&admin, &None, &10u32); - /// // Second page - /// let (page2, next) = client.get_developer_balances_cursor(&admin, &next, &10u32); - /// ``` pub fn get_developer_balances_cursor( env: Env, caller: Address, cursor: Option
, limit: u32, + token: Address, ) -> (Vec, Option
) { caller.require_auth(); let admin = Self::get_admin(env.clone()); @@ -892,10 +835,11 @@ pub fn withdraw_developer_balance( let balance: i128 = env .storage() .persistent() - .get(&StorageKey::DeveloperBalance(address.clone())) + .get(&StorageKey::DeveloperBalance(address.clone(), token.clone())) .unwrap_or(0i128); result.push_back(DeveloperBalance { address: address.clone(), + token: token.clone(), balance, }); last_address = Some(address.clone()); @@ -1222,3 +1166,6 @@ mod test_invariant; #[cfg(test)] mod test_error_codes; + +#[cfg(test)] +mod test_multi_asset; diff --git a/contracts/settlement/src/test.rs b/contracts/settlement/src/test.rs index e5c6881..f37bc65 100644 --- a/contracts/settlement/src/test.rs +++ b/contracts/settlement/src/test.rs @@ -6,7 +6,7 @@ mod settlement_tests { use soroban_sdk::testutils::{Address as _, Ledger as _, Events as _}; use soroban_sdk::{token, Address, Env, Error, InvokeError, Symbol, BytesN, TryFromVal}; - fn setup_contract() -> (Env, Address, Address, Address, Address) { + fn setup_contract() -> (Env, Address, Address, Address, Address, Address) { let env = Env::default(); env.mock_all_auths(); let admin = Address::generate(&env); @@ -15,7 +15,8 @@ mod settlement_tests { let client = CalloraSettlementClient::new(&env, &addr); client.init(&admin, &vault); let third_party = Address::generate(&env); - (env, addr, admin, vault, third_party) + let token = Address::generate(&env); + (env, addr, admin, vault, third_party, token) } fn is_error, E: Into>( @@ -50,6 +51,7 @@ mod settlement_tests { 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); @@ -68,9 +70,9 @@ mod settlement_tests { 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); + let all_balances = client.get_all_developer_balances(&admin, &token); assert_eq!(all_balances.len(), 0); - assert_eq!(client.get_developer_balance(&developer), 0); + assert_eq!(client.get_developer_balance(&developer, &token), 0); } #[test] #[should_panic(expected = "invalid config: admin and vault_address must be distinct")] @@ -136,8 +138,9 @@ mod settlement_tests { 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); + client.receive_payment(&vault, &1000i128, &true, &None, &token); let global_pool = client.get_global_pool(); assert_eq!(global_pool.total_balance, 1000i128); @@ -153,10 +156,11 @@ mod settlement_tests { 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())); + client.receive_payment(&vault, &500i128, &false, &Some(developer.clone()), &token); - assert_eq!(client.get_developer_balance(&developer), 500i128); + assert_eq!(client.get_developer_balance(&developer, &token), 500i128); assert_eq!(client.get_global_pool().total_balance, 0); } @@ -170,11 +174,12 @@ mod settlement_tests { 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())); - client.receive_payment(&vault, &150i128, &false, &Some(developer.clone())); + 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), 250i128); + assert_eq!(client.get_developer_balance(&developer, &token), 250i128); } #[test] @@ -187,8 +192,9 @@ mod settlement_tests { 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); + let balance = client.get_developer_balance(&developer, &token); assert_eq!(balance, 0); } @@ -201,8 +207,9 @@ mod settlement_tests { 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); + let all = client.get_all_developer_balances(&admin, &token); assert_eq!(all.len(), 0); } @@ -216,7 +223,8 @@ mod settlement_tests { let addr = env.register(CalloraSettlement, ()); let client = CalloraSettlementClient::new(&env, &addr); client.init(&admin, &vault); - client.receive_payment(&admin, &100i128, &true, &None); + let token = Address::generate(&env); + client.receive_payment(&admin, &100i128, &true, &None, &token); } #[test] @@ -230,10 +238,11 @@ mod settlement_tests { 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())); + client.receive_payment(&admin, &200i128, &false, &Some(developer.clone()), &token); - assert_eq!(client.get_developer_balance(&developer), 200i128); + assert_eq!(client.get_developer_balance(&developer, &token), 200i128); } #[test] @@ -245,9 +254,10 @@ mod settlement_tests { 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); - client.receive_payment(&vault, &600i128, &true, &None); + 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); } @@ -262,8 +272,9 @@ mod settlement_tests { 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), 0i128); + assert_eq!(client.get_developer_balance(&stranger, &token), 0i128); } #[test] @@ -279,12 +290,12 @@ mod settlement_tests { client.init(&admin, &vault); client.set_usdc_token(&admin, &usdc_address); - client.receive_payment(&vault, &100i128, &false, &Some(developer.clone())); + 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); + let result = client.try_withdraw_developer_balance(&developer, &100i128, &None, &usdc_address); assert!(result.is_ok()); - assert_eq!(client.get_developer_balance(&developer), 0i128); + assert_eq!(client.get_developer_balance(&developer, &usdc_address), 0i128); assert_eq!( token::Client::new(&env, &usdc_address).balance(&addr), 0i128 @@ -308,12 +319,12 @@ mod settlement_tests { client.init(&admin, &vault); client.set_usdc_token(&admin, &usdc_address); - client.receive_payment(&vault, &100i128, &false, &Some(developer.clone())); + 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); + let result = client.try_withdraw_developer_balance(&developer, &101i128, &None, &usdc_address); assert!(result.is_err()); - assert_eq!(client.get_developer_balance(&developer), 100i128); + assert_eq!(client.get_developer_balance(&developer, &usdc_address), 100i128); } #[test] @@ -325,11 +336,12 @@ mod settlement_tests { 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); - let negative_result = client.try_withdraw_developer_balance(&developer, &-1i128, &None); + 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()); @@ -351,10 +363,10 @@ mod settlement_tests { client.init(&admin, &vault); client.set_usdc_token(&admin, &usdc_address); - client.receive_payment(&vault, &200i128, &false, &Some(developer.clone())); + 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); + let result = client.try_withdraw_developer_balance(&developer, &200i128, &None, &usdc_address); assert!(result.is_ok()); let events = env.events().all(); @@ -392,12 +404,12 @@ mod settlement_tests { client.init(&admin, &vault); client.set_usdc_token(&admin, &usdc_address); - client.receive_payment(&vault, &150i128, &false, &Some(developer.clone())); + 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())); + let result = client.try_withdraw_developer_balance(&developer, &150i128, &Some(custodial.clone()), &usdc_address); assert!(result.is_ok()); - assert_eq!(client.get_developer_balance(&developer), 0i128); + assert_eq!(client.get_developer_balance(&developer, &usdc_address), 0i128); assert_eq!( token::Client::new(&env, &usdc_address).balance(&addr), 0i128 @@ -425,10 +437,10 @@ mod settlement_tests { client.init(&admin, &vault); client.set_usdc_token(&admin, &usdc_address); - client.receive_payment(&vault, &200i128, &false, &Some(developer.clone())); + 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())); + let result = client.try_withdraw_developer_balance(&developer, &200i128, &Some(custodial.clone()), &usdc_address); assert!(result.is_ok()); let events = env.events().all(); @@ -459,14 +471,14 @@ mod settlement_tests { 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); + 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())); + 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())); + client.withdraw_developer_balance(&developer, &100i128, &Some(addr.clone()), &usdc_address); } #[test] @@ -480,12 +492,13 @@ mod settlement_tests { 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())); - client.receive_payment(&vault, &200i128, &false, &Some(dev2.clone())); - client.receive_payment(&vault, &150i128, &false, &Some(dev1.clone())); + 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); + let all = client.get_all_developer_balances(&admin, &token); assert_eq!(all.len(), 2); let mut dev1_seen = false; let mut dev2_seen = false; @@ -513,13 +526,9 @@ mod settlement_tests { 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); - assert_eq!(all.len(), 0); - } - - #[test] - fn test_get_developer_balances_page() { + let all = client.get_all_developer_balances(&admin, &token); let env = Env::default(); env.mock_all_auths(); let admin = Address::generate(&env); @@ -530,12 +539,13 @@ mod settlement_tests { 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())); - client.receive_payment(&vault, &200i128, &false, &Some(dev2.clone())); - client.receive_payment(&vault, &300i128, &false, &Some(dev3.clone())); + 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); + 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); @@ -550,14 +560,15 @@ mod settlement_tests { 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)); + 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); + let page = client.get_developer_balances_page(&admin, &0u32, &200u32, &token); assert_eq!(page.len(), 100); } @@ -570,13 +581,14 @@ mod settlement_tests { 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)); + client.receive_payment(&vault, &1i128, &false, &Some(developer), &token); } - let all = client.get_all_developer_balances(&admin); + let all = client.get_all_developer_balances(&admin, &token); assert_eq!(all.len(), 101); } @@ -951,10 +963,11 @@ mod settlement_tests { 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())); - let dev_balance_before = client.get_developer_balance(&developer); + 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 @@ -962,7 +975,7 @@ mod settlement_tests { client.accept_admin(); // State preserved - assert_eq!(client.get_developer_balance(&developer), dev_balance_before); + assert_eq!(client.get_developer_balance(&developer, &token), dev_balance_before); assert_eq!( client.get_global_pool().total_balance, pool_before.total_balance @@ -1065,8 +1078,9 @@ mod settlement_tests { 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); + let result = client.try_receive_payment(&vault, &0i128, &true, &None, &token); assert!(is_error(result, SettlementError::AmountNotPositive)); } @@ -1079,8 +1093,9 @@ mod settlement_tests { 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); + let result = client.try_receive_payment(&vault, &-1i128, &true, &None, &token); assert!(is_error(result, SettlementError::AmountNotPositive)); } @@ -1093,6 +1108,7 @@ mod settlement_tests { 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(); @@ -1103,7 +1119,7 @@ mod settlement_tests { inst.set(&crate::StorageKey::GlobalPool, &pool); }); - let result = client.try_receive_payment(&vault, &1i128, &true, &None); + let result = client.try_receive_payment(&vault, &1i128, &true, &None, &token); assert!(is_error(result, SettlementError::PoolOverflow)); } @@ -1117,15 +1133,16 @@ mod settlement_tests { 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()), + &crate::StorageKey::DeveloperBalance(developer.clone(), token.clone()), &i128::MAX, ); }); - let result = client.try_receive_payment(&vault, &1i128, &false, &Some(developer)); + let result = client.try_receive_payment(&vault, &1i128, &false, &Some(developer), &token); assert!(is_error(result, SettlementError::DeveloperOverflow)); } @@ -1138,8 +1155,9 @@ mod settlement_tests { 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); + let result = client.try_receive_payment(&vault, &100i128, &false, &None, &token); assert!(is_error(result, SettlementError::DeveloperRequired)); } @@ -1153,8 +1171,9 @@ mod settlement_tests { 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)); + let result = client.try_receive_payment(&vault, &100i128, &true, &Some(developer), &token); assert!(is_error(result, SettlementError::DeveloperMustBeNone)); } @@ -1191,7 +1210,7 @@ mod settlement_tests { ]; for case in cases { - let (env, addr, admin, vault, third_party) = setup_contract(); + let (env, addr, admin, vault, third_party, token) = setup_contract(); let client = CalloraSettlementClient::new(&env, &addr); let caller = match case.role { CallerRole::Vault => vault, @@ -1199,7 +1218,7 @@ mod settlement_tests { CallerRole::ThirdParty => third_party, }; - let result = client.try_receive_payment(&caller, &100i128, &true, &None); + 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); @@ -1228,8 +1247,9 @@ mod settlement_tests { 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); + client.receive_payment(&vault, &1000i128, &true, &None, &token); let events = env.events().all(); let ev = events @@ -1265,11 +1285,11 @@ mod settlement_tests { 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())); + client.receive_payment(&vault, &500i128, &false, &Some(developer.clone()), &token); let events = env.events().all(); - let pr_ev = events .iter() .find(|e| { @@ -1316,9 +1336,10 @@ mod settlement_tests { 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())); - client.receive_payment(&vault, &200i128, &false, &Some(developer.clone())); + 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(); @@ -1350,13 +1371,14 @@ mod settlement_tests { 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); + client.receive_payment(&vault, &1000i128, &true, &None, &token); assert_eq!(client.get_global_pool().total_balance, 1000i128); } @@ -1371,17 +1393,18 @@ mod settlement_tests { 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); + 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); + client.receive_payment(&new_vault, &1000i128, &true, &None, &token); assert_eq!(client.get_global_pool().total_balance, 1000i128); } @@ -1397,19 +1420,20 @@ mod settlement_tests { 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())); + 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), 500i128); + 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); + 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); } @@ -1426,20 +1450,21 @@ mod settlement_tests { 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())); + 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())); - client.receive_payment(&new_vault, &200i128, &false, &Some(developer.clone())); + 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), 450i128); + assert_eq!(client.get_developer_balance(&developer, &token), 450i128); } #[test] @@ -1455,9 +1480,10 @@ mod settlement_tests { 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); + 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); @@ -1467,7 +1493,7 @@ mod settlement_tests { env.ledger().set_timestamp(1_700_000_100); // New payment updates timestamp - client.receive_payment(&vault, &500i128, &true, &None); + 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); @@ -1483,6 +1509,7 @@ mod settlement_tests { 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); @@ -1490,14 +1517,14 @@ mod settlement_tests { // Advance time and credit pool � last_updated must change env.ledger().set_timestamp(2_000); - client.receive_payment(&vault, &100i128, &true, &None); + 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); + 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); @@ -1514,24 +1541,25 @@ mod settlement_tests { 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())); + 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), 200i128); + 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) = setup_contract(); + let (env, addr, admin, vault, third_party, _token) = setup_contract(); let client = CalloraSettlementClient::new(&env, &addr); let new_admin = Address::generate(&env); @@ -1549,7 +1577,7 @@ mod settlement_tests { #[test] fn test_set_vault_authorization_matrix() { - let (env, addr, admin, vault, third_party) = setup_contract(); + let (env, addr, admin, vault, third_party, _token) = setup_contract(); let client = CalloraSettlementClient::new(&env, &addr); let new_vault = Address::generate(&env); @@ -1567,7 +1595,7 @@ mod settlement_tests { #[test] fn test_accept_vault_rejects_unauthorized_caller() { - let (env, addr, admin, vault, third_party) = setup_contract(); + let (env, addr, admin, vault, third_party, _token) = setup_contract(); let client = CalloraSettlementClient::new(&env, &addr); let new_vault = Address::generate(&env); @@ -1580,7 +1608,7 @@ mod settlement_tests { #[test] fn test_accept_vault_allows_admin_to_finalize() { - let (env, addr, admin, vault, _third_party) = setup_contract(); + let (env, addr, admin, vault, _third_party, _token) = setup_contract(); let client = CalloraSettlementClient::new(&env, &addr); let new_vault = Address::generate(&env); @@ -1607,7 +1635,7 @@ mod settlement_tests { #[test] fn test_accept_admin_authorization_matrix() { - let (env, addr, admin, _vault, _third_party) = setup_contract(); + let (env, addr, admin, _vault, _third_party, _token) = setup_contract(); let client = CalloraSettlementClient::new(&env, &addr); let new_admin = Address::generate(&env); @@ -1620,18 +1648,18 @@ mod settlement_tests { #[test] fn test_get_all_developer_balances_authorization_matrix() { - let (env, addr, admin, vault, third_party) = setup_contract(); + 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); + let _ = client.get_all_developer_balances(&admin, &token); // Vault cannot call - let result = client.try_get_all_developer_balances(&vault); + 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); + let result = client.try_get_all_developer_balances(&third_party, &token); assert!(is_error(result, SettlementError::Unauthorized)); } @@ -1639,7 +1667,7 @@ mod settlement_tests { #[test] fn test_batch_receive_payment_credits_multiple_developers() { - let (env, addr, _admin, vault, _third_party) = setup_contract(); + 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); @@ -1648,54 +1676,54 @@ mod settlement_tests { items.push_back((dev1.clone(), 100i128)); items.push_back((dev2.clone(), 200i128)); - client.batch_receive_payment(&vault, &items); + client.batch_receive_payment(&vault, &items, &token); - assert_eq!(client.get_developer_balance(&dev1), 100i128); - assert_eq!(client.get_developer_balance(&dev2), 200i128); + 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) = setup_contract(); + 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())); + 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); + client.batch_receive_payment(&vault, &items, &token); - assert_eq!(client.get_developer_balance(&dev), 125i128); + 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) = setup_contract(); + 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); + client.batch_receive_payment(&admin, &items, &token); - assert_eq!(client.get_developer_balance(&dev), 300i128); + 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) = setup_contract(); + 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); + 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) = setup_contract(); + let (env, addr, _admin, vault, _third_party, token) = setup_contract(); let client = CalloraSettlementClient::new(&env, &addr); let dev = Address::generate(&env); @@ -1703,63 +1731,63 @@ mod settlement_tests { for _ in 0..=MAX_BATCH_SIZE { items.push_back((dev.clone(), 1i128)); } - let result = client.try_batch_receive_payment(&vault, &items); + 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) = setup_contract(); + 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); + 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) = setup_contract(); + 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); + 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) = setup_contract(); + 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); + 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) = setup_contract(); + 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); + client.batch_receive_payment(&vault, &items, &token); - assert_eq!(client.get_developer_balance(&dev), 999i128); + 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) = setup_contract(); + let (env, addr, _admin, vault, _third_party, token) = setup_contract(); let client = CalloraSettlementClient::new(&env, &addr); let mut items = soroban_sdk::Vec::new(&env); @@ -1769,10 +1797,10 @@ mod settlement_tests { devs.push(dev.clone()); items.push_back((dev, 1i128)); } - client.batch_receive_payment(&vault, &items); + client.batch_receive_payment(&vault, &items, &token); for dev in &devs { - assert_eq!(client.get_developer_balance(dev), 1i128); + assert_eq!(client.get_developer_balance(dev, &token), 1i128); } } @@ -1780,19 +1808,19 @@ mod settlement_tests { #[test] fn test_force_credit_developer_happy_path() { - let (env, addr, admin, _vault, _third_party) = setup_contract(); + 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, &reason); + client.force_credit_developer(&admin, &developer, &1000i128, &token, &reason); - assert_eq!(client.get_developer_balance(&developer), 1000i128); + assert_eq!(client.get_developer_balance(&developer, &token), 1000i128); } #[test] fn test_force_credit_developer_accumulates() { - let (env, addr, admin, _vault, _third_party) = setup_contract(); + let (env, addr, admin, _vault, _third_party, token) = setup_contract(); let client = CalloraSettlementClient::new(&env, &addr); let developer = Address::generate(&env); @@ -1800,37 +1828,39 @@ mod settlement_tests { &admin, &developer, &500i128, + &token, &Symbol::new(&env, "first"), ); client.force_credit_developer( &admin, &developer, &300i128, + &token, &Symbol::new(&env, "second"), ); - assert_eq!(client.get_developer_balance(&developer), 800i128); + assert_eq!(client.get_developer_balance(&developer, &token), 800i128); } #[test] fn test_force_credit_developer_unauthorized() { - let (env, addr, _admin, vault, third_party) = setup_contract(); + 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); + client.try_force_credit_developer(&vault, &developer, &100i128, &token, &reason); assert!(is_error(vault_result, SettlementError::Unauthorized)); let third_party_result = - client.try_force_credit_developer(&third_party, &developer, &100i128, &reason); + 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) = setup_contract(); + let (env, addr, admin, _vault, _third_party, token) = setup_contract(); let client = CalloraSettlementClient::new(&env, &addr); let developer = Address::generate(&env); @@ -1838,6 +1868,7 @@ mod settlement_tests { &admin, &developer, &0i128, + &token, &Symbol::new(&env, "zero"), ); assert!(is_error(result, SettlementError::AmountNotPositive)); @@ -1845,7 +1876,7 @@ mod settlement_tests { #[test] fn test_force_credit_developer_negative_amount() { - let (env, addr, admin, _vault, _third_party) = setup_contract(); + let (env, addr, admin, _vault, _third_party, token) = setup_contract(); let client = CalloraSettlementClient::new(&env, &addr); let developer = Address::generate(&env); @@ -1853,6 +1884,7 @@ mod settlement_tests { &admin, &developer, &-1i128, + &token, &Symbol::new(&env, "negative"), ); assert!(is_error(result, SettlementError::AmountNotPositive)); @@ -1863,12 +1895,12 @@ mod settlement_tests { use soroban_sdk::testutils::Events as _; use soroban_sdk::IntoVal; - let (env, addr, admin, _vault, _third_party) = setup_contract(); + 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, &reason); + client.force_credit_developer(&admin, &developer, &2500i128, &token, &reason); let events = env.events().all(); let ev = events @@ -1893,22 +1925,22 @@ mod settlement_tests { #[test] fn test_force_credit_developer_repeated_reason() { - let (env, addr, admin, _vault, _third_party) = setup_contract(); + 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, &reason); - client.force_credit_developer(&admin, &developer2, &200i128, &reason); + 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), 100i128); - assert_eq!(client.get_developer_balance(&developer2), 200i128); + 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) = setup_contract(); + let (env, addr, admin, _vault, _third_party, token) = setup_contract(); let client = CalloraSettlementClient::new(&env, &addr); let developer = Address::generate(&env); @@ -1916,7 +1948,7 @@ mod settlement_tests { env.storage() .persistent() .set( - &crate::StorageKey::DeveloperBalance(developer.clone()), + &crate::StorageKey::DeveloperBalance(developer.clone(), token.clone()), &i128::MAX, ); }); @@ -1925,6 +1957,7 @@ mod settlement_tests { &admin, &developer, &1i128, + &token, &Symbol::new(&env, "overflow"), ); assert!(is_error(result, SettlementError::DeveloperOverflow)); @@ -1936,7 +1969,7 @@ mod settlement_tests { /// Includes overflow-boundary cases near i128::MAX. #[test] fn test_conservation_invariant_randomized() { - let (env, addr, admin, vault, _third_party) = setup_contract(); + let (env, addr, admin, vault, _third_party, token) = setup_contract(); let client = CalloraSettlementClient::new(&env, &addr); let mut developers = std::vec::Vec::new(); @@ -1959,11 +1992,11 @@ mod settlement_tests { let amount = (next_rand() % 1_000_000) as i128 + 1; if to_pool { - client.receive_payment(&vault, &amount, &true, &None); + 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())); + client.receive_payment(&vault, &amount, &false, &Some(developer.clone()), &token); } } total_credited += amount; @@ -1978,12 +2011,12 @@ mod settlement_tests { let half_remaining = remaining / 2; // Large credit to pool - client.receive_payment(&vault, &half_remaining, &true, &None); + 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())); + client.receive_payment(&vault, &half_remaining, &false, &Some(developer.clone()), &token); total_credited += half_remaining; } } @@ -1992,7 +2025,7 @@ mod settlement_tests { let pool = client.get_global_pool(); let mut sum_dev_balances: i128 = 0; - let all_balances = client.get_all_developer_balances(&admin); + let all_balances = client.get_all_developer_balances(&admin, &token); for record in all_balances.iter() { sum_dev_balances += record.balance; } @@ -2005,7 +2038,7 @@ mod settlement_tests { } #[test] fn test_upgrade_and_get_version() { - let (env, addr, admin, _vault, _third_party) = setup_contract(); + let (env, addr, admin, _vault, _third_party, _token) = setup_contract(); let client = CalloraSettlementClient::new(&env, &addr); assert_eq!(client.get_version(), None); @@ -2038,18 +2071,18 @@ mod settlement_tests { 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())); + 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); + let result = client.try_withdraw_developer_balance(&developer, &300i128, &None, &usdc_address); assert!(result.is_ok()); - assert_eq!(client.get_developer_balance(&developer), 700i128); + 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); + let result = client.try_withdraw_developer_balance(&developer, &300i128, &None, &usdc_address); assert!(is_error(result, SettlementError::DailyWithdrawCapExceeded)); - assert_eq!(client.get_developer_balance(&developer), 700i128); + assert_eq!(client.get_developer_balance(&developer, &usdc_address), 700i128); } #[test] @@ -2066,20 +2099,20 @@ mod settlement_tests { 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())); + 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).is_ok()); - assert!(client.try_withdraw_developer_balance(&developer, &200i128).is_ok()); - assert_eq!(client.get_developer_balance(&developer), 600i128); + assert!(client.try_withdraw_developer_balance(&developer, &200i128, &None, &usdc_address).is_ok()); + assert!(client.try_withdraw_developer_balance(&developer, &200i128, &None, &usdc_address).is_ok()); + assert_eq!(client.get_developer_balance(&developer, &usdc_address), 600i128); // Third withdrawal of 100 would push to 500 (exact cap — allowed) - assert!(client.try_withdraw_developer_balance(&developer, &100i128).is_ok()); - assert_eq!(client.get_developer_balance(&developer), 500i128); + assert!(client.try_withdraw_developer_balance(&developer, &100i128, &None, &usdc_address).is_ok()); + assert_eq!(client.get_developer_balance(&developer, &usdc_address), 500i128); // Fourth withdrawal of 1 would exceed cap - let result = client.try_withdraw_developer_balance(&developer, &1i128); + let result = client.try_withdraw_developer_balance(&developer, &1i128, &None, &usdc_address); assert!(is_error(result, SettlementError::DailyWithdrawCapExceeded)); } @@ -2098,11 +2131,11 @@ mod settlement_tests { 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())); + 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).is_ok()); - assert_eq!(client.get_developer_balance(&developer), 0i128); + assert!(client.try_withdraw_developer_balance(&developer, &1000i128, &None, &usdc_address).is_ok()); + assert_eq!(client.get_developer_balance(&developer, &usdc_address), 0i128); } #[test] @@ -2119,11 +2152,11 @@ mod settlement_tests { 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())); + 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).is_ok()); - assert_eq!(client.get_developer_balance(&developer), 0i128); + assert!(client.try_withdraw_developer_balance(&developer, &1000i128, &None, &usdc_address).is_ok()); + assert_eq!(client.get_developer_balance(&developer, &usdc_address), 0i128); } #[test] @@ -2142,15 +2175,15 @@ mod settlement_tests { 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())); + 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).is_ok()); - assert_eq!(client.get_developer_balance(&developer), 600i128); + assert!(client.try_withdraw_developer_balance(&developer, &400i128, &None, &usdc_address).is_ok()); + assert_eq!(client.get_developer_balance(&developer, &usdc_address), 600i128); // Another 200 would exceed the 500 cap - let result = client.try_withdraw_developer_balance(&developer, &200i128); + let result = client.try_withdraw_developer_balance(&developer, &200i128, &None, &usdc_address); assert!(is_error(result, SettlementError::DailyWithdrawCapExceeded)); // Advance to day 1 @@ -2159,13 +2192,13 @@ mod settlement_tests { usdc_admin_client.mint(&addr, &500i128); // Withdrawal should succeed now (cap resets) - assert!(client.try_withdraw_developer_balance(&developer, &500i128).is_ok()); - assert_eq!(client.get_developer_balance(&developer), 100i128); + assert!(client.try_withdraw_developer_balance(&developer, &500i128, &None, &usdc_address).is_ok()); + assert_eq!(client.get_developer_balance(&developer, &usdc_address), 100i128); } #[test] fn test_set_daily_withdraw_cap_unauthorized() { - let (env, addr, _admin, vault, third_party) = setup_contract(); + let (env, addr, _admin, vault, third_party, _token) = setup_contract(); let client = CalloraSettlementClient::new(&env, &addr); let developer = Address::generate(&env); @@ -2215,7 +2248,7 @@ mod settlement_tests { #[test] fn test_get_daily_withdraw_cap_returns_zero_when_unset() { - let (env, addr, _admin, _vault, _third_party) = setup_contract(); + let (env, addr, _admin, _vault, _third_party, _token) = setup_contract(); let client = CalloraSettlementClient::new(&env, &addr); let developer = Address::generate(&env); @@ -2225,7 +2258,7 @@ mod settlement_tests { #[test] fn test_get_withdrawal_today_returns_zero_after_no_withdrawals() { - let (env, addr, _admin, _vault, _third_party) = setup_contract(); + let (env, addr, _admin, _vault, _third_party, _token) = setup_contract(); let client = CalloraSettlementClient::new(&env, &addr); let developer = Address::generate(&env); @@ -2247,15 +2280,15 @@ mod settlement_tests { 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())); + 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); + client.withdraw_developer_balance(&developer, &300i128, &None, &usdc_address); assert_eq!(client.get_withdrawal_today(&developer), 300i128); - client.withdraw_developer_balance(&developer, &200i128); + client.withdraw_developer_balance(&developer, &200i128, &None, &usdc_address); assert_eq!(client.get_withdrawal_today(&developer), 500i128); } @@ -2276,20 +2309,20 @@ mod settlement_tests { 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())); - client.receive_payment(&vault, &500i128, &false, &Some(dev2.clone())); + 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).is_ok()); + assert!(client.try_withdraw_developer_balance(&dev1, &300i128, &None, &usdc_address).is_ok()); // Still within cap (300 < 500) - assert!(client.try_withdraw_developer_balance(&dev1, &200i128).is_ok()); + assert!(client.try_withdraw_developer_balance(&dev1, &200i128, &None, &usdc_address).is_ok()); // Exceeds cap (300 + 200 + 1 > 500) - let result = client.try_withdraw_developer_balance(&dev1, &1i128); + let result = client.try_withdraw_developer_balance(&dev1, &1i128, &None, &usdc_address); assert!(is_error(result, SettlementError::DailyWithdrawCapExceeded)); // dev2 can still withdraw (no cap) - assert!(client.try_withdraw_developer_balance(&dev2, &500i128).is_ok()); + assert!(client.try_withdraw_developer_balance(&dev2, &500i128, &None, &usdc_address).is_ok()); } // ── cursor-based pagination tests ──────────────────────────────────────── @@ -2306,15 +2339,16 @@ mod settlement_tests { 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())); - client.receive_payment(&vault, &200i128, &false, &Some(dev2.clone())); - client.receive_payment(&vault, &300i128, &false, &Some(dev3.clone())); + 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); + 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 @@ -2337,21 +2371,22 @@ mod settlement_tests { 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())); - client.receive_payment(&vault, &20i128, &false, &Some(dev2.clone())); - client.receive_payment(&vault, &30i128, &false, &Some(dev3.clone())); + 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); + 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); + let (page2, next2) = client.get_developer_balances_cursor(&admin, &next1, &2u32, &token); 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"); @@ -2376,21 +2411,22 @@ mod settlement_tests { 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())); - client.receive_payment(&vault, &2i128, &false, &Some(dev2.clone())); + 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); + 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); + 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()); } @@ -2406,27 +2442,28 @@ mod settlement_tests { 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())); - client.receive_payment(&vault, &1i128, &false, &Some(dev_b.clone())); - client.receive_payment(&vault, &1i128, &false, &Some(dev_c.clone())); + 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); + 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())); + 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); + client.get_developer_balances_cursor(&admin, &cursor_after_first, &10u32, &token); assert_eq!(page2.len(), 2, "two records must remain after the cursor"); // The first developer must not appear again in page2. @@ -2450,16 +2487,17 @@ mod settlement_tests { 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)); + 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)); + client.get_developer_balances_cursor(&admin, &None, &(MAX_DEVELOPER_BALANCES_PAGE_SIZE + 50), &token); assert_eq!( page.len(), MAX_DEVELOPER_BALANCES_PAGE_SIZE, @@ -2477,11 +2515,12 @@ mod settlement_tests { 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)); + client.receive_payment(&vault, &1i128, &false, &Some(dev), &token); - let (page, next) = client.get_developer_balances_cursor(&admin, &None, &0u32); + let (page, next) = client.get_developer_balances_cursor(&admin, &None, &0u32, &token); assert_eq!(page.len(), 0); assert!(next.is_none()); } @@ -2496,8 +2535,9 @@ mod settlement_tests { 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); + let (page, next) = client.get_developer_balances_cursor(&admin, &None, &10u32, &token); assert_eq!(page.len(), 0); assert!(next.is_none()); } @@ -2512,8 +2552,9 @@ mod settlement_tests { 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); + 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" @@ -2531,18 +2572,19 @@ mod settlement_tests { 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())); + 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); + let (page, nc) = client.get_developer_balances_cursor(&admin, &next, &2u32, &token); for r in page.iter() { cursor_pages.push(r.address.clone()); } diff --git a/contracts/settlement/src/test_invariant.rs b/contracts/settlement/src/test_invariant.rs index 06c51e9..a7a4629 100644 --- a/contracts/settlement/src/test_invariant.rs +++ b/contracts/settlement/src/test_invariant.rs @@ -33,6 +33,8 @@ extern crate std; +use std::boxed::Box; + use proptest::prelude::*; use soroban_sdk::testutils::Address as _; use soroban_sdk::{token, Address, Env, Vec}; @@ -189,7 +191,7 @@ fn make_usdc<'a>( } fn setup_env() -> ( - Env, + &'static Env, Address, // contract address CalloraSettlementClient<'static>, Address, // admin @@ -231,13 +233,14 @@ fn check_invariant( env: &Env, client: &CalloraSettlementClient<'_>, admin: &Address, + token: &Address, expected_dev_total: i128, expected_pool_total: i128, trace: &Trace, step: u32, ) { // Sum all developer balances via the paginated view. - let balances = client.get_all_developer_balances(admin); + let balances = client.get_all_developer_balances(admin, token); let dev_sum: i128 = balances.iter().map(|b| b.balance).sum(); let pool = client.get_global_pool().total_balance; @@ -306,7 +309,7 @@ fn run_trace(seed: u64) { let mut expected_pool_total: i128 = 0; // Check invariant at t=0 (empty state). - check_invariant(env, &client, &admin, 0, 0, &trace, 0); + check_invariant(env, &client, &admin, &usdc_addr, 0, 0, &trace, 0); for step in 1..=TRACE_LENGTH { let op = (rng.next_u64() % OP_COUNT) as u8; @@ -315,7 +318,7 @@ fn run_trace(seed: u64) { x if x == Op::ReceiveDev as u8 => { let dev = devs[rng.gen_usize(0, DEV_POOL_SIZE - 1)].clone(); let amount = rng.gen_i128(1, AMOUNT_CAP); - client.receive_payment(&vault, &amount, &false, &Some(dev.clone())); + client.receive_payment(&vault, &amount, &false, &Some(dev.clone()), &usdc_addr); expected_dev_total = expected_dev_total .checked_add(amount) .expect("test tally overflow"); @@ -328,7 +331,7 @@ fn run_trace(seed: u64) { x if x == Op::ReceivePool as u8 => { let amount = rng.gen_i128(1, AMOUNT_CAP); - client.receive_payment(&vault, &amount, &true, &None); + client.receive_payment(&vault, &amount, &true, &None, &usdc_addr); expected_pool_total = expected_pool_total .checked_add(amount) .expect("test tally overflow"); @@ -345,7 +348,7 @@ fn run_trace(seed: u64) { items.push_back((dev, amount)); batch_total = batch_total.checked_add(amount).expect("batch tally overflow"); } - client.batch_receive_payment(&vault, &items); + client.batch_receive_payment(&vault, &items, &usdc_addr); expected_dev_total = expected_dev_total .checked_add(batch_total) .expect("test tally overflow"); @@ -359,10 +362,10 @@ fn run_trace(seed: u64) { x if x == Op::Withdraw as u8 => { // Pick a developer who has a positive balance. let dev = devs[rng.gen_usize(0, DEV_POOL_SIZE - 1)].clone(); - let current: i128 = client.get_developer_balance(&dev); + let current: i128 = client.get_developer_balance(&dev, &usdc_addr); if current > 0 { let amount = rng.gen_i128(1, current.min(AMOUNT_CAP)); - let result = client.try_withdraw_developer_balance(&dev, &amount, &None); + let result = client.try_withdraw_developer_balance(&dev, &amount, &None, &usdc_addr); if result.is_ok() { expected_dev_total = expected_dev_total .checked_sub(amount) @@ -395,6 +398,7 @@ fn run_trace(seed: u64) { env, &client, &admin, + &usdc_addr, expected_dev_total, expected_pool_total, &trace, @@ -441,14 +445,14 @@ fn test_invariant_pool_only() { let amounts = [100i128, 200, 300, 50, 1]; let mut expected_pool: i128 = 0; for (i, &amount) in amounts.iter().enumerate() { - client.receive_payment(&vault, &amount, &true, &None); + client.receive_payment(&vault, &amount, &true, &None, &usdc_addr); expected_pool += amount; let pool = client.get_global_pool().total_balance; assert_eq!( pool, expected_pool, "pool invariant failed at step {i}: expected {expected_pool}, got {pool}" ); - let dev_sum: i128 = client.get_all_developer_balances(&admin).iter().map(|b| b.balance).sum(); + let dev_sum: i128 = client.get_all_developer_balances(&admin, &usdc_addr).iter().map(|b| b.balance).sum(); assert_eq!(dev_sum, 0, "no developer should have a balance (step {i})"); } } @@ -475,21 +479,21 @@ fn test_invariant_single_dev_full_withdraw() { client.set_usdc_token(&admin, &usdc_addr); // Credit the developer. - client.receive_payment(&vault, &1_000, &false, &Some(dev.clone())); - client.receive_payment(&vault, &2_000, &false, &Some(dev.clone())); - client.receive_payment(&vault, &500, &false, &Some(dev.clone())); + client.receive_payment(&vault, &1_000, &false, &Some(dev.clone()), &usdc_addr); + client.receive_payment(&vault, &2_000, &false, &Some(dev.clone()), &usdc_addr); + client.receive_payment(&vault, &500, &false, &Some(dev.clone()), &usdc_addr); - let balance = client.get_developer_balance(&dev); + let balance = client.get_developer_balance(&dev, &usdc_addr); assert_eq!(balance, 3_500); - let dev_sum: i128 = client.get_all_developer_balances(&admin).iter().map(|b| b.balance).sum(); + let dev_sum: i128 = client.get_all_developer_balances(&admin, &usdc_addr).iter().map(|b| b.balance).sum(); assert_eq!(dev_sum, 3_500, "dev sum before withdraw"); // Full withdraw. - client.withdraw_developer_balance(&dev, &3_500, &None); + client.withdraw_developer_balance(&dev, &3_500, &None, &usdc_addr); let dev_sum_after: i128 = client - .get_all_developer_balances(&admin) + .get_all_developer_balances(&admin, &usdc_addr) .iter() .map(|b| b.balance) .sum(); @@ -522,11 +526,11 @@ fn test_invariant_batch_duplicate_dev() { let mut items: Vec<(Address, i128)> = Vec::new(env); items.push_back((dev.clone(), 100)); items.push_back((dev.clone(), 200)); - client.batch_receive_payment(&vault, &items); + client.batch_receive_payment(&vault, &items, &usdc_addr); - let dev_sum: i128 = client.get_all_developer_balances(&admin).iter().map(|b| b.balance).sum(); + let dev_sum: i128 = client.get_all_developer_balances(&admin, &usdc_addr).iter().map(|b| b.balance).sum(); assert_eq!(dev_sum, 300, "batch duplicate dev: expected 300, got {dev_sum}"); - assert_eq!(client.get_developer_balance(&dev), 300); + assert_eq!(client.get_developer_balance(&dev, &usdc_addr), 300); } /// Edge case: interleaved developer and pool payments preserve the full conservation invariant. @@ -565,14 +569,14 @@ fn test_invariant_interleaved_dev_and_pool() { for &(to_pool, amount, is_dev1) in ops { if to_pool { - client.receive_payment(&vault, &amount, &true, &None); + client.receive_payment(&vault, &amount, &true, &None, &usdc_addr); exp_pool += amount; } else { let dev = if is_dev1 { dev1.clone() } else { dev2.clone() }; - client.receive_payment(&vault, &amount, &false, &Some(dev)); + client.receive_payment(&vault, &amount, &false, &Some(dev), &usdc_addr); exp_dev += amount; } - let dev_sum: i128 = client.get_all_developer_balances(&admin).iter().map(|b| b.balance).sum(); + let dev_sum: i128 = client.get_all_developer_balances(&admin, &usdc_addr).iter().map(|b| b.balance).sum(); let pool = client.get_global_pool().total_balance; assert_eq!(dev_sum, exp_dev, "dev sum mismatch"); assert_eq!(pool, exp_pool, "pool mismatch"); diff --git a/contracts/settlement/src/test_multi_asset.rs b/contracts/settlement/src/test_multi_asset.rs new file mode 100644 index 0000000..813dc38 --- /dev/null +++ b/contracts/settlement/src/test_multi_asset.rs @@ -0,0 +1,323 @@ +extern crate std; + +use crate::{CalloraSettlement, CalloraSettlementClient, SettlementError, StorageKey}; +use soroban_sdk::testutils::{Address as _, Ledger as _}; +use soroban_sdk::{token, Address, Env, Symbol}; + +fn create_token<'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) +} + +/// Acceptance: two different tokens can be tracked independently for the same developer. +#[test] +fn test_two_tokens_independent_balances() { + 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_a, _, _) = create_token(&env, &admin); + let (token_b, _, _) = create_token(&env, &admin); + + client.init(&admin, &vault); + + // Credit token_a to developer + client.receive_payment(&vault, &1000i128, &false, &Some(developer.clone()), &token_a); + // Credit token_b to developer + client.receive_payment(&vault, &2000i128, &false, &Some(developer.clone()), &token_b); + + // Balances are independent per token + assert_eq!( + client.get_developer_balance(&developer, &token_a), + 1000i128 + ); + assert_eq!( + client.get_developer_balance(&developer, &token_b), + 2000i128 + ); + + // get_all_developer_balances filters by token + let all_a = client.get_all_developer_balances(&admin, &token_a); + assert_eq!(all_a.len(), 1); + assert_eq!(all_a.get(0).unwrap().balance, 1000i128); + assert_eq!(all_a.get(0).unwrap().token, token_a); + + let all_b = client.get_all_developer_balances(&admin, &token_b); + assert_eq!(all_b.len(), 1); + assert_eq!(all_b.get(0).unwrap().balance, 2000i128); + assert_eq!(all_b.get(0).unwrap().token, token_b); +} + +/// Acceptance: receives independently for two developers across two tokens. +#[test] +fn test_two_tokens_two_developers() { + 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 dev1 = Address::generate(&env); + let dev2 = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + + let (token_a, _, _) = create_token(&env, &admin); + let (token_b, _, _) = create_token(&env, &admin); + + client.init(&admin, &vault); + + // dev1 gets token_a, dev2 gets token_b + client.receive_payment(&vault, &100i128, &false, &Some(dev1.clone()), &token_a); + client.receive_payment(&vault, &200i128, &false, &Some(dev2.clone()), &token_b); + + assert_eq!(client.get_developer_balance(&dev1, &token_a), 100i128); + assert_eq!(client.get_developer_balance(&dev2, &token_b), 200i128); + // Cross-token queries return zero + assert_eq!(client.get_developer_balance(&dev1, &token_b), 0i128); + assert_eq!(client.get_developer_balance(&dev2, &token_a), 0i128); +} + +/// Acceptance: withdraw asserts token — can only withdraw the same token as credited. +#[test] +fn test_withdraw_asserts_token() { + 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 recipient = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + + let (token_a, _, token_a_sac) = create_token(&env, &admin); + let (token_b, token_b_client, token_b_sac) = create_token(&env, &admin); + + client.init(&admin, &vault); + + // Credit both tokens to developer + client.receive_payment(&vault, &500i128, &false, &Some(developer.clone()), &token_a); + client.receive_payment(&vault, &300i128, &false, &Some(developer.clone()), &token_b); + + // Fund the settlement contract with both tokens so withdrawal succeeds. + token_a_sac.mint(&addr, &1000i128); + token_b_sac.mint(&addr, &1000i128); + + // Withdraw token_a — succeeds, uses token_a's contract + let result = client.try_withdraw_developer_balance( + &developer, + &200i128, + &Some(recipient.clone()), + &token_a, + ); + assert!(result.is_ok()); + assert_eq!(client.get_developer_balance(&developer, &token_a), 300i128); + assert_eq!(token_b_client.balance(&recipient), 0i128); // token_b not touched + + // Withdraw token_b — succeeds, uses token_b's contract + let result = client.try_withdraw_developer_balance( + &developer, + &100i128, + &Some(recipient.clone()), + &token_b, + ); + assert!(result.is_ok()); + assert_eq!(client.get_developer_balance(&developer, &token_b), 200i128); + + // Cannot withdraw token_a balance when passing token_b (wrong token assertion) + // token_a balance is 300, trying to withdraw 300 but passing token_b address + // This should check balance for token_b (which is 200) and reject. + let result = client.try_withdraw_developer_balance( + &developer, + &300i128, + &Some(recipient.clone()), + &token_b, + ); + assert!(result.is_err()); // InsufficientDeveloperBalance for token_b + + // Cannot withdraw token_b balance when passing token_a (301 > token_a's 300 balance) + let result = client.try_withdraw_developer_balance( + &developer, + &301i128, + &Some(recipient.clone()), + &token_a, + ); + assert!(result.is_err()); // InsufficientDeveloperBalance for token_a +} + +/// Acceptance: migration helper converts legacy single-USDC entry to per-token format. +#[test] +fn test_migrate_developer_balance() { + 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 (usdc, _, _) = create_token(&env, &admin); + + client.init(&admin, &vault); + client.set_usdc_token(&admin, &usdc); + + // Write a legacy single-token balance using the old storage key directly + env.as_contract(&addr, || { + let legacy_key = StorageKey::DeveloperBalanceV1(developer.clone()); + env.storage().persistent().set(&legacy_key, &999i128); + env.storage() + .persistent() + .extend_ttl(&legacy_key, 50000, 50000); + }); + + // Before migration, new per-token read returns 0 + assert_eq!( + client.get_developer_balance(&developer, &usdc), + 0i128, + "legacy balance not yet migrated" + ); + + // Run migration + let result = client.try_migrate_developer_balance(&admin, &developer); + assert!(result.is_ok(), "migration should succeed"); + + // After migration, new per-token read returns the migrated value + assert_eq!( + client.get_developer_balance(&developer, &usdc), + 999i128, + "migrated balance should be 999" + ); + + // Legacy entry is removed + let legacy_exists = env.as_contract(&addr, || { + env.storage() + .persistent() + .has(&StorageKey::DeveloperBalanceV1(developer.clone())) + }); + assert!(!legacy_exists, "legacy entry should be removed"); +} + +/// Migration is idempotent — running twice is a no-op. +#[test] +fn test_migrate_developer_balance_idempotent() { + 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 (usdc, _, _) = create_token(&env, &admin); + + client.init(&admin, &vault); + client.set_usdc_token(&admin, &usdc); + + // Write legacy balance + env.as_contract(&addr, || { + env.storage() + .persistent() + .set(&StorageKey::DeveloperBalanceV1(developer.clone()), &555i128); + env.storage() + .persistent() + .extend_ttl(&StorageKey::DeveloperBalanceV1(developer.clone()), 50000, 50000); + }); + + // First migration + assert!(client.try_migrate_developer_balance(&admin, &developer).is_ok()); + assert_eq!(client.get_developer_balance(&developer, &usdc), 555i128); + + // Second migration — idempotent, no error, balance unchanged + assert!(client.try_migrate_developer_balance(&admin, &developer).is_ok()); + assert_eq!(client.get_developer_balance(&developer, &usdc), 555i128); +} + +/// Migration requires admin authorization. +#[test] +fn test_migrate_developer_balance_unauthorized() { + 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 (usdc, _, _) = create_token(&env, &admin); + + client.init(&admin, &vault); + client.set_usdc_token(&admin, &usdc); + + // Non-admin tries to migrate + let attacker = Address::generate(&env); + let result = client.try_migrate_developer_balance(&attacker, &developer); + assert!(result.is_err()); +} + +/// Migration fails if USDC token not configured. +#[test] +fn test_migrate_developer_balance_no_usdc() { + 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); + + client.init(&admin, &vault); + // Do NOT set USDC token + + let result = client.try_migrate_developer_balance(&admin, &developer); + assert!(result.is_err()); +} + +/// Batch receive payment per-token works correctly. +#[test] +fn test_batch_receive_payment_with_token() { + 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 dev1 = Address::generate(&env); + let dev2 = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + + let (token, _, _) = create_token(&env, &admin); + + client.init(&admin, &vault); + + let mut items: soroban_sdk::Vec<(Address, i128)> = 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); +} diff --git a/contracts/settlement/src/test_views.rs b/contracts/settlement/src/test_views.rs index d742c72..018ef40 100644 --- a/contracts/settlement/src/test_views.rs +++ b/contracts/settlement/src/test_views.rs @@ -43,11 +43,12 @@ fn test_get_global_pool_uninitialized() { fn test_get_developer_balance_uninitialized() { let env = Env::default(); let dev = Address::generate(&env); + let token = Address::generate(&env); let addr = env.register(CalloraSettlement, ()); let client = CalloraSettlementClient::new(&env, &addr); assert!(is_not_initialized( - client.try_get_developer_balance(&dev) + client.try_get_developer_balance(&dev, &token) )); } @@ -58,9 +59,10 @@ fn test_get_all_developer_balances_uninitialized() { let addr = env.register(CalloraSettlement, ()); let client = CalloraSettlementClient::new(&env, &addr); let dummy = Address::generate(&env); + let token = Address::generate(&env); assert!(is_not_initialized( - client.try_get_all_developer_balances(&dummy) + client.try_get_all_developer_balances(&dummy, &token) )); } @@ -72,13 +74,14 @@ fn test_get_developer_balance_returns_zero_when_not_stored() { let admin = Address::generate(&env); let vault = Address::generate(&env); let dev = Address::generate(&env); + let token = Address::generate(&env); let addr = env.register(CalloraSettlement, ()); let client = CalloraSettlementClient::new(&env, &addr); client.init(&admin, &vault); - let balance = client.get_developer_balance(&dev); + let balance = client.get_developer_balance(&dev, &token); assert_eq!(balance, 0); } @@ -91,8 +94,9 @@ fn test_get_developer_balances_cursor_uninitialized() { let addr = env.register(CalloraSettlement, ()); let client = CalloraSettlementClient::new(&env, &addr); let dummy = Address::generate(&env); + let token = Address::generate(&env); - let result = client.try_get_developer_balances_cursor(&dummy, &None, &10u32); + let result = client.try_get_developer_balances_cursor(&dummy, &None, &10u32, &token); assert!( is_not_initialized(result), "expected NotInitialized before init" diff --git a/contracts/settlement/src/types.rs b/contracts/settlement/src/types.rs new file mode 100644 index 0000000..b39708c --- /dev/null +++ b/contracts/settlement/src/types.rs @@ -0,0 +1,156 @@ +use soroban_sdk::{contracttype, Address, Symbol}; + +/// The maximum message length in bytes allowed for `broadcast` calls. +pub const MAX_MESSAGE_LEN: u32 = 256; + +/// Maximum number of items allowed in a single `batch_receive_payment` call. +pub const MAX_BATCH_SIZE: u32 = 50; + +/// Maximum number of developer balance records returned in a single +/// non-cursor-based query (gas guard). +pub const MAX_DEVELOPER_BALANCES_PAGE_SIZE: u32 = 100; + +/// Persistent storage keys for settlement contract. +/// +/// # Migration note +/// Discriminant 5 was the original `DeveloperBalance(Address)` (single-token, now +/// `DeveloperBalanceV1` — kept for migration only). New per-token entries use +/// `DeveloperBalance(Address, Address)` at discriminant 6. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum StorageKey { + Admin, + Vault, + PendingAdmin, + PendingVault, + DeveloperIndex, + /// Legacy single-token balance — kept for migration reads. Do NOT use for + /// new writes. + DeveloperBalanceV1(Address), + /// Per-token developer balance `(developer, token)`. + DeveloperBalance(Address, Address), + GlobalPool, + Usdc, + DailyWithdrawCap(Address), + WithdrawalToday(Address), + ContractVersion, +} + +/// Severity levels for admin broadcast messages. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum Severity { + Info, + Warn, + Crit, +} + +/// Payload for the `admin_broadcast` event. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct AdminBroadcast { + pub severity: Severity, + pub message: soroban_sdk::String, +} + +/// Developer balance record in settlement contract. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct DeveloperBalance { + pub address: Address, + pub token: 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, +} + +/// Payment received event. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct PaymentReceivedEvent { + pub from_vault: Address, + pub amount: i128, + pub to_pool: bool, + pub developer: Option
, + pub token: Address, +} + +/// Balance credited event. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct BalanceCreditedEvent { + pub developer: Address, + pub amount: i128, + pub new_balance: i128, + pub token: Address, +} + +/// 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, + pub token: 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 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, +} diff --git a/contracts/vault/src/lib.rs b/contracts/vault/src/lib.rs index 4684fb5..0ba9017 100644 --- a/contracts/vault/src/lib.rs +++ b/contracts/vault/src/lib.rs @@ -196,6 +196,7 @@ trait Settlement { amount: i128, to_pool: bool, developer: Option
, + token: Address, ); } @@ -835,6 +836,7 @@ impl CalloraVault { &amount, &true, // to_pool = true: credit global pool &None, // no specific developer + &ut, // token ); // Now that external operations succeeded, update internal state @@ -942,6 +944,7 @@ impl CalloraVault { &total, &true, // to_pool = true: credit global pool &None, // no specific developer + &ut, // token ); // Now that external operations succeeded, update internal state diff --git a/contracts/vault/src/test_reentrancy.rs b/contracts/vault/src/test_reentrancy.rs index b0a54aa..4509188 100644 --- a/contracts/vault/src/test_reentrancy.rs +++ b/contracts/vault/src/test_reentrancy.rs @@ -78,6 +78,7 @@ impl MaliciousSettlement { _amount: i128, _to_pool: bool, _developer: Option
, + _token: Address, ) { let vault_addr: Option
= env .storage() diff --git a/tests/e2e_full_cycle.rs b/tests/e2e_full_cycle.rs index 78eb2d5..96ab538 100644 --- a/tests/e2e_full_cycle.rs +++ b/tests/e2e_full_cycle.rs @@ -101,7 +101,7 @@ use soroban_sdk::{vec, Env, Symbol}; /// every checkpoint computes the conservation invariant identically. fn settlement_total(h: &Harness, devs: &[soroban_sdk::Address]) -> i128 { let pool = h.settlement.get_global_pool().total_balance; - let dev_sum: i128 = devs.iter().map(|d| h.settlement.get_developer_balance(d)).sum(); + let dev_sum: i128 = devs.iter().map(|d| h.settlement.get_developer_balance(d, &h.usdc_id)).sum(); pool + dev_sum } @@ -220,9 +220,9 @@ fn e2e_full_cycle() { // ------------------------------------------------------------------ let dev_a_credit: i128 = 4_000_000; h.settlement - .receive_payment(&h.owner, &dev_a_credit, &false, &Some(h.dev_a.clone())); + .receive_payment(&h.owner, &dev_a_credit, &false, &Some(h.dev_a.clone()), &h.usdc_id); - assert_eq!(h.settlement.get_developer_balance(&h.dev_a), dev_a_credit); + assert_eq!(h.settlement.get_developer_balance(&h.dev_a, &h.usdc_id), dev_a_credit); assert_eq!( h.settlement.get_global_pool().total_balance, total_deducted, @@ -237,10 +237,10 @@ fn e2e_full_cycle() { h.settlement.set_usdc_token(&h.owner, &h.usdc_id); let dev_a_withdraw: i128 = 1_500_000; h.settlement - .withdraw_developer_balance(&h.dev_a, &dev_a_withdraw); + .withdraw_developer_balance(&h.dev_a, &dev_a_withdraw, &h.usdc_id); assert_eq!( - h.settlement.get_developer_balance(&h.dev_a), + h.settlement.get_developer_balance(&h.dev_a, &h.usdc_id), dev_a_credit - dev_a_withdraw ); assert_eq!(h.usdc.balance(&h.dev_a), dev_a_withdraw);