diff --git a/UPGRADE.md b/UPGRADE.md
index c7ce7cbf..a701924c 100644
--- a/UPGRADE.md
+++ b/UPGRADE.md
@@ -171,6 +171,20 @@ pub fn init(env: Env, admin: Address, usdc_token: Address)
| `global_pool` | `GlobalPool` | Total balance and last updated timestamp |
| `ContractVersion` | `BytesN<32>` | WASM hash set by `upgrade` function |
+#### Per-developer minimum claim balance
+
+Settlement admins can configure a per-developer minimum accrued balance that must be met before `withdraw_developer_balance` settles a claim:
+
+```rust
+pub fn set_minimum_balance(env: Env, caller: Address, developer: Address, min_balance: i128)
+pub fn get_minimum_balance(env: Env, developer: Address) -> i128
+```
+
+- `caller` must be the current settlement admin.
+- `min_balance` is denominated in token micro-units and must be non-negative.
+- `0` means no minimum requirement, matching previous behavior.
+- If a developer's accrued balance is below their configured minimum, withdrawal returns `SettlementError::MinimumBalanceRequired` before token transfer or balance mutation.
+
#### Upgradeability
The settlement contract supports in-place upgrades via an admin-gated `upgrade` function.
diff --git a/contracts/settlement/src/errors.rs b/contracts/settlement/src/errors.rs
index 3d38e507..ec090f2b 100644
--- a/contracts/settlement/src/errors.rs
+++ b/contracts/settlement/src/errors.rs
@@ -30,7 +30,9 @@ use soroban_sdk::contracterror;
/// | 20 | MigrationNotFound | No migration is pending for the source |
/// | 21 | TimelockNotExpired | Migration delay has not elapsed |
/// | 22 | MigrationBalanceChanged | Approved amount is no longer available |
-/// | 23 | OverDraft | Withdrawal amount exceeds the developer's balance |
+/// | 23 | MinimumBalanceRequired | Developer balance is below configured claim minimum |
+/// | 24 | InvalidClaimWindow | Claim window end is before start |
+/// | 25 | ClaimWindowClosed | Claim attempted outside developer's claim window |
#[contracterror]
#[derive(Clone, Copy, Debug, PartialEq)]
#[repr(u32)]
@@ -58,4 +60,6 @@ pub enum SettlementError {
TimelockNotExpired = 21,
MigrationBalanceChanged = 22,
MinimumBalanceRequired = 23,
+ InvalidClaimWindow = 24,
+ ClaimWindowClosed = 25,
}
diff --git a/contracts/settlement/src/lib.rs b/contracts/settlement/src/lib.rs
index 8b39bfc5..77eaf775 100644
--- a/contracts/settlement/src/lib.rs
+++ b/contracts/settlement/src/lib.rs
@@ -1,205 +1,27 @@
#![no_std]
use soroban_sdk::{
- contract, contracterror, contractimpl, contracttype, token, Address, BytesN, Env, Symbol, Vec,
+ contract, contractimpl, token, Address, BytesN, Env, Symbol, Vec, String,
};
-/// Maximum number of items allowed in a single `batch_receive_payment` call.
-pub const MAX_BATCH_SIZE: u32 = 50;
-
-/// Maximum number of developer balances returned per page in paginated queries.
-pub const MAX_DEVELOPER_BALANCES_PAGE_SIZE: u32 = 100;
-
-/// Typed errors for the settlement contract.
-///
-/// Using `#[contracterror]` encodes each variant as a stable `u32` code.
-/// Callers and indexers can match on the code rather than parsing raw panic strings,
-/// and the WASM binary shrinks because no error string literals are embedded.
-///
-/// | Code | Variant | When |
-/// |------|------------------------------|---------------------------------------------------|
-/// | 1 | NotInitialized | A function is called before `init` |
-/// | 2 | AlreadyInitialized | `init` is called more than once |
-/// | 3 | Unauthorized | Caller is not the vault or admin |
-/// | 4 | AmountNotPositive | `amount` is zero or negative |
-/// | 5 | DeveloperRequired | `to_pool=false` but no developer address supplied |
-/// | 6 | DeveloperMustBeNone | `to_pool=true` but a developer address was given |
-/// | 7 | PoolOverflow | Global pool `i128` addition would overflow |
-/// | 8 | DeveloperOverflow | Developer balance `i128` addition would overflow |
-/// | 9 | UsdcTokenNotConfigured | USDC token address not configured for withdrawals |
-/// | 10 | InsufficientDeveloperBalance | Developer balance is less than withdrawal amount |
-/// | 11 | DeveloperBalanceUnderflow | Developer balance subtraction would overflow |
-/// | 12 | InsufficientContractBalance | Settlement contract lacks on-ledger USDC |
-/// | 13 | DailyWithdrawCapExceeded | Developer's daily withdrawal cap would be exceeded|
-/// | 14 | GasExhaustionRisk | Index too large for safe full scan; use pagination|
-/// | 15 | ReasonTooLong | Reason Symbol exceeds maximum allowed length |
-/// | 16 | InvalidClaimWindow | Claim window end is before start |
-/// | 17 | ClaimWindowClosed | Claim attempted outside developer's claim window |
-#[contracterror]
-#[derive(Clone, Copy, Debug, PartialEq)]
-#[repr(u32)]
-pub enum SettlementError {
- NotInitialized = 1,
- AlreadyInitialized = 2,
- Unauthorized = 3,
- AmountNotPositive = 4,
- DeveloperRequired = 5,
- DeveloperMustBeNone = 6,
- PoolOverflow = 7,
- DeveloperOverflow = 8,
- UsdcTokenNotConfigured = 9,
- InsufficientDeveloperBalance = 10,
- DeveloperBalanceUnderflow = 11,
- InsufficientContractBalance = 12,
- DailyWithdrawCapExceeded = 13,
- GasExhaustionRisk = 14,
- ReasonTooLong = 15,
- InvalidClaimWindow = 16,
- ClaimWindowClosed = 17,
-}
-
-/// Persistent storage keys for settlement contract
-#[contracttype]
-#[derive(Clone, Debug, PartialEq)]
-pub enum StorageKey {
- Admin,
- Vault,
- PendingAdmin,
- PendingVault,
- DeveloperIndex,
- DeveloperBalance(Address),
- GlobalPool,
- Usdc,
- DailyWithdrawCap(Address),
- WithdrawalToday(Address),
- DeveloperClaimWindow(Address),
- ContractVersion,
-}
-
-/// Developer balance record in settlement contract
-#[contracttype]
-#[derive(Clone, Debug, PartialEq)]
-pub struct DeveloperBalance {
- pub address: Address,
- pub balance: i128,
-}
-
-/// Global pool balance tracking.
-///
-/// `last_updated` is set to `env.ledger().timestamp()` on every
-/// `receive_payment` call that credits the pool (`to_pool = true`).
-/// It is also set at `init` time. It is **not** updated when payments
-/// are routed to individual developer balances.
-#[contracttype]
-#[derive(Clone, Debug, PartialEq)]
-pub struct GlobalPool {
- pub total_balance: i128,
- /// Ledger timestamp of the last pool credit. Useful for analytics
- /// and staleness checks.
- pub last_updated: u64,
-}
-
-/// Tracks a developer's cumulative withdrawal amount for a given epoch day.
-///
-/// `day` is `timestamp / 86400` (UTC epoch day). When the current call's day
-/// differs from the stored day the accumulator is silently reset.
-#[contracttype]
-#[derive(Clone, Debug, PartialEq)]
-pub struct DailyWithdrawState {
- pub day: u64,
- pub amount: i128,
-}
-
-/// Timestamp range during which a developer may claim accrued balance.
-///
-/// `start_ts` and `end_ts` are ledger timestamps in seconds. The window is
-/// inclusive on both ends: a withdrawal is allowed when
-/// `start_ts <= env.ledger().timestamp() <= end_ts`.
-#[contracttype]
-#[derive(Clone, Debug, PartialEq)]
-pub struct DeveloperClaimWindow {
- pub start_ts: u64,
- pub end_ts: u64,
-}
-
-/// Payment received event
-#[contracttype]
-#[derive(Clone, Debug, PartialEq)]
-pub struct PaymentReceivedEvent {
- pub from_vault: Address,
- pub amount: i128,
- pub to_pool: bool, // true if credited to global pool, false if to specific developer
- pub developer: Option
, // developer address if credited to specific developer
-}
-
-/// Balance credited event
-#[contracttype]
-#[derive(Clone, Debug, PartialEq)]
-pub struct BalanceCreditedEvent {
- pub developer: Address,
- pub amount: i128,
- pub new_balance: i128,
-}
-
-/// Emitted when a new vault address is proposed via `propose_vault()`.
-#[contracttype]
-#[derive(Clone, Debug, PartialEq)]
-pub struct VaultProposedEvent {
- pub current_vault: Address,
- pub proposed_vault: Address,
-}
-
-/// Emitted when the proposed vault is accepted via `accept_vault()`.
-#[contracttype]
-#[derive(Clone, Debug, PartialEq)]
-pub struct VaultAcceptedEvent {
- pub old_vault: Address,
- pub new_vault: Address,
- pub accepted_by: Address,
-}
-
-/// Emitted when a developer withdraws their balance.
-#[contracttype]
-#[derive(Clone, Debug, PartialEq)]
-pub struct DeveloperWithdrawEvent {
- pub developer: Address,
- pub amount: i128,
- pub remaining_balance: i128,
- pub to: Address,
-}
-
-/// Emitted when the admin sets or changes a developer's daily withdrawal cap.
-#[contracttype]
-#[derive(Clone, Debug, PartialEq)]
-pub struct DailyWithdrawCapChanged {
- pub developer: Address,
- pub new_cap: i128,
-}
-
-/// Emitted when the admin sets or clears a developer claim window.
-#[contracttype]
-#[derive(Clone, Debug, PartialEq)]
-pub struct DeveloperClaimWindowChanged {
- pub developer: Address,
- pub start_ts: u64,
- pub end_ts: u64,
- pub enabled: bool,
-}
-
-/// Emitted when an admin force-credits a developer balance (escape hatch).
-#[contracttype]
-#[derive(Clone, Debug, PartialEq)]
-pub struct DeveloperForceCreditedEvent {
- pub developer: Address,
- pub amount: i128,
- pub reason: Symbol,
- pub new_balance: i128,
-}
-
-/// 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 admin;
+mod errors;
+mod events;
+mod limits;
+pub mod migrate;
+mod pagination;
+mod timelock;
+mod types;
+
+pub use crate::errors::SettlementError;
+pub use crate::types::{
+ AdminBroadcast, AdminMigrationEvent, BalanceCreditedEvent, DailyWithdrawCapChanged,
+ DailyWithdrawState, DeveloperBalance, DeveloperClaimWindow, DeveloperClaimWindowChanged,
+ DeveloperForceCreditedEvent, DeveloperWithdrawEvent, GlobalPool, PaymentReceivedEvent,
+ Severity, StorageEntryTtl, StorageKey, VaultAcceptedEvent, VaultProposedEvent, MAX_BATCH_SIZE,
+ MAX_DEVELOPER_BALANCES_PAGE_SIZE, MAX_MESSAGE_LEN,
+};
+pub use crate::timelock::PendingDeveloperMigration;
#[contract]
pub struct CalloraSettlement;
@@ -415,20 +237,19 @@ impl CalloraSettlement {
.checked_add(amount)
.unwrap_or_else(|| env.panic_with_error(SettlementError::DeveloperOverflow));
env.storage().persistent().set(&balance_key, &new_balance);
+
+ // Extend TTL for the developer's balance entry
env.storage()
.persistent()
- .set(&StorageKey::DeveloperBalance(dev.clone()), &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)
.unwrap_or_else(|| Vec::new(&env));
Self::sorted_insert(&env, &mut index, dev.clone());
inst.set(&StorageKey::DeveloperIndex, &index);
+
env.events().publish(
(events::event_balance_credited(&env), dev.clone()),
BalanceCreditedEvent {
@@ -541,12 +362,6 @@ impl CalloraSettlement {
.set(&StorageKey::Usdc, &usdc_address);
}
- fn get_usdc_token(env: Env) -> Result {
- env.storage()
- .instance()
- .get(&StorageKey::Usdc)
- .ok_or(SettlementError::UsdcTokenNotConfigured)
- }
/// Withdraw developer balance as USDC to a designated recipient.
///
@@ -558,6 +373,7 @@ impl CalloraSettlement {
/// * `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`.
+ /// * `token` - The token contract address for this withdrawal.
///
/// # Errors
/// - `AmountNotPositive` if amount is <= 0.
@@ -574,6 +390,7 @@ impl CalloraSettlement {
developer: Address,
amount: i128,
to: Option,
+ token: Address,
) -> Result<(), SettlementError> {
developer.require_auth();
if amount <= 0 {
@@ -591,8 +408,9 @@ impl CalloraSettlement {
let current_balance: i128 = env
.storage()
.persistent()
- .get(&StorageKey::DeveloperBalance(developer.clone()))
+ .get(&StorageKey::DeveloperBalance(developer.clone(), token.clone()))
.unwrap_or(0);
+ limits::require_developer_min_balance(&env, &developer, current_balance)?;
if amount > current_balance {
return Err(SettlementError::InsufficientDeveloperBalance);
}
@@ -625,21 +443,20 @@ impl CalloraSettlement {
.checked_sub(amount)
.ok_or(SettlementError::DeveloperBalanceUnderflow)?;
- let usdc_address = Self::get_usdc_token(env.clone())?;
- let usdc = token::Client::new(&env, &usdc_address);
+ let usdc_client = token::Client::new(&env, &token);
- if usdc.balance(&contract_address) < amount {
+ if usdc_client.balance(&contract_address) < amount {
return Err(SettlementError::InsufficientContractBalance);
}
- usdc.transfer(&contract_address, &recipient, &amount);
+ usdc_client.transfer(&contract_address, &recipient, &amount);
env.storage().persistent().set(
- &StorageKey::DeveloperBalance(developer.clone()),
+ &StorageKey::DeveloperBalance(developer.clone(), token.clone()),
&new_balance,
);
env.storage().persistent().extend_ttl(
- &StorageKey::DeveloperBalance(developer.clone()),
+ &StorageKey::DeveloperBalance(developer.clone(), token.clone()),
50000,
50000,
);
@@ -674,6 +491,7 @@ impl CalloraSettlement {
amount,
remaining_balance: new_balance,
to: recipient,
+ token,
},
);
@@ -725,7 +543,7 @@ impl CalloraSettlement {
events::event_developer_claim_window_changed(&env),
developer.clone(),
),
- DeveloperClaimWindowChanged {
+ crate::types::DeveloperClaimWindowChanged {
developer,
start_ts,
end_ts,
@@ -763,7 +581,7 @@ impl CalloraSettlement {
events::event_developer_claim_window_changed(&env),
developer.clone(),
),
- DeveloperClaimWindowChanged {
+ crate::types::DeveloperClaimWindowChanged {
developer,
start_ts: 0,
end_ts: 0,
@@ -817,14 +635,22 @@ impl CalloraSettlement {
);
}
- /// Set the minimum balance for a developer (admin only).
+ /// Set the minimum accrued balance required before a developer may claim.
///
- /// This entrypoint allows the admin to enforce a per‑developer minimum balance.
- /// It delegates to the limits module for storage and auth checks.
+ /// Only the current admin may configure the per-developer minimum. A value
+ /// of `0` clears the effective requirement and preserves the historical
+ /// behavior for developers with no configured minimum.
pub fn set_minimum_balance(env: Env, caller: Address, developer: Address, min_balance: i128) {
limits::set_developer_min_balance(env, caller, developer, min_balance);
}
+ /// Return the minimum accrued balance required before `developer` may claim.
+ ///
+ /// Returns `0` when no per-developer minimum has been configured.
+ pub fn get_minimum_balance(env: Env, developer: Address) -> i128 {
+ limits::get_developer_min_balance(env, developer)
+ }
+
/// Get the daily withdrawal cap for a developer.
///
/// Returns `0` if no cap has been set (meaning unlimited).
@@ -902,11 +728,11 @@ impl CalloraSettlement {
.unwrap_or_else(|| env.panic_with_error(SettlementError::DeveloperOverflow));
env.storage().persistent().set(
- &StorageKey::DeveloperBalance(developer.clone()),
+ &balance_key,
&new_balance,
);
env.storage().persistent().extend_ttl(
- &StorageKey::DeveloperBalance(developer.clone()),
+ &balance_key,
50000,
50000,
);
@@ -933,46 +759,12 @@ impl CalloraSettlement {
amount,
reason,
new_balance,
+ token,
},
);
}
/// 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.
- /// For production integrations with many developers (>100), implement off-chain indexing
- /// by listening to `BalanceCreditedEvent` and maintaining a local database.
- ///
- /// # 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.
- ///
- /// # Iteration Behavior
- /// - Uses developer index Vec for iteration; order is based on credit insertion order
- /// - **Small index (< 100 entries)**: Safe to iterate; yields current state
- /// - **Large index (> 100 entries)**: Consider off-chain indexing to avoid excessive gas costs
- /// - **Order guarantees**: Based on insertion order (first credit = first in index)
- ///
- /// # Returns
- /// Result containing a Vec of DeveloperBalance records or a gas exhaustion error.
- /// Iteration order is based on index insertion order.
- ///
- /// # Use Cases
- /// ✅ Administrative dashboards and reporting
- /// ✅ Audit compliance queries
- /// ✅ Contract state verification
- /// ⚠️ Automatic routing based on iteration order (order is insertion-order stable but may not match business logic)
- /// ❌ Deterministic selection of developers
- ///
- /// # Performance
- /// Gas cost scales with number of developers:
- /// - 50 developers: ~500 gas
- /// - 100 developers: ~1,000 gas
- /// - 500 developers: ~5,000 gas (consider off-chain indexing)
pub fn get_all_developer_balances(
env: Env,
caller: Address,
@@ -1014,16 +806,6 @@ impl CalloraSettlement {
}
/// 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,
@@ -1076,36 +858,6 @@ impl CalloraSettlement {
}
/// 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
- /// sorted index when `cursor` is `None`. The index is maintained in
- /// deterministic ascending order by address bytes, so pages are stable
- /// across interleaved `receive_payment` calls for developers that sort
- /// **after** the cursor.
- ///
- /// # Arguments
- /// * `caller` – Must be the current admin; must authorize.
- /// * `cursor` – Exclusive start position. Pass `None` for the first page;
- /// pass the `next_cursor` returned by the previous call for
- /// 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:
- /// * `page` – Vec of [`DeveloperBalance`] for this page (may be empty).
- /// * `next_cursor` – `Some(address)` of the last record returned, which can be
- /// passed as `cursor` on the next call; `None` when this is the
- /// last page.
- ///
- /// # Access Control
- /// Admin only.
- ///
- /// # Errors
- /// * [`SettlementError::NotInitialized`] – contract not yet initialised.
- /// * [`SettlementError::Unauthorized`] – caller is not the admin.
pub fn get_developer_balances_cursor(
env: Env,
caller: Address,
@@ -1124,117 +876,14 @@ impl CalloraSettlement {
.get(&StorageKey::DeveloperIndex)
.unwrap_or_else(|| Vec::new(&env));
- pagination::get_page(&env, &index, cursor, limit)
+ pagination::get_page(&env, &index, cursor, limit, token)
}
/// Return the remaining TTL for each storage key category.
- ///
- /// # Parameters
- /// - `developer_addresses` — optional list of developers to check. If empty, the index is used.
pub fn get_storage_ttl(env: Env, developer_addresses: Vec) -> Vec {
- let mut result = Vec::new(&env);
-
- // 1. Instance Storage
- let instance_ttl = {
- #[cfg(any(test, feature = "testutils"))]
- {
- env.storage().instance().get_ttl()
- }
- #[cfg(not(any(test, feature = "testutils")))]
- {
- 17_280 * 60
- }
- };
- result.push_back(StorageEntryTtl {
- category: String::from_str(&env, "Instance"),
- key_desc: String::from_str(&env, "Instance"),
- storage_type: String::from_str(&env, "Instance"),
- ttl: instance_ttl,
- threshold: 17_280 * 30,
- bump_amount: 17_280 * 60,
- });
-
- // Determine which developer addresses to inspect
- let devs = if developer_addresses.len() > 0 {
- developer_addresses
- } else {
- env.storage()
- .instance()
- .get(&StorageKey::DeveloperIndex)
- .unwrap_or_else(|| Vec::new(&env))
- };
-
- for dev in devs.iter() {
- // Check DeveloperBalance (Persistent)
- let bal_key = StorageKey::DeveloperBalance(dev.clone());
- if env.storage().persistent().has(&bal_key) {
- let ttl = {
- #[cfg(any(test, feature = "testutils"))]
- {
- env.storage().persistent().get_ttl(&bal_key)
- }
- #[cfg(not(any(test, feature = "testutils")))]
- {
- 50000
- }
- };
- result.push_back(StorageEntryTtl {
- category: String::from_str(&env, "DeveloperBalance"),
- key_desc: String::from_str(&env, "DeveloperBalance"),
- storage_type: String::from_str(&env, "Persistent"),
- ttl,
- threshold: 50000,
- bump_amount: 50000,
- });
- }
-
- let balance: i128 = env
- .storage()
- .persistent()
- .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());
-
- // Check DailyWithdrawCap (Persistent)
- let cap_key = StorageKey::DailyWithdrawCap(dev.clone());
- if env.storage().persistent().has(&cap_key) {
- let ttl = {
- #[cfg(any(test, feature = "testutils"))]
- {
- env.storage().persistent().get_ttl(&cap_key)
- }
- #[cfg(not(any(test, feature = "testutils")))]
- {
- 50000
- }
- };
- result.push_back(StorageEntryTtl {
- category: String::from_str(&env, "DailyWithdrawCap"),
- key_desc: String::from_str(&env, "DailyWithdrawCap"),
- storage_type: String::from_str(&env, "Persistent"),
- ttl,
- threshold: 50000,
- bump_amount: 50000,
- });
- }
- }
-
- result
- }
+ #[cfg(any(test, feature = "testutils"))]
+ use soroban_sdk::testutils::storage::{Instance, Persistent};
- /// Return the remaining TTL for each storage key category.
- ///
- /// # Parameters
- /// - `developer_addresses` — optional list of developers to check. If empty, the index is used.
- pub fn get_storage_ttl(env: Env, developer_addresses: Vec) -> Vec {
let mut result = Vec::new(&env);
// 1. Instance Storage
@@ -1268,13 +917,13 @@ impl CalloraSettlement {
};
for dev in devs.iter() {
- // Check DeveloperBalance (Persistent)
- let bal_key = StorageKey::DeveloperBalance(dev.clone());
- if env.storage().persistent().has(&bal_key) {
+ // Check DeveloperMinBalance (Persistent)
+ let min_bal_key = StorageKey::DeveloperMinBalance(dev.clone());
+ if env.storage().persistent().has(&min_bal_key) {
let ttl = {
#[cfg(any(test, feature = "testutils"))]
{
- env.storage().persistent().get_ttl(&bal_key)
+ env.storage().persistent().get_ttl(&min_bal_key)
}
#[cfg(not(any(test, feature = "testutils")))]
{
@@ -1282,31 +931,8 @@ impl CalloraSettlement {
}
};
result.push_back(StorageEntryTtl {
- category: String::from_str(&env, "DeveloperBalance"),
- key_desc: String::from_str(&env, "DeveloperBalance"),
- storage_type: String::from_str(&env, "Persistent"),
- ttl,
- threshold: 50000,
- bump_amount: 50000,
- });
- }
-
- // Check WithdrawalToday (Persistent)
- let today_key = StorageKey::WithdrawalToday(dev.clone());
- if env.storage().persistent().has(&today_key) {
- let ttl = {
- #[cfg(any(test, feature = "testutils"))]
- {
- env.storage().persistent().get_ttl(&today_key)
- }
- #[cfg(not(any(test, feature = "testutils")))]
- {
- 50000
- }
- };
- result.push_back(StorageEntryTtl {
- category: String::from_str(&env, "WithdrawalToday"),
- key_desc: String::from_str(&env, "WithdrawalToday"),
+ category: String::from_str(&env, "DeveloperMinBalance"),
+ key_desc: String::from_str(&env, "DeveloperMinBalance"),
storage_type: String::from_str(&env, "Persistent"),
ttl,
threshold: 50000,
@@ -1342,38 +968,11 @@ impl CalloraSettlement {
}
/// Return the pending admin address, or `None` if no two-step admin transfer is in progress.
- ///
- /// Integrators can poll this to detect an in-flight admin handover
- /// before `accept_admin` is called.
- ///
- /// # Returns
- /// `Some(Address)` of the nominated admin, or `None` when no transfer is pending.
pub fn get_pending_admin(env: Env) -> Option {
env.storage().instance().get(&StorageKey::PendingAdmin)
}
/// Nominate a new admin (admin only).
- ///
- /// # Arguments
- /// * `caller` - Current admin address; must match stored admin
- /// * `new_admin` - Address to nominate as new admin
- ///
- /// # Access Control
- /// Only the current admin can call this function.
- ///
- /// # Security
- /// This implements a two-step admin transfer process:
- /// 1. Current admin calls `set_admin()` to nominate new admin
- /// 2. Nominated admin must call `accept_admin()` to complete transfer
- ///
- /// This prevents accidental admin loss and ensures the new admin
- /// has control of their private keys before gaining privileges.
- ///
- /// # Events
- /// Emits `admin_nominated` event with current and new admin addresses.
- ///
- /// # Panics
- /// Panics if caller is not the current admin.
pub fn set_admin(env: Env, caller: Address, new_admin: Address) {
caller.require_auth();
let current_admin = Self::get_admin(env.clone());
@@ -1395,21 +994,6 @@ impl CalloraSettlement {
}
/// Accept the admin role (pending admin only).
- ///
- /// # Access Control
- /// Only the nominated pending admin can call this function.
- ///
- /// # Security
- /// This is the second step of the two-step admin transfer process.
- /// The nominated admin must explicitly accept, proving control of
- /// their private keys before gaining admin privileges.
- ///
- /// # Events
- /// Emits `admin_accepted` event with old and new admin addresses.
- ///
- /// # Panics
- /// Panics if there is no pending admin transfer (i.e., `set_admin()`
- /// was not called first).
pub fn accept_admin(env: Env) {
let inst = env.storage().instance();
let pending: Address = inst
@@ -1426,13 +1010,6 @@ impl CalloraSettlement {
}
/// Cancel a pending admin transfer. Only the current admin may call this.
- ///
- /// # Arguments
- /// * `caller` - Current admin address; must match stored admin
- ///
- /// # Panics
- /// * Panics if caller is not the current admin.
- /// * Panics if no admin transfer is pending.
pub fn cancel_admin_transfer(env: Env, caller: Address) {
caller.require_auth();
let current = Self::get_admin(env.clone());
@@ -1451,34 +1028,12 @@ impl CalloraSettlement {
}
/// Propose a new vault address (admin only).
- ///
- /// # Arguments
- /// * `caller` - Current admin address; must match stored admin
- /// * `new_vault` - New vault contract address to register
- ///
- /// # Access Control
- /// Only the current admin can call this function.
- ///
pub fn set_vault(env: Env, caller: Address, new_vault: Address) {
// Backwards-compatible alias: `set_vault` now behaves like `propose_vault`.
Self::propose_vault(env, caller, new_vault);
}
/// Propose a new vault address (admin only).
- ///
- /// This is the first step of a two-step vault rotation:
- /// 1. Admin calls `propose_vault()` to set `PendingVault`
- /// 2. Proposed vault (or admin) calls `accept_vault()` to activate it
- ///
- /// # Security
- /// This prevents a typo from instantly routing settlement credits to the wrong contract.
- ///
- /// # Events
- /// Emits `vault_proposed` with current and proposed vault addresses.
- ///
- /// # Panics
- /// - `"unauthorized: caller is not admin"` if caller is not admin
- /// - `"invalid config: vault cannot be the contract itself"` if proposed vault is this contract
pub fn propose_vault(env: Env, caller: Address, new_vault: Address) {
caller.require_auth();
let current_admin = Self::get_admin(env.clone());
@@ -1503,16 +1058,6 @@ impl CalloraSettlement {
}
/// Accept the proposed vault and activate it.
- ///
- /// # Arguments
- /// * `caller` - Must be either the proposed vault address or the admin.
- ///
- /// # Events
- /// Emits `vault_accepted` with old vault, new vault, and acceptor.
- ///
- /// # Panics
- /// - `"no vault rotation pending"` if no `propose_vault()` was called
- /// - `"unauthorized: caller must be pending vault or admin"` if caller is neither
pub fn accept_vault(env: Env, caller: Address) {
caller.require_auth();
@@ -1571,11 +1116,6 @@ impl CalloraSettlement {
Ok(())
}
- /// Admin-gated contract upgrade.
- ///
- /// Only the current admin may call. This will instruct the host to update
- /// the current contract WASM to `new_wasm_hash` and persist the version marker.
- /// Emits an `upgraded` event with the admin as topic and the new version as data.
pub fn broadcast(env: Env, caller: Address, severity: Severity, message: String) {
caller.require_auth();
let admin = Self::get_admin(env.clone());
@@ -1617,20 +1157,11 @@ impl CalloraSettlement {
}
/// Read the stored contract version (WASM hash) as last set by `upgrade`.
- ///
- /// Returns `None` if no upgrade has been performed yet (initial deployment).
pub fn get_version(env: Env) -> Option> {
env.storage().instance().get(&StorageKey::ContractVersion)
}
/// Insert `addr` into `index` in sorted order (ascending by raw bytes).
- ///
- /// Soroban's `Vec` does not expose a binary-search API, so we do a linear
- /// scan to find the insertion point. The index is expected to be small
- /// (≤ `MAX_DEVELOPER_BALANCES_PAGE_SIZE`), so the O(n) cost is acceptable
- /// and the result is a deterministic, stable ordering that cursors can rely on.
- ///
- /// If `addr` is already present the index is left unchanged.
pub(crate) fn sorted_insert(env: &Env, index: &mut Vec, addr: Address) {
// Check for duplicates and find insertion position in one pass.
let mut insert_pos: Option = None;
@@ -1652,41 +1183,11 @@ impl CalloraSettlement {
}
/// One-shot V1 -> V2 storage migration (admin only).
- ///
- /// Converts all `DeveloperBalanceV1(addr)` persistent slots to per-token
- /// `DeveloperBalance(addr, usdc_token)` slots in a single transaction.
- /// For deployments with more than [`MAX_BATCH_SIZE`] developers use
- /// [`migrate_v1_to_v2_page`] to spread the work across multiple ledgers.
- ///
- /// # Access Control
- /// Only the current admin may call this function.
- ///
- /// # Idempotency
- /// Safe to call multiple times; re-running after `StorageVersion == 2`
- /// returns immediately without modifying any state.
- ///
- /// # Panics
- /// - [`SettlementError::NotInitialized`] if the contract is not initialised.
- /// - [`SettlementError::Unauthorized`] if the caller is not the admin.
- /// - [`SettlementError::UsdcTokenNotConfigured`] if USDC is not configured.
pub fn migrate_v1_to_v2(env: Env, caller: Address) {
migrate::migrate_v1_to_v2(&env, &caller);
}
/// Paginated V1 -> V2 storage migration (admin only).
- ///
- /// Processes up to `batch_size` (capped at [`MAX_BATCH_SIZE`]) developer
- /// accounts per call, starting from index position `offset`.
- ///
- /// # Returns
- /// `(next_offset, is_complete)`. When `is_complete` is `true` all developer
- /// slots have been converted and `StorageVersion` is set to `2`.
- ///
- /// # Access Control
- /// Only the current admin may call this function.
- ///
- /// # Idempotency
- /// Returns `(0, true)` immediately when migration is already complete.
pub fn migrate_v1_to_v2_page(
env: Env,
caller: Address,
@@ -1697,20 +1198,11 @@ impl CalloraSettlement {
}
/// Return the current storage-layout version.
- ///
- /// `1` = V1 layout (pre-migration or key absent).
- /// `2` = V2 per-token layout (migration complete).
pub fn migration_storage_version(env: Env) -> u32 {
migrate::storage_version(&env)
}
}
-mod events;
-pub mod migrate;
-
-#[cfg(test)]
-mod test;
-
#[cfg(test)]
mod test_views;
@@ -1722,3 +1214,6 @@ mod test_error_codes;
#[cfg(test)]
mod test_multi_asset;
+
+#[cfg(test)]
+mod test_admin_migration;
diff --git a/contracts/settlement/src/limits.rs b/contracts/settlement/src/limits.rs
index 3f79cd71..2854ba64 100644
--- a/contracts/settlement/src/limits.rs
+++ b/contracts/settlement/src/limits.rs
@@ -1,36 +1,60 @@
-//! Limits module for per-developer minimum balance.
+//! Per-developer minimum accrued-balance limits for settlement claims.
+//!
+//! A configured minimum is checked immediately before a developer withdrawal is
+//! settled. The requirement is intentionally per developer rather than global so
+//! operations can tune eligibility without affecting existing developers.
-use soroban_sdk::{Env, Address, Symbol, contracterror, contracttype};
-use crate::errors::SettlementError;
-use crate::types::StorageKey;
+use crate::{SettlementError, StorageKey};
+use soroban_sdk::{Address, Env};
-/// Set the minimum balance for a developer.
+const MIN_BALANCE_TTL_THRESHOLD: u32 = 50_000;
+const MIN_BALANCE_TTL_EXTEND_TO: u32 = 50_000;
+
+/// Set the minimum accrued balance required before a developer may claim.
///
-/// # Arguments
-/// * `env` - Execution environment.
-/// * `caller` - Must be admin.
-/// * `developer` - Target developer address.
-/// * `min_balance` - Minimum balance in token micro‑units (>= 0).
+/// The settlement admin is the only caller allowed to configure this value. A
+/// minimum of `0` is allowed and behaves the same as an unset minimum.
+/// Negative minimums are rejected because they would make the gate meaningless.
pub fn set_developer_min_balance(env: Env, caller: Address, developer: Address, min_balance: i128) {
- // Auth check – admin only.
caller.require_auth();
- let admin = crate::lib::CalloraSettlement::get_admin(env.clone());
+ let admin = crate::CalloraSettlement::get_admin(env.clone());
if caller != admin {
env.panic_with_error(SettlementError::Unauthorized);
}
if min_balance < 0 {
- panic!("minimum balance must be non‑negative");
+ env.panic_with_error(SettlementError::AmountNotPositive);
}
- // Store the value.
- env.storage().persistent().set(&StorageKey::DeveloperMinBalance(developer.clone()), &min_balance);
- // Optional TTL similar to other persistent entries.
- env.storage().persistent().extend_ttl(&StorageKey::DeveloperMinBalance(developer), 50000, 50000);
+
+ let key = StorageKey::DeveloperMinBalance(developer);
+ env.storage().persistent().set(&key, &min_balance);
+ env.storage().persistent().extend_ttl(
+ &key,
+ MIN_BALANCE_TTL_THRESHOLD,
+ MIN_BALANCE_TTL_EXTEND_TO,
+ );
}
-/// Retrieve the minimum balance for a developer. Returns `0` if not set.
+/// Retrieve a developer's configured claim minimum, or `0` if unset.
pub fn get_developer_min_balance(env: Env, developer: Address) -> i128 {
env.storage()
.persistent()
.get(&StorageKey::DeveloperMinBalance(developer))
.unwrap_or(0)
}
+
+/// Ensure the developer has accrued enough balance to be eligible to claim.
+///
+/// This checks the balance before settlement. The minimum is an eligibility
+/// threshold; once met, the developer may withdraw any amount otherwise allowed
+/// by balance, daily cap, claim window, and contract-token checks.
+pub fn require_developer_min_balance(
+ env: &Env,
+ developer: &Address,
+ current_balance: i128,
+) -> Result<(), SettlementError> {
+ let min_balance = get_developer_min_balance(env.clone(), developer.clone());
+ if min_balance > 0 && current_balance < min_balance {
+ return Err(SettlementError::MinimumBalanceRequired);
+ }
+ Ok(())
+}
diff --git a/contracts/settlement/src/pagination.rs b/contracts/settlement/src/pagination.rs
index b12e01af..a4b7d6fb 100644
--- a/contracts/settlement/src/pagination.rs
+++ b/contracts/settlement/src/pagination.rs
@@ -33,6 +33,7 @@ pub fn get_page(
index: &Vec,
cursor: Option,
limit: u32,
+ token: Address,
) -> (Vec, Option) {
let effective_limit = if limit == 0 {
return (Vec::new(env), None);
@@ -57,11 +58,12 @@ pub fn get_page(
let balance: i128 = 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,
});
last_address = Some(address.clone());
diff --git a/contracts/settlement/src/test.rs b/contracts/settlement/src/test.rs
deleted file mode 100644
index 162d3998..00000000
--- a/contracts/settlement/src/test.rs
+++ /dev/null
@@ -1,2947 +0,0 @@
-#[cfg(test)]
-mod settlement_tests {
- extern crate std;
-
- use crate::{CalloraSettlement, CalloraSettlementClient, SettlementError, StorageKey};
- use soroban_sdk::testutils::{Address as _, Events as _, Ledger as _};
- use soroban_sdk::{token, Address, BytesN, Env, Error, InvokeError, Symbol, TryFromVal};
-
- fn setup_contract() -> (Env, Address, Address, Address, Address, Address) {
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- client.init(&admin, &vault);
- let third_party = Address::generate(&env);
- let token = Address::generate(&env);
- (env, addr, admin, vault, third_party, token)
- }
-
- fn is_error, E: Into>(
- result: Result, Result>,
- expected: SettlementError,
- ) -> bool {
- let expected_code = expected as u32;
- match result {
- Err(Ok(e)) => e.into().get_code() == expected_code,
- _ => false,
- }
- }
-
- fn create_usdc<'a>(
- env: &'a Env,
- admin: &Address,
- ) -> (Address, token::Client<'a>, token::StellarAssetClient<'a>) {
- let contract_address = env.register_stellar_asset_contract_v2(admin.clone());
- let address = contract_address.address();
- let client = token::Client::new(env, &address);
- let admin_client = token::StellarAssetClient::new(env, &address);
- (address, client, admin_client)
- }
-
- #[test]
- fn test_settlement_initialization() {
- let env = Env::default();
- env.mock_all_auths();
- env.ledger().set_timestamp(1_700_000_000);
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let developer = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- let token = Address::generate(&env);
-
- client.init(&admin, &vault);
-
- env.as_contract(&addr, || {
- let inst = env.storage().instance();
- assert!(inst.has(&StorageKey::Admin));
- assert!(inst.has(&StorageKey::Vault));
- assert!(inst.has(&StorageKey::GlobalPool));
- // DeveloperIndex is written lazily on first payment, not at init
- });
-
- assert_eq!(client.get_admin(), admin);
- assert_eq!(client.get_vault(), vault);
-
- let global_pool = client.get_global_pool();
- assert_eq!(global_pool.total_balance, 0);
- assert_eq!(global_pool.last_updated, 1_700_000_000);
-
- let all_balances = client.get_all_developer_balances(&admin, &token);
- assert_eq!(all_balances.len(), 0);
- assert_eq!(client.get_developer_balance(&developer, &token), 0);
- }
- #[test]
- #[should_panic(expected = "invalid config: admin and vault_address must be distinct")]
- fn test_init_admin_equals_vault_panics() {
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
-
- // Passing the same address for admin and vault should be rejected.
- client.init(&admin, &admin);
- }
-
- #[test]
- #[should_panic(expected = "invalid config: admin cannot be the contract itself")]
- fn test_init_admin_is_contract_panics() {
- let env = Env::default();
- env.mock_all_auths();
- let vault = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
-
- // Passing the contract's own address as admin should be rejected.
- client.init(&addr, &vault);
- }
-
- #[test]
- #[should_panic(expected = "invalid config: vault_address cannot be the contract itself")]
- fn test_init_vault_is_contract_panics() {
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
-
- // Passing the contract's own address as vault_address should be rejected.
- client.init(&admin, &addr);
- }
-
- #[test]
- fn test_init_requires_admin_signature() {
- let env = Env::default();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
-
- env.set_auths(&[]);
- let result = client.try_init(&admin, &vault);
- assert!(
- result.is_err(),
- "expected init to require the admin signature"
- );
- }
-
- #[test]
- fn test_receive_payment_to_pool() {
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- client.init(&admin, &vault);
- let token = Address::generate(&env);
-
- client.receive_payment(&vault, &1000i128, &true, &None, &token);
-
- let global_pool = client.get_global_pool();
- assert_eq!(global_pool.total_balance, 1000i128);
- }
-
- #[test]
- fn test_receive_payment_to_developer() {
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let developer = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- client.init(&admin, &vault);
- let token = Address::generate(&env);
-
- client.receive_payment(&vault, &500i128, &false, &Some(developer.clone()), &token);
-
- assert_eq!(client.get_developer_balance(&developer, &token), 500i128);
- assert_eq!(client.get_global_pool().total_balance, 0);
- }
-
- #[test]
- fn test_receive_multiple_payments_accumulate() {
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let developer = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- client.init(&admin, &vault);
- let token = Address::generate(&env);
-
- client.receive_payment(&vault, &100i128, &false, &Some(developer.clone()), &token);
- client.receive_payment(&vault, &150i128, &false, &Some(developer.clone()), &token);
-
- assert_eq!(client.get_developer_balance(&developer, &token), 250i128);
- }
-
- #[test]
- fn test_get_developer_balance_when_empty() {
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let developer = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- client.init(&admin, &vault);
- let token = Address::generate(&env);
-
- let balance = client.get_developer_balance(&developer, &token);
- assert_eq!(balance, 0);
- }
-
- #[test]
- fn test_get_all_developer_balances_when_empty() {
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- client.init(&admin, &vault);
- let token = Address::generate(&env);
-
- let all = client.get_all_developer_balances(&admin, &token);
- assert_eq!(all.len(), 0);
- }
-
- #[test]
- fn test_admin_can_receive_payment_to_pool() {
- // Admin can route payments directly to global pool (not just via vault)
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- client.init(&admin, &vault);
- let token = Address::generate(&env);
- client.receive_payment(&admin, &100i128, &true, &None, &token);
- }
-
- #[test]
- fn test_admin_can_receive_payment_to_developer() {
- // Admin routing a payment directly to a developer (not via vault)
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let developer = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- client.init(&admin, &vault);
- let token = Address::generate(&env);
-
- client.receive_payment(&admin, &200i128, &false, &Some(developer.clone()), &token);
-
- assert_eq!(client.get_developer_balance(&developer, &token), 200i128);
- }
-
- #[test]
- fn test_pool_accumulates_across_multiple_payments() {
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- client.init(&admin, &vault);
- let token = Address::generate(&env);
-
- client.receive_payment(&vault, &400i128, &true, &None, &token);
- client.receive_payment(&vault, &600i128, &true, &None, &token);
-
- assert_eq!(client.get_global_pool().total_balance, 1000i128);
- }
-
- #[test]
- fn test_get_developer_balance_returns_zero_for_unknown() {
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let stranger = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- client.init(&admin, &vault);
- let token = Address::generate(&env);
-
- assert_eq!(client.get_developer_balance(&stranger, &token), 0i128);
- }
-
- #[test]
- fn test_withdraw_developer_balance_succeeds_exact_balance() {
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let developer = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- let (usdc_address, _, usdc_admin_client) = create_usdc(&env, &admin);
-
- client.init(&admin, &vault);
- client.set_usdc_token(&admin, &usdc_address);
- client.receive_payment(&vault, &100i128, &false, &Some(developer.clone()), &usdc_address);
- usdc_admin_client.mint(&addr, &100i128);
-
- let result = client.try_withdraw_developer_balance(&developer, &100i128, &None, &usdc_address);
- assert!(result.is_ok());
- assert_eq!(client.get_developer_balance(&developer, &usdc_address), 0i128);
- assert_eq!(
- token::Client::new(&env, &usdc_address).balance(&addr),
- 0i128
- );
- assert_eq!(
- token::Client::new(&env, &usdc_address).balance(&developer),
- 100i128
- );
- }
-
- #[test]
- fn test_withdraw_developer_balance_rejects_overdraw() {
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let developer = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- let (usdc_address, _, usdc_admin_client) = create_usdc(&env, &admin);
-
- client.init(&admin, &vault);
- client.set_usdc_token(&admin, &usdc_address);
- client.receive_payment(&vault, &100i128, &false, &Some(developer.clone()), &usdc_address);
- usdc_admin_client.mint(&addr, &100i128);
-
- let result = client.try_withdraw_developer_balance(&developer, &101i128, &None, &usdc_address);
- assert!(result.is_err());
- assert_eq!(client.get_developer_balance(&developer, &usdc_address), 100i128);
- }
-
- #[test]
- fn test_withdraw_developer_balance_rejects_non_positive_amount() {
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let developer = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- let token = Address::generate(&env);
-
- client.init(&admin, &vault);
-
- let zero_result = client.try_withdraw_developer_balance(&developer, &0i128, &None, &token);
- let negative_result = client.try_withdraw_developer_balance(&developer, &-1i128, &None, &token);
-
- assert!(zero_result.is_err());
- assert!(negative_result.is_err());
- }
-
- #[test]
- fn test_withdraw_developer_balance_emits_event() {
- use soroban_sdk::testutils::Events as _;
- use soroban_sdk::{IntoVal, Symbol};
-
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let developer = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- let (usdc_address, _, usdc_admin_client) = create_usdc(&env, &admin);
-
- client.init(&admin, &vault);
- client.set_usdc_token(&admin, &usdc_address);
- client.receive_payment(&vault, &200i128, &false, &Some(developer.clone()), &usdc_address);
- usdc_admin_client.mint(&addr, &200i128);
-
- let result = client.try_withdraw_developer_balance(&developer, &200i128, &None, &usdc_address);
- assert!(result.is_ok());
-
- let events = env.events().all();
- let ev = events
- .iter()
- .find(|e| {
- !e.1.is_empty() && {
- let t: Symbol = e.1.get(0).unwrap().into_val(&env);
- t == Symbol::new(&env, "developer_withdraw")
- }
- })
- .expect("expected developer_withdraw event");
-
- let topic1: Address = ev.1.get(1).unwrap().into_val(&env);
- assert_eq!(topic1, developer);
-
- let data: crate::DeveloperWithdrawEvent = ev.2.into_val(&env);
- assert_eq!(data.developer, developer);
- assert_eq!(data.amount, 200i128);
- assert_eq!(data.remaining_balance, 0i128);
- assert_eq!(data.to, developer);
- }
-
- #[test]
- fn test_withdraw_developer_balance_to_custodial_address() {
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let developer = Address::generate(&env);
- let custodial = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- let (usdc_address, _, usdc_admin_client) = create_usdc(&env, &admin);
-
- client.init(&admin, &vault);
- client.set_usdc_token(&admin, &usdc_address);
- client.receive_payment(&vault, &150i128, &false, &Some(developer.clone()), &usdc_address);
- usdc_admin_client.mint(&addr, &150i128);
-
- let result =
- client.try_withdraw_developer_balance(&developer, &150i128, &Some(custodial.clone()));
- assert!(result.is_ok());
- assert_eq!(client.get_developer_balance(&developer, &usdc_address), 0i128);
- assert_eq!(
- token::Client::new(&env, &usdc_address).balance(&addr),
- 0i128
- );
- assert_eq!(
- token::Client::new(&env, &usdc_address).balance(&custodial),
- 150i128
- );
- }
-
- #[test]
- fn test_withdraw_developer_balance_emits_event_with_custodial_to() {
- use soroban_sdk::testutils::Events as _;
- use soroban_sdk::{IntoVal, Symbol};
-
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let developer = Address::generate(&env);
- let custodial = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- let (usdc_address, _, usdc_admin_client) = create_usdc(&env, &admin);
-
- client.init(&admin, &vault);
- client.set_usdc_token(&admin, &usdc_address);
- client.receive_payment(&vault, &200i128, &false, &Some(developer.clone()), &usdc_address);
- usdc_admin_client.mint(&addr, &200i128);
-
- let result =
- client.try_withdraw_developer_balance(&developer, &200i128, &Some(custodial.clone()));
- assert!(result.is_ok());
-
- let events = env.events().all();
- let ev = events
- .iter()
- .find(|e| {
- !e.1.is_empty() && {
- let t: Symbol = e.1.get(0).unwrap().into_val(&env);
- t == Symbol::new(&env, "developer_withdraw")
- }
- })
- .expect("expected developer_withdraw event");
-
- let data: crate::DeveloperWithdrawEvent = ev.2.into_val(&env);
- assert_eq!(data.developer, developer);
- assert_eq!(data.amount, 200i128);
- assert_eq!(data.remaining_balance, 0i128);
- assert_eq!(data.to, custodial);
- }
-
- #[test]
- #[should_panic(expected = "invalid recipient: cannot withdraw to contract itself")]
- fn test_withdraw_developer_balance_rejects_self_address() {
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let developer = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- let (usdc_address, _, usdc_admin_client) = create_usdc(&env, &admin);
-
- client.init(&admin, &vault);
- client.set_usdc_token(&admin, &usdc_address);
- client.receive_payment(&vault, &100i128, &false, &Some(developer.clone()), &usdc_address);
- usdc_admin_client.mint(&addr, &100i128);
-
- client.withdraw_developer_balance(&developer, &100i128, &Some(addr.clone()), &usdc_address);
- }
-
- #[test]
- fn test_get_all_developer_balances() {
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let dev1 = Address::generate(&env);
- let dev2 = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- client.init(&admin, &vault);
- let token = Address::generate(&env);
-
- client.receive_payment(&vault, &300i128, &false, &Some(dev1.clone()), &token);
- client.receive_payment(&vault, &200i128, &false, &Some(dev2.clone()), &token);
- client.receive_payment(&vault, &150i128, &false, &Some(dev1.clone()), &token);
-
- let all = client.get_all_developer_balances(&admin, &token);
- assert_eq!(all.len(), 2);
- let mut dev1_seen = false;
- let mut dev2_seen = false;
- for balance in all.iter() {
- if balance.address == dev1 {
- assert_eq!(balance.balance, 450i128);
- dev1_seen = true;
- } else if balance.address == dev2 {
- assert_eq!(balance.balance, 200i128);
- dev2_seen = true;
- } else {
- panic!("unexpected developer in get_all_developer_balances");
- }
- }
- assert!(dev1_seen);
- assert!(dev2_seen);
- }
-
- #[test]
- fn test_get_all_developer_balances_empty() {
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- client.init(&admin, &vault);
- let token = Address::generate(&env);
-
- let all = client.get_all_developer_balances(&admin, &token);
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let dev1 = Address::generate(&env);
- let dev2 = Address::generate(&env);
- let dev3 = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- client.init(&admin, &vault);
- let token = Address::generate(&env);
-
- client.receive_payment(&vault, &100i128, &false, &Some(dev1.clone()), &token);
- client.receive_payment(&vault, &200i128, &false, &Some(dev2.clone()), &token);
- client.receive_payment(&vault, &300i128, &false, &Some(dev3.clone()), &token);
-
- let page = client.get_developer_balances_page(&admin, &1u32, &2u32, &token);
- assert_eq!(page.len(), 2);
- assert_eq!(page.get(0).unwrap().address, dev2);
- assert_eq!(page.get(1).unwrap().address, dev3);
- }
-
- #[test]
- fn test_get_developer_balances_page_respects_limit_cap() {
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- client.init(&admin, &vault);
- let token = Address::generate(&env);
-
- for _ in 0..105 {
- let developer = Address::generate(&env);
- client.receive_payment(&vault, &1i128, &false, &Some(developer), &token);
- }
-
- // limit higher than MAX should be capped at MAX_DEVELOPER_BALANCES_PAGE_SIZE (100)
- let page = client.get_developer_balances_page(&admin, &0u32, &200u32, &token);
- assert_eq!(page.len(), 100);
- }
-
- #[test]
- fn test_get_all_developer_balances_large_index() {
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- client.init(&admin, &vault);
- let token = Address::generate(&env);
-
- for _ in 0..101 {
- let developer = Address::generate(&env);
- client.receive_payment(&vault, &1i128, &false, &Some(developer), &token);
- }
-
- let all = client.get_all_developer_balances(&admin, &token);
- assert_eq!(all.len(), 101);
- }
-
- #[test]
- fn test_set_admin_two_step() {
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let new_admin = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- client.init(&admin, &vault);
-
- client.set_admin(&admin, &new_admin);
- assert_eq!(client.get_admin(), admin); // Still old admin
-
- client.accept_admin();
- assert_eq!(client.get_admin(), new_admin);
- }
-
- #[test]
- fn test_get_pending_admin_none_before_nomination() {
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- client.init(&admin, &vault);
-
- assert_eq!(client.get_pending_admin(), None);
- }
-
- #[test]
- fn test_get_pending_admin_some_after_nomination() {
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let new_admin = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- client.init(&admin, &vault);
-
- client.set_admin(&admin, &new_admin);
- assert_eq!(client.get_pending_admin(), Some(new_admin.clone()));
-
- // clears after acceptance
- client.accept_admin();
- assert_eq!(client.get_pending_admin(), None);
- }
-
- #[test]
- #[should_panic(expected = "no admin transfer pending")]
- fn test_accept_admin_fails_if_not_nominated() {
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- client.init(&admin, &vault);
-
- client.accept_admin();
- }
-
- #[test]
- fn test_set_admin_unauthorized() {
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let new_admin = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- client.init(&admin, &vault);
-
- let result = client.try_set_admin(&vault, &new_admin);
- assert!(is_error(result, SettlementError::Unauthorized));
- }
-
- #[test]
- fn test_cancel_admin_transfer_success() {
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let new_admin = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- client.init(&admin, &vault);
-
- client.set_admin(&admin, &new_admin);
- assert_eq!(client.get_pending_admin(), Some(new_admin.clone()));
-
- client.cancel_admin_transfer(&admin);
- assert_eq!(client.get_pending_admin(), None);
-
- let events = env.events().all();
- let last_event = events.last().unwrap();
- let event_name = Symbol::try_from_val(&env, &last_event.1.get(0).unwrap()).unwrap();
- assert_eq!(event_name, Symbol::new(&env, "admin_cancelled"));
- }
-
- #[test]
- fn test_cancel_admin_transfer_unauthorized() {
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let new_admin = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- client.init(&admin, &vault);
-
- client.set_admin(&admin, &new_admin);
-
- let result = client.try_cancel_admin_transfer(&vault);
- assert!(is_error(result, SettlementError::Unauthorized));
- }
-
- #[test]
- #[should_panic(expected = "no admin transfer pending")]
- fn test_cancel_admin_transfer_no_pending_panics() {
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- client.init(&admin, &vault);
-
- client.cancel_admin_transfer(&admin);
- }
-
- #[test]
- fn test_set_vault_unauthorized() {
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let new_vault = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- client.init(&admin, &vault);
-
- let attacker = Address::generate(&env);
- let result = client.try_set_vault(&attacker, &new_vault);
- assert!(is_error(result, SettlementError::Unauthorized));
- }
-
- #[test]
- fn test_propose_and_accept_vault_happy_path() {
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let new_vault = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- client.init(&admin, &vault);
-
- // Step 1: propose by admin
- client.propose_vault(&admin, &new_vault);
- assert_eq!(client.get_vault(), vault); // still old until accepted
-
- // Step 2: accept by pending vault
- client.accept_vault(&new_vault);
- assert_eq!(client.get_vault(), new_vault);
- }
-
- #[test]
- fn test_propose_vault_emits_event() {
- use soroban_sdk::testutils::Events as _;
- use soroban_sdk::{IntoVal, Symbol};
-
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let new_vault = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- client.init(&admin, &vault);
-
- client.propose_vault(&admin, &new_vault);
-
- let events = env.events().all();
- let ev = events
- .iter()
- .find(|e| {
- !e.1.is_empty() && {
- let t: Symbol = e.1.get(0).unwrap().into_val(&env);
- t == Symbol::new(&env, "vault_proposed")
- }
- })
- .expect("expected vault_proposed event");
-
- let topic1: Address = ev.1.get(1).unwrap().into_val(&env);
- assert_eq!(topic1, admin);
-
- let data: crate::VaultProposedEvent = ev.2.into_val(&env);
- assert_eq!(data.current_vault, vault);
- assert_eq!(data.proposed_vault, new_vault);
- }
-
- #[test]
- fn test_accept_vault_emits_event() {
- use soroban_sdk::testutils::Events as _;
- use soroban_sdk::{IntoVal, Symbol};
-
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let new_vault = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- client.init(&admin, &vault);
-
- client.propose_vault(&admin, &new_vault);
- client.accept_vault(&new_vault);
-
- let events = env.events().all();
- let ev = events
- .iter()
- .find(|e| {
- !e.1.is_empty() && {
- let t: Symbol = e.1.get(0).unwrap().into_val(&env);
- t == Symbol::new(&env, "vault_accepted")
- }
- })
- .expect("expected vault_accepted event");
-
- let topic1: Address = ev.1.get(1).unwrap().into_val(&env);
- assert_eq!(topic1, new_vault);
-
- let data: crate::VaultAcceptedEvent = ev.2.into_val(&env);
- assert_eq!(data.old_vault, vault);
- assert_eq!(data.new_vault, new_vault);
- assert_eq!(data.accepted_by, new_vault);
- }
-
- // ── admin rotation edge cases ────────────────────────────────────────────
-
- #[test]
- fn test_set_admin_to_same_address_succeeds() {
- // Admin can nominate themselves again (useful for re-confirming control)
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- client.init(&admin, &vault);
-
- client.set_admin(&admin, &admin);
- // Still current admin until accept
- assert_eq!(client.get_admin(), admin);
-
- client.accept_admin();
- assert_eq!(client.get_admin(), admin);
- }
-
- #[test]
- fn test_set_vault_to_same_address_succeeds() {
- // Admin can propose + accept the same vault (no-op but valid)
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- client.init(&admin, &vault);
-
- client.propose_vault(&admin, &vault);
- client.accept_vault(&vault);
- assert_eq!(client.get_vault(), vault);
- }
-
- #[test]
- fn test_rapid_consecutive_admin_updates() {
- // Admin can change nomination before acceptance
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let new_admin1 = Address::generate(&env);
- let new_admin2 = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- client.init(&admin, &vault);
-
- // First nomination
- client.set_admin(&admin, &new_admin1);
- // Change nomination before acceptance
- client.set_admin(&admin, &new_admin2);
- // Only second nominee can accept
- client.accept_admin();
- assert_eq!(client.get_admin(), new_admin2);
- }
-
- #[test]
- fn test_admin_cannot_accept_own_nomination() {
- // Current admin cannot bypass two-step process
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- client.init(&admin, &vault);
-
- client.set_admin(&admin, &admin);
- // Admin must still accept to complete transfer
- client.accept_admin();
- assert_eq!(client.get_admin(), admin);
- }
-
- #[test]
- fn test_pending_admin_cannot_set_admin() {
- // Pending admin has no privileges until accepted
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let new_admin = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- client.init(&admin, &vault);
-
- client.set_admin(&admin, &new_admin);
- // New admin tries to set another admin before accepting
- let result = client.try_set_admin(&new_admin, &vault);
- assert!(is_error(result, SettlementError::Unauthorized));
- }
-
- #[test]
- fn test_vault_update_after_admin_rotation() {
- // Ensure vault updates work correctly after admin change
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let new_admin = Address::generate(&env);
- let new_vault = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- client.init(&admin, &vault);
-
- // Rotate admin
- client.set_admin(&admin, &new_admin);
- client.accept_admin();
-
- // New admin updates vault
- client.propose_vault(&new_admin, &new_vault);
- client.accept_vault(&new_vault);
- assert_eq!(client.get_vault(), new_vault);
- assert_eq!(client.get_admin(), new_admin);
- }
-
- #[test]
- fn test_admin_rotation_preserves_state() {
- // Admin rotation doesn't affect pool or balances
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let developer = Address::generate(&env);
- let new_admin = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- client.init(&admin, &vault);
- let token = Address::generate(&env);
-
- // Add some balance
- client.receive_payment(&vault, &1000i128, &false, &Some(developer.clone()), &token);
- let dev_balance_before = client.get_developer_balance(&developer, &token);
- let pool_before = client.get_global_pool();
-
- // Rotate admin
- client.set_admin(&admin, &new_admin);
- client.accept_admin();
-
- // State preserved
- assert_eq!(client.get_developer_balance(&developer, &token), dev_balance_before);
- assert_eq!(
- client.get_global_pool().total_balance,
- pool_before.total_balance
- );
- }
-
- // ── event emission tests ────────────────────────────────────────────────
-
- #[test]
- fn test_set_admin_emits_nomination_event() {
- use soroban_sdk::testutils::Events as _;
- use soroban_sdk::{IntoVal, Symbol};
-
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let new_admin = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- client.init(&admin, &vault);
-
- client.set_admin(&admin, &new_admin);
-
- let events = env.events().all();
- let nom_ev = events
- .iter()
- .find(|e| {
- !e.1.is_empty() && {
- let t: Symbol = e.1.get(0).unwrap().into_val(&env);
- t == Symbol::new(&env, "admin_nominated")
- }
- })
- .expect("expected admin_nominated event");
-
- let topic_current: Address = nom_ev.1.get(1).unwrap().into_val(&env);
- let topic_new: Address = nom_ev.1.get(2).unwrap().into_val(&env);
- assert_eq!(topic_current, admin);
- assert_eq!(topic_new, new_admin);
- }
-
- #[test]
- fn test_accept_admin_emits_accepted_event() {
- use soroban_sdk::testutils::Events as _;
- use soroban_sdk::{IntoVal, Symbol};
-
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let new_admin = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- client.init(&admin, &vault);
-
- client.set_admin(&admin, &new_admin);
- client.accept_admin();
-
- let events = env.events().all();
- let acc_ev = events
- .iter()
- .find(|e| {
- !e.1.is_empty() && {
- let t: Symbol = e.1.get(0).unwrap().into_val(&env);
- t == Symbol::new(&env, "admin_accepted")
- }
- })
- .expect("expected admin_accepted event");
-
- let topic_old: Address = acc_ev.1.get(1).unwrap().into_val(&env);
- let topic_new: Address = acc_ev.1.get(2).unwrap().into_val(&env);
- assert_eq!(topic_old, admin);
- assert_eq!(topic_new, new_admin);
- }
-
- // ── panic / error paths ──────────────────────────────────────────────────
-
- #[test]
- fn test_double_init_returns_already_initialized() {
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- client.init(&admin, &vault);
- let result = client.try_init(&admin, &vault);
- assert!(
- is_error(result, SettlementError::AlreadyInitialized),
- "expected AlreadyInitialized"
- );
- }
-
- #[test]
- fn test_receive_payment_zero_amount() {
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- client.init(&admin, &vault);
- let token = Address::generate(&env);
-
- let result = client.try_receive_payment(&vault, &0i128, &true, &None, &token);
- assert!(is_error(result, SettlementError::AmountNotPositive));
- }
-
- #[test]
- fn test_receive_payment_negative_amount() {
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- client.init(&admin, &vault);
- let token = Address::generate(&env);
-
- let result = client.try_receive_payment(&vault, &-1i128, &true, &None, &token);
- assert!(is_error(result, SettlementError::AmountNotPositive));
- }
-
- #[test]
- fn test_receive_payment_to_pool_overflow() {
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- client.init(&admin, &vault);
- let token = Address::generate(&env);
-
- env.as_contract(&addr, || {
- let inst = env.storage().instance();
- let pool = crate::GlobalPool {
- total_balance: i128::MAX,
- last_updated: env.ledger().timestamp(),
- };
- inst.set(&crate::StorageKey::GlobalPool, &pool);
- });
-
- let result = client.try_receive_payment(&vault, &1i128, &true, &None, &token);
- assert!(is_error(result, SettlementError::PoolOverflow));
- }
-
- #[test]
- fn test_receive_payment_to_developer_overflow() {
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let developer = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- client.init(&admin, &vault);
- let token = Address::generate(&env);
-
- env.as_contract(&addr, || {
- env.storage().persistent().set(
- &crate::StorageKey::DeveloperBalance(developer.clone(), token.clone()),
- &i128::MAX,
- );
- });
-
- let result = client.try_receive_payment(&vault, &1i128, &false, &Some(developer), &token);
- assert!(is_error(result, SettlementError::DeveloperOverflow));
- }
-
- #[test]
- fn test_receive_payment_pool_false_no_developer() {
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- client.init(&admin, &vault);
- let token = Address::generate(&env);
-
- let result = client.try_receive_payment(&vault, &100i128, &false, &None, &token);
- assert!(is_error(result, SettlementError::DeveloperRequired));
- }
-
- #[test]
- fn test_receive_payment_pool_true_with_developer() {
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let developer = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- client.init(&admin, &vault);
- let token = Address::generate(&env);
-
- let result = client.try_receive_payment(&vault, &100i128, &true, &Some(developer), &token);
- assert!(is_error(result, SettlementError::DeveloperMustBeNone));
- }
-
- #[test]
- fn test_receive_payment_authorization_matrix() {
- enum CallerRole {
- Vault,
- Admin,
- ThirdParty,
- }
-
- struct Case {
- name: &'static str,
- role: CallerRole,
- should_succeed: bool,
- }
-
- let cases = [
- Case {
- name: "vault address succeeds",
- role: CallerRole::Vault,
- should_succeed: true,
- },
- Case {
- name: "admin address succeeds",
- role: CallerRole::Admin,
- should_succeed: true,
- },
- Case {
- name: "third party fails",
- role: CallerRole::ThirdParty,
- should_succeed: false,
- },
- ];
-
- for case in cases {
- let (env, addr, admin, vault, third_party, token) = setup_contract();
- let client = CalloraSettlementClient::new(&env, &addr);
- let caller = match case.role {
- CallerRole::Vault => vault,
- CallerRole::Admin => admin,
- CallerRole::ThirdParty => third_party,
- };
-
- let result = client.try_receive_payment(&caller, &100i128, &true, &None, &token);
-
- if case.should_succeed {
- assert!(result.is_ok(), "expected success for case: {}", case.name);
- assert_eq!(client.get_global_pool().total_balance, 100i128);
- } else {
- assert!(
- is_error(result, SettlementError::Unauthorized),
- "expected Unauthorized for case: {}",
- case.name
- );
- }
- }
- }
-
- // ── event shape tests ────────────────────────────────────────────────────
-
- #[test]
- fn test_payment_received_event_to_pool() {
- use soroban_sdk::testutils::Events as _;
- use soroban_sdk::{IntoVal, Symbol};
-
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- client.init(&admin, &vault);
- let token = Address::generate(&env);
-
- client.receive_payment(&vault, &1000i128, &true, &None, &token);
-
- let events = env.events().all();
- let ev = events
- .iter()
- .find(|e| {
- !e.1.is_empty() && {
- let t: Symbol = e.1.get(0).unwrap().into_val(&env);
- t == Symbol::new(&env, "payment_received")
- }
- })
- .expect("expected payment_received event");
-
- let topic1: Address = ev.1.get(1).unwrap().into_val(&env);
- assert_eq!(topic1, vault);
-
- let data: crate::PaymentReceivedEvent = ev.2.into_val(&env);
- assert_eq!(data.from_vault, vault);
- assert_eq!(data.amount, 1000i128);
- assert!(data.to_pool);
- assert!(data.developer.is_none());
- }
-
- /// Snapshot: asserts the full `PaymentReceivedEvent` struct shape and values
- /// in a single comparison, for the to_pool=true branch. Any future change to
- /// the struct's fields or this call's emitted values will fail this test,
- /// making accidental output-structure drift impossible to miss.
- #[test]
- fn test_payment_received_event_snapshot_to_pool() {
- use soroban_sdk::testutils::Events as _;
- use soroban_sdk::{IntoVal, Symbol};
-
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- client.init(&admin, &vault);
-
- client.receive_payment(&vault, &750i128, &true, &None);
-
- let events = env.events().all();
- let ev = events
- .iter()
- .find(|e| {
- !e.1.is_empty() && {
- let t: Symbol = e.1.get(0).unwrap().into_val(&env);
- t == Symbol::new(&env, "payment_received")
- }
- })
- .expect("expected payment_received event");
-
- let data: crate::PaymentReceivedEvent = ev.2.into_val(&env);
-
- let expected = crate::PaymentReceivedEvent {
- from_vault: vault.clone(),
- amount: 750i128,
- to_pool: true,
- developer: None,
- };
-
- assert_eq!(data, expected);
- }
-
- /// Snapshot: asserts the full `PaymentReceivedEvent` struct shape and values
- /// in a single comparison, for the to_pool=false (developer) branch.
- #[test]
- fn test_payment_received_event_snapshot_to_developer() {
- use soroban_sdk::testutils::Events as _;
- use soroban_sdk::{IntoVal, Symbol};
-
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let developer = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- client.init(&admin, &vault);
-
- client.receive_payment(&vault, &321i128, &false, &Some(developer.clone()));
-
- let events = env.events().all();
- let ev = events
- .iter()
- .find(|e| {
- !e.1.is_empty() && {
- let t: Symbol = e.1.get(0).unwrap().into_val(&env);
- t == Symbol::new(&env, "payment_received")
- }
- })
- .expect("expected payment_received event");
-
- let data: crate::PaymentReceivedEvent = ev.2.into_val(&env);
-
- let expected = crate::PaymentReceivedEvent {
- from_vault: vault.clone(),
- amount: 321i128,
- to_pool: false,
- developer: Some(developer.clone()),
- };
-
- assert_eq!(data, expected);
- }
-
- #[test]
- fn test_payment_received_and_balance_credited_events_to_developer() {
- use soroban_sdk::testutils::Events as _;
- use soroban_sdk::{IntoVal, Symbol};
-
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let developer = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- client.init(&admin, &vault);
- let token = Address::generate(&env);
-
- client.receive_payment(&vault, &500i128, &false, &Some(developer.clone()), &token);
-
- let events = env.events().all();
- let pr_ev = events
- .iter()
- .find(|e| {
- !e.1.is_empty() && {
- let t: Symbol = e.1.get(0).unwrap().into_val(&env);
- t == Symbol::new(&env, "payment_received")
- }
- })
- .expect("expected payment_received event");
-
- let pr_data: crate::PaymentReceivedEvent = pr_ev.2.into_val(&env);
- assert!(!pr_data.to_pool);
- assert_eq!(pr_data.developer, Some(developer.clone()));
-
- let bc_ev = events
- .iter()
- .find(|e| {
- !e.1.is_empty() && {
- let t: Symbol = e.1.get(0).unwrap().into_val(&env);
- t == Symbol::new(&env, "balance_credited")
- }
- })
- .expect("expected balance_credited event");
-
- let topic1: Address = bc_ev.1.get(1).unwrap().into_val(&env);
- assert_eq!(topic1, developer);
-
- let bc_data: crate::BalanceCreditedEvent = bc_ev.2.into_val(&env);
- assert_eq!(bc_data.developer, developer);
- assert_eq!(bc_data.amount, 500i128);
- assert_eq!(bc_data.new_balance, 500i128);
- }
-
- #[test]
- fn test_balance_credited_new_balance_is_cumulative() {
- use soroban_sdk::testutils::Events as _;
- use soroban_sdk::{IntoVal, Symbol};
-
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let developer = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- client.init(&admin, &vault);
- let token = Address::generate(&env);
-
- client.receive_payment(&vault, &300i128, &false, &Some(developer.clone()), &token);
- client.receive_payment(&vault, &200i128, &false, &Some(developer.clone()), &token);
-
- // grab the last balance_credited event
- let events = env.events().all();
- let bc_ev = events
- .iter()
- .rev()
- .find(|e| {
- !e.1.is_empty() && {
- let t: Symbol = e.1.get(0).unwrap().into_val(&env);
- t == Symbol::new(&env, "balance_credited")
- }
- })
- .expect("expected balance_credited event");
-
- let bc_data: crate::BalanceCreditedEvent = bc_ev.2.into_val(&env);
- assert_eq!(bc_data.new_balance, 500i128);
- }
-
- // ── regression tests: ensure settlement logic intact after rotation ─────
-
- #[test]
- fn test_receive_payment_works_after_admin_rotation() {
- // Ensure payment processing still works after admin change
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let new_admin = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- client.init(&admin, &vault);
- let token = Address::generate(&env);
-
- // Rotate admin
- client.set_admin(&admin, &new_admin);
- client.accept_admin();
-
- // Vault can still send payments
- client.receive_payment(&vault, &1000i128, &true, &None, &token);
- assert_eq!(client.get_global_pool().total_balance, 1000i128);
- }
-
- #[test]
- fn test_receive_payment_works_after_vault_update() {
- // Ensure payment processing works with new vault address
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let new_vault = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- client.init(&admin, &vault);
- let token = Address::generate(&env);
-
- // Update vault
- client.propose_vault(&admin, &new_vault);
- client.accept_vault(&new_vault);
-
- // Old vault cannot send payments
- let result = client.try_receive_payment(&vault, &1000i128, &true, &None, &token);
- assert!(is_error(result, SettlementError::Unauthorized));
-
- // New vault can send payments
- client.receive_payment(&new_vault, &1000i128, &true, &None, &token);
- assert_eq!(client.get_global_pool().total_balance, 1000i128);
- }
-
- #[test]
- fn test_developer_withdrawal_after_admin_rotation() {
- // Ensure developer balances accessible after admin change
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let developer = Address::generate(&env);
- let new_admin = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- client.init(&admin, &vault);
- let token = Address::generate(&env);
-
- // Credit developer
- client.receive_payment(&vault, &500i128, &false, &Some(developer.clone()), &token);
-
- // Rotate admin
- client.set_admin(&admin, &new_admin);
- client.accept_admin();
-
- // Balance still accessible
- assert_eq!(client.get_developer_balance(&developer, &token), 500i128);
-
- // Admin can still view all balances
- let all_balances = client.get_all_developer_balances(&new_admin, &token);
- assert_eq!(all_balances.len(), 1);
- assert_eq!(all_balances.get(0).unwrap().balance, 500i128);
- }
-
- #[test]
- fn test_multiple_payments_accumulate_after_vault_update() {
- // Ensure accumulation logic works correctly after vault changes
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let new_vault = Address::generate(&env);
- let developer = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- client.init(&admin, &vault);
- let token = Address::generate(&env);
-
- // Some payments from old vault
- client.receive_payment(&vault, &100i128, &false, &Some(developer.clone()), &token);
-
- // Update vault
- client.propose_vault(&admin, &new_vault);
- client.accept_vault(&new_vault);
-
- // More payments from new vault
- client.receive_payment(&new_vault, &150i128, &false, &Some(developer.clone()), &token);
- client.receive_payment(&new_vault, &200i128, &false, &Some(developer.clone()), &token);
-
- // Total should accumulate correctly
- assert_eq!(client.get_developer_balance(&developer, &token), 450i128);
- }
-
- #[test]
- fn test_global_pool_timestamp_updates_after_admin_change() {
- // Ensure pool timestamp updates correctly regardless of admin
- let env = Env::default();
- env.mock_all_auths();
- env.ledger().set_timestamp(1_700_000_000);
-
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let new_admin = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- client.init(&admin, &vault);
- let token = Address::generate(&env);
-
- // Initial payment
- client.receive_payment(&vault, &1000i128, &true, &None, &token);
- let pool_before = client.get_global_pool();
- assert_eq!(pool_before.last_updated, 1_700_000_000);
-
- // Rotate admin and advance time
- client.set_admin(&admin, &new_admin);
- client.accept_admin();
- env.ledger().set_timestamp(1_700_000_100);
-
- // New payment updates timestamp
- client.receive_payment(&vault, &500i128, &true, &None, &token);
- let pool_after = client.get_global_pool();
- assert_eq!(pool_after.last_updated, 1_700_000_100);
- assert_eq!(pool_after.total_balance, 1500i128);
- }
-
- /// `last_updated` reflects the ledger timestamp at the moment of each pool credit.
- #[test]
- fn test_global_pool_last_updated_on_receive_payment() {
- let env = Env::default();
- env.mock_all_auths();
-
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- let token = Address::generate(&env);
-
- env.ledger().set_timestamp(1_000);
- client.init(&admin, &vault);
- assert_eq!(client.get_global_pool().last_updated, 1_000);
-
- // Advance time and credit pool � last_updated must change
- env.ledger().set_timestamp(2_000);
- client.receive_payment(&vault, &100i128, &true, &None, &token);
- let pool = client.get_global_pool();
- assert_eq!(pool.last_updated, 2_000);
- assert_eq!(pool.total_balance, 100i128);
-
- // Advance again � each credit stamps the new time
- env.ledger().set_timestamp(3_000);
- client.receive_payment(&vault, &50i128, &true, &None, &token);
- let pool2 = client.get_global_pool();
- assert_eq!(pool2.last_updated, 3_000);
- assert_eq!(pool2.total_balance, 150i128);
- }
-
- /// Routing to a developer does NOT update `last_updated` on the global pool.
- #[test]
- fn test_global_pool_last_updated_unchanged_for_developer_payment() {
- let env = Env::default();
- env.mock_all_auths();
-
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let developer = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- let token = Address::generate(&env);
-
- env.ledger().set_timestamp(1_000);
- client.init(&admin, &vault);
-
- env.ledger().set_timestamp(5_000);
- client.receive_payment(&vault, &200i128, &false, &Some(developer.clone()), &token);
-
- // Pool timestamp must still be the init timestamp
- assert_eq!(client.get_global_pool().last_updated, 1_000);
- assert_eq!(client.get_global_pool().total_balance, 0);
- assert_eq!(client.get_developer_balance(&developer, &token), 200i128);
- }
-
- // --- Authorization Matrix Tests ---
-
- #[test]
- fn test_set_admin_authorization_matrix() {
- let (env, addr, admin, vault, third_party, _token) = setup_contract();
- let client = CalloraSettlementClient::new(&env, &addr);
- let new_admin = Address::generate(&env);
-
- // Admin can set admin
- client.set_admin(&admin, &new_admin);
-
- // Vault cannot set admin
- let result = client.try_set_admin(&vault, &new_admin);
- assert!(is_error(result, SettlementError::Unauthorized));
-
- // Third party cannot set admin
- let result = client.try_set_admin(&third_party, &new_admin);
- assert!(is_error(result, SettlementError::Unauthorized));
- }
-
- #[test]
- fn test_set_vault_authorization_matrix() {
- let (env, addr, admin, vault, third_party, _token) = setup_contract();
- let client = CalloraSettlementClient::new(&env, &addr);
- let new_vault = Address::generate(&env);
-
- // Admin can propose vault (set_vault is an alias)
- client.propose_vault(&admin, &new_vault);
-
- // Vault cannot set vault
- let result = client.try_set_vault(&vault, &new_vault);
- assert!(is_error(result, SettlementError::Unauthorized));
-
- // Third party cannot set vault
- let result = client.try_set_vault(&third_party, &new_vault);
- assert!(is_error(result, SettlementError::Unauthorized));
- }
-
- #[test]
- fn test_accept_vault_rejects_unauthorized_caller() {
- let (env, addr, admin, vault, third_party, _token) = setup_contract();
- let client = CalloraSettlementClient::new(&env, &addr);
- let new_vault = Address::generate(&env);
-
- client.propose_vault(&admin, &new_vault);
- assert_eq!(client.get_vault(), vault);
-
- let result = client.try_accept_vault(&third_party);
- assert!(result.is_err());
- }
-
- #[test]
- fn test_accept_vault_allows_admin_to_finalize() {
- let (env, addr, admin, vault, _third_party, _token) = setup_contract();
- let client = CalloraSettlementClient::new(&env, &addr);
- let new_vault = Address::generate(&env);
-
- client.propose_vault(&admin, &new_vault);
- assert_eq!(client.get_vault(), vault);
-
- client.accept_vault(&admin);
- assert_eq!(client.get_vault(), new_vault);
- }
-
- #[test]
- fn test_propose_vault_rejects_self_address() {
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- client.init(&admin, &vault);
-
- let result = client.try_propose_vault(&admin, &addr);
- assert!(result.is_err());
- }
-
- #[test]
- fn test_accept_admin_authorization_matrix() {
- let (env, addr, admin, _vault, _third_party, _token) = setup_contract();
- let client = CalloraSettlementClient::new(&env, &addr);
- let new_admin = Address::generate(&env);
-
- client.set_admin(&admin, &new_admin);
-
- // Accept for new_admin (using mock_all_auths which is ON from setup_contract)
- client.accept_admin();
- assert_eq!(client.get_admin(), new_admin);
- }
-
- #[test]
- fn test_get_all_developer_balances_authorization_matrix() {
- let (env, addr, admin, vault, third_party, token) = setup_contract();
- let client = CalloraSettlementClient::new(&env, &addr);
-
- // Admin can call
- let _ = client.get_all_developer_balances(&admin, &token);
-
- // Vault cannot call
- let result = client.try_get_all_developer_balances(&vault, &token);
- assert!(is_error(result, SettlementError::Unauthorized));
-
- // Third party cannot call
- let result = client.try_get_all_developer_balances(&third_party, &token);
- assert!(is_error(result, SettlementError::Unauthorized));
- }
-
- // ── batch_receive_payment tests ──────────────────────────────────────────
-
- #[test]
- fn test_batch_receive_payment_credits_multiple_developers() {
- let (env, addr, _admin, vault, _third_party, token) = setup_contract();
- let client = CalloraSettlementClient::new(&env, &addr);
- let dev1 = Address::generate(&env);
- let dev2 = Address::generate(&env);
-
- let mut items = soroban_sdk::Vec::new(&env);
- items.push_back((dev1.clone(), 100i128));
- items.push_back((dev2.clone(), 200i128));
-
- client.batch_receive_payment(&vault, &items, &token);
-
- assert_eq!(client.get_developer_balance(&dev1, &token), 100i128);
- assert_eq!(client.get_developer_balance(&dev2, &token), 200i128);
- }
-
- #[test]
- fn test_batch_receive_payment_accumulates_existing_balance() {
- let (env, addr, _admin, vault, _third_party, token) = setup_contract();
- let client = CalloraSettlementClient::new(&env, &addr);
- let dev = Address::generate(&env);
-
- client.receive_payment(&vault, &50i128, &false, &Some(dev.clone()), &token);
-
- let mut items = soroban_sdk::Vec::new(&env);
- items.push_back((dev.clone(), 75i128));
- client.batch_receive_payment(&vault, &items, &token);
-
- assert_eq!(client.get_developer_balance(&dev, &token), 125i128);
- }
-
- #[test]
- fn test_batch_receive_payment_admin_caller_allowed() {
- let (env, addr, admin, _vault, _third_party, token) = setup_contract();
- let client = CalloraSettlementClient::new(&env, &addr);
- let dev = Address::generate(&env);
-
- let mut items = soroban_sdk::Vec::new(&env);
- items.push_back((dev.clone(), 300i128));
- client.batch_receive_payment(&admin, &items, &token);
-
- assert_eq!(client.get_developer_balance(&dev, &token), 300i128);
- }
-
- #[test]
- fn test_batch_receive_payment_rejects_empty_batch() {
- let (env, addr, _admin, vault, _third_party, token) = setup_contract();
- let client = CalloraSettlementClient::new(&env, &addr);
-
- let items: soroban_sdk::Vec<(Address, i128)> = soroban_sdk::Vec::new(&env);
- let result = client.try_batch_receive_payment(&vault, &items, &token);
- assert!(result.is_err());
- }
-
- #[test]
- fn test_batch_receive_payment_rejects_oversized_batch() {
- use crate::MAX_BATCH_SIZE;
- let (env, addr, _admin, vault, _third_party, token) = setup_contract();
- let client = CalloraSettlementClient::new(&env, &addr);
- let dev = Address::generate(&env);
-
- let mut items = soroban_sdk::Vec::new(&env);
- for _ in 0..=MAX_BATCH_SIZE {
- items.push_back((dev.clone(), 1i128));
- }
- let result = client.try_batch_receive_payment(&vault, &items, &token);
- assert!(result.is_err());
- }
-
- #[test]
- fn test_batch_receive_payment_rejects_zero_amount() {
- let (env, addr, _admin, vault, _third_party, token) = setup_contract();
- let client = CalloraSettlementClient::new(&env, &addr);
- let dev = Address::generate(&env);
-
- let mut items = soroban_sdk::Vec::new(&env);
- items.push_back((dev.clone(), 0i128));
- let result = client.try_batch_receive_payment(&vault, &items, &token);
- assert!(result.is_err());
- }
-
- #[test]
- fn test_batch_receive_payment_rejects_negative_amount() {
- let (env, addr, _admin, vault, _third_party, token) = setup_contract();
- let client = CalloraSettlementClient::new(&env, &addr);
- let dev = Address::generate(&env);
-
- let mut items = soroban_sdk::Vec::new(&env);
- items.push_back((dev.clone(), -1i128));
- let result = client.try_batch_receive_payment(&vault, &items, &token);
- assert!(result.is_err());
- }
-
- #[test]
- fn test_batch_receive_payment_unauthorized_caller_rejected() {
- let (env, addr, _admin, _vault, third_party, token) = setup_contract();
- let client = CalloraSettlementClient::new(&env, &addr);
- let dev = Address::generate(&env);
-
- let mut items = soroban_sdk::Vec::new(&env);
- items.push_back((dev.clone(), 100i128));
- let result = client.try_batch_receive_payment(&third_party, &items, &token);
- assert!(is_error(result, SettlementError::Unauthorized));
- }
-
- #[test]
- fn test_batch_receive_payment_single_item() {
- let (env, addr, _admin, vault, _third_party, token) = setup_contract();
- let client = CalloraSettlementClient::new(&env, &addr);
- let dev = Address::generate(&env);
-
- let mut items = soroban_sdk::Vec::new(&env);
- items.push_back((dev.clone(), 999i128));
- client.batch_receive_payment(&vault, &items, &token);
-
- assert_eq!(client.get_developer_balance(&dev, &token), 999i128);
- }
-
- #[test]
- fn test_batch_receive_payment_max_batch_size_accepted() {
- use crate::MAX_BATCH_SIZE;
- let (env, addr, _admin, vault, _third_party, token) = setup_contract();
- let client = CalloraSettlementClient::new(&env, &addr);
-
- let mut items = soroban_sdk::Vec::new(&env);
- let mut devs = std::vec::Vec::new();
- for _ in 0..MAX_BATCH_SIZE {
- let dev = Address::generate(&env);
- devs.push(dev.clone());
- items.push_back((dev, 1i128));
- }
- client.batch_receive_payment(&vault, &items, &token);
-
- for dev in &devs {
- assert_eq!(client.get_developer_balance(dev, &token), 1i128);
- }
- }
-
- // ── force_credit_developer tests ─────────────────────────────────────────
-
- #[test]
- fn test_force_credit_developer_happy_path() {
- let (env, addr, admin, _vault, _third_party, token) = setup_contract();
- let client = CalloraSettlementClient::new(&env, &addr);
- let developer = Address::generate(&env);
- let reason = Symbol::new(&env, "offline_settlement");
-
- client.force_credit_developer(&admin, &developer, &1000i128, &token, &reason);
-
- assert_eq!(client.get_developer_balance(&developer, &token), 1000i128);
- }
-
- #[test]
- fn test_force_credit_developer_accumulates() {
- let (env, addr, admin, _vault, _third_party, token) = setup_contract();
- let client = CalloraSettlementClient::new(&env, &addr);
- let developer = Address::generate(&env);
-
- client.force_credit_developer(&admin, &developer, &500i128, &Symbol::new(&env, "first"));
- client.force_credit_developer(&admin, &developer, &300i128, &Symbol::new(&env, "second"));
-
- assert_eq!(client.get_developer_balance(&developer, &token), 800i128);
- }
-
- #[test]
- fn test_force_credit_developer_unauthorized() {
- let (env, addr, _admin, vault, third_party, token) = setup_contract();
- let client = CalloraSettlementClient::new(&env, &addr);
- let developer = Address::generate(&env);
- let reason = Symbol::new(&env, "unauthorized_test");
-
- let vault_result = client.try_force_credit_developer(&vault, &developer, &100i128, &reason);
- assert!(is_error(vault_result, SettlementError::Unauthorized));
-
- let third_party_result =
- client.try_force_credit_developer(&third_party, &developer, &100i128, &token, &reason);
- assert!(is_error(third_party_result, SettlementError::Unauthorized));
- }
-
- #[test]
- fn test_force_credit_developer_zero_amount() {
- let (env, addr, admin, _vault, _third_party, token) = setup_contract();
- let client = CalloraSettlementClient::new(&env, &addr);
- let developer = Address::generate(&env);
-
- let result = client.try_force_credit_developer(
- &admin,
- &developer,
- &0i128,
- &token,
- &Symbol::new(&env, "zero"),
- );
- assert!(is_error(result, SettlementError::AmountNotPositive));
- }
-
- #[test]
- fn test_force_credit_developer_negative_amount() {
- let (env, addr, admin, _vault, _third_party, token) = setup_contract();
- let client = CalloraSettlementClient::new(&env, &addr);
- let developer = Address::generate(&env);
-
- let result = client.try_force_credit_developer(
- &admin,
- &developer,
- &-1i128,
- &token,
- &Symbol::new(&env, "negative"),
- );
- assert!(is_error(result, SettlementError::AmountNotPositive));
- }
-
- #[test]
- fn test_force_credit_developer_emits_event() {
- use soroban_sdk::testutils::Events as _;
- use soroban_sdk::IntoVal;
-
- let (env, addr, admin, _vault, _third_party, token) = setup_contract();
- let client = CalloraSettlementClient::new(&env, &addr);
- let developer = Address::generate(&env);
- let reason = Symbol::new(&env, "dispute_resolution");
-
- client.force_credit_developer(&admin, &developer, &2500i128, &token, &reason);
-
- let events = env.events().all();
- let ev = events
- .iter()
- .find(|e| {
- !e.1.is_empty() && {
- let t: Symbol = e.1.get(0).unwrap().into_val(&env);
- t == Symbol::new(&env, "developer_force_credited")
- }
- })
- .expect("expected developer_force_credited event");
-
- let topic1: Address = ev.1.get(1).unwrap().into_val(&env);
- assert_eq!(topic1, developer);
-
- let data: crate::DeveloperForceCreditedEvent = ev.2.into_val(&env);
- assert_eq!(data.developer, developer);
- assert_eq!(data.amount, 2500i128);
- assert_eq!(data.reason, reason);
- assert_eq!(data.new_balance, 2500i128);
- }
-
- #[test]
- fn test_force_credit_developer_repeated_reason() {
- let (env, addr, admin, _vault, _third_party, token) = setup_contract();
- let client = CalloraSettlementClient::new(&env, &addr);
- let developer1 = Address::generate(&env);
- let developer2 = Address::generate(&env);
- let reason = Symbol::new(&env, "bulk_reconciliation");
-
- client.force_credit_developer(&admin, &developer1, &100i128, &token, &reason);
- client.force_credit_developer(&admin, &developer2, &200i128, &token, &reason);
-
- assert_eq!(client.get_developer_balance(&developer1, &token), 100i128);
- assert_eq!(client.get_developer_balance(&developer2, &token), 200i128);
- }
-
- #[test]
- fn test_force_credit_developer_overflow() {
- let (env, addr, admin, _vault, _third_party, token) = setup_contract();
- let client = CalloraSettlementClient::new(&env, &addr);
- let developer = Address::generate(&env);
-
- env.as_contract(&addr, || {
- env.storage().persistent().set(
- &crate::StorageKey::DeveloperBalance(developer.clone()),
- &i128::MAX,
- );
- });
-
- let result = client.try_force_credit_developer(
- &admin,
- &developer,
- &1i128,
- &token,
- &Symbol::new(&env, "overflow"),
- );
- assert!(is_error(result, SettlementError::DeveloperOverflow));
- }
-
- /// Property-based test that drives many randomized receive_payment calls
- /// (mix of to_pool=true / false) and asserts the conservation invariant:
- /// sum of all credits == pool total + sum of all developer balances.
- /// Includes overflow-boundary cases near i128::MAX.
- #[test]
- fn test_conservation_invariant_randomized() {
- let (env, addr, admin, vault, _third_party, token) = setup_contract();
- let client = CalloraSettlementClient::new(&env, &addr);
-
- let mut developers = std::vec::Vec::new();
- for _ in 0..10 {
- developers.push(Address::generate(&env));
- }
-
- let mut total_credited: i128 = 0;
-
- // Simple deterministic pseudo-random generator
- let mut seed: u128 = 42;
- let mut next_rand = || {
- seed = seed.wrapping_mul(1103515245).wrapping_add(12345);
- seed
- };
-
- // 1. Run 100 randomized payments with small-to-medium amounts
- for _ in 0..100 {
- let to_pool = (next_rand() % 2) == 0;
- let amount = (next_rand() % 1_000_000) as i128 + 1;
-
- if to_pool {
- client.receive_payment(&vault, &amount, &true, &None, &token);
- } else {
- let dev_idx = (next_rand() % 10) as usize;
- if let Some(developer) = developers.get(dev_idx) {
- client.receive_payment(&vault, &amount, &false, &Some(developer.clone()), &token);
- }
- }
- total_credited += amount;
- }
-
- // 2. Drive towards i128::MAX boundary
- // Calculate remaining room to reach very close to i128::MAX
- let buffer = 1_000_000_000_i128;
- let remaining = i128::MAX - total_credited - buffer;
-
- if remaining > 0 {
- let half_remaining = remaining / 2;
-
- // Large credit to pool
- client.receive_payment(&vault, &half_remaining, &true, &None, &token);
- total_credited += half_remaining;
-
- // Large credit to a developer
- if let Some(developer) = developers.get(0) {
- client.receive_payment(&vault, &half_remaining, &false, &Some(developer.clone()), &token);
- total_credited += half_remaining;
- }
- }
-
- // Final Invariant Check
- let pool = client.get_global_pool();
- let mut sum_dev_balances: i128 = 0;
-
- let all_balances = client.get_all_developer_balances(&admin, &token);
- for record in all_balances.iter() {
- sum_dev_balances += record.balance;
- }
-
- assert_eq!(
- total_credited,
- pool.total_balance + sum_dev_balances,
- "Conservation invariant violated: total credits must equal pool + developer balances"
- );
- }
- #[test]
- fn test_upgrade_and_get_version() {
- let (env, addr, admin, _vault, _third_party, _token) = setup_contract();
- let client = CalloraSettlementClient::new(&env, &addr);
-
- assert_eq!(client.get_version(), None);
-
- let new_hash = BytesN::from_array(&env, &[1u8; 32]);
- client.upgrade(&admin, &new_hash);
-
- assert_eq!(client.get_version(), Some(new_hash.clone()));
-
- // An `upgraded` event should have been emitted
- let events = env.events().all();
- let ev = events.last().unwrap();
- let name = soroban_sdk::Symbol::try_from_val(&env, &ev.1.get(0).unwrap()).unwrap();
- assert_eq!(name, soroban_sdk::Symbol::new(&env, "upgraded"));
- }
-
- // ── daily withdrawal cap tests ──────────────────────────────────────────
-
- #[test]
- fn test_withdraw_respects_daily_cap() {
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let developer = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- let (usdc_address, _, usdc_admin_client) = create_usdc(&env, &admin);
-
- client.init(&admin, &vault);
- client.set_usdc_token(&admin, &usdc_address);
- client.set_daily_withdraw_cap(&admin, &developer, &500i128);
- client.receive_payment(&vault, &1000i128, &false, &Some(developer.clone()), &usdc_address);
- usdc_admin_client.mint(&addr, &1000i128);
-
- // First withdrawal of 300 should succeed (under 500 cap)
- let result = client.try_withdraw_developer_balance(&developer, &300i128, &None);
- assert!(result.is_ok());
- assert_eq!(client.get_developer_balance(&developer, &usdc_address), 700i128);
-
- // Second withdrawal of 300 would push total to 600 (over 500 cap)
- let result = client.try_withdraw_developer_balance(&developer, &300i128, &None);
- assert!(is_error(result, SettlementError::DailyWithdrawCapExceeded));
- assert_eq!(client.get_developer_balance(&developer, &usdc_address), 700i128);
- }
-
- #[test]
- fn test_daily_cap_accumulates_across_multiple_withdrawals() {
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let developer = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- let (usdc_address, _, usdc_admin_client) = create_usdc(&env, &admin);
-
- client.init(&admin, &vault);
- client.set_usdc_token(&admin, &usdc_address);
- client.set_daily_withdraw_cap(&admin, &developer, &500i128);
- client.receive_payment(&vault, &1000i128, &false, &Some(developer.clone()), &usdc_address);
- usdc_admin_client.mint(&addr, &1000i128);
-
- // Withdraw 200 + 200 = 400, still under 500
- assert!(client
- .try_withdraw_developer_balance(&developer, &200i128, &None)
- .is_ok());
- assert!(client
- .try_withdraw_developer_balance(&developer, &200i128, &None)
- .is_ok());
- assert_eq!(client.get_developer_balance(&developer), 600i128);
-
- // Third withdrawal of 100 would push to 500 (exact cap — allowed)
- assert!(client
- .try_withdraw_developer_balance(&developer, &100i128, &None)
- .is_ok());
- assert_eq!(client.get_developer_balance(&developer), 500i128);
-
- // Fourth withdrawal of 1 would exceed cap
- let result = client.try_withdraw_developer_balance(&developer, &1i128, &None);
- assert!(is_error(result, SettlementError::DailyWithdrawCapExceeded));
- }
-
- #[test]
- fn test_daily_cap_zero_means_unlimited() {
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let developer = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- let (usdc_address, _, usdc_admin_client) = create_usdc(&env, &admin);
-
- client.init(&admin, &vault);
- client.set_usdc_token(&admin, &usdc_address);
- // Cap = 0 explicitly means unlimited
- client.set_daily_withdraw_cap(&admin, &developer, &0i128);
- client.receive_payment(&vault, &1000i128, &false, &Some(developer.clone()), &usdc_address);
- usdc_admin_client.mint(&addr, &1000i128);
-
- assert!(client
- .try_withdraw_developer_balance(&developer, &1000i128, &None)
- .is_ok());
- assert_eq!(client.get_developer_balance(&developer), 0i128);
- }
-
- #[test]
- fn test_no_cap_set_is_unlimited() {
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let developer = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- let (usdc_address, _, usdc_admin_client) = create_usdc(&env, &admin);
-
- client.init(&admin, &vault);
- client.set_usdc_token(&admin, &usdc_address);
- // No cap set at all — should be unlimited
- client.receive_payment(&vault, &1000i128, &false, &Some(developer.clone()), &usdc_address);
- usdc_admin_client.mint(&addr, &1000i128);
-
- assert!(client
- .try_withdraw_developer_balance(&developer, &1000i128, &None)
- .is_ok());
- assert_eq!(client.get_developer_balance(&developer), 0i128);
- }
-
- #[test]
- fn test_daily_cap_resets_on_new_day() {
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let developer = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- let (usdc_address, _, usdc_admin_client) = create_usdc(&env, &admin);
-
- // Day 0: timestamp = 0
- env.ledger().set_timestamp(0);
- client.init(&admin, &vault);
- client.set_usdc_token(&admin, &usdc_address);
- client.set_daily_withdraw_cap(&admin, &developer, &500i128);
- client.receive_payment(&vault, &1000i128, &false, &Some(developer.clone()), &usdc_address);
- usdc_admin_client.mint(&addr, &1000i128);
-
- // Withdraw 400 on day 0
- assert!(client
- .try_withdraw_developer_balance(&developer, &400i128, &None)
- .is_ok());
- assert_eq!(client.get_developer_balance(&developer), 600i128);
-
- // Another 200 would exceed the 500 cap
- let result = client.try_withdraw_developer_balance(&developer, &200i128, &None);
- assert!(is_error(result, SettlementError::DailyWithdrawCapExceeded));
-
- // Advance to day 1
- env.ledger().set_timestamp(86400);
- // Mint more USDC for the new day
- usdc_admin_client.mint(&addr, &500i128);
-
- // Withdrawal should succeed now (cap resets)
- assert!(client
- .try_withdraw_developer_balance(&developer, &500i128, &None)
- .is_ok());
- assert_eq!(client.get_developer_balance(&developer), 100i128);
- }
-
- #[test]
- fn test_set_daily_withdraw_cap_unauthorized() {
- let (env, addr, _admin, vault, third_party, _token) = setup_contract();
- let client = CalloraSettlementClient::new(&env, &addr);
- let developer = Address::generate(&env);
-
- // Vault cannot set cap
- let result = client.try_set_daily_withdraw_cap(&vault, &developer, &1000i128);
- assert!(is_error(result, SettlementError::Unauthorized));
-
- // Third party cannot set cap
- let result = client.try_set_daily_withdraw_cap(&third_party, &developer, &1000i128);
- assert!(is_error(result, SettlementError::Unauthorized));
- }
-
- #[test]
- fn test_set_daily_withdraw_cap_emits_event() {
- use soroban_sdk::testutils::Events as _;
- use soroban_sdk::{IntoVal, Symbol};
-
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let developer = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- client.init(&admin, &vault);
-
- client.set_daily_withdraw_cap(&admin, &developer, &1000i128);
-
- let events = env.events().all();
- let ev = events
- .iter()
- .find(|e| {
- !e.1.is_empty() && {
- let t: Symbol = e.1.get(0).unwrap().into_val(&env);
- t == Symbol::new(&env, "daily_withdraw_cap_changed")
- }
- })
- .expect("expected daily_withdraw_cap_changed event");
-
- let topic1: Address = ev.1.get(1).unwrap().into_val(&env);
- assert_eq!(topic1, admin);
-
- let data: crate::DailyWithdrawCapChanged = ev.2.into_val(&env);
- assert_eq!(data.developer, developer);
- assert_eq!(data.new_cap, 1000i128);
- }
-
- #[test]
- fn test_get_daily_withdraw_cap_returns_zero_when_unset() {
- let (env, addr, _admin, _vault, _third_party, _token) = setup_contract();
- let client = CalloraSettlementClient::new(&env, &addr);
- let developer = Address::generate(&env);
-
- let cap = client.get_daily_withdraw_cap(&developer);
- assert_eq!(cap, 0);
- }
-
- #[test]
- fn test_get_withdrawal_today_returns_zero_after_no_withdrawals() {
- let (env, addr, _admin, _vault, _third_party, _token) = setup_contract();
- let client = CalloraSettlementClient::new(&env, &addr);
- let developer = Address::generate(&env);
-
- let today = client.get_withdrawal_today(&developer);
- assert_eq!(today, 0);
- }
-
- #[test]
- fn test_get_withdrawal_today_tracks_cumulative_withdrawals() {
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let developer = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- let (usdc_address, _, usdc_admin_client) = create_usdc(&env, &admin);
-
- client.init(&admin, &vault);
- client.set_usdc_token(&admin, &usdc_address);
- client.set_daily_withdraw_cap(&admin, &developer, &1000i128);
- client.receive_payment(&vault, &1000i128, &false, &Some(developer.clone()), &usdc_address);
- usdc_admin_client.mint(&addr, &1000i128);
-
- assert_eq!(client.get_withdrawal_today(&developer), 0i128);
-
- client.withdraw_developer_balance(&developer, &300i128, &None);
- assert_eq!(client.get_withdrawal_today(&developer), 300i128);
-
- client.withdraw_developer_balance(&developer, &200i128, &None);
- assert_eq!(client.get_withdrawal_today(&developer), 500i128);
- }
-
- #[test]
- fn test_daily_cap_does_not_affect_other_developers() {
- let env = Env::default();
- env.mock_all_auths();
- env.ledger().set_timestamp(0);
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let dev1 = Address::generate(&env);
- let dev2 = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- let (usdc_address, _, usdc_admin_client) = create_usdc(&env, &admin);
-
- client.init(&admin, &vault);
- client.set_usdc_token(&admin, &usdc_address);
- client.set_daily_withdraw_cap(&admin, &dev1, &500i128);
- // dev2 has no cap (unlimited)
- client.receive_payment(&vault, &1000i128, &false, &Some(dev1.clone()), &usdc_address);
- client.receive_payment(&vault, &500i128, &false, &Some(dev2.clone()), &usdc_address);
- usdc_admin_client.mint(&addr, &1500i128);
-
- // dev1 hits cap at 500
- assert!(client
- .try_withdraw_developer_balance(&dev1, &300i128, &None)
- .is_ok());
- // Still within cap (300 < 500)
- assert!(client
- .try_withdraw_developer_balance(&dev1, &200i128, &None)
- .is_ok());
- // Exceeds cap (300 + 200 + 1 > 500)
- let result = client.try_withdraw_developer_balance(&dev1, &1i128, &None);
- assert!(is_error(result, SettlementError::DailyWithdrawCapExceeded));
-
- // dev2 can still withdraw (no cap)
- assert!(client
- .try_withdraw_developer_balance(&dev2, &500i128, &None)
- .is_ok());
- }
-
- // ── developer claim window tests ────────────────────────────────────────
-
- #[test]
- fn test_claim_window_blocks_withdraw_before_start() {
- let env = Env::default();
- env.mock_all_auths();
- env.ledger().set_timestamp(99);
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let developer = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- let (usdc_address, _, usdc_admin_client) = create_usdc(&env, &admin);
-
- client.init(&admin, &vault);
- client.set_usdc_token(&admin, &usdc_address);
- client
- .try_set_developer_claim_window(&admin, &developer, &100u64, &200u64)
- .unwrap()
- .unwrap();
- client.receive_payment(&vault, &100i128, &false, &Some(developer.clone()));
- usdc_admin_client.mint(&addr, &100i128);
-
- let result = client.try_withdraw_developer_balance(&developer, &100i128, &None);
- assert!(is_error(result, SettlementError::ClaimWindowClosed));
- assert_eq!(client.get_developer_balance(&developer), 100i128);
- assert_eq!(
- token::Client::new(&env, &usdc_address).balance(&developer),
- 0i128
- );
- }
-
- #[test]
- fn test_claim_window_allows_withdraw_at_start_and_end() {
- let env = Env::default();
- env.mock_all_auths();
- env.ledger().set_timestamp(100);
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let developer = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- let (usdc_address, _, usdc_admin_client) = create_usdc(&env, &admin);
-
- client.init(&admin, &vault);
- client.set_usdc_token(&admin, &usdc_address);
- client
- .try_set_developer_claim_window(&admin, &developer, &100u64, &200u64)
- .unwrap()
- .unwrap();
- client.receive_payment(&vault, &200i128, &false, &Some(developer.clone()));
- usdc_admin_client.mint(&addr, &200i128);
-
- assert!(client
- .try_withdraw_developer_balance(&developer, &50i128, &None)
- .is_ok());
- assert_eq!(client.get_developer_balance(&developer), 150i128);
-
- env.ledger().set_timestamp(200);
- assert!(client
- .try_withdraw_developer_balance(&developer, &150i128, &None)
- .is_ok());
- assert_eq!(client.get_developer_balance(&developer), 0i128);
- assert_eq!(
- token::Client::new(&env, &usdc_address).balance(&developer),
- 200i128
- );
- }
-
- #[test]
- fn test_claim_window_blocks_withdraw_after_end() {
- let env = Env::default();
- env.mock_all_auths();
- env.ledger().set_timestamp(201);
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let developer = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- let (usdc_address, _, usdc_admin_client) = create_usdc(&env, &admin);
-
- client.init(&admin, &vault);
- client.set_usdc_token(&admin, &usdc_address);
- client
- .try_set_developer_claim_window(&admin, &developer, &100u64, &200u64)
- .unwrap()
- .unwrap();
- client.receive_payment(&vault, &100i128, &false, &Some(developer.clone()));
- usdc_admin_client.mint(&addr, &100i128);
-
- let result = client.try_withdraw_developer_balance(&developer, &100i128, &None);
- assert!(is_error(result, SettlementError::ClaimWindowClosed));
- assert_eq!(client.get_developer_balance(&developer), 100i128);
- }
-
- #[test]
- fn test_claim_window_clear_restores_unrestricted_withdraw() {
- let env = Env::default();
- env.mock_all_auths();
- env.ledger().set_timestamp(201);
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let developer = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- let (usdc_address, _, usdc_admin_client) = create_usdc(&env, &admin);
-
- client.init(&admin, &vault);
- client.set_usdc_token(&admin, &usdc_address);
- client
- .try_set_developer_claim_window(&admin, &developer, &100u64, &200u64)
- .unwrap()
- .unwrap();
- assert!(client.get_developer_claim_window(&developer).is_some());
-
- client
- .try_clear_developer_claim_window(&admin, &developer)
- .unwrap()
- .unwrap();
- assert!(client.get_developer_claim_window(&developer).is_none());
-
- client.receive_payment(&vault, &100i128, &false, &Some(developer.clone()));
- usdc_admin_client.mint(&addr, &100i128);
-
- assert!(client
- .try_withdraw_developer_balance(&developer, &100i128, &None)
- .is_ok());
- assert_eq!(client.get_developer_balance(&developer), 0i128);
- }
-
- #[test]
- fn test_set_claim_window_rejects_invalid_range() {
- let (env, addr, admin, _vault, _third_party) = setup_contract();
- let client = CalloraSettlementClient::new(&env, &addr);
- let developer = Address::generate(&env);
-
- let result = client.try_set_developer_claim_window(&admin, &developer, &200u64, &100u64);
-
- assert!(is_error(result, SettlementError::InvalidClaimWindow));
- assert!(client.get_developer_claim_window(&developer).is_none());
- }
-
- #[test]
- fn test_set_and_clear_claim_window_unauthorized() {
- let (env, addr, _admin, vault, third_party) = setup_contract();
- let client = CalloraSettlementClient::new(&env, &addr);
- let developer = Address::generate(&env);
-
- let result = client.try_set_developer_claim_window(&vault, &developer, &100u64, &200u64);
- assert!(is_error(result, SettlementError::Unauthorized));
-
- let result =
- client.try_set_developer_claim_window(&third_party, &developer, &100u64, &200u64);
- assert!(is_error(result, SettlementError::Unauthorized));
-
- let result = client.try_clear_developer_claim_window(&third_party, &developer);
- assert!(is_error(result, SettlementError::Unauthorized));
- }
-
- #[test]
- fn test_claim_window_is_per_developer() {
- let env = Env::default();
- env.mock_all_auths();
- env.ledger().set_timestamp(201);
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let restricted = Address::generate(&env);
- let unrestricted = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- let (usdc_address, _, usdc_admin_client) = create_usdc(&env, &admin);
-
- client.init(&admin, &vault);
- client.set_usdc_token(&admin, &usdc_address);
- client
- .try_set_developer_claim_window(&admin, &restricted, &100u64, &200u64)
- .unwrap()
- .unwrap();
- client.receive_payment(&vault, &100i128, &false, &Some(restricted.clone()));
- client.receive_payment(&vault, &100i128, &false, &Some(unrestricted.clone()));
- usdc_admin_client.mint(&addr, &200i128);
-
- let result = client.try_withdraw_developer_balance(&restricted, &100i128, &None);
- assert!(is_error(result, SettlementError::ClaimWindowClosed));
- assert!(client
- .try_withdraw_developer_balance(&unrestricted, &100i128, &None)
- .is_ok());
- assert_eq!(client.get_developer_balance(&restricted), 100i128);
- assert_eq!(client.get_developer_balance(&unrestricted), 0i128);
- }
-
- #[test]
- fn test_set_claim_window_emits_event_and_getter_returns_window() {
- use soroban_sdk::testutils::Events as _;
- use soroban_sdk::{IntoVal, Symbol};
-
- let (env, addr, admin, _vault, _third_party) = setup_contract();
- let client = CalloraSettlementClient::new(&env, &addr);
- let developer = Address::generate(&env);
-
- client
- .try_set_developer_claim_window(&admin, &developer, &100u64, &200u64)
- .unwrap()
- .unwrap();
-
- let window = client.get_developer_claim_window(&developer).unwrap();
- assert_eq!(window.start_ts, 100u64);
- assert_eq!(window.end_ts, 200u64);
-
- let events = env.events().all();
- let ev = events
- .iter()
- .find(|e| {
- !e.1.is_empty() && {
- let t: Symbol = e.1.get(0).unwrap().into_val(&env);
- t == Symbol::new(&env, "claim_window_changed")
- }
- })
- .expect("expected claim_window_changed event");
-
- let topic1: Address = ev.1.get(1).unwrap().into_val(&env);
- assert_eq!(topic1, developer);
-
- let data: crate::DeveloperClaimWindowChanged = ev.2.into_val(&env);
- assert_eq!(data.developer, developer);
- assert_eq!(data.start_ts, 100u64);
- assert_eq!(data.end_ts, 200u64);
- assert!(data.enabled);
- }
-
- // ── cursor-based pagination tests ────────────────────────────────────────
-
- /// First page with cursor=None returns up to `limit` records from the
- /// beginning of the sorted index and yields a non-None next_cursor when the
- /// index has more entries.
- #[test]
- fn test_cursor_first_page_returns_records_and_next_cursor() {
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- client.init(&admin, &vault);
- let token = Address::generate(&env);
-
- let dev1 = Address::generate(&env);
- let dev2 = Address::generate(&env);
- let dev3 = Address::generate(&env);
- client.receive_payment(&vault, &100i128, &false, &Some(dev1.clone()), &token);
- client.receive_payment(&vault, &200i128, &false, &Some(dev2.clone()), &token);
- client.receive_payment(&vault, &300i128, &false, &Some(dev3.clone()), &token);
-
- let (page, next) = client.get_developer_balances_cursor(&admin, &None, &2u32, &token);
-
- assert_eq!(
- page.len(),
- 2,
- "first page must contain exactly limit records"
- );
- // next_cursor must point at the last record on this page so the caller
- // can continue from there.
- assert!(
- next.is_some(),
- "next_cursor must be Some when more records exist"
- );
- assert_eq!(
- next.as_ref().unwrap(),
- &page.get(1).unwrap().address,
- "next_cursor must equal the last address on the page"
- );
- }
-
- /// Subsequent page retrieved via next_cursor returns the remaining records.
- #[test]
- fn test_cursor_subsequent_page_returns_remaining_records() {
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- client.init(&admin, &vault);
- let token = Address::generate(&env);
-
- let dev1 = Address::generate(&env);
- let dev2 = Address::generate(&env);
- let dev3 = Address::generate(&env);
- client.receive_payment(&vault, &10i128, &false, &Some(dev1.clone()), &token);
- client.receive_payment(&vault, &20i128, &false, &Some(dev2.clone()), &token);
- client.receive_payment(&vault, &30i128, &false, &Some(dev3.clone()), &token);
-
- // Page 1
- let (page1, next1) = client.get_developer_balances_cursor(&admin, &None, &2u32, &token);
- assert_eq!(page1.len(), 2);
- assert!(next1.is_some());
-
- // Page 2 — use next_cursor from page 1
- let (page2, next2) = client.get_developer_balances_cursor(&admin, &next1, &2u32);
- assert_eq!(
- page2.len(),
- 1,
- "last page must contain the remaining record"
- );
- // Reached the end of the index.
- assert!(next2.is_none(), "next_cursor must be None on the last page");
-
- // Together the two pages must cover all three developers exactly once.
- let mut all_addrs: std::vec::Vec = std::vec::Vec::new();
- for r in page1.iter() {
- all_addrs.push(r.address.clone());
- }
- for r in page2.iter() {
- all_addrs.push(r.address.clone());
- }
- assert_eq!(all_addrs.len(), 3);
- assert!(all_addrs.contains(&dev1));
- assert!(all_addrs.contains(&dev2));
- assert!(all_addrs.contains(&dev3));
- }
-
- /// Cursor pointing past the last entry returns an empty page and None.
- #[test]
- fn test_cursor_past_last_entry_returns_empty_page() {
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- client.init(&admin, &vault);
- let token = Address::generate(&env);
-
- let dev1 = Address::generate(&env);
- let dev2 = Address::generate(&env);
- client.receive_payment(&vault, &1i128, &false, &Some(dev1.clone()), &token);
- client.receive_payment(&vault, &2i128, &false, &Some(dev2.clone()), &token);
-
- // Exhaust the index with a large limit to find the last cursor.
- let (full_page, last_cursor) = client.get_developer_balances_cursor(&admin, &None, &100u32, &token);
- assert_eq!(full_page.len(), 2);
- assert!(last_cursor.is_none());
-
- // Use the address of the last record as the cursor — nothing should follow.
- let last_addr = full_page.get(full_page.len() - 1).unwrap().address;
- let (empty_page, next) =
- client.get_developer_balances_cursor(&admin, &Some(last_addr), &10u32, &token);
- assert_eq!(empty_page.len(), 0, "page after last cursor must be empty");
- assert!(next.is_none());
- }
-
- /// Cursor stability: credits to developers that sort **after** the cursor do
- /// not disturb already-returned pages.
- #[test]
- fn test_cursor_stable_across_interleaved_credits() {
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- client.init(&admin, &vault);
- let token = Address::generate(&env);
-
- // Pre-populate three developers so the sorted index is stable.
- let dev_a = Address::generate(&env);
- let dev_b = Address::generate(&env);
- let dev_c = Address::generate(&env);
- client.receive_payment(&vault, &1i128, &false, &Some(dev_a.clone()), &token);
- client.receive_payment(&vault, &1i128, &false, &Some(dev_b.clone()), &token);
- client.receive_payment(&vault, &1i128, &false, &Some(dev_c.clone()), &token);
-
- // Fetch first page (limit=1) to get the cursor.
- let (page1, cursor_after_first) =
- client.get_developer_balances_cursor(&admin, &None, &1u32, &token);
- assert_eq!(page1.len(), 1);
- let first_addr = page1.get(0).unwrap().address.clone();
-
- // Credit the first developer again — this must NOT shift remaining pages.
- client.receive_payment(&vault, &999i128, &false, &Some(first_addr.clone()), &token);
-
- // Continue pagination from the saved cursor.
- let (page2, _) = client.get_developer_balances_cursor(&admin, &cursor_after_first, &10u32);
- assert_eq!(page2.len(), 2, "two records must remain after the cursor");
-
- // The first developer must not appear again in page2.
- for rec in page2.iter() {
- assert_ne!(
- rec.address, first_addr,
- "already-paged developer must not re-appear"
- );
- }
- }
-
- /// Limit is capped at MAX_DEVELOPER_BALANCES_PAGE_SIZE even if caller
- /// passes a larger value.
- #[test]
- fn test_cursor_limit_is_capped_at_max_page_size() {
- use crate::MAX_DEVELOPER_BALANCES_PAGE_SIZE;
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- client.init(&admin, &vault);
- let token = Address::generate(&env);
-
- // Insert more developers than MAX_DEVELOPER_BALANCES_PAGE_SIZE.
- for _ in 0..(MAX_DEVELOPER_BALANCES_PAGE_SIZE + 10) {
- let dev = Address::generate(&env);
- client.receive_payment(&vault, &1i128, &false, &Some(dev), &token);
- }
-
- // Request more than the cap.
- let (page, _) = client.get_developer_balances_cursor(
- &admin,
- &None,
- &(MAX_DEVELOPER_BALANCES_PAGE_SIZE + 50),
- );
- assert_eq!(
- page.len(),
- MAX_DEVELOPER_BALANCES_PAGE_SIZE,
- "page must be capped at MAX_DEVELOPER_BALANCES_PAGE_SIZE"
- );
- }
-
- /// limit=0 returns an empty page and None immediately.
- #[test]
- fn test_cursor_zero_limit_returns_empty() {
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- client.init(&admin, &vault);
- let token = Address::generate(&env);
-
- let dev = Address::generate(&env);
- client.receive_payment(&vault, &1i128, &false, &Some(dev), &token);
-
- let (page, next) = client.get_developer_balances_cursor(&admin, &None, &0u32, &token);
- assert_eq!(page.len(), 0);
- assert!(next.is_none());
- }
-
- /// Cursor on an empty index returns an empty page and None.
- #[test]
- fn test_cursor_empty_index_returns_empty_page() {
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- client.init(&admin, &vault);
- let token = Address::generate(&env);
-
- let (page, next) = client.get_developer_balances_cursor(&admin, &None, &10u32, &token);
- assert_eq!(page.len(), 0);
- assert!(next.is_none());
- }
-
- /// Non-admin caller is rejected with Unauthorized.
- #[test]
- fn test_cursor_unauthorized_caller_rejected() {
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- client.init(&admin, &vault);
- let token = Address::generate(&env);
-
- let result = client.try_get_developer_balances_cursor(&vault, &None, &10u32, &token);
- assert!(
- is_error(result, SettlementError::Unauthorized),
- "non-admin caller must be rejected with Unauthorized"
- );
- }
-
- /// Sorted order: DeveloperIndex stays sorted; cursor pages come out in the
- /// same deterministic order regardless of credit sequence.
- #[test]
- fn test_cursor_sorted_order_is_deterministic() {
- let env = Env::default();
- env.mock_all_auths();
- let admin = Address::generate(&env);
- let vault = Address::generate(&env);
- let addr = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &addr);
- client.init(&admin, &vault);
- let token = Address::generate(&env);
-
- // Generate addresses in arbitrary order and credit them.
- let mut devs: std::vec::Vec = (0..5).map(|_| Address::generate(&env)).collect();
- for dev in &devs {
- client.receive_payment(&vault, &1i128, &false, &Some(dev.clone()), &token);
- }
-
- // Collect all balances via cursor pagination.
- let mut cursor_pages: std::vec::Vec = std::vec::Vec::new();
- let mut next: Option = None;
- loop {
- let (page, nc) = client.get_developer_balances_cursor(&admin, &next, &2u32, &token);
- for r in page.iter() {
- cursor_pages.push(r.address.clone());
- }
- next = nc;
- if next.is_none() {
- break;
- }
- }
-
- assert_eq!(
- cursor_pages.len(),
- 5,
- "all developers must be returned across pages"
- );
-
- // The cursor pages must be in sorted order (ascending by address).
- devs.sort();
- assert_eq!(
- cursor_pages, devs,
- "cursor pages must iterate in deterministic sorted order"
- );
- }
-}
diff --git a/contracts/settlement/src/test_admin_migration.rs b/contracts/settlement/src/test_admin_migration.rs
index 00e6f355..de9774ef 100644
--- a/contracts/settlement/src/test_admin_migration.rs
+++ b/contracts/settlement/src/test_admin_migration.rs
@@ -1,234 +1,56 @@
extern crate std;
-use crate::{
- AdminMigrationEvent, CalloraSettlement, CalloraSettlementClient, SettlementError, StorageKey,
- DEVELOPER_MIGRATION_TIMELOCK_SECONDS,
-};
-use soroban_sdk::testutils::{Address as _, Events as _, Ledger as _};
-use soroban_sdk::{Address, Env, Error, IntoVal, InvokeError, Symbol};
+use crate::{CalloraSettlement, CalloraSettlementClient, SettlementError, StorageKey, timelock};
+use soroban_sdk::testutils::{Address as _, Ledger as _};
+use soroban_sdk::{token, Address, Env};
-fn setup() -> (Env, Address, Address, Address, Address, Address) {
+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)
+}
+
+#[test]
+fn test_balance_migration_happy_path() {
let env = Env::default();
env.mock_all_auths();
- env.ledger().set_timestamp(1_700_000_000);
+ env.ledger().set_timestamp(1_000_000);
+
let admin = Address::generate(&env);
let vault = Address::generate(&env);
let from = Address::generate(&env);
let to = Address::generate(&env);
- let contract = env.register(CalloraSettlement, ());
- let client = CalloraSettlementClient::new(&env, &contract);
- client.init(&admin, &vault);
- client.receive_payment(&vault, &500, &false, &Some(from.clone()));
- (env, contract, admin, vault, from, to)
-}
+ let addr = env.register(CalloraSettlement, ());
+ let client = CalloraSettlementClient::new(&env, &addr);
-fn is_error, E: Into>(
- result: Result, Result>,
- expected: SettlementError,
-) -> bool {
- match result {
- Err(Ok(error)) => error.into().get_code() == expected as u32,
- _ => false,
- }
-}
+ let (token, _, _) = create_token(&env, &admin);
-#[test]
-fn proposal_stores_balance_snapshot_and_deadline() {
- let (env, contract, admin, _vault, from, to) = setup();
- let client = CalloraSettlementClient::new(&env, &contract);
+ client.init(&admin, &vault);
+ client.set_usdc_token(&admin, &token);
- client.propose_balance_migration(&admin, &from, &to);
+ // Initial balance for source
+ client.receive_payment(&vault, &500i128, &false, &Some(from.clone()), &token);
+ assert_eq!(client.get_developer_balance(&from, &token), 500);
+ // Propose
+ client.propose_balance_migration(&admin, &from, &to);
let pending = client.get_balance_migration(&from).unwrap();
assert_eq!(pending.from, from);
assert_eq!(pending.to, to);
assert_eq!(pending.amount, 500);
- assert_eq!(pending.proposed_at, 1_700_000_000);
- assert_eq!(
- pending.execute_after,
- 1_700_000_000 + DEVELOPER_MIGRATION_TIMELOCK_SECONDS
- );
-}
-
-#[test]
-fn execution_requires_timelock_and_succeeds_at_boundary() {
- let (env, contract, admin, _vault, from, to) = setup();
- let client = CalloraSettlementClient::new(&env, &contract);
- client.propose_balance_migration(&admin, &from, &to);
+ assert_eq!(pending.execute_after, 1_000_000 + timelock::DEVELOPER_MIGRATION_TIMELOCK_SECONDS);
- let early = client.try_execute_balance_migration(&admin, &from);
- assert!(is_error(early, SettlementError::TimelockNotExpired));
- assert_eq!(client.get_developer_balance(&from), 500);
- assert_eq!(client.get_developer_balance(&to), 0);
+ // Advance time
+ env.ledger().set_timestamp(pending.execute_after);
- env.ledger()
- .set_timestamp(1_700_000_000 + DEVELOPER_MIGRATION_TIMELOCK_SECONDS);
+ // Execute
client.execute_balance_migration(&admin, &from);
- assert_eq!(client.get_developer_balance(&from), 0);
- assert_eq!(client.get_developer_balance(&to), 500);
- assert_eq!(client.get_balance_migration(&from), None);
-}
-
-#[test]
-fn execution_adds_to_destination_and_leaves_later_source_credits() {
- let (env, contract, admin, vault, from, to) = setup();
- let client = CalloraSettlementClient::new(&env, &contract);
- client.receive_payment(&vault, &40, &false, &Some(to.clone()));
- client.propose_balance_migration(&admin, &from, &to);
- client.receive_payment(&vault, &25, &false, &Some(from.clone()));
- env.ledger()
- .set_timestamp(1_700_000_000 + DEVELOPER_MIGRATION_TIMELOCK_SECONDS);
-
- client.execute_balance_migration(&admin, &from);
-
- assert_eq!(client.get_developer_balance(&from), 25);
- assert_eq!(client.get_developer_balance(&to), 540);
-}
-
-#[test]
-fn execute_emits_admin_migration_event_and_cannot_replay() {
- let (env, contract, admin, _vault, from, to) = setup();
- let client = CalloraSettlementClient::new(&env, &contract);
- client.propose_balance_migration(&admin, &from, &to);
- let executed_at = 1_700_000_000 + DEVELOPER_MIGRATION_TIMELOCK_SECONDS + 1;
- env.ledger().set_timestamp(executed_at);
-
- client.execute_balance_migration(&admin, &from);
-
- let events = env.events().all();
- let event = events
- .iter()
- .find(|event| {
- let topic: Symbol = event.1.get(0).unwrap().into_val(&env);
- topic == Symbol::new(&env, "admin_migration")
- })
- .expect("admin_migration event");
- let data: AdminMigrationEvent = event.2.into_val(&env);
- assert_eq!(data.from, from);
- assert_eq!(data.to, to);
- assert_eq!(data.amount, 500);
- assert_eq!(data.executed_at, executed_at);
-
- let replay = client.try_execute_balance_migration(&admin, &from);
- assert!(is_error(replay, SettlementError::MigrationNotFound));
-}
-
-#[test]
-fn both_state_changes_require_current_admin_auth() {
- let (env, contract, admin, _vault, from, to) = setup();
- let client = CalloraSettlementClient::new(&env, &contract);
- env.set_auths(&[]);
- assert!(client
- .try_propose_balance_migration(&admin, &from, &to)
- .is_err());
-
- env.mock_all_auths();
- client.propose_balance_migration(&admin, &from, &to);
- env.ledger()
- .set_timestamp(1_700_000_000 + DEVELOPER_MIGRATION_TIMELOCK_SECONDS);
- env.set_auths(&[]);
- assert!(client.try_execute_balance_migration(&admin, &from).is_err());
-}
-
-#[test]
-fn unauthorized_address_is_rejected_even_when_it_authorizes() {
- let (env, contract, _admin, _vault, from, to) = setup();
- let client = CalloraSettlementClient::new(&env, &contract);
- let outsider = Address::generate(&env);
-
- let result = client.try_propose_balance_migration(&outsider, &from, &to);
- assert!(is_error(result, SettlementError::Unauthorized));
-}
-
-#[test]
-fn invalid_proposals_are_rejected() {
- let (env, contract, admin, _vault, from, _to) = setup();
- let client = CalloraSettlementClient::new(&env, &contract);
- assert!(is_error(
- client.try_propose_balance_migration(&admin, &from, &from),
- SettlementError::MigrationSameAddress
- ));
- assert!(is_error(
- client.try_propose_balance_migration(&admin, &from, &contract),
- SettlementError::InvalidMigrationTarget
- ));
- let empty = Address::generate(&env);
- let target = Address::generate(&env);
- assert!(is_error(
- client.try_propose_balance_migration(&admin, &empty, &target),
- SettlementError::NoDeveloperBalance
- ));
-}
-
-#[test]
-fn reproposal_replaces_target_and_restarts_timelock() {
- let (env, contract, admin, _vault, from, first_to) = setup();
- let client = CalloraSettlementClient::new(&env, &contract);
- let second_to = Address::generate(&env);
- client.propose_balance_migration(&admin, &from, &first_to);
- env.ledger().set_timestamp(1_700_000_100);
-
- client.propose_balance_migration(&admin, &from, &second_to);
-
- let pending = client.get_balance_migration(&from).unwrap();
- assert_eq!(pending.to, second_to);
- assert_eq!(
- pending.execute_after,
- 1_700_000_100 + DEVELOPER_MIGRATION_TIMELOCK_SECONDS
- );
-}
-
-#[test]
-fn proposal_rejects_timestamp_overflow() {
- let (env, contract, admin, _vault, from, to) = setup();
- let client = CalloraSettlementClient::new(&env, &contract);
- env.ledger().set_timestamp(u64::MAX);
-
- let result = client.try_propose_balance_migration(&admin, &from, &to);
-
- assert!(is_error(result, SettlementError::TimelockOverflow));
- assert_eq!(client.get_balance_migration(&from), None);
-}
-
-#[test]
-fn execution_rejects_a_spent_snapshot_without_partial_writes() {
- let (env, contract, admin, _vault, from, to) = setup();
- let client = CalloraSettlementClient::new(&env, &contract);
- client.propose_balance_migration(&admin, &from, &to);
- env.as_contract(&contract, || {
- env.storage()
- .persistent()
- .set(&StorageKey::DeveloperBalance(from.clone()), &499_i128);
- });
- env.ledger()
- .set_timestamp(1_700_000_000 + DEVELOPER_MIGRATION_TIMELOCK_SECONDS);
-
- let result = client.try_execute_balance_migration(&admin, &from);
-
- assert!(is_error(result, SettlementError::MigrationBalanceChanged));
- assert_eq!(client.get_developer_balance(&from), 499);
- assert_eq!(client.get_developer_balance(&to), 0);
- assert!(client.get_balance_migration(&from).is_some());
-}
-
-#[test]
-fn destination_overflow_reverts_all_migration_state() {
- let (env, contract, admin, _vault, from, to) = setup();
- let client = CalloraSettlementClient::new(&env, &contract);
- client.propose_balance_migration(&admin, &from, &to);
- env.as_contract(&contract, || {
- env.storage()
- .persistent()
- .set(&StorageKey::DeveloperBalance(to.clone()), &i128::MAX);
- });
- env.ledger()
- .set_timestamp(1_700_000_000 + DEVELOPER_MIGRATION_TIMELOCK_SECONDS);
-
- let result = client.try_execute_balance_migration(&admin, &from);
-
- assert!(is_error(result, SettlementError::DeveloperOverflow));
- assert_eq!(client.get_developer_balance(&from), 500);
- assert_eq!(client.get_developer_balance(&to), i128::MAX);
- assert!(client.get_balance_migration(&from).is_some());
+ assert_eq!(client.get_developer_balance(&from, &token), 0);
+ assert_eq!(client.get_developer_balance(&to, &token), 500);
}
diff --git a/contracts/settlement/src/test_error_codes.rs b/contracts/settlement/src/test_error_codes.rs
index 9b745fdc..f16bd8e3 100644
--- a/contracts/settlement/src/test_error_codes.rs
+++ b/contracts/settlement/src/test_error_codes.rs
@@ -28,7 +28,9 @@ fn settlement_error_codes_are_stable_and_unique() {
(20, SettlementError::MigrationNotFound),
(21, SettlementError::TimelockNotExpired),
(22, SettlementError::MigrationBalanceChanged),
- (23, SettlementError::OverDraft),
+ (23, SettlementError::MinimumBalanceRequired),
+ (24, SettlementError::InvalidClaimWindow),
+ (25, SettlementError::ClaimWindowClosed),
];
let mut seen = BTreeSet::new();
@@ -40,7 +42,7 @@ fn settlement_error_codes_are_stable_and_unique() {
);
}
- assert_eq!(seen.len(), 23);
+ assert_eq!(seen.len(), 25);
}
#[test]
diff --git a/contracts/settlement/src/test_invariant.rs b/contracts/settlement/src/test_invariant.rs
index 28588282..411dc7a4 100644
--- a/contracts/settlement/src/test_invariant.rs
+++ b/contracts/settlement/src/test_invariant.rs
@@ -219,7 +219,7 @@ fn setup_env() -> (
token::StellarAssetClient::new(env, &usdc_addr);
(
- (*env).clone(),
+ env,
contract,
client,
admin,
diff --git a/contracts/settlement/src/test_multi_asset.rs b/contracts/settlement/src/test_multi_asset.rs
index 813dc389..07607f2b 100644
--- a/contracts/settlement/src/test_multi_asset.rs
+++ b/contracts/settlement/src/test_multi_asset.rs
@@ -2,7 +2,7 @@ extern crate std;
use crate::{CalloraSettlement, CalloraSettlementClient, SettlementError, StorageKey};
use soroban_sdk::testutils::{Address as _, Ledger as _};
-use soroban_sdk::{token, Address, Env, Symbol};
+use soroban_sdk::{token, Address, Env};
fn create_token<'a>(
env: &'a Env,
@@ -184,6 +184,11 @@ fn test_migrate_developer_balance() {
env.storage()
.persistent()
.extend_ttl(&legacy_key, 50000, 50000);
+
+ // Also need to add to index for migrate_v1_to_v2 to find it
+ let mut idx: soroban_sdk::Vec = env.storage().instance().get(&StorageKey::DeveloperIndex).unwrap_or_else(|| soroban_sdk::Vec::new(&env));
+ idx.push_back(developer.clone());
+ env.storage().instance().set(&StorageKey::DeveloperIndex, &idx);
});
// Before migration, new per-token read returns 0
@@ -194,7 +199,7 @@ fn test_migrate_developer_balance() {
);
// Run migration
- let result = client.try_migrate_developer_balance(&admin, &developer);
+ let result = client.try_migrate_v1_to_v2(&admin);
assert!(result.is_ok(), "migration should succeed");
// After migration, new per-token read returns the migrated value
@@ -239,14 +244,18 @@ fn test_migrate_developer_balance_idempotent() {
env.storage()
.persistent()
.extend_ttl(&StorageKey::DeveloperBalanceV1(developer.clone()), 50000, 50000);
+
+ let mut idx: soroban_sdk::Vec = env.storage().instance().get(&StorageKey::DeveloperIndex).unwrap_or_else(|| soroban_sdk::Vec::new(&env));
+ idx.push_back(developer.clone());
+ env.storage().instance().set(&StorageKey::DeveloperIndex, &idx);
});
// First migration
- assert!(client.try_migrate_developer_balance(&admin, &developer).is_ok());
+ assert!(client.try_migrate_v1_to_v2(&admin).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!(client.try_migrate_v1_to_v2(&admin).is_ok());
assert_eq!(client.get_developer_balance(&developer, &usdc), 555i128);
}
@@ -270,7 +279,7 @@ fn test_migrate_developer_balance_unauthorized() {
// Non-admin tries to migrate
let attacker = Address::generate(&env);
- let result = client.try_migrate_developer_balance(&attacker, &developer);
+ let result = client.try_migrate_v1_to_v2(&attacker);
assert!(result.is_err());
}
@@ -290,7 +299,7 @@ fn test_migrate_developer_balance_no_usdc() {
client.init(&admin, &vault);
// Do NOT set USDC token
- let result = client.try_migrate_developer_balance(&admin, &developer);
+ let result = client.try_migrate_v1_to_v2(&admin);
assert!(result.is_err());
}
diff --git a/contracts/settlement/src/test_views.rs b/contracts/settlement/src/test_views.rs
index abe46cfc..05991aff 100644
--- a/contracts/settlement/src/test_views.rs
+++ b/contracts/settlement/src/test_views.rs
@@ -109,6 +109,7 @@ fn test_pagination_fewer_than_limit() {
env.mock_all_auths();
let admin = Address::generate(&env);
let vault = Address::generate(&env);
+ let token = Address::generate(&env);
let addr = env.register(CalloraSettlement, ());
let client = CalloraSettlementClient::new(&env, &addr);
client.init(&admin, &vault);
@@ -116,11 +117,11 @@ fn test_pagination_fewer_than_limit() {
// 5 developers
for _ in 0..5 {
let dev = Address::generate(&env);
- client.receive_payment(&admin, &1000i128, &false, &Some(dev));
+ client.receive_payment(&admin, &1000i128, &false, &Some(dev), &token);
}
// limit 10
- let (page, next_cursor) = client.get_developer_balances_cursor(&admin, &None, &10u32);
+ let (page, next_cursor) = client.get_developer_balances_cursor(&admin, &None, &10u32, &token);
assert_eq!(page.len(), 5);
assert!(next_cursor.is_none());
}
@@ -131,6 +132,7 @@ fn test_pagination_exactly_limit() {
env.mock_all_auths();
let admin = Address::generate(&env);
let vault = Address::generate(&env);
+ let token = Address::generate(&env);
let addr = env.register(CalloraSettlement, ());
let client = CalloraSettlementClient::new(&env, &addr);
client.init(&admin, &vault);
@@ -139,17 +141,17 @@ fn test_pagination_exactly_limit() {
let mut devs = soroban_sdk::Vec::new(&env);
for _ in 0..10 {
let dev = Address::generate(&env);
- client.receive_payment(&admin, &1000i128, &false, &Some(dev.clone()));
+ client.receive_payment(&admin, &1000i128, &false, &Some(dev.clone()), &token);
devs.push_back(dev);
}
// limit 10
- let (page, next_cursor) = client.get_developer_balances_cursor(&admin, &None, &10u32);
+ let (page, next_cursor) = client.get_developer_balances_cursor(&admin, &None, &10u32, &token);
assert_eq!(page.len(), 10);
assert!(next_cursor.is_some());
// Page 2 using next_cursor
- let (page2, next_cursor2) = client.get_developer_balances_cursor(&admin, &next_cursor, &10u32);
+ let (page2, next_cursor2) = client.get_developer_balances_cursor(&admin, &next_cursor, &10u32, &token);
assert_eq!(page2.len(), 0);
assert!(next_cursor2.is_none());
}
@@ -160,6 +162,7 @@ fn test_pagination_more_than_limit() {
env.mock_all_auths();
let admin = Address::generate(&env);
let vault = Address::generate(&env);
+ let token = Address::generate(&env);
let addr = env.register(CalloraSettlement, ());
let client = CalloraSettlementClient::new(&env, &addr);
client.init(&admin, &vault);
@@ -167,16 +170,16 @@ fn test_pagination_more_than_limit() {
// 15 developers
for _ in 0..15 {
let dev = Address::generate(&env);
- client.receive_payment(&admin, &1000i128, &false, &Some(dev));
+ client.receive_payment(&admin, &1000i128, &false, &Some(dev), &token);
}
// Page 1: limit 10
- let (page1, cursor1) = client.get_developer_balances_cursor(&admin, &None, &10u32);
+ let (page1, cursor1) = client.get_developer_balances_cursor(&admin, &None, &10u32, &token);
assert_eq!(page1.len(), 10);
assert!(cursor1.is_some());
// Page 2: limit 10
- let (page2, cursor2) = client.get_developer_balances_cursor(&admin, &cursor1, &10u32);
+ let (page2, cursor2) = client.get_developer_balances_cursor(&admin, &cursor1, &10u32, &token);
assert_eq!(page2.len(), 5);
assert!(cursor2.is_none());
}
@@ -187,24 +190,25 @@ fn test_pagination_stable_ordering() {
env.mock_all_auths();
let admin = Address::generate(&env);
let vault = Address::generate(&env);
+ let token = Address::generate(&env);
let addr = env.register(CalloraSettlement, ());
let client = CalloraSettlementClient::new(&env, &addr);
client.init(&admin, &vault);
for _ in 0..8 {
let dev = Address::generate(&env);
- client.receive_payment(&admin, &1000i128, &false, &Some(dev));
+ client.receive_payment(&admin, &1000i128, &false, &Some(dev), &token);
}
- let (p1_run1, cursor1_run1) = client.get_developer_balances_cursor(&admin, &None, &5u32);
- let (p1_run2, cursor1_run2) = client.get_developer_balances_cursor(&admin, &None, &5u32);
+ let (p1_run1, cursor1_run1) = client.get_developer_balances_cursor(&admin, &None, &5u32, &token);
+ let (p1_run2, cursor1_run2) = client.get_developer_balances_cursor(&admin, &None, &5u32, &token);
assert_eq!(p1_run1.len(), 5);
assert_eq!(p1_run1, p1_run2);
assert_eq!(cursor1_run1, cursor1_run2);
- let (p2_run1, cursor2_run1) = client.get_developer_balances_cursor(&admin, &cursor1_run1, &5u32);
- let (p2_run2, cursor2_run2) = client.get_developer_balances_cursor(&admin, &cursor1_run2, &5u32);
+ let (p2_run1, cursor2_run1) = client.get_developer_balances_cursor(&admin, &cursor1_run1, &5u32, &token);
+ let (p2_run2, cursor2_run2) = client.get_developer_balances_cursor(&admin, &cursor1_run2, &5u32, &token);
assert_eq!(p2_run1.len(), 3);
assert_eq!(p2_run1, p2_run2);
@@ -217,11 +221,12 @@ fn test_pagination_empty() {
env.mock_all_auths();
let admin = Address::generate(&env);
let vault = Address::generate(&env);
+ let token = Address::generate(&env);
let addr = env.register(CalloraSettlement, ());
let client = CalloraSettlementClient::new(&env, &addr);
client.init(&admin, &vault);
- let (page, next_cursor) = client.get_developer_balances_cursor(&admin, &None, &10u32);
+ let (page, next_cursor) = client.get_developer_balances_cursor(&admin, &None, &10u32, &token);
assert_eq!(page.len(), 0);
assert!(next_cursor.is_none());
}
@@ -232,18 +237,18 @@ fn test_pagination_invalid_cursor() {
env.mock_all_auths();
let admin = Address::generate(&env);
let vault = Address::generate(&env);
+ let token = Address::generate(&env);
let addr = env.register(CalloraSettlement, ());
let client = CalloraSettlementClient::new(&env, &addr);
client.init(&admin, &vault);
for _ in 0..5 {
let dev = Address::generate(&env);
- client.receive_payment(&admin, &1000i128, &false, &Some(dev));
+ client.receive_payment(&admin, &1000i128, &false, &Some(dev), &token);
}
let invalid_cursor = Some(Address::generate(&env));
- let (page, next_cursor) = client.get_developer_balances_cursor(&admin, &invalid_cursor, &10u32);
+ let (page, next_cursor) = client.get_developer_balances_cursor(&admin, &invalid_cursor, &10u32, &token);
assert_eq!(page.len(), 0);
assert!(next_cursor.is_none());
}
-
diff --git a/contracts/settlement/src/types.rs b/contracts/settlement/src/types.rs
index d5fc1c10..3b33a8f2 100644
--- a/contracts/settlement/src/types.rs
+++ b/contracts/settlement/src/types.rs
@@ -43,6 +43,31 @@ pub enum StorageKey {
/// Absent → V1 (pre-migration, no version tracking).
/// Value 2 → V2 (single-token → per-token migration complete).
StorageVersion,
+ DeveloperClaimWindow(Address),
+}
+
+/// Timestamp range during which a developer may claim accrued balance.
+///
+/// `start_ts` and `end_ts` are ledger timestamps in seconds. The window is
+/// inclusive on both ends: a withdrawal is allowed when
+/// `start_ts <= env.ledger().timestamp() <= end_ts`.
+#[contracttype]
+#[derive(Clone, Debug, PartialEq)]
+pub struct DeveloperClaimWindow {
+ pub start_ts: u64,
+ pub end_ts: u64,
+}
+
+/// Return the remaining TTL for each storage key category.
+#[contracttype]
+#[derive(Clone, Debug, PartialEq)]
+pub struct StorageEntryTtl {
+ pub category: soroban_sdk::String,
+ pub key_desc: soroban_sdk::String,
+ pub storage_type: soroban_sdk::String,
+ pub ttl: u32,
+ pub threshold: u32,
+ pub bump_amount: u32,
}
/// Severity levels for admin broadcast messages.
@@ -154,12 +179,23 @@ pub struct DailyWithdrawCapChanged {
pub new_cap: i128,
}
+/// Emitted when the admin sets or clears a developer claim window.
+#[contracttype]
+#[derive(Clone, Debug, PartialEq)]
+pub struct DeveloperClaimWindowChanged {
+ pub developer: Address,
+ pub start_ts: u64,
+ pub end_ts: u64,
+ pub enabled: bool,
+}
+
/// Emitted when an admin force-credits a developer balance (escape hatch).
#[contracttype]
#[derive(Clone, Debug, PartialEq)]
pub struct DeveloperForceCreditedEvent {
pub developer: Address,
pub amount: i128,
+ pub token: Address,
pub reason: Symbol,
pub new_balance: i128,
}