Skip to content
Closed
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
28 changes: 28 additions & 0 deletions contracts/tholos-v2/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,11 @@ pub enum Error {
/// Rejected outright rather than treated as a no-op, so this call can
/// never be read as altering an already-decided result.
RoundAlreadyDecided = 36,
/// `initialize` was called with `max_total_weight` greater than
/// `MAX_TOTAL_WEIGHT_TO_POSITION_RATIO * max_position`. This caps how
/// many effective seats a single split actor can occupy, raising the
/// cost of Sybil-style address splitting.
InvalidWeightRatio = 37,
}

const DAY_IN_LEDGERS: u32 = 17280;
Expand All @@ -576,6 +581,13 @@ const INSTANCE_LIFETIME_THRESHOLD: u32 = INSTANCE_BUMP_AMOUNT - DAY_IN_LEDGERS;
/// archival. See #72.
const SETTLEMENT_GRACE_SECS: u64 = 7 * 24 * 60 * 60;

/// Maximum allowed ratio between `max_total_weight` and `max_position`.
///
/// A bounded ratio raises the cost of Sybil-style splitting: a single actor
/// must control at least this many distinct positions to approach the
/// plutocratic threshold. See `docs/src/CONTRACT_V2.md` and issue #168.
const MAX_TOTAL_WEIGHT_TO_POSITION_RATIO: i128 = 10;

/// Soroban's target ledger close time. `extend_ttl` operates in ledgers,
/// but every duration elsewhere in this contract is tracked in seconds via
/// `env.ledger().timestamp()`, so this only translates a
Expand Down Expand Up @@ -709,6 +721,15 @@ impl TholosV2 {
return Err(Error::InvalidMaxPosition);
}

// Issue #168: bound the Sybil-splitting surface by requiring
// max_total_weight be no more than a fixed multiple of max_position.
let max_allowed_total = max_position
.checked_mul(MAX_TOTAL_WEIGHT_TO_POSITION_RATIO)
.unwrap_or(i128::MAX);
if max_total_weight > max_allowed_total {
return Err(Error::InvalidWeightRatio);
}

let policy = PolicySnapshotV2 {
token,
base_bond,
Expand Down Expand Up @@ -1354,6 +1375,13 @@ impl TholosV2 {
position.revealed = true;
position.agrees_with_outcome = Some(agrees_with_asserter);
Self::set_position(env, id, fixed_voter, &position, &assertion.policy);

Revealed {
id,
voter: fixed_voter.clone(),
choice: agrees_with_asserter,
}
.publish(env);
}

Self::set_resolution(env, id, &resolution, &assertion.policy);
Expand Down
69 changes: 67 additions & 2 deletions contracts/tholos-v2/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

use super::*;
use soroban_sdk::testutils::storage::Persistent as _;
use soroban_sdk::testutils::{Address as _, Ledger};
use soroban_sdk::testutils::{Address as _, Events, Ledger};
use soroban_sdk::xdr;

const DEFAULT_BOND: i128 = 100;
const DEFAULT_CHALLENGE_WINDOW: u64 = 3600;
Expand Down Expand Up @@ -547,6 +548,52 @@ fn test_initialize_rejects_max_total_weight_over_max_bond() {
assert_eq!(result, Err(Ok(Error::InvalidMaxTotalWeight)));
}


#[test]
fn test_initialize_accepts_max_total_weight_at_ratio_bound() {
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,
DEFAULT_ANTI_SNIPE_HARD_MAX_SECS,
DEFAULT_REVEAL_SECS,
1000i128,
10000i128,
);
assert!(result.is_ok());
}

#[test]
fn test_initialize_rejects_max_total_weight_above_ratio_bound() {
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,
DEFAULT_ANTI_SNIPE_HARD_MAX_SECS,
DEFAULT_REVEAL_SECS,
1000i128,
10001i128,
);
assert_eq!(result, Err(Ok(Error::InvalidWeightRatio)));
}
#[test]
fn test_initialize_rejects_zero_challenge_window() {
let env = Env::default();
Expand Down Expand Up @@ -1275,7 +1322,7 @@ fn test_register_exceeds_max_position_fails() {
DEFAULT_ANTI_SNIPE_HARD_MAX_SECS,
DEFAULT_REVEAL_SECS,
DEFAULT_BOND + 50,
DEFAULT_MAX_TOTAL_WEIGHT,
(DEFAULT_BOND + 50) * 10,
)
.unwrap()
.unwrap();
Expand Down Expand Up @@ -1444,6 +1491,24 @@ fn test_reveal_opens_phase_counts_fixed_positions_and_verifies_commitment() {
f.advance_past_registration_deadline(id);
f.client.reveal(&voter, &id, &true, &s);

// Auto-reveal of the asserter and disputer fixed positions must each emit
// a Revealed event, in addition to the explicit Revealed event from the
// external voter's reveal() call.
let contract_events = f.env.events().all();
let all_events = contract_events.events();
let mut revealed_count: u32 = 0;
for event in all_events.iter() {
let xdr::ContractEventBody::V0(event_data) = &event.body;
if event_data.topics.len() == 2 {
if let xdr::ScVal::Symbol(topic0) = &event_data.topics[0] {
if topic0.to_utf8_string().unwrap_or_default() == "revealed" {
revealed_count += 1;
}
}
}
}
assert_eq!(revealed_count, 3, "expected asserter, disputer, and voter Revealed events");

let assertion = f.client.get_assertion(&id);
assert_eq!(assertion.phase, PhaseV2::Reveal);

Expand Down
Loading