diff --git a/contracts/tholos-v2/src/lib.rs b/contracts/tholos-v2/src/lib.rs index 9f2fccc..5c92eee 100644 --- a/contracts/tholos-v2/src/lib.rs +++ b/contracts/tholos-v2/src/lib.rs @@ -564,6 +564,10 @@ 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, + /// `max_total_weight` exceeded `MAX_TOTAL_WEIGHT_TO_POSITION_RATIO * max_position`. + /// 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; @@ -627,6 +631,13 @@ 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`. +/// +/// Bounding this 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; + /// 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 @@ -709,6 +720,13 @@ impl TholosV2 { return Err(Error::InvalidMaxPosition); } + 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, diff --git a/contracts/tholos-v2/src/test.rs b/contracts/tholos-v2/src/test.rs index 776ecd5..dc99f83 100644 --- a/contracts/tholos-v2/src/test.rs +++ b/contracts/tholos-v2/src/test.rs @@ -547,6 +547,56 @@ fn test_initialize_rejects_max_total_weight_over_max_bond() { assert_eq!(result, Err(Ok(Error::InvalidMaxTotalWeight))); } +#[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 max_position = 100i128; + let max_total_weight = max_position * MAX_TOTAL_WEIGHT_TO_POSITION_RATIO + 1; + 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, + max_position, + max_total_weight, + ); + assert_eq!(result, Err(Ok(Error::InvalidWeightRatio))); +} + +#[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 max_position = 100i128; + let max_total_weight = max_position * MAX_TOTAL_WEIGHT_TO_POSITION_RATIO; + 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, + max_position, + max_total_weight, + ); + assert_eq!(result, Ok(Ok(()))); +} + #[test] fn test_initialize_rejects_zero_challenge_window() { let env = Env::default(); @@ -1275,7 +1325,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(); diff --git a/contracts/tholos-v2/test_snapshots/test/test_initialize_accepts_max_total_weight_at_ratio_bound.1.json b/contracts/tholos-v2/test_snapshots/test/test_initialize_accepts_max_total_weight_at_ratio_bound.1.json new file mode 100644 index 0000000..7e216b8 --- /dev/null +++ b/contracts/tholos-v2/test_snapshots/test/test_initialize_accepts_max_total_weight_at_ratio_bound.1.json @@ -0,0 +1,467 @@ +{ + "generators": { + "address": 4, + "nonce": 0, + "mux_id": 0 + }, + "auth": [ + [], + [ + [ + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEGWF", + { + "function": { + "contract_fn": { + "contract_address": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL", + "function_name": "set_admin", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "function_name": "initialize", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + }, + { + "address": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL" + }, + { + "i128": "100" + }, + { + "u64": "3600" + }, + { + "u32": 0 + }, + { + "u64": "3600" + }, + { + "u64": "300" + }, + { + "u64": "3900" + }, + { + "u64": "3600" + }, + { + "i128": "100" + }, + { + "i128": "1000" + } + ] + } + }, + "sub_invocations": [] + } + ] + ] + ], + "ledger": { + "protocol_version": 26, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "account": { + "account_id": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEGWF", + "balance": "0", + "seq_num": "0", + "num_sub_entries": 0, + "inflation_dest": null, + "flags": 0, + "home_domain": "", + "thresholds": "01010101", + "signers": [], + "ext": "v0" + } + }, + "ext": "v0" + }, + "live_until": null + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEGWF", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + } + }, + { + "key": { + "vec": [ + { + "symbol": "NextId" + } + ] + }, + "val": { + "u64": "0" + } + }, + { + "key": { + "vec": [ + { + "symbol": "Paused" + } + ] + }, + "val": { + "bool": false + } + }, + { + "key": { + "vec": [ + { + "symbol": "Policy" + } + ] + }, + "val": { + "map": [ + { + "key": { + "symbol": "anti_snipe_extension_secs" + }, + "val": { + "u64": "300" + } + }, + { + "key": { + "symbol": "anti_snipe_hard_max_secs" + }, + "val": { + "u64": "3900" + } + }, + { + "key": { + "symbol": "base_bond" + }, + "val": { + "i128": "100" + } + }, + { + "key": { + "symbol": "challenge_window_secs" + }, + "val": { + "u64": "3600" + } + }, + { + "key": { + "symbol": "finalize_reward_bps" + }, + "val": { + "u32": 0 + } + }, + { + "key": { + "symbol": "max_position" + }, + "val": { + "i128": "100" + } + }, + { + "key": { + "symbol": "max_total_weight" + }, + "val": { + "i128": "1000" + } + }, + { + "key": { + "symbol": "min_resolution_bond" + }, + "val": { + "i128": "100" + } + }, + { + "key": { + "symbol": "payout_rule" + }, + "val": { + "vec": [ + { + "symbol": "ProRataV1" + } + ] + } + }, + { + "key": { + "symbol": "registration_duration_secs" + }, + "val": { + "u64": "3600" + } + }, + { + "key": { + "symbol": "reveal_duration_secs" + }, + "val": { + "u64": "3600" + } + }, + { + "key": { + "symbol": "timeout_default" + }, + "val": { + "vec": [ + { + "symbol": "AssertedOutcomeStands" + } + ] + } + }, + { + "key": { + "symbol": "token" + }, + "val": { + "address": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL" + } + }, + { + "key": { + "symbol": "weight_rule" + }, + "val": { + "vec": [ + { + "symbol": "LinearStakeV1" + } + ] + } + } + ] + } + } + ] + } + } + } + }, + "ext": "v0" + }, + "live_until": 518400 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + "key": { + "ledger_key_nonce": { + "nonce": "5541220902715666415" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": "stellar_asset", + "storage": [ + { + "key": { + "symbol": "METADATA" + }, + "val": { + "map": [ + { + "key": { + "symbol": "decimal" + }, + "val": { + "u32": 7 + } + }, + { + "key": { + "symbol": "name" + }, + "val": { + "string": "aaa:GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEGWF" + } + }, + { + "key": { + "symbol": "symbol" + }, + "val": { + "string": "aaa" + } + } + ] + } + }, + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + }, + { + "key": { + "vec": [ + { + "symbol": "AssetInfo" + } + ] + }, + "val": { + "vec": [ + { + "symbol": "AlphaNum4" + }, + { + "map": [ + { + "key": { + "symbol": "asset_code" + }, + "val": { + "string": "aaa\\0" + } + }, + { + "key": { + "symbol": "issuer" + }, + "val": { + "bytes": "0000000000000000000000000000000000000000000000000000000000000002" + } + } + ] + } + ] + } + } + ] + } + } + } + }, + "ext": "v0" + }, + "live_until": 120960 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + "live_until": 518400 + } + ] + }, + "events": [] +} \ No newline at end of file diff --git a/contracts/tholos-v2/test_snapshots/test/test_initialize_rejects_max_total_weight_above_ratio_bound.1.json b/contracts/tholos-v2/test_snapshots/test/test_initialize_rejects_max_total_weight_above_ratio_bound.1.json new file mode 100644 index 0000000..7c1642d --- /dev/null +++ b/contracts/tholos-v2/test_snapshots/test/test_initialize_rejects_max_total_weight_above_ratio_bound.1.json @@ -0,0 +1,225 @@ +{ + "generators": { + "address": 4, + "nonce": 0, + "mux_id": 0 + }, + "auth": [ + [], + [ + [ + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEGWF", + { + "function": { + "contract_fn": { + "contract_address": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL", + "function_name": "set_admin", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [] + ], + "ledger": { + "protocol_version": 26, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "account": { + "account_id": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEGWF", + "balance": "0", + "seq_num": "0", + "num_sub_entries": 0, + "inflation_dest": null, + "flags": 0, + "home_domain": "", + "thresholds": "01010101", + "signers": [], + "ext": "v0" + } + }, + "ext": "v0" + }, + "live_until": null + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEGWF", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": null + } + } + } + }, + "ext": "v0" + }, + "live_until": 4095 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": "stellar_asset", + "storage": [ + { + "key": { + "symbol": "METADATA" + }, + "val": { + "map": [ + { + "key": { + "symbol": "decimal" + }, + "val": { + "u32": 7 + } + }, + { + "key": { + "symbol": "name" + }, + "val": { + "string": "aaa:GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEGWF" + } + }, + { + "key": { + "symbol": "symbol" + }, + "val": { + "string": "aaa" + } + } + ] + } + }, + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + }, + { + "key": { + "vec": [ + { + "symbol": "AssetInfo" + } + ] + }, + "val": { + "vec": [ + { + "symbol": "AlphaNum4" + }, + { + "map": [ + { + "key": { + "symbol": "asset_code" + }, + "val": { + "string": "aaa\\0" + } + }, + { + "key": { + "symbol": "issuer" + }, + "val": { + "bytes": "0000000000000000000000000000000000000000000000000000000000000002" + } + } + ] + } + ] + } + } + ] + } + } + } + }, + "ext": "v0" + }, + "live_until": 120960 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + "live_until": 4095 + } + ] + }, + "events": [] +} \ No newline at end of file diff --git a/contracts/tholos-v2/test_snapshots/test/test_register_exceeds_max_position_fails.1.json b/contracts/tholos-v2/test_snapshots/test/test_register_exceeds_max_position_fails.1.json index a62dbc5..48a1105 100644 --- a/contracts/tholos-v2/test_snapshots/test/test_register_exceeds_max_position_fails.1.json +++ b/contracts/tholos-v2/test_snapshots/test/test_register_exceeds_max_position_fails.1.json @@ -66,7 +66,7 @@ "i128": "150" }, { - "i128": "10000000" + "i128": "1500" } ] } @@ -483,7 +483,7 @@ "symbol": "max_total_weight" }, "val": { - "i128": "10000000" + "i128": "1500" } }, { @@ -562,7 +562,7 @@ "symbol": "policy_hash" }, "val": { - "bytes": "bea16dd2b9c07d0117fd494aca3f5351976ffbed6c9fd7d6946bd14c5d342278" + "bytes": "4e8a18a6733c6797b89d20f8b8c8f702bc74b120ee73891bf2ada8ca3e1dbb65" } }, { @@ -975,7 +975,7 @@ "symbol": "max_total_weight" }, "val": { - "i128": "10000000" + "i128": "1500" } }, { diff --git a/docs/src/CONTRACT_V2.md b/docs/src/CONTRACT_V2.md index a9411ee..008d1e4 100644 --- a/docs/src/CONTRACT_V2.md +++ b/docs/src/CONTRACT_V2.md @@ -147,7 +147,7 @@ only affect assertions created after the change. | `timeout_default` | `TimeoutDefaultRule` | Always `AssertedOutcomeStands` today. | | `payout_rule` | `PayoutRuleVersion` | Always `ProRataV1` today. | | `max_position` | `i128` | Upper bound on any single position's size, so settlement arithmetic can't overflow. | -| `max_total_weight` | `i128` | Upper bound on the frozen eligible total `W`, for the same reason. | +| `max_total_weight` | `i128` | Upper bound on the frozen eligible total `W`, for the same reason. Also bounded by `MAX_TOTAL_WEIGHT_TO_POSITION_RATIO * max_position`. | ### `AssertionV2` @@ -178,6 +178,7 @@ only affect assertions created after the change. | `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` (29 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`. | +| `InvalidWeightRatio` | `max_total_weight` exceeds `MAX_TOTAL_WEIGHT_TO_POSITION_RATIO * max_position`. | | `InvalidChallengeWindow` | `challenge_window_secs` is zero or exceeds 7 days. | | `InvalidFinalizeReward` | `finalize_reward_bps` exceeds `MAX_FINALIZE_REWARD_BPS` (1000). | | `NotPending` | Action requires `PhaseV2::Pending` but the assertion isn't. | @@ -221,7 +222,9 @@ and at most 7 days. `finalize_reward_bps` must be at most 1000. `max_total_weight` must be positive and no greater than `MAX_SETTLEMENT_TOTAL_WEIGHT` (so settlement's forfeiture-distribution multiply can't overflow); `max_position` must be positive and no greater -than `max_total_weight`. `min_resolution_bond` is always set equal to +than `max_total_weight`. In addition, `max_total_weight` must not exceed +`MAX_TOTAL_WEIGHT_TO_POSITION_RATIO` (10) multiplied by `max_position` +(raising Sybil-splitting cost). `min_resolution_bond` is always set equal to `base_bond`. Fails with `AlreadyInitialized` if called twice, or the matching `Invalid*` error for any out-of-range parameter. @@ -554,6 +557,19 @@ is required unconditionally, so this value is always verified regardless of whether `finalize_reward_bps` is non-zero, the same guarantee v1's `Finalized` event carries. +## Sybil resistance and residual plutocratic risk + +`initialize` enforces `max_total_weight <= MAX_TOTAL_WEIGHT_TO_POSITION_RATIO * max_position` +(with ratio = 10). This caps the number of maximum-sized "seats" a single participant can occupy +in a dispute, requiring an attacker seeking a strict majority (>50% of `W`) to fund and coordinate +across at least 6 distinct maximum-stake accounts. + +As documented in `docs/src/V2_RESOLUTION.md`, linear stake-weighting without external identity +verification cannot eliminate plutocratic risk: an actor or coalition controlling more than half +of the eligible bonded capital can always dictate the resolution outcome. Bounding the ratio +ensures that deployments enforce a minimum capital fragmentation cost on attackers while retaining +flexibility for legitimate participants. + ## Known gaps - **No top-up path for fixed positions.** The asserter's and disputer's