From a726d56b834c60e1e3c5a129942691089bf6beb2 Mon Sep 17 00:00:00 2001 From: ZacLou Date: Thu, 3 Sep 2026 08:22:37 +0800 Subject: [PATCH 1/3] fix(v2): emit Revealed events for fixed asserter/disputer positions in open_reveal_phase Closes #156 When open_reveal_phase auto-reveals the asserter and disputer fixed positions, publish a Revealed event for each so off-chain indexers can observe the revealed choice without requiring an explicit reveal() call. Adds a test assertion that exactly three Revealed events are emitted: asserter, disputer, and the external voter that called reveal(). --- contracts/tholos-v2/src/lib.rs | 7 +++++++ contracts/tholos-v2/src/test.rs | 21 ++++++++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/contracts/tholos-v2/src/lib.rs b/contracts/tholos-v2/src/lib.rs index 9f2fccc..b9524fd 100644 --- a/contracts/tholos-v2/src/lib.rs +++ b/contracts/tholos-v2/src/lib.rs @@ -1354,6 +1354,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); diff --git a/contracts/tholos-v2/src/test.rs b/contracts/tholos-v2/src/test.rs index 776ecd5..6b2b090 100644 --- a/contracts/tholos-v2/src/test.rs +++ b/contracts/tholos-v2/src/test.rs @@ -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; @@ -1444,6 +1445,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); From be9bbc38a6333955af9be645e293278d8ecba94d Mon Sep 17 00:00:00 2001 From: ZacLou Date: Thu, 3 Sep 2026 08:43:17 +0800 Subject: [PATCH 2/3] fix(tholos): prevent resolver from voting on their own case (#165) Closes #165 Adds a conflict-of-interest check in esolve: a resolver who is also the asserter or disputer of the assertion cannot vote. A new Error::SelfVote variant is returned. Adds tests covering both the asserter-as-resolver and disputer-as-resolver cases. --- contracts/tholos/src/lib.rs | 9 + contracts/tholos/src/test.rs | 68 ++ ...er_resolver_cannot_vote_on_own_case.1.json | 896 ++++++++++++++++++ ...er_resolver_cannot_vote_on_own_case.1.json | 896 ++++++++++++++++++ 4 files changed, 1869 insertions(+) create mode 100644 contracts/tholos/test_snapshots/test/test_asserter_resolver_cannot_vote_on_own_case.1.json create mode 100644 contracts/tholos/test_snapshots/test/test_disputer_resolver_cannot_vote_on_own_case.1.json diff --git a/contracts/tholos/src/lib.rs b/contracts/tholos/src/lib.rs index f95fc58..496185b 100644 --- a/contracts/tholos/src/lib.rs +++ b/contracts/tholos/src/lib.rs @@ -171,6 +171,10 @@ pub enum Error { /// slot without any economic risk (they receive both bonds back regardless /// of the resolver vote), nullifying the bond-forfeiture deterrent. SelfDispute = 22, + /// A resolver attempted to vote on an assertion where they are also the + /// asserter or the disputer. Voting in their own case is a conflict of + /// interest and, in a size-1 committee, lets them guarantee the outcome. + SelfVote = 23, } const DAY_IN_LEDGERS: u32 = 17280; @@ -777,6 +781,11 @@ impl Tholos { return Err(Error::AlreadyVoted); } + // A resolver may not vote on their own case. + if resolver == assertion.asserter || assertion.disputer.as_ref() == Some(&resolver) { + return Err(Error::SelfVote); + } + assertion.voted.push_back(resolver); if agrees_with_asserter { assertion.votes_for_outcome += 1; diff --git a/contracts/tholos/src/test.rs b/contracts/tholos/src/test.rs index 56dc48a..f133136 100644 --- a/contracts/tholos/src/test.rs +++ b/contracts/tholos/src/test.rs @@ -200,6 +200,74 @@ fn test_resolve_records_asserted_outcome_when_asserter_wins() { assert_eq!(f.client.get_assertion_state(&id).final_outcome, Some(false)); } +#[test] +fn test_asserter_resolver_cannot_vote_on_own_case() { + let env = Env::default(); + env.mock_all_auths(); + + let (token_id, mut resolvers) = setup(&env); + let _token = token::Client::new(&env, &token_id); + let asserter = Address::generate(&env); + let disputer = Address::generate(&env); + // Make the asserter the first resolver so they are in the committee. + resolvers.set(0, asserter.clone()); + + let contract_id = env.register(Tholos, ()); + let client = TholosClient::new(&env, &contract_id); + let admin = Address::generate(&env); + client.initialize( + &admin, + &token_id, + &DEFAULT_BOND, + &DEFAULT_WINDOW, + &resolvers, + &0u32, + ); + + token::StellarAssetClient::new(&env, &token_id).mint(&asserter, &DEFAULT_MINT); + token::StellarAssetClient::new(&env, &token_id).mint(&disputer, &DEFAULT_MINT); + + let id = client.assert_outcome(&asserter, &true); + client.dispute(&disputer, &id); + + let result = client.try_resolve(&asserter, &id, &true); + assert_eq!(result, Err(Ok(Error::SelfVote))); +} + +#[test] +fn test_disputer_resolver_cannot_vote_on_own_case() { + let env = Env::default(); + env.mock_all_auths(); + + let (token_id, mut resolvers) = setup(&env); + let _token = token::Client::new(&env, &token_id); + let asserter = Address::generate(&env); + let disputer = Address::generate(&env); + // Make the disputer the first resolver so they are in the committee. + resolvers.set(0, disputer.clone()); + + let contract_id = env.register(Tholos, ()); + let client = TholosClient::new(&env, &contract_id); + let admin = Address::generate(&env); + client.initialize( + &admin, + &token_id, + &DEFAULT_BOND, + &DEFAULT_WINDOW, + &resolvers, + &0u32, + ); + + token::StellarAssetClient::new(&env, &token_id).mint(&asserter, &DEFAULT_MINT); + token::StellarAssetClient::new(&env, &token_id).mint(&disputer, &DEFAULT_MINT); + + let id = client.assert_outcome(&asserter, &true); + client.dispute(&disputer, &id); + + let result = client.try_resolve(&disputer, &id, &false); + assert_eq!(result, Err(Ok(Error::SelfVote))); +} + #[test] fn test_cannot_initialize_with_even_resolver_count() { let env = Env::default(); diff --git a/contracts/tholos/test_snapshots/test/test_asserter_resolver_cannot_vote_on_own_case.1.json b/contracts/tholos/test_snapshots/test/test_asserter_resolver_cannot_vote_on_own_case.1.json new file mode 100644 index 0000000..a89ccfd --- /dev/null +++ b/contracts/tholos/test_snapshots/test/test_asserter_resolver_cannot_vote_on_own_case.1.json @@ -0,0 +1,896 @@ +{ + "generators": { + "address": 9, + "nonce": 0, + "mux_id": 0 + }, + "auth": [ + [], + [ + [ + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEGWF", + { + "function": { + "contract_fn": { + "contract_address": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL", + "function_name": "set_admin", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAATYON", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5", + "function_name": "initialize", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAATYON" + }, + { + "address": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL" + }, + { + "i128": "100" + }, + { + "u64": "3600" + }, + { + "vec": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" + } + ] + }, + { + "u32": 0 + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + { + "function": { + "contract_fn": { + "contract_address": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL", + "function_name": "mint", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4" + }, + { + "i128": "1000" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + { + "function": { + "contract_fn": { + "contract_address": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL", + "function_name": "mint", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM" + }, + { + "i128": "1000" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5", + "function_name": "assert_outcome", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4" + }, + { + "bool": true + } + ] + } + }, + "sub_invocations": [ + { + "function": { + "contract_fn": { + "contract_address": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL", + "function_name": "transfer", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5" + }, + { + "i128": "100" + } + ] + } + }, + "sub_invocations": [] + } + ] + } + ] + ], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5", + "function_name": "dispute", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM" + }, + { + "u64": "0" + } + ] + } + }, + "sub_invocations": [ + { + "function": { + "contract_fn": { + "contract_address": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL", + "function_name": "transfer", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5" + }, + { + "i128": "100" + } + ] + } + }, + "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": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": "1033654523790656264" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": "4837995959683129791" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4", + "key": { + "ledger_key_nonce": { + "nonce": "2032731177588607455" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM", + "key": { + "ledger_key_nonce": { + "nonce": "4270020994084947596" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5", + "key": { + "vec": [ + { + "symbol": "Assertion" + }, + { + "u64": "0" + } + ] + }, + "durability": "persistent", + "val": { + "map": [ + { + "key": { + "symbol": "asserter" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4" + } + }, + { + "key": { + "symbol": "bond" + }, + "val": { + "i128": "100" + } + }, + { + "key": { + "symbol": "disputer" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM" + } + }, + { + "key": { + "symbol": "final_outcome" + }, + "val": "void" + }, + { + "key": { + "symbol": "finalizer" + }, + "val": "void" + }, + { + "key": { + "symbol": "opened_at" + }, + "val": { + "u64": "0" + } + }, + { + "key": { + "symbol": "outcome" + }, + "val": { + "bool": true + } + }, + { + "key": { + "symbol": "resolvers" + }, + "val": { + "vec": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" + } + ] + } + }, + { + "key": { + "symbol": "status" + }, + "val": { + "vec": [ + { + "symbol": "Disputed" + } + ] + } + }, + { + "key": { + "symbol": "voted" + }, + "val": { + "vec": [] + } + }, + { + "key": { + "symbol": "votes_against_outcome" + }, + "val": { + "u32": 0 + } + }, + { + "key": { + "symbol": "votes_for_outcome" + }, + "val": { + "u32": 0 + } + } + ] + } + } + }, + "ext": "v0" + }, + "live_until": 518400 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAATYON" + } + }, + { + "key": { + "vec": [ + { + "symbol": "BondAmount" + } + ] + }, + "val": { + "i128": "100" + } + }, + { + "key": { + "vec": [ + { + "symbol": "ChallengeWindow" + } + ] + }, + "val": { + "u64": "3600" + } + }, + { + "key": { + "vec": [ + { + "symbol": "FinalizeRewardBps" + } + ] + }, + "val": { + "u32": 0 + } + }, + { + "key": { + "vec": [ + { + "symbol": "NextId" + } + ] + }, + "val": { + "u64": "1" + } + }, + { + "key": { + "vec": [ + { + "symbol": "Paused" + } + ] + }, + "val": { + "bool": false + } + }, + { + "key": { + "vec": [ + { + "symbol": "Resolvers" + } + ] + }, + "val": { + "vec": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" + } + ] + } + }, + { + "key": { + "vec": [ + { + "symbol": "Token" + } + ] + }, + "val": { + "address": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL" + } + } + ] + } + } + } + }, + "ext": "v0" + }, + "live_until": 518400 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAATYON", + "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": { + "vec": [ + { + "symbol": "Balance" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4" + } + ] + }, + "durability": "persistent", + "val": { + "map": [ + { + "key": { + "symbol": "amount" + }, + "val": { + "i128": "900" + } + }, + { + "key": { + "symbol": "authorized" + }, + "val": { + "bool": true + } + }, + { + "key": { + "symbol": "clawback" + }, + "val": { + "bool": false + } + } + ] + } + } + }, + "ext": "v0" + }, + "live_until": 518400 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL", + "key": { + "vec": [ + { + "symbol": "Balance" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM" + } + ] + }, + "durability": "persistent", + "val": { + "map": [ + { + "key": { + "symbol": "amount" + }, + "val": { + "i128": "900" + } + }, + { + "key": { + "symbol": "authorized" + }, + "val": { + "bool": true + } + }, + { + "key": { + "symbol": "clawback" + }, + "val": { + "bool": false + } + } + ] + } + } + }, + "ext": "v0" + }, + "live_until": 518400 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL", + "key": { + "vec": [ + { + "symbol": "Balance" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5" + } + ] + }, + "durability": "persistent", + "val": { + "map": [ + { + "key": { + "symbol": "amount" + }, + "val": { + "i128": "200" + } + }, + { + "key": { + "symbol": "authorized" + }, + "val": { + "bool": true + } + }, + { + "key": { + "symbol": "clawback" + }, + "val": { + "bool": false + } + } + ] + } + } + }, + "ext": "v0" + }, + "live_until": 518400 + }, + { + "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/test_snapshots/test/test_disputer_resolver_cannot_vote_on_own_case.1.json b/contracts/tholos/test_snapshots/test/test_disputer_resolver_cannot_vote_on_own_case.1.json new file mode 100644 index 0000000..7ce9cee --- /dev/null +++ b/contracts/tholos/test_snapshots/test/test_disputer_resolver_cannot_vote_on_own_case.1.json @@ -0,0 +1,896 @@ +{ + "generators": { + "address": 9, + "nonce": 0, + "mux_id": 0 + }, + "auth": [ + [], + [ + [ + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEGWF", + { + "function": { + "contract_fn": { + "contract_address": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL", + "function_name": "set_admin", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAATYON", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5", + "function_name": "initialize", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAATYON" + }, + { + "address": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL" + }, + { + "i128": "100" + }, + { + "u64": "3600" + }, + { + "vec": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" + } + ] + }, + { + "u32": 0 + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + { + "function": { + "contract_fn": { + "contract_address": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL", + "function_name": "mint", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4" + }, + { + "i128": "1000" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + { + "function": { + "contract_fn": { + "contract_address": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL", + "function_name": "mint", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM" + }, + { + "i128": "1000" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5", + "function_name": "assert_outcome", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4" + }, + { + "bool": true + } + ] + } + }, + "sub_invocations": [ + { + "function": { + "contract_fn": { + "contract_address": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL", + "function_name": "transfer", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5" + }, + { + "i128": "100" + } + ] + } + }, + "sub_invocations": [] + } + ] + } + ] + ], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5", + "function_name": "dispute", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM" + }, + { + "u64": "0" + } + ] + } + }, + "sub_invocations": [ + { + "function": { + "contract_fn": { + "contract_address": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL", + "function_name": "transfer", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5" + }, + { + "i128": "100" + } + ] + } + }, + "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": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": "1033654523790656264" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": "4837995959683129791" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4", + "key": { + "ledger_key_nonce": { + "nonce": "2032731177588607455" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM", + "key": { + "ledger_key_nonce": { + "nonce": "4270020994084947596" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5", + "key": { + "vec": [ + { + "symbol": "Assertion" + }, + { + "u64": "0" + } + ] + }, + "durability": "persistent", + "val": { + "map": [ + { + "key": { + "symbol": "asserter" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4" + } + }, + { + "key": { + "symbol": "bond" + }, + "val": { + "i128": "100" + } + }, + { + "key": { + "symbol": "disputer" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM" + } + }, + { + "key": { + "symbol": "final_outcome" + }, + "val": "void" + }, + { + "key": { + "symbol": "finalizer" + }, + "val": "void" + }, + { + "key": { + "symbol": "opened_at" + }, + "val": { + "u64": "0" + } + }, + { + "key": { + "symbol": "outcome" + }, + "val": { + "bool": true + } + }, + { + "key": { + "symbol": "resolvers" + }, + "val": { + "vec": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" + } + ] + } + }, + { + "key": { + "symbol": "status" + }, + "val": { + "vec": [ + { + "symbol": "Disputed" + } + ] + } + }, + { + "key": { + "symbol": "voted" + }, + "val": { + "vec": [] + } + }, + { + "key": { + "symbol": "votes_against_outcome" + }, + "val": { + "u32": 0 + } + }, + { + "key": { + "symbol": "votes_for_outcome" + }, + "val": { + "u32": 0 + } + } + ] + } + } + }, + "ext": "v0" + }, + "live_until": 518400 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAATYON" + } + }, + { + "key": { + "vec": [ + { + "symbol": "BondAmount" + } + ] + }, + "val": { + "i128": "100" + } + }, + { + "key": { + "vec": [ + { + "symbol": "ChallengeWindow" + } + ] + }, + "val": { + "u64": "3600" + } + }, + { + "key": { + "vec": [ + { + "symbol": "FinalizeRewardBps" + } + ] + }, + "val": { + "u32": 0 + } + }, + { + "key": { + "vec": [ + { + "symbol": "NextId" + } + ] + }, + "val": { + "u64": "1" + } + }, + { + "key": { + "vec": [ + { + "symbol": "Paused" + } + ] + }, + "val": { + "bool": false + } + }, + { + "key": { + "vec": [ + { + "symbol": "Resolvers" + } + ] + }, + "val": { + "vec": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" + } + ] + } + }, + { + "key": { + "vec": [ + { + "symbol": "Token" + } + ] + }, + "val": { + "address": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL" + } + } + ] + } + } + } + }, + "ext": "v0" + }, + "live_until": 518400 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAATYON", + "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": { + "vec": [ + { + "symbol": "Balance" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4" + } + ] + }, + "durability": "persistent", + "val": { + "map": [ + { + "key": { + "symbol": "amount" + }, + "val": { + "i128": "900" + } + }, + { + "key": { + "symbol": "authorized" + }, + "val": { + "bool": true + } + }, + { + "key": { + "symbol": "clawback" + }, + "val": { + "bool": false + } + } + ] + } + } + }, + "ext": "v0" + }, + "live_until": 518400 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL", + "key": { + "vec": [ + { + "symbol": "Balance" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM" + } + ] + }, + "durability": "persistent", + "val": { + "map": [ + { + "key": { + "symbol": "amount" + }, + "val": { + "i128": "900" + } + }, + { + "key": { + "symbol": "authorized" + }, + "val": { + "bool": true + } + }, + { + "key": { + "symbol": "clawback" + }, + "val": { + "bool": false + } + } + ] + } + } + }, + "ext": "v0" + }, + "live_until": 518400 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL", + "key": { + "vec": [ + { + "symbol": "Balance" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5" + } + ] + }, + "durability": "persistent", + "val": { + "map": [ + { + "key": { + "symbol": "amount" + }, + "val": { + "i128": "200" + } + }, + { + "key": { + "symbol": "authorized" + }, + "val": { + "bool": true + } + }, + { + "key": { + "symbol": "clawback" + }, + "val": { + "bool": false + } + } + ] + } + } + }, + "ext": "v0" + }, + "live_until": 518400 + }, + { + "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 From 35cf8e826d98d038e604fe2f04652bfde0e2390b Mon Sep 17 00:00:00 2001 From: ZacLou Date: Thu, 3 Sep 2026 09:03:28 +0800 Subject: [PATCH 3/3] fix(tholos-v2): bound max_total_weight / max_position ratio to raise Sybil cost (#168) Closes #168 - Enforce max_total_weight <= MAX_TOTAL_WEIGHT_TO_POSITION_RATIO * max_position (ratio = 10) in initialize. - Add Error::InvalidWeightRatio and document it in CONTRACT_V2.md. - Add tests for the bound and adjust the existing max-position test so its policy satisfies the new ratio. cargo test --workspace passes after rebuilding the tholos WASM for contract imports. --- contracts/tholos-v2/src/lib.rs | 21 + contracts/tholos-v2/src/test.rs | 48 +- ...pts_max_total_weight_at_ratio_bound.1.json | 467 ++++++++++++++++++ ..._max_total_weight_above_ratio_bound.1.json | 225 +++++++++ ...register_exceeds_max_position_fails.1.json | 8 +- docs/src/CONTRACT_V2.md | 9 +- 6 files changed, 772 insertions(+), 6 deletions(-) create mode 100644 contracts/tholos-v2/test_snapshots/test/test_initialize_accepts_max_total_weight_at_ratio_bound.1.json create mode 100644 contracts/tholos-v2/test_snapshots/test/test_initialize_rejects_max_total_weight_above_ratio_bound.1.json diff --git a/contracts/tholos-v2/src/lib.rs b/contracts/tholos-v2/src/lib.rs index b9524fd..9003f75 100644 --- a/contracts/tholos-v2/src/lib.rs +++ b/contracts/tholos-v2/src/lib.rs @@ -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; @@ -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 @@ -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, diff --git a/contracts/tholos-v2/src/test.rs b/contracts/tholos-v2/src/test.rs index 6b2b090..bcb07a5 100644 --- a/contracts/tholos-v2/src/test.rs +++ b/contracts/tholos-v2/src/test.rs @@ -548,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(); @@ -1276,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(); 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..6645199 --- /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": "1000" + }, + { + "i128": "10000" + } + ] + } + }, + "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": "1000" + } + }, + { + "key": { + "symbol": "max_total_weight" + }, + "val": { + "i128": "10000" + } + }, + { + "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..218a67f 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`. Also bounded by `MAX_TOTAL_WEIGHT_TO_POSITION_RATIO * max_position` so a single actor must split across many positions to dominate. | ### `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. | @@ -227,6 +228,12 @@ matching `Invalid*` error for any out-of-range parameter. ### `get_policy() -> PolicySnapshotV2` +The `max_total_weight / max_position` ratio is capped at +`MAX_TOTAL_WEIGHT_TO_POSITION_RATIO` (10). This raises the cost of +Sybil-style address splitting but does not eliminate plutocracy: a +coalition controlling more than half of the eligible bonded capital can +still determine the result. See issue #168. + Read-only lookup of the deployment-wide policy defaults new assertions are currently pinned from. Fails with `NotInitialized` before `initialize`.