From 379dcf4f461259d2b7dd8a9d3ee7785b1b8f35b8 Mon Sep 17 00:00:00 2001 From: Liam Phung Date: Fri, 16 Oct 2026 14:47:36 +0700 Subject: [PATCH] feat(fee): transfer configured fee token on deposit and withdraw (#1187) --- contracts/fee/src/lib.rs | 160 +++++++++++++++++++++++------ contracts/fee/src/test_ttl_bump.rs | 60 ++++++++--- contracts/fee/tests/auth_snap.rs | 59 ++++++++--- 3 files changed, 219 insertions(+), 60 deletions(-) diff --git a/contracts/fee/src/lib.rs b/contracts/fee/src/lib.rs index 7d877888..b171dd82 100644 --- a/contracts/fee/src/lib.rs +++ b/contracts/fee/src/lib.rs @@ -1,6 +1,6 @@ #![no_std] -use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, Symbol}; +use soroban_sdk::{contract, contractimpl, contracttype, token, Address, Env, Symbol}; pub mod errors; pub use errors::ContractError; @@ -25,6 +25,7 @@ const ZERO_CONTRACT_ADDRESS: &str = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA #[contracttype] pub enum DataKey { Admin, + Token, FeeBps, Accumulated, } @@ -52,14 +53,15 @@ impl FeeContract { .extend_ttl(INSTANCE_BUMP_THRESHOLD, INSTANCE_BUMP_AMOUNT); } - /// Initialise the fee contract with an admin. + /// Initialise the fee contract with an admin and fee token. /// /// # Arguments /// * `admin` - Address that will hold admin privileges. + /// * `token` - Token contract address used for fee deposits and withdrawals. /// /// # Errors /// - `AlreadyInitialized` if the contract is already initialised. - pub fn init(env: Env, admin: Address) -> Result<(), ContractError> { + pub fn init(env: Env, admin: Address, token: Address) -> Result<(), ContractError> { admin.require_auth(); if env.storage().instance().has(&DataKey::Admin) { @@ -67,6 +69,7 @@ impl FeeContract { } env.storage().instance().set(&DataKey::Admin, &admin); + env.storage().instance().set(&DataKey::Token, &token); env.storage().instance().set(&DataKey::FeeBps, &0u32); env.storage().instance().set(&DataKey::Accumulated, &0i128); @@ -104,7 +107,8 @@ impl FeeContract { Ok(()) } - /// Deposit fees into the accumulated balance. + /// Deposit fees into the accumulated balance by transferring `amount` of the + /// configured fee token from `caller` to this contract. /// /// # Arguments /// * `caller` - The address depositing fees. @@ -117,9 +121,11 @@ impl FeeContract { pub fn deposit(env: Env, caller: Address, amount: i128) -> Result<(), ContractError> { caller.require_auth(); - if !env.storage().instance().has(&DataKey::Admin) { - return Err(ContractError::NotInitialized); - } + let token_addr: Address = env + .storage() + .instance() + .get(&DataKey::Token) + .ok_or(ContractError::NotInitialized)?; if amount <= 0 { return Err(ContractError::InvalidAmount); } @@ -133,6 +139,9 @@ impl FeeContract { .checked_add(amount) .ok_or(ContractError::Overflow)?; + let contract_addr = env.current_contract_address(); + token::Client::new(&env, &token_addr).transfer(&caller, &contract_addr, &amount); + env.storage() .instance() .set(&DataKey::Accumulated, &new_accumulated); @@ -159,7 +168,8 @@ impl FeeContract { Ok(()) } - /// Withdraw accumulated fees to a recipient. + /// Withdraw accumulated fees to a recipient by transferring `amount` of the + /// configured fee token from this contract to `recipient`. /// /// # Arguments /// * `admin` - The admin address (must match stored admin). @@ -194,6 +204,12 @@ impl FeeContract { } Self::validate_recipient(&env, &recipient)?; + let token_addr: Address = env + .storage() + .instance() + .get(&DataKey::Token) + .ok_or(ContractError::NotInitialized)?; + let accumulated: i128 = env .storage() .instance() @@ -207,10 +223,18 @@ impl FeeContract { .checked_sub(amount) .ok_or(ContractError::Overflow)?; + let contract_addr = env.current_contract_address(); + let token_client = token::Client::new(&env, &token_addr); + if token_client.balance(&contract_addr) < amount { + return Err(ContractError::InsufficientBalance); + } + env.storage() .instance() .set(&DataKey::Accumulated, &remaining); + token_client.transfer(&contract_addr, &recipient, &amount); + env.events() .publish((Symbol::new(&env, "fee_withdrawn"),), (recipient, amount)); @@ -256,12 +280,34 @@ impl FeeContract { Self::bump_instance_ttl(&env); Ok(admin) } + + /// Return the configured fee token address. + pub fn get_token(env: Env) -> Result { + let token: Address = env + .storage() + .instance() + .get(&DataKey::Token) + .ok_or(ContractError::NotInitialized)?; + Self::bump_instance_ttl(&env); + Ok(token) + } } #[cfg(test)] mod test { use super::*; - use soroban_sdk::{testutils::Address as _, Address, Env}; + use soroban_sdk::{testutils::Address as _, token, Address, Env}; + + fn create_token<'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) + } #[test] fn test_init() { @@ -271,10 +317,12 @@ mod test { let contract_id = env.register_contract(None, FeeContract); let client = FeeContractClient::new(&env, &contract_id); let admin = Address::generate(&env); + let (token_addr, _, _) = create_token(&env, &admin); - client.init(&admin); + client.init(&admin, &token_addr); + assert_eq!(client.get_token(), token_addr); assert_eq!( - client.try_init(&admin).unwrap_err().unwrap(), + client.try_init(&admin, &token_addr).unwrap_err().unwrap(), ContractError::AlreadyInitialized ); } @@ -287,8 +335,9 @@ mod test { let contract_id = env.register_contract(None, FeeContract); let client = FeeContractClient::new(&env, &contract_id); let admin = Address::generate(&env); + let (token_addr, _, _) = create_token(&env, &admin); - client.init(&admin); + client.init(&admin, &token_addr); client.set_fee(&admin, &250); let config = client.get_fee_config(); @@ -303,8 +352,9 @@ mod test { let contract_id = env.register_contract(None, FeeContract); let client = FeeContractClient::new(&env, &contract_id); let admin = Address::generate(&env); + let (token_addr, _, _) = create_token(&env, &admin); - client.init(&admin); + client.init(&admin, &token_addr); assert_eq!( client.try_set_fee(&admin, &10_001).unwrap_err().unwrap(), ContractError::FeeTooHigh @@ -321,13 +371,40 @@ mod test { let admin = Address::generate(&env); let caller = Address::generate(&env); let recipient = Address::generate(&env); + let (token_addr, token_client, token_admin) = create_token(&env, &admin); - client.init(&admin); - client.deposit(&caller, &1000); - assert_eq!(client.get_accumulated(), 1000); + token_admin.mint(&caller, &1_500); + client.init(&admin, &token_addr); + assert_eq!(client.get_token(), token_addr); + + client.deposit(&caller, &1_000); + assert_eq!(client.get_accumulated(), 1_000); + assert_eq!(token_client.balance(&contract_id), 1_000); + assert_eq!(token_client.balance(&caller), 500); + assert!(client.get_accumulated() <= token_client.balance(&contract_id)); client.withdraw(&admin, &recipient, &400); assert_eq!(client.get_accumulated(), 600); + assert_eq!(token_client.balance(&contract_id), 600); + assert_eq!(token_client.balance(&recipient), 400); + assert!(client.get_accumulated() <= token_client.balance(&contract_id)); + } + + #[test] + fn test_deposit_without_token_balance_fails_and_keeps_counter_in_sync() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, FeeContract); + let client = FeeContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let caller = Address::generate(&env); + let (token_addr, token_client, _) = create_token(&env, &admin); + + client.init(&admin, &token_addr); + assert!(client.try_deposit(&caller, &500).is_err()); + assert_eq!(client.get_accumulated(), 0); + assert_eq!(token_client.balance(&contract_id), 0); } #[test] @@ -339,8 +416,9 @@ mod test { let client = FeeContractClient::new(&env, &contract_id); let admin = Address::generate(&env); let caller = Address::generate(&env); + let (token_addr, _, _) = create_token(&env, &admin); - client.init(&admin); + client.init(&admin, &token_addr); assert_eq!( client.try_deposit(&caller, &0).unwrap_err().unwrap(), ContractError::InvalidAmount @@ -361,8 +439,10 @@ mod test { let admin = Address::generate(&env); let caller = Address::generate(&env); let recipient = Address::generate(&env); + let (token_addr, token_client, token_admin) = create_token(&env, &admin); - client.init(&admin); + token_admin.mint(&caller, &100); + client.init(&admin, &token_addr); client.deposit(&caller, &100); assert_eq!( client @@ -371,6 +451,8 @@ mod test { .unwrap(), ContractError::InsufficientBalance ); + assert_eq!(client.get_accumulated(), 100); + assert_eq!(token_client.balance(&contract_id), 100); } #[test] @@ -383,8 +465,9 @@ mod test { let admin = Address::generate(&env); let fake_admin = Address::generate(&env); let recipient = Address::generate(&env); + let (token_addr, _, _) = create_token(&env, &admin); - client.init(&admin); + client.init(&admin, &token_addr); assert_eq!( client.try_set_fee(&fake_admin, &100).unwrap_err().unwrap(), ContractError::Unauthorized @@ -415,6 +498,10 @@ mod test { client.try_get_fee_config().unwrap_err().unwrap(), ContractError::NotInitialized ); + assert_eq!( + client.try_get_token().unwrap_err().unwrap(), + ContractError::NotInitialized + ); } #[test] @@ -425,8 +512,9 @@ mod test { let contract_id = env.register_contract(None, FeeContract); let client = FeeContractClient::new(&env, &contract_id); let admin = Address::generate(&env); + let (token_addr, _, _) = create_token(&env, &admin); - client.init(&admin); + client.init(&admin, &token_addr); assert_eq!(client.get_admin(), admin); } @@ -439,14 +527,18 @@ mod test { let client = FeeContractClient::new(&env, &contract_id); let admin = Address::generate(&env); let caller = Address::generate(&env); + let (token_addr, token_client, token_admin) = create_token(&env, &admin); - client.init(&admin); + token_admin.mint(&caller, &i128::MAX); + client.init(&admin, &token_addr); client.deposit(&caller, &i128::MAX); + assert_eq!(token_client.balance(&contract_id), i128::MAX); assert_eq!( client.try_deposit(&caller, &1).unwrap_err().unwrap(), ContractError::Overflow ); } + #[test] fn test_withdraw_to_contract_self_fails() { let env = Env::default(); @@ -456,9 +548,11 @@ mod test { let client = FeeContractClient::new(&env, &contract_id); let admin = Address::generate(&env); let caller = Address::generate(&env); + let (token_addr, token_client, token_admin) = create_token(&env, &admin); - client.init(&admin); - client.deposit(&caller, &1000); + token_admin.mint(&caller, &1_000); + client.init(&admin, &token_addr); + client.deposit(&caller, &1_000); assert_eq!( client @@ -467,7 +561,8 @@ mod test { .unwrap(), ContractError::InvalidRecipient ); - assert_eq!(client.get_accumulated(), 1000); + assert_eq!(client.get_accumulated(), 1_000); + assert_eq!(token_client.balance(&contract_id), 1_000); } #[test] @@ -479,9 +574,11 @@ mod test { let client = FeeContractClient::new(&env, &contract_id); let admin = Address::generate(&env); let caller = Address::generate(&env); + let (token_addr, token_client, token_admin) = create_token(&env, &admin); - client.init(&admin); - client.deposit(&caller, &1000); + token_admin.mint(&caller, &1_000); + client.init(&admin, &token_addr); + client.deposit(&caller, &1_000); let zero = Address::from_str(&env, ZERO_CONTRACT_ADDRESS); assert_eq!( @@ -491,7 +588,8 @@ mod test { .unwrap(), ContractError::InvalidRecipient ); - assert_eq!(client.get_accumulated(), 1000); + assert_eq!(client.get_accumulated(), 1_000); + assert_eq!(token_client.balance(&contract_id), 1_000); } #[test] @@ -504,12 +602,16 @@ mod test { let admin = Address::generate(&env); let caller = Address::generate(&env); let recipient = Address::generate(&env); + let (token_addr, token_client, token_admin) = create_token(&env, &admin); - client.init(&admin); - client.deposit(&caller, &1000); + token_admin.mint(&caller, &1_000); + client.init(&admin, &token_addr); + client.deposit(&caller, &1_000); client.withdraw(&admin, &recipient, &400); assert_eq!(client.get_accumulated(), 600); + assert_eq!(token_client.balance(&contract_id), 600); + assert_eq!(token_client.balance(&recipient), 400); } } diff --git a/contracts/fee/src/test_ttl_bump.rs b/contracts/fee/src/test_ttl_bump.rs index 006b8b07..4f20bfbf 100644 --- a/contracts/fee/src/test_ttl_bump.rs +++ b/contracts/fee/src/test_ttl_bump.rs @@ -1,35 +1,43 @@ #![cfg(test)] use crate::{ - ContractError, FeeContract, FeeContractClient, INSTANCE_BUMP_AMOUNT, INSTANCE_BUMP_THRESHOLD, - LEDGERS_PER_DAY, + FeeContract, FeeContractClient, INSTANCE_BUMP_AMOUNT, INSTANCE_BUMP_THRESHOLD, LEDGERS_PER_DAY, }; use soroban_sdk::testutils::storage::Instance as _; use soroban_sdk::testutils::{Address as _, Ledger as _}; -use soroban_sdk::{Address, Env}; - -fn setup() -> (Env, Address, FeeContractClient<'static>) { +use soroban_sdk::{token, Address, Env}; + +fn setup() -> ( + Env, + Address, + Address, + token::StellarAssetClient<'static>, + FeeContractClient<'static>, +) { let env = Env::default(); env.mock_all_auths(); let admin = Address::generate(&env); + let token_contract = env.register_stellar_asset_contract_v2(admin.clone()); + let token_addr = token_contract.address(); + let token_admin = token::StellarAssetClient::new(&env, &token_addr); let contract_id = env.register_contract(None, FeeContract); let client = FeeContractClient::new(&env, &contract_id); - (env, admin, client) + (env, admin, token_addr, token_admin, client) } #[test] fn test_init_bumps_instance_ttl() { - let (env, admin, client) = setup(); + let (env, admin, token_addr, _token_admin, client) = setup(); - client.init(&admin); + client.init(&admin, &token_addr); let ttl = env.storage().instance().get_ttl(); assert_eq!(ttl, INSTANCE_BUMP_AMOUNT); } #[test] fn test_set_fee_bumps_instance_ttl() { - let (env, admin, client) = setup(); - client.init(&admin); + let (env, admin, token_addr, _token_admin, client) = setup(); + client.init(&admin, &token_addr); // Advance sequence number to reduce instance TTL below threshold. let seq = env.ledger().sequence(); @@ -47,10 +55,11 @@ fn test_set_fee_bumps_instance_ttl() { #[test] fn test_deposit_and_withdraw_bump_instance_ttl() { - let (env, admin, client) = setup(); - client.init(&admin); + let (env, admin, token_addr, token_admin, client) = setup(); + client.init(&admin, &token_addr); let caller = Address::generate(&env); let recipient = Address::generate(&env); + token_admin.mint(&caller, &1_000); // Advance sequence number to reduce instance TTL below threshold. let seq = env.ledger().sequence(); @@ -78,9 +87,10 @@ fn test_deposit_and_withdraw_bump_instance_ttl() { #[test] fn test_read_paths_bump_instance_ttl() { - let (env, admin, client) = setup(); - client.init(&admin); + let (env, admin, token_addr, token_admin, client) = setup(); + client.init(&admin, &token_addr); let caller = Address::generate(&env); + token_admin.mint(&caller, &1_000); client.deposit(&caller, &1_000); // 1. get_admin bumps TTL @@ -106,6 +116,14 @@ fn test_read_paths_bump_instance_ttl() { assert!(env.storage().instance().get_ttl() < INSTANCE_BUMP_THRESHOLD); assert_eq!(client.get_accumulated(), 1_000); assert_eq!(env.storage().instance().get_ttl(), INSTANCE_BUMP_AMOUNT); + + // 4. get_token bumps TTL + let seq4 = env.ledger().sequence(); + env.ledger() + .set_sequence_number(seq4 + INSTANCE_BUMP_AMOUNT - INSTANCE_BUMP_THRESHOLD + 10); + assert!(env.storage().instance().get_ttl() < INSTANCE_BUMP_THRESHOLD); + assert_eq!(client.get_token(), token_addr); + assert_eq!(env.storage().instance().get_ttl(), INSTANCE_BUMP_AMOUNT); } #[test] @@ -121,21 +139,31 @@ fn test_isolation_between_contract_instances() { env.mock_all_auths(); let admin1 = Address::generate(&env); + let token1 = env + .register_stellar_asset_contract_v2(admin1.clone()) + .address(); + let token1_admin = token::StellarAssetClient::new(&env, &token1); let contract_id1 = env.register_contract(None, FeeContract); let client1 = FeeContractClient::new(&env, &contract_id1); let admin2 = Address::generate(&env); + let token2 = env + .register_stellar_asset_contract_v2(admin2.clone()) + .address(); let contract_id2 = env.register_contract(None, FeeContract); let client2 = FeeContractClient::new(&env, &contract_id2); - client1.init(&admin1); - client2.init(&admin2); + client1.init(&admin1, &token1); + client2.init(&admin2, &token2); let user = Address::generate(&env); + token1_admin.mint(&user, &500); client1.deposit(&user, &500); assert_eq!(client1.get_accumulated(), 500); assert_eq!(client2.get_accumulated(), 0); assert_eq!(client1.get_admin(), admin1); assert_eq!(client2.get_admin(), admin2); + assert_eq!(client1.get_token(), token1); + assert_eq!(client2.get_token(), token2); } diff --git a/contracts/fee/tests/auth_snap.rs b/contracts/fee/tests/auth_snap.rs index 482c2eea..3d6072d8 100644 --- a/contracts/fee/tests/auth_snap.rs +++ b/contracts/fee/tests/auth_snap.rs @@ -4,36 +4,50 @@ extern crate std; use callora_fee::{FeeConfig, FeeContract, FeeContractClient}; use soroban_sdk::testutils::Address as _; -use soroban_sdk::{Address, Env}; +use soroban_sdk::{token, Address, Env}; fn create_contract(env: &Env) -> FeeContractClient<'_> { let contract_id = env.register(FeeContract, ()); FeeContractClient::new(env, &contract_id) } -fn setup(env: &Env) -> (Address, FeeContractClient<'_>) { +fn setup( + env: &Env, +) -> ( + Address, + Address, + token::StellarAssetClient<'_>, + FeeContractClient<'_>, +) { env.mock_all_auths(); let admin = Address::generate(env); + let token_addr = env + .register_stellar_asset_contract_v2(admin.clone()) + .address(); + let token_admin = token::StellarAssetClient::new(env, &token_addr); let client = create_contract(env); - client.init(&admin); - (admin, client) + client.init(&admin, &token_addr); + (admin, token_addr, token_admin, client) } #[test] fn init_requires_auth() { let env = Env::default(); let admin = Address::generate(&env); + let token_addr = env + .register_stellar_asset_contract_v2(admin.clone()) + .address(); let client = create_contract(&env); env.set_auths(&[]); - let res = client.try_init(&admin); + let res = client.try_init(&admin, &token_addr); assert!(res.is_err(), "init must require auth"); } #[test] fn set_fee_requires_auth() { let env = Env::default(); - let (admin, client) = setup(&env); + let (admin, _token_addr, _token_admin, client) = setup(&env); env.set_auths(&[]); let res = client.try_set_fee(&admin, &250); @@ -43,7 +57,7 @@ fn set_fee_requires_auth() { #[test] fn deposit_requires_auth() { let env = Env::default(); - let (admin, client) = setup(&env); + let (_admin, _token_addr, _token_admin, client) = setup(&env); env.set_auths(&[]); let caller = Address::generate(&env); @@ -54,11 +68,12 @@ fn deposit_requires_auth() { #[test] fn withdraw_requires_auth() { let env = Env::default(); - let (admin, client) = setup(&env); + let (admin, _token_addr, token_admin, client) = setup(&env); env.mock_all_auths(); let caller = Address::generate(&env); - client.deposit(&caller, &1000); + token_admin.mint(&caller, &1_000); + client.deposit(&caller, &1_000); env.set_auths(&[]); let recipient = Address::generate(&env); @@ -69,7 +84,7 @@ fn withdraw_requires_auth() { #[test] fn get_fee_config_does_not_require_auth() { let env = Env::default(); - let (admin, client) = setup(&env); + let (_admin, _token_addr, _token_admin, client) = setup(&env); env.set_auths(&[]); let config = client.get_fee_config(); @@ -85,7 +100,7 @@ fn get_fee_config_does_not_require_auth() { #[test] fn get_accumulated_does_not_require_auth() { let env = Env::default(); - let (_admin, client) = setup(&env); + let (_admin, _token_addr, _token_admin, client) = setup(&env); env.set_auths(&[]); let accumulated = client.get_accumulated(); @@ -95,27 +110,41 @@ fn get_accumulated_does_not_require_auth() { #[test] fn get_admin_does_not_require_auth() { let env = Env::default(); - let (admin, client) = setup(&env); + let (admin, _token_addr, _token_admin, client) = setup(&env); env.set_auths(&[]); assert_eq!(client.get_admin(), admin); } +#[test] +fn get_token_does_not_require_auth() { + let env = Env::default(); + let (_admin, token_addr, _token_admin, client) = setup(&env); + + env.set_auths(&[]); + assert_eq!(client.get_token(), token_addr); +} + #[test] fn admin_with_auth_can_call_all_entrypoints() { let env = Env::default(); env.mock_all_auths(); let admin = Address::generate(&env); + let token_addr = env + .register_stellar_asset_contract_v2(admin.clone()) + .address(); + let token_admin = token::StellarAssetClient::new(&env, &token_addr); let client = create_contract(&env); - client.init(&admin); + client.init(&admin, &token_addr); client.set_fee(&admin, &250); assert_eq!(client.get_fee_config().fee_bps, 250); let caller = Address::generate(&env); - client.deposit(&caller, &1000); - assert_eq!(client.get_accumulated(), 1000); + token_admin.mint(&caller, &1_000); + client.deposit(&caller, &1_000); + assert_eq!(client.get_accumulated(), 1_000); let recipient = Address::generate(&env); let _ = client.try_withdraw(&admin, &recipient, &400);