diff --git a/src/lib.rs b/src/lib.rs index 053a46b7..732275ba 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10796,7 +10796,7 @@ impl RevoraRevenueShare { ); let n = holders.len(); - let mut payouts: Vec = Vec::new(env); + let mut payout_rows: std::vec::Vec<(u32, u32, Address, i128)> = std::vec::Vec::new(); let mut total: i128 = 0; for i in 0..n { @@ -10829,8 +10829,19 @@ impl RevoraRevenueShare { Self::compute_share(env.clone(), period_revenue, bounded_bps, mode); total = total.saturating_add(normalized_payout); + payout_rows.push((bounded_bps, share_bps, holder.clone(), normalized_payout)); + } + + payout_rows.sort_by(|a, b| match b.0.cmp(&a.0) { + core::cmp::Ordering::Equal => a.2.cmp(&b.2), + other => other, + }); + + let mut payouts: Vec = Vec::new(env); + for (bounded_bps, share_bps, holder, normalized_payout) in payout_rows { + let _ = bounded_bps; payouts.push_back(DistributionEntry { - holder: holder.clone(), + holder, share_bps, normalized_payout, }); diff --git a/src/proptest_helpers.rs b/src/proptest_helpers.rs index bbc71ec7..e4b92e18 100644 --- a/src/proptest_helpers.rs +++ b/src/proptest_helpers.rs @@ -30,6 +30,21 @@ use proptest::prelude::*; +/// Return a reproducible shuffled copy of a slice using a deterministic local PRNG. +/// Useful for regression tests that need to exercise many re-orderings of the same fixture. +pub fn shuffle_vec_with_seed(values: &[T], seed: u64) -> std::vec::Vec { + let mut shuffled = values.to_vec(); + let mut state = seed.wrapping_mul(0x9E3779B97F4A7C15).wrapping_add(0x85EBCA6B); + + for index in (1..shuffled.len()).rev() { + state = state.rotate_left(17).wrapping_mul(0x9E3779B97F4A7C15); + let j = (state as usize) % (index + 1); + shuffled.swap(index, j); + } + + shuffled +} + // ── Primitive strategies ───────────────────────────────────────────────────── /// Any valid basis-points value (0–10 000 inclusive). diff --git a/src/test_close_period.rs b/src/test_close_period.rs index 67d496b3..a7f686a4 100644 --- a/src/test_close_period.rs +++ b/src/test_close_period.rs @@ -1,5 +1,7 @@ #![cfg(test)] use super::*; +use crate::proptest_helpers::shuffle_vec_with_seed; +use proptest::prelude::*; use soroban_sdk::{ testutils::{Address as _, Events as _, Ledger}, token, Address, Env, @@ -84,6 +86,93 @@ fn setup_offering() -> (Env, RevoraRevenueShareClient<'static>, Address, Address (env, client, issuer, token, payment_token) } +proptest! { + #![proptest_config(ProptestConfig { + cases: 16, + ..ProptestConfig::default() + })] + + #[test] + fn close_period_preflight_is_deterministic_across_shuffled_holders( + seed in any::(), + ) { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, RevoraRevenueShare); + let client = RevoraRevenueShareClient::new(&env, &contract_id); + + let issuer = Address::generate(&env); + let token = Address::generate(&env); + let payment_token = env.register_stellar_asset_contract_v2(issuer.clone()).address(); + let ns = symbol_short!("ns"); + + client.register_offering( + &issuer, + &Vec::from_array(&env, []), + &1u32, + &ns, + &token, + &10_000u32, + &payment_token, + &0i128, + &symbol_short!(""), + &0u32, + ); + + let holder_a = Address::generate(&env); + let holder_b = Address::generate(&env); + let holder_c = Address::generate(&env); + let holder_d = Address::generate(&env); + let holder_e = Address::generate(&env); + + client.set_holder_share(&issuer, &ns, &token, &holder_a, &3_000u32); + client.set_holder_share(&issuer, &ns, &token, &holder_b, &2_500u32); + client.set_holder_share(&issuer, &ns, &token, &holder_c, &1_500u32); + client.set_holder_share(&issuer, &ns, &token, &holder_d, &1_000u32); + client.set_holder_share(&issuer, &ns, &token, &holder_e, &2_000u32); + + mint(&env, &payment_token, &issuer, 10_000_000); + client.deposit_revenue(&issuer, &ns, &token, &payment_token, &10_000_000i128, &1u64); + + let base_holders = std::vec![ + holder_a.clone(), + holder_b.clone(), + holder_c.clone(), + holder_d.clone(), + holder_e.clone(), + holder_a.clone(), + ]; + + let mut baseline: Option = None; + for iteration in 0..512u64 { + let shuffled = shuffle_vec_with_seed(&base_holders, seed.wrapping_add(iteration)); + let mut soroban_holders = Vec::new(&env); + for holder in shuffled.iter() { + soroban_holders.push_back(holder.clone()); + } + + let result = RevoraRevenueShare::preflight_close_period( + env.clone(), + OfferingId { + issuer: issuer.clone(), + namespace: ns.clone(), + token: token.clone(), + }, + 1u64, + soroban_holders, + ) + .unwrap(); + + if let Some(ref expected) = baseline { + prop_assert_eq!(result.payouts, expected.payouts); + prop_assert_eq!(result.total_distributed, expected.total_distributed); + } else { + baseline = Some(result); + } + } + } +} + #[test] fn close_period_happy_path() { let (_env, client, issuer, token, _payment) = setup_offering();