Skip to content
Merged
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
293 changes: 101 additions & 192 deletions contracts/split/src/fuzz_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Value = Vec<u32>> {
proptest::collection::vec(0u32..=TOTAL_BPS, 1..=16)
}

fn normalize_percentages(weights: &[u32]) -> Vec<u32> {
let total_weight: u64 = weights.iter().map(|weight| *weight as u64).sum();
// ---------------------------------------------------------------------------
// Pure arithmetic helpers that mirror the contract's logic.
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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<i128> {
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<u32>, 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<Value = u32> {
0u32..=MAX_BPS
}

fn pos_i128() -> impl Strategy<Value = i128> {
1i128..=1_000_000_000_000_000i128
}

fn zero_or_pos_i128() -> impl Strategy<Value = i128> {
0i128..=1_000_000_000_000_000i128
}

percentages.push(TOTAL_BPS - assigned);
percentages
fn invoice_amounts() -> impl Strategy<Value = Vec<i128>> {
(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<u128> {
let mut payouts = percentages
.iter()
.map(|percentage| invoice_amount * *percentage as u128 / TOTAL_BPS as u128)
.collect::<Vec<_>>();

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<u128>,
}

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::<u32>(), TOTAL_BPS);
prop_assert_eq!(payouts.iter().sum::<u128>(), 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::<u128>(), 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::<u32>(), TOTAL_BPS);
prop_assert!(percentages.iter().all(|percentage| *percentage <= TOTAL_BPS));
}
}
Loading