Skip to content
Open
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
1 change: 1 addition & 0 deletions contracts/cascade_splitter/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@ doctest = false
soroban-sdk = { workspace = true }

[dev-dependencies]
proptest = "1.6"
soroban-sdk = { workspace = true, features = ["testutils"] }
220 changes: 220 additions & 0 deletions contracts/cascade_splitter/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,3 +158,223 @@ fn no_table_pays_owner_in_full() {
s.c.distribute(&s.payer, &owner, &s.token.address, &42);
assert_eq!(s.token.balance(&owner), 42);
}

mod proptests {
use super::*;
use proptest::{prop_assert, prop_assert_eq, proptest, test_runner::ProptestConfig};

fn normalize_weights(raw: &[u32; 4]) -> [u32; 4] {
let sum: u32 = raw.iter().sum();
if sum == 0 {
return [0; 4];
}
if sum <= TOTAL_BPS {
return *raw;
}
let mut out = [0u32; 4];
let mut total = 0u32;
for k in 0..4 {
if raw[k] > 0 {
let scaled = ((raw[k] as u64 * TOTAL_BPS as u64) / sum as u64) as u32;
let val = scaled.max(1);
out[k] = val;
total += val;
}
}
while total > TOTAL_BPS {
let mut max_idx = 0;
for k in 1..4 {
if out[k] > out[max_idx] {
max_idx = k;
}
}
out[max_idx] -= 1;
total -= 1;
}
out
}

proptest! {
#![proptest_config(ProptestConfig::with_cases(64))]

#[test]
fn property_test_random_split_graphs_conserve_funds(
raw_w in proptest::collection::vec(0u32..=10_000u32, 20),
amount in 1i128..=1_000_000_000_000_000i128,
root_idx in 0usize..5usize,
) {
let env = Env::default();
env.mock_all_auths();
let sac = env.register_stellar_asset_contract_v2(Address::generate(&env));
let token = TokenClient::new(&env, &sac.address());
let payer = Address::generate(&env);
StellarAssetClient::new(&env, &sac.address()).mint(&payer, &amount);

let id = env.register(CascadeSplitter, ());
let client = CascadeSplitterClient::new(&env, &id);

let nodes = [
Address::generate(&env),
Address::generate(&env),
Address::generate(&env),
Address::generate(&env),
Address::generate(&env),
];

for i in 0..5 {
let mut raw_slice = [0u32; 4];
raw_slice.copy_from_slice(&raw_w[i * 4..(i + 1) * 4]);
let norm = normalize_weights(&raw_slice);

let neighbors: std::vec::Vec<Address> = (0..5)
.filter(|&j| j != i)
.map(|j| nodes[j].clone())
.collect();

let mut splits_vec = soroban_sdk::Vec::new(&env);
for (k, &bps) in norm.iter().enumerate() {
if bps > 0 {
splits_vec.push_back(split(&env, &neighbors[k], bps, "pkg"));
}
}
if !splits_vec.is_empty() {
client.set_splits(&nodes[i], &splits_vec);
}
}

let root = &nodes[root_idx];
let preview = client.preview(root, &amount);

// Invariant 1: Total payouts must identically conserve funds
let sum_preview: i128 = preview.iter().map(|p| p.amount).sum();
prop_assert_eq!(sum_preview, amount);

// Invariant 2: Bounded depth (<= MAX_DEPTH) and positive payouts
for p in preview.iter() {
prop_assert!(p.amount > 0, "non-positive payout");
prop_assert!(p.depth <= MAX_DEPTH, "depth {} > MAX_DEPTH", p.depth);
}

// Invariant 3: Actual token distribution mirrors preview and conserves balance
let payouts = client.distribute(&payer, root, &token.address, &amount);
prop_assert_eq!(payouts, preview);

let mut total_transferred = 0i128;
for n in nodes.iter() {
total_transferred += token.balance(n);
}
prop_assert_eq!(total_transferred, amount);
prop_assert_eq!(token.balance(&payer), 0);
}

#[test]
fn property_test_two_node_cycles_conserve_funds(
bps_a in 1u32..=10_000u32,
bps_b in 1u32..=10_000u32,
amount in 1i128..=1_000_000_000_000_000i128,
) {
let env = Env::default();
env.mock_all_auths();
let sac = env.register_stellar_asset_contract_v2(Address::generate(&env));
let token = TokenClient::new(&env, &sac.address());
let payer = Address::generate(&env);
StellarAssetClient::new(&env, &sac.address()).mint(&payer, &amount);

let id = env.register(CascadeSplitter, ());
let client = CascadeSplitterClient::new(&env, &id);

let a = Address::generate(&env);
let b = Address::generate(&env);

client.set_splits(&a, &soroban_sdk::vec![&env, split(&env, &b, bps_a, "b")]);
client.set_splits(&b, &soroban_sdk::vec![&env, split(&env, &a, bps_b, "a")]);

let payouts = client.distribute(&payer, &a, &token.address, &amount);
let total: i128 = payouts.iter().map(|p| p.amount).sum();
prop_assert_eq!(total, amount);

for p in payouts.iter() {
prop_assert!(p.depth <= MAX_DEPTH);
prop_assert!(p.amount > 0);
}

prop_assert_eq!(token.balance(&a) + token.balance(&b), amount);
prop_assert_eq!(token.balance(&payer), 0);
}

#[test]
fn property_test_three_node_cycles_conserve_funds(
bps_a in 1u32..=10_000u32,
bps_b in 1u32..=10_000u32,
bps_c in 1u32..=10_000u32,
amount in 1i128..=1_000_000_000_000_000i128,
) {
let env = Env::default();
env.mock_all_auths();
let sac = env.register_stellar_asset_contract_v2(Address::generate(&env));
let token = TokenClient::new(&env, &sac.address());
let payer = Address::generate(&env);
StellarAssetClient::new(&env, &sac.address()).mint(&payer, &amount);

let id = env.register(CascadeSplitter, ());
let client = CascadeSplitterClient::new(&env, &id);

let a = Address::generate(&env);
let b = Address::generate(&env);
let c = Address::generate(&env);

client.set_splits(&a, &soroban_sdk::vec![&env, split(&env, &b, bps_a, "b")]);
client.set_splits(&b, &soroban_sdk::vec![&env, split(&env, &c, bps_b, "c")]);
client.set_splits(&c, &soroban_sdk::vec![&env, split(&env, &a, bps_c, "a")]);

let payouts = client.distribute(&payer, &a, &token.address, &amount);
let total: i128 = payouts.iter().map(|p| p.amount).sum();
prop_assert_eq!(total, amount);

for p in payouts.iter() {
prop_assert!(p.depth <= MAX_DEPTH);
prop_assert!(p.amount > 0);
}

prop_assert_eq!(token.balance(&a) + token.balance(&b) + token.balance(&c), amount);
prop_assert_eq!(token.balance(&payer), 0);
}

#[test]
fn property_test_depth_bound_on_deep_chain(
amount in 1i128..=1_000_000_000_000_000i128,
) {
let env = Env::default();
env.mock_all_auths();
let id = env.register(CascadeSplitter, ());
let client = CascadeSplitterClient::new(&env, &id);

let nodes: [Address; 6] = [
Address::generate(&env),
Address::generate(&env),
Address::generate(&env),
Address::generate(&env),
Address::generate(&env),
Address::generate(&env),
];

for i in 0..5 {
client.set_splits(&nodes[i], &soroban_sdk::vec![&env, split(&env, &nodes[i + 1], 10_000, "hop")]);
}

let payouts = client.preview(&nodes[0], &amount);
let total: i128 = payouts.iter().map(|p| p.amount).sum();
prop_assert_eq!(total, amount);

for p in payouts.iter() {
prop_assert!(p.depth <= MAX_DEPTH);
}

prop_assert_eq!(payouts.len(), 1);
let p0 = payouts.get(0).unwrap();
prop_assert_eq!(p0.recipient, nodes[3]);
prop_assert_eq!(p0.amount, amount);
prop_assert_eq!(p0.depth, 3);
}
}
}