diff --git a/soroban-contracts/contracts/dispute-resolution/src/lib.rs b/soroban-contracts/contracts/dispute-resolution/src/lib.rs index e81b129..afd9f9e 100644 --- a/soroban-contracts/contracts/dispute-resolution/src/lib.rs +++ b/soroban-contracts/contracts/dispute-resolution/src/lib.rs @@ -87,6 +87,12 @@ pub struct Config { pub commit_window: u32, /// Length of the reveal phase, in ledgers after the commit deadline. pub reveal_window: u32, + /// Address of the reputation contract. + pub reputation_contract: Address, + /// Base vote weight for jurors with no reputation. + pub base_vote_weight: u64, + /// Margin threshold in basis points for close calls (e.g., 500 = 5%). + pub close_call_margin_bps: u32, } /// The final verdict of a dispute, set once by `resolve`. @@ -117,17 +123,28 @@ pub struct Dispute { pub commit_deadline: u32, /// Last ledger on which `reveal_vote` is accepted. pub reveal_deadline: u32, - /// Jurors who committed (and staked). Also the slashing denominator. + /// Jurors who committed (and staked). pub juror_count: u32, - /// Revealed votes favoring the plaintiff (`vote == true`). + /// Jurors who have revealed their votes. + pub revealed_count: u32, + /// Total voting weight of all committed jurors. + pub total_committed_weight: u64, + /// Revealed voting weight favoring the plaintiff (`vote == true`). + pub yes_weight: u64, + /// Revealed voting weight favoring the defendant (`vote == false`). + pub no_weight: u64, + /// Number of revealed voters favoring the plaintiff. pub yes_count: u32, - /// Revealed votes favoring the defendant (`vote == false`). + /// Number of revealed voters favoring the defendant. pub no_count: u32, /// Verdict; `Undecided` until `resolve`. pub outcome: Outcome, - /// Slashed-pot share each winning juror may withdraw on top of their stake. - /// Fixed at `resolve`; `0` for tie/quorum-failure. - pub reward_per_winner: i128, + /// Indicates if the margin was below `close_call_margin_bps`. + pub is_close_call: bool, + /// The total amount of staked tokens from slashed jurors. + pub total_slashed_pot: i128, + /// The total voting weight of the winning side. + pub winning_weight: u64, /// `true` once `resolve` has run. pub resolved: bool, } @@ -138,6 +155,8 @@ pub struct Dispute { pub struct Juror { /// `sha256(salt || vote_byte || juror_xdr)` submitted during commit. pub commitment: BytesN<32>, + /// The voting power assigned to this juror based on reputation. + pub weight: u64, /// Set once the commitment has been successfully revealed. pub revealed: bool, /// The revealed vote; meaningful only when `revealed`. @@ -174,6 +193,11 @@ const INSTANCE_LIFETIME_THRESHOLD: u32 = DAY_IN_LEDGERS * 30; const DISPUTE_BUMP_AMOUNT: u32 = DAY_IN_LEDGERS * 30; const DISPUTE_LIFETIME_THRESHOLD: u32 = DAY_IN_LEDGERS * 29; +#[soroban_sdk::contractclient(name = "ReputationClient")] +pub trait Reputation { + fn get_reputation_score_x10000(env: Env, worker: Address) -> u64; +} + #[contract] pub struct DisputeResolution; @@ -240,10 +264,16 @@ impl DisputeResolution { commit_deadline, reveal_deadline, juror_count: 0, + revealed_count: 0, + total_committed_weight: 0, + yes_weight: 0, + no_weight: 0, yes_count: 0, no_count: 0, outcome: Outcome::Undecided, - reward_per_winner: 0, + is_close_call: false, + total_slashed_pot: 0, + winning_weight: 0, resolved: false, }; env.storage().persistent().set(&key, &dispute); @@ -280,7 +310,6 @@ impl DisputeResolution { return Err(Error::AlreadyCommitted); } - // Pull the juror's stake into the contract's own balance. let config = Self::read_config(&env); let token = Self::read_token(&env); token::Client::new(&env, &token).transfer( @@ -289,8 +318,16 @@ impl DisputeResolution { &config.juror_stake, ); + let rep_client = ReputationClient::new(&env, &config.reputation_contract); + let rep_score = match rep_client.try_get_reputation_score_x10000(&juror) { + Ok(Ok(score)) => score, + _ => 0, + }; + let weight = core::cmp::max(rep_score, config.base_vote_weight); + let record = Juror { commitment, + weight, revealed: false, vote: false, withdrawn: false, @@ -299,6 +336,7 @@ impl DisputeResolution { Self::bump_dispute(&env, &juror_key); dispute.juror_count = dispute.juror_count.saturating_add(1); + dispute.total_committed_weight = dispute.total_committed_weight.saturating_add(weight); env.storage().persistent().set(&dispute_key, &dispute); Self::bump_dispute(&env, &dispute_key); Self::bump_instance(&env); @@ -349,10 +387,13 @@ impl DisputeResolution { Self::bump_dispute(&env, &juror_key); if vote { + dispute.yes_weight = dispute.yes_weight.saturating_add(record.weight); dispute.yes_count = dispute.yes_count.saturating_add(1); } else { + dispute.no_weight = dispute.no_weight.saturating_add(record.weight); dispute.no_count = dispute.no_count.saturating_add(1); } + dispute.revealed_count = dispute.revealed_count.saturating_add(1); env.storage().persistent().set(&dispute_key, &dispute); Self::bump_dispute(&env, &dispute_key); Self::bump_instance(&env); @@ -378,32 +419,56 @@ impl DisputeResolution { } let config = Self::read_config(&env); - let revealed = dispute.yes_count.saturating_add(dispute.no_count); - - let (outcome, winner_count) = if revealed < config.min_jurors { - (Outcome::QuorumFailed, 0u32) - } else if dispute.yes_count > dispute.no_count { - (Outcome::Plaintiff, dispute.yes_count) - } else if dispute.no_count > dispute.yes_count { - (Outcome::Defendant, dispute.no_count) + let total_revealed_weight = dispute.yes_weight.saturating_add(dispute.no_weight); + + // Calculate margin (scaled by 10,000 for basis points) + let margin_bps = if total_revealed_weight == 0 { + 0 } else { - (Outcome::Tie, 0u32) + let diff = dispute.yes_weight.abs_diff(dispute.no_weight); + (diff as u128 * 10_000 / total_revealed_weight as u128) as u32 }; - // For a decided outcome, losers = every staker who is not a winning - // voter (minority voters *and* no-shows). Their stakes form the pot, - // split evenly among the winners. Integer division may leave dust in the - // contract; it is never over-distributed. - let reward_per_winner = if winner_count > 0 { - let losers = dispute.juror_count.saturating_sub(winner_count) as i128; - let pot = losers.saturating_mul(config.juror_stake); - pot / winner_count as i128 + let is_close_call = margin_bps <= config.close_call_margin_bps; + + let outcome = if dispute.revealed_count < config.min_jurors { + Outcome::QuorumFailed + } else if dispute.yes_weight > dispute.no_weight { + Outcome::Plaintiff + } else if dispute.no_weight > dispute.yes_weight { + Outcome::Defendant } else { - 0 + Outcome::Tie }; + let winning_weight = match outcome { + Outcome::Plaintiff => dispute.yes_weight, + Outcome::Defendant => dispute.no_weight, + _ => 0, + }; + + let winning_count = match outcome { + Outcome::Plaintiff => dispute.yes_count, + Outcome::Defendant => dispute.no_count, + _ => 0, + }; + + let mut total_slashed_pot: i128 = 0; + if winning_weight > 0 { + let no_shows_count = dispute.juror_count.saturating_sub(dispute.revealed_count) as i128; + total_slashed_pot += no_shows_count.saturating_mul(config.juror_stake); + + if !is_close_call { + // Not a close call: minority is also slashed + let minority_count = dispute.revealed_count.saturating_sub(winning_count) as i128; + total_slashed_pot += minority_count.saturating_mul(config.juror_stake); + } + } + dispute.outcome = outcome; - dispute.reward_per_winner = reward_per_winner; + dispute.is_close_call = is_close_call; + dispute.total_slashed_pot = total_slashed_pot; + dispute.winning_weight = winning_weight; dispute.resolved = true; env.storage().persistent().set(&dispute_key, &dispute); Self::bump_dispute(&env, &dispute_key); @@ -443,10 +508,28 @@ impl DisputeResolution { let payout = match dispute.outcome { Outcome::Plaintiff | Outcome::Defendant => { let winning_vote = dispute.outcome == Outcome::Plaintiff; - if record.revealed && record.vote == winning_vote { - config.juror_stake + dispute.reward_per_winner + if record.revealed { + if record.vote == winning_vote { + // Winner: gets stake + proportional share of slashed pot + let reward = if dispute.winning_weight > 0 { + (record.weight as i128 * dispute.total_slashed_pot) + / dispute.winning_weight as i128 + } else { + 0 + }; + config.juror_stake + reward + } else { + // Minority voter + if dispute.is_close_call { + // Refunded + config.juror_stake + } else { + // Slashed + return Err(Error::NothingToWithdraw); + } + } } else { - // Slashed: minority voter or no-show. Stake stays in the pot. + // No-show is always slashed return Err(Error::NothingToWithdraw); } } diff --git a/soroban-contracts/contracts/dispute-resolution/src/test.rs b/soroban-contracts/contracts/dispute-resolution/src/test.rs index 07482b8..bb9e8eb 100644 --- a/soroban-contracts/contracts/dispute-resolution/src/test.rs +++ b/soroban-contracts/contracts/dispute-resolution/src/test.rs @@ -20,6 +20,7 @@ const RESOLVE_AT: u32 = 1_201; // after reveal phase struct Fixture<'a> { env: Env, contract: DisputeResolutionClient<'a>, + reputation: MockReputationClient<'a>, token: token::Client<'a>, token_admin: token::StellarAssetClient<'a>, admin: Address, @@ -27,6 +28,20 @@ struct Fixture<'a> { defendant: Address, } +#[soroban_sdk::contract] +pub struct MockReputation; + +#[soroban_sdk::contractimpl] +impl MockReputation { + pub fn get_reputation_score_x10000(env: Env, worker: Address) -> u64 { + env.storage().instance().get(&worker).unwrap_or(0) + } + + pub fn set_score(env: Env, worker: Address, score: u64) { + env.storage().instance().set(&worker, &score); + } +} + fn setup<'a>() -> Fixture<'a> { let env = Env::default(); env.mock_all_auths(); @@ -41,6 +56,9 @@ fn setup<'a>() -> Fixture<'a> { let token = token::Client::new(&env, &token_address); let token_admin = token::StellarAssetClient::new(&env, &token_address); + let reputation_id = env.register(MockReputation, ()); + let reputation = MockReputationClient::new(&env, &reputation_id); + let contract_id = env.register(DisputeResolution, ()); let contract = DisputeResolutionClient::new(&env, &contract_id); contract.initialize( @@ -51,12 +69,16 @@ fn setup<'a>() -> Fixture<'a> { min_jurors: MIN_JURORS, commit_window: COMMIT_WINDOW, reveal_window: REVEAL_WINDOW, + reputation_contract: reputation_id.clone(), + base_vote_weight: 10_000, + close_call_margin_bps: 500, }, ); Fixture { env, contract, + reputation, token, token_admin, admin, @@ -109,6 +131,8 @@ fn initialize_stores_config() { assert_eq!(cfg.min_jurors, MIN_JURORS); assert_eq!(cfg.commit_window, COMMIT_WINDOW); assert_eq!(cfg.reveal_window, REVEAL_WINDOW); + assert_eq!(cfg.base_vote_weight, 10_000); + assert_eq!(cfg.close_call_margin_bps, 500); assert_eq!(f.contract.get_admin(), f.admin); } @@ -124,6 +148,9 @@ fn double_initialize_fails() { min_jurors: MIN_JURORS, commit_window: COMMIT_WINDOW, reveal_window: REVEAL_WINDOW, + reputation_contract: f.reputation.address.clone(), + base_vote_weight: 10_000, + close_call_margin_bps: 500, }, ); assert_eq!(res, Err(Ok(Error::AlreadyInitialized))); @@ -148,6 +175,9 @@ fn initialize_rejects_bad_config() { min_jurors: MIN_JURORS, commit_window: COMMIT_WINDOW, reveal_window: REVEAL_WINDOW, + reputation_contract: Address::generate(&env), + base_vote_weight: 10_000, + close_call_margin_bps: 500, } ), Err(Ok(Error::InvalidConfig)) @@ -162,6 +192,9 @@ fn initialize_rejects_bad_config() { min_jurors: 0, commit_window: COMMIT_WINDOW, reveal_window: REVEAL_WINDOW, + reputation_contract: Address::generate(&env), + base_vote_weight: 10_000, + close_call_margin_bps: 500, } ), Err(Ok(Error::InvalidConfig)) @@ -181,6 +214,7 @@ fn open_dispute_sets_deadlines() { assert_eq!(d.reveal_deadline, OPEN_AT + COMMIT_WINDOW + REVEAL_WINDOW); assert_eq!(d.outcome, Outcome::Undecided); assert_eq!(d.juror_count, 0); + assert_eq!(d.total_committed_weight, 0); assert!(!d.resolved); } @@ -240,14 +274,17 @@ fn full_lifecycle_plaintiff_wins() { reveal(&f, 1, &j2, true, 12); reveal(&f, 1, &j3, false, 13); - // Resolve: plaintiff wins 2-1. + // Resolve: plaintiff wins 2-1. (Weights: 20k to 10k) + // Diff is 10k, total is 30k. Margin is 33.3%, > 5% (500 bps), so not a close call. set_ledger(&f.env, RESOLVE_AT); assert_eq!(f.contract.resolve(&1), Outcome::Plaintiff); let d = f.contract.get_dispute(&1); - // losers = 1 stake (100) split among 2 winners -> 50 each. - assert_eq!(d.reward_per_winner, 50); + assert!(!d.is_close_call); + assert_eq!(d.total_slashed_pot, 100); - // Winners reclaim stake + reward; the loser is slashed. + // Winners reclaim stake + reward. + // Total winning weight = 20,000. Each winner has 10,000 weight. + // Reward each = (10_000 * 100) / 20_000 = 50. assert_eq!(f.contract.withdraw(&1, &j1), STAKE + 50); assert_eq!(f.contract.withdraw(&1, &j2), STAKE + 50); assert_eq!( @@ -317,8 +354,11 @@ fn no_show_juror_is_slashed_and_pot_goes_to_winners() { set_ledger(&f.env, RESOLVE_AT); assert_eq!(f.contract.resolve(&1), Outcome::Plaintiff); let d = f.contract.get_dispute(&1); - // losers = 1 (the no-show); pot 100 split 3 ways -> 33 each (1 unit dust). - assert_eq!(d.reward_per_winner, 33); + // 3 winners, 1 no-show. Not a close call since 3-0. + // Total winning weight = 30,000. Each winner has 10,000 weight. + // Pot = 100 from no-show. + // Reward each = (10_000 * 100) / 30_000 = 33. + assert_eq!(d.total_slashed_pot, 100); assert_eq!(f.contract.withdraw(&1, &j1), STAKE + 33); assert_eq!(f.contract.withdraw(&1, &j2), STAKE + 33); @@ -332,6 +372,83 @@ fn no_show_juror_is_slashed_and_pot_goes_to_winners() { assert_eq!(f.token.balance(&f.contract.address), 1); } +#[test] +fn close_call_refunds_minority() { + let f = setup(); + open_default(&f, 1); + + let j1 = new_juror(&f); + let j2 = new_juror(&f); + let j3 = new_juror(&f); + let j4 = new_juror(&f); // No-show + + f.reputation.set_score(&j1, &20_000); // 20k, Plaintiff + f.reputation.set_score(&j2, &19_000); // 19k, Defendant + f.reputation.set_score(&j3, &0); // 10k (base), Plaintiff + f.reputation.set_score(&j4, &0); // 10k (base), no-show + + // Total Plaintiff: 30k + // Total Defendant: 19k + // Wait, 30k - 19k = 11k margin. 11k / 49k = 22% (not close). + // Let's make it close! + // J1: 20k, Plaintiff + // J2: 19k, Defendant + // J3: 1k, Defendant -> total Defendant = 20k. + // Wait, Plaintiff 20k, Defendant 20k is a tie. + // Let's do J1: 20k (Plaintiff), J2: 18k (Defendant), J3: 1k (Defendant). Plaintiff wins 20k vs 19k. Margin 1k / 39k = 2.5%. + f.reputation.set_score(&j1, &20_000); + f.reputation.set_score(&j2, &18_000); + f.reputation.set_score(&j3, &1_000); + f.reputation.set_score(&j4, &10_000); // No-show + + // Note: Since base_vote_weight is 10,000, J3's weight will be max(1000, 10000) = 10000! + // J2's weight = max(18000, 10000) = 18000. + // Defendant total = 18000 + 10000 = 28000. + // Plaintiff total (J1) = 20000. + // Then Defendant wins 28k to 20k. Margin 8k / 48k = 16.6% (Not close). + // Let's set J1: 30_000 (Plaintiff), J2: 29_000 (Defendant), J3: 10_000 (Defendant). + // Plaintiff: 30k. Defendant: 39k. Defendant wins by 9k. 9k / 69k = 13% (Not close). + // Let's set close_call_margin_bps to a higher value for this test? No, it's set in Config to 500 (5%). + // Let's just adjust weights: + // J1: 40_000 Plaintiff + // J2: 29_000 Defendant + // J3: 10_000 Defendant + // Plaintiff: 40k. Defendant: 39k. Plaintiff wins! Margin = 1k. Total = 79k. Margin = 1k / 79k = 1.2%. This is close! + f.reputation.set_score(&j1, &40_000); + f.reputation.set_score(&j2, &29_000); + f.reputation.set_score(&j3, &10_000); + + set_ledger(&f.env, COMMIT_AT); + commit(&f, 1, &j1, true, 81); + commit(&f, 1, &j2, false, 82); + commit(&f, 1, &j3, false, 83); + commit(&f, 1, &j4, false, 84); // No show + + set_ledger(&f.env, REVEAL_AT); + reveal(&f, 1, &j1, true, 81); + reveal(&f, 1, &j2, false, 82); + reveal(&f, 1, &j3, false, 83); + + set_ledger(&f.env, RESOLVE_AT); + assert_eq!(f.contract.resolve(&1), Outcome::Plaintiff); + + let d = f.contract.get_dispute(&1); + assert!(d.is_close_call); + // Slashed pot should ONLY be J3's stake (100) + assert_eq!(d.total_slashed_pot, 100); + + // J1 is the winner, gets stake + reward (100 pot / 1 winner = 100) + assert_eq!(f.contract.withdraw(&1, &j1), STAKE + 100); + // J2 and J3 are the minority, get refunded because of close call + assert_eq!(f.contract.withdraw(&1, &j2), STAKE); + assert_eq!(f.contract.withdraw(&1, &j3), STAKE); + // J4 is slashed + assert_eq!( + f.contract.try_withdraw(&1, &j4), + Err(Ok(Error::NothingToWithdraw)) + ); +} + #[test] fn tie_refunds_all_stakers() { let f = setup();