From 851e4a7bf9fdad7561f2bc4c6d00630d9e6e4939 Mon Sep 17 00:00:00 2001 From: AbuJulaybeeb Date: Tue, 30 Jun 2026 09:03:02 +0100 Subject: [PATCH] feat: add match cancellation fee to discourage abuse --- contracts/escrow/src/errors.rs | 1 + contracts/escrow/src/lib.rs | 50 +++++++++++- .../escrow/src/tests/cancellation_fee.rs | 80 +++++++++++++++++++ contracts/escrow/src/tests/mod.rs | 1 + contracts/escrow/src/types.rs | 8 ++ 5 files changed, 137 insertions(+), 3 deletions(-) create mode 100644 contracts/escrow/src/tests/cancellation_fee.rs diff --git a/contracts/escrow/src/errors.rs b/contracts/escrow/src/errors.rs index ff7a7776..b151eb2f 100644 --- a/contracts/escrow/src/errors.rs +++ b/contracts/escrow/src/errors.rs @@ -24,4 +24,5 @@ pub enum Error { MatchAlreadyActive = 19, InvalidTimeout = 20, SnapshotNotFound = 21, + NotInitialized = 22, } diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index 27a64090..ac1af4d7 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -5,7 +5,7 @@ pub mod types; use errors::Error; use soroban_sdk::{contract, contractimpl, symbol_short, token, Address, Env, String, Symbol, Vec}; -use types::{BalanceSnapshot, DataKey, Match, MatchState, Platform, SnapshotReason, Winner}; +use types::{BalanceSnapshot, DataKey, Match, MatchState, Platform, ProtocolConfig, SnapshotReason, Winner}; /// ~30 days at 5s/ledger. Used as the default TTL and expiration threshold. const MATCH_TTL_LEDGERS: u32 = 518_400; @@ -61,6 +61,11 @@ impl EscrowContract { env.storage().instance().set(&DataKey::Paused, &false); env.storage().instance().set(&DataKey::AllowlistEnforced, &false); env.storage().instance().set(&DataKey::AllowedTokenCount, &0u32); + let config = ProtocolConfig { + cancellation_fee_basis_points: 100, + treasury: admin.clone(), + }; + env.storage().instance().set(&DataKey::ProtocolConfig, &config); env.events().publish( (Symbol::new(&env, "escrow"), symbol_short!("init")), (oracle, admin), @@ -98,6 +103,23 @@ impl EscrowContract { Ok(()) } + /// Update the protocol configuration. + pub fn set_protocol_config(env: Env, config: ProtocolConfig) -> Result<(), Error> { + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .ok_or(Error::Unauthorized)?; + admin.require_auth(); + env.storage().instance().set(&DataKey::ProtocolConfig, &config); + Ok(()) + } + + /// Get the current protocol configuration. + pub fn get_protocol_config(env: Env) -> Result { + env.storage().instance().get(&DataKey::ProtocolConfig).ok_or(Error::NotInitialized) + } + /// Add a token to the allowlist — admin only. pub fn add_allowed_token(env: Env, token: Address) -> Result<(), Error> { let admin: Address = env @@ -611,11 +633,33 @@ impl EscrowContract { let client = token::Client::new(&env, &m.token); + let config: ProtocolConfig = env.storage().instance().get(&DataKey::ProtocolConfig).unwrap_or(ProtocolConfig { + cancellation_fee_basis_points: 0, + treasury: env.current_contract_address(), + }); + + let fee_amount = if config.cancellation_fee_basis_points > 0 { + m.stake_amount.checked_mul(config.cancellation_fee_basis_points as i128).ok_or(Error::Overflow)? / 10_000 + } else { + 0 + }; + let refund_amount = m.stake_amount.checked_sub(fee_amount).ok_or(Error::Overflow)?; + if m.player1_deposited { - client.transfer(&env.current_contract_address(), &m.player1, &m.stake_amount); + if is_p1 && fee_amount > 0 { + client.transfer(&env.current_contract_address(), &m.player1, &refund_amount); + client.transfer(&env.current_contract_address(), &config.treasury, &fee_amount); + } else { + client.transfer(&env.current_contract_address(), &m.player1, &m.stake_amount); + } } if m.player2_deposited { - client.transfer(&env.current_contract_address(), &m.player2, &m.stake_amount); + if is_p2 && fee_amount > 0 { + client.transfer(&env.current_contract_address(), &m.player2, &refund_amount); + client.transfer(&env.current_contract_address(), &config.treasury, &fee_amount); + } else { + client.transfer(&env.current_contract_address(), &m.player2, &m.stake_amount); + } } m.state = MatchState::Cancelled; diff --git a/contracts/escrow/src/tests/cancellation_fee.rs b/contracts/escrow/src/tests/cancellation_fee.rs new file mode 100644 index 00000000..132aec54 --- /dev/null +++ b/contracts/escrow/src/tests/cancellation_fee.rs @@ -0,0 +1,80 @@ +#![cfg(test)] +extern crate std; + +use super::*; +use soroban_sdk::testutils::Address as _; + +#[test] +fn test_cancellation_fee_deduction() { + let (env, contract_id, _oracle, player1, player2, token, admin) = setup(); + let client = EscrowContractClient::new(&env, &contract_id); + let token_client = token_client(&env, &token); + + // Initial balances + assert_eq!(token_client.balance(&player1), 1000); + + let match_id = helpers::create_default_match(&client, &env, &player1, &player2, &token, "test_fee_game"); + + // Player 1 deposits + client.deposit(&match_id, &player1); + assert_eq!(token_client.balance(&player1), 900); + assert_eq!(token_client.balance(&contract_id), 100); + + // Default fee is 1%. Stake is 100. Fee should be 1. + // Player 1 cancels + client.cancel_match(&match_id, &player1); + + // Contract should refund 99 to Player 1, and send 1 to admin (default treasury) + assert_eq!(token_client.balance(&player1), 999); + assert_eq!(token_client.balance(&admin), 1); // Admin starts with 0 + assert_eq!(token_client.balance(&contract_id), 0); +} + +#[test] +fn test_cancellation_no_fee_if_no_deposit() { + let (env, contract_id, _oracle, player1, player2, token, admin) = setup(); + let client = EscrowContractClient::new(&env, &contract_id); + let token_client = token_client(&env, &token); + + let match_id = helpers::create_default_match(&client, &env, &player1, &player2, &token, "test_no_fee_game"); + + // Neither player deposits + // Player 1 cancels + client.cancel_match(&match_id, &player1); + + // Player 1 should not be charged since they didn't deposit + assert_eq!(token_client.balance(&player1), 1000); + assert_eq!(token_client.balance(&admin), 0); + assert_eq!(token_client.balance(&contract_id), 0); +} + +#[test] +fn test_cancellation_fee_with_custom_config() { + let (env, contract_id, _oracle, player1, player2, token, admin) = setup(); + let client = EscrowContractClient::new(&env, &contract_id); + let token_client = token_client(&env, &token); + + let new_treasury = Address::generate(&env); + + // Admin sets new config: 5% fee + let config = types::ProtocolConfig { + cancellation_fee_basis_points: 500, // 5% + treasury: new_treasury.clone(), + }; + + env.mock_all_auths(); + client.set_protocol_config(&config); + + let match_id = helpers::create_default_match(&client, &env, &player1, &player2, &token, "test_custom_fee_game"); + + // Player 2 deposits + client.deposit(&match_id, &player2); + + // Player 1 cancels + client.cancel_match(&match_id, &player1); + + // Player 2 should be refunded 95. Treasury gets 5. + assert_eq!(token_client.balance(&player2), 995); + assert_eq!(token_client.balance(&new_treasury), 5); + assert_eq!(token_client.balance(&contract_id), 0); +} diff --git a/contracts/escrow/src/tests/mod.rs b/contracts/escrow/src/tests/mod.rs index be85e9f2..c61dca90 100644 --- a/contracts/escrow/src/tests/mod.rs +++ b/contracts/escrow/src/tests/mod.rs @@ -124,3 +124,4 @@ pub fn setup_with_four_players() -> ( pub fn mint_player_balance(asset_client: &StellarAssetClient, player: &Address, amount: i128) { asset_client.mint(player, &amount); } +mod cancellation_fee; diff --git a/contracts/escrow/src/types.rs b/contracts/escrow/src/types.rs index f5d40d5f..72098896 100644 --- a/contracts/escrow/src/types.rs +++ b/contracts/escrow/src/types.rs @@ -24,6 +24,13 @@ pub enum Winner { Draw, } +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProtocolConfig { + pub cancellation_fee_basis_points: u32, + pub treasury: Address, +} + #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct Match { @@ -65,6 +72,7 @@ pub enum DataKey { Snapshot(u64, u32), /// Total number of snapshots ever recorded for a match (monotonic, never reset). SnapshotCount(u64), + ProtocolConfig, } /// The lifecycle event that triggered a balance snapshot.