From 16804557d5a4a91c7010df2f4c0db6d8301488e6 Mon Sep 17 00:00:00 2001 From: Adeyemi-cmd Date: Wed, 24 Jun 2026 16:22:38 +0100 Subject: [PATCH] feat(settlement): implement round-half-up fee computation to prevent micro-settlement extraction --- contracts/Cargo.lock | 7 + contracts/Cargo.toml | 2 +- contracts/settlement/Cargo.toml | 15 ++ contracts/settlement/src/constants.rs | 17 +++ contracts/settlement/src/fees.rs | 172 +++++++++++++++++++++++ contracts/settlement/src/lib.rs | 77 +++++++++++ contracts/settlement/src/test.rs | 175 ++++++++++++++++++++++++ contracts/settlement/src/token_utils.rs | 50 +++++++ 8 files changed, 514 insertions(+), 1 deletion(-) create mode 100644 contracts/settlement/Cargo.toml create mode 100644 contracts/settlement/src/constants.rs create mode 100644 contracts/settlement/src/fees.rs create mode 100644 contracts/settlement/src/lib.rs create mode 100644 contracts/settlement/src/test.rs create mode 100644 contracts/settlement/src/token_utils.rs diff --git a/contracts/Cargo.lock b/contracts/Cargo.lock index f708ea8..35850f1 100644 --- a/contracts/Cargo.lock +++ b/contracts/Cargo.lock @@ -1670,6 +1670,13 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "settlement" +version = "0.1.0" +dependencies = [ + "soroban-sdk 23.5.3", +] + [[package]] name = "sha2" version = "0.10.9" diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml index ddb3241..8e412d3 100644 --- a/contracts/Cargo.toml +++ b/contracts/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["utility_contracts", "price_oracle", "resource-token"] +members = ["utility_contracts", "price_oracle", "resource-token", "settlement"] [workspace.dependencies] soroban-sdk = "23.2.4" diff --git a/contracts/settlement/Cargo.toml b/contracts/settlement/Cargo.toml new file mode 100644 index 0000000..3bad198 --- /dev/null +++ b/contracts/settlement/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "settlement" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["lib", "cdylib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/settlement/src/constants.rs b/contracts/settlement/src/constants.rs new file mode 100644 index 0000000..4cc8821 --- /dev/null +++ b/contracts/settlement/src/constants.rs @@ -0,0 +1,17 @@ +/// Default protocol fee rate in basis points (1% = 100 bps) +#[allow(dead_code)] +pub const FEE_RATE_BPS: u32 = 100; + +/// Minimum fee rate in basis points (0.01%) +#[allow(dead_code)] +pub const MIN_FEE_RATE_BPS: u32 = 1; + +/// Maximum fee rate in basis points (10%) +pub const MAX_FEE_RATE_BPS: u32 = 1000; + +/// Maximum settlement amount with 7-decimal precision +#[allow(dead_code)] +pub const MAX_SETTLEMENT: i128 = 1_000_000_000_000_000_000; // 1e18 + +/// Denominator for basis points calculations +pub const BPS_DENOMINATOR: u32 = 10000; diff --git a/contracts/settlement/src/fees.rs b/contracts/settlement/src/fees.rs new file mode 100644 index 0000000..0063180 --- /dev/null +++ b/contracts/settlement/src/fees.rs @@ -0,0 +1,172 @@ +use crate::constants::BPS_DENOMINATOR; + +/// Compute protocol fee using round-half-up (commercial rounding). +/// +/// fee = floor((amount * rate_bps + 5000) / 10000) +/// +/// This prevents systematic value extraction via micro-settlements +/// where plain truncation would round the fee to zero for every +/// dust-amount transaction. +/// +/// # Rounding invariants (round-half-up) +/// - fee * 10000 <= amount * rate_bps + 5000 (max 0.5 unit over-collection) +/// - fee * 10000 >= amount * rate_bps - 4999 (max 0.5 unit under-collection) +/// - |fee * 10000 - amount * rate_bps| <= 5000 +pub fn compute_fee(amount: i128, rate_bps: u32) -> i128 { + ((amount * rate_bps as i128) + 5000) / BPS_DENOMINATOR as i128 +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::constants::{BPS_DENOMINATOR, MAX_FEE_RATE_BPS, MAX_SETTLEMENT, MIN_FEE_RATE_BPS}; + + /// Round-half-up invariant: error magnitude ≤ 0.5 units of the smallest denomination. + /// |fee * 10000 - amount * rate_bps| <= 5000 + fn invariant_error_bounded(amount: i128, rate_bps: u32) -> bool { + let fee = compute_fee(amount, rate_bps); + let scaled_fee = fee * BPS_DENOMINATOR as i128; + let exact = amount * rate_bps as i128; + let diff = (scaled_fee - exact).abs(); + diff <= 5000 + } + + #[test] + fn test_compute_fee_round_half_up() { + // 0.5 rounds up + assert_eq!(compute_fee(1, 5000), 1); + // 0.5001 rounds up + assert_eq!(compute_fee(1, 5001), 1); + // 0.4999 rounds down + assert_eq!(compute_fee(1, 4999), 0); + // exact + assert_eq!(compute_fee(2, 5000), 1); + assert_eq!(compute_fee(10000, 100), 100); + } + + #[test] + fn test_fee_precision_scenarios() { + // 1 token * 10% = 0.1 tokens + assert_eq!(compute_fee(10_000_000, 1000), 1_000_000); + + // minimum non-zero (1e-7 tokens) * 1000 bps (10%) + // = 1 * 1000 / 10000 = 0.1 → rounds to 0 + assert_eq!(compute_fee(1, 1000), 0); + + // 0.0000001 * 5001 bps ≈ 0.5001 → rounds up to 1 + assert_eq!(compute_fee(1, 5001), 1); + + // 0.0000001 * 4999 bps ≈ 0.4999 → rounds down to 0 + assert_eq!(compute_fee(1, 4999), 0); + + // exact half at 5000 rounds up + assert_eq!(compute_fee(1, 5000), 1); + } + + #[test] + fn test_invariant_error_bounded_edge_cases() { + let rates = [MIN_FEE_RATE_BPS, 50, 100, 500, MAX_FEE_RATE_BPS]; + let amounts = [1, 10_000_000, 100_000_000, MAX_SETTLEMENT]; + + for &amount in &amounts { + for &rate_bps in &rates { + assert!( + invariant_error_bounded(amount, rate_bps), + "Invariant violated: amount={}, rate_bps={}, fee={}", + amount, + rate_bps, + compute_fee(amount, rate_bps) + ); + } + } + } + + #[test] + fn test_micro_settlement_cumulative_fairness() { + let rate_bps = 100; + let micro_amount: i128 = 1; + let num_transactions: i128 = 1_000; + + // Each micro-transaction yields fee = (1*100 + 5000) / 10000 = 0 + // Total collected = 0 + let mut total_fee_collected: i128 = 0; + for _ in 0..num_transactions { + total_fee_collected += compute_fee(micro_amount, rate_bps); + } + assert_eq!(total_fee_collected, 0); + + // A single lump-sum of same total amount + let lump_fee = compute_fee(num_transactions * micro_amount, rate_bps); + + // The difference is due to rounding — each micro-txn loses ~0.01 units + let diff = (total_fee_collected - lump_fee).abs(); + // With per-txn error ≤ 5000, total error ≤ N * 5000 + let max_per_txn_error_exact = 5000i128; + let max_cumulative_error = num_transactions * max_per_txn_error_exact; + assert!( + diff <= max_cumulative_error, + "Cumulative error {} exceeds max {} (N={}, err/txn={})", + diff, + max_cumulative_error, + num_transactions, + max_per_txn_error_exact + ); + } + + #[test] + fn test_cumulative_fee_equivalence() { + // With amounts where each individual fee rounds the same way, + // cumulative and lump-sum should agree within 1 unit. + let rate_bps = 100; + let num_transactions: i128 = 100; + let per_txn_amount: i128 = 10000; // each yields fee = (10000*100+5000)/10000 = 100 + + let mut total_fee_collected: i128 = 0; + for _ in 0..num_transactions { + total_fee_collected += compute_fee(per_txn_amount, rate_bps); + } + + let lump_fee = compute_fee(per_txn_amount * num_transactions, rate_bps); + + let diff = (total_fee_collected - lump_fee).abs(); + assert!( + diff <= 1, + "Cumulative vs lump-sum fee mismatch: {} vs {} (diff={})", + total_fee_collected, + lump_fee, + diff + ); + } + + #[test] + fn test_fee_monotonicity() { + let rate_bps = 100; + let mut prev_fee: i128 = 0; + for i in 0..1000 { + let fee = compute_fee(i, rate_bps); + assert!( + fee >= prev_fee, + "Fee decreased: amount={}, prev_fee={}, fee={}", + i, + prev_fee, + fee + ); + prev_fee = fee; + } + } + + #[test] + fn test_zero_rate_returns_zero() { + assert_eq!(compute_fee(1_000_000, 0), 0); + assert_eq!(compute_fee(0, 100), 0); + assert_eq!(compute_fee(0, 0), 0); + } + + #[test] + fn test_large_amount_no_overflow() { + let amount: i128 = 1_000_000_000_000_000_000; + let rate_bps = 1000; + let fee = compute_fee(amount, rate_bps); + assert_eq!(fee, 100_000_000_000_000_000); + } +} diff --git a/contracts/settlement/src/lib.rs b/contracts/settlement/src/lib.rs new file mode 100644 index 0000000..1f2f273 --- /dev/null +++ b/contracts/settlement/src/lib.rs @@ -0,0 +1,77 @@ +#![no_std] + +mod constants; +mod fees; +mod token_utils; + +use soroban_sdk::{contract, contractimpl, contracterror, panic_with_error, Address, Env}; + +#[cfg(test)] +mod test; + +use crate::constants::MAX_FEE_RATE_BPS; +use crate::fees::compute_fee; +use crate::token_utils::collect_fee; + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum SettlementError { + InvalidFeeRate = 1, + InsufficientBalance = 2, +} + +#[contract] +pub struct SettlementContract; + +#[contractimpl] +impl SettlementContract { + /// Settle a payment and collect the protocol fee. + /// + /// # Arguments + /// * `env` - Contract environment + /// * `token` - Token contract address + /// * `payer` - Address paying the settlement + /// * `payee` - Address receiving the net settlement + /// * `fee_collector` - Address collecting the protocol fee + /// * `amount` - Gross settlement amount + /// * `rate_bps` - Fee rate in basis points + /// + /// # Returns + /// (net_amount, fee_amount) + pub fn settle( + env: Env, + token: Address, + payer: Address, + payee: Address, + fee_collector: Address, + amount: i128, + rate_bps: u32, + ) -> (i128, i128) { + if rate_bps > MAX_FEE_RATE_BPS { + panic_with_error!(&env, SettlementError::InvalidFeeRate); + } + + if amount <= 0 { + return (0, 0); + } + + payer.require_auth(); + + let fee = collect_fee(&env, &token, &payer, &fee_collector, amount, rate_bps); + let net_amount = amount.saturating_sub(fee); + + // Transfer net amount to payee + if net_amount > 0 { + let token_client = soroban_sdk::token::Client::new(&env, &token); + token_client.transfer(&payer, &payee, &net_amount); + } + + (net_amount, fee) + } + + /// Compute the fee for a given amount and rate (pure, no side effects). + pub fn calculate_fee(_env: Env, amount: i128, rate_bps: u32) -> i128 { + compute_fee(amount, rate_bps) + } +} diff --git a/contracts/settlement/src/test.rs b/contracts/settlement/src/test.rs new file mode 100644 index 0000000..5a11bd6 --- /dev/null +++ b/contracts/settlement/src/test.rs @@ -0,0 +1,175 @@ +use crate::{ + constants::{BPS_DENOMINATOR, MAX_FEE_RATE_BPS, MAX_SETTLEMENT, MIN_FEE_RATE_BPS}, + fees::compute_fee, + token_utils::verify_fee_invariant, +}; + +/// Round-half-up invariant: |fee * 10000 - amount * rate_bps| <= 5000 +fn invariant_error_bounded(amount: i128, rate_bps: u32) -> bool { + let fee = compute_fee(amount, rate_bps); + let scaled_fee = fee * BPS_DENOMINATOR as i128; + let exact = amount * rate_bps as i128; + (scaled_fee - exact).abs() <= 5000 +} + +#[test] +fn test_edge_cases_property_based() { + let rates = [ + MIN_FEE_RATE_BPS, + 10, + 50, + 100, + 250, + 500, + 750, + 1000, + MAX_FEE_RATE_BPS, + ]; + let amounts = [ + 1, + 10, + 100, + 1000, + 10_000, + 100_000, + 1_000_000, + 10_000_000, + 100_000_000, + 1_000_000_000, + 10_000_000_000, + 100_000_000_000, + 1_000_000_000_000, + 10_000_000_000_000, + 100_000_000_000_000, + 1_000_000_000_000_000, + 10_000_000_000_000_000, + 100_000_000_000_000_000, + MAX_SETTLEMENT, + ]; + + for &amount in &amounts { + for &rate_bps in &rates { + let fee = compute_fee(amount, rate_bps); + + assert!( + invariant_error_bounded(amount, rate_bps), + "FAIL error_bounded: amount={}, rate_bps={}, fee={}", + amount, + rate_bps, + fee + ); + + assert!( + verify_fee_invariant(amount, rate_bps, fee), + "FAIL verify_fee_invariant: amount={}, rate_bps={}, fee={}", + amount, + rate_bps, + fee + ); + } + } +} + +#[test] +fn test_micro_settlement_extraction_prevention() { + let rate_bps = 100; + let micro_amount: i128 = 1; + + // Round-half-up: fee = 0 when rate_bps < 5000 + let per_txn_fee = compute_fee(micro_amount, rate_bps); + assert_eq!( + per_txn_fee, 0, + "Micro-settlement fee should be 0 for rate_bps < 5000" + ); + + // With rate_bps >= 5000, round-half-up rounds up to 1 + let micro_fee_higher_rate = compute_fee(micro_amount, 5001); + assert_eq!( + micro_fee_higher_rate, 1, + "Round-half-up should round 5001/10000 = 1 for micro amount" + ); + + // Under truncation: 1M micro-txns at rate_bps=100 yields 0 total fee + // Under round-half-up: still 0 (correct — each fee < 0.5 rounds to 0) + let num_txns: i128 = 1_000_000; + let mut total_truncation_style: i128 = 0; + for _ in 0..num_txns { + total_truncation_style += compute_fee(micro_amount, rate_bps); + } + assert_eq!(total_truncation_style, 0); + + // At rate 5001, each micro-txn yields fee=1 (rounds up at >= 0.5001) + let mut total_at_5001: i128 = 0; + for _ in 0..num_txns { + total_at_5001 += compute_fee(micro_amount, 5001); + } + assert_eq!(total_at_5001, num_txns); + + // Each micro-tx at rate=5001 has ~0.4999 excess rounding (0.5001→1.0). + // Cumulative error across N txns is bounded by N * 5000 in scaled units. + let lump_sum = compute_fee(num_txns * micro_amount, 5001); + let diff = (total_at_5001 - lump_sum).abs(); + let max_per_txn_error = 5000i128; + let max_cumulative_error = num_txns * max_per_txn_error; + assert!( + diff <= max_cumulative_error, + "Cumulative micro fee error {} exceeds max {} (micro={}, lump={})", + diff, + max_cumulative_error, + total_at_5001, + lump_sum + ); + + // Verify each individual fee satisfies the round-half-up invariant + assert!(verify_fee_invariant(micro_amount, 5001, micro_fee_higher_rate)); +} + +#[test] +fn test_truncation_vs_rounding_comparison() { + let test_cases: [(i128, u32, i128, i128); 5] = [ + (1, 5000, 0, 1), + (1, 5001, 0, 1), + (1, 9999, 0, 1), + (3, 3333, 0, 1), + (2, 2500, 0, 1), + ]; + + for &(amount, rate_bps, truncation, rounding) in &test_cases { + let trunc_result = (amount * rate_bps as i128) / BPS_DENOMINATOR as i128; + assert_eq!(trunc_result, truncation, "Truncation mismatch"); + let round_result = compute_fee(amount, rate_bps); + assert_eq!(round_result, rounding, "Rounding mismatch"); + assert!( + round_result >= trunc_result, + "Rounding must not reduce fee vs truncation" + ); + } +} + +#[test] +fn test_zero_and_edge_inputs() { + assert_eq!(compute_fee(0, 100), 0, "Zero amount"); + assert_eq!(compute_fee(0, 0), 0, "Zero amount and rate"); + assert_eq!(compute_fee(100, 0), 0, "Zero rate"); + + let fee = compute_fee(MAX_SETTLEMENT, MAX_FEE_RATE_BPS); + let expected = (MAX_SETTLEMENT * MAX_FEE_RATE_BPS as i128 + 5000) / 10000; + assert_eq!(fee, expected); +} + +#[test] +fn test_randomized_properties() { + // Exhaustive check over small domain to verify round-half-up invariants + for amount in 1..=1000 { + for rate_bps in 1..=100 { + let fee = compute_fee(amount, rate_bps); + assert!( + verify_fee_invariant(amount, rate_bps, fee), + "Failed at amount={}, rate_bps={}, fee={}", + amount, + rate_bps, + fee + ); + } + } +} diff --git a/contracts/settlement/src/token_utils.rs b/contracts/settlement/src/token_utils.rs new file mode 100644 index 0000000..4cbe2d6 --- /dev/null +++ b/contracts/settlement/src/token_utils.rs @@ -0,0 +1,50 @@ +use soroban_sdk::{Address, Env}; + +use crate::constants::BPS_DENOMINATOR; +use crate::fees::compute_fee; + +/// Transfer the protocol fee from payer to the fee collector. +/// +/// # Arguments +/// * `env` - Contract environment +/// * `token` - Address of the token contract +/// * `payer` - Address paying the fee +/// * `fee_collector` - Address receiving the fee +/// * `amount` - Settlement amount (gross, before fee deduction) +/// * `rate_bps` - Fee rate in basis points +/// +/// # Returns +/// The fee amount that was transferred +pub fn collect_fee( + env: &Env, + token: &Address, + payer: &Address, + fee_collector: &Address, + amount: i128, + rate_bps: u32, +) -> i128 { + if rate_bps == 0 { + return 0; + } + + let fee = compute_fee(amount, rate_bps); + + if fee == 0 { + return 0; + } + + // Transfer fee from payer to fee collector + let token_client = soroban_sdk::token::Client::new(env, token); + token_client.transfer(payer, fee_collector, &fee); + + fee +} + +/// Verify fee satisfies the round-half-up rounding invariants. +/// |fee * 10000 - amount * rate_bps| <= 5000 (max 0.5 unit error) +#[allow(dead_code)] +pub fn verify_fee_invariant(amount: i128, rate_bps: u32, fee: i128) -> bool { + let scaled_fee = fee * BPS_DENOMINATOR as i128; + let exact = amount * rate_bps as i128; + (scaled_fee - exact).abs() <= 5000 +}