From d721e370a75ddc66217d09a5de919d1e2acdb0a0 Mon Sep 17 00:00:00 2001 From: gloskull Date: Wed, 29 Jul 2026 18:42:45 +0100 Subject: [PATCH] feat: simulate claim view --- contracts/settlement/API.md | 15 +++++ contracts/settlement/src/lib.rs | 103 +++++++++++++++++++++++++++++++ contracts/settlement/src/test.rs | 72 +++++++++++++++++++++ 3 files changed, 190 insertions(+) create mode 100644 contracts/settlement/API.md diff --git a/contracts/settlement/API.md b/contracts/settlement/API.md new file mode 100644 index 00000000..cebe4e2f --- /dev/null +++ b/contracts/settlement/API.md @@ -0,0 +1,15 @@ +# Settlement API Notes + +## `simulate_claim(developer, amount, to)` + +`simulate_claim` is a read-only view that previews the result of a developer +claim through `withdraw_developer_balance` without side effects. It returns a +`ClaimSimulation` record containing the simulated recipient, configured USDC +token, current developer balance, remaining balance, contract token balance, +daily cap, current same-day withdrawal amount, and same-day withdrawal amount +after the simulated claim. + +The view performs the same claim validations for positive amount, claim window, +developer balance, daily withdrawal cap, configured USDC token, and settlement +contract token liquidity. It does not require developer authorization, transfer +tokens, mutate storage, extend TTLs, or emit events. diff --git a/contracts/settlement/src/lib.rs b/contracts/settlement/src/lib.rs index 8b39bfc5..55696c53 100644 --- a/contracts/settlement/src/lib.rs +++ b/contracts/settlement/src/lib.rs @@ -122,6 +122,26 @@ pub struct DeveloperClaimWindow { pub end_ts: u64, } +/// Read-only preview of a developer claim/withdrawal. +/// +/// Returned by `simulate_claim` after running the same validation checks as +/// `withdraw_developer_balance`, without requiring auth, transferring tokens, +/// writing storage, or emitting events. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct ClaimSimulation { + pub developer: Address, + pub amount: i128, + pub recipient: Address, + pub token: Address, + pub current_balance: i128, + pub remaining_balance: i128, + pub contract_balance: i128, + pub daily_withdraw_cap: i128, + pub withdrawn_today: i128, + pub withdrawn_today_after: i128, +} + /// Payment received event #[contracttype] #[derive(Clone, Debug, PartialEq)] @@ -680,6 +700,89 @@ impl CalloraSettlement { Ok(()) } + /// Simulate a developer claim without side effects. + /// + /// This read-only view previews the outcome of `withdraw_developer_balance` + /// for the configured USDC token. It intentionally does not require + /// developer authorization and does not transfer tokens, mutate balances, + /// update daily withdrawal counters, extend TTLs, or emit events. + /// + /// The view returns the same typed errors as a real claim for amount, claim + /// window, developer balance, daily cap, USDC configuration, and contract + /// token-liquidity failures. If `to` is `None`, the simulated recipient is + /// the developer address. + pub fn simulate_claim( + env: Env, + developer: Address, + amount: i128, + to: Option
, + ) -> Result { + if amount <= 0 { + return Err(SettlementError::AmountNotPositive); + } + + let recipient = to.unwrap_or_else(|| developer.clone()); + let contract_address = env.current_contract_address(); + if recipient == contract_address { + panic!("invalid recipient: cannot withdraw to contract itself"); + } + + Self::require_claim_window_open(&env, &developer)?; + + let current_balance: i128 = env + .storage() + .persistent() + .get(&StorageKey::DeveloperBalance(developer.clone())) + .unwrap_or(0); + if amount > current_balance { + return Err(SettlementError::InsufficientDeveloperBalance); + } + + let today = env.ledger().timestamp() / 86400; + let cap: i128 = env + .storage() + .persistent() + .get(&StorageKey::DailyWithdrawCap(developer.clone())) + .unwrap_or(0); + let daily = env + .storage() + .persistent() + .get::<_, DailyWithdrawState>(&StorageKey::WithdrawalToday(developer.clone())) + .unwrap_or(DailyWithdrawState { + day: today, + amount: 0, + }); + let withdrawn_today = if daily.day == today { daily.amount } else { 0 }; + let withdrawn_today_after = withdrawn_today + .checked_add(amount) + .ok_or(SettlementError::DailyWithdrawCapExceeded)?; + if cap > 0 && withdrawn_today_after > cap { + return Err(SettlementError::DailyWithdrawCapExceeded); + } + + let remaining_balance = current_balance + .checked_sub(amount) + .ok_or(SettlementError::DeveloperBalanceUnderflow)?; + let usdc_address = Self::get_usdc_token(env.clone())?; + let contract_balance = token::Client::new(&env, &usdc_address).balance(&contract_address); + if contract_balance < amount { + return Err(SettlementError::InsufficientContractBalance); + } + + Ok(ClaimSimulation { + developer, + amount, + recipient, + token: usdc_address, + current_balance, + remaining_balance, + contract_balance, + daily_withdraw_cap: cap, + withdrawn_today, + withdrawn_today_after, + }) + } + /// Configure the inclusive claim window for a developer. /// /// A configured window restricts `withdraw_developer_balance` so the diff --git a/contracts/settlement/src/test.rs b/contracts/settlement/src/test.rs index 162d3998..2cb13071 100644 --- a/contracts/settlement/src/test.rs +++ b/contracts/settlement/src/test.rs @@ -277,6 +277,78 @@ mod settlement_tests { assert_eq!(client.get_developer_balance(&stranger, &token), 0i128); } + #[test] + fn test_simulate_claim_returns_preview_without_side_effects() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(86_400); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let developer = Address::generate(&env); + let recipient = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + let (usdc_address, usdc, usdc_admin_client) = create_usdc(&env, &admin); + + client.init(&admin, &vault); + client.set_usdc_token(&admin, &usdc_address); + client.receive_payment(&vault, &500i128, &false, &Some(developer.clone()), &usdc_address); + client.set_daily_withdraw_cap(&admin, &developer, &400i128); + usdc_admin_client.mint(&addr, &500i128); + + let preview = client + .try_simulate_claim(&developer, &250i128, &Some(recipient.clone())) + .unwrap() + .unwrap(); + + assert_eq!(preview.developer, developer); + assert_eq!(preview.amount, 250i128); + assert_eq!(preview.recipient, recipient); + assert_eq!(preview.token, usdc_address); + assert_eq!(preview.current_balance, 500i128); + assert_eq!(preview.remaining_balance, 250i128); + assert_eq!(preview.contract_balance, 500i128); + assert_eq!(preview.daily_withdraw_cap, 400i128); + assert_eq!(preview.withdrawn_today, 0i128); + assert_eq!(preview.withdrawn_today_after, 250i128); + + assert_eq!(client.get_developer_balance(&developer, &usdc_address), 500i128); + assert_eq!(client.get_withdrawal_today(&developer), 0i128); + assert_eq!(usdc.balance(&addr), 500i128); + assert_eq!(usdc.balance(&recipient), 0i128); + } + + #[test] + fn test_simulate_claim_reuses_claim_validation_errors() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(50); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + let developer = Address::generate(&env); + let addr = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &addr); + let (usdc_address, _, usdc_admin_client) = create_usdc(&env, &admin); + + client.init(&admin, &vault); + client.set_usdc_token(&admin, &usdc_address); + client.receive_payment(&vault, &100i128, &false, &Some(developer.clone()), &usdc_address); + usdc_admin_client.mint(&addr, &100i128); + + let zero = client.try_simulate_claim(&developer, &0i128, &None); + assert!(is_error(zero, SettlementError::AmountNotPositive)); + + let overdraw = client.try_simulate_claim(&developer, &101i128, &None); + assert!(is_error(overdraw, SettlementError::InsufficientDeveloperBalance)); + + client + .try_set_developer_claim_window(&admin, &developer, &100u64, &200u64) + .unwrap() + .unwrap(); + let closed = client.try_simulate_claim(&developer, &100i128, &None); + assert!(is_error(closed, SettlementError::ClaimWindowClosed)); + } + #[test] fn test_withdraw_developer_balance_succeeds_exact_balance() { let env = Env::default();