diff --git a/soroban-contracts/Cargo.lock b/soroban-contracts/Cargo.lock index f3270fb..668c24c 100644 --- a/soroban-contracts/Cargo.lock +++ b/soroban-contracts/Cargo.lock @@ -725,6 +725,13 @@ dependencies = [ "subtle", ] +[[package]] +name = "guildworkman-dispute-resolution" +version = "0.1.0" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "guildworkman-escrow" version = "0.1.0" diff --git a/soroban-contracts/Cargo.toml b/soroban-contracts/Cargo.toml index dc55639..cdee984 100644 --- a/soroban-contracts/Cargo.toml +++ b/soroban-contracts/Cargo.toml @@ -6,6 +6,7 @@ members = [ "contracts/loyalty-token", "contracts/loyalty-emissions", "contracts/governance-guard", + "contracts/dispute-resolution", ] [workspace.dependencies] diff --git a/soroban-contracts/README.md b/soroban-contracts/README.md index 2798690..24da3a7 100644 --- a/soroban-contracts/README.md +++ b/soroban-contracts/README.md @@ -3,7 +3,7 @@ ![CI](https://github.com/workman-labs/guildworkman-contracts/actions/workflows/ci.yml/badge.svg) Soroban (Stellar) smart contracts for GuildWorkman, the skilled-worker booking -marketplace. This workspace has four independent contracts: +marketplace. This workspace has five independent contracts: | Contract | Path | Purpose | |---|---|---| @@ -11,7 +11,8 @@ marketplace. This workspace has four independent contracts: | `reputation` | `contracts/reputation` | Stores one immutable review per completed appointment and keeps a running rating aggregate per skilled worker. | | `loyalty-token` | `contracts/loyalty-token` | A SEP-41-style fungible token used to reward clients/workers with points on completed appointments. Only a designated `minter` (the backend's service account) can mint. | | `loyalty-emissions` | `contracts/loyalty-emissions` | An emission engine that owns the `loyalty-token`'s `minter` role. Instead of minting rewards in a lump sum, it streams them out of per-account linear vesting schedules, throttled by per-account and global rate limits, and lets the admin reclaim allocations left unclaimed past a deadline. | -| `governance-guard` | `contracts/governance-guard` | Not a deployed contract — a shared library the four above depend on, providing the multi-sig upgrade/migration pattern described in [Upgrade governance](#upgrade-governance). | +| `dispute-resolution` | `contracts/dispute-resolution` | Decentralized alternative to `escrow`'s single-admin arbitration: resolves a dispute via a **staked jury** using **commit-reveal** voting, then pays the majority out of the slashed stakes of the minority and no-shows. | +| `governance-guard` | `contracts/governance-guard` | Not a deployed contract — a shared library that four of the contracts above (all except `dispute-resolution`) depend on, providing the multi-sig upgrade/migration pattern described in [Upgrade governance](#upgrade-governance). | These mirror the domain already implemented server-side in the backend ([`../backend-api`](../backend-api): `AppointmentService`, `ReviewService`, @@ -185,6 +186,16 @@ Each contract has unit tests under `contracts//src/test.rs` using schedule params, reclaim-before-vesting-end, double-claim, double-reclaim, claiming a missing/reclaimed schedule, and looping claims across many windows never mints more than a schedule's `total`. +- **dispute-resolution** (27 tests): the full commit → reveal → resolve → + withdraw lifecycle pays a plaintiff- or defendant-majority jury out of the + losers' stakes; a no-show who committed but never revealed is slashed and + their stake flows to the winners; a tie or a below-quorum turnout refunds + every staker (including non-revealers) with no slashing; the state machine + rejects committing/revealing/resolving/withdrawing out of phase; and + adversarial paths — double-init, bad config, duplicate/same-party disputes, + double commit/reveal/resolve/withdraw, revealing with the wrong vote or salt, + a copycat replaying another juror's commitment being unable to reveal it, and + a slashed loser never draining the pot via repeated withdrawals. These are unit tests against the in-process Soroban test host (`Env::default()` + `mock_all_auths()`), not integration tests against a real @@ -206,8 +217,9 @@ stellar contract invoke \ ``` Repeat `deploy` for `guildworkman_reputation.wasm`, -`guildworkman_loyalty_token.wasm`, and `guildworkman_loyalty_emissions.wasm`, -then call each contract's `initialize` once. For the emission engine to be +`guildworkman_loyalty_token.wasm`, `guildworkman_loyalty_emissions.wasm`, and +`guildworkman_dispute_resolution.wasm`, then call each contract's `initialize` +once. For the emission engine to be able to mint, point it at the token in its `initialize` and then hand it the token's `minter` role: @@ -556,6 +568,122 @@ stellar contract invoke --id $EMISSIONS --source admin --network testnet \ -- reclaim --beneficiary $WORKER_ADDR ``` +### dispute-resolution + +> ⚠️ **v1, unaudited, no appeals** — single-round staked-jury voting with no sybil-resistant/weighted jury selection. Read [Security considerations / known limitations](#security-considerations--known-limitations) before integrating. + +Decentralized dispute resolution: a staked jury decides the outcome via +commit-reveal voting, and the majority is paid out of the slashed stakes of the +minority and no-shows. This is the trust-minimized counterpart to `escrow`'s +admin-only `resolve_dispute` — an integration could have `escrow` read a +resolved dispute's `Outcome` instead of trusting a single arbiter. + +- `initialize(admin: Address, token: Address, config: Config)` — `config` is + `{ juror_stake: i128, min_jurors: u32, commit_window: u32, reveal_window: u32 }` +- `open_dispute(dispute_id: u64, plaintiff: Address, defendant: Address)` — + admin-only; starts the commit phase (`commit_deadline = now + commit_window`, + `reveal_deadline = commit_deadline + reveal_window`) +- `commit_vote(dispute_id: u64, juror: Address, commitment: BytesN<32>)` — + juror-authorized; stakes `juror_stake` and records a hidden vote. Accepted + only while `now <= commit_deadline` +- `reveal_vote(dispute_id: u64, juror: Address, vote: bool, salt: BytesN<32>)` — + juror-authorized; discloses the vote (`true` = plaintiff, `false` = defendant) + during the reveal phase. `vote`+`salt` must hash to the committed value +- `resolve(dispute_id: u64) -> Outcome` — permissionless; after + `reveal_deadline`, tallies revealed votes and fixes the per-winner reward +- `withdraw(dispute_id: u64, juror: Address) -> i128` — juror-authorized; + after resolution, pays a winning juror `juror_stake + reward_per_winner`, + refunds every staker on a tie/quorum-failure, and slashes losers/no-shows +- `compute_commitment(juror: Address, vote: bool, salt: BytesN<32>) -> BytesN<32>` — + helper so callers build the commitment with the exact domain separation the + contract enforces +- `get_dispute(dispute_id) -> Dispute`, `get_juror(dispute_id, juror) -> Juror`, + `get_config() -> Config`, `get_admin() -> Address`, `get_token() -> Address` + — read-only views + +#### Commit-reveal & incentive model + +A juror votes in two steps so no one can copy the current leader or be coerced +for a visible vote: + +1. **Commit**: submit `commitment = sha256(salt || vote_byte || juror_xdr)` and + stake `juror_stake`. Binding the juror's own address into the preimage means + a copycat who replays someone else's commitment can never produce a matching + reveal from their own address. +2. **Reveal**: disclose `(vote, salt)`; the contract recomputes the hash and, on + a match, records the vote and bumps the tally. + +At `resolve` the side with more revealed votes wins. The **slashed pot** — the +stakes of the minority voters *and* of everyone who committed but never revealed +— is split evenly among the winners (integer division; any remainder dust stays +in the contract). On a **tie** or a turnout below `min_jurors` (`QuorumFailed`) +nobody is slashed and every staker reclaims their stake. Withdrawals use a pull +pattern, so resolution never loops over an unbounded juror set, and a juror is +marked settled before any transfer (checks-effects-interactions). + +#### Storage layout + +| `DataKey` variant | Storage | Holds | +|---|---|---| +| `Admin` | instance | The `Address` allowed to `open_dispute`, set once in `initialize`. | +| `Token` | instance | The staking-token contract `Address` jurors post collateral in and are paid from. | +| `Config` | instance | `Config { juror_stake, min_jurors, commit_window, reveal_window }`, fixed at `initialize`. | +| `Dispute(u64)` | persistent | A `Dispute { plaintiff, defendant, commit_deadline, reveal_deadline, juror_count, yes_count, no_count, outcome, reward_per_winner, resolved }` per `dispute_id`. | +| `Juror(u64, Address)` | persistent | A `Juror { commitment, revealed, vote, withdrawn }` per `(dispute_id, juror)`. | + +#### Errors + +| Variant | Code | Meaning | +|---|---|---| +| `AlreadyInitialized` | 1 | `initialize` called more than once. | +| `NotInitialized` | 2 | A method needing state was called before `initialize`. | +| `InvalidConfig` | 3 | A non-positive stake, or a zero quorum/commit/reveal window, passed to `initialize`. | +| `DisputeExists` | 4 | `open_dispute` reused an existing `dispute_id`. | +| `DisputeNotFound` | 5 | No dispute stored under that `dispute_id`. | +| `NotCommitPhase` | 6 | `commit_vote` after the commit deadline. | +| `NotRevealPhase` | 7 | `reveal_vote` outside the reveal window. | +| `AlreadyCommitted` | 8 | `commit_vote` twice for the same `(dispute, juror)`. | +| `NotCommitted` | 9 | `reveal_vote`/`withdraw`/`get_juror` for a juror who never committed. | +| `AlreadyRevealed` | 10 | `reveal_vote` twice. | +| `InvalidReveal` | 11 | Revealed `(vote, salt)` doesn't hash to the stored commitment. | +| `NotResolvable` | 12 | `resolve` called on or before the reveal deadline. | +| `AlreadyResolved` | 13 | `resolve` called on an already-resolved dispute. | +| `NotResolved` | 14 | `withdraw` before the dispute is resolved. | +| `AlreadyWithdrawn` | 15 | `withdraw` called twice by the same juror. | +| `NothingToWithdraw` | 16 | `withdraw` by a slashed juror (minority voter or no-show) on a decided dispute. | +| `SameParties` | 17 | `open_dispute` with `plaintiff == defendant`. | + +#### CLI usage + +```sh +# One-time setup: stake 100 units, quorum of 3, ~1h commit + ~1h reveal windows. +stellar contract invoke --id $DISPUTES --source admin --network testnet \ + -- initialize --admin $ADMIN_ADDR --token $TOKEN_ADDR \ + --config '{ "juror_stake": "100", "min_jurors": 3, "commit_window": 720, "reveal_window": 720 }' + +# Admin opens a dispute between a client (plaintiff) and worker (defendant). +stellar contract invoke --id $DISPUTES --source admin --network testnet \ + -- open_dispute --dispute_id 1 --plaintiff $CLIENT_ADDR --defendant $WORKER_ADDR + +# A juror builds their commitment off-chain (favoring the plaintiff), then stakes + commits. +stellar contract invoke --id $DISPUTES --source juror --network testnet \ + -- compute_commitment --juror $JUROR_ADDR --vote true --salt $SALT_32B_HEX +stellar contract invoke --id $DISPUTES --source juror --network testnet \ + -- commit_vote --dispute_id 1 --juror $JUROR_ADDR --commitment $COMMITMENT_HEX + +# During the reveal window, the juror discloses their vote and salt. +stellar contract invoke --id $DISPUTES --source juror --network testnet \ + -- reveal_vote --dispute_id 1 --juror $JUROR_ADDR --vote true --salt $SALT_32B_HEX + +# After the reveal window, anyone tallies the result. +stellar contract invoke --id $DISPUTES --source anyone --network testnet \ + -- resolve --dispute_id 1 + +# Each juror settles: winners collect stake + reward, losers are slashed. +stellar contract invoke --id $DISPUTES --source juror --network testnet \ + -- withdraw --dispute_id 1 --juror $JUROR_ADDR +``` + ## Security considerations / known limitations - **Unaudited.** None of these contracts have had an independent security @@ -609,6 +737,31 @@ stellar contract invoke --id $EMISSIONS --source admin --network testnet \ is an arbitrary `String` supplied by the reviewer with no length cap or content moderation — treat it as untrusted user input wherever it's displayed. +- **Jury sybil / stake-weighting.** `dispute-resolution` gives every juror who + posts `juror_stake` exactly one vote and lets anyone join a dispute during the + commit phase. It resists *free* sybils (each identity must lock real + collateral) and hidden-vote manipulation (commit-reveal), but it does **not** + defend against a well-capitalized actor funding many jurors to swing a verdict + — there is no random jury selection, reputation weighting, or per-identity + gating. Set `juror_stake`/`min_jurors` relative to the value at stake, and + treat this as a coordination mechanism among semi-trusted jurors, not a + Kleros-grade court. Reputation-weighted / randomized jury selection (building + on the attestation-based reputation scoring in #20) is a deliberate v1 scope + boundary tracked in #29, not an oversight. +- **No appeals and majority-takes-all slashing.** A dispute resolves in a single + round; there is no appeal path, and honest jurors who happen to land in the + minority are slashed alongside malicious ones. A dishonest majority both wins + the verdict and confiscates the honest minority's stake. Ties and below-quorum + turnouts are handled safely (everyone is refunded, no slashing), and integer + division of the slashed pot can leave at most `winner_count - 1` units of dust + in the contract. Slashing the whole minority is an intentional Schelling-point + incentive (commit-reveal is what makes it defensible), but softening it for + close calls — margin-based partial refunds or an appeal round — is tracked as a + candidate v2 direction in #29. +- **Reveal-phase liveness assumption.** A juror who commits but never reveals is + treated as a loser and slashed (on a decided outcome), which is the intended + anti-griefing incentive — but it also means a juror censored or offline during + the reveal window loses their stake. Size `reveal_window` accordingly. ## Notes / follow-ups diff --git a/soroban-contracts/contracts/dispute-resolution/Cargo.toml b/soroban-contracts/contracts/dispute-resolution/Cargo.toml new file mode 100644 index 0000000..56be912 --- /dev/null +++ b/soroban-contracts/contracts/dispute-resolution/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "guildworkman-dispute-resolution" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib", "rlib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } + +[features] +testutils = ["soroban-sdk/testutils"] diff --git a/soroban-contracts/contracts/dispute-resolution/src/lib.rs b/soroban-contracts/contracts/dispute-resolution/src/lib.rs new file mode 100644 index 0000000..e81b129 --- /dev/null +++ b/soroban-contracts/contracts/dispute-resolution/src/lib.rs @@ -0,0 +1,580 @@ +#![no_std] + +//! Decentralized dispute resolution for GuildWorkman. +//! +//! Models a dispute as an on-chain state machine resolved by a **staked jury** +//! using **commit-reveal** voting, with **reward distribution** to the majority +//! and **slashing** of the minority and no-shows. It is the decentralized +//! counterpart to `escrow`'s single-admin `resolve_dispute`: instead of one +//! arbiter unilaterally deciding, any number of jurors stake collateral, vote +//! secretly, and are paid out of — or slashed into — a shared pot based on +//! whether they sided with the eventual majority. +//! +//! ## Lifecycle +//! +//! ```text +//! open_dispute commit_vote reveal_vote resolve +//! │ │ │ │ +//! ▼ ▼ ▼ ▼ +//! ┌────────┐ commit ┌────────┐ reveal ┌────────┐ tally ┌──────────┐ +//! │ OPEN │────────► │ COMMIT │────────► │ REVEAL │───────► │ RESOLVED │ +//! └────────┘ phase └────────┘ phase └────────┘ └──────────┘ +//! ``` +//! +//! There are two time-boxed phases, derived from the current ledger against the +//! dispute's two deadlines (not stored as an explicit enum, so they can't drift +//! out of sync with the clock): +//! +//! - **Commit phase** (`now <= commit_deadline`): a juror stakes `juror_stake` +//! tokens and submits `commitment = sha256(salt || vote_byte || juror_xdr)` — +//! a binding but hidden vote. +//! - **Reveal phase** (`commit_deadline < now <= reveal_deadline`): the juror +//! discloses `(vote, salt)`; the contract recomputes the hash and, on a match, +//! records the vote and bumps the running tally. +//! - **Resolution** (`now > reveal_deadline`): anyone calls `resolve`, which +//! tallies revealed votes into an outcome and fixes the per-winner reward. +//! Jurors then `withdraw` individually (a pull pattern, so resolution never +//! loops over an unbounded juror set). +//! +//! ## Why commit-reveal +//! +//! If votes were cast in the clear, later jurors could copy the current leader +//! (bandwagon / herding) and an attacker could bribe or coerce for a *visible* +//! vote. Hiding the vote behind a salted hash until everyone has committed +//! removes both. Binding the commitment to the juror's own address +//! (`… || juror_xdr`) stops a copycat from front-running by replaying someone +//! else's commitment: the copied hash can never be revealed from the copycat's +//! address. +//! +//! ## Incentives (rewards & slashing) +//! +//! At resolution the side with more revealed votes wins. Each **winner** gets +//! their stake back plus an equal share of the **slashed pot** — the stakes of +//! the minority voters *and* of everyone who committed but never revealed (a +//! no-show is treated as a loser, so jurors are paid to actually show up). On a +//! **tie** or a **quorum failure** (fewer than `min_jurors` revealed) nobody is +//! slashed: the vote is inconclusive, so every juror who staked simply reclaims +//! their stake. + +use soroban_sdk::{ + contract, contracterror, contractimpl, contracttype, token, xdr::ToXdr, Address, Bytes, BytesN, + Env, +}; + +/// Storage keys. See the README "Storage layout" table for the durability of +/// each and what it holds. +#[contracttype] +pub enum DataKey { + Admin, + Token, + Config, + /// A dispute case, keyed by `dispute_id`. + Dispute(u64), + /// A juror's per-dispute state: `(dispute_id, juror) -> Juror`. + Juror(u64, Address), +} + +/// Immutable-after-initialize jury parameters. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Config { + /// Collateral each juror must stake to vote, in `token` units. + pub juror_stake: i128, + /// Minimum number of *revealed* votes for the tally to be binding. Below + /// this the dispute resolves as `QuorumFailed` and no one is slashed. + pub min_jurors: u32, + /// Length of the commit phase, in ledgers after `open_dispute`. + pub commit_window: u32, + /// Length of the reveal phase, in ledgers after the commit deadline. + pub reveal_window: u32, +} + +/// The final verdict of a dispute, set once by `resolve`. +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Outcome { + /// Not yet resolved. + Undecided, + /// Majority sided with the plaintiff (`vote == true`). + Plaintiff, + /// Majority sided with the defendant (`vote == false`). + Defendant, + /// Equal revealed votes on each side — inconclusive, stakes refunded. + Tie, + /// Fewer than `min_jurors` revealed — inconclusive, stakes refunded. + QuorumFailed, +} + +/// A single dispute case and its running tally. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Dispute { + /// The party claiming they are owed the outcome (`vote == true` favors them). + pub plaintiff: Address, + /// The counterparty (`vote == false` favors them). + pub defendant: Address, + /// Last ledger on which `commit_vote` is accepted. + 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. + pub juror_count: u32, + /// Revealed votes favoring the plaintiff (`vote == true`). + pub yes_count: u32, + /// Revealed votes favoring the defendant (`vote == false`). + 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, + /// `true` once `resolve` has run. + pub resolved: bool, +} + +/// A juror's participation in one dispute. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Juror { + /// `sha256(salt || vote_byte || juror_xdr)` submitted during commit. + pub commitment: BytesN<32>, + /// Set once the commitment has been successfully revealed. + pub revealed: bool, + /// The revealed vote; meaningful only when `revealed`. + pub vote: bool, + /// Set once the juror has withdrawn (or been slashed) — blocks double spend. + pub withdrawn: bool, +} + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +pub enum Error { + AlreadyInitialized = 1, + NotInitialized = 2, + InvalidConfig = 3, + DisputeExists = 4, + DisputeNotFound = 5, + NotCommitPhase = 6, + NotRevealPhase = 7, + AlreadyCommitted = 8, + NotCommitted = 9, + AlreadyRevealed = 10, + InvalidReveal = 11, + NotResolvable = 12, + AlreadyResolved = 13, + NotResolved = 14, + AlreadyWithdrawn = 15, + NothingToWithdraw = 16, + SameParties = 17, +} + +const DAY_IN_LEDGERS: u32 = 17_280; // ~1 day at ~5s/ledger +const INSTANCE_BUMP_AMOUNT: u32 = DAY_IN_LEDGERS * 60; +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; + +#[contract] +pub struct DisputeResolution; + +#[contractimpl] +impl DisputeResolution { + /// One-time setup. + /// + /// - `admin` opens disputes and is the trust root for scheduling them. + /// - `token` is the staking token jurors post collateral in and are paid + /// from (a standard SEP-41 / Stellar Asset Contract). + /// - `config` fixes the stake, quorum, and phase windows for the contract's + /// life. + pub fn initialize( + env: Env, + admin: Address, + token: Address, + config: Config, + ) -> Result<(), Error> { + if env.storage().instance().has(&DataKey::Admin) { + return Err(Error::AlreadyInitialized); + } + admin.require_auth(); + Self::validate_config(&config)?; + + env.storage().instance().set(&DataKey::Admin, &admin); + env.storage().instance().set(&DataKey::Token, &token); + env.storage().instance().set(&DataKey::Config, &config); + env.storage() + .instance() + .extend_ttl(INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT); + Ok(()) + } + + /// Admin-only: open a new dispute between `plaintiff` and `defendant`. + /// + /// Starts the commit phase immediately: `commit_deadline = now + + /// commit_window` and `reveal_deadline = commit_deadline + reveal_window`. + pub fn open_dispute( + env: Env, + dispute_id: u64, + plaintiff: Address, + defendant: Address, + ) -> Result<(), Error> { + let admin = Self::require_admin(&env)?; + admin.require_auth(); + + if plaintiff == defendant { + return Err(Error::SameParties); + } + + let key = DataKey::Dispute(dispute_id); + if env.storage().persistent().has(&key) { + return Err(Error::DisputeExists); + } + + let config = Self::read_config(&env); + let now = env.ledger().sequence(); + let commit_deadline = now.saturating_add(config.commit_window); + let reveal_deadline = commit_deadline.saturating_add(config.reveal_window); + + let dispute = Dispute { + plaintiff, + defendant, + commit_deadline, + reveal_deadline, + juror_count: 0, + yes_count: 0, + no_count: 0, + outcome: Outcome::Undecided, + reward_per_winner: 0, + resolved: false, + }; + env.storage().persistent().set(&key, &dispute); + Self::bump_dispute(&env, &key); + Self::bump_instance(&env); + Ok(()) + } + + /// Juror-authorized: stake collateral and submit a hidden vote. + /// + /// `commitment` must be `sha256(salt || vote_byte || juror_xdr)`, where + /// `vote_byte` is `1` to favor the plaintiff or `0` to favor the defendant, + /// `salt` is a 32-byte secret, and `juror_xdr` is the juror's own address + /// XDR (see [`compute_commitment`]). Transfers `juror_stake` from the juror + /// into the contract. One commitment per juror per dispute. + pub fn commit_vote( + env: Env, + dispute_id: u64, + juror: Address, + commitment: BytesN<32>, + ) -> Result<(), Error> { + juror.require_auth(); + + let dispute_key = DataKey::Dispute(dispute_id); + let mut dispute = Self::read_dispute(&env, &dispute_key)?; + + // Commit phase: on or before the commit deadline. + if env.ledger().sequence() > dispute.commit_deadline { + return Err(Error::NotCommitPhase); + } + + let juror_key = DataKey::Juror(dispute_id, juror.clone()); + if env.storage().persistent().has(&juror_key) { + 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( + &juror, + env.current_contract_address(), + &config.juror_stake, + ); + + let record = Juror { + commitment, + revealed: false, + vote: false, + withdrawn: false, + }; + env.storage().persistent().set(&juror_key, &record); + Self::bump_dispute(&env, &juror_key); + + dispute.juror_count = dispute.juror_count.saturating_add(1); + env.storage().persistent().set(&dispute_key, &dispute); + Self::bump_dispute(&env, &dispute_key); + Self::bump_instance(&env); + Ok(()) + } + + /// Juror-authorized: reveal a previously committed vote. + /// + /// Recomputes `sha256(salt || vote_byte || juror_xdr)` and requires it to + /// equal the stored commitment, then records the vote and bumps the running + /// tally. Accepted only during the reveal phase, once per juror. + pub fn reveal_vote( + env: Env, + dispute_id: u64, + juror: Address, + vote: bool, + salt: BytesN<32>, + ) -> Result<(), Error> { + juror.require_auth(); + + let dispute_key = DataKey::Dispute(dispute_id); + let mut dispute = Self::read_dispute(&env, &dispute_key)?; + + // Reveal phase: strictly after the commit deadline, up to the reveal one. + let now = env.ledger().sequence(); + if now <= dispute.commit_deadline || now > dispute.reveal_deadline { + return Err(Error::NotRevealPhase); + } + + let juror_key = DataKey::Juror(dispute_id, juror.clone()); + let mut record: Juror = env + .storage() + .persistent() + .get(&juror_key) + .ok_or(Error::NotCommitted)?; + if record.revealed { + return Err(Error::AlreadyRevealed); + } + + let expected = Self::commitment_hash(&env, &juror, vote, &salt); + if expected != record.commitment { + return Err(Error::InvalidReveal); + } + + record.revealed = true; + record.vote = vote; + env.storage().persistent().set(&juror_key, &record); + Self::bump_dispute(&env, &juror_key); + + if vote { + dispute.yes_count = dispute.yes_count.saturating_add(1); + } else { + dispute.no_count = dispute.no_count.saturating_add(1); + } + env.storage().persistent().set(&dispute_key, &dispute); + Self::bump_dispute(&env, &dispute_key); + Self::bump_instance(&env); + Ok(()) + } + + /// Permissionless: tally the revealed votes into a final `Outcome` once the + /// reveal phase has ended. + /// + /// Computes the winning side, the number of winners, and the per-winner + /// slashed-pot share. On a tie or below-quorum turnout nobody is slashed and + /// every staker can reclaim their stake. Idempotent guard: a dispute can + /// only be resolved once. + pub fn resolve(env: Env, dispute_id: u64) -> Result { + let dispute_key = DataKey::Dispute(dispute_id); + let mut dispute = Self::read_dispute(&env, &dispute_key)?; + + if dispute.resolved { + return Err(Error::AlreadyResolved); + } + if env.ledger().sequence() <= dispute.reveal_deadline { + return Err(Error::NotResolvable); + } + + 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) + } else { + (Outcome::Tie, 0u32) + }; + + // 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 + } else { + 0 + }; + + dispute.outcome = outcome; + dispute.reward_per_winner = reward_per_winner; + dispute.resolved = true; + env.storage().persistent().set(&dispute_key, &dispute); + Self::bump_dispute(&env, &dispute_key); + Self::bump_instance(&env); + Ok(outcome) + } + + /// Juror-authorized: settle a juror's position after resolution. + /// + /// - Decided (`Plaintiff`/`Defendant`): a juror who revealed a vote for the + /// winning side gets `juror_stake + reward_per_winner`; a minority voter or + /// a no-show is slashed and this errors with `NothingToWithdraw`. + /// - `Tie`/`QuorumFailed`: every staker reclaims exactly `juror_stake`. + /// + /// Marks the juror `withdrawn` before transferring (checks-effects- + /// interactions), so it can never pay out twice. Returns the amount paid. + pub fn withdraw(env: Env, dispute_id: u64, juror: Address) -> Result { + juror.require_auth(); + + let dispute_key = DataKey::Dispute(dispute_id); + let dispute = Self::read_dispute(&env, &dispute_key)?; + if !dispute.resolved { + return Err(Error::NotResolved); + } + + let juror_key = DataKey::Juror(dispute_id, juror.clone()); + let mut record: Juror = env + .storage() + .persistent() + .get(&juror_key) + .ok_or(Error::NotCommitted)?; + if record.withdrawn { + return Err(Error::AlreadyWithdrawn); + } + + let config = Self::read_config(&env); + 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 + } else { + // Slashed: minority voter or no-show. Stake stays in the pot. + return Err(Error::NothingToWithdraw); + } + } + // Inconclusive: refund every staker their collateral, no slashing. + Outcome::Tie | Outcome::QuorumFailed => config.juror_stake, + Outcome::Undecided => return Err(Error::NotResolved), + }; + + // Effects before interaction: mark settled, then transfer. + record.withdrawn = true; + env.storage().persistent().set(&juror_key, &record); + Self::bump_dispute(&env, &juror_key); + + let token = Self::read_token(&env); + token::Client::new(&env, &token).transfer(&env.current_contract_address(), &juror, &payout); + Self::bump_instance(&env); + Ok(payout) + } + + // --- views --- + + /// Recompute the commitment for a `(juror, vote, salt)` triple. Exposed so + /// off-chain callers can build their commitment with the exact same domain + /// separation the contract enforces on reveal. + pub fn compute_commitment( + env: Env, + juror: Address, + vote: bool, + salt: BytesN<32>, + ) -> BytesN<32> { + Self::commitment_hash(&env, &juror, vote, &salt) + } + + pub fn get_dispute(env: Env, dispute_id: u64) -> Result { + Self::read_dispute(&env, &DataKey::Dispute(dispute_id)) + } + + pub fn get_juror(env: Env, dispute_id: u64, juror: Address) -> Result { + env.storage() + .persistent() + .get(&DataKey::Juror(dispute_id, juror)) + .ok_or(Error::NotCommitted) + } + + pub fn get_config(env: Env) -> Result { + env.storage() + .instance() + .get(&DataKey::Config) + .ok_or(Error::NotInitialized) + } + + pub fn get_admin(env: Env) -> Result { + Self::require_admin(&env) + } + + pub fn get_token(env: Env) -> Result { + env.storage() + .instance() + .get(&DataKey::Token) + .ok_or(Error::NotInitialized) + } + + // --- internal helpers --- + + fn validate_config(config: &Config) -> Result<(), Error> { + if config.juror_stake <= 0 + || config.min_jurors == 0 + || config.commit_window == 0 + || config.reveal_window == 0 + { + return Err(Error::InvalidConfig); + } + Ok(()) + } + + /// `sha256(salt || vote_byte || juror_xdr)`. Binding the juror's own address + /// into the preimage stops a copycat from replaying someone else's + /// commitment: the copied hash can never be revealed from a different + /// address. + fn commitment_hash(env: &Env, juror: &Address, vote: bool, salt: &BytesN<32>) -> BytesN<32> { + let mut preimage = Bytes::new(env); + let salt_bytes: Bytes = salt.clone().into(); + preimage.append(&salt_bytes); + preimage.push_back(if vote { 1u8 } else { 0u8 }); + preimage.append(&juror.clone().to_xdr(env)); + env.crypto().sha256(&preimage).to_bytes() + } + + fn require_admin(env: &Env) -> Result { + env.storage() + .instance() + .get(&DataKey::Admin) + .ok_or(Error::NotInitialized) + } + + fn read_token(env: &Env) -> Address { + env.storage() + .instance() + .get(&DataKey::Token) + .expect("not initialized") + } + + fn read_config(env: &Env) -> Config { + env.storage() + .instance() + .get(&DataKey::Config) + .expect("not initialized") + } + + fn read_dispute(env: &Env, key: &DataKey) -> Result { + env.storage() + .persistent() + .get(key) + .ok_or(Error::DisputeNotFound) + } + + fn bump_dispute(env: &Env, key: &DataKey) { + env.storage() + .persistent() + .extend_ttl(key, DISPUTE_LIFETIME_THRESHOLD, DISPUTE_BUMP_AMOUNT); + } + + fn bump_instance(env: &Env) { + env.storage() + .instance() + .extend_ttl(INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT); + } +} + +#[cfg(test)] +mod test; diff --git a/soroban-contracts/contracts/dispute-resolution/src/test.rs b/soroban-contracts/contracts/dispute-resolution/src/test.rs new file mode 100644 index 0000000..07482b8 --- /dev/null +++ b/soroban-contracts/contracts/dispute-resolution/src/test.rs @@ -0,0 +1,667 @@ +#![cfg(test)] + +use super::*; +use soroban_sdk::testutils::{Address as _, Ledger}; +use soroban_sdk::{token, Address, BytesN, Env}; + +const STAKE: i128 = 100; +const MIN_JURORS: u32 = 3; +const COMMIT_WINDOW: u32 = 100; +const REVEAL_WINDOW: u32 = 100; +const FUNDING: i128 = 10_000; + +// Ledger checkpoints relative to a dispute opened at ledger 1_000: +// commit_deadline = 1_100, reveal_deadline = 1_200. +const OPEN_AT: u32 = 1_000; +const COMMIT_AT: u32 = 1_050; // within commit phase +const REVEAL_AT: u32 = 1_150; // within reveal phase +const RESOLVE_AT: u32 = 1_201; // after reveal phase + +struct Fixture<'a> { + env: Env, + contract: DisputeResolutionClient<'a>, + token: token::Client<'a>, + token_admin: token::StellarAssetClient<'a>, + admin: Address, + plaintiff: Address, + defendant: Address, +} + +fn setup<'a>() -> Fixture<'a> { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let token_issuer = Address::generate(&env); + let plaintiff = Address::generate(&env); + let defendant = Address::generate(&env); + + let sac = env.register_stellar_asset_contract_v2(token_issuer.clone()); + let token_address = sac.address(); + let token = token::Client::new(&env, &token_address); + let token_admin = token::StellarAssetClient::new(&env, &token_address); + + let contract_id = env.register(DisputeResolution, ()); + let contract = DisputeResolutionClient::new(&env, &contract_id); + contract.initialize( + &admin, + &token_address, + &Config { + juror_stake: STAKE, + min_jurors: MIN_JURORS, + commit_window: COMMIT_WINDOW, + reveal_window: REVEAL_WINDOW, + }, + ); + + Fixture { + env, + contract, + token, + token_admin, + admin, + plaintiff, + defendant, + } +} + +fn set_ledger(env: &Env, seq: u32) { + env.ledger().with_mut(|l| l.sequence_number = seq); +} + +fn new_juror(f: &Fixture) -> Address { + let j = Address::generate(&f.env); + f.token_admin.mint(&j, &FUNDING); + j +} + +fn salt(env: &Env, seed: u8) -> BytesN<32> { + BytesN::from_array(env, &[seed; 32]) +} + +/// Commit `vote` for `juror` in dispute `id`, using `seed` to derive the salt. +/// The same `seed` must be passed to [`reveal`] to recompute the salt. +fn commit(f: &Fixture, id: u64, juror: &Address, vote: bool, seed: u8) { + let s = salt(&f.env, seed); + let commitment = f.contract.compute_commitment(juror, &vote, &s); + f.contract.commit_vote(&id, juror, &commitment); +} + +fn reveal(f: &Fixture, id: u64, juror: &Address, vote: bool, seed: u8) { + let s = salt(&f.env, seed); + f.contract.reveal_vote(&id, juror, &vote, &s); +} + +fn open_default(f: &Fixture, id: u64) { + set_ledger(&f.env, OPEN_AT); + f.contract.open_dispute(&id, &f.plaintiff, &f.defendant); +} + +// --------------------------------------------------------------------------- +// Initialization +// --------------------------------------------------------------------------- + +#[test] +fn initialize_stores_config() { + let f = setup(); + let cfg = f.contract.get_config(); + assert_eq!(cfg.juror_stake, STAKE); + assert_eq!(cfg.min_jurors, MIN_JURORS); + assert_eq!(cfg.commit_window, COMMIT_WINDOW); + assert_eq!(cfg.reveal_window, REVEAL_WINDOW); + assert_eq!(f.contract.get_admin(), f.admin); +} + +#[test] +fn double_initialize_fails() { + let f = setup(); + let token = f.contract.get_token(); + let res = f.contract.try_initialize( + &f.admin, + &token, + &Config { + juror_stake: STAKE, + min_jurors: MIN_JURORS, + commit_window: COMMIT_WINDOW, + reveal_window: REVEAL_WINDOW, + }, + ); + assert_eq!(res, Err(Ok(Error::AlreadyInitialized))); +} + +#[test] +fn initialize_rejects_bad_config() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let token = Address::generate(&env); + let id = env.register(DisputeResolution, ()); + let contract = DisputeResolutionClient::new(&env, &id); + + // Zero stake. + assert_eq!( + contract.try_initialize( + &admin, + &token, + &Config { + juror_stake: 0, + min_jurors: MIN_JURORS, + commit_window: COMMIT_WINDOW, + reveal_window: REVEAL_WINDOW, + } + ), + Err(Ok(Error::InvalidConfig)) + ); + // Zero quorum. + assert_eq!( + contract.try_initialize( + &admin, + &token, + &Config { + juror_stake: STAKE, + min_jurors: 0, + commit_window: COMMIT_WINDOW, + reveal_window: REVEAL_WINDOW, + } + ), + Err(Ok(Error::InvalidConfig)) + ); +} + +// --------------------------------------------------------------------------- +// Opening disputes +// --------------------------------------------------------------------------- + +#[test] +fn open_dispute_sets_deadlines() { + let f = setup(); + open_default(&f, 1); + let d = f.contract.get_dispute(&1); + assert_eq!(d.commit_deadline, OPEN_AT + COMMIT_WINDOW); + assert_eq!(d.reveal_deadline, OPEN_AT + COMMIT_WINDOW + REVEAL_WINDOW); + assert_eq!(d.outcome, Outcome::Undecided); + assert_eq!(d.juror_count, 0); + assert!(!d.resolved); +} + +#[test] +fn open_duplicate_dispute_fails() { + let f = setup(); + open_default(&f, 1); + let res = f.contract.try_open_dispute(&1, &f.plaintiff, &f.defendant); + assert_eq!(res, Err(Ok(Error::DisputeExists))); +} + +#[test] +fn open_same_parties_fails() { + let f = setup(); + set_ledger(&f.env, OPEN_AT); + let res = f.contract.try_open_dispute(&1, &f.plaintiff, &f.plaintiff); + assert_eq!(res, Err(Ok(Error::SameParties))); +} + +#[test] +fn commit_on_missing_dispute_fails() { + let f = setup(); + let juror = new_juror(&f); + set_ledger(&f.env, COMMIT_AT); + let c = f + .contract + .compute_commitment(&juror, &true, &salt(&f.env, 1)); + let res = f.contract.try_commit_vote(&99, &juror, &c); + assert_eq!(res, Err(Ok(Error::DisputeNotFound))); +} + +// --------------------------------------------------------------------------- +// Full lifecycle: happy paths +// --------------------------------------------------------------------------- + +#[test] +fn full_lifecycle_plaintiff_wins() { + let f = setup(); + open_default(&f, 1); + + let j1 = new_juror(&f); + let j2 = new_juror(&f); + let j3 = new_juror(&f); + + // Commit phase: two for the plaintiff, one for the defendant. + set_ledger(&f.env, COMMIT_AT); + commit(&f, 1, &j1, true, 11); + commit(&f, 1, &j2, true, 12); + commit(&f, 1, &j3, false, 13); + // Each juror staked, contract holds the pooled collateral. + assert_eq!(f.token.balance(&j1), FUNDING - STAKE); + assert_eq!(f.token.balance(&f.contract.address), STAKE * 3); + + // Reveal phase. + set_ledger(&f.env, REVEAL_AT); + reveal(&f, 1, &j1, true, 11); + reveal(&f, 1, &j2, true, 12); + reveal(&f, 1, &j3, false, 13); + + // Resolve: plaintiff wins 2-1. + 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); + + // Winners reclaim stake + reward; the loser is slashed. + assert_eq!(f.contract.withdraw(&1, &j1), STAKE + 50); + assert_eq!(f.contract.withdraw(&1, &j2), STAKE + 50); + assert_eq!( + f.contract.try_withdraw(&1, &j3), + Err(Ok(Error::NothingToWithdraw)) + ); + + assert_eq!(f.token.balance(&j1), FUNDING + 50); + assert_eq!(f.token.balance(&j2), FUNDING + 50); + assert_eq!(f.token.balance(&j3), FUNDING - STAKE); // slashed + assert_eq!(f.token.balance(&f.contract.address), 0); +} + +#[test] +fn full_lifecycle_defendant_wins() { + let f = setup(); + open_default(&f, 1); + + let j1 = new_juror(&f); + let j2 = new_juror(&f); + let j3 = new_juror(&f); + + set_ledger(&f.env, COMMIT_AT); + commit(&f, 1, &j1, false, 21); + commit(&f, 1, &j2, false, 22); + commit(&f, 1, &j3, true, 23); + + set_ledger(&f.env, REVEAL_AT); + reveal(&f, 1, &j1, false, 21); + reveal(&f, 1, &j2, false, 22); + reveal(&f, 1, &j3, true, 23); + + set_ledger(&f.env, RESOLVE_AT); + assert_eq!(f.contract.resolve(&1), Outcome::Defendant); + + assert_eq!(f.contract.withdraw(&1, &j1), STAKE + 50); + assert_eq!(f.contract.withdraw(&1, &j2), STAKE + 50); + assert_eq!( + f.contract.try_withdraw(&1, &j3), + Err(Ok(Error::NothingToWithdraw)) + ); + assert_eq!(f.token.balance(&f.contract.address), 0); +} + +#[test] +fn no_show_juror_is_slashed_and_pot_goes_to_winners() { + 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); // will commit but never reveal (no-show) + + set_ledger(&f.env, COMMIT_AT); + commit(&f, 1, &j1, true, 31); + commit(&f, 1, &j2, true, 32); + commit(&f, 1, &j3, true, 33); + commit(&f, 1, &j4, true, 34); + + set_ledger(&f.env, REVEAL_AT); + reveal(&f, 1, &j1, true, 31); + reveal(&f, 1, &j2, true, 32); + reveal(&f, 1, &j3, true, 33); + // j4 never reveals. + + 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); + + assert_eq!(f.contract.withdraw(&1, &j1), STAKE + 33); + assert_eq!(f.contract.withdraw(&1, &j2), STAKE + 33); + assert_eq!(f.contract.withdraw(&1, &j3), STAKE + 33); + // No-show is slashed. + assert_eq!( + f.contract.try_withdraw(&1, &j4), + Err(Ok(Error::NothingToWithdraw)) + ); + // 1 unit of integer-division dust is retained by the contract. + assert_eq!(f.token.balance(&f.contract.address), 1); +} + +#[test] +fn tie_refunds_all_stakers() { + 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); + + set_ledger(&f.env, COMMIT_AT); + commit(&f, 1, &j1, true, 41); + commit(&f, 1, &j2, true, 42); + commit(&f, 1, &j3, false, 43); + commit(&f, 1, &j4, false, 44); + + set_ledger(&f.env, REVEAL_AT); + reveal(&f, 1, &j1, true, 41); + reveal(&f, 1, &j2, true, 42); + reveal(&f, 1, &j3, false, 43); + reveal(&f, 1, &j4, false, 44); + + set_ledger(&f.env, RESOLVE_AT); + assert_eq!(f.contract.resolve(&1), Outcome::Tie); + + // Everyone reclaims exactly their stake; no slashing. + for j in [&j1, &j2, &j3, &j4] { + assert_eq!(f.contract.withdraw(&1, j), STAKE); + assert_eq!(f.token.balance(j), FUNDING); + } + assert_eq!(f.token.balance(&f.contract.address), 0); +} + +#[test] +fn quorum_failure_refunds_including_non_revealers() { + let f = setup(); + open_default(&f, 1); + + let j1 = new_juror(&f); + let j2 = new_juror(&f); + let j3 = new_juror(&f); // commits but never reveals + + set_ledger(&f.env, COMMIT_AT); + commit(&f, 1, &j1, true, 51); + commit(&f, 1, &j2, false, 52); + commit(&f, 1, &j3, true, 53); + + set_ledger(&f.env, REVEAL_AT); + reveal(&f, 1, &j1, true, 51); + reveal(&f, 1, &j2, false, 52); + // j3 does not reveal -> only 2 revealed, below the quorum of 3. + + set_ledger(&f.env, RESOLVE_AT); + assert_eq!(f.contract.resolve(&1), Outcome::QuorumFailed); + + // All three, including the non-revealer, get their stake back. + for j in [&j1, &j2, &j3] { + assert_eq!(f.contract.withdraw(&1, j), STAKE); + assert_eq!(f.token.balance(j), FUNDING); + } + assert_eq!(f.token.balance(&f.contract.address), 0); +} + +// --------------------------------------------------------------------------- +// Phase / state-machine enforcement +// --------------------------------------------------------------------------- + +#[test] +fn commit_after_deadline_fails() { + let f = setup(); + open_default(&f, 1); + let juror = new_juror(&f); + set_ledger(&f.env, OPEN_AT + COMMIT_WINDOW + 1); // past commit deadline + let c = f + .contract + .compute_commitment(&juror, &true, &salt(&f.env, 1)); + assert_eq!( + f.contract.try_commit_vote(&1, &juror, &c), + Err(Ok(Error::NotCommitPhase)) + ); +} + +#[test] +fn reveal_during_commit_phase_fails() { + let f = setup(); + open_default(&f, 1); + let juror = new_juror(&f); + set_ledger(&f.env, COMMIT_AT); + commit(&f, 1, &juror, true, 1); + // Still in the commit phase. + assert_eq!( + f.contract + .try_reveal_vote(&1, &juror, &true, &salt(&f.env, 1)), + Err(Ok(Error::NotRevealPhase)) + ); +} + +#[test] +fn reveal_after_deadline_fails() { + let f = setup(); + open_default(&f, 1); + let juror = new_juror(&f); + set_ledger(&f.env, COMMIT_AT); + commit(&f, 1, &juror, true, 1); + set_ledger(&f.env, RESOLVE_AT); // past reveal deadline + assert_eq!( + f.contract + .try_reveal_vote(&1, &juror, &true, &salt(&f.env, 1)), + Err(Ok(Error::NotRevealPhase)) + ); +} + +#[test] +fn double_commit_fails() { + let f = setup(); + open_default(&f, 1); + let juror = new_juror(&f); + set_ledger(&f.env, COMMIT_AT); + commit(&f, 1, &juror, true, 1); + let c = f + .contract + .compute_commitment(&juror, &true, &salt(&f.env, 1)); + assert_eq!( + f.contract.try_commit_vote(&1, &juror, &c), + Err(Ok(Error::AlreadyCommitted)) + ); +} + +#[test] +fn double_reveal_fails() { + let f = setup(); + open_default(&f, 1); + let juror = new_juror(&f); + set_ledger(&f.env, COMMIT_AT); + commit(&f, 1, &juror, true, 1); + set_ledger(&f.env, REVEAL_AT); + reveal(&f, 1, &juror, true, 1); + assert_eq!( + f.contract + .try_reveal_vote(&1, &juror, &true, &salt(&f.env, 1)), + Err(Ok(Error::AlreadyRevealed)) + ); +} + +#[test] +fn reveal_without_commit_fails() { + let f = setup(); + open_default(&f, 1); + let juror = new_juror(&f); + set_ledger(&f.env, REVEAL_AT); + assert_eq!( + f.contract + .try_reveal_vote(&1, &juror, &true, &salt(&f.env, 1)), + Err(Ok(Error::NotCommitted)) + ); +} + +#[test] +fn resolve_before_reveal_deadline_fails() { + let f = setup(); + open_default(&f, 1); + set_ledger(&f.env, OPEN_AT + COMMIT_WINDOW + REVEAL_WINDOW); // == reveal_deadline + assert_eq!(f.contract.try_resolve(&1), Err(Ok(Error::NotResolvable))); +} + +#[test] +fn double_resolve_fails() { + let f = setup(); + open_default(&f, 1); + set_ledger(&f.env, RESOLVE_AT); + f.contract.resolve(&1); + assert_eq!(f.contract.try_resolve(&1), Err(Ok(Error::AlreadyResolved))); +} + +#[test] +fn withdraw_before_resolve_fails() { + let f = setup(); + open_default(&f, 1); + let juror = new_juror(&f); + set_ledger(&f.env, COMMIT_AT); + commit(&f, 1, &juror, true, 1); + set_ledger(&f.env, REVEAL_AT); + reveal(&f, 1, &juror, true, 1); + assert_eq!( + f.contract.try_withdraw(&1, &juror), + Err(Ok(Error::NotResolved)) + ); +} + +#[test] +fn double_withdraw_fails() { + let f = setup(); + open_default(&f, 1); + + let j1 = new_juror(&f); + let j2 = new_juror(&f); + let j3 = new_juror(&f); + + set_ledger(&f.env, COMMIT_AT); + commit(&f, 1, &j1, true, 61); + commit(&f, 1, &j2, true, 62); + commit(&f, 1, &j3, true, 63); + set_ledger(&f.env, REVEAL_AT); + reveal(&f, 1, &j1, true, 61); + reveal(&f, 1, &j2, true, 62); + reveal(&f, 1, &j3, true, 63); + set_ledger(&f.env, RESOLVE_AT); + f.contract.resolve(&1); + + f.contract.withdraw(&1, &j1); + assert_eq!( + f.contract.try_withdraw(&1, &j1), + Err(Ok(Error::AlreadyWithdrawn)) + ); +} + +// --------------------------------------------------------------------------- +// Adversarial: commit-reveal integrity & sybil/front-running +// --------------------------------------------------------------------------- + +#[test] +fn reveal_with_wrong_vote_fails() { + let f = setup(); + open_default(&f, 1); + let juror = new_juror(&f); + set_ledger(&f.env, COMMIT_AT); + commit(&f, 1, &juror, true, 1); // committed to `true` + set_ledger(&f.env, REVEAL_AT); + // Try to reveal the opposite vote with the right salt -> hash mismatch. + assert_eq!( + f.contract + .try_reveal_vote(&1, &juror, &false, &salt(&f.env, 1)), + Err(Ok(Error::InvalidReveal)) + ); +} + +#[test] +fn reveal_with_wrong_salt_fails() { + let f = setup(); + open_default(&f, 1); + let juror = new_juror(&f); + set_ledger(&f.env, COMMIT_AT); + commit(&f, 1, &juror, true, 1); + set_ledger(&f.env, REVEAL_AT); + assert_eq!( + f.contract + .try_reveal_vote(&1, &juror, &true, &salt(&f.env, 99)), + Err(Ok(Error::InvalidReveal)) + ); +} + +#[test] +fn copycat_commitment_cannot_be_revealed() { + // A front-runner copies a victim's commitment hash and submits it as their + // own. Because the hash binds the committer's address, the copycat can never + // produce a matching reveal from their own address. + let f = setup(); + open_default(&f, 1); + + let victim = new_juror(&f); + let copycat = new_juror(&f); + + set_ledger(&f.env, COMMIT_AT); + let victim_salt = salt(&f.env, 1); + let victim_commitment = f.contract.compute_commitment(&victim, &true, &victim_salt); + f.contract.commit_vote(&1, &victim, &victim_commitment); + // Copycat replays the exact same commitment bytes. + f.contract.commit_vote(&1, ©cat, &victim_commitment); + + set_ledger(&f.env, REVEAL_AT); + // Victim reveals fine. + f.contract.reveal_vote(&1, &victim, &true, &victim_salt); + // Copycat tries the victim's (vote, salt) but from a different address. + assert_eq!( + f.contract + .try_reveal_vote(&1, ©cat, &true, &victim_salt), + Err(Ok(Error::InvalidReveal)) + ); +} + +#[test] +fn losing_juror_cannot_drain_via_repeated_withdraw() { + // Adversarial: a slashed juror repeatedly calling withdraw must never pay + // out, and winners' withdrawals stay bounded by the pool. + let f = setup(); + open_default(&f, 1); + + let j1 = new_juror(&f); + let j2 = new_juror(&f); + let j3 = new_juror(&f); + + set_ledger(&f.env, COMMIT_AT); + commit(&f, 1, &j1, true, 71); + commit(&f, 1, &j2, true, 72); + commit(&f, 1, &j3, false, 73); + set_ledger(&f.env, REVEAL_AT); + reveal(&f, 1, &j1, true, 71); + reveal(&f, 1, &j2, true, 72); + reveal(&f, 1, &j3, false, 73); + set_ledger(&f.env, RESOLVE_AT); + f.contract.resolve(&1); + + // The slashed loser gets nothing, no matter how many times they try. + for _ in 0..5 { + assert_eq!( + f.contract.try_withdraw(&1, &j3), + Err(Ok(Error::NothingToWithdraw)) + ); + } + // Winners can still withdraw their fair share afterwards. + assert_eq!(f.contract.withdraw(&1, &j1), STAKE + 50); + assert_eq!(f.contract.withdraw(&1, &j2), STAKE + 50); + assert_eq!(f.token.balance(&f.contract.address), 0); +} + +#[test] +fn get_juror_reflects_commit_and_reveal() { + let f = setup(); + open_default(&f, 1); + let juror = new_juror(&f); + set_ledger(&f.env, COMMIT_AT); + commit(&f, 1, &juror, true, 1); + + let before = f.contract.get_juror(&1, &juror); + assert!(!before.revealed); + assert!(!before.withdrawn); + + set_ledger(&f.env, REVEAL_AT); + reveal(&f, 1, &juror, true, 1); + let after = f.contract.get_juror(&1, &juror); + assert!(after.revealed); + assert!(after.vote); +}