From a3d89cb8ae33a7fa242e0a6f5bd1f5681f765754 Mon Sep 17 00:00:00 2001 From: blackpanda Date: Thu, 28 May 2026 16:45:22 +0100 Subject: [PATCH 1/2] feat: allow developers to withdraw credited settlement balances --- SETTLEMENT_IMPLEMENTATION.md | 30 +++++- contracts/settlement/src/lib.rs | 159 ++++++++++++++++++++++++++----- contracts/settlement/src/test.rs | 128 +++++++++++++++++++++++-- 3 files changed, 283 insertions(+), 34 deletions(-) diff --git a/SETTLEMENT_IMPLEMENTATION.md b/SETTLEMENT_IMPLEMENTATION.md index a131eef..fbf308b 100644 --- a/SETTLEMENT_IMPLEMENTATION.md +++ b/SETTLEMENT_IMPLEMENTATION.md @@ -139,7 +139,13 @@ pub struct BalanceCreditedEvent { - Creates empty developer balances and global pool - Panic: "settlement contract already initialized" -2. **`receive_payment(env, caller, amount, to_pool, developer)`** +4. **`set_usdc_token(env, caller, usdc_address)`** + - Configures the USDC token contract address for withdrawals + - Authorization: Current admin only + - Validation: Token address cannot be the contract itself + - Panic: "unauthorized: caller is not admin" or "invalid config: usdc_token cannot be the contract itself" + +5. **`receive_payment(env, caller, amount, to_pool, developer)`** - **Access Control**: Only vault or admin can call - **Validation**: Amount must be positive - **Pool Credit**: If `to_pool=true`, credits global pool @@ -148,6 +154,14 @@ pub struct BalanceCreditedEvent { - `PaymentReceivedEvent` for all payments - `BalanceCreditedEvent` for developer credits +6. **`withdraw_developer_balance(env, developer, amount)`** + - **Access Control**: Only the developer may call + - **Validation**: Amount must be positive and cannot exceed tracked balance + - **Token Flow**: Transfers USDC from the settlement contract to the developer + - **State Update**: Deducts the withdrawn amount from the tracked balance using checked arithmetic + - **Events**: + - `DeveloperWithdrawEvent` after transfer succeeds + 3. **Query Functions** - `get_admin()`, `get_vault()`, `get_global_pool()` - `get_developer_balance(developer)` @@ -258,6 +272,20 @@ CalloraSettlement::receive_payment( ); ``` +### Developer Withdrawal + +```rust +// Configure USDC if not already configured by admin +CalloraSettlement::set_usdc_token(env, admin_address, usdc_contract_address); + +// Developer withdraws their available tracked balance +CalloraSettlement::withdraw_developer_balance( + env, + developer_address, + withdrawal_amount, +); +``` + ## Gas Optimization ### Efficient Operations diff --git a/contracts/settlement/src/lib.rs b/contracts/settlement/src/lib.rs index e18826a..a344707 100644 --- a/contracts/settlement/src/lib.rs +++ b/contracts/settlement/src/lib.rs @@ -1,6 +1,6 @@ #![no_std] -use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, Symbol, Vec}; +use soroban_sdk::{contract, contractimpl, contracttype, contracterror, token, Address, Env, Symbol, Vec}; /// Persistent storage keys for settlement contract #[contracttype] @@ -12,6 +12,7 @@ pub enum StorageKey { DeveloperIndex, DeveloperBalance(Address), GlobalPool, + Usdc, } /// Developer balance record in settlement contract @@ -56,6 +57,26 @@ pub struct BalanceCreditedEvent { pub new_balance: i128, } +/// Emitted when a developer withdraws tracked USDC from settlement. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct DeveloperWithdrawEvent { + pub developer: Address, + pub amount: i128, + pub remaining_balance: i128, +} + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum SettlementError { + ContractNotInitialized = 1, + UsdcTokenNotConfigured = 2, + AmountNotPositive = 3, + InsufficientDeveloperBalance = 4, + DeveloperBalanceUnderflow = 5, + InsufficientContractBalance = 6, +} + /// Emitted when the registered vault address is changed via `set_vault()`. #[contracttype] #[derive(Clone, Debug, PartialEq)] @@ -64,14 +85,7 @@ pub struct VaultChangedEvent { pub new_vault: Address, } -/// Storage key for the registered vault address. -const VAULT_KEY: &str = "vault"; -/// Storage key for the admin address. -const ADMIN_KEY: &str = "admin"; -const PENDING_ADMIN_KEY: &str = "pending_admin"; -const DEVELOPER_BALANCES_KEY: &str = "developer_balances"; -/// Storage key for the global pool state. -const GLOBAL_POOL_KEY: &str = "global_pool"; +const MAX_BATCH_SIZE: u32 = 100; #[contract] pub struct CalloraSettlement; @@ -109,10 +123,10 @@ impl CalloraSettlement { if vault_address == env.current_contract_address() { panic!("invalid config: vault_address cannot be the contract itself"); } - inst.set(&Symbol::new(&env, ADMIN_KEY), &admin); - inst.set(&Symbol::new(&env, VAULT_KEY), &vault_address); - let empty_balances: Map = Map::new(&env); - inst.set(&Symbol::new(&env, DEVELOPER_BALANCES_KEY), &empty_balances); + inst.set(&StorageKey::Admin, &admin); + inst.set(&StorageKey::Vault, &vault_address); + let empty_index: Vec
= Vec::new(&env); + inst.set(&StorageKey::DeveloperIndex, &empty_index); let global_pool = GlobalPool { total_balance: 0, last_updated: env.ledger().timestamp(), @@ -186,7 +200,7 @@ impl CalloraSettlement { // Read current balance from persistent storage - let current_balance = env + let current_balance: i128 = env .storage() .persistent() .get(&StorageKey::DeveloperBalance(dev_address.clone())) @@ -209,7 +223,7 @@ impl CalloraSettlement { let mut index: Vec
= inst .get(&StorageKey::DeveloperIndex) .unwrap_or_else(|| Vec::new(&env)); - if !index.iter().any(|addr| addr == &dev_address) { + if !index.iter().any(|addr| addr == dev_address) { index.push_back(dev_address.clone()); inst.set(&StorageKey::DeveloperIndex, &index); } @@ -274,28 +288,40 @@ impl CalloraSettlement { } let inst = env.storage().instance(); - let mut balances: Map = inst - .get(&Symbol::new(&env, DEVELOPER_BALANCES_KEY)) - .unwrap_or_else(|| Map::new(&env)); + let mut index: Vec
= inst + .get(&StorageKey::DeveloperIndex) + .unwrap_or_else(|| Vec::new(&env)); for item in items.iter() { let (dev, amount) = item; - let current = balances.get(dev.clone()).unwrap_or(0); - let new_balance = current + let current_balance: i128 = env + .storage() + .persistent() + .get(&StorageKey::DeveloperBalance(dev.clone())) + .unwrap_or(0); + let new_balance = current_balance .checked_add(amount) .unwrap_or_else(|| panic!("developer balance overflow")); - balances.set(dev.clone(), new_balance); + env.storage() + .persistent() + .set(&StorageKey::DeveloperBalance(dev.clone()), &new_balance); + env.storage() + .persistent() + .extend_ttl(&StorageKey::DeveloperBalance(dev.clone()), 50000, 50000); + if !index.iter().any(|addr| addr == dev) { + index.push_back(dev.clone()); + } env.events().publish( (Symbol::new(&env, "balance_credited"), dev.clone()), BalanceCreditedEvent { - developer: dev, - amount, + developer: dev.clone(), + amount: amount, new_balance, }, ); } - inst.set(&Symbol::new(&env, DEVELOPER_BALANCES_KEY), &balances); + inst.set(&StorageKey::DeveloperIndex, &index); } /// Get current admin address @@ -345,6 +371,87 @@ impl CalloraSettlement { .unwrap_or(0) } + /// Configure the USDC token contract address. + /// + /// Only the current admin may set the on-chain USDC token address that this + /// contract will use to execute withdrawals. + pub fn set_usdc_token(env: Env, caller: Address, usdc_address: Address) { + caller.require_auth(); + let current_admin = Self::get_admin(env.clone()); + if caller != current_admin { + panic!("unauthorized: caller is not admin"); + } + if usdc_address == env.current_contract_address() { + panic!("invalid config: usdc_token cannot be the contract itself"); + } + env.storage() + .instance() + .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 the requesting developer. + /// + /// Requires the developer to authorize the request and the requested amount + /// to be positive and covered by the tracked developer balance. + pub fn withdraw_developer_balance( + env: Env, + developer: Address, + amount: i128, + ) -> Result<(), SettlementError> { + developer.require_auth(); + if amount <= 0 { + return Err(SettlementError::AmountNotPositive); + } + + let current_balance: i128 = env + .storage() + .persistent() + .get(&StorageKey::DeveloperBalance(developer.clone())) + .unwrap_or(0); + if amount > current_balance { + return Err(SettlementError::InsufficientDeveloperBalance); + } + + let new_balance = current_balance + .checked_sub(amount) + .ok_or(SettlementError::DeveloperBalanceUnderflow)?; + + let usdc_address = Self::get_usdc_token(env.clone())?; + let usdc = token::Client::new(&env, &usdc_address); + let contract_address = env.current_contract_address(); + + if usdc.balance(&contract_address) < amount { + return Err(SettlementError::InsufficientContractBalance); + } + + usdc.transfer(&contract_address, &developer, &amount); + + env.storage() + .persistent() + .set(&StorageKey::DeveloperBalance(developer.clone()), &new_balance); + env.storage() + .persistent() + .extend_ttl(&StorageKey::DeveloperBalance(developer.clone()), 50000, 50000); + + env.events().publish( + (Symbol::new(&env, "developer_withdraw"), developer.clone()), + DeveloperWithdrawEvent { + developer, + amount, + remaining_balance: new_balance, + }, + ); + + Ok(()) + } + /// Get all developer balances (admin only) /// /// **CRITICAL**: Uses developer index for iteration; order is based on index insertion order. @@ -395,7 +502,7 @@ impl CalloraSettlement { let balance = env .storage() .persistent() - .get(&StorageKey::DeveloperBalance(address)) + .get(&StorageKey::DeveloperBalance(address.clone())) .unwrap_or(0); result.push_back(DeveloperBalance { address: address.clone(), @@ -506,7 +613,7 @@ impl CalloraSettlement { } let inst = env.storage().instance(); let old_vault = Self::get_vault(env.clone()); - inst.set(&Symbol::new(&env, VAULT_KEY), &new_vault); + inst.set(&StorageKey::Vault, &new_vault); env.events().publish( (Symbol::new(&env, "vault_changed"), caller.clone()), diff --git a/contracts/settlement/src/test.rs b/contracts/settlement/src/test.rs index d426bab..0305f0d 100644 --- a/contracts/settlement/src/test.rs +++ b/contracts/settlement/src/test.rs @@ -4,7 +4,7 @@ mod settlement_tests { use crate::{CalloraSettlement, CalloraSettlementClient, StorageKey}; use soroban_sdk::testutils::{Address as _, Ledger as _}; - use soroban_sdk::{Address, Env, Vec}; + use soroban_sdk::{token, Address, Env, Vec}; use std::any::Any; use std::panic::{catch_unwind, AssertUnwindSafe}; @@ -20,6 +20,17 @@ mod settlement_tests { (env, addr, admin, vault, third_party) } + 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) + } + fn panic_message(err: std::boxed::Box) -> std::string::String { if let Some(message) = err.downcast_ref::<&str>() { std::string::String::from(*message) @@ -257,6 +268,111 @@ mod settlement_tests { assert_eq!(client.get_developer_balance(&stranger), 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_admin_client.mint(&addr, &100i128); + + let result = client.try_withdraw_developer_balance(&developer, &100i128); + assert!(result.is_ok()); + assert_eq!(client.get_developer_balance(&developer), 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_admin_client.mint(&addr, &100i128); + + let result = client.try_withdraw_developer_balance(&developer, &101i128); + assert!(result.is_err()); + assert_eq!(client.get_developer_balance(&developer), 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); + + client.init(&admin, &vault); + + let zero_result = client.try_withdraw_developer_balance(&developer, &0i128); + let negative_result = client.try_withdraw_developer_balance(&developer, &-1i128); + + 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_admin_client.mint(&addr, &200i128); + + let result = client.try_withdraw_developer_balance(&developer, &200i128); + 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); + } + #[test] fn test_get_all_developer_balances() { let env = Env::default(); @@ -695,7 +811,7 @@ mod settlement_tests { total_balance: i128::MAX, last_updated: env.ledger().timestamp(), }; - inst.set(&Symbol::new(&env, "global_pool"), &pool); + inst.set(&crate::StorageKey::GlobalPool, &pool); }); client.receive_payment(&vault, &1i128, &true, &None); @@ -714,11 +830,9 @@ mod settlement_tests { client.init(&admin, &vault); env.as_contract(&addr, || { - let inst = env.storage().instance(); - let mut balances: Map = - inst.get(&Symbol::new(&env, "developer_balances")).unwrap(); - balances.set(developer.clone(), i128::MAX); - inst.set(&Symbol::new(&env, "developer_balances"), &balances); + env.storage() + .persistent() + .set(&crate::StorageKey::DeveloperBalance(developer.clone()), &i128::MAX); }); client.receive_payment(&vault, &1i128, &false, &Some(developer)); From 498c325ad0979fb85eb02f6599c25f96b071903c Mon Sep 17 00:00:00 2001 From: blackpanda Date: Fri, 29 May 2026 09:17:17 +0100 Subject: [PATCH 2/2] =?UTF-8?q?=1B[200~task:=20benchmark=20vault=20single?= =?UTF-8?q?=20vs=20batch=20deduct=20costs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- contracts/vault/src/test.rs | 515 +++++++++++++++--------------------- 1 file changed, 210 insertions(+), 305 deletions(-) diff --git a/contracts/vault/src/test.rs b/contracts/vault/src/test.rs index 26b2eec..c3cb2a1 100644 --- a/contracts/vault/src/test.rs +++ b/contracts/vault/src/test.rs @@ -3038,59 +3038,10 @@ fn remove_allowed_depositor_preserves_other_entries() { client.set_allowed_depositor(&owner, &Some(d1.clone())); client.set_allowed_depositor(&owner, &Some(d2.clone())); - client.remove_allowed_depositor(&owner, &d1); - - let list = client.get_allowed_depositors(); - assert_eq!(list.len(), 1); - assert_eq!(list.get(0).unwrap(), d2); - - let events = env.events().all(); - let last = events.last().unwrap(); - let topic0: Symbol = last.1.get(0).unwrap().into_val(&env); - assert_eq!(topic0, Symbol::new(&env, "allowlist_remove")); - let data: Address = last.2.into_val(&env); - assert_eq!(data, d1); -} - -#[test] -fn remove_allowed_depositor_on_absent_address_is_noop() { - let env = Env::default(); - let owner = Address::generate(&env); - let d1 = Address::generate(&env); - let d2 = Address::generate(&env); - let absent = Address::generate(&env); - let (vault_address, client) = create_vault(&env); - let (usdc, _, usdc_admin) = create_usdc(&env, &owner); - - env.mock_all_auths(); - fund_vault(&usdc_admin, &vault_address, 100); - client.init(&owner, &usdc, &Some(100), &None, &None, &None, &None); - - client.set_allowed_depositor(&owner, &Some(d1.clone())); - client.set_allowed_depositor(&owner, &Some(d2.clone())); - client.remove_allowed_depositor(&owner, &absent); + client.set_allowed_depositor(&owner, &None); let list = client.get_allowed_depositors(); - assert_eq!(list.len(), 2); - assert!(list.contains(&d1)); - assert!(list.contains(&d2)); -} - -#[test] -fn non_owner_cannot_remove_allowed_depositor() { - let env = Env::default(); - let owner = Address::generate(&env); - let attacker = Address::generate(&env); - let depositor = Address::generate(&env); - let (vault_address, client) = create_vault(&env); - let (usdc, _, usdc_admin) = create_usdc(&env, &owner); - - env.mock_all_auths(); - fund_vault(&usdc_admin, &vault_address, 100); - client.init(&owner, &usdc, &Some(100), &None, &None, &None, &None); - - let result = client.try_remove_allowed_depositor(&attacker, &depositor); - assert!(result.is_err(), "non-owner must not remove allowed depositor"); + assert_eq!(list.len(), 0); } #[test] @@ -3619,258 +3570,6 @@ fn accept_ownership_without_pending_fails() { client.accept_ownership(); } -// --------------------------------------------------------------------------- -// Cancel ownership transfer tests -// --------------------------------------------------------------------------- - -#[test] -fn cancel_ownership_transfer_clears_pending() { - let env = Env::default(); - let owner = Address::generate(&env); - let new_owner = Address::generate(&env); - let (vault_address, client) = create_vault(&env); - let (usdc, _, usdc_admin) = create_usdc(&env, &owner); - - env.mock_all_auths(); - fund_vault(&usdc_admin, &vault_address, 100); - client.init(&owner, &usdc, &Some(100), &None, &None, &None, &None); - - // Nominate new owner - client.transfer_ownership(&new_owner); - let meta = client.get_meta(); - assert_eq!(meta.owner, owner); // Still old owner - - // Cancel the transfer - client.cancel_ownership_transfer(); - - // Verify pending is cleared - let meta2 = client.get_meta(); - assert_eq!(meta2.owner, owner); // Still old owner - - // Verify that accept_ownership now fails (no pending) - let result = client.try_accept_ownership(); - assert!(result.is_err(), "expected error when accepting after cancel"); -} - -#[test] -fn cancel_ownership_transfer_emits_event() { - let env = Env::default(); - let owner = Address::generate(&env); - let new_owner = Address::generate(&env); - let (vault_address, client) = create_vault(&env); - let (usdc, _, usdc_admin) = create_usdc(&env, &owner); - - env.mock_all_auths(); - fund_vault(&usdc_admin, &vault_address, 100); - client.init(&owner, &usdc, &Some(100), &None, &None, &None, &None); - - // Nominate new owner - client.transfer_ownership(&new_owner); - - // Cancel the transfer - client.cancel_ownership_transfer(); - - // Verify event was emitted - let events = env.events().all(); - let cancel_ev = events - .iter() - .find(|e| { - e.0 == vault_address && !e.1.is_empty() && { - let t: Symbol = e.1.get(0).unwrap().into_val(&env); - t == Symbol::new(&env, "ownership_cancelled") - } - }) - .expect("expected ownership_cancelled event"); - - let current: Address = cancel_ev.1.get(1).unwrap().into_val(&env); - let cancelled: Address = cancel_ev.1.get(2).unwrap().into_val(&env); - assert_eq!(current, owner); - assert_eq!(cancelled, new_owner); -} - -#[test] -#[should_panic(expected = "no ownership transfer pending")] -fn cancel_ownership_transfer_without_pending_fails() { - let env = Env::default(); - let owner = Address::generate(&env); - let (_, client) = create_vault(&env); - let (usdc, _, _) = create_usdc(&env, &owner); - - env.mock_all_auths(); - client.init(&owner, &usdc, &None, &None, &None, &None, &None); - - // Try to cancel without pending transfer - client.cancel_ownership_transfer(); -} - -#[test] -fn cancel_ownership_transfer_unauthorized_fails() { - let env = Env::default(); - let owner = Address::generate(&env); - let new_owner = Address::generate(&env); - let intruder = Address::generate(&env); - let (vault_address, client) = create_vault(&env); - let (usdc, _, usdc_admin) = create_usdc(&env, &owner); - - env.mock_all_auths(); - fund_vault(&usdc_admin, &vault_address, 100); - client.init(&owner, &usdc, &Some(100), &None, &None, &None, &None); - - // Nominate new owner - client.transfer_ownership(&new_owner); - - // Try to cancel as intruder - env.mock_auths(&soroban_sdk::testutils::Auth { - address: &intruder, - ..Default::default() - }); - let result = client.try_cancel_ownership_transfer(); - assert!( - result.is_err(), - "expected error when non-owner calls cancel_ownership_transfer" - ); -} - -// --------------------------------------------------------------------------- -// Cancel admin transfer tests -// --------------------------------------------------------------------------- - -#[test] -fn cancel_admin_transfer_clears_pending() { - let env = Env::default(); - let owner = Address::generate(&env); - let new_admin = Address::generate(&env); - let (vault_address, client) = create_vault(&env); - let (usdc, _, usdc_admin) = create_usdc(&env, &owner); - - env.mock_all_auths(); - fund_vault(&usdc_admin, &vault_address, 100); - client.init(&owner, &usdc, &Some(100), &None, &None, &None, &None); - - // Nominate new admin - client.set_admin(&owner, &new_admin); - assert_eq!(client.get_admin(), owner); // Still old admin - - // Cancel the transfer - client.cancel_admin_transfer(); - - // Verify pending is cleared - assert_eq!(client.get_admin(), owner); // Still old admin - - // Verify that accept_admin now fails (no pending) - let result = client.try_accept_admin(); - assert!(result.is_err(), "expected error when accepting after cancel"); -} - -#[test] -fn cancel_admin_transfer_emits_event() { - let env = Env::default(); - let owner = Address::generate(&env); - let new_admin = Address::generate(&env); - let (vault_address, client) = create_vault(&env); - let (usdc, _, usdc_admin) = create_usdc(&env, &owner); - - env.mock_all_auths(); - fund_vault(&usdc_admin, &vault_address, 100); - client.init(&owner, &usdc, &Some(100), &None, &None, &None, &None); - - // Nominate new admin - client.set_admin(&owner, &new_admin); - - // Cancel the transfer - client.cancel_admin_transfer(); - - // Verify event was emitted - let events = env.events().all(); - let cancel_ev = events - .iter() - .find(|e| { - e.0 == vault_address && !e.1.is_empty() && { - let t: Symbol = e.1.get(0).unwrap().into_val(&env); - t == Symbol::new(&env, "admin_cancelled") - } - }) - .expect("expected admin_cancelled event"); - - let current: Address = cancel_ev.1.get(1).unwrap().into_val(&env); - let cancelled: Address = cancel_ev.1.get(2).unwrap().into_val(&env); - assert_eq!(current, owner); - assert_eq!(cancelled, new_admin); -} - -#[test] -#[should_panic(expected = "no admin transfer pending")] -fn cancel_admin_transfer_without_pending_fails() { - let env = Env::default(); - let owner = Address::generate(&env); - let (_, client) = create_vault(&env); - let (usdc, _, _) = create_usdc(&env, &owner); - - env.mock_all_auths(); - client.init(&owner, &usdc, &None, &None, &None, &None, &None); - - // Try to cancel without pending transfer - client.cancel_admin_transfer(); -} - -#[test] -fn cancel_admin_transfer_unauthorized_fails() { - let env = Env::default(); - let owner = Address::generate(&env); - let new_admin = Address::generate(&env); - let intruder = Address::generate(&env); - let (vault_address, client) = create_vault(&env); - let (usdc, _, usdc_admin) = create_usdc(&env, &owner); - - env.mock_all_auths(); - fund_vault(&usdc_admin, &vault_address, 100); - client.init(&owner, &usdc, &Some(100), &None, &None, &None, &None); - - // Nominate new admin - client.set_admin(&owner, &new_admin); - - // Try to cancel as intruder - env.mock_auths(&soroban_sdk::testutils::Auth { - address: &intruder, - ..Default::default() - }); - let result = client.try_cancel_admin_transfer(); - assert!( - result.is_err(), - "expected error when non-admin calls cancel_admin_transfer" - ); -} - -#[test] -fn cancel_after_nomination_allows_new_nomination() { - let env = Env::default(); - let owner = Address::generate(&env); - let new_owner1 = Address::generate(&env); - let new_owner2 = Address::generate(&env); - let (vault_address, client) = create_vault(&env); - let (usdc, _, usdc_admin) = create_usdc(&env, &owner); - - env.mock_all_auths(); - fund_vault(&usdc_admin, &vault_address, 100); - client.init(&owner, &usdc, &Some(100), &None, &None, &None, &None); - - // Nominate first owner - client.transfer_ownership(&new_owner1); - - // Cancel the transfer - client.cancel_ownership_transfer(); - - // Nominate different owner (should succeed) - client.transfer_ownership(&new_owner2); - - // Accept the new nomination - client.accept_ownership(); - - // Verify new owner is set - let meta = client.get_meta(); - assert_eq!(meta.owner, new_owner2); -} - #[test] #[should_panic(expected = "amount must be positive")] fn withdraw_negative_fails() { @@ -4306,7 +4005,7 @@ mod fuzz { env.mock_all_auths(); let owner = Address::generate(&env); - let _caller = Address::generate(&env); + let caller = Address::generate(&env); let (usdc_addr, _, usdc_admin) = create_usdc(&env, &owner); let (vault_addr, client) = create_vault(&env); @@ -4354,7 +4053,7 @@ mod fuzz { env.mock_all_auths(); let owner = Address::generate(&env); - let _caller = Address::generate(&env); + let caller = Address::generate(&env); let (usdc_addr, _, usdc_admin) = create_usdc(&env, &owner); let (vault_addr, client) = create_vault(&env); let max_d: i128 = 100; @@ -5382,3 +5081,209 @@ fn instance_ttl_extended_on_deduct_and_batch_deduct() { env.ledger().set_sequence_number(seq + INSTANCE_BUMP_THRESHOLD - 1); assert_eq!(client.balance(), 300, "balance readable after ledger advance post-batch_deduct"); } + +// --------------------------------------------------------------------------- +// BUDGET MEASUREMENT TESTS — for benchmarking and cost analysis +// --------------------------------------------------------------------------- + +/// Captures CPU, memory, and ledger read/write metrics from Soroban budget. +#[derive(Clone)] +struct BudgetSnapshot { + cpu_instructions: u64, + memory_bytes: u64, + ledger_read_bytes: u64, + ledger_write_bytes: u64, +} + +impl BudgetSnapshot { + /// Capture the current budget state from the environment. + fn capture(env: &Env) -> Self { + let ce = env.cost_estimate(); + let budget = ce.budget(); + Self { + cpu_instructions: budget.get_cpu_insns_consumed().unwrap_or_default(), + memory_bytes: budget.get_mem_bytes_consumed().unwrap_or_default(), + ledger_read_bytes: ce.resources().read_bytes as u64, + ledger_write_bytes: ce.resources().write_bytes as u64, + } + } + + /// Calculate delta between two snapshots (after - before). + fn delta(&self, before: &BudgetSnapshot) -> BudgetSnapshot { + BudgetSnapshot { + cpu_instructions: self.cpu_instructions.saturating_sub(before.cpu_instructions), + memory_bytes: self.memory_bytes.saturating_sub(before.memory_bytes), + ledger_read_bytes: self.ledger_read_bytes.saturating_sub(before.ledger_read_bytes), + ledger_write_bytes: self.ledger_write_bytes.saturating_sub(before.ledger_write_bytes), + } + } +} + +/// Helper function to set up a fully initialized vault with settlement and sufficient balance. +fn setup_vault_for_deduct(env: &Env, initial_balance: i128) -> (Address, CalloraVaultClient) { + let owner = Address::generate(env); + let (vault_address, client) = create_vault(env); + let (usdc, _, usdc_admin) = create_usdc(env, &owner); + + env.mock_all_auths(); + fund_vault(&usdc_admin, &vault_address, initial_balance); + client.init(&owner, &usdc, &Some(initial_balance), &None, &None, &None, &None); + let settlement = Address::generate(env); + client.set_settlement(&owner, &settlement); + + (owner, client) +} + +/// Benchmark: measure the cost of a single `deduct` operation. +/// +/// Prints CPU instructions, memory, ledger read/write metrics in CSV format for analysis. +#[test] +#[ignore] +fn budget_measure_single_deduct() { + let env = Env::default(); + let (owner, client) = setup_vault_for_deduct(&env, 100_000_000); + + let before = BudgetSnapshot::capture(&env); + client.deduct(&owner, &1_000_000, &None); + let after = BudgetSnapshot::capture(&env); + + let delta = after.delta(&before); + std::println!( + "BUDGET_SINGLE_DEDUCT,cpu_instructions,{},memory_bytes,{},ledger_read_bytes,{},ledger_write_bytes,{}", + delta.cpu_instructions, delta.memory_bytes, delta.ledger_read_bytes, delta.ledger_write_bytes + ); +} + +/// Benchmark: measure the cost of `batch_deduct` with batch size = 1. +/// +/// For comparison: a batch of 1 item should have similar cost to single deduct, +/// with possible overhead from the batch validation loop. +#[test] +#[ignore] +fn budget_measure_batch_deduct_size_1() { + let env = Env::default(); + let (owner, client) = setup_vault_for_deduct(&env, 100_000_000); + + let items = soroban_sdk::vec![ + &env, + DeductItem { + amount: 1_000_000, + request_id: None + } + ]; + + let before = BudgetSnapshot::capture(&env); + client.batch_deduct(&owner, &items); + let after = BudgetSnapshot::capture(&env); + + let delta = after.delta(&before); + std::println!( + "BUDGET_BATCH_DEDUCT_SIZE_1,cpu_instructions,{},memory_bytes,{},ledger_read_bytes,{},ledger_write_bytes,{}", + delta.cpu_instructions, delta.memory_bytes, delta.ledger_read_bytes, delta.ledger_write_bytes + ); +} + +/// Benchmark: measure the cost of `batch_deduct` with batch size = 10. +/// +/// Captures the incremental cost of processing 10 items in a single call. +#[test] +#[ignore] +fn budget_measure_batch_deduct_size_10() { + let env = Env::default(); + let (owner, client) = setup_vault_for_deduct(&env, 100_000_000); + + let mut items = soroban_sdk::Vec::new(&env); + for _ in 0..10 { + items.push_back(DeductItem { + amount: 1_000_000, + request_id: Some(Symbol::new(&env, "req")), + }); + } + + let before = BudgetSnapshot::capture(&env); + client.batch_deduct(&owner, &items); + let after = BudgetSnapshot::capture(&env); + + let delta = after.delta(&before); + std::println!( + "BUDGET_BATCH_DEDUCT_SIZE_10,cpu_instructions,{},memory_bytes,{},ledger_read_bytes,{},ledger_write_bytes,{}", + delta.cpu_instructions, delta.memory_bytes, delta.ledger_read_bytes, delta.ledger_write_bytes + ); +} + +/// Benchmark: measure the cost of `batch_deduct` with batch size = 25. +/// +/// Tests mid-range batching to identify potential scaling inflection points. +#[test] +#[ignore] +fn budget_measure_batch_deduct_size_25() { + let env = Env::default(); + let (owner, client) = setup_vault_for_deduct(&env, 100_000_000); + + let mut items = soroban_sdk::Vec::new(&env); + for _ in 0..25 { + items.push_back(DeductItem { + amount: 500_000, + request_id: Some(Symbol::new(&env, "req")), + }); + } + + let before = BudgetSnapshot::capture(&env); + client.batch_deduct(&owner, &items); + let after = BudgetSnapshot::capture(&env); + + let delta = after.delta(&before); + std::println!( + "BUDGET_BATCH_DEDUCT_SIZE_25,cpu_instructions,{},memory_bytes,{},ledger_read_bytes,{},ledger_write_bytes,{}", + delta.cpu_instructions, delta.memory_bytes, delta.ledger_read_bytes, delta.ledger_write_bytes + ); +} + +/// Benchmark: measure the cost of `batch_deduct` with batch size = 50 (MAX_BATCH_SIZE). +/// +/// Tests the maximum allowed batch size to understand the upper-bound cost. +#[test] +#[ignore] +fn budget_measure_batch_deduct_size_50() { + let env = Env::default(); + let (owner, client) = setup_vault_for_deduct(&env, 100_000_000); + + let mut items = soroban_sdk::Vec::new(&env); + for _ in 0..50 { + items.push_back(DeductItem { + amount: 300_000, + request_id: Some(Symbol::new(&env, "req")), + }); + } + + let before = BudgetSnapshot::capture(&env); + client.batch_deduct(&owner, &items); + let after = BudgetSnapshot::capture(&env); + + let delta = after.delta(&before); + std::println!( + "BUDGET_BATCH_DEDUCT_SIZE_50,cpu_instructions,{},memory_bytes,{},ledger_read_bytes,{},ledger_write_bytes,{}", + delta.cpu_instructions, delta.memory_bytes, delta.ledger_read_bytes, delta.ledger_write_bytes + ); +} + +/// Run all budget benchmarks in sequence. +/// +/// This is a convenience function for easily running the entire benchmark suite. +/// Execute with: `cargo test budget_measure_all -- --ignored --nocapture` +#[test] +#[ignore] +fn budget_measure_all() { + std::println!("\n=== VAULT BUDGET MEASUREMENT SUITE ===\n"); + + // Single deduct baseline + budget_measure_single_deduct(); + + // Batch deduct at various sizes + budget_measure_batch_deduct_size_1(); + budget_measure_batch_deduct_size_10(); + budget_measure_batch_deduct_size_25(); + budget_measure_batch_deduct_size_50(); + + std::println!("\n=== END VAULT BUDGET MEASUREMENTS ===\n"); +}