Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions contracts/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions contracts/settlement/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"] }
17 changes: 17 additions & 0 deletions contracts/settlement/src/constants.rs
Original file line number Diff line number Diff line change
@@ -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;
172 changes: 172 additions & 0 deletions contracts/settlement/src/fees.rs
Original file line number Diff line number Diff line change
@@ -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);
}
}
77 changes: 77 additions & 0 deletions contracts/settlement/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading