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
255 changes: 240 additions & 15 deletions Cargo.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ publish = false

[workspace.dependencies]
soroban-sdk = "26.1.0"
proptest = "1.4"

# Build wasm contracts as small and fast as possible.
[profile.release]
Expand Down
3 changes: 3 additions & 0 deletions contracts/common/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,6 @@ crate-type = ["rlib"]

[dependencies]
soroban-sdk = { workspace = true }

[dev-dependencies]
proptest = { workspace = true }
67 changes: 66 additions & 1 deletion contracts/common/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
#![no_std]

use soroban_sdk::{Address, Env, IntoVal, Val};
use soroban_sdk::{token, Address, Env, IntoVal, Val};

mod split;
pub use split::{compute_split, sort_remainders_desc, Payouts, SplitError};

#[cfg(test)]
mod test_fuzz;

/// Trait to identify the Admin key for a contract's DataKey enum
pub trait AdminKey {
fn admin_key() -> Self;
Expand Down Expand Up @@ -57,6 +60,30 @@ where
pub const BPS_DENOMINATOR: i128 = 10_000;
pub const MAX_SPONSORS: u32 = 20;

/// Maximum allowed single-step fee change (basis points) - Issue #20
/// Prevents accidental or malicious fee spikes (e.g., 2.5% to 99%)
pub const MAX_FEE_CHANGE_BPS: u32 = 500; // 5% maximum change per call

/// Validates a fee change is within acceptable bounds (Issue #20).
/// Ensures new fee is valid (≤100%) and change is ≤5% to prevent spikes.
pub fn validate_fee_change(old_fee: u32, new_fee: u32) -> Result<(), ()> {
if new_fee as i128 > BPS_DENOMINATOR {
return Err(());
}

let delta = if new_fee > old_fee {
new_fee - old_fee
} else {
old_fee - new_fee
};

if delta > MAX_FEE_CHANGE_BPS {
return Err(());
}

Ok(())
}

pub fn extend_ttl<K>(env: &Env, key: &K)
where
K: IntoVal<Env, Val>,
Expand All @@ -70,6 +97,44 @@ where
/// here, not sub-day precision. See `extend_ttl_for_target`'s docs.
const APPROX_SECONDS_PER_LEDGER: u64 = 5;

/// Measures the actual balance delta from a token transfer operation,
/// protecting against fee-on-transfer tokens, rebasing tokens, and malicious
/// token contracts (Issue #3).
///
/// Instead of trusting the caller-supplied `amount`, this queries the
/// contract's actual token balance before and after the transfer and returns
/// the real delta. This prevents accounting desync where internal bookkeeping
/// (escrow.amount, milestone.remaining_budget) diverges from the contract's
/// actual holdings.
///
/// # Example
/// ```ignore
/// let actual_received = measure_transfer_delta(
/// &env,
/// &token,
/// &env.current_contract_address(),
/// || {
/// token_client.transfer(&sponsor, &env.current_contract_address(), &amount);
/// },
/// );
/// // Use actual_received for bookkeeping instead of amount
/// ```
pub fn measure_transfer_delta<F>(
env: &Env,
token: &Address,
contract_addr: &Address,
operation: F,
) -> i128
where
F: FnOnce(),
{
let token_client = token::Client::new(env, token);
let balance_before = token_client.balance(contract_addr);
operation();
let balance_after = token_client.balance(contract_addr);
balance_after - balance_before
}

/// Extends a persistent entry's TTL to (approximately) survive until
/// `target_timestamp`, not just the fixed ~29-day (`500_000`-ledger) bump
/// `extend_ttl` always applies regardless of context.
Expand Down
247 changes: 247 additions & 0 deletions contracts/common/src/test_fuzz.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
//! Property-based fuzz testing for compute_split (Issue #17)
//!
//! These tests use proptest to verify mathematical invariants hold across
//! a wide range of inputs, catching edge cases that unit tests might miss.

#[cfg(test)]
mod tests {
extern crate std;
use std::vec;
use std::vec::Vec;

use crate::BPS_DENOMINATOR;
use proptest::prelude::*;

/// Simplified compute_split for testing (mirrors the contract logic)
fn compute_split_test(total: i128, recipients: &[u32], fee_bps: u32) -> Vec<i128> {
let fee = (total * fee_bps as i128) / BPS_DENOMINATOR;
let after_fee = total - fee;

recipients.iter().map(|&bps| {
(after_fee * bps as i128) / BPS_DENOMINATOR
}).collect()
}

proptest! {
/// Property: The sum of all recipient amounts should equal total - fee
/// This is the core invariant: no funds should disappear or be created
#[test]
fn test_sum_equals_total_minus_fee(
total in 1i128..=i128::MAX / 10_000,
fee_bps in 0u32..=10_000u32,
num_recipients in 2usize..=5usize,
) {
// Generate random BPS values that sum to exactly 10000
let mut bps_values: Vec<u32> = Vec::new();
let mut remaining = 10_000u32;

for i in 0..num_recipients - 1 {
let max_value = remaining.saturating_sub((num_recipients - i - 1) as u32);
let value = (i as u32 * 1000).min(max_value);
bps_values.push(value);
remaining -= value;
}
bps_values.push(remaining);

// Compute the split
let result = compute_split_test(total, &bps_values, fee_bps);

// Calculate expected fee
let fee = (total * fee_bps as i128) / BPS_DENOMINATOR;
let after_fee = total - fee;

// Sum all recipient amounts
let sum: i128 = result.iter().sum();

// Property: sum should equal total - fee (within rounding tolerance)
// We allow ±num_recipients difference due to rounding
let diff = (sum - after_fee).abs();
prop_assert!(diff <= num_recipients as i128,
"Sum mismatch: sum={}, expected={}, diff={}", sum, after_fee, diff);
}

/// Property: With 0% fee, the sum should equal the total (minus rounding)
#[test]
fn test_zero_fee_preserves_total(
total in 1i128..=i128::MAX / 10_000,
num_recipients in 2usize..=5usize,
) {
let mut bps_values: Vec<u32> = Vec::new();
let mut remaining = 10_000u32;

for i in 0..num_recipients - 1 {
let max_value = remaining.saturating_sub((num_recipients - i - 1) as u32);
let value = (i as u32 * 1000).min(max_value);
bps_values.push(value);
remaining -= value;
}
bps_values.push(remaining);

let result = compute_split_test(total, &bps_values, 0);

let sum: i128 = result.iter().sum();
let diff = (sum - total).abs();

prop_assert!(diff <= num_recipients as i128,
"Zero-fee sum mismatch: sum={}, expected={}, diff={}", sum, total, diff);
}

/// Property: With 100% fee, all recipients should get 0
#[test]
fn test_full_fee_yields_zero(
total in 1i128..=i128::MAX / 10_000,
num_recipients in 2usize..=5usize,
) {
let mut bps_values: Vec<u32> = Vec::new();
let mut remaining = 10_000u32;

for i in 0..num_recipients - 1 {
let max_value = remaining.saturating_sub((num_recipients - i - 1) as u32);
let value = (i as u32 * 1000).min(max_value);
bps_values.push(value);
remaining -= value;
}
bps_values.push(remaining);

let result = compute_split_test(total, &bps_values, 10_000);

for amount in result.iter() {
prop_assert_eq!(*amount, 0, "100% fee should yield 0 for all recipients");
}
}

/// Property: Each recipient's share should be proportional to their BPS
#[test]
fn test_proportional_distribution(
total in 1i128..=i128::MAX / 10_000,
fee_bps in 0u32..=10_000u32,
) {
// Use fixed BPS for easier verification: 50%, 30%, 20%
let recipients = vec![5000u32, 3000u32, 2000u32];
let result = compute_split_test(total, &recipients, fee_bps);

let fee = (total * fee_bps as i128) / BPS_DENOMINATOR;
let after_fee = total - fee;

// Calculate expected amounts
let expected_0 = (after_fee * 5000) / BPS_DENOMINATOR;
let expected_1 = (after_fee * 3000) / BPS_DENOMINATOR;
let expected_2 = (after_fee * 2000) / BPS_DENOMINATOR;

// Allow small rounding differences
prop_assert!((result[0] - expected_0).abs() <= 1);
prop_assert!((result[1] - expected_1).abs() <= 1);
prop_assert!((result[2] - expected_2).abs() <= 1);
}

/// Property: Increasing a recipient's BPS should never decrease their amount
#[test]
fn test_monotonic_bps(
total in 1i128..=i128::MAX / 10_000,
fee_bps in 0u32..=10_000u32,
increase in 1u32..=1000u32,
) {
// Start with 50%, 50% split
let recipients_before = vec![5000u32, 5000u32];
let result_before = compute_split_test(total, &recipients_before, fee_bps);

// Increase first recipient's share, decrease second
let new_bps = (5000u32 + increase).min(9999);
let recipients_after = vec![new_bps, 10000 - new_bps];
let result_after = compute_split_test(total, &recipients_after, fee_bps);

// First recipient should get more (or same)
prop_assert!(result_after[0] >= result_before[0],
"Increasing BPS should not decrease amount: before={}, after={}",
result_before[0], result_after[0]);
}

/// Property: No recipient should receive more than the total amount
#[test]
fn test_no_amount_exceeds_total(
total in 1i128..=i128::MAX / 10_000,
fee_bps in 0u32..=10_000u32,
num_recipients in 2usize..=5usize,
) {
let mut bps_values: Vec<u32> = Vec::new();
let mut remaining = 10_000u32;

for i in 0..num_recipients - 1 {
let max_value = remaining.saturating_sub((num_recipients - i - 1) as u32);
let value = (i as u32 * 1000).min(max_value);
bps_values.push(value);
remaining -= value;
}
bps_values.push(remaining);

let result = compute_split_test(total, &bps_values, fee_bps);

for amount in result.iter() {
prop_assert!(*amount <= total,
"Recipient amount {} exceeds total {}", amount, total);
}
}

/// Property: All amounts should be non-negative
#[test]
fn test_non_negative_amounts(
total in 1i128..=i128::MAX / 10_000,
fee_bps in 0u32..=10_000u32,
num_recipients in 2usize..=5usize,
) {
let mut bps_values: Vec<u32> = Vec::new();
let mut remaining = 10_000u32;

for i in 0..num_recipients - 1 {
let max_value = remaining.saturating_sub((num_recipients - i - 1) as u32);
let value = (i as u32 * 1000).min(max_value);
bps_values.push(value);
remaining -= value;
}
bps_values.push(remaining);

let result = compute_split_test(total, &bps_values, fee_bps);

for amount in result.iter() {
prop_assert!(*amount >= 0, "Amount should be non-negative: {}", amount);
}
}
}

/// Edge case: Single recipient should get (total - fee)
#[test]
fn test_single_recipient() {
let total = 1_000_000i128;
let fee_bps = 250u32; // 2.5%
let recipients = vec![10_000u32]; // 100%

let result = compute_split_test(total, &recipients, fee_bps);
let fee = (total * fee_bps as i128) / BPS_DENOMINATOR;
let expected = total - fee;

assert_eq!(result.len(), 1);
assert_eq!(result[0], expected);
}

/// Edge case: Zero total amount
#[test]
fn test_zero_total() {
let total = 0i128;
let recipients = vec![5000u32, 5000u32];
let result = compute_split_test(total, &recipients, 250);

assert_eq!(result, vec![0i128, 0i128]);
}

/// Edge case: Very small amounts with rounding
#[test]
fn test_small_amounts_rounding() {
let total = 10i128;
let recipients = vec![3333u32, 3333u32, 3334u32]; // Sums to 10000
let result = compute_split_test(total, &recipients, 0);

// Sum should equal total (within rounding)
let sum: i128 = result.iter().sum();
assert!((sum - total).abs() <= 3, "Rounding error too large");
}
}
Loading