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
27 changes: 27 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 Down Expand Up @@ -627,6 +632,16 @@ const MAX_BOND_AMOUNT: i128 = i128::MAX / (MAX_FINALIZE_REWARD_BPS as i128);
/// any deployment token's full realistic supply range.
const MAX_SETTLEMENT_TOTAL_WEIGHT: i128 = 10_000_000_000_000_000_000;

/// 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. Even with this bound, a coalition controlling more
/// than half of the eligible bonded capital can still determine the result;
/// the ratio only bounds how cheaply one actor can approach that via address
/// splitting. See `docs/src/CONTRACT_V2.md` and issue #168.
const MAX_TOTAL_WEIGHT_TO_POSITION_RATIO: i128 = 10;

/// This proposal has exactly one weighted round: no recursive appeals or
/// repeated stake rounds, per V2_RESOLUTION.md's "Lifecycle and the single
/// weighted round". `round` is part of the commitment preimage now so a
Expand Down Expand Up @@ -709,6 +724,18 @@ 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.
// checked_mul guards against a pathologically large max_position that
// would overflow the product; in that case the product would exceed
// any real max_total_weight anyway, so unwrap_or(i128::MAX) is safe.
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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This directly conflicts with docs/src/V2_BOND_SIZING.md's own published profile table, which isn't touched by this PR. Public testnet / low value (max_position 5x, max_total_weight 100x, ratio 20) and Higher-value mainnet candidate (3x, 200x, ratio 66.7) both now revert with InvalidWeightRatio if anyone actually follows that guidance. Only Private beta (10x, 50x, ratio 5) survives. Please update that table's max_position/max_total_weight columns for the other two profiles to fit within the new ratio 10 cap, or adjust the cap if 20x/66x should remain valid.

}

let policy = PolicySnapshotV2 {
token,
base_bond,
Expand Down
50 changes: 49 additions & 1 deletion contracts/tholos-v2/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -547,6 +547,54 @@ 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);

// max_total_weight == max_position * 10 is exactly at the bound; must succeed.
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,
1_000i128,
10_000i128,
);
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);

// max_total_weight == max_position * 10 + 1 is one over the bound; must fail.
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,
1_000i128,
10_001i128,
);
assert_eq!(result, Err(Ok(Error::InvalidWeightRatio)));
}

#[test]
fn test_initialize_rejects_zero_challenge_window() {
let env = Env::default();
Expand Down Expand Up @@ -1388,7 +1436,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
Loading
Loading