diff --git a/contracts/split/src/fuzz_tests.rs b/contracts/split/src/fuzz_tests.rs index ad93bad..7424b64 100644 --- a/contracts/split/src/fuzz_tests.rs +++ b/contracts/split/src/fuzz_tests.rs @@ -4,10 +4,16 @@ extern crate std; use proptest::prelude::*; -use std::vec::Vec; +use std::{vec, vec::Vec}; -const MAX_BPS: u32 = 10_000; +const TOTAL_BPS: u32 = 10_000; +fn percentage_weights() -> impl Strategy> { + proptest::collection::vec(0u32..=TOTAL_BPS, 1..=16) +} + +fn normalize_percentages(weights: &[u32]) -> Vec { + let total_weight: u64 = weights.iter().map(|weight| *weight as u64).sum(); // --------------------------------------------------------------------------- // Pure arithmetic helpers that mirror the contract's logic. // --------------------------------------------------------------------------- @@ -83,241 +89,144 @@ fn distribute_split( (payouts, fees, taxes, total_fee, total_tax) } -/// Penalty shares distributed across recipients (mirrors `_pay` penalty logic). -fn penalty_shares(penalty_amount: i128, amounts: &[i128]) -> Vec { - if penalty_amount <= 0 || amounts.is_empty() { - return std::vec![0i128; amounts.len()]; - } - let total_amounts: i128 = amounts.iter().sum(); - if total_amounts <= 0 { - return std::vec![0i128; amounts.len()]; + if total_weight == 0 { + let mut percentages = vec![0; weights.len()]; + percentages[0] = TOTAL_BPS; + return percentages; } - let n = amounts.len(); - let mut shares = std::vec![0i128; n]; - let mut distributed: i128 = 0; - for i in 0..n { - let share = if i == n - 1 { - penalty_amount.saturating_sub(distributed) - } else { - (penalty_amount as u128 * amounts[i] as u128 / total_amounts as u128) as i128 - }; - shares[i] = share; - distributed = distributed.saturating_add(share); - } - shares -} + let mut percentages = Vec::with_capacity(weights.len()); + let mut assigned = 0u32; -/// Distribute according to a SplitRule (mirrors `_release_full` rule matching). -fn split_rule_payout(rule_bps: Option, funded: i128) -> i128 { - match rule_bps { - Some(bps) => (funded as u128 * bps as u128 / MAX_BPS as u128) as i128, - None => 0, + for weight in weights.iter().take(weights.len() - 1) { + let percentage = ((*weight as u64 * TOTAL_BPS as u64) / total_weight) as u32; + percentages.push(percentage); + assigned += percentage; } -} - -// --------------------------------------------------------------------------- -// Strategy generators -// --------------------------------------------------------------------------- - -fn bps() -> impl Strategy { - 0u32..=MAX_BPS -} - -fn pos_i128() -> impl Strategy { - 1i128..=1_000_000_000_000_000i128 -} - -fn zero_or_pos_i128() -> impl Strategy { - 0i128..=1_000_000_000_000_000i128 -} + percentages.push(TOTAL_BPS - assigned); + percentages fn invoice_amounts() -> impl Strategy> { (1usize..=20usize).prop_flat_map(|n| proptest::collection::vec(pos_i128(), n)) } -// --------------------------------------------------------------------------- -// Property 1: Sum of recipient payouts + total fees == funded amount -// --------------------------------------------------------------------------- - -proptest! { - #![proptest_config(ProptestConfig::with_cases(10_000))] - #[test] - fn payouts_plus_fees_equal_funded( - amounts in invoice_amounts(), - funded in zero_or_pos_i128(), - platform_fee_bps in bps(), - tax_bps in bps(), - ) { - let total: i128 = amounts.iter().sum(); - prop_assume!(funded <= total); - - let (_payouts, _fees, _taxes, total_fee, total_tax) = - distribute_split(&amounts, funded, platform_fee_bps, tax_bps, false); - - let sum_payouts: i128 = _payouts.iter().sum(); - let gross = sum_payouts + total_fee + total_tax; - prop_assert_eq!(gross, funded, - "payouts({}) + fees({}) + taxes({}) = {} != funded({})", - sum_payouts, total_fee, total_tax, gross, funded); - } +fn recipient_entitlement(invoice_amount: u128, percentage_bps: u32) -> u128 { + let numerator = invoice_amount * percentage_bps as u128; + (numerator + TOTAL_BPS as u128 - 1) / TOTAL_BPS as u128 } -// --------------------------------------------------------------------------- -// Property 2: Penalty shares sum to penalty_amount, never exceed amount -// --------------------------------------------------------------------------- +fn split_payouts(invoice_amount: u128, percentages: &[u32]) -> Vec { + let mut payouts = percentages + .iter() + .map(|percentage| invoice_amount * *percentage as u128 / TOTAL_BPS as u128) + .collect::>(); -proptest! { - #![proptest_config(ProptestConfig::with_cases(10_000))] - #[test] - fn penalty_shares_sum_to_total( - amounts in invoice_amounts(), - payment_amount in pos_i128(), - penalty_bps in bps(), - ) { - let penalty_amount = (payment_amount as u128 * penalty_bps as u128 / MAX_BPS as u128) as i128; + let distributed: u128 = payouts.iter().sum(); + let mut remaining = invoice_amount - distributed; - // Invariant: penalty never exceeds payment amount - prop_assert!(penalty_amount <= payment_amount, - "penalty({}) > payment({})", penalty_amount, payment_amount); + while remaining > 0 { + let mut assigned_in_pass = false; - let shares = penalty_shares(penalty_amount, &amounts); - let sum_shares: i128 = shares.iter().sum(); + for (index, percentage) in percentages.iter().enumerate() { + if remaining == 0 { + break; + } - // All shares must be non-negative - for (i, &s) in shares.iter().enumerate() { - prop_assert!(s >= 0, "negative share at index {}", i); + let entitlement = recipient_entitlement(invoice_amount, *percentage); + if payouts[index] < entitlement { + payouts[index] += 1; + remaining -= 1; + assigned_in_pass = true; + } } - // Sum of shares must equal penalty amount - prop_assert_eq!(sum_shares, penalty_amount, - "penalty shares sum({}) != penalty_amount({})", sum_shares, penalty_amount); + assert!(assigned_in_pass, "no recipient can receive the remaining rounding unit"); } + + payouts } -// --------------------------------------------------------------------------- -// Property 3: SplitRule Percentage payouts -// --------------------------------------------------------------------------- +#[derive(Clone, Debug)] +struct ReleaseState { + released: bool, + payouts: Vec, +} -proptest! { - #![proptest_config(ProptestConfig::with_cases(10_000))] - #[test] - fn split_rule_percentage_accuracy( - funded in zero_or_pos_i128(), - bps_value in bps(), - ) { - let payout = split_rule_payout(Some(bps_value), funded); - let expected = (funded as u128 * bps_value as u128 / MAX_BPS as u128) as i128; - prop_assert_eq!(payout, expected, - "payout({}) != expected({}) for bps={}, funded={}", - payout, expected, bps_value, funded); +fn release_funds(state: &mut ReleaseState, invoice_amount: u128, percentages: &[u32]) { + if state.released { + return; } -} -// --------------------------------------------------------------------------- -// Property 4: Refund total == sum of payments -// --------------------------------------------------------------------------- + state.payouts = split_payouts(invoice_amount, percentages); + state.released = true; +} proptest! { - #![proptest_config(ProptestConfig::with_cases(10_000))] + #![proptest_config(ProptestConfig::with_cases(1000))] + #[test] - fn refund_returns_total_paid( - amounts in invoice_amounts(), - payment_split_bps in bps(), + fn payout_sum_equals_invoice_amount( + invoice_amount in 1u128..=1_000_000_000_000_000u128, + weights in percentage_weights(), ) { - let total: i128 = amounts.iter().sum(); - prop_assume!(total > 0); + let percentages = normalize_percentages(&weights); + let payouts = split_payouts(invoice_amount, &percentages); - // Simulate a single payer funding `payment_amount` = total * split_bps / 10000 - let payment_amount = (total as u128 * payment_split_bps as u128 / MAX_BPS as u128) as i128; - prop_assume!(payment_amount > 0); - - // The payer should get back exactly what they paid on refund - prop_assert_eq!(payment_amount, payment_amount, - "refund invariant holds"); + prop_assert_eq!(percentages.iter().sum::(), TOTAL_BPS); + prop_assert_eq!(payouts.iter().sum::(), invoice_amount); } -} -// --------------------------------------------------------------------------- -// Property 5: Multi-recipient proportional distribution correctness -// --------------------------------------------------------------------------- - -proptest! { - #![proptest_config(ProptestConfig::with_cases(10_000))] #[test] - fn proportional_distribution_no_remainder( - amounts in invoice_amounts(), - funded_pct in bps(), + fn no_recipient_exceeds_percentage_entitlement( + invoice_amount in 1u128..=1_000_000_000_000_000u128, + weights in percentage_weights(), ) { - let total: i128 = amounts.iter().sum(); - prop_assume!(total > 0); - - let funded = (total as u128 * funded_pct as u128 / MAX_BPS as u128) as i128; - prop_assume!(funded > 0); - - let n = amounts.len(); - let mut distributed: i128 = 0; - for i in 0..n { - let share = proportional_share(amounts[i], total, funded, i == n - 1, distributed); - distributed += share; - prop_assert!(share >= 0, "negative share at index {}", i); - prop_assert!(share <= funded, "share({}) > funded({}) at index {}", share, funded, i); - } + let percentages = normalize_percentages(&weights); + let payouts = split_payouts(invoice_amount, &percentages); - // The last-recipient-gets-remainder trick guarantees sum == funded - prop_assert_eq!(distributed, funded, - "distributed({}) != funded({})", distributed, funded); + for (payout, percentage) in payouts.iter().zip(percentages.iter()) { + let entitlement = recipient_entitlement(invoice_amount, *percentage); + prop_assert!(*payout <= entitlement); + } } -} - -// --------------------------------------------------------------------------- -// Property 6: Zero fees / zero tax edge case -// --------------------------------------------------------------------------- -proptest! { - #![proptest_config(ProptestConfig::with_cases(10_000))] #[test] - fn zero_fees_no_deduction( - amounts in invoice_amounts(), - funded in zero_or_pos_i128(), + fn release_funds_is_idempotent( + invoice_amount in 1u128..=1_000_000_000_000_000u128, + weights in percentage_weights(), ) { - let total: i128 = amounts.iter().sum(); - prop_assume!(total > 0 && funded <= total); + let percentages = normalize_percentages(&weights); + let mut state = ReleaseState { + released: false, + payouts: vec![0; percentages.len()], + }; - let (_payouts, _fees, _taxes, total_fee, total_tax) = - distribute_split(&amounts, funded, 0, 0, false); + release_funds(&mut state, invoice_amount, &percentages); + let payouts_after_first_release = state.payouts.clone(); - prop_assert_eq!(total_fee, 0, "expected zero fee"); - prop_assert_eq!(total_tax, 0, "expected zero tax"); + release_funds(&mut state, invoice_amount, &percentages); - let sum_payouts: i128 = _payouts.iter().sum(); - prop_assert_eq!(sum_payouts, funded, - "payouts({}) != funded({}) with zero fees", sum_payouts, funded); + prop_assert!(state.released); + prop_assert_eq!(state.payouts, payouts_after_first_release); + prop_assert_eq!(state.payouts.iter().sum::(), invoice_amount); } -} - -// --------------------------------------------------------------------------- -// Property 7: Single recipient always gets everything (net of fees) -// --------------------------------------------------------------------------- -proptest! { - #![proptest_config(ProptestConfig::with_cases(10_000))] #[test] - fn single_recipient_gets_all( - amount in pos_i128(), - funded_pct in bps(), - platform_fee_bps in bps(), - tax_bps in bps(), + fn fully_funded_invoice_has_at_least_target_amount( + target_amount in 0u128..=1_000_000_000_000_000u128, + additional_funding in 0u128..=1_000_000_000_000_000u128, ) { - let funded = (amount as u128 * funded_pct as u128 / MAX_BPS as u128) as i128; - prop_assume!(funded > 0); + let funded_amount = target_amount + additional_funding; + prop_assert!(funded_amount >= target_amount); + } - let (payouts, _fees, _taxes, total_fee, total_tax) = - distribute_split(&[amount], funded, platform_fee_bps, tax_bps, false); + #[test] + fn recipient_percentages_sum_to_exactly_one_hundred_percent( + weights in percentage_weights(), + ) { + let percentages = normalize_percentages(&weights); - prop_assert_eq!(payouts.len(), 1); - prop_assert_eq!(payouts[0] + total_fee + total_tax, funded, - "single recipient: payout({}) + fee({}) + tax({}) != funded({})", - payouts[0], total_fee, total_tax, funded); + prop_assert_eq!(percentages.iter().sum::(), TOTAL_BPS); + prop_assert!(percentages.iter().all(|percentage| *percentage <= TOTAL_BPS)); } }