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();