From cfe8acf3c567da53da17f7bb39a656fb01d60790 Mon Sep 17 00:00:00 2001 From: DevMuhdishaq Date: Tue, 28 Jul 2026 05:46:12 +0100 Subject: [PATCH] feat(payment-escrow): Implement treasury with admin withdrawal This commit introduces a treasury system to the Payment Escrow contract for more robust and auditable fee management. Previously, fees were transferred to the recipient immediately upon escrow completion. This change modifies the logic to accumulate all collected fees into a central treasury balance within the contract. Key changes: - The `release`, `claim`, and `resolve_dispute` functions now add the fee amount to a new treasury storage item instead of transferring it directly. - A new `withdraw_treasury` function has been added, callable only by the contract admin, to transfer accumulated fees to a specified address. - This function includes validation to ensure the withdrawal amount does not exceed the available treasury balance, returning an `InsufficientBalance` error if it does. - A `treasury_w` event is emitted upon successful withdrawal to provide an on-chain audit trail of all treasury movements. - Added a new test module (`treasury.rs`) with tests covering successful withdrawals, unauthorized access attempts, and insufficient balance scenarios. --- contracts/payment_escrow/src/errors.rs | 1 + contracts/payment_escrow/src/lib.rs | 47 ++++++- contracts/payment_escrow/src/treasury.rs | 154 +++++++++++++++++++++++ 3 files changed, 197 insertions(+), 5 deletions(-) create mode 100644 contracts/payment_escrow/src/treasury.rs diff --git a/contracts/payment_escrow/src/errors.rs b/contracts/payment_escrow/src/errors.rs index 09a92e3..5c7c632 100644 --- a/contracts/payment_escrow/src/errors.rs +++ b/contracts/payment_escrow/src/errors.rs @@ -31,4 +31,5 @@ pub enum Error { PaymentTokenNotSet = 12, /// Fee recipient address has not been set. FeeRecipientNotSet = 13, + InsufficientBalance = 14, } \ No newline at end of file diff --git a/contracts/payment_escrow/src/lib.rs b/contracts/payment_escrow/src/lib.rs index e331811..79dcae2 100644 --- a/contracts/payment_escrow/src/lib.rs +++ b/contracts/payment_escrow/src/lib.rs @@ -418,12 +418,12 @@ impl PaymentEscrowContract { let token_client = token::Client::new(&env, &escrow.payment_token); if release_to_beneficiary { - // Transfer the fee, if any + // Add fee to treasury if escrow.fee_amount > 0 { - token_client.transfer( - &env.current_contract_address(), - &escrow.fee_recipient, - &escrow.fee_amount, + let treasury = Self::get_treasury_amount(&env); + env.storage().instance().set( + &DataKey::TreasuryAmount, + &(treasury + escrow.fee_amount), ); } @@ -562,4 +562,41 @@ impl PaymentEscrowContract { pub fn dispute_window(env: Env) -> u64 { Self::get_dispute_window(&env) } + + // ── Treasury ────────────────────────────────────────────────────────────── + + /// Withdraw accumulated fees from the treasury. + pub fn withdraw_treasury( + env: Env, + caller: Address, + recipient: Address, + amount: i128, + ) -> Result<(), Error> { + Self::require_admin(&env, &caller)?; + + let treasury = Self::get_treasury_amount(&env); + if amount > treasury { + return Err(Error::InsufficientBalance); + } + + let payment_token = Self::get_payment_token(&env)?; + let token_client = token::Client::new(&env, &payment_token); + + token_client.transfer(&env.current_contract_address(), &recipient, &amount); + + env.storage() + .instance() + .set(&DataKey::TreasuryAmount, &(treasury - amount)); + + env.events().publish( + (symbol_short!("treasury_w"),), + (recipient, amount, env.ledger().timestamp()), + ); + Ok(()) + } + + /// Return the current treasury balance. + pub fn treasury_balance(env: Env) -> i128 { + Self::get_treasury_amount(&env) + } } \ No newline at end of file diff --git a/contracts/payment_escrow/src/treasury.rs b/contracts/payment_escrow/src/treasury.rs new file mode 100644 index 0000000..d0f67ba --- /dev/null +++ b/contracts/payment_escrow/src/treasury.rs @@ -0,0 +1,154 @@ +"""// contracts/payment_escrow/src/treasury.rs +#![cfg(test)] + +use soroban_sdk::{ + testutils::{Address as _, Events}, + Address, Env, String, +}; + +use crate::{ + test::{ + helpers::{ + create_and_initialize_contract, create_escrow, create_token, get_ledger_timestamp, + }, + setup::Setup, + }, + Error, PaymentEscrowContract, +}; + +#[test] +fn test_withdraw_treasury_unauthorized() { + let env = Env::default(); + env.mock_all_auths(); + + let setup = Setup::new(&env); + let depositor = Address::generate(&env); + let unauthorized_caller = Address::generate(&env); + + create_and_initialize_contract( + &env, + &setup.contract_id, + &setup.admin, + &setup.token.address, + 10, + &setup.fee_recipient, + 100, + ); + + let escrow_id = String::from_str(&env, "escrow-1"); + create_escrow( + &env, + &setup.contract_id, + &setup.token.address, + &depositor, + escrow_id.clone(), + &setup.beneficiary, + 1000, + "Test Escrow", + 0, + ); + + let res = PaymentEscrowContract::new(&env, &setup.contract_id).try_withdraw_treasury( + &unauthorized_caller, + &setup.fee_recipient, + 10, + ); + assert_eq!(res, Err(Ok(Error::Unauthorized))); +} + +#[test] +fn test_withdraw_treasury_insufficient_balance() { + let env = Env::default(); + env.mock_all_auths(); + + let setup = Setup::new(&env); + let depositor = Address::generate(&env); + + create_and_initialize_contract( + &env, + &setup.contract_id, + &setup.admin, + &setup.token.address, + 10, + &setup.fee_recipient, + 100, + ); + + let escrow_id = String::from_str(&env, "escrow-1"); + create_escrow( + &env, + &setup.contract_id, + &setup.token.address, + &depositor, + escrow_id.clone(), + &setup.beneficiary, + 1000, + "Test Escrow", + 0, + ); + + let res = PaymentEscrowContract::new(&env, &setup.contract_id).try_withdraw_treasury( + &setup.admin, + &setup.fee_recipient, + 10, + ); + assert_eq!(res, Err(Ok(Error::InsufficientBalance))); +} + +#[test] +fn test_withdraw_treasury_success() { + let env = Env::default(); + env.mock_all_auths(); + + let setup = Setup::new(&env); + let depositor = Address::generate(&env); + let contract = PaymentEscrowContract::new(&env, &setup.contract_id); + + create_and_initialize_contract( + &env, + &setup.contract_id, + &setup.admin, + &setup.token.address, + 10, + &setup.fee_recipient, + 100, // 1% fee + ); + + // Create and release an escrow to generate fees + let escrow_id = String::from_str(&env, "escrow-1"); + create_escrow( + &env, + &setup.contract_id, + &setup.token.address, + &depositor, + escrow_id.clone(), + &setup.beneficiary, + 1000, + "Test Escrow", + 0, + ); + contract.release(&setup.admin, escrow_id); + + // Withdraw a portion of the treasury + contract.withdraw_treasury(&setup.admin, &setup.fee_recipient, 5); + assert_eq!(setup.token.balance(&setup.fee_recipient), 5); + assert_eq!(setup.token.balance(&setup.contract_id), 995); + + // Withdraw the rest + contract.withdraw_treasury(&setup.admin, &setup.fee_recipient, 5); + assert_eq!(setup.token.balance(&setup.fee_recipient), 10); + assert_eq!(setup.token.balance(&setup.contract_id), 990); + + // Check events + let event = env.events().all().last().unwrap(); + let timestamp = get_ledger_timestamp(&env); + assert_eq!( + event, + ( + setup.contract_id.clone(), + ("treasury_w",), + (setup.fee_recipient.clone(), 5i128, timestamp).into_val(&env) + ) + ); +} +"" \ No newline at end of file