From a3d89cb8ae33a7fa242e0a6f5bd1f5681f765754 Mon Sep 17 00:00:00 2001 From: blackpanda Date: Thu, 28 May 2026 16:45:22 +0100 Subject: [PATCH] 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));