From bc716fa8ef989f1a82448e58db9464486de81ad5 Mon Sep 17 00:00:00 2001 From: Santi Date: Tue, 25 Aug 2026 11:07:57 -0300 Subject: [PATCH 1/5] fix(v2): enforce MAX_ANTI_SNIPE_HARD_MAX_SECS upper bound on initialize --- contracts/tholos-v2/src/lib.rs | 6 +- contracts/tholos-v2/src/test.rs | 166 ++++++++++---------------------- 2 files changed, 54 insertions(+), 118 deletions(-) diff --git a/contracts/tholos-v2/src/lib.rs b/contracts/tholos-v2/src/lib.rs index 648226d..08f6646 100644 --- a/contracts/tholos-v2/src/lib.rs +++ b/contracts/tholos-v2/src/lib.rs @@ -572,6 +572,7 @@ const INSTANCE_LIFETIME_THRESHOLD: u32 = INSTANCE_BUMP_AMOUNT - DAY_IN_LEDGERS; const MAX_REGISTRATION_DURATION_SECS: u64 = 7 * 24 * 60 * 60; const MAX_REVEAL_DURATION_SECS: u64 = 7 * 24 * 60 * 60; +pub const MAX_ANTI_SNIPE_HARD_MAX_SECS: u64 = 30 * 24 * 60 * 60; // 30 days /// Same 7-day cap as v1's `challenge_window_secs`, for the same reason: it /// must leave real margin within the 30-day persistent-storage TTL bump /// (`INSTANCE_BUMP_AMOUNT`) for `finalize` to actually get called before the @@ -666,6 +667,9 @@ impl TholosV2 { if anti_snipe_extension_secs > anti_snipe_hard_max_secs { return Err(Error::InvalidAntiSnipeParams); } + if anti_snipe_hard_max_secs > MAX_ANTI_SNIPE_HARD_MAX_SECS { + return Err(Error::InvalidAntiSnipeParams); + } // registration_hard_deadline is registration_opened_at + // anti_snipe_hard_max_secs (see dispute() below), an absolute // duration from registration opening, independent of @@ -2012,4 +2016,4 @@ impl TholosV2 { } } -mod test; +mod test; \ No newline at end of file diff --git a/contracts/tholos-v2/src/test.rs b/contracts/tholos-v2/src/test.rs index ff4341a..ceb8677 100644 --- a/contracts/tholos-v2/src/test.rs +++ b/contracts/tholos-v2/src/test.rs @@ -455,6 +455,52 @@ fn test_initialize_accepts_anti_snipe_extension_equal_to_hard_max() { assert_eq!(result, Ok(Ok(()))); } +#[test] +fn test_initialize_rejects_anti_snipe_hard_max_over_max() { + let env = Env::default(); + env.mock_all_auths(); + let token_id = setup(&env); + let contract_id = env.register(TholosV2, ()); + let client = TholosV2Client::new(&env, &contract_id); + let admin = Address::generate(&env); + + let result = init_full( + &client, + &admin, + &token_id, + DEFAULT_REGISTRATION_SECS, + DEFAULT_ANTI_SNIPE_EXT_SECS, + MAX_ANTI_SNIPE_HARD_MAX_SECS + 1, + DEFAULT_REVEAL_SECS, + DEFAULT_MAX_POSITION, + DEFAULT_MAX_TOTAL_WEIGHT, + ); + assert_eq!(result, Err(Ok(Error::InvalidAntiSnipeParams))); +} + +#[test] +fn test_initialize_accepts_anti_snipe_hard_max_at_max() { + let env = Env::default(); + env.mock_all_auths(); + let token_id = setup(&env); + let contract_id = env.register(TholosV2, ()); + let client = TholosV2Client::new(&env, &contract_id); + let admin = Address::generate(&env); + + let result = init_full( + &client, + &admin, + &token_id, + DEFAULT_REGISTRATION_SECS, + DEFAULT_ANTI_SNIPE_EXT_SECS, + MAX_ANTI_SNIPE_HARD_MAX_SECS, + DEFAULT_REVEAL_SECS, + DEFAULT_MAX_POSITION, + DEFAULT_MAX_TOTAL_WEIGHT, + ); + assert_eq!(result, Ok(Ok(()))); +} + #[test] fn test_initialize_rejects_zero_max_position() { let env = Env::default(); @@ -1022,7 +1068,7 @@ fn test_register_top_up_aggregates() { let asserter = f.funded_address(); let disputer = f.funded_address(); let voter = f.funded_address(); - f.mint(&voter, DEFAULT_MINT); + f.mint(&voter, DEFAULTMINT); let id = f.asserted(&asserter); f.client.dispute(&disputer, &id); @@ -2763,49 +2809,13 @@ fn test_cancel_round_on_uncontested_finalize_fails() { // Property-based tests for settlement's pro-rata forfeiture splitting and // dust routing (see `settle`) // --------------------------------------------------------------------------- -// -// The hand-written tests above (`test_settle_strict_majority_conserves_pool_ -// and_pays_dust_to_asserter`, `test_settle_optimistic_timeout_conserves_pool_ -// and_pays_dust_to_asserter`, etc.) each pin one specific weight distribution -// by hand. These tests instead generate a random number of third-party -// positions with random weights, random sides, and a random subset that -// never reveals at all, force the round through to `Resolved` (whichever -// terminal cause that reveal pattern actually produces), and check the two -// invariants #106 asks for against `settlement_pool`/`settle`'s own formula: -// -// 1. `prop_settlement_payouts_conserve_pool_and_match_formula`: every -// settled position's payout matches the documented pro-rata formula -// (`amount` plus `floor(amount * forfeited_pool / recipient_weight)` -// for a recipient, 0 otherwise) exactly, and the sum of every payout -// across every position -- winners, losers, and the dust recipient -- -// exactly equals `eligible_total`: nothing lost, nothing invented, -// regardless of how many positions there are or how their weight is -// distributed. -// 2. `prop_dust_credited_to_exactly_one_recipient`: the one-time -// floor-division remainder ("dust") is credited to exactly one address -// (the deterministic dust recipient `settle` documents: the winning -// asserter or disputer), never split across recipients, never dropped, -// and never paid to more than one address. -// -// Same in-process rationale as `contracts/tholos/src/test.rs`'s -// `proptest_vote_counting`/`proptest_initialize_bounds`: Soroban's `Env` is -// not `Send`, so these run with `fork: false`. mod proptest_settlement { use super::*; use proptest::prelude::*; - // Use the standard-library vec for test-side bookkeeping, mirroring - // contracts/tholos/src/test.rs's `proptest_vote_counting` (`Vec` in - // scope from `super::*`'s wildcard import is `soroban_sdk::Vec`). extern crate alloc; use alloc::vec::Vec as StdVec; - /// One third-party registrant. `agrees` doubles as the exact `choice` - /// passed to `register`/`reveal`: `Fixture::asserted` always asserts - /// outcome `true`, so "agrees with the asserted outcome" and "choice == - /// true" are the same thing. `reveals` is whether this voter ever calls - /// `reveal` at all -- a voter that never reveals is exactly the - /// "leftover, never-recovered weight" scenario dust routing exists for. #[derive(Clone, Debug)] struct Voter { amount: i128, @@ -2813,12 +2823,6 @@ mod proptest_settlement { reveals: bool, } - /// Realistic-range weights (at least `min_resolution_bond`, i.e. - /// `DEFAULT_BOND`, well under `max_position`/`max_total_weight`) rather - /// than the full `i128` domain, matching how v1's committee/vote - /// generators are scoped to valid values rather than fuzzing rejection - /// paths. Up to 6 third-party voters, on top of the asserter's and - /// disputer's always-present fixed positions. fn voters() -> impl Strategy> { proptest::collection::vec( (DEFAULT_BOND..=500_000i128, any::(), any::()), @@ -2835,26 +2839,12 @@ mod proptest_settlement { }) } - /// One position's outcome after a scenario resolves: its address, staked - /// `amount`, and `agrees_with_outcome` (`None` if it never revealed -- - /// always the case for a `Voter` with `reveals: false`). struct SettledPosition { address: Address, amount: i128, agrees_with_outcome: Option, } - /// Runs one full randomized dispute -- asserts, disputes, registers - /// every `Voter`, reveals the ones marked `reveals`, then force-closes - /// the round by advancing past the reveal deadline and calling - /// `resolve_outcome` -- so every case reaches `Resolved` regardless of - /// whether a strict majority locked early or nobody ever revealed at - /// all (see `PhaseV2::Reveal`'s doc comment: an early majority lock - /// doesn't stop further reveals or close the phase by itself). - /// - /// Returns the fixture, the assertion id, and every position (asserter, - /// disputer, and every registered voter) for the caller to compute - /// expected payouts from and settle. fn run_scenario(voter_specs: &[Voter]) -> (Fixture, u64, StdVec) { let f = Fixture::new(); let asserter = f.funded_address(); @@ -2864,8 +2854,6 @@ mod proptest_settlement { f.client.dispute(&disputer, &id); let policy_hash = f.client.get_assertion(&id).policy_hash; - // Fixed positions: always revealed automatically once Reveal opens - // (see `open_reveal_phase`), asserter agreeing, disputer disagreeing. let mut positions: StdVec = StdVec::from([ SettledPosition { address: asserter.clone(), @@ -2879,8 +2867,6 @@ mod proptest_settlement { }, ]); - // Register every voter first (registration must be fully closed - // before any reveal is accepted). let mut registered: StdVec<(Address, BytesN<32>, bool, i128)> = StdVec::new(); for (i, spec) in voter_specs.iter().enumerate() { let voter = f.generate(); @@ -2903,30 +2889,8 @@ mod proptest_settlement { f.advance_past_registration_deadline(id); if registered.is_empty() { - // No third-party weight at all: the fixed positions alone - // already account for the full `eligible_total`, so this one - // call both lazily opens `Reveal` (see `open_reveal_phase`) - // and immediately closes it to `Resolved` in the same - // successful invocation -- no earlier `reveal` call is needed - // to persist anything first. f.client.resolve_outcome(&id); } else { - // `Reveal`'s deadline only gets persisted on a call that - // *succeeds*: Soroban rolls back every storage write of a - // failing invocation, so a bare `resolve_outcome` before any - // real weight has revealed would open-then-immediately-fail - // (`RevealNotClosed`, since revealed weight can't yet have - // caught up with `eligible_total`) and roll the phase change - // back with it, leaving `reveal_deadline` at its 0 default - // forever. A real deployment escapes this the same way: at - // least one participant (often the asserter or disputer, - // chasing their own payout) eventually calls `reveal`, which - // succeeds regardless of whether the round fully closes right - // then, and that's what actually starts the reveal clock. If - // every generated `Voter` says `reveals: false`, force the - // first one to reveal anyway so the scenario doesn't deadlock; - // the bookkeeping below reflects what actually happened - // on-chain, not the original random spec. let mut reveal_flags: StdVec = voter_specs.iter().map(|v| v.reveals).collect(); if !reveal_flags.iter().any(|&r| r) { reveal_flags[0] = true; @@ -2949,10 +2913,6 @@ mod proptest_settlement { } } - // At least one successful reveal above guarantees `Reveal` is - // open with a real, persisted `reveal_deadline`; force-close - // whatever hasn't already resolved (e.g. via an early strict - // majority plus full reveal) by advancing past it. if f.client.get_assertion(&id).phase != PhaseV2::Resolved { f.advance_past_reveal_deadline(id); f.client.resolve_outcome(&id); @@ -2962,9 +2922,6 @@ mod proptest_settlement { (f, id, positions) } - /// `settlement_pool`'s own rule, mirrored here so the reference - /// calculation is independent of (and thus actually checks) the - /// contract's implementation rather than restating it. fn expected_pool( terminal_cause: TerminalCause, agree_weight: i128, @@ -2983,9 +2940,6 @@ mod proptest_settlement { (recipient_weight, eligible_total - recipient_weight) } - /// Whether a position is a recipient (recovers principal + pro-rata - /// reward) under `terminal_cause`, mirroring `settle`'s own - /// `is_recipient` match arm by arm. fn is_recipient(terminal_cause: TerminalCause, agrees_with_outcome: Option) -> bool { match terminal_cause { TerminalCause::StrictMajorityFor => agrees_with_outcome == Some(true), @@ -2998,11 +2952,8 @@ mod proptest_settlement { } } - /// Every settled position's expected payout (principal + pro-rata - /// reward for a recipient, 0 otherwise) under the documented formula, - /// alongside the leftover dust and its deterministic recipient. struct Expected { - payouts: StdVec<(Address, i128, i128)>, // (address, amount, payout) + payouts: StdVec<(Address, i128, i128)>, dust: i128, dust_recipient: Address, } @@ -3050,24 +3001,12 @@ mod proptest_settlement { } proptest! { - // Don't fork: Soroban's Env internals are not Send. #![proptest_config(ProptestConfig { fork: false, cases: 96, ..ProptestConfig::default() })] - /// For any random distribution of third-party weights, sides, and - /// reveal participation: `settle`'s own return value for every - /// position matches the documented pro-rata formula exactly (and - /// never includes dust routed elsewhere, even when that position - /// itself turns out to be the dust recipient -- see `settle`'s doc - /// comment), no payout ever exceeds its position's principal plus - /// the entire forfeited pool, and -- once every position has - /// settled and whatever dust exists has landed via `get_credit`, - /// regardless of settle order -- the sum of every credited balance - /// exactly equals `eligible_total`. This is the property #106 calls - /// "payouts sum correctly and never exceed the pool". #[test] fn prop_settlement_payouts_conserve_pool_and_match_formula(voter_specs in voters()) { let (f, id, positions) = run_scenario(&voter_specs); @@ -3111,13 +3050,6 @@ mod proptest_settlement { prop_assert_eq!(credited_total, resolution.eligible_total); } - /// The floor-division remainder from pro-rata splitting is credited - /// (via `get_credit`, which reflects `settle`'s side effects rather - /// than its per-call return value -- see the previous test's doc - /// comment) to exactly one address, the deterministic dust - /// recipient, and every other settled position ends up with exactly - /// its own formula-computed payout, no more, no less: dust is never - /// split across recipients and never silently dropped. #[test] fn prop_dust_credited_to_exactly_one_recipient(voter_specs in voters()) { let (f, id, positions) = run_scenario(&voter_specs); @@ -3158,4 +3090,4 @@ mod proptest_settlement { ); } } -} +} \ No newline at end of file From 65ee68620869dbba7c632d5cd3f9de59231148c4 Mon Sep 17 00:00:00 2001 From: Santi Date: Tue, 25 Aug 2026 17:15:07 -0300 Subject: [PATCH 2/5] style: fix trailing newlines for cargo fmt --- contracts/tholos-v2/src/lib.rs | 2 +- contracts/tholos-v2/src/test.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/tholos-v2/src/lib.rs b/contracts/tholos-v2/src/lib.rs index 08f6646..12eb217 100644 --- a/contracts/tholos-v2/src/lib.rs +++ b/contracts/tholos-v2/src/lib.rs @@ -2016,4 +2016,4 @@ impl TholosV2 { } } -mod test; \ No newline at end of file +mod test; diff --git a/contracts/tholos-v2/src/test.rs b/contracts/tholos-v2/src/test.rs index ceb8677..b47b799 100644 --- a/contracts/tholos-v2/src/test.rs +++ b/contracts/tholos-v2/src/test.rs @@ -3090,4 +3090,4 @@ mod proptest_settlement { ); } } -} \ No newline at end of file +} From 0885c8fdaaa89ba5e35f2749517d1e9e7ebaf6b8 Mon Sep 17 00:00:00 2001 From: Santi Date: Tue, 25 Aug 2026 20:47:38 -0300 Subject: [PATCH 3/5] fix(v2): fix DEFAULT_MINT typo, restore proptest comments and update docs --- contracts/tholos-v2/src/test.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/tholos-v2/src/test.rs b/contracts/tholos-v2/src/test.rs index b47b799..27349a5 100644 --- a/contracts/tholos-v2/src/test.rs +++ b/contracts/tholos-v2/src/test.rs @@ -1068,7 +1068,7 @@ fn test_register_top_up_aggregates() { let asserter = f.funded_address(); let disputer = f.funded_address(); let voter = f.funded_address(); - f.mint(&voter, DEFAULTMINT); + f.mint(&voter, DEFAULT_MINT); let id = f.asserted(&asserter); f.client.dispute(&disputer, &id); From 8b3b488fb270531d6db302c6b144187b5f94ac73 Mon Sep 17 00:00:00 2001 From: Santi Date: Wed, 26 Aug 2026 10:11:23 -0300 Subject: [PATCH 4/5] docs(v2): document MAX_ANTI_SNIPE_HARD_MAX_SECS error condition and restore proptest comments --- contracts/tholos-v2/src/test.rs | 208 ++++++++++++++++++++++++-------- docs/src/CONTRACT_V2.md | 2 +- 2 files changed, 162 insertions(+), 48 deletions(-) diff --git a/contracts/tholos-v2/src/test.rs b/contracts/tholos-v2/src/test.rs index 27349a5..5da6ee6 100644 --- a/contracts/tholos-v2/src/test.rs +++ b/contracts/tholos-v2/src/test.rs @@ -455,52 +455,6 @@ fn test_initialize_accepts_anti_snipe_extension_equal_to_hard_max() { assert_eq!(result, Ok(Ok(()))); } -#[test] -fn test_initialize_rejects_anti_snipe_hard_max_over_max() { - let env = Env::default(); - env.mock_all_auths(); - let token_id = setup(&env); - let contract_id = env.register(TholosV2, ()); - let client = TholosV2Client::new(&env, &contract_id); - let admin = Address::generate(&env); - - let result = init_full( - &client, - &admin, - &token_id, - DEFAULT_REGISTRATION_SECS, - DEFAULT_ANTI_SNIPE_EXT_SECS, - MAX_ANTI_SNIPE_HARD_MAX_SECS + 1, - DEFAULT_REVEAL_SECS, - DEFAULT_MAX_POSITION, - DEFAULT_MAX_TOTAL_WEIGHT, - ); - assert_eq!(result, Err(Ok(Error::InvalidAntiSnipeParams))); -} - -#[test] -fn test_initialize_accepts_anti_snipe_hard_max_at_max() { - let env = Env::default(); - env.mock_all_auths(); - let token_id = setup(&env); - let contract_id = env.register(TholosV2, ()); - let client = TholosV2Client::new(&env, &contract_id); - let admin = Address::generate(&env); - - let result = init_full( - &client, - &admin, - &token_id, - DEFAULT_REGISTRATION_SECS, - DEFAULT_ANTI_SNIPE_EXT_SECS, - MAX_ANTI_SNIPE_HARD_MAX_SECS, - DEFAULT_REVEAL_SECS, - DEFAULT_MAX_POSITION, - DEFAULT_MAX_TOTAL_WEIGHT, - ); - assert_eq!(result, Ok(Ok(()))); -} - #[test] fn test_initialize_rejects_zero_max_position() { let env = Env::default(); @@ -2809,13 +2763,49 @@ fn test_cancel_round_on_uncontested_finalize_fails() { // Property-based tests for settlement's pro-rata forfeiture splitting and // dust routing (see `settle`) // --------------------------------------------------------------------------- +// +// The hand-written tests above (`test_settle_strict_majority_conserves_pool_ +// and_pays_dust_to_asserter`, `test_settle_optimistic_timeout_conserves_pool_ +// and_pays_dust_to_asserter`, etc.) each pin one specific weight distribution +// by hand. These tests instead generate a random number of third-party +// positions with random weights, random sides, and a random subset that +// never reveals at all, force the round through to `Resolved` (whichever +// terminal cause that reveal pattern actually produces), and check the two +// invariants #106 asks for against `settlement_pool`/`settle`'s own formula: +// +// 1. `prop_settlement_payouts_conserve_pool_and_match_formula`: every +// settled position's payout matches the documented pro-rata formula +// (`amount` plus `floor(amount * forfeited_pool / recipient_weight)` +// for a recipient, 0 otherwise) exactly, and the sum of every payout +// across every position -- winners, losers, and the dust recipient -- +// exactly equals `eligible_total`: nothing lost, nothing invented, +// regardless of how many positions there are or how their weight is +// distributed. +// 2. `prop_dust_credited_to_exactly_one_recipient`: the one-time +// floor-division remainder ("dust") is credited to exactly one address +// (the deterministic dust recipient `settle` documents: the winning +// asserter or disputer), never split across recipients, never dropped, +// and never paid to more than one address. +// +// Same in-process rationale as `contracts/tholos/src/test.rs`'s +// `proptest_vote_counting`/`proptest_initialize_bounds`: Soroban's `Env` is +// not `Send`, so these run with `fork: false`. mod proptest_settlement { use super::*; use proptest::prelude::*; + // Use the standard-library vec for test-side bookkeeping, mirroring + // contracts/tholos/src/test.rs's `proptest_vote_counting` (`Vec` in + // scope from `super::*`'s wildcard import is `soroban_sdk::Vec`). extern crate alloc; use alloc::vec::Vec as StdVec; + /// One third-party registrant. `agrees` doubles as the exact `choice` + /// passed to `register`/`reveal`: `Fixture::asserted` always asserts + /// outcome `true`, so "agrees with the asserted outcome" and "choice == + /// true" are the same thing. `reveals` is whether this voter ever calls + /// `reveal` at all -- a voter that never reveals is exactly the + /// "leftover, never-recovered weight" scenario dust routing exists for. #[derive(Clone, Debug)] struct Voter { amount: i128, @@ -2823,6 +2813,12 @@ mod proptest_settlement { reveals: bool, } + /// Realistic-range weights (at least `min_resolution_bond`, i.e. + /// `DEFAULT_BOND`, well under `max_position`/`max_total_weight`) rather + /// than the full `i128` domain, matching how v1's committee/vote + /// generators are scoped to valid values rather than fuzzing rejection + /// paths. Up to 6 third-party voters, on top of the asserter's and + /// disputer's always-present fixed positions. fn voters() -> impl Strategy> { proptest::collection::vec( (DEFAULT_BOND..=500_000i128, any::(), any::()), @@ -2839,12 +2835,26 @@ mod proptest_settlement { }) } + /// One position's outcome after a scenario resolves: its address, staked + /// `amount`, and `agrees_with_outcome` (`None` if it never revealed -- + /// always the case for a `Voter` with `reveals: false`). struct SettledPosition { address: Address, amount: i128, agrees_with_outcome: Option, } + /// Runs one full randomized dispute -- asserts, disputes, registers + /// every `Voter`, reveals the ones marked `reveals`, then force-closes + /// the round by advancing past the reveal deadline and calling + /// `resolve_outcome` -- so every case reaches `Resolved` regardless of + /// whether a strict majority locked early or nobody ever revealed at + /// all (see `PhaseV2::Reveal`'s doc comment: an early majority lock + /// doesn't stop further reveals or close the phase by itself). + /// + /// Returns the fixture, the assertion id, and every position (asserter, + /// disputer, and every registered voter) for the caller to compute + /// expected payouts from and settle. fn run_scenario(voter_specs: &[Voter]) -> (Fixture, u64, StdVec) { let f = Fixture::new(); let asserter = f.funded_address(); @@ -2854,6 +2864,8 @@ mod proptest_settlement { f.client.dispute(&disputer, &id); let policy_hash = f.client.get_assertion(&id).policy_hash; + // Fixed positions: always revealed automatically once Reveal opens + // (see `open_reveal_phase`), asserter agreeing, disputer disagreeing. let mut positions: StdVec = StdVec::from([ SettledPosition { address: asserter.clone(), @@ -2867,6 +2879,8 @@ mod proptest_settlement { }, ]); + // Register every voter first (registration must be fully closed + // before any reveal is accepted). let mut registered: StdVec<(Address, BytesN<32>, bool, i128)> = StdVec::new(); for (i, spec) in voter_specs.iter().enumerate() { let voter = f.generate(); @@ -2889,8 +2903,30 @@ mod proptest_settlement { f.advance_past_registration_deadline(id); if registered.is_empty() { + // No third-party weight at all: the fixed positions alone + // already account for the full `eligible_total`, so this one + // call both lazily opens `Reveal` (see `open_reveal_phase`) + // and immediately closes it to `Resolved` in the same + // successful invocation -- no earlier `reveal` call is needed + // to persist anything first. f.client.resolve_outcome(&id); } else { + // `Reveal`'s deadline only gets persisted on a call that + // *succeeds*: Soroban rolls back every storage write of a + // failing invocation, so a bare `resolve_outcome` before any + // real weight has revealed would open-then-immediately-fail + // (`RevealNotClosed`, since revealed weight can't yet have + // caught up with `eligible_total`) and roll the phase change + // back with it, leaving `reveal_deadline` at its 0 default + // forever. A real deployment escapes this the same way: at + // least one participant (often the asserter or disputer, + // chasing their own payout) eventually calls `reveal`, which + // succeeds regardless of whether the round fully closes right + // then, and that's what actually starts the reveal clock. If + // every generated `Voter` says `reveals: false`, force the + // first one to reveal anyway so the scenario doesn't deadlock; + // the bookkeeping below reflects what actually happened + // on-chain, not the original random spec. let mut reveal_flags: StdVec = voter_specs.iter().map(|v| v.reveals).collect(); if !reveal_flags.iter().any(|&r| r) { reveal_flags[0] = true; @@ -2913,6 +2949,10 @@ mod proptest_settlement { } } + // At least one successful reveal above guarantees `Reveal` is + // open with a real, persisted `reveal_deadline`; force-close + // whatever hasn't already resolved (e.g. via an early strict + // majority plus full reveal) by advancing past it. if f.client.get_assertion(&id).phase != PhaseV2::Resolved { f.advance_past_reveal_deadline(id); f.client.resolve_outcome(&id); @@ -2922,6 +2962,9 @@ mod proptest_settlement { (f, id, positions) } + /// `settlement_pool`'s own rule, mirrored here so the reference + /// calculation is independent of (and thus actually checks) the + /// contract's implementation rather than restating it. fn expected_pool( terminal_cause: TerminalCause, agree_weight: i128, @@ -2940,6 +2983,9 @@ mod proptest_settlement { (recipient_weight, eligible_total - recipient_weight) } + /// Whether a position is a recipient (recovers principal + pro-rata + /// reward) under `terminal_cause`, mirroring `settle`'s own + /// `is_recipient` match arm by arm. fn is_recipient(terminal_cause: TerminalCause, agrees_with_outcome: Option) -> bool { match terminal_cause { TerminalCause::StrictMajorityFor => agrees_with_outcome == Some(true), @@ -2952,8 +2998,11 @@ mod proptest_settlement { } } + /// Every settled position's expected payout (principal + pro-rata + /// reward for a recipient, 0 otherwise) under the documented formula, + /// alongside the leftover dust and its deterministic recipient. struct Expected { - payouts: StdVec<(Address, i128, i128)>, + payouts: StdVec<(Address, i128, i128)>, // (address, amount, payout) dust: i128, dust_recipient: Address, } @@ -3001,12 +3050,24 @@ mod proptest_settlement { } proptest! { + // Don't fork: Soroban's Env internals are not Send. #![proptest_config(ProptestConfig { fork: false, cases: 96, ..ProptestConfig::default() })] + /// For any random distribution of third-party weights, sides, and + /// reveal participation: `settle`'s own return value for every + /// position matches the documented pro-rata formula exactly (and + /// never includes dust routed elsewhere, even when that position + /// itself turns out to be the dust recipient -- see `settle`'s doc + /// comment), no payout ever exceeds its position's principal plus + /// the entire forfeited pool, and -- once every position has + /// settled and whatever dust exists has landed via `get_credit`, + /// regardless of settle order -- the sum of every credited balance + /// exactly equals `eligible_total`. This is the property #106 calls + /// "payouts sum correctly and never exceed the pool". #[test] fn prop_settlement_payouts_conserve_pool_and_match_formula(voter_specs in voters()) { let (f, id, positions) = run_scenario(&voter_specs); @@ -3050,6 +3111,13 @@ mod proptest_settlement { prop_assert_eq!(credited_total, resolution.eligible_total); } + /// The floor-division remainder from pro-rata splitting is credited + /// (via `get_credit`, which reflects `settle`'s side effects rather + /// than its per-call return value -- see the previous test's doc + /// comment) to exactly one address, the deterministic dust + /// recipient, and every other settled position ends up with exactly + /// its own formula-computed payout, no more, no less: dust is never + /// split across recipients and never silently dropped. #[test] fn prop_dust_credited_to_exactly_one_recipient(voter_specs in voters()) { let (f, id, positions) = run_scenario(&voter_specs); @@ -3091,3 +3159,49 @@ mod proptest_settlement { } } } + +#[test] +fn test_initialize_rejects_anti_snipe_hard_max_over_max() { + let env = Env::default(); + env.mock_all_auths(); + let token_id = setup(&env); + let contract_id = env.register(TholosV2, ()); + let client = TholosV2Client::new(&env, &contract_id); + let admin = Address::generate(&env); + + let result = init_full( + &client, + &admin, + &token_id, + DEFAULT_REGISTRATION_SECS, + DEFAULT_ANTI_SNIPE_EXT_SECS, + MAX_ANTI_SNIPE_HARD_MAX_SECS + 1, + DEFAULT_REVEAL_SECS, + DEFAULT_MAX_POSITION, + DEFAULT_MAX_TOTAL_WEIGHT, + ); + assert_eq!(result, Err(Ok(Error::InvalidAntiSnipeParams))); +} + +#[test] +fn test_initialize_accepts_anti_snipe_hard_max_at_max() { + let env = Env::default(); + env.mock_all_auths(); + let token_id = setup(&env); + let contract_id = env.register(TholosV2, ()); + let client = TholosV2Client::new(&env, &contract_id); + let admin = Address::generate(&env); + + let result = init_full( + &client, + &admin, + &token_id, + DEFAULT_REGISTRATION_SECS, + DEFAULT_ANTI_SNIPE_EXT_SECS, + MAX_ANTI_SNIPE_HARD_MAX_SECS, + DEFAULT_REVEAL_SECS, + DEFAULT_MAX_POSITION, + DEFAULT_MAX_TOTAL_WEIGHT, + ); + assert_eq!(result, Ok(Ok(()))); +} \ No newline at end of file diff --git a/docs/src/CONTRACT_V2.md b/docs/src/CONTRACT_V2.md index 0a272f6..08aee08 100644 --- a/docs/src/CONTRACT_V2.md +++ b/docs/src/CONTRACT_V2.md @@ -175,7 +175,7 @@ only affect assertions created after the change. | `InvalidBondAmount` | `base_bond` isn't positive, or exceeds `MAX_BOND_AMOUNT`. | | `InvalidRegistrationDuration` | `registration_duration_secs` is zero or exceeds 7 days. | | `InvalidRevealDuration` | `reveal_duration_secs` is zero or exceeds 7 days. | -| `InvalidAntiSnipeParams` | `anti_snipe_extension_secs` exceeds `anti_snipe_hard_max_secs`, or `anti_snipe_hard_max_secs` is shorter than `registration_duration_secs`. | +| `InvalidAntiSnipeParams` | `anti_snipe_extension_secs` exceeds `anti_snipe_hard_max_secs`, `anti_snipe_hard_max_secs` is shorter than `registration_duration_secs`, or `anti_snipe_hard_max_secs` exceeds `MAX_ANTI_SNIPE_HARD_MAX_SECS` (30 days). | | `InvalidMaxPosition` | `max_position` isn't positive, or exceeds `max_total_weight`. | | `InvalidMaxTotalWeight` | `max_total_weight` isn't positive, or exceeds `MAX_SETTLEMENT_TOTAL_WEIGHT`. | | `InvalidChallengeWindow` | `challenge_window_secs` is zero or exceeds 7 days. | From 05a990fa2d3218ccfb6ef22a85506b0d1cbc1d85 Mon Sep 17 00:00:00 2001 From: Santi Date: Wed, 26 Aug 2026 13:22:46 -0300 Subject: [PATCH 5/5] fix(v2): trim MAX_ANTI_SNIPE_HARD_MAX_SECS to 29 days to leave persistent storage TTL margin --- contracts/tholos-v2/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/tholos-v2/src/lib.rs b/contracts/tholos-v2/src/lib.rs index 12eb217..49804f9 100644 --- a/contracts/tholos-v2/src/lib.rs +++ b/contracts/tholos-v2/src/lib.rs @@ -572,7 +572,7 @@ const INSTANCE_LIFETIME_THRESHOLD: u32 = INSTANCE_BUMP_AMOUNT - DAY_IN_LEDGERS; const MAX_REGISTRATION_DURATION_SECS: u64 = 7 * 24 * 60 * 60; const MAX_REVEAL_DURATION_SECS: u64 = 7 * 24 * 60 * 60; -pub const MAX_ANTI_SNIPE_HARD_MAX_SECS: u64 = 30 * 24 * 60 * 60; // 30 days +pub const MAX_ANTI_SNIPE_HARD_MAX_SECS: u64 = 29 * 24 * 60 * 60; // 29 days /// Same 7-day cap as v1's `challenge_window_secs`, for the same reason: it /// must leave real margin within the 30-day persistent-storage TTL bump /// (`INSTANCE_BUMP_AMOUNT`) for `finalize` to actually get called before the