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 99baeeb..2a00956 100644 --- a/contracts/settlement/src/lib.rs +++ b/contracts/settlement/src/lib.rs @@ -49,6 +49,7 @@ pub enum StorageKey { DeveloperIndex, DeveloperBalance(Address), GlobalPool, + Usdc, } /// Developer balance record in settlement contract @@ -337,8 +338,8 @@ impl CalloraSettlement { env.events().publish( (Symbol::new(&env, "balance_credited"), dev.clone()), BalanceCreditedEvent { - developer: dev, - amount, + developer: dev.clone(), + amount: amount, new_balance, }, ); @@ -392,6 +393,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. diff --git a/contracts/settlement/src/test.rs b/contracts/settlement/src/test.rs index 232afe0..1aef81c 100644 --- a/contracts/settlement/src/test.rs +++ b/contracts/settlement/src/test.rs @@ -251,6 +251,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(); diff --git a/contracts/vault/src/test.rs b/contracts/vault/src/test.rs index 4233a40..f104a92 100644 --- a/contracts/vault/src/test.rs +++ b/contracts/vault/src/test.rs @@ -5892,3 +5892,209 @@ fn upgrade_multiple_times_updates_version() { client.upgrade(&owner, &hash3); assert_eq!(client.version(), Some(hash3)); } + +// --------------------------------------------------------------------------- +// 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"); +}